From 884182e1f70f70454860757046f239bfc143ff38 Mon Sep 17 00:00:00 2001 From: Stefan Ostermann Date: Sun, 19 Jul 2026 15:25:04 +0200 Subject: [PATCH] Add Home Assistant custom integration and ESP availability tracking - Add custom HA integration (config flow, switch, water_for service) - Add MQTT Last Will & Testament for offline detection - Publish 'online' on successful MQTT connect - Update README with integration docs --- irrigation/README.md | 50 ++++-- irrigation/homeassistant/README.md | 124 ++++++++++++++ .../homeassistant/greenhousino/__init__.py | 143 +++++++++++++++++ .../homeassistant/greenhousino/config_flow.py | 88 ++++++++++ .../homeassistant/greenhousino/const.py | 25 +++ .../homeassistant/greenhousino/manifest.json | 14 ++ .../homeassistant/greenhousino/services.yaml | 14 ++ .../homeassistant/greenhousino/strings.json | 39 +++++ .../homeassistant/greenhousino/switch.py | 151 ++++++++++++++++++ .../greenhousino/translations/en.json | 39 +++++ irrigation/src/main.cpp | 6 +- 11 files changed, 680 insertions(+), 13 deletions(-) create mode 100644 irrigation/homeassistant/README.md create mode 100644 irrigation/homeassistant/greenhousino/__init__.py create mode 100644 irrigation/homeassistant/greenhousino/config_flow.py create mode 100644 irrigation/homeassistant/greenhousino/const.py create mode 100644 irrigation/homeassistant/greenhousino/manifest.json create mode 100644 irrigation/homeassistant/greenhousino/services.yaml create mode 100644 irrigation/homeassistant/greenhousino/strings.json create mode 100644 irrigation/homeassistant/greenhousino/switch.py create mode 100644 irrigation/homeassistant/greenhousino/translations/en.json diff --git a/irrigation/README.md b/irrigation/README.md index 3dc6356..a94bf6a 100644 --- a/irrigation/README.md +++ b/irrigation/README.md @@ -1,15 +1,41 @@ # Irrigation with an ESP8266 Tested with D1 mini. Connect a pump with a relais. Relais activation on D4. -## Homeassistant integration -payload_on can also contain the duration of the pump in ms. -Example: -` -switch 5: -- platform: mqtt - command_topic: greenhousino/pump - state_topic: greenhousino/pumpstate - payload_on: "on" - payload_off: "off" - name: "Pumpe" -` \ No newline at end of file +## Home Assistant Integration + +### Custom Integration (recommended) + +A full custom integration with config flow, device registry, and a `water_for` service is included in the [`homeassistant/`](homeassistant/) directory. + +See [homeassistant/README.md](homeassistant/README.md) for installation and usage. + +### YAML-only (built-in MQTT platform) + +`payload_on` can also contain the duration of the pump in ms. + +```yaml +switch: + - platform: mqtt + name: "Irrigation Pump" + command_topic: greenhousino/pump + state_topic: greenhousino/pumpstate + payload_on: "on" + payload_off: "off" + state_on: "on" + state_off: "off" + availability_topic: greenhousino/pump/status + payload_available: "online" + payload_not_available: "offline" + qos: 1 +``` + +For duration-based watering via YAML, use an automation: +```yaml +automation: + - alias: "Water for 5 minutes" + action: + - service: mqtt.publish + data: + topic: greenhousino/pump + payload: "300000" # 300000 ms = 5 minutes +``` \ No newline at end of file diff --git a/irrigation/homeassistant/README.md b/irrigation/homeassistant/README.md new file mode 100644 index 0000000..d346e31 --- /dev/null +++ b/irrigation/homeassistant/README.md @@ -0,0 +1,124 @@ +# Greenhousino Irrigation — Home Assistant Integration + +Custom Home Assistant integration for the Greenhousino ESP8266/ESP32 irrigation controller. + +## Features + +- **Pump switch** — Turn the irrigation pump on/off from Home Assistant +- **State tracking** — Real-time pump state via MQTT +- **Availability** — Shows online/offline status +- **`water_for` service** — Water for a specific duration (1–3600 seconds) +- **Config flow** — Set up via Home Assistant UI (Settings → Devices & Services → Add Integration) +- **Device registry** — Appears as a proper device with manufacturer/model info + +## Installation + +### Option 1: HACS (recommended) + +1. Install [HACS](https://hacs.xyz/) in Home Assistant +2. Add this repository as a custom repository +3. Search for "Greenhousino Irrigation" and install +4. Restart Home Assistant + +### Option 2: Manual installation + +1. Copy the `greenhousino` folder to your Home Assistant config directory: + +```bash +cp -r greenhousino /config/custom_components/ +``` + +2. Restart Home Assistant + +3. Go to **Settings → Devices & Services → Add Integration** and search for "Greenhousino Irrigation" + +## Configuration + +The config flow will guide you through two steps: + +### Step 1: Host +- **Host IP address** — The IP of your ESP device (e.g., `192.168.178.50`). This is used for the web UI link in device info. + +### Step 2: MQTT Topics +- **Command topic** — MQTT topic to send commands (default: `greenhousino/pump`) +- **State topic** — MQTT topic for state updates (default: `greenhousino/pumpstate`) + +## Usage + +### Switch entity + +The integration creates a switch entity (`switch.greenhousino_irrigation_pump`) that you can: +- Toggle on/off from the Home Assistant UI +- Use in automations +- Add to dashboards + +### `water_for` service + +Water for a specific duration: + +```yaml +service: greenhousino.water_for +data: + duration: 60 # seconds +``` + +Or from an automation: +```yaml +automation: + - alias: "Water garden every morning" + trigger: + - platform: time + at: "06:00:00" + action: + - service: greenhousino.water_for + data: + duration: 300 # 5 minutes +``` + +## MQTT Topics + +| Direction | Topic | Payload | Description | +|-----------|-------|---------|-------------| +| HA → ESP | `greenhousino/pump` | `on` | Turn pump on (default 30s) | +| HA → ESP | `greenhousino/pump` | `off` | Turn pump off | +| HA → ESP | `greenhousino/pump` | `5000` | Turn pump on for 5000ms | +| ESP → HA | `greenhousino/pumpstate` | `on` | Pump is active | +| ESP → HA | `greenhousino/pumpstate` | `off` | Pump is off | +| ESP → HA | `greenhousino/pump/status` | `offline` | Device disconnected | + +## Requirements + +- Home Assistant 2024.x or later +- MQTT integration configured and connected to the same broker as your ESP device + +## Troubleshooting + +### Switch shows "unavailable" +- Make sure the MQTT integration is connected +- Verify the ESP device is publishing to the state topic +- Check that topics match between the ESP firmware and HA config + +### Service call doesn't work +- Ensure MQTT integration is set up +- Check HA logs: `Logger → greenhousino` +- Verify the ESP is subscribed to the command topic + +## YAML-only alternative (no custom component) + +If you don't want a custom integration, you can use the built-in MQTT platform: + +```yaml +switch: + - platform: mqtt + name: "Irrigation Pump" + command_topic: "greenhousino/pump" + state_topic: "greenhousino/pumpstate" + payload_on: "on" + payload_off: "off" + state_on: "on" + state_off: "off" + availability_topic: "greenhousino/pump/status" + payload_available: "online" + payload_not_available: "offline" + qos: 1 +``` diff --git a/irrigation/homeassistant/greenhousino/__init__.py b/irrigation/homeassistant/greenhousino/__init__.py new file mode 100644 index 0000000..591c7a3 --- /dev/null +++ b/irrigation/homeassistant/greenhousino/__init__.py @@ -0,0 +1,143 @@ +"""Greenhousino Irrigation integration.""" + +import logging + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import ( + CONF_COMMAND_TOPIC, + CONF_HOST, + CONF_STATE_TOPIC, + DEFAULT_COMMAND_TOPIC, + DEFAULT_STATE_TOPIC, + DOMAIN, + MANUFACTURER, + MODEL, + SERVICE_DATA_DURATION, + SERVICE_WATER_FOR, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["switch"] + +# Service schema +WATER_FOR_SCHEMA = vol.Schema( + { + vol.Required(SERVICE_DATA_DURATION): vol.All( + vol.Coerce(float), + vol.Range(min=1, max=3600), + ), + } +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Greenhousino from a config entry.""" + hass_data = entry.runtime_data = GreenhousinoData(entry) + + host = entry.data.get(CONF_HOST, "") + + # Register device in HA device registry + await _register_device(hass, entry, host) + + # Store entry data for service access + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {"entries": {}} + + hass.data[DOMAIN]["entries"][entry.entry_id] = hass_data + + # Register the water_for service (only once) + if "service_registered" not in hass.data[DOMAIN]: + hass.services.async_register( + DOMAIN, + SERVICE_WATER_FOR, + _make_water_for_handler(hass), + schema=WATER_FOR_SCHEMA, + ) + hass.data[DOMAIN]["service_registered"] = True + _LOGGER.info("Registered %s.%s service", DOMAIN, SERVICE_WATER_FOR) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN]["entries"].pop(entry.entry_id, None) + + if len(hass.data[DOMAIN]["entries"]) == 0: + hass.services.async_remove(DOMAIN, SERVICE_WATER_FOR) + hass.data[DOMAIN].clear() + + return unload_ok + + +def _make_water_for_handler(hass: HomeAssistant): + """Create the water_for service handler with hass in closure.""" + + async def async_water_for(call) -> None: + """Handle the water_for service call.""" + duration_seconds = call.data[SERVICE_DATA_DURATION] + duration_ms = int(duration_seconds * 1000) + + # Use first available command topic (or default) + entries = hass.data[DOMAIN].get("entries", {}) + command_topic = DEFAULT_COMMAND_TOPIC + for entry_id, data in entries.items(): + command_topic = data.command_topic + _LOGGER.info( + "Watering for %d seconds (%d ms) on topic %s (entry: %s)", + duration_seconds, + duration_ms, + command_topic, + entry_id, + ) + await hass.services.async_call( + "mqtt", + "publish", + { + "topic": command_topic, + "payload": str(duration_ms), + "qos": 1, + "retain": False, + }, + blocking=True, + ) + + if not entries: + _LOGGER.warning("No Greenhousino entries configured") + + return async_water_for + + +async def _register_device(hass: HomeAssistant, entry: ConfigEntry, host: str): + """Register the device in the device registry.""" + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer=MANUFACTURER, + model=MODEL, + name=entry.title, + configuration_url=f"http://{host}" if host else None, + ) + + +class GreenhousinoData: + """Container for integration data.""" + + def __init__(self, entry: ConfigEntry) -> None: + self.entry = entry + self.command_topic = entry.data.get( + CONF_COMMAND_TOPIC, DEFAULT_COMMAND_TOPIC + ) + self.state_topic = entry.data.get(CONF_STATE_TOPIC, DEFAULT_STATE_TOPIC) diff --git a/irrigation/homeassistant/greenhousino/config_flow.py b/irrigation/homeassistant/greenhousino/config_flow.py new file mode 100644 index 0000000..38f0ad3 --- /dev/null +++ b/irrigation/homeassistant/greenhousino/config_flow.py @@ -0,0 +1,88 @@ +"""Config flow for Greenhousino Irrigation integration.""" + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_COMMAND_TOPIC, + CONF_HOST, + CONF_STATE_TOPIC, + DEFAULT_COMMAND_TOPIC, + DEFAULT_NAME, + DEFAULT_STATE_TOPIC, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_ONE_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, description={"suggested_value": ""}): str, + } +) + +STEP_TWO_DATA_SCHEMA = vol.Schema( + { + vol.Required( + CONF_COMMAND_TOPIC, + description={"suggested_value": DEFAULT_COMMAND_TOPIC}, + ): str, + vol.Required( + CONF_STATE_TOPIC, + description={"suggested_value": DEFAULT_STATE_TOPIC}, + ): str, + } +) + + +class GreenhousinoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Greenhousino Irrigation.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step (host).""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + self._tmp_data = user_input + return await self.async_step_mqtt() + except Exception: + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", + data_schema=STEP_ONE_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_mqtt( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the MQTT topics step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + data = {**self._tmp_data, **user_input} + title = DEFAULT_NAME + if data.get(CONF_HOST): + title = f"{DEFAULT_NAME} ({data[CONF_HOST]})" + + return self.async_create_entry(title=title, data=data) + except Exception: + errors["base"] = "unknown" + + return self.async_show_form( + step_id="mqtt", + data_schema=STEP_TWO_DATA_SCHEMA, + errors=errors, + ) diff --git a/irrigation/homeassistant/greenhousino/const.py b/irrigation/homeassistant/greenhousino/const.py new file mode 100644 index 0000000..84917f8 --- /dev/null +++ b/irrigation/homeassistant/greenhousino/const.py @@ -0,0 +1,25 @@ +"""Constants for Greenhousino Irrigation integration.""" + +DOMAIN = "greenhousino" +MANUFACTURER = "Greenhousino" +MODEL = "Irrigation Controller" + +# Defaults +DEFAULT_NAME = "Greenhousino Irrigation" +DEFAULT_COMMAND_TOPIC = "greenhousino/pump" +DEFAULT_STATE_TOPIC = "greenhousino/pumpstate" +DEFAULT_PAYLOAD_ON = "on" +DEFAULT_PAYLOAD_OFF = "off" + +# Config flow keys +CONF_COMMAND_TOPIC = "command_topic" +CONF_STATE_TOPIC = "state_topic" +CONF_HOST = "host" + +# Service +SERVICE_WATER_FOR = "water_for" +SERVICE_DATA_DURATION = "duration" + +# State +ATTR_DURATION_MS = "duration_ms" +ATTR_PUMP_ACTIVE = "pump_active" diff --git a/irrigation/homeassistant/greenhousino/manifest.json b/irrigation/homeassistant/greenhousino/manifest.json new file mode 100644 index 0000000..83eeca6 --- /dev/null +++ b/irrigation/homeassistant/greenhousino/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "greenhousino", + "name": "Greenhousino Irrigation", + "codeowners": [], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://github.com/greenhousino/irrigation", + "integration_type": "device", + "iot_class": "local_push", + "issue_tracker": "https://github.com/greenhousino/irrigation/issues", + "loggers": ["greenhousino"], + "requirements": [], + "version": "1.0.0" +} diff --git a/irrigation/homeassistant/greenhousino/services.yaml b/irrigation/homeassistant/greenhousino/services.yaml new file mode 100644 index 0000000..de09afc --- /dev/null +++ b/irrigation/homeassistant/greenhousino/services.yaml @@ -0,0 +1,14 @@ +water_for: + name: Water for duration + description: Activate the irrigation pump for a specified number of seconds. + fields: + duration: + name: Duration + description: How long to water, in seconds (1-3600). + required: true + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: "seconds" + mode: box diff --git a/irrigation/homeassistant/greenhousino/strings.json b/irrigation/homeassistant/greenhousino/strings.json new file mode 100644 index 0000000..4f73c54 --- /dev/null +++ b/irrigation/homeassistant/greenhousino/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "title": "Greenhousino Irrigation", + "description": "Enter the IP address of your ESP irrigation controller.", + "data": { + "host": "Host IP address" + } + }, + "mqtt": { + "title": "MQTT Topics", + "description": "Configure the MQTT topics for your irrigation controller.", + "data": { + "command_topic": "Command topic", + "state_topic": "State topic" + } + } + }, + "error": { + "unknown": "An unknown error occurred." + }, + "abort": { + "already_configured": "Device is already configured." + } + }, + "services": { + "water_for": { + "name": "Water for duration", + "description": "Activate the irrigation pump for a specified number of seconds.", + "fields": { + "duration": { + "name": "Duration", + "description": "How long to water, in seconds." + } + } + } + } +} diff --git a/irrigation/homeassistant/greenhousino/switch.py b/irrigation/homeassistant/greenhousino/switch.py new file mode 100644 index 0000000..5d22ba4 --- /dev/null +++ b/irrigation/homeassistant/greenhousino/switch.py @@ -0,0 +1,151 @@ +"""Switch platform for Greenhousino Irrigation.""" + +import logging +from typing import Any + +from homeassistant.components import mqtt +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_PUMP_ACTIVE, + DOMAIN, + MANUFACTURER, + MODEL, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Greenhousino switch from config entry.""" + hass_data = entry.runtime_data + + async_add_entities( + [ + GreenhousinoPumpSwitch( + entry, + hass_data.command_topic, + hass_data.state_topic, + ) + ] + ) + + +class GreenhousinoPumpSwitch(SwitchEntity): + """Representation of a Greenhousino pump switch.""" + + _attr_has_entity_name = True + _attr_name = "Pump" + _attr_should_poll = False + _attr_unique_id = None # will be set from entry_id + _entry_id = None + + def __init__( + self, + entry: ConfigEntry, + command_topic: str, + state_topic: str, + ) -> None: + """Initialize the switch.""" + self._command_topic = command_topic + self._state_topic = state_topic + self._entry_id = entry.entry_id + self._attr_unique_id = f"{entry.entry_id}-pump" + self._attr_available = False + self._state = False + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": {(DOMAIN, self._entry_id)}, + "manufacturer": MANUFACTURER, + "model": MODEL, + } + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + entry = self.config_entry + + self.async_on_remove( + mqtt.async_subscribe( + self.hass, + self._state_topic, + self._state_received, + qos=1, + ) + ) + + # Publish availability via MQTT discovery-style + # The device publishes to greenhousino/pump/status with "offline" on disconnect + status_topic = self._state_topic.replace("pumpstate", "pump/status") + self.async_on_remove( + mqtt.async_subscribe( + self.hass, + status_topic, + self._availability_received, + qos=1, + ) + ) + + def _state_received(self, msg: mqtt.MqttReceivePayload) -> None: + """Handle new MQTT state message.""" + payload = msg.decode().strip() + self._state = payload == "on" + self._attr_available = True + self.async_write_ha_state() + _LOGGER.debug("State received: %s -> pump_active=%s", payload, self._state) + + def _availability_received(self, msg: mqtt.MqttReceivePayload) -> None: + """Handle availability message.""" + payload = msg.decode().strip() + self._attr_available = payload != "offline" + self.async_write_ha_state() + _LOGGER.debug("Availability: %s", payload) + + @property + def is_on(self) -> bool: + """Return True if the pump is on.""" + return self._state + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device state attributes.""" + return { + ATTR_PUMP_ACTIVE: self._state, + "command_topic": self._command_topic, + "state_topic": self._state_topic, + "manufacturer": MANUFACTURER, + "model": MODEL, + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the pump on.""" + await mqtt.async_publish( + self.hass, + self._command_topic, + "on", + qos=1, + retain=False, + ) + self._state = True + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Turn the pump off.""" + await mqtt.async_publish( + self.hass, + self._command_topic, + "off", + qos=1, + retain=False, + ) + self._state = False + self.async_write_ha_state() diff --git a/irrigation/homeassistant/greenhousino/translations/en.json b/irrigation/homeassistant/greenhousino/translations/en.json new file mode 100644 index 0000000..4f73c54 --- /dev/null +++ b/irrigation/homeassistant/greenhousino/translations/en.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "title": "Greenhousino Irrigation", + "description": "Enter the IP address of your ESP irrigation controller.", + "data": { + "host": "Host IP address" + } + }, + "mqtt": { + "title": "MQTT Topics", + "description": "Configure the MQTT topics for your irrigation controller.", + "data": { + "command_topic": "Command topic", + "state_topic": "State topic" + } + } + }, + "error": { + "unknown": "An unknown error occurred." + }, + "abort": { + "already_configured": "Device is already configured." + } + }, + "services": { + "water_for": { + "name": "Water for duration", + "description": "Activate the irrigation pump for a specified number of seconds.", + "fields": { + "duration": { + "name": "Duration", + "description": "How long to water, in seconds." + } + } + } + } +} diff --git a/irrigation/src/main.cpp b/irrigation/src/main.cpp index 1f323c7..c41bcca 100644 --- a/irrigation/src/main.cpp +++ b/irrigation/src/main.cpp @@ -126,7 +126,8 @@ void reconnect() { Serial.println("INFO: Attempting MQTT connection..."); // Attempt to connect - if (mqttClient.connect(MQTT_CLIENT_ID, MQTT_CLIENT_USER, MQTT_CLIENT_PW)) + if (mqttClient.connect(MQTT_CLIENT_ID, MQTT_CLIENT_USER, MQTT_CLIENT_PW, + MQTT_LAST_WILL_TOPIC, 1, true, MQTT_LAST_WILL_MSG)) { Serial.println("INFO: connected"); @@ -136,6 +137,9 @@ void reconnect() mqttClient.publish(MQTT_STATE_TOPIC, MQTT_OFF); } + // Signal online to Home Assistant availability tracker + mqttClient.publish(MQTT_LAST_WILL_TOPIC, "online"); + mqttClient.subscribe(MQTT_TF_TOPIC); } else {