├── .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 | [](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 |
--------------------------------------------------------------------------------