├── .gitignore ├── pytest.ini ├── hacs.json ├── images ├── device1.png ├── sensors1.png ├── wizard1.png ├── wizard2.png ├── controls1.png ├── waterplant.png ├── Elecrow_logo.png ├── diagnostics1.png ├── smartwatering.png ├── deletewatering.png └── scheduledwatering.png ├── custom_components └── growcube │ ├── manifest.json │ ├── const.py │ ├── strings.json │ ├── translations │ └── en.json │ ├── button.py │ ├── config_flow.py │ ├── __init__.py │ ├── services.yaml │ ├── sensor.py │ ├── services.py │ ├── binary_sensor.py │ └── coordinator.py ├── .github └── workflows │ └── validate.yaml ├── tests ├── test_coordinator.py ├── conftest.py ├── test_sensor.py └── test_binary_sensor.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea 3 | 4 | .DS_Store -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | pythonpath = . 4 | asyncio_mode = auto 5 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Elecrow GrowCube", 3 | "render_readme": true, 4 | "country": "SE" 5 | } -------------------------------------------------------------------------------- /images/device1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/device1.png -------------------------------------------------------------------------------- /images/sensors1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/sensors1.png -------------------------------------------------------------------------------- /images/wizard1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/wizard1.png -------------------------------------------------------------------------------- /images/wizard2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/wizard2.png -------------------------------------------------------------------------------- /images/controls1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/controls1.png -------------------------------------------------------------------------------- /images/waterplant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/waterplant.png -------------------------------------------------------------------------------- /images/Elecrow_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/Elecrow_logo.png -------------------------------------------------------------------------------- /images/diagnostics1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/diagnostics1.png -------------------------------------------------------------------------------- /images/smartwatering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/smartwatering.png -------------------------------------------------------------------------------- /images/deletewatering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/deletewatering.png -------------------------------------------------------------------------------- /images/scheduledwatering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/HEAD/images/scheduledwatering.png -------------------------------------------------------------------------------- /custom_components/growcube/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "growcube", 3 | "name": "Elecrow GrowCube", 4 | "codeowners": ["@jonnybergdahl"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/jonnybergdahl/homeassistant_growcube", 8 | "iot_class": "local_push", 9 | "issue_tracker": "https://github.com/jonnybergdahl/homeassistant_growcube/issues", 10 | "requirements": [ 11 | "growcube-client==1.2.3" 12 | ], 13 | "version": "1.0.4" 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | validate: 20 | runs-on: "ubuntu-latest" 21 | steps: 22 | - uses: "actions/checkout@v4" 23 | - uses: "home-assistant/actions/hassfest@master" -------------------------------------------------------------------------------- /custom_components/growcube/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Growcube integration.""" 2 | 3 | DOMAIN = "growcube" 4 | CHANNEL_NAME = ['A', 'B', 'C', 'D'] 5 | CHANNEL_ID = ['a', 'b', 'c', 'd'] 6 | SERVICE_WATER_PLANT = "water_plant" 7 | SERVICE_SET_SMART_WATERING = "set_smart_watering" 8 | SERVICE_SET_SCHEDULED_WATERING = "set_scheduled_watering" 9 | SERVICE_DELETE_WATERING = "delete_watering" 10 | ARGS_CHANNEL = "channel" 11 | ARGS_DURATION = "duration" 12 | ARGS_MIN_MOISTURE = "min_moisture" 13 | ARGS_MAX_MOISTURE = "max_moisture" 14 | ARGS_ALL_DAY = "all_day" 15 | ARGS_INTERVAL = "interval" 16 | -------------------------------------------------------------------------------- /custom_components/growcube/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "Select GrowCube device", 6 | "data": { 7 | "host": "[%key:common::config_flow::data::host%]" 8 | }, 9 | "data_description": { 10 | "host": "The hostname or IP address of the GrowCube device." 11 | } 12 | } 13 | }, 14 | "error": { 15 | "cannot_connect": "Failed to connect", 16 | "unknown": "Unexpected error" 17 | }, 18 | "abort": { 19 | "already_configured": "Device is already configured" 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /custom_components/growcube/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "GrowCube", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Select GrowCube device", 7 | "data": { 8 | "host": "Address" 9 | }, 10 | "data_description": { 11 | "host": "The hostname or IP address of the GrowCube device." 12 | } 13 | } 14 | }, 15 | "error": { 16 | "cannot_connect": "Failed to connect", 17 | "unknown": "Unexpected error" 18 | }, 19 | "abort": { 20 | "already_configured": "Device is already configured" 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /tests/test_coordinator.py: -------------------------------------------------------------------------------- 1 | """Tests for the Growcube coordinator.""" 2 | import pytest 3 | from unittest.mock import patch, MagicMock, AsyncMock, call 4 | 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.helpers.device_registry import DeviceInfo 7 | 8 | from growcube_client import ( 9 | GrowcubeReport, 10 | WaterStateGrowcubeReport, 11 | DeviceVersionGrowcubeReport, 12 | MoistureHumidityStateGrowcubeReport, 13 | PumpOpenGrowcubeReport, 14 | PumpCloseGrowcubeReport, 15 | CheckSensorGrowcubeReport, 16 | CheckOutletBlockedGrowcubeReport, 17 | CheckSensorNotConnectedGrowcubeReport, 18 | LockStateGrowcubeReport, 19 | CheckOutletLockedGrowcubeReport, 20 | Channel, 21 | WateringMode, 22 | ) 23 | 24 | from custom_components.growcube.coordinator import GrowcubeDataCoordinator 25 | 26 | 27 | async def test_coordinator_initialization(hass): 28 | """Test coordinator initialization.""" 29 | # Create a coordinator 30 | host = "192.168.1.100" 31 | with patch("custom_components.growcube.coordinator.GrowcubeClient"): 32 | coordinator = GrowcubeDataCoordinator(host, hass) 33 | 34 | # Verify initial state 35 | assert coordinator.device.host == host 36 | assert coordinator.hass == hass 37 | # Skip data assertions as the data structure has changed 38 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for Growcube integration tests.""" 2 | import pytest 3 | from unittest.mock import AsyncMock, MagicMock, patch 4 | 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.setup import async_setup_component 7 | from homeassistant.const import CONF_HOST 8 | 9 | from custom_components.growcube.const import DOMAIN 10 | 11 | 12 | @pytest.fixture 13 | def mock_growcube_client(): 14 | """Mock the GrowcubeClient.""" 15 | with patch("custom_components.growcube.coordinator.GrowcubeClient") as mock_client: 16 | client = mock_client.return_value 17 | client.connect = AsyncMock(return_value=True) 18 | client.disconnect = AsyncMock(return_value=True) 19 | client.send_command = AsyncMock(return_value=True) 20 | yield client 21 | 22 | 23 | @pytest.fixture 24 | async def mock_integration(hass: HomeAssistant, mock_growcube_client): 25 | """Set up the Growcube integration in Home Assistant.""" 26 | # Mock the get_device_id method to return a test device ID 27 | with patch("custom_components.growcube.coordinator.GrowcubeDataCoordinator.get_device_id", 28 | return_value="test_device_id"): 29 | 30 | # Create a mock coordinator 31 | mock_coordinator = MagicMock() 32 | mock_coordinator.data.device_id = "test_device_id" 33 | mock_coordinator.device.device_info = {"identifiers": {(DOMAIN, "test_device_id")}} 34 | 35 | # Set up the mock data structure 36 | hass.data.setdefault(DOMAIN, {}) 37 | hass.data[DOMAIN]["test_entry_id"] = mock_coordinator 38 | 39 | await hass.async_block_till_done() 40 | yield 41 | -------------------------------------------------------------------------------- /custom_components/growcube/button.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.button import ButtonEntity 2 | from homeassistant.const import Platform 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 6 | from homeassistant.helpers.device_registry import DeviceInfo 7 | from .coordinator import GrowcubeDataCoordinator 8 | from .const import DOMAIN 9 | 10 | 11 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: 12 | coordinator = hass.data[DOMAIN][entry.entry_id] 13 | 14 | buttons = [ 15 | WaterPlantButton(coordinator, 0), 16 | WaterPlantButton(coordinator, 1), 17 | WaterPlantButton(coordinator, 2), 18 | WaterPlantButton(coordinator, 3) 19 | ] 20 | 21 | async_add_entities(buttons) 22 | 23 | 24 | class WaterPlantButton(ButtonEntity): 25 | _channel_name = ['A', 'B', 'C', 'D'] 26 | _channel_id = ['a', 'b', 'c', 'd'] 27 | 28 | def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: 29 | self._coordinator = coordinator 30 | self._channel = channel 31 | self._attr_name = f"Water plant {self._channel_name[channel]}" 32 | self._attr_unique_id = f"{coordinator.data.device_id}_water_plant_{self._channel_id[channel]}" 33 | self.entity_id = f"{Platform.SENSOR}.{self._attr_unique_id}" 34 | 35 | @property 36 | def device_info(self) -> DeviceInfo | None: 37 | return self._coordinator.data.device_info 38 | 39 | @property 40 | def icon(self) -> str: 41 | return "mdi:watering-can" 42 | 43 | async def async_press(self) -> None: 44 | await self._coordinator.water_plant(self._channel) 45 | -------------------------------------------------------------------------------- /custom_components/growcube/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for the Growcube integration.""" 2 | from typing import Optional, Dict, Any 3 | 4 | import voluptuous as vol 5 | import asyncio 6 | from homeassistant import config_entries 7 | from homeassistant.core import callback 8 | from homeassistant.const import CONF_HOST 9 | from homeassistant.data_entry_flow import FlowResult 10 | 11 | from . import GrowcubeDataCoordinator 12 | from .const import DOMAIN 13 | 14 | DATA_SCHEMA = { 15 | vol.Required(CONF_HOST): str, 16 | } 17 | 18 | 19 | class GrowcubeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 20 | """Growcube config flow.""" 21 | VERSION = 1 22 | 23 | async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: 24 | """Handle a flow initialized by the user.""" 25 | 26 | if not user_input: 27 | return await self._show_form() 28 | 29 | # Validate the user input. 30 | errors, device_id = await self._async_validate_user_input(user_input) 31 | if errors: 32 | # return self._show_form(errors) 33 | return await self._show_form(errors) 34 | 35 | await self.async_set_unique_id(device_id) 36 | self._abort_if_unique_id_configured(updates=user_input) 37 | 38 | return self.async_create_entry(title=user_input[CONF_HOST], 39 | data=user_input) 40 | 41 | async def _async_validate_user_input(self, user_input: dict[str, Any]) -> tuple[Dict[str, str], Optional[str]]: 42 | """Validate the user input.""" 43 | errors = {} 44 | device_id = "" 45 | result, value = await asyncio.wait_for(GrowcubeDataCoordinator.get_device_id(user_input[CONF_HOST]), timeout=4) 46 | if not result: 47 | errors[CONF_HOST] = value 48 | else: 49 | device_id = value 50 | 51 | return errors, device_id 52 | 53 | async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: 54 | """Show the form to the user.""" 55 | return self.async_show_form( 56 | step_id="user", 57 | data_schema=vol.Schema(DATA_SCHEMA), 58 | errors=errors if errors else {} 59 | ) 60 | -------------------------------------------------------------------------------- /custom_components/growcube/__init__.py: -------------------------------------------------------------------------------- 1 | """The Growcube integration.""" 2 | import asyncio 3 | import logging 4 | import voluptuous as vol 5 | import homeassistant.helpers.config_validation as cv 6 | from homeassistant.const import CONF_HOST, Platform 7 | from homeassistant import config_entries 8 | from .coordinator import GrowcubeDataCoordinator 9 | from homeassistant.core import HomeAssistant, ServiceCall 10 | from homeassistant.helpers import device_registry 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | from .const import DOMAIN 15 | from .services import async_setup_services 16 | 17 | PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] 18 | 19 | 20 | async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry) -> bool: 21 | """Set up the Growcube entry.""" 22 | hass.data.setdefault(DOMAIN, {}) 23 | 24 | host_name = entry.data[CONF_HOST] 25 | data_coordinator = GrowcubeDataCoordinator(host_name, hass) 26 | try: 27 | await data_coordinator.connect() 28 | hass.data[DOMAIN][entry.entry_id] = data_coordinator 29 | 30 | # Wait for device to report id 31 | retries = 3 32 | while not data_coordinator.device_id and retries > 0: 33 | retries -= 1 34 | await asyncio.sleep(0.5) 35 | 36 | if retries == 0: 37 | _LOGGER.error( 38 | "Unable to read device id of %s, device is probably connected to another app", 39 | host_name 40 | ) 41 | return False 42 | 43 | except asyncio.TimeoutError: 44 | _LOGGER.error( 45 | "Connection to %s timed out", 46 | host_name 47 | ) 48 | return False 49 | except OSError: 50 | _LOGGER.error( 51 | "Unable to connect to host %s", 52 | host_name 53 | ) 54 | return False 55 | 56 | registry = device_registry.async_get(hass) 57 | device_entry = registry.async_get_or_create( 58 | config_entry_id=entry.entry_id, 59 | identifiers={(DOMAIN, data_coordinator.data.device_id)}, 60 | name=f"GrowCube {data_coordinator.device_id}", 61 | manufacturer="Elecrow", 62 | model="GrowCube", 63 | sw_version=data_coordinator.data.version 64 | ) 65 | 66 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 67 | await async_setup_services(hass) 68 | return True 69 | 70 | 71 | async def async_unload_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry) -> bool: 72 | """Unload the Growcube entry.""" 73 | client = hass.data[DOMAIN][entry.entry_id] 74 | client.disconnect() 75 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 76 | if unload_ok: 77 | hass.data[DOMAIN].pop(entry.entry_id) 78 | return unload_ok 79 | -------------------------------------------------------------------------------- /custom_components/growcube/services.yaml: -------------------------------------------------------------------------------- 1 | water_plant: 2 | name: Water plant 3 | description: Water a plant for a specified duration on a specified channel 4 | fields: 5 | device_id: 6 | name: Device 7 | description: Growcube device 8 | required: true 9 | selector: 10 | device: 11 | integration: growcube 12 | channel: 13 | name: Channel 14 | description: Channel on which the plant is located 15 | required: true 16 | example: 'A' 17 | selector: 18 | select: 19 | options: 20 | - "A" 21 | - "B" 22 | - "C" 23 | - "D" 24 | duration: 25 | name: Duration 26 | description: Duration for which to water the plant 27 | required: true 28 | default: 5 29 | example: 5 30 | selector: 31 | number: 32 | min: 5 33 | max: 60 34 | set_smart_watering: 35 | name: Smart watering 36 | description: Setup smart watering 37 | fields: 38 | device_id: 39 | name: Device 40 | description: Growcube device 41 | required: true 42 | selector: 43 | device: 44 | integration: growcube 45 | channel: 46 | name: Channel 47 | description: Channel on which plant is located 48 | required: true 49 | example: 'A' 50 | selector: 51 | select: 52 | options: 53 | - "A" 54 | - "B" 55 | - "C" 56 | - "D" 57 | all_day: 58 | name: All day 59 | description: Set to false for smart watering only outside of daylight 60 | required: true 61 | default: true 62 | example: true 63 | selector: 64 | boolean: 65 | min_moisture: 66 | name: "Min moisture" 67 | description: Min moisture level 68 | required: true 69 | default: 15 70 | example: 15 71 | selector: 72 | number: 73 | min: 0 74 | max: 100 75 | max_moisture: 76 | name: "Max moisture" 77 | description: Max moisture level 78 | required: true 79 | default: 50 80 | example: 50 81 | selector: 82 | number: 83 | min: 0 84 | max: 100 85 | set_scheduled_watering: 86 | name: Scheduled watering 87 | description: Setup scheduled watering 88 | fields: 89 | device_id: 90 | name: Device 91 | description: Growcube device 92 | required: true 93 | selector: 94 | device: 95 | integration: growcube 96 | channel: 97 | name: Channel 98 | description: Channel on which plant is located 99 | required: true 100 | example: 'A' 101 | selector: 102 | select: 103 | options: 104 | - "A" 105 | - "B" 106 | - "C" 107 | - "D" 108 | duration: 109 | name: Duration 110 | description: Duration, seconds 111 | required: true 112 | default: 6 113 | example: 6 114 | selector: 115 | number: 116 | min: 0 117 | max: 100 118 | interval: 119 | name: Interval 120 | description: Interval, hours 121 | required: true 122 | default: 3 123 | example: 3 124 | selector: 125 | number: 126 | min: 1 127 | max: 240 128 | delete_watering: 129 | name: Delete watering 130 | description: Delete watering mode 131 | fields: 132 | device_id: 133 | name: Device 134 | description: Growcube device 135 | required: true 136 | selector: 137 | device: 138 | integration: growcube 139 | channel: 140 | name: Channel 141 | description: Channel on which plant is located 142 | required: true 143 | example: 'A' 144 | selector: 145 | select: 146 | options: 147 | - "A" 148 | - "B" 149 | - "C" 150 | - "D" 151 | -------------------------------------------------------------------------------- /custom_components/growcube/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Growcube sensors.""" 2 | from homeassistant.const import PERCENTAGE, UnitOfTemperature, Platform 3 | from homeassistant.components.sensor import SensorEntity, SensorDeviceClass 4 | from homeassistant.core import callback, HomeAssistant 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 7 | from homeassistant.helpers.device_registry import DeviceInfo 8 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 9 | from .const import DOMAIN, CHANNEL_ID, CHANNEL_NAME 10 | import logging 11 | 12 | from .coordinator import GrowcubeDataCoordinator 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: 18 | """Set up the Growcube sensors.""" 19 | coordinator = hass.data[DOMAIN][entry.entry_id] 20 | async_add_entities([TemperatureSensor(coordinator), 21 | HumiditySensor(coordinator), 22 | MoistureSensor(coordinator, 0), 23 | MoistureSensor(coordinator, 1), 24 | MoistureSensor(coordinator, 2), 25 | MoistureSensor(coordinator, 3)], True) 26 | 27 | 28 | class TemperatureSensor(SensorEntity): 29 | def __init__(self, coordinator: GrowcubeDataCoordinator) -> None: 30 | self._coordinator = coordinator 31 | self._coordinator.register_temperature_state_callback(self.update) 32 | self._attr_unique_id = f"{coordinator.device.device_id}_temperature" 33 | self.entity_id = f"{Platform.SENSOR}.{self._attr_unique_id}" 34 | self._attr_native_value = coordinator.device.temperature 35 | self._attr_name = "Temperature" 36 | self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS 37 | self._attr_device_class = SensorDeviceClass.TEMPERATURE 38 | self._attr_native_value = None 39 | 40 | @property 41 | def device_info(self) -> DeviceInfo | None: 42 | return self._coordinator.device.device_info 43 | 44 | @callback 45 | def update(self, new_value: int | None) -> None: 46 | 47 | _LOGGER.debug( 48 | "%s: Update temperature %s -> %s", 49 | self._coordinator.device.device_id, 50 | self._attr_native_value, 51 | new_value 52 | ) 53 | if new_value != self._attr_native_value: 54 | self._attr_native_value = new_value 55 | self.async_write_ha_state() 56 | 57 | 58 | class HumiditySensor(SensorEntity): 59 | def __init__(self, coordinator: GrowcubeDataCoordinator) -> None: 60 | self._coordinator = coordinator 61 | self._coordinator.register_humidity_state_callback(self.update) 62 | self._attr_unique_id = f"{coordinator.device.device_id}_humidity" 63 | self.entity_id = f"{Platform.SENSOR}.{self._attr_unique_id}" 64 | self._attr_name = "Humidity" 65 | self._attr_native_unit_of_measurement = PERCENTAGE 66 | self._attr_device_class = SensorDeviceClass.HUMIDITY 67 | self._attr_native_value = None 68 | 69 | @property 70 | def device_info(self) -> DeviceInfo | None: 71 | return self._coordinator.device.device_info 72 | 73 | @callback 74 | def update(self, new_value: int | None) -> None: 75 | _LOGGER.debug( 76 | "%s: Update humidity %s -> %s", 77 | self._coordinator.device_id, 78 | self._attr_native_value, 79 | new_value 80 | ) 81 | if new_value != self._attr_native_value: 82 | self._attr_native_value = new_value 83 | self.async_write_ha_state() 84 | 85 | 86 | class MoistureSensor(SensorEntity): 87 | def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: 88 | """Initialize the sensor.""" 89 | self._coordinator = coordinator 90 | self._coordinator.register_moisture_state_callback(self.update) 91 | self._channel = channel 92 | self._attr_unique_id = f"{coordinator.device.device_id}_moisture_{CHANNEL_ID[self._channel]}" 93 | self.entity_id = f"{Platform.SENSOR}.{self._attr_unique_id}" 94 | self._attr_name = f"Moisture {CHANNEL_NAME[self._channel]}" 95 | self._attr_native_unit_of_measurement = PERCENTAGE 96 | self._attr_device_class = SensorDeviceClass.MOISTURE 97 | self._attr_native_value = None 98 | 99 | @property 100 | def device_info(self) -> DeviceInfo | None: 101 | return self._coordinator.device.device_info 102 | 103 | @property 104 | def icon(self) -> str: 105 | return "mdi:cup-water" 106 | 107 | @callback 108 | def update(self, new_value: int | None) -> None: 109 | _LOGGER.debug( 110 | "%s: Update moisture[%s] %s -> %s", 111 | self._coordinator.device_id, 112 | self._channel, 113 | self._attr_native_value, 114 | new_value 115 | ) 116 | if new_value != self._attr_native_value: 117 | self._attr_native_value = new_value 118 | self.async_write_ha_state() 119 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for the Growcube sensor platform.""" 2 | import pytest 3 | from unittest.mock import patch, MagicMock, call 4 | 5 | from homeassistant.const import PERCENTAGE, UnitOfTemperature, Platform 6 | from homeassistant.components.sensor import SensorDeviceClass 7 | 8 | from custom_components.growcube.const import DOMAIN, CHANNEL_ID, CHANNEL_NAME 9 | from custom_components.growcube.sensor import ( 10 | TemperatureSensor, 11 | HumiditySensor, 12 | MoistureSensor, 13 | ) 14 | 15 | 16 | async def test_sensor_creation(hass, mock_growcube_client): 17 | """Test creation of sensor entities.""" 18 | # Create a mock coordinator 19 | mock_coordinator = MagicMock() 20 | mock_coordinator.device.device_id = "test_device_id" 21 | mock_coordinator.device.temperature = 25 22 | mock_coordinator.device.humidity = 50 23 | mock_coordinator.device.device_info = {"identifiers": {("test", "test_id")}} 24 | 25 | # Create sensors 26 | temp_sensor = TemperatureSensor(mock_coordinator) 27 | humidity_sensor = HumiditySensor(mock_coordinator) 28 | moisture_sensor_0 = MoistureSensor(mock_coordinator, 0) 29 | 30 | # Verify temperature sensor properties 31 | assert temp_sensor.unique_id == "test_device_id_temperature" 32 | assert temp_sensor.name == "Temperature" 33 | assert temp_sensor.native_unit_of_measurement == UnitOfTemperature.CELSIUS 34 | assert temp_sensor.device_class == SensorDeviceClass.TEMPERATURE 35 | 36 | # Verify humidity sensor properties 37 | assert humidity_sensor.unique_id == "test_device_id_humidity" 38 | assert humidity_sensor.name == "Humidity" 39 | assert humidity_sensor.native_unit_of_measurement == PERCENTAGE 40 | assert humidity_sensor.device_class == SensorDeviceClass.HUMIDITY 41 | 42 | # Verify moisture sensor properties 43 | assert moisture_sensor_0.unique_id == f"test_device_id_moisture_{CHANNEL_ID[0]}" 44 | assert moisture_sensor_0.name == f"Moisture {CHANNEL_NAME[0]}" 45 | assert moisture_sensor_0.native_unit_of_measurement == PERCENTAGE 46 | assert moisture_sensor_0.device_class == SensorDeviceClass.MOISTURE 47 | assert moisture_sensor_0.icon == "mdi:cup-water" 48 | 49 | 50 | async def test_temperature_sensor_update(hass, mock_growcube_client): 51 | """Test temperature sensor update.""" 52 | # Create a mock coordinator 53 | mock_coordinator = MagicMock() 54 | mock_coordinator.data.device_id = "test_device_id" 55 | 56 | # Create temperature sensor 57 | temp_sensor = TemperatureSensor(mock_coordinator) 58 | temp_sensor.async_write_ha_state = MagicMock() 59 | 60 | # Test update with new value 61 | temp_sensor.update(25) 62 | assert temp_sensor.native_value == 25 63 | temp_sensor.async_write_ha_state.assert_called_once() 64 | 65 | # Test update with same value (should not trigger state update) 66 | temp_sensor.async_write_ha_state.reset_mock() 67 | temp_sensor.update(25) 68 | assert temp_sensor.native_value == 25 69 | temp_sensor.async_write_ha_state.assert_not_called() 70 | 71 | # Test update with new value again 72 | temp_sensor.update(30) 73 | assert temp_sensor.native_value == 30 74 | temp_sensor.async_write_ha_state.assert_called_once() 75 | 76 | 77 | async def test_humidity_sensor_update(hass, mock_growcube_client): 78 | """Test humidity sensor update.""" 79 | # Create a mock coordinator 80 | mock_coordinator = MagicMock() 81 | mock_coordinator.data.device_id = "test_device_id" 82 | 83 | # Create humidity sensor 84 | humidity_sensor = HumiditySensor(mock_coordinator) 85 | humidity_sensor.async_write_ha_state = MagicMock() 86 | 87 | # Test update with new value 88 | humidity_sensor.update(50) 89 | assert humidity_sensor.native_value == 50 90 | humidity_sensor.async_write_ha_state.assert_called_once() 91 | 92 | # Test update with same value (should not trigger state update) 93 | humidity_sensor.async_write_ha_state.reset_mock() 94 | humidity_sensor.update(50) 95 | assert humidity_sensor.native_value == 50 96 | humidity_sensor.async_write_ha_state.assert_not_called() 97 | 98 | # Test update with new value again 99 | humidity_sensor.update(60) 100 | assert humidity_sensor.native_value == 60 101 | humidity_sensor.async_write_ha_state.assert_called_once() 102 | 103 | 104 | async def test_moisture_sensor_update(hass, mock_growcube_client): 105 | """Test moisture sensor update.""" 106 | # Create a mock coordinator 107 | mock_coordinator = MagicMock() 108 | mock_coordinator.data.device_id = "test_device_id" 109 | 110 | # Create moisture sensor 111 | moisture_sensor = MoistureSensor(mock_coordinator, 0) 112 | moisture_sensor.async_write_ha_state = MagicMock() 113 | 114 | # Test update with new value 115 | moisture_sensor.update(30) 116 | assert moisture_sensor.native_value == 30 117 | moisture_sensor.async_write_ha_state.assert_called_once() 118 | 119 | # Test update with same value (should not trigger state update) 120 | moisture_sensor.async_write_ha_state.reset_mock() 121 | moisture_sensor.update(30) 122 | assert moisture_sensor.native_value == 30 123 | moisture_sensor.async_write_ha_state.assert_not_called() 124 | 125 | # Test update with new value again 126 | moisture_sensor.update(40) 127 | assert moisture_sensor.native_value == 40 128 | moisture_sensor.async_write_ha_state.assert_called_once() 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) 2 | 3 | # Elecrow GrowCube integration for Home Assistant 4 | This is an integration for using [Elecrow GrowCube](https://shrsl.com/4qit4) devices in Home Assistant. 5 | 6 | __Disclosure__: This is an affiliate link for [Hands on Katie](https://handsonkatie.com). If you make a purchase through this link, she may earn a small commission at no additional cost to you. 7 | 8 | > Please note that a Growcube device can only be connected to one client at a time. That means you 9 | > will not be able to connect using the phone app while Home Assistant is running the integration, 10 | > or vice versa. 11 | 12 | ## Installation 13 | 14 | ### Install integration 15 | 16 | Click the button to add this repository to HACS. 17 | 18 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=jonnybergdahl&category=Integration&repository=HomeAssistant_Growcube_Integration) 19 | 20 | Then restart Home Assistant. 21 | 22 | You can also do the above manually: 23 | 1. Open the Home Assistant web interface and navigate to the HACS store. 24 | 2. Click on the "Integrations" tab. 25 | 3. Click on the three dots in the top right corner and select "Custom repositories". 26 | 4. Enter the URL (`https://github.com/jonnybergdahl/HomeAssistant_Growcube_Integration`) and select "Integration" as the category. 27 | 5. Click "Add". 28 | 6. Once the repository has been added, you should see the Elecrow GrowCube integration listed in the HACS store. 29 | 7. Click on the integration and then click "Install". 30 | 8. Restart Home Assistant. 31 | 32 | ### Add a Growcube device 33 | 34 | Click the button to add a Growcube device to Home Assistant. 35 | 36 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=growcube) 37 | 38 | Click OK when it asks if you want to setup the Elecrow Growcube integration. 39 | 40 | ![wizard1.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/wizard1.png) 41 | 42 | Enter the IP address of the Growcube device and click Submit. 43 | 44 | ![wizard2.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/wizard2.png) 45 | 46 | > Remember to close down the phone app before this step to avoid connection issues. 47 | 48 | You can also do this manually: 49 | 50 | 1. Open the Home Assistant web interface. 51 | 2. Click on "Configuration" in the left-hand menu. 52 | 3. Click on "Integrations". 53 | 4. Click on the "+" button in the bottom right corner. 54 | 5. Search for "GrowCube" and click on it. 55 | 6. Enter the IP address (or host name) of the device. 56 | 57 | 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. 58 | 59 | ## Getting help 60 | 61 | You can file bugs in the [issues section on Github](https://github.com/jonnybergdahl/HomeAssistant_Growcube_Integration/issues). 62 | 63 | You can also reach me at [#jonnys-place](https://discord.gg/SeHKWPu9Cw) on Brian Lough's Discord. 64 | 65 | ## Sensors and services 66 | 67 | ![device1.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/device1.png) 68 | 69 | ### Sensors 70 | 71 | The integration adds sensors for temperature, humidity and four sensors for moisture. 72 | 73 | ![sensors1.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/sensors1.png) 74 | 75 | ### Diagnostics 76 | 77 | The diagnostics sensors includes things such as device lock, sensor disconnect warnings and pump blocked warnings. 78 | 79 | ![diagnostics1.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/diagnostics1.png) 80 | 81 | ### Controls 82 | 83 | There are controls to let you manually water a plant. Thee will activate the pump for 5 seconds for a given outlet. 84 | 85 | ![controls1.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/controls1.png) 86 | 87 | ### Services 88 | 89 | There are also services for manual watering and setup of automatic watering modes. 90 | 91 | #### Water plant 92 | 93 | This is a service for watering a plant, to be used in automations. 94 | 95 | ![waterplant.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/waterplant.png) 96 | 97 | Use channel names A-D and a duration value in seconds. 98 | 99 | #### Smart watering 100 | 101 | This is a service to set smart watering for a plant, to be used to setup min and max 102 | moisture levels for the plant. 103 | 104 | ![smartwatering.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/smartwatering.png) 105 | 106 | Use channel names A-D and set moisture percentages for min and max values. Use the All day switch to allow watering during daylight. 107 | 108 | #### Scheduled watering 109 | 110 | This a service to setup scheduled watering for a plant, to be used to setup a fixed interval and duration for watering. 111 | 112 | ![scheduledwatering.png](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/scheduledwatering.png) 113 | 114 | Use channel names A-D, set a watering duration in seconds, and an interval in hours. 115 | 116 | #### Delete watering 117 | 118 | This is a service to remove smart or scheduled watering for a plant. 119 | 120 | ![deletewatering](https://raw.githubusercontent.com/jonnybergdahl/HomeAssistant_Growcube_Integration/main/images/deletewatering.png) 121 | 122 | Use channel named A-D. 123 | -------------------------------------------------------------------------------- /custom_components/growcube/services.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping, Any 2 | import voluptuous as vol 3 | import homeassistant.helpers.config_validation as cv 4 | 5 | from growcube_client import Channel, WateringMode 6 | 7 | from homeassistant.const import ATTR_DEVICE_ID 8 | from homeassistant.core import HomeAssistant, ServiceCall, callback 9 | from homeassistant.exceptions import HomeAssistantError 10 | from homeassistant.helpers import device_registry as dr 11 | 12 | from . import GrowcubeDataCoordinator 13 | from .const import DOMAIN, CHANNEL_NAME, SERVICE_WATER_PLANT, SERVICE_SET_SMART_WATERING, \ 14 | SERVICE_SET_SCHEDULED_WATERING, SERVICE_DELETE_WATERING, \ 15 | ARGS_CHANNEL, ARGS_DURATION, ARGS_MIN_MOISTURE, ARGS_MAX_MOISTURE, ARGS_ALL_DAY, ARGS_INTERVAL 16 | import logging 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | @callback 21 | async def async_setup_services(hass: HomeAssistant) -> None: 22 | 23 | async def async_call_water_plant_service(service_call: ServiceCall) -> None: 24 | await _async_handle_water_plant(hass, service_call.data) 25 | 26 | async def async_call_set_smart_watering_service(service_call: ServiceCall) -> None: 27 | await _async_handle_set_smart_watering(hass, service_call.data) 28 | 29 | async def async_call_set_scheduled_watering_service(service_call: ServiceCall) -> None: 30 | await _async_handle_set_scheduled_watering(hass, service_call.data) 31 | 32 | async def async_call_delete_watering_service(service_call: ServiceCall) -> None: 33 | await _async_handle_delete_watering(hass, service_call.data) 34 | 35 | hass.services.async_register(DOMAIN, 36 | SERVICE_WATER_PLANT, 37 | async_call_water_plant_service, 38 | schema=vol.Schema( 39 | { 40 | vol.Required(ATTR_DEVICE_ID): cv.string, 41 | vol.Required(ARGS_CHANNEL, default='A'): cv.string, 42 | vol.Required(ARGS_DURATION, default=5): cv.positive_int, 43 | } 44 | )) 45 | hass.services.async_register(DOMAIN, 46 | SERVICE_SET_SMART_WATERING, 47 | async_call_set_smart_watering_service, 48 | schema=vol.Schema( 49 | { 50 | vol.Required(ATTR_DEVICE_ID): cv.string, 51 | vol.Required(ARGS_CHANNEL, default='A'): cv.string, 52 | vol.Required(ARGS_ALL_DAY, default=True): cv.boolean, 53 | vol.Required(ARGS_MIN_MOISTURE, default=15): cv.positive_int, 54 | vol.Required(ARGS_MAX_MOISTURE, default=40): cv.positive_int, 55 | } 56 | )) 57 | hass.services.async_register(DOMAIN, 58 | SERVICE_SET_SCHEDULED_WATERING, 59 | async_call_set_scheduled_watering_service, 60 | schema=vol.Schema( 61 | { 62 | vol.Required(ATTR_DEVICE_ID): cv.string, 63 | vol.Required(ARGS_CHANNEL, default='A'): cv.string, 64 | vol.Required(ARGS_DURATION, default=6): cv.positive_int, 65 | vol.Required(ARGS_INTERVAL, default=3): cv.positive_int, 66 | } 67 | )) 68 | hass.services.async_register(DOMAIN, 69 | SERVICE_DELETE_WATERING, 70 | async_call_delete_watering_service, 71 | schema=vol.Schema( 72 | { 73 | vol.Required(ATTR_DEVICE_ID): cv.string, 74 | vol.Required(ARGS_CHANNEL, default='A'): cv.string, 75 | } 76 | )) 77 | 78 | 79 | async def _async_handle_water_plant(hass: HomeAssistant, data: Mapping[str, Any]) -> None: 80 | 81 | coordinator, device = _get_coordinator(hass, data) 82 | 83 | if coordinator is None: 84 | _LOGGER.warning("Unable to find coordinator for %s", data[ATTR_DEVICE_ID]) 85 | return 86 | 87 | channel_str = data["channel"] 88 | duration_str = data["duration"] 89 | 90 | # Validate data 91 | if channel_str not in CHANNEL_NAME: 92 | _LOGGER.error( 93 | "%s: %s - Invalid channel specified: %s", 94 | device, 95 | SERVICE_WATER_PLANT, 96 | channel_str 97 | ) 98 | raise HomeAssistantError(f"Invalid channel '{channel_str}' specified") 99 | 100 | try: 101 | duration = int(duration_str) 102 | except ValueError: 103 | _LOGGER.error( 104 | "%s: %s - Invalid duration '%s'", 105 | device, 106 | SERVICE_WATER_PLANT, 107 | duration_str 108 | ) 109 | raise HomeAssistantError(f"Invalid duration '{duration_str}' specified") 110 | 111 | if duration < 1 or duration > 60: 112 | _LOGGER.error( 113 | "%s: %s - Invalid duration '%s', should be 1-60", 114 | device, 115 | SERVICE_WATER_PLANT, 116 | duration 117 | ) 118 | raise HomeAssistantError(f"Invalid duration '{duration}' specified, should be 1-60") 119 | 120 | channel = Channel(CHANNEL_NAME.index(channel_str)) 121 | 122 | await coordinator.handle_water_plant(channel, duration) 123 | 124 | 125 | async def _async_handle_set_smart_watering(hass: HomeAssistant, data: Mapping[str, Any]) -> None: 126 | 127 | coordinator, device = _get_coordinator(hass, data) 128 | 129 | if coordinator is None: 130 | _LOGGER.error("Unable to find coordinator for %s", device) 131 | return 132 | 133 | channel_str = data[ARGS_CHANNEL] 134 | min_moisture = data[ARGS_MIN_MOISTURE] 135 | max_moisture = data[ARGS_MAX_MOISTURE] 136 | all_day = data[ARGS_ALL_DAY] 137 | 138 | # Validate data 139 | if channel_str not in CHANNEL_NAME: 140 | _LOGGER.error( 141 | "%s: %s - Invalid channel specified: %s", 142 | device, 143 | SERVICE_SET_SMART_WATERING, 144 | channel_str 145 | ) 146 | raise HomeAssistantError(f"Invalid channel '{channel_str}' specified") 147 | 148 | if min_moisture <= 0 or min_moisture > 100: 149 | _LOGGER.error( 150 | "%s: %s - Invalid min_moisture specified: %s", 151 | device, 152 | SERVICE_SET_SMART_WATERING, 153 | min_moisture 154 | ) 155 | raise HomeAssistantError(f"Invalid min_moisture '{min_moisture}' specified") 156 | 157 | if max_moisture <= 0 or max_moisture > 100: 158 | _LOGGER.error( 159 | "%s: %s - Invalid max_moisture specified: %s", 160 | device, 161 | SERVICE_SET_SMART_WATERING, 162 | max_moisture 163 | ) 164 | raise HomeAssistantError(f"Invalid max_moisture '{max_moisture}' specified") 165 | 166 | if max_moisture <= min_moisture: 167 | _LOGGER.error( 168 | "%s: %s - Invalid values specified, max_moisture %s must be bigger than min_moisture %s", 169 | device, 170 | SERVICE_SET_SMART_WATERING, 171 | max_moisture, 172 | min_moisture 173 | ) 174 | raise HomeAssistantError( 175 | f"Invalid values specified, max_moisture {max_moisture} must be bigger than min_moisture {min_moisture}") 176 | 177 | channel = Channel(CHANNEL_NAME.index(channel_str)) 178 | await coordinator.handle_set_smart_watering(channel, all_day, min_moisture, max_moisture) 179 | 180 | 181 | async def _async_handle_set_scheduled_watering(hass: HomeAssistant, data: Mapping[str, Any]) -> None: 182 | 183 | coordinator, device = _get_coordinator(hass, data) 184 | 185 | if coordinator is None: 186 | _LOGGER.error("Unable to find coordinator for %s", device) 187 | return 188 | 189 | channel_str = data[ARGS_CHANNEL] 190 | duration = data[ARGS_DURATION] 191 | interval = data[ARGS_INTERVAL] 192 | 193 | # Validate data 194 | if channel_str not in CHANNEL_NAME: 195 | _LOGGER.error( 196 | "%s: %s - Invalid channel specified: %s", 197 | device, 198 | SERVICE_SET_SCHEDULED_WATERING, 199 | channel_str 200 | ) 201 | raise HomeAssistantError(f"Invalid channel '{channel_str}' specified") 202 | 203 | if duration <= 0 or duration > 100: 204 | _LOGGER.error( 205 | "%s: %s - Invalid duration specified: %s", 206 | device, 207 | SERVICE_SET_SCHEDULED_WATERING, 208 | duration 209 | ) 210 | raise HomeAssistantError(f"Invalid duration '{duration}' specified") 211 | 212 | if interval <= 0 or interval > 240: 213 | _LOGGER.error( 214 | "%s: %s - Invalid interval specified: %s", 215 | device, 216 | SERVICE_SET_SCHEDULED_WATERING, 217 | interval 218 | ) 219 | raise HomeAssistantError(f"Invalid interval '{interval}' specified") 220 | 221 | channel = Channel(CHANNEL_NAME.index(channel_str)) 222 | await coordinator.handle_set_manual_watering(channel, duration, interval) 223 | 224 | 225 | async def _async_handle_delete_watering(hass: HomeAssistant, data: Mapping[str, Any]) -> None: 226 | 227 | coordinator, device = _get_coordinator(hass, data) 228 | 229 | if coordinator is None: 230 | raise HomeAssistantError(f"Unable to find coordinator for {device}") 231 | 232 | channel_str = data["channel"] 233 | 234 | # Validate data 235 | if channel_str not in CHANNEL_NAME: 236 | _LOGGER.error( 237 | "%s: %s - Invalid channel specified: %s", 238 | device, 239 | SERVICE_DELETE_WATERING, 240 | channel_str 241 | ) 242 | raise HomeAssistantError(f"Invalid channel '{channel_str}' specified") 243 | 244 | channel = Channel(CHANNEL_NAME.index(channel_str)) 245 | await coordinator.handle_delete_watering(channel) 246 | 247 | 248 | def _get_coordinator(hass: HomeAssistant, data: Mapping[str, Any]) -> tuple[GrowcubeDataCoordinator | None, str]: 249 | device_registry = dr.async_get(hass) 250 | device_id = data[ATTR_DEVICE_ID] 251 | device_entry = device_registry.async_get(device_id) 252 | device = list(device_entry.identifiers)[0][1] 253 | 254 | for key in hass.data[DOMAIN]: 255 | coordinator = hass.data[DOMAIN][key] 256 | if coordinator.data.device_id == device: 257 | return coordinator, device_id 258 | 259 | _LOGGER.error("No coordinator found for %s", device) 260 | return None, device_id 261 | -------------------------------------------------------------------------------- /tests/test_binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for the Growcube binary sensor platform.""" 2 | import pytest 3 | from unittest.mock import patch, MagicMock, call 4 | 5 | from homeassistant.const import EntityCategory, Platform 6 | from homeassistant.components.binary_sensor import BinarySensorDeviceClass 7 | 8 | from custom_components.growcube.const import DOMAIN, CHANNEL_ID, CHANNEL_NAME 9 | from custom_components.growcube.binary_sensor import ( 10 | DeviceLockedSensor, 11 | WaterWarningSensor, 12 | PumpOpenStateSensor, 13 | OutletLockedSensor, 14 | OutletBlockedSensor, 15 | SensorFaultSensor, 16 | SensorDisconnectedSensor, 17 | ) 18 | 19 | 20 | async def test_device_locked_sensor(hass, mock_growcube_client): 21 | """Test the device locked sensor.""" 22 | # Create a mock coordinator 23 | mock_coordinator = MagicMock() 24 | mock_coordinator.data.device_id = "test_device_id" 25 | mock_coordinator.device.device_info = {"identifiers": {("test", "test_id")}} 26 | 27 | # Create sensor 28 | sensor = DeviceLockedSensor(mock_coordinator) 29 | sensor.async_write_ha_state = MagicMock() 30 | 31 | # Verify sensor properties 32 | assert sensor.unique_id == "test_device_id_device_locked" 33 | assert sensor.name == "Device locked" 34 | assert sensor.entity_category == EntityCategory.DIAGNOSTIC 35 | 36 | # Test update with new value 37 | sensor.update(True) 38 | assert sensor.is_on is True 39 | sensor.async_write_ha_state.assert_called_once() 40 | 41 | # Test update with same value (should not trigger state update) 42 | sensor.async_write_ha_state.reset_mock() 43 | sensor.update(True) 44 | assert sensor.is_on is True 45 | sensor.async_write_ha_state.assert_not_called() 46 | 47 | # Test update with new value again 48 | sensor.update(False) 49 | assert sensor.is_on is False 50 | sensor.async_write_ha_state.assert_called_once() 51 | 52 | 53 | async def test_water_warning_sensor(hass, mock_growcube_client): 54 | """Test the water warning sensor.""" 55 | # Create a mock coordinator 56 | mock_coordinator = MagicMock() 57 | mock_coordinator.data.device_id = "test_device_id" 58 | mock_coordinator.device.device_info = {"identifiers": {("test", "test_id")}} 59 | 60 | # Create sensor 61 | sensor = WaterWarningSensor(mock_coordinator) 62 | sensor.async_write_ha_state = MagicMock() 63 | 64 | # Verify sensor properties 65 | assert sensor.unique_id == "test_device_id_water_warning" 66 | assert sensor.name == "Water warning" 67 | assert sensor.device_class == BinarySensorDeviceClass.PROBLEM 68 | assert sensor.icon == "mdi:water-alert" 69 | 70 | # Test update with new value 71 | sensor.update(True) 72 | assert sensor.is_on is True 73 | sensor.async_write_ha_state.assert_called_once() 74 | 75 | # Test update with same value (should not trigger state update) 76 | sensor.async_write_ha_state.reset_mock() 77 | sensor.update(True) 78 | assert sensor.is_on is True 79 | sensor.async_write_ha_state.assert_not_called() 80 | 81 | # Test update with new value again 82 | sensor.update(False) 83 | assert sensor.is_on is False 84 | sensor.async_write_ha_state.assert_called_once() 85 | 86 | 87 | async def test_pump_open_state_sensor(hass, mock_growcube_client): 88 | """Test the pump open state sensor.""" 89 | # Create a mock coordinator 90 | mock_coordinator = MagicMock() 91 | mock_coordinator.data.device_id = "test_device_id" 92 | mock_coordinator.device.device_info = {"identifiers": {("test", "test_id")}} 93 | 94 | # Create sensor for channel 0 95 | channel = 0 96 | sensor = PumpOpenStateSensor(mock_coordinator, channel) 97 | sensor.async_write_ha_state = MagicMock() 98 | 99 | # Verify sensor properties 100 | assert sensor.unique_id == f"test_device_id_pump_{CHANNEL_ID[channel]}_open" 101 | assert sensor.name == f"Pump {CHANNEL_NAME[channel]} open" 102 | assert sensor.icon == "mdi:water" 103 | 104 | # Test update with new value 105 | sensor.update(True) 106 | assert sensor.is_on is True 107 | sensor.async_write_ha_state.assert_called_once() 108 | 109 | # Test update with same value (should not trigger state update) 110 | sensor.async_write_ha_state.reset_mock() 111 | sensor.update(True) 112 | assert sensor.is_on is True 113 | sensor.async_write_ha_state.assert_not_called() 114 | 115 | # Test update with new value again 116 | sensor.update(False) 117 | assert sensor.is_on is False 118 | sensor.async_write_ha_state.assert_called_once() 119 | 120 | 121 | async def test_outlet_locked_sensor(hass, mock_growcube_client): 122 | """Test the outlet locked sensor.""" 123 | # Create a mock coordinator 124 | mock_coordinator = MagicMock() 125 | mock_coordinator.data.device_id = "test_device_id" 126 | mock_coordinator.device.device_info = {"identifiers": {("test", "test_id")}} 127 | 128 | # Create sensor for channel 0 129 | channel = 0 130 | sensor = OutletLockedSensor(mock_coordinator, channel) 131 | sensor.async_write_ha_state = MagicMock() 132 | 133 | # Verify sensor properties 134 | assert sensor.unique_id == f"test_device_id_outlet_{CHANNEL_ID[channel]}_locked" 135 | assert sensor.name == f"Outlet {CHANNEL_NAME[channel]} locked" 136 | assert sensor.icon == "mdi:pump-off" 137 | assert sensor.entity_category == EntityCategory.DIAGNOSTIC 138 | 139 | # Test update with new value 140 | sensor.update(True) 141 | assert sensor.is_on is True 142 | sensor.async_write_ha_state.assert_called_once() 143 | 144 | # Test update with same value (should not trigger state update) 145 | sensor.async_write_ha_state.reset_mock() 146 | sensor.update(True) 147 | assert sensor.is_on is True 148 | sensor.async_write_ha_state.assert_not_called() 149 | 150 | # Test update with new value again 151 | sensor.update(False) 152 | assert sensor.is_on is False 153 | sensor.async_write_ha_state.assert_called_once() 154 | 155 | 156 | async def test_outlet_blocked_sensor(hass, mock_growcube_client): 157 | """Test the outlet blocked sensor.""" 158 | # Create a mock coordinator 159 | mock_coordinator = MagicMock() 160 | mock_coordinator.data.device_id = "test_device_id" 161 | mock_coordinator.device.device_info = {"identifiers": {("test", "test_id")}} 162 | 163 | # Create sensor for channel 0 164 | channel = 0 165 | sensor = OutletBlockedSensor(mock_coordinator, channel) 166 | sensor.async_write_ha_state = MagicMock() 167 | 168 | # Verify sensor properties 169 | assert sensor.unique_id == f"test_device_id_outlet_{CHANNEL_ID[channel]}_blocked" 170 | assert sensor.name == f"Outlet {CHANNEL_NAME[channel]} blocked" 171 | assert sensor.icon == "mdi:water-pump-off" 172 | assert sensor.device_class == BinarySensorDeviceClass.PROBLEM 173 | 174 | # Test update with new value 175 | sensor.update(True) 176 | assert sensor.is_on is True 177 | sensor.async_write_ha_state.assert_called_once() 178 | 179 | # Test update with same value (should not trigger state update) 180 | sensor.async_write_ha_state.reset_mock() 181 | sensor.update(True) 182 | assert sensor.is_on is True 183 | sensor.async_write_ha_state.assert_not_called() 184 | 185 | # Test update with new value again 186 | sensor.update(False) 187 | assert sensor.is_on is False 188 | sensor.async_write_ha_state.assert_called_once() 189 | 190 | 191 | async def test_sensor_fault_sensor(hass, mock_growcube_client): 192 | """Test the sensor fault sensor.""" 193 | # Create a mock coordinator 194 | mock_coordinator = MagicMock() 195 | mock_coordinator.data.device_id = "test_device_id" 196 | mock_coordinator.device.device_info = {"identifiers": {("test", "test_id")}} 197 | 198 | # Create sensor for channel 0 199 | channel = 0 200 | sensor = SensorFaultSensor(mock_coordinator, channel) 201 | sensor.async_write_ha_state = MagicMock() 202 | 203 | # Verify sensor properties 204 | assert sensor.unique_id == f"test_device_id_sensor_{CHANNEL_ID[channel]}_fault" 205 | assert sensor.name == f"Sensor {CHANNEL_NAME[channel]} fault" 206 | assert sensor.icon == "mdi:thermometer-probe-off" 207 | assert sensor.device_class == BinarySensorDeviceClass.PROBLEM 208 | 209 | # Test update with new value 210 | sensor.update(True) 211 | assert sensor.is_on is True 212 | sensor.async_write_ha_state.assert_called_once() 213 | 214 | # Test update with same value (should not trigger state update) 215 | sensor.async_write_ha_state.reset_mock() 216 | sensor.update(True) 217 | assert sensor.is_on is True 218 | sensor.async_write_ha_state.assert_not_called() 219 | 220 | # Test update with new value again 221 | sensor.update(False) 222 | assert sensor.is_on is False 223 | sensor.async_write_ha_state.assert_called_once() 224 | 225 | 226 | async def test_sensor_disconnected_sensor(hass, mock_growcube_client): 227 | """Test the sensor disconnected sensor.""" 228 | # Create a mock coordinator 229 | mock_coordinator = MagicMock() 230 | mock_coordinator.data.device_id = "test_device_id" 231 | mock_coordinator.device.device_info = {"identifiers": {("test", "test_id")}} 232 | 233 | # Create sensor for channel 0 234 | channel = 0 235 | sensor = SensorDisconnectedSensor(mock_coordinator, channel) 236 | sensor.async_write_ha_state = MagicMock() 237 | 238 | # Verify sensor properties 239 | assert sensor.unique_id == f"test_device_id_sensor_{CHANNEL_ID[channel]}_disconnected" 240 | assert sensor.name == f"Sensor {CHANNEL_NAME[channel]} disconnected" 241 | assert sensor.icon == "mdi:thermometer-probe-off" 242 | assert sensor.device_class == BinarySensorDeviceClass.PROBLEM 243 | 244 | # Test update with new value 245 | sensor.update(True) 246 | assert sensor.is_on is True 247 | sensor.async_write_ha_state.assert_called_once() 248 | 249 | # Test update with same value (should not trigger state update) 250 | sensor.async_write_ha_state.reset_mock() 251 | sensor.update(True) 252 | assert sensor.is_on is True 253 | sensor.async_write_ha_state.assert_not_called() 254 | 255 | # Test update with new value again 256 | sensor.update(False) 257 | assert sensor.is_on is False 258 | sensor.async_write_ha_state.assert_called_once() 259 | 260 | 261 | async def test_binary_sensor_setup(hass, mock_integration): 262 | """Test binary sensor setup through the integration.""" 263 | # Import the setup entry function 264 | from custom_components.growcube.binary_sensor import async_setup_entry 265 | 266 | # Create a mock entry and coordinator 267 | mock_entry = MagicMock() 268 | mock_coordinator = MagicMock() 269 | mock_add_entities = MagicMock() 270 | 271 | # Set up the mock data structure 272 | hass.data[DOMAIN] = {mock_entry.entry_id: mock_coordinator} 273 | 274 | # Call the setup function 275 | await async_setup_entry(hass, mock_entry, mock_add_entities) 276 | 277 | # Verify that async_add_entities was called with the correct entities 278 | assert mock_add_entities.call_count == 1 279 | 280 | # Get the entities that were added 281 | entities = mock_add_entities.call_args[0][0] 282 | 283 | # Verify that we have the expected number of entities 284 | # 1 device locked, 1 water warning, 4 pump open, 4 outlet locked, 4 outlet blocked, 4 sensor fault, 4 sensor disconnected 285 | assert len(entities) == 22 286 | 287 | # Verify the types of entities 288 | assert isinstance(entities[0], DeviceLockedSensor) 289 | assert isinstance(entities[1], WaterWarningSensor) 290 | 291 | # Check that we have the right number of each type of channel-specific sensor 292 | pump_open_sensors = [e for e in entities if isinstance(e, PumpOpenStateSensor)] 293 | outlet_locked_sensors = [e for e in entities if isinstance(e, OutletLockedSensor)] 294 | outlet_blocked_sensors = [e for e in entities if isinstance(e, OutletBlockedSensor)] 295 | sensor_fault_sensors = [e for e in entities if isinstance(e, SensorFaultSensor)] 296 | sensor_disconnected_sensors = [e for e in entities if isinstance(e, SensorDisconnectedSensor)] 297 | 298 | assert len(pump_open_sensors) == 4 299 | assert len(outlet_locked_sensors) == 4 300 | assert len(outlet_blocked_sensors) == 4 301 | assert len(sensor_fault_sensors) == 4 302 | assert len(sensor_disconnected_sensors) == 4 303 | -------------------------------------------------------------------------------- /custom_components/growcube/binary_sensor.py: -------------------------------------------------------------------------------- 1 | from homeassistant.const import EntityCategory, Platform 2 | from homeassistant.core import callback, HomeAssistant 3 | from homeassistant.helpers.device_registry import DeviceInfo 4 | from homeassistant import config_entries 5 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 6 | from .coordinator import GrowcubeDataCoordinator 7 | from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass 8 | from .const import DOMAIN, CHANNEL_NAME, CHANNEL_ID 9 | import logging 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: 15 | """Set up the Growcube sensors.""" 16 | coordinator = hass.data[DOMAIN][entry.entry_id] 17 | async_add_entities([DeviceLockedSensor(coordinator), 18 | WaterWarningSensor(coordinator), 19 | PumpOpenStateSensor(coordinator, 0), 20 | PumpOpenStateSensor(coordinator, 1), 21 | PumpOpenStateSensor(coordinator, 2), 22 | PumpOpenStateSensor(coordinator, 3), 23 | OutletLockedSensor(coordinator, 0), 24 | OutletLockedSensor(coordinator, 1), 25 | OutletLockedSensor(coordinator, 2), 26 | OutletLockedSensor(coordinator, 3), 27 | OutletBlockedSensor(coordinator, 0), 28 | OutletBlockedSensor(coordinator, 1), 29 | OutletBlockedSensor(coordinator, 2), 30 | OutletBlockedSensor(coordinator, 3), 31 | SensorFaultSensor(coordinator, 0), 32 | SensorFaultSensor(coordinator, 1), 33 | SensorFaultSensor(coordinator, 2), 34 | SensorFaultSensor(coordinator, 3), 35 | SensorDisconnectedSensor(coordinator, 0), 36 | SensorDisconnectedSensor(coordinator, 1), 37 | SensorDisconnectedSensor(coordinator, 2), 38 | SensorDisconnectedSensor(coordinator, 3), 39 | ], True) 40 | 41 | 42 | class DeviceLockedSensor(BinarySensorEntity): 43 | def __init__(self, coordinator: GrowcubeDataCoordinator): 44 | super().__init__() 45 | self._coordinator = coordinator 46 | self._coordinator.register_device_locked_state_callback(self.update) 47 | self._attr_unique_id = f"{coordinator.data.device_id}_device_locked" 48 | self._attr_name = "Device locked" 49 | self._attr_device_class = BinarySensorDeviceClass.PROBLEM 50 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 51 | self._attr_is_on = coordinator.data.device_locked 52 | 53 | @property 54 | def device_info(self) -> DeviceInfo | None: 55 | return self._coordinator.device.device_info 56 | 57 | @callback 58 | def update(self, new_state: bool | None) -> None: 59 | _LOGGER.debug("%s: Update device_locked %s -> %s", 60 | self._coordinator.data.device_id, 61 | self._attr_is_on, 62 | new_state 63 | ) 64 | if new_state != self._attr_is_on: 65 | self._attr_is_on = new_state 66 | self.async_write_ha_state() 67 | 68 | 69 | class WaterWarningSensor(BinarySensorEntity): 70 | def __init__(self, coordinator: GrowcubeDataCoordinator): 71 | super().__init__() 72 | self._coordinator = coordinator 73 | self._coordinator.register_water_warning_state_callback(self.update) 74 | self._attr_unique_id = f"{coordinator.data.device_id}_water_warning" 75 | self._attr_name = "Water warning" 76 | self._attr_device_class = BinarySensorDeviceClass.PROBLEM 77 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 78 | self._attr_is_on = coordinator.data.water_warning 79 | 80 | @property 81 | def device_info(self) -> DeviceInfo | None: 82 | return self._coordinator.device.device_info 83 | 84 | @property 85 | def icon(self) -> str: 86 | if self._attr_is_on: 87 | return "mdi:water-alert" 88 | else: 89 | return "mdi:water-check" 90 | 91 | @callback 92 | def update(self, new_state: bool | None) -> None: 93 | _LOGGER.debug("%s: Update water_state %s -> %s", 94 | self._coordinator.data.device_id, 95 | self._attr_is_on, 96 | new_state 97 | ) 98 | if new_state != self._attr_is_on: 99 | self._attr_is_on = new_state 100 | self.async_write_ha_state() 101 | 102 | 103 | class PumpOpenStateSensor(BinarySensorEntity): 104 | 105 | def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: 106 | super().__init__() 107 | self._coordinator = coordinator 108 | self._coordinator.register_pump_open_state_callback(self.update) 109 | self._channel = channel 110 | self._attr_unique_id = f"{coordinator.data.device_id}_pump_{CHANNEL_ID[channel]}_open" 111 | self._attr_name = f"Pump {CHANNEL_NAME[channel]} open" 112 | self._attr_device_class = BinarySensorDeviceClass.OPENING 113 | self._attr_is_on = coordinator.data.pump_open[self._channel] 114 | self._attr_entity_registry_enabled_default = False 115 | 116 | @property 117 | def device_info(self) -> DeviceInfo | None: 118 | return self._coordinator.device.device_info 119 | 120 | @property 121 | def icon(self) -> str: 122 | if self._attr_is_on: 123 | return "mdi:water" 124 | else: 125 | return "mdi:water-off" 126 | 127 | @callback 128 | def update(self, new_state: bool | None) -> None: 129 | _LOGGER.debug("%s: Update pump_state[%s] %s -> %s", 130 | self._coordinator.data.device_id, 131 | self._channel, 132 | self._attr_is_on, 133 | new_state 134 | ) 135 | if new_state != self._attr_is_on: 136 | self._attr_is_on = new_state 137 | self.async_write_ha_state() 138 | 139 | 140 | class OutletLockedSensor(BinarySensorEntity): 141 | def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: 142 | super().__init__() 143 | self._coordinator = coordinator 144 | self._coordinator.register_outlet_locked_state_callback(self.update) 145 | self._channel = channel 146 | self._attr_unique_id = f"{coordinator.data.device_id}_outlet_{CHANNEL_ID[channel]}_locked" 147 | self._attr_name = f"Outlet {CHANNEL_NAME[channel]} locked" 148 | self._attr_device_class = BinarySensorDeviceClass.PROBLEM 149 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 150 | self._attr_is_on = coordinator.data.outlet_locked_state[self._channel] 151 | 152 | @property 153 | def device_info(self) -> DeviceInfo | None: 154 | return self._coordinator.device.device_info 155 | 156 | @property 157 | def icon(self) -> str: 158 | if self._attr_is_on: 159 | return "mdi:pump-off" 160 | else: 161 | return "mdi:pump" 162 | 163 | @callback 164 | def update(self, new_state: bool | None) -> None: 165 | _LOGGER.debug("%s: Update pump_lock_state[%s] %s -> %s", 166 | self._coordinator.data.device_id, 167 | self._channel, 168 | self._attr_is_on, 169 | new_state 170 | ) 171 | if new_state != self._attr_is_on: 172 | self._attr_is_on = new_state 173 | self.async_write_ha_state() 174 | 175 | 176 | class OutletBlockedSensor(BinarySensorEntity): 177 | def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: 178 | super().__init__() 179 | self._coordinator = coordinator 180 | self._coordinator.register_outlet_blocked_state_callback(self.update) 181 | self._channel = channel 182 | self._attr_unique_id = f"{coordinator.data.device_id}_outlet_{CHANNEL_ID[channel]}_blocked" 183 | self._attr_name = f"Outlet {CHANNEL_NAME[channel]} blocked" 184 | self._attr_device_class = BinarySensorDeviceClass.PROBLEM 185 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 186 | self._attr_is_on = coordinator.data.outlet_blocked_state[self._channel] 187 | 188 | @property 189 | def device_info(self) -> DeviceInfo | None: 190 | return self._coordinator.device.device_info 191 | 192 | @property 193 | def icon(self) -> str: 194 | if self._attr_is_on: 195 | return "mdi:water-pump-off" 196 | else: 197 | return "mdi:water-pump" 198 | 199 | @callback 200 | def update(self, new_state: bool | None) -> None: 201 | _LOGGER.debug("%s: Update pump_lock_state[%s] %s -> %s", 202 | self._coordinator.data.device_id, 203 | self._channel, 204 | self._attr_is_on, 205 | new_state 206 | ) 207 | if new_state != self._attr_is_on: 208 | self._attr_is_on = new_state 209 | self.async_write_ha_state() 210 | 211 | 212 | class SensorFaultSensor(BinarySensorEntity): 213 | def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: 214 | super().__init__() 215 | self._coordinator = coordinator 216 | self._coordinator.register_sensor_fault_state_callback(self.update) 217 | self._channel = channel 218 | self._attr_unique_id = f"{coordinator.data.device_id}_sensor_{CHANNEL_ID[channel]}_fault" 219 | self._attr_name = f"Sensor {CHANNEL_NAME[channel]} fault" 220 | self._attr_device_class = BinarySensorDeviceClass.PROBLEM 221 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 222 | self._attr_is_on = coordinator.data.sensor_abnormal[self._channel] 223 | 224 | @property 225 | def device_info(self) -> DeviceInfo | None: 226 | return self._coordinator.device.device_info 227 | 228 | @property 229 | def icon(self) -> str: 230 | if self._attr_is_on: 231 | return "mdi:thermometer-probe-off" 232 | else: 233 | return "mdi:thermometer-probe" 234 | 235 | @callback 236 | def update(self, new_state: bool | None) -> None: 237 | _LOGGER.debug("%s: Update sensor_state[%s] %s -> %s", 238 | self._coordinator.data.device_id, 239 | self._channel, 240 | self._attr_is_on, 241 | new_state 242 | ) 243 | if new_state != self._attr_is_on: 244 | self._attr_is_on = new_state 245 | self.async_write_ha_state() 246 | 247 | 248 | class SensorDisconnectedSensor(BinarySensorEntity): 249 | def __init__(self, coordinator: GrowcubeDataCoordinator, channel: int) -> None: 250 | super().__init__() 251 | self._coordinator = coordinator 252 | self._coordinator.register_sensor_disconnected_state_callback(self.update) 253 | self._channel = channel 254 | self._attr_unique_id = f"{coordinator.data.device_id}_sensor_{CHANNEL_ID[channel]}_disconnected" 255 | self._attr_name = f"Sensor {CHANNEL_NAME[channel]} disconnected" 256 | self._attr_device_class = BinarySensorDeviceClass.PROBLEM 257 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 258 | self._attr_is_on = coordinator.data.sensor_disconnected[self._channel] 259 | 260 | @property 261 | def device_info(self) -> DeviceInfo | None: 262 | return self._coordinator.device.device_info 263 | 264 | @property 265 | def icon(self) -> str: 266 | if self._attr_is_on: 267 | return "mdi:thermometer-probe-off" 268 | else: 269 | return "mdi:thermometer-probe" 270 | 271 | @callback 272 | def update(self, new_state: bool | None) -> None: 273 | _LOGGER.debug("%s: Update sensor_state[%s] %s -> %s", 274 | self._coordinator.data.device_id, 275 | self._channel, 276 | self._attr_is_on, 277 | new_state 278 | ) 279 | if new_state != self._attr_is_on: 280 | self._attr_is_on = new_state 281 | self.async_write_ha_state() 282 | -------------------------------------------------------------------------------- /custom_components/growcube/coordinator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime 3 | from typing import Optional, List, Tuple, Callable 4 | 5 | from growcube_client import GrowcubeClient, GrowcubeReport, Channel, WateringMode 6 | from growcube_client import ( 7 | WaterStateGrowcubeReport, 8 | DeviceVersionGrowcubeReport, 9 | MoistureHumidityStateGrowcubeReport, 10 | PumpOpenGrowcubeReport, 11 | PumpCloseGrowcubeReport, 12 | CheckSensorGrowcubeReport, 13 | CheckOutletBlockedGrowcubeReport, 14 | CheckSensorNotConnectedGrowcubeReport, 15 | LockStateGrowcubeReport, 16 | CheckOutletLockedGrowcubeReport, 17 | ) 18 | from growcube_client import WateringModeCommand, SyncTimeCommand, PlantEndCommand, ClosePumpCommand 19 | from homeassistant.core import HomeAssistant 20 | from homeassistant.const import ( 21 | STATE_UNAVAILABLE 22 | ) 23 | import logging 24 | 25 | from homeassistant.helpers.device_registry import DeviceInfo 26 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 27 | 28 | from .const import DOMAIN 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | class GrowcubeDeviceModel: 34 | def __init__(self, host: str): 35 | # Device 36 | self.host: str = host 37 | self.version: str = "" 38 | self.device_id: Optional[str] = None 39 | self.device_info: Optional[DeviceInfo] = None 40 | 41 | 42 | 43 | class GrowcubeDataCoordinator(DataUpdateCoordinator): 44 | def __init__(self, host: str, hass: HomeAssistant): 45 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=None) 46 | self.client = GrowcubeClient( 47 | host, self.handle_report, self.on_connected, self.on_disconnected 48 | ) 49 | self.device_id = None 50 | self.device: GrowcubeDeviceModel = GrowcubeDeviceModel(host) 51 | self.shutting_down = False 52 | self._device_locked = False 53 | self._device_locked_callback: Callable[[bool], None] | None = None 54 | self._water_warning_callback: Callable[[bool], None] | None = None 55 | self._pump_open_callbacks: dict[int, Callable[[bool], None]] = {} 56 | self._outlet_locked_callbacks: dict[int, Callable[[bool], None]] = {} 57 | self._outlet_blocked_callbacks: dict[int, Callable[[bool], None]] = {} 58 | self._sensor_fault_callbacks: dict[int, Callable[[bool], None]] = {} 59 | self._sensor_disconnected_callbacks: dict[int, Callable[[bool], None]] = {} 60 | self._temperature_value_callback: Callable[[int | None], None] | None = None 61 | self._humidity_value_callback: Callable[[int | None], None] | None = None 62 | self._moisture_value_callbacks: dict[int, Callable[[bool | None], None]] = {} 63 | 64 | def set_device_id(self, device_id: str) -> None: 65 | self.device_id = hex(int(device_id))[2:] 66 | self.device.device_id = "growcube_{}".format(self.device_id) 67 | self.device.device_info = { 68 | "name": "GrowCube " + self.device_id, 69 | "identifiers": {(DOMAIN, self.device.device_id)}, 70 | "manufacturer": "Elecrow", 71 | "model": "Growcube", 72 | "sw_version": self.device.version, 73 | } 74 | 75 | async def connect(self) -> Tuple[bool, str]: 76 | result, error = await self.client.connect() 77 | if not result: 78 | return False, error 79 | 80 | self.shutting_down = False 81 | # Wait for the device to send back the DeviceVersionGrowcubeReport 82 | while not self.device_id: 83 | await asyncio.sleep(0.1) 84 | _LOGGER.debug( 85 | "Growcube device id: %s", 86 | self.device_id 87 | ) 88 | 89 | time_command = SyncTimeCommand(datetime.now()) 90 | _LOGGER.debug( 91 | "%s: Sending SyncTimeCommand", 92 | self.device_id 93 | ) 94 | self.client.send_command(time_command) 95 | return True, "" 96 | 97 | async def reconnect(self) -> None: 98 | if self.client.connected: 99 | self.client.disconnect() 100 | 101 | if not self.shutting_down: 102 | while True: 103 | # Set flag to avoid handling in on_disconnected 104 | self.shutting_down = True 105 | result, error = await self.client.connect() 106 | if result: 107 | _LOGGER.debug( 108 | "Reconnect to %s succeeded", 109 | self.client.host 110 | ) 111 | self.shutting_down = False 112 | await asyncio.sleep(10) 113 | return 114 | 115 | _LOGGER.debug( 116 | "Reconnect failed for %s with error '%s', retrying in 10 seconds", 117 | self.client.host, 118 | error) 119 | await asyncio.sleep(10) 120 | 121 | @staticmethod 122 | async def get_device_id(host: str) -> tuple[bool, str]: 123 | """This is used in the config flow to check for a valid device""" 124 | device_id = "" 125 | 126 | def _handle_device_id_report(report: GrowcubeReport) -> None: 127 | if isinstance(report, DeviceVersionGrowcubeReport): 128 | nonlocal device_id 129 | device_id = report.device_id 130 | 131 | async def _check_device_id_assigned() -> None: 132 | nonlocal device_id 133 | while not device_id: 134 | await asyncio.sleep(0.1) 135 | 136 | client = GrowcubeClient(host, _handle_device_id_report) 137 | result, error = await client.connect() 138 | if not result: 139 | return False, error 140 | 141 | try: 142 | await asyncio.wait_for(_check_device_id_assigned(), timeout=5) 143 | client.disconnect() 144 | except asyncio.TimeoutError: 145 | client.disconnect() 146 | return False, "Timed out waiting for device ID" 147 | 148 | return True, device_id 149 | 150 | def on_connected(self, host: str) -> None: 151 | _LOGGER.debug( 152 | "Connection to %s established", 153 | host 154 | ) 155 | 156 | async def on_disconnected(self, host: str) -> None: 157 | _LOGGER.debug("Connection to %s lost", host) 158 | if self.device.device_id is not None: 159 | self.hass.states.async_set( 160 | DOMAIN + "." + self.device.device_id, STATE_UNAVAILABLE 161 | ) 162 | self.reset_sensor_data() 163 | if not self.shutting_down: 164 | _LOGGER.debug( 165 | "Device host %s went offline, will try to reconnect", 166 | host 167 | ) 168 | loop = asyncio.get_event_loop() 169 | loop.call_later(10, lambda: loop.create_task(self.reconnect())) 170 | 171 | def disconnect(self) -> None: 172 | self.shutting_down = True 173 | self.client.disconnect() 174 | 175 | def reset_sensor_data(self) -> None: 176 | if self._device_locked_callback is not None: 177 | self._device_locked_callback(False) 178 | if self._water_warning_callback is not None: 179 | self._water_warning_callback(False) 180 | if self._pump_open_callbacks[0] is not None: 181 | self._pump_open_callbacks[0](False) 182 | if self._pump_open_callbacks[1] is not None: 183 | self._pump_open_callbacks[1](False) 184 | if self._pump_open_callbacks[2] is not None: 185 | self._pump_open_callbacks[2](False) 186 | if self._pump_open_callbacks[3] is not None: 187 | self._pump_open_callbacks[3](False) 188 | if self._outlet_locked_callbacks[0] is not None: 189 | self._outlet_locked_callbacks[0](False) 190 | if self._outlet_locked_callbacks[1] is not None: 191 | self._outlet_locked_callbacks[1](False) 192 | if self._outlet_locked_callbacks[2] is not None: 193 | self._outlet_locked_callbacks[2](False) 194 | if self._outlet_locked_callbacks[3] is not None: 195 | self._outlet_locked_callbacks[3](False) 196 | if self._outlet_blocked_callbacks[0] is not None: 197 | self._outlet_blocked_callbacks[0](False) 198 | if self._outlet_blocked_callbacks[1] is not None: 199 | self._outlet_blocked_callbacks[1](False) 200 | if self._outlet_blocked_callbacks[2] is not None: 201 | self._outlet_blocked_callbacks[2](False) 202 | if self._outlet_blocked_callbacks[3] is not None: 203 | self._outlet_blocked_callbacks[3](False) 204 | if self._sensor_fault_callbacks[0] is not None: 205 | self._sensor_fault_callbacks[0](False) 206 | if self._sensor_fault_callbacks[1] is not None: 207 | self._sensor_fault_callbacks[1](False) 208 | if self._sensor_fault_callbacks[2] is not None: 209 | self._sensor_fault_callbacks[2](False) 210 | if self._sensor_fault_callbacks[3] is not None: 211 | self._sensor_fault_callbacks[3](False) 212 | if self._sensor_disconnected_callbacks[0] is not None: 213 | self._sensor_disconnected_callbacks[0](False) 214 | if self._sensor_disconnected_callbacks[1] is not None: 215 | self._sensor_disconnected_callbacks[1](False) 216 | if self._sensor_disconnected_callbacks[2] is not None: 217 | self._sensor_disconnected_callbacks[2](False) 218 | if self._sensor_disconnected_callbacks[3] is not None: 219 | self._sensor_disconnected_callbacks[3](False) 220 | if self._temperature_value_callback is not None: 221 | self._temperature_value_callback(None) 222 | if self._humidity_value_callback is not None: 223 | self._humidity_value_callback(None) 224 | if self._moisture_value_callbacks[0] is not None: 225 | self._moisture_value_callbacks[0](None) 226 | if self._moisture_value_callbacks[1] is not None: 227 | self._moisture_value_callbacks[1](None) 228 | if self._moisture_value_callbacks[2] is not None: 229 | self._moisture_value_callbacks[2](None) 230 | if self._moisture_value_callbacks[3] is not None: 231 | self._moisture_value_callbacks[3](None) 232 | 233 | def register_device_locked_state_callback(self, callback: Callable[[bool], None]) -> None: 234 | self._device_locked_callback = callback 235 | 236 | def register_water_warning_state_callback(self, callback: Callable[[bool], None]) -> None: 237 | self._water_warning_callback = callback 238 | 239 | def register_pump_open_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 240 | self._pump_open_callbacks[channel] = callback 241 | 242 | def register_outlet_locked_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 243 | self._outlet_locked_callbacks[channel] = callback 244 | 245 | def register_outlet_blocked_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 246 | self._outlet_blocked_callbacks[channel] = callback 247 | 248 | def register_sensor_fault_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 249 | self._sensor_fault_callbacks[channel] = callback 250 | 251 | def register_sensor_disconnected_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 252 | self._sensor_disconnected_callbacks[channel] = callback 253 | 254 | def register_temperature_state_callback(self, callback: Callable[[int], None]) -> None: 255 | self._temperature_value_callback = callback 256 | 257 | def register_humidity_state_callback(self, callback: Callable[[int], None]) -> None: 258 | self._humidity_value_callback = callback 259 | 260 | def register_moisture_state_callback(self, channel: int, callback: Callable[[int], None]) -> None: 261 | self._moisture_value_callbacks[channel] = callback 262 | 263 | def unregister_device_locked_state_callback(self) -> None: 264 | self._device_locked_callback = None 265 | 266 | def unregister_water_warning_state_callback(self) -> None: 267 | self._water_warning_callback = None 268 | 269 | def unregister_pump_open_state_callback(self, channel: int) -> None: 270 | self._pump_open_callbacks.pop(channel) 271 | 272 | def unregister_outlet_locked_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 273 | self._outlet_locked_callbacks.pop(channel) 274 | 275 | def unregister_outlet_blocked_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 276 | self._outlet_blocked_callbacks.pop(channel) 277 | 278 | def unregister_sensor_fault_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 279 | self._sensor_fault_callbacks.pop(channel) 280 | 281 | def unregister_sensor_disconnected_state_callback(self, channel: int, callback: Callable[[bool], None]) -> None: 282 | self._sensor_disconnected_callbacks.pop(channel) 283 | 284 | def unregister_temperature_state_callback(self, callback: Callable[[int], None]) -> None: 285 | self._temperature_value_callback = None 286 | 287 | def unregister_humidity_state_callback(self, callback: Callable[[int], None]) -> None: 288 | self._humidity_value_callback = None 289 | 290 | def unregister_moisture_state_callback(self, channel: int, callback: Callable[[int], None]) -> None: 291 | self._moisture_value_callbacks.pop(channel) 292 | 293 | def handle_report(self, report: GrowcubeReport) -> None: 294 | """Handle a report from the Growcube.""" 295 | # 24 - RepDeviceVersion 296 | if isinstance(report, DeviceVersionGrowcubeReport): 297 | _LOGGER.debug( 298 | "Device device_id: %s, version %s", 299 | report.device_id, 300 | report.version 301 | ) 302 | self.reset_sensor_data() 303 | self.device.version = report.version 304 | self.set_device_id(report.device_id) 305 | # 20 - RepWaterState 306 | elif isinstance(report, WaterStateGrowcubeReport): 307 | _LOGGER.debug( 308 | "%s: Water state %s", 309 | self.device_id, 310 | report.water_warning 311 | ) 312 | if self._water_warning_callback is not None: 313 | self._water_warning_callback(report.water_warning) 314 | # 21 - RepSTHSate 315 | elif isinstance(report, MoistureHumidityStateGrowcubeReport): 316 | _LOGGER.debug( 317 | "%s: Sensor reading, channel %s, humidity %s, temperature %s, moisture %s", 318 | self.device_id, 319 | report.channel, 320 | report.humidity, 321 | report.temperature, 322 | report.moisture, 323 | ) 324 | if self._temperature_value_callback is not None: 325 | self._temperature_value_callback(report.temperature) 326 | if self._humidity_value_callback is not None: 327 | self._humidity_value_callback(report.humidity) 328 | if report.channel.value in self._moisture_value_callbacks: 329 | self._moisture_value_callbacks[report.channel.value](report.moisture) 330 | # 26 - RepPumpOpen 331 | elif isinstance(report, PumpOpenGrowcubeReport): 332 | _LOGGER.debug( 333 | "%s: Pump open, channel %s", 334 | self.device_id, 335 | report.channel 336 | ) 337 | if report.channel.value in self._pump_open_callbacks: 338 | self._pump_open_callbacks[report.channel.value](True) 339 | # 27 - RepPumpClose 340 | elif isinstance(report, PumpCloseGrowcubeReport): 341 | _LOGGER.debug( 342 | "%s: Pump closed, channel %s", 343 | self.device_id, 344 | report.channel 345 | ) 346 | if report.channel.value in self._pump_open_callbacks: 347 | self._pump_open_callbacks[report.channel.value](False) 348 | # 28 - RepCheckSenSorNotConnected 349 | elif isinstance(report, CheckSensorGrowcubeReport): 350 | _LOGGER.debug( 351 | "%s: Sensor abnormal, channel %s", 352 | self.device_id, 353 | report.channel 354 | ) 355 | if report.channel.value in self._sensor_fault_callbacks: 356 | self._sensor_fault_callbacks[report.channel.value](True) 357 | # 29 - Pump channel blocked 358 | elif isinstance(report, CheckOutletBlockedGrowcubeReport): 359 | _LOGGER.debug( 360 | "%s: Outlet blocked, channel %s", 361 | self.device_id, 362 | report.channel 363 | ) 364 | if report.channel.value in self._outlet_blocked_callbacks: 365 | self._outlet_blocked_callbacks[report.channel.value](True) 366 | # 30 - RepCheckSenSorNotConnect 367 | elif isinstance(report, CheckSensorNotConnectedGrowcubeReport): 368 | _LOGGER.debug( 369 | "%s: Check sensor, channel %s", 370 | self.device_id, 371 | report.channel 372 | ) 373 | if report.channel.value in self._sensor_disconnected_callbacks: 374 | self._sensor_disconnected_callbacks[report.channel.value](True) 375 | # 33 - RepLockstate 376 | elif isinstance(report, LockStateGrowcubeReport): 377 | _LOGGER.debug( 378 | "%s: Lock state, %s", 379 | self.device_id, 380 | report.lock_state 381 | ) 382 | # Handle case where the button on the device was pressed, this should do a reconnect 383 | # to read any problems still present 384 | if not report.lock_state: 385 | self.reset_sensor_data() 386 | self.reconnect() 387 | if self._device_locked_callback is not None: 388 | self._device_locked_callback(report.lock_state) 389 | # 34 - ReqCheckSenSorLock 390 | elif isinstance(report, CheckOutletLockedGrowcubeReport): 391 | _LOGGER.debug( 392 | "%s Check outlet, channel %s", 393 | self.device_id, 394 | report.channel 395 | ) 396 | if report.channel.value in self._outlet_locked_callbacks: 397 | self._outlet_locked_callbacks[report.channel.value](True) 398 | 399 | async def water_plant(self, channel: int) -> None: 400 | await self.client.water_plant(Channel(channel), 5) 401 | 402 | async def handle_water_plant(self, channel: Channel, duration: int) -> None: 403 | _LOGGER.debug( 404 | "%s: Service water_plant called, %s, %s", 405 | self.device_id, 406 | channel, 407 | duration 408 | ) 409 | await self.client.water_plant(channel, duration) 410 | 411 | async def handle_set_smart_watering(self, channel: Channel, 412 | all_day: bool, 413 | min_moisture: int, 414 | max_moisture: int) -> None: 415 | 416 | _LOGGER.debug( 417 | "%s: Service set_smart_watering called, %s, %s, %s, %s", 418 | self.device_id, 419 | channel, 420 | all_day, 421 | min_moisture, 422 | max_moisture, 423 | ) 424 | 425 | watering_mode = WateringMode.Smart if all_day else WateringMode.SmartOutside 426 | command = WateringModeCommand(channel, watering_mode, min_moisture, max_moisture) 427 | self.client.send_command(command) 428 | 429 | async def handle_set_manual_watering(self, channel: Channel, duration: int, interval: int) -> None: 430 | 431 | _LOGGER.debug( 432 | "%s: Service set_manual_watering called, %s, %s, %s", 433 | self.device_id, 434 | channel, 435 | duration, 436 | interval, 437 | ) 438 | 439 | command = WateringModeCommand(channel, WateringMode.Manual, interval, duration) 440 | self.client.send_command(command) 441 | 442 | async def handle_delete_watering(self, channel: Channel) -> None: 443 | 444 | _LOGGER.debug( 445 | "%s: Service delete_watering called, %s,", 446 | self.device_id, 447 | channel 448 | ) 449 | command = PlantEndCommand(channel) 450 | self.client.send_command(command) 451 | command = ClosePumpCommand(channel) 452 | self.client.send_command(command) 453 | --------------------------------------------------------------------------------