diff --git a/README.md b/README.md index 9ed367a..d284347 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ [![Project Maintenance][maintenance-shield]][user_profile] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] -**This component will set up the following platforms.** +**This component will set up the following sensors.** -| Entity | Description | -| ------------------------------ | ------------------------------------ | -| `binary_sensor`:`connectivity` | Show whether the server is connected | -| `sensor`:`open_sessions` | Show number of open audio sessions | +| Entity | Type | Description | +| --------------- | ---------------- | ------------------------------------ | +| `connectivity` | `binary_sensor` | Show whether the server is connected | +| `sessions` | `sensor` | Show number of open audio sessions | +| `libraries` | `sensor` | Number of libraries on the server | ## Installation diff --git a/custom_components/audiobookshelf/__init__.py b/custom_components/audiobookshelf/__init__.py index ca31d1a..a60d330 100644 --- a/custom_components/audiobookshelf/__init__.py +++ b/custom_components/audiobookshelf/__init__.py @@ -1,160 +1,44 @@ -"""Init for audiobookshelf integration""" - -import asyncio +"""Custom component for Audiobookshelf.""" import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Config, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from requests import HTTPError, Timeout - -from custom_components.audiobookshelf.api import AudiobookshelfApiClient - -from .const import ( - CONF_ACCESS_TOKEN, - CONF_HOST, - DOMAIN, - ISSUE_URL, - PLATFORMS, - SCAN_INTERVAL, - VERSION, -) - -_LOGGER: logging.Logger = logging.getLogger(__package__) - - -class AudiobookshelfDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the API.""" - - def __init__( - self, - hass: HomeAssistant, - client: AudiobookshelfApiClient, - ) -> None: - """Initialize.""" - self.api = client - self.platforms = [] - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - - async def _async_update_data(self) -> dict[str, None]: - """Update data via library.""" - update = {"connectivity": None, "users": None, "sessions": None} - try: - connectivity_update = await self.api.api_wrapper( - method="get", - url=self.api.get_host() + "/ping", - ) - _LOGGER.debug( - """async_update connectivity_update: %s""", - connectivity_update, - ) - update["connectivity"] = connectivity_update - except ConnectionError: - update["connectivity"] = "ConnectionError: Unable to connect." - except (TimeoutError, Timeout): - update["connectivity"] = "TimeoutError: Request timed out." - except HTTPError as http_error: - update["connectivity"] = f"HTTPError: Generic HTTP Error happened {http_error}" - try: - users_update = await self.api.api_wrapper( - method="get", - url=self.api.get_host() + "/api/users", - ) - num_users = self.api.count_active_users(users_update) - _LOGGER.debug("""async_update num_users: %s""", num_users) - update["users"] = num_users - except ConnectionError: - update["users"] = "ConnectionError: Unable to connect." - except (TimeoutError, Timeout): - update["users"] = "TimeoutError: Request timed out." - except HTTPError as http_error: - update["users"] = f"HTTPError: Generic HTTP Error happened {http_error}" - try: - online_users_update = await self.api.api_wrapper( - method="get", - url=self.api.get_host() + "/api/users/online", - ) - open_sessions = self.api.count_open_sessions(online_users_update) - _LOGGER.debug("""async_update open_sessions: %s""", open_sessions) - update["sessions"] = open_sessions - except ConnectionError: - update["sessions"] = "ConnectionError: Unable to connect." - except (TimeoutError, Timeout): - update["sessions"] = "TimeoutError: Request timed out." - except HTTPError as http_error: - update["sessions"] = f"HTTPError: Generic HTTP Error happened {http_error}" - return update - -async def async_setup(hass: HomeAssistant, config: Config) -> bool: - """Setting up this integration using YAML is not supported.""" - return True - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, -) -> bool: - """Set up this integration using UI.""" - if hass.data.get(DOMAIN) is None: - hass.data.setdefault(DOMAIN, {}) - _LOGGER.info( - """ - ------------------------------------------------------------------- - Audiobookshelf - Version: %s - This is a custom integration! - If you have any issues with this you need to open an issue here: - %s - ------------------------------------------------------------------- - """, - VERSION, - ISSUE_URL, +import voluptuous as vol +from homeassistant.helpers import config_validation as cv, discovery + +DOMAIN = "audiobookshelf" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required("api_key"): cv.string, + vol.Required("api_url"): cv.string, + vol.Optional("scan_interval", default=300): cv.positive_int + } ) + }, + extra=vol.ALLOW_EXTRA, +) - host = entry.data.get(CONF_HOST) - access_token = entry.data.get(CONF_ACCESS_TOKEN) - - session = async_get_clientsession(hass) - client = AudiobookshelfApiClient(host, access_token, session) - - coordinator = AudiobookshelfDataUpdateCoordinator(hass=hass, client=client) - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - hass.data[DOMAIN][entry.entry_id] = coordinator - - for platform in PLATFORMS: - if entry.options.get(platform, True): - coordinator.platforms.append(platform) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform), - ) - entry.add_update_listener(async_reload_entry) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle removal of an entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - unloaded = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS - if platform in coordinator.platforms - ], - ), - ) - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) - - return unloaded - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) +_LOGGER = logging.getLogger(__name__) + +async def async_setup(hass, config): + """Set up the Audiobookshelf component.""" + conf = config.get(DOMAIN) + if conf is None: + _LOGGER.error(f"No config found for {DOMAIN}!") + return True + api_key = conf["api_key"] + api_url = conf["api_url"] + scan_interval = conf["scan_interval"] + + _LOGGER.info("API URL: %s", api_url) + _LOGGER.info("Scan Interval: %s", scan_interval) + + hass.data[DOMAIN] = { + "api_key": api_key, + "api_url": api_url, + "scan_interval": scan_interval + } + # Schedule the setup of sensor platform if needed + hass.async_create_task(discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config)) + + return True \ No newline at end of file diff --git a/custom_components/audiobookshelf/api.py b/custom_components/audiobookshelf/api.py deleted file mode 100644 index 2647f73..0000000 --- a/custom_components/audiobookshelf/api.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Sample API Client.""" -import asyncio -import logging -import socket - -import aiohttp - -TIMEOUT = 10 - - -_LOGGER: logging.Logger = logging.getLogger(__package__) - -HEADERS = {"Content-type": "application/json; charset=UTF-8"} - - -class AudiobookshelfApiClient: - """API Client for communicating with Audiobookshelf server""" - - def __init__( - self, - host: str, - access_token: str, - session: aiohttp.ClientSession, - ) -> None: - """Sample API Client.""" - self._host = host - self._access_token = access_token - self._session = session - - def get_host(self) -> str: - """Getter for host var""" - return self._host - - def count_active_users(self, data: dict) -> int: - """ - Takes in an object with an array of users - and counts the active ones minus - the dummy hass one - """ - count = 0 - for user in data["users"]: - if user["isActive"] and user["username"] != "hass": - if ( - self._access_token is not None - and "token" in user - and user["token"] == self._access_token - ): - continue # Skip user with provided access_token - count += 1 - return count - - def count_open_sessions(self, data: dict) -> int: - """ - Counts the number of open stream sessions - """ - return len(data["openSessions"]) - - async def api_wrapper( - self, - method: str, - url: str, - data: dict | None = None, - headers: dict | None = None, - ) -> dict: - """Get information from the API.""" - if headers is not None: - headers["Authorization"] = f"Bearer {self._access_token}" - else: - headers = {"Authorization": f"Bearer {self._access_token}"} - try: - async with asyncio.timeout(TIMEOUT): # loop=asyncio.get_event_loop() - if method == "get": - response = await self._session.get(url, headers=headers) - if response.status >= 200 and response.status < 300: - return await response.json() - - if method == "put": - await self._session.put(url, headers=headers, json=data) - - elif method == "patch": - await self._session.patch(url, headers=headers, json=data) - - elif method == "post": - await self._session.post(url, headers=headers, json=data) - - except asyncio.TimeoutError as exception: - _LOGGER.error( - "Timeout error fetching information from %s - %s", - url, - exception, - ) - - except (KeyError, TypeError) as exception: - _LOGGER.error( - "Error parsing information from %s - %s", - url, - exception, - ) - except (aiohttp.ClientError, socket.gaierror) as exception: - _LOGGER.error( - "Error fetching information from %s - %s", - url, - exception, - ) - except Exception as exception: - _LOGGER.error("Something really wrong happened! - %s", exception) - raise exception diff --git a/custom_components/audiobookshelf/binary_sensor.py b/custom_components/audiobookshelf/binary_sensor.py deleted file mode 100644 index bdac326..0000000 --- a/custom_components/audiobookshelf/binary_sensor.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Binary sensor platform for Audiobookshelf.""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN -from .entity import AudiobookshelfEntity - -_LOGGER: logging.Logger = logging.getLogger(__package__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, -) -> None: - """Setup binary_sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([AudiobookshelfBinarySensor(coordinator, entry)]) - - -class AudiobookshelfBinarySensor(AudiobookshelfEntity, BinarySensorEntity): - """audiobookshelf binary_sensor class.""" - - @property - def name(self) -> str: - """Return the name of the binary_sensor.""" - return f"{DOMAIN}_connected" - - @property - def device_class(self) -> str: - """Return the class of this binary_sensor.""" - return "connectivity" - - @property - def is_on(self) -> bool: - """Return true if the binary_sensor is on.""" - try: - coordinator_get = self.coordinator.data.get("connectivity", "").get( - "success", - "", - ) - _LOGGER.debug("""binary_sensor coordinator got: %s""", coordinator_get) - return isinstance(coordinator_get, bool) and coordinator_get - except AttributeError: - _LOGGER.debug( - "binary_sensor: AttributeError caught while accessing coordinator data.", - ) - return False diff --git a/custom_components/audiobookshelf/config_flow.py b/custom_components/audiobookshelf/config_flow.py deleted file mode 100644 index 8c38dd7..0000000 --- a/custom_components/audiobookshelf/config_flow.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Adds config flow for Audiobookshelf.""" -from __future__ import annotations - -import logging -from typing import Any - -import aiohttp -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_create_clientsession - -from .api import AudiobookshelfApiClient -from .const import CONF_ACCESS_TOKEN, CONF_HOST, DOMAIN, PLATFORMS - -_LOGGER: logging.Logger = logging.getLogger(__package__) - - -class AudiobookshelfFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Config flow for audiobookshelf.""" - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - - def __init__(self) -> None: - """Initialize.""" - self._errors = {} - - async def async_step_user( - self, - user_input: dict[str, Any] | None = None, - ) -> FlowResult: - """Handle a flow initialized by the user.""" - self._errors = {} - - # Uncomment the next 2 lines if only a single instance of the integration is allowed: - # if self._async_current_entries(): - # return self.async_abort(reason="single_instance_allowed") - - if user_input is not None: - valid = await self._test_credentials( - user_input[CONF_HOST], - user_input[CONF_ACCESS_TOKEN], - ) - if valid: - return self.async_create_entry( - title=user_input[CONF_HOST], - data=user_input, - ) - self._errors["base"] = "auth" - - return await self._show_config_form(user_input) - - return await self._show_config_form(user_input) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> AudiobookshelfOptionsFlowHandler: - return AudiobookshelfOptionsFlowHandler(config_entry) - - async def _show_config_form( - self, - user_input: dict[str, Any] | None, # pylint: disable=unused-argument - ) -> FlowResult: - """Show the configuration form to edit location data.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_HOST): str, vol.Required(CONF_ACCESS_TOKEN): str}, - ), - errors=self._errors, - ) - - async def _test_credentials( - self, - host: str, - access_token: str, - ) -> bool: - """Return true if credentials is valid.""" - try: - session = async_create_clientsession(self.hass) - api = AudiobookshelfApiClient(host, access_token, session) - response = await api.api_wrapper( - method="get", - url=api.get_host() + "/api/users", - ) - _LOGGER.debug("""test_credentials response was: %s""", response) - if response: - return True - return False - except (ConnectionError, TimeoutError) as connection_or_timeout_error: - _LOGGER.debug("Connection or Timeout error: %s", connection_or_timeout_error) - return False - - except aiohttp.ClientResponseError as client_response_error: - _LOGGER.debug("ClientResponse Error: %s - %s", client_response_error.status, client_response_error.message) - return False - - -class AudiobookshelfOptionsFlowHandler(config_entries.OptionsFlow): - """Config flow options handler for audiobookshelf.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize HACS options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - - async def async_step_init( - self, - user_input: dict[str, Any] | None = None, # pylint: disable=unused-argument - ) -> FlowResult: - """Manage the options.""" - return await self.async_step_user() - - async def async_step_user( - self, - user_input: dict[str, Any] | None = None, - ) -> FlowResult: - """Handle a flow initialized by the user.""" - if user_input is not None: - self.options.update(user_input) - return await self._update_options() - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(x, default=self.options.get(x, True)): bool - for x in sorted(PLATFORMS) - }, - ), - ) - - async def _update_options(self) -> FlowResult: - """Update config entry options.""" - return self.async_create_entry( - title=self.config_entry.data.get(CONF_HOST), - data=self.options, - ) diff --git a/custom_components/audiobookshelf/const.py b/custom_components/audiobookshelf/const.py deleted file mode 100644 index 4db0e41..0000000 --- a/custom_components/audiobookshelf/const.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Constant for the Audiobookshelf integration""" - -# Base component constants -from datetime import timedelta - -NAME = "Audiobookshelf" -DOMAIN = "audiobookshelf" -DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "v0.0.6" - -ATTRIBUTION = "Server by https://www.audiobookshelf.org/" -ISSUE_URL = "https://github.com/wolffshots/hass-audiobookshelf/issues" - -SCAN_INTERVAL = timedelta(seconds=30) - -CONF_ACCESS_TOKEN = "access_token" -CONF_HOST = "host" - -PLATFORMS = ["binary_sensor", "sensor"] diff --git a/custom_components/audiobookshelf/entity.py b/custom_components/audiobookshelf/entity.py deleted file mode 100644 index 0f64d7f..0000000 --- a/custom_components/audiobookshelf/entity.py +++ /dev/null @@ -1,42 +0,0 @@ -"""AudiobookshelfEntity class""" -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import ATTRIBUTION, DOMAIN, NAME, VERSION - - -class AudiobookshelfEntity(CoordinatorEntity): - """Extends the Coordinator Entity which handles polling""" - - def __init__( - self, - coordinator: CoordinatorEntity, - config_entry: ConfigEntry, - ) -> None: - super().__init__(coordinator) - self.config_entry = config_entry - - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return self.config_entry.entry_id - - @property - def device_info(self) -> dict[str, Any]: - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": NAME, - "model": VERSION, - "manufacturer": NAME, - } - - @property - def device_state_attributes(self) -> dict[str, str]: - """Return the state attributes.""" - return { - "attribution": ATTRIBUTION, - "id": str(self.coordinator.data.get("id")), - "integration": DOMAIN, - } diff --git a/custom_components/audiobookshelf/manifest.json b/custom_components/audiobookshelf/manifest.json index 9febe94..e94228b 100644 --- a/custom_components/audiobookshelf/manifest.json +++ b/custom_components/audiobookshelf/manifest.json @@ -1,12 +1,12 @@ { "domain": "audiobookshelf", "name": "Audiobookshelf", - "codeowners": ["@wolffshots"], - "config_flow": true, - "dependencies": [], + "version": "0.1.1", "documentation": "https://github.com/wolffshots/hass-audiobookshelf", "iot_class": "local_polling", "issue_tracker": "https://github.com/wolffshots/hass-audiobookshelf/issues", - "requirements": [], - "version": "v0.0.6" -} + "dependencies": [], + "requirements": ["aiohttp"], + "codeowners": ["@wolffshots"], + "integration_type": "device" +} \ No newline at end of file diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index fccf7b7..23bc938 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -1,61 +1,257 @@ -"""Sensor platform for Audiobookshelf.""" + +import asyncio import logging +from typing import Any +import aiohttp +from datetime import timedelta +from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry, EntityRegistry -from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .entity import AudiobookshelfEntity +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "audiobookshelf" + +async def count_active_users(data: dict) -> int: + """ + Takes in an object with an array of users + and counts the active ones minus + the dummy hass one + """ + count = 0 + for user in data["users"]: + if user["isActive"] and user["username"] != "hass": + if ("token" in user and user["token"] == API_KEY): + continue # Skip user with provided access_token + count += 1 + return count + +async def clean_user_attributes(data: dict): + """ + Removes the token and some extra data from users + """ + for user in data["users"]: + user["token"] = "" + return data + +async def count_open_sessions(data: dict) -> int: + """ + Counts the number of open stream sessions + """ + return len(data["openSessions"]) + +async def count_libraries(data: dict) -> int: + """ + Counts the number libraries + """ + return len(data["libraries"]) + +async def extract_library_details(data: dict) -> dict: + details = {} + for library in data.get('libraries', []): + details.update({library['id']: {"mediaType": library['mediaType'],"provider": library['provider']}}) + return details + +def get_total_duration(total_duration: float): + """Calculate the total duration in hours and round it to 0 decimal places.""" + return round(total_duration / 60.0 / 60.0, 0) + +def get_total_size(total_size: float): + return round(total_size / 1024.0 / 1024.0 / 1024.0, 2) + +async def fetch_library_stats(session, id): + """Fetch data from a single endpoint.""" + headers = {"Authorization": f"Bearer {API_KEY}"} + endpoint = f"api/libraries/{id}/stats" + try: + async with session.get(f"{API_URL}/{endpoint}", headers=headers) as response: + if response.status != 200: + _LOGGER.error(f"Failed to fetch data from {endpoint}, status: {response.status}") + return None + return await response.json() + except Exception as e: + _LOGGER.error(f"Exception occurred while fetching data from {endpoint}: {e}") + return None + +async def get_library_stats(data: dict) -> dict: + library_details = await extract_library_details(data) + async with aiohttp.ClientSession() as session: + results = {} + for id in library_details: + library_stats = await fetch_library_stats(session, id) + if isinstance(library_stats, Exception): + _LOGGER.error(f"Error fetching data: {library_stats}") + else: + # response for a decent library will be HUGE if we don't pick and choose bits + summary = {} + if library_details[id]["mediaType"] == "book": + summary.update({"totalAuthors":library_stats["totalAuthors"]}) + if library_stats["totalAuthors"] is not None: + summary.update({"totalAuthors":library_stats["totalAuthors"]}) + else: + summary.update({"totalAuthors": "0"}) + elif library_details[id]["mediaType"] == "podcast": + if library_stats["numAudioTracks"] is not None: + summary.update({"numAudioTracks":library_stats["numAudioTracks"]}) + else: + summary.update({"numAudioTracks": "0"}) + + if library_stats["totalItems"] is not None: + summary.update({"totalItems":library_stats["totalItems"]}) + else: + summary.update({"totalItems": "0"}) + + if library_stats["totalSize"] is not None: + summary.update({"totalSize": f"{get_total_size(library_stats["totalSize"])}GB"}) + else: + summary.update({"totalSize": "0 GB"}) + + if library_stats["totalDuration"] is not None: + summary.update({"totalDuration": f"{get_total_duration(library_stats["totalDuration"])} hours"}) + else: + summary.update({"totalDuration": "0 hours"}) + + results.update({id: summary}) + return results -_LOGGER: logging.Logger = logging.getLogger(__package__) +async def do_nothing(data): + return data +type Sensor = dict[str, Any] -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, -) -> None: - """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([AudiobookshelfSensor(coordinator, entry)]) +# simple polling sensors +sensors: dict[str, Sensor] = { + "users": { + "endpoint": "api/users", + "name": "Audiobookshelf Users", + "data_function": count_active_users, + "attributes_function": clean_user_attributes + }, + "sessions": { + "endpoint": "api/users/online", + "name": "Audiobookshelf Open Sessions", + "data_function": count_open_sessions, + "attributes_function": do_nothing + }, + "libraries": { + "endpoint": "api/libraries", + "name": "Audiobookshelf Libraries", + "data_function": count_libraries, + "attributes_function": get_library_stats + }, +} +async def async_setup_platform(hass: HomeAssistant, config, async_add_entities, discovery_info=None): + """Set up the sensor platform.""" -class AudiobookshelfSensor(AudiobookshelfEntity): - """audiobookshelf Sensor class.""" + conf = hass.data.get(DOMAIN) + if conf is None: + _LOGGER.error("Configuration not found in hass.data") + return + + global API_URL + API_URL = conf["api_url"] + global API_KEY + API_KEY = conf["api_key"] + global SCAN_INTERVAL + SCAN_INTERVAL = timedelta(seconds=conf["scan_interval"]) + + coordinator = AudiobookshelfDataUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + + entities = [ + AudiobookshelfSensor(coordinator, sensors["users"]), + AudiobookshelfSensor(coordinator, sensors["sessions"]), + AudiobookshelfSensor(coordinator, sensors["libraries"]) + ] + async_add_entities(entities, True) + +class AudiobookshelfDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Audiobookshelf data from the API.""" + + def __init__(self, hass: HomeAssistant): + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name="audiobookshelf", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + headers = {"Authorization": f"Bearer {API_KEY}"} + data = {} + try: + async with aiohttp.ClientSession() as session: + for sensor in sensors: + async with session.get(f"{API_URL}/{sensors[sensor]["endpoint"]}", headers=headers) as response: + if response.status != 200: + raise UpdateFailed(f"Error fetching data: {response.status}") + data[sensors[sensor]["endpoint"]] = await response.json() + return data + except aiohttp.ClientError as err: + raise UpdateFailed(f"Error fetching data: {err}") + +class AudiobookshelfSensor(Entity): + """Representation of a sensor.""" + + def __init__(self, coordinator: AudiobookshelfDataUpdateCoordinator, sensor: Sensor): + """Initialize the sensor.""" + self._name = sensor["name"] + self._endpoint = sensor["endpoint"] + self.coordinator = coordinator + self._state = None + self._attributes = {} + self._process_data = sensor["data_function"] + self._process_attributes = sensor["attributes_function"] @property - def name(self) -> str: + def name(self): """Return the name of the sensor.""" - return f"{DOMAIN}_sessions" + return self._name @property - def state(self) -> int | None: + def state(self): """Return the state of the sensor.""" - try: - coordinator_get = self.coordinator.data.get( - "sessions", - "", - ) # need to work out how to add functionality to the coordinator to fetch /api/users - _LOGGER.debug("""sensor coordinator got: %s""", coordinator_get) + return self._state - if isinstance(coordinator_get, int): - return coordinator_get + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return self._attributes - return None + @property + def device_info(self): + """Return device information about this entity.""" + return { + "identifiers": {(DOMAIN, "audiobookshelf_id")}, + "name": "Audiobookshelf", + "manufacturer": "My Company", + "model": "My Model", + "sw_version": "1.0", + } - except AttributeError: - _LOGGER.debug( - "sensor: AttributeError caught while accessing coordinator data.", - ) - return None + async def async_update(self): + """Fetch new state data for the sensor.""" + data = self.coordinator.data + if data: + endpoint_data = data.get(self._endpoint, {}) + if isinstance(endpoint_data, dict): + self._attributes.update(await self._process_attributes(endpoint_data)) + self._state = await self._process_data(data = endpoint_data) + else: + _LOGGER.error("Expected endpoint_data to be a dictionary, got %s", type(endpoint_data)) + _LOGGER.debug(f"Data: {endpoint_data}") - @property - def icon(self) -> str: - """Return the icon of the sensor.""" - return "mdi:format-quote-close" + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) - @property - def device_class(self) -> str: - """Return device class of the sensor.""" - return "audiobookshelf__custom_device_class" diff --git a/custom_components/audiobookshelf/translations/en.json b/custom_components/audiobookshelf/translations/en.json deleted file mode 100644 index c8f3b8d..0000000 --- a/custom_components/audiobookshelf/translations/en.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Audiobookshelf", - "description": "If you need help with the configuration have a look here: https://github.com/wolffshots/hass-audiobookshelf", - "data": { - "host": "Host", - "access_token": "Access Token" - } - } - }, - "error": { - "auth": "Auth failed." - }, - "abort": { - "single_instance_allowed": "Only a single instance is allowed." - } - }, - "options": { - "step": { - "user": { - "data": { - "binary_sensor": "Binary sensor enabled", - "sensor": "Sensor enabled" - } - } - } - } -} diff --git a/info.md b/info.md index be3e4ed..06bad6f 100644 --- a/info.md +++ b/info.md @@ -2,19 +2,17 @@ [![GitHub Activity][commits-shield]][commits] [![License][license-shield]](LICENSE) -[![pre-commit][pre-commit-shield]][pre-commit] -[![Black][black-shield]][black] - [![hacs][hacsbadge]][hacs] [![Project Maintenance][maintenance-shield]][user_profile] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] **This component will set up the following platforms.** -| Entity | Description | -| ------------------------------ | ------------------------------------ | -| `binary_sensor`:`connectivity` | Show whether the server is connected | -| `sensor`:`open_sessions` | Show number of open audio sessions | +| Entity | Type | Description | +| --------------- | ---------------- | ------------------------------------ | +| `connectivity` | `binary_sensor` | Show whether the server is connected | +| `sessions` | `sensor` | Show number of open audio sessions | +| `libraries` | `sensor` | Number of libraries on the server | {% if not installed %} diff --git a/pyproject.toml b/pyproject.toml index cdfb3be..530f011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,10 +49,11 @@ max-complexity = 15 [tool.poetry] name = "audiobookshelf" -version = "v0.0.6" +version = "v0.1.1" description = "Audiobookshelf HA custom component" authors = ["wolffshots <16850875+wolffshots@users.noreply.github.com>"] readme = "README.md" +package-mode = false [tool.poetry.group.dev.dependencies] pre-commit = "^3.3" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 1234966..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for Audiobookshelf integration.""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index f3c4bf1..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,60 +0,0 @@ -"""pytest fixtures.""" -from unittest.mock import patch - -import aiohttp -import pytest -from _pytest.fixtures import FixtureRequest -from requests import HTTPError - - -@pytest.fixture(autouse=True) -def auto_enable_custom_integrations(enable_custom_integrations: FixtureRequest) -> None: - """Enable custom integrations defined in the test dir.""" - yield - -# In this fixture, we are forcing calls to api_wrapper to raise an Exception. This is useful -# for exception handling. -@pytest.fixture(name="error_on_get_data") -def error_get_data_fixture() -> None: - """Simulate error when retrieving data from API.""" - with patch( - "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", - side_effect=Exception, - ): - yield None - -@pytest.fixture(name="connectivity_error_on_get_data") -def connectivity_error_get_data_fixture() -> None: - """Simulate error when retrieving data from API.""" - with patch( - "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", - side_effect=ConnectionError, - ): - yield None - -@pytest.fixture(name="timeout_error_on_get_data") -def timeout_error_get_data_fixture() -> None: - """Simulate error when retrieving data from API.""" - with patch( - "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", - side_effect=TimeoutError, - ): - yield None - -@pytest.fixture(name="http_error_on_get_data") -def http_error_get_data_fixture() -> None: - """Simulate error when retrieving data from API.""" - with patch( - "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", - side_effect=HTTPError, - ): - yield None - -@pytest.fixture(name="client_error_on_get_data") -def client_error_get_data_fixture() -> None: - """Simulate error when retrieving data from API.""" - with patch( - "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", - side_effect=aiohttp.ClientResponseError(request_info=None, history=None), - ): - yield None diff --git a/tests/const.py b/tests/const.py deleted file mode 100644 index 4bc5473..0000000 --- a/tests/const.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Constants for Audiobookshelf tests.""" -from custom_components.audiobookshelf.const import ( - CONF_ACCESS_TOKEN, - CONF_HOST, -) - -MOCK_CONFIG = { - CONF_HOST: "some_host", - CONF_ACCESS_TOKEN: "some_access_token", -} diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 0d3a4ba..0000000 --- a/tests/test_api.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Tests for Audiobookshelf api.""" -import asyncio - -import aiohttp -import pytest -from _pytest.logging import LogCaptureFixture -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker - -from custom_components.audiobookshelf.api import ( - AudiobookshelfApiClient, -) - - -async def test_api( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - caplog: LogCaptureFixture, -) -> None: - """Test API calls.""" - - # To test the api submodule, we first create an instance of our API client - api = AudiobookshelfApiClient( - host="some_host", - access_token="some_access_token", - session=async_get_clientsession(hass), - ) - - caplog.clear() - aioclient_mock.clear_requests() - aioclient_mock.get("some_host", exc=asyncio.TimeoutError) - assert await api.api_wrapper("get", "some_host") is None - assert ( - len(caplog.record_tuples) == 1 - and "Timeout error fetching information from" in caplog.record_tuples[0][2] - ) - - caplog.clear() - aioclient_mock.clear_requests() - aioclient_mock.get("some_host", json={"test": "test"}) - assert (await api.api_wrapper("get", "some_host")) == {"test": "test"} - assert len(caplog.record_tuples) == 0 - - caplog.clear() - aioclient_mock.clear_requests() - aioclient_mock.put("some_host", exc=asyncio.TimeoutError) - assert await api.api_wrapper("put", "some_host") is None - assert ( - len(caplog.record_tuples) == 1 - and "Timeout error fetching information from" in caplog.record_tuples[0][2] - ) - - caplog.clear() - aioclient_mock.clear_requests() - aioclient_mock.patch("some_host", exc=asyncio.TimeoutError) - assert await api.api_wrapper("patch", "some_host") is None - assert ( - len(caplog.record_tuples) == 1 - and "Timeout error fetching information from" in caplog.record_tuples[0][2] - ) - - caplog.clear() - aioclient_mock.clear_requests() - aioclient_mock.post("some_host", exc=aiohttp.ClientError) - assert await api.api_wrapper("post", "some_host") is None - assert ( - len(caplog.record_tuples) == 1 - and "Error fetching information from" in caplog.record_tuples[0][2] - ) - - caplog.clear() - aioclient_mock.clear_requests() - aioclient_mock.post("some_host/2", exc=Exception) - with pytest.raises(Exception) as e_info: - assert await api.api_wrapper("post", "some_host/2") - assert e_info.errisinstance(Exception) - assert ( - len(caplog.record_tuples) == 1 - and "Something really wrong happened!" in caplog.record_tuples[0][2] - ) - - caplog.clear() - aioclient_mock.clear_requests() - aioclient_mock.post("some_host/3", exc=TypeError) - with pytest.raises(Exception) as e_info: - assert await api.api_wrapper("post", "some_host/3") is None - assert e_info.errisinstance(Exception) - assert ( - len(caplog.record_tuples) == 1 - and "Error parsing information from" in caplog.record_tuples[0][2] - ) - - caplog.clear() - aioclient_mock.clear_requests() - aioclient_mock.put("some_host", exc=asyncio.TimeoutError) - assert ( - await api.api_wrapper( - method="put", - url="some_host", - headers={"Test": "test header"}, - ) - is None - ) - assert ( - len(caplog.record_tuples) == 1 - and "Timeout error fetching information from" in caplog.record_tuples[0][2] - ) - - -async def test_api_helpers( - hass: HomeAssistant, - caplog: LogCaptureFixture, -) -> None: - """Test the functions that extract data from API responses""" - caplog.clear() - api = AudiobookshelfApiClient( - host="some_host", - access_token="some_access_token", - session=async_get_clientsession(hass), - ) - data = {"openSessions": [], "users": []} - assert api.count_open_sessions(data) == 0 - assert api.count_active_users(data) == 0 - data = { - "openSessions": [ - { - "bookId": "testing_session_1", - "chapters": "testing_session_1", - "coverPath": "testing_session_1", - "currentTime": "testing_session_1", - "date": "testing_session_1", - "dayOfWeek": "testing_session_1", - "deviceInfo": "testing_session_1", - "displayAuthor": "testing_session_1", - "displayTitle": "testing_session_1", - "duration": "testing_session_1", - "episodeId": "testing_session_1", - "id": "testing_session_1", - "libraryId": "testing_session_1", - "libraryItemId": "testing_session_1", - "mediaMetadata": "testing_session_1", - "mediaPlayer": "testing_session_1", - "mediaType": "testing_session_1", - "playMethod": "testing_session_1", - "serverVersion": "testing_session_1", - "startTime": "testing_session_1", - "startedAt": "testing_session_1", - "timeListening": "testing_session_1", - "updatedAt": "testing_session_1", - "userId": "testing_session_1", - }, - ], - "users": [ - { - "createdAt": "testing_user_1", - "id": "testing_user_1", - "isActive": True, - "isLocked": "testing_user_1", - "itemTagsSelected": "testing_user_1", - "lastSeen": "testing_user_1", - "librariesAccessible": "testing_user_1", - "oldUserId": "testing_user_1", - "permissions": "testing_user_1", - "seriesHideFromContinueListening": "testing_user_1", - "token": "testing_user_1", - "type": "testing_user_1", - "username": "testing_user_1", - }, - { - "createdAt": "testing_user_2", - "id": "testing_user_2", - "isActive": False, - "isLocked": "testing_user_2", - "itemTagsSelected": "testing_user_2", - "lastSeen": "testing_user_2", - "librariesAccessible": "testing_user_2", - "oldUserId": "testing_user_2", - "permissions": "testing_user_2", - "seriesHideFromContinueListening": "testing_user_2", - "token": "testing_user_2", - "type": "testing_user_2", - "username": "testing_user_2", - }, - { - "createdAt": "testing_user_3", - "id": "testing_user_3", - "isActive": True, - "isLocked": "testing_user_3", - "itemTagsSelected": "testing_user_3", - "lastSeen": "testing_user_3", - "librariesAccessible": "testing_user_3", - "oldUserId": "testing_user_3", - "permissions": "testing_user_3", - "seriesHideFromContinueListening": "testing_user_3", - "token": "some_access_token", - "type": "testing_user_3", - "username": "testing_user_3", - }, - { - "createdAt": "testing_user_4", - "id": "testing_user_4", - "isActive": True, - "isLocked": "testing_user_4", - "itemTagsSelected": "testing_user_4", - "lastSeen": "testing_user_4", - "librariesAccessible": "testing_user_4", - "oldUserId": "testing_user_4", - "permissions": "testing_user_4", - "seriesHideFromContinueListening": "testing_user_4", - "token": "testing_user_4", - "type": "testing_user_4", - "username": "hass", - }, - ], - } - assert api.count_open_sessions(data) == 1 - assert api.count_active_users(data) == 1 - - caplog.clear() - assert api.get_host() == "some_host" diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py deleted file mode 100644 index 380e5b3..0000000 --- a/tests/test_binary_sensor.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Tests for Audiobookshelf binary sensor.""" -from unittest.mock import Mock, patch - -import pytest -from _pytest.logging import LogCaptureFixture -from homeassistant.core import HomeAssistant -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.audiobookshelf.binary_sensor import ( - AudiobookshelfBinarySensor, - async_setup_entry, -) -from custom_components.audiobookshelf.const import ( - DOMAIN, -) - -from .const import MOCK_CONFIG - - -@pytest.fixture(name="mock_coordinator") -async def mock_coordinator_fixture() -> Mock: - """Mock a coordinator for testing.""" - coordinator_mock = Mock() - coordinator_mock.data = { - "connectivity": { - "success": True, - }, - } - mock_coordinator_fixture.last_update_success = True - return coordinator_mock - - -@pytest.fixture(name="mock_coordinator_error") -async def mock_coordinator_error_fixture() -> None: - """Mock a coordinator error for testing.""" - with patch( - "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", - side_effect=Exception, - ): - yield None - - -@pytest.mark.asyncio -async def test_binary_sensor_init_entry( - hass: HomeAssistant, mock_coordinator: Mock, -) -> None: - """Test the initialisation.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") - m_add_entities = Mock() - m_device = AudiobookshelfBinarySensor( - coordinator=mock_coordinator, - config_entry=entry, - ) - - hass.data[DOMAIN] = { - "sensors": {"audiobookshelf_connected": m_device}, - } - - await async_setup_entry(hass, entry, m_add_entities) - assert isinstance( - hass.data[DOMAIN]["sensors"]["audiobookshelf_connected"], - AudiobookshelfBinarySensor, - ) - m_add_entities.assert_called_once() - - -async def test_binary_sensor_properties(mock_coordinator: Mock) -> None: - """Test that the sensor returns the correct properties""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") - sensor = AudiobookshelfBinarySensor( - coordinator=mock_coordinator, - config_entry=config_entry, - ) - assert sensor.name == "audiobookshelf_connected" - assert sensor.device_class == "connectivity" - assert sensor.is_on is True - - -async def test_binary_sensor_error( - mock_coordinator_error: Mock, caplog: LogCaptureFixture, -) -> None: - """Test for exception handling on exception on coordinator""" - caplog.clear() - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") - sensor = AudiobookshelfBinarySensor( - coordinator=mock_coordinator_error, - config_entry=config_entry, - ) - assert sensor.name == "audiobookshelf_connected" - assert sensor.device_class == "connectivity" - assert sensor.is_on is False - assert len(caplog.record_tuples) == 1 - assert ( - "AttributeError caught while accessing coordinator data." - in caplog.record_tuples[0][2] - ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py deleted file mode 100644 index b5e0f8e..0000000 --- a/tests/test_config_flow.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Test Audiobookshelf config flow.""" -from unittest.mock import patch - -import pytest -from _pytest.fixtures import FixtureRequest -from homeassistant import config_entries, data_entry_flow -from homeassistant.core import HomeAssistant -from pytest_homeassistant_custom_component.common import MockConfigEntry -from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker - -from custom_components.audiobookshelf.const import ( - DOMAIN, - PLATFORMS, -) - -from .const import MOCK_CONFIG - - -# This fixture bypasses the actual setup of the integration -# since we only want to test the config flow. We test the -# actual functionality of the integration in other test modules. -@pytest.fixture(autouse=True) -def bypass_setup_fixture() -> None: - """Prevent setup.""" - with patch("custom_components.audiobookshelf.async_setup", return_value=True), patch( - "custom_components.audiobookshelf.async_setup_entry", - return_value=True, - ): - yield - -# Here we simiulate a successful config flow from the backend. -# Note that we use the `bypass_get_data` fixture here because -# we want the config flow validation to succeed during the test. -async def test_successful_config_flow(hass:HomeAssistant, aioclient_mock: AiohttpClientMocker)-> None: - """Test a successful config flow.""" - - aioclient_mock.get("some_host/ping", json={"success": True}) - aioclient_mock.get("some_host/api/users", json={"users": []}) - aioclient_mock.get("some_host/api/users/online", json={"openSessions": []}) - - - # Initialize a config flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, - ) - - # Check that the config flow shows the user form as the first step - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - # If a user were to enter `some_host` for username and `test_password` - # for password, it would result in this function call - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG, - ) - - # Check that the config flow is complete and a new entry is created with - # the input data - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "some_host" - assert result["data"] == MOCK_CONFIG - assert result["result"] - - aioclient_mock.clear_requests() - - -# In this case, we want to simulate a failure during the config flow. -# We use the `error_on_get_data` mock instead of `bypass_get_data` -# (note the function parameters) to raise an Exception during -# validation of the input config. -async def test_failed_config_flow(hass:HomeAssistant, aioclient_mock: AiohttpClientMocker)-> None: - """Test a failed config flow due to credential validation failure.""" - aioclient_mock.get("some_host/ping", json={"success": True}) - aioclient_mock.get("some_host/api/users", status=404) - aioclient_mock.get("some_host/api/users/online", status=404) - - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "auth"} - - aioclient_mock.clear_requests() - - -async def test_timeout_error_config_flow(hass: HomeAssistant, timeout_error_on_get_data: FixtureRequest)-> None: - """Test a failed config flow due to credential validation failure.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "auth"} - -async def test_connectivity_error_config_flow(hass: HomeAssistant, connectivity_error_on_get_data:FixtureRequest)-> None: - """Test a failed config flow due to credential validation failure.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "auth"} - -async def test_client_error_config_flow(hass:HomeAssistant, client_error_on_get_data:FixtureRequest)-> None: - """Test a failed config flow due to credential validation failure.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "auth"} - -# Our config flow also has an options flow, so we must test it as well. -async def test_options_flow(hass:HomeAssistant)-> None: - """Test an options flow.""" - # Create a new MockConfigEntry and add to HASS (we're bypassing config - # flow entirely) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") - entry.add_to_hass(hass) - - # Initialize an options flow - await hass.config_entries.async_setup(entry.entry_id) - result = await hass.config_entries.options.async_init(entry.entry_id) - - # Verify that the first options step is a user form - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - # Enter some fake data into the form - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={platform: platform != "sensor" for platform in PLATFORMS}, - ) - - # Verify that the flow finishes - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "some_host" - - # Verify that the options were updated - assert entry.options == {"binary_sensor": True, "sensor": False} diff --git a/tests/test_entity.py b/tests/test_entity.py deleted file mode 100644 index a3574f9..0000000 --- a/tests/test_entity.py +++ /dev/null @@ -1,49 +0,0 @@ -from unittest.mock import Mock - -import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.audiobookshelf.const import DOMAIN, VERSION -from custom_components.audiobookshelf.entity import AudiobookshelfEntity - -from .const import MOCK_CONFIG - - -@pytest.fixture(name="mock_coordinator") -async def mock_coordinator_fixture() -> Mock: - """Mock a coordinator for testing.""" - coordinator_mock = Mock() - coordinator_mock.data = {"sessions": 6} - mock_coordinator_fixture.last_update_success = True - return coordinator_mock - - -def test_unique_id(mock_coordinator: Mock) -> None: - """Test unique id response for entity""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="audiobookshelf") - entity = AudiobookshelfEntity(coordinator=mock_coordinator, config_entry=entry) - assert entity.unique_id == "audiobookshelf" - - -def test_device_info(mock_coordinator: Mock) -> None: - """Test device info response for entity""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="audiobookshelf") - entity = AudiobookshelfEntity(coordinator=mock_coordinator, config_entry=entry) - - assert entity.device_info == { - "identifiers": {("audiobookshelf", "audiobookshelf")}, - "manufacturer": "Audiobookshelf", - "model": VERSION, - "name": "Audiobookshelf", - } - - -def test_device_state_attributes(mock_coordinator: Mock) -> None: - """Test device state attributes response for entity""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="audiobookshelf") - entity = AudiobookshelfEntity(coordinator=mock_coordinator, config_entry=entry) - assert entity.device_state_attributes == { - "attribution": "Server by https://www.audiobookshelf.org/", - "id": "None", - "integration": DOMAIN, - } diff --git a/tests/test_init.py b/tests/test_init.py deleted file mode 100644 index a51fbd7..0000000 --- a/tests/test_init.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Test Audiobookshelf setup process.""" - -import pytest -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker - -from custom_components.audiobookshelf import ( - AudiobookshelfDataUpdateCoordinator, - async_reload_entry, - async_setup, - async_setup_entry, - async_unload_entry, -) -from custom_components.audiobookshelf.const import ( - DOMAIN, -) - -from .const import MOCK_CONFIG - -pytest_plugins = "pytest_homeassistant_custom_component" - -config_entry = ConfigEntry( - domain=DOMAIN, - data=MOCK_CONFIG, - entry_id="test_entry_id_setup", - version=1, - title="Audiobookshelf", - source="some source", - minor_version=1, -) - -async def test_setup(hass: HomeAssistant)->None: - assert (await async_setup(hass, MOCK_CONFIG)) is True - -async def test_setup_entry( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, -) -> None: - aioclient_mock.get("some_host/ping", json={"success": True}) - aioclient_mock.get("some_host/api/users", json={"users": []}) - aioclient_mock.get("some_host/api/users/online", json={"openSessions": []}) - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert isinstance( - hass.data["audiobookshelf"]["test_entry_id_setup"], - AudiobookshelfDataUpdateCoordinator, - ) - aioclient_mock.clear_requests() - -async def test_unload_entry( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, -) -> None: - aioclient_mock.get("some_host/ping", json={"success": True}) - aioclient_mock.get("some_host/api/users", json={"users": []}) - aioclient_mock.get("some_host/api/users/online", json={"openSessions": []}) - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert isinstance( - hass.data[DOMAIN][config_entry.entry_id], - AudiobookshelfDataUpdateCoordinator, - ) - assert await async_unload_entry(hass, config_entry) - assert config_entry.entry_id not in hass.data[DOMAIN] - aioclient_mock.clear_requests() - - -async def test_setup_unload_and_reload_entry( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test entry setup and unload.""" - # Create a mock entry so we don't have to go through config flow - aioclient_mock.get("some_host/ping", json={"success": True}) - aioclient_mock.get("some_host/api/users", json={"users": []}) - aioclient_mock.get("some_host/api/users/online", json={"openSessions": []}) - - # Set up the entry and assert that the values set during setup are where we expect - # them to be. Because we have patched the AudiobookshelfDataUpdateCoordinator.async_get_data - # call, no code from custom_components/audiobookshelf/api.py actually runs. - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert isinstance( - hass.data[DOMAIN][config_entry.entry_id], - AudiobookshelfDataUpdateCoordinator, - ) - assert await async_unload_entry(hass, config_entry) - assert config_entry.entry_id not in hass.data[DOMAIN] - - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert isinstance( - hass.data[DOMAIN][config_entry.entry_id], - AudiobookshelfDataUpdateCoordinator, - ) - - # Reload the entry and assert that the data from above is still there - assert await async_reload_entry(hass, config_entry) is None - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert isinstance( - hass.data[DOMAIN][config_entry.entry_id], - AudiobookshelfDataUpdateCoordinator, - ) - - # Unload the entry and verify that the data has been removed - assert await async_unload_entry(hass, config_entry) - assert config_entry.entry_id not in hass.data[DOMAIN] - - aioclient_mock.clear_requests() - - -async def test_setup_entry_exception( - hass: HomeAssistant, - error_on_get_data: None, # pylint: disable=unused-argument -) -> None: - """Test ConfigEntryNotReady when API raises an exception during entry setup.""" - # In this case we are testing the condition where async_setup_entry raises - # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates - # an error. - with pytest.raises(ConfigEntryNotReady): - assert await async_setup_entry(hass, config_entry) - -async def test_setup_entry_connectivity_exception( - hass: HomeAssistant, - connectivity_error_on_get_data: None, # pylint: disable=unused-argument -) -> None: - """Test connectivity error response when API raises an exception during entry setup.""" - - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert isinstance( - hass.data[DOMAIN][config_entry.entry_id], - AudiobookshelfDataUpdateCoordinator, - ) - assert hass.data[DOMAIN][config_entry.entry_id].data.get("connectivity", "") == "ConnectionError: Unable to connect." - assert hass.data[DOMAIN][config_entry.entry_id].data.get("users", "") == "ConnectionError: Unable to connect." - assert hass.data[DOMAIN][config_entry.entry_id].data.get("sessions", "") == "ConnectionError: Unable to connect." - - assert await async_unload_entry(hass, config_entry) - assert config_entry.entry_id not in hass.data[DOMAIN] - -async def test_setup_entry_timeout_exception( - hass: HomeAssistant, - timeout_error_on_get_data: None, # pylint: disable=unused-argument -) -> None: - """Test timeout error response when API raises an exception during entry setup.""" - - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert isinstance( - hass.data[DOMAIN][config_entry.entry_id], - AudiobookshelfDataUpdateCoordinator, - ) - assert hass.data[DOMAIN][config_entry.entry_id].data.get("connectivity", "") == "TimeoutError: Request timed out." - assert hass.data[DOMAIN][config_entry.entry_id].data.get("users", "") == "TimeoutError: Request timed out." - assert hass.data[DOMAIN][config_entry.entry_id].data.get("sessions", "") == "TimeoutError: Request timed out." - - assert await async_unload_entry(hass, config_entry) - assert config_entry.entry_id not in hass.data[DOMAIN] - - -async def test_setup_entry_http_exception( - hass: HomeAssistant, - http_error_on_get_data: None, # pylint: disable=unused-argument -) -> None: - """Test http error response when API raises an exception during entry setup.""" - - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert isinstance( - hass.data[DOMAIN][config_entry.entry_id], - AudiobookshelfDataUpdateCoordinator, - ) - assert hass.data[DOMAIN][config_entry.entry_id].data.get("connectivity", "") == "HTTPError: Generic HTTP Error happened " - assert hass.data[DOMAIN][config_entry.entry_id].data.get("users", "") == "HTTPError: Generic HTTP Error happened " - assert hass.data[DOMAIN][config_entry.entry_id].data.get("sessions", "") == "HTTPError: Generic HTTP Error happened " - - assert await async_unload_entry(hass, config_entry) - assert config_entry.entry_id not in hass.data[DOMAIN] diff --git a/tests/test_sensor.py b/tests/test_sensor.py deleted file mode 100644 index da5cddc..0000000 --- a/tests/test_sensor.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Tests for Audiobookshelf sensor.""" -from unittest.mock import Mock, patch - -import pytest -from _pytest.logging import LogCaptureFixture -from homeassistant.core import HomeAssistant -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.audiobookshelf.const import ( - DOMAIN, -) -from custom_components.audiobookshelf.sensor import ( - AudiobookshelfSensor, - async_setup_entry, -) - -from .const import MOCK_CONFIG - - -@pytest.fixture(name="mock_coordinator") -async def mock_coordinator_fixture() -> Mock: - """Mock a coordinator for testing.""" - coordinator_mock = Mock() - coordinator_mock.data = {"sessions": 6} - mock_coordinator_fixture.last_update_success = True - return coordinator_mock - - -@pytest.fixture(name="mock_coordinator_unknown") -async def mock_coordinator_unknown_fixture() -> Mock: - """Mock a coordinator for testing.""" - coordinator_mock = Mock() - coordinator_mock.data = {"sessions": "some other type"} - mock_coordinator_fixture.last_update_success = True - return coordinator_mock - - -@pytest.fixture(name="mock_coordinator_error") -async def mock_coordinator_error_fixture() -> None: - """Mock a coordinator error for testing.""" - with patch( - "custom_components.audiobookshelf.AudiobookshelfApiClient.api_wrapper", - side_effect=Exception, - ): - yield None - - -@pytest.mark.asyncio -async def test_sensor_init_entry( - hass: HomeAssistant, - mock_coordinator: Mock, -) -> None: - """Test the initialisation.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") - m_add_entities = Mock() - m_device = AudiobookshelfSensor( - coordinator=mock_coordinator, - config_entry=entry, - ) - - hass.data[DOMAIN] = { - "sensors": {"audiobookshelf_sessions": m_device}, - } - - await async_setup_entry(hass, entry, m_add_entities) - assert isinstance( - hass.data[DOMAIN]["sensors"]["audiobookshelf_sessions"], - AudiobookshelfSensor, - ) - m_add_entities.assert_called_once() - - -async def test_sensor_properties(mock_coordinator: Mock) -> None: - """Test that the sensor returns the correct properties""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") - sensor = AudiobookshelfSensor( - coordinator=mock_coordinator, - config_entry=config_entry, - ) - assert sensor.name == "audiobookshelf_sessions" - assert sensor.device_class == "audiobookshelf__custom_device_class" - assert sensor.icon == "mdi:format-quote-close" - assert sensor.state == 6 - - -async def test_sensor_unknown(mock_coordinator_unknown: Mock) -> None: - """Test that the sensor returns the correct properties""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") - sensor = AudiobookshelfSensor( - coordinator=mock_coordinator_unknown, - config_entry=config_entry, - ) - assert sensor.state is None - - -async def test_sensor_error( - mock_coordinator_error: Mock, - caplog: LogCaptureFixture, -) -> None: - """Test for exception handling on exception on coordinator""" - caplog.clear() - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") - sensor = AudiobookshelfSensor( - coordinator=mock_coordinator_error, - config_entry=config_entry, - ) - assert sensor.name == "audiobookshelf_sessions" - assert sensor.device_class == "audiobookshelf__custom_device_class" - assert sensor.icon == "mdi:format-quote-close" - assert sensor.state is None - assert len(caplog.record_tuples) == 1 - assert ( - "AttributeError caught while accessing coordinator data." - in caplog.record_tuples[0][2] - )