mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 21:27:38 +00:00
2024.5.2 (#116937)
This commit is contained in:
commit
a8f3b699b3
@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||||
|
|
||||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MAX_RETRIES_AFTER_STARTUP
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
|
|
||||||
@ -61,6 +61,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
# Once its setup and we know we are not going to delay
|
||||||
|
# the startup of Home Assistant, we can set the max attempts
|
||||||
|
# to a higher value. If the first connection attempt fails,
|
||||||
|
# Home Assistant's built-in retry logic will take over.
|
||||||
|
airthings.set_max_attempts(MAX_RETRIES_AFTER_STARTUP)
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
@ -7,3 +7,5 @@ VOLUME_BECQUEREL = "Bq/m³"
|
|||||||
VOLUME_PICOCURIE = "pCi/L"
|
VOLUME_PICOCURIE = "pCi/L"
|
||||||
|
|
||||||
DEFAULT_SCAN_INTERVAL = 300
|
DEFAULT_SCAN_INTERVAL = 300
|
||||||
|
|
||||||
|
MAX_RETRIES_AFTER_STARTUP = 5
|
||||||
|
@ -24,5 +24,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["airthings-ble==0.8.0"]
|
"requirements": ["airthings-ble==0.9.0"]
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ from homeassistant.const import (
|
|||||||
HTTP_BASIC_AUTHENTICATION,
|
HTTP_BASIC_AUTHENTICATION,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.exceptions import Unauthorized, UnknownUser
|
from homeassistant.exceptions import Unauthorized, UnknownUser
|
||||||
from homeassistant.helpers import discovery
|
from homeassistant.helpers import discovery
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
@ -177,7 +177,8 @@ class AmcrestChecker(ApiWrapper):
|
|||||||
"""Return event flag that indicates if camera's API is responding."""
|
"""Return event flag that indicates if camera's API is responding."""
|
||||||
return self._async_wrap_event_flag
|
return self._async_wrap_event_flag
|
||||||
|
|
||||||
def _start_recovery(self) -> None:
|
@callback
|
||||||
|
def _async_start_recovery(self) -> None:
|
||||||
self.available_flag.clear()
|
self.available_flag.clear()
|
||||||
self.async_available_flag.clear()
|
self.async_available_flag.clear()
|
||||||
async_dispatcher_send(
|
async_dispatcher_send(
|
||||||
@ -222,50 +223,98 @@ class AmcrestChecker(ApiWrapper):
|
|||||||
yield
|
yield
|
||||||
except LoginError as ex:
|
except LoginError as ex:
|
||||||
async with self._async_wrap_lock:
|
async with self._async_wrap_lock:
|
||||||
self._handle_offline(ex)
|
self._async_handle_offline(ex)
|
||||||
raise
|
raise
|
||||||
except AmcrestError:
|
except AmcrestError:
|
||||||
async with self._async_wrap_lock:
|
async with self._async_wrap_lock:
|
||||||
self._handle_error()
|
self._async_handle_error()
|
||||||
raise
|
raise
|
||||||
async with self._async_wrap_lock:
|
async with self._async_wrap_lock:
|
||||||
self._set_online()
|
self._async_set_online()
|
||||||
|
|
||||||
def _handle_offline(self, ex: Exception) -> None:
|
def _handle_offline_thread_safe(self, ex: Exception) -> bool:
|
||||||
|
"""Handle camera offline status shared between threads and event loop.
|
||||||
|
|
||||||
|
Returns if the camera was online as a bool.
|
||||||
|
"""
|
||||||
with self._wrap_lock:
|
with self._wrap_lock:
|
||||||
was_online = self.available
|
was_online = self.available
|
||||||
was_login_err = self._wrap_login_err
|
was_login_err = self._wrap_login_err
|
||||||
self._wrap_login_err = True
|
self._wrap_login_err = True
|
||||||
if not was_login_err:
|
if not was_login_err:
|
||||||
_LOGGER.error("%s camera offline: Login error: %s", self._wrap_name, ex)
|
_LOGGER.error("%s camera offline: Login error: %s", self._wrap_name, ex)
|
||||||
if was_online:
|
return was_online
|
||||||
self._start_recovery()
|
|
||||||
|
|
||||||
def _handle_error(self) -> None:
|
def _handle_offline(self, ex: Exception) -> None:
|
||||||
|
"""Handle camera offline status from a thread."""
|
||||||
|
if self._handle_offline_thread_safe(ex):
|
||||||
|
self._hass.loop.call_soon_threadsafe(self._async_start_recovery)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_handle_offline(self, ex: Exception) -> None:
|
||||||
|
if self._handle_offline_thread_safe(ex):
|
||||||
|
self._async_start_recovery()
|
||||||
|
|
||||||
|
def _handle_error_thread_safe(self) -> bool:
|
||||||
|
"""Handle camera error status shared between threads and event loop.
|
||||||
|
|
||||||
|
Returns if the camera was online and is now offline as
|
||||||
|
a bool.
|
||||||
|
"""
|
||||||
with self._wrap_lock:
|
with self._wrap_lock:
|
||||||
was_online = self.available
|
was_online = self.available
|
||||||
errs = self._wrap_errors = self._wrap_errors + 1
|
errs = self._wrap_errors = self._wrap_errors + 1
|
||||||
offline = not self.available
|
offline = not self.available
|
||||||
_LOGGER.debug("%s camera errs: %i", self._wrap_name, errs)
|
_LOGGER.debug("%s camera errs: %i", self._wrap_name, errs)
|
||||||
if was_online and offline:
|
return was_online and offline
|
||||||
_LOGGER.error("%s camera offline: Too many errors", self._wrap_name)
|
|
||||||
self._start_recovery()
|
|
||||||
|
|
||||||
def _set_online(self) -> None:
|
def _handle_error(self) -> None:
|
||||||
|
"""Handle camera error status from a thread."""
|
||||||
|
if self._handle_error_thread_safe():
|
||||||
|
_LOGGER.error("%s camera offline: Too many errors", self._wrap_name)
|
||||||
|
self._hass.loop.call_soon_threadsafe(self._async_start_recovery)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_handle_error(self) -> None:
|
||||||
|
"""Handle camera error status from the event loop."""
|
||||||
|
if self._handle_error_thread_safe():
|
||||||
|
_LOGGER.error("%s camera offline: Too many errors", self._wrap_name)
|
||||||
|
self._async_start_recovery()
|
||||||
|
|
||||||
|
def _set_online_thread_safe(self) -> bool:
|
||||||
|
"""Set camera online status shared between threads and event loop.
|
||||||
|
|
||||||
|
Returns if the camera was offline as a bool.
|
||||||
|
"""
|
||||||
with self._wrap_lock:
|
with self._wrap_lock:
|
||||||
was_offline = not self.available
|
was_offline = not self.available
|
||||||
self._wrap_errors = 0
|
self._wrap_errors = 0
|
||||||
self._wrap_login_err = False
|
self._wrap_login_err = False
|
||||||
if was_offline:
|
return was_offline
|
||||||
assert self._unsub_recheck is not None
|
|
||||||
self._unsub_recheck()
|
def _set_online(self) -> None:
|
||||||
self._unsub_recheck = None
|
"""Set camera online status from a thread."""
|
||||||
_LOGGER.error("%s camera back online", self._wrap_name)
|
if self._set_online_thread_safe():
|
||||||
self.available_flag.set()
|
self._hass.loop.call_soon_threadsafe(self._async_signal_online)
|
||||||
self.async_available_flag.set()
|
|
||||||
async_dispatcher_send(
|
@callback
|
||||||
self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)
|
def _async_set_online(self) -> None:
|
||||||
)
|
"""Set camera online status from the event loop."""
|
||||||
|
if self._set_online_thread_safe():
|
||||||
|
self._async_signal_online()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_signal_online(self) -> None:
|
||||||
|
"""Signal that camera is back online."""
|
||||||
|
assert self._unsub_recheck is not None
|
||||||
|
self._unsub_recheck()
|
||||||
|
self._unsub_recheck = None
|
||||||
|
_LOGGER.error("%s camera back online", self._wrap_name)
|
||||||
|
self.available_flag.set()
|
||||||
|
self.async_available_flag.set()
|
||||||
|
async_dispatcher_send(
|
||||||
|
self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)
|
||||||
|
)
|
||||||
|
|
||||||
async def _wrap_test_online(self, now: datetime) -> None:
|
async def _wrap_test_online(self, now: datetime) -> None:
|
||||||
"""Test if camera is back online."""
|
"""Test if camera is back online."""
|
||||||
|
@ -16,7 +16,7 @@ import voluptuous as vol
|
|||||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||||
from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager
|
from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON
|
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import (
|
from homeassistant.helpers.aiohttp_client import (
|
||||||
async_aiohttp_proxy_stream,
|
async_aiohttp_proxy_stream,
|
||||||
@ -325,7 +325,8 @@ class AmcrestCam(Camera):
|
|||||||
|
|
||||||
# Other Entity method overrides
|
# Other Entity method overrides
|
||||||
|
|
||||||
async def async_on_demand_update(self) -> None:
|
@callback
|
||||||
|
def async_on_demand_update(self) -> None:
|
||||||
"""Update state."""
|
"""Update state."""
|
||||||
self.async_schedule_update_ha_state(True)
|
self.async_schedule_update_ha_state(True)
|
||||||
|
|
||||||
|
@ -8,6 +8,6 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["androidtvremote2"],
|
"loggers": ["androidtvremote2"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["androidtvremote2==0.0.14"],
|
"requirements": ["androidtvremote2==0.0.15"],
|
||||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
"requirements": [
|
"requirements": [
|
||||||
"bleak==0.21.1",
|
"bleak==0.21.1",
|
||||||
"bleak-retry-connector==3.5.0",
|
"bleak-retry-connector==3.5.0",
|
||||||
"bluetooth-adapters==0.19.1",
|
"bluetooth-adapters==0.19.2",
|
||||||
"bluetooth-auto-recovery==1.4.2",
|
"bluetooth-auto-recovery==1.4.2",
|
||||||
"bluetooth-data-tools==1.19.0",
|
"bluetooth-data-tools==1.19.0",
|
||||||
"dbus-fast==2.21.1",
|
"dbus-fast==2.21.1",
|
||||||
|
@ -43,21 +43,21 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = {
|
|||||||
"smartplug": SHCSwitchEntityDescription(
|
"smartplug": SHCSwitchEntityDescription(
|
||||||
key="smartplug",
|
key="smartplug",
|
||||||
device_class=SwitchDeviceClass.OUTLET,
|
device_class=SwitchDeviceClass.OUTLET,
|
||||||
on_key="state",
|
on_key="switchstate",
|
||||||
on_value=SHCSmartPlug.PowerSwitchService.State.ON,
|
on_value=SHCSmartPlug.PowerSwitchService.State.ON,
|
||||||
should_poll=False,
|
should_poll=False,
|
||||||
),
|
),
|
||||||
"smartplugcompact": SHCSwitchEntityDescription(
|
"smartplugcompact": SHCSwitchEntityDescription(
|
||||||
key="smartplugcompact",
|
key="smartplugcompact",
|
||||||
device_class=SwitchDeviceClass.OUTLET,
|
device_class=SwitchDeviceClass.OUTLET,
|
||||||
on_key="state",
|
on_key="switchstate",
|
||||||
on_value=SHCSmartPlugCompact.PowerSwitchService.State.ON,
|
on_value=SHCSmartPlugCompact.PowerSwitchService.State.ON,
|
||||||
should_poll=False,
|
should_poll=False,
|
||||||
),
|
),
|
||||||
"lightswitch": SHCSwitchEntityDescription(
|
"lightswitch": SHCSwitchEntityDescription(
|
||||||
key="lightswitch",
|
key="lightswitch",
|
||||||
device_class=SwitchDeviceClass.SWITCH,
|
device_class=SwitchDeviceClass.SWITCH,
|
||||||
on_key="state",
|
on_key="switchstate",
|
||||||
on_value=SHCLightSwitch.PowerSwitchService.State.ON,
|
on_value=SHCLightSwitch.PowerSwitchService.State.ON,
|
||||||
should_poll=False,
|
should_poll=False,
|
||||||
),
|
),
|
||||||
|
@ -142,6 +142,9 @@ async def websocket_list_agents(
|
|||||||
agent = manager.async_get_agent(agent_info.id)
|
agent = manager.async_get_agent(agent_info.id)
|
||||||
assert agent is not None
|
assert agent is not None
|
||||||
|
|
||||||
|
if isinstance(agent, ConversationEntity):
|
||||||
|
continue
|
||||||
|
|
||||||
supported_languages = agent.supported_languages
|
supported_languages = agent.supported_languages
|
||||||
if language and supported_languages != MATCH_ALL:
|
if language and supported_languages != MATCH_ALL:
|
||||||
supported_languages = language_util.matches(
|
supported_languages = language_util.matches(
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20240501.0"]
|
"requirements": ["home-assistant-frontend==20240501.1"]
|
||||||
}
|
}
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/goodwe",
|
"documentation": "https://www.home-assistant.io/integrations/goodwe",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["goodwe"],
|
"loggers": ["goodwe"],
|
||||||
"requirements": ["goodwe==0.3.2"]
|
"requirements": ["goodwe==0.3.4"]
|
||||||
}
|
}
|
||||||
|
@ -75,7 +75,7 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Optional(CONF_FOLDER, default="INBOX"): str,
|
vol.Optional(CONF_FOLDER, default="INBOX"): str,
|
||||||
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
|
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
|
||||||
# The default for new entries is to not include text and headers
|
# The default for new entries is to not include text and headers
|
||||||
vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): cv.ensure_list,
|
vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
CONFIG_SCHEMA_ADVANCED = {
|
CONFIG_SCHEMA_ADVANCED = {
|
||||||
|
@ -106,4 +106,4 @@ class LutronEventEntity(LutronKeypad, EventEntity):
|
|||||||
}
|
}
|
||||||
self.hass.bus.fire("lutron_event", data)
|
self.hass.bus.fire("lutron_event", data)
|
||||||
self._trigger_event(action)
|
self._trigger_event(action)
|
||||||
self.async_write_ha_state()
|
self.schedule_update_ha_state()
|
||||||
|
@ -84,7 +84,7 @@ if TYPE_CHECKING:
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DISCOVERY_COOLDOWN = 5
|
DISCOVERY_COOLDOWN = 5
|
||||||
INITIAL_SUBSCRIBE_COOLDOWN = 1.0
|
INITIAL_SUBSCRIBE_COOLDOWN = 3.0
|
||||||
SUBSCRIBE_COOLDOWN = 0.1
|
SUBSCRIBE_COOLDOWN = 0.1
|
||||||
UNSUBSCRIBE_COOLDOWN = 0.1
|
UNSUBSCRIBE_COOLDOWN = 0.1
|
||||||
TIMEOUT_ACK = 10
|
TIMEOUT_ACK = 10
|
||||||
@ -891,6 +891,7 @@ class MQTT:
|
|||||||
qos=birth_message.qos,
|
qos=birth_message.qos,
|
||||||
retain=birth_message.retain,
|
retain=birth_message.retain,
|
||||||
)
|
)
|
||||||
|
_LOGGER.info("MQTT client initialized, birth message sent")
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_mqtt_on_connect(
|
def _async_mqtt_on_connect(
|
||||||
@ -950,6 +951,7 @@ class MQTT:
|
|||||||
name="mqtt re-subscribe",
|
name="mqtt re-subscribe",
|
||||||
)
|
)
|
||||||
self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN)
|
self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN)
|
||||||
|
_LOGGER.info("MQTT client initialized")
|
||||||
|
|
||||||
self._async_connection_result(True)
|
self._async_connection_result(True)
|
||||||
|
|
||||||
|
@ -69,7 +69,6 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Current bill electric cost to date",
|
name="Current bill electric cost to date",
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
native_unit_of_measurement="USD",
|
native_unit_of_measurement="USD",
|
||||||
suggested_unit_of_measurement="USD",
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.cost_to_date,
|
value_fn=lambda data: data.cost_to_date,
|
||||||
@ -79,7 +78,6 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Current bill electric forecasted cost",
|
name="Current bill electric forecasted cost",
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
native_unit_of_measurement="USD",
|
native_unit_of_measurement="USD",
|
||||||
suggested_unit_of_measurement="USD",
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.forecasted_cost,
|
value_fn=lambda data: data.forecasted_cost,
|
||||||
@ -89,7 +87,6 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Typical monthly electric cost",
|
name="Typical monthly electric cost",
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
native_unit_of_measurement="USD",
|
native_unit_of_measurement="USD",
|
||||||
suggested_unit_of_measurement="USD",
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.typical_cost,
|
value_fn=lambda data: data.typical_cost,
|
||||||
@ -101,7 +98,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Current bill gas usage to date",
|
name="Current bill gas usage to date",
|
||||||
device_class=SensorDeviceClass.GAS,
|
device_class=SensorDeviceClass.GAS,
|
||||||
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.usage_to_date,
|
value_fn=lambda data: data.usage_to_date,
|
||||||
@ -111,7 +107,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Current bill gas forecasted usage",
|
name="Current bill gas forecasted usage",
|
||||||
device_class=SensorDeviceClass.GAS,
|
device_class=SensorDeviceClass.GAS,
|
||||||
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.forecasted_usage,
|
value_fn=lambda data: data.forecasted_usage,
|
||||||
@ -121,7 +116,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Typical monthly gas usage",
|
name="Typical monthly gas usage",
|
||||||
device_class=SensorDeviceClass.GAS,
|
device_class=SensorDeviceClass.GAS,
|
||||||
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.typical_usage,
|
value_fn=lambda data: data.typical_usage,
|
||||||
@ -131,7 +125,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Current bill gas cost to date",
|
name="Current bill gas cost to date",
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
native_unit_of_measurement="USD",
|
native_unit_of_measurement="USD",
|
||||||
suggested_unit_of_measurement="USD",
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.cost_to_date,
|
value_fn=lambda data: data.cost_to_date,
|
||||||
@ -141,7 +134,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Current bill gas forecasted cost",
|
name="Current bill gas forecasted cost",
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
native_unit_of_measurement="USD",
|
native_unit_of_measurement="USD",
|
||||||
suggested_unit_of_measurement="USD",
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.forecasted_cost,
|
value_fn=lambda data: data.forecasted_cost,
|
||||||
@ -151,7 +143,6 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
|||||||
name="Typical monthly gas cost",
|
name="Typical monthly gas cost",
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
native_unit_of_measurement="USD",
|
native_unit_of_measurement="USD",
|
||||||
suggested_unit_of_measurement="USD",
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL,
|
||||||
suggested_display_precision=0,
|
suggested_display_precision=0,
|
||||||
value_fn=lambda data: data.typical_cost,
|
value_fn=lambda data: data.typical_cost,
|
||||||
|
@ -46,7 +46,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC):
|
|||||||
"""Data update coordinator for the Radarr integration."""
|
"""Data update coordinator for the Radarr integration."""
|
||||||
|
|
||||||
config_entry: ConfigEntry
|
config_entry: ConfigEntry
|
||||||
update_interval = timedelta(seconds=30)
|
_update_interval = timedelta(seconds=30)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -59,7 +59,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC):
|
|||||||
hass=hass,
|
hass=hass,
|
||||||
logger=LOGGER,
|
logger=LOGGER,
|
||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=self.update_interval,
|
update_interval=self._update_interval,
|
||||||
)
|
)
|
||||||
self.api_client = api_client
|
self.api_client = api_client
|
||||||
self.host_configuration = host_configuration
|
self.host_configuration = host_configuration
|
||||||
@ -133,7 +133,7 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator):
|
|||||||
class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]):
|
class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]):
|
||||||
"""Calendar update coordinator."""
|
"""Calendar update coordinator."""
|
||||||
|
|
||||||
update_interval = timedelta(hours=1)
|
_update_interval = timedelta(hours=1)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -485,6 +485,12 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) -
|
|||||||
|
|
||||||
The actual calculation is delegated to the platforms.
|
The actual calculation is delegated to the platforms.
|
||||||
"""
|
"""
|
||||||
|
# Define modified_statistic_ids outside of the "with" statement as
|
||||||
|
# _compile_statistics may raise and be trapped by
|
||||||
|
# filter_unique_constraint_integrity_error which would make
|
||||||
|
# modified_statistic_ids unbound.
|
||||||
|
modified_statistic_ids: set[str] | None = None
|
||||||
|
|
||||||
# Return if we already have 5-minute statistics for the requested period
|
# Return if we already have 5-minute statistics for the requested period
|
||||||
with session_scope(
|
with session_scope(
|
||||||
session=instance.get_session(),
|
session=instance.get_session(),
|
||||||
|
@ -285,6 +285,9 @@ async def async_setup_platform(
|
|||||||
class StatisticsSensor(SensorEntity):
|
class StatisticsSensor(SensorEntity):
|
||||||
"""Representation of a Statistics sensor."""
|
"""Representation of a Statistics sensor."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_icon = ICON
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
source_entity_id: str,
|
source_entity_id: str,
|
||||||
@ -298,9 +301,7 @@ class StatisticsSensor(SensorEntity):
|
|||||||
percentile: int,
|
percentile: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the Statistics sensor."""
|
"""Initialize the Statistics sensor."""
|
||||||
self._attr_icon: str = ICON
|
|
||||||
self._attr_name: str = name
|
self._attr_name: str = name
|
||||||
self._attr_should_poll: bool = False
|
|
||||||
self._attr_unique_id: str | None = unique_id
|
self._attr_unique_id: str | None = unique_id
|
||||||
self._source_entity_id: str = source_entity_id
|
self._source_entity_id: str = source_entity_id
|
||||||
self.is_binary: bool = (
|
self.is_binary: bool = (
|
||||||
@ -326,35 +327,37 @@ class StatisticsSensor(SensorEntity):
|
|||||||
|
|
||||||
self._update_listener: CALLBACK_TYPE | None = None
|
self._update_listener: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_stats_sensor_state_listener(
|
||||||
|
self,
|
||||||
|
event: Event[EventStateChangedData],
|
||||||
|
) -> None:
|
||||||
|
"""Handle the sensor state changes."""
|
||||||
|
if (new_state := event.data["new_state"]) is None:
|
||||||
|
return
|
||||||
|
self._add_state_to_queue(new_state)
|
||||||
|
self._async_purge_update_and_schedule()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_stats_sensor_startup(self, _: HomeAssistant) -> None:
|
||||||
|
"""Add listener and get recorded state."""
|
||||||
|
_LOGGER.debug("Startup for %s", self.entity_id)
|
||||||
|
self.async_on_remove(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass,
|
||||||
|
[self._source_entity_id],
|
||||||
|
self._async_stats_sensor_state_listener,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "recorder" in self.hass.config.components:
|
||||||
|
self.hass.async_create_task(self._initialize_from_database())
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callbacks."""
|
"""Register callbacks."""
|
||||||
|
self.async_on_remove(
|
||||||
@callback
|
async_at_start(self.hass, self._async_stats_sensor_startup)
|
||||||
def async_stats_sensor_state_listener(
|
)
|
||||||
event: Event[EventStateChangedData],
|
|
||||||
) -> None:
|
|
||||||
"""Handle the sensor state changes."""
|
|
||||||
if (new_state := event.data["new_state"]) is None:
|
|
||||||
return
|
|
||||||
self._add_state_to_queue(new_state)
|
|
||||||
self.async_schedule_update_ha_state(True)
|
|
||||||
|
|
||||||
async def async_stats_sensor_startup(_: HomeAssistant) -> None:
|
|
||||||
"""Add listener and get recorded state."""
|
|
||||||
_LOGGER.debug("Startup for %s", self.entity_id)
|
|
||||||
|
|
||||||
self.async_on_remove(
|
|
||||||
async_track_state_change_event(
|
|
||||||
self.hass,
|
|
||||||
[self._source_entity_id],
|
|
||||||
async_stats_sensor_state_listener,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if "recorder" in self.hass.config.components:
|
|
||||||
self.hass.async_create_task(self._initialize_from_database())
|
|
||||||
|
|
||||||
self.async_on_remove(async_at_start(self.hass, async_stats_sensor_startup))
|
|
||||||
|
|
||||||
def _add_state_to_queue(self, new_state: State) -> None:
|
def _add_state_to_queue(self, new_state: State) -> None:
|
||||||
"""Add the state to the queue."""
|
"""Add the state to the queue."""
|
||||||
@ -499,7 +502,8 @@ class StatisticsSensor(SensorEntity):
|
|||||||
self.ages.popleft()
|
self.ages.popleft()
|
||||||
self.states.popleft()
|
self.states.popleft()
|
||||||
|
|
||||||
def _next_to_purge_timestamp(self) -> datetime | None:
|
@callback
|
||||||
|
def _async_next_to_purge_timestamp(self) -> datetime | None:
|
||||||
"""Find the timestamp when the next purge would occur."""
|
"""Find the timestamp when the next purge would occur."""
|
||||||
if self.ages and self._samples_max_age:
|
if self.ages and self._samples_max_age:
|
||||||
if self.samples_keep_last and len(self.ages) == 1:
|
if self.samples_keep_last and len(self.ages) == 1:
|
||||||
@ -521,6 +525,10 @@ class StatisticsSensor(SensorEntity):
|
|||||||
|
|
||||||
async def async_update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Get the latest data and updates the states."""
|
"""Get the latest data and updates the states."""
|
||||||
|
self._async_purge_update_and_schedule()
|
||||||
|
|
||||||
|
def _async_purge_update_and_schedule(self) -> None:
|
||||||
|
"""Purge old states, update the sensor and schedule the next update."""
|
||||||
_LOGGER.debug("%s: updating statistics", self.entity_id)
|
_LOGGER.debug("%s: updating statistics", self.entity_id)
|
||||||
if self._samples_max_age is not None:
|
if self._samples_max_age is not None:
|
||||||
self._purge_old_states(self._samples_max_age)
|
self._purge_old_states(self._samples_max_age)
|
||||||
@ -531,23 +539,28 @@ class StatisticsSensor(SensorEntity):
|
|||||||
# If max_age is set, ensure to update again after the defined interval.
|
# If max_age is set, ensure to update again after the defined interval.
|
||||||
# By basing updates off the timestamps of sampled data we avoid updating
|
# By basing updates off the timestamps of sampled data we avoid updating
|
||||||
# when none of the observed entities change.
|
# when none of the observed entities change.
|
||||||
if timestamp := self._next_to_purge_timestamp():
|
if timestamp := self._async_next_to_purge_timestamp():
|
||||||
_LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp)
|
_LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp)
|
||||||
if self._update_listener:
|
self._async_cancel_update_listener()
|
||||||
self._update_listener()
|
|
||||||
self._update_listener = None
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _scheduled_update(now: datetime) -> None:
|
|
||||||
"""Timer callback for sensor update."""
|
|
||||||
_LOGGER.debug("%s: executing scheduled update", self.entity_id)
|
|
||||||
self.async_schedule_update_ha_state(True)
|
|
||||||
self._update_listener = None
|
|
||||||
|
|
||||||
self._update_listener = async_track_point_in_utc_time(
|
self._update_listener = async_track_point_in_utc_time(
|
||||||
self.hass, _scheduled_update, timestamp
|
self.hass, self._async_scheduled_update, timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_cancel_update_listener(self) -> None:
|
||||||
|
"""Cancel the scheduled update listener."""
|
||||||
|
if self._update_listener:
|
||||||
|
self._update_listener()
|
||||||
|
self._update_listener = None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_scheduled_update(self, now: datetime) -> None:
|
||||||
|
"""Timer callback for sensor update."""
|
||||||
|
_LOGGER.debug("%s: executing scheduled update", self.entity_id)
|
||||||
|
self._async_cancel_update_listener()
|
||||||
|
self._async_purge_update_and_schedule()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
def _fetch_states_from_database(self) -> list[State]:
|
def _fetch_states_from_database(self) -> list[State]:
|
||||||
"""Fetch the states from the database."""
|
"""Fetch the states from the database."""
|
||||||
_LOGGER.debug("%s: initializing values from the database", self.entity_id)
|
_LOGGER.debug("%s: initializing values from the database", self.entity_id)
|
||||||
@ -589,8 +602,8 @@ class StatisticsSensor(SensorEntity):
|
|||||||
for state in reversed(states):
|
for state in reversed(states):
|
||||||
self._add_state_to_queue(state)
|
self._add_state_to_queue(state)
|
||||||
|
|
||||||
self.async_schedule_update_ha_state(True)
|
self._async_purge_update_and_schedule()
|
||||||
|
self.async_write_ha_state()
|
||||||
_LOGGER.debug("%s: initializing from database completed", self.entity_id)
|
_LOGGER.debug("%s: initializing from database completed", self.entity_id)
|
||||||
|
|
||||||
def _update_attributes(self) -> None:
|
def _update_attributes(self) -> None:
|
||||||
|
@ -7,6 +7,7 @@ import logging
|
|||||||
|
|
||||||
from synology_dsm.api.surveillance_station import SynoSurveillanceStation
|
from synology_dsm.api.surveillance_station import SynoSurveillanceStation
|
||||||
from synology_dsm.api.surveillance_station.camera import SynoCamera
|
from synology_dsm.api.surveillance_station.camera import SynoCamera
|
||||||
|
from synology_dsm.exceptions import SynologyDSMNotLoggedInException
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL
|
from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL
|
||||||
@ -69,7 +70,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await api.async_setup()
|
await api.async_setup()
|
||||||
except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err:
|
except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err:
|
||||||
raise_config_entry_auth_error(err)
|
raise_config_entry_auth_error(err)
|
||||||
except SYNOLOGY_CONNECTION_EXCEPTIONS as err:
|
except (*SYNOLOGY_CONNECTION_EXCEPTIONS, SynologyDSMNotLoggedInException) as err:
|
||||||
|
# SynologyDSMNotLoggedInException may be raised even if the user is
|
||||||
|
# logged in because the session may have expired, and we need to retry
|
||||||
|
# the login later.
|
||||||
if err.args[0] and isinstance(err.args[0], dict):
|
if err.args[0] and isinstance(err.args[0], dict):
|
||||||
details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN)
|
details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN)
|
||||||
else:
|
else:
|
||||||
@ -86,12 +90,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
coordinator_central = SynologyDSMCentralUpdateCoordinator(hass, entry, api)
|
coordinator_central = SynologyDSMCentralUpdateCoordinator(hass, entry, api)
|
||||||
await coordinator_central.async_config_entry_first_refresh()
|
|
||||||
|
|
||||||
available_apis = api.dsm.apis
|
available_apis = api.dsm.apis
|
||||||
|
|
||||||
# The central coordinator needs to be refreshed first since
|
|
||||||
# the next two rely on data from it
|
|
||||||
coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None = None
|
coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None = None
|
||||||
if api.surveillance_station is not None:
|
if api.surveillance_station is not None:
|
||||||
coordinator_cameras = SynologyDSMCameraUpdateCoordinator(hass, entry, api)
|
coordinator_cameras = SynologyDSMCameraUpdateCoordinator(hass, entry, api)
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
import logging
|
import logging
|
||||||
@ -38,6 +39,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_DEVICE_TOKEN,
|
CONF_DEVICE_TOKEN,
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
EXCEPTION_DETAILS,
|
EXCEPTION_DETAILS,
|
||||||
EXCEPTION_UNKNOWN,
|
EXCEPTION_UNKNOWN,
|
||||||
SYNOLOGY_CONNECTION_EXCEPTIONS,
|
SYNOLOGY_CONNECTION_EXCEPTIONS,
|
||||||
@ -82,6 +84,31 @@ class SynoApi:
|
|||||||
self._with_upgrade = True
|
self._with_upgrade = True
|
||||||
self._with_utilisation = True
|
self._with_utilisation = True
|
||||||
|
|
||||||
|
self._login_future: asyncio.Future[None] | None = None
|
||||||
|
|
||||||
|
async def async_login(self) -> None:
|
||||||
|
"""Login to the Synology DSM API.
|
||||||
|
|
||||||
|
This function will only login once if called multiple times
|
||||||
|
by multiple different callers.
|
||||||
|
|
||||||
|
If a login is already in progress, the function will await the
|
||||||
|
login to complete before returning.
|
||||||
|
"""
|
||||||
|
if self._login_future:
|
||||||
|
return await self._login_future
|
||||||
|
|
||||||
|
self._login_future = self._hass.loop.create_future()
|
||||||
|
try:
|
||||||
|
await self.dsm.login()
|
||||||
|
self._login_future.set_result(None)
|
||||||
|
except BaseException as err:
|
||||||
|
if not self._login_future.done():
|
||||||
|
self._login_future.set_exception(err)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._login_future = None
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
"""Start interacting with the NAS."""
|
"""Start interacting with the NAS."""
|
||||||
session = async_get_clientsession(self._hass, self._entry.data[CONF_VERIFY_SSL])
|
session = async_get_clientsession(self._hass, self._entry.data[CONF_VERIFY_SSL])
|
||||||
@ -92,10 +119,10 @@ class SynoApi:
|
|||||||
self._entry.data[CONF_USERNAME],
|
self._entry.data[CONF_USERNAME],
|
||||||
self._entry.data[CONF_PASSWORD],
|
self._entry.data[CONF_PASSWORD],
|
||||||
self._entry.data[CONF_SSL],
|
self._entry.data[CONF_SSL],
|
||||||
timeout=self._entry.options.get(CONF_TIMEOUT) or 10,
|
timeout=self._entry.options.get(CONF_TIMEOUT) or DEFAULT_TIMEOUT,
|
||||||
device_token=self._entry.data.get(CONF_DEVICE_TOKEN),
|
device_token=self._entry.data.get(CONF_DEVICE_TOKEN),
|
||||||
)
|
)
|
||||||
await self.dsm.login()
|
await self.async_login()
|
||||||
|
|
||||||
# check if surveillance station is used
|
# check if surveillance station is used
|
||||||
self._with_surveillance_station = bool(
|
self._with_surveillance_station = bool(
|
||||||
|
@ -179,7 +179,9 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
port = DEFAULT_PORT
|
port = DEFAULT_PORT
|
||||||
|
|
||||||
session = async_get_clientsession(self.hass, verify_ssl)
|
session = async_get_clientsession(self.hass, verify_ssl)
|
||||||
api = SynologyDSM(session, host, port, username, password, use_ssl, timeout=30)
|
api = SynologyDSM(
|
||||||
|
session, host, port, username, password, use_ssl, timeout=DEFAULT_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
try:
|
try:
|
||||||
|
@ -40,7 +40,7 @@ DEFAULT_PORT = 5000
|
|||||||
DEFAULT_PORT_SSL = 5001
|
DEFAULT_PORT_SSL = 5001
|
||||||
# Options
|
# Options
|
||||||
DEFAULT_SCAN_INTERVAL = 15 # min
|
DEFAULT_SCAN_INTERVAL = 15 # min
|
||||||
DEFAULT_TIMEOUT = 10 # sec
|
DEFAULT_TIMEOUT = 30 # sec
|
||||||
DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED
|
DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED
|
||||||
|
|
||||||
ENTITY_UNIT_LOAD = "load"
|
ENTITY_UNIT_LOAD = "load"
|
||||||
|
@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable, Coroutine
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, TypeVar
|
from typing import Any, Concatenate, ParamSpec, TypeVar
|
||||||
|
|
||||||
from synology_dsm.api.surveillance_station.camera import SynoCamera
|
from synology_dsm.api.surveillance_station.camera import SynoCamera
|
||||||
from synology_dsm.exceptions import (
|
from synology_dsm.exceptions import (
|
||||||
@ -30,6 +31,36 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
_DataT = TypeVar("_DataT")
|
_DataT = TypeVar("_DataT")
|
||||||
|
|
||||||
|
|
||||||
|
_T = TypeVar("_T", bound="SynologyDSMUpdateCoordinator")
|
||||||
|
_P = ParamSpec("_P")
|
||||||
|
|
||||||
|
|
||||||
|
def async_re_login_on_expired(
|
||||||
|
func: Callable[Concatenate[_T, _P], Awaitable[_DataT]],
|
||||||
|
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _DataT]]:
|
||||||
|
"""Define a wrapper to re-login when expired."""
|
||||||
|
|
||||||
|
async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _DataT:
|
||||||
|
for attempts in range(2):
|
||||||
|
try:
|
||||||
|
return await func(self, *args, **kwargs)
|
||||||
|
except SynologyDSMNotLoggedInException:
|
||||||
|
# If login is expired, try to login again
|
||||||
|
_LOGGER.debug("login is expired, try to login again")
|
||||||
|
try:
|
||||||
|
await self.api.async_login()
|
||||||
|
except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err:
|
||||||
|
raise_config_entry_auth_error(err)
|
||||||
|
if attempts == 0:
|
||||||
|
continue
|
||||||
|
except SYNOLOGY_CONNECTION_EXCEPTIONS as err:
|
||||||
|
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||||
|
|
||||||
|
raise UpdateFailed("Unknown error when communicating with API")
|
||||||
|
|
||||||
|
return _async_wrap
|
||||||
|
|
||||||
|
|
||||||
class SynologyDSMUpdateCoordinator(DataUpdateCoordinator[_DataT]):
|
class SynologyDSMUpdateCoordinator(DataUpdateCoordinator[_DataT]):
|
||||||
"""DataUpdateCoordinator base class for synology_dsm."""
|
"""DataUpdateCoordinator base class for synology_dsm."""
|
||||||
|
|
||||||
@ -72,6 +103,7 @@ class SynologyDSMSwitchUpdateCoordinator(
|
|||||||
assert info is not None
|
assert info is not None
|
||||||
self.version = info["data"]["CMSMinVersion"]
|
self.version = info["data"]["CMSMinVersion"]
|
||||||
|
|
||||||
|
@async_re_login_on_expired
|
||||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||||
"""Fetch all data from api."""
|
"""Fetch all data from api."""
|
||||||
surveillance_station = self.api.surveillance_station
|
surveillance_station = self.api.surveillance_station
|
||||||
@ -102,21 +134,10 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@async_re_login_on_expired
|
||||||
async def _async_update_data(self) -> None:
|
async def _async_update_data(self) -> None:
|
||||||
"""Fetch all data from api."""
|
"""Fetch all data from api."""
|
||||||
for attempts in range(2):
|
await self.api.async_update()
|
||||||
try:
|
|
||||||
await self.api.async_update()
|
|
||||||
except SynologyDSMNotLoggedInException:
|
|
||||||
# If login is expired, try to login again
|
|
||||||
try:
|
|
||||||
await self.api.dsm.login()
|
|
||||||
except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err:
|
|
||||||
raise_config_entry_auth_error(err)
|
|
||||||
if attempts == 0:
|
|
||||||
continue
|
|
||||||
except SYNOLOGY_CONNECTION_EXCEPTIONS as err:
|
|
||||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
|
||||||
|
|
||||||
|
|
||||||
class SynologyDSMCameraUpdateCoordinator(
|
class SynologyDSMCameraUpdateCoordinator(
|
||||||
@ -133,6 +154,7 @@ class SynologyDSMCameraUpdateCoordinator(
|
|||||||
"""Initialize DataUpdateCoordinator for cameras."""
|
"""Initialize DataUpdateCoordinator for cameras."""
|
||||||
super().__init__(hass, entry, api, timedelta(seconds=30))
|
super().__init__(hass, entry, api, timedelta(seconds=30))
|
||||||
|
|
||||||
|
@async_re_login_on_expired
|
||||||
async def _async_update_data(self) -> dict[str, dict[int, SynoCamera]]:
|
async def _async_update_data(self) -> dict[str, dict[int, SynoCamera]]:
|
||||||
"""Fetch all camera data from api."""
|
"""Fetch all camera data from api."""
|
||||||
surveillance_station = self.api.surveillance_station
|
surveillance_station = self.api.surveillance_station
|
||||||
|
@ -23,7 +23,7 @@ if TYPE_CHECKING:
|
|||||||
APPLICATION_NAME: Final = "HomeAssistant"
|
APPLICATION_NAME: Final = "HomeAssistant"
|
||||||
MAJOR_VERSION: Final = 2024
|
MAJOR_VERSION: Final = 2024
|
||||||
MINOR_VERSION: Final = 5
|
MINOR_VERSION: Final = 5
|
||||||
PATCH_VERSION: Final = "1"
|
PATCH_VERSION: Final = "2"
|
||||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
|
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
|
||||||
|
@ -352,6 +352,18 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]):
|
|||||||
) -> _FlowResultT:
|
) -> _FlowResultT:
|
||||||
"""Continue a data entry flow."""
|
"""Continue a data entry flow."""
|
||||||
result: _FlowResultT | None = None
|
result: _FlowResultT | None = None
|
||||||
|
|
||||||
|
# Workaround for flow handlers which have not been upgraded to pass a show
|
||||||
|
# progress task, needed because of the change to eager tasks in HA Core 2024.5,
|
||||||
|
# can be removed in HA Core 2024.8.
|
||||||
|
flow = self._progress.get(flow_id)
|
||||||
|
if flow and flow.deprecated_show_progress:
|
||||||
|
if (cur_step := flow.cur_step) and cur_step[
|
||||||
|
"type"
|
||||||
|
] == FlowResultType.SHOW_PROGRESS:
|
||||||
|
# Allow the progress task to finish before we call the flow handler
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
while not result or result["type"] == FlowResultType.SHOW_PROGRESS_DONE:
|
while not result or result["type"] == FlowResultType.SHOW_PROGRESS_DONE:
|
||||||
result = await self._async_configure(flow_id, user_input)
|
result = await self._async_configure(flow_id, user_input)
|
||||||
flow = self._progress.get(flow_id)
|
flow = self._progress.get(flow_id)
|
||||||
|
@ -1436,12 +1436,18 @@ class _TrackPointUTCTime:
|
|||||||
"""Initialize track job."""
|
"""Initialize track job."""
|
||||||
loop = self.hass.loop
|
loop = self.hass.loop
|
||||||
self._cancel_callback = loop.call_at(
|
self._cancel_callback = loop.call_at(
|
||||||
loop.time() + self.expected_fire_timestamp - time.time(), self._run_action
|
loop.time() + self.expected_fire_timestamp - time.time(), self
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _run_action(self) -> None:
|
def __call__(self) -> None:
|
||||||
"""Call the action."""
|
"""Call the action.
|
||||||
|
|
||||||
|
We implement this as __call__ so when debug logging logs the object
|
||||||
|
it shows the name of the job. This is especially helpful when asyncio
|
||||||
|
debug logging is enabled as we can see the name of the job that is
|
||||||
|
being called that is blocking the event loop.
|
||||||
|
"""
|
||||||
# Depending on the available clock support (including timer hardware
|
# Depending on the available clock support (including timer hardware
|
||||||
# and the OS kernel) it can happen that we fire a little bit too early
|
# and the OS kernel) it can happen that we fire a little bit too early
|
||||||
# as measured by utcnow(). That is bad when callbacks have assumptions
|
# as measured by utcnow(). That is bad when callbacks have assumptions
|
||||||
@ -1450,7 +1456,7 @@ class _TrackPointUTCTime:
|
|||||||
if (delta := (self.expected_fire_timestamp - time_tracker_timestamp())) > 0:
|
if (delta := (self.expected_fire_timestamp - time_tracker_timestamp())) > 0:
|
||||||
_LOGGER.debug("Called %f seconds too early, rearming", delta)
|
_LOGGER.debug("Called %f seconds too early, rearming", delta)
|
||||||
loop = self.hass.loop
|
loop = self.hass.loop
|
||||||
self._cancel_callback = loop.call_at(loop.time() + delta, self._run_action)
|
self._cancel_callback = loop.call_at(loop.time() + delta, self)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.hass.async_run_hass_job(self.job, self.utc_point_in_time)
|
self.hass.async_run_hass_job(self.job, self.utc_point_in_time)
|
||||||
|
@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.0
|
|||||||
aiodiscover==2.1.0
|
aiodiscover==2.1.0
|
||||||
aiodns==3.2.0
|
aiodns==3.2.0
|
||||||
aiohttp-fast-url-dispatcher==0.3.0
|
aiohttp-fast-url-dispatcher==0.3.0
|
||||||
aiohttp-isal==0.2.0
|
aiohttp-isal==0.3.1
|
||||||
aiohttp==3.9.5
|
aiohttp==3.9.5
|
||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
aiohttp_session==2.12.0
|
aiohttp_session==2.12.0
|
||||||
@ -17,7 +17,7 @@ awesomeversion==24.2.0
|
|||||||
bcrypt==4.1.2
|
bcrypt==4.1.2
|
||||||
bleak-retry-connector==3.5.0
|
bleak-retry-connector==3.5.0
|
||||||
bleak==0.21.1
|
bleak==0.21.1
|
||||||
bluetooth-adapters==0.19.1
|
bluetooth-adapters==0.19.2
|
||||||
bluetooth-auto-recovery==1.4.2
|
bluetooth-auto-recovery==1.4.2
|
||||||
bluetooth-data-tools==1.19.0
|
bluetooth-data-tools==1.19.0
|
||||||
cached_ipaddress==0.3.0
|
cached_ipaddress==0.3.0
|
||||||
@ -32,7 +32,7 @@ habluetooth==2.8.1
|
|||||||
hass-nabucasa==0.78.0
|
hass-nabucasa==0.78.0
|
||||||
hassil==1.6.1
|
hassil==1.6.1
|
||||||
home-assistant-bluetooth==1.12.0
|
home-assistant-bluetooth==1.12.0
|
||||||
home-assistant-frontend==20240501.0
|
home-assistant-frontend==20240501.1
|
||||||
home-assistant-intents==2024.4.24
|
home-assistant-intents==2024.4.24
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
ifaddr==0.2.0
|
ifaddr==0.2.0
|
||||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "homeassistant"
|
name = "homeassistant"
|
||||||
version = "2024.5.1"
|
version = "2024.5.2"
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "Apache-2.0"}
|
||||||
description = "Open-source home automation platform running on Python 3."
|
description = "Open-source home automation platform running on Python 3."
|
||||||
readme = "README.rst"
|
readme = "README.rst"
|
||||||
@ -28,7 +28,7 @@ dependencies = [
|
|||||||
"aiohttp_cors==0.7.0",
|
"aiohttp_cors==0.7.0",
|
||||||
"aiohttp_session==2.12.0",
|
"aiohttp_session==2.12.0",
|
||||||
"aiohttp-fast-url-dispatcher==0.3.0",
|
"aiohttp-fast-url-dispatcher==0.3.0",
|
||||||
"aiohttp-isal==0.2.0",
|
"aiohttp-isal==0.3.1",
|
||||||
"astral==2.2",
|
"astral==2.2",
|
||||||
"async-interrupt==1.1.1",
|
"async-interrupt==1.1.1",
|
||||||
"attrs==23.2.0",
|
"attrs==23.2.0",
|
||||||
|
@ -8,7 +8,7 @@ aiohttp==3.9.5
|
|||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
aiohttp_session==2.12.0
|
aiohttp_session==2.12.0
|
||||||
aiohttp-fast-url-dispatcher==0.3.0
|
aiohttp-fast-url-dispatcher==0.3.0
|
||||||
aiohttp-isal==0.2.0
|
aiohttp-isal==0.3.1
|
||||||
astral==2.2
|
astral==2.2
|
||||||
async-interrupt==1.1.1
|
async-interrupt==1.1.1
|
||||||
attrs==23.2.0
|
attrs==23.2.0
|
||||||
|
@ -413,7 +413,7 @@ aioymaps==1.2.2
|
|||||||
airly==1.1.0
|
airly==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.airthings_ble
|
# homeassistant.components.airthings_ble
|
||||||
airthings-ble==0.8.0
|
airthings-ble==0.9.0
|
||||||
|
|
||||||
# homeassistant.components.airthings
|
# homeassistant.components.airthings
|
||||||
airthings-cloud==0.2.0
|
airthings-cloud==0.2.0
|
||||||
@ -437,7 +437,7 @@ amcrest==1.9.8
|
|||||||
androidtv[async]==0.0.73
|
androidtv[async]==0.0.73
|
||||||
|
|
||||||
# homeassistant.components.androidtv_remote
|
# homeassistant.components.androidtv_remote
|
||||||
androidtvremote2==0.0.14
|
androidtvremote2==0.0.15
|
||||||
|
|
||||||
# homeassistant.components.anel_pwrctrl
|
# homeassistant.components.anel_pwrctrl
|
||||||
anel-pwrctrl-homeassistant==0.0.1.dev2
|
anel-pwrctrl-homeassistant==0.0.1.dev2
|
||||||
@ -579,7 +579,7 @@ bluemaestro-ble==0.2.3
|
|||||||
# bluepy==1.3.0
|
# bluepy==1.3.0
|
||||||
|
|
||||||
# homeassistant.components.bluetooth
|
# homeassistant.components.bluetooth
|
||||||
bluetooth-adapters==0.19.1
|
bluetooth-adapters==0.19.2
|
||||||
|
|
||||||
# homeassistant.components.bluetooth
|
# homeassistant.components.bluetooth
|
||||||
bluetooth-auto-recovery==1.4.2
|
bluetooth-auto-recovery==1.4.2
|
||||||
@ -952,7 +952,7 @@ glances-api==0.6.0
|
|||||||
goalzero==0.2.2
|
goalzero==0.2.2
|
||||||
|
|
||||||
# homeassistant.components.goodwe
|
# homeassistant.components.goodwe
|
||||||
goodwe==0.3.2
|
goodwe==0.3.4
|
||||||
|
|
||||||
# homeassistant.components.google_mail
|
# homeassistant.components.google_mail
|
||||||
# homeassistant.components.google_tasks
|
# homeassistant.components.google_tasks
|
||||||
@ -1078,7 +1078,7 @@ hole==0.8.0
|
|||||||
holidays==0.47
|
holidays==0.47
|
||||||
|
|
||||||
# homeassistant.components.frontend
|
# homeassistant.components.frontend
|
||||||
home-assistant-frontend==20240501.0
|
home-assistant-frontend==20240501.1
|
||||||
|
|
||||||
# homeassistant.components.conversation
|
# homeassistant.components.conversation
|
||||||
home-assistant-intents==2024.4.24
|
home-assistant-intents==2024.4.24
|
||||||
|
@ -386,7 +386,7 @@ aioymaps==1.2.2
|
|||||||
airly==1.1.0
|
airly==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.airthings_ble
|
# homeassistant.components.airthings_ble
|
||||||
airthings-ble==0.8.0
|
airthings-ble==0.9.0
|
||||||
|
|
||||||
# homeassistant.components.airthings
|
# homeassistant.components.airthings
|
||||||
airthings-cloud==0.2.0
|
airthings-cloud==0.2.0
|
||||||
@ -404,7 +404,7 @@ amberelectric==1.1.0
|
|||||||
androidtv[async]==0.0.73
|
androidtv[async]==0.0.73
|
||||||
|
|
||||||
# homeassistant.components.androidtv_remote
|
# homeassistant.components.androidtv_remote
|
||||||
androidtvremote2==0.0.14
|
androidtvremote2==0.0.15
|
||||||
|
|
||||||
# homeassistant.components.anova
|
# homeassistant.components.anova
|
||||||
anova-wifi==0.10.0
|
anova-wifi==0.10.0
|
||||||
@ -494,7 +494,7 @@ bluecurrent-api==1.2.3
|
|||||||
bluemaestro-ble==0.2.3
|
bluemaestro-ble==0.2.3
|
||||||
|
|
||||||
# homeassistant.components.bluetooth
|
# homeassistant.components.bluetooth
|
||||||
bluetooth-adapters==0.19.1
|
bluetooth-adapters==0.19.2
|
||||||
|
|
||||||
# homeassistant.components.bluetooth
|
# homeassistant.components.bluetooth
|
||||||
bluetooth-auto-recovery==1.4.2
|
bluetooth-auto-recovery==1.4.2
|
||||||
@ -781,7 +781,7 @@ glances-api==0.6.0
|
|||||||
goalzero==0.2.2
|
goalzero==0.2.2
|
||||||
|
|
||||||
# homeassistant.components.goodwe
|
# homeassistant.components.goodwe
|
||||||
goodwe==0.3.2
|
goodwe==0.3.4
|
||||||
|
|
||||||
# homeassistant.components.google_mail
|
# homeassistant.components.google_mail
|
||||||
# homeassistant.components.google_tasks
|
# homeassistant.components.google_tasks
|
||||||
@ -880,7 +880,7 @@ hole==0.8.0
|
|||||||
holidays==0.47
|
holidays==0.47
|
||||||
|
|
||||||
# homeassistant.components.frontend
|
# homeassistant.components.frontend
|
||||||
home-assistant-frontend==20240501.0
|
home-assistant-frontend==20240501.1
|
||||||
|
|
||||||
# homeassistant.components.conversation
|
# homeassistant.components.conversation
|
||||||
home-assistant-intents==2024.4.24
|
home-assistant-intents==2024.4.24
|
||||||
|
@ -4819,3 +4819,21 @@ async def test_track_state_change_deprecated(
|
|||||||
"of `async_track_state_change_event` which is deprecated and "
|
"of `async_track_state_change_event` which is deprecated and "
|
||||||
"will be removed in Home Assistant 2025.5. Please report this issue."
|
"will be removed in Home Assistant 2025.5. Please report this issue."
|
||||||
) in caplog.text
|
) in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_track_point_in_time_repr(
|
||||||
|
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test track point in time."""
|
||||||
|
|
||||||
|
@ha.callback
|
||||||
|
def _raise_exception(_):
|
||||||
|
raise RuntimeError("something happened and its poorly described")
|
||||||
|
|
||||||
|
async_track_point_in_utc_time(hass, _raise_exception, dt_util.utcnow())
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
|
assert "Exception in callback _TrackPointUTCTime" in caplog.text
|
||||||
|
assert "._raise_exception" in caplog.text
|
||||||
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
@ -7,6 +7,12 @@ IGNORE_UNCAUGHT_EXCEPTIONS = [
|
|||||||
"tests.test_runner",
|
"tests.test_runner",
|
||||||
"test_unhandled_exception_traceback",
|
"test_unhandled_exception_traceback",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
# This test explicitly throws an uncaught exception
|
||||||
|
# and should not be removed.
|
||||||
|
"tests.helpers.test_event",
|
||||||
|
"test_track_point_in_time_repr",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"test_homeassistant_bridge",
|
"test_homeassistant_bridge",
|
||||||
"test_homeassistant_bridge_fan_setup",
|
"test_homeassistant_bridge_fan_setup",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user