Compare commits

..

1 Commits

Author SHA1 Message Date
Paulus Schoutsen
176f9c9f94 Add decorator to define Python tools from Python functions 2025-08-17 20:59:10 +00:00
653 changed files with 5567 additions and 43983 deletions

View File

@@ -14,8 +14,7 @@ tests
# Other virtualization methods
venv
.venv
.vagrant
# Temporary files
**/__pycache__
**/__pycache__

View File

@@ -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
```

View File

@@ -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.1
with:
license-check: false # We use our own license audit checks
@@ -1341,7 +1341,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@v5.5.0
uses: codecov/codecov-action@v5.4.3
with:
fail_ci_if_error: true
flags: full-suite
@@ -1491,7 +1491,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@v5.5.0
uses: codecov/codecov-action@v5.4.3
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -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.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.11
uses: github/codeql-action/analyze@v3.29.9
with:
category: "/language:python"

View File

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

View File

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

6
CODEOWNERS generated
View File

@@ -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
@@ -424,8 +422,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/emby/ @mezz64
/homeassistant/components/emoncms/ @borpin @alexandrecuer
/tests/components/emoncms/ @borpin @alexandrecuer
/homeassistant/components/emoncms_history/ @alexandrecuer
/tests/components/emoncms_history/ @alexandrecuer
/homeassistant/components/emonitor/ @bdraco
/tests/components/emonitor/ @bdraco
/homeassistant/components/emulated_hue/ @bdraco @Tho85
@@ -1185,8 +1181,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

View File

@@ -14,8 +14,5 @@ Still interested? Then you should take a peek at the [developer documentation](h
## Feature suggestions
If you want to suggest a new feature for Home Assistant (e.g. new integrations), please [start a discussion](https://github.com/orgs/home-assistant/discussions) on GitHub.
## Issue Tracker
If you want to report an issue, please [create an issue](https://github.com/home-assistant/core/issues) on GitHub.
If you want to suggest a new feature for Home Assistant (e.g., new integrations), please open a thread in our [Community Forum: Feature Requests](https://community.home-assistant.io/c/feature-requests).
We use [GitHub for tracking issues](https://github.com/home-assistant/core/issues), not for tracking feature requests.

View File

@@ -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,
)

View File

@@ -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."""

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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"
}

View File

@@ -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,
)

View File

@@ -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:
- "*"

View File

@@ -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."
}
}
}
}
}

View File

@@ -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)}>"

View File

@@ -61,7 +61,7 @@
"display_pm_standard": {
"name": "Display PM standard",
"state": {
"ugm3": "μg/m³",
"ugm3": "µg/m³",
"us_aqi": "US AQI"
}
},

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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))

View File

@@ -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"])

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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": []
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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})."
}
}
}

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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",
}

View File

@@ -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",

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "silver",
"requirements": ["aioamazondevices==5.0.1"]
"requirements": ["aioamazondevices==4.0.0"]
}

View File

@@ -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

View File

@@ -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,
),
)

View File

@@ -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%]"
}
},

View File

@@ -16,7 +16,7 @@ from homeassistant.helpers.selector import (
SelectSelectorMode,
)
from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN, REQUEST_TIMEOUT
from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN
API_URL = "https://app.amber.com.au/developers"
@@ -64,9 +64,7 @@ class AmberElectricConfigFlow(ConfigFlow, domain=DOMAIN):
api = amberelectric.AmberApi(api_client)
try:
sites: list[Site] = filter_sites(
api.get_sites(_request_timeout=REQUEST_TIMEOUT)
)
sites: list[Site] = filter_sites(api.get_sites())
except amberelectric.ApiException as api_exception:
if api_exception.status == 403:
self._errors[CONF_API_TOKEN] = "invalid_api_token"

View File

@@ -21,5 +21,3 @@ SERVICE_GET_FORECASTS = "get_forecasts"
GENERAL_CHANNEL = "general"
CONTROLLED_LOAD_CHANNEL = "controlled_load"
FEED_IN_CHANNEL = "feed_in"
REQUEST_TIMEOUT = 15

View File

@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER, REQUEST_TIMEOUT
from .const import LOGGER
from .helpers import normalize_descriptor
type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
@@ -82,11 +82,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
"grid": {},
}
try:
data = self._api.get_current_prices(
self.site_id,
next=288,
_request_timeout=REQUEST_TIMEOUT,
)
data = self._api.get_current_prices(self.site_id, next=288)
intervals = [interval.actual_instance for interval in data]
except ApiException as api_exception:
raise UpdateFailed("Missing price data, skipping update") from api_exception

View File

@@ -449,6 +449,5 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
return {
"version": "home-assistant:1",
"home_assistant": HA_VERSION,
"devices": devices,
}

View File

@@ -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

View File

@@ -10,9 +10,9 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
from .entity import APCUPSdEntity
PARALLEL_UPDATES = 0
@@ -40,16 +40,22 @@ async def async_setup_entry(
async_add_entities([OnlineStatus(coordinator, _DESCRIPTION)])
class OnlineStatus(APCUPSdEntity, BinarySensorEntity):
class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity):
"""Representation of a UPS online status."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: APCUPSdCoordinator,
description: BinarySensorEntityDescription,
) -> None:
"""Initialize the APCUPSd binary device."""
super().__init__(coordinator, description)
super().__init__(coordinator, context=description.key.upper())
self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info
@property
def is_on(self) -> bool | None:

View File

@@ -1,26 +0,0 @@
"""Base entity for APCUPSd integration."""
from __future__ import annotations
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import APCUPSdCoordinator
class APCUPSdEntity(CoordinatorEntity[APCUPSdCoordinator]):
"""Base entity for APCUPSd integration."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: APCUPSdCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the APCUPSd entity."""
super().__init__(coordinator, context=description.key.upper())
self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info

View File

@@ -3,7 +3,10 @@ rules:
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
common-modules:
status: done
comment: |
Consider deriving a base entity.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done

View File

@@ -23,10 +23,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import LAST_S_TEST
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
from .entity import APCUPSdEntity
PARALLEL_UPDATES = 0
@@ -490,16 +490,22 @@ def infer_unit(value: str) -> tuple[str, str | None]:
return value, None
class APCUPSdSensor(APCUPSdEntity, SensorEntity):
class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
"""Representation of a sensor entity for APCUPSd status values."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: APCUPSdCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, description)
super().__init__(coordinator=coordinator, context=description.key.upper())
self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info
# Initial update of attributes.
self._update_attrs()

View File

@@ -0,0 +1 @@
"""Virtual integration: Arizona Public Service (APS)."""

View File

@@ -0,0 +1,6 @@
{
"domain": "aps",
"name": "Arizona Public Service (APS)",
"integration_type": "virtual",
"supported_by": "opower"
}

View File

@@ -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"]
}

View File

@@ -15,7 +15,6 @@ from asusrouter import AsusRouter, AsusRouterError
from asusrouter.modules.client import AsusClient
from asusrouter.modules.data import AsusData
from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors
from asusrouter.tools.connection import get_cookie_jar
from homeassistant.const import (
CONF_HOST,
@@ -26,7 +25,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import UpdateFailed
@@ -110,10 +109,7 @@ class AsusWrtBridge(ABC):
) -> AsusWrtBridge:
"""Get Bridge instance."""
if conf[CONF_PROTOCOL] in (PROTOCOL_HTTPS, PROTOCOL_HTTP):
session = async_create_clientsession(
hass,
cookie_jar=get_cookie_jar(),
)
session = async_get_clientsession(hass)
return AsusWrtHttpBridge(conf, session)
return AsusWrtLegacyBridge(conf, options)

View File

@@ -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"]
}

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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"])

View File

@@ -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"

View File

@@ -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,
}

View 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"]
}

View File

@@ -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": {

View File

@@ -199,19 +199,23 @@ class AuthProvidersView(HomeAssistantView):
)
def _prepare_result_json(result: AuthFlowResult) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
def _prepare_result_json(
result: AuthFlowResult,
) -> AuthFlowResult:
"""Convert result to JSON."""
if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY:
return {
key: val for key, val in result.items() if key not in ("result", "data")
}
data = result.copy()
data.pop("result")
data.pop("data")
return data
if result["type"] != data_entry_flow.FlowResultType.FORM:
return result # type: ignore[return-value]
return result
data = dict(result)
if (schema := result["data_schema"]) is None:
data["data_schema"] = []
data = result.copy()
if (schema := data["data_schema"]) is None:
data["data_schema"] = [] # type: ignore[typeddict-item] # json result type
else:
data["data_schema"] = voluptuous_serialize.convert(schema)

View File

@@ -149,16 +149,20 @@ def websocket_depose_mfa(
hass.async_create_task(async_depose(msg))
def _prepare_result_json(result: data_entry_flow.FlowResult) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
def _prepare_result_json(
result: data_entry_flow.FlowResult,
) -> data_entry_flow.FlowResult:
"""Convert result to JSON."""
if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY:
return dict(result)
if result["type"] != data_entry_flow.FlowResultType.FORM:
return result # type: ignore[return-value]
return result.copy()
data = dict(result)
if (schema := result["data_schema"]) is None:
data["data_schema"] = []
if result["type"] != data_entry_flow.FlowResultType.FORM:
return result
data = result.copy()
if (schema := data["data_schema"]) is None:
data["data_schema"] = [] # type: ignore[typeddict-item] # json result type
else:
data["data_schema"] = voluptuous_serialize.convert(schema)

View File

@@ -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"]
}

View File

@@ -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]

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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%]"
}
}
}
}

View File

@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==1.0.1",
"bleak-retry-connector==4.4.3",
"bleak-retry-connector==4.0.1",
"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"
]
}

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"requirements": ["brother==5.0.1"],
"requirements": ["brother==5.0.0"],
"zeroconf": [
{
"type": "_printer._tcp.local.",

View File

@@ -72,7 +72,7 @@ SENSOR_DESCRIPTIONS = {
key=str(BTHomeExtendedSensorDeviceClass.CHANNEL),
state_class=SensorStateClass.MEASUREMENT,
),
# Conductivity (μS/cm)
# Conductivity (µS/cm)
(
BTHomeSensorDeviceClass.CONDUCTIVITY,
Units.CONDUCTIVITY,
@@ -215,7 +215,7 @@ SENSOR_DESCRIPTIONS = {
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
# PM10 (μg/m3)
# PM10 (µg/m3)
(
BTHomeSensorDeviceClass.PM10,
Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
@@ -225,7 +225,7 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
# PM2.5 (μg/m3)
# PM2.5 (µg/m3)
(
BTHomeSensorDeviceClass.PM25,
Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
@@ -318,7 +318,7 @@ SENSOR_DESCRIPTIONS = {
key=str(BTHomeSensorDeviceClass.UV_INDEX),
state_class=SensorStateClass.MEASUREMENT,
),
# Volatile organic Compounds (VOC) (μg/m3)
# Volatile organic Compounds (VOC) (µg/m3)
(
BTHomeSensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,

View File

@@ -14,7 +14,7 @@
"documentation": "https://www.home-assistant.io/integrations/cast",
"iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==14.0.9"],
"requirements": ["PyChromecast==14.0.7"],
"single_config_entry": true,
"zeroconf": ["_googlecast._tcp.local."]
}

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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"]
}

View File

@@ -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:

View File

@@ -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)

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aioelectricitymaps"],
"requirements": ["aioelectricitymaps==1.1.1"]
"requirements": ["aioelectricitymaps==0.4.0"]
}

View File

@@ -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 = (

View File

@@ -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)
@@ -138,16 +137,20 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView):
def _prepare_config_flow_result_json(
result: data_entry_flow.FlowResult,
prepare_result_json: Callable[[data_entry_flow.FlowResult], dict[str, Any]],
) -> dict[str, Any]:
prepare_result_json: Callable[
[data_entry_flow.FlowResult], data_entry_flow.FlowResult
],
) -> data_entry_flow.FlowResult:
"""Convert result to JSON."""
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
return prepare_result_json(result)
data = {key: val for key, val in result.items() if key not in ("data", "context")}
entry: config_entries.ConfigEntry = result["result"] # type: ignore[typeddict-item]
data = result.copy()
entry: config_entries.ConfigEntry = data["result"] # type: ignore[typeddict-item]
# We overwrite the ConfigEntry object with its json representation.
data["result"] = entry.as_json_fragment
data["result"] = entry.as_json_fragment # type: ignore[typeddict-unknown-key]
data.pop("data")
data.pop("context")
return data
@@ -201,8 +204,8 @@ class ConfigManagerFlowIndexView(
def _prepare_result_json(
self, result: data_entry_flow.FlowResult
) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
) -> data_entry_flow.FlowResult:
"""Convert result to JSON."""
return _prepare_config_flow_result_json(result, super()._prepare_result_json)
@@ -226,8 +229,8 @@ class ConfigManagerFlowResourceView(
def _prepare_result_json(
self, result: data_entry_flow.FlowResult
) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
) -> data_entry_flow.FlowResult:
"""Convert result to JSON."""
return _prepare_config_flow_result_json(result, super()._prepare_result_json)
@@ -732,47 +735,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(
{

View File

@@ -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"]
}

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.16.0"],
"requirements": ["pydaikin==2.15.0"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@@ -99,18 +99,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry, version=1, minor_version=3
)
if config_entry.minor_version < 4:
# Ensure we use the correct units
new_options = {**config_entry.options}
if new_options.get("unit_prefix") == "\u00b5":
# Ensure we use the preferred coding of μ
new_options["unit_prefix"] = "\u03bc"
hass.config_entries.async_update_entry(
config_entry, options=new_options, version=1, minor_version=4
)
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
config_entry.version,

View File

@@ -36,7 +36,7 @@ from .const import (
UNIT_PREFIXES = [
selector.SelectOptionDict(value="n", label="n (nano)"),
selector.SelectOptionDict(value="μ", label="μ (micro)"),
selector.SelectOptionDict(value="µ", label="µ (micro)"),
selector.SelectOptionDict(value="m", label="m (milli)"),
selector.SelectOptionDict(value="k", label="k (kilo)"),
selector.SelectOptionDict(value="M", label="M (mega)"),
@@ -142,7 +142,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
options_flow = OPTIONS_FLOW
VERSION = 1
MINOR_VERSION = 4
MINOR_VERSION = 3
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""

View File

@@ -63,7 +63,7 @@ ATTR_SOURCE_ID = "source"
UNIT_PREFIXES = {
None: 1,
"n": 1e-9,
"μ": 1e-6,
"µ": 1e-6,
"m": 1e-3,
"k": 1e3,
"M": 1e6,

View File

@@ -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"]
}

View File

@@ -157,7 +157,7 @@ SENSORS: dict[str | None, SensorEntityDescription] = {
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
),
"μg/m³": SensorEntityDescription(
"µg/m³": SensorEntityDescription(
key="concentration|microgram_per_cubic_meter",
translation_key="concentration",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,

View File

@@ -1,11 +1,10 @@
"""Support for sending data to Emoncms."""
from datetime import datetime, timedelta
from functools import partial
from datetime import timedelta
from http import HTTPStatus
import logging
import aiohttp
from pyemoncms import EmoncmsClient
import requests
import voluptuous as vol
from homeassistant.const import (
@@ -18,9 +17,9 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.event import track_point_in_time
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -43,51 +42,61 @@ CONFIG_SCHEMA = vol.Schema(
)
async def async_send_to_emoncms(
hass: HomeAssistant,
emoncms_client: EmoncmsClient,
whitelist: list[str],
node: str | int,
_: datetime,
) -> None:
"""Send data to Emoncms."""
payload_dict = {}
for entity_id in whitelist:
state = hass.states.get(entity_id)
if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE):
continue
try:
payload_dict[entity_id] = state_helper.state_as_number(state)
except ValueError:
continue
if payload_dict:
try:
await emoncms_client.async_input_post(data=payload_dict, node=node)
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.warning("Network error when sending data to Emoncms: %s", err)
except ValueError as err:
_LOGGER.warning("Value error when preparing data for Emoncms: %s", err)
else:
_LOGGER.debug("Sent data to Emoncms: %s", payload_dict)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Emoncms history component."""
conf = config[DOMAIN]
whitelist = conf.get(CONF_WHITELIST)
input_node = str(conf.get(CONF_INPUTNODE))
emoncms_client = EmoncmsClient(
url=conf.get(CONF_URL),
api_key=conf.get(CONF_API_KEY),
session=async_get_clientsession(hass),
)
async_track_time_interval(
hass,
partial(async_send_to_emoncms, hass, emoncms_client, whitelist, input_node),
timedelta(seconds=conf.get(CONF_SCAN_INTERVAL)),
)
def send_data(url, apikey, node, payload):
"""Send payload data to Emoncms."""
try:
fullurl = f"{url}/input/post.json"
data = {"apikey": apikey, "data": payload}
parameters = {"node": node}
req = requests.post(
fullurl, params=parameters, data=data, allow_redirects=True, timeout=5
)
except requests.exceptions.RequestException:
_LOGGER.error("Error saving data '%s' to '%s'", payload, fullurl)
else:
if req.status_code != HTTPStatus.OK:
_LOGGER.error(
"Error saving data %s to %s (http status code = %d)",
payload,
fullurl,
req.status_code,
)
def update_emoncms(time):
"""Send whitelisted entities states regularly to Emoncms."""
payload_dict = {}
for entity_id in whitelist:
state = hass.states.get(entity_id)
if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE):
continue
try:
payload_dict[entity_id] = state_helper.state_as_number(state)
except ValueError:
continue
if payload_dict:
payload = ",".join(f"{key}:{val}" for key, val in payload_dict.items())
send_data(
conf.get(CONF_URL),
conf.get(CONF_API_KEY),
str(conf.get(CONF_INPUTNODE)),
f"{{{payload}}}",
)
track_point_in_time(
hass, update_emoncms, time + timedelta(seconds=conf.get(CONF_SCAN_INTERVAL))
)
update_emoncms(dt_util.utcnow())
return True

View File

@@ -1,9 +1,8 @@
{
"domain": "emoncms_history",
"name": "Emoncms History",
"codeowners": ["@alexandrecuer"],
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["pyemoncms==0.1.2"]
"quality_scale": "legacy"
}

View File

@@ -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"]
}

View File

@@ -17,7 +17,7 @@ DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
DEFAULT_PORT: Final = 6053
STABLE_BLE_VERSION_STR = "2025.8.0"
STABLE_BLE_VERSION_STR = "2025.5.0"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
PROJECT_URLS = {
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",

View File

@@ -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."]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/foscam",
"iot_class": "local_polling",
"loggers": ["libpyfoscamcgi"],
"requirements": ["libpyfoscamcgi==0.0.7"]
"requirements": ["libpyfoscamcgi==0.0.6"]
}

View File

@@ -36,7 +36,7 @@ async def async_setup_entry(
async_add_entities(
(
FreeboxAlarm(router, node)
FreeboxAlarm(hass, router, node)
for node in router.home_devices.values()
if node["category"] == FreeboxHomeCategory.ALARM
),
@@ -49,9 +49,11 @@ class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity):
_attr_code_arm_required = False
def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None:
def __init__(
self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any]
) -> None:
"""Initialize an alarm."""
super().__init__(router, node)
super().__init__(hass, router, node)
# Commands
self._command_trigger = self.get_command_id(

View File

@@ -50,12 +50,12 @@ async def async_setup_entry(
for node in router.home_devices.values():
if node["category"] == FreeboxHomeCategory.PIR:
binary_entities.append(FreeboxPirSensor(router, node))
binary_entities.append(FreeboxPirSensor(hass, router, node))
elif node["category"] == FreeboxHomeCategory.DWS:
binary_entities.append(FreeboxDwsSensor(router, node))
binary_entities.append(FreeboxDwsSensor(hass, router, node))
binary_entities.extend(
FreeboxCoverSensor(router, node)
FreeboxCoverSensor(hass, router, node)
for endpoint in node["show_endpoints"]
if (
endpoint["name"] == "cover"
@@ -74,12 +74,13 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity):
def __init__(
self,
hass: HomeAssistant,
router: FreeboxRouter,
node: dict[str, Any],
sub_node: dict[str, Any] | None = None,
) -> None:
"""Initialize a Freebox binary sensor."""
super().__init__(router, node, sub_node)
super().__init__(hass, router, node, sub_node)
self._command_id = self.get_command_id(
node["type"]["endpoints"], "signal", self._sensor_name
)
@@ -122,7 +123,9 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor):
_sensor_name = "cover"
def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None:
def __init__(
self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any]
) -> None:
"""Initialize a cover for another device."""
cover_node = next(
filter(
@@ -131,7 +134,7 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor):
),
None,
)
super().__init__(router, node, cover_node)
super().__init__(hass, router, node, cover_node)
class FreeboxRaidDegradedSensor(BinarySensorEntity):

View File

@@ -74,7 +74,7 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera):
) -> None:
"""Initialize a camera."""
super().__init__(router, node)
super().__init__(hass, router, node)
device_info = {
CONF_NAME: node["label"].strip(),
CONF_INPUT: node["props"]["Stream"],

View File

@@ -2,9 +2,11 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -20,11 +22,13 @@ class FreeboxHomeEntity(Entity):
def __init__(
self,
hass: HomeAssistant,
router: FreeboxRouter,
node: dict[str, Any],
sub_node: dict[str, Any] | None = None,
) -> None:
"""Initialize a Freebox Home entity."""
self._hass = hass
self._router = router
self._node = node
self._sub_node = sub_node
@@ -40,6 +44,7 @@ class FreeboxHomeEntity(Entity):
self._available = True
self._firmware = node["props"].get("FwVersion")
self._manufacturer = "Freebox SAS"
self._remove_signal_update: Callable[[], None] | None = None
self._model = CATEGORY_TO_MODEL.get(node["category"])
if self._model is None:
@@ -56,7 +61,10 @@ class FreeboxHomeEntity(Entity):
model=self._model,
name=self._device_name,
sw_version=self._firmware,
via_device=(DOMAIN, router.mac),
via_device=(
DOMAIN,
router.mac,
),
)
async def async_update_signal(self) -> None:
@@ -108,14 +116,23 @@ class FreeboxHomeEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Register state update callback."""
self.async_on_remove(
self.remove_signal_update(
async_dispatcher_connect(
self.hass,
self._hass,
self._router.signal_home_device_update,
self.async_update_signal,
)
)
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from hass."""
if self._remove_signal_update is not None:
self._remove_signal_update()
def remove_signal_update(self, dispatcher: Callable[[], None]) -> None:
"""Register state update callback."""
self._remove_signal_update = dispatcher
def get_value(self, ep_type: str, name: str):
"""Get the value."""
node = next(

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