Initial commit: Olimex ESP32-C6-EVB HA integration + Arduino sketch
This commit is contained in:
107
custom_components/olimex_esp32_c6/README.md
Normal file
107
custom_components/olimex_esp32_c6/README.md
Normal file
@@ -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://<IP>:<PORT>/api/status
|
||||
Response: {
|
||||
"temperature": 25.5,
|
||||
"wifi_rssi": -45
|
||||
}
|
||||
```
|
||||
|
||||
#### Relay Control
|
||||
```
|
||||
POST http://<IP>:<PORT>/api/relay/<relay_id>/on
|
||||
POST http://<IP>:<PORT>/api/relay/<relay_id>/off
|
||||
GET http://<IP>:<PORT>/api/relay/<relay_id>/status
|
||||
Response: {"state": true}
|
||||
```
|
||||
|
||||
#### LED Control
|
||||
```
|
||||
POST http://<IP>:<PORT>/api/led/<led_id>/on
|
||||
POST http://<IP>:<PORT>/api/led/<led_id>/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 <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
|
||||
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/)
|
||||
203
custom_components/olimex_esp32_c6/SETUP.md
Normal file
203
custom_components/olimex_esp32_c6/SETUP.md
Normal file
@@ -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
|
||||
117
custom_components/olimex_esp32_c6/__init__.py
Normal file
117
custom_components/olimex_esp32_c6/__init__.py
Normal file
@@ -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
|
||||
90
custom_components/olimex_esp32_c6/binary_sensor.py
Normal file
90
custom_components/olimex_esp32_c6/binary_sensor.py
Normal file
@@ -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",
|
||||
}
|
||||
112
custom_components/olimex_esp32_c6/config_flow.py
Normal file
112
custom_components/olimex_esp32_c6/config_flow.py
Normal file
@@ -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),
|
||||
},
|
||||
)
|
||||
28
custom_components/olimex_esp32_c6/const.py
Normal file
28
custom_components/olimex_esp32_c6/const.py
Normal file
@@ -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,
|
||||
}
|
||||
11
custom_components/olimex_esp32_c6/manifest.json
Normal file
11
custom_components/olimex_esp32_c6/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
BIN
custom_components/olimex_esp32_c6/olimex.png
Normal file
BIN
custom_components/olimex_esp32_c6/olimex.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
122
custom_components/olimex_esp32_c6/sensor.py
Normal file
122
custom_components/olimex_esp32_c6/sensor.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Sensor platform for Olimex ESP32-C6-EVB."""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import aiohttp
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, DEVICE_INFO, DEFAULT_SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> 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
|
||||
85
custom_components/olimex_esp32_c6/sensor_updater.py
Normal file
85
custom_components/olimex_esp32_c6/sensor_updater.py
Normal file
@@ -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",
|
||||
}
|
||||
49
custom_components/olimex_esp32_c6/strings.json
Normal file
49
custom_components/olimex_esp32_c6/strings.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
custom_components/olimex_esp32_c6/switch.py
Normal file
117
custom_components/olimex_esp32_c6/switch.py
Normal file
@@ -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",
|
||||
}
|
||||
29
custom_components/olimex_esp32_c6/webhook.py
Normal file
29
custom_components/olimex_esp32_c6/webhook.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user