Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updates and fixes for HA 2024.4: v2.4.0 #43

Merged
merged 5 commits into from
Apr 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading