From 0104c4e8f310d437c1559595aaf57bf07a69eea4 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Wed, 24 Apr 2024 19:34:35 +0200 Subject: [PATCH 01/13] feat: start of new sensor for libraries counting --- custom_components/audiobookshelf/__init__.py | 13 +++++ custom_components/audiobookshelf/const.py | 2 +- .../audiobookshelf/manifest.json | 2 +- custom_components/audiobookshelf/sensor.py | 50 +++++++++++++++++-- pyproject.toml | 2 +- tests/test_sensor.py | 12 ++--- 6 files changed, 67 insertions(+), 14 deletions(-) diff --git a/custom_components/audiobookshelf/__init__.py b/custom_components/audiobookshelf/__init__.py index ca31d1a..50d3313 100644 --- a/custom_components/audiobookshelf/__init__.py +++ b/custom_components/audiobookshelf/__init__.py @@ -1,6 +1,7 @@ """Init for audiobookshelf integration""" import asyncio +import json import logging from homeassistant.config_entries import ConfigEntry @@ -86,6 +87,18 @@ async def _async_update_data(self) -> dict[str, None]: update["sessions"] = "TimeoutError: Request timed out." except HTTPError as http_error: update["sessions"] = f"HTTPError: Generic HTTP Error happened {http_error}" + try: + library_stats_update = await self.api.api_wrapper( + method="get", + url=self.api.get_host() + "/api/libraries", + ) + update["libraries"] = json.loads(library_stats_update) + except ConnectionError: + update["libraries"] = "ConnectionError: Unable to connect." + except (TimeoutError, Timeout): + update["libraries"] = "TimeoutError: Request timed out." + except HTTPError as http_error: + update["libraries"] = f"HTTPError: Generic HTTP Error happened {http_error}" return update async def async_setup(hass: HomeAssistant, config: Config) -> bool: diff --git a/custom_components/audiobookshelf/const.py b/custom_components/audiobookshelf/const.py index 4db0e41..c63a69d 100644 --- a/custom_components/audiobookshelf/const.py +++ b/custom_components/audiobookshelf/const.py @@ -6,7 +6,7 @@ NAME = "Audiobookshelf" DOMAIN = "audiobookshelf" DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "v0.0.6" +VERSION = "v0.1.0" ATTRIBUTION = "Server by https://www.audiobookshelf.org/" ISSUE_URL = "https://github.com/wolffshots/hass-audiobookshelf/issues" diff --git a/custom_components/audiobookshelf/manifest.json b/custom_components/audiobookshelf/manifest.json index 9febe94..344f5ad 100644 --- a/custom_components/audiobookshelf/manifest.json +++ b/custom_components/audiobookshelf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/wolffshots/hass-audiobookshelf/issues", "requirements": [], - "version": "v0.0.6" + "version": "v0.1.0" } diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index fccf7b7..c227b8d 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -18,16 +18,16 @@ async def async_setup_entry( ) -> None: """Setup sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([AudiobookshelfSensor(coordinator, entry)]) + async_add_devices([AudiobookshelfSessionsSensor(coordinator, entry)]) -class AudiobookshelfSensor(AudiobookshelfEntity): - """audiobookshelf Sensor class.""" +class AudiobookshelfSessionsSensor(AudiobookshelfEntity): + """audiobookshelf Sessions Sensor class.""" @property def name(self) -> str: """Return the name of the sensor.""" - return f"{DOMAIN}_sessions" + return f"{DOMAIN} Sessions" @property def state(self) -> int | None: @@ -36,7 +36,7 @@ def state(self) -> int | None: 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) if isinstance(coordinator_get, int): @@ -59,3 +59,43 @@ def icon(self) -> str: def device_class(self) -> str: """Return device class of the sensor.""" return "audiobookshelf__custom_device_class" + +class AudiobookshelfNumberOfLibrariesSensor(AudiobookshelfEntity): + """audiobookshelf Number of Libraries Sensor class.""" + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{DOMAIN} Number of Libraries" + + @property + def state(self) -> int | None: + """Return the state of the sensor.""" + try: + coordinator_get: dict | str = self.coordinator.data.get( + "libraries", + "", + ) + _LOGGER.debug("""sensor coordinator got: %s""", coordinator_get) + + if not isinstance(coordinator_get, str): + # count and return int + return len(coordinator_get["libraries"]) + + return None + + except AttributeError: + _LOGGER.debug( + "sensor: AttributeError caught while accessing coordinator data.", + ) + return None + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return "mdi:format-quote-close" + + @property + def device_class(self) -> str: + """Return device class of the sensor.""" + return "audiobookshelf__custom_device_class" diff --git a/pyproject.toml b/pyproject.toml index cdfb3be..c7ef7db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ max-complexity = 15 [tool.poetry] name = "audiobookshelf" -version = "v0.0.6" +version = "v0.1.0" description = "Audiobookshelf HA custom component" authors = ["wolffshots <16850875+wolffshots@users.noreply.github.com>"] readme = "README.md" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index da5cddc..6e83898 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -10,7 +10,7 @@ DOMAIN, ) from custom_components.audiobookshelf.sensor import ( - AudiobookshelfSensor, + AudiobookshelfSessionsSensor, async_setup_entry, ) @@ -53,7 +53,7 @@ async def test_sensor_init_entry( """Test the initialisation.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") m_add_entities = Mock() - m_device = AudiobookshelfSensor( + m_device = AudiobookshelfSessionsSensor( coordinator=mock_coordinator, config_entry=entry, ) @@ -65,7 +65,7 @@ async def test_sensor_init_entry( await async_setup_entry(hass, entry, m_add_entities) assert isinstance( hass.data[DOMAIN]["sensors"]["audiobookshelf_sessions"], - AudiobookshelfSensor, + AudiobookshelfSessionsSensor, ) m_add_entities.assert_called_once() @@ -73,7 +73,7 @@ async def test_sensor_init_entry( 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( + sensor = AudiobookshelfSessionsSensor( coordinator=mock_coordinator, config_entry=config_entry, ) @@ -86,7 +86,7 @@ async def test_sensor_properties(mock_coordinator: Mock) -> None: 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( + sensor = AudiobookshelfSessionsSensor( coordinator=mock_coordinator_unknown, config_entry=config_entry, ) @@ -100,7 +100,7 @@ async def test_sensor_error( """Test for exception handling on exception on coordinator""" caplog.clear() config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="sensors") - sensor = AudiobookshelfSensor( + sensor = AudiobookshelfSessionsSensor( coordinator=mock_coordinator_error, config_entry=config_entry, ) From 1cf592bc93e5ce0969bd1e75bae372474e581db9 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Wed, 24 Apr 2024 19:42:58 +0200 Subject: [PATCH 02/13] fix: don't try re-parse dict --- custom_components/audiobookshelf/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/audiobookshelf/__init__.py b/custom_components/audiobookshelf/__init__.py index 50d3313..6b23898 100644 --- a/custom_components/audiobookshelf/__init__.py +++ b/custom_components/audiobookshelf/__init__.py @@ -1,7 +1,6 @@ """Init for audiobookshelf integration""" import asyncio -import json import logging from homeassistant.config_entries import ConfigEntry @@ -92,7 +91,7 @@ async def _async_update_data(self) -> dict[str, None]: method="get", url=self.api.get_host() + "/api/libraries", ) - update["libraries"] = json.loads(library_stats_update) + update["libraries"] = library_stats_update except ConnectionError: update["libraries"] = "ConnectionError: Unable to connect." except (TimeoutError, Timeout): From fedf190973a8172fdd05be9001fb9202ddb14d10 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Wed, 24 Apr 2024 19:51:10 +0200 Subject: [PATCH 03/13] fix: add library sensor to setup and update readmes --- README.md | 11 ++++++----- custom_components/audiobookshelf/sensor.py | 5 ++++- info.md | 12 +++++------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9ed367a..7b0ecf1 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 | +| `open_sessions` | `sensor` | Show number of open audio sessions | +| `libraries` | `sensor` | Number of libraries on the server | ## Installation diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index c227b8d..9ec2348 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -18,7 +18,10 @@ async def async_setup_entry( ) -> None: """Setup sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([AudiobookshelfSessionsSensor(coordinator, entry)]) + async_add_devices([ + AudiobookshelfSessionsSensor(coordinator, entry), + AudiobookshelfNumberOfLibrariesSensor(coordinator, entry), + ]) class AudiobookshelfSessionsSensor(AudiobookshelfEntity): diff --git a/info.md b/info.md index be3e4ed..7f55596 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 | +| `open_sessions` | `sensor` | Show number of open audio sessions | +| `libraries` | `sensor` | Number of libraries on the server | {% if not installed %} From c6c4473ec7c2a51cc8824e4b2741c785c589153d Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Wed, 24 Apr 2024 20:03:29 +0200 Subject: [PATCH 04/13] feat: try move unique ids into the sensors --- README.md | 2 +- custom_components/audiobookshelf/binary_sensor.py | 7 ++++++- custom_components/audiobookshelf/entity.py | 5 ----- custom_components/audiobookshelf/sensor.py | 10 ++++++++++ info.md | 2 +- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7b0ecf1..d284347 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ | Entity | Type | Description | | --------------- | ---------------- | ------------------------------------ | | `connectivity` | `binary_sensor` | Show whether the server is connected | -| `open_sessions` | `sensor` | Show number of open audio sessions | +| `sessions` | `sensor` | Show number of open audio sessions | | `libraries` | `sensor` | Number of libraries on the server | ## Installation diff --git a/custom_components/audiobookshelf/binary_sensor.py b/custom_components/audiobookshelf/binary_sensor.py index bdac326..c79aea1 100644 --- a/custom_components/audiobookshelf/binary_sensor.py +++ b/custom_components/audiobookshelf/binary_sensor.py @@ -25,10 +25,15 @@ async def async_setup_entry( class AudiobookshelfBinarySensor(AudiobookshelfEntity, BinarySensorEntity): """audiobookshelf binary_sensor class.""" + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{DOMAIN}_connected" + @property def name(self) -> str: """Return the name of the binary_sensor.""" - return f"{DOMAIN}_connected" + return f"{DOMAIN} Connected" @property def device_class(self) -> str: diff --git a/custom_components/audiobookshelf/entity.py b/custom_components/audiobookshelf/entity.py index 0f64d7f..1469a32 100644 --- a/custom_components/audiobookshelf/entity.py +++ b/custom_components/audiobookshelf/entity.py @@ -18,11 +18,6 @@ def __init__( 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 { diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index 9ec2348..cc1a848 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -27,6 +27,11 @@ async def async_setup_entry( class AudiobookshelfSessionsSensor(AudiobookshelfEntity): """audiobookshelf Sessions Sensor class.""" + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{DOMAIN}_sessions" + @property def name(self) -> str: """Return the name of the sensor.""" @@ -66,6 +71,11 @@ def device_class(self) -> str: class AudiobookshelfNumberOfLibrariesSensor(AudiobookshelfEntity): """audiobookshelf Number of Libraries Sensor class.""" + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{DOMAIN}_libraries" + @property def name(self) -> str: """Return the name of the sensor.""" diff --git a/info.md b/info.md index 7f55596..06bad6f 100644 --- a/info.md +++ b/info.md @@ -11,7 +11,7 @@ | Entity | Type | Description | | --------------- | ---------------- | ------------------------------------ | | `connectivity` | `binary_sensor` | Show whether the server is connected | -| `open_sessions` | `sensor` | Show number of open audio sessions | +| `sessions` | `sensor` | Show number of open audio sessions | | `libraries` | `sensor` | Number of libraries on the server | {% if not installed %} From 4e8194c7bdd6113b09a11d454448b4e327b542b6 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Wed, 24 Apr 2024 20:48:45 +0200 Subject: [PATCH 05/13] chore: simplified sensors --- .../audiobookshelf/binary_sensor.py | 24 +++------ custom_components/audiobookshelf/sensor.py | 50 ++++--------------- 2 files changed, 17 insertions(+), 57 deletions(-) diff --git a/custom_components/audiobookshelf/binary_sensor.py b/custom_components/audiobookshelf/binary_sensor.py index c79aea1..303cfcc 100644 --- a/custom_components/audiobookshelf/binary_sensor.py +++ b/custom_components/audiobookshelf/binary_sensor.py @@ -1,7 +1,7 @@ """Binary sensor platform for Audiobookshelf.""" import logging -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -15,30 +15,20 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Setup binary_sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([AudiobookshelfBinarySensor(coordinator, entry)]) + async_add_entities([AudiobookshelfBinarySensor(coordinator, entry)]) class AudiobookshelfBinarySensor(AudiobookshelfEntity, BinarySensorEntity): """audiobookshelf binary_sensor class.""" - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{DOMAIN}_connected" - - @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" + _attr_name = f"{DOMAIN} Connected" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_icon ="mdi:format-quote-close" + entity_id = f"{DOMAIN}_connected" @property def is_on(self) -> bool: diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index cc1a848..7e8fc25 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -14,11 +14,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Setup sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([ + async_add_entities([ AudiobookshelfSessionsSensor(coordinator, entry), AudiobookshelfNumberOfLibrariesSensor(coordinator, entry), ]) @@ -27,15 +27,10 @@ async def async_setup_entry( class AudiobookshelfSessionsSensor(AudiobookshelfEntity): """audiobookshelf Sessions Sensor class.""" - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{DOMAIN}_sessions" - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{DOMAIN} Sessions" + _attr_name = f"{DOMAIN} Sessions" + _attr_device_class = f"{DOMAIN}__custom_device_class" + _attr_icon ="mdi:format-quote-close" + entity_id = f"{DOMAIN}_sessions" @property def state(self) -> int | None: @@ -58,28 +53,13 @@ def state(self) -> int | None: ) return None - @property - def icon(self) -> str: - """Return the icon of the sensor.""" - return "mdi:format-quote-close" - - @property - def device_class(self) -> str: - """Return device class of the sensor.""" - return "audiobookshelf__custom_device_class" - class AudiobookshelfNumberOfLibrariesSensor(AudiobookshelfEntity): """audiobookshelf Number of Libraries Sensor class.""" - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{DOMAIN}_libraries" - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{DOMAIN} Number of Libraries" + _attr_name = f"{DOMAIN} Libraries" + _attr_device_class = f"{DOMAIN}__custom_device_class" + _attr_icon ="mdi:format-quote-close" + entity_id = f"{DOMAIN}_libraries" @property def state(self) -> int | None: @@ -102,13 +82,3 @@ def state(self) -> int | None: "sensor: AttributeError caught while accessing coordinator data.", ) return None - - @property - def icon(self) -> str: - """Return the icon of the sensor.""" - return "mdi:format-quote-close" - - @property - def device_class(self) -> str: - """Return device class of the sensor.""" - return "audiobookshelf__custom_device_class" From a3b4e248108e46d19fa27e7dd3d2d542443948d8 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Wed, 24 Apr 2024 21:02:21 +0200 Subject: [PATCH 06/13] chore: add platform to entity ids --- custom_components/audiobookshelf/binary_sensor.py | 2 +- custom_components/audiobookshelf/sensor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/audiobookshelf/binary_sensor.py b/custom_components/audiobookshelf/binary_sensor.py index 303cfcc..825b0ba 100644 --- a/custom_components/audiobookshelf/binary_sensor.py +++ b/custom_components/audiobookshelf/binary_sensor.py @@ -28,7 +28,7 @@ class AudiobookshelfBinarySensor(AudiobookshelfEntity, BinarySensorEntity): _attr_name = f"{DOMAIN} Connected" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_icon ="mdi:format-quote-close" - entity_id = f"{DOMAIN}_connected" + entity_id = f"binary_sensor.{DOMAIN}_connected" @property def is_on(self) -> bool: diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index 7e8fc25..bb06377 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -30,7 +30,7 @@ class AudiobookshelfSessionsSensor(AudiobookshelfEntity): _attr_name = f"{DOMAIN} Sessions" _attr_device_class = f"{DOMAIN}__custom_device_class" _attr_icon ="mdi:format-quote-close" - entity_id = f"{DOMAIN}_sessions" + entity_id = f"sensor.{DOMAIN}_sessions" @property def state(self) -> int | None: @@ -59,7 +59,7 @@ class AudiobookshelfNumberOfLibrariesSensor(AudiobookshelfEntity): _attr_name = f"{DOMAIN} Libraries" _attr_device_class = f"{DOMAIN}__custom_device_class" _attr_icon ="mdi:format-quote-close" - entity_id = f"{DOMAIN}_libraries" + entity_id = f"sensor.{DOMAIN}_libraries" @property def state(self) -> int | None: From aeb035a3ad8dfed5070fbfaf47bd6aecbe1fbeb6 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Wed, 24 Apr 2024 21:30:12 +0200 Subject: [PATCH 07/13] chore: add init to sensors --- .../audiobookshelf/binary_sensor.py | 10 ++++++---- custom_components/audiobookshelf/sensor.py | 20 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/custom_components/audiobookshelf/binary_sensor.py b/custom_components/audiobookshelf/binary_sensor.py index 825b0ba..eff5fb2 100644 --- a/custom_components/audiobookshelf/binary_sensor.py +++ b/custom_components/audiobookshelf/binary_sensor.py @@ -25,10 +25,12 @@ async def async_setup_entry( class AudiobookshelfBinarySensor(AudiobookshelfEntity, BinarySensorEntity): """audiobookshelf binary_sensor class.""" - _attr_name = f"{DOMAIN} Connected" - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - _attr_icon ="mdi:format-quote-close" - entity_id = f"binary_sensor.{DOMAIN}_connected" + def __init__(self) -> None: + self._attr_name = f"{DOMAIN} Connected" + self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + # self._attr_device_info = ... # For automatic device registration + self._attr_unique_id = f"binary_sensor.{DOMAIN}_connected" + self._attr_icon = "mdi:format-quote-close" @property def is_on(self) -> bool: diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index bb06377..4e2934c 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -27,10 +27,12 @@ async def async_setup_entry( class AudiobookshelfSessionsSensor(AudiobookshelfEntity): """audiobookshelf Sessions Sensor class.""" - _attr_name = f"{DOMAIN} Sessions" - _attr_device_class = f"{DOMAIN}__custom_device_class" - _attr_icon ="mdi:format-quote-close" - entity_id = f"sensor.{DOMAIN}_sessions" + def __init__(self) -> None: + self._attr_name = f"{DOMAIN} Sessions" + self._attr_device_class = f"{DOMAIN}__custom_device_class" + # self._attr_device_info = ... # For automatic device registration + self._attr_unique_id = f"sensor.{DOMAIN}_sessions" + self._attr_icon ="mdi:format-quote-close" @property def state(self) -> int | None: @@ -56,10 +58,12 @@ def state(self) -> int | None: class AudiobookshelfNumberOfLibrariesSensor(AudiobookshelfEntity): """audiobookshelf Number of Libraries Sensor class.""" - _attr_name = f"{DOMAIN} Libraries" - _attr_device_class = f"{DOMAIN}__custom_device_class" - _attr_icon ="mdi:format-quote-close" - entity_id = f"sensor.{DOMAIN}_libraries" + def __init__(self) -> None: + self._attr_name = f"{DOMAIN} Libraries" + self._attr_device_class = f"{DOMAIN}__custom_device_class" + # self._attr_device_info = ... # For automatic device registration + self._attr_unique_id = f"sensor.{DOMAIN}_libraries" + self._attr_icon ="mdi:format-quote-close" @property def state(self) -> int | None: From 70861da12544ec07855e36602e55149c10cd38d4 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Wed, 24 Apr 2024 21:40:36 +0200 Subject: [PATCH 08/13] chore: updated signature for init for sensors --- custom_components/audiobookshelf/binary_sensor.py | 4 +++- custom_components/audiobookshelf/sensor.py | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/custom_components/audiobookshelf/binary_sensor.py b/custom_components/audiobookshelf/binary_sensor.py index eff5fb2..44057ac 100644 --- a/custom_components/audiobookshelf/binary_sensor.py +++ b/custom_components/audiobookshelf/binary_sensor.py @@ -5,6 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .entity import AudiobookshelfEntity @@ -25,12 +26,13 @@ async def async_setup_entry( class AudiobookshelfBinarySensor(AudiobookshelfEntity, BinarySensorEntity): """audiobookshelf binary_sensor class.""" - def __init__(self) -> None: + def __init__(self, coordinator: CoordinatorEntity, entry: ConfigEntry) -> None: self._attr_name = f"{DOMAIN} Connected" self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY # self._attr_device_info = ... # For automatic device registration self._attr_unique_id = f"binary_sensor.{DOMAIN}_connected" self._attr_icon = "mdi:format-quote-close" + super().__init__(coordinator, entry) @property def is_on(self) -> bool: diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index 4e2934c..73a38c9 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -4,6 +4,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .entity import AudiobookshelfEntity @@ -27,12 +28,13 @@ async def async_setup_entry( class AudiobookshelfSessionsSensor(AudiobookshelfEntity): """audiobookshelf Sessions Sensor class.""" - def __init__(self) -> None: + def __init__(self, coordinator: CoordinatorEntity, entry: ConfigEntry) -> None: self._attr_name = f"{DOMAIN} Sessions" self._attr_device_class = f"{DOMAIN}__custom_device_class" # self._attr_device_info = ... # For automatic device registration self._attr_unique_id = f"sensor.{DOMAIN}_sessions" self._attr_icon ="mdi:format-quote-close" + super().__init__(coordinator, entry) @property def state(self) -> int | None: @@ -58,12 +60,13 @@ def state(self) -> int | None: class AudiobookshelfNumberOfLibrariesSensor(AudiobookshelfEntity): """audiobookshelf Number of Libraries Sensor class.""" - def __init__(self) -> None: + def __init__(self, coordinator: CoordinatorEntity, entry: ConfigEntry) -> None: self._attr_name = f"{DOMAIN} Libraries" self._attr_device_class = f"{DOMAIN}__custom_device_class" # self._attr_device_info = ... # For automatic device registration self._attr_unique_id = f"sensor.{DOMAIN}_libraries" self._attr_icon ="mdi:format-quote-close" + super().__init__(coordinator, entry) @property def state(self) -> int | None: From e49e32a29dac347de4e47838a25b82d771705652 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Fri, 24 May 2024 17:30:43 +0200 Subject: [PATCH 09/13] chore!: rewrite it all --- custom_components/audiobookshelf/__init__.py | 186 ++------- custom_components/audiobookshelf/api.py | 107 ------ .../audiobookshelf/binary_sensor.py | 51 --- .../audiobookshelf/config_flow.py | 143 ------- custom_components/audiobookshelf/const.py | 19 - custom_components/audiobookshelf/entity.py | 37 -- .../audiobookshelf/manifest.json | 15 +- custom_components/audiobookshelf/sensor.py | 352 ++++++++++++++---- .../audiobookshelf/translations/en.json | 30 -- pyproject.toml | 3 +- tests/__init__.py | 1 - tests/conftest.py | 60 --- tests/const.py | 10 - tests/test_api.py | 221 ----------- tests/test_binary_sensor.py | 96 ----- tests/test_config_flow.py | 173 --------- tests/test_entity.py | 49 --- tests/test_init.py | 181 --------- tests/test_sensor.py | 115 ------ 19 files changed, 308 insertions(+), 1541 deletions(-) delete mode 100644 custom_components/audiobookshelf/api.py delete mode 100644 custom_components/audiobookshelf/binary_sensor.py delete mode 100644 custom_components/audiobookshelf/config_flow.py delete mode 100644 custom_components/audiobookshelf/const.py delete mode 100644 custom_components/audiobookshelf/entity.py delete mode 100644 custom_components/audiobookshelf/translations/en.json delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/const.py delete mode 100644 tests/test_api.py delete mode 100644 tests/test_binary_sensor.py delete mode 100644 tests/test_config_flow.py delete mode 100644 tests/test_entity.py delete mode 100644 tests/test_init.py delete mode 100644 tests/test_sensor.py diff --git a/custom_components/audiobookshelf/__init__.py b/custom_components/audiobookshelf/__init__.py index 6b23898..1a76984 100644 --- a/custom_components/audiobookshelf/__init__.py +++ b/custom_components/audiobookshelf/__init__.py @@ -1,172 +1,32 @@ -"""Init for audiobookshelf integration""" - -import asyncio +"""Custom component for Audiobookshelf.""" import logging +from homeassistant.helpers import discovery +# from .sensor import async_refresh_libraries -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}" - try: - library_stats_update = await self.api.api_wrapper( - method="get", - url=self.api.get_host() + "/api/libraries", - ) - update["libraries"] = library_stats_update - except ConnectionError: - update["libraries"] = "ConnectionError: Unable to connect." - except (TimeoutError, Timeout): - update["libraries"] = "TimeoutError: Request timed out." - except HTTPError as http_error: - update["libraries"] = 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, - ) - - 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() +DOMAIN = "audiobookshelf" - if not coordinator.last_update_success: - raise ConfigEntryNotReady +_LOGGER = logging.getLogger(__name__) - hass.data[DOMAIN][entry.entry_id] = coordinator +# async def async_setup(hass, config): +# """Set up the Audiobookshelf component.""" +# # Schedule the setup of sensor platform +# hass.async_create_task(discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config)) +# hass.async_create_task(async_refresh_libraries(hass)) - 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 +# return True +async def async_setup(hass, config): + """Set up the Audiobookshelf component.""" + # Schedule the setup of sensor platform + hass.async_create_task(discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config)) -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) + # Use a helper to get the async_add_entities function from the sensor platform setup + # async def platform_setup(): + # """Wait for platform to be set up and then start refreshing libraries.""" + # platform = hass.data.get('sensor_platform') + # if platform: + # await async_refresh_libraries(hass, platform.async_add_entities) - return unloaded + # hass.async_create_task(platform_setup()) -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) + 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 44057ac..0000000 --- a/custom_components/audiobookshelf/binary_sensor.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Binary sensor platform for Audiobookshelf.""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -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_entities: AddEntitiesCallback, -) -> None: - """Setup binary_sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([AudiobookshelfBinarySensor(coordinator, entry)]) - - -class AudiobookshelfBinarySensor(AudiobookshelfEntity, BinarySensorEntity): - """audiobookshelf binary_sensor class.""" - - def __init__(self, coordinator: CoordinatorEntity, entry: ConfigEntry) -> None: - self._attr_name = f"{DOMAIN} Connected" - self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - # self._attr_device_info = ... # For automatic device registration - self._attr_unique_id = f"binary_sensor.{DOMAIN}_connected" - self._attr_icon = "mdi:format-quote-close" - super().__init__(coordinator, entry) - - @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 c63a69d..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.1.0" - -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 1469a32..0000000 --- a/custom_components/audiobookshelf/entity.py +++ /dev/null @@ -1,37 +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 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 344f5ad..ada0aab 100644 --- a/custom_components/audiobookshelf/manifest.json +++ b/custom_components/audiobookshelf/manifest.json @@ -1,12 +1,11 @@ { "domain": "audiobookshelf", "name": "Audiobookshelf", - "codeowners": ["@wolffshots"], - "config_flow": true, + "version": "1.0", + "documentation": "https://www.example.com", "dependencies": [], - "documentation": "https://github.com/wolffshots/hass-audiobookshelf", - "iot_class": "local_polling", - "issue_tracker": "https://github.com/wolffshots/hass-audiobookshelf/issues", - "requirements": [], - "version": "v0.1.0" -} + "codeowners": ["@your_github_username"], + "iot_class": "cloud_polling", + "integration_type": "device", + "requirements": ["aiohttp"] +} \ No newline at end of file diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index 73a38c9..33a0883 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -1,91 +1,291 @@ -"""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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .entity import AudiobookshelfEntity +_LOGGER = logging.getLogger(__name__) -_LOGGER: logging.Logger = logging.getLogger(__package__) +DOMAIN = "audiobookshelf" +SCAN_INTERVAL = timedelta(seconds=10) +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 async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([ - AudiobookshelfSessionsSensor(coordinator, entry), - AudiobookshelfNumberOfLibrariesSensor(coordinator, entry), - ]) +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"]) -class AudiobookshelfSessionsSensor(AudiobookshelfEntity): - """audiobookshelf Sessions Sensor class.""" +async def count_libraries(data: dict) -> int: + """ + Counts the number libraries + """ + return len(data["libraries"]) - def __init__(self, coordinator: CoordinatorEntity, entry: ConfigEntry) -> None: - self._attr_name = f"{DOMAIN} Sessions" - self._attr_device_class = f"{DOMAIN}__custom_device_class" - # self._attr_device_info = ... # For automatic device registration - self._attr_unique_id = f"sensor.{DOMAIN}_sessions" - self._attr_icon ="mdi:format-quote-close" - super().__init__(coordinator, entry) +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 - @property - def state(self) -> int | None: - """Return the state of the sensor.""" +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 / 60, 0) + +def get_total_size(total_size: float): + return round(total_size / 1024 / 1024 / 1024, 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 + +async def do_nothing(data): + return data + +type Sensor = dict[str, Any] + +# 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 fetch_libraries(): +# headers = {"Authorization": f"Bearer {API_KEY}"} +# async with aiohttp.ClientSession() as session: +# async with session.get(f"{API_URL}/api/libraries", headers=headers) as response: +# return await response.json() + +# async def async_remove_sensors(hass: HomeAssistant, entity_type, sensor_type: str): +# """Remove all existing sensors of a specific type.""" +# entity_registry: EntityRegistry = async_get_entity_registry(hass) + +# entities_to_remove = [] + +# for entity in entity_registry.entities.values(): +# if entity.domain == entity_type: +# state = hass.states.get(entity.entity_id) +# if state and state.attributes.get("sensor_type") == sensor_type: +# entities_to_remove.append(entity.entity_id) + +# async def async_refresh_libraries(hass: HomeAssistant, async_add_entities): +# """Periodically refresh the sensors.""" +# while True: +# _LOGGER.error("Refreshing Audiobookshelf library stats") +# libraries_data = await fetch_libraries() +# # remove and re-add library sensors +# await async_remove_sensors(hass, "sensor", "audiobookshelf_library") +# coordinator = AudiobookshelfDataUpdateCoordinator(hass) +# await coordinator.async_config_entry_first_refresh() + +# ids = get_library_ids(libraries_data) +# entities = [] +# for id in ids: +# entities.append( +# AudiobookshelfSensor( +# coordinator, { +# "endpoint": f"api/libraries/{id}/stats", +# "name": f"Audiobookshelf Library {id}", +# "data_function": do_nothing, +# "attributes_function": do_nothing, +# "type": "audiobookshelf_library" +# }, +# ) +# ) +# async_add_entities(entities, True) +# await asyncio.sleep(60) # Wait for 1 minute before refreshing again + +async def async_setup_platform(hass: HomeAssistant, config, async_add_entities, discovery_info=None): + """Set up the sensor platform.""" + + 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: - coordinator_get = self.coordinator.data.get( - "sessions", - "", - ) - _LOGGER.debug("""sensor coordinator got: %s""", coordinator_get) - - if isinstance(coordinator_get, int): - return coordinator_get - - return None - - except AttributeError: - _LOGGER.debug( - "sensor: AttributeError caught while accessing coordinator data.", - ) - return None - -class AudiobookshelfNumberOfLibrariesSensor(AudiobookshelfEntity): - """audiobookshelf Number of Libraries Sensor class.""" - - def __init__(self, coordinator: CoordinatorEntity, entry: ConfigEntry) -> None: - self._attr_name = f"{DOMAIN} Libraries" - self._attr_device_class = f"{DOMAIN}__custom_device_class" - # self._attr_device_info = ... # For automatic device registration - self._attr_unique_id = f"sensor.{DOMAIN}_libraries" - self._attr_icon ="mdi:format-quote-close" - super().__init__(coordinator, entry) + 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): + """Return the name of the sensor.""" + return self._name @property - def state(self) -> int | None: + def state(self): """Return the state of the sensor.""" - try: - coordinator_get: dict | str = self.coordinator.data.get( - "libraries", - "", - ) - _LOGGER.debug("""sensor coordinator got: %s""", coordinator_get) - - if not isinstance(coordinator_get, str): - # count and return int - return len(coordinator_get["libraries"]) - - return None - - except AttributeError: - _LOGGER.debug( - "sensor: AttributeError caught while accessing coordinator data.", - ) - return None + return self._state + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @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", + } + + 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}") + + 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) + ) + 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/pyproject.toml b/pyproject.toml index c7ef7db..530f011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,10 +49,11 @@ max-complexity = 15 [tool.poetry] name = "audiobookshelf" -version = "v0.1.0" +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 6e83898..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 ( - AudiobookshelfSessionsSensor, - 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 = AudiobookshelfSessionsSensor( - 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"], - AudiobookshelfSessionsSensor, - ) - 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 = AudiobookshelfSessionsSensor( - 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 = AudiobookshelfSessionsSensor( - 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 = AudiobookshelfSessionsSensor( - 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] - ) From 57272d380c9f9d5b77d0b210290cc05928345df1 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Fri, 24 May 2024 18:21:13 +0200 Subject: [PATCH 10/13] feat: set api key and url from config.yaml --- custom_components/audiobookshelf/__init__.py | 52 ++++++++++++-------- custom_components/audiobookshelf/sensor.py | 12 +++++ 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/custom_components/audiobookshelf/__init__.py b/custom_components/audiobookshelf/__init__.py index 1a76984..a60d330 100644 --- a/custom_components/audiobookshelf/__init__.py +++ b/custom_components/audiobookshelf/__init__.py @@ -1,32 +1,44 @@ """Custom component for Audiobookshelf.""" import logging -from homeassistant.helpers import discovery -# from .sensor import async_refresh_libraries +import voluptuous as vol +from homeassistant.helpers import config_validation as cv, discovery DOMAIN = "audiobookshelf" -_LOGGER = logging.getLogger(__name__) - -# async def async_setup(hass, config): -# """Set up the Audiobookshelf component.""" -# # Schedule the setup of sensor platform -# hass.async_create_task(discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config)) -# hass.async_create_task(async_refresh_libraries(hass)) +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, +) -# return True +_LOGGER = logging.getLogger(__name__) async def async_setup(hass, config): """Set up the Audiobookshelf component.""" - # Schedule the setup of sensor platform + 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)) - # Use a helper to get the async_add_entities function from the sensor platform setup - # async def platform_setup(): - # """Wait for platform to be set up and then start refreshing libraries.""" - # platform = hass.data.get('sensor_platform') - # if platform: - # await async_refresh_libraries(hass, platform.async_add_entities) - - # hass.async_create_task(platform_setup()) - return True \ No newline at end of file diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index 33a0883..e6260e7 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -195,6 +195,18 @@ async def do_nothing(data): async def async_setup_platform(hass: HomeAssistant, config, async_add_entities, discovery_info=None): """Set up the sensor platform.""" + 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() From 1293f0d3586f79d9916a0044d85945d86b0fa1d3 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Fri, 24 May 2024 19:42:18 +0200 Subject: [PATCH 11/13] chore: some cleanup --- .../audiobookshelf/manifest.json | 13 +++--- custom_components/audiobookshelf/sensor.py | 45 ------------------- 2 files changed, 7 insertions(+), 51 deletions(-) diff --git a/custom_components/audiobookshelf/manifest.json b/custom_components/audiobookshelf/manifest.json index ada0aab..e94228b 100644 --- a/custom_components/audiobookshelf/manifest.json +++ b/custom_components/audiobookshelf/manifest.json @@ -1,11 +1,12 @@ { "domain": "audiobookshelf", "name": "Audiobookshelf", - "version": "1.0", - "documentation": "https://www.example.com", + "version": "0.1.1", + "documentation": "https://github.com/wolffshots/hass-audiobookshelf", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/wolffshots/hass-audiobookshelf/issues", "dependencies": [], - "codeowners": ["@your_github_username"], - "iot_class": "cloud_polling", - "integration_type": "device", - "requirements": ["aiohttp"] + "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 e6260e7..c682bd8 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -147,51 +147,6 @@ async def do_nothing(data): }, } -# async def fetch_libraries(): -# headers = {"Authorization": f"Bearer {API_KEY}"} -# async with aiohttp.ClientSession() as session: -# async with session.get(f"{API_URL}/api/libraries", headers=headers) as response: -# return await response.json() - -# async def async_remove_sensors(hass: HomeAssistant, entity_type, sensor_type: str): -# """Remove all existing sensors of a specific type.""" -# entity_registry: EntityRegistry = async_get_entity_registry(hass) - -# entities_to_remove = [] - -# for entity in entity_registry.entities.values(): -# if entity.domain == entity_type: -# state = hass.states.get(entity.entity_id) -# if state and state.attributes.get("sensor_type") == sensor_type: -# entities_to_remove.append(entity.entity_id) - -# async def async_refresh_libraries(hass: HomeAssistant, async_add_entities): -# """Periodically refresh the sensors.""" -# while True: -# _LOGGER.error("Refreshing Audiobookshelf library stats") -# libraries_data = await fetch_libraries() -# # remove and re-add library sensors -# await async_remove_sensors(hass, "sensor", "audiobookshelf_library") -# coordinator = AudiobookshelfDataUpdateCoordinator(hass) -# await coordinator.async_config_entry_first_refresh() - -# ids = get_library_ids(libraries_data) -# entities = [] -# for id in ids: -# entities.append( -# AudiobookshelfSensor( -# coordinator, { -# "endpoint": f"api/libraries/{id}/stats", -# "name": f"Audiobookshelf Library {id}", -# "data_function": do_nothing, -# "attributes_function": do_nothing, -# "type": "audiobookshelf_library" -# }, -# ) -# ) -# async_add_entities(entities, True) -# await asyncio.sleep(60) # Wait for 1 minute before refreshing again - async def async_setup_platform(hass: HomeAssistant, config, async_add_entities, discovery_info=None): """Set up the sensor platform.""" From 505933e86ce01421589a732cf38478864954dfca Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Fri, 24 May 2024 19:43:39 +0200 Subject: [PATCH 12/13] chore: remove unused scan interval --- custom_components/audiobookshelf/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index c682bd8..7aa4b76 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -16,7 +16,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "audiobookshelf" -SCAN_INTERVAL = timedelta(seconds=10) async def count_active_users(data: dict) -> int: """ From 1830daf6aa42810832809c6f181176cc18537628 Mon Sep 17 00:00:00 2001 From: jadon <16850875+wolffshots@users.noreply.github.com> Date: Fri, 24 May 2024 19:46:22 +0200 Subject: [PATCH 13/13] chore: just making sure we don't truncate --- custom_components/audiobookshelf/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/audiobookshelf/sensor.py b/custom_components/audiobookshelf/sensor.py index 7aa4b76..23bc938 100644 --- a/custom_components/audiobookshelf/sensor.py +++ b/custom_components/audiobookshelf/sensor.py @@ -59,10 +59,10 @@ async def extract_library_details(data: dict) -> dict: 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 / 60, 0) + return round(total_duration / 60.0 / 60.0, 0) def get_total_size(total_size: float): - return round(total_size / 1024 / 1024 / 1024, 2) + return round(total_size / 1024.0 / 1024.0 / 1024.0, 2) async def fetch_library_stats(session, id): """Fetch data from a single endpoint."""