├── .gitignore ├── LICENSE ├── README.md ├── custom_updater.json ├── docker_monitor ├── __init__.py ├── sensor.py └── switch.py └── sensor ├── eetlijst.py └── luftdaten_cu.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python ### 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | 118 | ### Python Patch ### 119 | .venv/ 120 | 121 | ### VisualStudioCode ### 122 | .vscode/* 123 | !.vscode/settings.json 124 | !.vscode/tasks.json 125 | !.vscode/launch.json 126 | !.vscode/extensions.json 127 | 128 | ### VisualStudioCode Patch ### 129 | # Ignore all local history of files 130 | .history 131 | 132 | # End of https://www.gitignore.io/api/python,visualstudiocode -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom components for Home Assistant 2 | 3 | [![maintainer](https://img.shields.io/badge/maintainer-Sander%20Huisman%20-blue.svg?style=for-the-badge)](https://github.com/Sanderhuisman) 4 | 5 | ## About 6 | 7 | This repository contains custom components I developed for my own [Home-Assistant](https://www.home-assistant.io) setup. Feel free to use the components and report bugs if you find them. If you want to contribute, please report a bug or pull request and I will reply as soon as possible. Please star & watch my project such I can see how many people like my components and for you to stay in the loop as updates come along. 8 | 9 | ## Components 10 | 11 | * [Docker Monitor](#docker_monitor) 12 | * [Eetlijst sensor](#eetlijst) 13 | * [Luftdaten](#luftdaten) 14 | 15 | ### Docker Monitor 16 | 17 | The Docker monitor allows you to monitor statistics and turn on/off containers. The monitor can connected to a daemon through the url parameter. When home assistant is used within a Docker container, the daemon can be mounted as follows `-v /var/run/docker.sock:/var/run/docker.sock`. The monitor is based on [Glances](https://github.com/nicolargo/glances) and [ha-dockermon](https://github.com/philhawthorne/ha-dockermon) and combines (in my opinion the best of both integrated in HA :)). 18 | 19 | **Important note: as the loading path of platforms have been changed in issue [#20807](https://github.com/home-assistant/home-assistant/pull/20807), the current version requires HA versions 0.88 and above. For older versions, use version [0.0.1](https://github.com/Sanderhuisman/home-assistant-custom-components/releases/tag/0.0.1).** 20 | 21 | #### Events 22 | 23 | The monitor can listen for events on the Docker event bus and can fire an event on the Home Assistant Bus. The monitor will use the following event: 24 | 25 | * `{name}_container_event` with name the same set in the configuration. 26 | 27 | The event will contain the following data: 28 | 29 | * `Container`: Container name 30 | * `Image`: Container image 31 | * `Status`: Container satus 32 | * `Id`: Container ID (long) 33 | 34 | #### Configuration 35 | 36 | To use the `docker_monitor` in your installation, add the following to your `configuration.yaml` file: 37 | 38 | ```yaml 39 | # Example configuration.yaml entry 40 | docker_monitor: 41 | containers: 42 | - homeassistant_homeassistant_1 43 | - homeassistant_mariadb_1 44 | - homeassistant_mosquitto_1 45 | monitored_conditions: 46 | - utilization_version 47 | - container_status 48 | - container_memory_usage 49 | - container_memory_percentage_usage 50 | - container_cpu_percentage_usage 51 | ``` 52 | 53 | ##### Configuration variables 54 | 55 | | Parameter | Type | Description | 56 | | -------------------- | ------------------------ | --------------------------------------------------------------------- | 57 | | name | string (Optional) | Client name of Docker daemon. Defaults to `Docker`. | 58 | | url | string (Optional) | Host URL of Docker daemon. Defaults to `unix://var/run/docker.sock`. | 59 | | scan_interval | time_period (Optional) | Update interval. Defaults to 10 seconds. | 60 | | events | boolean (Optional) | Listen for events from Docker. Defaults to false. | 61 | | containers | list (Optional) | Array of containers to monitor. Defaults to all containers. | 62 | | monitored_conditions | list (Optional) | Array of conditions to be monitored. Defaults to all conditions | 63 | 64 | | Condition | Description | Unit | 65 | | --------------------------------- | ------------------------------- | ----- | 66 | | utilization_version | Docker version | - | 67 | | container_status | Container status | - | 68 | | container_uptime | Container start time | - | 69 | | container_image | Container image | - | 70 | | container_cpu_percentage_usage | CPU usage | % | 71 | | container_memory_usage | Memory usage | MB | 72 | | container_memory_percentage_usage | Memory usage | % | 73 | | container_network_speed_up | Network total speed upstream | kB/s | 74 | | container_network_speed_down | Network total speed downstream | kB/s | 75 | | container_network_total_up | Network total upstream | MB | 76 | | container_network_total_down | Network total downstream | MB | 77 | 78 | ### Eetlijst Sensor 79 | 80 | An Eetlijst sensor to monitor the eat/cook status of your student home. 81 | 82 | #### Configuration 83 | 84 | To use `eetlijst` in your installation, add the following to your `configuration.yaml` file: 85 | 86 | ```yaml 87 | # Example configuration.yaml entry 88 | sensor: 89 | - platform: eetlijst 90 | username: !secret eetlijst_username 91 | password: !secret eetlijst_password 92 | ``` 93 | 94 | ##### Configuration variables 95 | 96 | | Parameter | Type | Description | 97 | | --------------------- | ----------------------- | ------------- | 98 | | username | string (Required) | Username | 99 | | password | string (Required) | Password | 100 | 101 | ### Lufdaten Sensor 102 | 103 | A custom Luftdaten sensor to monitor polution of a station. 104 | 105 | #### Configuration 106 | 107 | To use `luftdaten_cu` in your installation, add the following to your `configuration.yaml` file: 108 | 109 | ```yaml 110 | # Example configuration.yaml entry 111 | sensor: 112 | - platform: luftdaten_cu 113 | sensorid: 15307 114 | monitored_conditions: 115 | - P1 116 | - P2 117 | ``` 118 | 119 | ##### Configuration variables 120 | 121 | | Parameter | Type | Description | 122 | | --------------------- | ----------------------- | --------------------------------------------------------------- | 123 | | sensorid | int (Required) | Sensor id of the lufdaten sensor to be monitored. | 124 | | monitored_conditions | list (Optional) | Array of conditions to be monitored. Defaults to all conditions | 125 | 126 | | Condition | Description | Unit | 127 | | --------------------------------- | --------------------- | ----- | 128 | | temperature | Temperature | °C | 129 | | humidity | Container status | % | 130 | | pressure | Air pressure | Pa | 131 | | P1 | PM10 | µg/m3 | 132 | | P2 | PM2.5 | µg/m3 | 133 | 134 | ## Track Updates 135 | This custom component can be tracked with the help of [custom-lovelace](https://github.com/ciotlosm/custom-lovelace) cards with the [custom_updater](https://github.com/custom-cards/tracker-card) card. 136 | 137 | In your configuration.yaml 138 | 139 | ```yaml 140 | custom_updater: 141 | component_urls: 142 | - 'https://raw.githubusercontent.com/Sanderhuisman/home-assistant-custom-components/master/custom_updater.json' 143 | ``` 144 | 145 | ## Credits 146 | 147 | * [frenck](https://github.com/frenck/home-assistant-config) 148 | * [robmarkcole](https://github.com/robmarkcole/Hue-sensors-HASS) 149 | -------------------------------------------------------------------------------- /custom_updater.json: -------------------------------------------------------------------------------- 1 | { 2 | "docker_monitor": { 3 | "updated_at": "2019-02-19", 4 | "version": "0.0.3", 5 | "local_location": "/custom_components/docker_monitor/__init__.py", 6 | "remote_location": "https://raw.githubusercontent.com/Sanderhuisman/home-assistant-custom-components/master/docker_monitor/__init__.py", 7 | "changelog": "https://github.com/Sanderhuisman/home-assistant-custom-components/releases/latest", 8 | "visit_repo": "https://github.com/Sanderhuisman/home-assistant-custom-components" 9 | }, 10 | "docker_monitor.sensor": { 11 | "updated_at": "2019-02-19", 12 | "version": "0.0.3", 13 | "local_location": "/custom_components/docker_monitor/sensor.py", 14 | "remote_location": "https://raw.githubusercontent.com/Sanderhuisman/home-assistant-custom-components/master/docker_monitor/sensor.py", 15 | "changelog": "https://github.com/Sanderhuisman/home-assistant-custom-components/releases/latest", 16 | "visit_repo": "https://github.com/Sanderhuisman/home-assistant-custom-components" 17 | }, 18 | "docker_monitor.switch": { 19 | "updated_at": "2019-02-19", 20 | "version": "0.0.3", 21 | "local_location": "/custom_components/docker_monitor/switch.py", 22 | "remote_location": "https://raw.githubusercontent.com/Sanderhuisman/home-assistant-custom-components/master/docker_monitor/switch.py", 23 | "changelog": "https://github.com/Sanderhuisman/home-assistant-custom-components/releases/latest", 24 | "visit_repo": "https://github.com/Sanderhuisman/home-assistant-custom-components" 25 | }, 26 | "eetlijst.sensor": { 27 | "updated_at": "2019-02-19", 28 | "version": "0.0.3", 29 | "local_location": "/custom_components/sensor/eetlijst.py", 30 | "remote_location": "https://raw.githubusercontent.com/Sanderhuisman/home-assistant-custom-components/master/sensor/eetlijst.py", 31 | "changelog": "https://github.com/Sanderhuisman/home-assistant-custom-components/releases/latest", 32 | "visit_repo": "https://github.com/Sanderhuisman/home-assistant-custom-components" 33 | }, 34 | "luftdaten.sensor": { 35 | "updated_at": "2019-02-19", 36 | "version": "0.0.3", 37 | "local_location": "/custom_components/sensor/luftdaten_cu.py", 38 | "remote_location": "https://raw.githubusercontent.com/Sanderhuisman/home-assistant-custom-components/master/sensor/luftdaten_cu.py", 39 | "changelog": "https://github.com/Sanderhuisman/home-assistant-custom-components/releases/latest", 40 | "visit_repo": "https://github.com/Sanderhuisman/home-assistant-custom-components" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /docker_monitor/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Docker Monitor component 3 | 4 | For more details about this component, please refer to the documentation at 5 | https://github.com/Sanderhuisman/home-assistant-custom-components 6 | ''' 7 | import logging 8 | import threading 9 | import time 10 | from datetime import timedelta 11 | 12 | import homeassistant.helpers.config_validation as cv 13 | import voluptuous as vol 14 | from homeassistant.const import ( 15 | ATTR_ATTRIBUTION, 16 | CONF_MONITORED_CONDITIONS, 17 | CONF_NAME, 18 | CONF_SCAN_INTERVAL, 19 | CONF_URL, 20 | EVENT_HOMEASSISTANT_STOP 21 | ) 22 | from homeassistant.core import callback 23 | from homeassistant.helpers.discovery import load_platform 24 | from homeassistant.util import slugify as util_slugify 25 | 26 | VERSION = '0.0.3' 27 | 28 | REQUIREMENTS = ['docker==3.7.0', 'python-dateutil==2.7.5'] 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | DOMAIN = 'docker_monitor' 33 | 34 | CONF_ATTRIBUTION = 'Data provided by Docker' 35 | 36 | DOCKER_HANDLE = 'docker_handle' 37 | DATA_DOCKER_API = 'api' 38 | DATA_CONFIG = 'config' 39 | 40 | EVENT_CONTAINER = 'container_event' 41 | 42 | PRECISION = 2 43 | 44 | DEFAULT_URL = 'unix://var/run/docker.sock' 45 | DEFAULT_NAME = 'Docker' 46 | 47 | DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) 48 | 49 | DOCKER_TYPE = [ 50 | 'sensor', 51 | 'switch' 52 | ] 53 | 54 | CONF_EVENTS = 'events' 55 | CONF_CONTAINERS = 'containers' 56 | 57 | UTILISATION_MONITOR_VERSION = 'utilization_version' 58 | 59 | CONTAINER_MONITOR_STATUS = 'container_status' 60 | CONTAINER_MONITOR_UPTIME = 'container_uptime' 61 | CONTAINER_MONITOR_IMAGE = 'container_image' 62 | CONTAINER_MONITOR_CPU_PERCENTAGE = 'container_cpu_percentage_usage' 63 | CONTAINER_MONITOR_MEMORY_USAGE = 'container_memory_usage' 64 | CONTAINER_MONITOR_MEMORY_PERCENTAGE = 'container_memory_percentage_usage' 65 | CONTAINER_MONITOR_NETWORK_SPEED_UP = 'container_network_speed_up' 66 | CONTAINER_MONITOR_NETWORK_SPEED_DOWN = 'container_network_speed_down' 67 | CONTAINER_MONITOR_NETWORK_TOTAL_UP = 'container_network_total_up' 68 | CONTAINER_MONITOR_NETWORK_TOTAL_DOWN = 'container_network_total_down' 69 | 70 | _UTILISATION_MON_COND = { 71 | UTILISATION_MONITOR_VERSION: ['Version', None, 'mdi:information-outline', None], 72 | } 73 | 74 | _CONTAINER_MON_COND = { 75 | CONTAINER_MONITOR_STATUS: ['Status', None, 'mdi:checkbox-marked-circle-outline', None], 76 | CONTAINER_MONITOR_UPTIME: ['Up Time', '', 'mdi:clock', 'timestamp'], 77 | CONTAINER_MONITOR_IMAGE: ['Image', None, 'mdi:information-outline', None], 78 | CONTAINER_MONITOR_CPU_PERCENTAGE: ['CPU use', '%', 'mdi:chip', None], 79 | CONTAINER_MONITOR_MEMORY_USAGE: ['Memory use', 'MB', 'mdi:memory', None], 80 | CONTAINER_MONITOR_MEMORY_PERCENTAGE: ['Memory use (percent)', '%', 'mdi:memory', None], 81 | CONTAINER_MONITOR_NETWORK_SPEED_UP: ['Network speed Up', 'kB/s', 'mdi:upload', None], 82 | CONTAINER_MONITOR_NETWORK_SPEED_DOWN: ['Network speed Down', 'kB/s', 'mdi:download', None], 83 | CONTAINER_MONITOR_NETWORK_TOTAL_UP: ['Network total Up', 'MB', 'mdi:upload', None], 84 | CONTAINER_MONITOR_NETWORK_TOTAL_DOWN: ['Network total Down', 'MB', 'mdi:download', None], 85 | } 86 | 87 | _MONITORED_CONDITIONS = \ 88 | list(_UTILISATION_MON_COND.keys()) + \ 89 | list(_CONTAINER_MON_COND.keys()) 90 | 91 | CONFIG_SCHEMA = vol.Schema({ 92 | DOMAIN: vol.Schema({ 93 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): 94 | cv.string, 95 | vol.Optional(CONF_URL, default=DEFAULT_URL): 96 | cv.string, 97 | vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): 98 | cv.time_period, 99 | vol.Optional(CONF_EVENTS, default=False): 100 | cv.boolean, 101 | vol.Optional(CONF_MONITORED_CONDITIONS, default=_MONITORED_CONDITIONS): 102 | vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]), 103 | vol.Optional(CONF_CONTAINERS): 104 | cv.ensure_list, 105 | }) 106 | }, extra=vol.ALLOW_EXTRA) 107 | 108 | 109 | def setup(hass, config): 110 | _LOGGER.info("Settings: {}".format(config[DOMAIN])) 111 | 112 | host = config[DOMAIN].get(CONF_URL) 113 | 114 | try: 115 | api = DockerAPI(host) 116 | except (ImportError, ConnectionError) as e: 117 | _LOGGER.info("Error setting up Docker API ({})".format(e)) 118 | return False 119 | else: 120 | version = api.get_info() 121 | _LOGGER.debug("Docker version: {}".format( 122 | version.get('version', None))) 123 | 124 | hass.data[DOCKER_HANDLE] = {} 125 | hass.data[DOCKER_HANDLE][DATA_DOCKER_API] = api 126 | hass.data[DOCKER_HANDLE][DATA_CONFIG] = { 127 | CONF_NAME: config[DOMAIN][CONF_NAME], 128 | CONF_CONTAINERS: config[DOMAIN].get(CONF_CONTAINERS, [container.get_name() for container in api.get_containers()]), 129 | CONF_MONITORED_CONDITIONS: config[DOMAIN].get(CONF_MONITORED_CONDITIONS), 130 | CONF_SCAN_INTERVAL: config[DOMAIN].get(CONF_SCAN_INTERVAL), 131 | } 132 | 133 | for component in DOCKER_TYPE: 134 | load_platform(hass, component, DOMAIN, {}, config) 135 | 136 | def monitor_stop(_service_or_event): 137 | """Stop the monitor thread.""" 138 | _LOGGER.info("Stopping threads for Docker monitor") 139 | api.exit() 140 | 141 | hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, monitor_stop) 142 | 143 | def event_listener(message): 144 | event = util_slugify("{} {}".format(config[DOMAIN][CONF_NAME], EVENT_CONTAINER)) 145 | _LOGGER.debug("Sending event {} notification with message {}".format(event, message)) 146 | hass.bus.fire(event, message) 147 | 148 | if config[DOMAIN][CONF_EVENTS]: 149 | api.events(event_listener) 150 | 151 | return True 152 | 153 | 154 | """ 155 | Docker API abstraction 156 | """ 157 | 158 | 159 | class DockerAPI: 160 | def __init__(self, base_url): 161 | self._base_url = base_url 162 | try: 163 | import docker 164 | except ImportError as e: 165 | _LOGGER.error("Missing Docker library ({})".format(e)) 166 | raise ImportError() 167 | 168 | self._containers = {} 169 | self._event_callback_listeners = [] 170 | self._events = None 171 | 172 | try: 173 | self._client = docker.DockerClient(base_url=self._base_url) 174 | except Exception as e: 175 | _LOGGER.error("Can not connect to Docker ({})".format(e)) 176 | raise ConnectionError() 177 | 178 | for container in self._client.containers.list(all=True) or []: 179 | _LOGGER.debug("Found container: {}".format(container.name)) 180 | self._containers[container.name] = DockerContainerAPI( 181 | self._client, container.name) 182 | 183 | def exit(self): 184 | _LOGGER.info("Stopping threads for Docker monitor") 185 | if self._events: 186 | self._events.close() 187 | for container in self._containers.values(): 188 | container.exit() 189 | 190 | def events(self, callback): 191 | if not self._event_callback_listeners: 192 | thread = threading.Thread(target=self._runnable, kwargs={}) 193 | thread.start() 194 | 195 | if callback not in self._event_callback_listeners: 196 | self._event_callback_listeners.append(callback) 197 | 198 | def get_info(self): 199 | version = {} 200 | try: 201 | raw_stats = self._client.version() 202 | version = { 203 | 'version': raw_stats.get('Version', None), 204 | 'api_version': raw_stats.get('ApiVersion', None), 205 | 'os': raw_stats.get('Os', None), 206 | 'arch': raw_stats.get('Arch', None), 207 | 'kernel': raw_stats.get('KernelVersion', None), 208 | } 209 | except Exception as e: 210 | _LOGGER.error("Cannot get Docker version ({})".format(e)) 211 | 212 | return version 213 | 214 | def _runnable(self): 215 | self._events = self._client.events(decode=True) 216 | for event in self._events: 217 | _LOGGER.debug("Event: ({})".format(event)) 218 | try: 219 | # Only interested in container events 220 | if event['Type'] == 'container': 221 | message = { 222 | 'Container': event['Actor']['Attributes'].get('name'), 223 | 'Image': event['from'], 224 | 'Status': event['status'], 225 | 'Id': event['id'], 226 | } 227 | _LOGGER.info("Container event: ({})".format(message)) 228 | 229 | for callback in self._event_callback_listeners: 230 | callback(message) 231 | except KeyError as e: 232 | _LOGGER.error("Key error: ({})".format(e)) 233 | pass 234 | 235 | def get_containers(self): 236 | return list(self._containers.values()) 237 | 238 | def get_container(self, name): 239 | container = None 240 | if name in self._containers: 241 | container = self._containers[name] 242 | return container 243 | 244 | 245 | class DockerContainerAPI: 246 | def __init__(self, client, name): 247 | self._client = client 248 | self._name = name 249 | 250 | self._subscribers = [] 251 | 252 | self._container = client.containers.get(self._name) 253 | 254 | self._thread = None 255 | self._stopper = None 256 | 257 | def get_name(self): 258 | return self._name 259 | 260 | # Call from DockerAPI 261 | def exit(self, timeout=None): 262 | """Stop the thread.""" 263 | _LOGGER.debug("Close stats thread for container {}".format(self._name)) 264 | if self._thread is not None: 265 | self._stopper.set() 266 | 267 | def stats(self, callback, interval=10): 268 | if not self._subscribers: 269 | self._stopper = threading.Event() 270 | thread = threading.Thread(target=self._runnable, kwargs={ 271 | 'interval': interval}) 272 | self._thread = thread 273 | thread.start() 274 | 275 | if callback not in self._subscribers: 276 | self._subscribers.append(callback) 277 | 278 | def get_info(self): 279 | from dateutil import parser 280 | 281 | self._container.reload() 282 | info = { 283 | 'id': self._container.id, 284 | 'image': self._container.image.tags, 285 | 'status': self._container.attrs['State']['Status'], 286 | 'created': parser.parse(self._container.attrs['Created']), 287 | 'started': parser.parse(self._container.attrs['State']['StartedAt']), 288 | } 289 | 290 | return info 291 | 292 | def start(self): 293 | _LOGGER.info("Start container {}".format(self._name)) 294 | self._container.start() 295 | 296 | def stop(self, timeout=10): 297 | _LOGGER.info("Stop container {}".format(self._name)) 298 | self._container.stop(timeout=timeout) 299 | 300 | def _notify(self, message): 301 | _LOGGER.debug("Send notify for container {}".format(self._name)) 302 | for callback in self._subscribers: 303 | callback(message) 304 | 305 | def _runnable(self, interval): 306 | from dateutil import parser 307 | 308 | stream = self._container.stats(stream=True, decode=True) 309 | 310 | cpu_old = {} 311 | network_old = {} 312 | for raw in stream: 313 | if self._stopper.isSet(): 314 | break 315 | 316 | stats = {} 317 | 318 | stats['info'] = self.get_info() 319 | if stats['info']['status'] in ('running', 'paused'): 320 | stats['read'] = parser.parse(raw['read']) 321 | 322 | cpu_stats = {} 323 | try: 324 | cpu_new = {} 325 | cpu_new['total'] = raw['cpu_stats']['cpu_usage']['total_usage'] 326 | cpu_new['system'] = raw['cpu_stats']['system_cpu_usage'] 327 | 328 | # Compatibility wih older Docker API 329 | if 'online_cpus' in raw['cpu_stats']: 330 | cpu_stats['online_cpus'] = raw['cpu_stats']['online_cpus'] 331 | else: 332 | cpu_stats['online_cpus'] = len( 333 | raw['cpu_stats']['cpu_usage']['percpu_usage'] or []) 334 | except KeyError as e: 335 | # raw do not have CPU information 336 | _LOGGER.info("Cannot grab CPU usage for container {} ({})".format( 337 | self._container.id, e)) 338 | _LOGGER.debug(raw) 339 | else: 340 | if cpu_old: 341 | cpu_delta = float(cpu_new['total'] - cpu_old['total']) 342 | system_delta = float( 343 | cpu_new['system'] - cpu_old['system']) 344 | 345 | cpu_stats['total'] = round(0.0, PRECISION) 346 | if cpu_delta > 0.0 and system_delta > 0.0: 347 | cpu_stats['total'] = round( 348 | (cpu_delta / system_delta) * float(cpu_stats['online_cpus']) * 100.0, PRECISION) 349 | 350 | cpu_old = cpu_new 351 | 352 | memory_stats = {} 353 | try: 354 | memory_stats['usage'] = raw['memory_stats']['usage'] 355 | memory_stats['limit'] = raw['memory_stats']['limit'] 356 | memory_stats['max_usage'] = raw['memory_stats']['max_usage'] 357 | except (KeyError, TypeError) as e: 358 | # raw_stats do not have MEM information 359 | _LOGGER.info("Cannot grab MEM usage for container {} ({})".format( 360 | self._container.id, e)) 361 | _LOGGER.debug(raw) 362 | else: 363 | memory_stats['usage_percent'] = round( 364 | float(memory_stats['usage']) / float(memory_stats['limit']) * 100.0, PRECISION) 365 | 366 | network_stats = {} 367 | try: 368 | network_new = {} 369 | _LOGGER.debug("Found network stats: {}".format(raw["networks"])) 370 | network_stats['total_tx'] = 0 371 | network_stats['total_rx'] = 0 372 | for if_name, data in raw["networks"].items(): 373 | _LOGGER.debug("Stats for interface {} -> up {} / down {}".format( 374 | if_name, data["tx_bytes"], data["rx_bytes"])) 375 | network_stats['total_tx'] += data["tx_bytes"] 376 | network_stats['total_rx'] += data["rx_bytes"] 377 | 378 | network_new = { 379 | 'read': stats['read'], 380 | 'total_tx': network_stats['total_tx'], 381 | 'total_rx': network_stats['total_rx'], 382 | } 383 | 384 | except KeyError as e: 385 | # raw_stats do not have NETWORK information 386 | _LOGGER.info("Cannot grab NET usage for container {} ({})".format( 387 | self._container.id, e)) 388 | _LOGGER.debug(raw) 389 | else: 390 | if network_old: 391 | tx = network_new['total_tx'] - network_old['total_tx'] 392 | rx = network_new['total_rx'] - network_old['total_rx'] 393 | tim = (network_new['read'] - network_old['read']).total_seconds() 394 | 395 | network_stats['speed_tx'] = round(float(tx) / tim, PRECISION) 396 | network_stats['speed_rx'] = round(float(rx) / tim, PRECISION) 397 | 398 | network_old = network_new 399 | 400 | stats['cpu'] = cpu_stats 401 | stats['memory'] = memory_stats 402 | stats['network'] = network_stats 403 | else: 404 | stats['cpu'] = {} 405 | stats['memory'] = {} 406 | stats['network'] = {} 407 | 408 | self._notify(stats) 409 | time.sleep(interval) 410 | -------------------------------------------------------------------------------- /docker_monitor/sensor.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Docker Monitor component 3 | 4 | For more details about this component, please refer to the documentation at 5 | https://github.com/Sanderhuisman/home-assistant-custom-components 6 | ''' 7 | import logging 8 | from datetime import timedelta 9 | 10 | import homeassistant.util.dt as dt_util 11 | from homeassistant.const import ( 12 | ATTR_ATTRIBUTION, 13 | CONF_MONITORED_CONDITIONS, 14 | CONF_NAME, 15 | CONF_SCAN_INTERVAL, 16 | EVENT_HOMEASSISTANT_STOP 17 | ) 18 | from homeassistant.helpers.entity import Entity 19 | 20 | from custom_components.docker_monitor import ( 21 | _CONTAINER_MON_COND, 22 | _UTILISATION_MON_COND, 23 | CONF_ATTRIBUTION, 24 | CONF_CONTAINERS, 25 | CONTAINER_MONITOR_CPU_PERCENTAGE, 26 | CONTAINER_MONITOR_IMAGE, 27 | CONTAINER_MONITOR_MEMORY_PERCENTAGE, 28 | CONTAINER_MONITOR_MEMORY_USAGE, 29 | CONTAINER_MONITOR_NETWORK_SPEED_DOWN, 30 | CONTAINER_MONITOR_NETWORK_TOTAL_DOWN, 31 | CONTAINER_MONITOR_NETWORK_SPEED_UP, 32 | CONTAINER_MONITOR_NETWORK_TOTAL_UP, 33 | CONTAINER_MONITOR_STATUS, 34 | CONTAINER_MONITOR_UPTIME, 35 | DATA_CONFIG, 36 | DATA_DOCKER_API, 37 | DOCKER_HANDLE, 38 | PRECISION, 39 | UTILISATION_MONITOR_VERSION 40 | ) 41 | 42 | VERSION = '0.0.3' 43 | 44 | DEPENDENCIES = ['docker_monitor'] 45 | 46 | _LOGGER = logging.getLogger(__name__) 47 | 48 | ATTR_CREATED = 'Created' 49 | ATTR_IMAGE = 'Image' 50 | ATTR_MEMORY_LIMIT = 'Memory_limit' 51 | ATTR_ONLINE_CPUS = 'Online_CPUs' 52 | ATTR_STARTED_AT = 'Started_at' 53 | ATTR_VERSION_API = 'Api_version' 54 | ATTR_VERSION_ARCH = 'Architecture' 55 | ATTR_VERSION_OS = 'Os' 56 | 57 | 58 | def setup_platform(hass, config, add_entities, discovery_info=None): 59 | """Set up the Docker Monitor Sensor.""" 60 | 61 | api = hass.data[DOCKER_HANDLE][DATA_DOCKER_API] 62 | config = hass.data[DOCKER_HANDLE][DATA_CONFIG] 63 | clientname = config[CONF_NAME] 64 | interval = config[CONF_SCAN_INTERVAL].total_seconds() 65 | 66 | sensors = [DockerUtilSensor(api, clientname, variable, interval) 67 | for variable in config[CONF_MONITORED_CONDITIONS] if variable in _UTILISATION_MON_COND] 68 | 69 | containers = [container.get_name() for container in api.get_containers()] 70 | for name in config[CONF_CONTAINERS]: 71 | if name in containers: 72 | sensors += [DockerContainerSensor(api, clientname, name, variable, interval) 73 | for variable in config[CONF_MONITORED_CONDITIONS] if variable in _CONTAINER_MON_COND] 74 | 75 | if sensors: 76 | add_entities(sensors, True) 77 | else: 78 | _LOGGER.info("No containers setup") 79 | return False 80 | 81 | 82 | class DockerUtilSensor(Entity): 83 | """Representation of a Docker Sensor.""" 84 | 85 | def __init__(self, api, clientname, variable, interval): 86 | """Initialize the sensor.""" 87 | self._api = api 88 | self._clientname = clientname 89 | self._interval = interval # TODO implement 90 | 91 | self._var_id = variable 92 | self._var_name = _UTILISATION_MON_COND[variable][0] 93 | self._var_unit = _UTILISATION_MON_COND[variable][1] 94 | self._var_icon = _UTILISATION_MON_COND[variable][2] 95 | self._var_class = _UTILISATION_MON_COND[variable][3] 96 | 97 | self._state = None 98 | self._attributes = { 99 | ATTR_ATTRIBUTION: CONF_ATTRIBUTION 100 | } 101 | 102 | _LOGGER.info( 103 | "Initializing utilization sensor \"{}\"".format(self._var_id)) 104 | 105 | @property 106 | def name(self): 107 | """Return the name of the sensor.""" 108 | return "{} {}".format(self._clientname, self._var_name) 109 | 110 | @property 111 | def icon(self): 112 | """Icon to use in the frontend.""" 113 | return self._var_icon 114 | 115 | @property 116 | def state(self): 117 | """Return the state of the sensor.""" 118 | return self._state 119 | 120 | @property 121 | def device_class(self): 122 | """Return the class of this sensor.""" 123 | return self._var_class 124 | 125 | @property 126 | def unit_of_measurement(self): 127 | """Return the unit the value is expressed in.""" 128 | return self._var_unit 129 | 130 | def update(self): 131 | """Get the latest data for the states.""" 132 | if self._var_id == UTILISATION_MONITOR_VERSION: 133 | version = self._api.get_info() 134 | self._state = version.get('version', None) 135 | self._attributes[ATTR_VERSION_API] = version.get( 136 | 'api_version', None) 137 | self._attributes[ATTR_VERSION_OS] = version.get('os', None) 138 | self._attributes[ATTR_VERSION_ARCH] = version.get('arch', None) 139 | 140 | @property 141 | def device_state_attributes(self): 142 | """Return the state attributes.""" 143 | return self._attributes 144 | 145 | 146 | class DockerContainerSensor(Entity): 147 | """Representation of a Docker Sensor.""" 148 | 149 | def __init__(self, api, clientname, container_name, variable, interval): 150 | """Initialize the sensor.""" 151 | self._api = api 152 | self._clientname = clientname 153 | self._container_name = container_name 154 | self._interval = interval 155 | 156 | self._var_id = variable 157 | self._var_name = _CONTAINER_MON_COND[variable][0] 158 | self._var_unit = _CONTAINER_MON_COND[variable][1] 159 | self._var_icon = _CONTAINER_MON_COND[variable][2] 160 | self._var_class = _CONTAINER_MON_COND[variable][3] 161 | 162 | self._state = None 163 | self._attributes = { 164 | ATTR_ATTRIBUTION: CONF_ATTRIBUTION 165 | } 166 | 167 | self._container = api.get_container(container_name) 168 | 169 | _LOGGER.info("Initializing Docker sensor \"{}\" with parameter: {}".format( 170 | self._container_name, self._var_name)) 171 | 172 | def update_callback(stats): 173 | state = None 174 | # Info 175 | if self._var_id == CONTAINER_MONITOR_STATUS: 176 | state = stats['info']['status'] 177 | elif self._var_id == CONTAINER_MONITOR_UPTIME: 178 | up_time = stats.get('info', {}).get('started') 179 | if up_time is not None: 180 | state = dt_util.as_local(up_time).isoformat() 181 | elif self._var_id == CONTAINER_MONITOR_IMAGE: 182 | state = stats['info']['image'][0] # get first from array 183 | # cpu 184 | elif self._var_id == CONTAINER_MONITOR_CPU_PERCENTAGE: 185 | state = stats.get('cpu', {}).get('total') 186 | # memory 187 | elif self._var_id == CONTAINER_MONITOR_MEMORY_USAGE: 188 | use = stats.get('memory', {}).get('usage') 189 | if use is not None: 190 | state = round(use / (1024 ** 2), PRECISION) # Bytes to MB 191 | elif self._var_id == CONTAINER_MONITOR_MEMORY_PERCENTAGE: 192 | state = stats.get('memory', {}).get('usage_percent') 193 | # network 194 | elif self._var_id == CONTAINER_MONITOR_NETWORK_SPEED_UP: 195 | up = stats.get('network', {}).get('speed_tx') 196 | state = None 197 | if up is not None: 198 | state = round(up / (1024), PRECISION) # Bytes to kB 199 | elif self._var_id == CONTAINER_MONITOR_NETWORK_SPEED_DOWN: 200 | down = stats.get('network', {}).get('speed_rx') 201 | if down is not None: 202 | state = round(down / (1024), PRECISION) 203 | elif self._var_id == CONTAINER_MONITOR_NETWORK_TOTAL_UP: 204 | up = stats.get('network', {}).get('total_tx') # Bytes to kB 205 | if up is not None: 206 | state = round(up / (1024 ** 2), PRECISION) 207 | elif self._var_id == CONTAINER_MONITOR_NETWORK_TOTAL_DOWN: 208 | down = stats.get('network', {}).get('total_rx') 209 | if down is not None: 210 | state = round(down / (1024 ** 2), PRECISION) 211 | 212 | self._state = state 213 | 214 | # Attributes 215 | if self._var_id in (CONTAINER_MONITOR_STATUS): 216 | self._attributes[ATTR_IMAGE] = state = stats['info']['image'][0] 217 | self._attributes[ATTR_CREATED] = dt_util.as_local( 218 | stats['info']['created']).isoformat() 219 | self._attributes[ATTR_STARTED_AT] = dt_util.as_local( 220 | stats['info']['started']).isoformat() 221 | elif self._var_id in (CONTAINER_MONITOR_CPU_PERCENTAGE): 222 | cpus = stats.get('cpu', {}).get('online_cpus') 223 | if cpus is not None: 224 | self._attributes[ATTR_ONLINE_CPUS] = cpus 225 | elif self._var_id in (CONTAINER_MONITOR_MEMORY_USAGE, CONTAINER_MONITOR_MEMORY_PERCENTAGE): 226 | limit = stats.get('memory', {}).get('limit') 227 | if limit is not None: 228 | self._attributes[ATTR_MEMORY_LIMIT] = str( 229 | round(limit / (1024 ** 2), PRECISION)) + ' MB' 230 | 231 | self.schedule_update_ha_state() 232 | 233 | self._container.stats(update_callback, self._interval) 234 | 235 | @property 236 | def name(self): 237 | """Return the name of the sensor, if any.""" 238 | return "{} {} {}".format(self._clientname, self._container_name, self._var_name) 239 | 240 | @property 241 | def icon(self): 242 | """Icon to use in the frontend, if any.""" 243 | if self._var_id == CONTAINER_MONITOR_STATUS: 244 | if self._state == 'running': 245 | return 'mdi:checkbox-marked-circle-outline' 246 | else: 247 | return 'mdi:checkbox-blank-circle-outline' 248 | else: 249 | return self._var_icon 250 | 251 | @property 252 | def should_poll(self): 253 | return False 254 | 255 | @property 256 | def state(self): 257 | """Return the state of the sensor.""" 258 | return self._state 259 | 260 | @property 261 | def device_class(self): 262 | """Return the class of this sensor.""" 263 | return self._var_class 264 | 265 | @property 266 | def unit_of_measurement(self): 267 | """Return the unit the value is expressed in.""" 268 | return self._var_unit 269 | 270 | @property 271 | def device_state_attributes(self): 272 | """Return the state attributes.""" 273 | return self._attributes 274 | -------------------------------------------------------------------------------- /docker_monitor/switch.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Docker Monitor component 3 | 4 | For more details about this component, please refer to the documentation at 5 | https://github.com/Sanderhuisman/home-assistant-custom-components 6 | ''' 7 | import logging 8 | 9 | from homeassistant.components.switch import ( 10 | ENTITY_ID_FORMAT, 11 | PLATFORM_SCHEMA, 12 | SwitchDevice 13 | ) 14 | from homeassistant.const import ( 15 | ATTR_ATTRIBUTION, 16 | CONF_NAME 17 | ) 18 | from homeassistant.core import ServiceCall 19 | 20 | from custom_components.docker_monitor import ( 21 | CONF_ATTRIBUTION, 22 | CONF_CONTAINERS, 23 | DATA_CONFIG, 24 | DATA_DOCKER_API, 25 | DOCKER_HANDLE 26 | ) 27 | 28 | VERSION = '0.0.3' 29 | 30 | DEPENDENCIES = ['docker_monitor'] 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | 35 | def setup_platform(hass, config, add_devices_callback, discovery_info=None): 36 | """Set up the Docker Monitor Switch.""" 37 | 38 | api = hass.data[DOCKER_HANDLE][DATA_DOCKER_API] 39 | config = hass.data[DOCKER_HANDLE][DATA_CONFIG] 40 | clientname = config[CONF_NAME] 41 | 42 | containers = [container.get_name() for container in api.get_containers()] 43 | switches = [ContainerSwitch(api, clientname, name) 44 | for name in config[CONF_CONTAINERS] if name in containers] 45 | if switches: 46 | add_devices_callback(switches, True) 47 | else: 48 | _LOGGER.info("No containers setup") 49 | return False 50 | 51 | 52 | class ContainerSwitch(SwitchDevice): 53 | def __init__(self, api, clientname, container_name): 54 | self._api = api 55 | self._clientname = clientname 56 | self._container_name = container_name 57 | self._state = False 58 | 59 | self._container = api.get_container(container_name) 60 | 61 | def update_callback(stats): 62 | _LOGGER.debug("Received callback with message: {}".format(stats)) 63 | 64 | if stats['info']['status'] == 'running': 65 | state = True 66 | else: 67 | state = False 68 | 69 | if self._state is not state: 70 | self._state = state 71 | 72 | self.schedule_update_ha_state() 73 | 74 | self._container.stats(update_callback) 75 | 76 | @property 77 | def name(self): 78 | """Return the name of the sensor.""" 79 | return "{} {}".format(self._clientname, self._container_name) 80 | 81 | @property 82 | def should_poll(self): 83 | return True 84 | 85 | @property 86 | def icon(self): 87 | return 'mdi:docker' 88 | 89 | @property 90 | def device_state_attributes(self): 91 | return { 92 | ATTR_ATTRIBUTION: CONF_ATTRIBUTION 93 | } 94 | 95 | @property 96 | def is_on(self): 97 | return self._state 98 | 99 | def turn_on(self, **kwargs): 100 | self._container.start() 101 | 102 | def turn_off(self, **kwargs): 103 | self._container.stop() 104 | -------------------------------------------------------------------------------- /sensor/eetlijst.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Eetlijst Sensors. 3 | 4 | For more details about this platform, please refer to the documentation at 5 | https://github.com/Sanderhuisman/home-assistant-custom-components 6 | """ 7 | import logging 8 | import re 9 | import urllib.parse 10 | from datetime import datetime, timedelta 11 | 12 | import homeassistant.helpers.config_validation as cv 13 | import pytz 14 | import requests 15 | import voluptuous as vol 16 | from bs4 import BeautifulSoup 17 | from homeassistant.components.sensor import PLATFORM_SCHEMA 18 | from homeassistant.const import ( 19 | ATTR_ATTRIBUTION, 20 | CONF_PASSWORD, 21 | CONF_USERNAME 22 | ) 23 | from homeassistant.helpers.entity import Entity 24 | from homeassistant.util import Throttle 25 | 26 | VERSION = '0.0.3' 27 | 28 | REQUIREMENTS = ['beautifulsoup4==4.7.0'] 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | CONF_ATTRIBUTION = 'Data provided by Eetlijst' 33 | 34 | MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) 35 | 36 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 37 | vol.Required(CONF_USERNAME): cv.string, 38 | vol.Required(CONF_PASSWORD): cv.string, 39 | }) 40 | 41 | BASE_URL = "http://www.eetlijst.nl/" 42 | 43 | RE_DIGIT = re.compile(r"\d+") 44 | RE_JAVASCRIPT_VS_1 = re.compile(r"javascript:vs") 45 | RE_JAVASCRIPT_VS_2 = re.compile(r"javascript:vs\(([0-9]*)\);") 46 | RE_JAVASCRIPT_K = re.compile(r"javascript:k\(([0-9]*),([-0-9]*),([-0-9]*)\);") 47 | RE_RESIDENTS = re.compile(r"Meer informatie over") 48 | RE_LAST_CHANGED = re.compile(r"onveranderd sinds ([0-9]+):([0-9]+)") 49 | 50 | TIMEOUT_SESSION = 60 * 5 51 | TIMEOUT_CACHE = 60 * 5 / 2 52 | 53 | TZ_EETLIJST = pytz.timezone("Europe/Amsterdam") 54 | TZ_UTC = pytz.timezone("UTC") 55 | 56 | 57 | def setup_platform(hass, config, add_entities, discovery_info=None): 58 | """Set up the Eetlijst Sensor.""" 59 | 60 | username = config.get(CONF_USERNAME) 61 | password = config.get(CONF_PASSWORD) 62 | 63 | try: 64 | api = EetlijstApi(username, password) 65 | 66 | sensors = [] 67 | # Handle all containers 68 | for resident in api.residents: 69 | sensors.append(EetlijstSensor(api, api.accountname, resident)) 70 | 71 | add_entities(sensors, True) 72 | except: # noqa: E722 pylint: disable=bare-except 73 | _LOGGER.error("Error setting up Eetlijst sensor") 74 | 75 | 76 | class EetlijstApi: 77 | """Class to interface with Synology DSM API.""" 78 | 79 | def __init__(self, username, password): 80 | """Initialize the API wrapper class.""" 81 | 82 | self.username = username 83 | self.password = password 84 | 85 | self.session = None 86 | self.cache = {} 87 | 88 | # Initialize None 89 | self.accountname = None 90 | self.residents = None 91 | self.statuses = None 92 | 93 | self._get_session() 94 | self.get_statuses() 95 | 96 | def get_statuses(self, limit=None): 97 | content = self._main_page() 98 | soup = self._get_soup(content) 99 | 100 | # Find all names 101 | self.residents = [x.nobr.b.text for x in soup.find_all( 102 | ["th", "a"], title=RE_RESIDENTS)] 103 | 104 | # Grap the list name 105 | self.accountname = soup.find(["head", "title"]).text.replace( 106 | "Eetlijst.nl - ", "", 1).strip() 107 | 108 | # Find the main table by first navigating to a unique cell. 109 | start = soup.find(["table", "tbody", "tr", "th"], width="80") 110 | if not start: 111 | raise ScrapingError("Cannot parse status table") 112 | 113 | rows = start.parent.parent.find_all("tr") 114 | 115 | # Iterate over each status row 116 | has_deadline = False 117 | pattern = None 118 | results = [] 119 | start = 0 120 | 121 | for row in rows: 122 | # Check for limit 123 | if limit and len(results) >= limit: 124 | break 125 | 126 | # Skip header rows 127 | if len(row.find_all("th")) > 0: 128 | continue 129 | 130 | # Check if the list uses deadlines 131 | if len(results) == 0: 132 | has_deadline = bool( 133 | row.find(["td", "a"], href=RE_JAVASCRIPT_VS_1)) 134 | 135 | if has_deadline: 136 | start = 2 137 | pattern = RE_JAVASCRIPT_VS_2 138 | else: 139 | start = 1 140 | pattern = RE_JAVASCRIPT_K 141 | 142 | # Match date and deadline 143 | matches = re.search(pattern, str(row.renderContents())) 144 | timestamp = datetime.fromtimestamp( 145 | int(matches.group(1)), tz=TZ_UTC) 146 | 147 | # Parse each cell for diner status 148 | statuses = [] 149 | for index, cell in enumerate(row.find_all("td")): 150 | if index < start: 151 | continue 152 | 153 | # Count statuses 154 | images = str(cell.renderContents()) 155 | nop = images.count("nop.gif") 156 | kook = images.count("kook.gif") 157 | eet = images.count("eet.gif") 158 | leeg = images.count("leeg.gif") 159 | 160 | # Match numbers, in case there are more than 4 images 161 | extra = RE_DIGIT.findall(cell.text) 162 | extra = int(extra[0]) if extra else 1 163 | 164 | # Set the data 165 | if nop > 0: 166 | value = 0 167 | elif kook > 0 and eet == 0: 168 | value = kook 169 | elif kook > 0 and eet > 0: 170 | value = kook + (eet * extra) 171 | elif eet > 0: 172 | value = -1 * (eet * extra) 173 | elif leeg > 0: 174 | value = None 175 | else: 176 | raise ScrapingError("Cannot parse diner status.") 177 | 178 | # Append to results 179 | statuses.append(value) 180 | 181 | # Append to results 182 | results.append(StatusRow( 183 | timestamp=timestamp, 184 | deadline=timestamp if has_deadline else None, 185 | statuses=dict(zip(self.residents, statuses)))) 186 | 187 | return results 188 | 189 | def _get_session(self, is_retry=False, renew=True): 190 | # Start a session 191 | if self.session is None: 192 | if not renew: 193 | return 194 | self._login() 195 | 196 | # Check if session is still valid 197 | session, valid_until = self.session 198 | 199 | if valid_until < self._now(): 200 | if not renew: 201 | return 202 | 203 | if is_retry: 204 | raise SessionError("Unable to renew session.") 205 | else: 206 | self.session = None 207 | return self._get_session(is_retry=True) 208 | 209 | return session 210 | 211 | def _login(self): 212 | # Verify username and password 213 | if self.username is None and self.password is None: 214 | raise LoginError("Cannot login without username/password.") 215 | 216 | # Create request 217 | payload = { 218 | "login": self.username, 219 | "pass": self.password 220 | } 221 | response = requests.get(BASE_URL + "login.php", params=payload) 222 | 223 | # Check for errors 224 | if response.status_code != 200: 225 | raise SessionError("Unexpected status code: %d" % 226 | response.status_code) 227 | 228 | if "r=failed" in response.url: 229 | raise LoginError( 230 | "Unable to login. Username and/or password incorrect.") 231 | 232 | # Get session parameter 233 | query_string = urllib.parse.urlparse(response.url).query 234 | query_array = urllib.parse.parse_qs(query_string) 235 | 236 | try: 237 | self.session = ( 238 | query_array.get("session_id")[0], self._timeout(seconds=TIMEOUT_SESSION)) 239 | except IndexError: 240 | raise ScrapingError("Unable to strip session id from URL") 241 | 242 | # Login redirects to main page, so cache it 243 | self.cache["main_page"] = (response.content.decode( 244 | response.encoding), self._timeout(seconds=TIMEOUT_CACHE)) 245 | 246 | def _main_page(self, is_retry=False, data=None): 247 | if data is None: 248 | data = {} 249 | 250 | # Check if in cache 251 | response = self._from_cache("main_page") 252 | if response is None: # not in cache, so get it from website 253 | payload = { 254 | "session_id": self._get_session() 255 | } 256 | payload.update(data) 257 | 258 | response = requests.get(BASE_URL + "main.php", params=payload) 259 | # Check for errors 260 | if response.status_code != 200: 261 | raise SessionError( 262 | "Unexpected status code: %d" % response.status_code) 263 | 264 | # Session expired 265 | if "login.php" in response.url: 266 | self._clear_cache() 267 | 268 | # Determine to retry or not 269 | if is_retry: 270 | raise SessionError("Unable to retrieve page: main.php") 271 | else: 272 | return self._main_page(is_retry=True, data=data) 273 | 274 | # Convert to string, we don't need the rest anymore 275 | response = response.content.decode(response.encoding) 276 | 277 | # Update cache and session 278 | self.session = (self.session[0], self._timeout( 279 | seconds=TIMEOUT_SESSION)) 280 | self.cache["main_page"] = ( 281 | response, self._timeout(seconds=TIMEOUT_CACHE)) 282 | 283 | return response 284 | 285 | def _from_cache(self, key): 286 | try: 287 | response, valid_until = self.cache[key] 288 | except KeyError: 289 | return None 290 | return response if self._now() < valid_until else None 291 | 292 | def _clear_cache(self): 293 | """ 294 | Clear the internal cache and reset session. 295 | """ 296 | self.session = None 297 | self.cache = {} 298 | 299 | def _get_soup(self, content): 300 | return BeautifulSoup(content, "html.parser") 301 | 302 | def _now(self): 303 | """ 304 | Return current datetime object with UTC timezone. 305 | """ 306 | return datetime.now(tz=TZ_UTC) 307 | 308 | def _timeout(self, seconds): 309 | """ 310 | Helper to calculate datetime for now plus some seconds. 311 | """ 312 | return self._now() + timedelta(seconds=seconds) 313 | 314 | @Throttle(MIN_TIME_BETWEEN_UPDATES) 315 | def update(self): 316 | """Update function for updating api information.""" 317 | self.statuses = self.get_statuses() 318 | 319 | 320 | class StatusRow(object): 321 | """ 322 | Represent one row of the dinner status table. A status row has a timestamp, 323 | a deadline and a list of statuses (resident -> status). 324 | """ 325 | 326 | def __init__(self, timestamp, deadline, statuses): 327 | self.timestamp = timestamp 328 | self.deadline = deadline 329 | self.statuses = statuses 330 | 331 | def __repr__(self): 332 | return "StatusRow(timestamp={}, deadline={}, statuses={})".format(self.timestamp, self.deadline, self.statuses) 333 | 334 | 335 | class EetlijstSensor(Entity): 336 | """Representation of a Eetlijst Sensor.""" 337 | 338 | def __init__(self, api, accountname, resident): 339 | """Initialize the sensor.""" 340 | self.var_units = None 341 | self.var_icon = 'mdi:stove' 342 | self.accountname = accountname 343 | self.resident = resident 344 | self._api = api 345 | self._state = None 346 | 347 | @property 348 | def name(self): 349 | """Return the name of the sensor, if any.""" 350 | return "{}_{}".format(self.accountname, self.resident) 351 | 352 | @property 353 | def icon(self): 354 | """Icon to use in the frontend, if any.""" 355 | return self.var_icon 356 | 357 | @property 358 | def state(self): 359 | """Return the state of the sensor.""" 360 | 361 | # get status of today 362 | status = self._api.statuses[0].statuses[self.resident] 363 | if status is None: 364 | value = "?" 365 | elif status == 0: 366 | value = "No dinner" 367 | elif status == 1: 368 | value = "Cook" 369 | elif status == -1: 370 | value = "Dinner" 371 | elif status > 1: 372 | value = "Cook + %d" % (status - 1) 373 | elif status < -1: 374 | value = "Dinner + %d" % (-1 * status - 1) 375 | return value 376 | 377 | @property 378 | def unit_of_measurement(self): 379 | """Return the unit the value is expressed in.""" 380 | return self.var_units 381 | 382 | def update(self): 383 | """Get the latest data for the states.""" 384 | if self._api is not None: 385 | self._api.update() 386 | 387 | @property 388 | def device_state_attributes(self): 389 | """Return the state attributes.""" 390 | return { 391 | ATTR_ATTRIBUTION: CONF_ATTRIBUTION, 392 | } 393 | 394 | 395 | class Error(Exception): 396 | """ 397 | Base Eetlijst error. 398 | """ 399 | pass 400 | 401 | 402 | class LoginError(Error): 403 | """ 404 | Error class for bad logins. 405 | """ 406 | pass 407 | 408 | 409 | class SessionError(Error): 410 | """ 411 | Error class for session and/or other errors. 412 | """ 413 | pass 414 | 415 | 416 | class ScrapingError(Error): 417 | """ 418 | Error class for scraping related errors. 419 | """ 420 | pass 421 | -------------------------------------------------------------------------------- /sensor/luftdaten_cu.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Luftdaten sensors. 3 | 4 | For more details about this platform, please refer to the documentation at 5 | https://github.com/Sanderhuisman/home-assistant-custom-components 6 | """ 7 | import logging 8 | from datetime import timedelta 9 | 10 | import homeassistant.helpers.config_validation as cv 11 | import requests 12 | import voluptuous as vol 13 | from homeassistant.components.sensor import PLATFORM_SCHEMA 14 | from homeassistant.const import ( 15 | ATTR_ATTRIBUTION, 16 | ATTR_LATITUDE, 17 | ATTR_LONGITUDE, 18 | CONF_MONITORED_CONDITIONS, 19 | CONF_NAME, 20 | CONF_SHOW_ON_MAP, 21 | TEMP_CELSIUS 22 | ) 23 | from homeassistant.helpers.entity import Entity 24 | from homeassistant.util import Throttle 25 | 26 | VERSION = '0.0.3' 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | BASE_URL = 'https://api.luftdaten.info/v1' 31 | 32 | ATTR_SENSOR_ID = 'sensor_id' 33 | 34 | CONF_ATTRIBUTION = "Data provided by luftdaten.info" 35 | 36 | VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' 37 | 38 | SENSOR_TEMPERATURE = 'temperature' 39 | SENSOR_HUMIDITY = 'humidity' 40 | SENSOR_PM10 = 'P1' 41 | SENSOR_PM2_5 = 'P2' 42 | SENSOR_PRESSURE = 'pressure' 43 | 44 | SENSOR_TYPES = { 45 | SENSOR_TEMPERATURE: ['Temperature', TEMP_CELSIUS, 'mdi:thermometer'], 46 | SENSOR_HUMIDITY: ['Humidity', '%', 'mdi:water-percent'], 47 | SENSOR_PRESSURE: ['Pressure', 'Pa', 'mdi:arrow-down-bold'], 48 | SENSOR_PM10: ['PM10', VOLUME_MICROGRAMS_PER_CUBIC_METER, 'mdi:thought-bubble'], 49 | SENSOR_PM2_5: ['PM2.5', VOLUME_MICROGRAMS_PER_CUBIC_METER, 50 | 'mdi:thought-bubble-outline'] 51 | } 52 | 53 | DEFAULT_NAME = 'Luftdaten' 54 | 55 | CONF_SENSORID = 'sensorid' 56 | 57 | MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) 58 | 59 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 60 | vol.Required(CONF_SENSORID): cv.positive_int, 61 | vol.Required(CONF_MONITORED_CONDITIONS): 62 | vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), 63 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 64 | }) 65 | 66 | 67 | def setup_platform(hass, config, add_entities, discovery_info=None): 68 | """Set up the Luftdaten sensor.""" 69 | sensor_id = config.get(CONF_SENSORID) 70 | monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) 71 | 72 | try: 73 | api = LuftdatenApi(sensor_id) 74 | except Exception as e: 75 | _LOGGER.error("Could not setup Lufdaten sensor ({})".format(e)) 76 | return False 77 | else: 78 | if api.data is None: 79 | _LOGGER.error("Sensor is not available: {}".format(sensor_id)) 80 | return 81 | 82 | sensors = [LuftdatenSensor(api, variable) 83 | for variable in monitored_conditions 84 | if variable in SENSOR_TYPES and variable in api.data] 85 | 86 | add_entities(sensors, True) 87 | 88 | return True 89 | 90 | 91 | class LuftdatenApi: 92 | def __init__(self, sensor_id): 93 | self.sensor_id = sensor_id 94 | self.data = { 95 | 'humidity': None, 96 | 'P1': None, 97 | 'P2': None, 98 | 'pressure': None, 99 | 'temperature': None, 100 | } 101 | 102 | self._get_data() 103 | 104 | def _get_data(self): 105 | response = requests.get( 106 | BASE_URL + '/sensor/{}/'.format(self.sensor_id)) 107 | _LOGGER.info("Status code: {} with text: {}".format( 108 | response.status_code, response.text)) 109 | if response.status_code == 200: 110 | data = response.json() 111 | 112 | if data is not None: 113 | # Get last measurement 114 | sensor_data = sorted( 115 | data, key=lambda timestamp: timestamp['timestamp'], reverse=True)[0] 116 | 117 | for entry in sensor_data['sensordatavalues']: 118 | self.data[entry['value_type']] = float(entry['value']) 119 | else: 120 | self.data = None 121 | return 122 | 123 | @Throttle(MIN_TIME_BETWEEN_UPDATES) 124 | def update(self): 125 | """Update function for updating api information.""" 126 | self._get_data() 127 | 128 | 129 | class LuftdatenSensor(Entity): 130 | """Implementation of a Luftdaten sensor.""" 131 | 132 | def __init__(self, api, sensor): 133 | pass 134 | """Initialize the Luftdaten sensor.""" 135 | self._api = api 136 | self.sensor = sensor 137 | self._var_name = SENSOR_TYPES[sensor][0] 138 | self._var_units = SENSOR_TYPES[sensor][1] 139 | self._var_icon = SENSOR_TYPES[sensor][2] 140 | 141 | self._state = None 142 | self._attributes = {} 143 | self._attributes[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION 144 | 145 | @property 146 | def name(self): 147 | """Return the name of the sensor, if any.""" 148 | return "Luftdaten ({}) {}".format(self._api.sensor_id, self._var_name) 149 | 150 | @property 151 | def icon(self): 152 | """Icon to use in the frontend, if any.""" 153 | return self._var_icon 154 | 155 | @property 156 | def state(self): 157 | """Return the state of the sensor.""" 158 | return self._state 159 | 160 | @property 161 | def unit_of_measurement(self): 162 | """Return the unit of measurement of this entity, if any.""" 163 | return self._var_units 164 | 165 | def update(self): 166 | """Get the latest data for the states.""" 167 | self._api.update() 168 | 169 | self._state = self._api.data.get(self.sensor, None) 170 | 171 | self._attributes[ATTR_SENSOR_ID] = self._api.sensor_id 172 | 173 | @property 174 | def device_state_attributes(self): 175 | """Return the state attributes.""" 176 | return self._attributes 177 | --------------------------------------------------------------------------------