diff --git a/README.md b/README.md index 2f95ab4..cdeedaf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,12 @@ Home Assistant integration for the [Elecrow GrowCube](https://www.elecrow.com/growcube-gardening-plants-smart-watering-kit-device.html), a smart plant watering device. > Please note that a Growcube device can only be connected to one client at a time. That means you -> will not be able to connect using the phone app while Home Assistant is running the integration. +> will not be able to connect using the phone app while Home Assistant is running the integration, +> or vice versa. + +## Getting help + +You can reach me at [#jonnys-place](https://discord.gg/SeHKWPu9Cw) on Brian Lough's Discord. ## Device @@ -12,8 +17,7 @@ Home Assistant integration for the [Elecrow GrowCube](https://www.elecrow.com/gr ## Sensors -The integration adds sensors for temperature, humidity and four sensors for moisture. It adds four controls for watering, -this activates the pump for 5 seconds for the given channel. +The integration adds sensors for temperature, humidity and four sensors for moisture. ![sensors1.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/sensors1.png) @@ -25,7 +29,7 @@ The diagnostics sensors includes things such as device lock, sensor disconnect w ## Controls -There are controls to let you manually water a plant. +There are controls to let you manually water a plant. Thee will activate the pump for 5 seconds for a given outlet. ![controls1.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/controls1.png) @@ -74,11 +78,3 @@ Install the integration using HACS: And that's it! Once you've added your GrowCube device, you should be able to see its status and control it from the Home Assistant web interface. -## Getting help - -You can reach me in [#jonnys-place](https://discord.gg/SeHKWPu9Cw) on Brian Lough's Discord. - -# TODO - - - Add/Rename the diagnostics sensors to adhere to the last reverse engineering findings - - Add reconnect logic after connection lost event diff --git a/custom_components/growcube/__init__.py b/custom_components/growcube/__init__.py index aeb79b6..7c77105 100644 --- a/custom_components/growcube/__init__.py +++ b/custom_components/growcube/__init__.py @@ -56,6 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: dict): return True + async def async_unload_entry(hass: HomeAssistant, entry: dict): """Unload the Growcube entry.""" client = hass.data[DOMAIN][entry.entry_id] diff --git a/custom_components/growcube/binary_sensor.py b/custom_components/growcube/binary_sensor.py index e888fc3..5bf52ad 100644 --- a/custom_components/growcube/binary_sensor.py +++ b/custom_components/growcube/binary_sensor.py @@ -59,7 +59,10 @@ def is_on(self): @callback def update(self) -> None: - _LOGGER.debug("Update device_locked %s", self._coordinator.data.device_locked) + _LOGGER.debug("%s: Update device_locked %s", + self._coordinator.data.device_id, + self._coordinator.data.device_locked + ) if self._coordinator.data.device_locked != self._attr_native_value: self._attr_native_value = self._coordinator.data.device_locked self.schedule_update_ha_state() @@ -93,7 +96,10 @@ def is_on(self): @callback def update(self) -> None: - _LOGGER.debug("Update water_state %s", self._coordinator.data.water_warning) + _LOGGER.debug("%s: Update water_state %s", + self._coordinator.data.device_id, + self._coordinator.data.water_warning + ) if self._coordinator.data.water_warning != self._attr_native_value: self._attr_native_value = self._coordinator.data.water_warning self.schedule_update_ha_state() @@ -129,9 +135,11 @@ def is_on(self): @callback def update(self) -> None: - _LOGGER.debug("Update pump_state[%s] %s", + _LOGGER.debug("%s: Update pump_state[%s] %s", + self._coordinator.data.device_id, self._channel, - self._coordinator.data.pump_open[self._channel]) + self._coordinator.data.pump_open[self._channel] + ) if self._coordinator.data.pump_open[self._channel] != self._attr_native_value: self._attr_native_value = self._coordinator.data.pump_open[self._channel] self.schedule_update_ha_state() @@ -167,9 +175,11 @@ def is_on(self): @callback def update(self) -> None: - _LOGGER.debug("Update pump_lock_state[%s] %s", + _LOGGER.debug("%s: Update pump_lock_state[%s] %s", + self._coordinator.data.device_id, self._channel, - self._coordinator.data.outlet_locked_state[self._channel]) + self._coordinator.data.outlet_locked_state[self._channel] + ) if self._coordinator.data.outlet_locked_state[self._channel] != self._attr_native_value: self._attr_native_value = self._coordinator.data.outlet_locked_state[self._channel] self.schedule_update_ha_state() @@ -180,7 +190,7 @@ def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: self._coordinator = coordinator self._coordinator.entities.append(self) self._channel = channel - self._attr_unique_id = f"{coordinator.data.device_id}_outlet_" + self.CHANNEL_ID[channel] + "_blocked" + self._attr_unique_id = f"{coordinator.data.device_id}_outlet_" + CHANNEL_ID[channel] + "_blocked" self.entity_id = f"{Platform.SENSOR}.{self._attr_unique_id}" self._attr_name = f"Outlet " + CHANNEL_NAME[channel] + " blocked" self._attr_device_class = BinarySensorDeviceClass.PROBLEM @@ -205,9 +215,11 @@ def is_on(self): @callback def update(self) -> None: - _LOGGER.debug("Update pump_lock_state[%s] %s", + _LOGGER.debug("%s: Update pump_lock_state[%s] %s", + self._coordinator.data.device_id, self._channel, - self._coordinator.data.outlet_blocked_state[self._channel]) + self._coordinator.data.outlet_blocked_state[self._channel] + ) if self._coordinator.data.outlet_blocked_state[self._channel] != self._attr_native_value: self._attr_native_value = self._coordinator.data.outlet_blocked_state[self._channel] self.schedule_update_ha_state() @@ -243,9 +255,11 @@ def is_on(self): @callback def update(self) -> None: - _LOGGER.debug("Update sensor_state[%s] %s", + _LOGGER.debug("%s: Update sensor_state[%s] %s", + self._coordinator.data.device_id, self._channel, - self._coordinator.data.sensor_abnormal[self._channel]) + self._coordinator.data.sensor_abnormal[self._channel] + ) if self._coordinator.data.sensor_abnormal[self._channel] != self._attr_native_value: self._attr_native_value = self._coordinator.data.sensor_abnormal[self._channel] self.schedule_update_ha_state() @@ -281,9 +295,11 @@ def is_on(self): @callback def update(self) -> None: - _LOGGER.debug("Update sensor_state[%s] %s", + _LOGGER.debug("%s: Update sensor_state[%s] %s", + self._coordinator.data.device_id, self._channel, - self._coordinator.data.sensor_disconnected[self._channel]) + self._coordinator.data.sensor_disconnected[self._channel] + ) if self._coordinator.data.sensor_disconnected[self._channel] != self._attr_native_value: self._attr_native_value = self._coordinator.data.sensor_disconnected[self._channel] self.schedule_update_ha_state() diff --git a/custom_components/growcube/coordinator.py b/custom_components/growcube/coordinator.py index 140e769..e740ea9 100644 --- a/custom_components/growcube/coordinator.py +++ b/custom_components/growcube/coordinator.py @@ -30,14 +30,14 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import DOMAIN, CHANNEL_NAME _LOGGER = logging.getLogger(__name__) class GrowcubeDataModel: def __init__(self, host: str): - ## Device + # Device self.host: str = host self.version: str = "" self.device_id: Optional[str] = None @@ -67,6 +67,7 @@ def __init__(self, host: str, hass: HomeAssistant): self.entities = [] self.device_id = None self.data: GrowcubeDataModel = GrowcubeDataModel(host) + self.shutting_down = False def set_device_id(self, device_id: str) -> None: self.device_id = hex(int(device_id))[2:] @@ -87,12 +88,20 @@ async def connect(self) -> Tuple[bool, str]: if not result: return False, error + self.shutting_down = False # Wait for the device to send back the DeviceVersionGrowcubeReport while not self.data.device_id: await asyncio.sleep(0.1) - _LOGGER.debug("Growcube device id: %s", self.data.device_id) + _LOGGER.debug( + "Growcube device id: %s", + self.data.device_id + ) + time_command = SyncTimeCommand(datetime.now()) - _LOGGER.debug(f"{self.data.device_id} Sending SyncTimeCommand") + _LOGGER.debug( + "%s: Sending SyncTimeCommand", + self.data.device_id + ) self.client.send_command(time_command) return True, "" @@ -117,7 +126,7 @@ async def _check_device_id_assigned(): return False, error try: - await asyncio.wait_for(_check_device_id_assigned(), timeout=2) + await asyncio.wait_for(_check_device_id_assigned(), timeout = 5) client.disconnect() except asyncio.TimeoutError: client.disconnect() @@ -126,38 +135,80 @@ async def _check_device_id_assigned(): return True, device_id def on_connected(self, host: str) -> None: - _LOGGER.debug(f"Connection to {host} established") + _LOGGER.debug( + "Connection to %s established", + host + ) - def on_disconnected(self, host: str) -> None: - _LOGGER.debug(f"Connection to {host} lost") + async def on_disconnected(self, host: str) -> None: + _LOGGER.debug("Connection to %s lost", host) if self.data.device_id is not None: self.hass.states.async_set( DOMAIN + "." + self.data.device_id, STATE_UNAVAILABLE ) + self.reset_sensor_data() + if not self.shutting_down: + _LOGGER.debug( + "Device host %s went offline, will try to reconnect", + host + ) + while not self.shutting_down: + await asyncio.sleep(10) + result, error = await self.client.connect() + if not result: + _LOGGER.debug( + "Reconnect failed for %s with error '%s', retrying in 10 seconds", + host, + error) + else: + _LOGGER.debug( + "Reconnect to %s succeeded", + host + ) + return def disconnect(self) -> None: - """Disconnect from the Growcube device.""" + self.shutting_down = True self.client.disconnect() + def reset_sensor_data(self) -> None: + self.data.temperature = None + self.data.humidity = None + self.data.moisture = [None, None, None, None] + self.data.pump_open = [False, False, False, False] + self.data.device_locked = False + self.data.water_warning = False + self.data.sensor_abnormal = [False, False, False, False] + self.data.sensor_disconnected = [False, False, False, False] + self.data.outlet_blocked_state = [False, False, False, False] + self.data.outlet_locked_state = [False, False, False, False] + def handle_report(self, report: GrowcubeReport): """Handle a report from the Growcube.""" # 24 - RepDeviceVersion if isinstance(report, DeviceVersionGrowcubeReport): _LOGGER.debug( - f"Device device_id: {report.device_id}, version {report.version}" + "Device device_id: %s, version %s", + report.device_id, + report.version ) self.data.version = report.version self.set_device_id(report.device_id) # 20 - RepWaterState elif isinstance(report, WaterStateGrowcubeReport): - _LOGGER.debug(f"{self.data.device_id}: Water state {report.water_warning}") + _LOGGER.debug( + "%s: Water state %s", + self.data.device_id, + report.water_warning + ) self.data.water_warning = report.water_warning # 21 - RepSTHSate elif isinstance(report, MoistureHumidityStateGrowcubeReport): _LOGGER.debug( - f"{self.data.device_id}: Sensor reading, channel %s, humidity %s, temperature %s, moisture %s", + "%s: Sensor reading, channel %s, humidity %s, temperature %s, moisture %s", + self.data.device_id, report.channel, report.humidity, report.temperature, @@ -169,47 +220,65 @@ def handle_report(self, report: GrowcubeReport): # 26 - RepPumpOpen elif isinstance(report, PumpOpenGrowcubeReport): - _LOGGER.debug(f"{self.data.device_id}: Pump open, channel {report.channel}") + _LOGGER.debug( + "%s: Pump open, channel %s", + self.data.device_id, + report.channel + ) self.data.pump_open[report.channel.value] = True # 27 - RepPumpClose elif isinstance(report, PumpCloseGrowcubeReport): _LOGGER.debug( - f"{self.data.device_id}: Pump closed, channel {report.channel}" + "%s: Pump closed, channel %s", + self.data.device_id, + report.channel ) self.data.pump_open[report.channel.value] = False # 28 - RepCheckSenSorNotConnected elif isinstance(report, CheckSensorGrowcubeReport): _LOGGER.debug( - f"{self.data.device_id}: Sensor abnormal, channel {report.channel}" + "%s: Sensor abnormal, channel %s", + self.data.device_id, + report.channel ) self.data.sensor_abnormal[report.channel.value] = True # 29 - Pump channel blocked elif isinstance(report, CheckOutletBlockedGrowcubeReport): _LOGGER.debug( - f"{self.data.device_id}: Outlet blocked, channel {report.channel}" + "%s: Outlet blocked, channel %s", + self.data.device_id, + report.channel ) self.data.outlet_blocked_state[report.channel.value] = True # 30 - RepCheckSenSorNotConnect elif isinstance(report, CheckSensorNotConnectedGrowcubeReport): _LOGGER.debug( - f"{self.data.device_id}: Check sensor, channel {report.channel}" + "%s: Check sensor, channel %s", + self.data.device_id, + report.channel ) self.data.sensor_disconnected[report.channel.value] = True # self.moisture_sensors[report.channel.value].update(None) # 33 - RepLockstate elif isinstance(report, LockStateGrowcubeReport): - _LOGGER.debug(f"{self.data.device_id}: Lock state, {report.lock_state}") + _LOGGER.debug( + f"%s: Lock state, %s", + self.data.device_id, + report.lock_state + ) self.data.device_lock_state = report.lock_state # 34 - ReqCheckSenSorLock elif isinstance(report, CheckOutletLockedGrowcubeReport): _LOGGER.debug( - f"{self.data.device_id}: Check outlet, channel {report.channel}" + "%s Check outlet, channel %s", + self.data.device_id, + report.channel ) self.data.outlet_locked_state[report.channel.value] = True @@ -220,28 +289,38 @@ async def handle_water_plant(self, call: ServiceCall): # Validate channel channel_str = call.data.get("channel") duration_str = call.data.get("duration") - channel_names = ["A", "B", "C", "D"] # Validate data - if channel_str not in channel_names: - _LOGGER.error("Invalid channel specified for water_plant: %s", channel_str) + if channel_str not in CHANNEL_NAME: + _LOGGER.error( + "%s: Invalid channel specified for water_plant: %s", + self.data.device_id, + channel_str + ) return try: duration = int(duration_str) except ValueError: - _LOGGER.error("Invalid duration '%s' for water_plant", duration_str) + _LOGGER.error("%s: Invalid duration '%s' for water_plant", self.device_id, duration_str) return if duration < 1 or duration > 60: _LOGGER.error( - "Invalid duration '%s' for water_plant, should be 1-60", duration + "%s: Invalid duration '%s' for water_plant, should be 1-60", + self.data.device_id, + duration ) return - channel = Channel(channel_names.index(channel_str)) + channel = Channel(CHANNEL_NAME.index(channel_str)) - _LOGGER.debug("Service water_plant called, %s, %s", channel_str, duration_str) + _LOGGER.debug( + "%s: Service water_plant called, %s, %s", + self.data.device_id, + channel_str, + duration_str + ) await self.client.water_plant(channel, duration) async def handle_set_watering_mode(self, call: ServiceCall): @@ -254,21 +333,32 @@ async def handle_set_watering_mode(self, call: ServiceCall): # Validate data if channel_str not in channel_names: _LOGGER.error( - "Invalid channel specified for set_watering_mode: %i", channel_str + "%s: Invalid channel specified for set_watering_mode: %s", + self.data.device_id, + channel_str ) return if min_value <= 0 or min_value > 100: - _LOGGER.error("Invalid value specified for min_value: %i", min_value) + _LOGGER.error( + "%s: Invalid value specified for min_value: %s", + self.data.device_id, + min_value + ) return if max_value <= 0 or max_value > 100: - _LOGGER.error("Invalid value specified for max_value: %i", max_value) + _LOGGER.error( + "%sInvalid value specified for max_value: %s", + self.data.device_id, max_value, + max_value + ) return if max_value <= min_value: _LOGGER.error( - "Invalid values specified, max_value must be bigger than min_value" + "%s: Invalid values specified, max_value must be bigger than min_value", + self.data.device_id ) return @@ -276,7 +366,8 @@ async def handle_set_watering_mode(self, call: ServiceCall): command = WateringModeCommand(channel, WateringMode.Smart, min_value, max_value) _LOGGER.debug( - "Service set_watering_mode called, %s, %i, %i", + "%s: Service set_watering_mode called, %s, %s, %s", + self.data.device_id, channel_str, min_value, max_value, diff --git a/custom_components/growcube/manifest.json b/custom_components/growcube/manifest.json index 3c9c35a..41137f9 100644 --- a/custom_components/growcube/manifest.json +++ b/custom_components/growcube/manifest.json @@ -11,7 +11,7 @@ "issue_tracker": "https://github.com/jonnybergdahl/homeassistant_growcube/issues", "iot_class": "local_push", "requirements": [ - "growcube-client==1.1.0" + "growcube-client==1.2.0" ], - "version": "0.9.8" + "version": "0.10.0" } diff --git a/custom_components/growcube/sensor.py b/custom_components/growcube/sensor.py index be2efdd..9a3add8 100644 --- a/custom_components/growcube/sensor.py +++ b/custom_components/growcube/sensor.py @@ -4,7 +4,7 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, CHANNEL_ID, CHANNEL_NAME import logging from .coordinator import GrowcubeDataCoordinator @@ -20,12 +20,11 @@ async def async_setup_entry(hass, entry, async_add_entities): MoistureSensor(coordinator, 0), MoistureSensor(coordinator, 1), MoistureSensor(coordinator, 2), - MoistureSensor(coordinator, 3)], True) + MoistureSensor(coordinator, 3)], True) class TemperatureSensor(SensorEntity): def __init__(self, coordinator: GrowcubeDataCoordinator) -> None: - #super.__init__(coordinator) self._coordinator = coordinator self._coordinator.entities.append(self) self._attr_unique_id = f"{coordinator.data.device_id}" + "_temperature" @@ -42,7 +41,11 @@ def device_info(self) -> DeviceInfo | None: @callback def update(self) -> None: - _LOGGER.debug("Update temperature %s", self._coordinator.data.temperature) + _LOGGER.debug( + "%s: Update temperature %s", + self._coordinator.data.device_id, + self._coordinator.data.temperature + ) if self._coordinator.data.temperature != self.temperature: self._attr_native_value = self._coordinator.data.temperature self.schedule_update_ha_state() @@ -65,23 +68,25 @@ def device_info(self) -> DeviceInfo | None: @callback def update(self) -> None: - _LOGGER.debug("Update humidity %s", self._coordinator.data.humidity) + _LOGGER.debug( + "%s: Update humidity %s", + self._coordinator.data.device_id, + self._coordinator.data.humidity + ) if self._coordinator.data.humidity != self._attr_native_value: self._attr_native_value = self._coordinator.data.humidity self.schedule_update_ha_state() -class MoistureSensor(SensorEntity): - _channel_name = ['A', 'B', 'C', 'D'] - _channel_id = ['a', 'b', 'c', 'd'] +class MoistureSensor(SensorEntity): def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: """Initialize the sensor.""" self._coordinator = coordinator self._coordinator.entities.append(self) self._channel = channel - self._attr_unique_id = f"{coordinator.data.device_id}" + "_moisture_" + self._channel_id[self._channel] + self._attr_unique_id = f"{coordinator.data.device_id}" + "_moisture_" + CHANNEL_ID[self._channel] self.entity_id = f"{Platform.SENSOR}.{self._attr_unique_id}" - self._attr_name = "Moisture " + self._channel_name[self._channel] + self._attr_name = "Moisture " + CHANNEL_NAME[self._channel] self._attr_native_unit_of_measurement = PERCENTAGE self._attr_device_class = SensorDeviceClass.MOISTURE self._attr_native_value = coordinator.data.moisture[self._channel] @@ -96,7 +101,12 @@ def icon(self): @callback def update(self) -> None: - _LOGGER.debug("Update moisture[%s] %s", self._channel, self._coordinator.data.moisture[self._channel]) + _LOGGER.debug( + "%s: Update moisture[%s] %s", + self._coordinator.data.device_id, + self._channel, + self._coordinator.data.moisture[self._channel] + ) if self._coordinator.data.moisture[self._channel] != self._attr_native_value: self._attr_native_value = self._coordinator.data.moisture[self._channel] - self.schedule_update_ha_state() \ No newline at end of file + self.schedule_update_ha_state() diff --git a/images/diagnostics1.png b/images/diagnostics1.png index 66953b1..13831a4 100644 Binary files a/images/diagnostics1.png and b/images/diagnostics1.png differ diff --git a/images/sensors1.png b/images/sensors1.png index e78e5b3..b0a4b0a 100644 Binary files a/images/sensors1.png and b/images/sensors1.png differ