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
218 changed files with 1863 additions and 4085 deletions

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

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

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

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

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

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

View File

@@ -10,7 +10,13 @@ from homeassistant.const import CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_NOT_FIRST_RUN, DOMAIN, FIRST_RUN, MONOPRICE_OBJECT
from .const import (
CONF_NOT_FIRST_RUN,
DOMAIN,
FIRST_RUN,
MONOPRICE_OBJECT,
UNDO_UPDATE_LISTENER,
)
PLATFORMS = [Platform.MEDIA_PLAYER]
@@ -35,10 +41,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, data={**entry.data, CONF_NOT_FIRST_RUN: True}
)
entry.async_on_unload(entry.add_update_listener(_update_listener))
undo_listener = entry.add_update_listener(_update_listener)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
MONOPRICE_OBJECT: monoprice,
UNDO_UPDATE_LISTENER: undo_listener,
FIRST_RUN: first_run,
}
@@ -53,6 +60,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not unload_ok:
return False
hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]()
def _cleanup(monoprice) -> None:
"""Destroy the Monoprice object.

View File

@@ -18,3 +18,4 @@ SERVICE_RESTORE = "restore"
FIRST_RUN = "first_run"
MONOPRICE_OBJECT = "monoprice_object"
UNDO_UPDATE_LISTENER = "update_update_listener"

View File

@@ -34,7 +34,7 @@ class MusicAssistantEntity(Entity):
identifiers={(DOMAIN, player_id)},
manufacturer=self.player.device_info.manufacturer or provider.name,
model=self.player.device_info.model or self.player.name,
name=self.player.name,
name=self.player.display_name,
configuration_url=f"{mass.server_url}/#/settings/editplayer/{player_id}",
)

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
"loggers": ["music_assistant"],
"requirements": ["music-assistant-client==1.2.4"],
"requirements": ["music-assistant-client==1.2.0"],
"zeroconf": ["_mass._tcp.local."]
}

View File

@@ -6,7 +6,11 @@ import logging
from typing import TYPE_CHECKING, Any, cast
from music_assistant_models.enums import MediaType as MASSMediaType
from music_assistant_models.media_items import MediaItemType, SearchResults
from music_assistant_models.media_items import (
BrowseFolder,
MediaItemType,
SearchResults,
)
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -545,6 +549,8 @@ def _process_search_results(
# Add available items to results
for item in items:
if TYPE_CHECKING:
assert not isinstance(item, BrowseFolder)
if not item.available:
continue

View File

@@ -248,8 +248,10 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
player = self.player
active_queue = self.active_queue
# update generic attributes
if player.powered and player.playback_state is not None:
self._attr_state = MediaPlayerState(player.playback_state.value)
if player.powered and active_queue is not None:
self._attr_state = MediaPlayerState(active_queue.state.value)
if player.powered and player.state is not None:
self._attr_state = MediaPlayerState(player.state.value)
else:
self._attr_state = MediaPlayerState(STATE_OFF)
# active source and source list (translate to HA source names)
@@ -268,12 +270,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self._attr_source = active_source_name
group_members: list[str] = []
if player.group_members:
group_members = player.group_members
if player.group_childs:
group_members = player.group_childs
elif player.synced_to and (parent := self.mass.players.get(player.synced_to)):
group_members = parent.group_members
group_members = parent.group_childs
# translate MA group_members to HA group_members as entity id's
# translate MA group_childs to HA group_members as entity id's
entity_registry = er.async_get(self.hass)
group_members_entity_ids: list[str] = [
entity_id

View File

@@ -9,11 +9,13 @@ from pymystrom.bulb import MyStromBulb
from pymystrom.exceptions import MyStromConnectionError
from pymystrom.switch import MyStromSwitch
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .models import MyStromConfigEntry, MyStromData
from .const import DOMAIN
from .models import MyStromData
PLATFORMS_PLUGS = [Platform.SENSOR, Platform.SWITCH]
PLATFORMS_BULB = [Platform.LIGHT]
@@ -39,7 +41,7 @@ def _get_mystrom_switch(host: str) -> MyStromSwitch:
return MyStromSwitch(host)
async def async_setup_entry(hass: HomeAssistant, entry: MyStromConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up myStrom from a config entry."""
host = entry.data[CONF_HOST]
try:
@@ -71,7 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MyStromConfigEntry) -> b
_LOGGER.error("Unsupported myStrom device type: %s", device_type)
return False
entry.runtime_data = MyStromData(
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = MyStromData(
device=device,
info=info,
)
@@ -80,12 +82,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MyStromConfigEntry) -> b
return True
async def async_unload_entry(hass: HomeAssistant, entry: MyStromConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
device_type = entry.runtime_data.info["type"]
device_type = hass.data[DOMAIN][entry.entry_id].info["type"]
platforms = []
if device_type in [101, 106, 107, 120]:
platforms.extend(PLATFORMS_PLUGS)
elif device_type in [102, 105]:
platforms.extend(PLATFORMS_BULB)
return await hass.config_entries.async_unload_platforms(entry, platforms)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -15,12 +15,12 @@ from homeassistant.components.light import (
LightEntity,
LightEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .models import MyStromConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -32,12 +32,12 @@ EFFECT_SUNRISE = "sunrise"
async def async_setup_entry(
hass: HomeAssistant,
entry: MyStromConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the myStrom entities."""
info = entry.runtime_data.info
device = entry.runtime_data.device
info = hass.data[DOMAIN][entry.entry_id].info
device = hass.data[DOMAIN][entry.entry_id].device
async_add_entities([MyStromLight(device, entry.title, info["mac"])])

View File

@@ -6,10 +6,6 @@ from typing import Any
from pymystrom.bulb import MyStromBulb
from pymystrom.switch import MyStromSwitch
from homeassistant.config_entries import ConfigEntry
type MyStromConfigEntry = ConfigEntry[MyStromData]
@dataclass
class MyStromData:

View File

@@ -13,13 +13,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPower, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .models import MyStromConfigEntry
@dataclass(frozen=True)
@@ -56,11 +56,11 @@ SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: MyStromConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the myStrom entities."""
device: MyStromSwitch = entry.runtime_data.device
device: MyStromSwitch = hass.data[DOMAIN][entry.entry_id].device
async_add_entities(
MyStromSwitchSensor(device, entry.title, description)

View File

@@ -8,12 +8,12 @@ from typing import Any
from pymystrom.exceptions import MyStromConnectionError
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .models import MyStromConfigEntry
DEFAULT_NAME = "myStrom Switch"
@@ -22,11 +22,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyStromConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the myStrom entities."""
device = entry.runtime_data.device
device = hass.data[DOMAIN][entry.entry_id].device
async_add_entities([MyStromSwitch(device, entry.title)])

View File

@@ -44,6 +44,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool:
translation_key="device_communication_error",
translation_placeholders={"device": entry.title},
) from err
try:
await nam.async_check_credentials()
except (ApiError, ClientError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_communication_error",
translation_placeholders={"device": entry.title},
) from err
except AuthFailedError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import logging
from typing import Any
@@ -25,6 +26,15 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@dataclass
class NamConfig:
"""NAM device configuration class."""
mac_address: str
auth_enabled: bool
_LOGGER = logging.getLogger(__name__)
AUTH_SCHEMA = vol.Schema(
@@ -32,14 +42,29 @@ AUTH_SCHEMA = vol.Schema(
)
async def async_get_nam(
hass: HomeAssistant, host: str, data: dict[str, Any]
) -> NettigoAirMonitor:
"""Get NAM client."""
async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig:
"""Get device MAC address and auth_enabled property."""
websession = async_get_clientsession(hass)
options = ConnectionOptions(host)
nam = await NettigoAirMonitor.create(websession, options)
mac = await nam.async_get_mac_address()
return NamConfig(mac, nam.auth_enabled)
async def async_check_credentials(
hass: HomeAssistant, host: str, data: dict[str, Any]
) -> None:
"""Check if credentials are valid."""
websession = async_get_clientsession(hass)
options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD))
return await NettigoAirMonitor.create(websession, options)
nam = await NettigoAirMonitor.create(websession, options)
await nam.async_check_credentials()
class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -47,8 +72,8 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_config: NamConfig
host: str
auth_enabled: bool = False
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -60,20 +85,21 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
self.host = user_input[CONF_HOST]
try:
nam = await async_get_nam(self.hass, self.host, {})
config = await async_get_config(self.hass, self.host)
except (ApiError, ClientConnectorError, TimeoutError):
errors["base"] = "cannot_connect"
except CannotGetMacError:
return self.async_abort(reason="device_unsupported")
except AuthFailedError:
return await self.async_step_credentials()
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(nam.mac))
await self.async_set_unique_id(format_mac(config.mac_address))
self._abort_if_unique_id_configured({CONF_HOST: self.host})
if config.auth_enabled is True:
return await self.async_step_credentials()
return self.async_create_entry(
title=self.host,
data=user_input,
@@ -93,7 +119,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
nam = await async_get_nam(self.hass, self.host, user_input)
await async_check_credentials(self.hass, self.host, user_input)
except AuthFailedError:
errors["base"] = "invalid_auth"
except (ApiError, ClientConnectorError, TimeoutError):
@@ -102,9 +128,6 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(nam.mac))
self._abort_if_unique_id_configured({CONF_HOST: self.host})
return self.async_create_entry(
title=self.host,
data={**user_input, CONF_HOST: self.host},
@@ -125,16 +148,14 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_HOST: self.host})
try:
nam = await async_get_nam(self.hass, self.host, {})
self._config = await async_get_config(self.hass, self.host)
except (ApiError, ClientConnectorError, TimeoutError):
return self.async_abort(reason="cannot_connect")
except CannotGetMacError:
return self.async_abort(reason="device_unsupported")
except AuthFailedError:
self.auth_enabled = True
return await self.async_step_confirm_discovery()
await self.async_set_unique_id(format_mac(nam.mac))
await self.async_set_unique_id(format_mac(self._config.mac_address))
self._abort_if_unique_id_configured({CONF_HOST: self.host})
return await self.async_step_confirm_discovery()
@@ -150,7 +171,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
data={CONF_HOST: self.host},
)
if self.auth_enabled is True:
if self._config.auth_enabled is True:
return await self.async_step_credentials()
self._set_confirm_only()
@@ -177,7 +198,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
await async_get_nam(self.hass, self.host, user_input)
await async_check_credentials(self.hass, self.host, user_input)
except (
ApiError,
AuthFailedError,
@@ -207,11 +228,11 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
nam = await async_get_nam(self.hass, user_input[CONF_HOST], {})
config = await async_get_config(self.hass, user_input[CONF_HOST])
except (ApiError, ClientConnectorError, TimeoutError):
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(format_mac(nam.mac))
await self.async_set_unique_id(format_mac(config.mac_address))
self._abort_if_unique_id_mismatch(reason="another_device")
return self.async_update_reload_and_abort(

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["nettigo_air_monitor"],
"requirements": ["nettigo-air-monitor==5.0.0"],
"requirements": ["nettigo-air-monitor==4.1.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
import aiohttp
from pynordpool import (
Currency,
DeliveryPeriodData,
@@ -92,8 +91,6 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
except (
NordPoolResponseError,
NordPoolError,
TimeoutError,
aiohttp.ClientError,
) as error:
LOGGER.debug("Connection error: %s", error)
self.async_set_update_error(error)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import logging
from types import MappingProxyType
import httpx
import ollama
@@ -101,12 +100,8 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
for entry in entries:
use_existing = False
# Create subentry with model from entry.data and options from entry.options
subentry_data = entry.options.copy()
subentry_data[CONF_MODEL] = entry.data[CONF_MODEL]
subentry = ConfigSubentry(
data=MappingProxyType(subentry_data),
data=entry.options,
subentry_type="conversation",
title=entry.title,
unique_id=None,
@@ -159,11 +154,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_NAME,
# Update parent entry to only keep URL, remove model
data={CONF_URL: entry.data[CONF_URL]},
options={},
version=3,
minor_version=1,
version=2,
minor_version=2,
)
@@ -171,7 +164,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) ->
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 3:
if entry.version > 2:
# This means the user has downgraded from a future version
return False
@@ -189,25 +182,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) ->
hass.config_entries.async_update_entry(entry, minor_version=2)
if entry.version == 2 and entry.minor_version == 2:
# Update subentries to include the model
for subentry in entry.subentries.values():
if subentry.subentry_type == "conversation":
updated_data = dict(subentry.data)
updated_data[CONF_MODEL] = entry.data[CONF_MODEL]
hass.config_entries.async_update_subentry(
entry, subentry, data=MappingProxyType(updated_data)
)
# Update main entry to remove model and bump version
hass.config_entries.async_update_entry(
entry,
data={CONF_URL: entry.data[CONF_URL]},
version=3,
minor_version=1,
)
_LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)

View File

@@ -22,7 +22,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
@@ -38,7 +38,6 @@ from homeassistant.helpers.selector import (
)
from homeassistant.util.ssl import get_default_context
from . import OllamaConfigEntry
from .const import (
CONF_KEEP_ALIVE,
CONF_MAX_HISTORY,
@@ -73,43 +72,43 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ollama."""
VERSION = 3
MINOR_VERSION = 1
VERSION = 2
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize config flow."""
self.url: str | None = None
self.model: str | None = None
self.client: ollama.AsyncClient | None = None
self.download_task: asyncio.Task | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
user_input = user_input or {}
self.url = user_input.get(CONF_URL, self.url)
self.model = user_input.get(CONF_MODEL, self.model)
if self.url is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, last_step=False
)
errors = {}
url = user_input[CONF_URL]
self._async_abort_entries_match({CONF_URL: url})
self._async_abort_entries_match({CONF_URL: self.url})
try:
url = cv.url(url)
except vol.Invalid:
errors["base"] = "invalid_url"
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
self.client = ollama.AsyncClient(
host=self.url, verify=get_default_context()
)
try:
client = ollama.AsyncClient(host=url, verify=get_default_context())
async with asyncio.timeout(DEFAULT_TIMEOUT):
await client.list()
response = await self.client.list()
downloaded_models: set[str] = {
model_info["model"] for model_info in response.get("models", [])
}
except (TimeoutError, httpx.ConnectError):
errors["base"] = "cannot_connect"
except Exception:
@@ -118,69 +117,10 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
if errors:
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
return self.async_create_entry(
title=url,
data={CONF_URL: url},
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
super().__init__()
self._name: str | None = None
self._model: str | None = None
self.download_task: asyncio.Task | None = None
self._config_data: dict[str, Any] | None = None
@property
def _is_new(self) -> bool:
"""Return if this is a new subentry."""
return self.source == "user"
@property
def _client(self) -> ollama.AsyncClient:
"""Return the Ollama client."""
entry: OllamaConfigEntry = self._get_entry()
return entry.runtime_data
async def async_step_set_options(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle model selection and configuration step."""
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
if user_input is None:
# Get available models from Ollama server
try:
async with asyncio.timeout(DEFAULT_TIMEOUT):
response = await self._client.list()
downloaded_models: set[str] = {
model_info["model"] for model_info in response.get("models", [])
}
except (TimeoutError, httpx.ConnectError, httpx.HTTPError):
_LOGGER.exception("Failed to get models from Ollama server")
return self.async_abort(reason="cannot_connect")
if self.model is None:
# Show models that have been downloaded first, followed by all known
# models (only latest tags).
models_to_list = [
@@ -191,69 +131,52 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
for m in sorted(MODEL_NAMES)
if m not in downloaded_models
]
if self._is_new:
options = {}
else:
options = self._get_reconfigure_subentry().data.copy()
model_step_schema = vol.Schema(
{
vol.Required(
CONF_MODEL, description={"suggested_value": DEFAULT_MODEL}
): SelectSelector(
SelectSelectorConfig(options=models_to_list, custom_value=True)
),
}
)
return self.async_show_form(
step_id="set_options",
data_schema=vol.Schema(
ollama_config_option_schema(
self.hass, self._is_new, options, models_to_list
)
),
step_id="user",
data_schema=model_step_schema,
)
self._model = user_input[CONF_MODEL]
if self._is_new:
self._name = user_input.pop(CONF_NAME)
if self.model not in downloaded_models:
# Ollama server needs to download model first
return await self.async_step_download()
# Check if model needs to be downloaded
try:
async with asyncio.timeout(DEFAULT_TIMEOUT):
response = await self._client.list()
currently_downloaded_models: set[str] = {
model_info["model"] for model_info in response.get("models", [])
}
if self._model not in currently_downloaded_models:
# Store the user input to use after download
self._config_data = user_input
# Ollama server needs to download model first
return await self.async_step_download()
except Exception:
_LOGGER.exception("Failed to check model availability")
return self.async_abort(reason="cannot_connect")
# Model is already downloaded, create/update the entry
if self._is_new:
return self.async_create_entry(
title=self._name,
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
return self.async_create_entry(
title=self.url,
data={CONF_URL: self.url, CONF_MODEL: self.model},
subentries=[
{
"subentry_type": "conversation",
"data": {},
"title": _get_title(self.model),
"unique_id": None,
}
],
)
async def async_step_download(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
) -> ConfigFlowResult:
"""Step to wait for Ollama server to download a model."""
assert self._model is not None
assert self.model is not None
assert self.client is not None
if self.download_task is None:
# Tell Ollama server to pull the model.
# The task will block until the model and metadata are fully
# downloaded.
self.download_task = self.hass.async_create_background_task(
self._client.pull(self._model),
f"Downloading {self._model}",
self.client.pull(self.model),
f"Downloading {self.model}",
)
if self.download_task.done():
@@ -269,28 +192,80 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
progress_task=self.download_task,
)
async def async_step_finish(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step after model downloading has succeeded."""
assert self.url is not None
assert self.model is not None
return self.async_create_entry(
title=_get_title(self.model),
data={CONF_URL: self.url, CONF_MODEL: self.model},
subentries=[
{
"subentry_type": "conversation",
"data": {},
"title": _get_title(self.model),
"unique_id": None,
}
],
)
async def async_step_failed(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
) -> ConfigFlowResult:
"""Step after model downloading has failed."""
return self.async_abort(reason="download_failed")
async def async_step_finish(
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
@property
def _is_new(self) -> bool:
"""Return if this is a new subentry."""
return self.source == "user"
async def async_step_set_options(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Step after model downloading has succeeded."""
assert self._config_data is not None
"""Set conversation options."""
# abort if entry is not loaded
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
# Model download completed, create/update the entry with stored config
if self._is_new:
errors: dict[str, str] = {}
if user_input is None:
if self._is_new:
options = {}
else:
options = self._get_reconfigure_subentry().data.copy()
elif self._is_new:
return self.async_create_entry(
title=self._name,
data=self._config_data,
title=user_input.pop(CONF_NAME),
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=self._config_data,
else:
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
)
schema = ollama_config_option_schema(self.hass, self._is_new, options)
return self.async_show_form(
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
)
async_step_user = async_step_set_options
@@ -298,14 +273,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
def ollama_config_option_schema(
hass: HomeAssistant,
is_new: bool,
options: Mapping[str, Any],
models_to_list: list[SelectOptionDict],
hass: HomeAssistant, is_new: bool, options: Mapping[str, Any]
) -> dict:
"""Ollama options schema."""
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
]
if is_new:
schema: dict = {
schema: dict[vol.Required | vol.Optional, Any] = {
vol.Required(CONF_NAME, default="Ollama Conversation"): str,
}
else:
@@ -313,12 +293,6 @@ def ollama_config_option_schema(
schema.update(
{
vol.Required(
CONF_MODEL,
description={"suggested_value": options.get(CONF_MODEL, DEFAULT_MODEL)},
): SelectSelector(
SelectSelectorConfig(options=models_to_list, custom_value=True)
),
vol.Optional(
CONF_PROMPT,
description={
@@ -330,18 +304,7 @@ def ollama_config_option_schema(
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
],
multiple=True,
)
),
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Optional(
CONF_NUM_CTX,
description={
@@ -387,3 +350,11 @@ def ollama_config_option_schema(
)
return schema
def _get_title(model: str) -> str:
"""Get title for config entry."""
if model.endswith(":latest"):
model = model.split(":", maxsplit=1)[0]
return model

View File

@@ -166,14 +166,11 @@ class OllamaBaseLLMEntity(Entity):
self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id
model, _, version = subentry.data[CONF_MODEL].partition(":")
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Ollama",
model=model,
sw_version=version or "latest",
model=entry.data[CONF_MODEL],
entry_type=dr.DeviceEntryType.SERVICE,
)

View File

@@ -3,17 +3,24 @@
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]"
"url": "[%key:common::config_flow::data::url%]",
"model": "Model"
}
},
"download": {
"title": "Downloading model"
}
},
"abort": {
"download_failed": "Model downloading failed",
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"invalid_url": "[%key:common::config_flow::error::invalid_host%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"progress": {
"download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details."
}
},
"config_subentries": {
@@ -26,7 +33,6 @@
"step": {
"set_options": {
"data": {
"model": "Model",
"name": "[%key:common::config_flow::data::name%]",
"prompt": "Instructions",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
@@ -41,19 +47,11 @@
"num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.",
"think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency."
}
},
"download": {
"title": "Downloading model"
}
},
"abort": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"entry_not_loaded": "Failed to add agent. The configuration is disabled.",
"download_failed": "Model downloading failed",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"progress": {
"download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details."
"entry_not_loaded": "Cannot add things while the configuration is disabled."
}
}
}

View File

@@ -38,7 +38,7 @@ class PlaystationNetworkData:
presence: dict[str, Any] = field(default_factory=dict)
username: str = ""
account_id: str = ""
availability: str = "unavailable"
available: bool = False
active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict)
registered_platforms: set[PlatformType] = field(default_factory=set)
trophy_summary: TrophySummary | None = None
@@ -92,7 +92,10 @@ class PlaystationNetwork:
data.username = self.user.online_id
data.account_id = self.user.account_id
data.availability = data.presence["basicPresence"]["availability"]
data.available = (
data.presence.get("basicPresence", {}).get("availability")
== "availableToPlay"
)
session = SessionData()
session.platform = PlatformType(
@@ -124,6 +127,8 @@ class PlaystationNetwork:
if (game_title_info := presence[0] if presence else {}) and game_title_info[
"onlineStatus"
] == "online":
data.available = True
platform = PlatformType(game_title_info["platform"])
if platform is PlatformType.PS4:

View File

@@ -29,14 +29,6 @@
},
"last_online": {
"default": "mdi:account-clock"
},
"online_status": {
"default": "mdi:account-badge",
"state": {
"busy": "mdi:account-cancel",
"availabletocommunicate": "mdi:cellphone",
"offline": "mdi:account-off-outline"
}
}
}
}

View File

@@ -107,7 +107,7 @@ class PsnMediaPlayerEntity(
"""Media Player state getter."""
session = self.coordinator.data.active_sessions.get(self.key)
if session and session.status == "online":
if session.title_id is not None:
if self.coordinator.data.available and session.title_id is not None:
return MediaPlayerState.PLAYING
return MediaPlayerState.ON
return MediaPlayerState.OFF

View File

@@ -37,7 +37,6 @@ class PlaystationNetworkSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[PlaystationNetworkData], StateType | datetime]
entity_picture: str | None = None
available_fn: Callable[[PlaystationNetworkData], bool] = lambda _: True
class PlaystationNetworkSensor(StrEnum):
@@ -51,7 +50,6 @@ class PlaystationNetworkSensor(StrEnum):
EARNED_TROPHIES_BRONZE = "earned_trophies_bronze"
ONLINE_ID = "online_id"
LAST_ONLINE = "last_online"
ONLINE_STATUS = "online_status"
SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
@@ -119,16 +117,8 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
psn.presence["basicPresence"]["lastAvailableDate"]
)
),
available_fn=lambda psn: "lastAvailableDate" in psn.presence["basicPresence"],
device_class=SensorDeviceClass.TIMESTAMP,
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.ONLINE_STATUS,
translation_key=PlaystationNetworkSensor.ONLINE_STATUS,
value_fn=lambda psn: psn.availability.lower().replace("unavailable", "offline"),
device_class=SensorDeviceClass.ENUM,
options=["offline", "availabletoplay", "availabletocommunicate", "busy"],
),
)
@@ -193,12 +183,3 @@ class PlaystationNetworkSensorEntity(
)
return super().entity_picture
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
self.entity_description.available_fn(self.coordinator.data)
and super().available
)

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