Merge branch 'dev' into programTemp

This commit is contained in:
Christopher Fenner
2023-11-30 09:36:18 +01:00
committed by GitHub
201 changed files with 3555 additions and 1479 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ env:
CACHE_VERSION: 5
PIP_CACHE_VERSION: 4
MYPY_CACHE_VERSION: 6
HA_SHORT_VERSION: "2023.12"
HA_SHORT_VERSION: "2024.1"
DEFAULT_PYTHON: "3.11"
ALL_PYTHON_VERSIONS: "['3.11', '3.12']"
# 10.3 is the oldest supported version
+2
View File
@@ -120,6 +120,7 @@ homeassistant.components.energy.*
homeassistant.components.esphome.*
homeassistant.components.event.*
homeassistant.components.evil_genius_labs.*
homeassistant.components.faa_delays.*
homeassistant.components.fan.*
homeassistant.components.fastdotcom.*
homeassistant.components.feedreader.*
@@ -264,6 +265,7 @@ homeassistant.components.proximity.*
homeassistant.components.prusalink.*
homeassistant.components.pure_energie.*
homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
homeassistant.components.pvoutput.*
homeassistant.components.qnap_qsw.*
homeassistant.components.radarr.*
+3
View File
@@ -1,3 +1,6 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
FROM ${BUILD_FROM}
+9 -11
View File
@@ -41,7 +41,6 @@ from homeassistant.exceptions import (
Unauthorized,
)
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.aiohttp_compat import enable_compression
from homeassistant.helpers.event import EventStateChangedData
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.service import async_get_all_descriptions
@@ -218,9 +217,11 @@ class APIStatesView(HomeAssistantView):
if entity_perm(state.entity_id, "read")
)
response = web.Response(
body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON
body=f'[{",".join(states)}]',
content_type=CONTENT_TYPE_JSON,
zlib_executor_size=32768,
)
enable_compression(response)
response.enable_compression()
return response
@@ -390,17 +391,14 @@ class APIDomainServicesView(HomeAssistantView):
)
try:
async with timeout(SERVICE_WAIT_TIMEOUT):
# shield the service call from cancellation on connection drop
await shield(
hass.services.async_call(
domain, service, data, blocking=True, context=context
)
# shield the service call from cancellation on connection drop
await shield(
hass.services.async_call(
domain, service, data, blocking=True, context=context
)
)
except (vol.Invalid, ServiceNotFound) as ex:
raise HTTPBadRequest() from ex
except TimeoutError:
pass
finally:
cancel_listen()
@@ -1024,39 +1024,38 @@ class PipelineRun:
)
)
try:
# Synthesize audio and get URL
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
engine=self.tts_engine,
language=self.pipeline.tts_language,
options=self.tts_options,
)
tts_media = await media_source.async_resolve_media(
self.hass,
tts_media_id,
None,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text-to-speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text-to-speech",
) from src_error
if tts_input := tts_input.strip():
try:
# Synthesize audio and get URL
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
engine=self.tts_engine,
language=self.pipeline.tts_language,
options=self.tts_options,
)
tts_media = await media_source.async_resolve_media(
self.hass,
tts_media_id,
None,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text-to-speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text-to-speech",
) from src_error
_LOGGER.debug("TTS result %s", tts_media)
_LOGGER.debug("TTS result %s", tts_media)
tts_output = {
"media_id": tts_media_id,
**asdict(tts_media),
}
else:
tts_output = {}
self.process_event(
PipelineEvent(
PipelineEventType.TTS_END,
{
"tts_output": {
"media_id": tts_media_id,
**asdict(tts_media),
}
},
)
PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output})
)
return tts_media.url
+4 -1
View File
@@ -2,10 +2,13 @@
"config": {
"step": {
"user": {
"title": "Connect to the device",
"description": "Connect to the device",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the Atag device."
}
}
},
+5 -1
View File
@@ -3,12 +3,16 @@
"flow_title": "{name} ({host})",
"step": {
"user": {
"title": "Set up Axis device",
"description": "Set up an Axis device",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the Axis device.",
"username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant."
}
}
},
-2
View File
@@ -93,8 +93,6 @@ class BAFFan(BAFEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if preset_mode != PRESET_MODE_AUTO:
raise ValueError(f"Invalid preset mode: {preset_mode}")
self._device.fan_mode = OffOnAuto.AUTO
async def async_set_direction(self, direction: str) -> None:
+4 -1
View File
@@ -2,9 +2,12 @@
"config": {
"step": {
"user": {
"title": "Connect to the Balboa Wi-Fi device",
"description": "Connect to the Balboa Wi-Fi device",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58."
}
}
},
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"requirements": ["bimmer-connected==0.14.3"]
"requirements": ["bimmer-connected[china]==0.14.5"]
}
-4
View File
@@ -199,10 +199,6 @@ class BondFan(BondEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if preset_mode != PRESET_MODE_BREEZE or not self._device.has_action(
Action.BREEZE_ON
):
raise ValueError(f"Invalid preset mode: {preset_mode}")
await self._hub.bond.action(self._device.device_id, Action(Action.BREEZE_ON))
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -12,6 +12,9 @@
"data": {
"host": "[%key:common::config_flow::data::host%]",
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"host": "The IP address of your Bond hub."
}
}
},
@@ -6,6 +6,9 @@
"title": "SHC authentication parameters",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Bosch Smart Home Controller."
}
},
"credentials": {
@@ -3,10 +3,13 @@
"flow_title": "{name} ({model} at {host})",
"step": {
"user": {
"title": "Connect to the device",
"description": "Connect to the device",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"timeout": "Timeout"
},
"data_description": {
"host": "The hostname or IP address of your Broadlink device."
}
},
"auth": {
+1 -2
View File
@@ -60,8 +60,7 @@ async def async_setup_entry(
data.static,
entry,
)
],
True,
]
)
@@ -11,6 +11,9 @@
"passkey": "Passkey string",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "The hostname or IP address of your BSB-Lan device."
}
}
},
+33 -13
View File
@@ -2,10 +2,10 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
from datetime import date, datetime, timedelta
from functools import partial
import logging
from typing import cast
from typing import Any, cast
import caldav
from caldav.lib.error import DAVError, NotFoundError
@@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .api import async_get_calendars, get_attr_value
from .const import DOMAIN
@@ -71,6 +72,12 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
or (summary := get_attr_value(todo, "summary")) is None
):
return None
due: date | datetime | None = None
if due_value := get_attr_value(todo, "due"):
if isinstance(due_value, datetime):
due = dt_util.as_local(due_value)
elif isinstance(due_value, date):
due = due_value
return TodoItem(
uid=uid,
summary=summary,
@@ -78,9 +85,28 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
get_attr_value(todo, "status") or "",
TodoItemStatus.NEEDS_ACTION,
),
due=due,
description=get_attr_value(todo, "description"),
)
def _to_ics_fields(item: TodoItem) -> dict[str, Any]:
"""Convert a TodoItem to the set of add or update arguments."""
item_data: dict[str, Any] = {}
if summary := item.summary:
item_data["summary"] = summary
if status := item.status:
item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION")
if due := item.due:
if isinstance(due, datetime):
item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ")
else:
item_data["due"] = due.strftime("%Y%m%d")
if description := item.description:
item_data["description"] = description
return item_data
class WebDavTodoListEntity(TodoListEntity):
"""CalDAV To-do list entity."""
@@ -89,6 +115,9 @@ class WebDavTodoListEntity(TodoListEntity):
TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
| TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
)
def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None:
@@ -116,13 +145,7 @@ class WebDavTodoListEntity(TodoListEntity):
"""Add an item to the To-do list."""
try:
await self.hass.async_add_executor_job(
partial(
self._calendar.save_todo,
summary=item.summary,
status=TODO_STATUS_MAP_INV.get(
item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION"
),
),
partial(self._calendar.save_todo, **_to_ics_fields(item)),
)
except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
@@ -139,10 +162,7 @@ class WebDavTodoListEntity(TodoListEntity):
except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
vtodo = todo.icalendar_component # type: ignore[attr-defined]
if item.summary:
vtodo["summary"] = item.summary
if item.status:
vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION")
vtodo.update(**_to_ics_fields(item))
try:
await self.hass.async_add_executor_job(
partial(
@@ -68,13 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator):
async def _async_update_data(self) -> dict[str, Any]:
"""Update device data."""
_LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host)
try:
await self.api.login()
return await self.api.get_all_devices()
except exceptions.CannotConnect as err:
_LOGGER.warning("Connection error for %s", self._host)
await self.api.close()
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
except exceptions.CannotAuthenticate:
raise ConfigEntryAuthFailed
return await self.api.get_all_devices()
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/comelit",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"requirements": ["aiocomelit==0.5.2"]
"requirements": ["aiocomelit==0.6.2"]
}
@@ -13,6 +13,9 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"pin": "[%key:common::config_flow::data::pin%]"
},
"data_description": {
"host": "The hostname or IP address of your Comelit device."
}
}
},
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.17"]
"requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.29"]
}
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"title": "Set up your CoolMasterNet connection details.",
"description": "Set up your CoolMasterNet connection details.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"off": "Can be turned off",
@@ -12,6 +12,9 @@
"dry": "Support dry mode",
"fan_only": "Support fan only mode",
"swing_support": "Control swing mode"
},
"data_description": {
"host": "The hostname or IP address of your CoolMasterNet device."
}
}
},
+3 -10
View File
@@ -161,12 +161,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity):
def set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if self.preset_modes and preset_mode in self.preset_modes:
self._preset_mode = preset_mode
self._percentage = None
self.schedule_update_ha_state()
else:
raise ValueError(f"Invalid preset mode: {preset_mode}")
self._preset_mode = preset_mode
self._percentage = None
self.schedule_update_ha_state()
def turn_on(
self,
@@ -230,10 +227,6 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if self.preset_modes is None or preset_mode not in self.preset_modes:
raise ValueError(
f"{preset_mode} is not a valid preset_mode: {self.preset_modes}"
)
self._preset_mode = preset_mode
self._percentage = None
self.async_write_ha_state()
@@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=5)
class DevialetCoordinator(DataUpdateCoordinator):
class DevialetCoordinator(DataUpdateCoordinator[None]):
"""Devialet update coordinator."""
def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None:
@@ -27,6 +27,6 @@ class DevialetCoordinator(DataUpdateCoordinator):
)
self.client = client
async def _async_update_data(self):
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.client.async_update()
@@ -46,13 +46,15 @@ async def async_setup_entry(
async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)])
class DevialetMediaPlayerEntity(CoordinatorEntity, MediaPlayerEntity):
class DevialetMediaPlayerEntity(
CoordinatorEntity[DevialetCoordinator], MediaPlayerEntity
):
"""Devialet media player."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, coordinator, entry: ConfigEntry) -> None:
def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None:
"""Initialize the Devialet device."""
self.coordinator = coordinator
super().__init__(coordinator)
+1
View File
@@ -29,6 +29,7 @@ DATA_TASK = "task"
DEVICE_NAME_ELECTRICITY = "Electricity Meter"
DEVICE_NAME_GAS = "Gas Meter"
DEVICE_NAME_WATER = "Water Meter"
DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"}
+156 -43
View File
@@ -34,6 +34,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import CoreState, Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -57,6 +58,7 @@ from .const import (
DEFAULT_TIME_BETWEEN_UPDATE,
DEVICE_NAME_ELECTRICITY,
DEVICE_NAME_GAS,
DEVICE_NAME_WATER,
DOMAIN,
DSMR_PROTOCOL,
LOGGER,
@@ -73,6 +75,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription):
dsmr_versions: set[str] | None = None
is_gas: bool = False
is_water: bool = False
obis_reference: str
@@ -374,28 +377,138 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
)
def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription:
"""Return correct entity for 5B Gas meter."""
ref = None
if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram:
ref = obis_references.BELGIUM_MBUS1_METER_READING2
elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram:
ref = obis_references.BELGIUM_MBUS2_METER_READING2
elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram:
ref = obis_references.BELGIUM_MBUS3_METER_READING2
elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram:
ref = obis_references.BELGIUM_MBUS4_METER_READING2
elif ref is None:
ref = obis_references.BELGIUM_MBUS1_METER_READING2
return DSMRSensorEntityDescription(
key="belgium_5min_gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference=ref,
dsmr_versions={"5B"},
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
)
def create_mbus_entity(
mbus: int, mtype: int, telegram: dict[str, DSMRObject]
) -> DSMRSensorEntityDescription | None:
"""Create a new MBUS Entity."""
if (
mtype == 3
and (
obis_reference := getattr(
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2"
)
)
in telegram
):
return DSMRSensorEntityDescription(
key=f"mbus{mbus}_gas_reading",
translation_key="gas_meter_reading",
obis_reference=obis_reference,
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
)
if (
mtype == 7
and (
obis_reference := getattr(
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1"
)
)
in telegram
):
return DSMRSensorEntityDescription(
key=f"mbus{mbus}_water_reading",
translation_key="water_meter_reading",
obis_reference=obis_reference,
is_water=True,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
)
return None
def device_class_and_uom(
telegram: dict[str, DSMRObject],
entity_description: DSMRSensorEntityDescription,
) -> tuple[SensorDeviceClass | None, str | None]:
"""Get native unit of measurement from telegram,."""
dsmr_object = telegram[entity_description.obis_reference]
uom: str | None = getattr(dsmr_object, "unit") or None
with suppress(ValueError):
if entity_description.device_class == SensorDeviceClass.GAS and (
enery_uom := UnitOfEnergy(str(uom))
):
return (SensorDeviceClass.ENERGY, enery_uom)
if uom in UNIT_CONVERSION:
return (entity_description.device_class, UNIT_CONVERSION[uom])
return (entity_description.device_class, uom)
def rename_old_gas_to_mbus(
hass: HomeAssistant, entry: ConfigEntry, mbus_device_id: str
) -> None:
"""Rename old gas sensor to mbus variant."""
dev_reg = dr.async_get(hass)
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
if device_entry_v1 is not None:
device_id = device_entry_v1.id
ent_reg = er.async_get(hass)
entries = er.async_entries_for_device(ent_reg, device_id)
for entity in entries:
if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
try:
ent_reg.async_update_entity(
entity.entity_id,
new_unique_id=mbus_device_id,
device_id=mbus_device_id,
)
except ValueError:
LOGGER.debug(
"Skip migration of %s because it already exists",
entity.entity_id,
)
else:
LOGGER.debug(
"Migrated entity %s from unique id %s to %s",
entity.entity_id,
entity.unique_id,
mbus_device_id,
)
# Cleanup old device
dev_entities = er.async_entries_for_device(
ent_reg, device_id, include_disabled_entities=True
)
if not dev_entities:
dev_reg.async_remove_device(device_id)
def create_mbus_entities(
hass: HomeAssistant, telegram: dict[str, DSMRObject], entry: ConfigEntry
) -> list[DSMREntity]:
"""Create MBUS Entities."""
entities = []
for idx in range(1, 5):
if (
device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE")
) not in telegram:
continue
if (type_ := int(telegram[device_type].value)) not in (3, 7):
continue
if (
identifier := getattr(
obis_references,
f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER",
)
) in telegram:
serial_ = telegram[identifier].value
rename_old_gas_to_mbus(hass, entry, serial_)
else:
serial_ = ""
if description := create_mbus_entity(idx, type_, telegram):
entities.append(
DSMREntity(
description,
entry,
telegram,
*device_class_and_uom(telegram, description), # type: ignore[arg-type]
serial_,
idx,
)
)
return entities
async def async_setup_entry(
@@ -415,25 +528,10 @@ async def async_setup_entry(
add_entities_handler()
add_entities_handler = None
def device_class_and_uom(
telegram: dict[str, DSMRObject],
entity_description: DSMRSensorEntityDescription,
) -> tuple[SensorDeviceClass | None, str | None]:
"""Get native unit of measurement from telegram,."""
dsmr_object = telegram[entity_description.obis_reference]
uom: str | None = getattr(dsmr_object, "unit") or None
with suppress(ValueError):
if entity_description.device_class == SensorDeviceClass.GAS and (
enery_uom := UnitOfEnergy(str(uom))
):
return (SensorDeviceClass.ENERGY, enery_uom)
if uom in UNIT_CONVERSION:
return (entity_description.device_class, UNIT_CONVERSION[uom])
return (entity_description.device_class, uom)
all_sensors = SENSORS
if dsmr_version == "5B":
all_sensors += (add_gas_sensor_5B(telegram),)
mbus_entities = create_mbus_entities(hass, telegram, entry)
for mbus_entity in mbus_entities:
entities.append(mbus_entity)
entities.extend(
[
@@ -443,7 +541,7 @@ async def async_setup_entry(
telegram,
*device_class_and_uom(telegram, description), # type: ignore[arg-type]
)
for description in all_sensors
for description in SENSORS
if (
description.dsmr_versions is None
or dsmr_version in description.dsmr_versions
@@ -618,6 +716,8 @@ class DSMREntity(SensorEntity):
telegram: dict[str, DSMRObject],
device_class: SensorDeviceClass,
native_unit_of_measurement: str | None,
serial_id: str = "",
mbus_id: int = 0,
) -> None:
"""Initialize entity."""
self.entity_description = entity_description
@@ -629,8 +729,15 @@ class DSMREntity(SensorEntity):
device_serial = entry.data[CONF_SERIAL_ID]
device_name = DEVICE_NAME_ELECTRICITY
if entity_description.is_gas:
device_serial = entry.data[CONF_SERIAL_ID_GAS]
if serial_id:
device_serial = serial_id
else:
device_serial = entry.data[CONF_SERIAL_ID_GAS]
device_name = DEVICE_NAME_GAS
if entity_description.is_water:
if serial_id:
device_serial = serial_id
device_name = DEVICE_NAME_WATER
if device_serial is None:
device_serial = entry.entry_id
@@ -638,7 +745,13 @@ class DSMREntity(SensorEntity):
identifiers={(DOMAIN, device_serial)},
name=device_name,
)
self._attr_unique_id = f"{device_serial}_{entity_description.key}"
if mbus_id != 0:
if serial_id:
self._attr_unique_id = f"{device_serial}"
else:
self._attr_unique_id = f"{device_serial}_{mbus_id}"
else:
self._attr_unique_id = f"{device_serial}_{entity_description.key}"
@callback
def update_data(self, telegram: dict[str, DSMRObject] | None) -> None:
@@ -147,6 +147,9 @@
},
"voltage_swell_l3_count": {
"name": "Voltage swells phase L3"
},
"water_meter_reading": {
"name": "Water consumption"
}
}
},
@@ -6,7 +6,6 @@ import logging
from aiohttp import web
import voluptuous as vol
from homeassistant.components.http import HomeAssistantAccessLogger
from homeassistant.components.network import async_get_source_ip
from homeassistant.const import (
CONF_ENTITIES,
@@ -101,7 +100,7 @@ async def start_emulated_hue_bridge(
config.advertise_port or config.listen_port,
)
runner = web.AppRunner(app, access_log_class=HomeAssistantAccessLogger)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, config.host_ip_addr, config.listen_port)
@@ -173,8 +173,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
features |= ClimateEntityFeature.TARGET_TEMPERATURE
if self._static_info.supports_target_humidity:
features |= ClimateEntityFeature.TARGET_HUMIDITY
if self._static_info.supports_aux_heat:
features |= ClimateEntityFeature.AUX_HEAT
if self.preset_modes:
features |= ClimateEntityFeature.PRESET_MODE
if self.fan_modes:
@@ -272,12 +270,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
"""Return the humidity we try to reach."""
return round(self._state.target_humidity)
@property
@esphome_state_property
def is_aux_heat(self) -> bool:
"""Return the auxiliary heater state."""
return self._state.aux_heat
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature (and operation mode if set)."""
data: dict[str, Any] = {"key": self._key}
@@ -326,11 +318,3 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
await self._client.climate_command(
key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode)
)
async def async_turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
await self._client.climate_command(key=self._key, aux_heat=True)
async def async_turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
await self._client.climate_command(key=self._key, aux_heat=False)
@@ -15,7 +15,7 @@
"iot_class": "local_push",
"loggers": ["aioesphomeapi", "noiseprotocol"],
"requirements": [
"aioesphomeapi==19.2.0",
"aioesphomeapi==19.2.1",
"bluetooth-data-tools==1.15.0",
"esphome-dashboard-api==1.2.3"
],
@@ -186,16 +186,22 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
data_to_send = {"text": event.data["tts_input"]}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
assert event.data is not None
path = event.data["tts_output"]["url"]
url = async_process_play_media_url(self.hass, path)
data_to_send = {"url": url}
tts_output = event.data["tts_output"]
if tts_output:
path = tts_output["url"]
url = async_process_play_media_url(self.hass, path)
data_to_send = {"url": url}
if self.device_info.voice_assistant_version >= 2:
media_id = event.data["tts_output"]["media_id"]
self._tts_task = self.hass.async_create_background_task(
self._send_tts(media_id), "esphome_voice_assistant_tts"
)
if self.device_info.voice_assistant_version >= 2:
media_id = tts_output["media_id"]
self._tts_task = self.hass.async_create_background_task(
self._send_tts(media_id), "esphome_voice_assistant_tts"
)
else:
self._tts_done.set()
else:
# Empty TTS response
data_to_send = {}
self._tts_done.set()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END:
assert event.data is not None
@@ -1,44 +1,88 @@
"""Platform for FAA Delays sensor component."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
from faadelays import Airport
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FAADataUpdateCoordinator
from .const import DOMAIN
FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(
@dataclass(kw_only=True)
class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Mixin for required keys."""
is_on_fn: Callable[[Airport], bool | None]
extra_state_attributes_fn: Callable[[Airport], Mapping[str, Any]]
FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = (
FaaDelaysBinarySensorEntityDescription(
key="GROUND_DELAY",
name="Ground Delay",
translation_key="ground_delay",
icon="mdi:airport",
is_on_fn=lambda airport: airport.ground_delay.status,
extra_state_attributes_fn=lambda airport: {
"average": airport.ground_delay.average,
"reason": airport.ground_delay.reason,
},
),
BinarySensorEntityDescription(
FaaDelaysBinarySensorEntityDescription(
key="GROUND_STOP",
name="Ground Stop",
translation_key="ground_stop",
icon="mdi:airport",
is_on_fn=lambda airport: airport.ground_stop.status,
extra_state_attributes_fn=lambda airport: {
"endtime": airport.ground_stop.endtime,
"reason": airport.ground_stop.reason,
},
),
BinarySensorEntityDescription(
FaaDelaysBinarySensorEntityDescription(
key="DEPART_DELAY",
name="Departure Delay",
translation_key="depart_delay",
icon="mdi:airplane-takeoff",
is_on_fn=lambda airport: airport.depart_delay.status,
extra_state_attributes_fn=lambda airport: {
"minimum": airport.depart_delay.minimum,
"maximum": airport.depart_delay.maximum,
"trend": airport.depart_delay.trend,
"reason": airport.depart_delay.reason,
},
),
BinarySensorEntityDescription(
FaaDelaysBinarySensorEntityDescription(
key="ARRIVE_DELAY",
name="Arrival Delay",
translation_key="arrive_delay",
icon="mdi:airplane-landing",
is_on_fn=lambda airport: airport.arrive_delay.status,
extra_state_attributes_fn=lambda airport: {
"minimum": airport.arrive_delay.minimum,
"maximum": airport.arrive_delay.maximum,
"trend": airport.arrive_delay.trend,
"reason": airport.arrive_delay.reason,
},
),
BinarySensorEntityDescription(
FaaDelaysBinarySensorEntityDescription(
key="CLOSURE",
name="Closure",
translation_key="closure",
icon="mdi:airplane:off",
is_on_fn=lambda airport: airport.closure.status,
extra_state_attributes_fn=lambda airport: {
"begin": airport.closure.start,
"end": airport.closure.end,
},
),
)
@@ -57,60 +101,38 @@ async def async_setup_entry(
async_add_entities(entities)
class FAABinarySensor(CoordinatorEntity, BinarySensorEntity):
class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorEntity):
"""Define a binary sensor for FAA Delays."""
_attr_has_entity_name = True
entity_description: FaaDelaysBinarySensorEntityDescription
def __init__(
self, coordinator, entry_id, description: BinarySensorEntityDescription
self,
coordinator: FAADataUpdateCoordinator,
entry_id: str,
description: FaaDelaysBinarySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self.coordinator = coordinator
self._entry_id = entry_id
self._attrs: dict[str, Any] = {}
_id = coordinator.data.code
self._attr_name = f"{_id} {description.name}"
self._attr_unique_id = f"{_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, _id)},
name=_id,
manufacturer="Federal Aviation Administration",
entry_type=DeviceEntryType.SERVICE,
)
@property
def is_on(self):
def is_on(self) -> bool | None:
"""Return the status of the sensor."""
sensor_type = self.entity_description.key
if sensor_type == "GROUND_DELAY":
return self.coordinator.data.ground_delay.status
if sensor_type == "GROUND_STOP":
return self.coordinator.data.ground_stop.status
if sensor_type == "DEPART_DELAY":
return self.coordinator.data.depart_delay.status
if sensor_type == "ARRIVE_DELAY":
return self.coordinator.data.arrive_delay.status
if sensor_type == "CLOSURE":
return self.coordinator.data.closure.status
return None
return self.entity_description.is_on_fn(self.coordinator.data)
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return attributes for sensor."""
sensor_type = self.entity_description.key
if sensor_type == "GROUND_DELAY":
self._attrs["average"] = self.coordinator.data.ground_delay.average
self._attrs["reason"] = self.coordinator.data.ground_delay.reason
elif sensor_type == "GROUND_STOP":
self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime
self._attrs["reason"] = self.coordinator.data.ground_stop.reason
elif sensor_type == "DEPART_DELAY":
self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum
self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum
self._attrs["trend"] = self.coordinator.data.depart_delay.trend
self._attrs["reason"] = self.coordinator.data.depart_delay.reason
elif sensor_type == "ARRIVE_DELAY":
self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum
self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum
self._attrs["trend"] = self.coordinator.data.arrive_delay.trend
self._attrs["reason"] = self.coordinator.data.arrive_delay.reason
elif sensor_type == "CLOSURE":
self._attrs["begin"] = self.coordinator.data.closure.start
self._attrs["end"] = self.coordinator.data.closure.end
return self._attrs
return self.entity_description.extra_state_attributes_fn(self.coordinator.data)
@@ -1,5 +1,6 @@
"""Config flow for FAA Delays integration."""
import logging
from typing import Any
from aiohttp import ClientConnectionError
import faadelays
@@ -7,6 +8,7 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_ID
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
@@ -21,7 +23,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(self, user_input=None):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
@@ -6,6 +6,7 @@ import logging
from aiohttp import ClientConnectionError
from faadelays import Airport
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -14,19 +15,18 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class FAADataUpdateCoordinator(DataUpdateCoordinator):
class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]):
"""Class to manage fetching FAA API data from a single endpoint."""
def __init__(self, hass, code):
def __init__(self, hass: HomeAssistant, code: str) -> None:
"""Initialize the coordinator."""
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1)
)
self.session = aiohttp_client.async_get_clientsession(hass)
self.data = Airport(code, self.session)
self.code = code
async def _async_update_data(self):
async def _async_update_data(self) -> Airport:
try:
async with asyncio.timeout(10):
await self.data.update()
@@ -17,5 +17,76 @@
"abort": {
"already_configured": "This airport is already configured."
}
},
"entity": {
"binary_sensor": {
"ground_delay": {
"name": "Ground delay",
"state_attributes": {
"average": {
"name": "Average"
},
"reason": {
"name": "Reason"
}
}
},
"ground_stop": {
"name": "Ground stop",
"state_attributes": {
"endtime": {
"name": "End time"
},
"reason": {
"name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]"
}
}
},
"depart_delay": {
"name": "Departure delay",
"state_attributes": {
"minimum": {
"name": "Minimum"
},
"maximum": {
"name": "Maximum"
},
"trend": {
"name": "Trend"
},
"reason": {
"name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]"
}
}
},
"arrive_delay": {
"name": "Arrival delay",
"state_attributes": {
"minimum": {
"name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::minimum::name%]"
},
"maximum": {
"name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::maximum::name%]"
},
"trend": {
"name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::trend::name%]"
},
"reason": {
"name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]"
}
}
},
"closure": {
"name": "Closure",
"state_attributes": {
"begin": {
"name": "Begin"
},
"end": {
"name": "End"
}
}
}
}
}
}
+43 -6
View File
@@ -18,7 +18,8 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
@@ -77,8 +78,19 @@ ATTR_PRESET_MODES = "preset_modes"
# mypy: disallow-any-generics
class NotValidPresetModeError(ValueError):
"""Exception class when the preset_mode in not in the preset_modes list."""
class NotValidPresetModeError(ServiceValidationError):
"""Raised when the preset_mode is not in the preset_modes list."""
def __init__(
self, *args: object, translation_placeholders: dict[str, str] | None = None
) -> None:
"""Initialize the exception."""
super().__init__(
*args,
translation_domain=DOMAIN,
translation_key="not_valid_preset_mode",
translation_placeholders=translation_placeholders,
)
@bind_hass
@@ -107,7 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
),
vol.Optional(ATTR_PRESET_MODE): cv.string,
},
"async_turn_on",
"async_handle_turn_on_service",
)
component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off")
component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle")
@@ -156,7 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_SET_PRESET_MODE,
{vol.Required(ATTR_PRESET_MODE): cv.string},
"async_set_preset_mode",
"async_handle_set_preset_mode_service",
[FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE],
)
@@ -237,17 +249,30 @@ class FanEntity(ToggleEntity):
"""Set new preset mode."""
raise NotImplementedError()
@final
async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None:
"""Validate and set new preset mode."""
self._valid_preset_mode_or_raise(preset_mode)
await self.async_set_preset_mode(preset_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode)
@final
@callback
def _valid_preset_mode_or_raise(self, preset_mode: str) -> None:
"""Raise NotValidPresetModeError on invalid preset_mode."""
preset_modes = self.preset_modes
if not preset_modes or preset_mode not in preset_modes:
preset_modes_str: str = ", ".join(preset_modes or [])
raise NotValidPresetModeError(
f"The preset_mode {preset_mode} is not a valid preset_mode:"
f" {preset_modes}"
f" {preset_modes}",
translation_placeholders={
"preset_mode": preset_mode,
"preset_modes": preset_modes_str,
},
)
def set_direction(self, direction: str) -> None:
@@ -267,6 +292,18 @@ class FanEntity(ToggleEntity):
"""Turn on the fan."""
raise NotImplementedError()
@final
async def async_handle_turn_on_service(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Validate and turn on the fan."""
if preset_mode is not None:
self._valid_preset_mode_or_raise(preset_mode)
await self.async_turn_on(percentage, preset_mode, **kwargs)
async def async_turn_on(
self,
percentage: int | None = None,
@@ -144,5 +144,10 @@
"reverse": "Reverse"
}
}
},
"exceptions": {
"not_valid_preset_mode": {
"message": "Preset mode {preset_mode} is not valid, valid preset modes are: {preset_modes}."
}
}
}
+3 -5
View File
@@ -131,11 +131,9 @@ class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if command := PRESET_TO_COMMAND.get(preset_mode):
async with self.coordinator.async_connect_and_update() as device:
await device.send_command(command)
else:
raise UnsupportedPreset(f"The preset {preset_mode} is unsupported")
command = PRESET_TO_COMMAND[preset_mode]
async with self.coordinator.async_connect_and_update() as device:
await device.send_command(command)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20231030.2"]
"requirements": ["home-assistant-frontend==20231129.1"]
}
+9 -1
View File
@@ -7,6 +7,7 @@ import logging
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
@@ -44,12 +45,14 @@ class GdacsSensor(SensorEntity):
_attr_should_poll = False
_attr_icon = DEFAULT_ICON
_attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT
_attr_has_entity_name = True
_attr_name = None
def __init__(self, config_entry: ConfigEntry, manager) -> None:
"""Initialize entity."""
assert config_entry.unique_id
self._config_entry_id = config_entry.entry_id
self._attr_unique_id = config_entry.unique_id
self._attr_name = f"GDACS ({config_entry.title})"
self._manager = manager
self._status = None
self._last_update = None
@@ -60,6 +63,11 @@ class GdacsSensor(SensorEntity):
self._updated = None
self._removed = None
self._remove_signal_status: Callable[[], None] | None = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.unique_id)},
entry_type=DeviceEntryType.SERVICE,
manufacturer="GDACS",
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
+27 -12
View File
@@ -1,7 +1,7 @@
"""Google Tasks todo platform."""
from __future__ import annotations
from datetime import timedelta
from datetime import date, datetime, timedelta
from typing import Any, cast
from homeassistant.components.todo import (
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .api import AsyncConfigEntryAuth
from .const import DOMAIN
@@ -35,9 +36,31 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str]:
result["title"] = item.summary
if item.status is not None:
result["status"] = TODO_STATUS_MAP_INV[item.status]
if (due := item.due) is not None:
# due API field is a timestamp string, but with only date resolution
result["due"] = dt_util.start_of_local_day(due).isoformat()
if (description := item.description) is not None:
result["notes"] = description
return result
def _convert_api_item(item: dict[str, str]) -> TodoItem:
"""Convert tasks API items into a TodoItem."""
due: date | None = None
if (due_str := item.get("due")) is not None:
due = datetime.fromisoformat(due_str).date()
return TodoItem(
summary=item["title"],
uid=item["id"],
status=TODO_STATUS_MAP.get(
item.get("status", ""),
TodoItemStatus.NEEDS_ACTION,
),
due=due,
description=item.get("notes"),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
@@ -68,6 +91,8 @@ class GoogleTaskTodoListEntity(
TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
)
def __init__(
@@ -88,17 +113,7 @@ class GoogleTaskTodoListEntity(
"""Get the current set of To-do items."""
if self.coordinator.data is None:
return None
return [
TodoItem(
summary=item["title"],
uid=item["id"],
status=TODO_STATUS_MAP.get(
item.get("status"), # type: ignore[arg-type]
TodoItemStatus.NEEDS_ACTION,
),
)
for item in _order_tasks(self.coordinator.data)
]
return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)]
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the To-do list."""
+4 -11
View File
@@ -11,7 +11,6 @@ from homeassistant.helpers.event import async_track_time_interval
from .bridge import DiscoveryService
from .const import (
COORDINATORS,
DATA_DISCOVERY_INTERVAL,
DATA_DISCOVERY_SERVICE,
DISCOVERY_SCAN_INTERVAL,
DISPATCHERS,
@@ -29,7 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
gree_discovery = DiscoveryService(hass)
hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery
hass.data[DOMAIN].setdefault(DISPATCHERS, [])
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def _async_scan_update(_=None):
@@ -39,8 +37,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Scanning network for Gree devices")
await _async_scan_update()
hass.data[DOMAIN][DATA_DISCOVERY_INTERVAL] = async_track_time_interval(
hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL)
entry.async_on_unload(
async_track_time_interval(
hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL)
)
)
return True
@@ -48,13 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if hass.data[DOMAIN].get(DISPATCHERS) is not None:
for cleanup in hass.data[DOMAIN][DISPATCHERS]:
cleanup()
if hass.data[DOMAIN].get(DATA_DISCOVERY_INTERVAL) is not None:
hass.data[DOMAIN].pop(DATA_DISCOVERY_INTERVAL)()
if hass.data.get(DATA_DISCOVERY_SERVICE) is not None:
hass.data.pop(DATA_DISCOVERY_SERVICE)
+2 -3
View File
@@ -47,7 +47,6 @@ from .bridge import DeviceDataUpdateCoordinator
from .const import (
COORDINATORS,
DISPATCH_DEVICE_DISCOVERED,
DISPATCHERS,
DOMAIN,
FAN_MEDIUM_HIGH,
FAN_MEDIUM_LOW,
@@ -88,7 +87,7 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Gree HVAC device from a config entry."""
@@ -101,7 +100,7 @@ async def async_setup_entry(
for coordinator in hass.data[DOMAIN][COORDINATORS]:
init_device(coordinator)
hass.data[DOMAIN][DISPATCHERS].append(
entry.async_on_unload(
async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device)
)
-1
View File
@@ -3,7 +3,6 @@
COORDINATORS = "coordinators"
DATA_DISCOVERY_SERVICE = "gree_discovery"
DATA_DISCOVERY_INTERVAL = "gree_discovery_interval"
DISCOVERY_SCAN_INTERVAL = 300
DISCOVERY_TIMEOUT = 8
+3 -3
View File
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN
from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN
from .entity import GreeEntity
@@ -102,7 +102,7 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Gree HVAC device from a config entry."""
@@ -119,7 +119,7 @@ async def async_setup_entry(
for coordinator in hass.data[DOMAIN][COORDINATORS]:
init_device(coordinator)
hass.data[DOMAIN][DISPATCHERS].append(
entry.async_on_unload(
async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device)
)
+4
View File
@@ -6,6 +6,7 @@ from http import HTTPStatus
import logging
import os
import re
from typing import TYPE_CHECKING
from urllib.parse import quote, unquote
import aiohttp
@@ -156,6 +157,9 @@ class HassIOView(HomeAssistantView):
# _stored_content_type is only computed once `content_type` is accessed
if path == "backups/new/upload":
# We need to reuse the full content type that includes the boundary
if TYPE_CHECKING:
# pylint: disable-next=protected-access
assert isinstance(request._stored_content_type, str)
# pylint: disable-next=protected-access
headers[CONTENT_TYPE] = request._stored_content_type
+3 -3
View File
@@ -17,7 +17,6 @@ from yarl import URL
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_compat import enable_compression
from homeassistant.helpers.typing import UNDEFINED
from .const import X_HASS_SOURCE, X_INGRESS_PATH
@@ -172,7 +171,7 @@ class HassIOIngress(HomeAssistantView):
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
# Avoid parsing content_type in simple cases for better performance
if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE):
content_type = (maybe_content_type.partition(";"))[0].strip()
content_type: str = (maybe_content_type.partition(";"))[0].strip()
else:
content_type = result.content_type
# Simple request
@@ -188,11 +187,12 @@ class HassIOIngress(HomeAssistantView):
status=result.status,
content_type=content_type,
body=body,
zlib_executor_size=32768,
)
if content_length_int > MIN_COMPRESSED_SIZE and should_compress(
content_type or simple_response.content_type
):
enable_compression(simple_response)
simple_response.enable_compression()
await simple_response.prepare(request)
return simple_response
+3 -2
View File
@@ -21,7 +21,7 @@ import homeassistant.util.dt as dt_util
from . import websocket_api
from .const import DOMAIN
from .helpers import entities_may_have_state_changes_after
from .helpers import entities_may_have_state_changes_after, has_recorder_run_after
CONF_ORDER = "use_include_order"
@@ -106,7 +106,8 @@ class HistoryPeriodView(HomeAssistantView):
no_attributes = "no_attributes" in request.query
if (
not include_start_time_state
(end_time and not has_recorder_run_after(hass, end_time))
or not include_start_time_state
and entity_ids
and not entities_may_have_state_changes_after(
hass, entity_ids, start_time, no_attributes
@@ -4,6 +4,8 @@ from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime as dt
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import process_timestamp
from homeassistant.core import HomeAssistant
@@ -21,3 +23,10 @@ def entities_may_have_state_changes_after(
return True
return False
def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool:
"""Check if the recorder has any runs after a specific time."""
return run_time >= process_timestamp(
get_instance(hass).recorder_runs_manager.first.start
)
@@ -39,7 +39,7 @@ from homeassistant.helpers.typing import EventType
import homeassistant.util.dt as dt_util
from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES
from .helpers import entities_may_have_state_changes_after
from .helpers import entities_may_have_state_changes_after, has_recorder_run_after
_LOGGER = logging.getLogger(__name__)
@@ -142,7 +142,8 @@ async def ws_get_history_during_period(
no_attributes = msg["no_attributes"]
if (
not include_start_time_state
(end_time and not has_recorder_run_after(hass, end_time))
or not include_start_time_state
and entity_ids
and not entities_may_have_state_changes_after(
hass, entity_ids, start_time, no_attributes
+1 -23
View File
@@ -16,7 +16,6 @@ from aiohttp.http_parser import RawRequestMessage
from aiohttp.streams import StreamReader
from aiohttp.typedefs import JSONDecoder, StrOrURL
from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection
from aiohttp.web_log import AccessLogger
from aiohttp.web_protocol import RequestHandler
from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher
from aiohttp_zlib_ng import enable_zlib_ng
@@ -238,25 +237,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class HomeAssistantAccessLogger(AccessLogger):
"""Access logger for Home Assistant that does not log when disabled."""
def log(
self, request: web.BaseRequest, response: web.StreamResponse, time: float
) -> None:
"""Log the request.
The default implementation logs the request to the logger
with the INFO level and than throws it away if the logger
is not enabled for the INFO level. This implementation
does not log the request if the logger is not enabled for
the INFO level.
"""
if not self.logger.isEnabledFor(logging.INFO):
return
super().log(request, response, time)
class HomeAssistantRequest(web.Request):
"""Home Assistant request object."""
@@ -540,9 +520,7 @@ class HomeAssistantHTTP:
# pylint: disable-next=protected-access
self.app._router.freeze = lambda: None # type: ignore[method-assign]
self.runner = web.AppRunner(
self.app, access_log_class=HomeAssistantAccessLogger
)
self.runner = web.AppRunner(self.app, handler_cancellation=True)
await self.runner.setup()
self.site = HomeAssistantTCPSite(
+2 -2
View File
@@ -20,7 +20,6 @@ import voluptuous as vol
from homeassistant import exceptions
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, is_callback
from homeassistant.helpers.aiohttp_compat import enable_compression
from homeassistant.helpers.json import (
find_paths_unserializable_data,
json_bytes,
@@ -72,8 +71,9 @@ class HomeAssistantView:
content_type=CONTENT_TYPE_JSON,
status=int(status_code),
headers=headers,
zlib_executor_size=32768,
)
enable_compression(response)
response.enable_compression()
return response
def json_message(
+1 -19
View File
@@ -8,8 +8,6 @@ from datetime import datetime, timedelta
import logging
import re
from huawei_lte_api.enums.net import NetworkModeEnum
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
@@ -575,10 +573,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
"State": HuaweiSensorEntityDescription(
key="State",
translation_key="operator_search_mode",
format_fn=lambda x: (
{"0": "Auto", "1": "Manual"}.get(x),
None,
),
entity_category=EntityCategory.DIAGNOSTIC,
),
},
@@ -588,19 +582,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
descriptions={
"NetworkMode": HuaweiSensorEntityDescription(
key="NetworkMode",
translation_key="preferred_mode",
format_fn=lambda x: (
{
NetworkModeEnum.MODE_AUTO.value: "4G/3G/2G",
NetworkModeEnum.MODE_4G_3G_AUTO.value: "4G/3G",
NetworkModeEnum.MODE_4G_2G_AUTO.value: "4G/2G",
NetworkModeEnum.MODE_4G_ONLY.value: "4G",
NetworkModeEnum.MODE_3G_2G_AUTO.value: "3G/2G",
NetworkModeEnum.MODE_3G_ONLY.value: "3G",
NetworkModeEnum.MODE_2G_ONLY.value: "2G",
}.get(x),
None,
),
translation_key="preferred_network_mode",
entity_category=EntityCategory.DIAGNOSTIC,
),
},
@@ -231,10 +231,23 @@
"name": "Operator code"
},
"operator_search_mode": {
"name": "Operator search mode"
"name": "Operator search mode",
"state": {
"0": "Auto",
"1": "Manual"
}
},
"preferred_mode": {
"name": "Preferred mode"
"preferred_network_mode": {
"name": "Preferred network mode",
"state": {
"00": "4G/3G/2G auto",
"0302": "4G/3G auto",
"0301": "4G/2G auto",
"03": "4G only",
"0201": "3G/2G auto",
"02": "3G only",
"01": "2G only"
}
},
"sms_deleted_device": {
"name": "SMS deleted (device)"
@@ -3,13 +3,18 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
import logging
from typing import Any, TypeVar, cast
from aiohttp.client_exceptions import ClientError
from pykoplenti import ApiClient, ApiException, AuthenticationException
from pykoplenti import (
ApiClient,
ApiException,
AuthenticationException,
ExtendedApiClient,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
@@ -51,7 +56,9 @@ class Plenticore:
async def async_setup(self) -> bool:
"""Set up Plenticore API client."""
self._client = ApiClient(async_get_clientsession(self.hass), host=self.host)
self._client = ExtendedApiClient(
async_get_clientsession(self.hass), host=self.host
)
try:
await self._client.login(self.config_entry.data[CONF_PASSWORD])
except AuthenticationException as err:
@@ -124,7 +131,7 @@ class DataUpdateCoordinatorMixin:
async def async_read_data(
self, module_id: str, data_id: str
) -> dict[str, dict[str, str]] | None:
) -> Mapping[str, Mapping[str, str]] | None:
"""Read data from Plenticore."""
if (client := self._plenticore.client) is None:
return None
@@ -190,7 +197,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]):
class ProcessDataUpdateCoordinator(
PlenticoreUpdateCoordinator[dict[str, dict[str, str]]]
PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]]
):
"""Implementation of PlenticoreUpdateCoordinator for process data."""
@@ -206,18 +213,19 @@ class ProcessDataUpdateCoordinator(
return {
module_id: {
process_data.id: process_data.value
for process_data in fetched_data[module_id]
for process_data in fetched_data[module_id].values()
}
for module_id in fetched_data
}
class SettingDataUpdateCoordinator(
PlenticoreUpdateCoordinator[dict[str, dict[str, str]]], DataUpdateCoordinatorMixin
PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]],
DataUpdateCoordinatorMixin,
):
"""Implementation of PlenticoreUpdateCoordinator for settings data."""
async def _async_update_data(self) -> dict[str, dict[str, str]]:
async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]:
client = self._plenticore.client
if not self._fetch or client is None:
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/kostal_plenticore",
"iot_class": "local_polling",
"loggers": ["kostal"],
"requirements": ["pykoplenti==1.0.0"]
"requirements": ["pykoplenti==1.2.2"]
}
@@ -649,6 +649,39 @@ SENSOR_PROCESS_DATA = [
state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="scb:statistic:EnergyFlow",
key="Statistic:EnergyDischarge:Day",
name="Battery Discharge Day",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="scb:statistic:EnergyFlow",
key="Statistic:EnergyDischarge:Month",
name="Battery Discharge Month",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="scb:statistic:EnergyFlow",
key="Statistic:EnergyDischarge:Year",
name="Battery Discharge Year",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="scb:statistic:EnergyFlow",
key="Statistic:EnergyDischarge:Total",
name="Battery Discharge Total",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="scb:statistic:EnergyFlow",
key="Statistic:EnergyDischargeGrid:Day",
@@ -682,6 +715,52 @@ SENSOR_PROCESS_DATA = [
state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="_virt_",
key="pv_P",
name="Sum power of all PV DC inputs",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=True,
state_class=SensorStateClass.MEASUREMENT,
formatter="format_round",
),
PlenticoreSensorEntityDescription(
module_id="_virt_",
key="Statistic:EnergyGrid:Total",
name="Energy to Grid Total",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="_virt_",
key="Statistic:EnergyGrid:Year",
name="Energy to Grid Year",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="_virt_",
key="Statistic:EnergyGrid:Month",
name="Energy to Grid Month",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy",
),
PlenticoreSensorEntityDescription(
module_id="_virt_",
key="Statistic:EnergyGrid:Day",
name="Energy to Grid Day",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy",
),
]
+7 -8
View File
@@ -252,8 +252,9 @@ turn_on:
- light.ColorMode.RGBWW
selector:
color_temp:
min_mireds: 153
max_mireds: 500
unit: "mired"
min: 153
max: 500
kelvin:
filter:
attribute:
@@ -266,11 +267,10 @@ turn_on:
- light.ColorMode.RGBWW
advanced: true
selector:
number:
color_temp:
unit: "kelvin"
min: 2000
max: 6500
step: 100
unit_of_measurement: K
brightness:
filter:
attribute:
@@ -637,11 +637,10 @@ toggle:
- light.ColorMode.RGBWW
advanced: true
selector:
number:
color_temp:
unit: "kelvin"
min: 2000
max: 6500
step: 100
unit_of_measurement: K
brightness:
filter:
attribute:
+5 -1
View File
@@ -380,7 +380,11 @@ class MqttCover(MqttEntity, CoverEntity):
else STATE_OPEN
)
else:
state = STATE_CLOSED if self.state == STATE_CLOSING else STATE_OPEN
state = (
STATE_CLOSED
if self.state in [STATE_CLOSED, STATE_CLOSING]
else STATE_OPEN
)
elif payload == self._config[CONF_STATE_OPENING]:
state = STATE_OPENING
elif payload == self._config[CONF_STATE_CLOSING]:
-2
View File
@@ -553,8 +553,6 @@ class MqttFan(MqttEntity, FanEntity):
This method is a coroutine.
"""
self._valid_preset_mode_or_raise(preset_mode)
mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode)
await self.async_publish(
@@ -367,13 +367,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
if brightness_supported(self.supported_color_modes):
try:
if brightness := values["brightness"]:
scale = self._config[CONF_BRIGHTNESS_SCALE]
self._attr_brightness = min(
int(
brightness # type: ignore[operator]
/ float(self._config[CONF_BRIGHTNESS_SCALE])
* 255
),
255,
round(brightness * 255 / scale), # type: ignore[operator]
)
else:
_LOGGER.debug(
+1 -1
View File
@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["metar", "pynws"],
"quality_scale": "platinum",
"requirements": ["pynws==1.5.1"]
"requirements": ["pynws==1.6.0"]
}
@@ -66,6 +66,8 @@ async def async_setup_entry(
def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool:
"""Return True if at least one specified value is available within entry of list."""
if api.recordings_list is None:
return False
for rec in api.recordings_list["recordings"]:
if rec.get(entry) == value:
return True
+3 -4
View File
@@ -12,6 +12,7 @@ from homeassistant.components.todo import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -31,7 +32,7 @@ async def async_setup_entry(
"""Set up the Picnic shopping cart todo platform config entry."""
picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR]
async_add_entities([PicnicCart(hass, picnic_coordinator, config_entry)])
async_add_entities([PicnicCart(picnic_coordinator, config_entry)])
class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]):
@@ -44,7 +45,6 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]):
def __init__(
self,
hass: HomeAssistant,
coordinator: PicnicUpdateCoordinator,
config_entry: ConfigEntry,
) -> None:
@@ -56,7 +56,6 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]):
manufacturer="Picnic",
model=config_entry.unique_id,
)
self.hass = hass
self._attr_unique_id = f"{config_entry.unique_id}-cart"
@property
@@ -87,7 +86,7 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]):
)
if not product_id:
raise ValueError("No product found or no product ID given")
raise ServiceValidationError("No product found or no product ID given")
await self.hass.async_add_executor_job(
self.coordinator.picnic_api_client.add_product, product_id, 1
+2 -1
View File
@@ -1,4 +1,5 @@
"""Pushbullet Notification provider."""
from __future__ import annotations
from typing import Any
@@ -10,7 +11,7 @@ from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DATA_UPDATED
class PushBulletNotificationProvider(Listener):
class PushBulletNotificationProvider(Listener): # type: ignore[misc]
"""Provider for an account, leading to one or more sensors."""
def __init__(self, hass: HomeAssistant, pushbullet: PushBullet) -> None:
@@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .api import PushBulletNotificationProvider
from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -34,8 +35,10 @@ async def async_get_service(
"""Get the Pushbullet notification service."""
if TYPE_CHECKING:
assert discovery_info is not None
pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet
return PushBulletNotificationService(hass, pushbullet)
pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][
discovery_info["entry_id"]
]
return PushBulletNotificationService(hass, pb_provider.pushbullet)
class PushBulletNotificationService(BaseNotificationService):
@@ -120,7 +123,7 @@ class PushBulletNotificationService(BaseNotificationService):
pusher: PushBullet,
email: str | None = None,
phonenumber: str | None = None,
):
) -> None:
"""Create the message content."""
kwargs = {"body": message, "title": title}
if email:
@@ -183,7 +183,7 @@ def _async_fix_device_id(
device_entry_map = {}
migrations = {}
for device_entry in device_entries:
unique_id = next(iter(device_entry.identifiers))[1]
unique_id = str(next(iter(device_entry.identifiers))[1])
device_entry_map[unique_id] = device_entry
if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id:
migrations[unique_id] = f"{mac_address}{suffix}"
@@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -148,6 +149,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
firmware_coordinator=firmware_coordinator,
)
cleanup_disconnected_cams(hass, config_entry.entry_id, host)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
config_entry.async_on_unload(
@@ -175,3 +178,56 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
def cleanup_disconnected_cams(
hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
) -> None:
"""Clean-up disconnected camera channels or channels where a different model camera is connected."""
if not host.api.is_nvr:
return
device_reg = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
for device in devices:
device_id = [
dev_id[1].split("_ch")
for dev_id in device.identifiers
if dev_id[0] == DOMAIN
][0]
if len(device_id) < 2:
# Do not consider the NVR itself
continue
ch = int(device_id[1])
ch_model = host.api.camera_model(ch)
remove = False
if ch not in host.api.channels:
remove = True
_LOGGER.debug(
"Removing Reolink device %s, since no camera is connected to NVR channel %s anymore",
device.name,
ch,
)
if ch_model not in [device.model, "Unknown"]:
remove = True
_LOGGER.debug(
"Removing Reolink device %s, since the camera model connected to channel %s changed from %s to %s",
device.name,
ch,
device.model,
ch_model,
)
if not remove:
continue
# clean entity and device registry
entity_reg = er.async_get(hass)
entities = er.async_entries_for_device(
entity_reg, device.id, include_disabled_entities=True
)
for entity in entities:
entity_reg.async_remove(entity.entity_id)
device_reg.async_remove_device(device.id)
@@ -10,6 +10,6 @@ LOGGER = logging.getLogger(__package__)
class StookwijzerState(StrEnum):
"""Stookwijzer states for sensor entity."""
CODE_YELLOW = "code_yellow"
CODE_ORANGE = "code_orange"
CODE_RED = "code_red"
BLUE = "blauw"
ORANGE = "oranje"
RED = "rood"
@@ -24,9 +24,8 @@ async def async_get_config_entry_diagnostics(
return {
"state": client.state,
"last_updated": last_updated,
"alert": client.alert,
"air_quality_index": client.lki,
"windspeed_bft": client.windspeed_bft,
"windspeed_ms": client.windspeed_ms,
"forecast": client.forecast,
"lqi": client.lqi,
"windspeed": client.windspeed,
"weather": client.weather,
"concentrations": client.concentrations,
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["stookwijzer==1.4.2"]
"requirements": ["stookwijzer==1.3.0"]
}
@@ -29,7 +29,7 @@ async def async_setup_entry(
class StookwijzerSensor(SensorEntity):
"""Defines a Stookwijzer binary sensor."""
_attr_attribution = "Data provided by atlasleefomgeving.nl"
_attr_attribution = "Data provided by stookwijzer.nu"
_attr_device_class = SensorDeviceClass.ENUM
_attr_has_entity_name = True
_attr_name = None
@@ -43,9 +43,9 @@ class StookwijzerSensor(SensorEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{entry.entry_id}")},
name="Stookwijzer",
manufacturer="Atlas Leefomgeving",
manufacturer="stookwijzer.nu",
entry_type=DeviceEntryType.SERVICE,
configuration_url="https://www.atlasleefomgeving.nl/stookwijzer",
configuration_url="https://www.stookwijzer.nu",
)
def update(self) -> None:
@@ -13,9 +13,9 @@
"sensor": {
"stookwijzer": {
"state": {
"code_yellow": "Code yellow",
"code_orange": "Code orange",
"code_red": "Code red"
"blauw": "Blue",
"oranje": "Orange",
"rood": "Red"
}
}
}
-9
View File
@@ -282,15 +282,6 @@ class TemplateFan(TemplateEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset_mode of the fan."""
if self.preset_modes and preset_mode not in self.preset_modes:
_LOGGER.error(
"Received invalid preset_mode: %s for entity %s. Expected: %s",
preset_mode,
self.entity_id,
self.preset_modes,
)
return
self._preset_mode = preset_mode
if self._set_preset_mode_script:
+3 -5
View File
@@ -35,7 +35,7 @@ from .const import (
ATTR_DESCRIPTION,
ATTR_DUE,
ATTR_DUE_DATE,
ATTR_DUE_DATE_TIME,
ATTR_DUE_DATETIME,
DOMAIN,
TodoItemStatus,
TodoListEntityFeature,
@@ -73,7 +73,7 @@ TODO_ITEM_FIELDS = [
required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM,
),
TodoItemFieldDescription(
service_field=ATTR_DUE_DATE_TIME,
service_field=ATTR_DUE_DATETIME,
validation=vol.All(cv.datetime, dt_util.as_local),
todo_item_field=ATTR_DUE,
required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM,
@@ -89,9 +89,7 @@ TODO_ITEM_FIELDS = [
TODO_ITEM_FIELD_SCHEMA = {
vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS
}
TODO_ITEM_FIELD_VALIDATIONS = [
cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATE_TIME)
]
TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)]
def _validate_supported_features(
+1 -1
View File
@@ -6,7 +6,7 @@ DOMAIN = "todo"
ATTR_DUE = "due"
ATTR_DUE_DATE = "due_date"
ATTR_DUE_DATE_TIME = "due_date_time"
ATTR_DUE_DATETIME = "due_datetime"
ATTR_DESCRIPTION = "description"
+2 -2
View File
@@ -29,7 +29,7 @@ add_item:
example: "2023-11-17"
selector:
date:
due_date_time:
due_datetime:
example: "2023-11-17 13:30:00"
selector:
datetime:
@@ -65,7 +65,7 @@ update_item:
example: "2023-11-17"
selector:
date:
due_date_time:
due_datetime:
example: "2023-11-17 13:30:00"
selector:
datetime:
+4 -4
View File
@@ -28,8 +28,8 @@
"name": "Due date",
"description": "The date the to-do item is expected to be completed."
},
"due_date_time": {
"name": "Due date time",
"due_datetime": {
"name": "Due date and time",
"description": "The date and time the to-do item is expected to be completed."
},
"description": {
@@ -58,8 +58,8 @@
"name": "Due date",
"description": "The date the to-do item is expected to be completed."
},
"due_date_time": {
"name": "Due date time",
"due_datetime": {
"name": "Due date and time",
"description": "The date and time the to-do item is expected to be completed."
},
"description": {
+37 -4
View File
@@ -1,7 +1,8 @@
"""A todo platform for Todoist."""
import asyncio
from typing import cast
import datetime
from typing import Any, cast
from homeassistant.components.todo import (
TodoItem,
@@ -13,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import TodoistCoordinator
@@ -30,6 +32,24 @@ async def async_setup_entry(
)
def _task_api_data(item: TodoItem) -> dict[str, Any]:
"""Convert a TodoItem to the set of add or update arguments."""
item_data: dict[str, Any] = {}
if summary := item.summary:
item_data["content"] = summary
if due := item.due:
if isinstance(due, datetime.datetime):
item_data["due"] = {
"date": due.date().isoformat(),
"datetime": due.isoformat(),
}
else:
item_data["due"] = {"date": due.isoformat()}
if description := item.description:
item_data["description"] = description
return item_data
class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity):
"""A Todoist TodoListEntity."""
@@ -37,6 +57,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
| TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
)
def __init__(
@@ -66,11 +89,21 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
status = TodoItemStatus.COMPLETED
else:
status = TodoItemStatus.NEEDS_ACTION
due: datetime.date | datetime.datetime | None = None
if task_due := task.due:
if task_due.datetime:
due = dt_util.as_local(
datetime.datetime.fromisoformat(task_due.datetime)
)
elif task_due.date:
due = datetime.date.fromisoformat(task_due.date)
items.append(
TodoItem(
summary=task.content,
uid=task.id,
status=status,
due=due,
description=task.description or None, # Don't use empty string
)
)
self._attr_todo_items = items
@@ -81,7 +114,7 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
if item.status != TodoItemStatus.NEEDS_ACTION:
raise ValueError("Only active tasks may be created.")
await self.coordinator.api.add_task(
content=item.summary or "",
**_task_api_data(item),
project_id=self._project_id,
)
await self.coordinator.async_refresh()
@@ -89,8 +122,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update a To-do item."""
uid: str = cast(str, item.uid)
if item.summary:
await self.coordinator.api.update_task(task_id=uid, content=item.summary)
if update_data := _task_api_data(item):
await self.coordinator.api.update_task(task_id=uid, **update_data)
if item.status is not None:
if item.status == TodoItemStatus.COMPLETED:
await self.coordinator.api.close_task(task_id=uid)
+1 -2
View File
@@ -119,8 +119,7 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
if not self._device_control:
return
if not preset_mode == ATTR_AUTO:
raise ValueError("Preset must be 'Auto'.")
# Preset must be 'Auto'
await self._api(self._device_control.turn_on_auto_mode())
@@ -6,7 +6,7 @@ import logging
from pytrafikverket.trafikverket_camera import TrafikverketCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.const import CONF_API_KEY, CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -42,13 +42,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
api_key = entry.data[CONF_API_KEY]
web_session = async_get_clientsession(hass)
camera_api = TrafikverketCamera(web_session, api_key)
# Change entry unique id from location to camera id
if entry.version == 1:
location = entry.data[CONF_LOCATION]
api_key = entry.data[CONF_API_KEY]
web_session = async_get_clientsession(hass)
camera_api = TrafikverketCamera(web_session, api_key)
try:
camera_info = await camera_api.async_get_camera(location)
@@ -60,14 +59,40 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if camera_id := camera_info.camera_id:
entry.version = 2
_LOGGER.debug(
"Migrate Trafikverket Camera config entry unique id to %s",
camera_id,
)
hass.config_entries.async_update_entry(
entry,
unique_id=f"{DOMAIN}-{camera_id}",
)
_LOGGER.debug(
"Migrated Trafikverket Camera config entry unique id to %s",
camera_id,
)
else:
_LOGGER.error("Could not migrate the config entry. Camera has no id")
return False
# Change entry data from location to id
if entry.version == 2:
location = entry.data[CONF_LOCATION]
try:
camera_info = await camera_api.async_get_camera(location)
except Exception: # pylint: disable=broad-except
_LOGGER.error(
"Could not migrate the config entry. No connection to the api"
)
return False
if camera_id := camera_info.camera_id:
entry.version = 3
_LOGGER.debug(
"Migrate Trafikverket Camera config entry unique id to %s",
camera_id,
)
new_data = entry.data.copy()
new_data.pop(CONF_LOCATION)
new_data[CONF_ID] = camera_id
hass.config_entries.async_update_entry(entry, data=new_data)
return True
_LOGGER.error("Could not migrate the config entry. Camera has no id")
return False
@@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY
from homeassistant.const import CONF_API_KEY, CONF_ID
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -25,7 +25,7 @@ from .const import CONF_LOCATION, DOMAIN
class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Trafikverket Camera integration."""
VERSION = 2
VERSION = 3
entry: config_entries.ConfigEntry | None
@@ -53,10 +53,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if camera_info:
camera_id = camera_info.camera_id
if _location := camera_info.location:
camera_location = _location
else:
camera_location = camera_info.camera_name
camera_location = camera_info.camera_name or "Trafikverket Camera"
return (errors, camera_location, camera_id)
@@ -76,9 +73,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
api_key = user_input[CONF_API_KEY]
assert self.entry is not None
errors, _, _ = await self.validate_input(
api_key, self.entry.data[CONF_LOCATION]
)
errors, _, _ = await self.validate_input(api_key, self.entry.data[CONF_ID])
if not errors:
self.hass.config_entries.async_update_entry(
@@ -121,10 +116,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=camera_location,
data={
CONF_API_KEY: api_key,
CONF_LOCATION: camera_location,
},
data={CONF_API_KEY: api_key, CONF_ID: camera_id},
)
return self.async_show_form(
@@ -15,13 +15,13 @@ from pytrafikverket.exceptions import (
from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.const import CONF_API_KEY, CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_LOCATION, DOMAIN
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
TIME_BETWEEN_UPDATES = timedelta(minutes=5)
@@ -48,14 +48,14 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]):
)
self.session = async_get_clientsession(hass)
self._camera_api = TrafikverketCamera(self.session, entry.data[CONF_API_KEY])
self._location = entry.data[CONF_LOCATION]
self._id = entry.data[CONF_ID]
async def _async_update_data(self) -> CameraData:
"""Fetch data from Trafikverket."""
camera_data: CameraInfo
image: bytes | None = None
try:
camera_data = await self._camera_api.async_get_camera(self._location)
camera_data = await self._camera_api.async_get_camera(self._id)
except (NoCameraFound, MultipleCamerasFound, UnknownError) as error:
raise UpdateFailed from error
except InvalidAuthentication as error:
+2
View File
@@ -338,6 +338,7 @@ class DPCode(StrEnum):
TEMP_VALUE_V2 = "temp_value_v2"
TEMPER_ALARM = "temper_alarm" # Tamper alarm
TIME_TOTAL = "time_total"
TIME_USE = "time_use" # Total seconds of irrigation
TOTAL_CLEAN_AREA = "total_clean_area"
TOTAL_CLEAN_COUNT = "total_clean_count"
TOTAL_CLEAN_TIME = "total_clean_time"
@@ -362,6 +363,7 @@ class DPCode(StrEnum):
WATER_RESET = "water_reset" # Resetting of water usage days
WATER_SET = "water_set" # Water level
WATERSENSOR_STATE = "watersensor_state"
WEATHER_DELAY = "weather_delay"
WET = "wet" # Humidification
WINDOW_CHECK = "window_check"
WINDOW_STATE = "window_state"
+10
View File
@@ -75,6 +75,16 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
icon="mdi:thermometer-lines",
),
),
# Smart Water Timer
"sfkzq": (
# Irrigation will not be run within this set delay period
SelectEntityDescription(
key=DPCode.WEATHER_DELAY,
translation_key="weather_delay",
icon="mdi:weather-cloudy-clock",
entity_category=EntityCategory.CONFIG,
),
),
# Siren Alarm
# https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
"sgbj": (
+12
View File
@@ -517,6 +517,18 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
# Smart Water Timer
"sfkzq": (
# Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug)
TuyaSensorEntityDescription(
key=DPCode.TIME_USE,
translation_key="total_watering_time",
icon="mdi:history",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
*BATTERY_SENSORS,
),
# Water Detector
# https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli
"sj": BATTERY_SENSORS,
@@ -421,6 +421,19 @@
"4": "Mood 4",
"5": "Mood 5"
}
},
"weather_delay": {
"name": "Weather delay",
"state": {
"cancel": "Cancel",
"24h": "24h",
"48h": "48h",
"72h": "72h",
"96h": "96h",
"120h": "120h",
"144h": "144h",
"168h": "168h"
}
}
},
"sensor": {
@@ -556,6 +569,9 @@
"water_level": {
"name": "Water level"
},
"total_watering_time": {
"name": "Total watering time"
},
"filter_utilization": {
"name": "Filter utilization"
},
+8
View File
@@ -430,6 +430,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
# Smart Water Timer
"sfkzq": (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
icon="mdi:sprinkler-variant",
),
),
# Siren Alarm
# https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
"sgbj": (
+1 -11
View File
@@ -11,11 +11,7 @@ from vallox_websocket_api import (
ValloxInvalidInputException,
)
from homeassistant.components.fan import (
FanEntity,
FanEntityFeature,
NotValidPresetModeError,
)
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -200,12 +196,6 @@ class ValloxFanEntity(ValloxEntity, FanEntity):
Returns true if the mode has been changed, false otherwise.
"""
try:
self._valid_preset_mode_or_raise(preset_mode)
except NotValidPresetModeError as err:
raise ValueError(f"Not valid preset mode: {preset_mode}") from err
if preset_mode == self.preset_mode:
return False
+2 -2
View File
@@ -283,8 +283,8 @@
}
},
"water_heater": {
"water": {
"name": "Water"
"domestic_hot_water": {
"name": "Domestic hot water"
}
}
},
@@ -64,13 +64,13 @@ def _build_entities(
api: PyViCareDevice,
device_config: PyViCareDeviceConfig,
) -> list[ViCareWater]:
"""Create ViCare water entities for a device."""
"""Create ViCare domestic hot water entities for a device."""
return [
ViCareWater(
api,
circuit,
device_config,
"water",
"domestic_hot_water",
)
for circuit in get_circuits(api)
]
@@ -81,7 +81,7 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ViCare climate platform."""
"""Set up the ViCare water heater platform."""
api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API]
device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG]
+36 -8
View File
@@ -5,10 +5,12 @@ import asyncio
from collections import deque
from collections.abc import AsyncIterable, MutableSequence, Sequence
from functools import partial
import io
import logging
from pathlib import Path
import time
from typing import TYPE_CHECKING
import wave
from voip_utils import (
CallInfo,
@@ -285,7 +287,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
),
conversation_id=self._conversation_id,
device_id=self.voip_device.device_id,
tts_audio_output="raw",
tts_audio_output="wav",
)
if self._pipeline_error:
@@ -387,11 +389,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
self._conversation_id = event.data["intent_output"]["conversation_id"]
elif event.type == PipelineEventType.TTS_END:
# Send TTS audio to caller over RTP
media_id = event.data["tts_output"]["media_id"]
self.hass.async_create_background_task(
self._send_tts(media_id),
"voip_pipeline_tts",
)
tts_output = event.data["tts_output"]
if tts_output:
media_id = tts_output["media_id"]
self.hass.async_create_background_task(
self._send_tts(media_id),
"voip_pipeline_tts",
)
else:
# Empty TTS response
self._tts_done.set()
elif event.type == PipelineEventType.ERROR:
# Play error tone instead of wait for TTS
self._pipeline_error = True
@@ -402,11 +409,32 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
if self.transport is None:
return
_extension, audio_bytes = await tts.async_get_media_source_audio(
extension, data = await tts.async_get_media_source_audio(
self.hass,
media_id,
)
if extension != "wav":
raise ValueError(f"Only WAV audio can be streamed, got {extension}")
with io.BytesIO(data) as wav_io:
with wave.open(wav_io, "rb") as wav_file:
sample_rate = wav_file.getframerate()
sample_width = wav_file.getsampwidth()
sample_channels = wav_file.getnchannels()
if (
(sample_rate != 16000)
or (sample_width != 2)
or (sample_channels != 1)
):
raise ValueError(
"Expected rate/width/channels as 16000/2/1,"
" got {sample_rate}/{sample_width}/{sample_channels}}"
)
audio_bytes = wav_file.readframes(wav_file.getnframes())
_LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes))
# Time out 1 second after TTS audio should be finished
@@ -414,7 +442,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
tts_seconds = tts_samples / RATE
async with asyncio.timeout(tts_seconds + self.tts_extra_timeout):
# Assume TTS audio is 16Khz 16-bit mono
# TTS audio is 16Khz 16-bit mono
await self._async_send_audio(audio_bytes)
except asyncio.TimeoutError as err:
_LOGGER.warning("TTS timeout")
@@ -17,6 +17,7 @@ from .const import ( # noqa: F401
ERR_INVALID_FORMAT,
ERR_NOT_FOUND,
ERR_NOT_SUPPORTED,
ERR_SERVICE_VALIDATION_ERROR,
ERR_TEMPLATE_ERROR,
ERR_TIMEOUT,
ERR_UNAUTHORIZED,
@@ -778,7 +778,22 @@ async def handle_execute_script(
context = connection.context(msg)
script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN)
script_result = await script_obj.async_run(msg.get("variables"), context=context)
try:
script_result = await script_obj.async_run(
msg.get("variables"), context=context
)
except ServiceValidationError as err:
connection.logger.error(err)
connection.logger.debug("", exc_info=err)
connection.send_error(
msg["id"],
const.ERR_SERVICE_VALIDATION_ERROR,
str(err),
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
)
return
connection.send_result(
msg["id"],
{
@@ -255,7 +255,10 @@ class ActiveConnection:
log_handler = self.logger.error
code = const.ERR_UNKNOWN_ERROR
err_message = None
err_message: str | None = None
translation_domain: str | None = None
translation_key: str | None = None
translation_placeholders: dict[str, Any] | None = None
if isinstance(err, Unauthorized):
code = const.ERR_UNAUTHORIZED
@@ -268,6 +271,10 @@ class ActiveConnection:
err_message = "Timeout"
elif isinstance(err, HomeAssistantError):
err_message = str(err)
code = const.ERR_HOME_ASSISTANT_ERROR
translation_domain = err.translation_domain
translation_key = err.translation_key
translation_placeholders = err.translation_placeholders
# This if-check matches all other errors but also matches errors which
# result in an empty message. In that case we will also log the stack
@@ -276,7 +283,16 @@ class ActiveConnection:
err_message = "Unknown error"
log_handler = self.logger.exception
self.send_message(messages.error_message(msg["id"], code, err_message))
self.send_message(
messages.error_message(
msg["id"],
code,
err_message,
translation_domain=translation_domain,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
)
if code:
err_message += f" ({code})"
@@ -66,7 +66,7 @@ def get_event_name(category: WorkoutCategory) -> str:
class WithingsWorkoutCalendarEntity(
CalendarEntity, WithingsEntity[WithingsWorkoutDataUpdateCoordinator]
WithingsEntity[WithingsWorkoutDataUpdateCoordinator], CalendarEntity
):
"""A calendar entity."""
@@ -530,9 +530,6 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier):
This method is a coroutine.
"""
if preset_mode not in self.preset_modes:
_LOGGER.warning("'%s'is not a valid preset mode", preset_mode)
return
if await self._try_command(
"Setting operation mode of the miio device failed.",
self._device.set_mode,
@@ -623,9 +620,6 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if preset_mode not in self.preset_modes:
_LOGGER.warning("'%s'is not a valid preset mode", preset_mode)
return
if await self._try_command(
"Setting operation mode of the miio device failed.",
self._device.set_mode,
@@ -721,9 +715,6 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier):
This method is a coroutine.
"""
if preset_mode not in self.preset_modes:
_LOGGER.warning("'%s'is not a valid preset mode", preset_mode)
return
if await self._try_command(
"Setting operation mode of the miio device failed.",
self._device.set_mode,
@@ -809,9 +800,6 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan. This method is a coroutine."""
if preset_mode not in self.preset_modes:
_LOGGER.warning("'%s'is not a valid preset mode", preset_mode)
return
if await self._try_command(
"Setting operation mode of the miio device failed.",
self._device.set_mode,
@@ -958,10 +946,6 @@ class XiaomiFan(XiaomiGenericFan):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if preset_mode not in self.preset_modes:
_LOGGER.warning("'%s'is not a valid preset mode", preset_mode)
return
if preset_mode == ATTR_MODE_NATURE:
await self._try_command(
"Setting natural fan speed percentage of the miio device failed.",
@@ -1034,9 +1018,6 @@ class XiaomiFanP5(XiaomiGenericFan):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if preset_mode not in self.preset_modes:
_LOGGER.warning("'%s'is not a valid preset mode", preset_mode)
return
await self._try_command(
"Setting operation mode of the miio device failed.",
self._device.set_mode,
@@ -1093,9 +1074,6 @@ class XiaomiFanMiot(XiaomiGenericFan):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if preset_mode not in self.preset_modes:
_LOGGER.warning("'%s'is not a valid preset mode", preset_mode)
return
await self._try_command(
"Setting operation mode of the miio device failed.",
self._device.set_mode,
+1 -1
View File
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zamg",
"iot_class": "cloud_polling",
"requirements": ["zamg==0.3.0"]
"requirements": ["zamg==0.3.3"]
}
+76 -30
View File
@@ -9,12 +9,12 @@ import re
import voluptuous as vol
from zhaquirks import setup as setup_quirks
from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH
from zigpy.exceptions import NetworkSettingsInconsistent
from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -29,6 +29,7 @@ from .core.const import (
CONF_CUSTOM_QUIRKS_PATH,
CONF_DEVICE_CONFIG,
CONF_ENABLE_QUIRKS,
CONF_FLOW_CONTROL,
CONF_RADIO_TYPE,
CONF_USB_PATH,
CONF_ZIGPY,
@@ -36,6 +37,8 @@ from .core.const import (
DOMAIN,
PLATFORMS,
SIGNAL_ADD_ENTITIES,
STARTUP_FAILURE_DELAY_S,
STARTUP_RETRIES,
RadioType,
)
from .core.device import get_device_automation_triggers
@@ -158,42 +161,67 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
_LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache)
zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry)
# Retry setup a few times before giving up to deal with missing serial ports in VMs
for attempt in range(STARTUP_RETRIES):
try:
zha_gateway = await ZHAGateway.async_from_config(
hass=hass,
config=zha_data.yaml_config,
config_entry=config_entry,
)
break
except NetworkSettingsInconsistent as exc:
await warn_on_inconsistent_network_settings(
hass,
config_entry=config_entry,
old_state=exc.old_state,
new_state=exc.new_state,
)
raise ConfigEntryError(
"Network settings do not match most recent backup"
) from exc
except TransientConnectionError as exc:
raise ConfigEntryNotReady from exc
except Exception as exc: # pylint: disable=broad-except
_LOGGER.debug(
"Couldn't start coordinator (attempt %s of %s)",
attempt + 1,
STARTUP_RETRIES,
exc_info=exc,
)
try:
await zha_gateway.async_initialize()
except NetworkSettingsInconsistent as exc:
await warn_on_inconsistent_network_settings(
hass,
config_entry=config_entry,
old_state=exc.old_state,
new_state=exc.new_state,
)
raise HomeAssistantError(
"Network settings do not match most recent backup"
) from exc
except Exception:
if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp:
try:
await warn_on_wrong_silabs_firmware(
hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
)
except AlreadyRunningEZSP as exc:
# If connecting fails but we somehow probe EZSP (e.g. stuck in the
# bootloader), reconnect, it should work
raise ConfigEntryNotReady from exc
if attempt < STARTUP_RETRIES - 1:
await asyncio.sleep(STARTUP_FAILURE_DELAY_S)
continue
raise
if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp:
try:
# Ignore all exceptions during probing, they shouldn't halt setup
await warn_on_wrong_silabs_firmware(
hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
)
except AlreadyRunningEZSP as ezsp_exc:
raise ConfigEntryNotReady from ezsp_exc
raise
repairs.async_delete_blocking_issues(hass)
manufacturer = zha_gateway.state.node_info.manufacturer
model = zha_gateway.state.node_info.model
if manufacturer is None and model is None:
manufacturer = "Unknown"
model = "Unknown"
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))},
identifiers={(DOMAIN, str(zha_gateway.coordinator_ieee))},
connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.state.node_info.ieee))},
identifiers={(DOMAIN, str(zha_gateway.state.node_info.ieee))},
name="Zigbee Coordinator",
manufacturer="ZHA",
model=zha_gateway.radio_description,
manufacturer=manufacturer,
model=model,
sw_version=zha_gateway.state.node_info.version,
)
websocket_api.async_load_api(hass)
@@ -267,5 +295,23 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry.version = 3
hass.config_entries.async_update_entry(config_entry, data=data)
if config_entry.version == 3:
data = {**config_entry.data}
if not data[CONF_DEVICE].get(CONF_BAUDRATE):
data[CONF_DEVICE][CONF_BAUDRATE] = {
"deconz": 38400,
"xbee": 57600,
"ezsp": 57600,
"znp": 115200,
"zigate": 115200,
}[data[CONF_RADIO_TYPE]]
if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL):
data[CONF_DEVICE][CONF_FLOW_CONTROL] = None
config_entry.version = 4
hass.config_entries.async_update_entry(config_entry, data=data)
_LOGGER.info("Migration to version %s successful", config_entry.version)
return True

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