diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6dd54a7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,56 @@ +{ + "name": "Proxmox VE Custom Integration", + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers-contrib/features/poetry:2": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/rust:1": {} + }, + "postCreateCommand": "scripts/setup", + "runArgs": [ + "--network=host" + ], + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance", + "charliermarsh.ruff" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "[markdown]": { + "files.trimTrailingWhitespace": false + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + } + } + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..04f2d40 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + ignore: + # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json + - dependency-name: "homeassistant" \ No newline at end of file diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml deleted file mode 100644 index 94f63a6..0000000 --- a/.github/workflows/hassfest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: Hassfest - -on: - pull_request: - push: - schedule: - - cron: "0 0 * * *" - workflow_dispatch: - -jobs: - hassfest: - name: Hassfest - runs-on: ubuntu-latest - steps: - - name: ⤵️ Check out code from GitHub - uses: actions/checkout@v3 - - - name: 🚀 Run hassfest validation - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..c50edc8 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,32 @@ +name: "Lint" + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + ruff: + name: "Ruff" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.2.2" + + - name: "Set up Python" + uses: actions/setup-python@v5.3.0 + with: + python-version: "3.12" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Lint" + run: python3 -m ruff check . + + - name: "Format" + run: python3 -m ruff format . --check \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7207557 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: "Release" + +on: + release: + types: + - "published" + +permissions: {} + +jobs: + release: + name: "Release" + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.2.2" + + - name: Adjust version number + shell: bash + run: | + version="${{ github.event.release.tag_name }}" + version="${version,,}" + version="${version#v}" + yq e -P -o=json \ + -i ".version = \"${version}\"" \ + "${{ github.workspace }}/custom_components/proxmoxve/manifest.json" + + - name: "ZIP the integration directory" + shell: "bash" + run: | + cd "${{ github.workspace }}/custom_components/proxmoxve" + zip proxmoxve.zip -r ./ + + - name: Sign release package + uses: sigstore/gh-action-sigstore-python@v3.0.0 + with: + inputs: ${{ github.workspace }}/custom_components/proxmoxve/proxmoxve.zip + + - name: "Upload the ZIP file to the release" + uses: "softprops/action-gh-release@v2.1.0" + with: + files: ${{ github.workspace }}/custom_components/proxmoxve/proxmoxve.zip \ No newline at end of file diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..e0054ef --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,37 @@ +name: "Validate" + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest + name: "Hassfest Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.2.2" + + - name: "Run hassfest validation" + uses: "home-assistant/actions/hassfest@master" + + # hacs: # https://github.com/hacs/action + # name: "HACS Validation" + # runs-on: "ubuntu-latest" + # steps: + # - name: "Checkout the repository" + # uses: "actions/checkout@v4.2.2" + + # - name: "Run HACS validation" + # uses: "hacs/action@main" + # with: + # category: "integration" + # # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands + # ignore: "brands" \ No newline at end of file diff --git a/.gitignore b/.gitignore index ed8ebf5..0a8519a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,18 @@ -__pycache__ \ No newline at end of file +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* + + +# misc +.coverage +.vscode +coverage.xml +.ruff_cache + + +# Home Assistant configuration +config/* +!config/configuration.yaml \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..1b810a3 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,45 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py312" + +[lint] +select = ["ALL"] + +ignore = [ + "ANN101", # Missing type annotation for `self` in method + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "D203", # no-blank-line-before-class (incompatible with formatter) + "D212", # multi-line-summary-first-line (incompatible with formatter) + "COM812", # incompatible with formatter + "ISC001", # incompatible with formatter + + # Temporary exceptions for Ruff implementation + "PERF401", # manual-list-comprehension + "EXE002", + "PLR0912", + "DTZ005", + "PLR2004", + "E722", + "F821", + "E501", + "PLR0915", + "FBT002", + "PLR0913", + "ANN001", + "SIM102", + "S105", + "ARG002", + "BLE001", + "PERF402", + "S101", + "ANN201", +] + +[lint.flake8-pytest-style] +fixture-parentheses = false + +[lint.pyupgrade] +keep-runtime-typing = true + +[lint.mccabe] +max-complexity = 25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..972d266 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `main`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using `scripts/lint`). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) to make sure the code follows the style. + +## Test your code modification + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`configuration.yaml`](./config/configuration.yaml) +file. + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index 261eeb9..88e6a46 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +MIT License + +Copyright (c) 2019 - 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..a3cee27 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,12 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/homeassistant/ +homeassistant: + debug: true + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: info + logs: + custom_components.proxmoxve: debug \ No newline at end of file diff --git a/custom_components/proxmoxve/__init__.py b/custom_components/proxmoxve/__init__.py index 99cd055..1a77d64 100644 --- a/custom_components/proxmoxve/__init__.py +++ b/custom_components/proxmoxve/__init__.py @@ -3,18 +3,10 @@ from __future__ import annotations import warnings +from typing import TYPE_CHECKING -from proxmoxer import AuthenticationError -from proxmoxer.core import ResourceException -from requests.exceptions import ( - ConnectionError as connError, - ConnectTimeout, - RetryError, - SSLError, -) -from urllib3.exceptions import InsecureRequestWarning +import homeassistant.helpers.config_validation as cv import voluptuous as vol - from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, @@ -24,16 +16,28 @@ CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( device_registry as dr, +) +from homeassistant.helpers import ( entity_registry as er, +) +from homeassistant.helpers import ( issue_registry as ir, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.typing import ConfigType +from proxmoxer import AuthenticationError +from proxmoxer.core import ResourceException +from requests.exceptions import ( + ConnectionError as connError, +) +from requests.exceptions import ( + ConnectTimeout, + RetryError, + SSLError, +) +from urllib3.exceptions import InsecureRequestWarning from .api import ProxmoxClient, get_api from .const import ( @@ -45,9 +49,9 @@ CONF_QEMU, CONF_REALM, CONF_STORAGE, + CONF_TOKEN_NAME, CONF_VMS, COORDINATORS, - CONF_TOKEN_NAME, DEFAULT_PORT, DEFAULT_REALM, DEFAULT_VERIFY_SSL, @@ -66,7 +70,12 @@ ProxmoxStorageCoordinator, ProxmoxUpdateCoordinator, ) -from .models import ProxmoxDiskData, ProxmoxStorageData + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from homeassistant.helpers.typing import ConfigType + + from .models import ProxmoxDiskData, ProxmoxStorageData PLATFORMS = [ Platform.BINARY_SENSOR, @@ -119,7 +128,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the platform.""" - # import to config flow if DOMAIN in config: LOGGER.warning( @@ -339,7 +347,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the platform.""" - hass.data.setdefault(DOMAIN, {}) entry_data = config_entry.data @@ -366,22 +373,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except AuthenticationError as error: raise ConfigEntryAuthFailed from error except SSLError as error: - raise ConfigEntryNotReady( + msg = ( "Unable to verify proxmox server SSL. Try using 'verify_ssl: false' " f"for proxmox instance {host}:{port}" - ) from error + ) + raise ConfigEntryNotReady(msg) from error except ConnectTimeout as error: - raise ConfigEntryNotReady( - f"Connection to host {host} timed out during setup" - ) from error + msg = f"Connection to host {host} timed out during setup" + raise ConfigEntryNotReady(msg) from error except RetryError as error: - raise ConfigEntryNotReady( - f"Connection is unreachable to host {host}" - ) from error + msg = f"Connection is unreachable to host {host}" + raise ConfigEntryNotReady(msg) from error except connError as error: - raise ConfigEntryNotReady( - f"Connection is unreachable to host {host}" - ) from error + msg = f"Connection is unreachable to host {host}" + raise ConfigEntryNotReady(msg) from error except ResourceException as error: raise ConfigEntryNotReady from error @@ -636,7 +641,6 @@ def device_info( cordinator_resource: ProxmoxDiskData | ProxmoxStorageData | None = None, ): """Return the Device Info.""" - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] host = config_entry.data[CONF_HOST] @@ -740,7 +744,7 @@ def device_info( async def async_migrate_old_unique_ids( hass: HomeAssistant, platform: Platform, entities -): +) -> None: """Migration of the unique id of disk entities.""" registry = er.async_get(hass) for entity in entities: diff --git a/custom_components/proxmoxve/api.py b/custom_components/proxmoxve/api.py index 023eb88..48b9219 100644 --- a/custom_components/proxmoxve/api.py +++ b/custom_components/proxmoxve/api.py @@ -2,13 +2,12 @@ from typing import Any -from proxmoxer import ProxmoxAPI -from proxmoxer.core import ResourceException -from requests.exceptions import ConnectTimeout - from homeassistant.const import CONF_USERNAME from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir +from proxmoxer import ProxmoxAPI +from proxmoxer.core import ResourceException +from requests.exceptions import ConnectTimeout from .const import ( DEFAULT_PORT, @@ -31,13 +30,12 @@ def __init__( host: str, user: str, password: str, - token_name: str = '', + token_name: str = "", port: int | None = DEFAULT_PORT, realm: str | None = DEFAULT_REALM, verify_ssl: bool | None = DEFAULT_VERIFY_SSL, ) -> None: """Initialize the ProxmoxClient.""" - self._host = host self._port = port self._user = user @@ -47,15 +45,12 @@ def __init__( self._verify_ssl = verify_ssl def build_client(self) -> None: - """Construct the ProxmoxAPI client. + """ + Construct the ProxmoxAPI client. Allows inserting the realm within the `user` value. """ - - if "@" in self._user: - user_id = self._user - else: - user_id = f"{self._user}@{self._realm}" + user_id = self._user if "@" in self._user else f"{self._user}@{self._realm}" if self._token_name: self._proxmox = ProxmoxAPI( @@ -87,7 +82,6 @@ def get_api( api_path: str, ) -> dict[str, Any] | None: """Return data from the Proxmox API.""" - api_result = proxmox.get(api_path) LOGGER.debug("API GET Response - %s: %s", api_path, api_result) return api_result @@ -98,7 +92,6 @@ def post_api( api_path: str, ) -> dict[str, Any] | None: """Post data to Proxmox API.""" - api_result = proxmox.post(api_path) LOGGER.debug("API POST - %s: %s", api_path, api_result) return api_result @@ -118,7 +111,8 @@ def post_api_command( proxmox = proxmox_client.get_api_client() if command not in ProxmoxCommand: - raise ValueError("Invalid Command") + msg = "Invalid Command" + raise ValueError(msg) if api_category is ProxmoxType.Node: issue_id = f"{self.config_entry.entry_id}_{node}_command_forbiden" @@ -169,13 +163,15 @@ def post_api_command( "command": command, }, ) + msg = f"Proxmox {resource} {command} error - {error}" raise HomeAssistantError( - f"Proxmox {resource} {command} error - {error}", + msg, ) from error except ConnectTimeout as error: + msg = f"Proxmox {resource} {command} error - {error}" raise HomeAssistantError( - f"Proxmox {resource} {command} error - {error}", + msg, ) from error ir.delete_issue( diff --git a/custom_components/proxmoxve/binary_sensor.py b/custom_components/proxmoxve/binary_sensor.py index 14dd5eb..0e4ed61 100644 --- a/custom_components/proxmoxve/binary_sensor.py +++ b/custom_components/proxmoxve/binary_sensor.py @@ -3,32 +3,33 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Final +from typing import TYPE_CHECKING, Final from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import COORDINATORS, DOMAIN, async_migrate_old_unique_ids, device_info from .const import ( CONF_LXC, CONF_NODES, CONF_QEMU, - LOGGER, ProxmoxKeyAPIParse, ProxmoxType, ) from .entity import ProxmoxEntity, ProxmoxEntityDescription +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from homeassistant.helpers.device_registry import DeviceInfo + from homeassistant.helpers.entity_platform import AddEntitiesCallback + from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + @dataclass(frozen=True, kw_only=True) class ProxmoxBinarySensorEntityDescription( @@ -102,7 +103,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up binary sensors.""" - async_add_entities(await async_setup_binary_sensors_nodes(hass, config_entry)) async_add_entities(await async_setup_binary_sensors_qemu(hass, config_entry)) async_add_entities(await async_setup_binary_sensors_lxc(hass, config_entry)) @@ -113,7 +113,6 @@ async def async_setup_binary_sensors_nodes( config_entry: ConfigEntry, ) -> list: """Set up binary sensors.""" - sensors = [] migrate_unique_id_disks = [] @@ -206,7 +205,6 @@ async def async_setup_binary_sensors_qemu( config_entry: ConfigEntry, ) -> list: """Set up binary sensors.""" - sensors = [] coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] @@ -246,7 +244,6 @@ async def async_setup_binary_sensors_lxc( config_entry: ConfigEntry, ) -> list: """Set up binary sensors.""" - sensors = [] coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] @@ -331,5 +328,4 @@ def is_on(self) -> bool: @property def available(self) -> bool: """Return sensor availability.""" - return super().available and self.coordinator.data is not None diff --git a/custom_components/proxmoxve/button.py b/custom_components/proxmoxve/button.py index ab7f773..944e9e9 100644 --- a/custom_components/proxmoxve/button.py +++ b/custom_components/proxmoxve/button.py @@ -3,14 +3,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import TYPE_CHECKING, Final from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import device_info from .api import ProxmoxClient, post_api_command @@ -27,6 +22,13 @@ ) from .entity import ProxmoxEntity, ProxmoxEntityDescription +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from homeassistant.helpers.device_registry import DeviceInfo + from homeassistant.helpers.entity_platform import AddEntitiesCallback + from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + @dataclass(frozen=True, kw_only=True) class ProxmoxButtonEntityDescription(ProxmoxEntityDescription, ButtonEntityDescription): @@ -145,7 +147,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up button.""" - buttons = [] coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] @@ -287,15 +288,14 @@ def __init__( self._attr_device_info = info_device self.config_entry = config_entry - def _button_press(): + def _button_press() -> None: """Post start command & tell HA state is on.""" - if api_category == ProxmoxType.Node: node = resource_id vm_id = None else: if (data := self.coordinator.data) is None: - return None + return node = data.node vm_id = resource_id diff --git a/custom_components/proxmoxve/config_flow.py b/custom_components/proxmoxve/config_flow.py index fe095fe..561bddc 100644 --- a/custom_components/proxmoxve/config_flow.py +++ b/custom_components/proxmoxve/config_flow.py @@ -2,13 +2,11 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any +import homeassistant.helpers.config_validation as cv import proxmoxer -from requests.exceptions import ConnectTimeout, SSLError import voluptuous as vol - from homeassistant import config_entries from homeassistant.const import ( CONF_BASE, @@ -19,9 +17,10 @@ CONF_VERIFY_SSL, ) from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import device_registry as dr, issue_registry as ir, selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import selector +from requests.exceptions import ConnectTimeout, SSLError from .api import ProxmoxClient, get_api from .const import ( @@ -33,9 +32,9 @@ CONF_QEMU, CONF_REALM, CONF_STORAGE, + CONF_TOKEN_NAME, CONF_VMS, COORDINATORS, - CONF_TOKEN_NAME, DEFAULT_PORT, DEFAULT_REALM, DEFAULT_VERIFY_SSL, @@ -46,6 +45,11 @@ ProxmoxType, ) +if TYPE_CHECKING: + from collections.abc import Mapping + + from homeassistant.data_entry_flow import FlowResult + SCHEMA_HOST_BASE: vol.Schema = vol.Schema( { vol.Required(CONF_HOST): str, @@ -170,7 +174,6 @@ async def async_step_change_expose( user_input: dict[str, Any] | None = None, ) -> FlowResult: """Handle the Node/QEMU/LXC selection step.""" - if user_input is None: old_nodes = [] resource_nodes = [] @@ -344,7 +347,6 @@ async def async_process_selection_changes( user_input: dict[str, Any], ) -> dict[str, Any]: """Process resource selection changes.""" - node_selecition = [] if ( CONF_NODES in user_input @@ -486,7 +488,6 @@ def __init__(self) -> None: async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Import existing configuration.""" - errors = {} if f"{import_config.get(CONF_HOST)}_{import_config.get(CONF_PORT)}" in [ @@ -821,7 +822,6 @@ async def async_step_expose( node: str | None = None, ) -> FlowResult: """Handle the Node/QEMU/LXC selection step.""" - if user_input is None: if (proxmox_cliente := self._proxmox_client) is not None: proxmox = proxmox_cliente.get_api_client() diff --git a/custom_components/proxmoxve/const.py b/custom_components/proxmoxve/const.py index 2992977..3f151f5 100644 --- a/custom_components/proxmoxve/const.py +++ b/custom_components/proxmoxve/const.py @@ -1,7 +1,7 @@ """Constants for ProxmoxVE.""" -from enum import StrEnum import logging +from enum import StrEnum DOMAIN = "proxmoxve" PROXMOX_CLIENTS = "proxmox_clients" diff --git a/custom_components/proxmoxve/coordinator.py b/custom_components/proxmoxve/coordinator.py index 4eef02c..fe4dd0c 100644 --- a/custom_components/proxmoxve/coordinator.py +++ b/custom_components/proxmoxve/coordinator.py @@ -3,26 +3,26 @@ from __future__ import annotations from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any +from homeassistant.const import CONF_HOST, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from proxmoxer import AuthenticationError, ProxmoxAPI from proxmoxer.core import ResourceException from requests.exceptions import ( ConnectionError as connError, +) +from requests.exceptions import ( ConnectTimeout, HTTPError, RetryError, SSLError, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr, issue_registry as ir -from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - from .api import get_api from .const import CONF_NODE, DOMAIN, LOGGER, UPDATE_INTERVAL, ProxmoxType from .models import ( @@ -34,6 +34,10 @@ ProxmoxVMData, ) +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + class ProxmoxCoordinator( DataUpdateCoordinator[ @@ -59,7 +63,6 @@ def __init__( node_name: str, ) -> None: """Initialize the Proxmox Node coordinator.""" - super().__init__( hass, LOGGER, @@ -75,7 +78,6 @@ def __init__( async def _async_update_data(self) -> ProxmoxNodeData: """Update data for Proxmox Node.""" - api_path = "nodes" node_status = "" node_api = {} @@ -109,9 +111,8 @@ async def _async_update_data(self) -> ProxmoxNodeData: self.resource_id, ) if api_status is None: - raise UpdateFailed( - f"Node {self.resource_id} unable to be found in host {self.config_entry.data[CONF_HOST]}" - ) + msg = f"Node {self.resource_id} unable to be found in host {self.config_entry.data[CONF_HOST]}" + raise UpdateFailed(msg) api_status["status"] = node_api["status"] api_status["cpu"] = node_api["cpu"] @@ -216,9 +217,8 @@ async def _async_update_data(self) -> ProxmoxNodeData: if (("lxc" in api_status) and "list" in api_status["lxc"]) else UNDEFINED, ) - raise UpdateFailed( - f"Node {self.resource_id} unable to be found in host {self.config_entry.data[CONF_HOST]}" - ) + msg = f"Node {self.resource_id} unable to be found in host {self.config_entry.data[CONF_HOST]}" + raise UpdateFailed(msg) class ProxmoxQEMUCoordinator(ProxmoxCoordinator): @@ -232,7 +232,6 @@ def __init__( qemu_id: int, ) -> None: """Initialize the Proxmox QEMU coordinator.""" - super().__init__( hass, LOGGER, @@ -248,7 +247,6 @@ def __init__( async def _async_update_data(self) -> ProxmoxVMData: """Update data for Proxmox QEMU.""" - node_name = None api_status = None @@ -269,7 +267,7 @@ async def _async_update_data(self) -> ProxmoxVMData: node_name = resource["node"] if node_name is not None: - api_path = f"nodes/{str(node_name)}/qemu/{self.resource_id}/status/current" + api_path = f"nodes/{node_name!s}/qemu/{self.resource_id}/status/current" api_status = await self.hass.async_add_executor_job( poll_api, self.hass, @@ -280,10 +278,12 @@ async def _async_update_data(self) -> ProxmoxVMData: self.resource_id, ) else: - raise UpdateFailed(f"{self.resource_id} QEMU node not found") + msg = f"{self.resource_id} QEMU node not found" + raise UpdateFailed(msg) if api_status is None or "status" not in api_status: - raise UpdateFailed(f"QEMU {self.resource_id} unable to be found") + msg = f"QEMU {self.resource_id} unable to be found" + raise UpdateFailed(msg) update_device_via(self, ProxmoxType.QEMU, node_name) return ProxmoxVMData( @@ -319,7 +319,6 @@ def __init__( container_id: int, ) -> None: """Initialize the Proxmox LXC coordinator.""" - super().__init__( hass, LOGGER, @@ -335,7 +334,6 @@ def __init__( async def _async_update_data(self) -> ProxmoxLXCData: """Update data for Proxmox LXC.""" - node_name = None api_status = None @@ -356,7 +354,7 @@ async def _async_update_data(self) -> ProxmoxLXCData: node_name = resource["node"] if node_name is not None: - api_path = f"nodes/{str(node_name)}/lxc/{self.resource_id}/status/current" + api_path = f"nodes/{node_name!s}/lxc/{self.resource_id}/status/current" api_status = await self.hass.async_add_executor_job( poll_api, self.hass, @@ -367,10 +365,12 @@ async def _async_update_data(self) -> ProxmoxLXCData: self.resource_id, ) else: - raise UpdateFailed(f"{self.resource_id} LXC node not found") + msg = f"{self.resource_id} LXC node not found" + raise UpdateFailed(msg) if api_status is None or "status" not in api_status: - raise UpdateFailed(f"LXC {self.resource_id} unable to be found") + msg = f"LXC {self.resource_id} unable to be found" + raise UpdateFailed(msg) update_device_via(self, ProxmoxType.LXC, node_name) @@ -409,7 +409,6 @@ def __init__( storage_id: str, ) -> None: """Initialize the Proxmox Storage coordinator.""" - super().__init__( hass, LOGGER, @@ -425,7 +424,6 @@ def __init__( async def _async_update_data(self) -> ProxmoxStorageData: """Update data for Proxmox Update.""" - node_name = None api_status = None @@ -441,9 +439,8 @@ async def _async_update_data(self) -> ProxmoxStorageData: ) for resource in resources if resources is not None else []: - if "storage" in resource: - if resource["id"] == self.resource_id: - node_name = resource["node"] + if "storage" in resource and resource["id"] == self.resource_id: + node_name = resource["node"] api_path = "cluster/resources?type=storage" api_storages = await self.hass.async_add_executor_job( @@ -462,7 +459,8 @@ async def _async_update_data(self) -> ProxmoxStorageData: api_status = api_storage if api_status is None or "content" not in api_status: - raise UpdateFailed(f"Storage {self.resource_id} unable to be found") + msg = f"Storage {self.resource_id} unable to be found" + raise UpdateFailed(msg) storage_id = api_status["id"] name = f"Storage {storage_id.replace("storage/", "")}" @@ -487,7 +485,6 @@ def __init__( node_name: str, ) -> None: """Initialize the Proxmox Update coordinator.""" - super().__init__( hass, LOGGER, @@ -503,7 +500,6 @@ def __init__( async def _async_update_data(self) -> ProxmoxUpdateData: """Update data for Proxmox Update.""" - api_path = "nodes" node_status = "" node_api = {} @@ -527,7 +523,7 @@ async def _async_update_data(self) -> ProxmoxUpdateData: if node_status == "online": if self.node_name is not None: - api_path = f"nodes/{str(self.node_name)}/apt/update" + api_path = f"nodes/{self.node_name!s}/apt/update" api_status = await self.hass.async_add_executor_job( poll_api, self.hass, @@ -538,7 +534,8 @@ async def _async_update_data(self) -> ProxmoxUpdateData: self.resource_id, ) else: - raise UpdateFailed(f"{self.resource_id} node not found") + msg = f"{self.resource_id} node not found" + raise UpdateFailed(msg) if api_status is None: return ProxmoxUpdateData( @@ -578,7 +575,6 @@ def __init__( disk_id: str, ) -> None: """Initialize the Proxmox Disk coordinator.""" - super().__init__( hass, LOGGER, @@ -607,9 +603,8 @@ def text_to_smart_id(self, text: str) -> str: async def _async_update_data(self) -> ProxmoxDiskData: """Update data for Proxmox Disk.""" - if self.node_name is not None: - api_path = f"nodes/{str(self.node_name)}/disks/list" + api_path = f"nodes/{self.node_name!s}/disks/list" api_status = await self.hass.async_add_executor_job( poll_api, self.hass, @@ -620,7 +615,8 @@ async def _async_update_data(self) -> ProxmoxDiskData: self.resource_id, ) else: - raise UpdateFailed(f"{self.resource_id} node not found") + msg = f"{self.resource_id} node not found" + raise UpdateFailed(msg) if api_status is None: return ProxmoxDiskData( @@ -750,9 +746,8 @@ async def _async_update_data(self) -> ProxmoxDiskData: power_loss=disk_attributes.get("power_loss", UNDEFINED), ) - raise UpdateFailed( - f"Disk {self.resource_id} not found on node {self.node_name}." - ) + msg = f"Disk {self.resource_id} not found on node {self.node_name}." + raise UpdateFailed(msg) def update_device_via( @@ -808,7 +803,7 @@ def poll_api( def permission_to_resource( api_category: ProxmoxType, resource_id: int | str | None = None, - ): + ) -> str: """Return the permissions required for the resource.""" match api_category: case ProxmoxType.Node: diff --git a/custom_components/proxmoxve/diagnostics.py b/custom_components/proxmoxve/diagnostics.py index eb4a0f0..388f5fa 100644 --- a/custom_components/proxmoxve/diagnostics.py +++ b/custom_components/proxmoxve/diagnostics.py @@ -2,18 +2,14 @@ from __future__ import annotations -from collections.abc import Mapping import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from attr import asdict -from proxmoxer.core import ResourceException - from homeassistant.components.diagnostics.util import async_redact_data -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from proxmoxer.core import ResourceException from .api import get_api from .const import CONF_DISKS_ENABLE, COORDINATORS, DOMAIN, PROXMOX_CLIENT @@ -26,6 +22,13 @@ ProxmoxUpdateCoordinator, ) +if TYPE_CHECKING: + from collections.abc import Mapping + + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from homeassistant.helpers.device_registry import DeviceEntry + TO_REDACT_CONFIG = ["host", "username", "password"] TO_REDACT_COORD = [""] @@ -39,7 +42,6 @@ async def async_get_api_data_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Get API info for diagnostics.""" - proxmox_client = hass.data[DOMAIN][config_entry.entry_id][PROXMOX_CLIENT] proxmox = proxmox_client.get_api_client() @@ -191,7 +193,6 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators: dict[ str, ProxmoxNodeCoordinator @@ -283,7 +284,6 @@ async def async_get_device_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry ) -> Mapping[str, Any]: """Return diagnostics for a device entry.""" - config_entry_diagnostics = await async_get_config_entry_diagnostics( hass, config_entry ) diff --git a/custom_components/proxmoxve/manifest.json b/custom_components/proxmoxve/manifest.json index 859748f..ca9f3a3 100644 --- a/custom_components/proxmoxve/manifest.json +++ b/custom_components/proxmoxve/manifest.json @@ -1,12 +1,18 @@ { "domain": "proxmoxve", "name": "Proxmox VE", - "codeowners": ["@dougiteixeira"], + "codeowners": [ + "@dougiteixeira" + ], "config_flow": true, "documentation": "https://github.com/dougiteixeira/proxmoxve", "iot_class": "local_polling", "issue_tracker": "https://github.com/dougiteixeira/proxmoxve/issues", - "loggers": ["proxmoxer"], - "requirements": ["proxmoxer==2.0.1"], - "version": "3.5.2" -} + "loggers": [ + "proxmoxer" + ], + "requirements": [ + "proxmoxer==2.0.1" + ], + "version": "0.0.0" +} \ No newline at end of file diff --git a/custom_components/proxmoxve/models.py b/custom_components/proxmoxve/models.py index 45979be..29ed912 100644 --- a/custom_components/proxmoxve/models.py +++ b/custom_components/proxmoxve/models.py @@ -3,8 +3,10 @@ from __future__ import annotations import dataclasses +from typing import TYPE_CHECKING -from homeassistant.helpers.typing import UndefinedType +if TYPE_CHECKING: + from homeassistant.helpers.typing import UndefinedType @dataclasses.dataclass diff --git a/custom_components/proxmoxve/sensor.py b/custom_components/proxmoxve/sensor.py index eb2a3d0..d49828e 100644 --- a/custom_components/proxmoxve/sensor.py +++ b/custom_components/proxmoxve/sensor.py @@ -2,18 +2,17 @@ from __future__ import annotations -from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import timedelta -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final +import homeassistant.util.dt as dt_util from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -22,16 +21,10 @@ UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import homeassistant.util.dt as dt_util from . import async_migrate_old_unique_ids, device_info from .const import ( - LOGGER, CONF_LXC, CONF_NODES, CONF_QEMU, @@ -43,6 +36,15 @@ ) from .entity import ProxmoxEntity, ProxmoxEntityDescription +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from homeassistant.helpers.device_registry import DeviceInfo + from homeassistant.helpers.entity_platform import AddEntitiesCallback + from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + @dataclass(frozen=True, kw_only=True) class ProxmoxSensorEntityDescription(ProxmoxEntityDescription, SensorEntityDescription): @@ -489,7 +491,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor.""" - async_add_entities(await async_setup_sensors_nodes(hass, config_entry)) async_add_entities(await async_setup_sensors_qemu(hass, config_entry)) async_add_entities(await async_setup_sensors_lxc(hass, config_entry)) @@ -501,7 +502,6 @@ async def async_setup_sensors_nodes( config_entry: ConfigEntry, ) -> list: """Set up sensor.""" - sensors = [] migrate_unique_id_disks = [] @@ -516,14 +516,8 @@ async def async_setup_sensors_nodes( if coordinator.data is not None: for description in PROXMOX_SENSOR_NODES: if ( - ( - ( - data_value := getattr( - coordinator.data, description.key, False - ) - ) - #and data_value != UNDEFINED - ) + (data_value := getattr(coordinator.data, description.key, False)) + # and data_value != UNDEFINED or data_value == 0 or ( (value := description.value_fn) is not None @@ -579,11 +573,7 @@ async def async_setup_sensors_nodes( ) coordinator_disks_data: ProxmoxDiskData - for coordinator_disk in ( - coordinators[f"{ProxmoxType.Disk}_{node}"] - if f"{ProxmoxType.Disk}_{node}" in coordinators - else [] - ): + for coordinator_disk in coordinators.get(f"{ProxmoxType.Disk}_{node}", []): if (coordinator_disks_data := coordinator_disk.data) is None: continue @@ -635,7 +625,6 @@ async def async_setup_sensors_qemu( config_entry: ConfigEntry, ) -> list: """Set up sensor.""" - sensors = [] coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] @@ -650,36 +639,31 @@ async def async_setup_sensors_qemu( continue for description in PROXMOX_SENSOR_QEMU: - if description.api_category in (None, ProxmoxType.QEMU): - if ( - ( - ( - data_value := getattr( - coordinator.data, description.key, False - ) - ) - and data_value != UNDEFINED - ) - or data_value == 0 - or ( - (value := description.value_fn) is not None - and value(coordinator.data) is not None - ) - ): - sensors.append( - create_sensor( - coordinator=coordinator, - info_device=device_info( - hass=hass, - config_entry=config_entry, - api_category=ProxmoxType.QEMU, - resource_id=vm_id, - ), - description=description, - resource_id=vm_id, + if description.api_category in (None, ProxmoxType.QEMU) and ( + ( + (data_value := getattr(coordinator.data, description.key, False)) + and data_value != UNDEFINED + ) + or data_value == 0 + or ( + (value := description.value_fn) is not None + and value(coordinator.data) is not None + ) + ): + sensors.append( + create_sensor( + coordinator=coordinator, + info_device=device_info( + hass=hass, config_entry=config_entry, - ) + api_category=ProxmoxType.QEMU, + resource_id=vm_id, + ), + description=description, + resource_id=vm_id, + config_entry=config_entry, ) + ) return sensors @@ -689,7 +673,6 @@ async def async_setup_sensors_lxc( config_entry: ConfigEntry, ) -> list: """Set up sensor.""" - sensors = [] coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] @@ -704,36 +687,31 @@ async def async_setup_sensors_lxc( continue for description in PROXMOX_SENSOR_LXC: - if description.api_category in (None, ProxmoxType.LXC): - if ( - ( - ( - data_value := getattr( - coordinator.data, description.key, False - ) - ) - and data_value != UNDEFINED - ) - or data_value == 0 - or ( - (value := description.value_fn) is not None - and value(coordinator.data) is not None - ) - ): - sensors.append( - create_sensor( - coordinator=coordinator, - info_device=device_info( - hass=hass, - config_entry=config_entry, - api_category=ProxmoxType.LXC, - resource_id=ct_id, - ), - description=description, - resource_id=ct_id, + if description.api_category in (None, ProxmoxType.LXC) and ( + ( + (data_value := getattr(coordinator.data, description.key, False)) + and data_value != UNDEFINED + ) + or data_value == 0 + or ( + (value := description.value_fn) is not None + and value(coordinator.data) is not None + ) + ): + sensors.append( + create_sensor( + coordinator=coordinator, + info_device=device_info( + hass=hass, config_entry=config_entry, - ) + api_category=ProxmoxType.LXC, + resource_id=ct_id, + ), + description=description, + resource_id=ct_id, + config_entry=config_entry, ) + ) return sensors @@ -743,7 +721,6 @@ async def async_setup_sensors_storages( config_entry: ConfigEntry, ) -> list: """Set up sensor.""" - sensors = [] coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] @@ -758,37 +735,32 @@ async def async_setup_sensors_storages( continue for description in PROXMOX_SENSOR_STORAGE: - if description.api_category in (None, ProxmoxType.Storage): - if ( - ( - ( - data_value := getattr( - coordinator.data, description.key, False - ) - ) - and data_value != UNDEFINED - ) - or data_value == 0 - or ( - (value := description.value_fn) is not None - and value(coordinator.data) is not None - ) - ): - sensors.append( - create_sensor( - coordinator=coordinator, - info_device=device_info( - hass=hass, - config_entry=config_entry, - api_category=ProxmoxType.Storage, - resource_id=storage_id, - cordinator_resource=coordinator.data, - ), - description=description, - resource_id=storage_id, + if description.api_category in (None, ProxmoxType.Storage) and ( + ( + (data_value := getattr(coordinator.data, description.key, False)) + and data_value != UNDEFINED + ) + or data_value == 0 + or ( + (value := description.value_fn) is not None + and value(coordinator.data) is not None + ) + ): + sensors.append( + create_sensor( + coordinator=coordinator, + info_device=device_info( + hass=hass, config_entry=config_entry, - ) + api_category=ProxmoxType.Storage, + resource_id=storage_id, + cordinator_resource=coordinator.data, + ), + description=description, + resource_id=storage_id, + config_entry=config_entry, ) + ) return sensors @@ -833,7 +805,9 @@ def native_value(self) -> StateType: if (data := self.coordinator.data) is None: return None - if (not getattr(data, self.entity_description.key, False)) and getattr(data, self.entity_description.key, True) != 0: + if (not getattr(data, self.entity_description.key, False)) and getattr( + data, self.entity_description.key, True + ) != 0: if value := self.entity_description.value_fn: native_value = value(data) elif self.entity_description.key in ( @@ -861,7 +835,6 @@ def native_value(self) -> StateType: @property def available(self) -> bool: """Return sensor availability.""" - return super().available and self.coordinator.data is not None @property diff --git a/custom_components/proxmoxve/strings.json b/custom_components/proxmoxve/strings.json deleted file mode 100644 index e4c0094..0000000 --- a/custom_components/proxmoxve/strings.json +++ /dev/null @@ -1,327 +0,0 @@ -{ - "config": { - "step": { - "host": { - "description": "Proxmox host information", - "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "Password or token value", - "token_name": "Token name", - "realm": "Realm", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" - } - }, - "expose": { - "description": "Select the Proxmox instance nodes, Virtual Machines (QEMU), Containers (LXC) and Storages you want to expose", - "data": { - "nodes": "Nodes", - "qemu": "Virtual Machines (QEMU)", - "lxc": "Linux Containers (LXC)", - "storage": "Storages", - "disks_enable": "Enable physical disk information" - }, - "data_description": { - "disks_enable": "If you work with disk hibernation, you must disable this integration option so that it does not cause the disks to be reactivated unduly." - } - }, - "reauth_confirm": { - "description": "The username or password is invalid.", - "title": "[%key:common::config_flow::title::reauth%]", - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:component::proxmoxve::config::step::host::data::password%]", - "token_name": "[%key:component::proxmoxve::config::step::host::data::token_name%]" - } - } - }, - "error": { - "auth_error": "[%key:common::config_flow::error::invalid_auth%]", - "ssl_rejection": "Could not verify the SSL certificate", - "cant_connect": "[%key:common::config_flow::error::cannot_connect%]", - "general_error": "[%key:common::config_flow::error::unknown%]", - "invalid_port": "Invalid port number" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "no_resources": "No resources were returned for this instance." - } - }, - "issues": { - "import_success": { - "title": "{host}:{port} was imported from YAML configuration", - "description": "The YAML configuration of {host}:{port} instance of {integration} (`{platform}`) has been imported into the UI automatically.\n\nCan be safely removed from your `configuration.yaml` file." - }, - "import_invalid_port": { - "title": "Error in importing YAML configuration from {host}:{port}", - "description": "Importing YAML configuration from {host}:{port} instance of {integration} (`{platform}`) failed due to invalid port.\n\nYou must remove this configuration from your `configuration.yaml` file, restart Home Assistant and configure it manually." - }, - "import_auth_error": { - "title": "Error in importing YAML configuration from {host}:{port}", - "description": "Importing YAML configuration from {host}:{port} instance of {integration} (`{platform}`) failed due to authentication error.\n\nYou must remove this configuration from your `configuration.yaml` file, restart Home Assistant and configure it manually." - }, - "import_ssl_rejection": { - "title": "Error in importing YAML configuration from {host}:{port}", - "description": "Importing YAML configuration from {host}:{port} instance of {integration} (`{platform}`) failed due to SSL rejection.\n\nYou must remove this configuration from your `configuration.yaml` file, restart Home Assistant and configure it manually." - }, - "import_cant_connect": { - "title": "Error in importing YAML configuration from {host}:{port}", - "description": "Importing YAML configuration from {host}:{port} instance of {integration} (`{platform}`) failed due to connection failed.\n\nYou must remove this configuration from your `configuration.yaml` file, restart Home Assistant and configure it manually." - }, - "import_general_error": { - "title": "Error in importing YAML configuration from {host}:{port}", - "description": "Importing YAML configuration from {host}:{port} instance of {integration} (`{platform}`) failed due to unknown error.\n\nYou must remove this configuration from your `configuration.yaml` file, restart Home Assistant and configure it manually." - }, - "import_already_configured": { - "title": "The instance {host}:{port} already exists in the UI, can be removed", - "description": "The YAML configuration of instace {host}:{port} of {integration} (`{platform}`) already exists in the UI and was ignored on import.\n\nYou must remove this configuration from your `configuration.yaml` file and restart Home Assistant." - }, - "import_node_not_exist": { - "title": "Node {node} does not exist in {host}:{port}", - "description": "The {node} of the {host}:{port} instance of {integration} (`{platform}`) present in the YAML configuration does not exist in this instance and was ignored in the import.\n\nYou must remove this configuration from your `configuration.yaml` file and restart Home Assistant." - }, - "yaml_deprecated": { - "title": "Configuration of the {integration} in YAML is deprecated", - "description": "Configuration of the {integration} (`{platform}`) in YAML is deprecated and should be removed in {version}.\n\nResolve the import issues and remove the YAML configuration from your `configuration.yaml` file." - }, - "resource_nonexistent": { - "description": "{resource_type} {resource} does not exist on ({host}:{port}), remove it in integration options.\n\nThis can also be caused if the user doesn't have enough permission to access the resource.\n\nTip on required permissions:\n* `{permission}`", - "title": "{resource_type} {resource} does not exist" - }, - "no_permissions": { - "description": "The user `{user}` does not have the required permissions for all features.\n\nThe following features are not accessible by the user:\n`{errors}`\n\nCheck the user permissions as described in the documentation.", - "title": "User `{user}` does not have the required permissions" - }, - "resource_exception_forbiden": { - "description": "User `{user}` does not have sufficient permissions to access resource `{resource}`.\n\nTip on required permissions:\n* `{permission}`\n\nPlease check documentation and user permissions.", - "title": "Permissions error for `{resource}`" - }, - "resource_command_forbiden": { - "description": "User `{user}` does not have sufficient permissions to execute command `{command}` on resource `{resource}`.\n\nTip on required permissions:\n* `{permission}`\n\nPlease check documentation and user permissions.", - "title": "Permission error for `{resource}` command" - } - }, - "options": { - "step": { - "menu": { - "menu_options": { - "host_auth": "Change host authentication information", - "change_expose": "Add or remove Nodes, VMs, Containers or Storages" - } - }, - "host_auth": { - "description": "[%key:component::proxmoxve::config::step::host::description%]", - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:component::proxmoxve::config::step::host::data::password%]", - "token_name": "[%key:component::proxmoxve::config::step::host::data::token_name%]", - "realm": "[%key:component::proxmoxve::config::step::host::data::realm%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" - } - }, - "change_expose": { - "description": "[%key:component::proxmoxve::config::step::expose::description%]", - "data": { - "nodes": "[%key:component::proxmoxve::config::step::expose::data::nodes%]", - "qemu": "[%key:component::proxmoxve::config::step::expose::data::qemu%]", - "lxc": "[%key:component::proxmoxve::config::step::expose::data::lxc%]", - "storage": "[%key:component::proxmoxve::config::step::expose::data::storage%]", - "disks_enable": "[%key:component::proxmoxve::config::step::expose::data::disks_enable%]" - }, - "data_description": { - "disks_enable": "[%key:component::proxmoxve::config::step::expose::data_description::disks_enable%]" - } - } - }, - "error": { - "auth_error": "[%key:common::config_flow::error::invalid_auth%]", - "ssl_rejection": "[%key:component::proxmoxve::config::error::ssl_rejection%]", - "cant_connect": "[%key:common::config_flow::error::cannot_connect%]", - "general_error": "[%key:common::config_flow::error::unknown%]", - "invalid_port": "[%key:component::proxmoxve::config::error::invalid_port%]" - }, - "abort": { - "no_nodes": "No nodes were returned for the host.", - "no_vms": "There are no virtual machines or containers for this node, the configuration entry will be created for the node.", - "changes_successful": "Changes saved successfully.", - "no_nodes_to_add": "No nodes to add.", - "node_already_exists": "The selected node already exists.", - "auth_error": "[%key:common::config_flow::error::invalid_auth%]", - "ssl_rejection": "[%key:component::proxmoxve::config::error::ssl_rejection%]", - "cant_connect": "[%key:common::config_flow::error::cannot_connect%]", - "general_error": "[%key:common::config_flow::error::unknown%]", - "invalid_port": "[%key:component::proxmoxve::config::error::invalid_port%]" - } - }, - "entity": { - "binary_sensor": { - "status": { - "name": "Status" - }, - "health": { - "name": "Health" - }, - "update_avail": { - "name": "Updates packages" - } - }, - "button": { - "start_all": { - "name": "Start all" - }, - "stop_all": { - "name": "Stop all" - }, - "shutdown": { - "name": "Shutdown" - }, - "reboot": { - "name": "Reboot" - }, - "start": { - "name": "Start" - }, - "stop": { - "name": "Stop" - }, - "resume": { - "name": "Resume" - }, - "suspend": { - "name": "Suspend" - }, - "reset": { - "name": "Reset" - }, - "wakeonlan": { - "name": "Wake-on-LAN" - } - }, - "sensor": { - "cpu_used": { - "name": "CPU used" - }, - "disk_free": { - "name": "Disk free" - }, - "disk_free_perc": { - "name": "Disk free percentage" - }, - "disk_rpm": { - "name": "Disk speed" - }, - "disk_size": { - "name": "Size" - }, - "disk_total": { - "name": "Disk total" - }, - "disk_used": { - "name": "Disk used" - }, - "disk_used_perc": { - "name": "Disk used percentage" - }, - "disk_wearout": { - "name": "Wearout" - }, - "life_left": { - "name": "Life left" - }, - "lxc_on": { - "name": "Containers running", - "state_attributes": { - "lxc_on_list": { - "name": "Containers list" - } - } - }, - "memory_free": { - "name": "Memory free" - }, - "memory_free_perc": { - "name": "Memory free percentage" - }, - "memory_total": { - "name": "Memory total" - }, - "memory_used": { - "name": "Memory used" - }, - "memory_used_perc": { - "name": "Memory used percentage" - }, - "network_in": { - "name": "Network in" - }, - "network_out": { - "name": "Network out" - }, - "power_cycles": { - "name": "Power cycles" - }, - "power_loss": { - "name": "Unexpected power loss" - }, - "power_hours": { - "name": "Power-on hours" - }, - "qemu_on": { - "name": "Virtual machines running", - "state_attributes": { - "qemu_on_list": { - "name": "Virtual machines list" - } - } - }, - "node": { - "name": "Node" - }, - "status_raw": { - "name": "Status", - "state": { - "internal-error": "Internal error", - "prelaunch": "Pre launch", - "paused": "Paused", - "stopped": "Stopped", - "suspended": "Suspended", - "running": "Running" - } - }, - "swap_free": { - "name": "Swap free" - }, - "swap_free_perc": { - "name": "Swap free percentage" - }, - "swap_total": { - "name": "Swap total" - }, - "swap_used": { - "name": "Swap used" - }, - "swap_used_perc": { - "name": "Swap used percentage" - }, - "temperature": { - "name": "Temperature" - }, - "temperature_air": { - "name": "Airflow Temperature" - }, - "updates_total": { - "name": "Total updates", - "state_attributes": { - "updates_list": { - "name": "Updates list" - } - } - }, - "uptime": { - "name": "Last boot" - } - } - } -} diff --git a/hacs.json b/hacs.json index 6d16262..68ded0d 100644 --- a/hacs.json +++ b/hacs.json @@ -6,6 +6,7 @@ "sensor" ], "iot_class": "local_push", + "hide_default_branch": false, "homeassistant": "2024.1.0", "render_readme": true } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4725a4b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +colorlog==6.9.0 +homeassistant==2024.12.1 +pip>=21.3.1 +proxmoxer==2.0.1 +ruff==0.7.2 \ No newline at end of file diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 0000000..4ec6c4a --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/proxmoxve +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug \ No newline at end of file diff --git a/scripts/lint b/scripts/lint new file mode 100644 index 0000000..c13df0e --- /dev/null +++ b/scripts/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff format . +ruff check . --fix \ No newline at end of file diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 0000000..abe537a --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt \ No newline at end of file