diff --git a/.strict-typing b/.strict-typing index 175142e217e..a8d9466a01b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -40,6 +40,7 @@ homeassistant.components.slack.* homeassistant.components.sonos.media_player homeassistant.components.sun.* homeassistant.components.switch.* +homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tts.* homeassistant.components.vacuum.* diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 46e502cd155..7a316b0381d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import Any, Callable import async_timeout from synology_dsm import SynologyDSM @@ -15,6 +15,7 @@ from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation +from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, SynologyDSMLoginFailedException, @@ -38,9 +39,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry +from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import ( + DeviceEntry, + async_get_registry as get_dev_reg, +) from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -74,6 +80,7 @@ from .const import ( SYSTEM_LOADED, UNDO_UPDATE_LISTENER, UTILISATION_SENSORS, + EntityInfo, ) CONFIG_SCHEMA = vol.Schema( @@ -103,7 +110,7 @@ ATTRIBUTION = "Data provided by Synology" _LOGGER = logging.getLogger(__name__) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Synology DSM sensors from legacy config file.""" conf = config.get(DOMAIN) @@ -122,12 +129,16 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Synology DSM sensors.""" # Migrate old unique_id @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator( + entity_entry: entity_registry.RegistryEntry, + ) -> dict[str, str] | None: """Migrate away from ID using label.""" # Reject if new unique_id if "SYNO." in entity_entry.unique_id: @@ -152,7 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): ): return None - entity_type = None + entity_type: str | None = None for entity_key, entity_attrs in entries.items(): if ( device_id @@ -170,6 +181,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if entity_attrs[ENTITY_NAME] == label: entity_type = entity_key + if entity_type is None: + return None + new_unique_id = "_".join([serial, entity_type]) if device_id: new_unique_id += f"_{device_id}" @@ -183,6 +197,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): await entity_registry.async_migrate_entries(hass, entry.entry_id, _async_migrator) + # migrate device indetifiers + dev_reg = await get_dev_reg(hass) + devices: list[DeviceEntry] = device_registry.async_entries_for_config_entry( + dev_reg, entry.entry_id + ) + for device in devices: + old_identifier = list(next(iter(device.identifiers))) + if len(old_identifier) > 2: + new_identifier: set[tuple[str, ...]] = { + (old_identifier.pop(0), "_".join([str(x) for x in old_identifier])) + } + _LOGGER.debug( + "migrate identifier '%s' to '%s'", device.identifiers, new_identifier + ) + dev_reg.async_update_device(device.id, new_identifiers=new_identifier) + # Migrate existing entry configuration if entry.data.get(CONF_VERIFY_SSL) is None: hass.config_entries.async_update_entry( @@ -216,7 +246,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): entry, data={**entry.data, CONF_MAC: network.macs} ) - async def async_coordinator_update_data_cameras(): + async def async_coordinator_update_data_cameras() -> dict[ + str, dict[str, SynoCamera] + ] | None: """Fetch all camera data from api.""" if not hass.data[DOMAIN][entry.unique_id][SYSTEM_LOADED]: raise UpdateFailed("System not fully loaded") @@ -238,7 +270,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): } } - async def async_coordinator_update_data_central(): + async def async_coordinator_update_data_central() -> None: """Fetch all device and sensor data from api.""" try: await api.async_update() @@ -246,7 +278,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): raise UpdateFailed(f"Error communicating with API: {err}") from err return None - async def async_coordinator_update_data_switches(): + async def async_coordinator_update_data_switches() -> dict[ + str, dict[str, Any] + ] | None: """Fetch all switch data from api.""" if not hass.data[DOMAIN][entry.unique_id][SYSTEM_LOADED]: raise UpdateFailed("System not fully loaded") @@ -294,7 +328,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Synology DSM sensors.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -306,15 +340,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def _async_setup_services(hass: HomeAssistant): +async def _async_setup_services(hass: HomeAssistant) -> None: """Service handler setup.""" - async def service_handler(call: ServiceCall): + async def service_handler(call: ServiceCall) -> None: """Handle service call.""" serial = call.data.get(CONF_SERIAL) dsm_devices = hass.data[DOMAIN] @@ -350,7 +384,7 @@ async def _async_setup_services(hass: HomeAssistant): class SynoApi: """Class to interface with Synology DSM API.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the API wrapper class.""" self._hass = hass self._entry = entry @@ -367,7 +401,7 @@ class SynoApi: self.utilisation: SynoCoreUtilization = None # Should we fetch them - self._fetching_entities = {} + self._fetching_entities: dict[str, set[str]] = {} self._with_information = True self._with_security = True self._with_storage = True @@ -376,7 +410,7 @@ class SynoApi: self._with_upgrade = True self._with_utilisation = True - async def async_setup(self): + async def async_setup(self) -> None: """Start interacting with the NAS.""" self.dsm = SynologyDSM( self._entry.data[CONF_HOST], @@ -406,7 +440,7 @@ class SynoApi: await self.async_update() @callback - def subscribe(self, api_key, unique_id): + def subscribe(self, api_key: str, unique_id: str) -> Callable[[], None]: """Subscribe an entity to API fetches.""" _LOGGER.debug("Subscribe new entity: %s", unique_id) if api_key not in self._fetching_entities: @@ -424,7 +458,7 @@ class SynoApi: return unsubscribe @callback - def _async_setup_api_requests(self): + def _async_setup_api_requests(self) -> None: """Determine if we should fetch each API, if one entity needs it.""" # Entities not added yet, fetch all if not self._fetching_entities: @@ -488,7 +522,7 @@ class SynoApi: self.dsm.reset(self.utilisation) self.utilisation = None - def _fetch_device_configuration(self): + def _fetch_device_configuration(self) -> None: """Fetch initial device config.""" self.information = self.dsm.information self.network = self.dsm.network @@ -523,7 +557,7 @@ class SynoApi: ) self.surveillance_station = self.dsm.surveillance_station - async def async_reboot(self): + async def async_reboot(self) -> None: """Reboot NAS.""" try: await self._hass.async_add_executor_job(self.system.reboot) @@ -534,7 +568,7 @@ class SynoApi: ) _LOGGER.debug("Exception:%s", err) - async def async_shutdown(self): + async def async_shutdown(self) -> None: """Shutdown NAS.""" try: await self._hass.async_add_executor_job(self.system.shutdown) @@ -545,7 +579,7 @@ class SynoApi: ) _LOGGER.debug("Exception:%s", err) - async def async_unload(self): + async def async_unload(self) -> None: """Stop interacting with the NAS and prepare for removal from hass.""" try: await self._hass.async_add_executor_job(self.dsm.logout) @@ -554,7 +588,7 @@ class SynoApi: "Logout from '%s' not possible:%s", self._entry.unique_id, err ) - async def async_update(self, now=None): + async def async_update(self, now: timedelta | None = None) -> None: """Update function for updating API information.""" _LOGGER.debug("Start data update for '%s'", self._entry.unique_id) self._async_setup_api_requests() @@ -582,9 +616,9 @@ class SynologyDSMBaseEntity(CoordinatorEntity): self, api: SynoApi, entity_type: str, - entity_info: dict[str, str], - coordinator: DataUpdateCoordinator, - ): + entity_info: EntityInfo, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + ) -> None: """Initialize the Synology DSM entity.""" super().__init__(coordinator) @@ -609,12 +643,12 @@ class SynologyDSMBaseEntity(CoordinatorEntity): return self._name @property - def icon(self) -> str: + def icon(self) -> str | None: """Return the icon.""" return self._icon @property - def device_class(self) -> str: + def device_class(self) -> str | None: """Return the class of this device.""" return self._class @@ -639,7 +673,7 @@ class SynologyDSMBaseEntity(CoordinatorEntity): """Return if the entity should be enabled when first added to the entity registry.""" return self._enable_default - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register entity for updates from API.""" self.async_on_remove(self._api.subscribe(self._api_key, self.unique_id)) await super().async_added_to_hass() @@ -652,10 +686,10 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): self, api: SynoApi, entity_type: str, - entity_info: dict[str, str], - coordinator: DataUpdateCoordinator, - device_id: str = None, - ): + entity_info: EntityInfo, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + device_id: str | None = None, + ) -> None: """Initialize the Synology DSM disk or volume entity.""" super().__init__(api, entity_type, entity_info, coordinator) self._device_id = device_id @@ -691,16 +725,18 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return bool(self._api.storage) + return self._api.storage # type: ignore [no-any-return] @property def device_info(self) -> DeviceInfo: """Return the device information.""" return { - "identifiers": {(DOMAIN, self._api.information.serial, self._device_id)}, + "identifiers": { + (DOMAIN, f"{self._api.information.serial}_{self._device_id}") + }, "name": f"Synology NAS ({self._device_name} - {self._device_type})", - "manufacturer": self._device_manufacturer, - "model": self._device_model, - "sw_version": self._device_firmware, + "manufacturer": self._device_manufacturer, # type: ignore[typeddict-item] + "model": self._device_model, # type: ignore[typeddict-item] + "sw_version": self._device_firmware, # type: ignore[typeddict-item] "via_device": (DOMAIN, self._api.information.serial), } diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 587f89cf16a..e94dc1a94ac 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -5,8 +5,9 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SynologyDSMBaseEntity, SynologyDSMDeviceEntity +from . import SynoApi, SynologyDSMBaseEntity, SynologyDSMDeviceEntity from .const import ( COORDINATOR_CENTRAL, DOMAIN, @@ -18,15 +19,19 @@ from .const import ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS binary sensor.""" data = hass.data[DOMAIN][entry.unique_id] - api = data[SYNO_API] + api: SynoApi = data[SYNO_API] coordinator = data[COORDINATOR_CENTRAL] - entities = [ + entities: list[ + SynoDSMSecurityBinarySensor + | SynoDSMUpgradeBinarySensor + | SynoDSMStorageBinarySensor + ] = [ SynoDSMSecurityBinarySensor( api, sensor_type, SECURITY_BINARY_SENSORS[sensor_type], coordinator ) @@ -63,7 +68,7 @@ class SynoDSMSecurityBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return the state.""" - return getattr(self._api.security, self.entity_type) != "safe" + return getattr(self._api.security, self.entity_type) != "safe" # type: ignore[no-any-return] @property def available(self) -> bool: @@ -73,7 +78,7 @@ class SynoDSMSecurityBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): @property def extra_state_attributes(self) -> dict[str, str]: """Return security checks details.""" - return self._api.security.status_by_check + return self._api.security.status_by_check # type: ignore[no-any-return] class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, BinarySensorEntity): @@ -82,7 +87,7 @@ class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return the state.""" - return getattr(self._api.storage, self.entity_type)(self._device_id) + return bool(getattr(self._api.storage, self.entity_type)(self._device_id)) class SynoDSMUpgradeBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): @@ -91,7 +96,7 @@ class SynoDSMUpgradeBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return the state.""" - return getattr(self._api.upgrade, self.entity_type) + return bool(getattr(self._api.upgrade, self.entity_type)) @property def available(self) -> bool: diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 6183125ee8f..9baca38c16f 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from synology_dsm.api.surveillance_station import SynoSurveillanceStation +from synology_dsm.api.surveillance_station import SynoCamera, SynoSurveillanceStation from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, SynologyDSMRequestException, @@ -13,6 +13,7 @@ from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi, SynologyDSMBaseEntity @@ -31,19 +32,21 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS cameras.""" data = hass.data[DOMAIN][entry.unique_id] - api = data[SYNO_API] + api: SynoApi = data[SYNO_API] if SynoSurveillanceStation.CAMERA_API_KEY not in api.dsm.apis: return # initial data fetch - coordinator = data[COORDINATOR_CAMERAS] - await coordinator.async_refresh() + coordinator: DataUpdateCoordinator[dict[str, dict[str, SynoCamera]]] = data[ + COORDINATOR_CAMERAS + ] + await coordinator.async_config_entry_first_refresh() async_add_entities( SynoDSMCamera(api, coordinator, camera_id) @@ -54,9 +57,14 @@ async def async_setup_entry( class SynoDSMCamera(SynologyDSMBaseEntity, Camera): """Representation a Synology camera.""" + coordinator: DataUpdateCoordinator[dict[str, dict[str, SynoCamera]]] + def __init__( - self, api: SynoApi, coordinator: DataUpdateCoordinator, camera_id: int - ): + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, SynoCamera]]], + camera_id: str, + ) -> None: """Initialize a Synology camera.""" super().__init__( api, @@ -70,13 +78,11 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): }, coordinator, ) - Camera.__init__(self) - + Camera.__init__(self) # type: ignore[no-untyped-call] self._camera_id = camera_id - self._api = api @property - def camera_data(self): + def camera_data(self) -> SynoCamera: """Camera data.""" return self.coordinator.data["cameras"][self._camera_id] @@ -87,16 +93,14 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): "identifiers": { ( DOMAIN, - self._api.information.serial, - self.camera_data.id, + f"{self._api.information.serial}_{self.camera_data.id}", ) }, "name": self.camera_data.name, "model": self.camera_data.model, "via_device": ( DOMAIN, - self._api.information.serial, - SynoSurveillanceStation.INFO_API_KEY, + f"{self._api.information.serial}_{SynoSurveillanceStation.INFO_API_KEY}", ), } @@ -111,16 +115,16 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): return SUPPORT_STREAM @property - def is_recording(self): + def is_recording(self) -> bool: """Return true if the device is recording.""" - return self.camera_data.is_recording + return self.camera_data.is_recording # type: ignore[no-any-return] @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" - return self.camera_data.is_motion_detection_enabled + return self.camera_data.is_motion_detection_enabled # type: ignore[no-any-return] - def camera_image(self) -> bytes: + def camera_image(self) -> bytes | None: """Return bytes of camera image.""" _LOGGER.debug( "SynoDSMCamera.camera_image(%s)", @@ -129,7 +133,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): if not self.available: return None try: - return self._api.surveillance_station.get_camera_image(self._camera_id) + return self._api.surveillance_station.get_camera_image(self._camera_id) # type: ignore[no-any-return] except ( SynologyDSMAPIErrorException, SynologyDSMRequestException, @@ -142,7 +146,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): ) return None - async def stream_source(self) -> str: + async def stream_source(self) -> str | None: """Return the source of the stream.""" _LOGGER.debug( "SynoDSMCamera.stream_source(%s)", @@ -150,9 +154,9 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): ) if not self.available: return None - return self.camera_data.live_view.rtsp + return self.camera_data.live_view.rtsp # type: ignore[no-any-return] - def enable_motion_detection(self): + def enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" _LOGGER.debug( "SynoDSMCamera.enable_motion_detection(%s)", @@ -160,7 +164,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): ) self._api.surveillance_station.enable_motion_detection(self._camera_id) - def disable_motion_detection(self): + def disable_motion_detection(self) -> None: """Disable motion detection in camera.""" _LOGGER.debug( "SynoDSMCamera.disable_motion_detection(%s)", diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 3cbc58d4cf4..85d1cb4bf7a 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -1,5 +1,8 @@ """Config flow to configure the Synology DSM integration.""" +from __future__ import annotations + import logging +from typing import Any from urllib.parse import urlparse from synology_dsm import SynologyDSM @@ -12,8 +15,14 @@ from synology_dsm.exceptions import ( ) import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import exceptions from homeassistant.components import ssdp +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_POLL, + ConfigEntry, + ConfigFlow, + OptionsFlow, +) from homeassistant.const import ( CONF_DISKS, CONF_HOST, @@ -28,7 +37,9 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_DEVICE_TOKEN, @@ -47,11 +58,11 @@ _LOGGER = logging.getLogger(__name__) CONF_OTP_CODE = "otp_code" -def _discovery_schema_with_defaults(discovery_info): +def _discovery_schema_with_defaults(discovery_info: DiscoveryInfoType) -> vol.Schema: return vol.Schema(_ordered_shared_schema(discovery_info)) -def _user_schema_with_defaults(user_input): +def _user_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: user_schema = { vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, } @@ -60,7 +71,9 @@ def _user_schema_with_defaults(user_input): return vol.Schema(user_schema) -def _ordered_shared_schema(schema_input): +def _ordered_shared_schema( + schema_input: dict[str, Any] +) -> dict[vol.Required | vol.Optional, Any]: return { vol.Required(CONF_USERNAME, default=schema_input.get(CONF_USERNAME, "")): str, vol.Required(CONF_PASSWORD, default=schema_input.get(CONF_PASSWORD, "")): str, @@ -75,23 +88,30 @@ def _ordered_shared_schema(schema_input): } -class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_CLOUD_POLL @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> SynologyDSMOptionsFlowHandler: """Get the options flow for this handler.""" return SynologyDSMOptionsFlowHandler(config_entry) - def __init__(self): + def __init__(self) -> None: """Initialize the synology_dsm config flow.""" - self.saved_user_input = {} - self.discovered_conf = {} + self.saved_user_input: dict[str, Any] = {} + self.discovered_conf: dict[str, Any] = {} - async def _show_setup_form(self, user_input=None, errors=None): + async def _show_setup_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> FlowResult: """Show the setup form to the user.""" if not user_input: user_input = {} @@ -111,7 +131,9 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders=self.discovered_conf or {}, ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} @@ -188,7 +210,7 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=host, data=config_data) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle a discovered synology_dsm.""" parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) friendly_name = ( @@ -211,15 +233,19 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() - async def async_step_import(self, user_input=None): + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Import a config entry.""" return await self.async_step_user(user_input) - async def async_step_link(self, user_input): + async def async_step_link(self, user_input: dict[str, Any]) -> FlowResult: """Link a config entry from discovery.""" return await self.async_step_user(user_input) - async def async_step_2sa(self, user_input, errors=None): + async def async_step_2sa( + self, user_input: dict[str, Any], errors: dict[str, str] | None = None + ) -> FlowResult: """Enter 2SA code to anthenticate.""" if not self.saved_user_input: self.saved_user_input = user_input @@ -236,7 +262,7 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) - def _mac_already_configured(self, mac): + def _mac_already_configured(self, mac: str) -> bool: """See if we already have configured a NAS with this MAC address.""" existing_macs = [ mac.replace("-", "") @@ -246,14 +272,16 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return mac in existing_macs -class SynologyDSMOptionsFlowHandler(config_entries.OptionsFlow): +class SynologyDSMOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: ConfigEntry): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -277,7 +305,7 @@ class SynologyDSMOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="init", data_schema=data_schema) -def _login_and_fetch_syno_info(api, otp_code): +def _login_and_fetch_syno_info(api: SynologyDSM, otp_code: str) -> str: """Login to the NAS and fetch basic data.""" # These do i/o api.login(otp_code) @@ -293,7 +321,7 @@ def _login_and_fetch_syno_info(api, otp_code): ): raise InvalidData - return api.information.serial + return api.information.serial # type: ignore[no-any-return] class InvalidData(exceptions.HomeAssistantError): diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index ba1aa393c85..334832ddf2b 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -1,4 +1,7 @@ """Constants for Synology DSM.""" +from __future__ import annotations + +from typing import Final, TypedDict from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.upgrade import SynoCoreUpgrade @@ -17,6 +20,17 @@ from homeassistant.const import ( PERCENTAGE, ) + +class EntityInfo(TypedDict): + """TypedDict for EntityInfo.""" + + name: str + unit: str | None + icon: str | None + device_class: str | None + enable: bool + + DOMAIN = "synology_dsm" PLATFORMS = ["binary_sensor", "camera", "sensor", "switch"] COORDINATOR_CAMERAS = "coordinator_cameras" @@ -43,11 +57,11 @@ DEFAULT_TIMEOUT = 10 # sec ENTITY_UNIT_LOAD = "load" -ENTITY_NAME = "name" -ENTITY_UNIT = "unit" -ENTITY_ICON = "icon" -ENTITY_CLASS = "device_class" -ENTITY_ENABLE = "enable" +ENTITY_NAME: Final = "name" +ENTITY_UNIT: Final = "unit" +ENTITY_ICON: Final = "icon" +ENTITY_CLASS: Final = "device_class" +ENTITY_ENABLE: Final = "enable" # Services SERVICE_REBOOT = "reboot" @@ -60,7 +74,7 @@ SERVICES = [ # Entity keys should start with the API_KEY to fetch # Binary sensors -UPGRADE_BINARY_SENSORS = { +UPGRADE_BINARY_SENSORS: dict[str, EntityInfo] = { f"{SynoCoreUpgrade.API_KEY}:update_available": { ENTITY_NAME: "Update available", ENTITY_UNIT: None, @@ -70,7 +84,7 @@ UPGRADE_BINARY_SENSORS = { }, } -SECURITY_BINARY_SENSORS = { +SECURITY_BINARY_SENSORS: dict[str, EntityInfo] = { f"{SynoCoreSecurity.API_KEY}:status": { ENTITY_NAME: "Security status", ENTITY_UNIT: None, @@ -80,7 +94,7 @@ SECURITY_BINARY_SENSORS = { }, } -STORAGE_DISK_BINARY_SENSORS = { +STORAGE_DISK_BINARY_SENSORS: dict[str, EntityInfo] = { f"{SynoStorage.API_KEY}:disk_exceed_bad_sector_thr": { ENTITY_NAME: "Exceeded Max Bad Sectors", ENTITY_UNIT: None, @@ -98,7 +112,7 @@ STORAGE_DISK_BINARY_SENSORS = { } # Sensors -UTILISATION_SENSORS = { +UTILISATION_SENSORS: dict[str, EntityInfo] = { f"{SynoCoreUtilization.API_KEY}:cpu_other_load": { ENTITY_NAME: "CPU Utilization (Other)", ENTITY_UNIT: PERCENTAGE, @@ -212,7 +226,7 @@ UTILISATION_SENSORS = { ENTITY_ENABLE: True, }, } -STORAGE_VOL_SENSORS = { +STORAGE_VOL_SENSORS: dict[str, EntityInfo] = { f"{SynoStorage.API_KEY}:volume_status": { ENTITY_NAME: "Status", ENTITY_UNIT: None, @@ -256,7 +270,7 @@ STORAGE_VOL_SENSORS = { ENTITY_ENABLE: False, }, } -STORAGE_DISK_SENSORS = { +STORAGE_DISK_SENSORS: dict[str, EntityInfo] = { f"{SynoStorage.API_KEY}:disk_smart_status": { ENTITY_NAME: "Status (Smart)", ENTITY_UNIT: None, @@ -280,7 +294,7 @@ STORAGE_DISK_SENSORS = { }, } -INFORMATION_SENSORS = { +INFORMATION_SENSORS: dict[str, EntityInfo] = { f"{SynoDSMInformation.API_KEY}:temperature": { ENTITY_NAME: "temperature", ENTITY_UNIT: None, @@ -298,7 +312,7 @@ INFORMATION_SENSORS = { } # Switch -SURVEILLANCE_SWITCH = { +SURVEILLANCE_SWITCH: dict[str, EntityInfo] = { f"{SynoSurveillanceStation.HOME_MODE_API_KEY}:home_mode": { ENTITY_NAME: "home mode", ENTITY_UNIT: None, diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index d4a9b0bb7fc..4cf982e15f6 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -14,6 +15,7 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.temperature import display_temp from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utcnow @@ -30,19 +32,20 @@ from .const import ( SYNO_API, TEMP_SENSORS_KEYS, UTILISATION_SENSORS, + EntityInfo, ) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS Sensor.""" data = hass.data[DOMAIN][entry.unique_id] - api = data[SYNO_API] + api: SynoApi = data[SYNO_API] coordinator = data[COORDINATOR_CENTRAL] - entities = [ + entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ SynoDSMUtilSensor( api, sensor_type, UTILISATION_SENSORS[sensor_type], coordinator ) @@ -91,7 +94,7 @@ class SynoDSMSensor(SynologyDSMBaseEntity): """Mixin for sensor specific attributes.""" @property - def unit_of_measurement(self) -> str: + def unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" if self.entity_type in TEMP_SENSORS_KEYS: return self.hass.config.units.temperature_unit @@ -102,7 +105,7 @@ class SynoDSMUtilSensor(SynoDSMSensor, SensorEntity): """Representation a Synology Utilisation sensor.""" @property - def state(self): + def state(self) -> Any | None: """Return the state.""" attr = getattr(self._api.utilisation, self.entity_type) if callable(attr): @@ -134,7 +137,7 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor, SensorEntity) """Representation a Synology Storage sensor.""" @property - def state(self): + def state(self) -> Any | None: """Return the state.""" attr = getattr(self._api.storage, self.entity_type)(self._device_id) if attr is None: @@ -158,16 +161,16 @@ class SynoDSMInfoSensor(SynoDSMSensor, SensorEntity): self, api: SynoApi, entity_type: str, - entity_info: dict[str, str], - coordinator: DataUpdateCoordinator, - ): + entity_info: EntityInfo, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + ) -> None: """Initialize the Synology SynoDSMInfoSensor entity.""" super().__init__(api, entity_type, entity_info, coordinator) - self._previous_uptime = None - self._last_boot = None + self._previous_uptime: str | None = None + self._last_boot: str | None = None @property - def state(self): + def state(self) -> Any | None: """Return the state.""" attr = getattr(self._api.information, self.entity_type) if attr is None: diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 817c38674d5..27ffbfde799 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from synology_dsm.api.surveillance_station import SynoSurveillanceStation @@ -9,21 +10,28 @@ from homeassistant.components.switch import ToggleEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi, SynologyDSMBaseEntity -from .const import COORDINATOR_SWITCHES, DOMAIN, SURVEILLANCE_SWITCH, SYNO_API +from .const import ( + COORDINATOR_SWITCHES, + DOMAIN, + SURVEILLANCE_SWITCH, + SYNO_API, + EntityInfo, +) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS switch.""" data = hass.data[DOMAIN][entry.unique_id] - api = data[SYNO_API] + api: SynoApi = data[SYNO_API] entities = [] @@ -32,7 +40,7 @@ async def async_setup_entry( version = info["data"]["CMSMinVersion"] # initial data fetch - coordinator = data[COORDINATOR_SWITCHES] + coordinator: DataUpdateCoordinator = data[COORDINATOR_SWITCHES] await coordinator.async_refresh() entities += [ SynoDSMSurveillanceHomeModeToggle( @@ -47,14 +55,16 @@ async def async_setup_entry( class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, ToggleEntity): """Representation a Synology Surveillance Station Home Mode toggle.""" + coordinator: DataUpdateCoordinator[dict[str, dict[str, bool]]] + def __init__( self, api: SynoApi, entity_type: str, - entity_info: dict[str, str], + entity_info: EntityInfo, version: str, - coordinator: DataUpdateCoordinator, - ): + coordinator: DataUpdateCoordinator[dict[str, dict[str, bool]]], + ) -> None: """Initialize a Synology Surveillance Station Home Mode.""" super().__init__( api, @@ -69,7 +79,7 @@ class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, ToggleEntity): """Return the state.""" return self.coordinator.data["switches"][self.entity_type] - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on Home mode.""" _LOGGER.debug( "SynoDSMSurveillanceHomeModeToggle.turn_on(%s)", @@ -80,7 +90,7 @@ class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, ToggleEntity): ) await self.coordinator.async_request_refresh() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off Home mode.""" _LOGGER.debug( "SynoDSMSurveillanceHomeModeToggle.turn_off(%s)", @@ -103,8 +113,7 @@ class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, ToggleEntity): "identifiers": { ( DOMAIN, - self._api.information.serial, - SynoSurveillanceStation.INFO_API_KEY, + f"{self._api.information.serial}_{SynoSurveillanceStation.INFO_API_KEY}", ) }, "name": "Surveillance Station", diff --git a/mypy.ini b/mypy.ini index 7d78261e238..ef86441ef4d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -529,6 +529,19 @@ warn_return_any = true warn_unreachable = true warn_unused_ignores = true +[mypy-homeassistant.components.synology_dsm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +strict_equality = true +warn_return_any = true +warn_unreachable = true +warn_unused_ignores = true + [mypy-homeassistant.components.systemmonitor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1232,9 +1245,6 @@ ignore_errors = true [mypy-homeassistant.components.switcher_kis.*] ignore_errors = true -[mypy-homeassistant.components.synology_dsm.*] -ignore_errors = true - [mypy-homeassistant.components.synology_srm.*] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 85dcab6efef..a810e4baf98 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -205,7 +205,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.surepetcare.*", "homeassistant.components.switchbot.*", "homeassistant.components.switcher_kis.*", - "homeassistant.components.synology_dsm.*", "homeassistant.components.synology_srm.*", "homeassistant.components.system_health.*", "homeassistant.components.system_log.*",