├── .python-version ├── hacs.json ├── script └── setup.sh ├── tests ├── ruff.toml ├── const.py ├── __init__.py ├── test_device_registry.py ├── test_binary_sensor.py ├── test_init.py ├── conftest.py ├── test_api.py ├── graphql_responses.py ├── test_sensor.py └── test_config_flow.py ├── custom_components └── unraid_api │ ├── const.py │ ├── manifest.json │ ├── icons.json │ ├── models.py │ ├── api │ ├── v4_26.py │ ├── __init__.py │ └── v4_20.py │ ├── __init__.py │ ├── binary_sensor.py │ ├── translations │ └── en.json │ ├── config_flow.py │ ├── coordinator.py │ └── sensor.py ├── .gitignore ├── .github └── workflows │ ├── validate.yaml │ ├── test.yaml │ └── lint.yaml ├── ruff.toml ├── pyproject.toml ├── LICENSE ├── .devcontainer └── devcontainer.json └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Unraid API", 3 | "render_readme": true, 4 | "homeassistant": "2025.8" 5 | } -------------------------------------------------------------------------------- /script/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | python3 -m pip install uv 7 | uv sync --no-install-project --prerelease=allow --group test -------------------------------------------------------------------------------- /tests/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../ruff.toml" 3 | 4 | [lint] 5 | 6 | extend-ignore = [ 7 | "SLF001", # private-member-access 8 | "S101", # assert 9 | "PLR2004", # magic-value-comparison 10 | ] 11 | -------------------------------------------------------------------------------- /custom_components/unraid_api/const.py: -------------------------------------------------------------------------------- 1 | """Constants.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Final 6 | 7 | from homeassistant.const import Platform 8 | 9 | DOMAIN: Final = "unraid_api" 10 | PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] 11 | 12 | CONF_SHARES: Final[str] = "shares" 13 | CONF_DRIVES: Final[str] = "drives" 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__ 3 | *.egg-info 4 | *_cache 5 | 6 | *.code-workspace 7 | 8 | /.storage 9 | /blueprints 10 | /deps 11 | /tts 12 | secrets.yaml 13 | .HA_VERSION 14 | automations.yaml 15 | home-assistant_v2.db 16 | home-assistant_v2.db-shm 17 | home-assistant_v2.db-wal 18 | home-assistant.log 19 | home-assistant.log.1 20 | home-assistant.log.fault 21 | scenes.yaml 22 | scripts.yaml 23 | configuration.yaml 24 | .coverage 25 | devcontainer.env 26 | *.pyc 27 | -------------------------------------------------------------------------------- /custom_components/unraid_api/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "unraid_api", 3 | "name": "Unraid API", 4 | "codeowners": [ 5 | "@chris-mc1" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/chris-mc1/unraid_api", 9 | "integration_type": "device", 10 | "iot_class": "local_polling", 11 | "issue_tracker": "https://github.com/chris-mc1/unraid_api/issues", 12 | "loggers": [ 13 | "custom_components.unraid_api" 14 | ], 15 | "version": "1.4.0" 16 | } -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Test Constants.""" 2 | 3 | from __future__ import annotations 4 | 5 | from custom_components.unraid_api.const import CONF_DRIVES, CONF_SHARES 6 | from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL 7 | 8 | DEFAULT_HOST = "http://1.2.3.4" 9 | MOCK_CONFIG_DATA = {CONF_HOST: DEFAULT_HOST, CONF_API_KEY: "test_key", CONF_VERIFY_SSL: False} 10 | MOCK_OPTION_DATA = {CONF_SHARES: True, CONF_DRIVES: True} 11 | MOCK_OPTION_DATA_DISABLED = {CONF_SHARES: False, CONF_DRIVES: False} 12 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | hassfest: 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - uses: "actions/checkout@v4" 13 | - uses: home-assistant/actions/hassfest@master 14 | validate-hacs: 15 | name: "HACS Validation" 16 | runs-on: "ubuntu-latest" 17 | steps: 18 | - uses: "actions/checkout@v4" 19 | - uses: "hacs/action@main" 20 | with: 21 | category: "integration" 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ha-version: ["homeassistant==2025.8.*", "homeassistant==2025.10.*"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v3 18 | with: 19 | python-version: "3.13.2" 20 | - run: | 21 | sudo pip install uv 22 | sudo uv sync --group test 23 | - run: | 24 | sudo uv run --with ${{ matrix.ha-version }} pytest tests/ 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-python@v3 13 | with: 14 | python-version: "3.13.2" 15 | - run: | 16 | pip install uv 17 | uv sync --no-install-project --prerelease=allow 18 | - run: | 19 | uv run ruff format --check custom_components/ 20 | uv run ruff format --check tests/ 21 | - run: | 22 | uv run ruff check custom_components/ 23 | uv run ruff check tests/ 24 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py313" 4 | line-length = 100 5 | 6 | [lint] 7 | select = ["ALL"] 8 | 9 | 10 | ignore = [ 11 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 12 | "D102", # undocumented-public-method 13 | "D105", # undocumented-magic-method 14 | "D107", # Missing docstring in __init__ 15 | "D203", # no-blank-line-before-class (incompatible with formatter) 16 | "D212", # multi-line-summary-first-line (incompatible with formatter) 17 | "COM812", # incompatible with formatter 18 | "ISC001", # incompatible with formatter 19 | "ARG002", # unused-method-argument 20 | ] 21 | 22 | [lint.flake8-pytest-style] 23 | fixture-parentheses = false 24 | 25 | [lint.mccabe] 26 | max-complexity = 25 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | version = "1.4.0" 3 | name = "unraid_api" 4 | authors = [{ name = "chris-mc1" }] 5 | description = "Unraid integration for Homeassistant" 6 | readme = "README.md" 7 | requires-python = ">=3.13.2" 8 | dependencies = ["aiohttp>=3.11.13", "awesomeversion>=25.5.0"] 9 | [project.urls] 10 | Homepage = "https://github.com/chris-mc1/unraid_api" 11 | Issues = "https://github.com/chris-mc1/unraid_api/issues" 12 | 13 | [dependency-groups] 14 | dev = [ 15 | "colorlog", 16 | "homeassistant==2025.10.4", 17 | "pytest-homeassistant-custom-component", 18 | "voluptuous-stubs==0.1.1", 19 | "ruff>=0.9", 20 | ] 21 | test = [ 22 | "pytest>=8.3.0", 23 | "pytest-asyncio>=0.24.0", 24 | "pytest-aiohttp>=1.0.5", 25 | "coverage>=7.6.0", 26 | "pytest-cov>=5.0.0", 27 | ] 28 | 29 | [tool.coverage.run] 30 | branch = true 31 | 32 | [tool.coverage.report] 33 | exclude_lines = ["if TYPE_CHECKING:"] 34 | 35 | [tool.pytest.ini_options] 36 | asyncio_mode = "auto" 37 | asyncio_default_fixture_loop_scope = "function" 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 chris_mc1 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests init.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from custom_components.unraid_api.const import DOMAIN 8 | from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL 9 | from pytest_homeassistant_custom_component.common import MockConfigEntry 10 | 11 | from .const import DEFAULT_HOST, MOCK_OPTION_DATA 12 | 13 | if TYPE_CHECKING: 14 | from homeassistant.core import HomeAssistant 15 | 16 | from tests.conftest import GraphqlServerMocker 17 | 18 | 19 | def add_config_entry( 20 | hass: HomeAssistant, 21 | mocker: GraphqlServerMocker | None = None, 22 | options: dict[str, Any] | None = None, 23 | ) -> MockConfigEntry: 24 | """Add a MockConfigEntry.""" 25 | if options is None: 26 | options = MOCK_OPTION_DATA 27 | 28 | host = DEFAULT_HOST if mocker is None else mocker.host 29 | 30 | entry = MockConfigEntry( 31 | domain=DOMAIN, 32 | data={ 33 | CONF_HOST: host, 34 | CONF_API_KEY: "test_key", 35 | CONF_VERIFY_SSL: False, 36 | }, 37 | options=options, 38 | ) 39 | entry.add_to_hass(hass) 40 | return entry 41 | 42 | 43 | async def setup_config_entry( 44 | hass: HomeAssistant, 45 | mocker: GraphqlServerMocker | None = None, 46 | options: dict[str, Any] | None = None, 47 | ) -> MockConfigEntry: 48 | """Do add and setup a MockConfigEntry.""" 49 | entry = add_config_entry(hass, mocker, options) 50 | 51 | await hass.config_entries.async_setup(entry.entry_id) 52 | await hass.async_block_till_done() 53 | return entry 54 | -------------------------------------------------------------------------------- /custom_components/unraid_api/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "sensor": { 4 | "array_state": { 5 | "default": "mdi:database" 6 | }, 7 | "array_usage": { 8 | "default": "mdi:database" 9 | }, 10 | "array_free": { 11 | "default": "mdi:database" 12 | }, 13 | "array_used": { 14 | "default": "mdi:database" 15 | }, 16 | "ram_usage": { 17 | "default": "mdi:memory" 18 | }, 19 | "ram_used": { 20 | "default": "mdi:memory" 21 | }, 22 | "ram_free": { 23 | "default": "mdi:memory" 24 | }, 25 | "cpu_utilization": { 26 | "default": "mdi:chip" 27 | }, 28 | "cpu_temp": { 29 | "default": "mdi:chip" 30 | }, 31 | "cpu_power": { 32 | "default": "mdi:chip" 33 | }, 34 | "disk_status": { 35 | "default": "mdi:harddisk" 36 | }, 37 | "disk_temp": { 38 | "default": "mdi:harddisk" 39 | }, 40 | "disk_free": { 41 | "default": "mdi:harddisk" 42 | }, 43 | "disk_used": { 44 | "default": "mdi:harddisk" 45 | }, 46 | "disk_usage": { 47 | "default": "mdi:harddisk" 48 | }, 49 | "share_free": { 50 | "default": "mdi:folder-network" 51 | } 52 | }, 53 | "binary_sensor": { 54 | "disk_spinning": { 55 | "default": "mdi:harddisk" 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unraid_hass", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "postCreateCommand": [ 5 | "script/setup.sh" 6 | ], 7 | "forwardPorts": [ 8 | 8123 9 | ], 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "ms-python.python", 14 | "eamodio.gitlens", 15 | "charliermarsh.ruff", 16 | "ryanluker.vscode-coverage-gutters", 17 | "tamasfe.even-better-toml", 18 | "DavidAnson.vscode-markdownlint", 19 | "redhat.vscode-yaml" 20 | ], 21 | "settings": { 22 | "files.eol": "\n", 23 | "editor.tabSize": 4, 24 | "editor.formatOnPaste": true, 25 | "editor.formatOnSave": true, 26 | "editor.formatOnType": false, 27 | "files.trimTrailingWhitespace": true, 28 | "editor.defaultFormatter": "charliermarsh.ruff", 29 | "[python]": { 30 | "editor.defaultFormatter": "charliermarsh.ruff" 31 | }, 32 | "[json]": { 33 | "editor.defaultFormatter": "vscode.json-language-features" 34 | }, 35 | "python.testing.unittestEnabled": false, 36 | "python.testing.pytestEnabled": true, 37 | "python.testing.pytestArgs": [ 38 | "tests" 39 | ] 40 | } 41 | } 42 | }, 43 | "remoteUser": "root", 44 | "runArgs": [ 45 | "--memory=4g", 46 | "--env-file", 47 | "./devcontainer.env" 48 | ], 49 | "features": { 50 | "ghcr.io/devcontainers/features/github-cli:1": {} 51 | } 52 | } -------------------------------------------------------------------------------- /tests/test_device_registry.py: -------------------------------------------------------------------------------- 1 | """Tests for Device registry.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from custom_components.unraid_api.const import DOMAIN 8 | 9 | from . import setup_config_entry 10 | from .graphql_responses import API_RESPONSES_LATEST 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Awaitable, Callable 14 | 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.device_registry import DeviceRegistry 17 | 18 | from .conftest import GraphqlServerMocker 19 | 20 | 21 | async def test_device_registry( 22 | hass: HomeAssistant, 23 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 24 | device_registry: DeviceRegistry, 25 | ) -> None: 26 | """Test device registry.""" 27 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 28 | entry = await setup_config_entry(hass, mocker) 29 | device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) 30 | 31 | assert device.name == "Test Server" 32 | assert device.sw_version == "7.0.1" 33 | assert device.configuration_url == "http://1.2.3.4" 34 | 35 | 36 | async def test_ups_device_registry( 37 | hass: HomeAssistant, 38 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 39 | device_registry: DeviceRegistry, 40 | ) -> None: 41 | """Test UPS device registry.""" 42 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 43 | entry = await setup_config_entry(hass, mocker) 44 | 45 | root_device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) 46 | ups_device = device_registry.async_get_device({(DOMAIN, f"{entry.entry_id}_Back-UPS ES 650G2")}) 47 | 48 | assert ups_device.name == "Back-UPS ES 650G2" 49 | assert ups_device.model == "Back-UPS ES 650G2" 50 | assert ups_device.via_device_id == root_device.id 51 | -------------------------------------------------------------------------------- /tests/test_binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for Sensor entities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | from . import setup_config_entry 10 | from .const import MOCK_OPTION_DATA_DISABLED 11 | from .graphql_responses import API_RESPONSES, API_RESPONSES_LATEST, GraphqlResponses 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Awaitable, Callable 15 | 16 | from homeassistant.core import HomeAssistant 17 | 18 | from tests.conftest import GraphqlServerMocker 19 | 20 | 21 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 22 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 23 | async def test_main_binary_sensor( 24 | api_responses: GraphqlResponses, 25 | hass: HomeAssistant, 26 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 27 | ) -> None: 28 | """Test disk sensor entities.""" 29 | mocker = await mock_graphql_server(api_responses) 30 | assert await setup_config_entry(hass, mocker) 31 | 32 | state = hass.states.get("binary_sensor.test_server_parity_spinning") 33 | assert state.state == "off" 34 | 35 | state = hass.states.get("binary_sensor.test_server_disk1_spinning") 36 | assert state.state == "on" 37 | 38 | state = hass.states.get("binary_sensor.test_server_disk1_spinning") 39 | assert state.state == "on" 40 | 41 | 42 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 43 | async def test_disk_sensors_disabled( 44 | hass: HomeAssistant, 45 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 46 | ) -> None: 47 | """Test disk sensor disabled.""" 48 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 49 | assert await setup_config_entry(hass, mocker, options=MOCK_OPTION_DATA_DISABLED) 50 | 51 | state = hass.states.get("binary_sensor.test_server_parity_spinning") 52 | assert state is None 53 | 54 | state = hass.states.get("binary_sensor.test_server_disk1_spinning") 55 | assert state is None 56 | 57 | state = hass.states.get("binary_sensor.test_server_disk1_spinning") 58 | assert state is None 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unraid 2 | 3 | The **Unraid API** integration allows users to integrate their [Unraid](https://unraid.net/) server using Unraids local GraphQL API. 4 | 5 | ## Install the Integration 6 | 7 | 1. Go to the HACS -> Custom Repositories and add this repository as a Custom Repository [See HACS Documentation for help](https://hacs.xyz/docs/faq/custom_repositories/) 8 | 9 | 2. Click the button bellow and click 'Download' to install the Integration: 10 | 11 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?repository=unraid_api&owner=chris-mc1) 12 | 13 | 3. Restart Home Assistant. 14 | 15 | ## Prerequisites 16 | 17 | - Unraid v7.2 or later 18 | - Create an [API Key](https://docs.unraid.net/API/how-to-use-the-api/#managing-api-keys) with this Template: 19 | 20 | ```txt 21 | ?name=Homeassistant&scopes=array%2Bdisk%2Binfo%2Bservers%2Bshare%3Aread_any&description=Unraid+API+Homeassistant+integration 22 | ``` 23 | 24 | or set permissions manully: 25 | - Resources: 26 | - Info 27 | - Servers 28 | - Array 29 | - Disk 30 | - Share 31 | 32 | - Actions: Read (All) 33 | 34 | ## Setup 35 | 36 | 1. Click the button below or use "Add Integration" in Home Assistant and select "Unraid". 37 | 38 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=unraid_api) 39 | 40 | 2. Enter the URL of the Unraid WebUI and your API Key 41 | 3. Select if you want to monitor disk and shares 42 | 43 | ### Configuration parameters 44 | 45 | - Unraid WebUI: URL of the Unraid WebUI (including "http(s)://") 46 | - API Key: API Key for the Unraid API 47 | - Monitor Shares: Create Entities for each Network Share 48 | - Monitor Disks: Create Entities for each Disk 49 | 50 | ## Entities 51 | 52 | - State of the Array ("Stopped", "Started", ...) 53 | - Percentage of used space on the Array 54 | - Percentage of used RAM 55 | - CPU utilization 56 | 57 | - When "Monitor Shares" enabled: 58 | 59 | - Free space for each Share 60 | 61 | - When "Monitor Disks" enabled, for each Disk, including Cache disks: 62 | 63 | - State of the Disk 64 | - Disk Temperature (Temperature is unknown for spun down disk) 65 | - Disk spinning 66 | - Percentage of used space on the Disk 67 | 68 | ## Remove integration 69 | 70 | This integration follows standard integration removal, no extra steps are required. 71 | -------------------------------------------------------------------------------- /custom_components/unraid_api/models.py: -------------------------------------------------------------------------------- 1 | """Models for Unraid GraphQl Api.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from enum import StrEnum 7 | 8 | 9 | class DiskStatus(StrEnum): # noqa: D101 10 | DISK_NP = "DISK_NP" 11 | DISK_OK = "DISK_OK" 12 | DISK_NP_MISSING = "DISK_NP_MISSING" 13 | DISK_INVALID = "DISK_INVALID" 14 | DISK_WRONG = "DISK_WRONG" 15 | DISK_DSBL = "DISK_DSBL" 16 | DISK_NP_DSBL = "DISK_NP_DSBL" 17 | DISK_DSBL_NEW = "DISK_DSBL_NEW" 18 | DISK_NEW = "DISK_NEW" 19 | 20 | 21 | class DiskType(StrEnum): # noqa: D101 22 | Data = "DATA" 23 | Parity = "PARITY" 24 | Flash = "FLASH" 25 | Cache = "CACHE" 26 | 27 | 28 | class ArrayState(StrEnum): # noqa: D101 29 | STARTED = "STARTED" 30 | STOPPED = "STOPPED" 31 | NEW_ARRAY = "NEW_ARRAY" 32 | RECON_DISK = "RECON_DISK" 33 | DISABLE_DISK = "DISABLE_DISK" 34 | SWAP_DSBL = "SWAP_DSBL" 35 | INVALID_EXPANSION = "INVALID_EXPANSION" 36 | PARITY_NOT_BIGGEST = "PARITY_NOT_BIGGEST" 37 | TOO_MANY_MISSING_DISKS = "TOO_MANY_MISSING_DISKS" 38 | NEW_DISK_TOO_SMALL = "NEW_DISK_TOO_SMALL" 39 | NO_DATA_DISKS = "NO_DATA_DISKS" 40 | 41 | 42 | @dataclass 43 | class ServerInfo: 44 | """Server Info.""" 45 | 46 | localurl: str 47 | name: str 48 | unraid_version: str 49 | 50 | 51 | @dataclass 52 | class Metrics: 53 | """Metrics.""" 54 | 55 | memory_free: int 56 | memory_total: int 57 | memory_active: int 58 | memory_available: int 59 | memory_percent_total: float 60 | cpu_percent_total: float 61 | cpu_temp: float | None = None 62 | cpu_power: float | None = None 63 | 64 | 65 | @dataclass 66 | class Share: 67 | """Shares.""" 68 | 69 | name: str 70 | free: int 71 | used: int 72 | size: int 73 | allocator: str 74 | floor: str 75 | 76 | 77 | @dataclass 78 | class Disk: 79 | """Disk.""" 80 | 81 | name: str 82 | status: DiskStatus 83 | temp: int | None 84 | fs_size: int | None 85 | fs_free: int | None 86 | fs_used: int | None 87 | type: DiskType 88 | id: str 89 | is_spinning: bool 90 | 91 | 92 | @dataclass 93 | class Array: 94 | """Array.""" 95 | 96 | state: ArrayState 97 | capacity_free: int 98 | capacity_used: int 99 | capacity_total: int 100 | 101 | 102 | @dataclass 103 | class UpsDevice: 104 | """UPS device.""" 105 | 106 | id: str 107 | name: str 108 | model: str 109 | status: str 110 | battery_level: int 111 | battery_runtime: int 112 | battery_health: str 113 | load_percentage: int 114 | output_voltage: float 115 | input_voltage: float 116 | -------------------------------------------------------------------------------- /custom_components/unraid_api/api/v4_26.py: -------------------------------------------------------------------------------- 1 | """Unraid GraphQL API Client for Api >= 4.26.""" 2 | 3 | from __future__ import annotations 4 | 5 | from awesomeversion import AwesomeVersion 6 | from pydantic import BaseModel, Field 7 | 8 | from custom_components.unraid_api.models import Metrics, UpsDevice 9 | 10 | from .v4_20 import UnraidApiV420, _Metrics 11 | 12 | 13 | class UnraidApiV426(UnraidApiV420): 14 | """ 15 | Unraid GraphQL API Client. 16 | 17 | Api version > 4.26 18 | """ 19 | 20 | version = AwesomeVersion("4.26.0") 21 | 22 | async def query_metrics(self) -> Metrics: 23 | response = await self.call_api(METRICS_QUERY, MetricsQuery) 24 | return Metrics( 25 | memory_free=response.metrics.memory.free, 26 | memory_total=response.metrics.memory.total, 27 | memory_active=response.metrics.memory.active, 28 | memory_available=response.metrics.memory.available, 29 | memory_percent_total=response.metrics.memory.percent_total, 30 | cpu_percent_total=response.metrics.cpu.percent_total, 31 | cpu_temp=response.info.cpu.packages.temp[0], 32 | cpu_power=response.info.cpu.packages.power[0], 33 | ) 34 | 35 | async def query_ups(self) -> list[UpsDevice]: 36 | response = await self.call_api(UPS_QUERY, UpsQuery) 37 | return [ 38 | UpsDevice( 39 | id=device.id, 40 | name=device.name, 41 | model=device.model, 42 | status=device.status, 43 | battery_health=device.battery.health, 44 | battery_runtime=device.battery.estimated_runtime, 45 | battery_level=device.battery.charge_level, 46 | load_percentage=device.power.load_percentage, 47 | output_voltage=device.power.output_voltage, 48 | input_voltage=device.power.input_voltage, 49 | ) 50 | for device in response.ups_devices 51 | ] 52 | 53 | 54 | ## Queries 55 | 56 | METRICS_QUERY = """ 57 | query Metrics { 58 | metrics { 59 | memory { 60 | free 61 | total 62 | percentTotal 63 | active 64 | available 65 | } 66 | cpu { 67 | percentTotal 68 | } 69 | } 70 | info { 71 | cpu { 72 | packages { 73 | power 74 | temp 75 | } 76 | } 77 | } 78 | } 79 | """ 80 | 81 | UPS_QUERY = """ 82 | query UpsDevices { 83 | upsDevices { 84 | id 85 | name 86 | model 87 | status 88 | battery { 89 | chargeLevel 90 | estimatedRuntime 91 | health 92 | } 93 | power { 94 | inputVoltage 95 | outputVoltage 96 | loadPercentage 97 | } 98 | } 99 | } 100 | """ 101 | 102 | ## Api Models 103 | 104 | 105 | ### Metrics 106 | class MetricsQuery(BaseModel): # noqa: D101 107 | metrics: _Metrics 108 | info: Info 109 | 110 | 111 | class Info(BaseModel): # noqa: D101 112 | cpu: Cpu 113 | 114 | 115 | class Cpu(BaseModel): # noqa: D101 116 | packages: Packages 117 | 118 | 119 | class Packages(BaseModel): # noqa: D101 120 | power: list[float] 121 | temp: list[float] 122 | 123 | 124 | ### UPS 125 | class UpsQuery(BaseModel): # noqa: D101 126 | ups_devices: list[UpsDevices] = Field(alias="upsDevices") 127 | 128 | 129 | class UpsDevices(BaseModel): # noqa: D101 130 | id: str 131 | id: str 132 | name: str 133 | model: str 134 | status: str 135 | battery: UPSBattery 136 | power: UPSPower 137 | 138 | 139 | class UPSBattery(BaseModel): # noqa: D101 140 | charge_level: int = Field(alias="chargeLevel") 141 | estimated_runtime: int = Field(alias="estimatedRuntime") 142 | health: str 143 | 144 | 145 | class UPSPower(BaseModel): # noqa: D101 146 | input_voltage: float = Field(alias="inputVoltage") 147 | load_percentage: float = Field(alias="loadPercentage") 148 | output_voltage: float = Field(alias="outputVoltage") 149 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Tests for integration init.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | from unittest.mock import AsyncMock, MagicMock 7 | 8 | import pytest 9 | from aiohttp import ClientConnectionError, ClientConnectorSSLError 10 | from custom_components.unraid_api.api import UnraidApiClient 11 | from homeassistant.config_entries import ConfigEntryState 12 | 13 | from . import add_config_entry, setup_config_entry 14 | from .graphql_responses import ( 15 | API_RESPONSES, 16 | API_RESPONSES_LATEST, 17 | GraphqlResponses, 18 | GraphqlResponses410, 19 | ) 20 | 21 | if TYPE_CHECKING: 22 | from collections.abc import Awaitable, Callable 23 | 24 | from homeassistant.core import HomeAssistant 25 | 26 | from tests.conftest import GraphqlServerMocker 27 | 28 | 29 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 30 | async def test_load_unload_entry( 31 | api_responses: GraphqlResponses, 32 | hass: HomeAssistant, 33 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 34 | ) -> None: 35 | """Test setup and unload config entry.""" 36 | mocker = await mock_graphql_server(api_responses) 37 | entry = await setup_config_entry(hass, mocker) 38 | 39 | assert entry.state is ConfigEntryState.LOADED 40 | 41 | assert await hass.config_entries.async_unload(entry.entry_id) 42 | await hass.async_block_till_done() 43 | 44 | assert entry.state is ConfigEntryState.NOT_LOADED 45 | 46 | 47 | async def test_load_failure( 48 | hass: HomeAssistant, 49 | monkeypatch: pytest.MonkeyPatch, 50 | ) -> None: 51 | """Test setup and unload failure.""" 52 | mock_call_api = AsyncMock() 53 | monkeypatch.setattr(UnraidApiClient, "call_api", mock_call_api, raising=True) 54 | entry = add_config_entry(hass) 55 | 56 | mock_call_api.side_effect = ClientConnectorSSLError(MagicMock(), MagicMock()) 57 | await hass.config_entries.async_setup(entry.entry_id) 58 | await hass.async_block_till_done() 59 | 60 | assert entry.state is ConfigEntryState.SETUP_ERROR 61 | await hass.config_entries.async_unload(entry.entry_id) 62 | mock_call_api.reset_mock() 63 | 64 | mock_call_api.side_effect = TimeoutError() 65 | await hass.config_entries.async_setup(entry.entry_id) 66 | await hass.async_block_till_done() 67 | 68 | assert entry.state is ConfigEntryState.SETUP_RETRY 69 | await hass.config_entries.async_unload(entry.entry_id) 70 | mock_call_api.reset_mock() 71 | 72 | mock_call_api.side_effect = ClientConnectionError() 73 | await hass.config_entries.async_setup(entry.entry_id) 74 | await hass.async_block_till_done() 75 | 76 | assert entry.state is ConfigEntryState.SETUP_RETRY 77 | await hass.config_entries.async_unload(entry.entry_id) 78 | mock_call_api.reset_mock() 79 | 80 | 81 | async def test_load_failure_2( 82 | hass: HomeAssistant, 83 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 84 | ) -> None: 85 | """Test setup and unload failure.""" 86 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 87 | mocker.responses.is_unauthenticated = True 88 | entry = add_config_entry(hass, mocker) 89 | 90 | await hass.config_entries.async_setup(entry.entry_id) 91 | await hass.async_block_till_done() 92 | 93 | assert entry.state is ConfigEntryState.SETUP_ERROR 94 | await hass.config_entries.async_unload(entry.entry_id) 95 | 96 | mocker.responses.all_error = True 97 | await hass.config_entries.async_setup(entry.entry_id) 98 | await hass.async_block_till_done() 99 | 100 | assert entry.state is ConfigEntryState.SETUP_ERROR 101 | await hass.config_entries.async_unload(entry.entry_id) 102 | 103 | mocker.responses = GraphqlResponses410() 104 | await hass.config_entries.async_setup(entry.entry_id) 105 | await hass.async_block_till_done() 106 | 107 | assert entry.state is ConfigEntryState.SETUP_ERROR 108 | await hass.config_entries.async_unload(entry.entry_id) 109 | -------------------------------------------------------------------------------- /custom_components/unraid_api/__init__.py: -------------------------------------------------------------------------------- 1 | """The Unraid Homeassistant integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from dataclasses import dataclass 7 | from typing import TYPE_CHECKING 8 | 9 | from aiohttp import ClientConnectionError, ClientConnectorSSLError 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL 12 | from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady 13 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 14 | from homeassistant.helpers.entity import DeviceInfo 15 | 16 | from .api import IncompatibleApiError, UnraidAuthError, UnraidGraphQLError, get_api_client 17 | from .const import DOMAIN, PLATFORMS 18 | from .coordinator import UnraidDataUpdateCoordinator 19 | 20 | if TYPE_CHECKING: 21 | from homeassistant.core import HomeAssistant 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | @dataclass 27 | class UnraidData: 28 | """Dataclass for runtime data.""" 29 | 30 | coordinator: UnraidDataUpdateCoordinator 31 | device_info: DeviceInfo 32 | 33 | 34 | type UnraidConfigEntry = ConfigEntry[UnraidData] 35 | 36 | 37 | async def async_setup_entry( 38 | hass: HomeAssistant, 39 | config_entry: UnraidConfigEntry, 40 | ) -> bool: 41 | """Set up this integration using config entry.""" 42 | _LOGGER.debug("Setting up %s", config_entry.data[CONF_HOST]) 43 | try: 44 | api_client = await get_api_client( 45 | host=config_entry.data[CONF_HOST], 46 | api_key=config_entry.data[CONF_API_KEY], 47 | session=async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]), 48 | ) 49 | 50 | server_info = await api_client.query_server_info() 51 | except ClientConnectorSSLError as exc: 52 | _LOGGER.debug("Init: SSL error: %s", str(exc)) 53 | raise ConfigEntryError(translation_domain=DOMAIN, translation_key="ssl_error") from exc 54 | except (ClientConnectionError, TimeoutError) as exc: 55 | _LOGGER.debug("Init: Connection error: %s", str(exc)) 56 | raise ConfigEntryNotReady( 57 | translation_domain=DOMAIN, translation_key="cannot_connect" 58 | ) from exc 59 | except UnraidAuthError as exc: 60 | _LOGGER.debug("Init: Auth failed") 61 | raise ConfigEntryAuthFailed( 62 | translation_domain=DOMAIN, 63 | translation_key="auth_failed", 64 | translation_placeholders={"error_msg": exc.args[0]}, 65 | ) from exc 66 | except UnraidGraphQLError as exc: 67 | _LOGGER.debug("Init: GraphQL Error response: %s", exc.response) 68 | raise ConfigEntryError( 69 | translation_domain=DOMAIN, 70 | translation_key="error_response", 71 | translation_placeholders={"error_msg": exc.args[0]}, 72 | ) from exc 73 | except IncompatibleApiError as exc: 74 | _LOGGER.debug("Init: Incompatible API, %s < %s", exc.version, exc.min_version) 75 | raise ConfigEntryError( 76 | translation_domain=DOMAIN, 77 | translation_key="api_incompatible", 78 | translation_placeholders={"min_version": exc.min_version, "version": exc.version}, 79 | ) from exc 80 | 81 | device_info = DeviceInfo( 82 | identifiers={(DOMAIN, config_entry.entry_id)}, 83 | sw_version=server_info.unraid_version, 84 | name=server_info.name, 85 | configuration_url=server_info.localurl, 86 | ) 87 | coordinator = UnraidDataUpdateCoordinator(hass, config_entry, api_client) 88 | await coordinator.async_config_entry_first_refresh() 89 | 90 | config_entry.runtime_data = UnraidData( 91 | coordinator, 92 | device_info, 93 | ) 94 | 95 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 96 | return True 97 | 98 | 99 | async def async_unload_entry(hass: HomeAssistant, entry: UnraidConfigEntry) -> bool: 100 | """Unload qBittorrent config entry.""" 101 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 102 | del entry.runtime_data 103 | return unload_ok 104 | -------------------------------------------------------------------------------- /custom_components/unraid_api/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Unraid Binary Sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from awesomeversion import AwesomeVersion 9 | from homeassistant.components.binary_sensor import ( 10 | BinarySensorDeviceClass, 11 | BinarySensorEntity, 12 | BinarySensorEntityDescription, 13 | ) 14 | from homeassistant.core import callback 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | 17 | from .const import CONF_DRIVES 18 | from .coordinator import UnraidDataUpdateCoordinator 19 | 20 | if TYPE_CHECKING: 21 | from collections.abc import Callable 22 | 23 | from homeassistant.core import HomeAssistant 24 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 25 | from homeassistant.helpers.typing import StateType 26 | 27 | from . import UnraidConfigEntry 28 | from .models import Disk 29 | 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | class UnraidDiskBinarySensorEntityDescription(BinarySensorEntityDescription, frozen_or_thawed=True): 35 | """Description for Unraid Binary Sensor Entity.""" 36 | 37 | min_version: AwesomeVersion = AwesomeVersion("4.20.0") 38 | value_fn: Callable[[Disk], StateType] 39 | extra_values_fn: Callable[[Disk], dict[str, Any]] | None = None 40 | 41 | 42 | DISK_BINARY_SENSOR_DESCRIPTIONS: tuple[UnraidDiskBinarySensorEntityDescription, ...] = ( 43 | UnraidDiskBinarySensorEntityDescription( 44 | key="disk_spinning", 45 | device_class=BinarySensorDeviceClass.MOVING, 46 | value_fn=lambda disk: disk.is_spinning, 47 | ), 48 | ) 49 | 50 | 51 | async def async_setup_entry( 52 | hass: HomeAssistant, # noqa: ARG001 53 | config_entry: UnraidConfigEntry, 54 | async_add_entites: AddEntitiesCallback, 55 | ) -> None: 56 | """Set up this integration using config entry.""" 57 | 58 | @callback 59 | def add_disk_callback(disk: Disk) -> None: 60 | _LOGGER.debug("Adding new disk: %s", disk.name) 61 | entities = [ 62 | UnraidDiskBinarySensorEntity(description, config_entry, disk.id) 63 | for description in DISK_BINARY_SENSOR_DESCRIPTIONS 64 | if description.min_version <= config_entry.runtime_data.coordinator.api_client.version 65 | ] 66 | async_add_entites(entities) 67 | 68 | if config_entry.options[CONF_DRIVES]: 69 | config_entry.runtime_data.coordinator.subscribe_disks(add_disk_callback) 70 | 71 | 72 | class UnraidDiskBinarySensorEntity( 73 | CoordinatorEntity[UnraidDataUpdateCoordinator], BinarySensorEntity 74 | ): 75 | """Binary Sensor for Unraid Disks.""" 76 | 77 | entity_description: UnraidDiskBinarySensorEntityDescription 78 | _attr_has_entity_name = True 79 | 80 | def __init__( 81 | self, 82 | description: UnraidDiskBinarySensorEntityDescription, 83 | config_entry: UnraidConfigEntry, 84 | disk_id: str, 85 | ) -> None: 86 | super().__init__(config_entry.runtime_data.coordinator) 87 | self.disk_id = disk_id 88 | self.entity_description = description 89 | self._attr_unique_id = f"{config_entry.entry_id}-{description.key}-{self.disk_id}" 90 | self._attr_translation_key = description.key 91 | self._attr_translation_placeholders = { 92 | "disk_name": self.coordinator.data["disks"][self.disk_id].name 93 | } 94 | self._attr_available = False 95 | self._attr_device_info = config_entry.runtime_data.device_info 96 | 97 | @property 98 | def is_on(self) -> StateType: 99 | try: 100 | return self.entity_description.value_fn(self.coordinator.data["disks"][self.disk_id]) 101 | except (KeyError, AttributeError): 102 | return None 103 | 104 | @property 105 | def extra_state_attributes(self) -> dict[str, Any] | None: 106 | try: 107 | if self.entity_description.extra_values_fn: 108 | return self.entity_description.extra_values_fn( 109 | self.coordinator.data["disks"][self.disk_id] 110 | ) 111 | except (KeyError, AttributeError): 112 | return None 113 | return None 114 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for testing.""" 2 | 3 | from __future__ import annotations 4 | 5 | from contextlib import contextmanager 6 | from typing import TYPE_CHECKING, Any 7 | from unittest import mock 8 | from unittest.mock import AsyncMock, patch 9 | 10 | import pytest 11 | import pytest_asyncio 12 | from aiohttp import ClientSession, web 13 | from aiohttp.test_utils import TestClient, TestServer 14 | from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE 15 | 16 | if TYPE_CHECKING: 17 | from asyncio import AbstractEventLoop 18 | from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Iterator 19 | 20 | from homeassistant.core import Event, HomeAssistant 21 | from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker 22 | 23 | from .graphql_responses import GraphqlResponses 24 | 25 | pytest_plugins = ["aiohttp.pytest_plugin"] 26 | 27 | 28 | @pytest.fixture(autouse=True) 29 | def auto_enable_custom_integrations(enable_custom_integrations: None) -> None: # noqa: ARG001 30 | """Enable custom integrations defined in the test dir.""" 31 | return 32 | 33 | 34 | class GraphqlServerMocker: 35 | """Mock GraphQL client requests.""" 36 | 37 | def __init__(self, response_set: type[GraphqlResponses]) -> None: 38 | self.responses = response_set() 39 | self.app = web.Application() 40 | self.app.add_routes([web.post("/graphql", self.handler)]) 41 | self.server = TestServer(self.app) 42 | self.clients = set[GraphqlServerMocker]() 43 | 44 | async def handler(self, request: web.Request) -> web.Response: 45 | body = await request.json() 46 | query: str = body["query"] 47 | query = query.split(" ")[1] 48 | response = self.responses.get_response(query) 49 | return web.json_response(data=response) 50 | 51 | def create_session(self, loop: AbstractEventLoop | None = None) -> TestClient: 52 | """Create a ClientSession that is bound to this mocker.""" 53 | client = TestClient(self.server, loop=loop) 54 | self.clients.add(client) 55 | return client 56 | 57 | async def start_server(self) -> None: 58 | await self.server.start_server() 59 | 60 | async def close(self) -> None: 61 | while self.clients: 62 | await self.clients.pop().close() 63 | await self.server.close() 64 | 65 | @property 66 | def host(self) -> str: 67 | return f"http://{self.server.host}:{self.server.port}" 68 | 69 | 70 | @pytest.fixture 71 | def mock_setup_entry() -> Generator[AsyncMock]: 72 | """Override async_setup_entry.""" 73 | with patch( 74 | "custom_components.unraid_api.async_setup_entry", return_value=True 75 | ) as mock_setup_entry: 76 | yield mock_setup_entry 77 | 78 | 79 | # From https://github.com/home-assistant/core/blob/6357067f0f427abd995697aaa84fa9ed3e126aef/tests/components/conftest.py#L85 80 | @pytest.fixture 81 | def entity_registry_enabled_by_default() -> Generator[None]: 82 | """Test fixture that ensures all entities are enabled in the registry.""" 83 | with ( 84 | patch( 85 | "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", 86 | return_value=True, 87 | ), 88 | patch( 89 | "homeassistant.components.device_tracker.config_entry.ScannerEntity.entity_registry_enabled_default", 90 | return_value=True, 91 | ), 92 | ): 93 | yield 94 | 95 | 96 | @pytest_asyncio.fixture 97 | async def mock_graphql_server( 98 | socket_enabled: None, # noqa: ARG001 99 | ) -> AsyncGenerator[Callable[..., Awaitable[GraphqlServerMocker]]]: 100 | """Graphql Server.""" 101 | mocks = set[GraphqlServerMocker]() 102 | 103 | async def go(response_set: dict) -> GraphqlServerMocker: 104 | mocker = GraphqlServerMocker(response_set) 105 | mocks.add(mocker) 106 | await mocker.start_server() 107 | return mocker 108 | 109 | yield go 110 | 111 | while mocks: 112 | await mocks.pop().close() 113 | 114 | 115 | @contextmanager 116 | def mock_aiohttp_client(mocker: GraphqlServerMocker) -> Iterator[AiohttpClientMocker]: 117 | """Context manager to mock aiohttp client.""" 118 | 119 | def create_session(hass: HomeAssistant, *args: Any, **kwargs: Any) -> ClientSession: # noqa: ARG001 120 | session = mocker.create_session(hass.loop) 121 | 122 | async def close_session(event: Event) -> None: # noqa: ARG001 123 | """Close session.""" 124 | await session.close() 125 | 126 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, close_session) 127 | 128 | return session 129 | 130 | with mock.patch( 131 | "homeassistant.helpers.aiohttp_client._async_create_clientsession", 132 | side_effect=create_session, 133 | ): 134 | yield mocker 135 | -------------------------------------------------------------------------------- /custom_components/unraid_api/api/__init__.py: -------------------------------------------------------------------------------- 1 | """Unraid GraphQL API Client.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from abc import abstractmethod 8 | from typing import TYPE_CHECKING, Any, TypeVar 9 | 10 | from awesomeversion import AwesomeVersion 11 | from pydantic import BaseModel, ValidationError 12 | 13 | if TYPE_CHECKING: 14 | from aiohttp import ClientSession 15 | 16 | from unraid_api.models import Array, Disk, Metrics, ServerInfo, Share, UpsDevice 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class UnraidGraphQLError(Exception): 22 | """Raised when the response contains errors.""" 23 | 24 | def __init__(self, response: dict, *args: Any) -> None: 25 | self.response = response 26 | error_msg = ", ".join({entry.get("message") for entry in response["errors"]}) 27 | super().__init__(error_msg, *args) 28 | 29 | 30 | class UnraidAuthError(UnraidGraphQLError): 31 | """Raised when the request was unauthorized.""" 32 | 33 | 34 | class IncompatibleApiError(Exception): 35 | """Raised when the response contains errors.""" 36 | 37 | def __init__(self, version: AwesomeVersion, min_version: AwesomeVersion, *args: Any) -> None: 38 | self.version = version 39 | self.min_version = min_version 40 | super().__init__(*args) 41 | 42 | 43 | def _import_client_class( 44 | api_version: AwesomeVersion, 45 | ) -> type[UnraidApiClient]: 46 | if api_version >= AwesomeVersion("4.26.0"): 47 | from custom_components.unraid_api.api.v4_26 import UnraidApiV426 # noqa: PLC0415 48 | 49 | return UnraidApiV426 50 | if api_version >= AwesomeVersion("4.20.0"): 51 | from custom_components.unraid_api.api.v4_20 import UnraidApiV420 # noqa: PLC0415 52 | 53 | return UnraidApiV420 54 | 55 | raise IncompatibleApiError(version=api_version, min_version=AwesomeVersion("4.20.0")) 56 | 57 | 58 | async def get_api_client(host: str, api_key: str, session: ClientSession) -> UnraidApiClient: 59 | """Get Unraid API Client.""" 60 | client = UnraidApiClient(host, api_key, session) 61 | api_version = await client.query_api_version() 62 | loop = asyncio.get_event_loop() 63 | cls = await loop.run_in_executor(None, _import_client_class, api_version) 64 | return cls(host, api_key, session) 65 | 66 | 67 | _T = TypeVar("_T", bound=BaseModel) 68 | 69 | 70 | class UnraidApiClient: 71 | """Unraid GraphQL API Client.""" 72 | 73 | version: AwesomeVersion 74 | 75 | def __init__(self, host: str, api_key: str, session: ClientSession) -> None: 76 | self.host = host.rstrip("/") 77 | self.endpoint = self.host + "/graphql" 78 | self.api_key = api_key 79 | self.session = session 80 | 81 | async def call_api( 82 | self, 83 | query: str, 84 | model: type[_T], 85 | variables: dict[str, Any] | None = None, 86 | ) -> _T: 87 | response = await self.session.post( 88 | self.endpoint, 89 | json={"query": query, "variables": variables or {}}, 90 | headers={ 91 | "x-api-key": self.api_key, 92 | "Origin": self.host, 93 | "content-type": "application/json", 94 | }, 95 | ) 96 | result = await response.json() 97 | if "errors" in result: 98 | try: 99 | if result["errors"][0]["extensions"]["code"] == "UNAUTHENTICATED": 100 | raise UnraidAuthError(response=result) 101 | except KeyError: 102 | pass 103 | raise UnraidGraphQLError(response=result) 104 | 105 | return model.model_validate(result["data"]) 106 | 107 | async def query_api_version(self) -> AwesomeVersion: 108 | try: 109 | response = await self.call_api(API_VERSION_QUERY, ApiVersionQuery) 110 | return AwesomeVersion(response.info.versions.core.api.split("+")[0]) 111 | except ValidationError: 112 | return AwesomeVersion("") 113 | 114 | @abstractmethod 115 | async def query_server_info(self) -> ServerInfo: 116 | pass 117 | 118 | @abstractmethod 119 | async def query_metrics(self) -> Metrics: 120 | pass 121 | 122 | @abstractmethod 123 | async def query_shares(self) -> list[Share]: 124 | pass 125 | 126 | @abstractmethod 127 | async def query_disks(self) -> list[Disk]: 128 | pass 129 | 130 | @abstractmethod 131 | async def query_array(self) -> Array: 132 | pass 133 | 134 | @abstractmethod 135 | async def query_ups(self) -> list[UpsDevice]: 136 | pass 137 | 138 | 139 | ## Queries 140 | 141 | API_VERSION_QUERY = """ 142 | query ApiVersion { 143 | info { 144 | versions { 145 | core { 146 | api 147 | } 148 | } 149 | } 150 | } 151 | """ 152 | 153 | ## Api Models 154 | 155 | 156 | class ApiVersionQuery(BaseModel): # noqa: D101 157 | info: Info 158 | 159 | 160 | class Info(BaseModel): # noqa: D101 161 | versions: InfoVersions 162 | 163 | 164 | class InfoVersions(BaseModel): # noqa: D101 165 | core: InfoVersionsCore 166 | 167 | 168 | class InfoVersionsCore(BaseModel): # noqa: D101 169 | api: str 170 | -------------------------------------------------------------------------------- /custom_components/unraid_api/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Add new Unraid server", 6 | "data": { 7 | "host": "Unraid WebUI URL", 8 | "api_key": "API key", 9 | "verify_ssl": "Verify SSL certificate" 10 | } 11 | }, 12 | "options": { 13 | "data": { 14 | "shares": "Monitor shares", 15 | "drives": "Monitor disks" 16 | } 17 | }, 18 | "reauth_key": { 19 | "title": "Add new Unraid Server", 20 | "data": { 21 | "api_key": "API key" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "ssl_error": "SSL error when connecting to Unraid", 27 | "cannot_connect": "Failed to connect to Unraid", 28 | "invalid_url": "Invalid URL", 29 | "error_response": "Unraid: {error_msg}", 30 | "api_incompatible": "Incompatible API version (Current: {version}, minimum: {min_version})", 31 | "auth_failed": "Authentication failed" 32 | }, 33 | "abort": { 34 | "reauth_successful": "Re-authentication was successful", 35 | "reconfigure_successful": "Re-configuration was successful" 36 | } 37 | }, 38 | "entity": { 39 | "sensor": { 40 | "array_state": { 41 | "name": "Array state", 42 | "state": { 43 | "started": "Started", 44 | "stopped": "Stopped", 45 | "new_array": "New disks", 46 | "recon_disk": "disk being reconstructed", 47 | "disable_disk": "Disk is disabled", 48 | "swap_dsbl": "Disabled", 49 | "invalid_expansion": "Invalid expansion", 50 | "parity_not_biggest": "Parity not biggest", 51 | "too_many_missing_disks": "Too many missing disks", 52 | "new_disk_too_small": "New disk to small", 53 | "no_data_disks": "No data disks" 54 | } 55 | }, 56 | "array_usage": { 57 | "name": "Array usage" 58 | }, 59 | "array_free": { 60 | "name": "Array free space" 61 | }, 62 | "array_used": { 63 | "name": "Array used space" 64 | }, 65 | "ram_usage": { 66 | "name": "RAM usage" 67 | }, 68 | "ram_used": { 69 | "name": "RAM used" 70 | }, 71 | "ram_free": { 72 | "name": "RAM free" 73 | }, 74 | "cpu_utilization": { 75 | "name": "CPU utilization" 76 | }, 77 | "cpu_temp": { 78 | "name": "CPU temperature" 79 | }, 80 | "cpu_power": { 81 | "name": "CPU power" 82 | }, 83 | "disk_status": { 84 | "name": "{disk_name} Status", 85 | "state": { 86 | "disk_np": "No disk", 87 | "disk_ok": "OK", 88 | "disk_np_missing": "Missing", 89 | "disk_invalid": "Invalid", 90 | "disk_wrong": "Wrong disk", 91 | "disk_dsbl": "Disabled", 92 | "disk_np_dsbl": "Disabled, no disk", 93 | "disk_dsbl_new": "Disabled, new disk", 94 | "disk_new": "New disk" 95 | } 96 | }, 97 | "disk_temp": { 98 | "name": "{disk_name} temperature" 99 | }, 100 | "disk_free": { 101 | "name": "{disk_name} free space" 102 | }, 103 | "disk_used": { 104 | "name": "{disk_name} used space" 105 | }, 106 | "disk_usage": { 107 | "name": "{disk_name} usage" 108 | }, 109 | "share_free": { 110 | "name": "{share_name} free space" 111 | }, 112 | "ups_status": { 113 | "name": "Status" 114 | }, 115 | "ups_level": { 116 | "name": "Level" 117 | }, 118 | "ups_runtime": { 119 | "name": "Runtime" 120 | }, 121 | "ups_health": { 122 | "name": "Health" 123 | }, 124 | "ups_load": { 125 | "name": "Load" 126 | }, 127 | "ups_input_voltage": { 128 | "name": "Input voltage" 129 | }, 130 | "ups_output_voltage": { 131 | "name": "Output voltage" 132 | } 133 | }, 134 | "binary_sensor": { 135 | "disk_spinning": { 136 | "name": "{disk_name} spinning" 137 | } 138 | } 139 | }, 140 | "exceptions": { 141 | "ssl_error": { 142 | "message": "SSL Error when connecting to Unraid: {error}" 143 | }, 144 | "cannot_connect": { 145 | "message": "Failed to connect to Unraid: {error}" 146 | }, 147 | "error_response": { 148 | "message": "Recived an Error response from Unraid: {error_msg}" 149 | }, 150 | "data_invalid": { 151 | "message": "Recived invalid data from Unraid" 152 | }, 153 | "api_incompatible": { 154 | "message": "Incompatible API version (Current: {version}, minimum: {min_version})" 155 | }, 156 | "auth_failed": { 157 | "message": "Authentication failed {error_msg}" 158 | } 159 | }, 160 | "options": { 161 | "step": { 162 | "init": { 163 | "data": { 164 | "shares": "Monitor shares", 165 | "drives": "Monitor disks" 166 | } 167 | } 168 | } 169 | } 170 | } -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """API Client Tests.""" 2 | 3 | from collections.abc import Awaitable, Callable 4 | 5 | import pytest 6 | from custom_components.unraid_api.api import ( 7 | IncompatibleApiError, 8 | UnraidApiClient, 9 | get_api_client, 10 | ) 11 | from custom_components.unraid_api.models import ArrayState, DiskStatus, DiskType 12 | 13 | from tests.conftest import GraphqlServerMocker 14 | 15 | from .graphql_responses import API_RESPONSES, GraphqlResponses, GraphqlResponses410 16 | 17 | 18 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 19 | async def test_get_api_client( 20 | api_responses: GraphqlResponses, 21 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 22 | ) -> None: 23 | """Test get_api_client.""" 24 | mocker = await mock_graphql_server(api_responses) 25 | session = mocker.create_session() 26 | api_client = await get_api_client("", "test_key", session) 27 | 28 | assert api_client.version == api_responses.version 29 | 30 | 31 | async def test_get_api_client_incompatible( 32 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 33 | ) -> None: 34 | """Test get_api_client with incompatible version.""" 35 | mocker = await mock_graphql_server(GraphqlResponses410) 36 | session = mocker.create_session() 37 | 38 | with pytest.raises(IncompatibleApiError): 39 | await get_api_client("", "test_key", session) 40 | 41 | 42 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 43 | async def test_api_version( 44 | api_responses: GraphqlResponses, 45 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 46 | ) -> None: 47 | """Test querying api version.""" 48 | mocker = await mock_graphql_server(api_responses) 49 | session = mocker.create_session() 50 | api_client = UnraidApiClient("", "test_key", session) 51 | 52 | api_version = await api_client.query_api_version() 53 | 54 | assert api_version == api_responses.version 55 | 56 | 57 | @pytest.mark.parametrize("api_responses", API_RESPONSES) 58 | async def test_server_info( 59 | api_responses: GraphqlResponses, 60 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 61 | ) -> None: 62 | """Test querying server info.""" 63 | mocker = await mock_graphql_server(api_responses) 64 | session = mocker.create_session() 65 | api_client = await get_api_client("", "test_key", session) 66 | 67 | server_info = await api_client.query_server_info() 68 | 69 | assert server_info.localurl == "http://1.2.3.4" 70 | assert server_info.unraid_version == "7.0.1" 71 | assert server_info.name == "Test Server" 72 | 73 | 74 | @pytest.mark.parametrize("api_responses", API_RESPONSES) 75 | async def test_metrics( 76 | api_responses: GraphqlResponses, 77 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 78 | ) -> None: 79 | """Test querying metrics.""" 80 | mocker = await mock_graphql_server(api_responses) 81 | session = mocker.create_session() 82 | api_client = await get_api_client("", "test_key", session) 83 | 84 | metrics = await api_client.query_metrics() 85 | 86 | assert metrics.memory_free == 415510528 87 | assert metrics.memory_total == 16646950912 88 | assert metrics.memory_active == 12746354688 89 | assert metrics.memory_percent_total == 76.56870471583932 90 | assert metrics.memory_available == 3900596224 91 | assert metrics.cpu_percent_total == 5.1 92 | 93 | 94 | @pytest.mark.parametrize("api_responses", API_RESPONSES) 95 | async def test_shares( 96 | api_responses: GraphqlResponses, 97 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 98 | ) -> None: 99 | """Test querying share info.""" 100 | mocker = await mock_graphql_server(api_responses) 101 | session = mocker.create_session() 102 | api_client = await get_api_client("", "test_key", session) 103 | 104 | shares = await api_client.query_shares() 105 | 106 | assert shares[0].name == "Share_1" 107 | assert shares[0].free == 523094721 108 | assert shares[0].used == 11474981429 109 | assert shares[0].size == 0 110 | assert shares[0].allocator == "highwater" 111 | assert shares[0].floor == "20000000" 112 | 113 | assert shares[1].name == "Share_2" 114 | assert shares[1].free == 503491121 115 | assert shares[1].used == 5615496143 116 | assert shares[1].size == 0 117 | assert shares[1].allocator == "highwater" 118 | assert shares[1].floor == "0" 119 | 120 | 121 | @pytest.mark.parametrize("api_responses", API_RESPONSES) 122 | async def test_disks( 123 | api_responses: GraphqlResponses, 124 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 125 | ) -> None: 126 | """Test querying disk info.""" 127 | mocker = await mock_graphql_server(api_responses) 128 | session = mocker.create_session() 129 | api_client = await get_api_client("", "test_key", session) 130 | 131 | disks = await api_client.query_disks() 132 | 133 | assert disks[0].name == "disk1" 134 | assert disks[0].status == DiskStatus.DISK_OK 135 | assert disks[0].temp == 34 136 | assert disks[0].fs_size == 5999038075 137 | assert disks[0].fs_free == 464583438 138 | assert disks[0].fs_used == 5534454637 139 | assert disks[0].type == DiskType.Data 140 | assert disks[0].id == "c6b" 141 | assert disks[0].is_spinning is True 142 | 143 | assert disks[1].name == "cache" 144 | assert disks[1].status == DiskStatus.DISK_OK 145 | assert disks[1].temp == 30 146 | assert disks[1].fs_size == 119949189 147 | assert disks[1].fs_free == 38907683 148 | assert disks[1].fs_used == 81041506 149 | assert disks[1].type == DiskType.Cache 150 | assert disks[1].id == "8e0" 151 | assert disks[1].is_spinning is True 152 | 153 | assert disks[2].name == "parity" 154 | assert disks[2].status == DiskStatus.DISK_OK 155 | assert disks[2].temp is None 156 | assert disks[2].fs_size is None 157 | assert disks[2].fs_free is None 158 | assert disks[2].fs_used is None 159 | assert disks[2].type == DiskType.Parity 160 | assert disks[2].id == "4d5" 161 | assert disks[2].is_spinning is False 162 | 163 | 164 | @pytest.mark.parametrize("api_responses", API_RESPONSES) 165 | async def test_array( 166 | api_responses: GraphqlResponses, 167 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 168 | ) -> None: 169 | """Test querying array info.""" 170 | mocker = await mock_graphql_server(api_responses) 171 | session = mocker.create_session() 172 | api_client = await get_api_client("", "test_key", session) 173 | 174 | array = await api_client.query_array() 175 | 176 | assert array.state == ArrayState.STARTED 177 | assert array.capacity_free == 523094720 178 | assert array.capacity_used == 11474981430 179 | assert array.capacity_total == 11998076150 180 | -------------------------------------------------------------------------------- /custom_components/unraid_api/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING, Any 7 | 8 | import voluptuous as vol 9 | from aiohttp import ( 10 | ClientConnectionError, 11 | ClientConnectorSSLError, 12 | ContentTypeError, 13 | InvalidUrlClientError, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlowWithReload 16 | from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL 17 | from homeassistant.core import callback 18 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 19 | from homeassistant.helpers.selector import BooleanSelector 20 | from homeassistant.helpers.typing import UNDEFINED, UndefinedType 21 | 22 | from . import UnraidConfigEntry 23 | from .api import IncompatibleApiError, UnraidAuthError, UnraidGraphQLError, get_api_client 24 | from .const import CONF_DRIVES, CONF_SHARES, DOMAIN 25 | 26 | if TYPE_CHECKING: 27 | from homeassistant.config_entries import ConfigFlowResult 28 | 29 | from . import UnraidConfigEntry 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | USER_DATA_SCHEMA = vol.Schema( 34 | { 35 | vol.Required(CONF_HOST): str, 36 | vol.Required(CONF_API_KEY): str, 37 | vol.Optional(CONF_VERIFY_SSL, default=True): bool, 38 | } 39 | ) 40 | REAUTH_DATA_SCHEMA = vol.Schema( 41 | { 42 | vol.Required(CONF_API_KEY): str, 43 | } 44 | ) 45 | 46 | OPTIONS_SCHEMA = vol.Schema( 47 | { 48 | vol.Required(CONF_DRIVES, default=True): BooleanSelector(), 49 | vol.Required(CONF_SHARES, default=True): BooleanSelector(), 50 | } 51 | ) 52 | 53 | 54 | class UnraidConfigFlow(ConfigFlow, domain=DOMAIN): 55 | """Unraid Config flow.""" 56 | 57 | def __init__(self) -> None: 58 | super().__init__() 59 | self.errors = {} 60 | self.data = {} 61 | self.description_placeholders = {} 62 | self.title = "" 63 | self.reauth_entry: UnraidConfigEntry = None 64 | 65 | @staticmethod 66 | @callback 67 | def async_get_options_flow( 68 | config_entry: ConfigEntry, # noqa: ARG004 69 | ) -> UnraidOptionsFlow: 70 | """Create the options flow.""" 71 | return UnraidOptionsFlow() 72 | 73 | async def validate_config(self) -> None: 74 | try: 75 | api_client = await get_api_client( 76 | self.data[CONF_HOST], 77 | self.data[CONF_API_KEY], 78 | async_get_clientsession(self.hass, self.data[CONF_VERIFY_SSL]), 79 | ) 80 | response = await api_client.query_server_info() 81 | self.title = response.name 82 | except ClientConnectorSSLError: 83 | _LOGGER.exception("SSL error") 84 | self.errors = {"base": "ssl_error"} 85 | except (ClientConnectionError, TimeoutError, ContentTypeError): 86 | _LOGGER.exception("Connection error") 87 | self.errors = {"base": "cannot_connect"} 88 | except UnraidAuthError: 89 | _LOGGER.exception("Auth failed") 90 | self.errors = {"base": "auth_failed"} 91 | except UnraidGraphQLError as exc: 92 | _LOGGER.exception("GraphQL Error response: %s", exc.response) 93 | self.errors = {"base": "error_response"} 94 | self.description_placeholders["error_msg"] = exc.args[0] 95 | except InvalidUrlClientError: 96 | self.errors = {"base": "invalid_url"} 97 | except IncompatibleApiError as exc: 98 | _LOGGER.exception("Incompatible API, %s < %s", exc.version, exc.min_version) 99 | self.errors = {"base": "api_incompatible"} 100 | self.description_placeholders["min_version"] = exc.min_version 101 | self.description_placeholders["version"] = exc.version 102 | 103 | async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 104 | """Handle a flow initialized by the user.""" 105 | if user_input is not None: 106 | self.data[CONF_HOST] = user_input[CONF_HOST].rstrip("/") 107 | self.data[CONF_API_KEY] = user_input[CONF_API_KEY] 108 | self.data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] 109 | await self.validate_config() 110 | if not self.errors: 111 | return await self.async_step_options() 112 | schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) 113 | return self.async_show_form( 114 | step_id="user", 115 | data_schema=schema, 116 | errors=self.errors, 117 | description_placeholders=self.description_placeholders, 118 | ) 119 | 120 | async def async_step_options( 121 | self, user_input: dict[str, Any] | None = None 122 | ) -> ConfigFlowResult: 123 | if user_input is not None: 124 | return await self.async_step_create_entry(self.data, user_input) 125 | return self.async_show_form( 126 | step_id="options", 127 | data_schema=OPTIONS_SCHEMA, 128 | errors=self.errors, 129 | description_placeholders=self.description_placeholders, 130 | ) 131 | 132 | async def async_step_reauth(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 133 | """Reauth flow initialized.""" 134 | self.reauth_entry = self._get_reauth_entry() 135 | self.data = dict(self.reauth_entry.data) 136 | return await self.async_step_reauth_key() 137 | 138 | async def async_step_reauth_key( 139 | self, user_input: dict[str, Any] | None = None 140 | ) -> ConfigFlowResult: 141 | if user_input is not None: 142 | self.data[CONF_API_KEY] = user_input[CONF_API_KEY] 143 | await self.validate_config() 144 | if not self.errors: 145 | return await self.async_step_create_entry(self.data) 146 | 147 | schema = self.add_suggested_values_to_schema(REAUTH_DATA_SCHEMA, self.data) 148 | return self.async_show_form( 149 | step_id="reauth_key", 150 | data_schema=schema, 151 | errors=self.errors, 152 | description_placeholders=self.description_placeholders, 153 | ) 154 | 155 | async def async_step_create_entry( 156 | self, data: dict | UndefinedType = UNDEFINED, options: dict | UndefinedType = UNDEFINED 157 | ) -> ConfigFlowResult: 158 | """Create an config entry or update existing entry for reauth.""" 159 | if self.reauth_entry: 160 | return self.async_update_reload_and_abort( 161 | self.reauth_entry, 162 | data_updates=data, 163 | options=options, 164 | ) 165 | return self.async_create_entry(title=self.title, data=data, options=options) 166 | 167 | 168 | class UnraidOptionsFlow(OptionsFlowWithReload): 169 | """Unraid Options Flow.""" 170 | 171 | async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 172 | """Manage the Unraid options.""" 173 | if user_input is not None: 174 | return self.async_create_entry(data=user_input) 175 | 176 | schema = self.add_suggested_values_to_schema( 177 | OPTIONS_SCHEMA, 178 | self.config_entry.options, 179 | ) 180 | return self.async_show_form(step_id="init", data_schema=schema) 181 | -------------------------------------------------------------------------------- /custom_components/unraid_api/api/v4_20.py: -------------------------------------------------------------------------------- 1 | """Unraid GraphQL API Client for Api >= 4.20.""" 2 | 3 | from __future__ import annotations 4 | 5 | from awesomeversion import AwesomeVersion 6 | from pydantic import BaseModel, Field 7 | 8 | from custom_components.unraid_api.models import ( 9 | Array, 10 | ArrayState, 11 | Disk, 12 | DiskStatus, 13 | DiskType, 14 | Metrics, 15 | ServerInfo, 16 | Share, 17 | ) 18 | 19 | from . import UnraidApiClient 20 | 21 | 22 | class UnraidApiV420(UnraidApiClient): 23 | """ 24 | Unraid GraphQL API Client. 25 | 26 | Api version > 4.20 27 | """ 28 | 29 | version = AwesomeVersion("4.20.0") 30 | 31 | async def query_server_info(self) -> ServerInfo: 32 | response = await self.call_api(SERVER_INFO_QUERY, ServerInfoQuery) 33 | return ServerInfo( 34 | localurl=response.server.localurl, 35 | name=response.server.name, 36 | unraid_version=response.info.versions.core.unraid, 37 | ) 38 | 39 | async def query_metrics(self) -> Metrics: 40 | response = await self.call_api(METRICS_QUERY, MetricsQuery) 41 | return Metrics( 42 | memory_free=response.metrics.memory.free, 43 | memory_total=response.metrics.memory.total, 44 | memory_active=response.metrics.memory.active, 45 | memory_available=response.metrics.memory.available, 46 | memory_percent_total=response.metrics.memory.percent_total, 47 | cpu_percent_total=response.metrics.cpu.percent_total, 48 | ) 49 | 50 | async def query_shares(self) -> list[Share]: 51 | response = await self.call_api(SHARES_QUERY, SharesQuery) 52 | return [ 53 | Share( 54 | name=share.name, 55 | free=share.free, 56 | used=share.used, 57 | size=share.size, 58 | allocator=share.allocator, 59 | floor=share.floor, 60 | ) 61 | for share in response.shares 62 | ] 63 | 64 | async def query_disks(self) -> list[Disk]: 65 | response = await self.call_api(DISKS_QUERY, DiskQuery) 66 | disks = [ 67 | Disk( 68 | name=disk.name, 69 | status=disk.status, 70 | temp=disk.temp, 71 | fs_size=disk.fs_size, 72 | fs_free=disk.fs_free, 73 | fs_used=disk.fs_used, 74 | type=disk.type, 75 | id=disk.id, 76 | is_spinning=disk.is_spinning, 77 | ) 78 | for disk in response.array.disks 79 | ] 80 | disks.extend( 81 | [ 82 | Disk( 83 | name=disk.name, 84 | status=disk.status, 85 | temp=disk.temp, 86 | fs_size=disk.fs_size, 87 | fs_free=disk.fs_free, 88 | fs_used=disk.fs_used, 89 | type=disk.type, 90 | id=disk.id, 91 | is_spinning=disk.is_spinning, 92 | ) 93 | for disk in response.array.caches 94 | ] 95 | ) 96 | disks.extend( 97 | [ 98 | Disk( 99 | name=disk.name, 100 | status=disk.status, 101 | temp=disk.temp, 102 | fs_size=None, 103 | fs_free=None, 104 | fs_used=None, 105 | type=disk.type, 106 | id=disk.id, 107 | is_spinning=disk.is_spinning, 108 | ) 109 | for disk in response.array.parities 110 | ] 111 | ) 112 | return disks 113 | 114 | async def query_array(self) -> Array: 115 | response = await self.call_api(ARRAY_QUERY, ArrayQuery) 116 | return Array( 117 | state=response.array.state, 118 | capacity_free=response.array.capacity.kilobytes.free, 119 | capacity_used=response.array.capacity.kilobytes.used, 120 | capacity_total=response.array.capacity.kilobytes.total, 121 | ) 122 | 123 | 124 | ## Queries 125 | 126 | SERVER_INFO_QUERY = """ 127 | query ServerInfo { 128 | server { 129 | localurl 130 | name 131 | } 132 | info { 133 | versions { 134 | core { 135 | unraid 136 | } 137 | } 138 | } 139 | } 140 | """ 141 | 142 | METRICS_QUERY = """ 143 | query Metrics { 144 | metrics { 145 | memory { 146 | free 147 | total 148 | percentTotal 149 | active 150 | available 151 | } 152 | cpu { 153 | percentTotal 154 | } 155 | } 156 | } 157 | """ 158 | 159 | SHARES_QUERY = """ 160 | query Shares { 161 | shares { 162 | name 163 | free 164 | used 165 | size 166 | allocator 167 | floor 168 | } 169 | } 170 | """ 171 | 172 | DISKS_QUERY = """ 173 | query Disks { 174 | array { 175 | caches { 176 | name 177 | status 178 | temp 179 | fsSize 180 | fsFree 181 | fsUsed 182 | type 183 | id 184 | isSpinning 185 | } 186 | disks { 187 | name 188 | status 189 | temp 190 | fsSize 191 | fsFree 192 | fsUsed 193 | fsType 194 | type 195 | id 196 | isSpinning 197 | } 198 | parities { 199 | name 200 | status 201 | temp 202 | type 203 | id 204 | isSpinning 205 | } 206 | } 207 | } 208 | """ 209 | 210 | ARRAY_QUERY = """ 211 | query Array { 212 | array { 213 | state 214 | capacity { 215 | kilobytes { 216 | free 217 | used 218 | total 219 | } 220 | } 221 | } 222 | } 223 | 224 | """ 225 | 226 | ## Api Models 227 | 228 | 229 | ### Server Info 230 | class ServerInfoQuery(BaseModel): # noqa: D101 231 | server: Server 232 | info: Info 233 | 234 | 235 | class Server(BaseModel): # noqa: D101 236 | localurl: str 237 | name: str 238 | 239 | 240 | class Info(BaseModel): # noqa: D101 241 | versions: InfoVersions 242 | 243 | 244 | class InfoVersions(BaseModel): # noqa: D101 245 | core: InfoVersionsCore 246 | 247 | 248 | class InfoVersionsCore(BaseModel): # noqa: D101 249 | unraid: str 250 | 251 | 252 | ### Metrics 253 | class MetricsQuery(BaseModel): # noqa: D101 254 | metrics: _Metrics 255 | 256 | 257 | class _Metrics(BaseModel): 258 | memory: MetricsMemory 259 | cpu: MetricsCpu 260 | 261 | 262 | class MetricsMemory(BaseModel): # noqa: D101 263 | free: int 264 | total: int 265 | active: int 266 | percent_total: float = Field(alias="percentTotal") 267 | available: int 268 | 269 | 270 | class MetricsCpu(BaseModel): # noqa: D101 271 | percent_total: float = Field(alias="percentTotal") 272 | 273 | 274 | ### Shares 275 | class SharesQuery(BaseModel): # noqa: D101 276 | shares: list[_Share] 277 | 278 | 279 | class _Share(BaseModel): 280 | name: str 281 | free: int 282 | used: int 283 | size: int 284 | allocator: str 285 | floor: str 286 | 287 | 288 | ### Disks 289 | class DiskQuery(BaseModel): # noqa: D101 290 | array: DisksArray 291 | 292 | 293 | class DisksArray(BaseModel): # noqa: D101 294 | disks: list[FSDisk] 295 | parities: list[ParityDisk] 296 | caches: list[FSDisk] 297 | 298 | 299 | class ParityDisk(BaseModel): # noqa: D101 300 | name: str 301 | status: DiskStatus 302 | temp: int | None 303 | type: DiskType 304 | id: str 305 | is_spinning: bool = Field(alias="isSpinning") 306 | 307 | 308 | class FSDisk(ParityDisk): # noqa: D101 309 | fs_size: int | None = Field(alias="fsSize") 310 | fs_free: int | None = Field(alias="fsFree") 311 | fs_used: int | None = Field(alias="fsUsed") 312 | 313 | 314 | ### Array 315 | class ArrayQuery(BaseModel): # noqa: D101 316 | array: _Array 317 | 318 | 319 | class _Array(BaseModel): 320 | state: ArrayState 321 | capacity: ArrayCapacity 322 | 323 | 324 | class ArrayCapacity(BaseModel): # noqa: D101 325 | kilobytes: ArrayCapacityKilobytes 326 | 327 | 328 | class ArrayCapacityKilobytes(BaseModel): # noqa: D101 329 | free: int 330 | used: int 331 | total: int 332 | -------------------------------------------------------------------------------- /custom_components/unraid_api/coordinator.py: -------------------------------------------------------------------------------- 1 | """Unraid update coordinator.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from datetime import timedelta 8 | from typing import TYPE_CHECKING, Any, TypedDict 9 | 10 | from aiohttp import ClientConnectionError, ClientConnectorSSLError 11 | from awesomeversion import AwesomeVersion 12 | from homeassistant.exceptions import ConfigEntryAuthFailed 13 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 14 | from pydantic_core import ValidationError 15 | 16 | from .api import IncompatibleApiError, UnraidAuthError, UnraidGraphQLError 17 | from .const import CONF_DRIVES, CONF_SHARES, DOMAIN 18 | 19 | if TYPE_CHECKING: 20 | from collections.abc import Callable 21 | 22 | from homeassistant.core import HomeAssistant 23 | 24 | from . import UnraidConfigEntry 25 | from .api import UnraidApiClient 26 | from .models import Array, Disk, Metrics, Share, UpsDevice 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | class UnraidServerData(TypedDict): # noqa: D101 32 | metrics: Metrics | None 33 | array: Array | None 34 | disks: dict[str, Disk] 35 | shares: dict[str, Share] 36 | ups_devices: dict[str, UpsDevice] 37 | 38 | 39 | class UnraidDataUpdateCoordinator(DataUpdateCoordinator[UnraidServerData]): 40 | """Update Coordinator.""" 41 | 42 | known_disks: set[str] 43 | known_shares: set[str] 44 | known_ups_devices: set[str] 45 | config_entry: UnraidConfigEntry 46 | 47 | def __init__( 48 | self, hass: HomeAssistant, config_entry: UnraidConfigEntry, api_client: UnraidApiClient 49 | ) -> None: 50 | super().__init__( 51 | hass, 52 | logger=_LOGGER, 53 | config_entry=config_entry, 54 | name=DOMAIN, 55 | update_interval=timedelta(minutes=1), 56 | ) 57 | self.api_client = api_client 58 | self.disk_callbacks: set[Callable[[Disk], None]] = set() 59 | self.share_callbacks: set[Callable[[Share], None]] = set() 60 | self.ups_callbacks: set[Callable[[UpsDevice], None]] = set() 61 | 62 | async def _async_setup(self) -> None: 63 | self.known_disks: set[str] = set() 64 | self.known_shares: set[str] = set() 65 | self.known_ups_devices: set[str] = set() 66 | 67 | async def _async_update_data(self) -> UnraidServerData: 68 | data = UnraidServerData() 69 | try: 70 | async with asyncio.TaskGroup() as tg: 71 | tg.create_task(self._update_metrics(data)) 72 | tg.create_task(self._update_array(data)) 73 | if self.config_entry.options[CONF_DRIVES]: 74 | tg.create_task(self._update_disks(data)) 75 | if self.config_entry.options[CONF_SHARES]: 76 | tg.create_task(self._update_shares(data)) 77 | if self.api_client.version >= AwesomeVersion("4.26.0"): 78 | tg.create_task(self._update_ups(data)) 79 | 80 | except* ClientConnectorSSLError as exc: 81 | _LOGGER.debug("Update: SSL error: %s", str(exc)) 82 | raise UpdateFailed( 83 | translation_domain=DOMAIN, 84 | translation_key="ssl_error", 85 | translation_placeholders={"error": str(exc)}, 86 | ) from exc 87 | except* ( 88 | ClientConnectionError, 89 | TimeoutError, 90 | ) as exc: 91 | _LOGGER.debug("Update: Connection error: %s", str(exc)) 92 | raise UpdateFailed( 93 | translation_domain=DOMAIN, 94 | translation_key="cannot_connect", 95 | translation_placeholders={"error": str(exc)}, 96 | ) from exc 97 | except* UnraidAuthError as exc: 98 | _LOGGER.debug("Update: Auth failed") 99 | raise ConfigEntryAuthFailed( 100 | translation_domain=DOMAIN, 101 | translation_key="auth_failed", 102 | translation_placeholders={"error_msg": exc.args[0]}, 103 | ) from exc 104 | except* UnraidGraphQLError as exc: 105 | _LOGGER.debug("Update: GraphQL Error response: %s", exc.exceptions[0].response) 106 | raise UpdateFailed( 107 | translation_domain=DOMAIN, 108 | translation_key="error_response", 109 | translation_placeholders={"error_msg": exc.exceptions[0].args[0]}, 110 | ) from exc 111 | except* ValidationError as exc: 112 | _LOGGER.debug("Update: invalid data") 113 | raise UpdateFailed( 114 | translation_domain=DOMAIN, 115 | translation_key="data_invalid", 116 | ) from exc 117 | except* IncompatibleApiError as exc: 118 | _LOGGER.debug( 119 | "Update: Incompatible API, %s < %s", 120 | exc.exceptions[0].version, 121 | exc.exceptions[0].min_version, 122 | ) 123 | raise UpdateFailed( 124 | translation_domain=DOMAIN, 125 | translation_key="api_incompatible", 126 | translation_placeholders={ 127 | "min_version": exc.exceptions[0].min_version, 128 | "version": exc.exceptions[0].version, 129 | }, 130 | ) from exc 131 | 132 | return data 133 | 134 | async def _update_metrics(self, data: UnraidServerData) -> None: 135 | data["metrics"] = await self.api_client.query_metrics() 136 | 137 | async def _update_array(self, data: UnraidServerData) -> None: 138 | data["array"] = await self.api_client.query_array() 139 | 140 | async def _update_disks(self, data: UnraidServerData) -> None: 141 | disks = {} 142 | query_response = await self.api_client.query_disks() 143 | 144 | for disk in query_response: 145 | disks[disk.id] = disk 146 | if disk.id not in self.known_disks: 147 | self.known_disks.add(disk.id) 148 | self._do_callback(self.disk_callbacks, disk) 149 | data["disks"] = disks 150 | 151 | async def _update_shares(self, data: UnraidServerData) -> None: 152 | shares = {} 153 | query_response = await self.api_client.query_shares() 154 | 155 | for share in query_response: 156 | shares[share.name] = share 157 | if share.name not in self.known_shares: 158 | self.known_shares.add(share.name) 159 | self._do_callback(self.share_callbacks, share) 160 | data["shares"] = shares 161 | 162 | async def _update_ups(self, data: UnraidServerData) -> None: 163 | devices = {} 164 | try: 165 | query_response = await self.api_client.query_ups() 166 | 167 | for device in query_response: 168 | devices[device.id] = device 169 | if device.id not in self.known_ups_devices: 170 | self.known_ups_devices.add(device.id) 171 | self._do_callback(self.ups_callbacks, device) 172 | except UnraidGraphQLError: 173 | pass 174 | 175 | data["ups_devices"] = devices 176 | 177 | def subscribe_disks(self, callback: Callable[[Disk], None]) -> None: 178 | self.disk_callbacks.add(callback) 179 | for disk_id in self.known_disks: 180 | self._do_callback([callback], self.data["disks"][disk_id]) 181 | 182 | def subscribe_shares(self, callback: Callable[[Share], None]) -> None: 183 | self.share_callbacks.add(callback) 184 | for share_name in self.known_shares: 185 | self._do_callback([callback], self.data["shares"][share_name]) 186 | 187 | def subscribe_ups(self, callback: Callable[[UpsDevice], None]) -> None: 188 | self.ups_callbacks.add(callback) 189 | for ups_id in self.known_ups_devices: 190 | self._do_callback([callback], self.data["ups_devices"][ups_id]) 191 | 192 | def _do_callback( 193 | self, callbacks: set[Callable[..., None]], *args: tuple[Any], **kwargs: dict[Any] 194 | ) -> None: 195 | for callback in callbacks: 196 | try: 197 | callback(*args, **kwargs) 198 | except Exception: 199 | _LOGGER.exception("Error in callback") 200 | -------------------------------------------------------------------------------- /tests/graphql_responses.py: -------------------------------------------------------------------------------- 1 | """GraphQL API responses for Tests.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import ClassVar 6 | 7 | from awesomeversion import AwesomeVersion 8 | 9 | 10 | class GraphqlResponses: 11 | """Graphql Responses Baseclass.""" 12 | 13 | version = AwesomeVersion("4.20.0") 14 | api_version: ClassVar[dict] 15 | server_info: ClassVar[dict] 16 | metrics: ClassVar[dict] 17 | shares: ClassVar[dict] 18 | disks: ClassVar[dict] 19 | array: ClassVar[dict] 20 | ups: ClassVar[dict] 21 | 22 | is_unauthenticated = False 23 | unauthenticated: ClassVar[dict] = { 24 | "errors": [ 25 | { 26 | "message": "API key validation failed", 27 | "locations": [{"line": 3, "column": 3}], 28 | "path": ["info"], 29 | "extensions": { 30 | "code": "UNAUTHENTICATED", 31 | "originalError": { 32 | "message": "API key validation failed", 33 | "error": "Unauthorized", 34 | "statusCode": 401, 35 | }, 36 | }, 37 | } 38 | ], 39 | "data": None, 40 | } 41 | 42 | all_error = False 43 | error: ClassVar[dict] = { 44 | "errors": [ 45 | { 46 | "message": "Internal Server error", 47 | "locations": [{"line": 18, "column": 3}], 48 | "path": ["info"], 49 | "extensions": {"code": "INTERNAL_SERVER_ERROR"}, 50 | } 51 | ], 52 | "data": None, 53 | } 54 | 55 | not_found: ClassVar[dict] = { 56 | "errors": [ 57 | { 58 | "message": "Cannot query field", 59 | "locations": [{"line": 3, "column": 5}], 60 | "extensions": {"code": "GRAPHQL_VALIDATION_FAILED"}, 61 | } 62 | ] 63 | } 64 | 65 | def get_response(self, query: str) -> dict: # noqa: PLR0911 66 | try: 67 | if self.is_unauthenticated: 68 | return self.unauthenticated 69 | if self.all_error: 70 | return self.error 71 | match query: 72 | case "ApiVersion": 73 | return self.api_version 74 | case "ServerInfo": 75 | return self.server_info 76 | case "Metrics": 77 | return self.metrics 78 | case "Shares": 79 | return self.shares 80 | case "Disks": 81 | return self.disks 82 | case "Array": 83 | return self.array 84 | case "UpsDevices": 85 | return self.ups 86 | case _: 87 | return self.not_found 88 | except ArithmeticError: 89 | return self.error 90 | 91 | 92 | class GraphqlResponses420(GraphqlResponses): 93 | """Graphql Responses for version 4.20.""" 94 | 95 | version = AwesomeVersion("4.20.0") 96 | 97 | def __init__(self) -> None: 98 | self.api_version = {"data": {"info": {"versions": {"core": {"api": "4.20.0+196bd52"}}}}} 99 | self.server_info = { 100 | "data": { 101 | "server": {"localurl": "http://1.2.3.4", "name": "Test Server"}, 102 | "info": {"versions": {"core": {"unraid": "7.0.1"}}}, 103 | } 104 | } 105 | self.metrics = { 106 | "data": { 107 | "metrics": { 108 | "memory": { 109 | "free": 415510528, 110 | "total": 16646950912, 111 | "active": 12746354688, 112 | "percentTotal": 76.56870471583932, 113 | "available": 3900596224, 114 | }, 115 | "cpu": {"percentTotal": 5.1}, 116 | } 117 | } 118 | } 119 | 120 | self.shares = { 121 | "data": { 122 | "shares": [ 123 | { 124 | "name": "Share_1", 125 | "free": 523094721, 126 | "used": 11474981429, 127 | "size": 0, 128 | "allocator": "highwater", 129 | "floor": "20000000", 130 | }, 131 | { 132 | "name": "Share_2", 133 | "free": 503491121, 134 | "used": 5615496143, 135 | "size": 0, 136 | "allocator": "highwater", 137 | "floor": "0", 138 | }, 139 | ] 140 | } 141 | } 142 | self.disks = { 143 | "data": { 144 | "array": { 145 | "disks": [ 146 | { 147 | "name": "disk1", 148 | "status": "DISK_OK", 149 | "temp": 34, 150 | "fsSize": 5999038075, 151 | "fsFree": 464583438, 152 | "fsUsed": 5534454637, 153 | "type": "DATA", 154 | "id": "c6b", 155 | "isSpinning": True, 156 | }, 157 | ], 158 | "parities": [ 159 | { 160 | "name": "parity", 161 | "status": "DISK_OK", 162 | "temp": None, 163 | "fsSize": None, 164 | "fsFree": None, 165 | "fsUsed": None, 166 | "type": "PARITY", 167 | "id": "4d5", 168 | "isSpinning": False, 169 | } 170 | ], 171 | "caches": [ 172 | { 173 | "name": "cache", 174 | "status": "DISK_OK", 175 | "temp": 30, 176 | "fsSize": 119949189, 177 | "fsFree": 38907683, 178 | "fsUsed": 81041506, 179 | "type": "CACHE", 180 | "id": "8e0", 181 | "isSpinning": True, 182 | } 183 | ], 184 | } 185 | } 186 | } 187 | self.array = { 188 | "data": { 189 | "array": { 190 | "state": "STARTED", 191 | "capacity": { 192 | "kilobytes": { 193 | "free": "523094720", 194 | "used": "11474981430", 195 | "total": "11998076150", 196 | } 197 | }, 198 | } 199 | } 200 | } 201 | self.not_found = { 202 | "errors": [ 203 | { 204 | "message": "Cannot query field", 205 | "locations": [{"line": 3, "column": 5}], 206 | "extensions": {"code": "GRAPHQL_VALIDATION_FAILED"}, 207 | } 208 | ] 209 | } 210 | 211 | 212 | class GraphqlResponses426(GraphqlResponses420): 213 | """Graphql Responses for version 4.26.""" 214 | 215 | version = AwesomeVersion("4.26.0") 216 | 217 | def __init__(self) -> None: 218 | super().__init__() 219 | self.api_version = {"data": {"info": {"versions": {"core": {"api": "4.26.0"}}}}} 220 | self.metrics = { 221 | "data": { 222 | "metrics": { 223 | "memory": { 224 | "free": 415510528, 225 | "total": 16646950912, 226 | "active": 12746354688, 227 | "percentTotal": 76.56870471583932, 228 | "available": 3900596224, 229 | }, 230 | "cpu": {"percentTotal": 5.1}, 231 | }, 232 | "info": {"cpu": {"packages": {"power": [2.8], "temp": [31]}}}, 233 | } 234 | } 235 | self.ups = { 236 | "data": { 237 | "upsDevices": [ 238 | { 239 | "battery": {"chargeLevel": 100, "estimatedRuntime": 25, "health": "Good"}, 240 | "power": { 241 | "loadPercentage": 20, 242 | "outputVoltage": 120.5, 243 | "inputVoltage": 232, 244 | }, 245 | "model": "Back-UPS ES 650G2", 246 | "name": "Back-UPS ES 650G2", 247 | "status": "ONLINE", 248 | "id": "Back-UPS ES 650G2", 249 | } 250 | ] 251 | } 252 | } 253 | 254 | 255 | class GraphqlResponses410(GraphqlResponses420): 256 | """Graphql Responses for version 4.10 (Incompatible).""" 257 | 258 | version = AwesomeVersion("4.10.0") 259 | 260 | def __init__(self) -> None: 261 | self.api_version = {"data": {"info": {"versions": {"core": {"api": "4.10.0"}}}}} 262 | 263 | 264 | API_RESPONSES = [GraphqlResponses420, GraphqlResponses426] 265 | 266 | API_RESPONSES_LATEST = API_RESPONSES[-1] 267 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for Sensor entities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | from awesomeversion import AwesomeVersion 9 | 10 | from . import setup_config_entry 11 | from .const import MOCK_OPTION_DATA_DISABLED 12 | from .graphql_responses import API_RESPONSES, API_RESPONSES_LATEST, GraphqlResponses 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Awaitable, Callable 16 | 17 | from homeassistant.core import HomeAssistant 18 | 19 | from tests.conftest import GraphqlServerMocker 20 | 21 | 22 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 23 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 24 | async def test_main_sensors( 25 | api_responses: GraphqlResponses, 26 | hass: HomeAssistant, 27 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 28 | ) -> None: 29 | """Test main sensor entities.""" 30 | mocker = await mock_graphql_server(api_responses) 31 | assert await setup_config_entry(hass, mocker) 32 | 33 | # array_state 34 | state = hass.states.get("sensor.test_server_array_state") 35 | assert state.state == "started" 36 | 37 | # array_usage 38 | state = hass.states.get("sensor.test_server_array_usage") 39 | assert state.state == "95.6401783630953" 40 | assert state.attributes["used"] == 11474981430 41 | assert state.attributes["free"] == 523094720 42 | assert state.attributes["total"] == 11998076150 43 | 44 | # array_free 45 | state = hass.states.get("sensor.test_server_array_free_space") 46 | assert state.state == "523.09472" 47 | 48 | # array_used 49 | state = hass.states.get("sensor.test_server_array_used_space") 50 | assert state.state == "11474.98143" 51 | 52 | # ram_usage 53 | state = hass.states.get("sensor.test_server_ram_usage") 54 | assert state.state == "76.5687047158393" 55 | assert state.attributes["used"] == 12746354688 56 | assert state.attributes["free"] == 415510528 57 | assert state.attributes["total"] == 16646950912 58 | assert state.attributes["available"] == 3900596224 59 | 60 | # ram_used 61 | state = hass.states.get("sensor.test_server_ram_used") 62 | assert state.state == "12.746354688" 63 | 64 | # ram_free 65 | state = hass.states.get("sensor.test_server_ram_free") 66 | assert state.state == "0.415510528" 67 | 68 | # cpu_utilization 69 | state = hass.states.get("sensor.test_server_cpu_utilization") 70 | assert state.state == "5.1" 71 | 72 | if api_responses.version >= AwesomeVersion("4.26.0"): 73 | # cpu_temp 74 | state = hass.states.get("sensor.test_server_cpu_temperature") 75 | assert state.state == "31.0" 76 | # cpu_power 77 | state = hass.states.get("sensor.test_server_cpu_power") 78 | assert state.state == "2.8" 79 | else: 80 | # cpu_temp 81 | state = hass.states.get("sensor.test_server_cpu_temperature") 82 | assert state is None 83 | 84 | # cpu_power 85 | state = hass.states.get("sensor.test_server_cpu_power") 86 | assert state is None 87 | 88 | 89 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 90 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 91 | async def test_disk_sensors( 92 | api_responses: GraphqlResponses, 93 | hass: HomeAssistant, 94 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 95 | ) -> None: 96 | """Test disk sensor entities.""" 97 | mocker = await mock_graphql_server(api_responses) 98 | assert await setup_config_entry(hass, mocker) 99 | 100 | # disk_status 101 | state = hass.states.get("sensor.test_server_parity_status") 102 | assert state.state == "disk_ok" 103 | state = hass.states.get("sensor.test_server_disk1_status") 104 | assert state.state == "disk_ok" 105 | state = hass.states.get("sensor.test_server_cache_status") 106 | assert state.state == "disk_ok" 107 | 108 | # disk_temp 109 | state = hass.states.get("sensor.test_server_parity_temperature") 110 | assert state.state == "unknown" 111 | state = hass.states.get("sensor.test_server_disk1_temperature") 112 | assert state.state == "34" 113 | state = hass.states.get("sensor.test_server_cache_temperature") 114 | assert state.state == "30" 115 | 116 | # disk_usage 117 | state = hass.states.get("sensor.test_server_parity_usage") 118 | assert state is None 119 | state = hass.states.get("sensor.test_server_disk1_usage") 120 | assert state.state == "92.2557011275512" 121 | state = hass.states.get("sensor.test_server_cache_usage") 122 | assert state.state == "67.5631962797181" 123 | 124 | # disk_free 125 | state = hass.states.get("sensor.test_server_parity_free_space") 126 | assert state is None 127 | state = hass.states.get("sensor.test_server_disk1_free_space") 128 | assert state.state == "464.583438" 129 | state = hass.states.get("sensor.test_server_cache_free_space") 130 | assert state.state == "38.907683" 131 | 132 | # disk_used 133 | state = hass.states.get("sensor.test_server_parity_used_space") 134 | assert state is None 135 | state = hass.states.get("sensor.test_server_disk1_used_space") 136 | assert state.state == "5534.454637" 137 | state = hass.states.get("sensor.test_server_cache_used_space") 138 | assert state.state == "81.041506" 139 | 140 | 141 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 142 | async def test_disk_sensors_disabled( 143 | hass: HomeAssistant, 144 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 145 | ) -> None: 146 | """Test disk sensor disabled.""" 147 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 148 | assert await setup_config_entry(hass, mocker, options=MOCK_OPTION_DATA_DISABLED) 149 | 150 | state = hass.states.get("sensor.test_server_parity_status") 151 | assert state is None 152 | 153 | state = hass.states.get("sensor.test_server_disk1_status") 154 | assert state is None 155 | 156 | state = hass.states.get("sensor.test_server_cache_status") 157 | assert state is None 158 | 159 | 160 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 161 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 162 | async def test_share_sensors( 163 | api_responses: GraphqlResponses, 164 | hass: HomeAssistant, 165 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 166 | ) -> None: 167 | """Test share sensor entities.""" 168 | mocker = await mock_graphql_server(api_responses) 169 | assert await setup_config_entry(hass, mocker) 170 | 171 | # share_free 172 | state = hass.states.get("sensor.test_server_share_1_free_space") 173 | assert state.state == "523.094721" 174 | assert state.attributes["used"] == 11474981429 175 | assert state.attributes["total"] == 0 176 | assert state.attributes["allocator"] == "highwater" 177 | assert state.attributes["floor"] == "20000000" 178 | 179 | state = hass.states.get("sensor.test_server_share_2_free_space") 180 | assert state.state == "503.491121" 181 | assert state.attributes["used"] == 5615496143 182 | assert state.attributes["total"] == 0 183 | assert state.attributes["allocator"] == "highwater" 184 | assert state.attributes["floor"] == "0" 185 | 186 | 187 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 188 | async def test_share_sensors_disabled( 189 | hass: HomeAssistant, 190 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 191 | ) -> None: 192 | """Test share sensor disabled.""" 193 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 194 | assert await setup_config_entry(hass, mocker, options=MOCK_OPTION_DATA_DISABLED) 195 | 196 | state = hass.states.get("sensor.test_server_share_1_free_space") 197 | assert state is None 198 | 199 | state = hass.states.get("sensor.test_server_share_2_free_space") 200 | assert state is None 201 | 202 | 203 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 204 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 205 | async def test_ups_sensors( 206 | api_responses: GraphqlResponses, 207 | hass: HomeAssistant, 208 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 209 | ) -> None: 210 | """Test main sensor entities.""" 211 | mocker = await mock_graphql_server(api_responses) 212 | assert await setup_config_entry(hass, mocker) 213 | if api_responses.version >= AwesomeVersion("4.26.0"): 214 | # ups_status 215 | state = hass.states.get("sensor.back_ups_es_650g2_status") 216 | assert state.state == "ONLINE" 217 | 218 | # ups_level 219 | state = hass.states.get("sensor.back_ups_es_650g2_level") 220 | assert state.state == "100" 221 | 222 | # ups_runtime 223 | state = hass.states.get("sensor.back_ups_es_650g2_runtime") 224 | assert state.state == "0.416666666666667" 225 | 226 | # ups_health 227 | state = hass.states.get("sensor.back_ups_es_650g2_health") 228 | assert state.state == "Good" 229 | 230 | # ups_load 231 | state = hass.states.get("sensor.back_ups_es_650g2_load") 232 | assert state.state == "20.0" 233 | 234 | # ups_input_voltage 235 | state = hass.states.get("sensor.back_ups_es_650g2_input_voltage") 236 | assert state.state == "232.0" 237 | 238 | # ups_output_voltage 239 | state = hass.states.get("sensor.back_ups_es_650g2_output_voltage") 240 | assert state.state == "120.5" 241 | 242 | else: 243 | # ups_status 244 | state = hass.states.get("sensor.back_ups_es_650g2_status") 245 | assert state is None 246 | 247 | # ups_level 248 | state = hass.states.get("sensor.back_ups_es_650g2_level") 249 | assert state is None 250 | 251 | # ups_runtime 252 | state = hass.states.get("sensor.back_ups_es_650g2_runtime") 253 | assert state is None 254 | 255 | # ups_health 256 | state = hass.states.get("sensor.back_ups_es_650g2_health") 257 | assert state is None 258 | 259 | # ups_load 260 | state = hass.states.get("sensor.back_ups_es_650g2_load") 261 | assert state is None 262 | 263 | # ups_input_voltage 264 | state = hass.states.get("sensor.back_ups_es_650g2_input_voltage") 265 | assert state is None 266 | 267 | # ups_output_voltage 268 | state = hass.states.get("sensor.back_ups_es_650g2_output_voltage") 269 | assert state is None 270 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for config flow.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | from unittest.mock import AsyncMock, MagicMock 7 | 8 | import pytest 9 | from aiohttp import ClientConnectionError, ClientConnectorSSLError 10 | from custom_components.unraid_api.api import UnraidApiClient 11 | from custom_components.unraid_api.const import CONF_DRIVES, CONF_SHARES, DOMAIN 12 | from homeassistant.config_entries import SOURCE_USER 13 | from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL 14 | from homeassistant.data_entry_flow import FlowResultType 15 | 16 | from . import add_config_entry 17 | from .graphql_responses import ( 18 | API_RESPONSES, 19 | API_RESPONSES_LATEST, 20 | GraphqlResponses, 21 | GraphqlResponses410, 22 | ) 23 | 24 | if TYPE_CHECKING: 25 | from collections.abc import Awaitable, Callable 26 | 27 | from homeassistant.core import HomeAssistant 28 | 29 | from tests.conftest import GraphqlServerMocker 30 | 31 | 32 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 33 | async def test_user_init( 34 | api_responses: GraphqlResponses, 35 | hass: HomeAssistant, 36 | mock_setup_entry: AsyncMock, 37 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 38 | ) -> None: 39 | """Test config flow.""" 40 | mocker = await mock_graphql_server(api_responses) 41 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 42 | 43 | assert result["type"] is FlowResultType.FORM 44 | assert result["step_id"] == "user" 45 | assert not result["errors"] 46 | 47 | result = await hass.config_entries.flow.async_configure( 48 | result["flow_id"], 49 | user_input={CONF_HOST: mocker.host, CONF_API_KEY: "test_key", CONF_VERIFY_SSL: False}, 50 | ) 51 | 52 | assert result["type"] is FlowResultType.FORM 53 | assert result["step_id"] == "options" 54 | assert not result["errors"] 55 | 56 | result = await hass.config_entries.flow.async_configure( 57 | result["flow_id"], 58 | user_input={CONF_SHARES: True, CONF_DRIVES: True}, 59 | ) 60 | 61 | assert result["type"] is FlowResultType.CREATE_ENTRY 62 | assert result["title"] == "Test Server" 63 | assert result["data"][CONF_API_KEY] == "test_key" 64 | assert result["data"][CONF_HOST] == mocker.host 65 | assert result["data"][CONF_VERIFY_SSL] is False 66 | assert result["options"][CONF_SHARES] is True 67 | assert result["options"][CONF_DRIVES] is True 68 | 69 | mock_setup_entry.assert_awaited_once() 70 | 71 | 72 | async def test_user_error_response( 73 | hass: HomeAssistant, 74 | mock_setup_entry: AsyncMock, 75 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 76 | ) -> None: 77 | """Test a config flow flow with GraphQL error response.""" 78 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 79 | mocker.responses.all_error = True 80 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 81 | result = await hass.config_entries.flow.async_configure( 82 | result["flow_id"], 83 | user_input={CONF_HOST: mocker.host, CONF_API_KEY: "test_key", CONF_VERIFY_SSL: False}, 84 | ) 85 | assert result["type"] is FlowResultType.FORM 86 | assert result["step_id"] == "user" 87 | assert result["errors"]["base"] == "error_response" 88 | assert result["description_placeholders"]["error_msg"] == "Internal Server error" 89 | 90 | hass.config_entries.flow.async_abort(result["flow_id"]) 91 | mock_setup_entry.assert_not_awaited() 92 | 93 | 94 | async def test_user_connection_failed_timeout( 95 | hass: HomeAssistant, 96 | mock_setup_entry: AsyncMock, 97 | monkeypatch: pytest.MonkeyPatch, 98 | ) -> None: 99 | """Test a config flow with TimeoutError.""" 100 | monkeypatch.setattr( 101 | UnraidApiClient, "call_api", AsyncMock(side_effect=TimeoutError()), raising=True 102 | ) 103 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 104 | result = await hass.config_entries.flow.async_configure( 105 | result["flow_id"], 106 | user_input={CONF_HOST: "http://1.2.3.4", CONF_API_KEY: "test_key", CONF_VERIFY_SSL: False}, 107 | ) 108 | assert result["type"] is FlowResultType.FORM 109 | assert result["step_id"] == "user" 110 | assert result["errors"]["base"] == "cannot_connect" 111 | 112 | hass.config_entries.flow.async_abort(result["flow_id"]) 113 | mock_setup_entry.assert_not_awaited() 114 | 115 | 116 | async def test_user_connection_failed_connection_error( 117 | hass: HomeAssistant, 118 | mock_setup_entry: AsyncMock, 119 | monkeypatch: pytest.MonkeyPatch, 120 | ) -> None: 121 | """Test a config flow with ClientConnectionError.""" 122 | monkeypatch.setattr( 123 | UnraidApiClient, "call_api", AsyncMock(side_effect=ClientConnectionError()), raising=True 124 | ) 125 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 126 | result = await hass.config_entries.flow.async_configure( 127 | result["flow_id"], 128 | user_input={CONF_HOST: "http://1.2.3.4", CONF_API_KEY: "test_key", CONF_VERIFY_SSL: False}, 129 | ) 130 | assert result["type"] is FlowResultType.FORM 131 | assert result["step_id"] == "user" 132 | assert result["errors"]["base"] == "cannot_connect" 133 | 134 | hass.config_entries.flow.async_abort(result["flow_id"]) 135 | mock_setup_entry.assert_not_awaited() 136 | 137 | 138 | async def test_user_connection_failed_ssl_error( 139 | hass: HomeAssistant, 140 | mock_setup_entry: AsyncMock, 141 | monkeypatch: pytest.MonkeyPatch, 142 | ) -> None: 143 | """Test a config flow with ClientConnectorSSLError.""" 144 | monkeypatch.setattr( 145 | UnraidApiClient, 146 | "call_api", 147 | AsyncMock(side_effect=ClientConnectorSSLError(MagicMock(), MagicMock())), 148 | raising=True, 149 | ) 150 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 151 | result = await hass.config_entries.flow.async_configure( 152 | result["flow_id"], 153 | user_input={CONF_HOST: "http://1.2.3.4", CONF_API_KEY: "test_key", CONF_VERIFY_SSL: False}, 154 | ) 155 | assert result["type"] is FlowResultType.FORM 156 | assert result["step_id"] == "user" 157 | assert result["errors"]["base"] == "ssl_error" 158 | 159 | hass.config_entries.flow.async_abort(result["flow_id"]) 160 | mock_setup_entry.assert_not_awaited() 161 | 162 | 163 | async def test_user_connection_incompatible( 164 | hass: HomeAssistant, 165 | mock_setup_entry: AsyncMock, 166 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 167 | ) -> None: 168 | """Test a config flow with ClientConnectorSSLError.""" 169 | mocker = await mock_graphql_server(GraphqlResponses410) 170 | 171 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 172 | result = await hass.config_entries.flow.async_configure( 173 | result["flow_id"], 174 | user_input={CONF_HOST: mocker.host, CONF_API_KEY: "test_key", CONF_VERIFY_SSL: False}, 175 | ) 176 | assert result["type"] is FlowResultType.FORM 177 | assert result["step_id"] == "user" 178 | assert result["errors"]["base"] == "api_incompatible" 179 | 180 | hass.config_entries.flow.async_abort(result["flow_id"]) 181 | mock_setup_entry.assert_not_awaited() 182 | 183 | 184 | async def test_user_connection_auth_failed( 185 | hass: HomeAssistant, 186 | mock_setup_entry: AsyncMock, 187 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 188 | ) -> None: 189 | """Test a config flow with ClientConnectorSSLError.""" 190 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 191 | mocker.responses.is_unauthenticated = True 192 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 193 | result = await hass.config_entries.flow.async_configure( 194 | result["flow_id"], 195 | user_input={ 196 | CONF_HOST: mocker.host, 197 | CONF_API_KEY: "test_key", 198 | CONF_VERIFY_SSL: False, 199 | }, 200 | ) 201 | assert result["type"] is FlowResultType.FORM 202 | assert result["step_id"] == "user" 203 | assert result["errors"]["base"] == "auth_failed" 204 | 205 | hass.config_entries.flow.async_abort(result["flow_id"]) 206 | mock_setup_entry.assert_not_awaited() 207 | 208 | 209 | @pytest.mark.parametrize(("api_responses"), API_RESPONSES) 210 | async def test_reauth( 211 | api_responses: GraphqlResponses, 212 | hass: HomeAssistant, 213 | mock_setup_entry: AsyncMock, 214 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 215 | ) -> None: 216 | """Test a reauthentication flow.""" 217 | mocker = await mock_graphql_server(api_responses) 218 | mock_config = add_config_entry(hass, mocker) 219 | 220 | result = await mock_config.start_reauth_flow(hass) 221 | assert result["type"] is FlowResultType.FORM 222 | assert result["step_id"] == "reauth_key" 223 | 224 | result = await hass.config_entries.flow.async_configure( 225 | result["flow_id"], 226 | user_input={ 227 | CONF_API_KEY: "new_key", 228 | }, 229 | ) 230 | assert result["type"] is FlowResultType.ABORT 231 | assert result["reason"] == "reauth_successful" 232 | assert mock_config.data[CONF_API_KEY] == "new_key" 233 | assert mock_config.data[CONF_HOST] == mocker.host 234 | assert mock_config.data[CONF_VERIFY_SSL] is False 235 | 236 | await hass.async_block_till_done() 237 | mock_setup_entry.assert_awaited_once() 238 | 239 | 240 | async def test_reauth_error_response( 241 | hass: HomeAssistant, 242 | mock_setup_entry: AsyncMock, 243 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 244 | ) -> None: 245 | """Test a reauthentication flow with GraphQL error response.""" 246 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 247 | mocker.responses.all_error = True 248 | mock_config = add_config_entry(hass, mocker) 249 | 250 | result = await mock_config.start_reauth_flow(hass) 251 | 252 | result = await hass.config_entries.flow.async_configure( 253 | result["flow_id"], 254 | user_input={ 255 | CONF_API_KEY: "new_key", 256 | }, 257 | ) 258 | 259 | assert result["type"] is FlowResultType.FORM 260 | assert result["step_id"] == "reauth_key" 261 | assert result["errors"]["base"] == "error_response" 262 | assert result["description_placeholders"]["error_msg"] == "Internal Server error" 263 | 264 | hass.config_entries.flow.async_abort(result["flow_id"]) 265 | mock_setup_entry.assert_not_awaited() 266 | 267 | 268 | async def test_reauth_connection_failed_timeout( 269 | hass: HomeAssistant, 270 | mock_setup_entry: AsyncMock, 271 | monkeypatch: pytest.MonkeyPatch, 272 | ) -> None: 273 | """Test a reauthentication flow with TimeoutError.""" 274 | monkeypatch.setattr( 275 | UnraidApiClient, "call_api", AsyncMock(side_effect=TimeoutError()), raising=True 276 | ) 277 | mock_config = add_config_entry(hass) 278 | 279 | result = await mock_config.start_reauth_flow(hass) 280 | 281 | result = await hass.config_entries.flow.async_configure( 282 | result["flow_id"], 283 | user_input={ 284 | CONF_API_KEY: "new_key", 285 | }, 286 | ) 287 | 288 | assert result["type"] is FlowResultType.FORM 289 | assert result["step_id"] == "reauth_key" 290 | assert result["errors"]["base"] == "cannot_connect" 291 | 292 | hass.config_entries.flow.async_abort(result["flow_id"]) 293 | mock_setup_entry.assert_not_awaited() 294 | 295 | 296 | async def test_reauth_connection_failed_connection_error( 297 | hass: HomeAssistant, 298 | mock_setup_entry: AsyncMock, 299 | monkeypatch: pytest.MonkeyPatch, 300 | ) -> None: 301 | """Test a reauthentication flow with ClientConnectionError.""" 302 | monkeypatch.setattr( 303 | UnraidApiClient, "call_api", AsyncMock(side_effect=ClientConnectionError()), raising=True 304 | ) 305 | mock_config = add_config_entry(hass) 306 | 307 | result = await mock_config.start_reauth_flow(hass) 308 | 309 | result = await hass.config_entries.flow.async_configure( 310 | result["flow_id"], 311 | user_input={ 312 | CONF_API_KEY: "new_key", 313 | }, 314 | ) 315 | 316 | assert result["type"] is FlowResultType.FORM 317 | assert result["step_id"] == "reauth_key" 318 | assert result["errors"]["base"] == "cannot_connect" 319 | 320 | hass.config_entries.flow.async_abort(result["flow_id"]) 321 | mock_setup_entry.assert_not_awaited() 322 | 323 | 324 | async def test_reauth_connection_failed_ssl_error( 325 | hass: HomeAssistant, 326 | mock_setup_entry: AsyncMock, 327 | monkeypatch: pytest.MonkeyPatch, 328 | ) -> None: 329 | """Test a reauthentication flow with ClientConnectorSSLError.""" 330 | monkeypatch.setattr( 331 | UnraidApiClient, 332 | "call_api", 333 | AsyncMock(side_effect=ClientConnectorSSLError(MagicMock(), MagicMock())), 334 | raising=True, 335 | ) 336 | mock_config = add_config_entry(hass) 337 | 338 | result = await mock_config.start_reauth_flow(hass) 339 | 340 | result = await hass.config_entries.flow.async_configure( 341 | result["flow_id"], 342 | user_input={ 343 | CONF_API_KEY: "new_key", 344 | }, 345 | ) 346 | 347 | assert result["type"] is FlowResultType.FORM 348 | assert result["step_id"] == "reauth_key" 349 | assert result["errors"]["base"] == "ssl_error" 350 | 351 | hass.config_entries.flow.async_abort(result["flow_id"]) 352 | mock_setup_entry.assert_not_awaited() 353 | 354 | 355 | async def test_reauth_connection_auth_failed( 356 | hass: HomeAssistant, 357 | mock_setup_entry: AsyncMock, 358 | mock_graphql_server: Callable[..., Awaitable[GraphqlServerMocker]], 359 | ) -> None: 360 | """Test a reauthentication flow with ClientConnectorSSLError.""" 361 | mocker = await mock_graphql_server(API_RESPONSES_LATEST) 362 | mocker.responses.is_unauthenticated = True 363 | mock_config = add_config_entry(hass, mocker) 364 | 365 | result = await mock_config.start_reauth_flow(hass) 366 | 367 | result = await hass.config_entries.flow.async_configure( 368 | result["flow_id"], 369 | user_input={ 370 | CONF_API_KEY: "new_key", 371 | }, 372 | ) 373 | 374 | assert result["type"] is FlowResultType.FORM 375 | assert result["step_id"] == "reauth_key" 376 | assert result["errors"]["base"] == "auth_failed" 377 | 378 | hass.config_entries.flow.async_abort(result["flow_id"]) 379 | mock_setup_entry.assert_not_awaited() 380 | 381 | 382 | async def test_options( 383 | hass: HomeAssistant, 384 | mock_setup_entry: AsyncMock, 385 | ) -> None: 386 | """Test Reconfigure flow.""" 387 | mock_config = add_config_entry(hass) 388 | 389 | result = await hass.config_entries.options.async_init(mock_config.entry_id) 390 | 391 | assert result["type"] is FlowResultType.FORM 392 | assert result["step_id"] == "init" 393 | assert not result["errors"] 394 | 395 | result = await hass.config_entries.options.async_configure( 396 | result["flow_id"], 397 | user_input={CONF_SHARES: False, CONF_DRIVES: False}, 398 | ) 399 | 400 | assert mock_config.options[CONF_SHARES] is False 401 | assert mock_config.options[CONF_DRIVES] is False 402 | 403 | await hass.async_block_till_done() 404 | mock_setup_entry.assert_awaited_once() 405 | -------------------------------------------------------------------------------- /custom_components/unraid_api/sensor.py: -------------------------------------------------------------------------------- 1 | """Unraid Sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from awesomeversion import AwesomeVersion 9 | from homeassistant.components.sensor import ( 10 | SensorDeviceClass, 11 | SensorEntity, 12 | SensorEntityDescription, 13 | SensorStateClass, 14 | ) 15 | from homeassistant.const import ( 16 | PERCENTAGE, 17 | EntityCategory, 18 | UnitOfElectricPotential, 19 | UnitOfInformation, 20 | UnitOfPower, 21 | UnitOfTemperature, 22 | UnitOfTime, 23 | ) 24 | from homeassistant.core import callback 25 | from homeassistant.helpers.entity import DeviceInfo 26 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 27 | 28 | from .const import CONF_DRIVES, CONF_SHARES, DOMAIN 29 | from .coordinator import UnraidDataUpdateCoordinator 30 | from .models import Disk, DiskType, Share, UpsDevice 31 | 32 | if TYPE_CHECKING: 33 | from collections.abc import Callable 34 | 35 | from homeassistant.core import HomeAssistant 36 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 37 | from homeassistant.helpers.typing import StateType 38 | 39 | from . import UnraidConfigEntry 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | 44 | class UnraidSensorEntityDescription(SensorEntityDescription, frozen_or_thawed=True): 45 | """Description for Unraid Sensor Entity.""" 46 | 47 | min_version: AwesomeVersion = AwesomeVersion("4.20.0") 48 | value_fn: Callable[[UnraidDataUpdateCoordinator], StateType] 49 | extra_values_fn: Callable[[UnraidDataUpdateCoordinator], dict[str, Any]] | None = None 50 | 51 | 52 | class UnraidDiskSensorEntityDescription(SensorEntityDescription, frozen_or_thawed=True): 53 | """Description for Unraid Disk Sensor Entity.""" 54 | 55 | min_version: AwesomeVersion = AwesomeVersion("4.20.0") 56 | value_fn: Callable[[Disk], StateType] 57 | extra_values_fn: Callable[[Disk], dict[str, Any]] | None = None 58 | 59 | 60 | class UnraidShareSensorEntityDescription(SensorEntityDescription, frozen_or_thawed=True): 61 | """Description for Unraid Share Sensor Entity.""" 62 | 63 | min_version: AwesomeVersion = AwesomeVersion("4.20.0") 64 | value_fn: Callable[[Share], StateType] 65 | extra_values_fn: Callable[[Share], dict[str, Any]] | None = None 66 | 67 | 68 | class UnraidUpsSensorEntityDescription(SensorEntityDescription, frozen_or_thawed=True): 69 | """Description for Unraid UPS Sensor Entity.""" 70 | 71 | min_version: AwesomeVersion = AwesomeVersion("4.26.0") 72 | value_fn: Callable[[UpsDevice], StateType] 73 | 74 | 75 | def calc_array_usage_percentage(coordinator: UnraidDataUpdateCoordinator) -> StateType: 76 | """Calculate the array usage percentage.""" 77 | used = coordinator.data["array"].capacity_used 78 | total = coordinator.data["array"].capacity_total 79 | return (used / total) * 100 80 | 81 | 82 | def calc_disk_usage_percentage(disk: Disk) -> StateType: 83 | """Calculate the disk usage percentage.""" 84 | if disk.fs_used is None or disk.fs_size is None or disk.fs_size == 0: 85 | return None 86 | return (disk.fs_used / disk.fs_size) * 100 87 | 88 | 89 | SENSOR_DESCRIPTIONS: tuple[UnraidSensorEntityDescription, ...] = ( 90 | UnraidSensorEntityDescription( 91 | key="array_state", 92 | device_class=SensorDeviceClass.ENUM, 93 | value_fn=lambda coordinator: coordinator.data["array"].state.lower(), 94 | options=[ 95 | "started", 96 | "stopped", 97 | "new_array", 98 | "recon_disk", 99 | "disable_disk", 100 | "swap_dsbl", 101 | "invalid_expansion", 102 | "parity_not_biggest", 103 | "too_many_missing_disks", 104 | "new_disk_too_small", 105 | "no_data_disks", 106 | ], 107 | ), 108 | UnraidSensorEntityDescription( 109 | key="array_usage", 110 | native_unit_of_measurement=PERCENTAGE, 111 | state_class=SensorStateClass.MEASUREMENT, 112 | suggested_display_precision=2, 113 | value_fn=calc_array_usage_percentage, 114 | extra_values_fn=lambda coordinator: { 115 | "used": coordinator.data["array"].capacity_used, 116 | "free": coordinator.data["array"].capacity_free, 117 | "total": coordinator.data["array"].capacity_total, 118 | }, 119 | ), 120 | UnraidSensorEntityDescription( 121 | key="array_free", 122 | device_class=SensorDeviceClass.DATA_SIZE, 123 | state_class=SensorStateClass.MEASUREMENT, 124 | native_unit_of_measurement=UnitOfInformation.KILOBYTES, 125 | suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, 126 | suggested_display_precision=2, 127 | value_fn=lambda coordinator: coordinator.data["array"].capacity_free, 128 | entity_registry_enabled_default=False, 129 | ), 130 | UnraidSensorEntityDescription( 131 | key="array_used", 132 | device_class=SensorDeviceClass.DATA_SIZE, 133 | state_class=SensorStateClass.MEASUREMENT, 134 | native_unit_of_measurement=UnitOfInformation.KILOBYTES, 135 | suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, 136 | suggested_display_precision=2, 137 | value_fn=lambda coordinator: coordinator.data["array"].capacity_used, 138 | entity_registry_enabled_default=False, 139 | ), 140 | UnraidSensorEntityDescription( 141 | key="ram_usage", 142 | native_unit_of_measurement=PERCENTAGE, 143 | state_class=SensorStateClass.MEASUREMENT, 144 | suggested_display_precision=2, 145 | value_fn=lambda coordinator: coordinator.data["metrics"].memory_percent_total, 146 | extra_values_fn=lambda coordinator: { 147 | "used": coordinator.data["metrics"].memory_active, 148 | "free": coordinator.data["metrics"].memory_free, 149 | "total": coordinator.data["metrics"].memory_total, 150 | "available": coordinator.data["metrics"].memory_available, 151 | }, 152 | ), 153 | UnraidSensorEntityDescription( 154 | key="ram_used", 155 | device_class=SensorDeviceClass.DATA_SIZE, 156 | state_class=SensorStateClass.MEASUREMENT, 157 | native_unit_of_measurement=UnitOfInformation.BYTES, 158 | suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, 159 | suggested_display_precision=2, 160 | value_fn=lambda coordinator: coordinator.data["metrics"].memory_active, 161 | entity_registry_enabled_default=False, 162 | ), 163 | UnraidSensorEntityDescription( 164 | key="ram_free", 165 | device_class=SensorDeviceClass.DATA_SIZE, 166 | state_class=SensorStateClass.MEASUREMENT, 167 | native_unit_of_measurement=UnitOfInformation.BYTES, 168 | suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, 169 | suggested_display_precision=2, 170 | value_fn=lambda coordinator: coordinator.data["metrics"].memory_free, 171 | entity_registry_enabled_default=False, 172 | ), 173 | UnraidSensorEntityDescription( 174 | key="cpu_utilization", 175 | native_unit_of_measurement=PERCENTAGE, 176 | state_class=SensorStateClass.MEASUREMENT, 177 | suggested_display_precision=2, 178 | value_fn=lambda coordinator: coordinator.data["metrics"].cpu_percent_total, 179 | ), 180 | UnraidSensorEntityDescription( 181 | key="cpu_temp", 182 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 183 | state_class=SensorStateClass.MEASUREMENT, 184 | suggested_display_precision=2, 185 | value_fn=lambda coordinator: coordinator.data["metrics"].cpu_temp, 186 | min_version=AwesomeVersion("4.26.0"), 187 | ), 188 | UnraidSensorEntityDescription( 189 | key="cpu_power", 190 | native_unit_of_measurement=UnitOfPower.WATT, 191 | state_class=SensorStateClass.MEASUREMENT, 192 | suggested_display_precision=2, 193 | value_fn=lambda coordinator: coordinator.data["metrics"].cpu_power, 194 | min_version=AwesomeVersion("4.26.0"), 195 | ), 196 | ) 197 | 198 | DISK_SENSOR_DESCRIPTIONS: tuple[UnraidDiskSensorEntityDescription, ...] = ( 199 | UnraidDiskSensorEntityDescription( 200 | key="disk_status", 201 | device_class=SensorDeviceClass.ENUM, 202 | value_fn=lambda disk: disk.status.value.lower(), 203 | options=[ 204 | "disk_np", 205 | "disk_ok", 206 | "disk_np_missing", 207 | "disk_invalid", 208 | "disk_wrong", 209 | "disk_dsbl", 210 | "disk_np_dsbl", 211 | "disk_dsbl_new", 212 | "disk_new", 213 | ], 214 | entity_category=EntityCategory.DIAGNOSTIC, 215 | ), 216 | UnraidDiskSensorEntityDescription( 217 | key="disk_temp", 218 | device_class=SensorDeviceClass.TEMPERATURE, 219 | state_class=SensorStateClass.MEASUREMENT, 220 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 221 | value_fn=lambda disk: disk.temp, 222 | ), 223 | ) 224 | 225 | DISK_SENSOR_SPACE_DESCRIPTIONS: tuple[UnraidDiskSensorEntityDescription, ...] = ( 226 | UnraidDiskSensorEntityDescription( 227 | key="disk_usage", 228 | native_unit_of_measurement=PERCENTAGE, 229 | state_class=SensorStateClass.MEASUREMENT, 230 | suggested_display_precision=2, 231 | value_fn=calc_disk_usage_percentage, 232 | ), 233 | UnraidDiskSensorEntityDescription( 234 | key="disk_free", 235 | device_class=SensorDeviceClass.DATA_SIZE, 236 | state_class=SensorStateClass.MEASUREMENT, 237 | native_unit_of_measurement=UnitOfInformation.KILOBYTES, 238 | suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, 239 | suggested_display_precision=2, 240 | value_fn=lambda disk: disk.fs_free, 241 | entity_registry_enabled_default=False, 242 | ), 243 | UnraidDiskSensorEntityDescription( 244 | key="disk_used", 245 | device_class=SensorDeviceClass.DATA_SIZE, 246 | state_class=SensorStateClass.MEASUREMENT, 247 | native_unit_of_measurement=UnitOfInformation.KILOBYTES, 248 | suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, 249 | suggested_display_precision=2, 250 | value_fn=lambda disk: disk.fs_used, 251 | entity_registry_enabled_default=False, 252 | ), 253 | ) 254 | 255 | SHARE_SENSOR_DESCRIPTIONS: tuple[UnraidShareSensorEntityDescription, ...] = ( 256 | UnraidShareSensorEntityDescription( 257 | key="share_free", 258 | device_class=SensorDeviceClass.DATA_SIZE, 259 | state_class=SensorStateClass.MEASUREMENT, 260 | native_unit_of_measurement=UnitOfInformation.KILOBYTES, 261 | suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, 262 | suggested_display_precision=2, 263 | value_fn=lambda share: share.free, 264 | extra_values_fn=lambda share: { 265 | "used": share.used, 266 | "total": share.size, 267 | "allocator": share.allocator, 268 | "floor": share.floor, 269 | }, 270 | ), 271 | ) 272 | 273 | UPS_SENSOR_DESCRIPTIONS: tuple[UnraidUpsSensorEntityDescription, ...] = ( 274 | UnraidUpsSensorEntityDescription( 275 | key="ups_status", 276 | value_fn=lambda device: device.status, 277 | ), 278 | UnraidUpsSensorEntityDescription( 279 | key="ups_level", 280 | device_class=SensorDeviceClass.BATTERY, 281 | native_unit_of_measurement=PERCENTAGE, 282 | value_fn=lambda device: device.battery_level, 283 | ), 284 | UnraidUpsSensorEntityDescription( 285 | key="ups_runtime", 286 | device_class=SensorDeviceClass.DURATION, 287 | native_unit_of_measurement=UnitOfTime.SECONDS, 288 | suggested_unit_of_measurement=UnitOfTime.MINUTES, 289 | value_fn=lambda device: device.battery_runtime, 290 | ), 291 | UnraidUpsSensorEntityDescription( 292 | key="ups_health", 293 | value_fn=lambda device: device.battery_health, 294 | ), 295 | UnraidUpsSensorEntityDescription( 296 | key="ups_load", 297 | native_unit_of_measurement=PERCENTAGE, 298 | value_fn=lambda device: device.load_percentage, 299 | ), 300 | UnraidUpsSensorEntityDescription( 301 | key="ups_input_voltage", 302 | device_class=SensorDeviceClass.VOLTAGE, 303 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 304 | value_fn=lambda device: device.input_voltage, 305 | ), 306 | UnraidUpsSensorEntityDescription( 307 | key="ups_output_voltage", 308 | device_class=SensorDeviceClass.VOLTAGE, 309 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 310 | value_fn=lambda device: device.output_voltage, 311 | ), 312 | ) 313 | 314 | 315 | async def async_setup_entry( 316 | hass: HomeAssistant, # noqa: ARG001 317 | config_entry: UnraidConfigEntry, 318 | async_add_entites: AddEntitiesCallback, 319 | ) -> None: 320 | """Set up this integration using config entry.""" 321 | entities = [ 322 | UnraidSensor(description, config_entry) 323 | for description in SENSOR_DESCRIPTIONS 324 | if description.min_version <= config_entry.runtime_data.coordinator.api_client.version 325 | ] 326 | async_add_entites(entities) 327 | 328 | @callback 329 | def add_disk_callback(disk: Disk) -> None: 330 | _LOGGER.debug("Adding new Disk: %s", disk.name) 331 | entities = [ 332 | UnraidDiskSensor(description, config_entry, disk.id) 333 | for description in DISK_SENSOR_DESCRIPTIONS 334 | if description.min_version <= config_entry.runtime_data.coordinator.api_client.version 335 | ] 336 | if disk.type != DiskType.Parity: 337 | entities.extend( 338 | UnraidDiskSensor(description, config_entry, disk.id) 339 | for description in DISK_SENSOR_SPACE_DESCRIPTIONS 340 | if description.min_version 341 | <= config_entry.runtime_data.coordinator.api_client.version 342 | ) 343 | async_add_entites(entities) 344 | 345 | @callback 346 | def add_share_callback(share: Share) -> None: 347 | _LOGGER.debug("Adding new Share: %s", share.name) 348 | entities = [ 349 | UnraidShareSensor(description, config_entry, share.name) 350 | for description in SHARE_SENSOR_DESCRIPTIONS 351 | if description.min_version <= config_entry.runtime_data.coordinator.api_client.version 352 | ] 353 | async_add_entites(entities) 354 | 355 | @callback 356 | def add_ups_callback(device: UpsDevice) -> None: 357 | _LOGGER.debug("Adding new UPS: %s", device.name) 358 | device_info = DeviceInfo( 359 | identifiers={(DOMAIN, f"{config_entry.entry_id}_{device.id}")}, 360 | name=device.name, 361 | model=device.model, 362 | via_device=(DOMAIN, config_entry.entry_id), 363 | ) 364 | entities = [ 365 | UnraidUpsSensor(description, config_entry, device.id, device_info) 366 | for description in UPS_SENSOR_DESCRIPTIONS 367 | if description.min_version <= config_entry.runtime_data.coordinator.api_client.version 368 | ] 369 | async_add_entites(entities) 370 | 371 | if config_entry.options[CONF_DRIVES]: 372 | config_entry.runtime_data.coordinator.subscribe_disks(add_disk_callback) 373 | if config_entry.options[CONF_SHARES]: 374 | config_entry.runtime_data.coordinator.subscribe_shares(add_share_callback) 375 | config_entry.runtime_data.coordinator.subscribe_ups(add_ups_callback) 376 | 377 | 378 | class UnraidSensor(CoordinatorEntity[UnraidDataUpdateCoordinator], SensorEntity): 379 | """Sensor for Unraid Server.""" 380 | 381 | entity_description: UnraidSensorEntityDescription 382 | _attr_has_entity_name = True 383 | 384 | def __init__( 385 | self, 386 | description: UnraidSensorEntityDescription, 387 | config_entry: UnraidConfigEntry, 388 | ) -> None: 389 | super().__init__(config_entry.runtime_data.coordinator) 390 | self.entity_description = description 391 | self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" 392 | self._attr_translation_key = description.key 393 | self._attr_available = False 394 | self._attr_device_info = config_entry.runtime_data.device_info 395 | 396 | @property 397 | def native_value(self) -> StateType: 398 | try: 399 | return self.entity_description.value_fn(self.coordinator) 400 | except (KeyError, AttributeError): 401 | return None 402 | 403 | @property 404 | def extra_state_attributes(self) -> dict[str, Any] | None: 405 | try: 406 | if self.entity_description.extra_values_fn: 407 | return self.entity_description.extra_values_fn(self.coordinator) 408 | except (KeyError, AttributeError): 409 | return None 410 | return None 411 | 412 | 413 | class UnraidDiskSensor(CoordinatorEntity[UnraidDataUpdateCoordinator], SensorEntity): 414 | """Sensor for Unraid Disks.""" 415 | 416 | entity_description: UnraidDiskSensorEntityDescription 417 | _attr_has_entity_name = True 418 | 419 | def __init__( 420 | self, 421 | description: UnraidDiskSensorEntityDescription, 422 | config_entry: UnraidConfigEntry, 423 | disk_id: str, 424 | ) -> None: 425 | super().__init__(config_entry.runtime_data.coordinator) 426 | self.disk_id = disk_id 427 | self.entity_description = description 428 | self._attr_unique_id = f"{config_entry.entry_id}-{description.key}-{self.disk_id}" 429 | self._attr_translation_key = description.key 430 | self._attr_translation_placeholders = { 431 | "disk_name": self.coordinator.data["disks"][self.disk_id].name 432 | } 433 | self._attr_available = False 434 | self._attr_device_info = config_entry.runtime_data.device_info 435 | 436 | @property 437 | def native_value(self) -> StateType: 438 | try: 439 | return self.entity_description.value_fn(self.coordinator.data["disks"][self.disk_id]) 440 | except (KeyError, AttributeError): 441 | return None 442 | 443 | @property 444 | def extra_state_attributes(self) -> dict[str, Any] | None: 445 | try: 446 | if self.entity_description.extra_values_fn: 447 | return self.entity_description.extra_values_fn( 448 | self.coordinator.data["disks"][self.disk_id] 449 | ) 450 | except (KeyError, AttributeError): 451 | return None 452 | return None 453 | 454 | 455 | class UnraidShareSensor(CoordinatorEntity[UnraidDataUpdateCoordinator], SensorEntity): 456 | """Sensor for Unraid Shares.""" 457 | 458 | entity_description: UnraidShareSensorEntityDescription 459 | _attr_has_entity_name = True 460 | 461 | def __init__( 462 | self, 463 | description: UnraidShareSensorEntityDescription, 464 | config_entry: UnraidConfigEntry, 465 | share_name: str, 466 | ) -> None: 467 | super().__init__(config_entry.runtime_data.coordinator) 468 | self.share_name = share_name 469 | self.entity_description = description 470 | self._attr_unique_id = f"{config_entry.entry_id}-{description.key}-{self.share_name}" 471 | self._attr_translation_key = description.key 472 | self._attr_translation_placeholders = {"share_name": self.share_name} 473 | self._attr_available = False 474 | self._attr_device_info = config_entry.runtime_data.device_info 475 | 476 | @property 477 | def native_value(self) -> StateType: 478 | try: 479 | return self.entity_description.value_fn( 480 | self.coordinator.data["shares"][self.share_name] 481 | ) 482 | except (KeyError, AttributeError): 483 | return None 484 | 485 | @property 486 | def extra_state_attributes(self) -> dict[str, Any] | None: 487 | try: 488 | if self.entity_description.extra_values_fn: 489 | return self.entity_description.extra_values_fn( 490 | self.coordinator.data["shares"][self.share_name] 491 | ) 492 | except (KeyError, AttributeError): 493 | return None 494 | return None 495 | 496 | 497 | class UnraidUpsSensor(CoordinatorEntity[UnraidDataUpdateCoordinator], SensorEntity): 498 | """Sensor for Unraid UPS.""" 499 | 500 | entity_description: UnraidUpsSensorEntityDescription 501 | _attr_has_entity_name = True 502 | 503 | def __init__( 504 | self, 505 | description: UnraidUpsSensorEntityDescription, 506 | config_entry: UnraidConfigEntry, 507 | ups_id: str, 508 | device_info: DeviceInfo, 509 | ) -> None: 510 | super().__init__(config_entry.runtime_data.coordinator) 511 | self.ups_id = ups_id 512 | self.entity_description = description 513 | self._attr_unique_id = f"{config_entry.entry_id}-{description.key}-{self.ups_id}" 514 | self._attr_translation_key = description.key 515 | self._attr_available = False 516 | self._attr_device_info = device_info 517 | 518 | @property 519 | def native_value(self) -> StateType: 520 | try: 521 | return self.entity_description.value_fn( 522 | self.coordinator.data["ups_devices"][self.ups_id] 523 | ) 524 | except (KeyError, AttributeError): 525 | return None 526 | 527 | @property 528 | def available(self) -> bool: 529 | return ( 530 | self.ups_id in self.coordinator.data["ups_devices"] 531 | and self.coordinator.last_update_success 532 | ) 533 | --------------------------------------------------------------------------------