diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e67664c..f76421d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.9] + python-version: [3.12] steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index e21a86d..b7a4b22 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Add this repository to HACS, install this integration and restart Home Assistant - number of links - number of packages +Note: number of links/packages sensors contain state attributes that have information on ETA while downloading. + **Binary Sensor** - update available (deprecated, use designated update entity) diff --git a/custom_components/myjdownloader/__init__.py b/custom_components/myjdownloader/__init__.py index 42bd1a8..9354612 100644 --- a/custom_components/myjdownloader/__init__.py +++ b/custom_components/myjdownloader/__init__.py @@ -7,17 +7,12 @@ import datetime from http.client import HTTPException import logging -from typing import Dict from myjdapi.exception import MYJDConnectionException from myjdapi.myjdapi import Jddevice, Myjdapi, MYJDException -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -26,7 +21,7 @@ from .const import ( DATA_MYJDOWNLOADER_CLIENT, - DOMAIN, + DOMAIN as MYJDOWNLOADER_DOMAIN, MYJDAPI_APP_KEY, SCAN_INTERVAL_SECONDS, SERVICE_RESTART_AND_UPDATE, @@ -37,7 +32,14 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, SWITCH_DOMAIN, UPDATE_DOMAIN] + +# For your initial PR, limit it to 1 platform. +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] class MyJDownloaderHub: @@ -50,8 +52,8 @@ def __init__(self, hass: HomeAssistant) -> None: self._sem = asyncio.Semaphore(1) # API calls need to be sequential self.myjd = Myjdapi() self.myjd.set_app_key(MYJDAPI_APP_KEY) - self._devices = {} # type: Dict[str, Jddevice] - self.devices_platforms = defaultdict(lambda: set()) # type: Dict[str, set] + self._devices: dict[str, Jddevice] = {} + self.devices_platforms: dict[str, set] = defaultdict(lambda: set()) @Throttle(datetime.timedelta(seconds=SCAN_INTERVAL_SECONDS)) async def authenticate(self, email, password) -> bool: @@ -64,13 +66,12 @@ async def authenticate(self, email, password) -> bool: except MYJDException as exception: _LOGGER.error("Failed to connect to MyJDownloader") raise exception - else: - return self.myjd.is_connected() + + return self.myjd.is_connected() async def async_query(self, func, *args, **kwargs): """Perform query while ensuring sequentiality of API calls.""" - # TODO catch exceptions, retry once with reconnect, then connect, then reauth if invalid_auth - # TODO maybe with self.myjd.is_connected() + # TODO catch exceptions, retry once with reconnect, then connect, then reauth if invalid_auth maybe with self.myjd.is_connected() try: async with self._sem: return await self._hass.async_add_executor_job(func, *args, **kwargs) @@ -94,7 +95,7 @@ async def async_update_devices(self, *args, **kwargs): new_devices = {} available_device_infos = await self.async_query(self.myjd.list_devices) for device_info in available_device_infos: - if not device_info["id"] in self._devices: + if device_info["id"] not in self._devices: _LOGGER.debug("JDownloader (%s) is online", device_info["name"]) new_devices.update( { @@ -105,7 +106,7 @@ async def async_update_devices(self, *args, **kwargs): ) if new_devices: self._devices.update(new_devices) - async_dispatcher_send(self._hass, f"{DOMAIN}_new_devices") + async_dispatcher_send(self._hass, f"{MYJDOWNLOADER_DOMAIN}_new_devices") # remove JDownloader objects, that are not online anymore unavailable_device_ids = [ @@ -132,7 +133,9 @@ def get_device(self, device_id): try: return self._devices[device_id] except Exception as ex: - raise Exception(f"JDownloader ({device_id}) not online") from ex + raise JDownloaderOfflineException( + f"JDownloader ({device_id}) offline" + ) from ex async def make_request(self, url): """Make a http request.""" @@ -146,7 +149,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up MyJDownloader from a config entry.""" # create data storage - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_MYJDOWNLOADER_CLIENT: None} + hass.data.setdefault(MYJDOWNLOADER_DOMAIN, {})[entry.entry_id] = { + DATA_MYJDOWNLOADER_CLIENT: None + } # initial connection hub = MyJDownloaderHub(hass) @@ -157,17 +162,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady except MYJDException as exception: raise ConfigEntryNotReady from exception - else: - await hub.async_update_devices() # get initial list of JDownloaders - hass.data.setdefault(DOMAIN, {})[entry.entry_id][ - DATA_MYJDOWNLOADER_CLIENT - ] = hub - - # setup platforms - for platform in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + + await hub.async_update_devices() # get initial list of JDownloaders + hass.data.setdefault(MYJDOWNLOADER_DOMAIN, {})[entry.entry_id][ + DATA_MYJDOWNLOADER_CLIENT + ] = hub + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Services are defined in MyJDownloaderDeviceEntity and # registered in setup of sensor platform. @@ -179,21 +180,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" # remove services - hass.services.async_remove(DOMAIN, SERVICE_RESTART_AND_UPDATE) - hass.services.async_remove(DOMAIN, SERVICE_RUN_UPDATE_CHECK) - hass.services.async_remove(DOMAIN, SERVICE_START_DOWNLOADS) - hass.services.async_remove(DOMAIN, SERVICE_STOP_DOWNLOADS) + hass.services.async_remove(MYJDOWNLOADER_DOMAIN, SERVICE_RESTART_AND_UPDATE) + hass.services.async_remove(MYJDOWNLOADER_DOMAIN, SERVICE_RUN_UPDATE_CHECK) + hass.services.async_remove(MYJDOWNLOADER_DOMAIN, SERVICE_START_DOWNLOADS) + hass.services.async_remove(MYJDOWNLOADER_DOMAIN, SERVICE_STOP_DOWNLOADS) # unload platforms - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS - ] - ) - ) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[MYJDOWNLOADER_DOMAIN].pop(entry.entry_id) return unload_ok + + +class JDownloaderOfflineException(Exception): + """JDownloader offline exception.""" diff --git a/custom_components/myjdownloader/binary_sensor.py b/custom_components/myjdownloader/binary_sensor.py index 0074ae7..ea56756 100644 --- a/custom_components/myjdownloader/binary_sensor.py +++ b/custom_components/myjdownloader/binary_sensor.py @@ -1,4 +1,5 @@ """MyJDownloader binary sensors.""" + from __future__ import annotations import datetime @@ -8,9 +9,11 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyJDownloaderHub from .const import ( @@ -23,7 +26,12 @@ SCAN_INTERVAL = datetime.timedelta(seconds=SCAN_INTERVAL_SECONDS) -async def async_setup_entry(hass, entry, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info=None, +) -> None: """Set up the binary sensor using config entry.""" hub = hass.data[MYJDOWNLOADER_DOMAIN][entry.entry_id][DATA_MYJDOWNLOADER_CLIENT] @@ -31,7 +39,7 @@ async def async_setup_entry(hass, entry, async_add_entities, discovery_info=None def async_add_binary_sensor(devices=hub.devices): entities = [] - for device_id in devices.keys(): + for device_id in devices: if DOMAIN not in hub.devices_platforms[device_id]: hub.devices_platforms[device_id].add(DOMAIN) entities += [ @@ -60,7 +68,7 @@ def __init__( name_template: str, icon: str | None, measurement: str, - device_class: str = None, + device_class: BinarySensorDeviceClass | None = None, entity_category: EntityCategory | None = None, enabled_default: bool = True, ) -> None: @@ -90,7 +98,7 @@ def is_on(self) -> bool | None: return self._state @property - def device_class(self) -> str | None: + def device_class(self) -> BinarySensorDeviceClass | None: """Return the device class.""" return self._device_class diff --git a/custom_components/myjdownloader/config_flow.py b/custom_components/myjdownloader/config_flow.py index debf2ef..7c228de 100644 --- a/custom_components/myjdownloader/config_flow.py +++ b/custom_components/myjdownloader/config_flow.py @@ -1,4 +1,5 @@ """Config flow for MyJDownloader integration.""" + from __future__ import annotations import logging @@ -7,18 +8,22 @@ from myjdapi.myjdapi import MYJDException import voluptuous as vol -from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from . import MyJDownloaderHub -from .const import DOMAIN +from .const import DOMAIN, TITLE _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema({"email": str, "password": str}) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: @@ -26,7 +31,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - hub = MyJDownloaderHub(hass) try: if not await hub.authenticate(data[CONF_EMAIL], data[CONF_PASSWORD]): @@ -34,42 +38,32 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except MYJDException as exception: raise CannotConnect from exception - return {"title": "MyJDownloader"} + # Return info that you want to store in the config entry. + return {"title": TITLE} -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class MyJDownloaderConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for MyJDownloader.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - - entries = self._async_current_entries() - for entry in entries: - if entry.data[CONF_EMAIL] == user_input[CONF_EMAIL]: - return self.async_abort(reason="already_configured") - - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return self.async_create_entry(title=info["title"], data=user_input) + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors diff --git a/custom_components/myjdownloader/const.py b/custom_components/myjdownloader/const.py index e67a64a..1f51175 100644 --- a/custom_components/myjdownloader/const.py +++ b/custom_components/myjdownloader/const.py @@ -1,6 +1,7 @@ """Constants for the MyJDownloader integration.""" DOMAIN = "myjdownloader" +TITLE = "MyJDownloader" SCAN_INTERVAL_SECONDS = 60 diff --git a/custom_components/myjdownloader/entities.py b/custom_components/myjdownloader/entities.py index a2880c1..fdecf40 100644 --- a/custom_components/myjdownloader/entities.py +++ b/custom_components/myjdownloader/entities.py @@ -1,4 +1,5 @@ """Base entity classes for MyJDownloader integration.""" + from __future__ import annotations import logging @@ -6,8 +7,9 @@ from myjdapi.exception import MYJDConnectionException, MYJDException -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory +from homeassistant.const import EntityCategory +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity from . import MyJDownloaderHub from .const import DOMAIN @@ -79,7 +81,7 @@ async def async_update(self) -> None: async def _myjdownloader_update(self) -> None: """Update MyJDownloader entity.""" - raise NotImplementedError() + raise NotImplementedError class MyJDownloaderDeviceEntity(MyJDownloaderEntity): @@ -112,7 +114,7 @@ def device_info(self) -> DeviceInfo: manufacturer="AppWork GmbH", model=self._device_type, entry_type=DeviceEntryType.SERVICE, - # sw_version=self._sw_version # Todo await self.hub.async_query(device.jd.get_core_revision) + # sw_version=self._sw_version # TODO await self.hub.async_query(device.jd.get_core_revision) ) async def async_update(self) -> None: diff --git a/custom_components/myjdownloader/icons.json b/custom_components/myjdownloader/icons.json new file mode 100644 index 0000000..175d431 --- /dev/null +++ b/custom_components/myjdownloader/icons.json @@ -0,0 +1,8 @@ +{ + "services": { + "restart_and_update": "mdi:restart", + "run_update_check": "mdi:update", + "start_downloads": "mdi:play", + "stop_downloads": "mdi:stop" + } +} diff --git a/custom_components/myjdownloader/manifest.json b/custom_components/myjdownloader/manifest.json index d0d5fcc..29432ab 100644 --- a/custom_components/myjdownloader/manifest.json +++ b/custom_components/myjdownloader/manifest.json @@ -7,6 +7,6 @@ "requirements": ["myjdapi==1.1.6"], "dependencies": [], "codeowners": ["@doudz", "@oribafi"], - "version": "2.3.4", + "version": "2.4.0", "iot_class": "cloud_polling" } diff --git a/custom_components/myjdownloader/sensor.py b/custom_components/myjdownloader/sensor.py index 266771a..9543fe6 100644 --- a/custom_components/myjdownloader/sensor.py +++ b/custom_components/myjdownloader/sensor.py @@ -1,14 +1,19 @@ """MyJDownloader sensors.""" + from __future__ import annotations import datetime +from typing import Any + +from myjdapi.myjdapi import Jddevice from homeassistant.components.sensor import DOMAIN, SensorEntity, SensorStateClass -from homeassistant.const import DATA_RATE_MEGABYTES_PER_SECOND -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfDataRate +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyJDownloaderHub from .const import ( @@ -27,7 +32,12 @@ SCAN_INTERVAL = datetime.timedelta(seconds=SCAN_INTERVAL_SECONDS) -async def async_setup_entry(hass, entry, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info=None, +) -> None: """Set up the sensor using config entry.""" hub = hass.data[MYJDOWNLOADER_DOMAIN][entry.entry_id][DATA_MYJDOWNLOADER_CLIENT] @@ -38,7 +48,7 @@ async def async_setup_entry(hass, entry, async_add_entities, discovery_info=None def async_add_sensor(devices=hub.devices): entities = [] - for device_id in devices.keys(): + for device_id in devices: if DOMAIN not in hub.devices_platforms[device_id]: hub.devices_platforms[device_id].add(DOMAIN) entities += [ @@ -197,7 +207,7 @@ def __init__( super().__init__( hub, "JDownloaders Online", "mdi:download-multiple", "number", None, None ) - self.devices: list = [] + self.devices: dict[str, Jddevice] = {} async def _myjdownloader_update(self) -> None: """Update MyJDownloader entity.""" @@ -206,7 +216,7 @@ async def _myjdownloader_update(self) -> None: self._state = str(len(self.devices)) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" devices = sorted(self.devices.values(), key=lambda x: x.name) return { @@ -230,7 +240,7 @@ def __init__( "JDownloader $device_name Download Speed", "mdi:download", "download_speed", - DATA_RATE_MEGABYTES_PER_SECOND, + UnitOfDataRate.MEGABYTES_PER_SECOND, SensorStateClass.MEASUREMENT, ) @@ -258,7 +268,7 @@ def __init__( hub, device_id, "JDownloader $device_name Packages", - "mdi:download", + "mdi:package-down", "packages", None, None, @@ -275,7 +285,7 @@ async def _myjdownloader_update(self) -> None: self._state = str(len(self._packages_list)) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {ATTR_PACKAGES: self._packages_list} @@ -294,7 +304,7 @@ def __init__( hub, device_id, "JDownloader $device_name Links", - "mdi:download", + "mdi:link-box", "links", None, None, @@ -309,7 +319,7 @@ async def _myjdownloader_update(self) -> None: self._state = str(len(self._links_list)) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {ATTR_LINKS: self._links_list} @@ -320,7 +330,7 @@ class MyJDownloaderStatusSensor(MyJDownloaderDeviceSensor): STATE_ICONS = { "idle": "mdi:stop", "running": "mdi:play", - "pause": "mdi:pause", + "paused": "mdi:pause", "stopped": "mdi:stop", } @@ -351,4 +361,7 @@ async def _myjdownloader_update(self) -> None: """Update MyJDownloader entity.""" device = self.hub.get_device(self._device_id) status = await self.hub.async_query(device.downloadcontroller.get_current_state) - self._state = status.lower().replace("_state", "") # stopped_state -> stopped + status = status.lower() + status = status.replace("_state", "") # stopped_state -> stopped + status = "paused" if status == "pause" else status # pause -> paused + self._state = status diff --git a/custom_components/myjdownloader/services.yaml b/custom_components/myjdownloader/services.yaml index 8250f52..181fbef 100644 --- a/custom_components/myjdownloader/services.yaml +++ b/custom_components/myjdownloader/services.yaml @@ -1,24 +1,16 @@ restart_and_update: - name: Restart and update JDownloader - description: Updates all plugins and restarts JDownloader target: device: integration: myjdownloader run_update_check: - name: Run update check - description: Run update check of JDownloader target: device: integration: myjdownloader start_downloads: - name: Start downloads - description: Start downloads of JDownloader target: device: integration: myjdownloader stop_downloads: - name: Stop downloads - description: Stop downloads of JDownloader target: device: integration: myjdownloader diff --git a/custom_components/myjdownloader/strings.json b/custom_components/myjdownloader/strings.json index d8dbb30..fa4b4cd 100644 --- a/custom_components/myjdownloader/strings.json +++ b/custom_components/myjdownloader/strings.json @@ -1,20 +1,49 @@ -{ - "config": { - "step": { - "user": { - "data": { - "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - } -} +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity_component": { + "_": { + "name": "MyJDownloader", + "state": { + "idle": "[%key:common::state::idle%]", + "running": "Running", + "paused": "[%key:common::state::paused%]", + "stopped": "Stopped" + } + } + }, + "services": { + "restart_and_update": { + "name": "Restart and update", + "description": "Restarts and updates JDownloader." + }, + "run_update_check": { + "name": "Run update check", + "description": "Checks for updates." + }, + "stop_downloads": { + "name": "[%key:common::action::stop%]", + "description": "Stops downloading." + }, + "start_downloads": { + "name": "[%key:common::action::start%]", + "description": "Starts downloading." + } + } +} diff --git a/custom_components/myjdownloader/switch.py b/custom_components/myjdownloader/switch.py index f777f66..0a005f4 100644 --- a/custom_components/myjdownloader/switch.py +++ b/custom_components/myjdownloader/switch.py @@ -1,15 +1,19 @@ """MyJDownloader switches.""" + from __future__ import annotations import datetime import logging +from typing import Any from myjdapi.myjdapi import MYJDException from homeassistant.components.switch import DOMAIN, SwitchEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyJDownloaderHub from .const import ( @@ -24,7 +28,12 @@ SCAN_INTERVAL = datetime.timedelta(seconds=SCAN_INTERVAL_SECONDS) -async def async_setup_entry(hass, entry, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info=None, +) -> None: """Set up the switch using config entry.""" hub = hass.data[MYJDOWNLOADER_DOMAIN][entry.entry_id][DATA_MYJDOWNLOADER_CLIENT] @@ -32,7 +41,7 @@ async def async_setup_entry(hass, entry, async_add_entities, discovery_info=None def async_add_switch(devices=hub.devices): entities = [] - for device_id in devices.keys(): + for device_id in devices: if DOMAIN not in hub.devices_platforms[device_id]: hub.devices_platforms[device_id].add(DOMAIN) entities += [ @@ -80,7 +89,7 @@ def is_on(self) -> bool: """Return the state of the switch.""" return self._state - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" try: await self._myjdownloader_turn_off() @@ -90,9 +99,9 @@ async def async_turn_off(self, **kwargs) -> None: async def _myjdownloader_turn_off(self) -> None: """Turn off the switch.""" - raise NotImplementedError() + raise NotImplementedError - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" try: await self._myjdownloader_turn_on() @@ -102,7 +111,7 @@ async def async_turn_on(self, **kwargs) -> None: async def _myjdownloader_turn_on(self) -> None: """Turn on the switch.""" - raise NotImplementedError() + raise NotImplementedError class MyJDownloaderPauseSwitch(MyJDownloaderSwitch): diff --git a/custom_components/myjdownloader/translations/en.json b/custom_components/myjdownloader/translations/en.json index 51e57a2..a619775 100644 --- a/custom_components/myjdownloader/translations/en.json +++ b/custom_components/myjdownloader/translations/en.json @@ -1,20 +1,49 @@ -{ - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "email": "Email", - "password": "Password" - } - } - } - } +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + } + }, + "entity_component": { + "_": { + "name": "MyJDownloader", + "state": { + "idle": "Idle", + "paused": "Paused", + "running": "Running", + "stopped": "Stopped" + } + } + }, + "services": { + "restart_and_update": { + "description": "Restarts and updates JDownloader.", + "name": "Restart and update" + }, + "run_update_check": { + "description": "Checks for updates.", + "name": "Run update check" + }, + "start_downloads": { + "description": "Starts downloading.", + "name": "Start" + }, + "stop_downloads": { + "description": "Stops downloading.", + "name": "Stop" + } + } } \ No newline at end of file diff --git a/custom_components/myjdownloader/update.py b/custom_components/myjdownloader/update.py index 4ddf159..8bf86fb 100644 --- a/custom_components/myjdownloader/update.py +++ b/custom_components/myjdownloader/update.py @@ -1,4 +1,5 @@ """MyJDownloader update entities.""" + from __future__ import annotations import datetime @@ -7,11 +8,17 @@ import re from typing import Any -from homeassistant.components.update import DOMAIN, UpdateEntity -from homeassistant.components.update.const import UpdateEntityFeature -from homeassistant.core import callback +from homeassistant.components.update import ( + DOMAIN, + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle from . import MyJDownloaderHub @@ -22,6 +29,7 @@ LATEST_VERSION_SCAN_INTERVAL_SECONDS, LATEST_VERSION_URL, SCAN_INTERVAL_SECONDS, + TITLE, ) from .entities import MyJDownloaderDeviceEntity @@ -30,7 +38,12 @@ SCAN_INTERVAL = datetime.timedelta(seconds=SCAN_INTERVAL_SECONDS) -async def async_setup_entry(hass, entry, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info=None, +) -> None: """Set up the update entity using config entry.""" hub = hass.data[MYJDOWNLOADER_DOMAIN][entry.entry_id][DATA_MYJDOWNLOADER_CLIENT] @@ -38,7 +51,7 @@ async def async_setup_entry(hass, entry, async_add_entities, discovery_info=None def async_add_update(devices=hub.devices): entities = [] - for device_id in devices.keys(): + for device_id in devices: if DOMAIN not in hub.devices_platforms[device_id]: hub.devices_platforms[device_id].add(DOMAIN) entities += [ @@ -66,8 +79,7 @@ def __init__( device_id: str, name_template: str, icon: str | None, - measurement: str, - device_class: str = None, + device_class: UpdateDeviceClass | None = None, entity_category: EntityCategory | None = None, enabled_default: bool = True, ) -> None: @@ -75,7 +87,6 @@ def __init__( self._state = None # current version self._latest_version: str | None = None self._device_class = device_class - self.measurement = measurement super().__init__( hub, device_id, name_template, icon, entity_category, enabled_default ) @@ -88,7 +99,6 @@ def unique_id(self) -> str: MYJDOWNLOADER_DOMAIN, self._name, DOMAIN, - self.measurement, ] ) @@ -103,7 +113,7 @@ def latest_version(self) -> str | None: return self._latest_version @property - def device_class(self) -> str | None: + def device_class(self) -> UpdateDeviceClass | None: """Return the device class.""" return self._device_class @@ -112,7 +122,7 @@ class MyJDownloaderUpdateEntity(MyJDownloaderUpdate): """Defines a MyJDownloader update entity.""" _attr_supported_features = UpdateEntityFeature.INSTALL - _attr_title = "MyJDownloader" + _attr_title = TITLE def __init__( self, @@ -125,15 +135,14 @@ def __init__( device_id, "JDownloader $device_name Update", None, - "update", None, EntityCategory.DIAGNOSTIC, ) self._update_available: bool = False self._latest_version_checked_at: datetime.datetime = ( - datetime.datetime.utcfromtimestamp(0) + datetime.datetime.fromtimestamp(0, tz=datetime.UTC) ) - self._latest_version_date: datetime.datetime | None = None + self._latest_version_date: str | None = None async def _myjdownloader_update(self) -> None: """Update MyJDownloader entity.""" @@ -142,24 +151,23 @@ async def _myjdownloader_update(self) -> None: if self._state is None or self._update_available != update_available: self._state = await self.hub.async_query(device.jd.get_core_revision) - if LATEST_VERSION_SCAN_INTERVAL_SECONDS > 0: - if ( - self._latest_version is None - or self._update_available != update_available - ) or ( + if LATEST_VERSION_SCAN_INTERVAL_SECONDS > 0 and ( + (self._latest_version is None or self._update_available != update_available) + or ( update_available and ( - datetime.datetime.utcnow() - self._latest_version_checked_at + datetime.datetime.now(datetime.UTC) + - self._latest_version_checked_at ).total_seconds() > LATEST_VERSION_SCAN_INTERVAL_SECONDS - ): - await self._update_latest_version() - else: # do not do latest version checks - if update_available: - # Note, a second update will not unskip a previously skipped update - self._latest_version = str(self._state) + "+" - else: - self._latest_version = str(self._state) + ) + ): + await self._update_latest_version() + elif update_available: # do not do latest version checks + # Note, a second update will not unskip a previously skipped update + self._latest_version = str(self._state) + "+" + else: + self._latest_version = str(self._state) self._update_available = update_available @Throttle(datetime.timedelta(seconds=SCAN_INTERVAL_SECONDS)) @@ -174,7 +182,7 @@ async def _update_latest_version(self) -> None: if match := re.match(LATEST_VERSION_REGEX, response_text): self._latest_version = match.group(1) self._latest_version_date = match.group(2) - self._latest_version_checked_at = datetime.datetime.utcnow() + self._latest_version_checked_at = datetime.datetime.now(datetime.UTC) async def async_install( self, version: str | None, backup: bool, **kwargs: Any diff --git a/hacs.json b/hacs.json index 5b7140e..6aa08ec 100644 --- a/hacs.json +++ b/hacs.json @@ -3,5 +3,5 @@ "domains": ["sensor", "binary_sensor", "switch", "update"], "render_readme": true, "iot_class": "cloud_polling", - "homeassistant": "2022.4.0b0" + "homeassistant": "2024.4.0b0" } \ No newline at end of file