Compare commits

...

74 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
673971d395 Implement go2rtc camera recording support via stream URLs
Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com>
2025-08-21 08:36:43 +00:00
copilot-swe-agent[bot]
31e861b6de Initial plan 2025-08-21 08:25:33 +00:00
Andrea Turri
f9575a3b2f Add "profile" extra attribute to Miele program sensor on coffee machines (#145073) 2025-08-21 10:19:13 +02:00
Erik Montnemery
bbde98bc9f Bump pychromecast to 14.0.9 (#150939) 2025-08-21 10:18:29 +02:00
Paulus Schoutsen
90e8d74fcd Add HA version to device analytics (#150877) 2025-08-21 09:44:17 +02:00
dependabot[bot]
9489c19598 Bump github/codeql-action from 3.29.9 to 3.29.10 (#150913)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-21 09:42:36 +02:00
Erik Montnemery
210ec28f0a Fix bluetooth tests (#150978) 2025-08-21 09:38:42 +02:00
dependabot[bot]
88d853cfbd Bump codecov/codecov-action from 5.4.3 to 5.5.0 (#150977)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-21 09:26:35 +02:00
Yevhenii Vaskivskyi
069b21a5a5 Create a special cookie jar for aiohttp client session in asuswrt (#150973) 2025-08-21 06:35:12 +02:00
Ludovic BOUÉ
da864ca034 Matter Refrigerator DoorOpen alarm (#150759)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-08-20 23:01:03 +02:00
Joakim Plate
8d6b7bf950 Fix event affecting multiple probes (#150954) 2025-08-20 20:40:16 +02:00
Maciej Bieniek
cf8e7cfd28 Bump imgw-pib to version 1.5.4 (#150930) 2025-08-20 14:08:47 +03:00
Marc Mueller
8e5f9264b6 Fix togrill test warning (#150933) 2025-08-20 11:20:55 +02:00
J. Nick Koston
3d0ecf0585 Bump ESPHome minimum stable BLE version to 2025.8.0 (#150924) 2025-08-20 10:25:17 +03:00
JP-Ellis
19b2c6da23 fix(amberelectric): add request timeouts (#150613)
Signed-off-by: JP-Ellis <josh@jpellis.me>
2025-08-20 07:59:33 +02:00
Tobias Sauerwein
0b231ff042 update pyatmo to v9.2.3 (#150900) 2025-08-20 07:46:51 +02:00
Joost Lekkerkerker
d8ae89be6a Bump pysmartthings to 3.2.9 (#150892) 2025-08-20 07:45:35 +02:00
Keilin Bickar
e96ff77cbf Bump asyncsleepiq dependency to 1.6.0 (#150915) 2025-08-20 07:45:06 +02:00
Denis Shulyaka
9797d391af OpenAI external tools (#150599) 2025-08-19 23:36:23 +02:00
Paulus Schoutsen
e68df66028 Fix structured output object selector conversion for OpenAI (#150916) 2025-08-19 22:17:30 +02:00
dependabot[bot]
8d30d69af5 Bump actions/dependency-review-action from 4.7.1 to 4.7.2 (#150904)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-19 19:41:37 +02:00
J. Nick Koston
93e9fab6c7 Bump bleak-retry-connector to 4.0.2 (#150899) 2025-08-19 19:21:37 +02:00
Jan Bouwhuis
48300f4563 Use greek small letter mu "\u03bc" instead of micro sign "\u00B5" for micro unit prefix (alt 1) (#144853)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-08-19 18:48:50 +02:00
Erik Montnemery
48091e5995 Improve test of WS command get_services (#150901)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-19 17:47:12 +02:00
Erik Montnemery
26582cecbd Improve test of REST endpoint /api/services (#150897) 2025-08-19 17:32:52 +02:00
jan iversen
7ecf32390c Modbus: Avoid duplicate updates. (#150895) 2025-08-19 17:15:51 +02:00
Luke Heckman
cded163930 Update contributing guide links (#150159)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-08-19 17:15:31 +02:00
rossfoss
10fe479311 Add new attributes to Met Éireann (#150653)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-08-19 17:04:35 +02:00
Stefan Agner
65696f9b53 Bump aiohasupervisor from version 0.3.1 to version 0.3.2b0 (#150893) 2025-08-19 16:55:36 +02:00
Stefan Agner
08fc2ab03b Add missing unsupported reasons to list (#150866) 2025-08-19 16:55:16 +02:00
jan iversen
2290940638 Modbus: Remove unused variable. (#150894) 2025-08-19 16:54:05 +02:00
Matthias Alphart
63640af4d4 Update voluptuous-serialize to 2.7.0 (#150822) 2025-08-19 16:42:49 +02:00
Paulus Schoutsen
f4400516b8 Fix PWA theme color to match darker blue color scheme in 2025.8 (#150896)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-19 17:24:32 +03:00
Thomas D
b52a806b36 Show charging power as 0 when not charging for the Volvo integration (#150797) 2025-08-19 16:23:30 +02:00
Paul Warren
76f3397aa0 Bump pyDaikin to 2.16.0 (#150867) 2025-08-19 16:12:53 +02:00
Retha Runolfsson
89abe65e1d Add air purifier for switchbot cloud integration (#147001) 2025-08-19 16:02:18 +02:00
jan iversen
785c9ebc3b Modbus: Retry primary connect. (#150853) 2025-08-19 15:03:05 +02:00
Joakim Plate
a08be4fcb6 Add event entity to Togrill (#150812) 2025-08-19 15:01:57 +02:00
markhannon
e8409e7c42 Bump to zcc-helper==3.6 (#150608) 2025-08-19 14:40:12 +02:00
epenet
c46618cbd1 Bump renault-api to 0.4.0 (#150624) 2025-08-19 14:37:56 +02:00
Alexandre CUER
319e37384f Migrate Emoncms_history to external async library (#149824) 2025-08-19 14:32:13 +02:00
Imeon-Energy
4c1788e757 Add sensors to Imeon inverter integration (#146437)
Co-authored-by: TheBushBoy <theodavid@icloud.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-08-19 14:21:33 +02:00
epenet
e6a158b1ac Add temperature sensor to Tuya solar inverters (#150878) 2025-08-19 14:20:29 +02:00
epenet
69dbcb0627 Add sound switch to Tuya fan light (#150879) 2025-08-19 14:20:05 +02:00
Andrew Jackson
c62c52e8cf Bump mastodon.py to 2.1.1 (#150876) 2025-08-19 14:19:44 +02:00
epenet
c18dc9b63b Fix icloud service calls (#150881) 2025-08-19 14:19:10 +02:00
Matrix
69757bed52 Support for YoLink YS4102 YS4103 (#150464) 2025-08-19 11:35:36 +02:00
epenet
899f0e03c1 Add Tuya test fixtures (#150835) 2025-08-19 11:34:25 +02:00
Imeon-Energy
fc5e720764 Use correct unit and class for the Imeon inverter sensors (#150847)
Co-authored-by: TheBushBoy <theodavid@icloud.com>
2025-08-19 10:19:03 +02:00
Noah Husby
0c0e54b541 Bump aiorussound to 4.8.1 (#150858) 2025-08-19 09:45:42 +02:00
Retha Runolfsson
86e3eca57f Bump pyswitchbot to 0.68.4 (#150871) 2025-08-19 09:43:35 +02:00
Martin Hjelmare
15505cdd56 Handle Z-Wave RssiErrorReceived (#150846) 2025-08-18 22:14:52 +02:00
G Johansson
c7001dcfc4 Bump holidays to 0.79 (#150857) 2025-08-18 21:40:43 +02:00
epenet
40feefc0fa Cleanup sw_version in Renault (#150844) 2025-08-18 22:28:58 +03:00
Marc Mueller
f6d23b9b34 Check for forbidden files in dependencies with hassfest (#150772) 2025-08-18 17:44:31 +02:00
Andrew Jackson
ab4aeb65f2 Bump mastodon.py to 2.1.0 and change quality scale (#150836) 2025-08-18 17:35:37 +02:00
Foscam-wangzhengyu
019c4ab874 Bump libpyfoscamcgi to 0.0.7 (#150829) 2025-08-18 17:34:00 +02:00
jan iversen
53ca369395 Do not start modbus update process until connection+delay. (#150796) 2025-08-18 17:20:41 +02:00
Manu
9910480980 Bump aiontfy to v0.5.4 (#150825) 2025-08-18 12:06:09 +02:00
Maciej Bieniek
7ecbe53b15 Bump brother to version 5.0.1 (#150840) 2025-08-18 12:05:10 +02:00
Maciej Bieniek
6baa162963 Bump brother to version 5.0.1 (#150840) 2025-08-18 12:04:52 +02:00
Ludovic BOUÉ
2f5561aeba Matter Custom Eve Weather trend (#147620) 2025-08-18 11:32:08 +02:00
Erik Montnemery
330bb46cf9 Revert "Bump automower-ble to 0.2.7" (#150833) 2025-08-18 10:07:57 +02:00
Joost Lekkerkerker
2f8ddae24d Include device data in Withings diagnostics (#150816) 2025-08-18 10:04:54 +02:00
Joost Lekkerkerker
419315d9cf Clean up freebox entity (#150695) 2025-08-18 09:47:02 +02:00
Joost Lekkerkerker
9138930cb9 Abort Nanoleaf discovery flows with user flow (#150818) 2025-08-18 09:41:37 +02:00
Joost Lekkerkerker
a325596898 Bump yt-dlp to 2025.08.11 (#150821) 2025-08-18 09:39:08 +02:00
Claudio Ruggeri - CR-Tech
5fdb95e83c Fix Modbus issue 150453: correct transition update for climate without HVAC mode enabled (#150522)
Co-authored-by: jan iversen <jancasacondor@gmail.com>
2025-08-18 08:49:09 +02:00
Joost Lekkerkerker
fcbfca52f3 Bump spotifyaio to 1.0.0 (#150820) 2025-08-18 08:28:58 +02:00
Yuxin Wang
f44578f45f Add more exception types for cannot_connect test in APCUPSD (#150830) 2025-08-18 07:19:47 +02:00
Yuxin Wang
2b7bd923d6 Add a base entity to APCUPSD integration (#150828) 2025-08-18 07:18:08 +02:00
Joakim Plate
3ab4fd3035 Add number entity to togrill (#150609) 2025-08-17 23:48:53 +02:00
tronikos
794deaa5fd Bump opower to 0.15.2 (#150809) 2025-08-17 23:48:14 +02:00
Pete Sage
79bbae2fde Change the default name of the speech enhancement select for Sonos (#150815) 2025-08-17 23:41:51 +02:00
263 changed files with 9450 additions and 1413 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.1
uses: actions/dependency-review-action@v4.7.2
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.4.3
uses: codecov/codecov-action@v5.5.0
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.4.3
uses: codecov/codecov-action@v5.5.0
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.9
uses: github/codeql-action/init@v3.29.10
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.9
uses: github/codeql-action/analyze@v3.29.10
with:
category: "/language:python"

2
CODEOWNERS generated
View File

@@ -422,6 +422,8 @@ 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,5 +14,8 @@ 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 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.
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.

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

View File

@@ -21,3 +21,5 @@ 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
from .const import LOGGER, REQUEST_TIMEOUT
from .helpers import normalize_descriptor
type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
@@ -82,7 +82,11 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
"grid": {},
}
try:
data = self._api.get_current_prices(self.site_id, next=288)
data = self._api.get_current_prices(
self.site_id,
next=288,
_request_timeout=REQUEST_TIMEOUT,
)
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,5 +449,6 @@ 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,22 +40,16 @@ async def async_setup_entry(
async_add_entities([OnlineStatus(coordinator, _DESCRIPTION)])
class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity):
class OnlineStatus(APCUPSdEntity, 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, 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
super().__init__(coordinator, description)
@property
def is_on(self) -> bool | None:

View File

@@ -0,0 +1,26 @@
"""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,10 +3,7 @@ rules:
action-setup: done
appropriate-polling: done
brands: done
common-modules:
status: done
comment: |
Consider deriving a base entity.
common-modules: done
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,22 +490,16 @@ def infer_unit(value: str) -> tuple[str, str | None]:
return value, None
class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
class APCUPSdSensor(APCUPSdEntity, 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=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
super().__init__(coordinator, description)
# Initial update of attributes.
self._update_attrs()

View File

@@ -15,6 +15,7 @@ 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,
@@ -25,7 +26,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import UpdateFailed
@@ -109,7 +110,10 @@ class AsusWrtBridge(ABC):
) -> AsusWrtBridge:
"""Get Bridge instance."""
if conf[CONF_PROTOCOL] in (PROTOCOL_HTTPS, PROTOCOL_HTTP):
session = async_get_clientsession(hass)
session = async_create_clientsession(
hass,
cookie_jar=get_cookie_jar(),
)
return AsusWrtHttpBridge(conf, session)
return AsusWrtLegacyBridge(conf, options)

View File

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

View File

@@ -149,20 +149,16 @@ def websocket_depose_mfa(
hass.async_create_task(async_depose(msg))
def _prepare_result_json(
result: data_entry_flow.FlowResult,
) -> data_entry_flow.FlowResult:
"""Convert result to JSON."""
def _prepare_result_json(result: data_entry_flow.FlowResult) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY:
return result.copy()
return dict(result)
if result["type"] != data_entry_flow.FlowResultType.FORM:
return result
return result # type: ignore[return-value]
data = result.copy()
if (schema := data["data_schema"]) is None:
data["data_schema"] = [] # type: ignore[typeddict-item] # json result type
data = dict(result)
if (schema := result["data_schema"]) is None:
data["data_schema"] = []
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.1",
"bleak-retry-connector==4.0.2",
"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.0"],
"requirements": ["brother==5.0.1"],
"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,6 +564,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
This is used by cameras with CameraEntityFeature.STREAM
and StreamType.HLS.
"""
# Check if camera has a go2rtc provider that can provide stream sources
if (
self._webrtc_provider
and hasattr(self._webrtc_provider, 'async_get_stream_source')
and self._webrtc_provider.domain == "go2rtc"
):
return await self._webrtc_provider.async_get_stream_source(self)
return None
async def async_handle_async_webrtc_offer(

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.7"],
"requirements": ["PyChromecast==14.0.9"],
"single_config_entry": true,
"zeroconf": ["_googlecast._tcp.local."]
}

View File

@@ -137,20 +137,16 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView):
def _prepare_config_flow_result_json(
result: data_entry_flow.FlowResult,
prepare_result_json: Callable[
[data_entry_flow.FlowResult], data_entry_flow.FlowResult
],
) -> data_entry_flow.FlowResult:
prepare_result_json: Callable[[data_entry_flow.FlowResult], dict[str, Any]],
) -> dict[str, Any]:
"""Convert result to JSON."""
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
return prepare_result_json(result)
data = result.copy()
entry: config_entries.ConfigEntry = data["result"] # type: ignore[typeddict-item]
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]
# We overwrite the ConfigEntry object with its json representation.
data["result"] = entry.as_json_fragment # type: ignore[typeddict-unknown-key]
data.pop("data")
data.pop("context")
data["result"] = entry.as_json_fragment
return data
@@ -204,8 +200,8 @@ class ConfigManagerFlowIndexView(
def _prepare_result_json(
self, result: data_entry_flow.FlowResult
) -> data_entry_flow.FlowResult:
"""Convert result to JSON."""
) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
return _prepare_config_flow_result_json(result, super()._prepare_result_json)
@@ -229,8 +225,8 @@ class ConfigManagerFlowResourceView(
def _prepare_result_json(
self, result: data_entry_flow.FlowResult
) -> data_entry_flow.FlowResult:
"""Convert result to JSON."""
) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
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.15.0"],
"requirements": ["pydaikin==2.16.0"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@@ -99,6 +99,18 @@ 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 = 3
MINOR_VERSION = 4
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,10 +1,11 @@
"""Support for sending data to Emoncms."""
from datetime import timedelta
from http import HTTPStatus
from datetime import datetime, timedelta
from functools import partial
import logging
import requests
import aiohttp
from pyemoncms import EmoncmsClient
import voluptuous as vol
from homeassistant.const import (
@@ -17,9 +18,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.event import track_point_in_time
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -42,61 +43,51 @@ CONFIG_SCHEMA = vol.Schema(
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
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:
"""Set up the Emoncms history component."""
conf = config[DOMAIN]
whitelist = conf.get(CONF_WHITELIST)
input_node = str(conf.get(CONF_INPUTNODE))
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
)
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)),
)
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,8 +1,9 @@
{
"domain": "emoncms_history",
"name": "Emoncms History",
"codeowners": [],
"codeowners": ["@alexandrecuer"],
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
"iot_class": "local_polling",
"quality_scale": "legacy"
"quality_scale": "legacy",
"requirements": ["pyemoncms==0.1.2"]
}

View File

@@ -17,7 +17,7 @@ DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
DEFAULT_PORT: Final = 6053
STABLE_BLE_VERSION_STR = "2025.5.0"
STABLE_BLE_VERSION_STR = "2025.8.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.6"]
"requirements": ["libpyfoscamcgi==0.0.7"]
}

View File

@@ -36,7 +36,7 @@ async def async_setup_entry(
async_add_entities(
(
FreeboxAlarm(hass, router, node)
FreeboxAlarm(router, node)
for node in router.home_devices.values()
if node["category"] == FreeboxHomeCategory.ALARM
),
@@ -49,11 +49,9 @@ class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity):
_attr_code_arm_required = False
def __init__(
self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any]
) -> None:
def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None:
"""Initialize an alarm."""
super().__init__(hass, router, node)
super().__init__(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(hass, router, node))
binary_entities.append(FreeboxPirSensor(router, node))
elif node["category"] == FreeboxHomeCategory.DWS:
binary_entities.append(FreeboxDwsSensor(hass, router, node))
binary_entities.append(FreeboxDwsSensor(router, node))
binary_entities.extend(
FreeboxCoverSensor(hass, router, node)
FreeboxCoverSensor(router, node)
for endpoint in node["show_endpoints"]
if (
endpoint["name"] == "cover"
@@ -74,13 +74,12 @@ 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__(hass, router, node, sub_node)
super().__init__(router, node, sub_node)
self._command_id = self.get_command_id(
node["type"]["endpoints"], "signal", self._sensor_name
)
@@ -123,9 +122,7 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor):
_sensor_name = "cover"
def __init__(
self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any]
) -> None:
def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None:
"""Initialize a cover for another device."""
cover_node = next(
filter(
@@ -134,7 +131,7 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor):
),
None,
)
super().__init__(hass, router, node, cover_node)
super().__init__(router, node, cover_node)
class FreeboxRaidDegradedSensor(BinarySensorEntity):

View File

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

View File

@@ -2,11 +2,9 @@
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
@@ -22,13 +20,11 @@ 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
@@ -44,7 +40,6 @@ 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:
@@ -61,10 +56,7 @@ 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:
@@ -116,23 +108,14 @@ class FreeboxHomeEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Register state update callback."""
self.remove_signal_update(
self.async_on_remove(
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,7 +68,6 @@ async def async_setup_entry(
) -> None:
"""Set up the sensors."""
router = entry.runtime_data
entities: list[SensorEntity] = []
_LOGGER.debug(
"%s - %s - %s temperature sensors",
@@ -76,7 +75,7 @@ async def async_setup_entry(
router.mac,
len(router.sensors_temperature),
)
entities = [
entities: list[SensorEntity] = [
FreeboxSensor(
router,
SensorEntityDescription(
@@ -105,14 +104,16 @@ async def async_setup_entry(
for description in DISK_PARTITION_SENSORS
)
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))
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
)
)
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 = "#03A9F4"
DEFAULT_THEME_COLOR = "#2980b9"
DATA_PANELS: HassKey[dict[str, Panel]] = HassKey("frontend_panels")

View File

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

View File

@@ -61,18 +61,19 @@ 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",
@@ -80,6 +81,7 @@ 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.1"],
"requirements": ["aiohasupervisor==0.3.2b0"],
"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.78", "babel==2.15.0"]
"requirements": ["holidays==0.79", "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.7"]
"requirements": ["automower-ble==0.2.1"]
}

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
from .account import IcloudAccount, IcloudConfigEntry
from .const import (
ATTR_ACCOUNT,
ATTR_DEVICE_NAME,
@@ -92,8 +92,10 @@ 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:
for account in service.hass.data[DOMAIN].values():
account.keep_alive()
# 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()
else:
_get_account(service.hass, account).keep_alive()
@@ -102,17 +104,12 @@ def _get_account(hass: HomeAssistant, account_identifier: str) -> IcloudAccount:
if account_identifier is None:
return None
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
entry: IcloudConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
if entry.runtime_data.username == account_identifier:
return entry.runtime_data
if icloud_account is None:
raise ValueError(
f"No iCloud account with username or name {account_identifier}"
)
return icloud_account
raise ValueError(f"No iCloud account with username or name {account_identifier}")
@callback

View File

@@ -7,3 +7,26 @@ 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 = timedelta(seconds=60)
INTERVAL = 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=INTERVAL,
update_interval=timedelta(seconds=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, ClientError) as e:
except (ValueError, TimeoutError, ClientError) as e:
raise UpdateFailed(e) from e
# Store data

View File

@@ -7,8 +7,14 @@
"battery_soc": {
"default": "mdi:battery-charging-100"
},
"battery_status": {
"default": "mdi:battery-alert"
},
"battery_stored": {
"default": "mdi:battery"
"default": "mdi:battery-arrow-up"
},
"battery_consumed": {
"default": "mdi:battery-arrow-down"
},
"grid_current_l1": {
"default": "mdi:current-ac"
@@ -50,7 +56,7 @@
"default": "mdi:power-socket"
},
"meter_power": {
"default": "mdi:power-plug"
"default": "mdi:meter-electric"
},
"output_current_l1": {
"default": "mdi:current-ac"
@@ -116,7 +122,7 @@
"default": "mdi:home-lightning-bolt"
},
"monitoring_minute_grid_consumption": {
"default": "mdi:transmission-tower"
"default": "mdi:transmission-tower-import"
},
"monitoring_minute_grid_injection": {
"default": "mdi:transmission-tower-export"
@@ -126,6 +132,43 @@
},
"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,6 +23,7 @@ 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
@@ -47,11 +48,24 @@ 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=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
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,
state_class=SensorStateClass.MEASUREMENT,
),
# Grid
@@ -148,6 +162,12 @@ 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",
@@ -238,16 +258,16 @@ SENSOR_DESCRIPTIONS = (
SensorEntityDescription(
key="pv_consumed",
translation_key="pv_consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="pv_injected",
translation_key="pv_injected",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="pv_power_1",
@@ -290,14 +310,14 @@ SENSOR_DESCRIPTIONS = (
key="monitoring_self_consumption",
translation_key="monitoring_self_consumption",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=2,
),
SensorEntityDescription(
key="monitoring_self_sufficiency",
translation_key="monitoring_self_sufficiency",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=2,
),
# Monitoring (instant minute data)
@@ -341,6 +361,62 @@ 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,9 +35,20 @@
"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"
},
@@ -77,6 +88,15 @@
"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"
},
@@ -135,25 +155,63 @@
"name": "Component temperature"
},
"monitoring_self_consumption": {
"name": "Monitoring self-consumption"
"name": "Self-consumption"
},
"monitoring_self_sufficiency": {
"name": "Monitoring self-sufficiency"
"name": "Self-sufficiency"
},
"monitoring_minute_building_consumption": {
"name": "Monitoring building consumption (minute)"
"name": "Building consumption"
},
"monitoring_minute_grid_consumption": {
"name": "Monitoring grid consumption (minute)"
"name": "Grid consumption"
},
"monitoring_minute_grid_injection": {
"name": "Monitoring grid injection (minute)"
"name": "Grid injection"
},
"monitoring_minute_grid_power_flow": {
"name": "Monitoring grid power flow (minute)"
"name": "Grid power flow"
},
"monitoring_minute_solar_production": {
"name": "Monitoring solar production (minute)"
"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"
}
}
}

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.3"]
"requirements": ["imgw_pib==1.5.4"]
}

View File

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

View File

@@ -6,10 +6,7 @@ rules:
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency:
status: todo
comment: |
Mastodon.py does not have CI build/publish.
dependency-transparency: done
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="dishwasher_alarm_door",
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
@@ -407,4 +407,19 @@ 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,6 +57,15 @@
"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,6 +70,14 @@ 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",
@@ -517,6 +525,19 @@ 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"
},
"dishwasher_alarm_door": {
"alarm_door": {
"name": "Door alarm"
}
},
@@ -434,6 +434,15 @@
"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.07.21"],
"requirements": ["yt-dlp[default]==2025.08.11"],
"single_config_entry": true
}

View File

@@ -10,9 +10,12 @@ 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,
@@ -34,6 +37,9 @@ 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,6 +138,16 @@ 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,6 +850,14 @@ 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
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import logging
from typing import Final, cast
from typing import Any, Final, cast
from pymiele import MieleDevice, MieleTemperature
@@ -30,6 +30,7 @@ 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,
@@ -61,6 +62,8 @@ PLATE_COUNT = {
"KMX": 6,
}
ATTRIBUTE_PROFILE = "profile"
def _get_plate_count(tech_type: str) -> int:
"""Get number of zones for hob."""
@@ -88,11 +91,21 @@ 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
@@ -157,7 +170,6 @@ 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,
@@ -172,6 +184,18 @@ 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,
@@ -710,6 +734,16 @@ 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."""
@@ -792,6 +826,8 @@ 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,6 +991,18 @@
"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,6 +490,11 @@ 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,7 +3,6 @@
from __future__ import annotations
from abc import abstractmethod
import asyncio
from collections.abc import Callable
from datetime import datetime, timedelta
import struct
@@ -28,7 +27,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, async_track_time_interval
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
@@ -92,7 +91,6 @@ 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)
@@ -109,29 +107,39 @@ 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, now: datetime | None = None) -> None:
async def async_update(self) -> None:
"""Update the entity state."""
async with self._update_lock:
await self._async_update()
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 def _async_update_write_state(self) -> None:
"""Update the entity state and write it to the state machine."""
await self.async_update()
self.async_write_ha_state()
if self._cancel_call:
self._cancel_call()
self._cancel_call = None
await self.async_local_update()
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
@@ -139,12 +147,9 @@ class BasePlatform(Entity):
"""Remote start entity."""
self._async_cancel_update_polling()
self._async_schedule_future_update(0.1)
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._cancel_call = async_call_later(
self.hass, timedelta(seconds=0.1), self.async_local_update
)
self._attr_available = True
self.async_write_ha_state()
@@ -177,9 +182,20 @@ 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_run()
self.async_on_remove(
async_call_later(
self.hass,
self._hub.config_delay + 0.1,
self.async_await_connection,
)
)
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_hold)
)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from collections import namedtuple
from collections.abc import Callable
from typing import Any
from pymodbus.client import (
@@ -28,11 +27,10 @@ from homeassistant.const import (
CONF_TYPE,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.core import Event, HomeAssistant, ServiceCall
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
@@ -73,6 +71,7 @@ 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
@@ -254,13 +253,12 @@ 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 = ""
@@ -313,22 +311,25 @@ class ModbusHub:
async def async_pb_connect(self) -> None:
"""Connect to device, async."""
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)
# 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
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)
if self.config_delay:
await asyncio.sleep(self.config_delay)
self.config_delay = 0
self.event_connected.set()
async def async_setup(self) -> bool:
"""Set up pymodbus client."""
@@ -349,12 +350,6 @@ 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:
@@ -364,9 +359,6 @@ 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()
@@ -415,7 +407,6 @@ 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(
@@ -426,8 +417,6 @@ 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,6 +16,7 @@ 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,
@@ -70,6 +71,12 @@ 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,6 +10,7 @@ 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,
@@ -133,9 +134,14 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT
f"together with state class '{state_class}'"
)
if (device_class := config.get(CONF_DEVICE_CLASS)) is None or (
unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)
) is None:
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:
return config
if (

View File

@@ -10,7 +10,12 @@ from typing import Any, Final, cast
from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_USER,
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
@@ -200,7 +205,9 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="unknown")
name = self.nanoleaf.name
await self.async_set_unique_id(name)
await self.async_set_unique_id(
name, raise_on_progress=self.source != SOURCE_USER
)
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.1"]
"requirements": ["pyatmo==9.2.3"]
}

View File

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

View File

@@ -31,6 +31,7 @@ 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,
@@ -368,6 +369,15 @@ 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:
@@ -375,7 +385,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
native_unit_of_measurement = self.__native_unit_of_measurement_compat
# device_class is checked after native_unit_of_measurement since most
# of the time we can avoid the device_class check
if (
@@ -444,7 +454,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
native_unit_of_measurement = self.__native_unit_of_measurement_compat
unit_of_measurement = self.unit_of_measurement
if native_unit_of_measurement != unit_of_measurement:
if TYPE_CHECKING:
@@ -473,7 +483,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
native_unit_of_measurement = self.__native_unit_of_measurement_compat
unit_of_measurement = self.unit_of_measurement
if native_unit_of_measurement != unit_of_measurement:
if TYPE_CHECKING:
@@ -496,7 +506,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
and self.__native_unit_of_measurement_compat
in UNIT_CONVERTERS[device_class].VALID_UNITS
and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS
):

View File

@@ -9,6 +9,7 @@ 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,
@@ -136,7 +137,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"
@@ -168,7 +169,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"
@@ -246,25 +247,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"
@@ -276,19 +277,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"
@@ -365,7 +366,7 @@ class NumberDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
Unit of measurement: `µg/m³`
Unit of measurement: `μg/m³`
"""
TEMPERATURE = "temperature"
@@ -377,7 +378,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"
@@ -389,7 +390,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"
@@ -436,7 +437,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`
"""
@@ -556,3 +557,16 @@ 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,6 +14,7 @@ from openai._streaming import AsyncStream
from openai.types.responses import (
EasyInputMessageParam,
FunctionToolParam,
ResponseCodeInterpreterToolCall,
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseFailedEvent,
@@ -21,6 +22,8 @@ from openai.types.responses import (
ResponseFunctionCallArgumentsDoneEvent,
ResponseFunctionToolCall,
ResponseFunctionToolCallParam,
ResponseFunctionWebSearch,
ResponseFunctionWebSearchParam,
ResponseIncompleteEvent,
ResponseInputFileParam,
ResponseInputImageParam,
@@ -92,6 +95,8 @@ 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
@@ -125,8 +130,6 @@ def _format_structured_output(
_adjust_schema(result)
result["strict"] = True
result["additionalProperties"] = False
return result
@@ -149,16 +152,27 @@ 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):
messages.append(
FunctionCallOutput(
type="function_call_output",
call_id=content.tool_call_id,
output=json.dumps(content.tool_result),
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),
)
)
)
continue
if content.content:
@@ -173,15 +187,27 @@ def _convert_content_to_param(
if isinstance(content, conversation.AssistantContent):
if content.tool_calls:
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
)
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,
)
)
if content.thinking_content:
reasoning_summary.append(content.thinking_content)
@@ -211,25 +237,37 @@ def _convert_content_to_param(
async def _transform_stream(
chat_log: conversation.ChatLog,
stream: AsyncStream[ResponseStreamEvent],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
"""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, ResponseOutputMessage):
yield {"role": event.item.role}
last_summary_index = None
elif isinstance(event.item, ResponseFunctionToolCall):
if 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 {
@@ -240,6 +278,52 @@ 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):
@@ -252,6 +336,7 @@ 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):
@@ -348,6 +433,33 @@ 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 = [
@@ -381,36 +493,11 @@ 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.1"]
"requirements": ["opower==0.15.2"]
}

View File

@@ -366,6 +366,7 @@ 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]
)
@@ -747,6 +748,9 @@ 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.3.1"]
"requirements": ["renault-api==0.4.0"]
}

View File

@@ -156,6 +156,7 @@ 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
)
result = self._prepare_result_json(result)
return self.json(result)
return self.json(
self._prepare_result_json(result),
)
class RepairsFlowResourceView(FlowManagerResourceView):

View File

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

View File

@@ -34,6 +34,7 @@ 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,
@@ -314,7 +315,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,
self.__native_unit_of_measurement_compat,
self.suggested_display_precision,
)
@@ -366,7 +367,8 @@ 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 not in unit_converter.VALID_UNITS
or self.__native_unit_of_measurement_compat
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:
@@ -387,7 +389,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
self.device_class, self.__native_unit_of_measurement_compat
)
if suggested_unit_of_measurement is None and (
@@ -396,7 +398,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
unit_converter.UNIT_CLASS, self.__native_unit_of_measurement_compat
)
if suggested_unit_of_measurement is None:
@@ -468,6 +470,16 @@ 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.
@@ -503,7 +515,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
native_unit_of_measurement = self.__native_unit_of_measurement_compat
# Second priority, for non registered entities: unit suggested by integration
if not self.registry_entry and (
@@ -543,7 +555,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
native_unit_of_measurement = self.__native_unit_of_measurement_compat
unit_of_measurement = self.unit_of_measurement
value = self.native_value
# For the sake of validation, we can ignore custom device classes
@@ -765,7 +777,8 @@ 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
self.suggested_unit_of_measurement
or self.__native_unit_of_measurement_compat
)
if default_unit_of_measurement is None:
return display_precision
@@ -843,7 +856,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
and self.__native_unit_of_measurement_compat
in UNIT_CONVERTERS[device_class].VALID_UNITS
and (custom_unit := sensor_options[secondary_key])
in UNIT_CONVERTERS[device_class].VALID_UNITS

View File

@@ -9,6 +9,7 @@ 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,
@@ -167,7 +168,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"
@@ -199,7 +200,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"
@@ -279,25 +280,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"
@@ -309,19 +310,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"
@@ -399,7 +400,7 @@ class SensorDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
Unit of measurement: `µg/m³`
Unit of measurement: `μg/m³`
"""
TEMPERATURE = "temperature"
@@ -411,7 +412,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"
@@ -423,7 +424,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"
@@ -470,7 +471,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`
"""
@@ -788,3 +789,16 @@ 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,6 +45,7 @@ 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,
@@ -79,7 +80,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.5.3"]
"requirements": ["asyncsleepiq==1.6.0"]
}

View File

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

View File

@@ -52,7 +52,7 @@
},
"select": {
"speech_dialog_level": {
"name": "Dialog level",
"name": "Speech enhancement",
"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==0.8.11"]
"requirements": ["spotifyaio==1.0.0"]
}

View File

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

View File

@@ -184,6 +184,11 @@ 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,6 +1,7 @@
"""Constants for the SwitchBot Cloud integration."""
from datetime import timedelta
from enum import Enum
from typing import Final
DOMAIN: Final = "switchbot_cloud"
@@ -17,5 +18,18 @@ 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,23 +1,30 @@
"""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
from .const import AFTER_COMMAND_REFRESH, DOMAIN, AirPurifierMode
from .entity import SwitchBotCloudEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
@@ -26,10 +33,13 @@ async def async_setup_entry(
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
async_add_entities(
SwitchBotCloudFan(data.api, device, coordinator)
for device, coordinator in data.devices.fans
)
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)])
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
@@ -37,6 +47,7 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
_attr_name = None
_api: SwitchBotAPI
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
@@ -118,3 +129,75 @@ 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

@@ -0,0 +1,22 @@
{
"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,5 +16,21 @@
"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.SENSOR]
_PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.NUMBER]
async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool:

View File

@@ -2,13 +2,20 @@
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
from togrill_bluetooth.packets import (
Packet,
PacketA0Notify,
PacketA1Notify,
PacketA8Write,
)
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
@@ -25,11 +32,15 @@ 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."""
@@ -44,7 +55,7 @@ class DeviceFailed(UpdateFailed):
"""Update failed due to device disconnected."""
class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Packet]]):
"""Class to manage fetching data."""
config_entry: ToGrillConfigEntry
@@ -68,6 +79,7 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
self.device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, self.address)}
)
self._packet_listeners: list[Callable[[Packet], None]] = []
config_entry.async_on_unload(
async_register_callback(
@@ -78,6 +90,23 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
)
)
@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(
@@ -86,7 +115,12 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
if not device:
raise DeviceNotFound("Unable to find device")
client = await Client.connect(device, self._notify_callback)
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
try:
packet_a0 = await client.read(PacketA0Notify)
except (BleakError, DecodeError) as exc:
@@ -123,16 +157,30 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
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):
self.data[packet.type] = packet
probe = getattr(packet, "probe", None)
self.data[(packet.type, probe)] = packet
self.async_update_packet_listeners(packet)
self.async_update_listeners()
async def _async_update_data(self) -> dict[int, Packet]:
async def _async_update_data(self) -> dict[tuple[int, int | None], 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,9 +2,16 @@
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 .coordinator import ToGrillCoordinator
from .const import DOMAIN
from .coordinator import LOGGER, ToGrillCoordinator
class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]):
@@ -16,3 +23,27 @@ 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

@@ -0,0 +1,63 @@
"""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,6 +13,7 @@
"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

@@ -0,0 +1,138 @@
"""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