├── LICENSE ├── Pipfile ├── README.md ├── custom_components └── openmediavault │ ├── __init__.py │ ├── apiparser.py │ ├── binary_sensor.py │ ├── binary_sensor_types.py │ ├── config_flow.py │ ├── const.py │ ├── diagnostics.py │ ├── helper.py │ ├── manifest.json │ ├── model.py │ ├── omv_api.py │ ├── omv_controller.py │ ├── sensor.py │ ├── sensor_types.py │ ├── services.yaml │ ├── strings.json │ └── translations │ ├── en.json │ ├── nl.json │ ├── pt_BR.json │ └── sv_SE.json ├── hacs.json ├── info.md ├── setup.cfg └── sonar-project.properties /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | wheel = ">=0.34" 8 | pygithub = ">=1.47" 9 | homeassistant = ">=0.110.0" 10 | pre-commit = "==2.2.0" 11 | pylint = "==2.4.4" 12 | pylint-strict-informational = "==0.1" 13 | 14 | [packages] 15 | 16 | [requires] 17 | python_version = ">=3.9" 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenMediaVault integration for Home Assistant 2 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/tomaae/homeassistant-openmediavault?style=plastic) 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=plastic)](https://github.com/hacs/integration) 4 | ![Project Stage](https://img.shields.io/badge/project%20stage-development-yellow.svg?style=plastic) 5 | ![GitHub all releases](https://img.shields.io/github/downloads/tomaae/homeassistant-openmediavault/total?style=plastic) 6 | 7 | ![GitHub commits since latest release](https://img.shields.io/github/commits-since/tomaae/homeassistant-openmediavault/latest?style=plastic) 8 | ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/tomaae/homeassistant-openmediavault?style=plastic) 9 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/tomaae/homeassistant-openmediavault/ci.yml?style=plastic) 10 | 11 | [![Help localize](https://img.shields.io/badge/lokalise-join-green?style=plastic&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6REVCNzgzOEY4NDYxMTFFQUIyMEY4Njc0NzVDOUZFMkMiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6REVCNzgzOEU4NDYxMTFFQUIyMEY4Njc0NzVDOUZFMkMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTcgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDozN0ZDRUY4Rjc0M0UxMUU3QUQ2MDg4M0Q0MkE0NjNCNSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDozN0ZDRUY5MDc0M0UxMUU3QUQ2MDg4M0Q0MkE0NjNCNSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pjs1zyIAAABVSURBVHjaYvz//z8DOYCJgUxAtkYW9+mXyXIrI7l+ZGHc0k5nGxkupdHZxve1yQR1CjbPZURXh9dGoGJZIPUI2QC4JEgjIfyuJuk/uhgj3dMqQIABAPEGTZ/+h0kEAAAAAElFTkSuQmCC)](https://app.lokalise.com/public/106503135ea170ab5e1f70.96389313/) 12 | 13 | ![English](https://raw.githubusercontent.com/tomaae/homeassistant-mikrotik_router/master/docs/assets/images/flags/us.png) 14 | 15 | ![OpenMediaVault Logo](https://raw.githubusercontent.com/tomaae/homeassistant-openmediavault/master/docs/assets/images/ui/header.png) 16 | 17 | Monitor your OpenMediaVault 5/6 NAS from Home Assistant. 18 | 19 | Features: 20 | * Filesystem usage sensors 21 | * System sensors (CPU, Memory, Uptime) 22 | * System status sensors (Available updates, Required reboot and Dirty config) 23 | * Disk and smart sensors 24 | * Service sensors 25 | 26 | # Features 27 | ## Filesystem usage 28 | Monitor your filesystem usage. 29 | 30 | ![Filesystem usage](https://raw.githubusercontent.com/tomaae/homeassistant-openmediavault/master/docs/assets/images/ui/filesystem_sensor.png) 31 | 32 | ## System 33 | Monitor your OpenMediaVault system. 34 | 35 | ![System](https://raw.githubusercontent.com/tomaae/homeassistant-openmediavault/master/docs/assets/images/ui/system_sensors.png) 36 | 37 | ## Disk smart 38 | Monitor your disks. 39 | 40 | ![Disk info](https://raw.githubusercontent.com/tomaae/homeassistant-openmediavault/master/docs/assets/images/ui/disk_sensor.png) 41 | 42 | # Install integration 43 | This integration is distributed using [HACS](https://hacs.xyz/). 44 | 45 | You can find it under "Integrations", named "OpenMediaVault" 46 | 47 | ## Setup integration 48 | Setup this integration for your OpenMediaVault NAS in Home Assistant via `Configuration -> Integrations -> Add -> OpenMediaVault`. 49 | You can add this integration several times for different devices. 50 | 51 | ![Add Integration](https://raw.githubusercontent.com/tomaae/homeassistant-openmediavault/master/docs/assets/images/ui/setup_integration.png) 52 | * "Name of the integration" - Friendly name for this NAS 53 | * "Host" - Use hostname or IP 54 | * "Use SSL" - Connect to OMV using SSL 55 | * "Verify SSL certificate" - Validate SSL certificate (must be trusted certificate) 56 | 57 | # Development 58 | ## Translation 59 | To help out with the translation you need an account on Lokalise, the easiest way to get one is to [click here](https://lokalise.com/login/) then select "Log in with GitHub". 60 | After you have created your account [click here to join OpenMediaVault project on Lokalise](https://app.lokalise.com/public/106503135ea170ab5e1f70.96389313/). 61 | 62 | If you want to add translations for a language that is not listed please [open a Feature request](https://github.com/tomaae/homeassistant-openmediavault/issues/new?labels=enhancement&title=%5BLokalise%5D%20Add%20new%20translations%20language). 63 | 64 | ## Enabling debug 65 | To enable debug for OpenMediaVault integration, add following to your configuration.yaml: 66 | ``` 67 | logger: 68 | default: info 69 | logs: 70 | custom_components.openmediavault: debug 71 | ``` 72 | -------------------------------------------------------------------------------- /custom_components/openmediavault/__init__.py: -------------------------------------------------------------------------------- 1 | """The OpenMediaVault integration.""" 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.exceptions import ConfigEntryNotReady 5 | 6 | from .const import DOMAIN, PLATFORMS 7 | from .omv_controller import OMVControllerData 8 | 9 | 10 | # --------------------------- 11 | # async_setup 12 | # --------------------------- 13 | async def async_setup(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 14 | """Set up configured OMV Controller.""" 15 | hass.data[DOMAIN] = {} 16 | return True 17 | 18 | 19 | # --------------------------- 20 | # update_listener 21 | # --------------------------- 22 | async def _async_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): 23 | """Handle options update.""" 24 | await hass.config_entries.async_reload(config_entry.entry_id) 25 | 26 | 27 | # --------------------------- 28 | # async_setup_entry 29 | # --------------------------- 30 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 31 | """Set up OMV config entry.""" 32 | hass.data.setdefault(DOMAIN, {}) 33 | controller = OMVControllerData(hass, config_entry) 34 | await controller.async_hwinfo_update() 35 | await controller.async_update() 36 | 37 | if not controller.data: 38 | raise ConfigEntryNotReady() 39 | 40 | await controller.async_init() 41 | hass.data[DOMAIN][config_entry.entry_id] = controller 42 | 43 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 44 | config_entry.async_on_unload( 45 | config_entry.add_update_listener(_async_update_listener) 46 | ) 47 | 48 | return True 49 | 50 | 51 | # --------------------------- 52 | # async_unload_entry 53 | # --------------------------- 54 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 55 | """Unload TrueNAS config entry.""" 56 | unload_ok = await hass.config_entries.async_unload_platforms( 57 | config_entry, PLATFORMS 58 | ) 59 | if unload_ok: 60 | controller = hass.data[DOMAIN][config_entry.entry_id] 61 | await controller.async_reset() 62 | hass.data[DOMAIN].pop(config_entry.entry_id) 63 | 64 | return True 65 | -------------------------------------------------------------------------------- /custom_components/openmediavault/apiparser.py: -------------------------------------------------------------------------------- 1 | """API parser for JSON APIs""" 2 | import re 3 | from pytz import utc 4 | from logging import getLogger 5 | from datetime import datetime 6 | from voluptuous import Optional 7 | from homeassistant.components.diagnostics import async_redact_data 8 | from .const import TO_REDACT 9 | 10 | _LOGGER = getLogger(__name__) 11 | 12 | 13 | # --------------------------- 14 | # utc_from_timestamp 15 | # --------------------------- 16 | def utc_from_timestamp(timestamp: float) -> datetime: 17 | """Return a UTC time from a timestamp""" 18 | return utc.localize(datetime.utcfromtimestamp(timestamp)) 19 | 20 | 21 | # --------------------------- 22 | # from_entry 23 | # --------------------------- 24 | def from_entry(entry, param, default="") -> str: 25 | """Validate and return str value an API dict""" 26 | if "/" in param: 27 | for tmp_param in param.split("/"): 28 | if isinstance(entry, dict) and tmp_param in entry: 29 | entry = entry[tmp_param] 30 | ret = entry 31 | else: 32 | ret = default 33 | break 34 | 35 | elif param in entry: 36 | ret = entry[param] 37 | else: 38 | ret = default 39 | 40 | if default != "": 41 | if isinstance(default, str): 42 | ret = str(ret) 43 | elif isinstance(default, int): 44 | if not isinstance(ret, int): 45 | if ret == "": 46 | ret = 0 47 | 48 | try: 49 | ret = int(ret) 50 | except Exception: 51 | ret = re.search(r"[0-9]+", ret) 52 | ret = ret.group() if ret else 0 53 | elif isinstance(default, float): 54 | if not isinstance(ret, float): 55 | if ret == "": 56 | ret = 0 57 | 58 | try: 59 | ret = float(ret) 60 | except Exception: 61 | ret = re.search(r"[0-9]+[.,]?[0-9]*", ret) 62 | ret = ret.group() if ret else 0 63 | 64 | ret = round(ret, 2) 65 | 66 | return ret[:255] if isinstance(ret, str) and len(ret) > 255 else ret 67 | 68 | 69 | # --------------------------- 70 | # from_entry_bool 71 | # --------------------------- 72 | def from_entry_bool(entry, param, default=False, reverse=False) -> bool: 73 | """Validate and return a bool value from an API dict""" 74 | if "/" in param: 75 | for tmp_param in param.split("/"): 76 | if isinstance(entry, dict) and tmp_param in entry: 77 | entry = entry[tmp_param] 78 | else: 79 | return default 80 | 81 | ret = entry 82 | elif param in entry: 83 | ret = entry[param] 84 | else: 85 | return default 86 | 87 | if isinstance(ret, str): 88 | if ret in ("on", "On", "ON", "yes", "Yes", "YES", "up", "Up", "UP"): 89 | ret = True 90 | elif ret in ("off", "Off", "OFF", "no", "No", "NO", "down", "Down", "DOWN"): 91 | ret = False 92 | 93 | if not isinstance(ret, bool): 94 | ret = default 95 | 96 | if reverse: 97 | return not ret 98 | 99 | return ret 100 | 101 | 102 | # --------------------------- 103 | # parse_api 104 | # --------------------------- 105 | def parse_api( 106 | data=None, 107 | source=None, 108 | key=None, 109 | key_secondary=None, 110 | key_search=None, 111 | vals=None, 112 | val_proc=None, 113 | ensure_vals=None, 114 | only=None, 115 | skip=None, 116 | ) -> dict: 117 | """Get data from API""" 118 | debug = _LOGGER.getEffectiveLevel() == 10 119 | if type(source) == dict: 120 | tmp = source 121 | source = [tmp] 122 | 123 | if not source: 124 | if not key and not key_search: 125 | data = fill_defaults(data, vals) 126 | return data 127 | 128 | if debug: 129 | _LOGGER.debug("Processing source %s", async_redact_data(source, TO_REDACT)) 130 | 131 | keymap = generate_keymap(data, key_search) 132 | for entry in source: 133 | if only and not matches_only(entry, only): 134 | continue 135 | 136 | if skip and can_skip(entry, skip): 137 | continue 138 | 139 | uid = None 140 | if key or key_search: 141 | uid = get_uid(entry, key, key_secondary, key_search, keymap) 142 | if not uid: 143 | # ZFS filesystems don't have a UUID, so use devicefile instead. 144 | if "type" in entry and entry["type"] == "zfs": 145 | uid = entry["devicefile"] 146 | entry["uuid"] = uid 147 | else: 148 | continue 149 | if uid not in data: 150 | data[uid] = {} 151 | 152 | if debug: 153 | _LOGGER.debug("Processing entry %s", async_redact_data(entry, TO_REDACT)) 154 | 155 | if vals: 156 | data = fill_vals(data, entry, uid, vals) 157 | 158 | if ensure_vals: 159 | data = fill_ensure_vals(data, uid, ensure_vals) 160 | 161 | if val_proc: 162 | data = fill_vals_proc(data, uid, val_proc) 163 | 164 | return data 165 | 166 | 167 | # --------------------------- 168 | # get_uid 169 | # --------------------------- 170 | def get_uid(entry, key, key_secondary, key_search, keymap) -> Optional(str): 171 | """Get UID for data list""" 172 | uid = None 173 | if not key_search: 174 | key_primary_found = key in entry 175 | if key_primary_found and key not in entry and not entry[key]: 176 | return None 177 | 178 | if key_primary_found: 179 | uid = entry[key] 180 | elif key_secondary: 181 | if key_secondary not in entry: 182 | return None 183 | 184 | if not entry[key_secondary]: 185 | return None 186 | 187 | uid = entry[key_secondary] 188 | elif keymap and key_search in entry and entry[key_search] in keymap: 189 | uid = keymap[entry[key_search]] 190 | else: 191 | return None 192 | 193 | return uid or None 194 | 195 | 196 | # --------------------------- 197 | # generate_keymap 198 | # --------------------------- 199 | def generate_keymap(data, key_search) -> Optional(dict): 200 | """Generate keymap""" 201 | return ( 202 | {data[uid][key_search]: uid for uid in data if key_search in data[uid]} 203 | if key_search 204 | else None 205 | ) 206 | 207 | 208 | # --------------------------- 209 | # matches_only 210 | # --------------------------- 211 | def matches_only(entry, only) -> bool: 212 | """Return True if all variables are matched""" 213 | ret = False 214 | for val in only: 215 | if val["key"] in entry and entry[val["key"]] == val["value"]: 216 | ret = True 217 | else: 218 | ret = False 219 | break 220 | 221 | return ret 222 | 223 | 224 | # --------------------------- 225 | # can_skip 226 | # --------------------------- 227 | def can_skip(entry, skip) -> bool: 228 | """Return True if at least one variable matches""" 229 | ret = False 230 | for val in skip: 231 | if val["name"] in entry and entry[val["name"]] == val["value"]: 232 | ret = True 233 | break 234 | 235 | if val["value"] == "" and val["name"] not in entry: 236 | ret = True 237 | break 238 | 239 | return ret 240 | 241 | 242 | # --------------------------- 243 | # fill_defaults 244 | # --------------------------- 245 | def fill_defaults(data, vals) -> dict: 246 | """Fill defaults if source is not present""" 247 | for val in vals: 248 | _name = val["name"] 249 | _type = val["type"] if "type" in val else "str" 250 | _source = val["source"] if "source" in val else _name 251 | 252 | if _type == "str": 253 | _default = val["default"] if "default" in val else "" 254 | if "default_val" in val and val["default_val"] in val: 255 | _default = val[val["default_val"]] 256 | 257 | if _name not in data: 258 | data[_name] = from_entry([], _source, default=_default) 259 | 260 | elif _type == "bool": 261 | _default = val["default"] if "default" in val else False 262 | _reverse = val["reverse"] if "reverse" in val else False 263 | if _name not in data: 264 | data[_name] = from_entry_bool( 265 | [], _source, default=_default, reverse=_reverse 266 | ) 267 | 268 | return data 269 | 270 | 271 | # --------------------------- 272 | # fill_vals 273 | # --------------------------- 274 | def fill_vals(data, entry, uid, vals) -> dict: 275 | """Fill all data""" 276 | for val in vals: 277 | _name = val["name"] 278 | _type = val["type"] if "type" in val else "str" 279 | _source = val["source"] if "source" in val else _name 280 | _convert = val["convert"] if "convert" in val else None 281 | 282 | if _type == "str": 283 | _default = val["default"] if "default" in val else "" 284 | if "default_val" in val and val["default_val"] in val: 285 | _default = val[val["default_val"]] 286 | 287 | if uid: 288 | data[uid][_name] = from_entry(entry, _source, default=_default) 289 | else: 290 | data[_name] = from_entry(entry, _source, default=_default) 291 | 292 | elif _type == "bool": 293 | _default = val["default"] if "default" in val else False 294 | _reverse = val["reverse"] if "reverse" in val else False 295 | 296 | if uid: 297 | data[uid][_name] = from_entry_bool( 298 | entry, _source, default=_default, reverse=_reverse 299 | ) 300 | else: 301 | data[_name] = from_entry_bool( 302 | entry, _source, default=_default, reverse=_reverse 303 | ) 304 | 305 | if _convert == "utc_from_timestamp": 306 | if uid: 307 | if isinstance(data[uid][_name], int) and data[uid][_name] > 0: 308 | if data[uid][_name] > 100000000000: 309 | data[uid][_name] = data[uid][_name] / 1000 310 | 311 | data[uid][_name] = utc_from_timestamp(data[uid][_name]) 312 | elif isinstance(data[_name], int) and data[_name] > 0: 313 | if data[_name] > 100000000000: 314 | data[_name] = data[_name] / 1000 315 | 316 | data[_name] = utc_from_timestamp(data[_name]) 317 | 318 | return data 319 | 320 | 321 | # --------------------------- 322 | # fill_ensure_vals 323 | # --------------------------- 324 | def fill_ensure_vals(data, uid, ensure_vals) -> dict: 325 | """Add required keys which are not available in data""" 326 | for val in ensure_vals: 327 | if uid: 328 | if val["name"] not in data[uid]: 329 | _default = val["default"] if "default" in val else "" 330 | data[uid][val["name"]] = _default 331 | 332 | elif val["name"] not in data: 333 | _default = val["default"] if "default" in val else "" 334 | data[val["name"]] = _default 335 | 336 | return data 337 | 338 | 339 | # --------------------------- 340 | # fill_vals_proc 341 | # --------------------------- 342 | def fill_vals_proc(data, uid, vals_proc) -> dict: 343 | """Add custom keys""" 344 | _data = data[uid] if uid else data 345 | for val_sub in vals_proc: 346 | _name = None 347 | _action = None 348 | _value = None 349 | for val in val_sub: 350 | if "name" in val: 351 | _name = val["name"] 352 | continue 353 | 354 | if "action" in val: 355 | _action = val["action"] 356 | continue 357 | 358 | if not _name and not _action: 359 | break 360 | 361 | if _action == "combine": 362 | if "key" in val: 363 | tmp = _data[val["key"]] if val["key"] in _data else "unknown" 364 | _value = f"{_value}{tmp}" if _value else tmp 365 | 366 | if "text" in val: 367 | tmp = val["text"] 368 | _value = f"{_value}{tmp}" if _value else tmp 369 | 370 | if _name and _value: 371 | if uid: 372 | data[uid][_name] = _value 373 | else: 374 | data[_name] = _value 375 | 376 | return data 377 | -------------------------------------------------------------------------------- /custom_components/openmediavault/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """OpenMediaVault binary sensor platform.""" 2 | from logging import getLogger 3 | from homeassistant.components.binary_sensor import BinarySensorEntity 4 | from .model import model_async_setup_entry, OMVEntity 5 | from .binary_sensor_types import SENSOR_TYPES, SENSOR_SERVICES 6 | 7 | _LOGGER = getLogger(__name__) 8 | 9 | 10 | # --------------------------- 11 | # async_setup_entry 12 | # --------------------------- 13 | async def async_setup_entry(hass, config_entry, async_add_entities): 14 | """Set up device tracker for OpenMediaVault component""" 15 | dispatcher = { 16 | "OMVBinarySensor": OMVBinarySensor, 17 | } 18 | await model_async_setup_entry( 19 | hass, 20 | config_entry, 21 | async_add_entities, 22 | SENSOR_SERVICES, 23 | SENSOR_TYPES, 24 | dispatcher, 25 | ) 26 | 27 | 28 | # --------------------------- 29 | # OMVBinarySensor 30 | # --------------------------- 31 | class OMVBinarySensor(OMVEntity, BinarySensorEntity): 32 | """Define an OpenMediaVault binary sensor.""" 33 | 34 | @property 35 | def is_on(self) -> bool: 36 | """Return true if device is on""" 37 | return self._data[self.entity_description.data_is_on] 38 | 39 | @property 40 | def icon(self) -> str: 41 | """Return the icon""" 42 | if self.entity_description.icon_enabled: 43 | if self._data[self.entity_description.data_is_on]: 44 | return self.entity_description.icon_enabled 45 | else: 46 | return self.entity_description.icon_disabled 47 | -------------------------------------------------------------------------------- /custom_components/openmediavault/binary_sensor_types.py: -------------------------------------------------------------------------------- 1 | """Definitions for OMV binary sensor entities.""" 2 | from dataclasses import dataclass, field 3 | from typing import List 4 | from homeassistant.helpers.entity import EntityCategory 5 | from homeassistant.components.binary_sensor import ( 6 | BinarySensorDeviceClass, 7 | BinarySensorEntityDescription, 8 | ) 9 | 10 | from .const import DOMAIN 11 | 12 | 13 | DEVICE_ATTRIBUTES_SERVICE = [ 14 | "name", 15 | "enabled", 16 | ] 17 | 18 | 19 | @dataclass 20 | class OMVBinarySensorEntityDescription(BinarySensorEntityDescription): 21 | """Class describing OMV entities.""" 22 | 23 | icon_enabled: str = "" 24 | icon_disabled: str = "" 25 | ha_group: str = "" 26 | ha_connection: str = "" 27 | ha_connection_value: str = "" 28 | data_path: str = "" 29 | data_is_on: str = "available" 30 | data_name: str = "" 31 | data_uid: str = "" 32 | data_reference: str = "" 33 | data_attributes_list: List = field(default_factory=lambda: []) 34 | func: str = "OMVBinarySensor" 35 | 36 | 37 | SENSOR_TYPES = { 38 | "system_pkgUpdatesAvailable": OMVBinarySensorEntityDescription( 39 | key="system_pkgUpdatesAvailable", 40 | name="Update available", 41 | icon_enabled="", 42 | icon_disabled="", 43 | device_class=BinarySensorDeviceClass.UPDATE, 44 | entity_category=EntityCategory.DIAGNOSTIC, 45 | ha_group="System", 46 | data_path="hwinfo", 47 | data_is_on="pkgUpdatesAvailable", 48 | data_name="", 49 | data_uid="", 50 | data_reference="", 51 | ), 52 | "system_rebootRequired": OMVBinarySensorEntityDescription( 53 | key="system_rebootRequired", 54 | name="Reboot pending", 55 | icon_enabled="", 56 | icon_disabled="", 57 | device_class=None, 58 | entity_category=EntityCategory.DIAGNOSTIC, 59 | ha_group="System", 60 | data_path="hwinfo", 61 | data_is_on="rebootRequired", 62 | data_name="", 63 | data_uid="", 64 | data_reference="", 65 | ), 66 | "system_configDirty": OMVBinarySensorEntityDescription( 67 | key="system_configDirty", 68 | name="Config dirty", 69 | icon_enabled="", 70 | icon_disabled="", 71 | device_class=None, 72 | entity_category=EntityCategory.DIAGNOSTIC, 73 | ha_group="System", 74 | data_path="hwinfo", 75 | data_is_on="configDirty", 76 | data_name="", 77 | data_uid="", 78 | data_reference="", 79 | ), 80 | "service": OMVBinarySensorEntityDescription( 81 | key="service", 82 | name="service", 83 | icon_enabled="mdi:cog", 84 | icon_disabled="mdi:cog-off", 85 | device_class=None, 86 | entity_category=EntityCategory.DIAGNOSTIC, 87 | ha_group="Services", 88 | ha_connection=DOMAIN, 89 | ha_connection_value="Services", 90 | data_path="service", 91 | data_is_on="running", 92 | data_name="title", 93 | data_uid="name", 94 | data_reference="name", 95 | data_attributes_list=DEVICE_ATTRIBUTES_SERVICE, 96 | ), 97 | } 98 | 99 | SENSOR_SERVICES = [] 100 | -------------------------------------------------------------------------------- /custom_components/openmediavault/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to configure OpenMediaVault.""" 2 | 3 | import voluptuous as vol 4 | import logging 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | from homeassistant.config_entries import ( 8 | CONN_CLASS_LOCAL_POLL, 9 | ConfigFlow, 10 | OptionsFlow, 11 | ) 12 | from homeassistant.const import ( 13 | CONF_HOST, 14 | CONF_NAME, 15 | CONF_PASSWORD, 16 | CONF_SSL, 17 | CONF_VERIFY_SSL, 18 | CONF_USERNAME, 19 | ) 20 | from homeassistant.core import callback 21 | 22 | from .const import ( 23 | DEFAULT_DEVICE_NAME, 24 | DEFAULT_HOST, 25 | DEFAULT_SSL, 26 | DEFAULT_SSL_VERIFY, 27 | DEFAULT_USERNAME, 28 | DOMAIN, 29 | CONF_SCAN_INTERVAL, 30 | DEFAULT_SCAN_INTERVAL, 31 | CONF_SMART_DISABLE, 32 | DEFAULT_SMART_DISABLE, 33 | ) 34 | from .omv_api import OpenMediaVaultAPI 35 | 36 | 37 | # --------------------------- 38 | # configured_instances 39 | # --------------------------- 40 | @callback 41 | def configured_instances(hass): 42 | """Return a set of configured instances.""" 43 | return set( 44 | entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN) 45 | ) 46 | 47 | 48 | # --------------------------- 49 | # OMVConfigFlow 50 | # --------------------------- 51 | class OMVConfigFlow(ConfigFlow, domain=DOMAIN): 52 | """OMVConfigFlow class.""" 53 | 54 | VERSION = 1 55 | CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL 56 | 57 | def __init__(self): 58 | """Initialize OMVConfigFlow.""" 59 | 60 | @staticmethod 61 | @callback 62 | def async_get_options_flow(config_entry): 63 | """Get the options flow for this handler.""" 64 | return OMVOptionsFlowHandler(config_entry) 65 | 66 | async def async_step_import(self, user_input=None): 67 | """Occurs when a previous entry setup fails and is re-initiated.""" 68 | return await self.async_step_user(user_input) 69 | 70 | async def async_step_user(self, user_input=None): 71 | """Handle a flow initialized by the user.""" 72 | errors = {} 73 | if user_input is not None: 74 | # Check if instance with this name already exists 75 | if user_input[CONF_NAME] in configured_instances(self.hass): 76 | errors["base"] = "name_exists" 77 | 78 | # Test connection 79 | api = await self.hass.async_add_executor_job( 80 | OpenMediaVaultAPI, 81 | self.hass, 82 | user_input[CONF_HOST], 83 | user_input[CONF_USERNAME], 84 | user_input[CONF_PASSWORD], 85 | user_input[CONF_SSL], 86 | user_input[CONF_VERIFY_SSL], 87 | ) 88 | 89 | if not await self.hass.async_add_executor_job(api.connect): 90 | _LOGGER.error("OpenMediaVault %s connect error", api.error) 91 | errors[CONF_HOST] = api.error 92 | 93 | # Save instance 94 | if not errors: 95 | return self.async_create_entry( 96 | title=user_input[CONF_NAME], data=user_input 97 | ) 98 | 99 | return self._show_config_form(user_input=user_input, errors=errors) 100 | 101 | return self._show_config_form( 102 | user_input={ 103 | CONF_NAME: DEFAULT_DEVICE_NAME, 104 | CONF_HOST: DEFAULT_HOST, 105 | CONF_USERNAME: DEFAULT_USERNAME, 106 | CONF_PASSWORD: DEFAULT_USERNAME, 107 | CONF_SSL: DEFAULT_SSL, 108 | CONF_VERIFY_SSL: DEFAULT_SSL_VERIFY, 109 | }, 110 | errors=errors, 111 | ) 112 | 113 | def _show_config_form(self, user_input, errors=None): 114 | """Show the configuration form.""" 115 | return self.async_show_form( 116 | step_id="user", 117 | data_schema=vol.Schema( 118 | { 119 | vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, 120 | vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, 121 | vol.Required(CONF_USERNAME, default=user_input[CONF_USERNAME]): str, 122 | vol.Required(CONF_PASSWORD, default=user_input[CONF_PASSWORD]): str, 123 | vol.Optional(CONF_SSL, default=user_input[CONF_SSL]): bool, 124 | vol.Optional( 125 | CONF_VERIFY_SSL, default=user_input[CONF_VERIFY_SSL] 126 | ): bool, 127 | } 128 | ), 129 | errors=errors, 130 | ) 131 | 132 | 133 | # --------------------------- 134 | # OMVOptionsFlowHandler 135 | # --------------------------- 136 | class OMVOptionsFlowHandler(OptionsFlow): 137 | """Handle options.""" 138 | 139 | def __init__(self, config_entry): 140 | """Initialize options flow.""" 141 | self.config_entry = config_entry 142 | self.options = dict(config_entry.options) 143 | 144 | async def async_step_init(self, user_input=None): 145 | """Manage the options.""" 146 | return await self.async_step_basic_options(user_input) 147 | 148 | async def async_step_basic_options(self, user_input=None): 149 | """Manage the basic options.""" 150 | if user_input is not None: 151 | self.options.update(user_input) 152 | return self.async_create_entry(title="", data=self.options) 153 | 154 | return self.async_show_form( 155 | step_id="basic_options", 156 | last_step=True, 157 | data_schema=vol.Schema( 158 | { 159 | vol.Optional( 160 | CONF_SCAN_INTERVAL, 161 | default=self.config_entry.options.get( 162 | CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL 163 | ), 164 | ): int, 165 | vol.Optional( 166 | CONF_SMART_DISABLE, 167 | default=self.config_entry.options.get( 168 | CONF_SMART_DISABLE, DEFAULT_SMART_DISABLE 169 | ), 170 | ): bool, 171 | } 172 | ), 173 | ) 174 | -------------------------------------------------------------------------------- /custom_components/openmediavault/const.py: -------------------------------------------------------------------------------- 1 | """Constants used by the OpenMediaVault integration.""" 2 | from homeassistant.const import Platform 3 | 4 | PLATFORMS = [ 5 | Platform.SENSOR, 6 | Platform.BINARY_SENSOR, 7 | ] 8 | 9 | DOMAIN = "openmediavault" 10 | DEFAULT_NAME = "OpenMediaVault" 11 | ATTRIBUTION = "Data provided by OpenMediaVault integration" 12 | 13 | DEFAULT_HOST = "10.0.0.1" 14 | DEFAULT_USERNAME = "admin" 15 | 16 | DEFAULT_DEVICE_NAME = "OMV" 17 | DEFAULT_SSL = False 18 | DEFAULT_SSL_VERIFY = True 19 | 20 | CONF_SCAN_INTERVAL = "scan_interval" 21 | DEFAULT_SCAN_INTERVAL = 60 22 | CONF_SMART_DISABLE = "smart_disable" 23 | DEFAULT_SMART_DISABLE = False 24 | 25 | TO_REDACT = { 26 | "username", 27 | "password", 28 | } 29 | 30 | 31 | SERVICE_SYSTEM_REBOOT = "system_reboot" 32 | SCHEMA_SERVICE_SYSTEM_REBOOT = {} 33 | 34 | SERVICE_SYSTEM_SHUTDOWN = "system_shutdown" 35 | SCHEMA_SERVICE_SYSTEM_SHUTDOWN = {} 36 | 37 | SERVICE_KVM_START = "kvm_start" 38 | SCHEMA_SERVICE_KVM_START = {} 39 | SERVICE_KVM_STOP = "kvm_stop" 40 | SCHEMA_SERVICE_KVM_STOP = {} 41 | SERVICE_KVM_RESTART = "kvm_restart" 42 | SCHEMA_SERVICE_KVM_RESTART = {} 43 | SERVICE_KVM_SNAPSHOT = "kvm_snapshot" 44 | SCHEMA_SERVICE_KVM_SNAPSHOT = {} 45 | -------------------------------------------------------------------------------- /custom_components/openmediavault/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for OMV.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | 6 | from homeassistant.components.diagnostics import async_redact_data 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .const import DOMAIN 11 | 12 | TO_REDACT = { 13 | "username", 14 | "password", 15 | } 16 | 17 | 18 | async def async_get_config_entry_diagnostics( 19 | hass: HomeAssistant, config_entry: ConfigEntry 20 | ) -> dict[str, Any]: 21 | """Return diagnostics for a config entry.""" 22 | controller = hass.data[DOMAIN][config_entry.entry_id] 23 | diag: dict[str, Any] = {} 24 | diag["entry"]: dict[str, Any] = {} 25 | 26 | diag["entry"]["data"] = async_redact_data(config_entry.data, TO_REDACT) 27 | diag["entry"]["options"] = async_redact_data(config_entry.options, TO_REDACT) 28 | diag["data"] = async_redact_data(controller.data, TO_REDACT) 29 | 30 | return diag 31 | -------------------------------------------------------------------------------- /custom_components/openmediavault/helper.py: -------------------------------------------------------------------------------- 1 | """Helper functions for OMV.""" 2 | 3 | 4 | # --------------------------- 5 | # format_attribute 6 | # --------------------------- 7 | def format_attribute(attr): 8 | res = attr.replace("-", "_") 9 | res = res.replace(" ", "_") 10 | res = res.lower() 11 | return res 12 | 13 | 14 | # --------------------------- 15 | # format_value 16 | # --------------------------- 17 | def format_value(res): 18 | res = res.replace("dhcp", "DHCP") 19 | res = res.replace("dns", "DNS") 20 | res = res.replace("capsman", "CAPsMAN") 21 | res = res.replace("wireless", "Wireless") 22 | res = res.replace("restored", "Restored") 23 | return res 24 | -------------------------------------------------------------------------------- /custom_components/openmediavault/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "openmediavault", 3 | "name": "OpenMediaVault", 4 | "codeowners": [ 5 | "@tomaae" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/tomaae/homeassistant-openmediavault", 10 | "iot_class": "local_polling", 11 | "issue_tracker": "https://github.com/tomaae/homeassistant-openmediavault/issues", 12 | "requirements": [], 13 | "version": "0.0.0" 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/openmediavault/model.py: -------------------------------------------------------------------------------- 1 | """OMV HA shared entity model""" 2 | from logging import getLogger 3 | from typing import Any 4 | from collections.abc import Mapping 5 | from homeassistant.helpers import entity_platform 6 | from homeassistant.helpers.entity import DeviceInfo 7 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant, callback 11 | from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_HOST 12 | from .helper import format_attribute 13 | from .const import DOMAIN, ATTRIBUTION 14 | from .omv_controller import OMVControllerData 15 | 16 | _LOGGER = getLogger(__name__) 17 | 18 | 19 | # --------------------------- 20 | # model_async_setup_entry 21 | # --------------------------- 22 | async def model_async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: ConfigEntry, 25 | async_add_entities: AddEntitiesCallback, 26 | sensor_services, 27 | sensor_types, 28 | dispatcher, 29 | ) -> None: 30 | inst = config_entry.data[CONF_NAME] 31 | omv_controller = hass.data[DOMAIN][config_entry.entry_id] 32 | sensors = {} 33 | 34 | platform = entity_platform.async_get_current_platform() 35 | for tmp in sensor_services: 36 | platform.async_register_entity_service(tmp[0], tmp[1], tmp[2]) 37 | 38 | @callback 39 | def update_controller(): 40 | """Update the values of the controller""" 41 | model_update_items( 42 | inst, 43 | omv_controller, 44 | async_add_entities, 45 | sensors, 46 | sensor_types, 47 | dispatcher, 48 | ) 49 | 50 | omv_controller.listeners.append( 51 | async_dispatcher_connect(hass, omv_controller.signal_update, update_controller) 52 | ) 53 | update_controller() 54 | 55 | 56 | # --------------------------- 57 | # model_update_items 58 | # --------------------------- 59 | def model_update_items( 60 | inst, 61 | omv_controller: OMVControllerData, 62 | async_add_entities: AddEntitiesCallback, 63 | sensors, 64 | sensor_types, 65 | dispatcher, 66 | ): 67 | def _register_entity(_sensors, _item_id, _uid, _uid_sensor): 68 | _LOGGER.debug("Updating entity %s", _item_id) 69 | if _item_id in _sensors: 70 | if _sensors[_item_id].enabled: 71 | _sensors[_item_id].async_schedule_update_ha_state() 72 | return None 73 | 74 | return dispatcher[_uid_sensor.func]( 75 | inst=inst, 76 | uid=_uid, 77 | omv_controller=omv_controller, 78 | entity_description=_uid_sensor, 79 | ) 80 | 81 | new_sensors = [] 82 | for sensor in sensor_types: 83 | uid_sensor = sensor_types[sensor] 84 | if not uid_sensor.data_reference: 85 | uid_sensor = sensor_types[sensor] 86 | if hasattr(uid_sensor, "data_attribute"): 87 | if ( 88 | uid_sensor.data_attribute 89 | not in omv_controller.data[uid_sensor.data_path] 90 | or omv_controller.data[uid_sensor.data_path][ 91 | uid_sensor.data_attribute 92 | ] 93 | == "unknown" 94 | ): 95 | continue 96 | elif hasattr(uid_sensor, "data_is_on"): 97 | if ( 98 | uid_sensor.data_is_on 99 | not in omv_controller.data[uid_sensor.data_path] 100 | or omv_controller.data[uid_sensor.data_path][uid_sensor.data_is_on] 101 | == "unknown" 102 | ): 103 | continue 104 | 105 | item_id = f"{inst}-{sensor}" 106 | if tmp := _register_entity(sensors, item_id, "", uid_sensor): 107 | sensors[item_id] = tmp 108 | new_sensors.append(sensors[item_id]) 109 | else: 110 | for uid in omv_controller.data[uid_sensor.data_path]: 111 | uid_data = omv_controller.data[uid_sensor.data_path] 112 | item_id = f"{inst}-{sensor}-{str(uid_data[uid][uid_sensor.data_reference]).lower()}" 113 | if tmp := _register_entity(sensors, item_id, uid, uid_sensor): 114 | sensors[item_id] = tmp 115 | new_sensors.append(sensors[item_id]) 116 | 117 | if new_sensors: 118 | async_add_entities(new_sensors, True) 119 | 120 | 121 | # --------------------------- 122 | # OMVEntity 123 | # --------------------------- 124 | class OMVEntity: 125 | """Define entity""" 126 | 127 | _attr_has_entity_name = True 128 | 129 | def __init__( 130 | self, 131 | inst, 132 | uid: "", 133 | omv_controller, 134 | entity_description, 135 | ): 136 | """Initialize entity""" 137 | self.entity_description = entity_description 138 | self._inst = inst 139 | self._ctrl = omv_controller 140 | self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} 141 | self._uid = uid 142 | if self._uid: 143 | self._data = omv_controller.data[self.entity_description.data_path][ 144 | self._uid 145 | ] 146 | else: 147 | self._data = omv_controller.data[self.entity_description.data_path] 148 | 149 | @property 150 | def name(self) -> str: 151 | """Return the name for this entity""" 152 | if not self._uid: 153 | return f"{self.entity_description.name}" 154 | 155 | if self.entity_description.name: 156 | return f"{self._data[self.entity_description.data_name]} {self.entity_description.name}" 157 | 158 | return f"{self._data[self.entity_description.data_name]}" 159 | 160 | @property 161 | def unique_id(self) -> str: 162 | """Return a unique id for this entity""" 163 | if self._uid: 164 | return f"{self._inst.lower()}-{self.entity_description.key}-{str(self._data[self.entity_description.data_reference]).lower()}" 165 | else: 166 | return f"{self._inst.lower()}-{self.entity_description.key}" 167 | 168 | @property 169 | def available(self) -> bool: 170 | """Return if controller is available""" 171 | return self._ctrl.connected() 172 | 173 | @property 174 | def device_info(self) -> DeviceInfo: 175 | """Return a description for device registry""" 176 | dev_connection = DOMAIN 177 | dev_connection_value = f"{self._ctrl.name}_{self.entity_description.ha_group}" 178 | dev_group = self.entity_description.ha_group 179 | if self.entity_description.ha_group == "System": 180 | dev_connection_value = self._ctrl.data["hwinfo"]["hostname"] 181 | 182 | if self.entity_description.ha_group.startswith("data__"): 183 | dev_group = self.entity_description.ha_group[6:] 184 | if dev_group in self._data: 185 | dev_group = self._data[dev_group] 186 | dev_connection_value = dev_group 187 | 188 | if self.entity_description.ha_connection: 189 | dev_connection = self.entity_description.ha_connection 190 | 191 | if self.entity_description.ha_connection_value: 192 | dev_connection_value = self.entity_description.ha_connection_value 193 | if dev_connection_value.startswith("data__"): 194 | dev_connection_value = dev_connection_value[6:] 195 | dev_connection_value = self._data[dev_connection_value] 196 | 197 | if self.entity_description.ha_group == "System": 198 | return DeviceInfo( 199 | connections={(dev_connection, f"{dev_connection_value}")}, 200 | identifiers={(dev_connection, f"{dev_connection_value}")}, 201 | name=f"{self._inst} {dev_group}", 202 | manufacturer="OpenMediaVault", 203 | sw_version=f"{self._ctrl.data['hwinfo']['version']}", 204 | configuration_url=f"http://{self._ctrl.config_entry.data[CONF_HOST]}", 205 | ) 206 | else: 207 | return DeviceInfo( 208 | connections={(dev_connection, f"{dev_connection_value}")}, 209 | default_name=f"{self._inst} {dev_group}", 210 | default_manufacturer="OpenMediaVault", 211 | via_device=(DOMAIN, f"{self._ctrl.data['hwinfo']['hostname']}"), 212 | ) 213 | 214 | @property 215 | def extra_state_attributes(self) -> Mapping[str, Any]: 216 | """Return the state attributes""" 217 | attributes = super().extra_state_attributes 218 | for variable in self.entity_description.data_attributes_list: 219 | if variable in self._data: 220 | attributes[format_attribute(variable)] = self._data[variable] 221 | 222 | return attributes 223 | 224 | async def async_added_to_hass(self): 225 | """Run when entity about to be added to hass""" 226 | _LOGGER.debug("New binary sensor %s (%s)", self._inst, self.unique_id) 227 | 228 | async def start(self): 229 | """Dummy run function""" 230 | raise NotImplementedError() 231 | 232 | async def stop(self): 233 | """Dummy stop function""" 234 | raise NotImplementedError() 235 | 236 | async def restart(self): 237 | """Dummy restart function""" 238 | raise NotImplementedError() 239 | 240 | async def reload(self): 241 | """Dummy reload function""" 242 | raise NotImplementedError() 243 | 244 | async def snapshot(self): 245 | """Dummy snapshot function""" 246 | raise NotImplementedError() 247 | -------------------------------------------------------------------------------- /custom_components/openmediavault/omv_api.py: -------------------------------------------------------------------------------- 1 | """OpenMediaVault API.""" 2 | 3 | import json 4 | import logging 5 | from os import path 6 | from typing import Any 7 | from pickle import dump as pickle_dump 8 | from pickle import load as pickle_load 9 | from threading import Lock 10 | from time import time 11 | 12 | import requests 13 | from voluptuous import Optional 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | # --------------------------- 19 | # load_cookies 20 | # --------------------------- 21 | def load_cookies(filename: str) -> Optional(dict): 22 | """Load cookies from file.""" 23 | if path.isfile(filename): 24 | with open(filename, "rb") as f: 25 | return pickle_load(f) 26 | return None 27 | 28 | 29 | # --------------------------- 30 | # save_cookies 31 | # --------------------------- 32 | def save_cookies(filename: str, data: dict): 33 | """Save cookies to file.""" 34 | with open(filename, "wb") as f: 35 | pickle_dump(data, f) 36 | 37 | 38 | # --------------------------- 39 | # OpenMediaVaultAPI 40 | # --------------------------- 41 | class OpenMediaVaultAPI(object): 42 | """Handle all communication with OMV.""" 43 | 44 | def __init__(self, hass, host, username, password, use_ssl=False, verify_ssl=True): 45 | """Initialize the OMV API.""" 46 | self._hass = hass 47 | self._host = host 48 | self._use_ssl = use_ssl 49 | self._username = username 50 | self._password = password 51 | self._protocol = "https" if self._use_ssl else "http" 52 | self._ssl_verify = verify_ssl 53 | if not self._use_ssl: 54 | self._ssl_verify = True 55 | self._resource = f"{self._protocol}://{self._host}/rpc.php" 56 | 57 | self.lock = Lock() 58 | 59 | self._connection = None 60 | self._cookie_jar = None 61 | self._cookie_jar_file = self._hass.config.path(".omv_cookies.json") 62 | self._connected = False 63 | self._reconnected = False 64 | self._connection_epoch = 0 65 | self._connection_retry_sec = 58 66 | self.error = None 67 | self.connection_error_reported = False 68 | self.accounting_last_run = None 69 | 70 | # --------------------------- 71 | # has_reconnected 72 | # --------------------------- 73 | def has_reconnected(self) -> bool: 74 | """Check if API has reconnected.""" 75 | if self._reconnected: 76 | self._reconnected = False 77 | return True 78 | 79 | return False 80 | 81 | # --------------------------- 82 | # connection_check 83 | # --------------------------- 84 | def connection_check(self) -> bool: 85 | """Check if API is connected.""" 86 | if not self._connected or not self._connection: 87 | if self._connection_epoch > time() - self._connection_retry_sec: 88 | return False 89 | 90 | if not self.connect(): 91 | return False 92 | 93 | return True 94 | 95 | # --------------------------- 96 | # disconnect 97 | # --------------------------- 98 | def disconnect(self, location="unknown", error=None): 99 | """Disconnect API.""" 100 | if not error: 101 | error = "unknown" 102 | 103 | if not self.connection_error_reported: 104 | if location == "unknown": 105 | _LOGGER.error("OpenMediaVault %s connection closed", self._host) 106 | else: 107 | _LOGGER.error( 108 | "OpenMediaVault %s error while %s : %s", self._host, location, error 109 | ) 110 | 111 | self.connection_error_reported = True 112 | 113 | self._reconnected = False 114 | self._connected = False 115 | self._connection = None 116 | self._connection_epoch = 0 117 | 118 | # --------------------------- 119 | # connect 120 | # --------------------------- 121 | def connect(self) -> bool: 122 | """Connect API.""" 123 | self.error = None 124 | self._connected = False 125 | self._connection_epoch = time() 126 | self._connection = requests.Session() 127 | self._cookie_jar = requests.cookies.RequestsCookieJar() 128 | 129 | # Load cookies 130 | if cookies := load_cookies(self._cookie_jar_file): 131 | self._connection.cookies.update(cookies) 132 | 133 | self.lock.acquire() 134 | error = False 135 | try: 136 | response = self._connection.post( 137 | self._resource, 138 | data=json.dumps( 139 | { 140 | "service": "session", 141 | "method": "login", 142 | "params": { 143 | "username": self._username, 144 | "password": self._password, 145 | }, 146 | } 147 | ), 148 | verify=self._ssl_verify, 149 | ) 150 | 151 | if response.status_code != 200: 152 | error = True 153 | 154 | data = response.json() 155 | if data["error"] is not None: 156 | if not self.connection_error_reported: 157 | _LOGGER.error( 158 | "OpenMediaVault %s unable to connect: %s", 159 | self._host, 160 | data["error"]["message"], 161 | ) 162 | self.connection_error_reported = True 163 | 164 | self.error_to_strings("%s" % data["error"]["message"]) 165 | self._connection = None 166 | self.lock.release() 167 | return False 168 | 169 | if not data["response"]["authenticated"]: 170 | _LOGGER.error("OpenMediaVault %s authenticated failed", self._host) 171 | self.error_to_strings() 172 | self._connection = None 173 | self.lock.release() 174 | return False 175 | 176 | except requests.exceptions.ConnectionError as api_error: 177 | error = True 178 | self.error_to_strings("%s" % api_error) 179 | self._connection = None 180 | except Exception: 181 | error = True 182 | else: 183 | if self.connection_error_reported: 184 | _LOGGER.warning("OpenMediaVault %s reconnected", self._host) 185 | self.connection_error_reported = False 186 | else: 187 | _LOGGER.debug("OpenMediaVault %s connected", self._host) 188 | 189 | self._connected = True 190 | self._reconnected = True 191 | self.lock.release() 192 | for cookie in self._connection.cookies: 193 | self._cookie_jar.set_cookie(cookie) 194 | 195 | save_cookies(self._cookie_jar_file, self._cookie_jar) 196 | 197 | # Socket errors 198 | if error: 199 | try: 200 | errorcode = response.status_code 201 | except Exception: 202 | errorcode = "no_respose" 203 | 204 | if errorcode == 200: 205 | errorcode = "cannot_connect" 206 | 207 | _LOGGER.warning( 208 | "OpenMediaVault %s connection error: %s", self._host, errorcode 209 | ) 210 | 211 | error_code = errorcode 212 | self.error = error_code 213 | self._connected = False 214 | self.disconnect("connect") 215 | self.lock.release() 216 | 217 | return self._connected 218 | 219 | # --------------------------- 220 | # error_to_strings 221 | # --------------------------- 222 | def error_to_strings(self, error=""): 223 | """Translate error output to error string.""" 224 | self.error = "cannot_connect" 225 | if "Incorrect username or password" in error: 226 | self.error = "wrong_login" 227 | 228 | if "certificate verify failed" in error: 229 | self.error = "ssl_verify_failed" 230 | 231 | # --------------------------- 232 | # connected 233 | # --------------------------- 234 | def connected(self) -> bool: 235 | """Return connected boolean.""" 236 | return self._connected 237 | 238 | # --------------------------- 239 | # query 240 | # --------------------------- 241 | def query( 242 | self, 243 | service: str, 244 | method: str, 245 | params: dict[str, Any] | None = {}, 246 | options: dict[str, Any] | None = {"updatelastaccess": True}, 247 | ) -> Optional(list): 248 | """Retrieve data from OMV.""" 249 | if not self.connection_check(): 250 | return None 251 | 252 | self.lock.acquire() 253 | error = False 254 | try: 255 | _LOGGER.debug( 256 | "OpenMediaVault %s query: %s, %s, %s, %s", 257 | self._host, 258 | service, 259 | method, 260 | params, 261 | options, 262 | ) 263 | response = self._connection.post( 264 | self._resource, 265 | data=json.dumps( 266 | { 267 | "service": service, 268 | "method": method, 269 | "params": params, 270 | "options": options, 271 | } 272 | ), 273 | verify=self._ssl_verify, 274 | ) 275 | 276 | if response.status_code == 200: 277 | data = response.json() 278 | _LOGGER.debug("OpenMediaVault %s query response: %s", self._host, data) 279 | else: 280 | error = True 281 | 282 | except ( 283 | requests.exceptions.ConnectionError, 284 | json.decoder.JSONDecodeError, 285 | ) as api_error: 286 | _LOGGER.warning("OpenMediaVault %s unable to fetch data", self._host) 287 | self.disconnect("query", api_error) 288 | self.lock.release() 289 | return None 290 | except Exception: 291 | self.disconnect("query") 292 | self.lock.release() 293 | return None 294 | 295 | # Socket errors 296 | if error: 297 | try: 298 | errorcode = response.status_code 299 | except Exception: 300 | errorcode = "no_respose" 301 | 302 | _LOGGER.warning( 303 | "OpenMediaVault %s unable to fetch data (%s)", self._host, errorcode 304 | ) 305 | 306 | error_code = errorcode 307 | self.error = error_code 308 | self._connected = False 309 | self.lock.release() 310 | return None 311 | 312 | # Api errors 313 | if data is not None and data["error"] is not None: 314 | error_message = data["error"]["message"] 315 | error_code = data["error"]["code"] 316 | if ( 317 | error_code == 5001 318 | or error_code == 5002 319 | or error_message == "Session not authenticated." 320 | or error_message == "Session expired." 321 | ): 322 | _LOGGER.debug("OpenMediaVault %s session expired", self._host) 323 | self.error = 5001 324 | if self.connect(): 325 | return self.query(service, method, params, options) 326 | 327 | self.error = None 328 | self.lock.release() 329 | 330 | return data["response"] 331 | -------------------------------------------------------------------------------- /custom_components/openmediavault/omv_controller.py: -------------------------------------------------------------------------------- 1 | """OpenMediaVault Controller.""" 2 | 3 | import asyncio 4 | import pytz 5 | from datetime import datetime, timedelta 6 | 7 | from homeassistant.const import ( 8 | CONF_HOST, 9 | CONF_NAME, 10 | CONF_PASSWORD, 11 | CONF_SSL, 12 | CONF_USERNAME, 13 | CONF_VERIFY_SSL, 14 | ) 15 | from homeassistant.core import callback 16 | from homeassistant.helpers.dispatcher import async_dispatcher_send 17 | from homeassistant.helpers.event import async_track_time_interval 18 | 19 | from .const import ( 20 | DOMAIN, 21 | CONF_SCAN_INTERVAL, 22 | DEFAULT_SCAN_INTERVAL, 23 | CONF_SMART_DISABLE, 24 | DEFAULT_SMART_DISABLE, 25 | ) 26 | from .apiparser import parse_api 27 | from .omv_api import OpenMediaVaultAPI 28 | 29 | DEFAULT_TIME_ZONE = None 30 | 31 | 32 | def utc_from_timestamp(timestamp: float) -> datetime: 33 | """Return a UTC time from a timestamp.""" 34 | return pytz.utc.localize(datetime.utcfromtimestamp(timestamp)) 35 | 36 | 37 | # --------------------------- 38 | # OMVControllerData 39 | # --------------------------- 40 | class OMVControllerData(object): 41 | """OMVControllerData Class.""" 42 | 43 | def __init__(self, hass, config_entry): 44 | """Initialize OMVController.""" 45 | self.hass = hass 46 | self.config_entry = config_entry 47 | self.name = config_entry.data[CONF_NAME] 48 | self.host = config_entry.data[CONF_HOST] 49 | 50 | self.data = { 51 | "hwinfo": {}, 52 | "plugin": {}, 53 | "disk": {}, 54 | "fs": {}, 55 | "service": {}, 56 | "network": {}, 57 | "kvm": {}, 58 | "compose": {}, 59 | } 60 | 61 | self.listeners = [] 62 | self.lock = asyncio.Lock() 63 | 64 | self.api = OpenMediaVaultAPI( 65 | hass, 66 | config_entry.data[CONF_HOST], 67 | config_entry.data[CONF_USERNAME], 68 | config_entry.data[CONF_PASSWORD], 69 | config_entry.data[CONF_SSL], 70 | config_entry.data[CONF_VERIFY_SSL], 71 | ) 72 | 73 | self._force_update_callback = None 74 | self._force_hwinfo_update_callback = None 75 | 76 | # --------------------------- 77 | # async_init 78 | # --------------------------- 79 | async def async_init(self) -> None: 80 | self._force_update_callback = async_track_time_interval( 81 | self.hass, self.force_update, self.option_scan_interval 82 | ) 83 | self._force_hwinfo_update_callback = async_track_time_interval( 84 | self.hass, self.force_hwinfo_update, timedelta(seconds=3600) 85 | ) 86 | 87 | # --------------------------- 88 | # option_scan_interval 89 | # --------------------------- 90 | @property 91 | def option_scan_interval(self): 92 | """Config entry option scan interval.""" 93 | scan_interval = self.config_entry.options.get( 94 | CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL 95 | ) 96 | return timedelta(seconds=scan_interval) 97 | 98 | # --------------------------- 99 | # option_smart_disable 100 | # --------------------------- 101 | @property 102 | def option_smart_disable(self): 103 | """Config entry option smart disable.""" 104 | return self.config_entry.options.get(CONF_SMART_DISABLE, DEFAULT_SMART_DISABLE) 105 | 106 | # --------------------------- 107 | # signal_update 108 | # --------------------------- 109 | @property 110 | def signal_update(self): 111 | """Event to signal new data.""" 112 | return f"{DOMAIN}-update-{self.name}" 113 | 114 | # --------------------------- 115 | # async_reset 116 | # --------------------------- 117 | async def async_reset(self) -> bool: 118 | """Reset dispatchers.""" 119 | for unsub_dispatcher in self.listeners: 120 | unsub_dispatcher() 121 | 122 | self.listeners = [] 123 | return True 124 | 125 | # --------------------------- 126 | # connected 127 | # --------------------------- 128 | def connected(self): 129 | """Return connected state.""" 130 | return self.api.connected() 131 | 132 | # --------------------------- 133 | # force_hwinfo_update 134 | # --------------------------- 135 | @callback 136 | async def force_hwinfo_update(self, _now=None): 137 | """Trigger update by timer.""" 138 | await self.async_hwinfo_update() 139 | 140 | # --------------------------- 141 | # async_hwinfo_update 142 | # --------------------------- 143 | async def async_hwinfo_update(self): 144 | """Update OpenMediaVault hardware info.""" 145 | try: 146 | await asyncio.wait_for(self.lock.acquire(), timeout=30) 147 | except Exception: 148 | return 149 | 150 | await self.hass.async_add_executor_job(self.get_hwinfo) 151 | if self.api.connected(): 152 | await self.hass.async_add_executor_job(self.get_plugin) 153 | if self.api.connected(): 154 | await self.hass.async_add_executor_job(self.get_disk) 155 | 156 | self.lock.release() 157 | 158 | # --------------------------- 159 | # force_update 160 | # --------------------------- 161 | @callback 162 | async def force_update(self, _now=None): 163 | """Trigger update by timer.""" 164 | await self.async_update() 165 | 166 | # --------------------------- 167 | # async_update 168 | # --------------------------- 169 | async def async_update(self): 170 | """Update OMV data.""" 171 | if self.api.has_reconnected(): 172 | await self.async_hwinfo_update() 173 | 174 | try: 175 | await asyncio.wait_for(self.lock.acquire(), timeout=10) 176 | except Exception: 177 | return 178 | 179 | await self.hass.async_add_executor_job(self.get_hwinfo) 180 | if self.api.connected(): 181 | await self.hass.async_add_executor_job(self.get_fs) 182 | 183 | if not self.option_smart_disable and self.api.connected(): 184 | await self.hass.async_add_executor_job(self.get_smart) 185 | 186 | if self.api.connected(): 187 | await self.hass.async_add_executor_job(self.get_network) 188 | 189 | if self.api.connected(): 190 | await self.hass.async_add_executor_job(self.get_service) 191 | 192 | if ( 193 | self.api.connected() 194 | and "openmediavault-kvm" in self.data["plugin"] 195 | and self.data["plugin"]["openmediavault-kvm"]["installed"] 196 | ): 197 | await self.hass.async_add_executor_job(self.get_kvm) 198 | if ( 199 | self.api.connected() 200 | and "openmediavault-compose" in self.data["plugin"] 201 | and self.data["plugin"]["openmediavault-compose"]["installed"] 202 | ): 203 | await self.hass.async_add_executor_job(self.get_compose) 204 | 205 | async_dispatcher_send(self.hass, self.signal_update) 206 | self.lock.release() 207 | 208 | # --------------------------- 209 | # get_hwinfo 210 | # --------------------------- 211 | def get_hwinfo(self): 212 | """Get hardware info from OMV.""" 213 | self.data["hwinfo"] = parse_api( 214 | data=self.data["hwinfo"], 215 | source=self.api.query("System", "getInformation"), 216 | vals=[ 217 | {"name": "hostname", "default": "unknown"}, 218 | {"name": "version", "default": "unknown"}, 219 | {"name": "cpuUsage", "default": 0.0}, 220 | {"name": "memTotal", "default": 0}, 221 | {"name": "memUsed", "default": 0}, 222 | {"name": "loadAverage_1", "source": "loadAverage/1min", "default": 0.0}, 223 | {"name": "loadAverage_5", "source": "loadAverage/5min", "default": 0.0}, 224 | { 225 | "name": "loadAverage_15", 226 | "source": "loadAverage/15min", 227 | "default": 0.0, 228 | }, 229 | {"name": "uptime", "default": "0 days 0 hours 0 minutes 0 seconds"}, 230 | {"name": "configDirty", "type": "bool", "default": False}, 231 | {"name": "rebootRequired", "type": "bool", "default": False}, 232 | {"name": "availablePkgUpdates", "default": 0}, 233 | ], 234 | ensure_vals=[ 235 | {"name": "memUsage", "default": 0.0}, 236 | {"name": "pkgUpdatesAvailable", "type": "bool", "default": False}, 237 | ], 238 | ) 239 | 240 | if not self.api.connected(): 241 | return 242 | 243 | tmp_uptime = 0 244 | if int(self.data["hwinfo"]["version"].split(".")[0]) > 5: 245 | tmp = float(self.data["hwinfo"]["uptime"]) 246 | pos = abs(int(tmp)) 247 | day = pos / (3600 * 24) 248 | rem = pos % (3600 * 24) 249 | hour = rem / 3600 250 | rem = rem % 3600 251 | mins = rem / 60 252 | secs = rem % 60 253 | res = "%d days %02d hours %02d minutes %02d seconds" % ( 254 | day, 255 | hour, 256 | mins, 257 | secs, 258 | ) 259 | if int(tmp) < 0: 260 | res = "-%s" % res 261 | tmp = res.split(" ") 262 | else: 263 | tmp = self.data["hwinfo"]["uptime"].split(" ") 264 | 265 | tmp_uptime += int(tmp[0]) * 86400 # days 266 | tmp_uptime += int(tmp[2]) * 3600 # hours 267 | tmp_uptime += int(tmp[4]) * 60 # minutes 268 | tmp_uptime += int(tmp[6]) # seconds 269 | now = datetime.now().replace(microsecond=0) 270 | uptime_tm = datetime.timestamp(now - timedelta(seconds=tmp_uptime)) 271 | self.data["hwinfo"]["uptimeEpoch"] = utc_from_timestamp(uptime_tm) 272 | 273 | self.data["hwinfo"]["cpuUsage"] = round(self.data["hwinfo"]["cpuUsage"], 1) 274 | mem = ( 275 | (int(self.data["hwinfo"]["memUsed"]) / int(self.data["hwinfo"]["memTotal"])) 276 | * 100 277 | if int(self.data["hwinfo"]["memTotal"]) > 0 278 | else 0 279 | ) 280 | self.data["hwinfo"]["memUsage"] = round(mem, 1) 281 | 282 | self.data["hwinfo"]["pkgUpdatesAvailable"] = ( 283 | self.data["hwinfo"]["availablePkgUpdates"] > 0 284 | ) 285 | 286 | # --------------------------- 287 | # get_disk 288 | # --------------------------- 289 | def get_disk(self): 290 | """Get all filesystems from OMV.""" 291 | self.data["disk"] = parse_api( 292 | data=self.data["disk"], 293 | source=self.api.query("DiskMgmt", "enumerateDevices"), 294 | key="devicename", 295 | vals=[ 296 | {"name": "devicename"}, 297 | {"name": "canonicaldevicefile"}, 298 | {"name": "size", "default": "unknown"}, 299 | {"name": "vendor", "default": "unknown"}, 300 | {"name": "model", "default": "unknown"}, 301 | {"name": "description", "default": "unknown"}, 302 | {"name": "serialnumber", "default": "unknown"}, 303 | {"name": "wwn", "default": "unknown"}, 304 | {"name": "israid", "type": "bool", "default": False}, 305 | {"name": "isroot", "type": "bool", "default": False}, 306 | {"name": "isreadonly", "type": "bool", "default": False}, 307 | ], 308 | ensure_vals=[ 309 | {"name": "temperature", "default": 0}, 310 | {"name": "Raw_Read_Error_Rate", "default": "unknown"}, 311 | {"name": "Spin_Up_Time", "default": "unknown"}, 312 | {"name": "Start_Stop_Count", "default": "unknown"}, 313 | {"name": "Reallocated_Sector_Ct", "default": "unknown"}, 314 | {"name": "Seek_Error_Rate", "default": "unknown"}, 315 | {"name": "Load_Cycle_Count", "default": "unknown"}, 316 | {"name": "UDMA_CRC_Error_Count", "default": "unknown"}, 317 | {"name": "Multi_Zone_Error_Rate", "default": "unknown"}, 318 | ], 319 | ) 320 | 321 | # --------------------------- 322 | # get_smart 323 | # --------------------------- 324 | def get_smart(self): 325 | """Get S.M.A.R.T. information from OMV.""" 326 | tmp_smart_get_list = self.api.query( 327 | "Smart", "getList", {"start": 0, "limit": -1} 328 | ) 329 | if "data" in tmp_smart_get_list: 330 | tmp_smart_get_list = tmp_smart_get_list["data"] 331 | 332 | self.data["disk"] = parse_api( 333 | data=self.data["disk"], 334 | source=tmp_smart_get_list, 335 | key="devicename", 336 | vals=[ 337 | {"name": "temperature", "default": 0}, 338 | ], 339 | ) 340 | 341 | for uid in self.data["disk"]: 342 | if self.data["disk"][uid]["devicename"].startswith("mmcblk"): 343 | continue 344 | 345 | if self.data["disk"][uid]["devicename"].startswith("sr"): 346 | continue 347 | 348 | if self.data["disk"][uid]["devicename"].startswith("bcache"): 349 | continue 350 | 351 | if ( 352 | self.data["disk"][uid]["wwn"] == "" 353 | or self.data["disk"][uid]["wwn"] == "unknown" 354 | ): 355 | continue 356 | 357 | tmp_data = parse_api( 358 | data={}, 359 | source=self.api.query( 360 | "Smart", 361 | "getAttributes", 362 | {"devicefile": self.data["disk"][uid]["canonicaldevicefile"]}, 363 | ), 364 | key="attrname", 365 | vals=[ 366 | {"name": "attrname"}, 367 | {"name": "threshold", "default": 0}, 368 | {"name": "rawvalue", "default": 0}, 369 | ], 370 | ) 371 | if not tmp_data: 372 | continue 373 | 374 | vals = [ 375 | "Raw_Read_Error_Rate", 376 | "Spin_Up_Time", 377 | "Start_Stop_Count", 378 | "Reallocated_Sector_Ct", 379 | "Seek_Error_Rate", 380 | "Load_Cycle_Count", 381 | "UDMA_CRC_Error_Count", 382 | "Multi_Zone_Error_Rate", 383 | ] 384 | 385 | for tmp_val in vals: 386 | if tmp_val in tmp_data: 387 | if ( 388 | isinstance(tmp_data[tmp_val]["rawvalue"], str) 389 | and " " in tmp_data[tmp_val]["rawvalue"] 390 | ): 391 | tmp_data[tmp_val]["rawvalue"] = tmp_data[tmp_val][ 392 | "rawvalue" 393 | ].split(" ")[0] 394 | 395 | self.data["disk"][uid][tmp_val] = tmp_data[tmp_val]["rawvalue"] 396 | 397 | # --------------------------- 398 | # get_fs 399 | # --------------------------- 400 | def get_fs(self): 401 | """Get all filesystems from OMV.""" 402 | self.data["fs"] = parse_api( 403 | data=self.data["fs"], 404 | source=self.api.query("FileSystemMgmt", "enumerateFilesystems"), 405 | key="uuid", 406 | vals=[ 407 | {"name": "uuid"}, 408 | {"name": "parentdevicefile", "default": "unknown"}, 409 | {"name": "label", "default": "unknown"}, 410 | {"name": "type", "default": "unknown"}, 411 | {"name": "mounted", "type": "bool", "default": False}, 412 | {"name": "devicename", "default": "unknown"}, 413 | {"name": "available", "default": 0}, 414 | {"name": "size", "default": 0}, 415 | {"name": "percentage", "default": 0}, 416 | {"name": "_readonly", "type": "bool", "default": False}, 417 | {"name": "_used", "type": "bool", "default": False}, 418 | {"name": "propreadonly", "type": "bool", "default": False}, 419 | ], 420 | skip=[ 421 | {"name": "type", "value": "swap"}, 422 | {"name": "type", "value": "iso9660"}, 423 | ], 424 | ) 425 | 426 | for uid in self.data["fs"]: 427 | tmp = self.data["fs"][uid]["devicename"] 428 | self.data["fs"][uid]["devicename"] = tmp[ 429 | tmp.startswith("mapper/") and len("mapper/") : 430 | ] 431 | 432 | self.data["fs"][uid]["size"] = round( 433 | int(self.data["fs"][uid]["size"]) / 1073741824, 1 434 | ) 435 | self.data["fs"][uid]["available"] = round( 436 | int(self.data["fs"][uid]["available"]) / 1073741824, 1 437 | ) 438 | 439 | # --------------------------- 440 | # get_service 441 | # --------------------------- 442 | def get_service(self): 443 | """Get OMV services status""" 444 | tmp = self.api.query("Services", "getStatus") 445 | if "data" in tmp: 446 | tmp = tmp["data"] 447 | 448 | self.data["service"] = parse_api( 449 | data=self.data["service"], 450 | source=tmp, 451 | key="name", 452 | vals=[ 453 | {"name": "name"}, 454 | {"name": "title", "default": "unknown"}, 455 | {"name": "enabled", "type": "bool", "default": False}, 456 | {"name": "running", "type": "bool", "default": False}, 457 | ], 458 | ) 459 | 460 | # --------------------------- 461 | # get_plugin 462 | # --------------------------- 463 | def get_plugin(self): 464 | """Get OMV plugin status""" 465 | self.data["plugin"] = parse_api( 466 | data=self.data["plugin"], 467 | source=self.api.query("Plugin", "enumeratePlugins"), 468 | key="name", 469 | vals=[ 470 | {"name": "name"}, 471 | {"name": "installed", "type": "bool", "default": False}, 472 | ], 473 | ) 474 | 475 | # --------------------------- 476 | # get_network 477 | # --------------------------- 478 | def get_network(self): 479 | """Get OMV plugin status""" 480 | self.data["network"] = parse_api( 481 | data=self.data["network"], 482 | source=self.api.query("Network", "enumerateDevices"), 483 | key="uuid", 484 | vals=[ 485 | {"name": "uuid"}, 486 | {"name": "devicename", "default": "unknown"}, 487 | {"name": "type", "default": "unknown"}, 488 | {"name": "method", "default": "unknown"}, 489 | {"name": "address", "default": "unknown"}, 490 | {"name": "netmask", "default": "unknown"}, 491 | {"name": "gateway", "default": "unknown"}, 492 | {"name": "mtu", "default": 0}, 493 | {"name": "link", "type": "bool", "default": False}, 494 | {"name": "wol", "type": "bool", "default": False}, 495 | {"name": "rx-current", "source": "stats/rx_packets", "default": 0.0}, 496 | {"name": "tx-current", "source": "stats/tx_packets", "default": 0.0}, 497 | ], 498 | ensure_vals=[ 499 | {"name": "rx-previous", "default": 0.0}, 500 | {"name": "tx-previous", "default": 0.0}, 501 | {"name": "rx", "default": 0.0}, 502 | {"name": "tx", "default": 0.0}, 503 | ], 504 | skip=[ 505 | {"name": "type", "value": "loopback"}, 506 | ], 507 | ) 508 | 509 | for uid, vals in self.data["network"].items(): 510 | current_tx = vals["tx-current"] 511 | previous_tx = vals["tx-previous"] 512 | if not previous_tx: 513 | previous_tx = current_tx 514 | 515 | delta_tx = max(0, current_tx - previous_tx) * 8 516 | self.data["network"][uid]["tx"] = round( 517 | delta_tx / self.option_scan_interval.seconds, 2 518 | ) 519 | self.data["network"][uid]["tx-previous"] = current_tx 520 | 521 | current_rx = vals["rx-current"] 522 | previous_rx = vals["rx-previous"] 523 | if not previous_rx: 524 | previous_rx = current_rx 525 | 526 | delta_rx = max(0, current_rx - previous_rx) * 8 527 | self.data["network"][uid]["rx"] = round( 528 | delta_rx / self.option_scan_interval.seconds, 2 529 | ) 530 | self.data["network"][uid]["rx-previous"] = current_rx 531 | 532 | # --------------------------- 533 | # get_kvm 534 | # --------------------------- 535 | def get_kvm(self): 536 | """Get OMV KVM""" 537 | tmp = self.api.query("Kvm", "getVmList", {"start": 0, "limit": 999}) 538 | if "data" not in tmp: 539 | return 540 | 541 | self.data["kvm"] = parse_api( 542 | data={}, 543 | source=tmp["data"], 544 | key="vmname", 545 | vals=[ 546 | {"name": "vmname"}, 547 | {"name": "type", "source": "virttype", "default": "unknown"}, 548 | {"name": "memory", "source": "mem", "default": "unknown"}, 549 | {"name": "cpu", "default": "unknown"}, 550 | {"name": "state", "default": "unknown"}, 551 | {"name": "architecture", "source": "arch", "default": "unknown"}, 552 | {"name": "autostart", "default": "unknown"}, 553 | {"name": "vncexists", "type": "bool", "default": False}, 554 | {"name": "spiceexists", "type": "bool", "default": False}, 555 | {"name": "vncport", "default": "unknown"}, 556 | {"name": "snapshots", "source": "snaps", "default": "unknown"}, 557 | ], 558 | ) 559 | 560 | # --------------------------- 561 | # get_compose 562 | # --------------------------- 563 | def get_compose(self): 564 | """Get OMV compose""" 565 | tmp = self.api.query("compose", "getContainerList", {"start": 0, "limit": 999}) 566 | if "data" not in tmp: 567 | return 568 | 569 | self.data["compose"] = parse_api( 570 | data={}, 571 | source=tmp["data"], 572 | key="name", 573 | vals=[ 574 | {"name": "name"}, 575 | {"name": "image", "default": "unknown"}, 576 | {"name": "project", "default": "unknown"}, 577 | {"name": "service", "default": "unknown"}, 578 | {"name": "created", "default": "unknown"}, 579 | {"name": "state", "default": "unknown"}, 580 | ], 581 | ) 582 | -------------------------------------------------------------------------------- /custom_components/openmediavault/sensor.py: -------------------------------------------------------------------------------- 1 | """OpenMediaVault sensor platform.""" 2 | from logging import getLogger 3 | from typing import Any 4 | from collections.abc import Mapping 5 | from datetime import date, datetime 6 | from decimal import Decimal 7 | from homeassistant.components.sensor import SensorEntity 8 | from homeassistant.helpers.typing import StateType 9 | from .helper import format_attribute 10 | from .model import model_async_setup_entry, OMVEntity 11 | from .sensor_types import ( 12 | SENSOR_TYPES, 13 | SENSOR_SERVICES, 14 | DEVICE_ATTRIBUTES_DISK_SMART, 15 | ) 16 | 17 | _LOGGER = getLogger(__name__) 18 | 19 | 20 | # --------------------------- 21 | # async_setup_entry 22 | # --------------------------- 23 | async def async_setup_entry(hass, config_entry, async_add_entities): 24 | """Set up device tracker for OpenMediaVault component.""" 25 | dispatcher = { 26 | "OMVSensor": OMVSensor, 27 | "OMVDiskSensor": OMVDiskSensor, 28 | "OMVUptimeSensor": OMVUptimeSensor, 29 | "OMVKVMSensor": OMVKVMSensor, 30 | } 31 | await model_async_setup_entry( 32 | hass, 33 | config_entry, 34 | async_add_entities, 35 | SENSOR_SERVICES, 36 | SENSOR_TYPES, 37 | dispatcher, 38 | ) 39 | 40 | 41 | # --------------------------- 42 | # OMVSensor 43 | # --------------------------- 44 | class OMVSensor(OMVEntity, SensorEntity): 45 | """Define an OpenMediaVault sensor.""" 46 | 47 | def __init__( 48 | self, 49 | inst, 50 | uid: "", 51 | omv_controller, 52 | entity_description, 53 | ): 54 | super().__init__(inst, uid, omv_controller, entity_description) 55 | self._attr_suggested_unit_of_measurement = ( 56 | self.entity_description.suggested_unit_of_measurement 57 | ) 58 | 59 | @property 60 | def native_value(self) -> StateType | date | datetime | Decimal: 61 | """Return the value reported by the sensor.""" 62 | return self._data[self.entity_description.data_attribute] 63 | 64 | @property 65 | def native_unit_of_measurement(self): 66 | """Return the unit the value is expressed in.""" 67 | if self.entity_description.native_unit_of_measurement: 68 | if self.entity_description.native_unit_of_measurement.startswith("data__"): 69 | uom = self.entity_description.native_unit_of_measurement[6:] 70 | if uom in self._data: 71 | return self._data[uom] 72 | 73 | return self.entity_description.native_unit_of_measurement 74 | 75 | return None 76 | 77 | 78 | # --------------------------- 79 | # OMVSensor 80 | # --------------------------- 81 | class OMVDiskSensor(OMVSensor): 82 | """Define an OpenMediaVault sensor.""" 83 | 84 | @property 85 | def extra_state_attributes(self) -> Mapping[str, Any]: 86 | """Return the state attributes.""" 87 | attributes = super().extra_state_attributes 88 | 89 | if not self._ctrl.option_smart_disable: 90 | for variable in DEVICE_ATTRIBUTES_DISK_SMART: 91 | if variable in self._data: 92 | attributes[format_attribute(variable)] = self._data[variable] 93 | 94 | return attributes 95 | 96 | 97 | # --------------------------- 98 | # OMVUptimeSensor 99 | # --------------------------- 100 | class OMVUptimeSensor(OMVSensor): 101 | """Define an OpenMediaVault Uptime sensor.""" 102 | 103 | async def restart(self) -> None: 104 | """Restart OpenMediaVault systen.""" 105 | await self.hass.async_add_executor_job( 106 | self._ctrl.api.query, 107 | "System", 108 | "reboot", 109 | {"delay": 0}, 110 | ) 111 | 112 | async def stop(self) -> None: 113 | """Shutdown OpenMediaVault systen.""" 114 | await self.hass.async_add_executor_job( 115 | self._ctrl.api.query, 116 | "System", 117 | "shutdown", 118 | {"delay": 0}, 119 | ) 120 | 121 | 122 | # --------------------------- 123 | # OMVKVMSensor 124 | # --------------------------- 125 | class OMVKVMSensor(OMVSensor): 126 | """Define an OpenMediaVault VM sensor.""" 127 | 128 | async def start(self) -> None: 129 | """Shutdown OpenMediaVault systen.""" 130 | tmp = await self.hass.async_add_executor_job( 131 | self._ctrl.api.query, "Kvm", "getVmList", {"start": 0, "limit": 999} 132 | ) 133 | 134 | state = "" 135 | if "data" in tmp: 136 | for tmp_i in tmp["data"]: 137 | if tmp_i["vmname"] == self._data["vmname"]: 138 | state = tmp_i["state"] 139 | break 140 | 141 | if state != "shutoff": 142 | _LOGGER.warning("VM %s is not powered off", self._data["vmname"]) 143 | return 144 | 145 | await self.hass.async_add_executor_job( 146 | self._ctrl.api.query, 147 | "Kvm", 148 | "doCommand", 149 | { 150 | "command": "poweron", 151 | "virttype": f"{self._data['type']}", 152 | "name": f"{self._data['vmname']}", 153 | }, 154 | ) 155 | 156 | async def stop(self) -> None: 157 | """Shutdown OpenMediaVault systen.""" 158 | tmp = await self.hass.async_add_executor_job( 159 | self._ctrl.api.query, "Kvm", "getVmList", {"start": 0, "limit": 999} 160 | ) 161 | 162 | state = "" 163 | if "data" in tmp: 164 | for tmp_i in tmp["data"]: 165 | if tmp_i["vmname"] == self._data["vmname"]: 166 | state = tmp_i["state"] 167 | break 168 | 169 | if state != "running": 170 | _LOGGER.warning("VM %s is not running", self._data["vmname"]) 171 | return 172 | 173 | await self.hass.async_add_executor_job( 174 | self._ctrl.api.query, 175 | "Kvm", 176 | "doCommand", 177 | { 178 | "command": "poweroff", 179 | "virttype": f"{self._data['type']}", 180 | "name": f"{self._data['vmname']}", 181 | }, 182 | ) 183 | 184 | async def restart(self) -> None: 185 | """Shutdown OpenMediaVault systen.""" 186 | tmp = await self.hass.async_add_executor_job( 187 | self._ctrl.api.query, "Kvm", "getVmList", {"start": 0, "limit": 999} 188 | ) 189 | 190 | state = "" 191 | if "data" in tmp: 192 | for tmp_i in tmp["data"]: 193 | if tmp_i["vmname"] == self._data["vmname"]: 194 | state = tmp_i["state"] 195 | break 196 | 197 | if state != "running": 198 | _LOGGER.warning("VM %s is not running", self._data["vmname"]) 199 | return 200 | 201 | await self.hass.async_add_executor_job( 202 | self._ctrl.api.query, 203 | "Kvm", 204 | "doCommand", 205 | { 206 | "command": "reboot", 207 | "virttype": f"{self._data['type']}", 208 | "name": f"{self._data['vmname']}", 209 | }, 210 | ) 211 | 212 | async def snapshot(self) -> None: 213 | """Shutdown OpenMediaVault systen.""" 214 | await self.hass.async_add_executor_job( 215 | self._ctrl.api.query, 216 | "Kvm", 217 | "addSnapshot", 218 | { 219 | "virttype": f"{self._data['type']}", 220 | "vmname": f"{self._data['vmname']}", 221 | }, 222 | ) 223 | -------------------------------------------------------------------------------- /custom_components/openmediavault/sensor_types.py: -------------------------------------------------------------------------------- 1 | """Definitions for OMV sensor entities.""" 2 | from dataclasses import dataclass, field 3 | from typing import List 4 | from homeassistant.helpers.entity import EntityCategory 5 | from homeassistant.components.sensor import ( 6 | SensorDeviceClass, 7 | SensorStateClass, 8 | SensorEntityDescription, 9 | ) 10 | from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfDataRate 11 | 12 | from .const import ( 13 | SCHEMA_SERVICE_SYSTEM_REBOOT, 14 | SCHEMA_SERVICE_SYSTEM_SHUTDOWN, 15 | SERVICE_SYSTEM_REBOOT, 16 | SERVICE_SYSTEM_SHUTDOWN, 17 | SERVICE_KVM_START, 18 | SCHEMA_SERVICE_KVM_START, 19 | SERVICE_KVM_STOP, 20 | SCHEMA_SERVICE_KVM_STOP, 21 | SERVICE_KVM_RESTART, 22 | SCHEMA_SERVICE_KVM_RESTART, 23 | SERVICE_KVM_SNAPSHOT, 24 | SCHEMA_SERVICE_KVM_SNAPSHOT, 25 | ) 26 | 27 | DEVICE_ATTRIBUTES_CPUUSAGE = [ 28 | "loadAverage_1", 29 | "loadAverage_5", 30 | "loadAverage_15", 31 | ] 32 | 33 | DEVICE_ATTRIBUTES_FS = [ 34 | "size", 35 | "available", 36 | "type", 37 | "devicename", 38 | "_readonly", 39 | "_used", 40 | "mounted", 41 | "propreadonly", 42 | ] 43 | 44 | DEVICE_ATTRIBUTES_DISK = [ 45 | "canonicaldevicefile", 46 | "size", 47 | "vendor", 48 | "model", 49 | "description", 50 | "serialnumber", 51 | "israid", 52 | "isroot", 53 | "isreadonly", 54 | ] 55 | 56 | DEVICE_ATTRIBUTES_DISK_SMART = [ 57 | "Raw_Read_Error_Rate", 58 | "Spin_Up_Time", 59 | "Start_Stop_Count", 60 | "Reallocated_Sector_Ct", 61 | "Seek_Error_Rate", 62 | "Load_Cycle_Count", 63 | "UDMA_CRC_Error_Count", 64 | "Multi_Zone_Error_Rate", 65 | ] 66 | 67 | DEVICE_ATTRIBUTES_NETWORK = [ 68 | "type", 69 | "method", 70 | "address", 71 | "netmask", 72 | "gateway", 73 | "mtu", 74 | "link", 75 | "wol", 76 | ] 77 | 78 | DEVICE_ATTRIBUTES_KVM = [ 79 | "type", 80 | "memory", 81 | "cpu", 82 | "architecture", 83 | "autostart", 84 | "vncexists", 85 | "spiceexists", 86 | "vncport", 87 | "snapshots", 88 | ] 89 | 90 | DEVICE_ATTRIBUTES_COMPOSE = [ 91 | "image", 92 | "project", 93 | "service", 94 | "created", 95 | ] 96 | 97 | 98 | @dataclass 99 | class OMVSensorEntityDescription(SensorEntityDescription): 100 | """Class describing OMV entities.""" 101 | 102 | ha_group: str = "" 103 | ha_connection: str = "" 104 | ha_connection_value: str = "" 105 | data_path: str = "" 106 | data_attribute: str = "" 107 | data_name: str = "" 108 | data_uid: str = "" 109 | data_reference: str = "" 110 | data_attributes_list: List = field(default_factory=lambda: []) 111 | func: str = "OMVSensor" 112 | 113 | 114 | SENSOR_TYPES = { 115 | "system_cpuUsage": OMVSensorEntityDescription( 116 | key="system_cpuUsage", 117 | name="CPU load", 118 | icon="mdi:speedometer", 119 | native_unit_of_measurement=PERCENTAGE, 120 | suggested_display_precision=0, 121 | device_class=None, 122 | state_class=None, 123 | entity_category=None, 124 | ha_group="System", 125 | data_path="hwinfo", 126 | data_attribute="cpuUsage", 127 | data_name="", 128 | data_uid="", 129 | data_reference="", 130 | data_attributes_list=DEVICE_ATTRIBUTES_CPUUSAGE, 131 | ), 132 | "system_memUsage": OMVSensorEntityDescription( 133 | key="system_memUsage", 134 | name="Memory", 135 | icon="mdi:memory", 136 | native_unit_of_measurement=PERCENTAGE, 137 | suggested_display_precision=0, 138 | device_class=None, 139 | state_class=None, 140 | entity_category=None, 141 | ha_group="System", 142 | data_path="hwinfo", 143 | data_attribute="memUsage", 144 | data_name="", 145 | data_uid="", 146 | data_reference="", 147 | ), 148 | "system_uptimeEpoch": OMVSensorEntityDescription( 149 | key="system_uptimeEpoch", 150 | name="Uptime", 151 | icon="mdi:clock-outline", 152 | native_unit_of_measurement=None, 153 | device_class=SensorDeviceClass.TIMESTAMP, 154 | state_class=None, 155 | entity_category=EntityCategory.DIAGNOSTIC, 156 | ha_group="System", 157 | data_path="hwinfo", 158 | data_attribute="uptimeEpoch", 159 | data_name="", 160 | data_uid="", 161 | data_reference="", 162 | func="OMVUptimeSensor", 163 | ), 164 | "fs": OMVSensorEntityDescription( 165 | key="fs", 166 | name="", 167 | icon="mdi:file-tree", 168 | native_unit_of_measurement=PERCENTAGE, 169 | suggested_display_precision=0, 170 | device_class=None, 171 | state_class=None, 172 | entity_category=None, 173 | ha_group="Filesystem", 174 | data_path="fs", 175 | data_attribute="percentage", 176 | data_name="devicename", 177 | data_uid="", 178 | data_reference="uuid", 179 | data_attributes_list=DEVICE_ATTRIBUTES_FS, 180 | ), 181 | "disk": OMVSensorEntityDescription( 182 | key="disk", 183 | name="", 184 | icon="mdi:harddisk", 185 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 186 | device_class=SensorDeviceClass.TEMPERATURE, 187 | state_class=SensorStateClass.MEASUREMENT, 188 | suggested_display_precision=0, 189 | entity_category=None, 190 | ha_group="Disk", 191 | data_path="disk", 192 | data_attribute="temperature", 193 | data_name="devicename", 194 | data_uid="", 195 | data_reference="devicename", 196 | data_attributes_list=DEVICE_ATTRIBUTES_DISK, 197 | func="OMVDiskSensor", 198 | ), 199 | "network_tx": OMVSensorEntityDescription( 200 | key="network_tx", 201 | name="TX", 202 | icon="mdi:upload-network-outline", 203 | native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, 204 | suggested_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, 205 | suggested_display_precision=1, 206 | device_class=SensorDeviceClass.DATA_RATE, 207 | state_class=SensorStateClass.MEASUREMENT, 208 | entity_category=None, 209 | ha_group="System", 210 | data_path="network", 211 | data_attribute="tx", 212 | data_name="devicename", 213 | data_uid="", 214 | data_reference="uuid", 215 | data_attributes_list=DEVICE_ATTRIBUTES_NETWORK, 216 | ), 217 | "network_rx": OMVSensorEntityDescription( 218 | key="network_rx", 219 | name="RX", 220 | icon="mdi:download-network-outline", 221 | native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, 222 | suggested_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, 223 | suggested_display_precision=1, 224 | device_class=SensorDeviceClass.DATA_RATE, 225 | state_class=SensorStateClass.MEASUREMENT, 226 | entity_category=None, 227 | ha_group="System", 228 | data_path="network", 229 | data_attribute="rx", 230 | data_name="devicename", 231 | data_uid="", 232 | data_reference="uuid", 233 | data_attributes_list=DEVICE_ATTRIBUTES_NETWORK, 234 | ), 235 | "kvm": OMVSensorEntityDescription( 236 | key="kvm", 237 | name="", 238 | icon="mdi:server", 239 | entity_category=None, 240 | ha_group="KVM", 241 | data_path="kvm", 242 | data_attribute="state", 243 | data_name="vmname", 244 | data_uid="", 245 | data_reference="vmname", 246 | data_attributes_list=DEVICE_ATTRIBUTES_KVM, 247 | func="OMVKVMSensor", 248 | ), 249 | "compose": OMVSensorEntityDescription( 250 | key="compose", 251 | name="", 252 | icon="mdi:text-box-multiple-outline", 253 | entity_category=None, 254 | ha_group="Compose", 255 | data_path="compose", 256 | data_attribute="state", 257 | data_name="name", 258 | data_uid="", 259 | data_reference="name", 260 | data_attributes_list=DEVICE_ATTRIBUTES_COMPOSE, 261 | ), 262 | } 263 | 264 | SENSOR_SERVICES = [ 265 | [SERVICE_SYSTEM_REBOOT, SCHEMA_SERVICE_SYSTEM_REBOOT, "restart"], 266 | [SERVICE_SYSTEM_SHUTDOWN, SCHEMA_SERVICE_SYSTEM_SHUTDOWN, "stop"], 267 | [SERVICE_KVM_START, SCHEMA_SERVICE_KVM_START, "start"], 268 | [SERVICE_KVM_STOP, SCHEMA_SERVICE_KVM_STOP, "stop"], 269 | [SERVICE_KVM_RESTART, SCHEMA_SERVICE_KVM_RESTART, "restart"], 270 | [SERVICE_KVM_SNAPSHOT, SCHEMA_SERVICE_KVM_SNAPSHOT, "snapshot"], 271 | ] 272 | -------------------------------------------------------------------------------- /custom_components/openmediavault/services.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | system_reboot: 3 | name: Reboot OpenMediaVault 4 | description: Reboot OpenMediaVault System (Target Uptime Sensor) 5 | target: 6 | entity: 7 | integration: openmediavault 8 | domain: sensor 9 | 10 | system_shutdown: 11 | name: Shutdown OpenMediaVault 12 | description: Shutdown OpenMediaVault System (Target Uptime Sensor) 13 | target: 14 | entity: 15 | integration: openmediavault 16 | domain: sensor 17 | 18 | kvm_start: 19 | name: Start KVM Virtual Machine 20 | description: Start KVM Virtual Machine 21 | target: 22 | entity: 23 | integration: openmediavault 24 | domain: sensor 25 | 26 | kvm_stop: 27 | name: Stop KVM Virtual Machine 28 | description: Stop KVM Virtual Machine 29 | target: 30 | entity: 31 | integration: openmediavault 32 | domain: sensor 33 | 34 | kvm_restart: 35 | name: Restart KVM Virtual Machine 36 | description: Restart KVM Virtual Machine 37 | target: 38 | entity: 39 | integration: openmediavault 40 | domain: sensor 41 | 42 | kvm_snapshot: 43 | name: Create snapshot of KVM Virtual Machine 44 | description: Create snapshot of KVM Virtual Machine 45 | target: 46 | entity: 47 | integration: openmediavault 48 | domain: sensor 49 | -------------------------------------------------------------------------------- /custom_components/openmediavault/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Set up OpenMediaVault", 6 | "description": "Set up OpenMediaVault integration.", 7 | "data": { 8 | "name": "Name of the integration", 9 | "host": "Host", 10 | "username": "Username", 11 | "password": "Password", 12 | "ssl": "Use SSL", 13 | "verify_ssl": "Verify SSL certificate" 14 | } 15 | } 16 | }, 17 | "error": { 18 | "name_exists": "Name already exists.", 19 | "cannot_connect": "Cannot connect to OpenMediaVault.", 20 | "wrong_login": "Invalid user name or password.", 21 | "ssl_verify_failed": "SSL certificate verify failed.", 22 | "no_respose": "No response from host.", 23 | "400": "Bad request.", 24 | "401": "No authorization for this endpoint.", 25 | "404": "API not found on this host.", 26 | "500": "Internal error.", 27 | "501": "Server does not support the functionality.", 28 | "5001": "OpenMediaVault connection timeout." 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "basic_options": { 34 | "data": { 35 | "scan_interval": "Scan interval", 36 | "smart_disable": "Disable S.M.A.R.T." 37 | }, 38 | "title": "OpenMediaVault options", 39 | "description": "Configure integration" 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /custom_components/openmediavault/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Set up OpenMediaVault", 6 | "description": "Set up OpenMediaVault integration.", 7 | "data": { 8 | "name": "Name of the integration", 9 | "host": "Host", 10 | "username": "Username", 11 | "password": "Password", 12 | "ssl": "Use SSL", 13 | "verify_ssl": "Verify SSL certificate" 14 | } 15 | } 16 | }, 17 | "error": { 18 | "name_exists": "Name already exists.", 19 | "cannot_connect": "Cannot connect to OpenMediaVault.", 20 | "wrong_login": "Invalid user name or password.", 21 | "ssl_verify_failed": "SSL certificate verify failed.", 22 | "no_respose": "No response from host.", 23 | "400": "Bad request.", 24 | "401": "No authorization for this endpoint.", 25 | "404": "API not found on this host.", 26 | "500": "Internal error.", 27 | "501": "Server does not support the functionality.", 28 | "5001": "OpenMediaVault connection timeout." 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "basic_options": { 34 | "data": { 35 | "scan_interval": "Scan interval", 36 | "smart_disable": "Disable S.M.A.R.T." 37 | }, 38 | "title": "OpenMediaVault options", 39 | "description": "Configure integration" 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /custom_components/openmediavault/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Stel OpenMediaVault in", 6 | "description": "Stel OpenMediaVault-integratie in.", 7 | "data": { 8 | "name": "Naam van de integratie", 9 | "host": "Host", 10 | "username": "Gebruikersnaam", 11 | "password": "Wachtwoord", 12 | "ssl": "Gebruik SSL", 13 | "verify_ssl": "SSL-certificaat verifiëren" 14 | } 15 | } 16 | }, 17 | "error": { 18 | "name_exists": "De naam bestaat al.", 19 | "cannot_connect": "Kan geen verbinding maken met OpenMediaVault.", 20 | "wrong_login": "Ongeldige gebruikersnaam of wachtwoord.", 21 | "ssl_verify_failed": "Verificatie van SSL-certificaat mislukt.", 22 | "no_respose": "Geen reactie van host.", 23 | "400": "Foute aanvraag.", 24 | "401": "Geen autorisatie voor dit eindpunt.", 25 | "404": "API niet gevonden op deze host.", 26 | "500": "Interne fout.", 27 | "501": "De server ondersteunt de functionaliteit niet.", 28 | "5001": "OpenMediaVault verbinding time-out." 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "basic_options": { 34 | "data": { 35 | "scan_interval": "Scan interval", 36 | "smart_disable": "Disable S.M.A.R.T." 37 | }, 38 | "title": "OpenMediaVault options", 39 | "description": "Integratie configureren" 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /custom_components/openmediavault/translations/pt_BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Configurar o OpenMediaVault", 6 | "description": "Configure a integração do OpenMediaVault.", 7 | "data": { 8 | "name": "Nome da integração", 9 | "host": "Host", 10 | "username": "Nome de usuário", 11 | "password": "Senha", 12 | "ssl": "Usar SSL", 13 | "verify_ssl": "Verificar o certificado SSL" 14 | } 15 | } 16 | }, 17 | "error": { 18 | "name_exists": "O nome já existe.", 19 | "cannot_connect": "Não é possível conectar ao OpenMediaVault.", 20 | "wrong_login": "Nome de usuário ou senha inválidos.", 21 | "ssl_verify_failed": "Falha na verificação do certificado SSL.", 22 | "no_respose": "Sem resposta do host.", 23 | "400": "Má solicitação.", 24 | "401": "Nenhuma autorização para este endpoint.", 25 | "404": "API não encontrada neste host.", 26 | "500": "Erro interno.", 27 | "501": "O servidor não suporta a funcionalidade.", 28 | "5001": "Tempo limite de conexão do OpenMediaVault." 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "basic_options": { 34 | "data": { 35 | "scan_interval": "Scan interval", 36 | "smart_disable": "Disable S.M.A.R.T." 37 | }, 38 | "title": "OpenMediaVault options", 39 | "description": "Configure integration" 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /custom_components/openmediavault/translations/sv_SE.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Konfigurera OpenMediaVault", 6 | "description": "Konfigurera OpenMediaVault-integration.", 7 | "data": { 8 | "name": "Namnet på integrationen", 9 | "host": "Värd", 10 | "username": "Användarnamn", 11 | "password": "Lösenord", 12 | "ssl": "Använd SSL", 13 | "verify_ssl": "Verifiera SSL-certifikat" 14 | } 15 | } 16 | }, 17 | "error": { 18 | "name_exists": "Namnet finns redan.", 19 | "cannot_connect": "Kan inte ansluta till OpenMediaVault.", 20 | "wrong_login": "Felaktigt användarnamn eller lösenord.", 21 | "ssl_verify_failed": "Verifieringen av SSL-certifikatet misslyckades.", 22 | "no_respose": "Inget svar från värden.", 23 | "400": "Dålig förfrågan.", 24 | "401": "Ingen auktorisering för den här ändpunkten.", 25 | "404": "API hittades inte på denna värd.", 26 | "500": "Internt fel.", 27 | "501": "Servern stöder inte funktionen.", 28 | "5001": "Timeout för OpenMediaVault-anslutningen." 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "basic_options": { 34 | "data": { 35 | "scan_interval": "Scan interval", 36 | "smart_disable": "Disable S.M.A.R.T." 37 | }, 38 | "title": "OpenMediaVault options", 39 | "description": "Configure integration" 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OpenMediaVault", 3 | "homeassistant": "2022.2.0", 4 | "render_readme": false, 5 | "zip_release": true, 6 | "hide_default_branch": true, 7 | "filename": "openmediavault.zip" 8 | } 9 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/tomaae/homeassistant-openmediavault?style=plastic) 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=plastic)](https://github.com/hacs/integration) 3 | ![Project Stage](https://img.shields.io/badge/project%20stage-development-yellow.svg?style=plastic) 4 | ![GitHub all releases](https://img.shields.io/github/downloads/tomaae/homeassistant-openmediavault/total?style=plastic) 5 | 6 | ![OpenMediaVault Logo](https://raw.githubusercontent.com/tomaae/homeassistant-openmediavault/master/docs/assets/images/ui/header.png) 7 | 8 | Monitor your OpenMediaVault 5/6 NAS from Home Assistant. 9 | 10 | Features: 11 | * Filesystem usage sensors 12 | * System sensors (CPU, Memory, Uptime) 13 | * System status sensors (Available updates, Required reboot and Dirty config) 14 | * Disk and smart sensors 15 | * Service sensors 16 | 17 | ## Links 18 | - [Documentation](https://github.com/tomaae/homeassistant-openmediavault/tree/master) 19 | - [Configuration](https://github.com/tomaae/homeassistant-openmediavault/tree/master#setup-integration) 20 | - [Report a Bug](https://github.com/tomaae/homeassistant-openmediavault/issues/new?labels=bug&template=bug_report.md&title=%5BBug%5D) 21 | - [Suggest an idea](https://github.com/tomaae/homeassistant-openmediavault/issues/new?labels=enhancement&template=feature_request.md&title=%5BFeature%5D) 22 | 23 | [![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/G2G71MKZG) 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W293 3 | 4 | max-line-length = 220 5 | max-complexity = 10 6 | exclude = ./.git, 7 | ./tests, 8 | ./.github, 9 | __pycache__, 10 | ./docs, 11 | ./custom_components/mikrotik_router/librouteros_custom 12 | 13 | # Run with: pylint --rcfile=setup.cfg --load-plugins=pylint.extensions.mccabe custom_components 14 | [pylint] 15 | disable = duplicate-code, 16 | too-many-public-methods, 17 | useless-return, 18 | import-error, 19 | too-many-arguments, 20 | too-many-instance-attributes, 21 | simplifiable-if-expression, 22 | bare-except 23 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=tomaae_homeassistant-openmediavault 2 | sonar.organization=tomaae 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=homeassistant-truenas 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | #sonar.sources=. 10 | 11 | # Encoding of the source code. Default is default system encoding 12 | #sonar.sourceEncoding=UTF-8 13 | sonar.python.version=3 --------------------------------------------------------------------------------