Merge branch 'dev' into epenet-20250527-1510

This commit is contained in:
epenet
2025-06-10 08:05:37 +02:00
committed by GitHub
185 changed files with 4627 additions and 1450 deletions

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.18
uses: github/codeql-action/init@v3.28.19
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.18
uses: github/codeql-action/analyze@v3.28.19
with:
category: "/language:python"

View File

@@ -14,30 +14,24 @@ from jaraco.abode.exceptions import (
)
from jaraco.abode.helpers.timeline import Groups as GROUPS
from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DATE,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
ATTR_TIME,
CONF_PASSWORD,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import CONF_POLLING, DOMAIN, LOGGER
SERVICE_SETTINGS = "change_setting"
SERVICE_CAPTURE_IMAGE = "capture_image"
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
from .services import async_setup_services
ATTR_DEVICE_NAME = "device_name"
ATTR_DEVICE_TYPE = "device_type"
@@ -45,22 +39,12 @@ ATTR_EVENT_CODE = "event_code"
ATTR_EVENT_NAME = "event_name"
ATTR_EVENT_TYPE = "event_type"
ATTR_EVENT_UTC = "event_utc"
ATTR_SETTING = "setting"
ATTR_USER_NAME = "user_name"
ATTR_APP_TYPE = "app_type"
ATTR_EVENT_BY = "event_by"
ATTR_VALUE = "value"
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
CHANGE_SETTING_SCHEMA = vol.Schema(
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
)
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
@@ -85,7 +69,7 @@ class AbodeSystem:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Abode component."""
setup_hass_services(hass)
async_setup_services(hass)
return True
@@ -138,60 +122,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
def setup_hass_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
def change_setting(call: ServiceCall) -> None:
"""Change an Abode system setting."""
setting = call.data[ATTR_SETTING]
value = call.data[ATTR_VALUE]
try:
hass.data[DOMAIN].abode.set_setting(setting, value)
except AbodeException as ex:
LOGGER.warning(ex)
def capture_image(call: ServiceCall) -> None:
"""Capture a new image."""
entity_ids = call.data[ATTR_ENTITY_ID]
target_entities = [
entity_id
for entity_id in hass.data[DOMAIN].entity_ids
if entity_id in entity_ids
]
for entity_id in target_entities:
signal = f"abode_camera_capture_{entity_id}"
dispatcher_send(hass, signal)
def trigger_automation(call: ServiceCall) -> None:
"""Trigger an Abode automation."""
entity_ids = call.data[ATTR_ENTITY_ID]
target_entities = [
entity_id
for entity_id in hass.data[DOMAIN].entity_ids
if entity_id in entity_ids
]
for entity_id in target_entities:
signal = f"abode_trigger_automation_{entity_id}"
dispatcher_send(hass, signal)
hass.services.async_register(
DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA
)
async def setup_hass_events(hass: HomeAssistant) -> None:
"""Home Assistant start and stop callbacks."""

View File

@@ -0,0 +1,89 @@
"""Support for the Abode Security System."""
from __future__ import annotations
from jaraco.abode.exceptions import Exception as AbodeException
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, LOGGER
SERVICE_SETTINGS = "change_setting"
SERVICE_CAPTURE_IMAGE = "capture_image"
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
ATTR_SETTING = "setting"
ATTR_VALUE = "value"
CHANGE_SETTING_SCHEMA = vol.Schema(
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
)
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
def _change_setting(call: ServiceCall) -> None:
"""Change an Abode system setting."""
setting = call.data[ATTR_SETTING]
value = call.data[ATTR_VALUE]
try:
call.hass.data[DOMAIN].abode.set_setting(setting, value)
except AbodeException as ex:
LOGGER.warning(ex)
def _capture_image(call: ServiceCall) -> None:
"""Capture a new image."""
entity_ids = call.data[ATTR_ENTITY_ID]
target_entities = [
entity_id
for entity_id in call.hass.data[DOMAIN].entity_ids
if entity_id in entity_ids
]
for entity_id in target_entities:
signal = f"abode_camera_capture_{entity_id}"
dispatcher_send(call.hass, signal)
def _trigger_automation(call: ServiceCall) -> None:
"""Trigger an Abode automation."""
entity_ids = call.data[ATTR_ENTITY_ID]
target_entities = [
entity_id
for entity_id in call.hass.data[DOMAIN].entity_ids
if entity_id in entity_ids
]
for entity_id in target_entities:
signal = f"abode_trigger_automation_{entity_id}"
dispatcher_send(call.hass, signal)
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
hass.services.async_register(
DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA
)
hass.services.async_register(
DOMAIN,
SERVICE_TRIGGER_AUTOMATION,
_trigger_automation,
schema=AUTOMATION_SCHEMA,
)

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
"requirements": ["aioairq==0.4.4"]
"requirements": ["aioairq==0.4.6"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
"iot_class": "local_push",
"loggers": ["airtouch5py"],
"requirements": ["airtouch5py==0.2.11"]
"requirements": ["airtouch5py==0.3.0"]
}

View File

@@ -25,15 +25,15 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_num = serial_num
model_details = coordinator.api.get_model_details(self.device)
model = model_details["model"] if model_details else None
model_details = coordinator.api.get_model_details(self.device) or {}
model = model_details.get("model")
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_num)},
name=self.device.account_name,
model=model,
model_id=self.device.device_type,
manufacturer="Amazon",
hw_version=model_details["hw_version"] if model_details else None,
manufacturer=model_details.get("manufacturer", "Amazon"),
hw_version=model_details.get("hw_version"),
sw_version=(
self.device.software_version if model != SPEAKER_GROUP_MODEL else None
),

View File

@@ -118,5 +118,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.0.5"]
"requirements": ["aioamazondevices==3.0.6"]
}

View File

@@ -16,10 +16,7 @@ from amcrest import AmcrestError, ApiWrapper, LoginError
import httpx
import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_CONTROL
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_AUTHENTICATION,
CONF_BINARY_SENSORS,
CONF_HOST,
@@ -30,21 +27,17 @@ from homeassistant.const import (
CONF_SENSORS,
CONF_SWITCHES,
CONF_USERNAME,
ENTITY_MATCH_ALL,
ENTITY_MATCH_NONE,
HTTP_BASIC_AUTHENTICATION,
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import async_extract_entity_ids
from homeassistant.helpers.typing import ConfigType
from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors
from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST
from .camera import STREAM_SOURCE_LIST
from .const import (
CAMERAS,
COMM_RETRIES,
@@ -58,6 +51,7 @@ from .const import (
)
from .helpers import service_signal
from .sensor import SENSOR_KEYS
from .services import async_setup_services
from .switch import SWITCH_KEYS
_LOGGER = logging.getLogger(__name__)
@@ -455,47 +449,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if not hass.data[DATA_AMCREST][DEVICES]:
return False
def have_permission(user: User | None, entity_id: str) -> bool:
return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL)
async def async_extract_from_service(call: ServiceCall) -> list[str]:
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(context=call.context)
else:
user = None
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
# Return all entity_ids user has permission to control.
return [
entity_id
for entity_id in hass.data[DATA_AMCREST][CAMERAS]
if have_permission(user, entity_id)
]
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
return []
call_ids = await async_extract_entity_ids(hass, call)
entity_ids = []
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids:
continue
if not have_permission(user, entity_id):
raise Unauthorized(
context=call.context, entity_id=entity_id, permission=POLICY_CONTROL
)
entity_ids.append(entity_id)
return entity_ids
async def async_service_handler(call: ServiceCall) -> None:
args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]]
for entity_id in await async_extract_from_service(call):
async_dispatcher_send(hass, service_signal(call.service, entity_id), *args)
for service, params in CAMERA_SERVICES.items():
hass.services.async_register(DOMAIN, service, async_service_handler, params[0])
async_setup_services(hass)
return True

View File

@@ -0,0 +1,61 @@
"""Support for Amcrest IP cameras."""
from __future__ import annotations
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_CONTROL
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import async_extract_entity_ids
from .camera import CAMERA_SERVICES
from .const import CAMERAS, DATA_AMCREST, DOMAIN
from .helpers import service_signal
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Amcrest IP Camera services."""
def have_permission(user: User | None, entity_id: str) -> bool:
return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL)
async def async_extract_from_service(call: ServiceCall) -> list[str]:
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(context=call.context)
else:
user = None
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
# Return all entity_ids user has permission to control.
return [
entity_id
for entity_id in hass.data[DATA_AMCREST][CAMERAS]
if have_permission(user, entity_id)
]
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
return []
call_ids = await async_extract_entity_ids(hass, call)
entity_ids = []
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids:
continue
if not have_permission(user, entity_id):
raise Unauthorized(
context=call.context, entity_id=entity_id, permission=POLICY_CONTROL
)
entity_ids.append(entity_id)
return entity_ids
async def async_service_handler(call: ServiceCall) -> None:
args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]]
for entity_id in await async_extract_from_service(call):
async_dispatcher_send(hass, service_signal(call.service, entity_id), *args)
for service, params in CAMERA_SERVICES.items():
hass.services.async_register(DOMAIN, service, async_service_handler, params[0])

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.1",
"dbus-fast==2.43.0",
"habluetooth==3.48.2"
"habluetooth==3.49.0"
]
}

View File

@@ -50,7 +50,7 @@ class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
"""Initialise a Bosch Alarm control panel entity."""
super().__init__(panel, area_id, unique_id, False, False, True)
super().__init__(panel, area_id, unique_id, True, False, True)
self._attr_unique_id = self._area_unique_id
@property

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
"requirements": ["python-bsblan==1.2.1"]
"requirements": ["python-bsblan==2.1.0"]
}

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated",
"quality_scale": "legacy",
"requirements": ["numpy==2.2.6"]
"requirements": ["numpy==2.3.0"]
}

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import timedelta
from ipaddress import IPv4Address, IPv6Address
import logging
from typing import Literal
import aiodns
from aiodns.error import DNSError
@@ -34,7 +35,7 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=120)
def sort_ips(ips: list, querytype: str) -> list:
def sort_ips(ips: list, querytype: Literal["A", "AAAA"]) -> list:
"""Join IPs into a single string."""
if querytype == "AAAA":
@@ -89,7 +90,7 @@ class WanIpSensor(SensorEntity):
self.hostname = hostname
self.resolver = aiodns.DNSResolver(tcp_port=port, udp_port=port)
self.resolver.nameservers = [resolver]
self.querytype = "AAAA" if ipv6 else "A"
self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A"
self._retries = DEFAULT_RETRIES
self._attr_extra_state_attributes = {
"resolver": resolver,
@@ -106,7 +107,7 @@ class WanIpSensor(SensorEntity):
async def async_update(self) -> None:
"""Get the current DNS IP address for hostname."""
try:
response = await self.resolver.query(self.hostname, self.querytype) # type: ignore[call-overload]
response = await self.resolver.query(self.hostname, self.querytype)
except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err)
response = None

View File

@@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import _LOGGER, CONF_DOWNLOAD_DIR
from .services import register_services
from .services import async_setup_services
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -25,6 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
return False
register_services(hass)
async_setup_services(hass)
return True

View File

@@ -141,7 +141,7 @@ def download_file(service: ServiceCall) -> None:
threading.Thread(target=do_download).start()
def register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the services for the downloader component."""
async_register_admin_service(
hass,

View File

@@ -1 +1,6 @@
"""The eddystone_temperature component."""
DOMAIN = "eddystone_temperature"
CONF_BEACONS = "beacons"
CONF_INSTANCE = "instance"
CONF_NAMESPACE = "namespace"

View File

@@ -23,17 +23,18 @@ from homeassistant.const import (
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_BEACONS, CONF_INSTANCE, CONF_NAMESPACE, DOMAIN
_LOGGER = logging.getLogger(__name__)
CONF_BEACONS = "beacons"
CONF_BT_DEVICE_ID = "bt_device_id"
CONF_INSTANCE = "instance"
CONF_NAMESPACE = "namespace"
BEACON_SCHEMA = vol.Schema(
{
@@ -58,6 +59,21 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Validate configuration, create devices and start monitoring thread."""
create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_system_packages_yaml_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Eddystone",
},
)
bt_device_id: int = config[CONF_BT_DEVICE_ID]
beacons: dict[str, dict[str, str]] = config[CONF_BEACONS]

View File

@@ -8,7 +8,7 @@ import re
from typing import Any
from elkm1_lib.elements import Element
from elkm1_lib.elk import Elk, Panel
from elkm1_lib.elk import Elk
from elkm1_lib.util import parse_url
import voluptuous as vol
@@ -26,12 +26,11 @@ from homeassistant.const import (
Platform,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.network import is_ip_address
from .const import (
@@ -62,6 +61,7 @@ from .discovery import (
async_update_entry_from_discovery,
)
from .models import ELKM1Data
from .services import async_setup_services
type ElkM1ConfigEntry = ConfigEntry[ELKM1Data]
@@ -79,19 +79,6 @@ PLATFORMS = [
Platform.SWITCH,
]
SPEAK_SERVICE_SCHEMA = vol.Schema(
{
vol.Required("number"): vol.All(vol.Coerce(int), vol.Range(min=0, max=999)),
vol.Optional("prefix", default=""): cv.string,
}
)
SET_TIME_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("prefix", default=""): cv.string,
}
)
def hostname_from_url(url: str) -> str:
"""Return the hostname from a url."""
@@ -179,7 +166,7 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Set up the Elk M1 platform."""
_create_elk_services(hass)
async_setup_services(hass)
async def _async_discovery(*_: Any) -> None:
async_trigger_discovery(
@@ -326,17 +313,6 @@ def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) -
values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1)
def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None:
"""Search all config entries for a given prefix."""
for entry in hass.config_entries.async_entries(DOMAIN):
if not entry.runtime_data:
continue
elk_data: ELKM1Data = entry.runtime_data
if elk_data.prefix == prefix:
return elk_data.elk
return None
async def async_unload_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -390,39 +366,3 @@ async def async_wait_for_elk_to_sync(
_LOGGER.debug("Received %s event", name)
return success
@callback
def _async_get_elk_panel(hass: HomeAssistant, service: ServiceCall) -> Panel:
"""Get the ElkM1 panel from a service call."""
prefix = service.data["prefix"]
elk = _find_elk_by_prefix(hass, prefix)
if elk is None:
raise HomeAssistantError(f"No ElkM1 with prefix '{prefix}' found")
return elk.panel
def _create_elk_services(hass: HomeAssistant) -> None:
"""Create ElkM1 services."""
@callback
def _speak_word_service(service: ServiceCall) -> None:
_async_get_elk_panel(hass, service).speak_word(service.data["number"])
@callback
def _speak_phrase_service(service: ServiceCall) -> None:
_async_get_elk_panel(hass, service).speak_phrase(service.data["number"])
@callback
def _set_time_service(service: ServiceCall) -> None:
_async_get_elk_panel(hass, service).set_time(dt_util.now())
hass.services.async_register(
DOMAIN, "speak_word", _speak_word_service, SPEAK_SERVICE_SCHEMA
)
hass.services.async_register(
DOMAIN, "speak_phrase", _speak_phrase_service, SPEAK_SERVICE_SCHEMA
)
hass.services.async_register(
DOMAIN, "set_time", _set_time_service, SET_TIME_SERVICE_SCHEMA
)

View File

@@ -0,0 +1,77 @@
"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
from __future__ import annotations
from elkm1_lib.elk import Elk, Panel
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .models import ELKM1Data
SPEAK_SERVICE_SCHEMA = vol.Schema(
{
vol.Required("number"): vol.All(vol.Coerce(int), vol.Range(min=0, max=999)),
vol.Optional("prefix", default=""): cv.string,
}
)
SET_TIME_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("prefix", default=""): cv.string,
}
)
def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None:
"""Search all config entries for a given prefix."""
for entry in hass.config_entries.async_entries(DOMAIN):
if not entry.runtime_data:
continue
elk_data: ELKM1Data = entry.runtime_data
if elk_data.prefix == prefix:
return elk_data.elk
return None
@callback
def _async_get_elk_panel(service: ServiceCall) -> Panel:
"""Get the ElkM1 panel from a service call."""
prefix = service.data["prefix"]
elk = _find_elk_by_prefix(service.hass, prefix)
if elk is None:
raise HomeAssistantError(f"No ElkM1 with prefix '{prefix}' found")
return elk.panel
@callback
def _speak_word_service(service: ServiceCall) -> None:
_async_get_elk_panel(service).speak_word(service.data["number"])
@callback
def _speak_phrase_service(service: ServiceCall) -> None:
_async_get_elk_panel(service).speak_phrase(service.data["number"])
@callback
def _set_time_service(service: ServiceCall) -> None:
_async_get_elk_panel(service).set_time(dt_util.now())
def async_setup_services(hass: HomeAssistant) -> None:
"""Create ElkM1 services."""
hass.services.async_register(
DOMAIN, "speak_word", _speak_word_service, SPEAK_SERVICE_SCHEMA
)
hass.services.async_register(
DOMAIN, "speak_phrase", _speak_phrase_service, SPEAK_SERVICE_SCHEMA
)
hass.services.async_register(
DOMAIN, "set_time", _set_time_service, SET_TIME_SERVICE_SCHEMA
)

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import httpx
from pyenphase import Envoy
from homeassistant.config_entries import ConfigEntry
@@ -10,14 +9,9 @@ from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import (
DOMAIN,
OPTION_DISABLE_KEEP_ALIVE,
OPTION_DISABLE_KEEP_ALIVE_DEFAULT_VALUE,
PLATFORMS,
)
from .const import DOMAIN, PLATFORMS
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
@@ -25,19 +19,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b
"""Set up Enphase Envoy from a config entry."""
host = entry.data[CONF_HOST]
options = entry.options
envoy = (
Envoy(
host,
httpx.AsyncClient(
verify=False, limits=httpx.Limits(max_keepalive_connections=0)
),
)
if options.get(
OPTION_DISABLE_KEEP_ALIVE, OPTION_DISABLE_KEEP_ALIVE_DEFAULT_VALUE
)
else Envoy(host, get_async_client(hass, verify_ssl=False))
)
session = async_create_clientsession(hass, verify_ssl=False)
envoy = Envoy(host, session)
coordinator = EnphaseUpdateCoordinator(hass, envoy, entry)
await coordinator.async_config_entry_first_refresh()

View File

@@ -24,7 +24,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
@@ -63,7 +63,7 @@ async def validate_input(
description_placeholders: dict[str, str],
) -> Envoy:
"""Validate the user input allows us to connect."""
envoy = Envoy(host, get_async_client(hass, verify_ssl=False))
envoy = Envoy(host, async_get_clientsession(hass, verify_ssl=False))
try:
await envoy.setup()
await envoy.authenticate(username=username, password=password)

View File

@@ -6,6 +6,7 @@ import copy
from datetime import datetime
from typing import TYPE_CHECKING, Any
from aiohttp import ClientResponse
from attr import asdict
from pyenphase.envoy import Envoy
from pyenphase.exceptions import EnvoyError
@@ -69,14 +70,14 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
for end_point in end_points:
try:
response = await envoy.request(end_point)
fixture_data[end_point] = response.text.replace("\n", "").replace(
serial, CLEAN_TEXT
response: ClientResponse = await envoy.request(end_point)
fixture_data[end_point] = (
(await response.text()).replace("\n", "").replace(serial, CLEAN_TEXT)
)
fixture_data[f"{end_point}_log"] = json_dumps(
{
"headers": dict(response.headers.items()),
"code": response.status_code,
"code": response.status,
}
)
except EnvoyError as err:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from httpx import HTTPError
from aiohttp import ClientError
from pyenphase import EnvoyData
from pyenphase.exceptions import EnvoyError
@@ -16,7 +16,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import EnphaseUpdateCoordinator
ACTIONERRORS = (EnvoyError, HTTPError)
ACTIONERRORS = (EnvoyError, ClientError)
class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):

View File

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

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.10.2"]
"requirements": ["env-canada==0.11.2"]
}

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==31.1.0",
"aioesphomeapi==32.2.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==2.16.0"
],

View File

@@ -11,32 +11,25 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONTENT_TYPE_MULTIPART,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.signal_type import SignalType
from homeassistant.util.system_info import is_official_image
DOMAIN = "ffmpeg"
SERVICE_START = "start"
SERVICE_STOP = "stop"
SERVICE_RESTART = "restart"
SIGNAL_FFMPEG_START = SignalType[list[str] | None]("ffmpeg.start")
SIGNAL_FFMPEG_STOP = SignalType[list[str] | None]("ffmpeg.stop")
SIGNAL_FFMPEG_RESTART = SignalType[list[str] | None]("ffmpeg.restart")
from .const import (
DOMAIN,
SIGNAL_FFMPEG_RESTART,
SIGNAL_FFMPEG_START,
SIGNAL_FFMPEG_STOP,
)
from .services import async_setup_services
DATA_FFMPEG = "ffmpeg"
@@ -63,8 +56,6 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_FFMPEG_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the FFmpeg component."""
@@ -74,29 +65,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await manager.async_get_version()
# Register service
async def async_service_handle(service: ServiceCall) -> None:
"""Handle service ffmpeg process."""
entity_ids: list[str] | None = service.data.get(ATTR_ENTITY_ID)
if service.service == SERVICE_START:
async_dispatcher_send(hass, SIGNAL_FFMPEG_START, entity_ids)
elif service.service == SERVICE_STOP:
async_dispatcher_send(hass, SIGNAL_FFMPEG_STOP, entity_ids)
else:
async_dispatcher_send(hass, SIGNAL_FFMPEG_RESTART, entity_ids)
hass.services.async_register(
DOMAIN, SERVICE_START, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_STOP, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_RESTART, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA
)
async_setup_services(hass)
hass.data[DATA_FFMPEG] = manager
return True

View File

@@ -0,0 +1,9 @@
"""Support for FFmpeg."""
from homeassistant.util.signal_type import SignalType
DOMAIN = "ffmpeg"
SIGNAL_FFMPEG_START = SignalType[list[str] | None]("ffmpeg.start")
SIGNAL_FFMPEG_STOP = SignalType[list[str] | None]("ffmpeg.stop")
SIGNAL_FFMPEG_RESTART = SignalType[list[str] | None]("ffmpeg.restart")

View File

@@ -0,0 +1,51 @@
"""Support for FFmpeg."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
DOMAIN,
SIGNAL_FFMPEG_RESTART,
SIGNAL_FFMPEG_START,
SIGNAL_FFMPEG_STOP,
)
SERVICE_START = "start"
SERVICE_STOP = "stop"
SERVICE_RESTART = "restart"
SERVICE_FFMPEG_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
async def _async_service_handle(service: ServiceCall) -> None:
"""Handle service ffmpeg process."""
entity_ids: list[str] | None = service.data.get(ATTR_ENTITY_ID)
if service.service == SERVICE_START:
async_dispatcher_send(service.hass, SIGNAL_FFMPEG_START, entity_ids)
elif service.service == SERVICE_STOP:
async_dispatcher_send(service.hass, SIGNAL_FFMPEG_STOP, entity_ids)
else:
async_dispatcher_send(service.hass, SIGNAL_FFMPEG_RESTART, entity_ids)
def async_setup_services(hass: HomeAssistant) -> None:
"""Register FFmpeg services."""
hass.services.async_register(
DOMAIN, SERVICE_START, _async_service_handle, schema=SERVICE_FFMPEG_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_STOP, _async_service_handle, schema=SERVICE_FFMPEG_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_RESTART, _async_service_handle, schema=SERVICE_FFMPEG_SCHEMA
)

View File

@@ -14,7 +14,7 @@ from homeassistant.helpers.typing import ConfigType
from . import api
from .const import DOMAIN
from .coordinator import GooglePhotosConfigEntry, GooglePhotosUpdateCoordinator
from .services import async_register_services
from .services import async_setup_services
__all__ = ["DOMAIN"]
@@ -24,7 +24,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Google Photos integration."""
async_register_services(hass)
async_setup_services(hass)
return True

View File

@@ -77,7 +77,7 @@ def _read_file_contents(
return results
def async_register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register Google Photos services."""
async def async_handle_upload(call: ServiceCall) -> ServiceResponse:

View File

@@ -2,48 +2,33 @@
from __future__ import annotations
from datetime import datetime
import aiohttp
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from gspread import Client
from gspread.exceptions import APIError
from gspread.utils import ValueInputOption
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.helpers.typing import ConfigType
from .const import DEFAULT_ACCESS, DOMAIN
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type GoogleSheetsConfigEntry = ConfigEntry[OAuth2Session]
DATA = "data"
DATA_CONFIG_ENTRY = "config_entry"
WORKSHEET = "worksheet"
SERVICE_APPEND_SHEET = "append_sheet"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Activate the Google Sheets component."""
SHEET_SERVICE_SCHEMA = vol.All(
{
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Optional(WORKSHEET): cv.string,
vol.Required(DATA): vol.Any(cv.ensure_list, [dict]),
},
)
async_setup_services(hass)
return True
async def async_setup_entry(
@@ -67,8 +52,6 @@ async def async_setup_entry(
raise ConfigEntryAuthFailed("Required scopes are not present, reauth required")
entry.runtime_data = session
await async_setup_service(hass)
return True
@@ -81,55 +64,4 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleSheetsConfigEntry
) -> bool:
"""Unload a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
return True
async def async_setup_service(hass: HomeAssistant) -> None:
"""Add the services for Google Sheets."""
def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
"""Run append in the executor."""
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
try:
sheet = service.open_by_key(entry.unique_id)
except RefreshError:
entry.async_start_reauth(hass)
raise
except APIError as ex:
raise HomeAssistantError("Failed to write data") from ex
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
now = str(datetime.now())
rows = []
for d in call.data[DATA]:
row_data = {"created": now} | d
row = [row_data.get(column, "") for column in columns]
for key, value in row_data.items():
if key not in columns:
columns.append(key)
worksheet.update_cell(1, len(columns), key)
row.append(value)
rows.append(row)
worksheet.append_rows(rows, value_input_option=ValueInputOption.user_entered)
async def append_to_sheet(call: ServiceCall) -> None:
"""Append new line of data to a Google Sheets document."""
entry: GoogleSheetsConfigEntry | None = hass.config_entries.async_get_entry(
call.data[DATA_CONFIG_ENTRY]
)
if not entry or not hasattr(entry, "runtime_data"):
raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}")
await entry.runtime_data.async_ensure_token_valid()
await hass.async_add_executor_job(_append_to_sheet, call, entry)
hass.services.async_register(
DOMAIN,
SERVICE_APPEND_SHEET,
append_to_sheet,
schema=SHEET_SERVICE_SCHEMA,
)

View File

@@ -0,0 +1,87 @@
"""Support for Google Sheets."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from gspread import Client
from gspread.exceptions import APIError
from gspread.utils import ValueInputOption
import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector
from .const import DOMAIN
if TYPE_CHECKING:
from . import GoogleSheetsConfigEntry
DATA = "data"
DATA_CONFIG_ENTRY = "config_entry"
WORKSHEET = "worksheet"
SERVICE_APPEND_SHEET = "append_sheet"
SHEET_SERVICE_SCHEMA = vol.All(
{
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Optional(WORKSHEET): cv.string,
vol.Required(DATA): vol.Any(cv.ensure_list, [dict]),
},
)
def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
"""Run append in the executor."""
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
try:
sheet = service.open_by_key(entry.unique_id)
except RefreshError:
entry.async_start_reauth(call.hass)
raise
except APIError as ex:
raise HomeAssistantError("Failed to write data") from ex
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
now = str(datetime.now())
rows = []
for d in call.data[DATA]:
row_data = {"created": now} | d
row = [row_data.get(column, "") for column in columns]
for key, value in row_data.items():
if key not in columns:
columns.append(key)
worksheet.update_cell(1, len(columns), key)
row.append(value)
rows.append(row)
worksheet.append_rows(rows, value_input_option=ValueInputOption.user_entered)
async def _async_append_to_sheet(call: ServiceCall) -> None:
"""Append new line of data to a Google Sheets document."""
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
call.data[DATA_CONFIG_ENTRY]
)
if not entry or not hasattr(entry, "runtime_data"):
raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}")
await entry.runtime_data.async_ensure_token_valid()
await call.hass.async_add_executor_job(_append_to_sheet, call, entry)
def async_setup_services(hass: HomeAssistant) -> None:
"""Add the services for Google Sheets."""
hass.services.async_register(
DOMAIN,
SERVICE_APPEND_SHEET,
_async_append_to_sheet,
schema=SHEET_SERVICE_SCHEMA,
)

View File

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

View File

@@ -83,3 +83,54 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=AUTH_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reconfigure flow."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input:
self.homee = Homee(
user_input[CONF_HOST],
reconfigure_entry.data[CONF_USERNAME],
reconfigure_entry.data[CONF_PASSWORD],
)
try:
await self.homee.get_access_token()
except HomeeConnectionFailedException:
errors["base"] = "cannot_connect"
except HomeeAuthenticationFailedException:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.hass.loop.create_task(self.homee.run())
await self.homee.wait_until_connected()
self.homee.disconnect()
await self.homee.wait_until_disconnected()
await self.async_set_unique_id(self.homee.settings.uid)
self._abort_if_unique_id_mismatch(reason="wrong_hub")
_LOGGER.debug("Updated homee entry with ID %s", self.homee.settings.uid)
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates=user_input
)
return self.async_show_form(
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
): str
}
),
description_placeholders={
"name": reconfigure_entry.runtime_data.settings.uid
},
errors=errors,
)

View File

@@ -0,0 +1,43 @@
"""Diagnostics for homee integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from . import DOMAIN, HomeeConfigEntry
TO_REDACT = [CONF_PASSWORD, CONF_USERNAME, "latitude", "longitude", "wlan_ssid"]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: HomeeConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"settings": async_redact_data(entry.runtime_data.settings.raw_data, TO_REDACT),
"devices": [{"node": node.raw_data} for node in entry.runtime_data.nodes],
}
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: HomeeConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
# Extract node_id from the device identifiers
split_uid = next(
identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN
).split("-")
# Homee hub itself only has MAC as identifier and a node_id of -1
node_id = -1 if len(split_uid) < 2 else split_uid[1]
node = entry.runtime_data.get_node_by_id(int(node_id))
assert node is not None
return {
"homee node": node.raw_data,
}

View File

@@ -2,7 +2,9 @@
"config": {
"flow_title": "homee {name} ({host})",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_hub": "Address belongs to a different homee."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -22,6 +24,16 @@
"username": "The username for your homee.",
"password": "The password for your homee."
}
},
"reconfigure": {
"title": "Reconfigure homee {name}",
"description": "Reconfigure the IP address of your homee.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The IP address of your homee."
}
}
}
},

View File

@@ -11,7 +11,7 @@ from homeassistant.helpers.typing import ConfigType
from .bridge import HueBridge, HueConfigEntry
from .const import DOMAIN
from .migration import check_migration
from .services import async_register_services
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -19,7 +19,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Hue integration."""
async_register_services(hass)
async_setup_services(hass)
return True

View File

@@ -25,7 +25,7 @@ from .const import (
LOGGER = logging.getLogger(__name__)
def async_register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for Hue integration."""
async def hue_activate_scene(call: ServiceCall, skip_reload=True) -> None:

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2025.3.0"]
"requirements": ["pydrawise==2025.6.0"]
}

View File

@@ -20,7 +20,7 @@ from .const import (
STORAGE_KEY,
STORAGE_VERSION,
)
from .services import register_services
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up iCloud integration."""
register_services(hass)
async_setup_services(hass)
return True

View File

@@ -115,8 +115,8 @@ def _get_account(hass: HomeAssistant, account_identifier: str) -> IcloudAccount:
return icloud_account
def register_services(hass: HomeAssistant) -> None:
"""Set up an iCloud account from a config entry."""
def async_setup_services(hass: HomeAssistant) -> None:
"""Register iCloud services."""
hass.services.async_register(
DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, schema=SERVICE_SCHEMA_PLAY_SOUND

View File

@@ -0,0 +1,40 @@
"""Imeon inverter base class for entities."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import InverterCoordinator
type InverterConfigEntry = ConfigEntry[InverterCoordinator]
class InverterEntity(CoordinatorEntity[InverterCoordinator]):
"""Common elements for all entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: InverterCoordinator,
entry: InverterConfigEntry,
entity_description: EntityDescription,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._inverter = coordinator.api.inverter
self.data_key = entity_description.key
assert entry.unique_id
self._attr_unique_id = f"{entry.unique_id}_{self.data_key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.unique_id)},
name="Imeon inverter",
manufacturer="Imeon Energy",
model=self._inverter.get("inverter"),
sw_version=self._inverter.get("software"),
serial_number=self._inverter.get("serial"),
configuration_url=self._inverter.get("url"),
)

View File

@@ -21,20 +21,18 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import InverterCoordinator
from .entity import InverterEntity
type InverterConfigEntry = ConfigEntry[InverterCoordinator]
_LOGGER = logging.getLogger(__name__)
ENTITY_DESCRIPTIONS = (
SENSOR_DESCRIPTIONS = (
# Battery
SensorEntityDescription(
key="battery_autonomy",
@@ -423,42 +421,18 @@ async def async_setup_entry(
"""Create each sensor for a given config entry."""
coordinator = entry.runtime_data
# Init sensor entities
async_add_entities(
InverterSensor(coordinator, entry, description)
for description in ENTITY_DESCRIPTIONS
for description in SENSOR_DESCRIPTIONS
)
class InverterSensor(CoordinatorEntity[InverterCoordinator], SensorEntity):
"""A sensor that returns numerical values with units."""
class InverterSensor(InverterEntity, SensorEntity):
"""Representation of an Imeon inverter sensor."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: InverterCoordinator,
entry: InverterConfigEntry,
description: SensorEntityDescription,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
self.entity_description = description
self._inverter = coordinator.api.inverter
self.data_key = description.key
assert entry.unique_id
self._attr_unique_id = f"{entry.unique_id}_{self.data_key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.unique_id)},
name="Imeon inverter",
manufacturer="Imeon Energy",
model=self._inverter.get("inverter"),
sw_version=self._inverter.get("software"),
)
@property
def native_value(self) -> StateType | None:
"""Value of the sensor."""
"""Return the state of the entity."""
return self.coordinator.data.get(self.data_key)

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "silver",
"requirements": ["aioimmich==0.8.0"]
"requirements": ["aioimmich==0.9.1"]
}

View File

@@ -25,7 +25,7 @@ from .const import (
DOMAIN,
INSTEON_PLATFORMS,
)
from .services import async_register_services
from .services import async_setup_services
from .utils import (
add_insteon_events,
get_device_platforms,
@@ -145,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Insteon device count: %s", len(devices))
register_new_device_callback(hass)
async_register_services(hass)
async_setup_services(hass)
create_insteon_device(hass, devices.modem, entry.entry_id)

View File

@@ -86,7 +86,7 @@ _LOGGER = logging.getLogger(__name__)
@callback
def async_register_services(hass: HomeAssistant) -> None: # noqa: C901
def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Register services used by insteon component."""
save_lock = asyncio.Lock()

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyiqvia"],
"requirements": ["numpy==2.2.6", "pyiqvia==2022.04.0"]
"requirements": ["numpy==2.3.0", "pyiqvia==2022.04.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyiskra"],
"requirements": ["pyiskra==0.1.19"]
"requirements": ["pyiskra==0.1.21"]
}

View File

@@ -329,8 +329,8 @@ class JellyfinSource(MediaSource):
movies = await self._get_children(library_id, ITEM_TYPE_MOVIE)
movies = sorted(
movies,
# Sort by whether a movies has an name first, then by name
# This allows for sorting moveis with, without and with missing names
# Sort by whether a movie has a name first, then by name
# This allows for sorting movies with, without and with missing names
key=lambda k: (
ITEM_KEY_NAME not in k,
k.get(ITEM_KEY_NAME),
@@ -388,7 +388,7 @@ class JellyfinSource(MediaSource):
series = await self._get_children(library_id, ITEM_TYPE_SERIES)
series = sorted(
series,
# Sort by whether a seroes has an name first, then by name
# Sort by whether a series has a name first, then by name
# This allows for sorting series with, without and with missing names
key=lambda k: (
ITEM_KEY_NAME not in k,

View File

@@ -131,7 +131,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_backbone_key": "Invalid backbone key. 32 hexadecimal numbers expected.",
"invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.",
"invalid_individual_address": "Value does not match pattern for KNX individual address.\n'area.line.device'",
"invalid_ip_address": "Invalid IPv4 address.",
"keyfile_invalid_signature": "The password to decrypt the `.knxkeys` file is wrong.",

View File

@@ -59,7 +59,7 @@ from .helpers import (
register_lcn_address_devices,
register_lcn_host_device,
)
from .services import register_services
from .services import async_setup_services
from .websocket import register_panel_and_ws_api
_LOGGER = logging.getLogger(__name__)
@@ -71,7 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LCN component."""
hass.data.setdefault(DOMAIN, {})
await register_services(hass)
async_setup_services(hass)
await register_panel_and_ws_api(hass)
return True

View File

@@ -438,7 +438,7 @@ SERVICES = (
)
async def register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for LCN."""
for service_name, service in SERVICES:
hass.services.async_register(

View File

@@ -31,6 +31,9 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle Zeroconf discovery."""
# Do not probe the device if the host is already configured
self._async_abort_entries_match({CONF_HOST: discovery_info.host})
session: ClientSession = await async_get_client_session(self.hass)
bridge: LinkPlayBridge | None = None

View File

@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["linkplay"],
"requirements": ["python-linkplay==0.2.9"],
"requirements": ["python-linkplay==0.2.10"],
"zeroconf": ["_linkplay._tcp.local."]
}

View File

@@ -44,7 +44,8 @@ from homeassistant.helpers.json import save_json
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.json import JsonObjectType, load_json_object
from .const import DOMAIN, FORMAT_HTML, FORMAT_TEXT, SERVICE_SEND_MESSAGE
from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML
from .services import register_services
_LOGGER = logging.getLogger(__name__)
@@ -57,17 +58,11 @@ CONF_WORD: Final = "word"
CONF_EXPRESSION: Final = "expression"
CONF_USERNAME_REGEX = "^@[^:]*:.*"
CONF_ROOMS_REGEX = "^[!|#][^:]*:.*"
EVENT_MATRIX_COMMAND = "matrix_command"
DEFAULT_CONTENT_TYPE = "application/octet-stream"
MESSAGE_FORMATS = [FORMAT_HTML, FORMAT_TEXT]
DEFAULT_MESSAGE_FORMAT = FORMAT_TEXT
ATTR_FORMAT = "format" # optional message format
ATTR_IMAGES = "images" # optional images
WordCommand = NewType("WordCommand", str)
ExpressionCommand = NewType("ExpressionCommand", re.Pattern)
@@ -117,27 +112,12 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema(
{
vol.Required(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_DATA, default={}): {
vol.Optional(ATTR_FORMAT, default=DEFAULT_MESSAGE_FORMAT): vol.In(
MESSAGE_FORMATS
),
vol.Optional(ATTR_IMAGES): vol.All(cv.ensure_list, [cv.string]),
},
vol.Required(ATTR_TARGET): vol.All(
cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)]
),
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Matrix bot component."""
config = config[DOMAIN]
matrix_bot = MatrixBot(
hass.data[DOMAIN] = MatrixBot(
hass,
os.path.join(hass.config.path(), SESSION_FILE),
config[CONF_HOMESERVER],
@@ -147,14 +127,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
config[CONF_ROOMS],
config[CONF_COMMANDS],
)
hass.data[DOMAIN] = matrix_bot
hass.services.async_register(
DOMAIN,
SERVICE_SEND_MESSAGE,
matrix_bot.handle_send_message,
schema=SERVICE_SCHEMA_SEND_MESSAGE,
)
register_services(hass)
return True

View File

@@ -6,3 +6,8 @@ SERVICE_SEND_MESSAGE = "send_message"
FORMAT_HTML = "html"
FORMAT_TEXT = "text"
ATTR_FORMAT = "format" # optional message format
ATTR_IMAGES = "images" # optional images
CONF_ROOMS_REGEX = "^[!|#][^:]*:.*"

View File

@@ -0,0 +1,61 @@
"""The Matrix bot component."""
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_FORMAT,
ATTR_IMAGES,
CONF_ROOMS_REGEX,
DOMAIN,
FORMAT_HTML,
FORMAT_TEXT,
SERVICE_SEND_MESSAGE,
)
if TYPE_CHECKING:
from . import MatrixBot
MESSAGE_FORMATS = [FORMAT_HTML, FORMAT_TEXT]
DEFAULT_MESSAGE_FORMAT = FORMAT_TEXT
SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema(
{
vol.Required(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_DATA, default={}): {
vol.Optional(ATTR_FORMAT, default=DEFAULT_MESSAGE_FORMAT): vol.In(
MESSAGE_FORMATS
),
vol.Optional(ATTR_IMAGES): vol.All(cv.ensure_list, [cv.string]),
},
vol.Required(ATTR_TARGET): vol.All(
cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)]
),
}
)
async def _handle_send_message(call: ServiceCall) -> None:
"""Handle the send_message service call."""
matrix_bot: MatrixBot = call.hass.data[DOMAIN]
await matrix_bot.handle_send_message(call)
def register_services(hass: HomeAssistant) -> None:
"""Set up the Matrix bot component."""
hass.services.async_register(
DOMAIN,
SERVICE_SEND_MESSAGE,
_handle_send_message,
schema=SERVICE_SCHEMA_SEND_MESSAGE,
)

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
"loggers": ["pymodbus"],
"requirements": ["pymodbus==3.8.3"]
"requirements": ["pymodbus==3.9.2"]
}

View File

@@ -8,6 +8,6 @@
"iot_class": "cloud_polling",
"loggers": ["pynordpool"],
"quality_scale": "platinum",
"requirements": ["pynordpool==0.2.4"],
"requirements": ["pynordpool==0.3.0"],
"single_config_entry": true
}

View File

@@ -8,7 +8,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, DOMAIN
from .coordinator import NZBGetDataUpdateCoordinator
from .services import async_register_services
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
@@ -17,7 +17,7 @@ PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up NZBGet integration."""
async_register_services(hass)
async_setup_services(hass)
return True

View File

@@ -48,7 +48,7 @@ def set_speed(call: ServiceCall) -> None:
_get_coordinator(call).nzbget.rate(call.data[ATTR_SPEED])
def async_register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register integration-level services."""
hass.services.async_register(DOMAIN, SERVICE_PAUSE, pause, schema=vol.Schema({}))

View File

@@ -21,6 +21,7 @@ from .const import (
CONF_MODEL,
CONF_NUM_CTX,
CONF_PROMPT,
CONF_THINK,
DEFAULT_TIMEOUT,
DOMAIN,
)
@@ -33,6 +34,7 @@ __all__ = [
"CONF_MODEL",
"CONF_NUM_CTX",
"CONF_PROMPT",
"CONF_THINK",
"CONF_URL",
"DOMAIN",
]

View File

@@ -22,6 +22,7 @@ from homeassistant.const import CONF_LLM_HASS_API, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
@@ -41,10 +42,12 @@ from .const import (
CONF_MODEL,
CONF_NUM_CTX,
CONF_PROMPT,
CONF_THINK,
DEFAULT_KEEP_ALIVE,
DEFAULT_MAX_HISTORY,
DEFAULT_MODEL,
DEFAULT_NUM_CTX,
DEFAULT_THINK,
DEFAULT_TIMEOUT,
DOMAIN,
MAX_NUM_CTX,
@@ -280,6 +283,12 @@ def ollama_config_option_schema(
min=-1, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX
)
),
vol.Optional(
CONF_THINK,
description={
"suggested_value": options.get("think", DEFAULT_THINK),
},
): BooleanSelector(),
}

View File

@@ -4,6 +4,7 @@ DOMAIN = "ollama"
CONF_MODEL = "model"
CONF_PROMPT = "prompt"
CONF_THINK = "think"
CONF_KEEP_ALIVE = "keep_alive"
DEFAULT_KEEP_ALIVE = -1 # seconds. -1 = indefinite, 0 = never
@@ -15,6 +16,7 @@ CONF_NUM_CTX = "num_ctx"
DEFAULT_NUM_CTX = 8192
MIN_NUM_CTX = 2048
MAX_NUM_CTX = 131072
DEFAULT_THINK = False
CONF_MAX_HISTORY = "max_history"
DEFAULT_MAX_HISTORY = 20

View File

@@ -24,6 +24,7 @@ from .const import (
CONF_MODEL,
CONF_NUM_CTX,
CONF_PROMPT,
CONF_THINK,
DEFAULT_KEEP_ALIVE,
DEFAULT_MAX_HISTORY,
DEFAULT_NUM_CTX,
@@ -256,6 +257,7 @@ class OllamaConversationEntity(
# keep_alive requires specifying unit. In this case, seconds
keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s",
options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)},
think=settings.get(CONF_THINK),
)
except (ollama.RequestError, ollama.ResponseError) as err:
_LOGGER.error("Unexpected error talking to Ollama server: %s", err)

View File

@@ -30,12 +30,14 @@
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"max_history": "Max history messages",
"num_ctx": "Context window size",
"keep_alive": "Keep alive"
"keep_alive": "Keep alive",
"think": "Think before responding"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template.",
"keep_alive": "Duration in seconds for Ollama to keep model in memory. -1 = indefinite, 0 = never.",
"num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities."
"num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.",
"think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency."
}
}
}

View File

@@ -34,7 +34,7 @@ from .coordinator import (
OneDriveRuntimeData,
OneDriveUpdateCoordinator,
)
from .services import async_register_services
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR]
@@ -44,7 +44,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the OneDrive integration."""
async_register_services(hass)
async_setup_services(hass)
return True

View File

@@ -70,7 +70,7 @@ def _read_file_contents(
return results
def async_register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register OneDrive services."""
async def async_handle_upload(call: ServiceCall) -> ServiceResponse:

View File

@@ -18,7 +18,7 @@ from .const import (
ListeningMode,
)
from .receiver import Receiver, async_interview
from .services import DATA_MP_ENTITIES, async_register_services
from .services import DATA_MP_ENTITIES, async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -41,7 +41,7 @@ type OnkyoConfigEntry = ConfigEntry[OnkyoData]
async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
"""Set up Onkyo component."""
await async_register_services(hass)
async_setup_services(hass)
return True

View File

@@ -40,7 +40,7 @@ ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema(
SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output"
async def async_register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register Onkyo services."""
hass.data.setdefault(DATA_MP_ENTITIES, {})

View File

@@ -5,7 +5,7 @@ from contextlib import suppress
from http import HTTPStatus
import logging
from httpx import RequestError
import aiohttp
from onvif.exceptions import ONVIFError
from onvif.util import is_auth_error, stringify_onvif_error
from zeep.exceptions import Fault, TransportError
@@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await device.async_setup()
if not entry.data.get(CONF_SNAPSHOT_AUTH):
await async_populate_snapshot_auth(hass, device, entry)
except RequestError as err:
except (TimeoutError, aiohttp.ClientError) as err:
await device.device.close()
raise ConfigEntryNotReady(
f"Could not connect to camera {device.device.host}:{device.device.port}: {err}"
@@ -119,7 +119,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if device.capabilities.events and device.events.started:
try:
await device.events.async_stop()
except (ONVIFError, Fault, RequestError, TransportError):
except (TimeoutError, ONVIFError, Fault, aiohttp.ClientError, TransportError):
LOGGER.warning("Error while stopping events: %s", device.name)
return await hass.config_entries.async_unload_platforms(entry, device.platforms)

View File

@@ -1,8 +1,9 @@
"""Constants for the onvif component."""
import asyncio
import logging
from httpx import RequestError
import aiohttp
from onvif.exceptions import ONVIFError
from zeep.exceptions import Fault, TransportError
@@ -48,4 +49,10 @@ SERVICE_PTZ = "ptz"
# Some cameras don't support the GetServiceCapabilities call
# and will return a 404 error which is caught by TransportError
GET_CAPABILITIES_EXCEPTIONS = (ONVIFError, Fault, RequestError, TransportError)
GET_CAPABILITIES_EXCEPTIONS = (
ONVIFError,
Fault,
aiohttp.ClientError,
asyncio.TimeoutError,
TransportError,
)

View File

@@ -9,7 +9,7 @@ import os
import time
from typing import Any
from httpx import RequestError
import aiohttp
import onvif
from onvif import ONVIFCamera
from onvif.exceptions import ONVIFError
@@ -235,7 +235,7 @@ class ONVIFDevice:
LOGGER.debug("%s: Retrieving current device date/time", self.name)
try:
device_time = await device_mgmt.GetSystemDateAndTime()
except (RequestError, Fault) as err:
except (TimeoutError, aiohttp.ClientError, Fault) as err:
LOGGER.warning(
"Couldn't get device '%s' date/time. Error: %s", self.name, err
)
@@ -303,7 +303,7 @@ class ONVIFDevice:
# Set Date and Time ourselves if Date and Time is set manually in the camera.
try:
await self.async_manually_set_date_and_time()
except (RequestError, TransportError, IndexError, Fault):
except (TimeoutError, aiohttp.ClientError, TransportError, IndexError, Fault):
LOGGER.warning("%s: Could not sync date/time on this camera", self.name)
self._async_log_time_out_of_sync(cam_date_utc, system_date)

View File

@@ -6,8 +6,8 @@ import asyncio
from collections.abc import Callable
import datetime as dt
import aiohttp
from aiohttp.web import Request
from httpx import RemoteProtocolError, RequestError, TransportError
from onvif import ONVIFCamera
from onvif.client import (
NotificationManager,
@@ -16,7 +16,7 @@ from onvif.client import (
)
from onvif.exceptions import ONVIFError
from onvif.util import stringify_onvif_error
from zeep.exceptions import Fault, ValidationError, XMLParseError
from zeep.exceptions import Fault, TransportError, ValidationError, XMLParseError
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
@@ -34,10 +34,23 @@ from .parsers import PARSERS
UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"}
SUBSCRIPTION_ERRORS = (Fault, TimeoutError, TransportError)
CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError)
CREATE_ERRORS = (
ONVIFError,
Fault,
aiohttp.ClientError,
asyncio.TimeoutError,
XMLParseError,
ValidationError,
)
SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError)
UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS)
RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
RENEW_ERRORS = (
ONVIFError,
aiohttp.ClientError,
asyncio.TimeoutError,
XMLParseError,
*SUBSCRIPTION_ERRORS,
)
#
# We only keep the subscription alive for 10 minutes, and will keep
# renewing it every 8 minutes. This is to avoid the camera
@@ -372,13 +385,13 @@ class PullPointManager:
"%s: PullPoint skipped because Home Assistant is not running yet",
self._name,
)
except RemoteProtocolError as err:
except aiohttp.ServerDisconnectedError as err:
# Either a shutdown event or the camera closed the connection. Because
# http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server
# to close the connection at any time, we treat this as a normal. Some
# cameras may close the connection if there are no messages to pull.
LOGGER.debug(
"%s: PullPoint subscription encountered a remote protocol error "
"%s: PullPoint subscription encountered a server disconnected error "
"(this is normal for some cameras): %s",
self._name,
stringify_onvif_error(err),
@@ -394,7 +407,12 @@ class PullPointManager:
# Treat errors as if the camera restarted. Assume that the pullpoint
# subscription is no longer valid.
self._pullpoint_manager.resume()
except (XMLParseError, RequestError, TimeoutError, TransportError) as err:
except (
XMLParseError,
aiohttp.ClientError,
TimeoutError,
TransportError,
) as err:
LOGGER.debug(
"%s: PullPoint subscription encountered an unexpected error and will be retried "
"(this is normal for some cameras): %s",

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/onvif",
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": ["onvif-zeep-async==3.2.5", "WSDiscovery==2.1.2"]
"requirements": ["onvif-zeep-async==4.0.1", "WSDiscovery==2.1.2"]
}

View File

@@ -1,62 +1,40 @@
"""Support for OpenTherm Gateway devices."""
import asyncio
from datetime import date, datetime
import logging
from pyotgw import OpenThermGateway
import pyotgw.vars as gw_vars
from serial import SerialException
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DATE,
ATTR_ID,
ATTR_MODE,
ATTR_TEMPERATURE,
ATTR_TIME,
CONF_DEVICE,
CONF_ID,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_CH_OVRD,
ATTR_DHW_OVRD,
ATTR_GW_ID,
ATTR_LEVEL,
ATTR_TRANSP_ARG,
ATTR_TRANSP_CMD,
CONF_TEMPORARY_OVRD_MODE,
CONNECTION_TIMEOUT,
DATA_GATEWAYS,
DATA_OPENTHERM_GW,
DOMAIN,
SERVICE_RESET_GATEWAY,
SERVICE_SEND_TRANSP_CMD,
SERVICE_SET_CH_OVRD,
SERVICE_SET_CLOCK,
SERVICE_SET_CONTROL_SETPOINT,
SERVICE_SET_GPIO_MODE,
SERVICE_SET_HOT_WATER_OVRD,
SERVICE_SET_HOT_WATER_SETPOINT,
SERVICE_SET_LED_MODE,
SERVICE_SET_MAX_MOD,
SERVICE_SET_OAT,
SERVICE_SET_SB_TEMP,
OpenThermDataSource,
OpenThermDeviceIdentifier,
)
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
@@ -67,6 +45,14 @@ PLATFORMS = [
]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up OpenTherm Gateway integration."""
async_setup_services(hass)
return True
async def options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
gateway = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][entry.data[CONF_ID]]
@@ -95,273 +81,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
register_services(hass)
return True
def register_services(hass: HomeAssistant) -> None:
"""Register services for the component."""
service_reset_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
)
}
)
service_set_central_heating_ovrd_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_CH_OVRD): cv.boolean,
}
)
service_set_clock_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Optional(ATTR_DATE, default=date.today): cv.date,
vol.Optional(ATTR_TIME, default=lambda: datetime.now().time()): cv.time,
}
)
service_set_control_setpoint_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_TEMPERATURE): vol.All(
vol.Coerce(float), vol.Range(min=0, max=90)
),
}
)
service_set_hot_water_setpoint_schema = service_set_control_setpoint_schema
service_set_hot_water_ovrd_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_DHW_OVRD): vol.Any(
vol.Equal("A"), vol.All(vol.Coerce(int), vol.Range(min=0, max=1))
),
}
)
service_set_gpio_mode_schema = vol.Schema(
vol.Any(
vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_ID): vol.Equal("A"),
vol.Required(ATTR_MODE): vol.All(
vol.Coerce(int), vol.Range(min=0, max=6)
),
}
),
vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_ID): vol.Equal("B"),
vol.Required(ATTR_MODE): vol.All(
vol.Coerce(int), vol.Range(min=0, max=7)
),
}
),
)
)
service_set_led_mode_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_ID): vol.In("ABCDEF"),
vol.Required(ATTR_MODE): vol.In("RXTBOFHWCEMP"),
}
)
service_set_max_mod_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_LEVEL): vol.All(
vol.Coerce(int), vol.Range(min=-1, max=100)
),
}
)
service_set_oat_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_TEMPERATURE): vol.All(
vol.Coerce(float), vol.Range(min=-40, max=99)
),
}
)
service_set_sb_temp_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_TEMPERATURE): vol.All(
vol.Coerce(float), vol.Range(min=0, max=30)
),
}
)
service_send_transp_cmd_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(
cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS])
),
vol.Required(ATTR_TRANSP_CMD): vol.All(
cv.string, vol.Length(min=2, max=2), vol.Coerce(str.upper)
),
vol.Required(ATTR_TRANSP_ARG): vol.All(
cv.string, vol.Length(min=1, max=12)
),
}
)
async def reset_gateway(call: ServiceCall) -> None:
"""Reset the OpenTherm Gateway."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
mode_rst = gw_vars.OTGW_MODE_RESET
await gw_hub.gateway.set_mode(mode_rst)
hass.services.async_register(
DOMAIN, SERVICE_RESET_GATEWAY, reset_gateway, service_reset_schema
)
async def set_ch_ovrd(call: ServiceCall) -> None:
"""Set the central heating override on the OpenTherm Gateway."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
await gw_hub.gateway.set_ch_enable_bit(1 if call.data[ATTR_CH_OVRD] else 0)
hass.services.async_register(
DOMAIN,
SERVICE_SET_CH_OVRD,
set_ch_ovrd,
service_set_central_heating_ovrd_schema,
)
async def set_control_setpoint(call: ServiceCall) -> None:
"""Set the control setpoint on the OpenTherm Gateway."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
await gw_hub.gateway.set_control_setpoint(call.data[ATTR_TEMPERATURE])
hass.services.async_register(
DOMAIN,
SERVICE_SET_CONTROL_SETPOINT,
set_control_setpoint,
service_set_control_setpoint_schema,
)
async def set_dhw_ovrd(call: ServiceCall) -> None:
"""Set the domestic hot water override on the OpenTherm Gateway."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
await gw_hub.gateway.set_hot_water_ovrd(call.data[ATTR_DHW_OVRD])
hass.services.async_register(
DOMAIN,
SERVICE_SET_HOT_WATER_OVRD,
set_dhw_ovrd,
service_set_hot_water_ovrd_schema,
)
async def set_dhw_setpoint(call: ServiceCall) -> None:
"""Set the domestic hot water setpoint on the OpenTherm Gateway."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
await gw_hub.gateway.set_dhw_setpoint(call.data[ATTR_TEMPERATURE])
hass.services.async_register(
DOMAIN,
SERVICE_SET_HOT_WATER_SETPOINT,
set_dhw_setpoint,
service_set_hot_water_setpoint_schema,
)
async def set_device_clock(call: ServiceCall) -> None:
"""Set the clock on the OpenTherm Gateway."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
attr_date = call.data[ATTR_DATE]
attr_time = call.data[ATTR_TIME]
await gw_hub.gateway.set_clock(datetime.combine(attr_date, attr_time))
hass.services.async_register(
DOMAIN, SERVICE_SET_CLOCK, set_device_clock, service_set_clock_schema
)
async def set_gpio_mode(call: ServiceCall) -> None:
"""Set the OpenTherm Gateway GPIO modes."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
gpio_id = call.data[ATTR_ID]
gpio_mode = call.data[ATTR_MODE]
await gw_hub.gateway.set_gpio_mode(gpio_id, gpio_mode)
hass.services.async_register(
DOMAIN, SERVICE_SET_GPIO_MODE, set_gpio_mode, service_set_gpio_mode_schema
)
async def set_led_mode(call: ServiceCall) -> None:
"""Set the OpenTherm Gateway LED modes."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
led_id = call.data[ATTR_ID]
led_mode = call.data[ATTR_MODE]
await gw_hub.gateway.set_led_mode(led_id, led_mode)
hass.services.async_register(
DOMAIN, SERVICE_SET_LED_MODE, set_led_mode, service_set_led_mode_schema
)
async def set_max_mod(call: ServiceCall) -> None:
"""Set the max modulation level."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
level = call.data[ATTR_LEVEL]
if level == -1:
# Backend only clears setting on non-numeric values.
level = "-"
await gw_hub.gateway.set_max_relative_mod(level)
hass.services.async_register(
DOMAIN, SERVICE_SET_MAX_MOD, set_max_mod, service_set_max_mod_schema
)
async def set_outside_temp(call: ServiceCall) -> None:
"""Provide the outside temperature to the OpenTherm Gateway."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
await gw_hub.gateway.set_outside_temp(call.data[ATTR_TEMPERATURE])
hass.services.async_register(
DOMAIN, SERVICE_SET_OAT, set_outside_temp, service_set_oat_schema
)
async def set_setback_temp(call: ServiceCall) -> None:
"""Set the OpenTherm Gateway SetBack temperature."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
await gw_hub.gateway.set_setback_temp(call.data[ATTR_TEMPERATURE])
hass.services.async_register(
DOMAIN, SERVICE_SET_SB_TEMP, set_setback_temp, service_set_sb_temp_schema
)
async def send_transparent_cmd(call: ServiceCall) -> None:
"""Send a transparent OpenTherm Gateway command."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]]
transp_cmd = call.data[ATTR_TRANSP_CMD]
transp_arg = call.data[ATTR_TRANSP_ARG]
await gw_hub.gateway.send_transparent_command(transp_cmd, transp_arg)
hass.services.async_register(
DOMAIN,
SERVICE_SEND_TRANSP_CMD,
send_transparent_cmd,
service_send_transp_cmd_schema,
)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Cleanup and disconnect from gateway."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,296 @@
"""Support for OpenTherm Gateway devices."""
from __future__ import annotations
from datetime import date, datetime
from typing import TYPE_CHECKING
import pyotgw.vars as gw_vars
import voluptuous as vol
from homeassistant.const import (
ATTR_DATE,
ATTR_ID,
ATTR_MODE,
ATTR_TEMPERATURE,
ATTR_TIME,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_CH_OVRD,
ATTR_DHW_OVRD,
ATTR_GW_ID,
ATTR_LEVEL,
ATTR_TRANSP_ARG,
ATTR_TRANSP_CMD,
DATA_GATEWAYS,
DATA_OPENTHERM_GW,
DOMAIN,
SERVICE_RESET_GATEWAY,
SERVICE_SEND_TRANSP_CMD,
SERVICE_SET_CH_OVRD,
SERVICE_SET_CLOCK,
SERVICE_SET_CONTROL_SETPOINT,
SERVICE_SET_GPIO_MODE,
SERVICE_SET_HOT_WATER_OVRD,
SERVICE_SET_HOT_WATER_SETPOINT,
SERVICE_SET_LED_MODE,
SERVICE_SET_MAX_MOD,
SERVICE_SET_OAT,
SERVICE_SET_SB_TEMP,
)
if TYPE_CHECKING:
from . import OpenThermGatewayHub
def _get_gateway(call: ServiceCall) -> OpenThermGatewayHub:
gw_id: str = call.data[ATTR_GW_ID]
gw_hub: OpenThermGatewayHub | None = (
call.hass.data.get(DATA_OPENTHERM_GW, {}).get(DATA_GATEWAYS, {}).get(gw_id)
)
if gw_hub is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_gateway_id",
translation_placeholders={"gw_id": gw_id},
)
return gw_hub
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for the component."""
service_reset_schema = vol.Schema({vol.Required(ATTR_GW_ID): vol.All(cv.string)})
service_set_central_heating_ovrd_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_CH_OVRD): cv.boolean,
}
)
service_set_clock_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Optional(ATTR_DATE, default=date.today): cv.date,
vol.Optional(ATTR_TIME, default=lambda: datetime.now().time()): cv.time,
}
)
service_set_control_setpoint_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_TEMPERATURE): vol.All(
vol.Coerce(float), vol.Range(min=0, max=90)
),
}
)
service_set_hot_water_setpoint_schema = service_set_control_setpoint_schema
service_set_hot_water_ovrd_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_DHW_OVRD): vol.Any(
vol.Equal("A"), vol.All(vol.Coerce(int), vol.Range(min=0, max=1))
),
}
)
service_set_gpio_mode_schema = vol.Schema(
vol.Any(
vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_ID): vol.Equal("A"),
vol.Required(ATTR_MODE): vol.All(
vol.Coerce(int), vol.Range(min=0, max=6)
),
}
),
vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_ID): vol.Equal("B"),
vol.Required(ATTR_MODE): vol.All(
vol.Coerce(int), vol.Range(min=0, max=7)
),
}
),
)
)
service_set_led_mode_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_ID): vol.In("ABCDEF"),
vol.Required(ATTR_MODE): vol.In("RXTBOFHWCEMP"),
}
)
service_set_max_mod_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_LEVEL): vol.All(
vol.Coerce(int), vol.Range(min=-1, max=100)
),
}
)
service_set_oat_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_TEMPERATURE): vol.All(
vol.Coerce(float), vol.Range(min=-40, max=99)
),
}
)
service_set_sb_temp_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_TEMPERATURE): vol.All(
vol.Coerce(float), vol.Range(min=0, max=30)
),
}
)
service_send_transp_cmd_schema = vol.Schema(
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Required(ATTR_TRANSP_CMD): vol.All(
cv.string, vol.Length(min=2, max=2), vol.Coerce(str.upper)
),
vol.Required(ATTR_TRANSP_ARG): vol.All(
cv.string, vol.Length(min=1, max=12)
),
}
)
async def reset_gateway(call: ServiceCall) -> None:
"""Reset the OpenTherm Gateway."""
gw_hub = _get_gateway(call)
mode_rst = gw_vars.OTGW_MODE_RESET
await gw_hub.gateway.set_mode(mode_rst)
hass.services.async_register(
DOMAIN, SERVICE_RESET_GATEWAY, reset_gateway, service_reset_schema
)
async def set_ch_ovrd(call: ServiceCall) -> None:
"""Set the central heating override on the OpenTherm Gateway."""
gw_hub = _get_gateway(call)
await gw_hub.gateway.set_ch_enable_bit(1 if call.data[ATTR_CH_OVRD] else 0)
hass.services.async_register(
DOMAIN,
SERVICE_SET_CH_OVRD,
set_ch_ovrd,
service_set_central_heating_ovrd_schema,
)
async def set_control_setpoint(call: ServiceCall) -> None:
"""Set the control setpoint on the OpenTherm Gateway."""
gw_hub = _get_gateway(call)
await gw_hub.gateway.set_control_setpoint(call.data[ATTR_TEMPERATURE])
hass.services.async_register(
DOMAIN,
SERVICE_SET_CONTROL_SETPOINT,
set_control_setpoint,
service_set_control_setpoint_schema,
)
async def set_dhw_ovrd(call: ServiceCall) -> None:
"""Set the domestic hot water override on the OpenTherm Gateway."""
gw_hub = _get_gateway(call)
await gw_hub.gateway.set_hot_water_ovrd(call.data[ATTR_DHW_OVRD])
hass.services.async_register(
DOMAIN,
SERVICE_SET_HOT_WATER_OVRD,
set_dhw_ovrd,
service_set_hot_water_ovrd_schema,
)
async def set_dhw_setpoint(call: ServiceCall) -> None:
"""Set the domestic hot water setpoint on the OpenTherm Gateway."""
gw_hub = _get_gateway(call)
await gw_hub.gateway.set_dhw_setpoint(call.data[ATTR_TEMPERATURE])
hass.services.async_register(
DOMAIN,
SERVICE_SET_HOT_WATER_SETPOINT,
set_dhw_setpoint,
service_set_hot_water_setpoint_schema,
)
async def set_device_clock(call: ServiceCall) -> None:
"""Set the clock on the OpenTherm Gateway."""
gw_hub = _get_gateway(call)
attr_date = call.data[ATTR_DATE]
attr_time = call.data[ATTR_TIME]
await gw_hub.gateway.set_clock(datetime.combine(attr_date, attr_time))
hass.services.async_register(
DOMAIN, SERVICE_SET_CLOCK, set_device_clock, service_set_clock_schema
)
async def set_gpio_mode(call: ServiceCall) -> None:
"""Set the OpenTherm Gateway GPIO modes."""
gw_hub = _get_gateway(call)
gpio_id = call.data[ATTR_ID]
gpio_mode = call.data[ATTR_MODE]
await gw_hub.gateway.set_gpio_mode(gpio_id, gpio_mode)
hass.services.async_register(
DOMAIN, SERVICE_SET_GPIO_MODE, set_gpio_mode, service_set_gpio_mode_schema
)
async def set_led_mode(call: ServiceCall) -> None:
"""Set the OpenTherm Gateway LED modes."""
gw_hub = _get_gateway(call)
led_id = call.data[ATTR_ID]
led_mode = call.data[ATTR_MODE]
await gw_hub.gateway.set_led_mode(led_id, led_mode)
hass.services.async_register(
DOMAIN, SERVICE_SET_LED_MODE, set_led_mode, service_set_led_mode_schema
)
async def set_max_mod(call: ServiceCall) -> None:
"""Set the max modulation level."""
gw_hub = _get_gateway(call)
level = call.data[ATTR_LEVEL]
if level == -1:
# Backend only clears setting on non-numeric values.
level = "-"
await gw_hub.gateway.set_max_relative_mod(level)
hass.services.async_register(
DOMAIN, SERVICE_SET_MAX_MOD, set_max_mod, service_set_max_mod_schema
)
async def set_outside_temp(call: ServiceCall) -> None:
"""Provide the outside temperature to the OpenTherm Gateway."""
gw_hub = _get_gateway(call)
await gw_hub.gateway.set_outside_temp(call.data[ATTR_TEMPERATURE])
hass.services.async_register(
DOMAIN, SERVICE_SET_OAT, set_outside_temp, service_set_oat_schema
)
async def set_setback_temp(call: ServiceCall) -> None:
"""Set the OpenTherm Gateway SetBack temperature."""
gw_hub = _get_gateway(call)
await gw_hub.gateway.set_setback_temp(call.data[ATTR_TEMPERATURE])
hass.services.async_register(
DOMAIN, SERVICE_SET_SB_TEMP, set_setback_temp, service_set_sb_temp_schema
)
async def send_transparent_cmd(call: ServiceCall) -> None:
"""Send a transparent OpenTherm Gateway command."""
gw_hub = _get_gateway(call)
transp_cmd = call.data[ATTR_TRANSP_CMD]
transp_arg = call.data[ATTR_TRANSP_ARG]
await gw_hub.gateway.send_transparent_command(transp_cmd, transp_arg)
hass.services.async_register(
DOMAIN,
SERVICE_SEND_TRANSP_CMD,
send_transparent_cmd,
service_send_transp_cmd_schema,
)

View File

@@ -354,6 +354,11 @@
}
}
},
"exceptions": {
"invalid_gateway_id": {
"message": "Gateway {gw_id} not found or not loaded!"
}
},
"options": {
"step": {
"init": {

View File

@@ -10,7 +10,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
DEGREE,
PERCENTAGE,
UV_INDEX,
@@ -170,7 +169,7 @@ AIRPOLLUTION_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
),
SensorEntityDescription(
key=ATTR_API_AIRPOLLUTION_CO,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.CO,
state_class=SensorStateClass.MEASUREMENT,
),

View File

@@ -10,7 +10,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import CONF_API, CONF_COORDINATOR, DOMAIN
from .coordinator import PicnicUpdateCoordinator
from .services import async_register_services
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR, Platform.TODO]
@@ -19,7 +19,7 @@ PLATFORMS = [Platform.SENSOR, Platform.TODO]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Picnic integration."""
await async_register_services(hass)
async_setup_services(hass)
return True

View File

@@ -26,7 +26,7 @@ class PicnicServiceException(Exception):
"""Exception for Picnic services."""
async def async_register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for the Picnic integration, if not registered yet."""
async def async_add_product_service(call: ServiceCall):

View File

@@ -29,7 +29,7 @@ from homeassistant.util.json import JsonObjectType, load_json_object
from .config_flow import PlayStation4FlowHandler # noqa: F401
from .const import ATTR_MEDIA_IMAGE_URL, COUNTRYCODE_NAMES, DOMAIN, GAMES_FILE, PS4_DATA
from .services import register_services
from .services import async_setup_services
if TYPE_CHECKING:
from .media_player import PS4Device
@@ -58,7 +58,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
protocol=protocol,
)
_LOGGER.debug("PS4 DDP endpoint created: %s, %s", transport, protocol)
register_services(hass)
async_setup_services(hass)
return True

View File

@@ -29,7 +29,7 @@ async def async_service_command(call: ServiceCall) -> None:
await device.async_send_command(command)
def register_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Handle for services."""
hass.services.async_register(

View File

@@ -9,7 +9,7 @@ from datetime import timedelta
import logging
from typing import Any
import httpx
import aiohttp
import voluptuous as vol
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
@@ -211,10 +211,10 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res
if not resource:
raise HomeAssistantError("Resource not set for RestData")
auth: httpx.DigestAuth | tuple[str, str] | None = None
auth: aiohttp.DigestAuthMiddleware | tuple[str, str] | None = None
if username and password:
if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
auth = httpx.DigestAuth(username, password)
auth = aiohttp.DigestAuthMiddleware(username, password)
else:
auth = (username, password)

View File

@@ -3,14 +3,15 @@
from __future__ import annotations
import logging
import ssl
from typing import Any
import httpx
import aiohttp
from multidict import CIMultiDictProxy
import xmltodict
from homeassistant.core import HomeAssistant
from homeassistant.helpers import template
from homeassistant.helpers.httpx_client import create_async_httpx_client
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.json import json_dumps
from homeassistant.util.ssl import SSLCipherList
@@ -30,7 +31,7 @@ class RestData:
method: str,
resource: str,
encoding: str,
auth: httpx.DigestAuth | tuple[str, str] | None,
auth: aiohttp.DigestAuthMiddleware | aiohttp.BasicAuth | tuple[str, str] | None,
headers: dict[str, str] | None,
params: dict[str, str] | None,
data: str | None,
@@ -43,17 +44,25 @@ class RestData:
self._method = method
self._resource = resource
self._encoding = encoding
self._auth = auth
# Convert auth tuple to aiohttp.BasicAuth if needed
if isinstance(auth, tuple) and len(auth) == 2:
self._auth: aiohttp.BasicAuth | aiohttp.DigestAuthMiddleware | None = (
aiohttp.BasicAuth(auth[0], auth[1])
)
else:
self._auth = auth
self._headers = headers
self._params = params
self._request_data = data
self._timeout = timeout
self._timeout = aiohttp.ClientTimeout(total=timeout)
self._verify_ssl = verify_ssl
self._ssl_cipher_list = SSLCipherList(ssl_cipher_list)
self._async_client: httpx.AsyncClient | None = None
self._session: aiohttp.ClientSession | None = None
self.data: str | None = None
self.last_exception: Exception | None = None
self.headers: httpx.Headers | None = None
self.headers: CIMultiDictProxy[str] | None = None
def set_payload(self, payload: str) -> None:
"""Set request data."""
@@ -84,38 +93,49 @@ class RestData:
async def async_update(self, log_errors: bool = True) -> None:
"""Get the latest data from REST service with provided method."""
if not self._async_client:
self._async_client = create_async_httpx_client(
if not self._session:
self._session = async_get_clientsession(
self._hass,
verify_ssl=self._verify_ssl,
default_encoding=self._encoding,
ssl_cipher_list=self._ssl_cipher_list,
ssl_cipher=self._ssl_cipher_list,
)
rendered_headers = template.render_complex(self._headers, parse_result=False)
rendered_params = template.render_complex(self._params)
_LOGGER.debug("Updating from %s", self._resource)
# Create request kwargs
request_kwargs: dict[str, Any] = {
"headers": rendered_headers,
"params": rendered_params,
"timeout": self._timeout,
}
# Handle authentication
if isinstance(self._auth, aiohttp.BasicAuth):
request_kwargs["auth"] = self._auth
elif isinstance(self._auth, aiohttp.DigestAuthMiddleware):
request_kwargs["middlewares"] = (self._auth,)
# Handle data/content
if self._request_data:
request_kwargs["data"] = self._request_data
try:
response = await self._async_client.request(
self._method,
self._resource,
headers=rendered_headers,
params=rendered_params,
auth=self._auth,
content=self._request_data,
timeout=self._timeout,
follow_redirects=True,
)
self.data = response.text
self.headers = response.headers
except httpx.TimeoutException as ex:
# Make the request
async with self._session.request(
self._method, self._resource, **request_kwargs
) as response:
# Read the response
self.data = await response.text(encoding=self._encoding)
self.headers = response.headers
except TimeoutError as ex:
if log_errors:
_LOGGER.error("Timeout while fetching data: %s", self._resource)
self.last_exception = ex
self.data = None
self.headers = None
except httpx.RequestError as ex:
except aiohttp.ClientError as ex:
if log_errors:
_LOGGER.error(
"Error fetching data: %s failed with %s", self._resource, ex
@@ -123,11 +143,3 @@ class RestData:
self.last_exception = ex
self.data = None
self.headers = None
except ssl.SSLError as ex:
if log_errors:
_LOGGER.error(
"Error connecting to %s failed with %s", self._resource, ex
)
self.last_exception = ex
self.data = None
self.headers = None

View File

@@ -34,10 +34,10 @@
"title": "Set up SMA Solar"
},
"discovery_confirm": {
"title": "[%key:component::sma::config::step::user::title]",
"description": "Do you want to setup the discovered SMA ({host})?",
"title": "[%key:component::sma::config::step::user::title%]",
"description": "Do you want to set up the discovered SMA device ({host})?",
"data": {
"group": "[%key:component::sma::config::step::user::data::group]",
"group": "[%key:component::sma::config::step::user::data::group%]",
"password": "[%key:common::config_flow::data::password%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"

View File

@@ -6,7 +6,7 @@ DOMAIN = "smarla"
HOST = "https://devices.swing2sleep.de"
PLATFORMS = [Platform.SWITCH]
PLATFORMS = [Platform.NUMBER, Platform.SWITCH]
DEVICE_MODEL_NAME = "Smarla"
MANUFACTURER_NAME = "Swing2Sleep"

View File

@@ -4,6 +4,11 @@
"smart_mode": {
"default": "mdi:refresh-auto"
}
},
"number": {
"intensity": {
"default": "mdi:sine-wave"
}
}
}
}

View File

@@ -0,0 +1,62 @@
"""Support for the Swing2Sleep Smarla number entities."""
from dataclasses import dataclass
from pysmarlaapi.federwiege.classes import Property
from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FederwiegeConfigEntry
from .entity import SmarlaBaseEntity, SmarlaEntityDescription
@dataclass(frozen=True, kw_only=True)
class SmarlaNumberEntityDescription(SmarlaEntityDescription, NumberEntityDescription):
"""Class describing Swing2Sleep Smarla number entities."""
NUMBERS: list[SmarlaNumberEntityDescription] = [
SmarlaNumberEntityDescription(
key="intensity",
translation_key="intensity",
service="babywiege",
property="intensity",
native_max_value=100,
native_min_value=0,
native_step=1,
mode=NumberMode.SLIDER,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FederwiegeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smarla numbers from config entry."""
federwiege = config_entry.runtime_data
async_add_entities(SmarlaNumber(federwiege, desc) for desc in NUMBERS)
class SmarlaNumber(SmarlaBaseEntity, NumberEntity):
"""Representation of Smarla number."""
entity_description: SmarlaNumberEntityDescription
_property: Property[int]
@property
def native_value(self) -> float:
"""Return the entity value to represent the entity state."""
return self._property.get()
def set_native_value(self, value: float) -> None:
"""Update to the smarla device."""
self._property.set(int(value))

View File

@@ -23,6 +23,11 @@
"smart_mode": {
"name": "Smart Mode"
}
},
"number": {
"intensity": {
"name": "Intensity"
}
}
}
}

View File

@@ -8,6 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["spotifyaio"],
"requirements": ["spotifyaio==0.8.11"],
"zeroconf": ["_spotify-connect._tcp.local."]
"requirements": ["spotifyaio==0.8.11"]
}

View File

@@ -7,9 +7,6 @@
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Spotify integration needs to re-authenticate with Spotify for account: {account}"
},
"oauth_discovery": {
"description": "Home Assistant has found Spotify on your network. Press **Submit** to continue setting up Spotify."
}
},
"abort": {

View File

@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.2.6"]
"requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.3.0"]
}

View File

@@ -9,11 +9,11 @@ import voluptuous as vol
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.event import async_track_entity_registry_updated_event
from .const import CONF_INVERT, CONF_TARGET_DOMAIN
from .const import CONF_INVERT, CONF_TARGET_DOMAIN, DOMAIN
from .light import LightSwitch
__all__ = ["LightSwitch"]
@@ -44,10 +44,12 @@ def async_add_to_device(
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
registry = er.async_get(hass)
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
try:
entity_id = er.async_validate_entity_id(registry, entry.options[CONF_ENTITY_ID])
entity_id = er.async_validate_entity_id(
entity_registry, entry.options[CONF_ENTITY_ID]
)
except vol.Invalid:
# The entity is identified by an unknown entity registry ID
_LOGGER.error(
@@ -68,24 +70,51 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return
if "entity_id" in data["changes"]:
# Entity_id changed, reload the config entry
await hass.config_entries.async_reload(entry.entry_id)
# Entity_id changed, update or reload the config entry
if valid_entity_id(entry.options[CONF_ENTITY_ID]):
# If the entity is pointed to by an entity ID, update the entry
hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_ENTITY_ID: data["entity_id"]},
)
else:
await hass.config_entries.async_reload(entry.entry_id)
if device_id and "device_id" in data["changes"]:
# If the tracked switch is no longer in the device, remove our config entry
# Handle the wrapped switch being moved to a different device or removed
# from the device
if (
not (entity_entry := registry.async_get(data[CONF_ENTITY_ID]))
not (entity_entry := entity_registry.async_get(data[CONF_ENTITY_ID]))
or not device_registry.async_get(device_id)
or entity_entry.device_id == device_id
):
# No need to do any cleanup
return
# The wrapped switch has been moved to a different device, update the
# switch_as_x entity and the device entry to include our config entry
switch_as_x_entity_id = entity_registry.async_get_entity_id(
entry.options[CONF_TARGET_DOMAIN], DOMAIN, entry.entry_id
)
if switch_as_x_entity_id:
# Update the switch_as_x entity to point to the new device (or no device)
entity_registry.async_update_entity(
switch_as_x_entity_id, device_id=entity_entry.device_id
)
if entity_entry.device_id is not None:
device_registry.async_update_device(
entity_entry.device_id, add_config_entry_id=entry.entry_id
)
device_registry.async_update_device(
device_id, remove_config_entry_id=entry.entry_id
)
# Reload the config entry so the switch_as_x entity is recreated with
# correct device info
await hass.config_entries.async_reload(entry.entry_id)
entry.async_on_unload(
async_track_entity_registry_updated_event(
hass, entity_id, async_registry_updated

View File

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

View File

@@ -19,6 +19,7 @@ from homeassistant.const import (
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
)
@@ -94,7 +95,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
),
"current": SensorEntityDescription(
key="current",
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
),
@@ -110,6 +111,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in AirQualityLevel],
),
"energy": SensorEntityDescription(
key="energy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
),
}

View File

@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["switchbot_api"],
"requirements": ["switchbot-api==2.4.0"]
"requirements": ["switchbot-api==2.5.0"]
}

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",
"iot_class": "local_polling",
"loggers": ["synology_dsm"],
"requirements": ["py-synologydsm-api==2.7.2"],
"requirements": ["py-synologydsm-api==2.7.3"],
"ssdp": [
{
"manufacturer": "Synology",

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