|
1 | | -"""The Detailed Hello World Push integration.""" |
2 | | -from __future__ import annotations |
| 1 | +"""Support for PH-803W.""" |
| 2 | +from datetime import timedelta |
| 3 | +import logging |
| 4 | +import threading |
| 5 | +import time |
3 | 6 |
|
4 | | -from homeassistant.config_entries import ConfigEntry |
5 | | -from homeassistant.core import HomeAssistant |
6 | | -from homeassistant.helpers.typing import ConfigType |
| 7 | +import voluptuous as vol |
7 | 8 |
|
8 | | -from . import hub |
| 9 | +from .lib import device |
9 | 10 | from .const import DOMAIN |
10 | 11 |
|
11 | | -VERSION = "0.0.1" |
| 12 | +from homeassistant.components import persistent_notification |
| 13 | +from homeassistant.const import ( |
| 14 | + CONF_HOST, |
| 15 | + EVENT_HOMEASSISTANT_STOP, |
| 16 | + Platform, |
| 17 | +) |
| 18 | +from homeassistant.core import HomeAssistant, callback |
| 19 | +from homeassistant.helpers import config_validation as cv, discovery |
| 20 | +from homeassistant.helpers.dispatcher import dispatcher_send |
| 21 | +from homeassistant.helpers.typing import ConfigType |
12 | 22 |
|
13 | | -# List of platforms to support. There should be a matching .py file for each, |
14 | | -# eg <cover.py> and <sensor.py> |
15 | | -PLATFORMS: list[str] = ["sensor"] |
| 23 | +_LOGGER = logging.getLogger(__name__) |
16 | 24 |
|
| 25 | +UPDATE_TOPIC = f"{DOMAIN}_update" |
| 26 | +SCAN_INTERVAL = timedelta(seconds=10) |
| 27 | +ERROR_INTERVAL = timedelta(seconds=300) |
| 28 | +MAX_FAILS = 10 |
| 29 | +NOTIFICATION_ID = "ph803w_device_notification" |
| 30 | +NOTIFICATION_TITLE = "PH-803W Device status" |
17 | 31 |
|
18 | | -# async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: |
19 | | -# """Set up a skeleton component.""" |
20 | | -# hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hub.Hub(hass, None) |
21 | | -# hass.config_entries.async_setup_platforms(entry, PLATFORMS) |
22 | 32 |
|
23 | | -# return True |
| 33 | +CONFIG_SCHEMA = vol.Schema( |
| 34 | + { |
| 35 | + DOMAIN: vol.Schema( |
| 36 | + { |
| 37 | + vol.Required(CONF_HOST): cv.string, |
| 38 | + } |
| 39 | + ) |
| 40 | + }, |
| 41 | + extra=vol.ALLOW_EXTRA, |
| 42 | +) |
| 43 | + |
24 | 44 |
|
| 45 | +def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: |
| 46 | + """Set up waterfurnace platform.""" |
25 | 47 |
|
26 | | -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: |
27 | | - """Set up Hello World from a config entry.""" |
28 | | - # Store an instance of the "connecting" class that does the work of speaking |
29 | | - # with your actual devices. |
30 | | - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hub.Hub(hass, entry.data["host"]) |
| 48 | + config = base_config[DOMAIN] |
31 | 49 |
|
32 | | - # This creates each HA object for each platform your device requires. |
33 | | - # It's done by calling the `async_setup_entry` function in each platform module. |
34 | | - hass.config_entries.async_setup_platforms(entry, PLATFORMS) |
| 50 | + host = config[CONF_HOST] |
| 51 | + |
| 52 | + ph_device = device.Device(host) |
| 53 | + try: |
| 54 | + if not ph_device.run(once=True): |
| 55 | + _LOGGER.error("Device found but no measuremetn was received") |
| 56 | + return False |
| 57 | + except TimeoutError: |
| 58 | + _LOGGER.error("Could no connect ot device") |
| 59 | + return False |
| 60 | + |
| 61 | + hass.data[DOMAIN] = DeviceData(hass, ph_device) |
| 62 | + hass.data[DOMAIN].start() |
| 63 | + |
| 64 | + discovery.load_platform(hass, Platform.SENSOR, DOMAIN, {}, config) |
| 65 | + discovery.load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) |
35 | 66 | return True |
36 | 67 |
|
37 | 68 |
|
38 | | -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: |
39 | | - """Unload a config entry.""" |
40 | | - # This is called when an entry/configured device is to be removed. The class |
41 | | - # needs to unload itself, and remove callbacks. See the classes for further |
42 | | - # details |
43 | | - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) |
44 | | - if unload_ok: |
45 | | - hass.data[DOMAIN].pop(entry.entry_id) |
| 69 | +class DeviceData(threading.Thread): |
| 70 | + """PH-803W Data Collector. |
| 71 | +
|
| 72 | + This is implemented as a dedicated thread polling the device as the |
| 73 | + device requires ping/pong every 4s. The alternative is to reconnect |
| 74 | + for every new data, could work for the pH and ORP data but for the |
| 75 | + switches a more direct feedback is wanted.""" |
| 76 | + |
| 77 | + def __init__(self, hass, client: device.Device) -> None: |
| 78 | + super().__init__() |
| 79 | + self.hass = hass |
| 80 | + self.client = client |
| 81 | + self.unit = self.client.host |
| 82 | + self._shutdown = False |
| 83 | + self._fails = 0 |
| 84 | + |
| 85 | + # def _reconnect(self): |
| 86 | + # """Reconnect on a failure.""" |
| 87 | + |
| 88 | + # self._fails += 1 |
| 89 | + # if self._fails > MAX_FAILS: |
| 90 | + # _LOGGER.error("Failed to reconnect. Thread stopped") |
| 91 | + # persistent_notification.create( |
| 92 | + # self.hass, |
| 93 | + # "Error:<br/>Connection to PH-803W device failed " |
| 94 | + # "the maximum number of times. Thread has stopped", |
| 95 | + # title=NOTIFICATION_TITLE, |
| 96 | + # notification_id=NOTIFICATION_ID, |
| 97 | + # ) |
| 98 | + |
| 99 | + # self._shutdown = True |
| 100 | + # return |
| 101 | + |
| 102 | + # # sleep first before the reconnect attempt |
| 103 | + # _LOGGER.debug("Sleeping for fail # %s", self._fails) |
| 104 | + # time.sleep(self._fails * ERROR_INTERVAL.total_seconds()) |
| 105 | + |
| 106 | + # try: |
| 107 | + # self.client.run(once=False) |
| 108 | + # except: |
| 109 | + # _LOGGER.exception("Failed to reconnect attempt %s", self._fails) |
| 110 | + # else: |
| 111 | + # _LOGGER.debug("Reconnected to device") |
| 112 | + # self._fails = 0 |
| 113 | + |
| 114 | + def run(self): |
| 115 | + """Thread run loop.""" |
| 116 | + |
| 117 | + @callback |
| 118 | + def register(): |
| 119 | + """Connect to hass for shutdown.""" |
| 120 | + |
| 121 | + def shutdown(event): |
| 122 | + """Shutdown the thread.""" |
| 123 | + _LOGGER.debug("Signaled to shutdown") |
| 124 | + self._shutdown = True |
| 125 | + self.client.abort() |
| 126 | + self.join() |
| 127 | + |
| 128 | + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) |
| 129 | + |
| 130 | + self.hass.add_job(register) |
| 131 | + |
| 132 | + # This does a tight loop in sending ping/pong to the |
| 133 | + # device. That's a blocking call, which returns pretty |
| 134 | + # quickly (0.5 second). It's important that we do this |
| 135 | + # frequently though, because if we don't call the websocket at |
| 136 | + # least every 4 seconds the device side closes the |
| 137 | + # connection. |
| 138 | + while True: |
| 139 | + if self._shutdown: |
| 140 | + _LOGGER.debug("Graceful shutdown") |
| 141 | + return |
| 142 | + |
| 143 | + if self._fails > MAX_FAILS: |
| 144 | + _LOGGER.error("Failed to reconnect. Thread stopped") |
| 145 | + persistent_notification.create( |
| 146 | + self.hass, |
| 147 | + "Error:<br/>Connection to PH-803W device failed " |
| 148 | + "the maximum number of times. Thread has stopped", |
| 149 | + title=NOTIFICATION_TITLE, |
| 150 | + notification_id=NOTIFICATION_ID, |
| 151 | + ) |
| 152 | + return |
| 153 | + |
| 154 | + try: |
| 155 | + self.client.run(once=False) |
| 156 | + except device.DeviceError: |
| 157 | + _LOGGER.exception("Failed to read data, attempting to recover") |
| 158 | + self.client.close() |
| 159 | + self._fails += 1 |
| 160 | + sleep_time = self._fails * ERROR_INTERVAL.total_seconds() |
| 161 | + _LOGGER.debug( |
| 162 | + "Sleeping for fail #%s, in %s seconds", self._fails, sleep_time |
| 163 | + ) |
| 164 | + self.client.reset_socket() |
| 165 | + time.sleep(sleep_time) |
| 166 | + |
| 167 | + # while True: |
| 168 | + # if self._shutdown: |
| 169 | + # _LOGGER.debug("Graceful shutdown") |
| 170 | + # return |
| 171 | + |
| 172 | + # try: |
| 173 | + # self.data = self.client.run(once=False) |
| 174 | + |
| 175 | + # except WFException: |
| 176 | + # # WFExceptions are things the WF library understands |
| 177 | + # # that pretty much can all be solved by logging in and |
| 178 | + # # back out again. |
| 179 | + # _LOGGER.exception("Failed to read data, attempting to recover") |
| 180 | + # self._reconnect() |
| 181 | + |
| 182 | + # else: |
| 183 | + # dispatcher_send(self.hass, UPDATE_TOPIC) |
| 184 | + # time.sleep(SCAN_INTERVAL.total_seconds()) |
| 185 | + |
| 186 | + |
| 187 | +# async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: |
| 188 | +# """Set up Hello World from a config entry.""" |
| 189 | +# # Store an instance of the "connecting" class that does the work of speaking |
| 190 | +# # with your actual devices. |
| 191 | +# hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hub.Hub(hass, entry.data["host"]) |
| 192 | + |
| 193 | +# # This creates each HA object for each platform your device requires. |
| 194 | +# # It's done by calling the `async_setup_entry` function in each platform module. |
| 195 | +# hass.config_entries.async_setup_platforms(entry, PLATFORMS) |
| 196 | +# return True |
| 197 | + |
| 198 | + |
| 199 | +# async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: |
| 200 | +# """Unload a config entry.""" |
| 201 | +# # This is called when an entry/configured device is to be removed. The class |
| 202 | +# # needs to unload itself, and remove callbacks. See the classes for further |
| 203 | +# # details |
| 204 | +# unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) |
| 205 | +# if unload_ok: |
| 206 | +# hass.data[DOMAIN].pop(entry.entry_id) |
46 | 207 |
|
47 | | - return unload_ok |
| 208 | +# return unload_ok |
0 commit comments