Skip to content

Commit

Permalink
Updates and fixes for HA 2024.4: v2.4.0 (#43)
Browse files Browse the repository at this point in the history
* Adjust code to HA 2024.4
* Add service icons and translations
* Use new download speed constant
* Fix typing
* Change "pause" state to "paused"
* Fix line endings
* Add note about ETA info in packages/links sensors
* Bump Python to 3.12 in test.yml
* Bump version from 2.3.4 to 2.4.0
  • Loading branch information
oribafi authored Apr 14, 2024
1 parent 7602ca8 commit 7bc7e1c
Show file tree
Hide file tree
Showing 16 changed files with 279 additions and 187 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [3.9]
python-version: [3.12]

steps:
- uses: actions/checkout@v2
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
83 changes: 40 additions & 43 deletions custom_components/myjdownloader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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(
{
Expand All @@ -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 = [
Expand All @@ -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."""
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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."""
20 changes: 14 additions & 6 deletions custom_components/myjdownloader/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""MyJDownloader binary sensors."""

from __future__ import annotations

import datetime
Expand All @@ -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 (
Expand All @@ -23,15 +26,20 @@
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]

@callback
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 += [
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
58 changes: 26 additions & 32 deletions custom_components/myjdownloader/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Config flow for MyJDownloader integration."""

from __future__ import annotations

import logging
Expand All @@ -7,69 +8,62 @@
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]:
"""Validate the user input allows us to connect.
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]):
raise InvalidAuth
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
Expand Down
1 change: 1 addition & 0 deletions custom_components/myjdownloader/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Constants for the MyJDownloader integration."""

DOMAIN = "myjdownloader"
TITLE = "MyJDownloader"

SCAN_INTERVAL_SECONDS = 60

Expand Down
10 changes: 6 additions & 4 deletions custom_components/myjdownloader/entities.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Base entity classes for MyJDownloader integration."""

from __future__ import annotations

import logging
from string import Template

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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 7bc7e1c

Please sign in to comment.