Compare commits

..

1 Commits

Author SHA1 Message Date
Erik
df446f5240 Don't allow adding entities which don't belong to an entity platform 2025-07-01 16:27:10 +02:00
271 changed files with 2083 additions and 5423 deletions

View File

@@ -1,51 +0,0 @@
name: Task
description: For staff only - Create a task
type: Task
body:
- type: markdown
attributes:
value: |
## ⚠️ RESTRICTED ACCESS
**This form is restricted to Open Home Foundation staff, authorized contributors, and integration code owners only.**
If you are a community member wanting to contribute, please:
- For bug reports: Use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)
- For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)
---
### For authorized contributors
Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked.
- type: textarea
id: description
attributes:
label: Task description
description: |
Provide a clear and detailed description of the task that needs to be accomplished.
Be specific about what needs to be done, why it's important, and any constraints or requirements.
placeholder: |
Describe the task, including:
- What needs to be done
- Why this task is needed
- Expected outcome
- Any constraints or requirements
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional context
description: |
Any additional information, links, research, or context that would be helpful.
Include links to related issues, research, prototypes, roadmap opportunities etc.
placeholder: |
- Roadmap opportunity: [links]
- Feature request: [link]
- Technical design documents: [link]
- Prototype/mockup: [link]
validations:
required: false

View File

@@ -1,84 +0,0 @@
name: Restrict task creation
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
jobs:
check-authorization:
runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.issue_type == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@v7
with:
script: |
const issueAuthor = context.payload.issue.user.login;
// First check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized, no need to check further
} catch (error) {
console.log(` ${issueAuthor} is not an organization member, checking codeowners...`);
}
// If not an org member, check if they're a codeowner
try {
// Fetch CODEOWNERS file from the repository
const { data: codeownersFile } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: 'CODEOWNERS',
ref: 'dev'
});
// Decode the content (it's base64 encoded)
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8');
// Check if the issue author is mentioned in CODEOWNERS
// GitHub usernames in CODEOWNERS are prefixed with @
if (codeownersContent.includes(`@${issueAuthor}`)) {
console.log(`✅ ${issueAuthor} is a integration code owner`);
return; // Authorized
}
} catch (error) {
console.error('Error checking CODEOWNERS:', error);
}
// If we reach here, user is not authorized
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff, authorized contributors, and integration code owners.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});

4
CODEOWNERS generated
View File

@@ -452,8 +452,8 @@ build.json @home-assistant/supervisor
/tests/components/eq3btsmart/ @eulemitkeule @dbuezas
/homeassistant/components/escea/ @lazdavila
/tests/components/escea/ @lazdavila
/homeassistant/components/esphome/ @jesserockz @kbx81 @bdraco
/tests/components/esphome/ @jesserockz @kbx81 @bdraco
/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco
/tests/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco
/homeassistant/components/eufylife_ble/ @bdr99
/tests/components/eufylife_ble/ @bdr99
/homeassistant/components/event/ @home-assistant/core

View File

@@ -1,7 +1,15 @@
FROM mcr.microsoft.com/vscode/devcontainers/base:debian
FROM mcr.microsoft.com/devcontainers/python:1-3.13
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Uninstall pre-installed formatting and linting tools
# They would conflict with our pinned versions
RUN \
pipx uninstall pydocstyle \
&& pipx uninstall pycodestyle \
&& pipx uninstall mypy \
&& pipx uninstall pylint
RUN \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& apt-get update \
@@ -24,18 +32,21 @@ RUN \
libxml2 \
git \
cmake \
autoconf \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
# Install uv
RUN pip3 install uv
WORKDIR /usr/src
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN uv python install 3.13.2
# Setup hass-release
RUN git clone --depth 1 https://github.com/home-assistant/hass-release \
&& uv pip install --system -e hass-release/ \
&& chown -R vscode /usr/src/hass-release/data
USER vscode
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
@@ -44,10 +55,6 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /tmp
# Setup hass-release
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
&& uv pip install -e ~/hass-release/
# Install Python dependencies from requirements
COPY requirements.txt ./
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
@@ -58,4 +65,4 @@ RUN uv pip install -r requirements_test.txt
WORKDIR /workspaces
# Set the default shell to bash instead of sh
ENV SHELL=/bin/bash
ENV SHELL /bin/bash

View File

@@ -1,12 +1,11 @@
"""Integration to offer AI tasks to Home Assistant."""
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import (
HassJobType,
HomeAssistant,
@@ -15,14 +14,12 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.helpers import config_validation as cv, selector, storage
from homeassistant.helpers import config_validation as cv, storage
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import (
ATTR_INSTRUCTIONS,
ATTR_REQUIRED,
ATTR_STRUCTURE,
ATTR_TASK_NAME,
DATA_COMPONENT,
DATA_PREFERENCES,
@@ -50,27 +47,6 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
STRUCTURE_FIELD_SCHEMA = vol.Schema(
{
vol.Optional(CONF_DESCRIPTION): str,
vol.Optional(ATTR_REQUIRED): bool,
vol.Required(CONF_SELECTOR): selector.validate_selector,
}
)
def _validate_structure_fields(value: dict[str, Any]) -> vol.Schema:
"""Validate the structure fields as a voluptuous Schema."""
if not isinstance(value, dict):
raise vol.Invalid("Structure must be a dictionary")
fields = {}
for k, v in value.items():
field_class = vol.Required if v.get(ATTR_REQUIRED, False) else vol.Optional
fields[field_class(k, description=v.get(CONF_DESCRIPTION))] = selector.selector(
v[CONF_SELECTOR]
)
return vol.Schema(fields, extra=vol.PREVENT_EXTRA)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the process service."""
@@ -88,10 +64,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Required(ATTR_TASK_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_INSTRUCTIONS): cv.string,
vol.Optional(ATTR_STRUCTURE): vol.All(
vol.Schema({str: STRUCTURE_FIELD_SCHEMA}),
_validate_structure_fields,
),
}
),
supports_response=SupportsResponse.ONLY,

View File

@@ -21,8 +21,6 @@ SERVICE_GENERATE_DATA = "generate_data"
ATTR_INSTRUCTIONS: Final = "instructions"
ATTR_TASK_NAME: Final = "task_name"
ATTR_STRUCTURE: Final = "structure"
ATTR_REQUIRED: Final = "required"
DEFAULT_SYSTEM_PROMPT = (
"You are a Home Assistant expert and help users with their tasks."

View File

@@ -17,9 +17,3 @@ generate_data:
domain: ai_task
supported_features:
- ai_task.AITaskEntityFeature.GENERATE_DATA
structure:
advanced: true
required: false
example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }'
selector:
object:

View File

@@ -15,10 +15,6 @@
"entity_id": {
"name": "Entity ID",
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
},
"structure": {
"name": "Structured output",
"description": "When set, the AI Task will output fields with this in structure. The structure is a dictionary where the keys are the field names and the values contain a 'description', a 'selector', and an optional 'required' field."
}
}
}

View File

@@ -5,8 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -19,7 +17,6 @@ async def async_generate_data(
task_name: str,
entity_id: str | None = None,
instructions: str,
structure: vol.Schema | None = None,
) -> GenDataTaskResult:
"""Run a task in the AI Task integration."""
if entity_id is None:
@@ -41,7 +38,6 @@ async def async_generate_data(
GenDataTask(
name=task_name,
instructions=instructions,
structure=structure,
)
)
@@ -56,9 +52,6 @@ class GenDataTask:
instructions: str
"""Instructions on what needs to be done."""
structure: vol.Schema | None = None
"""Optional structure for the data to be generated."""
def __str__(self) -> str:
"""Return task as a string."""
return f"<GenDataTask {self.name}: {id(self)}>"

View File

@@ -2,45 +2,19 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import CountrySelector
from .const import CONF_LOGIN_DATA, DOMAIN
STEP_REAUTH_DATA_SCHEMA = 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]:
"""Validate the user input allows us to connect."""
api = AmazonEchoApi(
data[CONF_COUNTRY],
data[CONF_USERNAME],
data[CONF_PASSWORD],
)
try:
data = await api.login_mode_interactive(data[CONF_CODE])
finally:
await api.close()
return data
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Alexa Devices."""
@@ -51,14 +25,17 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
errors = {}
if user_input:
client = AmazonEchoApi(
user_input[CONF_COUNTRY],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
data = await validate_input(self.hass, user_input)
data = await client.login_mode_interactive(user_input[CONF_CODE])
except CannotConnect:
errors["base"] = "cannot_connect"
except CannotAuthenticate:
errors["base"] = "invalid_auth"
except WrongCountry:
errors["base"] = "wrong_country"
else:
await self.async_set_unique_id(data["customer_info"]["user_id"])
self._abort_if_unique_id_configured()
@@ -67,6 +44,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
title=user_input[CONF_USERNAME],
data=user_input | {CONF_LOGIN_DATA: data},
)
finally:
await client.close()
return self.async_show_form(
step_id="user",
@@ -82,43 +61,3 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
}
),
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
self.context["title_placeholders"] = {CONF_USERNAME: entry_data[CONF_USERNAME]}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirm."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
entry_data = reauth_entry.data
if user_input is not None:
try:
await validate_input(self.hass, {**reauth_entry.data, **user_input})
except CannotConnect:
errors["base"] = "cannot_connect"
except CannotAuthenticate:
errors["base"] = "invalid_auth"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data={
CONF_USERNAME: entry_data[CONF_USERNAME],
CONF_PASSWORD: entry_data[CONF_PASSWORD],
CONF_CODE: user_input[CONF_CODE],
},
)
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_USERNAME: entry_data[CONF_USERNAME]},
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)

View File

@@ -12,10 +12,10 @@ from aioamazondevices.exceptions import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
from .const import _LOGGER, CONF_LOGIN_DATA
SCAN_INTERVAL = 30
@@ -55,8 +55,4 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
except (CannotConnect, CannotRetrieveData) as err:
raise UpdateFailed(f"Error occurred while updating {self.name}") from err
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
raise ConfigEntryError("Could not authenticate") from err

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.2.2"]
"requirements": ["aioamazondevices==3.1.22"]
}

View File

@@ -34,7 +34,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage:
status: todo
comment: all tests missing

View File

@@ -22,29 +22,17 @@
"password": "[%key:component::alexa_devices::common::data_description_password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
}
},
"reauth_confirm": {
"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": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"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%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"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

@@ -5,18 +5,26 @@ from __future__ import annotations
from asyncio import timeout
import logging
from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth
from androidtvremote2 import (
AndroidTVRemote,
CannotConnect,
ConnectionClosed,
InvalidAuth,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
from .helpers import create_api, get_enable_ime
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE]
AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote]
async def async_setup_entry(
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
@@ -74,17 +82,13 @@ async def async_setup_entry(
return True
async def async_unload_entry(
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.debug("async_unload_entry: %s", entry.data)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_options(
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
) -> None:
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
_LOGGER.debug(
"async_update_options: data: %s options: %s", entry.data, entry.options

View File

@@ -16,7 +16,7 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -33,7 +33,7 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
from .helpers import create_api, get_enable_ime
_LOGGER = logging.getLogger(__name__)
@@ -41,6 +41,12 @@ APPS_NEW_ID = "NewApp"
CONF_APP_DELETE = "app_delete"
CONF_APP_ID = "app_id"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
}
)
STEP_PAIR_DATA_SCHEMA = vol.Schema(
{
vol.Required("pin"): str,
@@ -61,7 +67,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial and reconfigure step."""
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self.host = user_input[CONF_HOST]
@@ -70,32 +76,15 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
await api.async_generate_cert_if_missing()
self.name, self.mac = await api.async_get_name_and_mac()
await self.async_set_unique_id(format_mac(self.mac))
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data={
CONF_HOST: self.host,
CONF_NAME: self.name,
CONF_MAC: self.mac,
},
)
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Likely invalid IP address or device is network unreachable. Stay
# in the user step allowing the user to enter a different host.
errors["base"] = "cannot_connect"
else:
user_input = {}
default_host = user_input.get(CONF_HOST, vol.UNDEFINED)
if self.source == SOURCE_RECONFIGURE:
default_host = self._get_reconfigure_entry().data[CONF_HOST]
return self.async_show_form(
step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user",
data_schema=vol.Schema(
{vol.Required(CONF_HOST, default=default_host): str}
),
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -228,16 +217,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user(user_input)
@staticmethod
@callback
def async_get_options_flow(
config_entry: AndroidTVRemoteConfigEntry,
config_entry: ConfigEntry,
) -> AndroidTVRemoteOptionsFlowHandler:
"""Create the options flow."""
return AndroidTVRemoteOptionsFlowHandler(config_entry)
@@ -246,7 +229,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
class AndroidTVRemoteOptionsFlowHandler(OptionsFlow):
"""Android TV Remote options flow."""
def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None:
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
self._conf_app_id: str | None = None

View File

@@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import HomeAssistant
from .helpers import AndroidTVRemoteConfigEntry
from . import AndroidTVRemoteConfigEntry
TO_REDACT = {CONF_HOST, CONF_MAC}

View File

@@ -6,6 +6,7 @@ from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
@@ -13,7 +14,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device
from homeassistant.helpers.entity import Entity
from .const import CONF_APPS, DOMAIN
from .helpers import AndroidTVRemoteConfigEntry
class AndroidTVRemoteBaseEntity(Entity):
@@ -23,9 +23,7 @@ class AndroidTVRemoteBaseEntity(Entity):
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self, api: AndroidTVRemote, config_entry: AndroidTVRemoteConfigEntry
) -> None:
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
"""Initialize the entity."""
self._api = api
self._host = config_entry.data[CONF_HOST]

View File

@@ -10,8 +10,6 @@ from homeassistant.helpers.storage import STORAGE_DIR
from .const import CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE
AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote]
def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRemote:
"""Create an AndroidTVRemote instance."""
@@ -25,6 +23,6 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem
)
def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool:
def get_enable_ime(entry: ConfigEntry) -> bool:
"""Get value of enable_ime option or its default value."""
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE)

View File

@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"requirements": ["androidtvremote2==0.2.3"],
"requirements": ["androidtvremote2==0.2.2"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed, VolumeInfo
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.components.media_player import (
BrowseMedia,
@@ -20,9 +20,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
from .entity import AndroidTVRemoteBaseEntity
from .helpers import AndroidTVRemoteConfigEntry
PARALLEL_UPDATES = 0
@@ -75,11 +75,13 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
else current_app
)
def _update_volume_info(self, volume_info: VolumeInfo) -> None:
def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None:
"""Update volume info."""
if volume_info.get("max"):
self._attr_volume_level = volume_info["level"] / volume_info["max"]
self._attr_is_volume_muted = volume_info["muted"]
self._attr_volume_level = int(volume_info["level"]) / int(
volume_info["max"]
)
self._attr_is_volume_muted = bool(volume_info["muted"])
else:
self._attr_volume_level = None
self._attr_is_volume_muted = None
@@ -91,7 +93,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
self.async_write_ha_state()
@callback
def _volume_info_updated(self, volume_info: VolumeInfo) -> None:
def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None:
"""Update the state when the volume info changes."""
self._update_volume_info(volume_info)
self.async_write_ha_state()
@@ -100,10 +102,8 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
"""Register callbacks."""
await super().async_added_to_hass()
if self._api.current_app is not None:
self._update_current_app(self._api.current_app)
if self._api.volume_info is not None:
self._update_volume_info(self._api.volume_info)
self._update_current_app(self._api.current_app)
self._update_volume_info(self._api.volume_info)
self._api.add_current_app_updated_callback(self._current_app_updated)
self._api.add_volume_info_updated_callback(self._volume_info_updated)

View File

@@ -20,9 +20,9 @@ from homeassistant.components.remote import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_NAME
from .entity import AndroidTVRemoteBaseEntity
from .helpers import AndroidTVRemoteConfigEntry
PARALLEL_UPDATES = 0
@@ -63,8 +63,7 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
self._attr_activity_list = [
app.get(CONF_APP_NAME, "") for app in self._apps.values()
]
if self._api.current_app is not None:
self._update_current_app(self._api.current_app)
self._update_current_app(self._api.current_app)
self._api.add_current_app_updated_callback(self._current_app_updated)
async def async_will_remove_from_hass(self) -> None:

View File

@@ -6,18 +6,6 @@
"description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of the Android TV device."
}
},
"reconfigure": {
"description": "Update the IP address of this previously configured Android TV device.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of the Android TV device."
}
},
"zeroconf_confirm": {
@@ -28,9 +16,6 @@
"description": "Enter the pairing code displayed on the Android TV ({name}).",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
},
"data_description": {
"pin": "Pairing code displayed on the Android TV device."
}
},
"reauth_confirm": {
@@ -47,9 +32,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {
@@ -57,11 +40,7 @@
"init": {
"data": {
"apps": "Configure applications list",
"enable_ime": "Enable IME"
},
"data_description": {
"apps": "Here you can define the list of applications, specify names and icons that will be displayed in the UI.",
"enable_ime": "Enable this option to be able to get the current app name and send text as keyboard input. Disable it for devices that show 'Use keyboard on mobile device screen' instead of the on-screen keyboard."
"enable_ime": "Enable IME. Needed for getting the current app. Disable for devices that show 'Use keyboard on mobile device screen' instead of the on screen keyboard."
}
},
"apps": {
@@ -74,10 +53,8 @@
"app_delete": "Check to delete this application"
},
"data_description": {
"app_name": "Name of the application as you would like it to be displayed in Home Assistant.",
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename",
"app_delete": "Check this box to delete the application from the list."
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename"
}
}
}

View File

@@ -72,7 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("message"): str,
vol.Optional("media_id"): _media_id_validator,
vol.Optional("preannounce", default=True): bool,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): _media_id_validator,
}
),
@@ -89,7 +89,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("start_message"): str,
vol.Optional("start_media_id"): _media_id_validator,
vol.Optional("preannounce", default=True): bool,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): _media_id_validator,
vol.Optional("extra_system_prompt"): str,
}
@@ -114,7 +114,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
ask_question_args = {
"question": call.data.get("question"),
"question_media_id": call.data.get("question_media_id"),
"preannounce": call.data.get("preannounce", True),
"preannounce": call.data.get("preannounce", False),
"answers": call.data.get("answers"),
}
@@ -137,7 +137,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
vol.Optional("question"): str,
vol.Optional("question_media_id"): _media_id_validator,
vol.Optional("preannounce", default=True): bool,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): _media_id_validator,
vol.Optional("answers"): [
{

View File

@@ -19,7 +19,7 @@
"bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.2",
"bluetooth-data-tools==1.28.1",
"dbus-fast==2.43.0",
"habluetooth==3.49.0"
]

View File

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

View File

@@ -6,18 +6,11 @@ from operator import itemgetter
import numpy as np
import voluptuous as vol
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA,
DOMAIN as SENSOR_DOMAIN,
STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_DEVICE_CLASS,
CONF_MAXIMUM,
CONF_MINIMUM,
CONF_NAME,
CONF_SOURCE,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
@@ -57,23 +50,20 @@ def datapoints_greater_than_degree(value: dict) -> dict:
COMPENSATION_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ATTRIBUTE): cv.string,
vol.Required(CONF_SOURCE): cv.entity_id,
vol.Required(CONF_DATAPOINTS): [
vol.ExactSequence([vol.Coerce(float), vol.Coerce(float)])
],
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_ATTRIBUTE): cv.string,
vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean,
vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean,
vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int,
vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=7),
),
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int,
vol.Required(CONF_SOURCE): cv.entity_id,
vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean,
}
)

View File

@@ -7,23 +7,15 @@ from typing import Any
import numpy as np
from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
CONF_STATE_CLASS,
SensorEntity,
)
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ATTRIBUTE,
CONF_DEVICE_CLASS,
CONF_MAXIMUM,
CONF_MINIMUM,
CONF_NAME,
CONF_SOURCE,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
@@ -67,13 +59,24 @@ async def async_setup_platform(
source: str = conf[CONF_SOURCE]
attribute: str | None = conf.get(CONF_ATTRIBUTE)
if not (name := conf.get(CONF_NAME)):
name = f"{DEFAULT_NAME} {source}"
if attribute is not None:
name = f"{name} {attribute}"
name = f"{DEFAULT_NAME} {source}"
if attribute is not None:
name = f"{name} {attribute}"
async_add_entities(
[CompensationSensor(conf.get(CONF_UNIQUE_ID), name, source, attribute, conf)]
[
CompensationSensor(
conf.get(CONF_UNIQUE_ID),
name,
source,
attribute,
conf[CONF_PRECISION],
conf[CONF_POLYNOMIAL],
conf.get(CONF_UNIT_OF_MEASUREMENT),
conf[CONF_MINIMUM],
conf[CONF_MAXIMUM],
)
]
)
@@ -88,27 +91,23 @@ class CompensationSensor(SensorEntity):
name: str,
source: str,
attribute: str | None,
config: dict[str, Any],
precision: int,
polynomial: np.poly1d,
unit_of_measurement: str | None,
minimum: tuple[float, float] | None,
maximum: tuple[float, float] | None,
) -> None:
"""Initialize the Compensation sensor."""
self._attr_name = name
self._source_entity_id = source
self._precision = precision
self._source_attribute = attribute
self._precision = config[CONF_PRECISION]
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
polynomial: np.poly1d = config[CONF_POLYNOMIAL]
self._attr_native_unit_of_measurement = unit_of_measurement
self._poly = polynomial
self._coefficients = polynomial.coefficients.tolist()
self._attr_unique_id = unique_id
self._minimum = config[CONF_MINIMUM]
self._maximum = config[CONF_MAXIMUM]
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_state_class = config.get(CONF_STATE_CLASS)
self._attr_name = name
self._minimum = minimum
self._maximum = maximum
async def async_added_to_hass(self) -> None:
"""Handle added to Hass."""
@@ -138,40 +137,13 @@ class CompensationSensor(SensorEntity):
"""Handle sensor state changes."""
new_state: State | None
if (new_state := event.data["new_state"]) is None:
_LOGGER.warning(
"While updating compensation %s, the new_state is None", self.name
)
self._attr_native_value = None
self.async_write_ha_state()
return
if new_state.state == STATE_UNKNOWN:
self._attr_native_value = None
self.async_write_ha_state()
return
if new_state.state == STATE_UNAVAILABLE:
self._attr_available = False
self.async_write_ha_state()
return
self._attr_available = True
if self.native_unit_of_measurement is None and self._source_attribute is None:
self._attr_native_unit_of_measurement = new_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT
)
if self._attr_device_class is None and (
device_class := new_state.attributes.get(ATTR_DEVICE_CLASS)
):
self._attr_device_class = device_class
if self._attr_state_class is None and (
state_class := new_state.attributes.get(ATTR_STATE_CLASS)
):
self._attr_state_class = state_class
if self._source_attribute:
value = new_state.attributes.get(self._source_attribute)
else:

View File

@@ -19,7 +19,10 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF
SUPPORT_BASIC_SERVICES = (
VacuumEntityFeature.STATE | VacuumEntityFeature.START | VacuumEntityFeature.STOP
VacuumEntityFeature.STATE
| VacuumEntityFeature.START
| VacuumEntityFeature.STOP
| VacuumEntityFeature.BATTERY
)
SUPPORT_MOST_SERVICES = (
@@ -28,6 +31,7 @@ SUPPORT_MOST_SERVICES = (
| VacuumEntityFeature.STOP
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.FAN_SPEED
)
@@ -42,6 +46,7 @@ SUPPORT_ALL_SERVICES = (
| VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.LOCATE
| VacuumEntityFeature.STATUS
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.LOCATE
| VacuumEntityFeature.MAP
| VacuumEntityFeature.CLEAN_SPOT
@@ -85,6 +90,12 @@ class StateDemoVacuum(StateVacuumEntity):
self._attr_activity = VacuumActivity.DOCKED
self._fan_speed = FAN_SPEEDS[1]
self._cleaned_area: float = 0
self._battery_level = 100
@property
def battery_level(self) -> int:
"""Return the current battery level of the vacuum."""
return max(0, min(100, self._battery_level))
@property
def fan_speed(self) -> str:
@@ -106,6 +117,7 @@ class StateDemoVacuum(StateVacuumEntity):
if self._attr_activity != VacuumActivity.CLEANING:
self._attr_activity = VacuumActivity.CLEANING
self._cleaned_area += 1.32
self._battery_level -= 1
self.schedule_update_ha_state()
def pause(self) -> None:
@@ -130,6 +142,7 @@ class StateDemoVacuum(StateVacuumEntity):
"""Perform a spot clean-up."""
self._attr_activity = VacuumActivity.CLEANING
self._cleaned_area += 1.32
self._battery_level -= 1
self.schedule_update_ha_state()
def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:

View File

@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pydoods"],
"quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==11.3.0"]
"requirements": ["pydoods==1.0.2", "Pillow==11.2.1"]
}

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.5.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"]
}

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "bronze",
"requirements": ["eheimdigital==1.3.0"],
"requirements": ["eheimdigital==1.2.0"],
"zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
]

View File

@@ -63,7 +63,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) ->
coordinator = entry.runtime_data
coordinator.async_cancel_token_refresh()
coordinator.async_cancel_firmware_refresh()
coordinator.async_cancel_mac_verification()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

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

View File

@@ -281,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
_static_info: _InfoT
_state: _StateT
_has_state: bool = False
_has_state: bool
unique_id: str
def __init__(

View File

@@ -2,7 +2,7 @@
"domain": "esphome",
"name": "ESPHome",
"after_dependencies": ["hassio", "zeroconf", "tag"],
"codeowners": ["@jesserockz", "@kbx81", "@bdraco"],
"codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"],
"config_flow": true,
"dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"],
"dhcp": [
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==34.1.0",
"aioesphomeapi==33.1.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==2.16.0"
],

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250702.0"]
"requirements": ["home-assistant-frontend==20250627.0"]
}

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["av==13.1.0", "Pillow==11.3.0"]
"requirements": ["av==13.1.0", "Pillow==11.2.1"]
}

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
from functools import partial
import mimetypes
from pathlib import Path
from types import MappingProxyType
@@ -38,13 +37,11 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_PROMPT,
DEFAULT_AI_TASK_NAME,
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
FILE_POLLING_INTERVAL_SECONDS,
LOGGER,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_TTS_OPTIONS,
TIMEOUT_MILLIS,
@@ -56,7 +53,6 @@ CONF_FILENAMES = "filenames"
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = (
Platform.AI_TASK,
Platform.CONVERSATION,
Platform.TTS,
)
@@ -191,9 +187,11 @@ async def async_setup_entry(
"""Set up Google Generative AI Conversation from a config entry."""
try:
client = await hass.async_add_executor_job(
partial(Client, api_key=entry.data[CONF_API_KEY])
)
def _init_client() -> Client:
return Client(api_key=entry.data[CONF_API_KEY])
client = await hass.async_add_executor_job(_init_client)
await client.aio.models.get(
model=RECOMMENDED_CHAT_MODEL,
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
@@ -352,19 +350,6 @@ async def async_migrate_entry(
hass.config_entries.async_update_entry(entry, minor_version=2)
if entry.version == 2 and entry.minor_version == 2:
# Add AI Task subentry with default options
hass.config_entries.async_add_subentry(
entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
subentry_type="ai_task_data",
title=DEFAULT_AI_TASK_NAME,
unique_id=None,
),
)
hass.config_entries.async_update_entry(entry, minor_version=3)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)

View File

@@ -1,57 +0,0 @@
"""AI Task integration for Google Generative AI Conversation."""
from __future__ import annotations
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "ai_task_data":
continue
async_add_entities(
[GoogleGenerativeAITaskEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class GoogleGenerativeAITaskEntity(
ai_task.AITaskEntity,
GoogleGenerativeAILLMBaseEntity,
):
"""Google Generative AI AI Task entity."""
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA
async def _async_generate_data(
self,
task: ai_task.GenDataTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(chat_log)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
LOGGER.error(
"Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response",
chat_log.content[-1],
)
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=chat_log.content[-1].content or "",
)

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
from functools import partial
import logging
from typing import Any, cast
@@ -47,12 +46,10 @@ from .const import (
CONF_TOP_K,
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
@@ -75,14 +72,12 @@ STEP_API_DATA_SCHEMA = vol.Schema(
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
client = await hass.async_add_executor_job(
partial(genai.Client, api_key=data[CONF_API_KEY])
)
client = genai.Client(api_key=data[CONF_API_KEY])
await client.aio.models.list(
config={
"http_options": {
@@ -97,7 +92,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Generative AI Conversation."""
VERSION = 2
MINOR_VERSION = 3
MINOR_VERSION = 2
async def async_step_api(
self, user_input: dict[str, Any] | None = None
@@ -107,7 +102,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self._async_abort_entries_match(user_input)
try:
await validate_input(self.hass, user_input)
await validate_input(user_input)
except (APIError, Timeout) as err:
if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
errors["base"] = "invalid_auth"
@@ -138,12 +133,6 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
"title": DEFAULT_TTS_NAME,
"unique_id": None,
},
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
],
)
return self.async_show_form(
@@ -192,7 +181,6 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
return {
"conversation": LLMSubentryFlowHandler,
"tts": LLMSubentryFlowHandler,
"ai_task_data": LLMSubentryFlowHandler,
}
@@ -226,8 +214,6 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
options: dict[str, Any]
if self._subentry_type == "tts":
options = RECOMMENDED_TTS_OPTIONS.copy()
elif self._subentry_type == "ai_task_data":
options = RECOMMENDED_AI_TASK_OPTIONS.copy()
else:
options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
else:
@@ -302,8 +288,6 @@ async def google_generative_ai_config_option_schema(
default_name = options[CONF_NAME]
elif subentry_type == "tts":
default_name = DEFAULT_TTS_NAME
elif subentry_type == "ai_task_data":
default_name = DEFAULT_AI_TASK_NAME
else:
default_name = DEFAULT_CONVERSATION_NAME
schema: dict[vol.Required | vol.Optional, Any] = {
@@ -331,7 +315,6 @@ async def google_generative_ai_config_option_schema(
),
}
)
schema.update(
{
vol.Required(
@@ -347,14 +330,13 @@ async def google_generative_ai_config_option_schema(
api_models = [api_model async for api_model in api_models_pager]
models = [
SelectOptionDict(
label=api_model.name.lstrip("models/"),
label=api_model.display_name,
value=api_model.name,
)
for api_model in sorted(
api_models, key=lambda x: x.name.lstrip("models/") or ""
)
for api_model in sorted(api_models, key=lambda x: x.display_name or "")
if (
api_model.name
api_model.display_name
and api_model.name
and ("tts" in api_model.name) == (subentry_type == "tts")
and "vision" not in api_model.name
and api_model.supported_actions
@@ -460,5 +442,4 @@ async def google_generative_ai_config_option_schema(
): bool,
}
)
return schema

View File

@@ -12,7 +12,6 @@ CONF_PROMPT = "prompt"
DEFAULT_CONVERSATION_NAME = "Google AI Conversation"
DEFAULT_TTS_NAME = "Google AI TTS"
DEFAULT_AI_TASK_NAME = "Google AI Task"
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
@@ -36,7 +35,6 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
TIMEOUT_MILLIS = 10000
FILE_POLLING_INTERVAL_SECONDS = 0.05
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
@@ -46,7 +44,3 @@ RECOMMENDED_CONVERSATION_OPTIONS = {
RECOMMENDED_TTS_OPTIONS = {
CONF_RECOMMENDED: True,
}
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}

View File

@@ -88,34 +88,6 @@
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"ai_task_data": {
"initiate_flow": {
"user": "Add Generate data with AI service",
"reconfigure": "Reconfigure Generate data with AI service"
},
"entry_type": "Generate data with AI service",
"step": {
"set_options": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
"chat_model": "[%key:common::generic::model%]",
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]",
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
"max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]",
"harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]",
"hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]",
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
"dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]"
}
}
},
"abort": {
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
},
"services": {

View File

@@ -11,7 +11,6 @@ from urllib.parse import quote
import aiohttp
from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web
from aiohttp.helpers import must_be_empty_body
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
from multidict import CIMultiDict
from yarl import URL
@@ -185,16 +184,13 @@ class HassIOIngress(HomeAssistantView):
content_type = "application/octet-stream"
# Simple request
if (empty_body := must_be_empty_body(result.method, result.status)) or (
if result.status in (204, 304) or (
content_length is not UNDEFINED
and (content_length_int := int(content_length))
<= MAX_SIMPLE_RESPONSE_SIZE
):
# Return Response
if empty_body:
body = None
else:
body = await result.read()
body = await result.read()
simple_response = web.Response(
headers=headers,
status=result.status,

View File

@@ -63,8 +63,8 @@ from .utils import get_device_macs, non_verifying_requests_session
_LOGGER = logging.getLogger(__name__)
class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN):
"""Huawei LTE config flow."""
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle Huawei LTE config flow."""
VERSION = 3
@@ -75,9 +75,9 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN):
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> HuaweiLteOptionsFlow:
) -> OptionsFlowHandler:
"""Get options flow."""
return HuaweiLteOptionsFlow()
return OptionsFlowHandler()
async def _async_show_user_form(
self,
@@ -354,7 +354,7 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_update_reload_and_abort(entry, data=new_data)
class HuaweiLteOptionsFlow(OptionsFlow):
class OptionsFlowHandler(OptionsFlow):
"""Huawei LTE options flow."""
async def async_step_init(

View File

@@ -73,6 +73,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
schedule = self.mower_attributes.calendar
cursor = schedule.timeline.active_after(dt_util.now())
program_event = next(cursor, None)
_LOGGER.debug("program_event %s", program_event)
if not program_event:
return None
work_area_name = None

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["Pillow==11.3.0"]
"requirements": ["Pillow==11.2.1"]
}

View File

@@ -209,12 +209,6 @@
"state": {
"off": "mdi:card-bulleted-off-outline"
}
},
"boost": {
"default": "mdi:thermometer-high",
"state": {
"off": "mdi:thermometer-off"
}
}
}
}

View File

@@ -464,16 +464,6 @@ class IronOSTemperatureNumberEntity(IronOSNumberEntity):
else super().native_max_value
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
if (
self.entity_description.key is PinecilNumber.BOOST_TEMP
and self.native_value == 0
):
return False
return super().available
class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity):
"""IronOS setpoint temperature entity."""

View File

@@ -278,9 +278,6 @@
},
"calibrate_cjc": {
"name": "Calibrate CJC"
},
"boost": {
"name": "Boost"
}
}
},

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from pynecil import CharSetting, SettingsDataResponse, TempUnit
from pynecil import CharSetting, SettingsDataResponse
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
@@ -15,7 +15,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IronOSConfigEntry
from .const import MIN_BOOST_TEMP, MIN_BOOST_TEMP_F
from .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity
@@ -40,7 +39,6 @@ class IronOSSwitch(StrEnum):
INVERT_BUTTONS = "invert_buttons"
DISPLAY_INVERT = "display_invert"
CALIBRATE_CJC = "calibrate_cjc"
BOOST = "boost"
SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = (
@@ -96,13 +94,6 @@ SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = (
entity_registry_enabled_default=False,
entity_category=EntityCategory.CONFIG,
),
IronOSSwitchEntityDescription(
key=IronOSSwitch.BOOST,
translation_key=IronOSSwitch.BOOST,
characteristic=CharSetting.BOOST_TEMP,
is_on_fn=lambda x: bool(x.get("boost_temp")),
entity_category=EntityCategory.CONFIG,
),
)
@@ -145,15 +136,7 @@ class IronOSSwitchEntity(IronOSBaseEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if self.entity_description.key is IronOSSwitch.BOOST:
await self.settings.write(
self.entity_description.characteristic,
MIN_BOOST_TEMP_F
if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT
else MIN_BOOST_TEMP,
)
else:
await self.settings.write(self.entity_description.characteristic, True)
await self.settings.write(self.entity_description.characteristic, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity on."""

View File

@@ -39,8 +39,7 @@ from .const import (
KNX_MODULE_KEY,
)
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .storage.const import CONF_ENTITY, CONF_GA_SENSOR
from .storage.util import ConfigExtractor
from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE
async def async_setup_entry(
@@ -147,17 +146,17 @@ class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity):
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
self._device = XknxBinarySensor(
xknx=knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR),
sync_state=knx_conf.get(CONF_SYNC_STATE),
invert=knx_conf.get(CONF_INVERT, default=False),
ignore_internal_state=knx_conf.get(
CONF_IGNORE_INTERNAL_STATE, default=False
),
context_timeout=knx_conf.get(CONF_CONTEXT_TIMEOUT),
reset_after=knx_conf.get(CONF_RESET_AFTER),
group_address_state=[
config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE],
*config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE],
],
sync_state=config[DOMAIN][CONF_SYNC_STATE],
invert=config[DOMAIN].get(CONF_INVERT, False),
ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False),
context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT),
reset_after=config[DOMAIN].get(CONF_RESET_AFTER),
)
self._attr_force_update = self._device.ignore_internal_state

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from xknx import XKNX
from xknx.devices import Cover as XknxCover
@@ -35,13 +35,15 @@ from .schema import CoverSchema
from .storage.const import (
CONF_ENTITY,
CONF_GA_ANGLE,
CONF_GA_PASSIVE,
CONF_GA_POSITION_SET,
CONF_GA_POSITION_STATE,
CONF_GA_STATE,
CONF_GA_STEP,
CONF_GA_STOP,
CONF_GA_UP_DOWN,
CONF_GA_WRITE,
)
from .storage.util import ConfigExtractor
async def async_setup_entry(
@@ -228,24 +230,38 @@ class KnxYamlCover(_KnxCover, KnxYamlEntity):
def _create_ui_cover(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxCover:
"""Return a KNX Light device to be used within XKNX."""
conf = ConfigExtractor(knx_config)
def get_address(
key: str, address_type: Literal["write", "state"] = CONF_GA_WRITE
) -> str | None:
"""Get a single group address for given key."""
return knx_config[key][address_type] if key in knx_config else None
def get_addresses(
key: str, address_type: Literal["write", "state"] = CONF_GA_STATE
) -> list[Any] | None:
"""Get group address including passive addresses as list."""
return (
[knx_config[key][address_type], *knx_config[key][CONF_GA_PASSIVE]]
if key in knx_config
else None
)
return XknxCover(
xknx=xknx,
name=name,
group_address_long=conf.get_write_and_passive(CONF_GA_UP_DOWN),
group_address_short=conf.get_write_and_passive(CONF_GA_STEP),
group_address_stop=conf.get_write_and_passive(CONF_GA_STOP),
group_address_position=conf.get_write_and_passive(CONF_GA_POSITION_SET),
group_address_position_state=conf.get_state_and_passive(CONF_GA_POSITION_STATE),
group_address_angle=conf.get_write(CONF_GA_ANGLE),
group_address_angle_state=conf.get_state_and_passive(CONF_GA_ANGLE),
travel_time_down=conf.get(CoverConf.TRAVELLING_TIME_DOWN),
travel_time_up=conf.get(CoverConf.TRAVELLING_TIME_UP),
invert_updown=conf.get(CoverConf.INVERT_UPDOWN, default=False),
invert_position=conf.get(CoverConf.INVERT_POSITION, default=False),
invert_angle=conf.get(CoverConf.INVERT_ANGLE, default=False),
sync_state=conf.get(CONF_SYNC_STATE),
group_address_long=get_addresses(CONF_GA_UP_DOWN, CONF_GA_WRITE),
group_address_short=get_addresses(CONF_GA_STEP, CONF_GA_WRITE),
group_address_stop=get_addresses(CONF_GA_STOP, CONF_GA_WRITE),
group_address_position=get_addresses(CONF_GA_POSITION_SET, CONF_GA_WRITE),
group_address_position_state=get_addresses(CONF_GA_POSITION_STATE),
group_address_angle=get_address(CONF_GA_ANGLE),
group_address_angle_state=get_addresses(CONF_GA_ANGLE),
travel_time_down=knx_config[CoverConf.TRAVELLING_TIME_DOWN],
travel_time_up=knx_config[CoverConf.TRAVELLING_TIME_UP],
invert_updown=knx_config.get(CoverConf.INVERT_UPDOWN, False),
invert_position=knx_config.get(CoverConf.INVERT_POSITION, False),
invert_angle=knx_config.get(CoverConf.INVERT_ANGLE, False),
sync_state=knx_config[CONF_SYNC_STATE],
)

View File

@@ -35,6 +35,7 @@ from .schema import LightSchema
from .storage.const import (
CONF_COLOR_TEMP_MAX,
CONF_COLOR_TEMP_MIN,
CONF_DPT,
CONF_ENTITY,
CONF_GA_BLUE_BRIGHTNESS,
CONF_GA_BLUE_SWITCH,
@@ -44,15 +45,17 @@ from .storage.const import (
CONF_GA_GREEN_BRIGHTNESS,
CONF_GA_GREEN_SWITCH,
CONF_GA_HUE,
CONF_GA_PASSIVE,
CONF_GA_RED_BRIGHTNESS,
CONF_GA_RED_SWITCH,
CONF_GA_SATURATION,
CONF_GA_STATE,
CONF_GA_SWITCH,
CONF_GA_WHITE_BRIGHTNESS,
CONF_GA_WHITE_SWITCH,
CONF_GA_WRITE,
)
from .storage.entity_store_schema import LightColorMode
from .storage.util import ConfigExtractor
async def async_setup_entry(
@@ -200,92 +203,94 @@ def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight:
def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight:
"""Return a KNX Light device to be used within XKNX."""
conf = ConfigExtractor(knx_config)
def get_write(key: str) -> str | None:
"""Get the write group address."""
return knx_config[key][CONF_GA_WRITE] if key in knx_config else None
def get_state(key: str) -> list[Any] | None:
"""Get the state group address."""
return (
[knx_config[key][CONF_GA_STATE], *knx_config[key][CONF_GA_PASSIVE]]
if key in knx_config
else None
)
def get_dpt(key: str) -> str | None:
"""Get the DPT."""
return knx_config[key].get(CONF_DPT) if key in knx_config else None
group_address_tunable_white = None
group_address_tunable_white_state = None
group_address_color_temp = None
group_address_color_temp_state = None
color_temperature_type = ColorTemperatureType.UINT_2_BYTE
if _color_temp_dpt := conf.get_dpt(CONF_GA_COLOR_TEMP):
if _color_temp_dpt == ColorTempModes.RELATIVE.value:
group_address_tunable_white = conf.get_write(CONF_GA_COLOR_TEMP)
group_address_tunable_white_state = conf.get_state_and_passive(
CONF_GA_COLOR_TEMP
)
if ga_color_temp := knx_config.get(CONF_GA_COLOR_TEMP):
if ga_color_temp[CONF_DPT] == ColorTempModes.RELATIVE.value:
group_address_tunable_white = ga_color_temp[CONF_GA_WRITE]
group_address_tunable_white_state = [
ga_color_temp[CONF_GA_STATE],
*ga_color_temp[CONF_GA_PASSIVE],
]
else:
# absolute uint or float
group_address_color_temp = conf.get_write(CONF_GA_COLOR_TEMP)
group_address_color_temp_state = conf.get_state_and_passive(
CONF_GA_COLOR_TEMP
)
if _color_temp_dpt == ColorTempModes.ABSOLUTE_FLOAT.value:
group_address_color_temp = ga_color_temp[CONF_GA_WRITE]
group_address_color_temp_state = [
ga_color_temp[CONF_GA_STATE],
*ga_color_temp[CONF_GA_PASSIVE],
]
if ga_color_temp[CONF_DPT] == ColorTempModes.ABSOLUTE_FLOAT.value:
color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE
color_dpt = conf.get_dpt(CONF_GA_COLOR)
_color_dpt = get_dpt(CONF_GA_COLOR)
return XknxLight(
xknx,
name=name,
group_address_switch=conf.get_write(CONF_GA_SWITCH),
group_address_switch_state=conf.get_state_and_passive(CONF_GA_SWITCH),
group_address_brightness=conf.get_write(CONF_GA_BRIGHTNESS),
group_address_brightness_state=conf.get_state_and_passive(CONF_GA_BRIGHTNESS),
group_address_color=conf.get_write(CONF_GA_COLOR)
if color_dpt == LightColorMode.RGB
group_address_switch=get_write(CONF_GA_SWITCH),
group_address_switch_state=get_state(CONF_GA_SWITCH),
group_address_brightness=get_write(CONF_GA_BRIGHTNESS),
group_address_brightness_state=get_state(CONF_GA_BRIGHTNESS),
group_address_color=get_write(CONF_GA_COLOR)
if _color_dpt == LightColorMode.RGB
else None,
group_address_color_state=conf.get_state_and_passive(CONF_GA_COLOR)
if color_dpt == LightColorMode.RGB
group_address_color_state=get_state(CONF_GA_COLOR)
if _color_dpt == LightColorMode.RGB
else None,
group_address_rgbw=conf.get_write(CONF_GA_COLOR)
if color_dpt == LightColorMode.RGBW
group_address_rgbw=get_write(CONF_GA_COLOR)
if _color_dpt == LightColorMode.RGBW
else None,
group_address_rgbw_state=conf.get_state_and_passive(CONF_GA_COLOR)
if color_dpt == LightColorMode.RGBW
group_address_rgbw_state=get_state(CONF_GA_COLOR)
if _color_dpt == LightColorMode.RGBW
else None,
group_address_hue=conf.get_write(CONF_GA_HUE),
group_address_hue_state=conf.get_state_and_passive(CONF_GA_HUE),
group_address_saturation=conf.get_write(CONF_GA_SATURATION),
group_address_saturation_state=conf.get_state_and_passive(CONF_GA_SATURATION),
group_address_xyy_color=conf.get_write(CONF_GA_COLOR)
if color_dpt == LightColorMode.XYY
group_address_hue=get_write(CONF_GA_HUE),
group_address_hue_state=get_state(CONF_GA_HUE),
group_address_saturation=get_write(CONF_GA_SATURATION),
group_address_saturation_state=get_state(CONF_GA_SATURATION),
group_address_xyy_color=get_write(CONF_GA_COLOR)
if _color_dpt == LightColorMode.XYY
else None,
group_address_xyy_color_state=conf.get_write(CONF_GA_COLOR)
if color_dpt == LightColorMode.XYY
group_address_xyy_color_state=get_write(CONF_GA_COLOR)
if _color_dpt == LightColorMode.XYY
else None,
group_address_tunable_white=group_address_tunable_white,
group_address_tunable_white_state=group_address_tunable_white_state,
group_address_color_temperature=group_address_color_temp,
group_address_color_temperature_state=group_address_color_temp_state,
group_address_switch_red=conf.get_write(CONF_GA_RED_SWITCH),
group_address_switch_red_state=conf.get_state_and_passive(CONF_GA_RED_SWITCH),
group_address_brightness_red=conf.get_write(CONF_GA_RED_BRIGHTNESS),
group_address_brightness_red_state=conf.get_state_and_passive(
CONF_GA_RED_BRIGHTNESS
),
group_address_switch_green=conf.get_write(CONF_GA_GREEN_SWITCH),
group_address_switch_green_state=conf.get_state_and_passive(
CONF_GA_GREEN_SWITCH
),
group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS),
group_address_brightness_green_state=conf.get_state_and_passive(
CONF_GA_GREEN_BRIGHTNESS
),
group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH),
group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH),
group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS),
group_address_brightness_blue_state=conf.get_state_and_passive(
CONF_GA_BLUE_BRIGHTNESS
),
group_address_switch_white=conf.get_write(CONF_GA_WHITE_SWITCH),
group_address_switch_white_state=conf.get_state_and_passive(
CONF_GA_WHITE_SWITCH
),
group_address_brightness_white=conf.get_write(CONF_GA_WHITE_BRIGHTNESS),
group_address_brightness_white_state=conf.get_state_and_passive(
CONF_GA_WHITE_BRIGHTNESS
),
group_address_switch_red=get_write(CONF_GA_RED_SWITCH),
group_address_switch_red_state=get_state(CONF_GA_RED_SWITCH),
group_address_brightness_red=get_write(CONF_GA_RED_BRIGHTNESS),
group_address_brightness_red_state=get_state(CONF_GA_RED_BRIGHTNESS),
group_address_switch_green=get_write(CONF_GA_GREEN_SWITCH),
group_address_switch_green_state=get_state(CONF_GA_GREEN_SWITCH),
group_address_brightness_green=get_write(CONF_GA_GREEN_BRIGHTNESS),
group_address_brightness_green_state=get_state(CONF_GA_GREEN_BRIGHTNESS),
group_address_switch_blue=get_write(CONF_GA_BLUE_SWITCH),
group_address_switch_blue_state=get_state(CONF_GA_BLUE_SWITCH),
group_address_brightness_blue=get_write(CONF_GA_BLUE_BRIGHTNESS),
group_address_brightness_blue_state=get_state(CONF_GA_BLUE_BRIGHTNESS),
group_address_switch_white=get_write(CONF_GA_WHITE_SWITCH),
group_address_switch_white_state=get_state(CONF_GA_WHITE_SWITCH),
group_address_brightness_white=get_write(CONF_GA_WHITE_BRIGHTNESS),
group_address_brightness_white_state=get_state(CONF_GA_WHITE_BRIGHTNESS),
color_temperature_type=color_temperature_type,
min_kelvin=knx_config[CONF_COLOR_TEMP_MIN],
max_kelvin=knx_config[CONF_COLOR_TEMP_MAX],

View File

@@ -1,51 +0,0 @@
"""Utility functions for the KNX integration."""
from functools import partial
from typing import Any
from homeassistant.helpers.typing import ConfigType
from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE
def nested_get(dic: ConfigType, *keys: str, default: Any | None = None) -> Any:
"""Get the value from a nested dictionary."""
for key in keys:
if key not in dic:
return default
dic = dic[key]
return dic
class ConfigExtractor:
"""Helper class for extracting values from a knx config store dictionary."""
__slots__ = ("get",)
def __init__(self, config: ConfigType) -> None:
"""Initialize the extractor."""
self.get = partial(nested_get, config)
def get_write(self, *path: str) -> str | None:
"""Get the write group address."""
return self.get(*path, CONF_GA_WRITE) # type: ignore[no-any-return]
def get_state(self, *path: str) -> str | None:
"""Get the state group address."""
return self.get(*path, CONF_GA_STATE) # type: ignore[no-any-return]
def get_write_and_passive(self, *path: str) -> list[Any | None]:
"""Get the group addresses of write and passive."""
write = self.get(*path, CONF_GA_WRITE)
passive = self.get(*path, CONF_GA_PASSIVE)
return [write, *passive] if passive else [write]
def get_state_and_passive(self, *path: str) -> list[Any | None]:
"""Get the group addresses of state and passive."""
state = self.get(*path, CONF_GA_STATE)
passive = self.get(*path, CONF_GA_PASSIVE)
return [state, *passive] if passive else [state]
def get_dpt(self, *path: str) -> str | None:
"""Get the data point type of a group address config key."""
return self.get(*path, CONF_DPT) # type: ignore[no-any-return]

View File

@@ -36,8 +36,13 @@ from .const import (
)
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .schema import SwitchSchema
from .storage.const import CONF_ENTITY, CONF_GA_SWITCH
from .storage.util import ConfigExtractor
from .storage.const import (
CONF_ENTITY,
CONF_GA_PASSIVE,
CONF_GA_STATE,
CONF_GA_SWITCH,
CONF_GA_WRITE,
)
async def async_setup_entry(
@@ -137,13 +142,15 @@ class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
self._device = XknxSwitch(
knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address=knx_conf.get_write(CONF_GA_SWITCH),
group_address_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH),
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
sync_state=knx_conf.get(CONF_SYNC_STATE),
invert=knx_conf.get(CONF_INVERT),
group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE],
group_address_state=[
config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE],
*config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE],
],
respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ],
sync_state=config[DOMAIN][CONF_SYNC_STATE],
invert=config[DOMAIN][CONF_INVERT],
)

View File

@@ -104,11 +104,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) -
) as ex:
await lcn_connection.async_close()
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={
"config_entry_title": config_entry.title,
},
f"Unable to connect to {config_entry.title}: {ex}"
) from ex
_LOGGER.info('LCN connected to "%s"', config_entry.title)

View File

@@ -26,7 +26,6 @@ from homeassistant.const import (
CONF_SWITCHES,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.typing import ConfigType
@@ -101,11 +100,7 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
return cast(str, domain_data["setpoint"])
if domain_name == "scene":
return f"{domain_data['register']}{domain_data['scene']}"
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_domain",
translation_placeholders={CONF_DOMAIN: domain_name},
)
raise ValueError("Unknown domain")
def generate_unique_id(
@@ -309,8 +304,6 @@ def get_device_config(
def is_states_string(states_string: str) -> list[str]:
"""Validate the given states string and return states list."""
if len(states_string) != 8:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="invalid_length_of_states_string"
)
raise ValueError("Invalid length of states string")
states = {"1": "ON", "0": "OFF", "T": "TOGGLE", "-": "NOCHANGE"}
return [states[state_string] for state_string in states_string]

View File

@@ -19,7 +19,7 @@ rules:
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt

View File

@@ -330,9 +330,8 @@ class SendKeys(LcnServiceCall):
if (delay_time := service.data[CONF_TIME]) != 0:
hit = pypck.lcn_defs.SendKeyCommand.HIT
if pypck.lcn_defs.SendKeyCommand[service.data[CONF_STATE]] != hit:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_send_keys_action",
raise ValueError(
"Only hit command is allowed when sending deferred keys."
)
delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT])
await device_connection.send_keys_hit_deferred(keys, delay_time, delay_unit)
@@ -369,9 +368,8 @@ class LockKeys(LcnServiceCall):
if (delay_time := service.data[CONF_TIME]) != 0:
if table_id != 0:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_lock_keys_table",
raise ValueError(
"Only table A is allowed when locking keys for a specific time."
)
delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT])
await device_connection.lock_keys_tab_a_temporary(

View File

@@ -414,23 +414,11 @@
}
},
"exceptions": {
"cannot_connect": {
"message": "Unable to connect to {config_entry_title}."
"invalid_address": {
"message": "LCN device for given address has not been configured."
},
"invalid_device_id": {
"message": "LCN device for given device ID {device_id} has not been configured."
},
"invalid_domain": {
"message": "Invalid domain {domain}."
},
"invalid_send_keys_action": {
"message": "Invalid state for sending keys. Only 'hit' allowed for deferred sending."
},
"invalid_lock_keys_table": {
"message": "Invalid table for locking keys. Only table A allowed when locking for a specific time."
},
"invalid_length_of_states_string": {
"message": "Invalid length of states string. Expected 8 characters."
"message": "LCN device for given device ID has not been configured."
}
}
}

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"]
"requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"]
}

View File

@@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"]
"requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"]
}

View File

@@ -200,7 +200,7 @@ async def async_remove_config_entry_device(
hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove lookin config entry from a device."""
data = entry.runtime_data
data: LookinData = hass.data[DOMAIN][entry.entry_id]
all_identifiers: set[tuple[str, str]] = {
(DOMAIN, data.lookin_device.id),
*((DOMAIN, remote["UUID"]) for remote in data.devices),

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"quality_scale": "legacy",
"requirements": ["matrix-nio==0.25.2", "Pillow==11.3.0"]
"requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"]
}

View File

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

View File

@@ -8,7 +8,6 @@ from typing import Any, cast
from chip.clusters import Objects as clusters
from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
from matter_server.client.models import device_types
from matter_server.common import custom_clusters
from homeassistant.components.number import (
@@ -19,7 +18,6 @@ from homeassistant.components.number import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
Platform,
UnitOfLength,
@@ -125,31 +123,6 @@ class MatterRangeNumber(MatterEntity, NumberEntity):
)
class MatterLevelControlNumber(MatterEntity, NumberEntity):
"""Representation of a Matter Attribute as a Number entity."""
entity_description: MatterNumberEntityDescription
async def async_set_native_value(self, value: float) -> None:
"""Set level value."""
send_value = int(value)
if value_convert := self.entity_description.ha_to_native_value:
send_value = value_convert(value)
await self.send_device_command(
clusters.LevelControl.Commands.MoveToLevel(
level=send_value,
)
)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_native_value = value
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
@@ -266,26 +239,6 @@ DISCOVERY_SCHEMAS = [
),
vendor_id=(4874,),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
key="pump_setpoint",
native_unit_of_measurement=PERCENTAGE,
translation_key="pump_setpoint",
native_max_value=100,
native_min_value=0.5,
native_step=0.5,
measurement_to_ha=(
lambda x: None if x is None else x / 2 # Matter range (1-200)
),
ha_to_native_value=lambda x: round(x * 2), # HA range 0.5100.0%
mode=NumberMode.SLIDER,
),
entity_class=MatterLevelControlNumber,
required_attributes=(clusters.LevelControl.Attributes.CurrentLevel,),
device_type=(device_types.Pump,),
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(

View File

@@ -180,9 +180,6 @@
"altitude": {
"name": "Altitude above sea level"
},
"pump_setpoint": {
"name": "Setpoint"
},
"temperature_offset": {
"name": "Temperature offset"
},

View File

@@ -32,18 +32,11 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)}
if user_input is not None:
if not user_input[CONF_LLM_HASS_API]:
errors[CONF_LLM_HASS_API] = "llm_api_required"
else:
return self.async_create_entry(
title=", ".join(
llm_apis[api_id] for api_id in user_input[CONF_LLM_HASS_API]
),
data=user_input,
)
return self.async_create_entry(
title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input
)
return self.async_show_form(
step_id="user",
@@ -51,7 +44,7 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Optional(
CONF_LLM_HASS_API,
default=[llm.LLM_API_ASSIST],
default=llm.LLM_API_ASSIST,
): SelectSelector(
SelectSelectorConfig(
options=[
@@ -60,12 +53,10 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
value=llm_api_id,
)
for llm_api_id, name in llm_apis.items()
],
multiple=True,
]
)
),
}
),
description_placeholders={"more_info_url": MORE_INFO_URL},
errors=errors,
)

View File

@@ -42,7 +42,7 @@ def _format_tool(
async def create_server(
hass: HomeAssistant, llm_api_id: str | list[str], llm_context: llm.LLMContext
hass: HomeAssistant, llm_api_id: str, llm_context: llm.LLMContext
) -> Server:
"""Create a new Model Context Protocol Server.

View File

@@ -11,9 +11,6 @@
}
}
},
"error": {
"llm_api_required": "At least one LLM API must be configured."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}

View File

@@ -2,23 +2,34 @@
from __future__ import annotations
from datetime import timedelta
import logging
from bleak import BleakError
from medcom_ble import MedcomBleDeviceData
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DOMAIN
from .coordinator import MedcomBleUpdateCoordinator
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
# Supported platforms
PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Medcom BLE radiation monitor from a config entry."""
address = entry.unique_id
elevation = hass.config.elevation
is_metric = hass.config.units is METRIC_SYSTEM
assert address is not None
ble_device = bluetooth.async_ble_device_from_address(hass, address)
@@ -27,7 +38,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
f"Could not find Medcom BLE device with address {address}"
)
coordinator = MedcomBleUpdateCoordinator(hass, entry, address)
async def _async_update_method():
"""Get data from Medcom BLE radiation monitor."""
ble_device = bluetooth.async_ble_device_from_address(hass, address)
inspector = MedcomBleDeviceData(_LOGGER, elevation, is_metric)
try:
data = await inspector.update_device(ble_device)
except BleakError as err:
raise UpdateFailed(f"Unable to fetch data: {err}") from err
return data
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_method=_async_update_method,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
await coordinator.async_config_entry_first_refresh()

View File

@@ -1,50 +0,0 @@
"""The Medcom BLE integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from bleak import BleakError
from medcom_ble import MedcomBleDevice, MedcomBleDeviceData
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
class MedcomBleUpdateCoordinator(DataUpdateCoordinator[MedcomBleDevice]):
"""Coordinator for Medcom BLE radiation monitor data."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, address: str) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
self._address = address
self._elevation = hass.config.elevation
self._is_metric = hass.config.units is METRIC_SYSTEM
async def _async_update_data(self) -> MedcomBleDevice:
"""Get data from Medcom BLE radiation monitor."""
ble_device = bluetooth.async_ble_device_from_address(self.hass, self._address)
inspector = MedcomBleDeviceData(_LOGGER, self._elevation, self._is_metric)
try:
data = await inspector.update_device(ble_device)
except BleakError as err:
raise UpdateFailed(f"Unable to fetch data: {err}") from err
return data

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from medcom_ble import MedcomBleDevice
from homeassistant import config_entries
from homeassistant.components.sensor import (
SensorEntity,
@@ -13,10 +15,12 @@ from homeassistant.components.sensor import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import DOMAIN, UNIT_CPM
from .coordinator import MedcomBleUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -37,7 +41,9 @@ async def async_setup_entry(
) -> None:
"""Set up Medcom BLE radiation monitor sensors."""
coordinator: MedcomBleUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: DataUpdateCoordinator[MedcomBleDevice] = hass.data[DOMAIN][
entry.entry_id
]
entities = []
_LOGGER.debug("got sensors: %s", coordinator.data.sensors)
@@ -56,14 +62,16 @@ async def async_setup_entry(
async_add_entities(entities)
class MedcomSensor(CoordinatorEntity[MedcomBleUpdateCoordinator], SensorEntity):
class MedcomSensor(
CoordinatorEntity[DataUpdateCoordinator[MedcomBleDevice]], SensorEntity
):
"""Medcom BLE radiation monitor sensors for the device."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: MedcomBleUpdateCoordinator,
coordinator: DataUpdateCoordinator[MedcomBleDevice],
entity_description: SensorEntityDescription,
) -> None:
"""Populate the medcom entity with relevant data."""

View File

@@ -30,7 +30,6 @@ from .const import (
DOMAIN,
MEDIA_CLASS_MAP,
MEDIA_MIME_TYPES,
MEDIA_SOURCE_DATA,
URI_SCHEME,
URI_SCHEME_REGEX,
)
@@ -79,7 +78,7 @@ def generate_media_source_id(domain: str, identifier: str) -> str:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the media_source component."""
hass.data[MEDIA_SOURCE_DATA] = {}
hass.data[DOMAIN] = {}
websocket_api.async_register_command(hass, websocket_browse_media)
websocket_api.async_register_command(hass, websocket_resolve_media)
frontend.async_register_built_in_panel(
@@ -98,7 +97,7 @@ async def _process_media_source_platform(
platform: MediaSourceProtocol,
) -> None:
"""Process a media source platform."""
hass.data[MEDIA_SOURCE_DATA][domain] = await platform.async_get_media_source(hass)
hass.data[DOMAIN][domain] = await platform.async_get_media_source(hass)
@callback
@@ -110,10 +109,10 @@ def _get_media_item(
item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player)
else:
# We default to our own domain if its only one registered
domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN
domain = None if len(hass.data[DOMAIN]) > 1 else DOMAIN
return MediaSourceItem(hass, domain, "", target_media_player)
if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]:
if item.domain is not None and item.domain not in hass.data[DOMAIN]:
raise UnknownMediaSource(
translation_domain=DOMAIN,
translation_key="unknown_media_source",

View File

@@ -1,18 +1,10 @@
"""Constants for the media_source integration."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from homeassistant.components.media_player import MediaClass
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from .models import MediaSource
DOMAIN = "media_source"
MEDIA_SOURCE_DATA: HassKey[dict[str, MediaSource]] = HassKey(DOMAIN)
MEDIA_MIME_TYPES = ("audio", "video", "image")
MEDIA_CLASS_MAP = {
"audio": MediaClass.MUSIC,

View File

@@ -6,7 +6,7 @@ import logging
import mimetypes
from pathlib import Path
import shutil
from typing import Any, cast
from typing import Any
from aiohttp import web
from aiohttp.web_request import FileField
@@ -18,7 +18,7 @@ from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, MEDIA_SOURCE_DATA
from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES
from .error import Unresolvable
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
@@ -30,7 +30,7 @@ LOGGER = logging.getLogger(__name__)
def async_setup(hass: HomeAssistant) -> None:
"""Set up local media source."""
source = LocalSource(hass)
hass.data[MEDIA_SOURCE_DATA][DOMAIN] = source
hass.data[DOMAIN][DOMAIN] = source
hass.http.register_view(LocalMediaView(hass, source))
hass.http.register_view(UploadMediaView(hass, source))
websocket_api.async_register_command(hass, websocket_remove_media)
@@ -352,7 +352,7 @@ async def websocket_remove_media(
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
return
source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][DOMAIN])
source: LocalSource = hass.data[DOMAIN][DOMAIN]
try:
source_dir_id, location = source.async_parse_identifier(item)

View File

@@ -3,12 +3,12 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import Any, cast
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
from homeassistant.core import HomeAssistant, callback
from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX
from .const import DOMAIN, URI_SCHEME, URI_SCHEME_REGEX
@dataclass(slots=True)
@@ -70,7 +70,7 @@ class MediaSourceItem:
can_play=False,
can_expand=True,
)
for source in self.hass.data[MEDIA_SOURCE_DATA].values()
for source in self.hass.data[DOMAIN].values()
),
key=lambda item: item.title,
)
@@ -85,9 +85,7 @@ class MediaSourceItem:
@callback
def async_media_source(self) -> MediaSource:
"""Return media source that owns this item."""
if TYPE_CHECKING:
assert self.domain is not None
return self.hass.data[MEDIA_SOURCE_DATA][self.domain]
return cast(MediaSource, self.hass.data[DOMAIN][self.domain])
@classmethod
def from_uri(

View File

@@ -27,11 +27,9 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDevice]]]
async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool:
"""Establish connection with MELCloud."""
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Establish connection with MELClooud."""
conf = entry.data
try:
mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN])
@@ -42,14 +40,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) ->
except (TimeoutError, ClientConnectionError) as ex:
raise ConfigEntryNotReady from ex
entry.runtime_data = mel_devices
hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices})
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
hass.data[DOMAIN].pop(config_entry.entry_id)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
return unload_ok
class MelCloudDevice:

View File

@@ -24,12 +24,13 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudConfigEntry, MelCloudDevice
from . import MelCloudDevice
from .const import (
ATTR_STATUS,
ATTR_VANE_HORIZONTAL,
@@ -37,6 +38,7 @@ from .const import (
ATTR_VANE_VERTICAL,
ATTR_VANE_VERTICAL_POSITIONS,
CONF_POSITION,
DOMAIN,
SERVICE_SET_VANE_HORIZONTAL,
SERVICE_SET_VANE_VERTICAL,
)
@@ -75,11 +77,11 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = {
async def async_setup_entry(
hass: HomeAssistant,
entry: MelCloudConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
mel_devices = entry.runtime_data
mel_devices = hass.data[DOMAIN][entry.entry_id]
entities: list[AtaDeviceClimate | AtwDeviceZoneClimate] = [
AtaDeviceClimate(mel_device, mel_device.device)
for mel_device in mel_devices[DEVICE_TYPE_ATA]

View File

@@ -5,12 +5,11 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import MelCloudConfigEntry
TO_REDACT = {
CONF_USERNAME,
CONF_TOKEN,
@@ -18,7 +17,7 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MelCloudConfigEntry
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for the config entry."""
ent_reg = er.async_get(hass)

View File

@@ -15,11 +15,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudConfigEntry, MelCloudDevice
from . import MelCloudDevice
from .const import DOMAIN
@dataclasses.dataclass(frozen=True, kw_only=True)
@@ -103,11 +105,11 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: MelCloudConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud device sensors based on config_entry."""
mel_devices = entry.runtime_data
mel_devices = hass.data[DOMAIN].get(entry.entry_id)
entities: list[MelDeviceSensor] = [
MelDeviceSensor(mel_device, description)

View File

@@ -17,21 +17,22 @@ from homeassistant.components.water_heater import (
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudConfigEntry, MelCloudDevice
from . import DOMAIN, MelCloudDevice
from .const import ATTR_STATUS
async def async_setup_entry(
hass: HomeAssistant,
entry: MelCloudConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
mel_devices = entry.runtime_data
mel_devices = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
AtwWaterHeater(mel_device, mel_device.device)

View File

@@ -6,11 +6,13 @@ from melnor_bluetooth.device import Device
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import MelnorDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.NUMBER,
@@ -20,8 +22,11 @@ PLATFORMS: list[Platform] = [
]
async def async_setup_entry(hass: HomeAssistant, entry: MelnorConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up melnor from a config entry."""
hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})
ble_device = bluetooth.async_ble_device_from_address(hass, entry.data[CONF_ADDRESS])
if not ble_device:
@@ -55,14 +60,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelnorConfigEntry) -> bo
coordinator = MelnorDataUpdateCoordinator(hass, entry, device)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MelnorConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.data.disconnect()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
device: Device = hass.data[DOMAIN][entry.entry_id].data
await device.disconnect()
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -11,17 +11,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type MelnorConfigEntry = ConfigEntry[MelnorDataUpdateCoordinator]
class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]):
"""Melnor data update coordinator."""
config_entry: MelnorConfigEntry
config_entry: ConfigEntry
_device: Device
def __init__(
self, hass: HomeAssistant, config_entry: MelnorConfigEntry, device: Device
self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device
) -> None:
"""Initialize my coordinator."""
super().__init__(

View File

@@ -13,11 +13,13 @@ from homeassistant.components.number import (
NumberEntityDescription,
NumberMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import MelnorDataUpdateCoordinator
from .entity import MelnorZoneEntity, get_entities_for_valves
@@ -65,12 +67,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MelnorConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the number platform."""
coordinator = config_entry.runtime_data
coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
get_entities_for_valves(

View File

@@ -15,6 +15,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@@ -25,7 +26,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import MelnorDataUpdateCoordinator
from .entity import MelnorBluetoothEntity, MelnorZoneEntity, get_entities_for_valves
@@ -102,12 +104,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MelnorConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
# Device-level sensors
async_add_entities(

View File

@@ -13,10 +13,12 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import MelnorDataUpdateCoordinator
from .entity import MelnorZoneEntity, get_entities_for_valves
@@ -49,12 +51,12 @@ ZONE_ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MelnorConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch platform."""
coordinator = config_entry.runtime_data
coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
get_entities_for_valves(

View File

@@ -10,11 +10,13 @@ from typing import Any
from melnor_bluetooth.device import Valve
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import MelnorDataUpdateCoordinator
from .entity import MelnorZoneEntity, get_entities_for_valves
@@ -39,12 +41,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MelnorConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the number platform."""
coordinator = config_entry.runtime_data
coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
get_entities_for_valves(

View File

@@ -1,21 +1,59 @@
"""The met_eireann component."""
from collections.abc import Mapping
from datetime import timedelta
import logging
from typing import Any, Self
import meteireann
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import MetEireannUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(minutes=60)
PLATFORMS = [Platform.WEATHER]
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up Met Éireann as config entry."""
coordinator = MetEireannUpdateCoordinator(hass, config_entry=config_entry)
hass.data.setdefault(DOMAIN, {})
raw_weather_data = meteireann.WeatherData(
async_get_clientsession(hass),
latitude=config_entry.data[CONF_LATITUDE],
longitude=config_entry.data[CONF_LONGITUDE],
altitude=config_entry.data[CONF_ELEVATION],
)
weather_data = MetEireannWeatherData(config_entry.data, raw_weather_data)
async def _async_update_data() -> MetEireannWeatherData:
"""Fetch data from Met Éireann."""
try:
return await weather_data.fetch_data()
except Exception as err:
raise UpdateFailed(f"Update failed: {err}") from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_method=_async_update_data,
update_interval=UPDATE_INTERVAL,
)
await coordinator.async_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
hass.data[DOMAIN][config_entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
@@ -30,3 +68,26 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
class MetEireannWeatherData:
"""Keep data for Met Éireann weather entities."""
def __init__(
self, config: Mapping[str, Any], weather_data: meteireann.WeatherData
) -> None:
"""Initialise the weather entity data."""
self._config = config
self._weather_data = weather_data
self.current_weather_data: dict[str, Any] = {}
self.daily_forecast: list[dict[str, Any]] = []
self.hourly_forecast: list[dict[str, Any]] = []
async def fetch_data(self) -> Self:
"""Fetch data from API - (current weather and forecast)."""
await self._weather_data.fetching_data()
self.current_weather_data = self._weather_data.get_current_weather()
time_zone = dt_util.get_default_time_zone()
self.daily_forecast = self._weather_data.get_forecast(time_zone, False)
self.hourly_forecast = self._weather_data.get_forecast(time_zone, True)
return self

View File

@@ -1,76 +0,0 @@
"""The met_eireann component."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import timedelta
import logging
from typing import Any, Self
import meteireann
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(minutes=60)
class MetEireannWeatherData:
"""Keep data for Met Éireann weather entities."""
def __init__(
self, config: Mapping[str, Any], weather_data: meteireann.WeatherData
) -> None:
"""Initialise the weather entity data."""
self._config = config
self._weather_data = weather_data
self.current_weather_data: dict[str, Any] = {}
self.daily_forecast: list[dict[str, Any]] = []
self.hourly_forecast: list[dict[str, Any]] = []
async def fetch_data(self) -> Self:
"""Fetch data from API - (current weather and forecast)."""
await self._weather_data.fetching_data()
self.current_weather_data = self._weather_data.get_current_weather()
time_zone = dt_util.get_default_time_zone()
self.daily_forecast = self._weather_data.get_forecast(time_zone, False)
self.hourly_forecast = self._weather_data.get_forecast(time_zone, True)
return self
class MetEireannUpdateCoordinator(DataUpdateCoordinator[MetEireannWeatherData]):
"""Coordinator for Met Éireann weather data."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
)
raw_weather_data = meteireann.WeatherData(
async_get_clientsession(hass),
latitude=config_entry.data[CONF_LATITUDE],
longitude=config_entry.data[CONF_LONGITUDE],
altitude=config_entry.data[CONF_ELEVATION],
)
self._weather_data = MetEireannWeatherData(config_entry.data, raw_weather_data)
async def _async_update_data(self) -> MetEireannWeatherData:
"""Fetch data from Met Éireann."""
try:
return await self._weather_data.fetch_data()
except Exception as err:
raise UpdateFailed(f"Update failed: {err}") from err

View File

@@ -1,6 +1,7 @@
"""Support for Met Éireann weather service."""
from collections.abc import Mapping
import logging
from typing import Any, cast
from homeassistant.components.weather import (
@@ -28,8 +29,10 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from . import MetEireannWeatherData
from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP
from .coordinator import MetEireannWeatherData
_LOGGER = logging.getLogger(__name__)
def format_condition(condition: str | None) -> str | None:

View File

@@ -23,6 +23,7 @@ from .const import (
COORDINATOR_RAIN,
DOMAIN,
PLATFORMS,
UNDO_UPDATE_LISTENER,
)
_LOGGER = logging.getLogger(__name__)
@@ -129,9 +130,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.title,
)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
undo_listener = entry.add_update_listener(_async_update_listener)
hass.data[DOMAIN][entry.entry_id] = {
UNDO_UPDATE_LISTENER: undo_listener,
COORDINATOR_FORECAST: coordinator_forecast,
}
if coordinator_rain and coordinator_rain.last_update_success:
@@ -161,6 +163,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]()
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)

View File

@@ -26,6 +26,7 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
COORDINATOR_FORECAST = "coordinator_forecast"
COORDINATOR_RAIN = "coordinator_rain"
COORDINATOR_ALERT = "coordinator_alert"
UNDO_UPDATE_LISTENER = "undo_update_listener"
ATTRIBUTION = "Data provided by Météo-France"
MODEL = "Météo-France mobile API"
MANUFACTURER = "Météo-France"

View File

@@ -6,8 +6,6 @@ import time
from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_SUNNY,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_NATIVE_PRECIPITATION,
@@ -51,13 +49,9 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
def format_condition(condition: str, force_day: bool = False) -> str:
def format_condition(condition: str):
"""Return condition from dict CONDITION_MAP."""
mapped_condition = CONDITION_MAP.get(condition, condition)
if force_day and mapped_condition == ATTR_CONDITION_CLEAR_NIGHT:
# Meteo-France can return clear night condition instead of sunny for daily weather, so we map it to sunny
return ATTR_CONDITION_SUNNY
return mapped_condition
return CONDITION_MAP.get(condition, condition)
async def async_setup_entry(
@@ -218,7 +212,7 @@ class MeteoFranceWeather(
forecast["dt"]
).isoformat(),
ATTR_FORECAST_CONDITION: format_condition(
forecast["weather12H"]["desc"], force_day=True
forecast["weather12H"]["desc"]
),
ATTR_FORECAST_HUMIDITY: forecast["humidity"]["max"],
ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"],

View File

@@ -1,15 +1,43 @@
"""Support for Meteoclimatic weather data."""
import logging
from meteoclimatic import MeteoclimaticClient
from meteoclimatic.exceptions import MeteoclimaticError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, PLATFORMS
from .coordinator import MeteoclimaticUpdateCoordinator
from .const import CONF_STATION_CODE, DOMAIN, PLATFORMS, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Meteoclimatic entry."""
coordinator = MeteoclimaticUpdateCoordinator(hass, entry)
station_code = entry.data[CONF_STATION_CODE]
meteoclimatic_client = MeteoclimaticClient()
async def async_update_data():
"""Obtain the latest data from Meteoclimatic."""
try:
data = await hass.async_add_executor_job(
meteoclimatic_client.weather_at_station, station_code
)
except MeteoclimaticError as err:
raise UpdateFailed(f"Error while retrieving data: {err}") from err
return data.__dict__
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=f"Meteoclimatic weather for {entry.title} ({station_code})",
update_method=async_update_data,
update_interval=SCAN_INTERVAL,
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})

View File

@@ -1,43 +0,0 @@
"""Support for Meteoclimatic weather data."""
import logging
from typing import Any
from meteoclimatic import MeteoclimaticClient
from meteoclimatic.exceptions import MeteoclimaticError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_STATION_CODE, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
class MeteoclimaticUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for Meteoclimatic weather data."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the coordinator."""
self._station_code = entry.data[CONF_STATION_CODE]
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"Meteoclimatic weather for {entry.title} ({self._station_code})",
update_interval=SCAN_INTERVAL,
)
self._meteoclimatic_client = MeteoclimaticClient()
async def _async_update_data(self) -> dict[str, Any]:
"""Obtain the latest data from Meteoclimatic."""
try:
data = await self.hass.async_add_executor_job(
self._meteoclimatic_client.weather_at_station, self._station_code
)
except MeteoclimaticError as err:
raise UpdateFailed(f"Error while retrieving data: {err}") from err
return data.__dict__

View File

@@ -18,10 +18,12 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL
from .coordinator import MeteoclimaticUpdateCoordinator
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -117,7 +119,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteoclimatic sensor platform."""
coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[MeteoclimaticSensor(coordinator, description) for description in SENSOR_TYPES],
@@ -125,17 +127,13 @@ async def async_setup_entry(
)
class MeteoclimaticSensor(
CoordinatorEntity[MeteoclimaticUpdateCoordinator], SensorEntity
):
class MeteoclimaticSensor(CoordinatorEntity, SensorEntity):
"""Representation of a Meteoclimatic sensor."""
_attr_attribution = ATTRIBUTION
def __init__(
self,
coordinator: MeteoclimaticUpdateCoordinator,
description: SensorEntityDescription,
self, coordinator: DataUpdateCoordinator, description: SensorEntityDescription
) -> None:
"""Initialize the Meteoclimatic sensor."""
super().__init__(coordinator)

View File

@@ -8,10 +8,12 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN, MANUFACTURER, MODEL
from .coordinator import MeteoclimaticUpdateCoordinator
def format_condition(condition):
@@ -29,14 +31,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteoclimatic weather platform."""
coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([MeteoclimaticWeather(coordinator)], False)
class MeteoclimaticWeather(
CoordinatorEntity[MeteoclimaticUpdateCoordinator], WeatherEntity
):
class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity):
"""Representation of a weather condition."""
_attr_attribution = ATTRIBUTION
@@ -44,7 +44,7 @@ class MeteoclimaticWeather(
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
def __init__(self, coordinator: MeteoclimaticUpdateCoordinator) -> None:
def __init__(self, coordinator: DataUpdateCoordinator) -> None:
"""Initialise the weather platform."""
super().__init__(coordinator)
self._unique_id = self.coordinator.data["station"].code

View File

@@ -9,7 +9,6 @@ from datapoint.Forecast import Forecast
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
EntityCategory,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -60,7 +59,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = (
native_attr_name="name",
name="Station name",
icon="mdi:label-outline",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
MetOfficeSensorEntityDescription(
@@ -237,13 +235,14 @@ class MetOfficeCurrentSensor(
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
native_attr = self.entity_description.native_attr_name
value = get_attribute(
self.coordinator.data.now(), self.entity_description.native_attr_name
)
if native_attr == "name":
return str(self.coordinator.data.name)
value = get_attribute(self.coordinator.data.now(), native_attr)
if native_attr == "significantWeatherCode" and value is not None:
if (
self.entity_description.native_attr_name == "significantWeatherCode"
and value is not None
):
value = CONDITION_MAP.get(value)
return value

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