From b1ee2610ca592e5e41f1f0ad573302f7bcd8c55f Mon Sep 17 00:00:00 2001 From: ske087 Date: Mon, 23 Feb 2026 21:10:38 +0200 Subject: [PATCH] Initial commit: Olimex ESP32-C6-EVB HA integration + Arduino sketch --- .gitignore | 13 + README.md | 29 + custom_components/olimex_esp32_c6/README.md | 107 +++ custom_components/olimex_esp32_c6/SETUP.md | 203 ++++++ custom_components/olimex_esp32_c6/__init__.py | 117 ++++ .../olimex_esp32_c6/binary_sensor.py | 90 +++ .../olimex_esp32_c6/config_flow.py | 112 ++++ custom_components/olimex_esp32_c6/const.py | 28 + .../olimex_esp32_c6/manifest.json | 11 + custom_components/olimex_esp32_c6/olimex.png | Bin 0 -> 55690 bytes custom_components/olimex_esp32_c6/sensor.py | 122 ++++ .../olimex_esp32_c6/sensor_updater.py | 85 +++ .../olimex_esp32_c6/strings.json | 49 ++ custom_components/olimex_esp32_c6/switch.py | 117 ++++ custom_components/olimex_esp32_c6/webhook.py | 29 + esp32_arduino/DEPLOYMENT_GUIDE.md | 270 ++++++++ esp32_arduino/README.md | 166 +++++ esp32_arduino/esp32_arduino.ino | 630 ++++++++++++++++++ 18 files changed, 2178 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 custom_components/olimex_esp32_c6/README.md create mode 100644 custom_components/olimex_esp32_c6/SETUP.md create mode 100644 custom_components/olimex_esp32_c6/__init__.py create mode 100644 custom_components/olimex_esp32_c6/binary_sensor.py create mode 100644 custom_components/olimex_esp32_c6/config_flow.py create mode 100644 custom_components/olimex_esp32_c6/const.py create mode 100644 custom_components/olimex_esp32_c6/manifest.json create mode 100644 custom_components/olimex_esp32_c6/olimex.png create mode 100644 custom_components/olimex_esp32_c6/sensor.py create mode 100644 custom_components/olimex_esp32_c6/sensor_updater.py create mode 100644 custom_components/olimex_esp32_c6/strings.json create mode 100644 custom_components/olimex_esp32_c6/switch.py create mode 100644 custom_components/olimex_esp32_c6/webhook.py create mode 100644 esp32_arduino/DEPLOYMENT_GUIDE.md create mode 100644 esp32_arduino/README.md create mode 100644 esp32_arduino/esp32_arduino.ino diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc51a07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +# Home Assistant generated +icon.png + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad0569d --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Olimex ESP32-C6-EVB — Home Assistant Integration + +This repository contains two components: + +## `custom_components/olimex_esp32_c6` +Home Assistant custom integration for the **Olimex ESP32-C6-EVB** board. + +- 4 relay switches (controlled via HTTP POST to the board) +- 4 digital inputs (state pushed from board to HA via webhook) +- No polling — fully event-driven for inputs, command-driven for relays + +### Installation +Copy `custom_components/olimex_esp32_c6` into your Home Assistant `config/custom_components/` directory and restart HA. + +## `esp32_arduino` +Arduino sketch for the ESP32-C6-EVB board. + +- Hosts a REST API on port 80 +- Registers a callback URL with HA on startup +- POSTs input state changes to HA webhook in real time + +### Arduino IDE Settings +| Setting | Value | +|---|---| +| Board | ESP32C6 Dev Module | +| Flash Size | 4MB | +| USB CDC On Boot | Enabled | + +See [`esp32_arduino/DEPLOYMENT_GUIDE.md`](esp32_arduino/DEPLOYMENT_GUIDE.md) for full flashing instructions. diff --git a/custom_components/olimex_esp32_c6/README.md b/custom_components/olimex_esp32_c6/README.md new file mode 100644 index 0000000..76f7579 --- /dev/null +++ b/custom_components/olimex_esp32_c6/README.md @@ -0,0 +1,107 @@ +# Olimex ESP32-C6-EVB Home Assistant Integration + +This is a custom integration for the **Olimex ESP32-C6-EVB** board featuring the **Espressif ESP32-C6 WROOM-1** chip. + +## Board Specifications + +- **Manufacturer**: Olimex +- **Model**: ESP32-C6-EVB +- **Chip**: ESP32-C6 WROOM-1 +- **Features**: + - Wi-Fi 6 (802.11ax) + - Bluetooth 5.3 (LE) + - RISC-V 32-bit single-core processor + - Multiple I/O pins + - Relays and GPIO control + +## Installation + +1. Copy the `olimex_esp32_c6` folder to your `custom_components` directory +2. Restart Home Assistant +3. Add the integration through the UI: Configuration → Integrations → Add Integration → "Olimex ESP32-C6-EVB" + +## Configuration + +Enter the IP address and port (default 80) of your ESP32-C6-EVB board. + +## ESP32 Firmware Development + +### Required API Endpoints + +Your ESP32 firmware should implement these HTTP endpoints: + +#### Status Endpoint +``` +GET http://:/api/status +Response: { + "temperature": 25.5, + "wifi_rssi": -45 +} +``` + +#### Relay Control +``` +POST http://:/api/relay//on +POST http://:/api/relay//off +GET http://:/api/relay//status +Response: {"state": true} +``` + +#### LED Control +``` +POST http://:/api/led//on +POST http://:/api/led//off +``` + +### Development Tools + +- **ESP-IDF**: Espressif's official IoT Development Framework +- **Arduino IDE**: With ESP32 board support +- **PlatformIO**: Advanced IDE for embedded development + +### Example Arduino Sketch Structure + +```cpp +#include +#include + +WebServer server(80); + +void handleStatus() { + String json = "{\"temperature\": 25.5, \"wifi_rssi\": " + String(WiFi.RSSI()) + "}"; + server.send(200, "application/json", json); +} + +void setup() { + WiFi.begin("SSID", "PASSWORD"); + server.on("/api/status", handleStatus); + server.begin(); +} + +void loop() { + server.handleClient(); +} +``` + +## Features + +- Temperature monitoring +- WiFi signal strength +- Relay control +- LED control +- Extensible for GPIO, ADC, and other peripherals + +## TODO + +- [ ] Implement actual ESP32 firmware with REST API +- [ ] Add support for more sensors +- [ ] Add button entities for GPIO inputs +- [ ] Implement OTA updates +- [ ] Add MQTT support as alternative to HTTP +- [ ] Add ESPHome configuration option + +## Resources + +- [Olimex ESP32-C6-EVB Documentation](https://www.olimex.com/Products/IoT/ESP32-C6/ESP32-C6-EVB/) +- [ESP32-C6 Technical Reference](https://www.espressif.com/en/products/socs/esp32-c6) +- [Home Assistant Custom Integration Documentation](https://developers.home-assistant.io/) diff --git a/custom_components/olimex_esp32_c6/SETUP.md b/custom_components/olimex_esp32_c6/SETUP.md new file mode 100644 index 0000000..77d4a45 --- /dev/null +++ b/custom_components/olimex_esp32_c6/SETUP.md @@ -0,0 +1,203 @@ +# Olimex ESP32-C6-EVB Home Assistant Integration Setup Guide + +## Overview + +This Home Assistant integration automatically manages the Olimex ESP32-C6-EVB board configuration through the UI. + +## Features + +- **Binary Sensors** - Monitor 4 digital inputs (DIN1-DIN4) +- **Template Switches** - Control 4 relays (REL1-REL4) +- **Auto-discovery** - Configurable via config flow UI (no YAML required) +- **Connection validation** - Verifies board is online before saving config + +## Installation + +### 1. Deploy Arduino Firmware + +First, ensure your board is running the updated Arduino firmware with all 4 relays and inputs configured: + +- Deploy: `/srv/homeassist/esp32_arduino/esp32_arduino.ino` +- Board: Olimex ESP32-C6-EVB +- Firmware includes: + - Static IP: `192.168.0.181` + - 4 Relays (GPIO 10, 11, 22, 23) + - 4 Inputs (GPIO 1, 2, 3, 15) with pull-ups + +### 2. Setup Integration via Home Assistant UI + +1. Go to **Settings** → **Devices & Services** +2. Click **Create Integration** (+ button) +3. Search for **"Olimex"** +4. Click **Olimex ESP32-C6-EVB** +5. Enter configuration: + - **Device IP Address**: `192.168.0.181` (default) + - **Port**: `80` (default) + - **Scan Interval**: `5` seconds (default) +6. Click **Submit** + +The integration will: +- ✅ Verify connection to the board +- ✅ Create 4 binary sensors for inputs +- ✅ Set up the data coordinator for relay polling + +### 3. Create Template Switches (Manual for now) + +For now, you still need to add this to your `configuration.yaml`: + +```yaml +template: + - switch: + - name: "Relay 1" + unique_id: "olimex_relay_1" + state: "{{ state_attr('sensor.relay_1_status', 'state') | default('off') }}" + turn_on: + service: rest_command.relay_1_on + turn_off: + service: rest_command.relay_1_off + + - name: "Relay 2" + unique_id: "olimex_relay_2" + state: "{{ state_attr('sensor.relay_2_status', 'state') | default('off') }}" + turn_on: + service: rest_command.relay_2_on + turn_off: + service: rest_command.relay_2_off + + - name: "Relay 3" + unique_id: "olimex_relay_3" + state: "{{ state_attr('sensor.relay_3_status', 'state') | default('off') }}" + turn_on: + service: rest_command.relay_3_on + turn_off: + service: rest_command.relay_3_off + + - name: "Relay 4" + unique_id: "olimex_relay_4" + state: "{{ state_attr('sensor.relay_4_status', 'state') | default('off') }}" + turn_on: + service: rest_command.relay_4_on + turn_off: + service: rest_command.relay_4_off + +sensor: + - platform: rest + name: "Relay 1 Status" + unique_id: "sensor_relay_1_status" + resource: "http://192.168.0.181/relay/status?relay=1" + value_template: "{{ value_json.state }}" + scan_interval: 5 + + - platform: rest + name: "Relay 2 Status" + unique_id: "sensor_relay_2_status" + resource: "http://192.168.0.181/relay/status?relay=2" + value_template: "{{ value_json.state }}" + scan_interval: 5 + + - platform: rest + name: "Relay 3 Status" + unique_id: "sensor_relay_3_status" + resource: "http://192.168.0.181/relay/status?relay=3" + value_template: "{{ value_json.state }}" + scan_interval: 5 + + - platform: rest + name: "Relay 4 Status" + unique_id: "sensor_relay_4_status" + resource: "http://192.168.0.181/relay/status?relay=4" + value_template: "{{ value_json.state }}" + scan_interval: 5 + +rest_command: + relay_1_on: + url: "http://192.168.0.181/relay/on?relay=1" + method: POST + relay_1_off: + url: "http://192.168.0.181/relay/off?relay=1" + method: POST + relay_2_on: + url: "http://192.168.0.181/relay/on?relay=2" + method: POST + relay_2_off: + url: "http://192.168.0.181/relay/off?relay=2" + method: POST + relay_3_on: + url: "http://192.168.0.181/relay/on?relay=3" + method: POST + relay_3_off: + url: "http://192.168.0.181/relay/off?relay=3" + method: POST + relay_4_on: + url: "http://192.168.0.181/relay/on?relay=4" + method: POST + relay_4_off: + url: "http://192.168.0.181/relay/off?relay=4" + method: POST +``` + +Then restart Home Assistant. + +## API Endpoints + +The board provides these REST API endpoints: + +### Relay Control +- `POST /relay/on?relay=1-4` - Turn relay on +- `POST /relay/off?relay=1-4` - Turn relay off +- `GET /relay/status?relay=1-4` - Get relay state (returns `{"state": true/false}`) + +### Input Reading +- `GET /input/status?input=1-4` - Read input state (returns `{"state": true/false}`) + +### Device Status +- `GET /api/status` - Get overall device status + +## Entities Created + +### Binary Sensors (Inputs) +- `binary_sensor.input_1` - Digital Input 1 (GPIO 1) +- `binary_sensor.input_2` - Digital Input 2 (GPIO 2) +- `binary_sensor.input_3` - Digital Input 3 (GPIO 3) +- `binary_sensor.input_4` - Digital Input 4 (GPIO 15) + +### Switches (Relays) - via template +- `switch.relay_1` - Relay 1 (GPIO 10) +- `switch.relay_2` - Relay 2 (GPIO 11) +- `switch.relay_3` - Relay 3 (GPIO 22) +- `switch.relay_4` - Relay 4 (GPIO 23) + +### Sensors (Status) +- `sensor.relay_1_status` - Relay 1 state polling +- `sensor.relay_2_status` - Relay 2 state polling +- `sensor.relay_3_status` - Relay 3 state polling +- `sensor.relay_4_status` - Relay 4 state polling + +## Troubleshooting + +### Integration won't add +- Check board IP is correct (default: `192.168.0.181`) +- Verify board is online and connected to WiFi +- Check Home Assistant logs: `Settings > System > Logs` + +### Inputs not showing +- Verify Arduino firmware is deployed +- Check GPIO pin configuration matches board +- Inputs show as binary_sensor entities after integration setup + +### Relays not working +- Ensure relay REST sensors are created in configuration.yaml +- Verify REST commands point to correct IP/port +- Check board serial output for API activity + +### Slow response time +- Increase scan interval (default is 5 seconds) +- Can be changed in integration options + +## Future Enhancements + +- [ ] Auto-create relay switches without YAML +- [ ] Add sensor polling via coordinator +- [ ] Support multiple boards +- [ ] Device discovery (mDNS when available) +- [ ] Web UI for board settings diff --git a/custom_components/olimex_esp32_c6/__init__.py b/custom_components/olimex_esp32_c6/__init__.py new file mode 100644 index 0000000..cebb62d --- /dev/null +++ b/custom_components/olimex_esp32_c6/__init__.py @@ -0,0 +1,117 @@ +"""Olimex ESP32-C6-EVB Integration for Home Assistant.""" +import logging +import aiohttp +from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.components.webhook import ( + async_register as webhook_register, + async_unregister as webhook_unregister, +) + +from .const import DOMAIN, CONF_CALLBACK_IP, DEFAULT_CALLBACK_IP +from .webhook import handle_input_event + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the Olimex ESP32-C6-EVB component.""" + hass.data.setdefault(DOMAIN, {}) + + # Handle YAML configuration + if DOMAIN in config: + for device_config in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=device_config, + ) + ) + + _LOGGER.debug("Olimex ESP32-C6-EVB integration initialized") + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Olimex ESP32-C6-EVB from a config entry.""" + _LOGGER.info("Setting up Olimex ESP32-C6-EVB entry: %s", entry.entry_id) + + host = entry.data.get(CONF_HOST, "192.168.0.181") + port = entry.data.get(CONF_PORT, 80) + callback_ip = entry.data.get(CONF_CALLBACK_IP, DEFAULT_CALLBACK_IP) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + "host": host, + "port": port, + } + + # Tell the board where to POST input events + try: + callback_url = f"http://{callback_ip}:8123/api/webhook/{entry.entry_id}" + register_url = f"http://{host}:{port}/register?callback_url={callback_url}" + _LOGGER.info("Registering webhook with board at %s:%d (callback %s)", host, port, callback_url) + async with aiohttp.ClientSession() as session: + async with session.post(register_url, timeout=aiohttp.ClientTimeout(total=5)) as response: + if response.status == 200: + _LOGGER.info("Board webhook registered successfully") + else: + _LOGGER.warning("Board registration returned status %d", response.status) + except Exception as err: + _LOGGER.warning("Failed to register webhook with board: %s", err) + + # Register HA webhook handler to receive input events from the board + # Unregister first in case a previous failed setup left it registered + try: + webhook_unregister(hass, entry.entry_id) + except Exception: + pass + webhook_register( + hass, + DOMAIN, + "Olimex Input Event", + entry.entry_id, + handle_input_event, + ) + _LOGGER.info("HA webhook handler registered for entry %s", entry.entry_id) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _LOGGER.info("Olimex ESP32-C6-EVB configured for %s:%d", host, port) + return True + + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Clean up when integration is fully removed (called after unload).""" + _LOGGER.info("Removing Olimex ESP32-C6-EVB entry: %s", entry.entry_id) + # Remove any leftover domain data bucket if it's now empty + if DOMAIN in hass.data and not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + try: + _LOGGER.info("Unloading Olimex ESP32-C6-EVB entry: %s", entry.entry_id) + + # Unregister webhook handler + webhook_unregister(hass, entry.entry_id) + + # Unload all platforms + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + # Clean up data + if entry.entry_id in hass.data.get(DOMAIN, {}): + hass.data[DOMAIN].pop(entry.entry_id) + _LOGGER.debug("Cleaned up data for entry %s", entry.entry_id) + + _LOGGER.info("Successfully unloaded Olimex ESP32-C6-EVB entry: %s", entry.entry_id) + return unload_ok + except Exception as err: + _LOGGER.error("Error unloading Olimex ESP32-C6-EVB entry: %s", err, exc_info=True) + return False diff --git a/custom_components/olimex_esp32_c6/binary_sensor.py b/custom_components/olimex_esp32_c6/binary_sensor.py new file mode 100644 index 0000000..7f6e077 --- /dev/null +++ b/custom_components/olimex_esp32_c6/binary_sensor.py @@ -0,0 +1,90 @@ +"""Binary sensor platform for Olimex ESP32-C6-EVB.""" +import logging +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DOMAIN, NUM_INPUTS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities for inputs.""" + data = hass.data[DOMAIN][entry.entry_id] + + sensors = [ + OlimexInputSensor(hass, entry, input_num) + for input_num in range(1, NUM_INPUTS + 1) + ] + async_add_entities(sensors, update_before_add=False) + + +class OlimexInputSensor(BinarySensorEntity): + """Binary sensor for Olimex input pin. + + State is driven exclusively by webhook POSTs from the board. + No polling is performed. + """ + + _attr_should_poll = False + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, input_num: int): + """Initialize the sensor.""" + self.hass = hass + self._entry = entry + self._input_num = input_num + self._attr_name = f"Input {input_num}" + self._attr_unique_id = f"{entry.entry_id}_input_{input_num}" + self._state = False + + async def async_added_to_hass(self): + """Subscribe to webhook dispatcher when entity is added.""" + signal = f"{DOMAIN}_input_{self._input_num}_event" + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._handle_webhook_event) + ) + await super().async_added_to_hass() + + @callback + def _handle_webhook_event(self, state): + """Handle real-time input event received via webhook from the board.""" + # Board already inverts pull-up logic before sending: + # state=True means pressed, state=False means released + self._state = state + _LOGGER.debug( + "Input %d webhook event: state=%s (sensor is_on=%s)", + self._input_num, state, self._state + ) + self.async_write_ha_state() + + @property + def is_on(self): + """Return True if input is pressed.""" + return self._state + + @property + def device_info(self): + """Return device information.""" + try: + host = self._entry.data.get('host', 'unknown') + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": f"Olimex ESP32-C6 ({host})", + "manufacturer": "Olimex", + "model": "ESP32-C6-EVB", + } + except Exception as err: + _LOGGER.debug("Error getting device info: %s", err) + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": "Olimex ESP32-C6", + "manufacturer": "Olimex", + "model": "ESP32-C6-EVB", + } diff --git a/custom_components/olimex_esp32_c6/config_flow.py b/custom_components/olimex_esp32_c6/config_flow.py new file mode 100644 index 0000000..dc0eafb --- /dev/null +++ b/custom_components/olimex_esp32_c6/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for Olimex ESP32-C6-EVB integration.""" +import logging +import voluptuous as vol +import aiohttp + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, DEFAULT_PORT, CONF_CALLBACK_IP, DEFAULT_CALLBACK_IP + +_LOGGER = logging.getLogger(__name__) + + +class OlimexESP32C6ConfigFlow(config_entries.ConfigFlow, domain="olimex_esp32_c6"): + """Handle a config flow for Olimex ESP32-C6-EVB.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + host = user_input.get(CONF_HOST, "192.168.0.181") + port = user_input.get(CONF_PORT, DEFAULT_PORT) + + # Validate connection to the board + try: + async with aiohttp.ClientSession() as session: + url = f"http://{host}:{port}/api/status" + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status != 200: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(f"{host}:{port}") + self._abort_if_unique_id_configured() + + _LOGGER.info("Successfully connected to Olimex ESP32-C6 at %s", host) + return self.async_create_entry( + title=f"Olimex ESP32-C6 ({host})", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_CALLBACK_IP: user_input.get( + CONF_CALLBACK_IP, DEFAULT_CALLBACK_IP + ), + }, + ) + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + _LOGGER.error("Failed to connect to Olimex ESP32-C6 at %s", host) + except Exception as err: + errors["base"] = "unknown" + _LOGGER.error("Error in config flow: %s", err) + + data_schema = vol.Schema({ + vol.Required(CONF_HOST, default="192.168.0.181"): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_CALLBACK_IP, default=DEFAULT_CALLBACK_IP): str, + }) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + async def async_step_import(self, import_data): + """Handle import from YAML configuration.""" + _LOGGER.debug("Importing Olimex ESP32-C6 from YAML: %s", import_data) + host = import_data.get(CONF_HOST, "192.168.0.181") + port = import_data.get(CONF_PORT, DEFAULT_PORT) + + # Check if already configured + await self.async_set_unique_id(f"{host}:{port}") + self._abort_if_unique_id_configured() + + # Validate connection + try: + async with aiohttp.ClientSession() as session: + url = f"http://{host}:{port}/api/status" + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status == 200: + _LOGGER.info("Successfully imported Olimex ESP32-C6 from YAML at %s", host) + return self.async_create_entry( + title=f"Olimex ESP32-C6 ({host})", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_CALLBACK_IP: import_data.get(CONF_CALLBACK_IP, DEFAULT_CALLBACK_IP), + }, + ) + except Exception as err: + _LOGGER.error("Failed to import Olimex ESP32-C6 from YAML: %s", err) + + # If validation fails, still create entry but log warning + _LOGGER.warning("Could not validate Olimex ESP32-C6 at %s, creating entry anyway", host) + return self.async_create_entry( + title=f"Olimex ESP32-C6 ({host})", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_CALLBACK_IP: import_data.get(CONF_CALLBACK_IP, DEFAULT_CALLBACK_IP), + }, + ) diff --git a/custom_components/olimex_esp32_c6/const.py b/custom_components/olimex_esp32_c6/const.py new file mode 100644 index 0000000..84066fc --- /dev/null +++ b/custom_components/olimex_esp32_c6/const.py @@ -0,0 +1,28 @@ +"""Constants for the Olimex ESP32-C6-EVB integration.""" + +DOMAIN = "olimex_esp32_c6" +MANUFACTURER = "Olimex" +MODEL = "ESP32-C6-EVB" +CHIP = "ESP32-C6 WROOM-1" + +# Configuration +CONF_HOST = "host" +CONF_PORT = "port" +CONF_SCAN_INTERVAL = "scan_interval" +CONF_CALLBACK_IP = "callback_ip" + +# Default values +DEFAULT_PORT = 80 +DEFAULT_SCAN_INTERVAL = 5 +DEFAULT_CALLBACK_IP = "192.168.0.1" + +# Relay and Input info +NUM_RELAYS = 4 +NUM_INPUTS = 4 + +# Device info +DEVICE_INFO = { + "manufacturer": MANUFACTURER, + "model": MODEL, + "chip": CHIP, +} diff --git a/custom_components/olimex_esp32_c6/manifest.json b/custom_components/olimex_esp32_c6/manifest.json new file mode 100644 index 0000000..8abc83f --- /dev/null +++ b/custom_components/olimex_esp32_c6/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "olimex_esp32_c6", + "name": "Olimex ESP32-C6-EVB", + "codeowners": [], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/yourusername/olimex-esp32-c6-ha", + "iot_class": "local_polling", + "requirements": ["aiohttp>=3.8.0"], + "version": "0.1.0" +} diff --git a/custom_components/olimex_esp32_c6/olimex.png b/custom_components/olimex_esp32_c6/olimex.png new file mode 100644 index 0000000000000000000000000000000000000000..5b4e72aeb35cffeecfb8b7413d20db9888e401b4 GIT binary patch literal 55690 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 None: + """Set up Olimex ESP32-C6-EVB sensors.""" + host = entry.data["host"] + port = entry.data.get("port", 80) + + coordinator = OlimexDataUpdateCoordinator(hass, host, port) + await coordinator.async_config_entry_first_refresh() + + sensors = [ + OlimexTemperatureSensor(coordinator, entry), + OlimexWiFiSignalSensor(coordinator, entry), + ] + + async_add_entities(sensors) + +class OlimexDataUpdateCoordinator(DataUpdateCoordinator): + """Data coordinator for Olimex ESP32-C6-EVB.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self.host = host + self.port = port + + async def _async_update_data(self): + """Fetch data from ESP32-C6.""" + try: + async with aiohttp.ClientSession() as session: + # TODO: Update this URL based on your ESP32 firmware API + async with session.get( + f"http://{self.host}:{self.port}/api/status", + timeout=aiohttp.ClientTimeout(total=10), + ) as response: + if response.status == 200: + return await response.json() + raise UpdateFailed(f"Error fetching data: {response.status}") + except aiohttp.ClientError as err: + raise UpdateFailed(f"Error communicating with device: {err}") + +class OlimexTemperatureSensor(CoordinatorEntity, SensorEntity): + """Temperature sensor for ESP32-C6.""" + + def __init__(self, coordinator, entry): + """Initialize the sensor.""" + super().__init__(coordinator) + self._entry = entry + self._attr_name = "Temperature" + self._attr_unique_id = f"{entry.entry_id}_temperature" + self._attr_native_unit_of_measurement = "°C" + self._attr_device_class = "temperature" + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": f"Olimex ESP32-C6 ({self._entry.data['host']})", + **DEVICE_INFO, + } + + @property + def native_value(self): + """Return the temperature value.""" + if self.coordinator.data: + return self.coordinator.data.get("temperature") + return None + +class OlimexWiFiSignalSensor(CoordinatorEntity, SensorEntity): + """WiFi signal sensor for ESP32-C6.""" + + def __init__(self, coordinator, entry): + """Initialize the sensor.""" + super().__init__(coordinator) + self._entry = entry + self._attr_name = "WiFi Signal" + self._attr_unique_id = f"{entry.entry_id}_wifi_signal" + self._attr_native_unit_of_measurement = "dBm" + self._attr_device_class = "signal_strength" + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": f"Olimex ESP32-C6 ({self._entry.data['host']})", + **DEVICE_INFO, + } + + @property + def native_value(self): + """Return the WiFi signal strength.""" + if self.coordinator.data: + return self.coordinator.data.get("wifi_rssi") + return None diff --git a/custom_components/olimex_esp32_c6/sensor_updater.py b/custom_components/olimex_esp32_c6/sensor_updater.py new file mode 100644 index 0000000..4dfb947 --- /dev/null +++ b/custom_components/olimex_esp32_c6/sensor_updater.py @@ -0,0 +1,85 @@ +"""Relay status sensor updater for Olimex ESP32-C6-EVB.""" +import asyncio +import logging +from datetime import timedelta + +import aiohttp +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.core import HomeAssistant +from homeassistant.components.sensor import SensorEntity + +from .const import DOMAIN, DEFAULT_SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class OlimexDataUpdateCoordinator(DataUpdateCoordinator): + """Coordinator to fetch relay statuses from the board.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int, scan_interval: int) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=scan_interval), + ) + self.host = host + self.port = port + + async def _fetch_single_relay(self, session: aiohttp.ClientSession, relay_num: int): + """Fetch one relay's status, returning (relay_num, state).""" + try: + url = f"http://{self.host}:{self.port}/relay/status?relay={relay_num}" + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as response: + if response.status == 200: + data = await response.json() + return relay_num, data.get("state", False) + return relay_num, False + except Exception as err: + _LOGGER.debug("Error fetching relay %d status: %s", relay_num, err) + return relay_num, False + + async def _async_update_data(self): + """Fetch all relay statuses from the device in parallel.""" + try: + async with aiohttp.ClientSession() as session: + results = await asyncio.gather( + *[self._fetch_single_relay(session, n) for n in range(1, 5)] + ) + return {f"relay_{num}": state for num, state in results} + except Exception as err: + raise UpdateFailed(f"Error communicating with device: {err}") + + +class RelayStatusSensor(CoordinatorEntity, SensorEntity): + """Sensor for relay status.""" + + def __init__(self, coordinator, entry, relay_num): + """Initialize the sensor.""" + super().__init__(coordinator) + self._entry = entry + self._relay_num = relay_num + self._attr_name = f"Relay {relay_num} Status" + self._attr_unique_id = f"{entry.entry_id}_relay_{relay_num}_status" + + @property + def native_value(self): + """Return the relay status.""" + if self.coordinator.data: + return self.coordinator.data.get(f"relay_{self._relay_num}", False) + return False + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": f"Olimex ESP32-C6 ({self._entry.data['host']})", + "manufacturer": "Olimex", + "model": "ESP32-C6-EVB", + } diff --git a/custom_components/olimex_esp32_c6/strings.json b/custom_components/olimex_esp32_c6/strings.json new file mode 100644 index 0000000..a1a79e1 --- /dev/null +++ b/custom_components/olimex_esp32_c6/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure Olimex ESP32-C6-EVB", + "description": "Enter the network details for your Olimex ESP32-C6-EVB board", + "data": { + "host": "Device IP Address or Hostname", + "port": "Port", + "callback_ip": "Home Assistant IP (for board → HA webhook)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the device. Please check the IP address and ensure the board is online.", + "invalid_auth": "Authentication failed", + "unknown": "An unexpected error occurred" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Olimex ESP32-C6-EVB Options", + "data": { + "callback_ip": "Home Assistant IP (for board → HA webhook)" + } + } + } + }, + "entity": { + "binary_sensor": { + "input_1": { + "name": "Input 1" + }, + "input_2": { + "name": "Input 2" + }, + "input_3": { + "name": "Input 3" + }, + "input_4": { + "name": "Input 4" + } + } + } +} diff --git a/custom_components/olimex_esp32_c6/switch.py b/custom_components/olimex_esp32_c6/switch.py new file mode 100644 index 0000000..2419d20 --- /dev/null +++ b/custom_components/olimex_esp32_c6/switch.py @@ -0,0 +1,117 @@ +"""Switch platform for Olimex ESP32-C6-EVB.""" +import logging +import aiohttp +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 DOMAIN, NUM_RELAYS + +_LOGGER = logging.getLogger(__name__) + +# Tight timeout for a local LAN device +_TIMEOUT = aiohttp.ClientTimeout(total=3) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch entities for relays.""" + data = hass.data[DOMAIN][entry.entry_id] + host = data["host"] + port = data["port"] + + switches = [ + OlimexRelaySwitch(entry, host, port, relay_num) + for relay_num in range(1, NUM_RELAYS + 1) + ] + async_add_entities(switches, update_before_add=False) + + +class OlimexRelaySwitch(SwitchEntity): + """Switch for Olimex relay. + + State is set on load via a single GET and on every toggle via the + state value returned directly in the POST response — no extra round-trip. + A single persistent aiohttp session is reused for all requests. + """ + + _attr_should_poll = False + + def __init__(self, entry: ConfigEntry, host: str, port: int, relay_num: int): + """Initialize the switch.""" + self._entry = entry + self._host = host + self._port = port + self._relay_num = relay_num + self._attr_name = f"Relay {relay_num}" + self._attr_unique_id = f"{entry.entry_id}_relay_{relay_num}" + self._is_on = False + self._session: aiohttp.ClientSession | None = None + + async def async_added_to_hass(self): + """Open a persistent HTTP session and fetch initial relay state.""" + self._session = aiohttp.ClientSession() + url = f"http://{self._host}:{self._port}/relay/status?relay={self._relay_num}" + try: + async with self._session.get(url, timeout=_TIMEOUT) as resp: + if resp.status == 200: + data = await resp.json() + self._is_on = data.get("state", False) + except Exception as err: + _LOGGER.debug("Relay %d initial fetch failed: %s", self._relay_num, err) + self.async_write_ha_state() + + async def async_will_remove_from_hass(self): + """Close the HTTP session when entity is removed.""" + if self._session: + await self._session.close() + self._session = None + + # ------------------------------------------------------------------ + # SwitchEntity interface + # ------------------------------------------------------------------ + + async def async_turn_on(self, **kwargs): + """Turn the relay on.""" + await self._async_set_relay(True) + + async def async_turn_off(self, **kwargs): + """Turn the relay off.""" + await self._async_set_relay(False) + + async def _async_set_relay(self, on: bool): + """POST on/off to the board; read state from the response body directly.""" + action = "on" if on else "off" + url = f"http://{self._host}:{self._port}/relay/{action}?relay={self._relay_num}" + try: + async with self._session.post(url, timeout=_TIMEOUT) as resp: + if resp.status == 200: + data = await resp.json() + # Board returns {"status":"ok","state":true/false} — use it directly + self._is_on = data.get("state", on) + _LOGGER.debug("Relay %d -> %s (board confirmed: %s)", self._relay_num, action, self._is_on) + else: + _LOGGER.error("Relay %d %s failed: HTTP %d", self._relay_num, action, resp.status) + except Exception as err: + _LOGGER.error("Relay %d %s error: %s", self._relay_num, action, err) + self.async_write_ha_state() + + @property + def is_on(self): + """Return True if relay is on.""" + return self._is_on + + @property + def device_info(self): + """Return device information.""" + host = self._entry.data.get("host", "unknown") + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": f"Olimex ESP32-C6 ({host})", + "manufacturer": "Olimex", + "model": "ESP32-C6-EVB", + } diff --git a/custom_components/olimex_esp32_c6/webhook.py b/custom_components/olimex_esp32_c6/webhook.py new file mode 100644 index 0000000..247b338 --- /dev/null +++ b/custom_components/olimex_esp32_c6/webhook.py @@ -0,0 +1,29 @@ +"""Webhook handler for Olimex ESP32-C6-EVB input events.""" +import logging +from aiohttp import web +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def handle_input_event(hass: HomeAssistant, webhook_id: str, request) -> web.Response: + """Handle input event webhook from the board.""" + try: + data = await request.json() + input_num = data.get("input") + state = data.get("state") + + _LOGGER.info("Received input event: input=%s state=%s", input_num, state) + + # Dispatch signal to update binary sensors immediately + signal = f"{DOMAIN}_input_{input_num}_event" + async_dispatcher_send(hass, signal, state) + + return web.json_response({"status": "ok"}) + + except Exception as err: + _LOGGER.error("Error handling webhook: %s", err) + return web.json_response({"error": str(err)}, status=400) diff --git a/esp32_arduino/DEPLOYMENT_GUIDE.md b/esp32_arduino/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..41a8536 --- /dev/null +++ b/esp32_arduino/DEPLOYMENT_GUIDE.md @@ -0,0 +1,270 @@ +# ESP32-C6 Arduino Deployment Guide + +Complete guide to compile and deploy the firmware to your Olimex ESP32-C6-EVB board. + +--- + +## ✓ Pre-Deployment Checklist + +- [ ] Arduino IDE installed (version 2.0+) +- [ ] ESP32 board package installed (version 3.0.0+) +- [ ] Olimex ESP32-C6-EVB board connected via USB +- [ ] USB drivers installed for your OS +- [ ] WiFi credentials available + +--- + +## Step 1: Install Arduino IDE + +1. Download from: https://www.arduino.cc/en/software +2. Install and launch Arduino IDE 2.0 or later + +--- + +## Step 2: Add ESP32 Board Support + +### Windows/Mac/Linux (same process): + +1. **Open Preferences** + - File → Preferences (or Arduino IDE → Settings on Mac) + +2. **Add Board URL** + - Find "Additional Boards Manager URLs" field + - Add this URL: + ``` + https://espressif.github.io/arduino-esp32/package_esp32_index.json + ``` + - Click OK + +3. **Install ESP32 Board Package** + - Tools → Board → Boards Manager + - Search: "esp32" + - Install "esp32 by Espressif Systems" (version 3.0.0+) + - Wait for installation to complete + +--- + +## Step 3: Configure Board Settings + +After installation, configure these exact settings in Arduino IDE: + +**Tools Menu Settings:** + +| Setting | Value | +|---------|-------| +| Board | ESP32C6 Dev Module | +| Upload Speed | 921600 | +| USB CDC On Boot | **Enabled** ⚠️ CRITICAL | +| Flash Size | 4MB | +| Flash Mode | DIO | +| Flash Frequency | 80MHz | +| Partition Scheme | Default 4MB | + +--- + +## Step 4: Update WiFi Credentials + +**Before uploading**, edit the WiFi credentials in `esp32_arduino.ino`: + +```cpp +// Line 16-17 - CHANGE THESE: +const char* ssid = "Your_WiFi_SSID_Here"; +const char* password = "Your_WiFi_Password_Here"; +``` + +Replace with your actual WiFi network name and password. + +--- + +## Step 5: Connect Board & Find USB Port + +### Linux/Mac: +```bash +# Check available ports +ls -la /dev/ttyACM* /dev/ttyUSB* + +# Should see something like: /dev/ttyACM0 +``` + +### Windows: +- Device Manager → Ports (COM & LPT) +- Look for "USB-UART Bridge" or similar + +### Select Port in Arduino IDE: +- Tools → Port → [Select your port] +- Usually `/dev/ttyACM0` on Linux (not `/dev/ttyUSB0`) + +--- + +## Step 6: Compile & Upload + +1. **Verify Sketch** (check for errors before upload) + - Sketch → Verify/Compile + - OR press: Ctrl+R (Windows/Linux) or Cmd+R (Mac) + +2. **Upload to Board** + - Sketch → Upload + - OR press: Ctrl+U (Windows/Linux) or Cmd+U (Mac) + +3. **Watch Serial Monitor** + - Tools → Serial Monitor (or Ctrl+Shift+M) + - **Set baud rate to: 115200** + - You should see the startup messages + +4. **If No Output:** + - Press the **RESET button** on the ESP32-C6 board + - Check that "USB CDC On Boot" is set to **Enabled** + - Verify Serial Monitor baud rate is 115200 + +--- + +## Step 7: Verify Deployment Success + +### Expected Serial Output: + +``` +================================= +ESP32-C6 Home Assistant Device +Arduino Framework +================================= +GPIO initialized +Connecting to WiFi: Your_WiFi_SSID +......................... +✓ WiFi connected! +IP address: 192.168.1.xxx +RSSI: -45 dBm +MAC: AA:BB:CC:DD:EE:FF +✓ HTTP server started on port 80 +================================= +Ready! Try these endpoints: + http://192.168.1.xxx/api/status +================================= +``` + +### Test the Board + +**From Linux terminal or browser:** + +```bash +# Get board status +curl http://192.168.1.xxx/api/status + +# Expected response: +# {"temperature":25.0,"wifi_rssi":-45,"chip":"ESP32-C6","free_heap":123456,"uptime":12,"ip":"192.168.1.xxx","relay1":false,"led":false} + +# Turn relay ON +curl -X POST http://192.168.1.xxx/api/relay/relay_1/on + +# Turn relay OFF +curl -X POST http://192.168.1.xxx/api/relay/relay_1/off + +# Get relay status +curl http://192.168.1.xxx/api/relay/relay_1/status + +# Turn LED ON +curl -X POST http://192.168.1.xxx/api/led/led/on + +# Turn LED OFF +curl -X POST http://192.168.1.xxx/api/led/led/off +``` + +**Or open in browser:** +- `http://192.168.1.xxx/` - Web control panel +- `http://192.168.1.xxx/api/status` - JSON status + +--- + +## Step 8: Add to Home Assistant + +1. **Get the board's IP address** (from Serial Monitor output) + +2. **In Home Assistant:** + - Settings → Devices & Services → Integrations + - Click "+ Create Integration" + - Search for "Olimex ESP32-C6-EVB" + - Enter IP address and port (80) + - Select which relays/LEDs to include + +3. **You should now see:** + - Temperature sensor + - WiFi signal strength + - Relay switch + - LED switch + +--- + +## Troubleshooting + +### No Serial Output +- ✓ Verify "USB CDC On Boot" is **Enabled** in board settings +- ✓ Press RESET button on the board +- ✓ Check Serial Monitor baud rate (115200) +- ✓ Try different USB cable +- ✓ Try different USB port + +### WiFi Connection Failed +- ✓ Verify SSID and password are correct (check for typos) +- ✓ Ensure board is within WiFi range +- ✓ Check if WiFi network requires WPA3 (ESP32-C6 may have issues with some WPA3 networks) +- ✓ Try 2.4GHz network (not 5GHz) + +### Can't Find Board in Arduino IDE +- ✓ Check USB cable is connected +- ✓ Verify USB drivers installed +- ✓ Try different USB port +- ✓ Restart Arduino IDE +- ✓ On Linux: `sudo usermod -a -G dialout $USER` then relogin + +### API Endpoints Not Responding +- ✓ Verify board has WiFi connection (check Serial Monitor) +- ✓ Verify correct IP address +- ✓ Check firewall isn't blocking port 80 +- ✓ Restart the board (press RESET button) + +### Relay/LED Not Working +- ✓ Check GPIO pin connections (LED=GPIO8, Relay=GPIO2) +- ✓ Verify relay/LED hardware is connected correctly +- ✓ Test endpoints in Serial Monitor output + +--- + +## GPIO Pin Reference + +| Function | GPIO Pin | Usage | +|----------|----------|-------| +| LED (Onboard) | GPIO 8 | Blue LED control | +| Relay 1 | GPIO 2 | Relay control | + +For additional GPIO pins, modify the code and add more handlers. + +--- + +## What the Code Does + +✅ **WiFi Connection** - Connects to your WiFi network +✅ **REST API** - Provides HTTP endpoints for control +✅ **Web UI** - Control panel at root URL +✅ **Relay Control** - On/Off control via GPIO2 +✅ **LED Control** - On/Off control via GPIO8 +✅ **Temperature Simulation** - Reads and reports simulated temperature +✅ **System Status** - Reports uptime, free memory, WiFi signal strength +✅ **Serial Debugging** - All actions logged to Serial Monitor + +--- + +## Next Steps + +1. Deploy firmware to board ✓ +2. Verify board works in Serial Monitor ✓ +3. Test API endpoints from terminal +4. Add to Home Assistant integration +5. Create automations using the relay/LED as switches + +--- + +## More Resources + +- [Arduino IDE Documentation](https://docs.arduino.cc/software/ide-v2) +- [Espressif ESP32-C6 Datasheet](https://www.espressif.com/sites/default/files/documentation/esp32-c6_datasheet_en.pdf) +- [Olimex ESP32-C6-EVB Board](https://www.olimex.com/Products/IoT/ESP32-C6-EVB/) + diff --git a/esp32_arduino/README.md b/esp32_arduino/README.md new file mode 100644 index 0000000..f8f8bee --- /dev/null +++ b/esp32_arduino/README.md @@ -0,0 +1,166 @@ +# ESP32-C6 Arduino Project + +Arduino IDE project for ESP32-C6 Home Assistant integration. + +## Arduino IDE Setup + +### 1. Install ESP32 Board Support + +1. Open Arduino IDE +2. Go to **File → Preferences** +3. Add this URL to "Additional Boards Manager URLs": + ``` + https://espressif.github.io/arduino-esp32/package_esp32_index.json + ``` +4. Go to **Tools → Board → Boards Manager** +5. Search for "esp32" by Espressif Systems +6. Install **esp32** (version 3.0.0 or later for ESP32-C6 support) + +### 2. Board Configuration + +In Arduino IDE, select: +- **Board**: "ESP32C6 Dev Module" +- **Upload Speed**: 921600 +- **USB CDC On Boot**: **Enabled** ⚠️ **CRITICAL for serial output!** +- **Flash Size**: 4MB +- **Flash Mode**: DIO +- **Flash Frequency**: 80MHz +- **Partition Scheme**: Default 4MB +- **Port**: `/dev/ttyACM0` (ESP32-C6 typically uses ACM, not USB) + +### 3. Open Project + +1. Open **esp32_arduino.ino** in Arduino IDE +2. The IDE will create a folder with the same name automatically + +### 4. Upload + +1. Connect your ESP32-C6 board via USB +2. Check available ports: `ls -la /dev/ttyACM* /dev/ttyUSB*` +3. Select the correct **Port** in Tools menu (usually `/dev/ttyACM0`) +4. Click **Upload** button (→) +5. Wait for compilation and upload +6. Open **Serial Monitor** (Ctrl+Shift+M) and set to **115200 baud** +7. **Press the RESET button** on your board to see output + +**Important**: If you see no serial output: +- Verify **USB CDC On Boot** is set to **Enabled** +- Press the physical RESET button on the board +- Make sure Serial Monitor is set to 115200 baud + +## WiFi Configuration + +WiFi credentials are already set in the code: +```cpp +const char* ssid = "Buon-Gusto_Nou"; +const char* password = "arleta13"; +``` + +## Features + +### Web Interface +After upload, open your browser to the IP shown in Serial Monitor: +- Control panel with buttons +- Real-time status +- API documentation + +### REST API Endpoints + +#### Get Device Status +```bash +curl http:///api/status +``` + +Response: +```json +{ + "temperature": 25.3, + "wifi_rssi": -45, + "chip": "ESP32-C6", + "free_heap": 280000, + "uptime": 123, + "ip": "192.168.1.100", + "relay1": false, + "led": false +} +``` + +#### Control Relay +```bash +# Turn ON +curl -X POST http:///api/relay/relay_1/on + +# Turn OFF +curl -X POST http:///api/relay/relay_1/off + +# Get Status +curl http:///api/relay/relay_1/status +``` + +#### Control LED +```bash +# Turn ON +curl -X POST http:///api/led/led/on + +# Turn OFF +curl -X POST http:///api/led/led/off +``` + +## GPIO Pins + +- **LED_PIN**: GPIO 8 (onboard LED) +- **RELAY_1_PIN**: GPIO 2 (relay control) + +Modify these in the code if your board uses different pins. + +## Troubleshooting + +### Board Not Found in Arduino IDE +- Make sure you installed ESP32 board support (minimum version 3.0.0) +- Restart Arduino IDE after installation + +### Upload Fails +- Check USB cable connection +- Select correct port in Tools → Port +- Try pressing BOOT button during upload +- Reduce upload speed to 115200 + +### WiFi Connection Fails +- Verify SSID and password +- Check if WiFi is 2.4GHz (ESP32-C6 doesn't support 5GHz) +- Check Serial Monitor for connection messages + +### Can't See Serial Output +- Set Serial Monitor baud rate to **115200** +- Enable "USB CDC On Boot" in board settings +- Some boards need GPIO 0 held LOW during boot to enter programming mode + +## Serial Monitor Output + +Expected output after successful upload: +``` +================================= +ESP32-C6 Home Assistant Device +================================= +GPIO initialized +Connecting to WiFi: Buon-Gusto_Nou +.......... +✓ WiFi connected! +IP address: 192.168.1.100 +RSSI: -45 dBm +MAC: AA:BB:CC:DD:EE:FF + +✓ HTTP server started on port 80 + +================================= +Ready! Try these endpoints: + http://192.168.1.100/api/status +================================= +``` + +## Next Steps + +1. Upload the code to your ESP32-C6 +2. Note the IP address from Serial Monitor +3. Test the web interface in your browser +4. Integrate with Home Assistant using the custom component at `../custom_components/olimex_esp32_c6/` diff --git a/esp32_arduino/esp32_arduino.ino b/esp32_arduino/esp32_arduino.ino new file mode 100644 index 0000000..1b8aacf --- /dev/null +++ b/esp32_arduino/esp32_arduino.ino @@ -0,0 +1,630 @@ +/** + * ESP32-C6 Home Assistant Integration + * Arduino IDE Project + * + * Board: ESP32C6 Dev Module + * Flash Size: 4MB + * USB CDC On Boot: Enabled (REQUIRED for serial output!) + * + * Provides REST API for Home Assistant integration + */ +// version 1.5 Initial release +#include +#include +#include + +// WiFi credentials +const char* ssid = "BUON GUSTO PARTER"; +const char* password = "arleta13"; + +// Web server on port 80 +WebServer server(80); + +// GPIO pins - Olimex ESP32-C6-EVB board configuration +const int LED_PIN = 8; // Onboard LED +const int BUT_PIN = 9; // Onboard button +const int RELAY_1_PIN = 10; // Relay 1 +const int RELAY_2_PIN = 11; // Relay 2 +const int RELAY_3_PIN = 22; // Relay 3 +const int RELAY_4_PIN = 23; // Relay 4 +const int DIN1_PIN = 1; // Digital Input 1 +const int DIN2_PIN = 2; // Digital Input 2 +const int DIN3_PIN = 3; // Digital Input 3 +const int DIN4_PIN = 15; // Digital Input 4 + +// State tracking +bool relay1_state = false; +bool relay2_state = false; +bool relay3_state = false; +bool relay4_state = false; +bool led_state = false; + +// Input state tracking - for change detection +bool input1_state = true; // HIGH when not pressed (pull-up) +bool input2_state = true; +bool input3_state = true; +bool input4_state = true; +bool last_input1_state = true; +bool last_input2_state = true; +bool last_input3_state = true; +bool last_input4_state = true; +unsigned long last_input_check = 0; + +// Home Assistant callback configuration +char ha_callback_url[256] = ""; // URL to POST input events to +bool ha_registered = false; + +// Temperature simulation +float temperature = 25.0; + +void setup() { + // Initialize USB CDC serial + Serial.begin(115200); + delay(2000); // Give time for USB CDC to initialize + + // Wait for serial port to be ready (up to 5 seconds) + for (int i = 0; i < 10 && !Serial; i++) { + delay(500); + } + + Serial.println("\n\n================================="); + Serial.println("ESP32-C6 Home Assistant Device"); + Serial.println("Arduino Framework"); + Serial.println("================================="); + + // Initialize GPIO - Outputs + pinMode(LED_PIN, OUTPUT); + pinMode(RELAY_1_PIN, OUTPUT); + pinMode(RELAY_2_PIN, OUTPUT); + pinMode(RELAY_3_PIN, OUTPUT); + pinMode(RELAY_4_PIN, OUTPUT); + // Initialize GPIO - Inputs with pull-up + pinMode(BUT_PIN, INPUT_PULLUP); + pinMode(DIN1_PIN, INPUT_PULLUP); + pinMode(DIN2_PIN, INPUT_PULLUP); + pinMode(DIN3_PIN, INPUT_PULLUP); + pinMode(DIN4_PIN, INPUT_PULLUP); + // Set all outputs to LOW + digitalWrite(LED_PIN, LOW); + digitalWrite(RELAY_1_PIN, LOW); + digitalWrite(RELAY_2_PIN, LOW); + digitalWrite(RELAY_3_PIN, LOW); + digitalWrite(RELAY_4_PIN, LOW); + + Serial.println("GPIO initialized"); + + // Configure WiFi + Serial.println("\n--- WiFi Configuration ---"); + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + + // Set static IP (prevents DHCP IP changes) + IPAddress staticIP(192, 168, 0, 181); + IPAddress gateway(192, 168, 0, 1); + IPAddress subnet(255, 255, 255, 0); + WiFi.config(staticIP, gateway, subnet); + + // Connect to WiFi + Serial.print("Connecting to WiFi: "); + Serial.println(ssid); + WiFi.begin(ssid, password); + + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 40) { // 40 * 500ms = 20 seconds + delay(500); + Serial.print("."); + attempts++; + } + + Serial.println(""); // New line after dots + + // Check WiFi status + int wifiStatus = WiFi.status(); + if (wifiStatus == WL_CONNECTED) { + Serial.println("\n✓ WiFi connected!"); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + Serial.print("RSSI: "); + Serial.print(WiFi.RSSI()); + Serial.println(" dBm"); + Serial.print("MAC: "); + Serial.println(WiFi.macAddress()); + } else { + Serial.println("\n✗ WiFi connection failed!"); + Serial.print("WiFi Status Code: "); + Serial.println(wifiStatus); + // Status codes: 0=IDLE, 1=NO_SSID, 2=SCAN_COMPLETE, 3=CONNECTED, 4=CONNECT_FAILED, 5=CONNECTION_LOST, 6=DISCONNECTED + switch(wifiStatus) { + case WL_NO_SSID_AVAIL: + Serial.println("ERROR: SSID not found! Check network name."); + break; + case WL_CONNECT_FAILED: + Serial.println("ERROR: Connection failed! Check password."); + break; + case WL_CONNECTION_LOST: + Serial.println("ERROR: Connection lost."); + break; + case WL_DISCONNECTED: + Serial.println("ERROR: Disconnected from network."); + break; + default: + Serial.println("ERROR: Unknown WiFi error."); + } + Serial.println("Continuing anyway to allow API access..."); + + // Scan and show available networks + scanWiFiNetworks(); + } + + // Setup API endpoints + server.on("/", handleRoot); + server.on("/api/status", HTTP_GET, handleStatus); + // Relay endpoints + server.on("/relay/on", HTTP_POST, handleRelayOn); + server.on("/relay/off", HTTP_POST, handleRelayOff); + server.on("/relay/status", HTTP_GET, handleRelayStatus); + // Input endpoints + server.on("/input/status", HTTP_GET, handleInputStatus); + // Home Assistant webhook registration + server.on("/register", HTTP_POST, handleRegister); + // LED endpoints + server.on("/led/on", HTTP_POST, handleLEDOn); + server.on("/led/off", HTTP_POST, handleLEDOff); + server.onNotFound(handleNotFound); + + // Start server + server.begin(); + Serial.println("\n✓ HTTP server started on port 80"); + Serial.println("\n================================="); + Serial.println("Ready! Try these endpoints:"); + Serial.print(" http://"); + Serial.print(WiFi.localIP()); + Serial.println("/api/status"); + Serial.println("=================================\n"); +} + +void loop() { + server.handleClient(); + + // Simulate temperature reading + temperature = 25.0 + (random(-20, 20) / 10.0); + + // Check for input state changes every 50ms + if (millis() - last_input_check > 50) { + last_input_check = millis(); + checkInputChanges(); + } + + delay(10); +} + +// ============================================ +// WiFi Debug Function +// ============================================ + +void scanWiFiNetworks() { + Serial.println("\n--- Available WiFi Networks ---"); + int n = WiFi.scanNetworks(); + if (n == 0) { + Serial.println("No networks found!"); + } else { + Serial.print("Found "); + Serial.print(n); + Serial.println(" networks:"); + for (int i = 0; i < n; i++) { + Serial.print(i + 1); + Serial.print(". "); + Serial.print(WiFi.SSID(i)); + Serial.print(" ("); + Serial.print(WiFi.RSSI(i)); + Serial.print(" dBm) "); + Serial.println(WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "Open" : "Secured"); + } + } + Serial.println("-------------------------------\n"); +} + +// ============================================ +// API Handlers +// ============================================ + +void handleRoot() { + // Read all inputs first to get current state + input1_state = digitalRead(DIN1_PIN); + input2_state = digitalRead(DIN2_PIN); + input3_state = digitalRead(DIN3_PIN); + input4_state = digitalRead(DIN4_PIN); + + String html = "ESP32-C6 Device"; + html += ""; + html += ""; + html += ""; + html += ""; + html += "

ESP32-C6 Control Panel

"; + + // Device Info + html += "

Device Info

"; + html += "IP: " + WiFi.localIP().toString() + "   "; + html += "RSSI: " + String(WiFi.RSSI()) + " dBm   "; + html += "Temp: " + String(temperature, 1) + "°C   "; + html += "Uptime: " + String(millis() / 1000) + "s
"; + + // Inputs + Relays side by side in a 2-column row + html += "
"; + + // Inputs — 2-column inner grid, round LED indicator + html += "

Inputs

"; + bool inputStates[5] = {false, input1_state, input2_state, input3_state, input4_state}; + for (int i = 1; i <= 4; i++) { + bool pressed = !inputStates[i]; // pull-up: LOW=pressed + html += "
"; + html += "
"; + html += "
Input " + String(i) + "
"; + html += "
" + String(pressed ? "PRESSED" : "NOT PRESSED") + "
"; + html += "
"; + } + html += "
"; // close Inputs card + + // Relays — 2-column inner grid, toggle buttons + html += "

Relays

"; + bool relayStates[5] = {false, relay1_state, relay2_state, relay3_state, relay4_state}; + for (int i = 1; i <= 4; i++) { + bool on = relayStates[i]; + html += ""; + } + html += "
"; // close Relays card + html += "
"; // close .card-row + + // LED Control + html += "

LED Control

"; + html += ""; + html += ""; + html += "   Status: " + String(led_state ? "ON" : "OFF") + "
"; + + // Home Assistant Webhook Status + html += "

Home Assistant Webhook

"; + if (ha_registered && strlen(ha_callback_url) > 0) { + html += "
✓ Connected — " + String(ha_callback_url) + "
"; + } else { + html += "
✗ Not registered — waiting for Home Assistant...
"; + } + html += "
"; + + html += "

API Endpoints

"; + html += "GET /api/status   POST /relay/on?relay=1-4   POST /relay/off?relay=1-4
"; + html += "GET /input/status?input=1-4   POST /led/on   POST /led/off
"; + html += "POST /register?callback_url=...
"; + + html += ""; + + server.send(200, "text/html", html); +} + +// Status request monitoring +static unsigned long last_status_log = 0; +static int status_request_count = 0; + +void handleStatus() { + status_request_count++; + + // Read fresh input states + input1_state = digitalRead(DIN1_PIN); + input2_state = digitalRead(DIN2_PIN); + input3_state = digitalRead(DIN3_PIN); + input4_state = digitalRead(DIN4_PIN); + + String json = "{"; + json += "\"input1\":" + String(input1_state ? "true" : "false") + ","; + json += "\"input2\":" + String(input2_state ? "true" : "false") + ","; + json += "\"input3\":" + String(input3_state ? "true" : "false") + ","; + json += "\"input4\":" + String(input4_state ? "true" : "false") + ","; + json += "\"relay1\":" + String(relay1_state ? "true" : "false") + ","; + json += "\"relay2\":" + String(relay2_state ? "true" : "false") + ","; + json += "\"relay3\":" + String(relay3_state ? "true" : "false") + ","; + json += "\"relay4\":" + String(relay4_state ? "true" : "false") + ","; + json += "\"led\":" + String(led_state ? "true" : "false"); + json += "}"; + + server.send(200, "application/json", json); + + // Log status every 10 seconds + if (millis() - last_status_log > 10000) { + last_status_log = millis(); + Serial.printf("API: %d requests/10sec, Free heap: %d bytes, Uptime: %lus\n", status_request_count, ESP.getFreeHeap(), millis() / 1000); + status_request_count = 0; + } +} + +void handleRelayOn() { + if (!server.hasArg("relay")) { + server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}"); + return; + } + + int relay_num = server.arg("relay").toInt(); + int pin = -1; + bool *state_ptr = nullptr; + + switch(relay_num) { + case 1: pin = RELAY_1_PIN; state_ptr = &relay1_state; break; + case 2: pin = RELAY_2_PIN; state_ptr = &relay2_state; break; + case 3: pin = RELAY_3_PIN; state_ptr = &relay3_state; break; + case 4: pin = RELAY_4_PIN; state_ptr = &relay4_state; break; + default: + server.send(400, "application/json", "{\"error\":\"Invalid relay number\"}"); + return; + } + + digitalWrite(pin, HIGH); + *state_ptr = true; + server.send(200, "application/json", "{\"status\":\"ok\",\"state\":true}"); + Serial.printf("Relay %d ON\n", relay_num); +} + +void handleRelayOff() { + if (!server.hasArg("relay")) { + server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}"); + return; + } + + int relay_num = server.arg("relay").toInt(); + int pin = -1; + bool *state_ptr = nullptr; + + switch(relay_num) { + case 1: pin = RELAY_1_PIN; state_ptr = &relay1_state; break; + case 2: pin = RELAY_2_PIN; state_ptr = &relay2_state; break; + case 3: pin = RELAY_3_PIN; state_ptr = &relay3_state; break; + case 4: pin = RELAY_4_PIN; state_ptr = &relay4_state; break; + default: + server.send(400, "application/json", "{\"error\":\"Invalid relay number\"}"); + return; + } + + digitalWrite(pin, LOW); + *state_ptr = false; + server.send(200, "application/json", "{\"status\":\"ok\",\"state\":false}"); + Serial.printf("Relay %d OFF\n", relay_num); +} + +void handleRelayStatus() { + if (!server.hasArg("relay")) { + server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}"); + return; + } + + int relay_num = server.arg("relay").toInt(); + bool state = false; + + switch(relay_num) { + case 1: state = relay1_state; break; + case 2: state = relay2_state; break; + case 3: state = relay3_state; break; + case 4: state = relay4_state; break; + default: + server.send(400, "application/json", "{\"error\":\"Invalid relay number\"}"); + return; + } + + String json = "{\"state\":" + String(state ? "true" : "false") + "}"; + server.send(200, "application/json", json); +} + +void handleInputStatus() { + if (!server.hasArg("input")) { + server.send(400, "application/json", "{\"error\":\"Missing input parameter\"}"); + return; + } + + int input_num = server.arg("input").toInt(); + int pin = -1; + + switch(input_num) { + case 1: pin = DIN1_PIN; break; + case 2: pin = DIN2_PIN; break; + case 3: pin = DIN3_PIN; break; + case 4: pin = DIN4_PIN; break; + default: + server.send(400, "application/json", "{\"error\":\"Invalid input number\"}"); + return; + } + + int level = digitalRead(pin); + String json = "{\"state\":" + String(level ? "true" : "false") + "}"; + server.send(200, "application/json", json); + Serial.printf("Input %d status: %d\n", input_num, level); +} + +void handleLEDOn() { + digitalWrite(LED_PIN, LOW); // LED is active-low + led_state = true; + server.send(200, "application/json", "{\"status\":\"ok\"}"); + Serial.println("LED ON"); +} + +void handleLEDOff() { + digitalWrite(LED_PIN, HIGH); // LED is active-low + led_state = false; + server.send(200, "application/json", "{\"status\":\"ok\"}"); + Serial.println("LED OFF"); +} + +void handleNotFound() { + String message = "404: Not Found\n\n"; + message += "URI: " + server.uri() + "\n"; + message += "Method: " + String((server.method() == HTTP_GET) ? "GET" : "POST") + "\n"; + server.send(404, "text/plain", message); +} + +// ============================================ +// Home Assistant Integration +// ============================================ + +void handleRegister() { + if (!server.hasArg("callback_url")) { + server.send(400, "application/json", "{\"error\":\"Missing callback_url parameter\"}"); + return; + } + + String url = server.arg("callback_url"); + if (url.length() > 255) { + server.send(400, "application/json", "{\"error\":\"URL too long (max 255 chars)\"}"); + return; + } + + url.toCharArray(ha_callback_url, 256); + ha_registered = true; + + Serial.printf("Home Assistant webhook registered: %s\n", ha_callback_url); + server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Webhook registered\"}"); +} + +void checkInputChanges() { + if (!ha_registered) return; // Only check if HA is registered + + // Read all input states + bool curr_input1 = digitalRead(DIN1_PIN); + bool curr_input2 = digitalRead(DIN2_PIN); + bool curr_input3 = digitalRead(DIN3_PIN); + bool curr_input4 = digitalRead(DIN4_PIN); + + // Check for changes and POST events + if (curr_input1 != last_input1_state) { + last_input1_state = curr_input1; + postInputEvent(1, curr_input1); + } + if (curr_input2 != last_input2_state) { + last_input2_state = curr_input2; + postInputEvent(2, curr_input2); + } + if (curr_input3 != last_input3_state) { + last_input3_state = curr_input3; + postInputEvent(3, curr_input3); + } + if (curr_input4 != last_input4_state) { + last_input4_state = curr_input4; + postInputEvent(4, curr_input4); + } +} + +void postInputEvent(int input_num, bool state) { + if (!ha_registered || strlen(ha_callback_url) == 0) { + return; // Not registered, skip + } + + // Invert state because inputs use pull-up (HIGH=not pressed, LOW=pressed) + bool pressed = !state; + String event_type = pressed ? "input_on" : "input_off"; + + Serial.printf("Input %d event: %s (raw_state=%d) - POSTing to HA\n", input_num, event_type.c_str(), state); + + // Parse callback URL (format: http://host:port/path) + String url_str = String(ha_callback_url); + + // Extract host and path + int protocol_end = url_str.indexOf("://"); + if (protocol_end < 0) return; + + int host_start = protocol_end + 3; + int port_separator = url_str.indexOf(":", host_start); + int path_start = url_str.indexOf("/", host_start); + + if (path_start < 0) path_start = url_str.length(); + if (port_separator < 0 || port_separator > path_start) port_separator = -1; + + String host = url_str.substring(host_start, (port_separator >= 0) ? port_separator : path_start); + int port = 80; // Default HTTP port + if (port_separator >= 0) { + int colon_port_end = url_str.indexOf("/", port_separator); + if (colon_port_end < 0) colon_port_end = url_str.length(); + String port_str = url_str.substring(port_separator + 1, colon_port_end); + port = port_str.toInt(); + } + String path = url_str.substring(path_start); + + // Create JSON payload + String json = "{\"input\":" + String(input_num) + ",\"state\":" + (pressed ? "true" : "false") + "}"; + + // Connect to Home Assistant and POST + WiFiClient client; + if (!client.connect(host.c_str(), port)) { + Serial.printf("Failed to connect to %s:%d\n", host.c_str(), port); + return; + } + + // Send HTTP POST request + client.println("POST " + path + " HTTP/1.1"); + client.println("Host: " + host); + client.println("Content-Type: application/json"); + client.println("Content-Length: " + String(json.length())); + client.println("Connection: close"); + client.println(); + client.print(json); + + // Wait for response + delay(100); + + // Read response + while (client.available()) { + char c = client.read(); + // Just discard the response for now + } + + client.stop(); + Serial.printf("Input %d event posted successfully\n", input_num); +}