Compare commits

..

1 Commits

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

View File

@@ -653,7 +653,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
- name: Dependency review
uses: actions/dependency-review-action@v4.7.2
uses: actions/dependency-review-action@v4.7.1
with:
license-check: false # We use our own license audit checks
@@ -1341,7 +1341,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@v5.5.0
uses: codecov/codecov-action@v5.4.3
with:
fail_ci_if_error: true
flags: full-suite
@@ -1491,7 +1491,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@v5.5.0
uses: codecov/codecov-action@v5.4.3
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.29.10
uses: github/codeql-action/init@v3.29.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.10
uses: github/codeql-action/analyze@v3.29.9
with:
category: "/language:python"

2
CODEOWNERS generated
View File

@@ -422,8 +422,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/emby/ @mezz64
/homeassistant/components/emoncms/ @borpin @alexandrecuer
/tests/components/emoncms/ @borpin @alexandrecuer
/homeassistant/components/emoncms_history/ @alexandrecuer
/tests/components/emoncms_history/ @alexandrecuer
/homeassistant/components/emonitor/ @bdraco
/tests/components/emonitor/ @bdraco
/homeassistant/components/emulated_hue/ @bdraco @Tho85

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"bleak==1.0.1",
"bleak-retry-connector==4.0.2",
"bleak-retry-connector==4.0.1",
"bluetooth-adapters==2.0.0",
"bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.2",

View File

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

View File

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

View File

@@ -564,13 +564,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
This is used by cameras with CameraEntityFeature.STREAM
and StreamType.HLS.
"""
# Check if camera has a go2rtc provider that can provide stream sources
if (
self._webrtc_provider
and hasattr(self._webrtc_provider, 'async_get_stream_source')
and self._webrtc_provider.domain == "go2rtc"
):
return await self._webrtc_provider.async_get_stream_source(self)
return None
async def async_handle_async_webrtc_offer(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,7 @@ async def async_setup_entry(
) -> None:
"""Set up the sensors."""
router = entry.runtime_data
entities: list[SensorEntity] = []
_LOGGER.debug(
"%s - %s - %s temperature sensors",
@@ -75,7 +76,7 @@ async def async_setup_entry(
router.mac,
len(router.sensors_temperature),
)
entities: list[SensorEntity] = [
entities = [
FreeboxSensor(
router,
SensorEntityDescription(
@@ -104,16 +105,14 @@ async def async_setup_entry(
for description in DISK_PARTITION_SENSORS
)
entities.extend(
FreeboxBatterySensor(router, node, endpoint)
for node in router.home_devices.values()
for endpoint in node["show_endpoints"]
if (
endpoint["name"] == "battery"
and endpoint["ep_type"] == "signal"
and endpoint.get("value") is not None
)
)
for node in router.home_devices.values():
for endpoint in node["show_endpoints"]:
if (
endpoint["name"] == "battery"
and endpoint["ep_type"] == "signal"
and endpoint.get("value") is not None
):
entities.append(FreeboxBatterySensor(hass, router, node, endpoint))
if entities:
async_add_entities(entities, True)

View File

@@ -50,7 +50,7 @@ CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5"
CONF_FRONTEND_REPO = "development_repo"
CONF_JS_VERSION = "javascript_version"
DEFAULT_THEME_COLOR = "#2980b9"
DEFAULT_THEME_COLOR = "#03A9F4"
DATA_PANELS: HassKey[dict[str, Panel]] = HassKey("frontend_panels")

View File

@@ -300,13 +300,6 @@ class WebRTCProvider(CameraWebRTCProvider):
camera.entity_id, width, height
)
async def async_get_stream_source(self, camera: Camera) -> str | None:
"""Get a stream source URL suitable for recording."""
await self._update_stream_source(camera)
# Return an HLS stream URL that can be used by the stream component for recording
# go2rtc provides HLS streams at /api/stream.m3u8?src=<stream_name>
return f"{self._url}api/stream.m3u8?src={camera.entity_id}"
async def _update_stream_source(self, camera: Camera) -> None:
"""Update the stream source in go2rtc config if needed."""
if not (stream_source := await camera.stream_source()):

View File

@@ -61,19 +61,18 @@ PLACEHOLDER_KEY_REASON = "reason"
UNSUPPORTED_REASONS = {
"apparmor",
"cgroup_version",
"connectivity_check",
"content_trust",
"dbus",
"dns_server",
"docker_configuration",
"docker_version",
"cgroup_version",
"job_conditions",
"lxc",
"network_manager",
"os",
"os_agent",
"os_version",
"restart_policy",
"software",
"source_mods",
@@ -81,7 +80,6 @@ UNSUPPORTED_REASONS = {
"systemd",
"systemd_journal",
"systemd_resolved",
"virtualization_image",
}
# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason
# provides no additional information beyond the unhealthy one then skip that repair.

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["aiohasupervisor==0.3.2b0"],
"requirements": ["aiohasupervisor==0.3.1"],
"single_config_entry": true
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.79", "babel==2.15.0"]
"requirements": ["holidays==0.78", "babel==2.15.0"]
}

View File

@@ -291,7 +291,7 @@ class NitrogenDioxideSensor(AirQualitySensor):
class VolatileOrganicCompoundsSensor(AirQualitySensor):
"""Generate a VolatileOrganicCompoundsSensor accessory as VOCs sensor.
Sensor entity must return VOC in μg/m3.
Sensor entity must return VOC in µg/m3.
"""
def create_services(self) -> None:

View File

@@ -494,7 +494,7 @@ def temperature_to_states(temperature: float, unit: str) -> float:
def density_to_air_quality(density: float) -> int:
"""Map PM2.5 μg/m3 density to HomeKit AirQuality level."""
"""Map PM2.5 µg/m3 density to HomeKit AirQuality level."""
if density <= 9: # US AQI 0-50 (HomeKit: Excellent)
return 1
if density <= 35.4: # US AQI 51-100 (HomeKit: Good)
@@ -507,7 +507,7 @@ def density_to_air_quality(density: float) -> int:
def density_to_air_quality_pm10(density: float) -> int:
"""Map PM10 μg/m3 density to HomeKit AirQuality level."""
"""Map PM10 µg/m3 density to HomeKit AirQuality level."""
if density <= 54: # US AQI 0-50 (HomeKit: Excellent)
return 1
if density <= 154: # US AQI 51-100 (HomeKit: Good)
@@ -520,7 +520,7 @@ def density_to_air_quality_pm10(density: float) -> int:
def density_to_air_quality_nitrogen_dioxide(density: float) -> int:
"""Map nitrogen dioxide μg/m3 to HomeKit AirQuality level."""
"""Map nitrogen dioxide µg/m3 to HomeKit AirQuality level."""
if density <= 30:
return 1
if density <= 60:
@@ -533,7 +533,7 @@ def density_to_air_quality_nitrogen_dioxide(density: float) -> int:
def density_to_air_quality_voc(density: float) -> int:
"""Map VOCs μg/m3 to HomeKit AirQuality level.
"""Map VOCs µg/m3 to HomeKit AirQuality level.
The VOC mappings use the IAQ guidelines for Europe released by the WHO (World Health Organization).
Referenced from Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf

View File

@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.1"]
"requirements": ["automower-ble==0.2.7"]
}

View File

@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.util import slugify
from .account import IcloudAccount, IcloudConfigEntry
from .account import IcloudAccount
from .const import (
ATTR_ACCOUNT,
ATTR_DEVICE_NAME,
@@ -92,10 +92,8 @@ def lost_device(service: ServiceCall) -> None:
def update_account(service: ServiceCall) -> None:
"""Call the update function of an iCloud account."""
if (account := service.data.get(ATTR_ACCOUNT)) is None:
# Update all accounts when no specific account is provided
entry: IcloudConfigEntry
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
entry.runtime_data.keep_alive()
for account in service.hass.data[DOMAIN].values():
account.keep_alive()
else:
_get_account(service.hass, account).keep_alive()
@@ -104,12 +102,17 @@ def _get_account(hass: HomeAssistant, account_identifier: str) -> IcloudAccount:
if account_identifier is None:
return None
entry: IcloudConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
if entry.runtime_data.username == account_identifier:
return entry.runtime_data
icloud_account: IcloudAccount | None = hass.data[DOMAIN].get(account_identifier)
if icloud_account is None:
for account in hass.data[DOMAIN].values():
if account.username == account_identifier:
icloud_account = account
raise ValueError(f"No iCloud account with username or name {account_identifier}")
if icloud_account is None:
raise ValueError(
f"No iCloud account with username or name {account_identifier}"
)
return icloud_account
@callback

View File

@@ -7,26 +7,3 @@ TIMEOUT = 30
PLATFORMS = [
Platform.SENSOR,
]
ATTR_BATTERY_STATUS = ["charging", "discharging", "charged"]
ATTR_INVERTER_STATE = [
"unsynchronized",
"grid_consumption",
"grid_injection",
"grid_synchronised_but_not_used",
]
ATTR_TIMELINE_STATUS = [
"com_lost",
"warning_grid",
"warning_pv",
"warning_bat",
"error_ond",
"error_soft",
"error_pv",
"error_grid",
"error_bat",
"good_1",
"info_soft",
"info_ond",
"info_bat",
"info_smartlo",
]

View File

@@ -17,7 +17,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import TIMEOUT
HUBNAME = "imeon_inverter_hub"
INTERVAL = 60
INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
type InverterConfigEntry = ConfigEntry[InverterCoordinator]
@@ -44,7 +44,7 @@ class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]):
hass,
_LOGGER,
name=HUBNAME,
update_interval=timedelta(seconds=INTERVAL),
update_interval=INTERVAL,
config_entry=entry,
)
@@ -83,7 +83,7 @@ class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]):
# Fetch data using distant API
try:
await self._api.update()
except (ValueError, TimeoutError, ClientError) as e:
except (ValueError, ClientError) as e:
raise UpdateFailed(e) from e
# Store data

View File

@@ -7,14 +7,8 @@
"battery_soc": {
"default": "mdi:battery-charging-100"
},
"battery_status": {
"default": "mdi:battery-alert"
},
"battery_stored": {
"default": "mdi:battery-arrow-up"
},
"battery_consumed": {
"default": "mdi:battery-arrow-down"
"default": "mdi:battery"
},
"grid_current_l1": {
"default": "mdi:current-ac"
@@ -56,7 +50,7 @@
"default": "mdi:power-socket"
},
"meter_power": {
"default": "mdi:meter-electric"
"default": "mdi:power-plug"
},
"output_current_l1": {
"default": "mdi:current-ac"
@@ -122,7 +116,7 @@
"default": "mdi:home-lightning-bolt"
},
"monitoring_minute_grid_consumption": {
"default": "mdi:transmission-tower-import"
"default": "mdi:transmission-tower"
},
"monitoring_minute_grid_injection": {
"default": "mdi:transmission-tower-export"
@@ -132,43 +126,6 @@
},
"monitoring_minute_solar_production": {
"default": "mdi:solar-power"
},
"timeline_type_msg": {
"default": "mdi:check-circle",
"state": {
"com_lost": "mdi:lan-disconnect",
"warning_grid": "mdi:alert-circle",
"warning_pv": "mdi:alert-circle",
"warning_bat": "mdi:alert-circle",
"error_ond": "mdi:close-octagon",
"error_soft": "mdi:close-octagon",
"error_pv": "mdi:close-octagon",
"error_grid": "mdi:close-octagon",
"error_bat": "mdi:close-octagon",
"good_1": "mdi:check-circle",
"info_soft": "mdi:information-slab-circle",
"info_ond": "mdi:information-slab-circle",
"info_bat": "mdi:information-slab-circle",
"info_smartlo": "mdi:information-slab-circle"
}
},
"energy_pv": {
"default": "mdi:solar-power"
},
"energy_grid_injected": {
"default": "mdi:transmission-tower-export"
},
"energy_grid_consumed": {
"default": "mdi:transmission-tower-import"
},
"energy_building_consumption": {
"default": "mdi:home-lightning-bolt-outline"
},
"energy_battery_stored": {
"default": "mdi:battery-arrow-up-outline"
},
"energy_battery_consumed": {
"default": "mdi:battery-arrow-down-outline"
}
}
}

View File

@@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import ATTR_BATTERY_STATUS, ATTR_INVERTER_STATE, ATTR_TIMELINE_STATUS
from .coordinator import InverterCoordinator
from .entity import InverterEntity
@@ -48,24 +47,11 @@ SENSOR_DESCRIPTIONS = (
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="battery_status",
translation_key="battery_status",
device_class=SensorDeviceClass.ENUM,
options=ATTR_BATTERY_STATUS,
),
SensorEntityDescription(
key="battery_stored",
translation_key="battery_stored",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="battery_consumed",
translation_key="battery_consumed",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
state_class=SensorStateClass.MEASUREMENT,
),
# Grid
@@ -162,12 +148,6 @@ SENSOR_DESCRIPTIONS = (
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="manager_inverter_state",
translation_key="manager_inverter_state",
device_class=SensorDeviceClass.ENUM,
options=ATTR_INVERTER_STATE,
),
# Meter
SensorEntityDescription(
key="meter_power",
@@ -258,16 +238,16 @@ SENSOR_DESCRIPTIONS = (
SensorEntityDescription(
key="pv_consumed",
translation_key="pv_consumed",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key="pv_injected",
translation_key="pv_injected",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key="pv_power_1",
@@ -310,14 +290,14 @@ SENSOR_DESCRIPTIONS = (
key="monitoring_self_consumption",
translation_key="monitoring_self_consumption",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
),
SensorEntityDescription(
key="monitoring_self_sufficiency",
translation_key="monitoring_self_sufficiency",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
),
# Monitoring (instant minute data)
@@ -361,62 +341,6 @@ SENSOR_DESCRIPTIONS = (
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
),
# Timeline
SensorEntityDescription(
key="timeline_type_msg",
translation_key="timeline_type_msg",
device_class=SensorDeviceClass.ENUM,
options=ATTR_TIMELINE_STATUS,
),
# Daily energy counters
SensorEntityDescription(
key="energy_pv",
translation_key="energy_pv",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
),
SensorEntityDescription(
key="energy_grid_injected",
translation_key="energy_grid_injected",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
),
SensorEntityDescription(
key="energy_grid_consumed",
translation_key="energy_grid_consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
),
SensorEntityDescription(
key="energy_building_consumption",
translation_key="energy_building_consumption",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
),
SensorEntityDescription(
key="energy_battery_stored",
translation_key="energy_battery_stored",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
),
SensorEntityDescription(
key="energy_battery_consumed",
translation_key="energy_battery_consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
),
)

View File

@@ -35,20 +35,9 @@
"battery_soc": {
"name": "Battery state of charge"
},
"battery_status": {
"name": "Battery status",
"state": {
"charged": "Charged",
"charging": "[%key:common::state::charging%]",
"discharging": "[%key:common::state::discharging%]"
}
},
"battery_stored": {
"name": "Battery stored"
},
"battery_consumed": {
"name": "Battery consumed"
},
"grid_current_l1": {
"name": "Grid current L1"
},
@@ -88,15 +77,6 @@
"inverter_injection_power_limit": {
"name": "Injection power limit"
},
"manager_inverter_state": {
"name": "Inverter state",
"state": {
"unsynchronized": "Unsynchronized",
"grid_consumption": "Grid consumption",
"grid_injection": "Grid injection",
"grid_synchronised_but_not_used": "Grid unsynchronized but used"
}
},
"meter_power": {
"name": "Meter power"
},
@@ -155,63 +135,25 @@
"name": "Component temperature"
},
"monitoring_self_consumption": {
"name": "Self-consumption"
"name": "Monitoring self-consumption"
},
"monitoring_self_sufficiency": {
"name": "Self-sufficiency"
"name": "Monitoring self-sufficiency"
},
"monitoring_minute_building_consumption": {
"name": "Building consumption"
"name": "Monitoring building consumption (minute)"
},
"monitoring_minute_grid_consumption": {
"name": "Grid consumption"
"name": "Monitoring grid consumption (minute)"
},
"monitoring_minute_grid_injection": {
"name": "Grid injection"
"name": "Monitoring grid injection (minute)"
},
"monitoring_minute_grid_power_flow": {
"name": "Grid power flow"
"name": "Monitoring grid power flow (minute)"
},
"monitoring_minute_solar_production": {
"name": "Solar production"
},
"timeline_type_msg": {
"name": "Timeline status",
"state": {
"com_lost": "Communication lost.",
"warning_grid": "Power grid warning detected.",
"warning_pv": "PV system warning detected.",
"warning_bat": "Battery warning detected.",
"error_ond": "Inverter error detected.",
"error_soft": "Software error detected.",
"error_pv": "PV system error detected.",
"error_grid": "Power grid error detected.",
"error_bat": "Battery error detected.",
"good_1": "System operating normally.",
"web_account": "Web account notification.",
"info_soft": "Software information available.",
"info_ond": "Inverter information available.",
"info_bat": "Battery information available.",
"info_smartlo": "Smart load information available."
}
},
"energy_pv": {
"name": "Today PV energy"
},
"energy_grid_injected": {
"name": "Today grid-injected energy"
},
"energy_grid_consumed": {
"name": "Today grid-consumed energy"
},
"energy_building_consumption": {
"name": "Today building consumption"
},
"energy_battery_stored": {
"name": "Today battery-stored energy"
},
"energy_battery_consumed": {
"name": "Today battery-consumed energy"
"name": "Monitoring solar production (minute)"
}
}
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["imgw_pib==1.5.4"]
"requirements": ["imgw_pib==1.5.3"]
}

View File

@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["mastodon"],
"quality_scale": "bronze",
"requirements": ["Mastodon.py==2.1.1"]
"requirements": ["Mastodon.py==2.0.1"]
}

View File

@@ -6,7 +6,10 @@ rules:
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
dependency-transparency:
status: todo
comment: |
Mastodon.py does not have CI build/publish.
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done

View File

@@ -396,7 +396,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="DishwasherAlarmDoorError",
translation_key="alarm_door",
translation_key="dishwasher_alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
@@ -407,19 +407,4 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.DishwasherAlarm.Attributes.State,),
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="RefrigeratorAlarmDoorOpen",
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
),
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.RefrigeratorAlarm.Attributes.State,),
allow_multi=True,
),
]

View File

@@ -57,15 +57,6 @@
"current_phase": {
"default": "mdi:state-machine"
},
"eve_weather_trend": {
"default": "mdi:weather",
"state": {
"sunny": "mdi:weather-sunny",
"cloudy": "mdi:weather-cloudy",
"rainy": "mdi:weather-rainy",
"stormy": "mdi:weather-windy"
}
},
"air_quality": {
"default": "mdi:air-filter"
},

View File

@@ -70,14 +70,6 @@ CONTAMINATION_STATE_MAP = {
clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kCritical: "critical",
}
EVE_CLUSTER_WEATHER_MAP = {
# enum with known Weather state values which we can translate
1: "sunny",
3: "cloudy",
6: "rainy",
14: "stormy",
}
OPERATIONAL_STATE_MAP = {
# enum with known Operation state values which we can translate
clusters.OperationalState.Enums.OperationalStateEnum.kStopped: "stopped",
@@ -525,19 +517,6 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(EveCluster.Attributes.Pressure,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="EveWeatherWeatherTrend",
translation_key="eve_weather_trend",
device_class=SensorDeviceClass.ENUM,
native_unit_of_measurement=None,
options=[x for x in EVE_CLUSTER_WEATHER_MAP.values() if x is not None],
device_to_ha=EVE_CLUSTER_WEATHER_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(EveCluster.Attributes.WeatherTrend,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(

View File

@@ -92,7 +92,7 @@
"dishwasher_alarm_inflow": {
"name": "Inflow alarm"
},
"alarm_door": {
"dishwasher_alarm_door": {
"name": "Door alarm"
}
},
@@ -434,15 +434,6 @@
"pump_speed": {
"name": "Rotation speed"
},
"eve_weather_trend": {
"name": "Weather trend",
"state": {
"cloudy": "Cloudy",
"rainy": "Rainy",
"sunny": "Sunny",
"stormy": "Stormy"
}
},
"evse_circuit_capacity": {
"name": "Circuit capacity"
},

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.08.11"],
"requirements": ["yt-dlp[default]==2025.07.21"],
"single_config_entry": true
}

View File

@@ -10,12 +10,9 @@ from homeassistant.components.weather import (
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SUNNY,
ATTR_FORECAST_CLOUD_COVERAGE,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_NATIVE_PRESSURE,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION,
ATTR_FORECAST_WIND_BEARING,
@@ -37,9 +34,6 @@ FORECAST_MAP = {
ATTR_FORECAST_NATIVE_TEMP_LOW: "templow",
ATTR_FORECAST_WIND_BEARING: "wind_bearing",
ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed",
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "wind_gust",
ATTR_FORECAST_CLOUD_COVERAGE: "cloudiness",
ATTR_FORECAST_HUMIDITY: "humidity",
}
CONDITION_MAP = {

View File

@@ -138,16 +138,6 @@ class MetEireannWeather(
"""Return the wind direction."""
return self.coordinator.data.current_weather_data.get("wind_bearing")
@property
def native_wind_gust_speed(self) -> float | None:
"""Return the wind gust speed in native units."""
return self.coordinator.data.current_weather_data.get("wind_gust")
@property
def cloud_coverage(self) -> float | None:
"""Return the cloud coverage."""
return self.coordinator.data.current_weather_data.get("cloudiness")
def _forecast(self, hourly: bool) -> list[Forecast]:
"""Return the forecast array."""
if hourly:

View File

@@ -850,14 +850,6 @@ COFFEE_SYSTEM_PROGRAM_ID: dict[int, str] = {
24813: "appliance_settings", # modify profile name
}
COFFEE_SYSTEM_PROFILE: dict[range, str] = {
range(24000, 24032): "profile_1",
range(24032, 24064): "profile_2",
range(24064, 24096): "profile_3",
range(24096, 24128): "profile_4",
range(24128, 24160): "profile_5",
}
STEAM_OVEN_MICRO_PROGRAM_ID: dict[int, str] = {
8: "steam_cooking",
19: "microwave",

View File

@@ -2,10 +2,10 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, Final, cast
from typing import Final, cast
from pymiele import MieleDevice, MieleTemperature
@@ -30,7 +30,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
COFFEE_SYSTEM_PROFILE,
DISABLED_TEMP_ENTITIES,
DOMAIN,
STATE_PROGRAM_ID,
@@ -62,8 +61,6 @@ PLATE_COUNT = {
"KMX": 6,
}
ATTRIBUTE_PROFILE = "profile"
def _get_plate_count(tech_type: str) -> int:
"""Get number of zones for hob."""
@@ -91,21 +88,11 @@ def _convert_temperature(
return raw_value
def _get_coffee_profile(value: MieleDevice) -> str | None:
"""Get coffee profile from value."""
if value.state_program_id is not None:
for key_range, profile in COFFEE_SYSTEM_PROFILE.items():
if value.state_program_id in key_range:
return profile
return None
@dataclass(frozen=True, kw_only=True)
class MieleSensorDescription(SensorEntityDescription):
"""Class describing Miele sensor entities."""
value_fn: Callable[[MieleDevice], StateType]
extra_attributes: dict[str, Callable[[MieleDevice], StateType]] | None = None
zone: int | None = None
unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None
@@ -170,6 +157,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.COFFEE_SYSTEM,
MieleAppliance.ROBOT_VACUUM_CLEANER,
MieleAppliance.WASHER_DRYER,
MieleAppliance.STEAM_OVEN_COMBI,
@@ -184,18 +172,6 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
value_fn=lambda value: value.state_program_id,
),
),
MieleSensorDefinition(
types=(MieleAppliance.COFFEE_SYSTEM,),
description=MieleSensorDescription(
key="state_program_id",
translation_key="program_id",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda value: value.state_program_id,
extra_attributes={
ATTRIBUTE_PROFILE: _get_coffee_profile,
},
),
),
MieleSensorDefinition(
types=(
MieleAppliance.WASHING_MACHINE,
@@ -734,16 +710,6 @@ class MieleSensor(MieleEntity, SensorEntity):
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.device)
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return extra_state_attributes."""
if self.entity_description.extra_attributes is None:
return None
attr = {}
for key, value in self.entity_description.extra_attributes.items():
attr[key] = value(self.device)
return attr
class MielePlateSensor(MieleSensor):
"""Representation of a Sensor."""
@@ -826,8 +792,6 @@ class MielePhaseSensor(MieleSensor):
class MieleProgramIdSensor(MieleSensor):
"""Representation of the program id sensor."""
_unrecorded_attributes = frozenset({ATTRIBUTE_PROFILE})
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""

View File

@@ -991,18 +991,6 @@
"yom_tov": "Yom tov",
"yorkshire_pudding": "Yorkshire pudding",
"zander_fillet": "Zander (fillet)"
},
"state_attributes": {
"profile": {
"name": "Profile",
"state": {
"profile_1": "Profile 1",
"profile_2": "Profile 2",
"profile_3": "Profile 3",
"profile_4": "Profile 4",
"profile_5": "Profile 5"
}
}
}
},
"spin_speed": {

View File

@@ -490,11 +490,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
if hvac_mode == value:
self._attr_hvac_mode = mode
break
else:
# since there are no hvac_mode_register, this
# integration should not touch the attr.
# However it lacks in the climate component.
self._attr_hvac_mode = HVACMode.AUTO
# Read the HVAC action register if defined
if self._hvac_action_register is not None:

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from abc import abstractmethod
import asyncio
from collections.abc import Callable
from datetime import datetime, timedelta
import struct
@@ -27,7 +28,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, ToggleEntity
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
@@ -91,6 +92,7 @@ class BasePlatform(Entity):
self._scan_interval = int(entry[CONF_SCAN_INTERVAL])
self._cancel_timer: Callable[[], None] | None = None
self._cancel_call: Callable[[], None] | None = None
self._attr_unique_id = entry.get(CONF_UNIQUE_ID)
self._attr_name = entry[CONF_NAME]
self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
@@ -107,39 +109,29 @@ class BasePlatform(Entity):
self._max_value = get_optional_numeric_config(CONF_MAX_VALUE)
self._nan_value = entry.get(CONF_NAN_VALUE)
self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS)
self._update_lock = asyncio.Lock()
@abstractmethod
async def _async_update(self) -> None:
"""Virtual function to be overwritten."""
async def async_update(self) -> None:
async def async_update(self, now: datetime | None = None) -> None:
"""Update the entity state."""
if self._cancel_call:
self._cancel_call()
await self.async_local_update()
async def async_local_update(self, now: datetime | None = None) -> None:
"""Update the entity state."""
await self._async_update()
self.async_write_ha_state()
if self._scan_interval > 0:
self._cancel_call = async_call_later(
self.hass,
timedelta(seconds=self._scan_interval),
self.async_local_update,
)
async with self._update_lock:
await self._async_update()
async def _async_update_write_state(self) -> None:
"""Update the entity state and write it to the state machine."""
if self._cancel_call:
self._cancel_call()
self._cancel_call = None
await self.async_local_update()
await self.async_update()
self.async_write_ha_state()
async def _async_update_if_not_in_progress(
self, now: datetime | None = None
) -> None:
"""Update the entity state if not already in progress."""
if self._update_lock.locked():
_LOGGER.debug("Update for entity %s is already in progress", self.name)
return
await self._async_update_write_state()
@callback
@@ -147,9 +139,12 @@ class BasePlatform(Entity):
"""Remote start entity."""
self._async_cancel_update_polling()
self._async_schedule_future_update(0.1)
self._cancel_call = async_call_later(
self.hass, timedelta(seconds=0.1), self.async_local_update
)
if self._scan_interval > 0:
self._cancel_timer = async_track_time_interval(
self.hass,
self._async_update_if_not_in_progress,
timedelta(seconds=self._scan_interval),
)
self._attr_available = True
self.async_write_ha_state()
@@ -182,20 +177,9 @@ class BasePlatform(Entity):
self._attr_available = False
self.async_write_ha_state()
async def async_await_connection(self, _now: Any) -> None:
"""Wait for first connect."""
await self._hub.event_connected.wait()
self.async_run()
async def async_base_added_to_hass(self) -> None:
"""Handle entity which will be added."""
self.async_on_remove(
async_call_later(
self.hass,
self._hub.config_delay + 0.1,
self.async_await_connection,
)
)
self.async_run()
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_hold)
)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections import namedtuple
from collections.abc import Callable
from typing import Any
from pymodbus.client import (
@@ -27,10 +28,11 @@ from homeassistant.const import (
CONF_TYPE,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -71,7 +73,6 @@ from .validators import check_config
DATA_MODBUS_HUBS: HassKey[dict[str, ModbusHub]] = HassKey(DOMAIN)
PRIMARY_RECONNECT_DELAY = 60
ConfEntry = namedtuple("ConfEntry", "call_type attr func_name value_attr_name") # noqa: PYI024
RunEntry = namedtuple("RunEntry", "attr func value_attr_name") # noqa: PYI024
@@ -253,12 +254,13 @@ class ModbusHub:
self._client: (
AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None
) = None
self._async_cancel_listener: Callable[[], None] | None = None
self._in_error = False
self._lock = asyncio.Lock()
self.event_connected = asyncio.Event()
self.hass = hass
self.name = client_config[CONF_NAME]
self._config_type = client_config[CONF_TYPE]
self.config_delay = client_config[CONF_DELAY]
self._config_delay = client_config[CONF_DELAY]
self._pb_request: dict[str, RunEntry] = {}
self._connect_task: asyncio.Task
self._last_log_error: str = ""
@@ -311,25 +313,22 @@ class ModbusHub:
async def async_pb_connect(self) -> None:
"""Connect to device, async."""
while True:
async with self._lock:
try:
if await self._client.connect(): # type: ignore[union-attr]
_LOGGER.info(f"modbus {self.name} communication open")
break
except ModbusException as exception_error:
self._log_error(
f"{self.name} connect failed, please check your configuration ({exception_error!s})"
)
_LOGGER.info(
f"modbus {self.name} connect NOT a success ! retrying in {PRIMARY_RECONNECT_DELAY} seconds"
)
await asyncio.sleep(PRIMARY_RECONNECT_DELAY)
async with self._lock:
try:
await self._client.connect() # type: ignore[union-attr]
except ModbusException as exception_error:
self._log_error(
f"{self.name} connect failed, please check your configuration ({exception_error!s})"
)
return
message = f"modbus {self.name} communication open"
_LOGGER.info(message)
if self.config_delay:
await asyncio.sleep(self.config_delay)
self.config_delay = 0
self.event_connected.set()
# Start counting down to allow modbus requests.
if self._config_delay:
self._async_cancel_listener = async_call_later(
self.hass, self._config_delay, self.async_end_delay
)
async def async_setup(self) -> bool:
"""Set up pymodbus client."""
@@ -350,6 +349,12 @@ class ModbusHub:
)
return True
@callback
def async_end_delay(self, args: Any) -> None:
"""End startup delay."""
self._async_cancel_listener = None
self._config_delay = 0
async def async_restart(self) -> None:
"""Reconnect client."""
if self._client:
@@ -359,6 +364,9 @@ class ModbusHub:
async def async_close(self) -> None:
"""Disconnect client."""
if self._async_cancel_listener:
self._async_cancel_listener()
self._async_cancel_listener = None
if not self._connect_task.done():
self._connect_task.cancel()
@@ -407,6 +415,7 @@ class ModbusHub:
error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True"
self._log_error(error)
return None
self._in_error = False
return result
async def async_pb_call(
@@ -417,6 +426,8 @@ class ModbusHub:
use_call: str,
) -> ModbusPDU | None:
"""Convert async to sync pymodbus call."""
if self._config_delay:
return None
async with self._lock:
if not self._client:
return None

View File

@@ -16,7 +16,6 @@ from homeassistant.components.number import (
NumberMode,
RestoreNumber,
)
from homeassistant.components.sensor import AMBIGUOUS_UNITS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_CLASS,
@@ -71,12 +70,6 @@ MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset(
def validate_config(config: ConfigType) -> ConfigType:
"""Validate that the configuration is valid, throws if it isn't."""
if (
CONF_UNIT_OF_MEASUREMENT in config
and (unit_of_measurement := config[CONF_UNIT_OF_MEASUREMENT]) in AMBIGUOUS_UNITS
):
config[CONF_UNIT_OF_MEASUREMENT] = AMBIGUOUS_UNITS[unit_of_measurement]
if config[CONF_MIN] > config[CONF_MAX]:
raise vol.Invalid(f"{CONF_MAX} must be >= {CONF_MIN}")

View File

@@ -10,7 +10,6 @@ import voluptuous as vol
from homeassistant.components import sensor
from homeassistant.components.sensor import (
AMBIGUOUS_UNITS,
CONF_STATE_CLASS,
DEVICE_CLASS_UNITS,
DEVICE_CLASSES_SCHEMA,
@@ -134,14 +133,9 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT
f"together with state class '{state_class}'"
)
if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None:
return config
unit_of_measurement = config[CONF_UNIT_OF_MEASUREMENT] = AMBIGUOUS_UNITS.get(
unit_of_measurement, unit_of_measurement
)
if (device_class := config.get(CONF_DEVICE_CLASS)) is None:
if (device_class := config.get(CONF_DEVICE_CLASS)) is None or (
unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)
) is None:
return config
if (

View File

@@ -10,12 +10,7 @@ from typing import Any, Final, cast
from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.json import save_json
@@ -205,9 +200,7 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="unknown")
name = self.nanoleaf.name
await self.async_set_unique_id(
name, raise_on_progress=self.source != SOURCE_USER
)
await self.async_set_unique_id(name)
self._abort_if_unique_id_configured({CONF_HOST: self.nanoleaf.host})
if discovery_integration_import:

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
"requirements": ["pyatmo==9.2.3"]
"requirements": ["pyatmo==9.2.1"]
}

View File

@@ -7,5 +7,5 @@
"iot_class": "cloud_push",
"loggers": ["aionfty"],
"quality_scale": "bronze",
"requirements": ["aiontfy==0.5.4"]
"requirements": ["aiontfy==0.5.3"]
}

View File

@@ -31,7 +31,6 @@ from homeassistant.loader import async_suggest_report_issue
from homeassistant.util.hass_dict import HassKey
from .const import ( # noqa: F401
AMBIGUOUS_UNITS,
ATTR_MAX,
ATTR_MIN,
ATTR_STEP,
@@ -369,15 +368,6 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return self.entity_description.native_unit_of_measurement
return None
@final
@property
def __native_unit_of_measurement_compat(self) -> str | None:
"""Process ambiguous units."""
native_unit_of_measurement = self.native_unit_of_measurement
return AMBIGUOUS_UNITS.get(
native_unit_of_measurement, native_unit_of_measurement
)
@property
@final
def unit_of_measurement(self) -> str | None:
@@ -385,7 +375,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if self._number_option_unit_of_measurement:
return self._number_option_unit_of_measurement
native_unit_of_measurement = self.__native_unit_of_measurement_compat
native_unit_of_measurement = self.native_unit_of_measurement
# device_class is checked after native_unit_of_measurement since most
# of the time we can avoid the device_class check
if (
@@ -454,7 +444,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if device_class not in UNIT_CONVERTERS:
return value
native_unit_of_measurement = self.__native_unit_of_measurement_compat
native_unit_of_measurement = self.native_unit_of_measurement
unit_of_measurement = self.unit_of_measurement
if native_unit_of_measurement != unit_of_measurement:
if TYPE_CHECKING:
@@ -483,7 +473,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if value is None or (device_class := self.device_class) not in UNIT_CONVERTERS:
return value
native_unit_of_measurement = self.__native_unit_of_measurement_compat
native_unit_of_measurement = self.native_unit_of_measurement
unit_of_measurement = self.unit_of_measurement
if native_unit_of_measurement != unit_of_measurement:
if TYPE_CHECKING:
@@ -506,7 +496,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
(number_options := self.registry_entry.options.get(DOMAIN))
and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT))
and (device_class := self.device_class) in UNIT_CONVERTERS
and self.__native_unit_of_measurement_compat
and self.native_unit_of_measurement
in UNIT_CONVERTERS[device_class].VALID_UNITS
and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS
):

View File

@@ -9,7 +9,6 @@ import voluptuous as vol
from homeassistant.const import (
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
@@ -137,7 +136,7 @@ class NumberDeviceClass(StrEnum):
CONDUCTIVITY = "conductivity"
"""Conductivity.
Unit of measurement: `S/cm`, `mS/cm`, `μS/cm`
Unit of measurement: `S/cm`, `mS/cm`, `µS/cm`
"""
CURRENT = "current"
@@ -169,7 +168,7 @@ class NumberDeviceClass(StrEnum):
DURATION = "duration"
"""Fixed duration.
Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `μs`
Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs`
"""
ENERGY = "energy"
@@ -247,25 +246,25 @@ class NumberDeviceClass(StrEnum):
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
"""Amount of NO.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
NITROUS_OXIDE = "nitrous_oxide"
"""Amount of N2O.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
OZONE = "ozone"
"""Amount of O3.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
PH = "ph"
@@ -277,19 +276,19 @@ class NumberDeviceClass(StrEnum):
PM1 = "pm1"
"""Particulate matter <= 1 μm.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
PM10 = "pm10"
"""Particulate matter <= 10 μm.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
PM25 = "pm25"
"""Particulate matter <= 2.5 μm.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
POWER_FACTOR = "power_factor"
@@ -366,7 +365,7 @@ class NumberDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
TEMPERATURE = "temperature"
@@ -378,7 +377,7 @@ class NumberDeviceClass(StrEnum):
VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds"
"""Amount of VOC.
Unit of measurement: `μg/m³`, `mg/m³`
Unit of measurement: `µg/m³`, `mg/m³`
"""
VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts"
@@ -390,7 +389,7 @@ class NumberDeviceClass(StrEnum):
VOLTAGE = "voltage"
"""Voltage.
Unit of measurement: `V`, `mV`, `μV`, `kV`, `MV`
Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV`
"""
VOLUME = "volume"
@@ -437,7 +436,7 @@ class NumberDeviceClass(StrEnum):
Weight is used instead of mass to fit with every day language.
Unit of measurement: `MASS_*` units
- SI / metric: `μg`, `mg`, `g`, `kg`
- SI / metric: `µg`, `mg`, `g`, `kg`
- USCS / imperial: `oz`, `lb`
"""
@@ -557,16 +556,3 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = {
NumberDeviceClass.TEMPERATURE: TemperatureConverter,
NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter,
}
# We translate units that were using using the legacy coding of μ \u00b5
# to units using recommended coding of μ \u03bc
AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
"\u00b5V": UnitOfElectricPotential.MICROVOLT,
"\u00b5g/ft³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
"\u00b5g": UnitOfMass.MICROGRAMS,
"\u00b5s": UnitOfTime.MICROSECONDS,
}

View File

@@ -14,7 +14,6 @@ from openai._streaming import AsyncStream
from openai.types.responses import (
EasyInputMessageParam,
FunctionToolParam,
ResponseCodeInterpreterToolCall,
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseFailedEvent,
@@ -22,8 +21,6 @@ from openai.types.responses import (
ResponseFunctionCallArgumentsDoneEvent,
ResponseFunctionToolCall,
ResponseFunctionToolCallParam,
ResponseFunctionWebSearch,
ResponseFunctionWebSearchParam,
ResponseIncompleteEvent,
ResponseInputFileParam,
ResponseInputImageParam,
@@ -95,8 +92,6 @@ MAX_TOOL_ITERATIONS = 10
def _adjust_schema(schema: dict[str, Any]) -> None:
"""Adjust the schema to be compatible with OpenAI API."""
if schema["type"] == "object":
schema.setdefault("strict", True)
schema.setdefault("additionalProperties", False)
if "properties" not in schema:
return
@@ -130,6 +125,8 @@ def _format_structured_output(
_adjust_schema(result)
result["strict"] = True
result["additionalProperties"] = False
return result
@@ -152,27 +149,16 @@ def _convert_content_to_param(
"""Convert any native chat message for this agent to the native format."""
messages: ResponseInputParam = []
reasoning_summary: list[str] = []
web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {}
for content in chat_content:
if isinstance(content, conversation.ToolResultContent):
if (
content.tool_name == "web_search_call"
and content.tool_call_id in web_search_calls
):
web_search_call = web_search_calls.pop(content.tool_call_id)
web_search_call["status"] = content.tool_result.get( # type: ignore[typeddict-item]
"status", "completed"
)
messages.append(web_search_call)
else:
messages.append(
FunctionCallOutput(
type="function_call_output",
call_id=content.tool_call_id,
output=json.dumps(content.tool_result),
)
messages.append(
FunctionCallOutput(
type="function_call_output",
call_id=content.tool_call_id,
output=json.dumps(content.tool_result),
)
)
continue
if content.content:
@@ -187,27 +173,15 @@ def _convert_content_to_param(
if isinstance(content, conversation.AssistantContent):
if content.tool_calls:
for tool_call in content.tool_calls:
if (
tool_call.external
and tool_call.tool_name == "web_search_call"
and "action" in tool_call.tool_args
):
web_search_calls[tool_call.id] = ResponseFunctionWebSearchParam(
type="web_search_call",
id=tool_call.id,
action=tool_call.tool_args["action"],
status="completed",
)
else:
messages.append(
ResponseFunctionToolCallParam(
type="function_call",
name=tool_call.tool_name,
arguments=json.dumps(tool_call.tool_args),
call_id=tool_call.id,
)
)
messages.extend(
ResponseFunctionToolCallParam(
type="function_call",
name=tool_call.tool_name,
arguments=json.dumps(tool_call.tool_args),
call_id=tool_call.id,
)
for tool_call in content.tool_calls
)
if content.thinking_content:
reasoning_summary.append(content.thinking_content)
@@ -237,37 +211,25 @@ def _convert_content_to_param(
async def _transform_stream(
chat_log: conversation.ChatLog,
stream: AsyncStream[ResponseStreamEvent],
) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform an OpenAI delta stream into HA format."""
last_summary_index = None
last_role: Literal["assistant", "tool_result"] | None = None
async for event in stream:
LOGGER.debug("Received event: %s", event)
if isinstance(event, ResponseOutputItemAddedEvent):
if isinstance(event.item, ResponseFunctionToolCall):
if isinstance(event.item, ResponseOutputMessage):
yield {"role": event.item.role}
last_summary_index = None
elif isinstance(event.item, ResponseFunctionToolCall):
# OpenAI has tool calls as individual events
# while HA puts tool calls inside the assistant message.
# We turn them into individual assistant content for HA
# to ensure that tools are called as soon as possible.
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
current_tool_call = event.item
elif (
isinstance(event.item, ResponseOutputMessage)
or (
isinstance(event.item, ResponseReasoningItem)
and last_summary_index is not None
) # Subsequent ResponseReasoningItem
or last_role != "assistant"
):
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
elif isinstance(event, ResponseOutputItemDoneEvent):
if isinstance(event.item, ResponseReasoningItem):
yield {
@@ -278,52 +240,6 @@ async def _transform_stream(
encrypted_content=event.item.encrypted_content,
)
}
last_summary_index = len(event.item.summary) - 1
elif isinstance(event.item, ResponseCodeInterpreterToolCall):
yield {
"tool_calls": [
llm.ToolInput(
id=event.item.id,
tool_name="code_interpreter",
tool_args={
"code": event.item.code,
"container": event.item.container_id,
},
external=True,
)
]
}
yield {
"role": "tool_result",
"tool_call_id": event.item.id,
"tool_name": "code_interpreter",
"tool_result": {
"output": [output.to_dict() for output in event.item.outputs] # type: ignore[misc]
if event.item.outputs is not None
else None
},
}
last_role = "tool_result"
elif isinstance(event.item, ResponseFunctionWebSearch):
yield {
"tool_calls": [
llm.ToolInput(
id=event.item.id,
tool_name="web_search_call",
tool_args={
"action": event.item.action.to_dict(),
},
external=True,
)
]
}
yield {
"role": "tool_result",
"tool_call_id": event.item.id,
"tool_name": "web_search_call",
"tool_result": {"status": event.item.status},
}
last_role = "tool_result"
elif isinstance(event, ResponseTextDeltaEvent):
yield {"content": event.delta}
elif isinstance(event, ResponseReasoningSummaryTextDeltaEvent):
@@ -336,7 +252,6 @@ async def _transform_stream(
and event.summary_index != last_summary_index
):
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = event.summary_index
yield {"thinking_content": event.delta}
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
@@ -433,33 +348,6 @@ class OpenAIBaseLLMEntity(Entity):
"""Generate an answer for the chat log."""
options = self.subentry.data
messages = _convert_content_to_param(chat_log.content)
model_args = ResponseCreateParamsStreaming(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
input=messages,
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
user=chat_log.conversation_id,
store=False,
stream=True,
)
if model_args["model"].startswith(("o", "gpt-5")):
model_args["reasoning"] = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
),
"summary": "auto",
}
model_args["include"] = ["reasoning.encrypted_content"]
if model_args["model"].startswith("gpt-5"):
model_args["text"] = {
"verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY)
}
tools: list[ToolParam] = []
if chat_log.llm_api:
tools = [
@@ -493,11 +381,36 @@ class OpenAIBaseLLMEntity(Entity):
),
)
)
model_args.setdefault("include", []).append("code_interpreter_call.outputs") # type: ignore[union-attr]
messages = _convert_content_to_param(chat_log.content)
model_args = ResponseCreateParamsStreaming(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
input=messages,
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
user=chat_log.conversation_id,
store=False,
stream=True,
)
if tools:
model_args["tools"] = tools
if model_args["model"].startswith(("o", "gpt-5")):
model_args["reasoning"] = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
),
"summary": "auto",
}
model_args["include"] = ["reasoning.encrypted_content"]
if model_args["model"].startswith("gpt-5"):
model_args["text"] = {
"verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY)
}
last_content = chat_log.content[-1]
# Handle attachments by adding them to the last user message

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling",
"loggers": ["opower"],
"requirements": ["opower==0.15.2"]
"requirements": ["opower==0.15.1"]
}

View File

@@ -366,7 +366,6 @@ class PrometheusMetrics:
@staticmethod
def _sanitize_metric_name(metric: str) -> str:
metric.replace("\u03bc", "\u00b5")
return "".join(
[c if c in ALLOWED_METRIC_CHARS else f"u{hex(ord(c))}" for c in metric]
)
@@ -748,9 +747,6 @@ class PrometheusMetrics:
PERCENTAGE: "percent",
}
default = unit.replace("/", "_per_")
# Unit conversion for CONCENTRATION_MICROGRAMS_PER_CUBIC_METER "μg/m³"
# "μ" == "\u03bc" but the API uses "\u00b5"
default = default.replace("\u03bc", "\u00b5")
default = default.lower()
return units.get(unit, default)

View File

@@ -261,7 +261,7 @@ def correct_db_schema_precision(
from ..migration import _modify_columns # noqa: PLC0415
precision_columns = _get_precision_column_types(table_object)
# Attempt to convert timestamp columns to μs precision
# Attempt to convert timestamp columns to µs precision
session_maker = instance.get_session
engine = instance.engine
assert engine is not None, "Engine should be set"

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.4.0"]
"requirements": ["renault-api==0.3.1"]
}

View File

@@ -156,7 +156,6 @@ class RenaultHub:
name=vehicle.device_info[ATTR_NAME],
model=vehicle.device_info[ATTR_MODEL],
model_id=vehicle.device_info[ATTR_MODEL_ID],
sw_version=None, # cleanup from PR #125399
)
self._vehicles[vehicle_link.vin] = vehicle

View File

@@ -137,9 +137,9 @@ class RepairsFlowIndexView(FlowManagerIndexView):
"Handler does not support user", HTTPStatus.BAD_REQUEST
)
return self.json(
self._prepare_result_json(result),
)
result = self._prepare_result_json(result)
return self.json(result)
class RepairsFlowResourceView(FlowManagerResourceView):

View File

@@ -7,6 +7,6 @@
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",
"requirements": ["aiorussound==4.8.1"],
"requirements": ["aiorussound==4.8.0"],
"zeroconf": ["_rio._tcp.local."]
}

View File

@@ -34,7 +34,6 @@ from homeassistant.util.enum import try_parse_enum
from homeassistant.util.hass_dict import HassKey
from .const import ( # noqa: F401
AMBIGUOUS_UNITS,
ATTR_LAST_RESET,
ATTR_OPTIONS,
ATTR_STATE_CLASS,
@@ -315,7 +314,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return _numeric_state_expected(
try_parse_enum(SensorDeviceClass, self.device_class),
self.state_class,
self.__native_unit_of_measurement_compat,
self.native_unit_of_measurement,
self.suggested_display_precision,
)
@@ -367,8 +366,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# Make sure we can convert the units
if (
(unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None
or self.__native_unit_of_measurement_compat
not in unit_converter.VALID_UNITS
or self.native_unit_of_measurement not in unit_converter.VALID_UNITS
or suggested_unit_of_measurement not in unit_converter.VALID_UNITS
):
if not self._invalid_suggested_unit_of_measurement_reported:
@@ -389,7 +387,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if suggested_unit_of_measurement is None:
# Fallback to unit suggested by the unit conversion rules from device class
suggested_unit_of_measurement = self.hass.config.units.get_converted_unit(
self.device_class, self.__native_unit_of_measurement_compat
self.device_class, self.native_unit_of_measurement
)
if suggested_unit_of_measurement is None and (
@@ -398,7 +396,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# If the device class is not known by the unit system but has a unit converter,
# fall back to the unit suggested by the unit converter's unit class.
suggested_unit_of_measurement = self.hass.config.units.get_converted_unit(
unit_converter.UNIT_CLASS, self.__native_unit_of_measurement_compat
unit_converter.UNIT_CLASS, self.native_unit_of_measurement
)
if suggested_unit_of_measurement is None:
@@ -470,16 +468,6 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return self.entity_description.native_unit_of_measurement
return None
@final
@property
def __native_unit_of_measurement_compat(self) -> str | None:
"""Process ambiguous units."""
native_unit_of_measurement = self.native_unit_of_measurement
return AMBIGUOUS_UNITS.get(
native_unit_of_measurement,
native_unit_of_measurement,
)
@cached_property
def suggested_unit_of_measurement(self) -> str | None:
"""Return the unit which should be used for the sensor's state.
@@ -515,7 +503,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if self._sensor_option_unit_of_measurement is not UNDEFINED:
return self._sensor_option_unit_of_measurement
native_unit_of_measurement = self.__native_unit_of_measurement_compat
native_unit_of_measurement = self.native_unit_of_measurement
# Second priority, for non registered entities: unit suggested by integration
if not self.registry_entry and (
@@ -555,7 +543,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@override
def state(self) -> Any:
"""Return the state of the sensor and perform unit conversions, if needed."""
native_unit_of_measurement = self.__native_unit_of_measurement_compat
native_unit_of_measurement = self.native_unit_of_measurement
unit_of_measurement = self.unit_of_measurement
value = self.native_value
# For the sake of validation, we can ignore custom device classes
@@ -777,8 +765,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return display_precision
default_unit_of_measurement = (
self.suggested_unit_of_measurement
or self.__native_unit_of_measurement_compat
self.suggested_unit_of_measurement or self.native_unit_of_measurement
)
if default_unit_of_measurement is None:
return display_precision
@@ -856,7 +843,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
(sensor_options := self.registry_entry.options.get(primary_key))
and secondary_key in sensor_options
and (device_class := self.device_class) in UNIT_CONVERTERS
and self.__native_unit_of_measurement_compat
and self.native_unit_of_measurement
in UNIT_CONVERTERS[device_class].VALID_UNITS
and (custom_unit := sensor_options[secondary_key])
in UNIT_CONVERTERS[device_class].VALID_UNITS

View File

@@ -9,7 +9,6 @@ import voluptuous as vol
from homeassistant.const import (
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
@@ -168,7 +167,7 @@ class SensorDeviceClass(StrEnum):
CONDUCTIVITY = "conductivity"
"""Conductivity.
Unit of measurement: `S/cm`, `mS/cm`, `μS/cm`
Unit of measurement: `S/cm`, `mS/cm`, `µS/cm`
"""
CURRENT = "current"
@@ -200,7 +199,7 @@ class SensorDeviceClass(StrEnum):
DURATION = "duration"
"""Fixed duration.
Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `μs`
Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs`
"""
ENERGY = "energy"
@@ -280,25 +279,25 @@ class SensorDeviceClass(StrEnum):
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
"""Amount of NO.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
NITROUS_OXIDE = "nitrous_oxide"
"""Amount of N2O.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
OZONE = "ozone"
"""Amount of O3.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
PH = "ph"
@@ -310,19 +309,19 @@ class SensorDeviceClass(StrEnum):
PM1 = "pm1"
"""Particulate matter <= 1 μm.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
PM10 = "pm10"
"""Particulate matter <= 10 μm.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
PM25 = "pm25"
"""Particulate matter <= 2.5 μm.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
POWER_FACTOR = "power_factor"
@@ -400,7 +399,7 @@ class SensorDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
Unit of measurement: `μg/m³`
Unit of measurement: `µg/m³`
"""
TEMPERATURE = "temperature"
@@ -412,7 +411,7 @@ class SensorDeviceClass(StrEnum):
VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds"
"""Amount of VOC.
Unit of measurement: `μg/m³`, `mg/m³`
Unit of measurement: `µg/m³`, `mg/m³`
"""
VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts"
@@ -424,7 +423,7 @@ class SensorDeviceClass(StrEnum):
VOLTAGE = "voltage"
"""Voltage.
Unit of measurement: `V`, `mV`, `μV`, `kV`, `MV`
Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV`
"""
VOLUME = "volume"
@@ -471,7 +470,7 @@ class SensorDeviceClass(StrEnum):
Weight is used instead of mass to fit with every day language.
Unit of measurement: `MASS_*` units
- SI / metric: `μg`, `mg`, `g`, `kg`
- SI / metric: `µg`, `mg`, `g`, `kg`
- USCS / imperial: `oz`, `lb`
"""
@@ -789,16 +788,3 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]] = {
SensorStateClass.MEASUREMENT_ANGLE: {DEGREE},
}
# We translate units that were using using the legacy coding of μ \u00b5
# to units using recommended coding of μ \u03bc
AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
"\u00b5V": UnitOfElectricPotential.MICROVOLT,
"\u00b5g/ft³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
"\u00b5g": UnitOfMass.MICROGRAMS,
"\u00b5s": UnitOfTime.MICROSECONDS,
}

View File

@@ -45,7 +45,6 @@ from homeassistant.util.enum import try_parse_enum
from homeassistant.util.hass_dict import HassKey
from .const import (
AMBIGUOUS_UNITS,
ATTR_LAST_RESET,
ATTR_STATE_CLASS,
DOMAIN,
@@ -80,7 +79,7 @@ EQUIVALENT_UNITS = {
"ft3": UnitOfVolume.CUBIC_FEET,
"m3": UnitOfVolume.CUBIC_METERS,
"ft³/m": UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE,
} | AMBIGUOUS_UNITS
}
# Keep track of entities for which a warning about decreasing value has been logged

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/sleepiq",
"iot_class": "cloud_polling",
"loggers": ["asyncsleepiq"],
"requirements": ["asyncsleepiq==1.6.0"]
"requirements": ["asyncsleepiq==1.5.3"]
}

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.2.9"]
"requirements": ["pysmartthings==3.2.8"]
}

View File

@@ -52,7 +52,7 @@
},
"select": {
"speech_dialog_level": {
"name": "Speech enhancement",
"name": "Dialog level",
"state": {
"off": "[%key:common::state::off%]",
"low": "[%key:common::state::low%]",

View File

@@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["spotifyaio"],
"requirements": ["spotifyaio==1.0.0"]
"requirements": ["spotifyaio==0.8.11"]
}

View File

@@ -41,5 +41,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==0.68.4"]
"requirements": ["PySwitchbot==0.68.3"]
}

View File

@@ -184,11 +184,6 @@ async def make_device_data(
devices_data.buttons.append((device, coordinator))
else:
devices_data.switches.append((device, coordinator))
if isinstance(device, Device) and device.device_type.startswith("Air Purifier"):
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.fans.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Battery Circulator Fan",

View File

@@ -1,7 +1,6 @@
"""Constants for the SwitchBot Cloud integration."""
from datetime import timedelta
from enum import Enum
from typing import Final
DOMAIN: Final = "switchbot_cloud"
@@ -18,18 +17,5 @@ VACUUM_FAN_SPEED_STRONG = "strong"
VACUUM_FAN_SPEED_MAX = "max"
AFTER_COMMAND_REFRESH = 5
COVER_ENTITY_AFTER_COMMAND_REFRESH = 10
class AirPurifierMode(Enum):
"""Air Purifier Modes."""
NORMAL = 1
AUTO = 2
SLEEP = 3
PET = 4
@classmethod
def get_modes(cls) -> list[str]:
"""Return a list of available air purifier modes as lowercase strings."""
return [mode.name.lower() for mode in cls]

View File

@@ -1,30 +1,23 @@
"""Support for the Switchbot Battery Circulator fan."""
import asyncio
import logging
from typing import Any
from switchbot_api import (
AirPurifierCommands,
BatteryCirculatorFanCommands,
BatteryCirculatorFanMode,
CommonCommands,
SwitchBotAPI,
)
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import AFTER_COMMAND_REFRESH, DOMAIN, AirPurifierMode
from .const import AFTER_COMMAND_REFRESH, DOMAIN
from .entity import SwitchBotCloudEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
@@ -33,13 +26,10 @@ async def async_setup_entry(
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
for device, coordinator in data.devices.fans:
if device.device_type.startswith("Air Purifier"):
async_add_entities(
[SwitchBotAirPurifierEntity(data.api, device, coordinator)]
)
else:
async_add_entities([SwitchBotCloudFan(data.api, device, coordinator)])
async_add_entities(
SwitchBotCloudFan(data.api, device, coordinator)
for device, coordinator in data.devices.fans
)
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
@@ -47,7 +37,6 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
_attr_name = None
_api: SwitchBotAPI
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
@@ -129,75 +118,3 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
class SwitchBotAirPurifierEntity(SwitchBotCloudEntity, FanEntity):
"""Representation of a Switchbot air purifier."""
_api: SwitchBotAPI
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_attr_preset_modes = AirPurifierMode.get_modes()
_attr_translation_key = "air_purifier"
_attr_name = None
_attr_is_on: bool | None = None
@property
def is_on(self) -> bool | None:
"""Return true if device is on."""
return self._attr_is_on
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if self.coordinator.data is None:
return
self._attr_is_on = self.coordinator.data.get("power") == STATE_ON.upper()
mode = self.coordinator.data.get("mode")
self._attr_preset_mode = (
AirPurifierMode(mode).name.lower() if mode is not None else None
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the air purifier."""
_LOGGER.debug(
"Switchbot air purifier to set preset mode %s %s",
preset_mode,
self._attr_unique_id,
)
await self.send_api_command(
AirPurifierCommands.SET_MODE,
parameters={"mode": AirPurifierMode[preset_mode.upper()].value},
)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the air purifier."""
_LOGGER.debug(
"Switchbot air purifier to set turn on %s %s %s",
percentage,
preset_mode,
self._attr_unique_id,
)
await self.send_api_command(CommonCommands.ON)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the air purifier."""
_LOGGER.debug("Switchbot air purifier to set turn off %s", self._attr_unique_id)
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()

View File

@@ -1,22 +0,0 @@
{
"entity": {
"fan": {
"air_purifier": {
"default": "mdi:air-purifier",
"state": {
"off": "mdi:air-purifier-off"
},
"state_attributes": {
"preset_mode": {
"state": {
"normal": "mdi:fan",
"auto": "mdi:auto-mode",
"pet": "mdi:paw",
"sleep": "mdi:power-sleep"
}
}
}
}
}
}
}

View File

@@ -16,21 +16,5 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"fan": {
"air_purifier": {
"state_attributes": {
"preset_mode": {
"state": {
"normal": "[%key:common::state::normal%]",
"auto": "[%key:common::state::auto%]",
"pet": "Pet",
"sleep": "Sleep"
}
}
}
}
}
}
}

View File

@@ -8,7 +8,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator
_PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.NUMBER]
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool:

View File

@@ -2,20 +2,13 @@
from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
import logging
from typing import TypeVar
from bleak.exc import BleakError
from togrill_bluetooth.client import Client
from togrill_bluetooth.exceptions import DecodeError
from togrill_bluetooth.packets import (
Packet,
PacketA0Notify,
PacketA1Notify,
PacketA8Write,
)
from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
@@ -32,15 +25,11 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_PROBE_COUNT
type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]
SCAN_INTERVAL = timedelta(seconds=30)
LOGGER = logging.getLogger(__name__)
PacketType = TypeVar("PacketType", bound=Packet)
def get_version_string(packet: PacketA0Notify) -> str:
"""Construct a version string from packet data."""
@@ -55,7 +44,7 @@ class DeviceFailed(UpdateFailed):
"""Update failed due to device disconnected."""
class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Packet]]):
class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
"""Class to manage fetching data."""
config_entry: ToGrillConfigEntry
@@ -79,7 +68,6 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
self.device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, self.address)}
)
self._packet_listeners: list[Callable[[Packet], None]] = []
config_entry.async_on_unload(
async_register_callback(
@@ -90,23 +78,6 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
)
)
@callback
def async_add_packet_listener(
self, packet_callback: Callable[[Packet], None]
) -> Callable[[], None]:
"""Add a listener for a given packet type."""
def _unregister():
self._packet_listeners.remove(packet_callback)
self._packet_listeners.append(packet_callback)
return _unregister
def async_update_packet_listeners(self, packet: Packet):
"""Update all packet listeners."""
for listener in self._packet_listeners:
listener(packet)
async def _connect_and_update_registry(self) -> Client:
"""Update device registry data."""
device = bluetooth.async_ble_device_from_address(
@@ -115,12 +86,7 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
if not device:
raise DeviceNotFound("Unable to find device")
try:
client = await Client.connect(device, self._notify_callback)
except BleakError as exc:
self.logger.debug("Connection failed", exc_info=True)
raise DeviceNotFound("Unable to connect to device") from exc
client = await Client.connect(device, self._notify_callback)
try:
packet_a0 = await client.read(PacketA0Notify)
except (BleakError, DecodeError) as exc:
@@ -157,30 +123,16 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
self.client = await self._connect_and_update_registry()
return self.client
def get_packet(
self, packet_type: type[PacketType], probe=None
) -> PacketType | None:
"""Get a cached packet of a certain type."""
if packet := self.data.get((packet_type.type, probe)):
assert isinstance(packet, packet_type)
return packet
return None
def _notify_callback(self, packet: Packet):
probe = getattr(packet, "probe", None)
self.data[(packet.type, probe)] = packet
self.async_update_packet_listeners(packet)
self.data[packet.type] = packet
self.async_update_listeners()
async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]:
async def _async_update_data(self) -> dict[int, Packet]:
"""Poll the device."""
client = await self._get_connected_client()
try:
await client.request(PacketA0Notify)
await client.request(PacketA1Notify)
for probe in range(1, self.config_entry.data[CONF_PROBE_COUNT] + 1):
await client.write(PacketA8Write(probe=probe))
except BleakError as exc:
raise DeviceFailed(f"Device failed {exc}") from exc
return self.data

View File

@@ -2,16 +2,9 @@
from __future__ import annotations
from bleak.exc import BleakError
from togrill_bluetooth.client import Client
from togrill_bluetooth.exceptions import BaseError
from togrill_bluetooth.packets import PacketWrite
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LOGGER, ToGrillCoordinator
from .coordinator import ToGrillCoordinator
class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]):
@@ -23,27 +16,3 @@ class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]):
"""Initialize coordinator entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
def _get_client(self) -> Client:
client = self.coordinator.client
if client is None or not client.is_connected:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="disconnected"
)
return client
async def _write_packet(self, packet: PacketWrite) -> None:
client = self._get_client()
try:
await client.write(packet)
except BleakError as exc:
LOGGER.debug("Failed to write", exc_info=True)
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="communication_failed"
) from exc
except BaseError as exc:
LOGGER.debug("Failed to write", exc_info=True)
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="rejected"
) from exc
await self.coordinator.async_request_refresh()

View File

@@ -1,63 +0,0 @@
"""Support for event entities."""
from __future__ import annotations
from togrill_bluetooth.packets import Packet, PacketA5Notify
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from . import ToGrillConfigEntry
from .const import CONF_PROBE_COUNT
from .coordinator import ToGrillCoordinator
from .entity import ToGrillEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ToGrillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up event platform."""
async_add_entities(
ToGrillEventEntity(config_entry.runtime_data, probe_number=probe_number)
for probe_number in range(1, config_entry.data[CONF_PROBE_COUNT] + 1)
)
class ToGrillEventEntity(ToGrillEntity, EventEntity):
"""Representation of a Hue Event entity from a button resource."""
def __init__(self, coordinator: ToGrillCoordinator, probe_number: int) -> None:
"""Initialize the entity."""
super().__init__(coordinator=coordinator)
self._attr_translation_key = "event"
self._attr_translation_placeholders = {"probe_number": f"{probe_number}"}
self._attr_unique_id = f"{coordinator.address}_{probe_number}"
self._probe_number = probe_number
self._attr_event_types: list[str] = [
slugify(event.name) for event in PacketA5Notify.Message
]
self.async_on_remove(coordinator.async_add_packet_listener(self._handle_event))
@callback
def _handle_event(self, packet: Packet) -> None:
if not isinstance(packet, PacketA5Notify):
return
try:
message = PacketA5Notify.Message(packet.message)
except ValueError:
return
if packet.probe != self._probe_number:
return
self._trigger_event(slugify(message.name))

View File

@@ -13,7 +13,6 @@
"dependencies": ["bluetooth"],
"documentation": "https://www.home-assistant.io/integrations/togrill",
"iot_class": "local_push",
"loggers": ["togrill_bluetooth"],
"quality_scale": "bronze",
"requirements": ["togrill-bluetooth==0.7.0"]
}

View File

@@ -1,138 +0,0 @@
"""Support for number entities."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
from togrill_bluetooth.packets import (
PacketA0Notify,
PacketA6Write,
PacketA8Notify,
PacketA301Write,
PacketWrite,
)
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ToGrillConfigEntry
from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT
from .coordinator import ToGrillCoordinator
from .entity import ToGrillEntity
PARALLEL_UPDATES = 0
@dataclass(kw_only=True, frozen=True)
class ToGrillNumberEntityDescription(NumberEntityDescription):
"""Description of entity."""
get_value: Callable[[ToGrillCoordinator], float | None]
set_packet: Callable[[float], PacketWrite]
entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True
def _get_temperature_target_description(
probe_number: int,
) -> ToGrillNumberEntityDescription:
def _set_packet(value: float | None) -> PacketWrite:
if value == 0.0:
value = None
return PacketA301Write(probe=probe_number, target=value)
def _get_value(coordinator: ToGrillCoordinator) -> float | None:
if packet := coordinator.get_packet(PacketA8Notify, probe_number):
if packet.alarm_type == PacketA8Notify.AlarmType.TEMPERATURE_TARGET:
return packet.temperature_1
return None
return ToGrillNumberEntityDescription(
key=f"temperature_target_{probe_number}",
translation_key="temperature_target",
translation_placeholders={"probe_number": f"{probe_number}"},
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
native_min_value=0,
native_max_value=250,
mode=NumberMode.BOX,
set_packet=_set_packet,
get_value=_get_value,
entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT],
)
ENTITY_DESCRIPTIONS = (
*[
_get_temperature_target_description(probe_number)
for probe_number in range(1, MAX_PROBE_COUNT + 1)
],
ToGrillNumberEntityDescription(
key="alarm_interval",
translation_key="alarm_interval",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
native_min_value=0,
native_max_value=15,
native_step=5,
mode=NumberMode.BOX,
set_packet=lambda x: (
PacketA6Write(temperature_unit=None, alarm_interval=round(x))
),
get_value=lambda x: (
packet.alarm_interval if (packet := x.get_packet(PacketA0Notify)) else None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ToGrillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
ToGrillNumber(coordinator, entity_description)
for entity_description in ENTITY_DESCRIPTIONS
if entity_description.entity_supported(entry.data)
)
class ToGrillNumber(ToGrillEntity, NumberEntity):
"""Representation of a number."""
entity_description: ToGrillNumberEntityDescription
def __init__(
self,
coordinator: ToGrillCoordinator,
entity_description: ToGrillNumberEntityDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.address}_{entity_description.key}"
@property
def native_value(self) -> float | None:
"""Return the value reported by the number."""
return self.entity_description.get_value(self.coordinator)
async def async_set_native_value(self, value: float) -> None:
"""Set value on device."""
packet = self.entity_description.set_packet(value)
await self._write_packet(packet)

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