commit e5fd3645d1fddf701b0a986ad7521e7769a839e3 Author: ske087 Date: Thu Jun 11 00:42:59 2026 +0300 Initial commit: ESP32-C5-EVB and ESP32-C6-EVB Arduino firmware + HA custom components diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e921fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Sensitive credentials - never commit +secrets.h + +# Arduino build artifacts +build/ +*.bin +*.elf +*.map +firmware_build/ + +# Python cache +__pycache__/ +*.pyc +*.pyo + +# OS files +.DS_Store +Thumbs.db diff --git a/olimex_ESP32-C5-EVB/NFC_DEBUG_GUIDE.md b/olimex_ESP32-C5-EVB/NFC_DEBUG_GUIDE.md new file mode 100644 index 0000000..5b5b427 --- /dev/null +++ b/olimex_ESP32-C5-EVB/NFC_DEBUG_GUIDE.md @@ -0,0 +1,161 @@ +# NFC (PN532) Debugging Guide + +## Current Status +- **Hardware**: GPIO26(TX) → PN532 RXD, GPIO25(RX) ← PN532 TXD (UEXT pins 3-4) +- **DIP Switches**: Both OFF (HSU mode) ✓ +- **Power**: UEXT pin 1 = 3.3V (verified), Pin 2 = GND (verified) +- **Wiring**: Confirmed correct +- **Problem**: PN532 module not responding to auto-baud probe + +## Why NFC Probe Fails +The firmware attempts to communicate with PN532 at various baud rates: +- 115200, 9600, 57600, 38400 baud +- Tries both GPIO26/GPIO25 and GPIO25/GPIO26 (swapped) +- At each combination: sends `getFirmwareVersion()` command +- **Issue**: No response from PN532 → returns 0x00 + +## Prerequisites for Debugging +### 1. Enable Serial Output +**Arduino IDE Settings (REQUIRED):** +``` +Tools → USB CDC On Boot: ENABLED +Tools → Port: /dev/ttyACM0 +``` + +**Rebuild and flash the firmware:** +```bash +cd ~/arduino/olimex_ESP32-C5-EVB/esp32_arduino +arduino --verify --board esp32:esp32:esp32c5 esp32_arduino.ino +esptool.py --chip esp32c5 --port /dev/ttyACM0 --baud 921600 erase-flash +esptool.py --chip esp32c5 --port /dev/ttyACM0 --baud 921600 write-flash 0x0 build_output/esp32_arduino.ino.merged.bin +``` + +**Open Serial Monitor:** +- Arduino IDE → Tools → Serial Monitor +- Baud rate: **115200** +- Wait for boot messages + +## Diagnostic Steps + +### Step 1: Verify Serial Output +Expected output during boot: +``` +================================= +ESP32-C5 Home Assistant Device +Arduino Framework +================================= +... +--- NFC (PN532 HSU) Initialization with Debug --- +Hardware: GPIO26(TX)→PN532-RX, GPIO25(RX)←PN532-TX, DIP:HSU mode + [1/8] Baud=115200, RX=GPIO25, TX=GPIO26 ... ✗ + [2/8] Baud=9600, RX=GPIO25, TX=GPIO26 ... ✗ + ... +``` + +**If you see:** +- ✓ All "✗" symbols → PN532 not responding at any baud rate +- Nothing after "Initialization" → Serial output not working (re-check USB CDC setting) + +### Step 2: Physical Verification Checklist +``` +□ PN532 powered: Measure 3.3V on VCC pin (should be 3.0-3.3V) +□ PN532 GND: Verify continuity to board GND with multimeter +□ UEXT Pin 1: Should be 3.3V (measure with multimeter) +□ UEXT Pin 2: Should be GND (continuity check) +□ UEXT Pin 3 (GPIO26): No continuity to Pin 4 (should be separate signals) +□ UEXT Pin 4 (GPIO25): No continuity to Pin 3 (should be separate signals) +□ PN532 Module: Try re-seating in connector or using different PN532 board +``` + +### Step 3: Signal Analysis (Advanced) +If serial output shows all "✗" but hardware checks pass: + +**Option A: Oscilloscope Check** +``` +Connect oscilloscope probe to GPIO26 (TX line) +- Should see UART signal transitions during probe +- If flat line: ESP32 not transmitting +- If signal present: PN532 not responding +``` + +**Option B: Loopback Test** +``` +Connect GPIO26 (TX) directly to GPIO25 (RX) with a jumper wire +Rebuild with loopback test code (at end of this file) +If loopback works: GPIO pins OK, PN532 module is issue +If loopback fails: GPIO configuration issue +``` + +## Possible Issues & Solutions + +### Issue 1: PN532 Module Defective +**Symptoms:** All baud rates fail, hardware verified correct +**Solution:** +- Try different PN532 board if available +- Test with I2C mode (different DIP setting) if supported +- Replace PN532 module + +### Issue 2: UEXT Connector Loose +**Symptoms:** Intermittent detection or complete failure +**Solution:** +- Fully remove and re-seat PN532 in UEXT connector +- Ensure connector is fully inserted until it clicks +- Check for bent pins on UEXT connector + +### Issue 3: Incorrect Pin Mapping +**Symptoms:** Detection works with swapped pins +**Solution:** +- Edit `esp32_arduino.ino`: + - Find: `#define NFC_TX_PIN 26` and `#define NFC_RX_PIN 25` + - If swapped RX/TX works: reverse these definitions +- Rebuild and test + +### Issue 4: Baud Rate Mismatch +**Symptoms:** Serial output shows some attempts as "✓ FOUND!" but then fails to init +**Solution:** +- Firmware will re-init with confirmed baud rate +- If fails at SAMConfig() step: PN532 module may be in wrong mode + +## Loopback Test Code +(If GPIO pins might be misconfigured): + +```cpp +// Add to setup() after WiFi init, BEFORE NFC init: +void setup() { + // ... existing code ... + + // LOOPBACK TEST + Serial.println("--- GPIO Loopback Test ---"); + Serial.println("Jumper GPIO26 to GPIO25 to test UART"); + + HardwareSerial testSerial(1); // UART1 + testSerial.begin(115200, SERIAL_8N1, 25, 26); // RX=GPIO25, TX=GPIO26 + + delay(100); + testSerial.print("TEST_LOOPBACK"); + delay(100); + + String received = ""; + while (testSerial.available()) { + received += (char)testSerial.read(); + } + + if (received.indexOf("TEST") >= 0) { + Serial.println("✓ GPIO26/GPIO25 loopback OK"); + } else { + Serial.println("✗ GPIO loopback FAILED - pin configuration issue"); + } +} +``` + +## Next Steps +1. **Enable Serial Output**: Change USB CDC On Boot to ENABLED +2. **Rebuild & Flash**: Use updated Arduino IDE settings +3. **Monitor Serial**: Watch boot output during NFC probe +4. **Share Output**: Post the serial output here for analysis + +**Expected Timeline:** +- Checking serial output: ~5 minutes +- Physical verification: ~10 minutes +- Analyzing results: ~5 minutes +- **Total**: ~20 minutes to identify root cause diff --git a/olimex_ESP32-C5-EVB/README.md b/olimex_ESP32-C5-EVB/README.md new file mode 100644 index 0000000..e0943b8 --- /dev/null +++ b/olimex_ESP32-C5-EVB/README.md @@ -0,0 +1,186 @@ +# Olimex ESP32-C5-EVB — Home Assistant Integration + +This repository contains two components: + +## `custom_components/olimex_esp32_c5` +Home Assistant custom integration for the **Olimex ESP32-C5-EVB** board. + +- 2 relay switches (250V / 10A, controlled via HTTP POST to the board) +- 2 opto-isolated digital inputs (110VAC-240VAC, state pushed from board to HA via webhook) +- NFC/PN532 card reader for access control (optional, via UEXT connector) +- No polling — fully event-driven for inputs, command-driven for relays + +### Installation +Copy `custom_components/olimex_esp32_c5` into your Home Assistant `config/custom_components/` directory and restart HA. + +## `esp32_arduino` +Arduino sketch for the ESP32-C5-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 +- Optional NFC card reader integration for relay trigger automation + +### Arduino IDE Settings +| Setting | Value | +|---|---| +| Board | ESP32C5 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. + +## Key Differences from C6-EVB + +| Feature | C6-EVB | C5-EVB | +|---------|--------|--------| +| **Relays** | 4 | 2 (250V / 10A) | +| **Inputs** | 4 | 2 (opto-isolated 110VAC-240VAC) | +| **Relay 1** | GPIO 10 | GPIO 24 | +| **Relay 2** | GPIO 11 | GPIO 23 | +| **Input 1** | GPIO 1 | GPIO 4 | +| **Input 2** | GPIO 2 | GPIO 5 | +| **LED** | GPIO 8 | GPIO 27 | +| **Button** | GPIO 9 | GPIO 28 | +| **NFC UART** | GPIO 4/5 (UEXT1) | GPIO 26/25 (UEXT) | + +--- + +## Hardware Overview + +### Pin Configuration + +**Relays** (250V / 10A AC/DC) +- Relay 1: GPIO24 +- Relay 2: GPIO23 + +**Opto-Isolated Inputs** (110VAC-240VAC) +- Opto Input 1: GPIO4 +- Opto Input 2: GPIO5 + +**LED & Button** +- User LED: GPIO27 (active LOW) +- User Button: GPIO28 (active LOW, internal pull-up) + +**UEXT Connector** (for NFC reader / additional peripherals) +- I2C: SDA=GPIO9, SCL=GPIO8 +- UART: TX=GPIO26, RX=GPIO25 +- SPI: CLK=GPIO2, MOSI=GPIO7, MISO=GPIO6 + +### Power & Connectivity +- USB-C power input +- WiFi 6 (802.11ax) dual-band (2.4 & 5 GHz) +- Bluetooth 5.0 LE +- Zigbee & Thread capable + +--- + +## API Endpoints + +### Status & Control +- `GET /api/status` — Returns all relay and input states (JSON) +- `POST /relay/on?relay=1-2` — Turn relay on +- `POST /relay/off?relay=1-2` — Turn relay off +- `GET /relay/status?relay=1-2` — Get relay state + +### Inputs +- `GET /input/status?input=1-2` — Get input state + +### LED +- `POST /led/on` — Turn LED on +- `POST /led/off` — Turn LED off + +### Home Assistant Integration +- `POST /register?callback_url=...` — Register HA webhook (no auth required for bootstrap) + +### NFC Access Control +- `GET /nfc/status` — Get NFC module status +- `GET /nfc/config` — Get NFC access control settings +- `POST /nfc/config?auth_uid=...&relay=1-2&pulse_ms=5000` — Set NFC access config +- `POST /nfc/enable?state=0|1` — Enable/disable NFC module + +### Debug +- `GET /debug` — System info (requires basic auth) + +All endpoints except `/register` require **Basic HTTP Authentication** (default: admin/admin) or HMAC-SHA256 request signing. + +--- + +## Setup Instructions + +1. **Flash the firmware** + - See [DEPLOYMENT_GUIDE.md](esp32_arduino/DEPLOYMENT_GUIDE.md) + +2. **Configure WiFi** + - Edit WiFi credentials in `esp32_arduino.ino` (lines ~58-59) + - Set a static IP (currently 192.168.0.240) + - Flash the board + +3. **Add to Home Assistant** + - Copy `custom_components/olimex_esp32_c5/` to `~/.homeassistant/custom_components/` + - Restart Home Assistant + - Settings → Devices & Services → Create Integration → Olimex ESP32-C5-EVB + - Enter board IP, port (80), and Home Assistant callback IP + +4. **Optional: NFC Card Reader Setup** + - Attach PN532 breakout to UEXT connector (TX=GPIO26, RX=GPIO25) + - Open board web UI (`http:///`) + - Enable NFC module + - Scan a card to authorize it + - Configure trigger relay and timeout + +--- + +## Home Assistant Integration + +Once configured, entities appear automatically: + +**Switches** +- `switch.olimex_esp32_c5_relay_1` +- `switch.olimex_esp32_c5_relay_2` + +**Binary Sensors** +- `binary_sensor.olimex_esp32_c5_input_1` (opto-isolated input) +- `binary_sensor.olimex_esp32_c5_input_2` (opto-isolated input) +- `binary_sensor.olimex_esp32_c5_nfc_card` (NFC card present, if enabled) + +**Automations** +Create automations to trigger relays on input events, NFC cards, time schedules, etc. + +--- + +## Troubleshooting + +### Board not reachable +1. Check WiFi SSID and password in code +2. Verify board IP with serial console output +3. Check firewall allows port 80 + +### Inputs not triggering +1. Verify wiring to opto-isolated inputs (GPIO4, GPIO5) +2. Use `/debug` endpoint to check input state +3. Ensure Home Assistant callback URL is registered + +### NFC not detecting cards +1. Check PN532 wiring (TX=GPIO26, RX=GPIO25) +2. Set PN532 DIP switches to HSU mode (both = 0) +3. Check serial output for PN532 detection message +4. Try different baud rates (115200, 9600, 57600, 38400) + +--- + +## Security Notes + +- Default credentials: admin / admin +- Change WEB_PASSWORD in `secrets.h` before deployment +- Enable API_SECRET for HMAC-SHA256 request signing (optional) +- Only expose board on trusted local networks +- NFC cards trigger relays when authenticated card is present + +--- + +## License & Attribution + +- Hardware: [Olimex](https://www.olimex.com/Products/IoT/ESP32-C5/ESP32-C5-EVB/open-source-hardware) +- Firmware: Arduino & ESP-IDF +- Integration: Home Assistant Custom Component diff --git a/olimex_ESP32-C5-EVB/board_verify.py b/olimex_ESP32-C5-EVB/board_verify.py new file mode 100644 index 0000000..5499172 --- /dev/null +++ b/olimex_ESP32-C5-EVB/board_verify.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +Olimex ESP32-C5-EVB — Remote Board Verification Script +========================================================= +Queries the board's REST API and verifies every subsystem. + +Usage: + python3 board_verify.py # uses default IP 192.168.0.240 + python3 board_verify.py 192.168.0.200 # custom IP + python3 board_verify.py --json # machine-readable output + +Requirements: pip install requests (already in location_managemet requirements) + +What it tests: + 1. Board reachability (GET /api/status) + 2. Both relays (POST /relay/on, GET /relay/status, POST /relay/off) + 3. Both digital inputs (GET /input/status) + 4. LED (POST /led/on + /led/off) + 5. NFC reader (GET /nfc/status) + 6. NFC config API (GET /nfc/config) + +NOTE: Relay tests cycle each relay ON→verify→OFF→verify. + You should hear/see the relay click during the test. +""" + +import sys +import json +import time +import argparse +import requests + +TIMEOUT = 5 # seconds per HTTP request +RELAY_DLY = 0.4 # seconds to wait between relay on/status/off + +# ───────────────────────────────────────────────────────────────────────────── +# Result tracking +# ───────────────────────────────────────────────────────────────────────────── + +results = [] + +def record(name: str, ok: bool, detail: str = "") -> bool: + results.append({"name": name, "pass": ok, "detail": detail}) + icon = "\033[32m[PASS]\033[0m" if ok else "\033[31m[FAIL]\033[0m" + print(f" {icon} {name}" + (f" — {detail}" if detail else "")) + return ok + + +def _get(url: str): + try: + r = requests.get(url, timeout=TIMEOUT) + return r.status_code, r.text, None + except requests.RequestException as e: + return None, None, str(e) + + +def _post(url: str, json_data=None): + try: + r = requests.post(url, json=json_data, timeout=TIMEOUT) + return r.status_code, r.text, None + except requests.RequestException as e: + return None, None, str(e) + + +# ───────────────────────────────────────────────────────────────────────────── +# Main verification +# ───────────────────────────────────────────────────────────────────────────── + +def verify_board(ip: str) -> int: + """Test all board subsystems. Returns count of failures.""" + base = f"http://{ip}" + failures = 0 + + print(f"\n{'='*70}") + print(f"Olimex ESP32-C5-EVB Verification") + print(f"{'='*70}") + print(f"Target IP: {ip}\n") + + # ─ 1. Board Reachability ────────────────────────────────────────────────── + print("1. Board Status") + status_code, resp_text, err = _get(f"{base}/api/status") + if err: + record("Board reachable", False, err) + return 1 # Can't proceed without board connectivity + if not (status_code and status_code == 200): + record("Board reachable", False, f"HTTP {status_code}") + return 1 + + try: + status_json = json.loads(resp_text) + record("Board reachable", True, f"HTTP 200") + except json.JSONDecodeError: + record("Board reachable (partial)", False, "Response not JSON") + status_json = {} + + # ─ 2. Relays ────────────────────────────────────────────────────────────── + print("\n2. Relays (2x 250V/10A)") + for relay_num in [1, 2]: + relay_name = f"Relay {relay_num}" + + # Turn ON + _, _, err = _post(f"{base}/relay/on?relay={relay_num}") + if err: + record(f"{relay_name} ON", False, err) + failures += 1 + continue + + time.sleep(RELAY_DLY) + status_code, resp_text, err = _get(f"{base}/relay/status?relay={relay_num}") + if err or status_code != 200: + record(f"{relay_name} ON verify", False, err or f"HTTP {status_code}") + failures += 1 + else: + try: + r = json.loads(resp_text) + if r.get("state") == True: + record(f"{relay_name} ON", True) + else: + record(f"{relay_name} ON verify", False, "State mismatch") + failures += 1 + except: + record(f"{relay_name} ON verify", False, "Invalid response") + failures += 1 + + time.sleep(RELAY_DLY) + + # Turn OFF + _, _, err = _post(f"{base}/relay/off?relay={relay_num}") + if err: + record(f"{relay_name} OFF", False, err) + failures += 1 + continue + + time.sleep(RELAY_DLY) + status_code, resp_text, err = _get(f"{base}/relay/status?relay={relay_num}") + if err or status_code != 200: + record(f"{relay_name} OFF verify", False, err or f"HTTP {status_code}") + failures += 1 + else: + try: + r = json.loads(resp_text) + if r.get("state") == False: + record(f"{relay_name} OFF", True) + else: + record(f"{relay_name} OFF verify", False, "State mismatch") + failures += 1 + except: + record(f"{relay_name} OFF verify", False, "Invalid response") + failures += 1 + + # ─ 3. Digital Inputs ────────────────────────────────────────────────────── + print("\n3. Opto-Isolated Inputs (110VAC-240VAC)") + for input_num in [1, 2]: + status_code, resp_text, err = _get(f"{base}/input/status?input={input_num}") + if err or status_code != 200: + record(f"Input {input_num}", False, err or f"HTTP {status_code}") + failures += 1 + else: + try: + r = json.loads(resp_text) + state = "HIGH" if r.get("state") else "LOW" + record(f"Input {input_num}", True, f"State: {state}") + except: + record(f"Input {input_num}", False, "Invalid response") + failures += 1 + + # ─ 4. LED ───────────────────────────────────────────────────────────────── + print("\n4. LED Control") + _, _, err = _post(f"{base}/led/on") + if err: + record("LED ON", False, err) + failures += 1 + else: + record("LED ON", True) + + time.sleep(0.2) + + _, _, err = _post(f"{base}/led/off") + if err: + record("LED OFF", False, err) + failures += 1 + else: + record("LED OFF", True) + + # ─ 5. NFC Status ────────────────────────────────────────────────────────── + print("\n5. NFC Module (PN532 via UEXT, optional)") + status_code, resp_text, err = _get(f"{base}/nfc/status") + if err or status_code != 200: + record("NFC Status API", False, err or f"HTTP {status_code}") + else: + try: + nfc_status = json.loads(resp_text) + init = nfc_status.get("initialized", False) + record("NFC Status API", True, f"Init: {init}") + except: + record("NFC Status API", False, "Invalid response") + + # ─ 6. NFC Config ────────────────────────────────────────────────────────── + status_code, resp_text, err = _get(f"{base}/nfc/config") + if err or status_code != 200: + record("NFC Config API", False, err or f"HTTP {status_code}") + else: + try: + config = json.loads(resp_text) + record("NFC Config API", True, f"Relay: {config.get('relay_num')}, Pulse: {config.get('pulse_ms')}ms") + except: + record("NFC Config API", False, "Invalid response") + + # ───────────────────────────────────────────────────────────────────────── + # Summary + # ───────────────────────────────────────────────────────────────────────── + print(f"\n{'='*70}") + passed = len([r for r in results if r["pass"]]) + total = len(results) + print(f"Result: {passed}/{total} tests passed") + if failures == 0: + print("✓ All systems functional!") + else: + print(f"✗ {failures} failures detected") + print(f"{'='*70}\n") + + return failures + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ip", nargs="?", default="192.168.0.240", help="Board IP (default: 192.168.0.240)") + parser.add_argument("--json", action="store_true", help="Output results as JSON") + args = parser.parse_args() + + failures = verify_board(args.ip) + + if args.json: + print(json.dumps(results, indent=2)) + + sys.exit(min(failures, 1)) diff --git a/olimex_ESP32-C5-EVB/build_and_deploy.sh b/olimex_ESP32-C5-EVB/build_and_deploy.sh new file mode 100755 index 0000000..beec7ce --- /dev/null +++ b/olimex_ESP32-C5-EVB/build_and_deploy.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Build and Deploy Script for ESP32-C5-EVB +# Usage: ./build_and_deploy.sh [--flash-only] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKETCH_DIR="$SCRIPT_DIR/esp32_arduino" +SKETCH_FILE="$SKETCH_DIR/esp32_arduino.ino" +DEVICE_PORT="${DEVICE_PORT:-/dev/ttyACM0}" +DEVICE_BAUD="${DEVICE_BAUD:-921600}" +DEVICE_IP="${DEVICE_IP:-192.168.0.241}" + +# ─ Colors ───────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ─ Functions ────────────────────────────────────────────────────────────────── +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[✓]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[⚠]${NC} $1"; } +log_error() { echo -e "${RED}[✗]${NC} $1"; } + +# ─ Check Prerequisites ──────────────────────────────────────────────────────── +log_info "Checking prerequisites..." + +if [ ! -f "$SKETCH_FILE" ]; then + log_error "Sketch not found: $SKETCH_FILE" + exit 1 +fi + +if ! command -v esptool.py &> /dev/null; then + log_error "esptool.py not found. Install: pip install esptool" + exit 1 +fi + +if [ ! -e "$DEVICE_PORT" ]; then + log_error "Device port not found: $DEVICE_PORT" + log_info "Available ports:" + ls -la /dev/ttyUSB* /dev/ttyACM* 2>/dev/null || echo " (none found)" + exit 1 +fi + +log_success "Prerequisites OK" + +# ─ Skip compilation if --flash-only ─────────────────────────────────────────── +if [ "$1" == "--flash-only" ]; then + log_warn "Skipping compilation (--flash-only)" + LATEST_BUILD=$(find /tmp -name "arduino_build_*" -type d 2>/dev/null | sort -V | tail -1) +else + # ─ Build ────────────────────────────────────────────────────────────────── + log_info "Compiling sketch (this may take 30-60 seconds)..." + log_warn "Please click 'Sketch → Verify' in Arduino IDE or use: arduino --verify --board esp32:esp32:esp32c5 '$SKETCH_FILE'" + log_info "" + log_info "Waiting for build to complete..." + + # Find the latest build directory created + BUILD_BEFORE=$(find /tmp -name "arduino_build_*" -type d 2>/dev/null | sort -V | tail -1) + START_TIME=$(date +%s) + + # Wait for new build or changes to existing build (max 120 seconds) + while true; do + LATEST_BUILD=$(find /tmp -name "arduino_build_*" -type d 2>/dev/null | sort -V | tail -1) + + if [ -n "$LATEST_BUILD" ] && [ -f "$LATEST_BUILD/esp32_arduino.ino.merged.bin" ]; then + MODIFIED=$(stat -f%m "$LATEST_BUILD/esp32_arduino.ino.merged.bin" 2>/dev/null || stat -c%Y "$LATEST_BUILD/esp32_arduino.ino.merged.bin") + CURRENT_TIME=$(date +%s) + + if [ $((CURRENT_TIME - MODIFIED)) -lt 10 ]; then + log_success "Build found and appears recent" + break + fi + fi + + CURRENT_TIME=$(date +%s) + if [ $((CURRENT_TIME - START_TIME)) -gt 120 ]; then + log_error "Timeout waiting for build. Please compile in Arduino IDE manually." + exit 1 + fi + + sleep 2 + done +fi + +# ─ Verify binary exists ─────────────────────────────────────────────────────── +if [ -z "$LATEST_BUILD" ] || [ ! -f "$LATEST_BUILD/esp32_arduino.ino.merged.bin" ]; then + log_error "Compiled binary not found" + exit 1 +fi + +BINARY_PATH="$LATEST_BUILD/esp32_arduino.ino.merged.bin" +BINARY_SIZE=$(ls -lh "$BINARY_PATH" | awk '{print $5}') +log_success "Binary found: $BINARY_PATH ($BINARY_SIZE)" + +# ─ Flash ────────────────────────────────────────────────────────────────────── +log_info "Erasing flash on $DEVICE_PORT..." +esptool.py --chip esp32c5 --port "$DEVICE_PORT" --baud "$DEVICE_BAUD" erase-flash + +log_info "Flashing firmware..." +cd "$LATEST_BUILD" +esptool.py --chip esp32c5 --port "$DEVICE_PORT" --baud "$DEVICE_BAUD" write-flash 0x0 esp32_arduino.ino.merged.bin + +log_success "Firmware flashed successfully!" + +# ─ Verify ───────────────────────────────────────────────────────────────────── +log_info "Waiting for device to boot (10 seconds)..." +sleep 10 + +log_info "Testing connectivity to $DEVICE_IP..." +if curl -s -f -u Ske087:Matei@123 "http://$DEVICE_IP/relay/status?relay=1" &> /dev/null; then + log_success "Device is online and responding!" + + # Test all endpoints + log_info "Testing API endpoints..." + echo -n " Relay 1: " + curl -s -u Ske087:Matei@123 "http://$DEVICE_IP/relay/status?relay=1" + echo -n " Relay 2: " + curl -s -u Ske087:Matei@123 "http://$DEVICE_IP/relay/status?relay=2" + echo -n " Input 1: " + curl -s -u Ske087:Matei@123 "http://$DEVICE_IP/input/status?input=1" + echo -n " Input 2: " + curl -s -u Ske087:Matei@123 "http://$DEVICE_IP/input/status?input=2" + + log_success "All tests passed!" +else + log_warn "Device not yet responding. This is normal if WiFi connection is in progress." + log_info "Check with: curl -u Ske087:Matei@123 http://$DEVICE_IP/relay/status?relay=1" +fi + +log_success "Deployment complete!" diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/__init__.py b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/__init__.py new file mode 100644 index 0000000..bfd6fbd --- /dev/null +++ b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/__init__.py @@ -0,0 +1,117 @@ +"""Olimex ESP32-C5-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-C5-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-C5-EVB integration initialized") + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Olimex ESP32-C5-EVB from a config entry.""" + _LOGGER.info("Setting up Olimex ESP32-C5-EVB entry: %s", entry.entry_id) + + host = entry.data.get(CONF_HOST, "192.168.0.240") + 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-C5-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-C5-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-C5-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-C5-EVB entry: %s", entry.entry_id) + return unload_ok + except Exception as err: + _LOGGER.error("Error unloading Olimex ESP32-C5-EVB entry: %s", err, exc_info=True) + return False diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/binary_sensor.py b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/binary_sensor.py new file mode 100644 index 0000000..e84724a --- /dev/null +++ b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/binary_sensor.py @@ -0,0 +1,90 @@ +"""Binary sensor platform for Olimex ESP32-C5-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 opto-isolated input. + + 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"Opto 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 opto logic before sending: + # state=True means triggered, state=False means not triggered + 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 opto-isolated input is triggered.""" + 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-C5 ({host})", + "manufacturer": "Olimex", + "model": "ESP32-C5-EVB", + } + except Exception as err: + _LOGGER.debug("Error getting device info: %s", err) + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": "Olimex ESP32-C5", + "manufacturer": "Olimex", + "model": "ESP32-C5-EVB", + } diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/config_flow.py b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/config_flow.py new file mode 100644 index 0000000..bc1029b --- /dev/null +++ b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for Olimex ESP32-C5-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 OlimexESP32C5ConfigFlow(config_entries.ConfigFlow, domain="olimex_esp32_c5"): + """Handle a config flow for Olimex ESP32-C5-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.240") + 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-C5 at %s", host) + return self.async_create_entry( + title=f"Olimex ESP32-C5 ({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-C5 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.240"): 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-C5 from YAML: %s", import_data) + host = import_data.get(CONF_HOST, "192.168.0.240") + 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-C5 from YAML at %s", host) + return self.async_create_entry( + title=f"Olimex ESP32-C5 ({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-C5 from YAML: %s", err) + + # If validation fails, still create entry but log warning + _LOGGER.warning("Could not validate Olimex ESP32-C5 at %s, creating entry anyway", host) + return self.async_create_entry( + title=f"Olimex ESP32-C5 ({host})", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_CALLBACK_IP: import_data.get(CONF_CALLBACK_IP, DEFAULT_CALLBACK_IP), + }, + ) diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/const.py b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/const.py new file mode 100644 index 0000000..9fe1cd9 --- /dev/null +++ b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/const.py @@ -0,0 +1,28 @@ +"""Constants for the Olimex ESP32-C5-EVB integration.""" + +DOMAIN = "olimex_esp32_c5" +MANUFACTURER = "Olimex" +MODEL = "ESP32-C5-EVB" +CHIP = "ESP32-C5" + +# 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 = 2 +NUM_INPUTS = 2 + +# Device info +DEVICE_INFO = { + "manufacturer": MANUFACTURER, + "model": MODEL, + "chip": CHIP, +} diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/manifest.json b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/manifest.json new file mode 100644 index 0000000..4668fba --- /dev/null +++ b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "olimex_esp32_c5", + "name": "Olimex ESP32-C5-EVB", + "codeowners": [], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/yourusername/olimex-esp32-c5-ha", + "iot_class": "local_polling", + "requirements": ["aiohttp>=3.8.0"], + "version": "0.1.0" +} diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/py.typed b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/strings.json b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/strings.json new file mode 100644 index 0000000..a7c23f6 --- /dev/null +++ b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure Olimex ESP32-C5-EVB", + "description": "Enter the IP address or hostname of your Olimex ESP32-C5-EVB board", + "data": { + "host": "Host", + "port": "Port", + "callback_ip": "Home Assistant IP" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the board. Check IP address and port.", + "unknown": "An unknown error occurred" + } + } +} diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/switch.py b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/switch.py new file mode 100644 index 0000000..0595051 --- /dev/null +++ b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/switch.py @@ -0,0 +1,117 @@ +"""Switch platform for Olimex ESP32-C5-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-C5 ({host})", + "manufacturer": "Olimex", + "model": "ESP32-C5-EVB", + } diff --git a/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/webhook.py b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/webhook.py new file mode 100644 index 0000000..137576a --- /dev/null +++ b/olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/webhook.py @@ -0,0 +1,29 @@ +"""Webhook handler for Olimex ESP32-C5-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/olimex_ESP32-C5-EVB/esp32_arduino/DEPLOYMENT_GUIDE.md b/olimex_ESP32-C5-EVB/esp32_arduino/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..7da0af0 --- /dev/null +++ b/olimex_ESP32-C5-EVB/esp32_arduino/DEPLOYMENT_GUIDE.md @@ -0,0 +1,299 @@ +# ESP32-C5 Arduino Deployment Guide + +Complete guide to compile and deploy the firmware to your Olimex ESP32-C5-EVB board. + +--- + +## ✓ Pre-Deployment Checklist + +- [ ] Arduino IDE installed (version 2.0+) +- [ ] ESP32 board package installed (version 3.0.0+) +- [ ] Olimex ESP32-C5-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** + +**⚠️ Arduino IDE 1.x (including the system package `arduino` on Linux) will NOT work with ESP32 core 3.x.** +Core 3.x uses nested variable references in its build system that only Arduino IDE 2.x resolves correctly. +Installing `arduino` via `apt` gives you IDE 1.8.19 — download the AppImage/zip directly from arduino.cc instead. + +--- + +## 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 | **ESP32C5 Dev Module** | +| USB CDC On Boot | **Enabled** ⚠️ **REQUIRED for Serial Monitor** | +| Flash Size | **4MB** | +| Flash Frequency | **80 MHz** | +| Flash Mode | **QIO** (default for C5) | +| PSRAM | Disabled | +| CPU Frequency | 240 MHz (WiFi) | +| Core Debug Level | None | +| Partition Scheme | **No OTA (2MB APP/2MB SPIFFS)** | +| Port | `/dev/ttyACM0` (Linux) or `/dev/cu.usbserial-*` (Mac) | + +> **Note:** The ESP32-C5 does not have a "USB Mode" menu. Its USB hardware is fixed (Hardware CDC). +> The correct partition scheme for 4 MB flash is **No OTA (2MB APP/2MB SPIFFS)** — this gives 2 MB app space vs 1 MB with `noota_3g`. + +**⚠️ IMPORTANT:** +- **USB CDC On Boot MUST be ENABLED** for Serial output to work in Arduino IDE Serial Monitor +- The device will show 2 serial ports when USB CDC is enabled: + - First port: USB CDC (for Serial.print output) + - Second port: USB-UART (legacy, can be ignored) + +**⚠️ Important:** USB CDC On Boot must be **Enabled** for serial output to work! + +--- + +## Step 4: Install Required Libraries + +The sketch uses these libraries. Install them via Sketch → Include Library → Manage Libraries: + +1. **PN532** (by Elechouse) + - Search: "PN532" + - Install the official PN532 library by Elechouse + - Version: 1.32 or later + +2. Other libraries (WiFi, WebServer, Preferences) are built-in to ESP32 core + +--- + +## Step 5: Configure Secrets + +1. Copy `secrets.h.example` to `secrets.h` in the same directory: + ```bash + cp esp32_arduino/secrets.h.example esp32_arduino/secrets.h + ``` + +2. Edit `secrets.h` with your credentials: + ```cpp + const char* WEB_USER = "admin"; + const char* WEB_PASSWORD = "admin"; // Change this! + const char* API_SECRET = ""; // Optional HMAC secret + ``` + +3. Also update WiFi credentials in `esp32_arduino.ino` (around line 58): + ```cpp + const char* ssid = "YOUR_SSID"; + const char* password = "YOUR_PASSWORD"; + ``` + +**⚠️ Never commit `secrets.h` to Git!** + +--- + +## Step 6: Flash the Board + +### Connect the Board: +1. Plug the Olimex ESP32-C5-EVB into your computer via USB +2. Wait for USB drivers to load (Windows: ~30 seconds) + +### In Arduino IDE: +1. Open `esp32_arduino.ino` +2. Tools → Port → Select your board's port + - **Linux:** `/dev/ttyUSB0`, `/dev/ttyUSB1`, or `/dev/ACM0` + - **macOS:** `/dev/cu.usbserial-*` or `/dev/cu.SLAB_USBtoUART` + - **Windows:** `COM3`, `COM4`, etc. + +3. Sketch → Upload (or press Ctrl+U / Cmd+U) + +4. Wait for compilation and flashing to complete (~30 seconds) + +### Expected Output: +``` +Connecting........___....._____ +esptool.py v4.5.1 +Serial port COM3 +Chip is ESP32-C5... +Uploading stub... +... +Leaving... +Hard resetting via RTS pin... +``` + +### If Upload Fails: + +**Problem:** "Failed to connect to ESP32" +- **Solution 1:** Try different baud rates (Tools → Upload Speed) +- **Solution 2:** Hold USER button during upload +- **Solution 3:** Different USB cable or port +- **Solution 4:** Update board drivers for your OS + +**Problem:** "No module named 'serial'" +- **Solution:** Update Arduino IDE to latest version + +--- + +## Step 7: Verify Installation + +After successful upload, the board will reboot. + +### Check Serial Monitor: +1. Tools → Serial Monitor (or Ctrl+Shift+M / Cmd+Shift+M) +2. Set baud rate to **115200** +3. Should see: + ``` + ================================= + ESP32-C5 Home Assistant Device + Arduino Framework + ================================= + GPIO initialized + Connecting to WiFi: YOUR_SSID (attempt 1/3) + ............................... + ✓ WiFi connected! + IP : 192.168.0.240 + RSSI : -42 dBm + MAC : xx:xx:xx:xx:xx:xx + ✓ HTTP server started on port 80 + ================================= + Ready! Try these endpoints: + http://192.168.0.240/api/status + ================================= + ``` + +### Test in Browser: +1. Open browser and go to: `http://192.168.0.240/` +2. Login with credentials from `secrets.h` (default: admin/admin) +3. Should see interactive control panel with relays and inputs + +### Test API: +```bash +curl http://192.168.0.240/api/status +# Expected response: +# {"input1":true,"input2":true,"relay1":false,"relay2":false,...} +``` + +--- + +## Step 8: Add to Home Assistant + +Once the board is verified and WiFi works: + +1. In Home Assistant: Settings → Devices & Services +2. Click "Create Automation" → Integration +3. Search for "Olimex ESP32-C5" +4. Click "Olimex ESP32-C5-EVB" +5. Enter: + - **Host:** 192.168.0.240 + - **Port:** 80 + - **Callback IP:** Your Home Assistant server IP (usually 192.168.0.1 or 192.168.0.100) +6. Click "Create Entry" + +Home Assistant will automatically: +- Discover relays and inputs +- Register a webhook for input events +- Create switches and binary sensors + +--- + +## Troubleshooting + +### Board doesn't show in Port menu: +- Install drivers: https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers +- Try different USB cable +- Check Device Manager (Windows) or System Report (Mac) + +### Serial output garbled: +- Check baud rate is **115200** in Serial Monitor +- Try different baud rates (9600, 57600) + +### WiFi connection fails: +- Verify SSID and password in `esp32_arduino.ino` +- Check 2.4 GHz WiFi is enabled (C5 may not support 5 GHz yet) +- Power cycle board and router + +### NFC not detected: +- Check PN532 is soldered correctly to UEXT connector +- Verify TX=GPIO26, RX=GPIO25 wiring +- Set PN532 DIP switches to HSU mode (both = 0) +- Check serial output for baud detection attempts + +### Can't reach board from Home Assistant: +- Verify board IP: check serial monitor output +- Ping from HA machine: `ping 192.168.0.240` +- Check firewall allows port 80 +- Ensure HA and board are on same network + +--- + +## Updating Firmware + +To update code after initial deployment: + +1. Make changes in Arduino IDE +2. Edit WiFi credentials if needed +3. Sketch → Upload +4. Monitor serial output to verify successful flash + +--- + +## Protecting Your Device + +**Before deployment to production:** + +1. Change default credentials in `secrets.h`: + ```cpp + const char* WEB_USER = "secure_user"; + const char* WEB_PASSWORD = "strong_password_123"; + ``` + +2. Set an API secret for request signing: + ```cpp + const char* API_SECRET = "my_random_secret_key_xyz123"; + ``` + +3. Only expose board on trusted networks (private LAN) + +4. Consider firewall rules to restrict access + +--- + +## Next Steps + +- Check [README.md](../README.md) for API endpoints and features +- Configure Home Assistant automations with the new switches/sensors +- Optional: Add NFC card reader to UEXT connector for access control +- Explore [custom_components](../custom_components/) for integration details + +--- + +## Support + +- Olimex Board: https://github.com/OLIMEX/ESP32-C5-EVB +- ESP32 Arduino: https://github.com/espressif/arduino-esp32 +- PN532 Library: https://github.com/elechouse/PN532 diff --git a/olimex_ESP32-C5-EVB/esp32_arduino/esp32_arduino.ino b/olimex_ESP32-C5-EVB/esp32_arduino/esp32_arduino.ino new file mode 100644 index 0000000..57e5c0b --- /dev/null +++ b/olimex_ESP32-C5-EVB/esp32_arduino/esp32_arduino.ino @@ -0,0 +1,1418 @@ +/** + * ESP32-C5 Home Assistant Integration + * Arduino IDE Project + * V10 + * Board: ESP32C5 Dev Module + * Flash Size: 4MB + * USB CDC On Boot: ENABLED (native USB serial for debug output) + * + * Provides REST API for Home Assistant integration + * + * NOTE: C5-EVB has 2 relays (GPIO24, GPIO23) and 2 opto-isolated inputs (GPIO4, GPIO5) + * NFC (PN532) via UART: TX=GPIO26, RX=GPIO25 (HSU mode) + * Serial output via USB CDC: Native USB (when USB CDC On Boot is ENABLED) + */ +// version 2.2.1 Persistent NFC config via NVS — C5-EVB variant +#include +#include +#include +#include + +Preferences prefs; + +// ── NFC: PN532 over UART (HSU mode) via UEXT ─────────────────────────────── +// C5-EVB UEXT pins: +// UEXT pin 3 (TXD) → GPIO26 (ESP32 → PN532 RXD) +// UEXT pin 4 (RXD) → GPIO25 (PN532 TXD → ESP32) +// PN532 module wiring note: set HSU mode — DIP1 = 0, DIP2 = 0 +#include +#include + +#define NFC_TX_PIN 26 // UEXT pin 3 — ESP32 transmits to PN532 +#define NFC_RX_PIN 25 // UEXT pin 4 — ESP32 receives from PN532 +#define NFC_POLL_MS 500 // idle detection interval (ms) + +// ── Credentials & shared secrets ───────────────────────────────────────────── +// Loaded from secrets.h which is NOT committed to version control. +// Copy secrets.h.example → secrets.h, fill in your values, then flash. +#include "secrets.h" +#include "mbedtls/md.h" // HMAC-SHA256 — API request authentication +#include // time() — NTP-based timestamp validation + +HardwareSerial nfcSerial(1); // UART1 +PN532_HSU pn532hsu(nfcSerial); +PN532 nfc(pn532hsu); + +bool nfc_initialized = false; +String nfc_last_uid = ""; +unsigned long nfc_last_poll_ms = 0; +int nfc_miss_count = 0; // consecutive polls with no card detected +unsigned long nfc_absent_since = 0; // millis() when card first went absent (GRANTED→absent) +// NFC Access Control +char nfc_auth_uid[32] = ""; // authorized card UID; empty = any card triggers +int nfc_relay_num = 1; // relay to open on match (1-2 for C5) +unsigned long nfc_pulse_ms = 0; // relay release delay after card removed (ms); 0 = off after ~1 s debounce +char nfc_access_state[8] = "idle"; // "idle" | "granted" | "denied" +bool nfc_enabled = false; // access control module on/off; off by default +// ──────────────────────────────────────────────────────────────────────────── + +// Web server on port 80 +WebServer server(80); + +// GPIO pins - Olimex ESP32-C5-EVB board configuration +// NOTE: C5-EVB has different pin mappings from C6-EVB +const int LED_PIN = 27; // Onboard LED (active LOW) +const int BUT_PIN = 28; // Onboard button (active LOW, internal pull-up) +const int RELAY_1_PIN = 24; // Relay 1 (250V / 10A) +const int RELAY_2_PIN = 23; // Relay 2 (250V / 10A) +const int DIN1_PIN = 4; // Opto-Isolated Digital Input 1 (110VAC-240VAC) +const int DIN2_PIN = 5; // Opto-Isolated Digital Input 2 (110VAC-240VAC) + +// State tracking +bool relay1_state = false; +bool relay2_state = false; +bool led_state = false; + +// Input state tracking - for change detection +bool input1_state = true; // HIGH when not triggered +bool input2_state = true; +bool last_input1_state = true; +bool last_input2_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; +unsigned long last_temp_update = 0; + +// ── Forward declarations ────────────────────────────────────────────────────── +void handleRoot(); +void handleStatus(); +void handleRelayOn(); +void handleRelayOff(); +void handleRelayStatus(); +void handleInputStatus(); +void handleRelayToggle(); +void handleRegister(); +void handleLEDOn(); +void handleLEDOff(); +void handleNotFound(); +void handleNFCStatus(); +void handleNFCConfigGet(); +void handleNFCConfigSet(); +void handleNFCEnable(); +void handleNFCProbe(); +void handleDebug(); +void postNFCEvent(const String& uid); +void postInputEvent(int input_num, bool state); +int nfcRelayPin(int rnum); +bool postJsonToHA(const String& json); +bool verifyAPIRequest(); +bool requireAuth(); +void checkInputChanges(); +void scanWiFiNetworks(); +// ───────────────────────────────────────────────────────────────────────────── + +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-C5 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); + // Initialize GPIO - Inputs with pull-up (opto-isolated inputs) + pinMode(BUT_PIN, INPUT_PULLUP); + pinMode(DIN1_PIN, INPUT_PULLUP); + pinMode(DIN2_PIN, INPUT_PULLUP); + // Set all outputs to idle state — active-LOW relays: HIGH = OFF + digitalWrite(LED_PIN, HIGH); // HIGH = OFF (active LOW) — will turn ON when WiFi connects + digitalWrite(RELAY_1_PIN, LOW); // LOW = relay OFF (active HIGH, same as C6) + digitalWrite(RELAY_2_PIN, LOW); // LOW = relay OFF (active HIGH, same as C6) + + Serial.println("GPIO initialized - LED OFF (will turn ON when WiFi connects)"); + + // ── Load persistent NFC config from NVS ────────────────────────────────── + prefs.begin("nfc_cfg", true); // read-only namespace + String saved_uid = prefs.getString("auth_uid", ""); + if (saved_uid.length() > 0 && saved_uid.length() < sizeof(nfc_auth_uid)) { + saved_uid.toCharArray(nfc_auth_uid, sizeof(nfc_auth_uid)); + } + nfc_relay_num = prefs.getInt("relay_num", 1); + nfc_pulse_ms = prefs.getULong("pulse_ms", 0); + nfc_enabled = prefs.getBool("enabled", false); + prefs.end(); + Serial.printf("NFC config loaded: auth='%s' relay=%d pulse=%lu ms\n", + nfc_auth_uid, nfc_relay_num, nfc_pulse_ms); + // ───────────────────────────────────────────────────────────────────────── + + // ── Load persistent HA registration from NVS ───────────────────────────── + prefs.begin("ha_cfg", true); // read-only namespace + String saved_url = prefs.getString("callback_url", ""); + if (saved_url.length() > 0 && saved_url.length() < sizeof(ha_callback_url)) { + saved_url.toCharArray(ha_callback_url, sizeof(ha_callback_url)); + ha_registered = true; + } + prefs.end(); + if (ha_registered) { + Serial.printf("HA registration loaded: %s\n", ha_callback_url); + } else { + Serial.println("HA registration: not registered"); + } + // ───────────────────────────────────────────────────────────────────────── + + // Configure WiFi + Serial.println("\n--- WiFi Configuration ---"); + WiFi.disconnect(true); // reset radio & erase any stale state + delay(200); + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + + // Static IP configuration from secrets.h + if (USE_STATIC_IP) { + IPAddress staticIP; + IPAddress gateway; + IPAddress subnet; + IPAddress dns1; + IPAddress dns2; + + if (staticIP.fromString(STATIC_IP_ADDR) && + gateway.fromString(STATIC_GATEWAY) && + subnet.fromString(STATIC_SUBNET) && + dns1.fromString(STATIC_DNS1) && + dns2.fromString(STATIC_DNS2)) { + WiFi.config(staticIP, gateway, subnet, dns1, dns2); + Serial.printf("Static IP configured: %s\n", STATIC_IP_ADDR); + } else { + Serial.println("⚠ Invalid static IP configuration — falling back to DHCP"); + } + } else { + Serial.println("Using DHCP for IP configuration"); + } + + bool wifi_ok = false; + for (int pass = 1; pass <= 3 && !wifi_ok; pass++) { + Serial.printf("Connecting to WiFi: %s (attempt %d/3)\n", WIFI_SSID, pass); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + for (int t = 0; t < 40 && WiFi.status() != WL_CONNECTED; t++) { + delay(500); + Serial.print("."); + } + Serial.println(); + if (WiFi.status() == WL_CONNECTED) wifi_ok = true; + else if (pass < 3) { + Serial.println(" Not connected yet, retrying..."); + WiFi.disconnect(true); + delay(1000); + WiFi.mode(WIFI_STA); + // WiFi config already set above with static IP or DHCP + } + } + + if (wifi_ok) { + Serial.println("\n✓ WiFi connected!"); + Serial.print(" IP : "); Serial.println(WiFi.localIP()); + Serial.print(" RSSI : "); Serial.print(WiFi.RSSI()); Serial.println(" dBm"); + Serial.print(" MAC : "); Serial.println(WiFi.macAddress()); + digitalWrite(LED_PIN, LOW); // LED steady ON = WiFi connected (active LOW) + led_state = true; + } else { + Serial.println("\n✗ WiFi connection failed after 3 attempts."); + Serial.print(" Status code: "); Serial.println(WiFi.status()); + Serial.println(" Check: correct WIFI_SSID/WIFI_PASSWORD, 2.4 GHz band, board in range."); + Serial.println(" HTTP server will start anyway — accessible once WiFi reconnects."); + digitalWrite(LED_PIN, HIGH); // LED OFF = no WiFi + led_state = false; + } + + // ── NTP time sync ──────────────────────────────────────────────────────────── + configTime(0, 0, "pool.ntp.org", "time.google.com"); + if (wifi_ok) { + Serial.print("NTP sync "); + time_t _ntp_now = time(nullptr); + for (int _i = 0; _i < 20 && _ntp_now < 1577836800L; _i++) { + delay(500); Serial.print("."); _ntp_now = time(nullptr); + } + Serial.println(); + if (_ntp_now > 1577836800L) { + char _tbuf[32]; + strftime(_tbuf, sizeof(_tbuf), "%Y-%m-%d %H:%M:%S UTC", gmtime(&_ntp_now)); + Serial.printf("✓ NTP synced: %s\n", _tbuf); + } else { + Serial.println("⚠ NTP sync failed — timestamp check disabled"); + } + } + // ───────────────────────────────────────────────────────────────────────── + + // 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/toggle", HTTP_POST, handleRelayToggle); + 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); + server.on("/nfc/status", HTTP_GET, handleNFCStatus); + server.on("/nfc/config", HTTP_GET, handleNFCConfigGet); + server.on("/nfc/config", HTTP_POST, handleNFCConfigSet); + server.on("/nfc/enable", HTTP_POST, handleNFCEnable); + server.on("/nfc/probe", HTTP_POST, handleNFCProbe); // Manual NFC detection at runtime + server.on("/debug", HTTP_GET, handleDebug); + + // ── Collect HMAC auth headers for verifyAPIRequest() ───────────────────────── + const char* _api_hdrs[] = {"X-Request-Time", "X-Request-Sig"}; + server.collectHeaders(_api_hdrs, 2); + + // 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"); + + // ── NFC (PN532 HSU) initialisation — multi-baud auto-detect ───────────── + Serial.println("--- NFC (PN532 HSU) Initialization ---"); + + const long NFC_BAUDS[] = {115200, 9600, 57600, 38400}; + const int NFC_NBAUDS = 4; + const int NFC_PINS[2][2] = {{NFC_RX_PIN, NFC_TX_PIN}, + {NFC_TX_PIN, NFC_RX_PIN}}; + + uint32_t versiondata = 0; + long found_baud = 0; + int found_rx = NFC_RX_PIN; + int found_tx = NFC_TX_PIN; + + for (int pi = 0; pi < 2 && !versiondata; pi++) { + int rx = NFC_PINS[pi][0]; + int tx = NFC_PINS[pi][1]; + for (int bi = 0; bi < NFC_NBAUDS && !versiondata; bi++) { + long baud = NFC_BAUDS[bi]; + Serial.printf(" Trying baud=%-7ld RX=GPIO%d TX=GPIO%d ...\n", baud, rx, tx); + nfcSerial.begin(baud, SERIAL_8N1, rx, tx); + // Serve HTTP and feed watchdog during probe delay instead of blocking + { unsigned long _t = millis(); while (millis() - _t < 500) { server.handleClient(); yield(); } } + nfc.begin(); // PN532_HSU::begin() is a no-op — pins already set + versiondata = nfc.getFirmwareVersion(); + if (!versiondata) { + unsigned long _t = millis(); while (millis() - _t < 200) { server.handleClient(); yield(); } + } else { found_baud = baud; found_rx = rx; found_tx = tx; } + } + } + + if (!versiondata) { + Serial.println("✗ PN532 not detected with any baud/pin combination."); + Serial.println(" Hardware checklist:"); + Serial.println(" 1. DIP/solder-jumpers on PN532 board: BOTH = 0 (HSU mode)"); + Serial.println(" Some boards label them SEL0/SEL1 or I0/I1 — both must be LOW."); + Serial.println(" 2. Power: UEXT pin 2 = 3V3, pin 1 = GND. Measure with multimeter."); + Serial.println(" 3. Wiring: UEXT pin 3 (GPIO26) ↔ PN532 RXD"); + Serial.println(" UEXT pin 4 (GPIO25) ↔ PN532 TXD"); + Serial.println(" 4. Some PN532 breakouts need a 100 ohm series resistor on TX line."); + } else { + Serial.printf("✓ PN532 found! baud=%ld RX=GPIO%d TX=GPIO%d\n", + found_baud, found_rx, found_tx); + Serial.print(" Chip: PN5"); + Serial.println((versiondata >> 24) & 0xFF, HEX); + Serial.printf(" Firmware: %d.%d\n", + (versiondata >> 16) & 0xFF, (versiondata >> 8) & 0xFF); + nfcSerial.begin(found_baud, SERIAL_8N1, found_rx, found_tx); + nfc.SAMConfig(); + nfc_initialized = true; + Serial.println("✓ NFC ready — waiting for ISO14443A / Mifare cards"); + } + // ────────────────────────────────────────────────────────────────────────── +} + +void loop() { + server.handleClient(); + + // Simulate temperature reading — update every 5 s + if (millis() - last_temp_update >= 5000) { + last_temp_update = millis(); + 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(); + } + + // ── WiFi LED status: steady ON = connected, OFF = disconnected ─────────── + static bool last_wifi_state = false; + bool wifi_now = (WiFi.status() == WL_CONNECTED); + if (wifi_now != last_wifi_state) { + last_wifi_state = wifi_now; + if (wifi_now) { + digitalWrite(LED_PIN, LOW); // LED ON — WiFi reconnected + led_state = true; + Serial.printf("✓ WiFi reconnected: %s\n", WiFi.localIP().toString().c_str()); + } else { + digitalWrite(LED_PIN, HIGH); // LED OFF — WiFi lost + led_state = false; + Serial.println("⚠ WiFi connection lost"); + } + } + // ───────────────────────────────────────────────────────────────────────── + + // ── NFC: two-speed polling ──────────────────────────────────────────────── + // IDLE : fast poll every NFC_POLL_MS (500 ms), 50 ms RF timeout + // GRANTED/DENIED: fast presence-check every 500 ms; relay turns OFF after + // nfc_pulse_ms of continuous absence ("Absent timeout") + { + bool is_active_state = (strcmp(nfc_access_state, "granted") == 0 || + strcmp(nfc_access_state, "denied") == 0); + unsigned long nfc_interval = is_active_state ? 500UL : (unsigned long)NFC_POLL_MS; + if (nfc_enabled && nfc_initialized && millis() - nfc_last_poll_ms >= nfc_interval) { + nfc_last_poll_ms = millis(); + uint16_t rf_timeout = is_active_state ? 500 : 50; + uint8_t uid[7] = {0}; + uint8_t uidLen = 0; + bool found = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLen, rf_timeout); + if (found && uidLen > 0) { + nfc.inRelease(1); // deselect → card returns to ISO14443A IDLE state for next poll + String uid_str = ""; + for (uint8_t i = 0; i < uidLen; i++) { + if (uid[i] < 0x10) uid_str += "0"; + uid_str += String(uid[i], HEX); + if (i < uidLen - 1) uid_str += ":"; + } + uid_str.toUpperCase(); + if (strcmp(nfc_access_state, "granted") == 0) { + // Only confirm presence (and reset absence timer) for the authorized card. + // Ignore ghost reads or unexpected UIDs to avoid prolonging relay-on. + if (uid_str == String(nfc_auth_uid)) { + nfc_miss_count = 0; + nfc_absent_since = 0; + Serial.printf("NFC: authorized card confirmed present UID=%s\n", uid_str.c_str()); + } else { + Serial.printf("NFC: unexpected UID=%s in GRANTED state — ignoring\n", uid_str.c_str()); + } + } else if (strcmp(nfc_access_state, "denied") == 0 && uid_str == nfc_last_uid) { + nfc_miss_count = 0; // same denied card still present + Serial.printf("NFC: denied card still present UID=%s\n", uid_str.c_str()); + } else { + // New card (or re-evaluation after config change) + nfc_miss_count = 0; + nfc_absent_since = 0; + nfc_last_uid = uid_str; + Serial.printf("NFC: card UID=%s\n", uid_str.c_str()); + postNFCEvent(uid_str); + if (strlen(nfc_auth_uid) == 0) { + strcpy(nfc_access_state, "denied"); + Serial.println("NFC: ACCESS DENIED — no authorized UID configured. Set one via /nfc/config."); + } else if (uid_str == String(nfc_auth_uid)) { + strcpy(nfc_access_state, "granted"); + int gpin = nfcRelayPin(nfc_relay_num); + if (gpin >= 0) { + digitalWrite(gpin, HIGH); // relay ON (active HIGH) + switch (nfc_relay_num) { + case 1: relay1_state = true; break; + case 2: relay2_state = true; break; + } + } + Serial.printf("NFC: ACCESS GRANTED relay=%d ON (absent-timeout=%lums)\n", + nfc_relay_num, nfc_pulse_ms); + } else { + strcpy(nfc_access_state, "denied"); + Serial.printf("NFC: ACCESS DENIED UID=%s\n", uid_str.c_str()); + } + } + } else { + // No card detected this poll + if (strcmp(nfc_access_state, "granted") == 0) { + // Track first miss to start the release-delay timer + if (nfc_absent_since == 0) { + nfc_absent_since = millis(); + } + nfc_miss_count++; + // Relay turns OFF when BOTH: + // • 2 consecutive missed polls (RF debounce, ~1 s) + // • nfc_pulse_ms have elapsed since first miss (0 = off immediately after debounce) + if (nfc_miss_count >= 2 && millis() - nfc_absent_since >= nfc_pulse_ms) { + int pin = nfcRelayPin(nfc_relay_num); + if (pin >= 0) { + digitalWrite(pin, LOW); // relay OFF (active HIGH) + switch (nfc_relay_num) { + case 1: relay1_state = false; break; + case 2: relay2_state = false; break; + } + } + nfc_absent_since = 0; + nfc_miss_count = 0; + strcpy(nfc_access_state, "idle"); + nfc_last_uid = ""; + Serial.printf("NFC: card gone — relay %d OFF\n", nfc_relay_num); + } else { + Serial.printf("NFC: absent miss=%d/%d (delay=%lu/%lu ms)\n", + nfc_miss_count, 2, + nfc_absent_since > 0 ? millis() - nfc_absent_since : 0UL, + nfc_pulse_ms); + } + } else if (strcmp(nfc_access_state, "denied") == 0) { + nfc_miss_count++; + if (nfc_miss_count >= 2) { + nfc_miss_count = 0; + nfc_absent_since = 0; + strcpy(nfc_access_state, "idle"); + nfc_last_uid = ""; + } + } else { + nfc_miss_count = 0; // idle — reset counter + nfc_absent_since = 0; + } + } + } + } + // ────────────────────────────────────────────────────────────────────────── + yield(); +} + +// ============================================ +// API Handlers +// ============================================ + +void handleRoot() { + if (!requireAuth()) return; + + // Read all inputs first to get current state + input1_state = digitalRead(DIN1_PIN); + input2_state = digitalRead(DIN2_PIN); + + String html = ""; + html += "ESP32-C5 Device"; + html += ""; + html += ""; + // ── JavaScript ───────────────────────────────────────────────────────────── + html += ""; + // ── Body ─────────────────────────────────────────────────────────────────── + html += ""; + html += "
"; + html += "

🔌 ESP32-C5

"; + html += "
"; + html += ""; + html += "
"; + html += "
"; + + // Device Info + html += "
"; + html += "
"; + html += "IP " + WiFi.localIP().toString() + ""; + html += "RSSI " + String(WiFi.RSSI()) + " dBm"; + html += "Temp " + String(temperature, 1) + " °C"; + html += "Uptime " + String(millis() / 1000) + " s"; + html += "
"; + + // Inputs + Relays side by side + html += "
"; + // Inputs + html += "

Opto Inputs

"; + bool inputStates[3] = {false, input1_state, input2_state}; + for (int i = 1; i <= 2; i++) { + bool triggered = !inputStates[i]; + html += "
"; + html += "
"; + html += "
IN " + String(i) + "
"; + html += "
" + String(triggered ? "TRIGGERED" : "NOT TRIGGERED") + "
"; + html += "
"; + } + html += "
"; + // Relays + html += "

Relays (250V/10A)

"; + bool relayStates[3] = {false, relay1_state, relay2_state}; + for (int i = 1; i <= 2; i++) { + bool on = relayStates[i]; + html += ""; + } + html += "
"; + html += "
"; // .card-row + + // LED Control + html += "

LED Control

"; + html += ""; + html += ""; + html += "   Status: " + String(led_state ? "ON" : "OFF") + "
"; + + // NFC Access Control + // nfc_present_now: true whenever a card UID is in the field (granted OR denied) + // This drives the "Use as authorized" button and the NFC LED in the static HTML. + bool nfc_present_now = nfc_initialized && nfc_last_uid.length() > 0; + String ac_class = String(nfc_access_state) == "granted" ? "nfc-granted" : + String(nfc_access_state) == "denied" ? "nfc-denied" : "nfc-idle"; + String ac_text = String(nfc_access_state) == "granted" ? "ACCESS GRANTED" : + String(nfc_access_state) == "denied" ? "ACCESS DENIED" : "Waiting for card"; + html += "

NFC Access Control (PN532 — UEXT)

"; + html += "
"; + html += "Module: " + String(nfc_enabled ? "Active" : "Disabled") + ""; + html += ""; + html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += "
Detected card UID
"; + html += "
" + (nfc_last_uid.length() > 0 ? nfc_last_uid : "No card inserted") + "
"; + html += "
"; + html += ""; + html += "
"; + html += "
" + ac_text + "
"; + html += "

Current Settings

"; + html += ""; + html += ""; + if (strlen(nfc_auth_uid) > 0) { + html += ""; + html += ""; + } else { + html += ""; + } + html += ""; + html += ""; + html += ""; + html += "
Authorized card" + String(nfc_auth_uid) + "None — no card authorized yet
Trigger relayRelay " + String(nfc_relay_num) + "
Relay release delay" + String(nfc_pulse_ms) + " ms (0 = off when card removed)
"; + html += "

Edit Settings

"; + html += "
"; + html += "
Authorized UID
"; + html += "
"; + html += "
Trigger relay
"; + html += "
"; + html += "
Relay release delay (ms) — 0 = off when card removed
"; + html += "
"; + html += ""; + html += "
"; + if (!nfc_initialized) { + html += "

✗ PN532 not detected — check UEXT wiring (TX=GPIO26, RX=GPIO25)

"; + } + if (nfc_initialized && strlen(nfc_auth_uid) == 0) { + html += "

⚠ No authorized UID — present a card, click “Use as authorized” then Save.

"; + } + html += "
"; + + // HA Webhook + 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 += "
"; + + // API reference + html += "

API Endpoints

"; + html += "GET /api/status  •  POST /relay/on?relay=1-2  •  POST /relay/off?relay=1-2
"; + html += "GET /input/status?input=1-2  •  POST /led/on  •  POST /led/off
"; + html += "GET /nfc/status  •  GET /nfc/config  •  POST /nfc/config?auth_uid=&relay=&pulse_ms=
"; + html += "POST /nfc/enable?state=0|1  •  POST /register?callback_url=..."; + html += "
"; + + html += "
"; + + server.send(200, "text/html", html); +} + +bool requireAuth() { + if (!server.authenticate(WEB_USER, WEB_PASSWORD)) { + server.requestAuthentication(BASIC_AUTH, "ESP32-C5 Control Panel", + "Login required"); + return false; + } + return true; +} + +bool verifyAPIRequest() { + if (server.authenticate(WEB_USER, WEB_PASSWORD)) return true; + + if (strlen(API_SECRET) == 0) return true; + + String ts_str = server.header("X-Request-Time"); + String sig_str = server.header("X-Request-Sig"); + + if (ts_str.length() == 0 || sig_str.length() == 0) { + server.send(401, "application/json", "{\"error\":\"missing auth headers\"}"); + return false; + } + + time_t now = time(nullptr); + if (now > 1000000000L) { + long ts = ts_str.toInt(); + if (abs((long)now - ts) > 60) { + server.send(401, "application/json", "{\"error\":\"timestamp out of window\"}"); + return false; + } + } + + String method = (server.method() == HTTP_POST) ? "POST" : "GET"; + String msg = method + ":" + server.uri() + ":" + ts_str; + + uint8_t hmac_out[32]; + mbedtls_md_context_t ctx; + mbedtls_md_init(&ctx); + mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1); + mbedtls_md_hmac_starts(&ctx, + (const unsigned char *)API_SECRET, strlen(API_SECRET)); + mbedtls_md_hmac_update(&ctx, + (const unsigned char *)msg.c_str(), msg.length()); + mbedtls_md_hmac_finish(&ctx, hmac_out); + mbedtls_md_free(&ctx); + + char expected[65]; + for (int i = 0; i < 32; i++) + sprintf(expected + i * 2, "%02x", hmac_out[i]); + expected[64] = '\0'; + + if (sig_str.length() != 64) { + server.send(401, "application/json", "{\"error\":\"invalid signature\"}"); + return false; + } + uint8_t diff = 0; + for (int i = 0; i < 64; i++) + diff |= (uint8_t)sig_str[i] ^ (uint8_t)expected[i]; + + if (diff != 0) { + server.send(401, "application/json", "{\"error\":\"invalid signature\"}"); + return false; + } + return true; +} + +static unsigned long last_status_log = 0; +static int status_request_count = 0; + +void handleStatus() { + if (!verifyAPIRequest()) return; + status_request_count++; + + input1_state = digitalRead(DIN1_PIN); + input2_state = digitalRead(DIN2_PIN); + + String json = "{"; + json += "\"input1\":" + String(input1_state ? "true" : "false") + ","; + json += "\"input2\":" + String(input2_state ? "true" : "false") + ","; + json += "\"relay1\":" + String(relay1_state ? "true" : "false") + ","; + json += "\"relay2\":" + String(relay2_state ? "true" : "false") + ","; + json += "\"led\":" + String(led_state ? "true" : "false") + ","; + bool nfc_present = nfc_initialized && nfc_last_uid.length() > 0; + json += "\"nfc_initialized\":" + String(nfc_initialized ? "true" : "false") + ","; + json += "\"nfc_card_present\":" + String(nfc_present ? "true" : "false") + ","; + json += "\"nfc_last_uid\":\"" + nfc_last_uid + "\","; + json += "\"nfc_access_state\":\"" + String(nfc_access_state) + "\","; + json += "\"nfc_auth_uid\":\"" + String(nfc_auth_uid) + "\","; + json += "\"nfc_relay_num\":" + String(nfc_relay_num) + ","; + json += "\"nfc_pulse_ms\":" + String(nfc_pulse_ms) + ","; + json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false"); + json += "}"; + + server.send(200, "application/json", json); + + 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 (!verifyAPIRequest()) return; + 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; + default: + server.send(400, "application/json", "{\"error\":\"Invalid relay number (1-2)\"}"); + return; + } + + digitalWrite(pin, HIGH); // active HIGH: HIGH = relay ON + *state_ptr = true; + server.send(200, "application/json", "{\"status\":\"ok\",\"state\":true}"); + Serial.printf("Relay %d ON\n", relay_num); +} + +void handleRelayOff() { + if (!verifyAPIRequest()) return; + 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; + default: + server.send(400, "application/json", "{\"error\":\"Invalid relay number (1-2)\"}"); + return; + } + + digitalWrite(pin, LOW); // active HIGH: LOW = relay OFF + *state_ptr = false; + server.send(200, "application/json", "{\"status\":\"ok\",\"state\":false}"); + Serial.printf("Relay %d OFF\n", relay_num); +} + +void handleRelayToggle() { + if (!verifyAPIRequest()) return; + 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; + default: + server.send(400, "application/json", "{\"error\":\"Invalid relay number (1-2)\"}"); + return; + } + + // Flip current state (active HIGH: HIGH = ON, LOW = OFF) + bool new_state = !(*state_ptr); + digitalWrite(pin, new_state ? HIGH : LOW); + *state_ptr = new_state; + String json = "{\"status\":\"ok\",\"state\":" + String(new_state ? "true" : "false") + "}"; + server.send(200, "application/json", json); + Serial.printf("Relay %d TOGGLE → %s\n", relay_num, new_state ? "ON" : "OFF"); +} + +void handleRelayStatus() { + if (!verifyAPIRequest()) return; + 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; + default: + server.send(400, "application/json", "{\"error\":\"Invalid relay number (1-2)\"}"); + return; + } + + String json = "{\"state\":" + String(state ? "true" : "false") + "}"; + server.send(200, "application/json", json); +} + +void handleInputStatus() { + if (!verifyAPIRequest()) return; + 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; + default: + server.send(400, "application/json", "{\"error\":\"Invalid input number (1-2)\"}"); + 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() { + if (!verifyAPIRequest()) return; + digitalWrite(LED_PIN, LOW); // LED is active-low + led_state = true; + server.send(200, "application/json", "{\"status\":\"ok\"}"); + Serial.println("LED ON"); +} + +void handleLEDOff() { + if (!verifyAPIRequest()) return; + 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); +} + +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; + + prefs.begin("ha_cfg", false); + prefs.putString("callback_url", ha_callback_url); + prefs.end(); + + 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; + + bool curr_input1 = digitalRead(DIN1_PIN); + bool curr_input2 = digitalRead(DIN2_PIN); + + 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); + } +} + +bool postJsonToHA(const String& json) { + if (!ha_registered || strlen(ha_callback_url) == 0) return false; + + String url_str = String(ha_callback_url); + int protocol_end = url_str.indexOf("://"); + if (protocol_end < 0) return false; + + int host_start = protocol_end + 3; + int port_sep = url_str.indexOf(":", host_start); + int path_start = url_str.indexOf("/", host_start); + if (path_start < 0) path_start = url_str.length(); + if (port_sep < 0 || port_sep > path_start) port_sep = -1; + + String host = url_str.substring(host_start, (port_sep >= 0) ? port_sep : path_start); + int port = 80; + if (port_sep >= 0) { + int colon_end = url_str.indexOf("/", port_sep); + if (colon_end < 0) colon_end = url_str.length(); + port = url_str.substring(port_sep + 1, colon_end).toInt(); + } + String path = url_str.substring(path_start); + + WiFiClient client; + client.setTimeout(3000); + if (!client.connect(host.c_str(), port)) { + Serial.printf("HA POST: failed to connect to %s:%d\n", host.c_str(), port); + return false; + } + 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); + + unsigned long deadline = millis() + 3000; + while (client.connected() && millis() < deadline) { + if (client.available()) client.read(); + else delay(1); + } + client.stop(); + return true; +} + +void postInputEvent(int input_num, bool state) { + if (!ha_registered || strlen(ha_callback_url) == 0) return; + bool triggered = !state; + Serial.printf("Input %d event: %s (raw_state=%d) - POSTing to HA\n", + input_num, triggered ? "input_on" : "input_off", state); + String json = "{\"input\":" + String(input_num) + + ",\"state\":" + (triggered ? "true" : "false") + "}"; + if (postJsonToHA(json)) + Serial.printf("Input %d event posted successfully\n", input_num); +} + +void handleNFCEnable() { + if (!verifyAPIRequest()) return; + if (!server.hasArg("state")) { + server.send(400, "application/json", "{\"error\":\"Missing state (0 or 1)\"}"); + return; + } + nfc_enabled = (server.arg("state").toInt() != 0); + if (!nfc_enabled) { + if (strcmp(nfc_access_state, "granted") == 0) { + int pin = nfcRelayPin(nfc_relay_num); + if (pin >= 0) { + digitalWrite(pin, LOW); // active HIGH: LOW = relay OFF + switch (nfc_relay_num) { + case 1: relay1_state = false; break; + case 2: relay2_state = false; break; + } + } + } + strcpy(nfc_access_state, "idle"); + nfc_last_uid = ""; + nfc_miss_count = 0; + nfc_absent_since = 0; + } + prefs.begin("nfc_cfg", false); + prefs.putBool("enabled", nfc_enabled); + prefs.end(); + Serial.printf("NFC access control: %s\n", nfc_enabled ? "ENABLED" : "DISABLED"); + server.send(200, "application/json", + String("{\"status\":\"ok\",\"nfc_enabled\":") + (nfc_enabled ? "true" : "false") + "}"); +} + +void handleNFCProbe() { + // Manual NFC detection at runtime with timeout protection + if (!requireAuth()) return; + + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "text/plain"); + server.sendContent("Starting NFC probe (with 300ms timeout per attempt)...\n\n"); + + const long NFC_BAUDS[] = {115200, 9600, 57600, 38400}; + const int NFC_NBAUDS = 4; + const int NFC_PINS[2][2] = {{NFC_RX_PIN, NFC_TX_PIN}, + {NFC_TX_PIN, NFC_RX_PIN}}; + + uint32_t versiondata = 0; + long found_baud = 0; + int found_rx = NFC_RX_PIN; + int found_tx = NFC_TX_PIN; + + for (int pi = 0; pi < 2 && !versiondata; pi++) { + int rx = NFC_PINS[pi][0]; + int tx = NFC_PINS[pi][1]; + for (int bi = 0; bi < NFC_NBAUDS && !versiondata; bi++) { + long baud = NFC_BAUDS[bi]; + char msg[128]; + snprintf(msg, sizeof(msg), " Trying baud=%-7ld RX=GPIO%d TX=GPIO%d ... ", baud, rx, tx); + server.sendContent(msg); + + nfcSerial.begin(baud, SERIAL_8N1, rx, tx); + delay(100); // Brief pause for serial to stabilize + + // Flush any garbage in the buffer + while (nfcSerial.available()) nfcSerial.read(); + + nfc.begin(); + + // Try to get firmware version with explicit timeout + // Note: getFirmwareVersion() may still block in library, but we do what we can + unsigned long probe_start = millis(); + versiondata = nfc.getFirmwareVersion(); + unsigned long probe_time = millis() - probe_start; + + if (versiondata) { + found_baud = baud; + found_rx = rx; + found_tx = tx; + snprintf(msg, sizeof(msg), "✓ FOUND in %lu ms\n", probe_time); + } else { + server.sendContent("✗\n"); + } + server.sendContent(msg); + + // Keep HTTP responsive + server.handleClient(); + yield(); + } + } + + server.sendContent("\n"); + if (!versiondata) { + server.sendContent("✗ PN532 not detected with any baud/pin combination.\n\n"); + server.sendContent("Hardware checklist:\n"); + server.sendContent("1. DIP/solder-jumpers on PN532: BOTH = 0 for HSU mode\n"); + server.sendContent(" (Some boards label them SEL0/SEL1 or I0/I1 — both must be LOW)\n"); + server.sendContent("2. Power: UEXT pin 1 = 3.3V, pin 2 = GND (measure with multimeter)\n"); + server.sendContent("3. Wiring: UEXT pin 3 (GPIO26) ↔ PN532 RXD\n"); + server.sendContent(" UEXT pin 4 (GPIO25) ↔ PN532 TXD\n"); + server.sendContent("4. Some PN532 boards need 100Ω series resistor on TX line\n"); + server.sendContent("5. Power off the module, power on again, then run probe\n"); + nfc_initialized = false; + } else { + char msg[256]; + snprintf(msg, sizeof(msg), + "✓ PN532 found! baud=%ld RX=GPIO%d TX=GPIO%d\n" + " Chip: PN5%02X\n" + " Firmware: %d.%d\n\n", + found_baud, found_rx, found_tx, + (versiondata >> 24) & 0xFF, + (versiondata >> 16) & 0xFF, (versiondata >> 8) & 0xFF); + server.sendContent(msg); + + // SAMConfig puts PN532 into normal card-detection mode. + // No need to re-call nfcSerial.begin() — UART already configured correctly above. + nfc.SAMConfig(); + nfc_initialized = true; + server.sendContent("✓ NFC initialized successfully — ready for card detection\n"); + } + server.sendContent(""); // Finish response +} + +void handleNFCStatus() { + if (!verifyAPIRequest()) return; + bool present = nfc_initialized && nfc_enabled && (strcmp(nfc_access_state, "granted") == 0); + String json = "{"; + json += "\"initialized\":" + String(nfc_initialized ? "true" : "false") + ","; + json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false") + ","; + json += "\"card_present\":" + String(present ? "true" : "false") + ","; + json += "\"last_uid\":\"" + nfc_last_uid + "\","; + json += "\"access_state\":\"" + String(nfc_access_state) + "\","; + json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\","; + json += "\"relay_num\":" + String(nfc_relay_num) + ","; + json += "\"pulse_ms\":" + String(nfc_pulse_ms); + json += "}"; + server.send(200, "application/json", json); +} + +void postNFCEvent(const String& uid) { + if (!ha_registered || strlen(ha_callback_url) == 0) return; + Serial.printf("NFC: posting UID %s to HA\n", uid.c_str()); + String json = "{\"type\":\"nfc_card\",\"uid\":\"" + uid + + "\",\"uptime\":" + String(millis() / 1000) + "}"; + if (postJsonToHA(json)) + Serial.printf("NFC: event posted for UID %s\n", uid.c_str()); +} + +int nfcRelayPin(int rnum) { + switch (rnum) { + case 1: return RELAY_1_PIN; + case 2: return RELAY_2_PIN; + default: return -1; + } +} + +void handleNFCConfigGet() { + if (!verifyAPIRequest()) return; + String json = "{"; + json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\","; + json += "\"relay_num\":" + String(nfc_relay_num) + ","; + json += "\"pulse_ms\":" + String(nfc_pulse_ms) + ","; + json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false"); + json += "}"; + server.send(200, "application/json", json); +} + +void handleNFCConfigSet() { + if (!verifyAPIRequest()) return; + if (server.hasArg("auth_uid")) { + String u = server.arg("auth_uid"); + u.trim(); + u.toUpperCase(); + if (u.length() < sizeof(nfc_auth_uid)) { + u.toCharArray(nfc_auth_uid, sizeof(nfc_auth_uid)); + } else { + server.send(400, "application/json", "{\"error\":\"auth_uid too long (max 31 chars)\"}"); + return; + } + } + if (server.hasArg("relay")) { + int r = server.arg("relay").toInt(); + if (r >= 1 && r <= 2) { + nfc_relay_num = r; + } else { + server.send(400, "application/json", "{\"error\":\"relay must be 1-2\"}"); + return; + } + } + if (server.hasArg("pulse_ms")) { + long p = server.arg("pulse_ms").toInt(); + if (p >= 0 && p <= 60000) { + nfc_pulse_ms = (unsigned long)p; + } else { + server.send(400, "application/json", "{\"error\":\"pulse_ms range: 0-60000\"}"); + return; + } + } + Serial.printf("NFC config: auth='%s' relay=%d pulse=%lu ms\n", + nfc_auth_uid, nfc_relay_num, nfc_pulse_ms); + // If relay was ON from a previous grant, turn it off before resetting state + if (strcmp(nfc_access_state, "granted") == 0) { + int pin = nfcRelayPin(nfc_relay_num); + if (pin >= 0) { + digitalWrite(pin, LOW); // relay OFF (active HIGH) + switch (nfc_relay_num) { + case 1: relay1_state = false; break; + case 2: relay2_state = false; break; + } + } + } + // Reset NFC state so a card still on the reader gets re-evaluated immediately + strcpy(nfc_access_state, "idle"); + nfc_last_uid = ""; + nfc_miss_count = 0; + nfc_absent_since = 0; + prefs.begin("nfc_cfg", false); + prefs.putString("auth_uid", nfc_auth_uid); + prefs.putInt("relay_num", nfc_relay_num); + prefs.putULong("pulse_ms", nfc_pulse_ms); + prefs.end(); + String json = "{\"status\":\"ok\"," + "\"auth_uid\":\"" + String(nfc_auth_uid) + "\"," + "\"relay_num\":" + String(nfc_relay_num) + "," + "\"pulse_ms\":" + String(nfc_pulse_ms) + "}"; + server.send(200, "application/json", json); +} + +void handleDebug() { + if (!requireAuth()) return; + + String out = "=== ESP32-C5 Debug ===\n"; + out += "Uptime: " + String(millis() / 1000) + " s\n"; + out += "Free heap: " + String(ESP.getFreeHeap()) + " bytes\n"; + out += "WiFi status: "; + switch (WiFi.status()) { + case WL_CONNECTED: out += "CONNECTED\n"; break; + case WL_NO_SSID_AVAIL: out += "NO SSID\n"; break; + case WL_CONNECT_FAILED: out += "FAILED\n"; break; + case WL_DISCONNECTED: out += "DISCONNECTED\n"; break; + default: out += String(WiFi.status()) + "\n"; + } + out += "IP: " + WiFi.localIP().toString() + "\n"; + out += "SSID: " + WiFi.SSID() + "\n"; + out += "RSSI: " + String(WiFi.RSSI()) + " dBm\n"; + out += "MAC: " + WiFi.macAddress() + "\n"; + out += "\n"; + out += "NFC init: " + String(nfc_initialized ? "YES" : "NO") + "\n"; + out += "NFC last UID:" + String(nfc_last_uid.length() > 0 ? nfc_last_uid : "(none)") + "\n"; + out += "NFC state: " + String(nfc_access_state) + "\n"; + out += "NFC relay: " + String(nfc_relay_num) + "\n"; + out += "NFC auth: " + String(strlen(nfc_auth_uid) > 0 ? nfc_auth_uid : "(any)") + "\n"; + out += "\n"; + out += "Relay states: R1=" + String(relay1_state) + " R2=" + String(relay2_state) + "\n"; + server.send(200, "text/plain", out); +} diff --git a/olimex_ESP32-C5-EVB/esp32_arduino/olimex_esp32_c5.code-workspace b/olimex_ESP32-C5-EVB/esp32_arduino/olimex_esp32_c5.code-workspace new file mode 100644 index 0000000..ea51f81 --- /dev/null +++ b/olimex_ESP32-C5-EVB/esp32_arduino/olimex_esp32_c5.code-workspace @@ -0,0 +1,20 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "files.exclude": { + "**/.git": true, + "**/.DS_Store": true, + "**/secrets.h": true, + "**/*.o": true + }, + "search.exclude": { + "**/.git": true, + "**/node_modules": true, + "**/.venv": true + } + } +} diff --git a/olimex_ESP32-C5-EVB/esp32_arduino/secrets.h.example b/olimex_ESP32-C5-EVB/esp32_arduino/secrets.h.example new file mode 100644 index 0000000..0831604 --- /dev/null +++ b/olimex_ESP32-C5-EVB/esp32_arduino/secrets.h.example @@ -0,0 +1,40 @@ +/** + * secrets.h — Credentials & API Keys (NOT Version Controlled) + * + * Instructions: + * 1. Copy this file to secrets.h (same directory) + * 2. Edit secrets.h with your WiFi credentials and API secret + * 3. DO NOT commit secrets.h to Git + * 4. Add secrets.h to .gitignore if not already present + * 5. Flash the board with Arduino IDE + */ + +#ifndef SECRETS_H +#define SECRETS_H + +// ─ WiFi Credentials ─────────────────────────────────────────────────────── +const char* WIFI_SSID = "YourWiFiSSID"; +const char* WIFI_PASSWORD = "YourWiFiPassword"; + +// ─ Static IP Configuration ──────────────────────────────────────────────── +// Set USE_STATIC_IP = true to use a fixed IP address +// Otherwise, board will request IP via DHCP +const bool USE_STATIC_IP = false; // true = static IP; false = DHCP +const char* STATIC_IP_ADDR = "192.168.0.240"; // e.g., "192.168.0.240" +const char* STATIC_GATEWAY = "192.168.0.1"; // e.g., "192.168.0.1" +const char* STATIC_SUBNET = "255.255.255.0"; // e.g., "255.255.255.0" +const char* STATIC_DNS1 = "8.8.8.8"; // e.g., "8.8.8.8" +const char* STATIC_DNS2 = "8.8.4.4"; // e.g., "8.8.4.4" + +// ─ Web Server Credentials ────────────────────────────────────────────────── +const char* WEB_USER = "admin"; +const char* WEB_PASSWORD = "admin"; + +// ─ API Authentication Secret ────────────────────────────────────────────── +// Used for HMAC-SHA256 request signing (optional) +// If empty, API requests do not require authentication. +// To enable, set a random string and include X-Request-Time/X-Request-Sig headers. +// Example: "my_super_secret_api_key_12345" +const char* API_SECRET = ""; + +#endif diff --git a/olimex_ESP32-C5-EVB/nfc_diagnostics.py b/olimex_ESP32-C5-EVB/nfc_diagnostics.py new file mode 100644 index 0000000..ecd2e84 --- /dev/null +++ b/olimex_ESP32-C5-EVB/nfc_diagnostics.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +NFC Pin Mapping Diagnostic for ESP32-C5-EVB +Verifies the UEXT connector GPIO assignments +""" + +import requests +import json +import sys + +DEVICE_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.0.240" +WEB_USER = "Ske087" +WEB_PASS = "Matei@123" + +print("=" * 70) +print("ESP32-C5-EVB UEXT Pin Mapping Diagnostic") +print("=" * 70) + +# Try to get device info +try: + resp = requests.get(f"http://{DEVICE_IP}/", + auth=(WEB_USER, WEB_PASS), + timeout=2) + if resp.status_code == 200: + print(f"\n✓ Device reachable at {DEVICE_IP}\n") + else: + print(f"\n✗ Device responded with {resp.status_code}") +except Exception as e: + print(f"\n✗ Cannot reach device: {e}") + sys.exit(1) + +# Check NFC status +try: + resp = requests.get(f"http://{DEVICE_IP}/nfc/status", + auth=(WEB_USER, WEB_PASS), + timeout=2) + nfc_status = resp.json() + + print("NFC Module Status:") + print(f" Initialized: {nfc_status.get('initialized', False)}") + print(f" Enabled: {nfc_status.get('nfc_enabled', False)}") + print(f" Access State: {nfc_status.get('access_state', 'unknown')}") + + if not nfc_status.get('initialized'): + print("\n⚠️ NFC module NOT initialized!") + print("\nDiagnostic Checklist:") + print(" 1. Is PN532 physically connected to UEXT connector?") + print(" 2. Are DIP switches set to HSU mode (DIP1=0, DIP2=0)?") + print(" 3. Check 3.3V power on UEXT Pin 2 (multimeter)") + print(" 4. Verify TX/RX wiring:") + print(" - UEXT Pin 3 (GPIO26) → PN532 RXD") + print(" - UEXT Pin 4 (GPIO25) ← PN532 TXD") + print(" 5. Some PN532 boards need 100Ω resistor on TX line") + +except Exception as e: + print(f"✗ Cannot get NFC status: {e}") + +print("\n" + "=" * 70) +print("C5-EVB vs C6-EVB UEXT Pin Mapping Reference:") +print("=" * 70) + +print(""" +C6-EVB (for reference): + UEXT Pin 3 → GPIO4 (UART1 TX) + UEXT Pin 4 → GPIO5 (UART1 RX) + +C5-EVB (current board): + UEXT Pin 3 → GPIO26 (UART1 TX) + UEXT Pin 4 → GPIO25 (UART1 RX) + UEXT Pin 1 → GND + UEXT Pin 2 → +3V3 (power) + +If you previously worked with C6 and connected PN532 to GPIO 4/5, +you MUST move it to GPIO 26/25 on the C5! +""") +print("=" * 70) diff --git a/olimex_ESP32-C6-EVB/.gitignore b/olimex_ESP32-C6-EVB/.gitignore new file mode 100644 index 0000000..6f38589 --- /dev/null +++ b/olimex_ESP32-C6-EVB/.gitignore @@ -0,0 +1,15 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +# Home Assistant generated +icon.png + +# OS +.DS_Store +Thumbs.db +/esp32_arduino/secrets.h + diff --git a/olimex_ESP32-C6-EVB/README.md b/olimex_ESP32-C6-EVB/README.md new file mode 100644 index 0000000..ad0569d --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/board_test/board_test.ino b/olimex_ESP32-C6-EVB/board_test/board_test.ino new file mode 100644 index 0000000..1841e82 --- /dev/null +++ b/olimex_ESP32-C6-EVB/board_test/board_test.ino @@ -0,0 +1,310 @@ +/** + * Olimex ESP32-C6-EVB — Board Functional Test + * ================================================= + * Flash this sketch BEFORE deploying the main firmware. + * It tests every hardware subsystem and reports PASS/FAIL + * on the Serial Monitor AND on a simple web page. + * + * Board settings (Arduino IDE): + * Board : ESP32C6 Dev Module + * USB CDC On Boot : Enabled ← REQUIRED + * Flash Size : 4MB + * Upload Speed : 921600 + * + * How to read results: + * 1. Open Serial Monitor at 115200 baud after upload. + * 2. Press RESET on the board — full report prints once. + * 3. Connect a phone/PC to the same WiFi and open: + * http://192.168.0.181/test (or whatever IP prints) + * + * Relay self-test: the test pulses each relay 200 ms ON then OFF. + * You will see/hear the relays click twice each. + * + * NFC self-test: tries all baud rates (115200 / 9600 / 57600) + * on both pin orientations. Module must be wired on UEXT1. + */ + +#include +#include +#include +#include + +// ── WiFi credentials ───────────────────────────────────────────────────────── +const char* SSID = "BUON GUSTO PARTER"; +const char* PASSWORD = "arleta13"; +IPAddress STATIC_IP(192, 168, 0, 181); +IPAddress GATEWAY (192, 168, 0, 1); +IPAddress SUBNET (255, 255, 255, 0); + +// ── Pin map ─────────────────────────────────────────────────────────────────── +const int LED_PIN = 8; +const int BUT_PIN = 9; +const int RELAY_PIN[] = {10, 11, 22, 23}; // Relay 1-4 +const int INPUT_PIN[] = {1, 2, 3, 15}; // Digital Input 1-4 +const int NFC_RX = 5; // UEXT1 pin 4 +const int NFC_TX = 4; // UEXT1 pin 3 + +// ── NFC objects ─────────────────────────────────────────────────────────────── +HardwareSerial nfcSerial(1); +PN532_HSU pn532hsu(nfcSerial); +PN532 nfc(pn532hsu); + +// ── Web server ──────────────────────────────────────────────────────────────── +WebServer server(80); + +// ── Test result storage ─────────────────────────────────────────────────────── +struct TestResult { + const char* name; + bool passed; + String detail; +}; + +static const int MAX_TESTS = 20; +TestResult results[MAX_TESTS]; +int result_count = 0; +int pass_count = 0; +int fail_count = 0; + +// ── Helper: record a result ─────────────────────────────────────────────────── +void record(const char* name, bool ok, String detail = "") { + if (result_count < MAX_TESTS) { + results[result_count++] = {name, ok, detail}; + } + if (ok) pass_count++; else fail_count++; + Serial.printf(" [%s] %s%s\n", + ok ? "PASS" : "FAIL", + name, + detail.length() ? (" — " + detail).c_str() : ""); +} + +// ───────────────────────────────────────────────────────────────────────────── +// TEST FUNCTIONS +// ───────────────────────────────────────────────────────────────────────────── + +void testGPIO() { + Serial.println("\n--- GPIO Test ---"); + + // ── LED ────────────────────────────────────────────────────────────────── + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, LOW); // LED on (active-low) + delay(300); + digitalWrite(LED_PIN, HIGH); // LED off + delay(100); + // We cannot read back an output pin reliably, so just record as attempted. + record("LED on/off", true, "GPIO8 — verify LED blinked"); + + // ── Button ──────────────────────────────────────────────────────────────── + pinMode(BUT_PIN, INPUT_PULLUP); + int btn = digitalRead(BUT_PIN); + // Button is pull-up; HIGH = not pressed. Either state is valid at test time. + record("Button readable", true, + String("GPIO9 = ") + (btn ? "HIGH (not pressed)" : "LOW (pressed)")); + + // ── Digital Inputs ──────────────────────────────────────────────────────── + const char* in_names[] = {"Input1 (GPIO1)", "Input2 (GPIO2)", + "Input3 (GPIO3)", "Input4 (GPIO15)"}; + for (int i = 0; i < 4; i++) { + pinMode(INPUT_PIN[i], INPUT_PULLUP); + int v = digitalRead(INPUT_PIN[i]); + record(in_names[i], true, + String("= ") + (v ? "HIGH (open)" : "LOW (active)")); + } + + // ── Relays ──────────────────────────────────────────────────────────────── + const char* rel_names[] = {"Relay1 (GPIO10)", "Relay2 (GPIO11)", + "Relay3 (GPIO22)", "Relay4 (GPIO23)"}; + for (int i = 0; i < 4; i++) { + pinMode(RELAY_PIN[i], OUTPUT); + digitalWrite(RELAY_PIN[i], LOW); + } + delay(100); + + for (int i = 0; i < 4; i++) { + // Pulse ON for 200 ms — you should hear/see relay click + digitalWrite(RELAY_PIN[i], HIGH); + delay(200); + digitalWrite(RELAY_PIN[i], LOW); + delay(100); + record(rel_names[i], true, "pulsed 200 ms — listen for click"); + } +} + +// ── WiFi ───────────────────────────────────────────────────────────────────── +void testWiFi() { + Serial.println("\n--- WiFi Test ---"); + WiFi.disconnect(true); + delay(200); + WiFi.mode(WIFI_STA); + WiFi.config(STATIC_IP, GATEWAY, SUBNET); + + bool connected = false; + for (int pass = 1; pass <= 3 && !connected; pass++) { + Serial.printf(" Connecting (attempt %d/3)...", pass); + WiFi.begin(SSID, PASSWORD); + for (int t = 0; t < 40 && WiFi.status() != WL_CONNECTED; t++) { + delay(500); + Serial.print("."); + } + Serial.println(); + connected = (WiFi.status() == WL_CONNECTED); + if (!connected && pass < 3) { WiFi.disconnect(true); delay(500); WiFi.mode(WIFI_STA); WiFi.config(STATIC_IP, GATEWAY, SUBNET); } + } + + if (connected) { + record("WiFi connect", true, + WiFi.localIP().toString() + " RSSI=" + String(WiFi.RSSI()) + " dBm"); + } else { + record("WiFi connect", false, + "status=" + String(WiFi.status()) + " — check SSID/password"); + } +} + +// ── NFC ────────────────────────────────────────────────────────────────────── +void testNFC() { + Serial.println("\n--- NFC (PN532 HSU) Test ---"); + + const long BAUDS[] = {115200, 9600, 57600, 38400}; + const int NPINS[2][2]= {{NFC_RX, NFC_TX}, {NFC_TX, NFC_RX}}; + uint32_t ver = 0; + long found_baud = 0; + int found_rx = NFC_RX, found_tx = NFC_TX; + + for (int pi = 0; pi < 2 && !ver; pi++) { + for (int bi = 0; bi < 4 && !ver; bi++) { + int rx = NPINS[pi][0], tx = NPINS[pi][1]; + Serial.printf(" baud=%-7ld RX=GPIO%d TX=GPIO%d ... ", BAUDS[bi], rx, tx); + nfcSerial.begin(BAUDS[bi], SERIAL_8N1, rx, tx); + delay(500); + nfc.begin(); + ver = nfc.getFirmwareVersion(); + if (ver) { + found_baud = BAUDS[bi]; found_rx = rx; found_tx = tx; + Serial.println("FOUND"); + } else { + Serial.println("no response"); + delay(100); + } + } + } + + if (ver) { + nfc.SAMConfig(); + char detail[80]; + snprintf(detail, sizeof(detail), + "PN5%02X FW=%d.%d baud=%ld RX=GPIO%d TX=GPIO%d", + (ver >> 24) & 0xFF, + (ver >> 16) & 0xFF, (ver >> 8) & 0xFF, + found_baud, found_rx, found_tx); + record("NFC PN532 init", true, detail); + } else { + record("NFC PN532 init", false, + "not detected — check DIP switches (both=0) & UEXT1 wiring"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// WEB PAGE GET /test +// ───────────────────────────────────────────────────────────────────────────── + +void handleTestPage() { + String h = "Board Test"; + h += ""; + h += ""; + h += "

Olimex ESP32-C6-EVB — Functional Test

"; + h += "
MAC: " + WiFi.macAddress() + "   IP: " + + WiFi.localIP().toString() + "   Uptime: " + + String(millis() / 1000) + "s
"; + + h += ""; + for (int i = 0; i < result_count; i++) { + bool ok = results[i].passed; + h += ""; + h += ""; + h += ""; + h += ""; + } + h += "
#TestResultDetail
" + String(i + 1) + "" + String(results[i].name) + "" + + String(ok ? "PASS" : "FAIL") + "" + results[i].detail + "
"; + + bool all_ok = (fail_count == 0); + h += "
"; + h += String(pass_count) + " PASSED   /   " + + String(fail_count) + " FAILED   out of " + + String(result_count) + " tests"; + if (all_ok) h += "   ✓ Board OK"; + else h += "   ✗ Check failures above"; + h += "
"; + server.send(200, "text/html", h); +} + +void handleTestJSON() { + String j = "{\"pass\":" + String(pass_count) + + ",\"fail\":" + String(fail_count) + + ",\"total\":" + String(result_count) + + ",\"board_ok\":" + String(fail_count == 0 ? "true" : "false") + + ",\"mac\":\"" + WiFi.macAddress() + "\"" + + ",\"ip\":\"" + WiFi.localIP().toString() + "\"" + + ",\"uptime_s\":" + String(millis() / 1000) + + ",\"tests\":["; + for (int i = 0; i < result_count; i++) { + if (i) j += ","; + j += "{\"name\":\"" + String(results[i].name) + "\"" + + ",\"pass\":" + String(results[i].passed ? "true" : "false") + + ",\"detail\":\"" + results[i].detail + "\"}"; + } + j += "]}"; + server.send(200, "application/json", j); +} + +// ───────────────────────────────────────────────────────────────────────────── +// setup / loop +// ───────────────────────────────────────────────────────────────────────────── + +void setup() { + Serial.begin(115200); + delay(2000); + for (int i = 0; i < 10 && !Serial; i++) delay(500); + + Serial.println("\n\n╔══════════════════════════════════════╗"); + Serial.println("║ Olimex ESP32-C6-EVB Board Test ║"); + Serial.println("╚══════════════════════════════════════╝"); + + testGPIO(); + testWiFi(); + testNFC(); + + // ── Start web server ────────────────────────────────────────────────────── + server.on("/", HTTP_GET, handleTestPage); + server.on("/test", HTTP_GET, handleTestPage); + server.on("/test.json", HTTP_GET, handleTestJSON); + server.onNotFound([](){ server.send(404, "text/plain", "use /test or /test.json"); }); + server.begin(); + + // ── Final summary on serial ─────────────────────────────────────────────── + Serial.println("\n╔══════════════════════════════════════╗"); + Serial.printf( "║ PASSED: %2d FAILED: %2d TOTAL: %2d ║\n", + pass_count, fail_count, result_count); + Serial.println(fail_count == 0 + ? "║ ✓ ALL TESTS PASSED — board is OK ║" + : "║ ✗ FAILURES DETECTED — see above ║"); + Serial.println("╚══════════════════════════════════════╝"); + if (WiFi.status() == WL_CONNECTED) { + Serial.printf("\nOpen browser: http://%s/test\n", WiFi.localIP().toString().c_str()); + Serial.printf("Or fetch JSON: http://%s/test.json\n\n", WiFi.localIP().toString().c_str()); + } +} + +void loop() { + server.handleClient(); +} diff --git a/olimex_ESP32-C6-EVB/board_verify.py b/olimex_ESP32-C6-EVB/board_verify.py new file mode 100644 index 0000000..1e709d3 --- /dev/null +++ b/olimex_ESP32-C6-EVB/board_verify.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +Olimex ESP32-C6-EVB — Remote Board Verification Script +========================================================= +Queries the board's REST API and verifies every subsystem. + +Usage: + python3 board_verify.py # uses default IP 192.168.0.181 + python3 board_verify.py 192.168.0.200 # custom IP + python3 board_verify.py --json # machine-readable output + +Requirements: pip install requests (already in location_managemet requirements) + +What it tests: + 1. Board reachability (GET /api/status) + 2. All 4 relays (POST /relay/on, GET /relay/status, POST /relay/off) + 3. All 4 digital inputs (GET /input/status) + 4. LED (POST /led/on + /led/off) + 5. NFC reader (GET /nfc/status) + 6. NFC config API (GET /nfc/config) + +NOTE: Relay tests cycle each relay ON→verify→OFF→verify. + You should hear/see the relay click during the test. +""" + +import sys +import json +import time +import argparse +import requests + +TIMEOUT = 5 # seconds per HTTP request +RELAY_DLY = 0.4 # seconds to wait between relay on/status/off + +# ───────────────────────────────────────────────────────────────────────────── +# Result tracking +# ───────────────────────────────────────────────────────────────────────────── + +results = [] + +def record(name: str, ok: bool, detail: str = "") -> bool: + results.append({"name": name, "pass": ok, "detail": detail}) + icon = "\033[32m[PASS]\033[0m" if ok else "\033[31m[FAIL]\033[0m" + print(f" {icon} {name}" + (f" — {detail}" if detail else "")) + return ok + + +def _get(url: str): + try: + r = requests.get(url, timeout=TIMEOUT) + r.raise_for_status() + return r.json() + except requests.exceptions.ConnectionError: + return None + except Exception as e: + return {"_error": str(e)} + + +def _post(url: str): + try: + r = requests.post(url, timeout=TIMEOUT) + r.raise_for_status() + return r.json() + except requests.exceptions.ConnectionError: + return None + except Exception as e: + return {"_error": str(e)} + + +# ───────────────────────────────────────────────────────────────────────────── +# Tests +# ───────────────────────────────────────────────────────────────────────────── + +def test_reachability(base: str) -> bool: + print("\n── Connectivity ──────────────────────────────") + data = _get(f"{base}/api/status") + if data is None: + record("Board reachable", False, f"no response from {base}") + return False + if "_error" in data: + record("Board reachable", False, data["_error"]) + return False + + record("Board reachable", True, + f"IP {base.split('//')[1]} " + f"nfc_init={data.get('nfc_initialized','?')} " + f"nfc_uid={data.get('nfc_last_uid') or '(none)'}") + return True + + +def test_relays(base: str): + print("\n── Relay Tests ───────────────────────────────") + for relay in range(1, 5): + # Turn ON + r_on = _post(f"{base}/relay/on?relay={relay}") + if r_on is None: + record(f"Relay {relay} ON", False, "no response"); continue + time.sleep(RELAY_DLY) + + # Verify state = true + r_st = _get(f"{base}/relay/status?relay={relay}") + on_ok = r_st is not None and r_st.get("state") is True + record(f"Relay {relay} ON", on_ok, + ("state=true" if on_ok else f"got {r_st}")) + + time.sleep(RELAY_DLY) + + # Turn OFF + _post(f"{base}/relay/off?relay={relay}") + time.sleep(RELAY_DLY) + + # Verify state = false + r_st2 = _get(f"{base}/relay/status?relay={relay}") + off_ok = r_st2 is not None and r_st2.get("state") is False + record(f"Relay {relay} OFF", off_ok, + ("state=false" if off_ok else f"got {r_st2}")) + + time.sleep(0.1) + + +def test_inputs(base: str): + print("\n── Digital Input Tests ───────────────────────") + for inp in range(1, 5): + data = _get(f"{base}/input/status?input={inp}") + if data is None: + record(f"Input {inp} readable", False, "no response"); continue + if "_error" in data: + record(f"Input {inp} readable", False, data["_error"]); continue + state = data.get("state") + record(f"Input {inp} readable", state is not None, + f"state={'HIGH' if state else 'LOW'}" if state is not None else f"got {data}") + + +def test_led(base: str): + print("\n── LED Test ──────────────────────────────────") + on_r = _post(f"{base}/led/on") + time.sleep(0.3) + off_r = _post(f"{base}/led/off") + led_ok = (on_r is not None and "status" in on_r + and off_r is not None and "status" in off_r) + record("LED on/off", led_ok, + "API responded OK — verify LED blinked" if led_ok else f"on={on_r} off={off_r}") + + +def test_nfc(base: str): + print("\n── NFC Test ──────────────────────────────────") + data = _get(f"{base}/nfc/status") + if data is None: + record("NFC endpoint", False, f"GET /nfc/status no response"); return + if "_error" in data: + record("NFC endpoint", False, data["_error"]); return + + record("NFC endpoint reachable", True, "") + + init = data.get("initialized", False) + record("NFC PN532 initialized", init, + f"last_uid={data.get('last_uid') or '(none)'} " + f"access_state={data.get('access_state','?')}" if init + else "PN532 not detected — check hardware") + + # Config endpoint + cfg = _get(f"{base}/nfc/config") + if cfg and "_error" not in cfg: + record("NFC config endpoint", True, + f"auth_uid='{cfg.get('auth_uid') or 'any'}' " + f"relay={cfg.get('relay_num')} " + f"pulse={cfg.get('pulse_ms')} ms") + else: + record("NFC config endpoint", False, str(cfg)) + + +# ───────────────────────────────────────────────────────────────────────────── +# Optional: read board_test sketch results directly +# ───────────────────────────────────────────────────────────────────────────── + +def test_sketch_results(base: str): + """If the board_test sketch is running it exposes /test.json — use it.""" + data = _get(f"{base}/test.json") + if data is None or "_error" in data: + return # main firmware running — no /test.json endpoint + print("\n── Board-Test Sketch Results (from /test.json) ──") + for t in data.get("tests", []): + record(f"[sketch] {t['name']}", t["pass"], t.get("detail", "")) + + +# ───────────────────────────────────────────────────────────────────────────── +# Entry point +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Olimex ESP32-C6-EVB board verifier") + parser.add_argument("ip", nargs="?", default="192.168.0.181", + help="Board IP address (default: 192.168.0.181)") + parser.add_argument("--json", action="store_true", + help="Output results as JSON") + parser.add_argument("--skip-relays", action="store_true", + help="Skip relay tests (no load wired)") + args = parser.parse_args() + + base = f"http://{args.ip}" + + print(f"\n╔══════════════════════════════════════════╗") + print(f"║ Olimex ESP32-C6-EVB Remote Verifier ║") + print(f"╚══════════════════════════════════════════╝") + print(f" Target: {base}\n") + + # Try board_test sketch first (if deployed) + test_sketch_results(base) + + # Connectivity gate — abort if board unreachable + if not test_reachability(base): + print("\n\033[31m Board unreachable — aborting remaining tests.\033[0m") + print(f" Try: ping {args.ip} or wget -qO- {base}/api/status\n") + sys.exit(1) + + if not args.skip_relays: + test_relays(base) + else: + print("\n── Relay Tests SKIPPED (--skip-relays) ──────") + + test_inputs(base) + test_led(base) + test_nfc(base) + + # ── Summary ─────────────────────────────────────────────────────────────── + passed = sum(1 for r in results if r["pass"]) + failed = sum(1 for r in results if not r["pass"]) + total = len(results) + all_ok = failed == 0 + + print(f"\n╔══════════════════════════════════════════╗") + print(f"║ PASSED: {passed:2d} FAILED: {failed:2d} TOTAL: {total:2d} ║") + print("║ \033[32m✓ ALL TESTS PASSED — board is OK\033[0m ║" if all_ok else + "║ \033[31m✗ FAILURES DETECTED — see above\033[0m ║") + print(f"╚══════════════════════════════════════════╝\n") + + if args.json: + summary = { + "board_ip": args.ip, + "pass": passed, + "fail": failed, + "total": total, + "board_ok": all_ok, + "tests": results, + } + print(json.dumps(summary, indent=2)) + + sys.exit(0 if all_ok else 1) + + +if __name__ == "__main__": + main() diff --git a/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/README.md b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/README.md new file mode 100644 index 0000000..76f7579 --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/SETUP.md b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/SETUP.md new file mode 100644 index 0000000..77d4a45 --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/__init__.py b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/__init__.py new file mode 100644 index 0000000..cebb62d --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/binary_sensor.py b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/binary_sensor.py new file mode 100644 index 0000000..7f6e077 --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/config_flow.py b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/config_flow.py new file mode 100644 index 0000000..dc0eafb --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/const.py b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/const.py new file mode 100644 index 0000000..84066fc --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/manifest.json b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/manifest.json new file mode 100644 index 0000000..8abc83f --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/olimex.png b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/olimex.png new file mode 100644 index 0000000..5b4e72a Binary files /dev/null and b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/olimex.png differ diff --git a/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/sensor.py b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/sensor.py new file mode 100644 index 0000000..2da4062 --- /dev/null +++ b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/sensor.py @@ -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 diff --git a/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/sensor_updater.py b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/sensor_updater.py new file mode 100644 index 0000000..4dfb947 --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/strings.json b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/strings.json new file mode 100644 index 0000000..a1a79e1 --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/switch.py b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/switch.py new file mode 100644 index 0000000..2419d20 --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/webhook.py b/olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/webhook.py new file mode 100644 index 0000000..247b338 --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/esp32_arduino/DEPLOYMENT_GUIDE.md b/olimex_ESP32-C6-EVB/esp32_arduino/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..41a8536 --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/esp32_arduino/README.md b/olimex_ESP32-C6-EVB/esp32_arduino/README.md new file mode 100644 index 0000000..f8f8bee --- /dev/null +++ b/olimex_ESP32-C6-EVB/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/olimex_ESP32-C6-EVB/esp32_arduino/esp32_arduino.ino b/olimex_ESP32-C6-EVB/esp32_arduino/esp32_arduino.ino new file mode 100644 index 0000000..ac33d42 --- /dev/null +++ b/olimex_ESP32-C6-EVB/esp32_arduino/esp32_arduino.ino @@ -0,0 +1,1390 @@ +/** + * 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 2.2.1 Persistent NFC config via NVS +#include +#include +#include +#include + +Preferences prefs; + +// ── NFC: PN532 over UART (HSU mode) via UEXT1 ─────────────────────────────── +// UEXT1 pin 3 = TXD (ESP32 → PN532 RXD) → GPIO4 +// UEXT1 pin 4 = RXD (PN532 TXD → ESP32) → GPO5 +// PN532 module wiring note: set HSU mode — DIP1 = 0, DIP2 = 0 +#include +#include + +#define NFC_TX_PIN 4 // UEXT1 pin 3 — ESP32 transmits to PN532 +#define NFC_RX_PIN 5 // UEXT1 pin 4 — ESP32 receives from PN532 +#define NFC_POLL_MS 500 // idle detection interval (ms) + +// ── Credentials & shared secrets ───────────────────────────────────────────── +// Loaded from secrets.h which is NOT committed to version control. +// Copy secrets.h.example → secrets.h, fill in your values, then flash. +#include "secrets.h" +#include "mbedtls/md.h" // HMAC-SHA256 — API request authentication +#include // time() — NTP-based timestamp validation + +HardwareSerial nfcSerial(1); // UART1 +PN532_HSU pn532hsu(nfcSerial); +PN532 nfc(pn532hsu); + +bool nfc_initialized = false; +String nfc_last_uid = ""; +unsigned long nfc_last_poll_ms = 0; +int nfc_miss_count = 0; // consecutive polls with no card detected +// NFC Access Control +char nfc_auth_uid[32] = ""; // authorized card UID; empty = any card triggers +int nfc_relay_num = 1; // relay to open on match (1-4) +unsigned long nfc_pulse_ms = 5000; // absence timeout: relay closes after this many ms of no card +char nfc_access_state[8] = "idle"; // "idle" | "granted" | "denied" +bool nfc_enabled = false; // access control module on/off; off by default +// ──────────────────────────────────────────────────────────────────────────── + +// 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; +unsigned long last_temp_update = 0; + +// ── Forward declarations ────────────────────────────────────────────────────── +// Required because setup()/loop() reference handlers defined later in the file. +void handleRoot(); +void handleStatus(); +void handleRelayOn(); +void handleRelayOff(); +void handleRelayStatus(); +void handleInputStatus(); +void handleRegister(); +void handleLEDOn(); +void handleLEDOff(); +void handleNotFound(); +void handleNFCStatus(); +void handleNFCConfigGet(); +void handleNFCConfigSet(); +void handleNFCEnable(); +void handleDebug(); +void postNFCEvent(const String& uid); +void postInputEvent(int input_num, bool state); +int nfcRelayPin(int rnum); +bool postJsonToHA(const String& json); +bool verifyAPIRequest(); +bool requireAuth(); +void checkInputChanges(); +void scanWiFiNetworks(); +// ───────────────────────────────────────────────────────────────────────────── + +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"); + + // ── Load persistent NFC config from NVS ────────────────────────────────── + prefs.begin("nfc_cfg", true); // read-only namespace + String saved_uid = prefs.getString("auth_uid", ""); + if (saved_uid.length() > 0 && saved_uid.length() < sizeof(nfc_auth_uid)) { + saved_uid.toCharArray(nfc_auth_uid, sizeof(nfc_auth_uid)); + } + nfc_relay_num = prefs.getInt("relay_num", 1); + nfc_pulse_ms = prefs.getULong("pulse_ms", 5000); + nfc_enabled = prefs.getBool("enabled", false); + prefs.end(); + Serial.printf("NFC config loaded: auth='%s' relay=%d pulse=%lu ms\n", + nfc_auth_uid, nfc_relay_num, nfc_pulse_ms); + // ───────────────────────────────────────────────────────────────────────── + + // ── Load persistent HA registration from NVS ───────────────────────────── + prefs.begin("ha_cfg", true); // read-only namespace + String saved_url = prefs.getString("callback_url", ""); + if (saved_url.length() > 0 && saved_url.length() < sizeof(ha_callback_url)) { + saved_url.toCharArray(ha_callback_url, sizeof(ha_callback_url)); + ha_registered = true; + } + prefs.end(); + if (ha_registered) { + Serial.printf("HA registration loaded: %s\n", ha_callback_url); + } else { + Serial.println("HA registration: not registered"); + } + // ───────────────────────────────────────────────────────────────────────── + + // Configure WiFi + // ESP32-C6 note: the radio sometimes needs a full reset cycle before it can + // associate. We do WiFi.disconnect(true) → mode() → config() → begin() and + // allow up to 3 full connection attempts (60 s total) before giving up. + Serial.println("\n--- WiFi Configuration ---"); + WiFi.disconnect(true); // reset radio & erase any stale state + delay(200); + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + + // Static IP + IPAddress staticIP(192, 168, 0, 240); + IPAddress gateway(192, 168, 0, 1); + IPAddress subnet(255, 255, 255, 0); + WiFi.config(staticIP, gateway, subnet); + + bool wifi_ok = false; + for (int pass = 1; pass <= 3 && !wifi_ok; pass++) { + Serial.printf("Connecting to WiFi: %s (attempt %d/3)\n", ssid, pass); + WiFi.begin(ssid, password); + for (int t = 0; t < 40 && WiFi.status() != WL_CONNECTED; t++) { + delay(500); + Serial.print("."); + } + Serial.println(); + if (WiFi.status() == WL_CONNECTED) wifi_ok = true; + else if (pass < 3) { + Serial.println(" Not connected yet, retrying..."); + WiFi.disconnect(true); + delay(1000); + WiFi.mode(WIFI_STA); + WiFi.config(staticIP, gateway, subnet); + } + } + + if (wifi_ok) { + Serial.println("\n\u2713 WiFi connected!"); + Serial.print(" IP : "); 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\u2717 WiFi connection failed after 3 attempts."); + Serial.print(" Status code: "); Serial.println(WiFi.status()); + Serial.println(" Check: correct SSID/password, 2.4 GHz band, board in range."); + Serial.println(" HTTP server will start anyway \u2014 accessible once WiFi reconnects."); + } + + // ── NTP time sync ──────────────────────────────────────────────────────────── + // Required for HMAC timestamp validation in verifyAPIRequest(). + // Syncs wall-clock time; if no internet the timestamp check is skipped but + // HMAC verification still runs (proving knowledge of API_SECRET). + configTime(0, 0, "pool.ntp.org", "time.google.com"); + if (wifi_ok) { + Serial.print("NTP sync "); + time_t _ntp_now = time(nullptr); + for (int _i = 0; _i < 20 && _ntp_now < 1577836800L; _i++) { + delay(500); Serial.print("."); _ntp_now = time(nullptr); + } + Serial.println(); + if (_ntp_now > 1577836800L) { + char _tbuf[32]; + strftime(_tbuf, sizeof(_tbuf), "%Y-%m-%d %H:%M:%S UTC", gmtime(&_ntp_now)); + Serial.printf("\u2713 NTP synced: %s\n", _tbuf); + } else { + Serial.println("\u26a0 NTP sync failed \u2014 timestamp check disabled"); + } + } + // ───────────────────────────────────────────────────────────────────────────── + + // 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); + server.on("/nfc/status", HTTP_GET, handleNFCStatus); + server.on("/nfc/config", HTTP_GET, handleNFCConfigGet); + server.on("/nfc/config", HTTP_POST, handleNFCConfigSet); + server.on("/nfc/enable", HTTP_POST, handleNFCEnable); + server.on("/debug", HTTP_GET, handleDebug); + + // ── Collect HMAC auth headers for verifyAPIRequest() ───────────────────────── + const char* _api_hdrs[] = {"X-Request-Time", "X-Request-Sig"}; + server.collectHeaders(_api_hdrs, 2); + + // 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"); + + // ── NFC (PN532 HSU) initialisation — multi-baud auto-detect ───────────── + // The PN532 default HSU baud is 115200, but some modules ship at 9600. + // We try both, plus RX/TX swapped, so the board will find the module + // regardless of those two variables. + Serial.println("--- NFC (PN532 HSU) Initialization ---"); + + // Baud rates to try, in order + const long NFC_BAUDS[] = {115200, 9600, 57600, 38400}; + const int NFC_NBAUDS = 4; + // Pin pairs to try: {RX, TX}. Second pair = swapped. + const int NFC_PINS[2][2] = {{NFC_RX_PIN, NFC_TX_PIN}, + {NFC_TX_PIN, NFC_RX_PIN}}; + + uint32_t versiondata = 0; + long found_baud = 0; + int found_rx = NFC_RX_PIN; + int found_tx = NFC_TX_PIN; + + for (int pi = 0; pi < 2 && !versiondata; pi++) { + int rx = NFC_PINS[pi][0]; + int tx = NFC_PINS[pi][1]; + for (int bi = 0; bi < NFC_NBAUDS && !versiondata; bi++) { + long baud = NFC_BAUDS[bi]; + Serial.printf(" Trying baud=%-7ld RX=GPIO%d TX=GPIO%d ...\n", baud, rx, tx); + nfcSerial.begin(baud, SERIAL_8N1, rx, tx); + // Serve HTTP and feed watchdog during probe delay instead of blocking + { unsigned long _t = millis(); while (millis() - _t < 500) { server.handleClient(); yield(); } } + nfc.begin(); // PN532_HSU::begin() is a no-op — pins already set + versiondata = nfc.getFirmwareVersion(); + if (!versiondata) { + unsigned long _t = millis(); while (millis() - _t < 200) { server.handleClient(); yield(); } + } else { found_baud = baud; found_rx = rx; found_tx = tx; } + } + } + + if (!versiondata) { + Serial.println("\u2717 PN532 not detected with any baud/pin combination."); + Serial.println(" Hardware checklist:"); + Serial.println(" 1. DIP/solder-jumpers on PN532 board: BOTH = 0 (HSU mode)"); + Serial.println(" Some boards label them SEL0/SEL1 or I0/I1 — both must be LOW."); + Serial.println(" 2. Power: UEXT1 pin 1 = 3V3, pin 2 = GND. Measure with multimeter."); + Serial.println(" 3. Wiring: UEXT1 pin 3 (GPIO4) ↔ PN532 RXD"); + Serial.println(" UEXT1 pin 4 (GPIO5) ↔ PN532 TXD"); + Serial.println(" 4. Some PN532 breakouts need a 100 ohm series resistor on TX line."); + } else { + Serial.printf("\u2713 PN532 found! baud=%ld RX=GPIO%d TX=GPIO%d\n", + found_baud, found_rx, found_tx); + Serial.print(" Chip: PN5"); + Serial.println((versiondata >> 24) & 0xFF, HEX); + Serial.printf(" Firmware: %d.%d\n", + (versiondata >> 16) & 0xFF, (versiondata >> 8) & 0xFF); + // Re-init serial with confirmed settings + nfcSerial.begin(found_baud, SERIAL_8N1, found_rx, found_tx); + nfc.SAMConfig(); + nfc_initialized = true; + Serial.println("\u2713 NFC ready \u2014 waiting for ISO14443A / Mifare cards"); + } + // ────────────────────────────────────────────────────────────────────────── +} + +void loop() { + server.handleClient(); + + // Simulate temperature reading — update every 5 s + if (millis() - last_temp_update >= 5000) { + last_temp_update = millis(); + 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(); + } + + // ── NFC: two-phase polling ──────────────────────────────────────────────── + // IDLE : fast poll every NFC_POLL_MS (500 ms), 50 ms RF timeout + // GRANTED/DENIED: slow presence-check every nfc_pulse_ms, 500 ms RF timeout + // 2 consecutive misses required to confirm card is gone + { + bool is_active_state = (strcmp(nfc_access_state, "granted") == 0 || + strcmp(nfc_access_state, "denied") == 0); + unsigned long nfc_interval = is_active_state ? nfc_pulse_ms : (unsigned long)NFC_POLL_MS; + if (nfc_enabled && nfc_initialized && millis() - nfc_last_poll_ms >= nfc_interval) { + nfc_last_poll_ms = millis(); + uint16_t rf_timeout = is_active_state ? 500 : 50; + uint8_t uid[7] = {0}; + uint8_t uidLen = 0; + bool found = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLen, rf_timeout); + if (found && uidLen > 0) { + nfc.inRelease(1); // deselect → card returns to ISO14443A IDLE state for next poll + nfc_miss_count = 0; + String uid_str = ""; + for (uint8_t i = 0; i < uidLen; i++) { + if (uid[i] < 0x10) uid_str += "0"; + uid_str += String(uid[i], HEX); + if (i < uidLen - 1) uid_str += ":"; + } + uid_str.toUpperCase(); + if (strcmp(nfc_access_state, "granted") == 0) { + // Presence check passed — card still on reader + Serial.printf("NFC: card present UID=%s\n", uid_str.c_str()); + } else if (strcmp(nfc_access_state, "denied") == 0 && uid_str == nfc_last_uid) { + // Same card that was already denied is still on reader — do nothing + Serial.printf("NFC: denied card still present UID=%s\n", uid_str.c_str()); + } else { + // New card — authenticate + nfc_last_uid = uid_str; + Serial.printf("NFC: card UID=%s\n", uid_str.c_str()); + postNFCEvent(uid_str); + // Require an explicit authorized UID — empty = no card is authorized yet + if (strlen(nfc_auth_uid) == 0) { + strcpy(nfc_access_state, "denied"); + Serial.printf("NFC: ACCESS DENIED — no authorized UID configured. Set one in the web UI.\n"); + } else if (uid_str == String(nfc_auth_uid)) { + strcpy(nfc_access_state, "granted"); + int gpin = nfcRelayPin(nfc_relay_num); + if (gpin >= 0) { + digitalWrite(gpin, HIGH); + switch (nfc_relay_num) { + case 1: relay1_state = true; break; + case 2: relay2_state = true; break; + case 3: relay3_state = true; break; + case 4: relay4_state = true; break; + } + } + Serial.printf("NFC: ACCESS GRANTED relay=%d (presence-check every %lums)\n", + nfc_relay_num, nfc_pulse_ms); + } else { + strcpy(nfc_access_state, "denied"); + Serial.printf("NFC: ACCESS DENIED UID=%s\n", uid_str.c_str()); + } + } + } else { + // No card this poll + if (strcmp(nfc_access_state, "granted") == 0) { + nfc_miss_count++; + if (nfc_miss_count >= 2) { // 2 consecutive presence-check failures = card gone + nfc_miss_count = 0; + int pin = nfcRelayPin(nfc_relay_num); + if (pin >= 0) { + digitalWrite(pin, LOW); + switch (nfc_relay_num) { + case 1: relay1_state = false; break; + case 2: relay2_state = false; break; + case 3: relay3_state = false; break; + case 4: relay4_state = false; break; + } + } + strcpy(nfc_access_state, "idle"); + nfc_last_uid = ""; + Serial.printf("NFC: card removed — relay %d closed\n", nfc_relay_num); + } else { + Serial.printf("NFC: presence miss %d/2 — retrying\n", nfc_miss_count); + } + } else if (strcmp(nfc_access_state, "denied") == 0) { + nfc_miss_count++; + if (nfc_miss_count >= 2) { + Serial.printf("NFC: denied card removed\n"); + strcpy(nfc_access_state, "idle"); + nfc_last_uid = ""; + nfc_miss_count = 0; + } + } + // idle: keep fast-polling, no action needed + } + } + } + + 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() { + if (!requireAuth()) return; + + // 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 = ""; + html += "ESP32-C6 Device"; + html += ""; + html += ""; + // ── JavaScript ───────────────────────────────────────────────────────── + html += ""; + // ── Body ─────────────────────────────────────────────────────────────── + html += ""; + // Topbar + html += "
"; + html += "

🔌 ESP32-C6

"; + html += "
"; + html += ""; + html += "
"; + html += "
"; + + // Device Info + html += "
"; + html += "
"; + html += "IP " + WiFi.localIP().toString() + ""; + html += "RSSI " + String(WiFi.RSSI()) + " dBm"; + html += "Temp " + String(temperature, 1) + " °C"; + html += "Uptime " + String(millis() / 1000) + " s"; + html += "
"; + + // Inputs + Relays side by side + html += "
"; + // Inputs + 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]; + html += "
"; + html += "
"; + html += "
IN " + String(i) + "
"; + html += "
" + String(pressed ? "PRESSED" : "OPEN") + "
"; + html += "
"; + } + html += "
"; + // Relays + 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 += "
"; + html += "
"; // .card-row + + // LED Control + html += "

LED Control

"; + html += ""; + html += ""; + html += "   Status: " + String(led_state ? "ON" : "OFF") + "
"; + + // NFC Access Control + bool nfc_present_now = nfc_initialized && nfc_enabled && (strcmp(nfc_access_state, "granted") == 0); + String ac_class = String(nfc_access_state) == "granted" ? "nfc-granted" : + String(nfc_access_state) == "denied" ? "nfc-denied" : "nfc-idle"; + String ac_text = String(nfc_access_state) == "granted" ? "ACCESS GRANTED" : + String(nfc_access_state) == "denied" ? "ACCESS DENIED" : "Waiting for card"; + html += "

NFC Access Control (PN532 — UEXT1)

"; + // Module enable row + html += "
"; + html += "Module: " + String(nfc_enabled ? "Active" : "Disabled") + ""; + html += ""; + html += "
"; + // Live UID row + html += "
"; + html += "
"; + html += "
"; + html += "
Detected card UID
"; + html += "
" + (nfc_last_uid.length() > 0 ? nfc_last_uid : "No card inserted") + "
"; + html += "
"; + html += ""; + html += "
"; + html += "
" + ac_text + "
"; + // Current settings table + html += "

Current Settings

"; + html += ""; + html += ""; + if (strlen(nfc_auth_uid) > 0) { + html += ""; + html += ""; + } else { + html += ""; + } + html += ""; + html += ""; + html += ""; + html += "
Authorized card" + String(nfc_auth_uid) + "None — no card authorized yet
Trigger relayRelay " + String(nfc_relay_num) + "
Absent timeout" + String(nfc_pulse_ms) + " ms
"; + // Edit settings form + html += "

Edit Settings

"; + html += "
"; + html += "
Authorized UID
"; + html += "
"; + html += "
Trigger relay
"; + html += "
"; + html += "
Absent timeout (ms)
"; + html += "
"; + html += ""; + html += "
"; + if (!nfc_initialized) { + html += "

✗ PN532 not detected — check UEXT1 wiring (TX=GPIO4, RX=GPIO5)

"; + } + if (nfc_initialized && strlen(nfc_auth_uid) == 0) { + html += "

⚠ No authorized UID — present a card, click “Use as authorized” then Save.

"; + } + html += "
"; // close NFC card + + // HA Webhook + 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 += "
"; + + // API reference + 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 += "GET /nfc/status  •  GET /nfc/config  •  POST /nfc/config?auth_uid=&relay=&pulse_ms=
"; + html += "POST /nfc/enable?state=0|1  •  POST /register?callback_url=..."; + html += "
"; + + html += "
"; // .page + + server.send(200, "text/html", html); +} + +// ── Web UI authentication helper ──────────────────────────────────────── +bool requireAuth() { + if (!server.authenticate(WEB_USER, WEB_PASSWORD)) { + server.requestAuthentication(BASIC_AUTH, "ESP32-C6 Control Panel", + "Login required"); + return false; + } + return true; +} + +// ── HMAC-SHA256 API request verification ───────────────────────────────────── +// Returns true if the request carries a valid X-Request-Sig header (or if +// API_SECRET is empty, meaning auth is disabled). +// Message format: "METHOD:path:unix_timestamp" e.g. "GET:/status:1710512345" +// Timestamp must be within ±60 s of board time (requires NTP sync). +bool verifyAPIRequest() { + // ── Option 1: browser UI — valid Digest Auth credentials ───────────────── + // The browser cannot generate HMAC headers, so we accept Digest Auth as an + // alternative. requireAuth() is still the sole gate on the HTML pages. + if (server.authenticate(WEB_USER, WEB_PASSWORD)) return true; + + // ── Option 2: driver API — valid HMAC-SHA256 signature ─────────────────── + // If no secret configured, allow all requests (backward compat / first boot) + if (strlen(API_SECRET) == 0) return true; + + String ts_str = server.header("X-Request-Time"); + String sig_str = server.header("X-Request-Sig"); + + if (ts_str.length() == 0 || sig_str.length() == 0) { + server.send(401, "application/json", "{\"error\":\"missing auth headers\"}"); + return false; + } + + // Replay prevention: only validate timestamp when NTP is synced + time_t now = time(nullptr); + if (now > 1000000000L) { // NTP synced (year > 2001) + long ts = ts_str.toInt(); + if (abs((long)now - ts) > 60) { + server.send(401, "application/json", "{\"error\":\"timestamp out of window\"}"); + return false; + } + } + + // Build expected message: "METHOD:path:timestamp" + String method = (server.method() == HTTP_POST) ? "POST" : "GET"; + String msg = method + ":" + server.uri() + ":" + ts_str; + + // Compute HMAC-SHA256 + uint8_t hmac_out[32]; + mbedtls_md_context_t ctx; + mbedtls_md_init(&ctx); + mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1); + mbedtls_md_hmac_starts(&ctx, + (const unsigned char *)API_SECRET, strlen(API_SECRET)); + mbedtls_md_hmac_update(&ctx, + (const unsigned char *)msg.c_str(), msg.length()); + mbedtls_md_hmac_finish(&ctx, hmac_out); + mbedtls_md_free(&ctx); + + // Hex-encode result + char expected[65]; + for (int i = 0; i < 32; i++) + sprintf(expected + i * 2, "%02x", hmac_out[i]); + expected[64] = '\0'; + + // Constant-time comparison + if (sig_str.length() != 64) { + server.send(401, "application/json", "{\"error\":\"invalid signature\"}"); + return false; + } + uint8_t diff = 0; + for (int i = 0; i < 64; i++) + diff |= (uint8_t)sig_str[i] ^ (uint8_t)expected[i]; + + if (diff != 0) { + server.send(401, "application/json", "{\"error\":\"invalid signature\"}"); + return false; + } + return true; +} + +// Status request monitoring +static unsigned long last_status_log = 0; +static int status_request_count = 0; + +void handleStatus() { + if (!verifyAPIRequest()) return; + 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") + ","; + bool nfc_present = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0); + json += "\"nfc_initialized\":" + String(nfc_initialized ? "true" : "false") + ","; + json += "\"nfc_card_present\":" + String(nfc_present ? "true" : "false") + ","; + json += "\"nfc_last_uid\":\"" + nfc_last_uid + "\","; + json += "\"nfc_access_state\":\"" + String(nfc_access_state) + "\","; + json += "\"nfc_auth_uid\":\"" + String(nfc_auth_uid) + "\","; + json += "\"nfc_relay_num\":" + String(nfc_relay_num) + ","; + json += "\"nfc_pulse_ms\":" + String(nfc_pulse_ms) + ","; + json += "\"nfc_enabled\":" + String(nfc_enabled ? "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 (!verifyAPIRequest()) return; + 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 (!verifyAPIRequest()) return; + 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 (!verifyAPIRequest()) return; + 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 (!verifyAPIRequest()) return; + 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() { + if (!verifyAPIRequest()) return; + digitalWrite(LED_PIN, LOW); // LED is active-low + led_state = true; + server.send(200, "application/json", "{\"status\":\"ok\"}"); + Serial.println("LED ON"); +} + +void handleLEDOff() { + if (!verifyAPIRequest()) return; + 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() { + // NOTE: /register is the bootstrap handshake — it intentionally has no HMAC + // guard because the api_secret cannot be exchanged until after registration. + // All other API endpoints require verifyAPIRequest(). + 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; + + // ── Persist HA registration to NVS ─────────────────────────────────────── + prefs.begin("ha_cfg", false); // read-write namespace + prefs.putString("callback_url", ha_callback_url); + prefs.end(); + // ───────────────────────────────────────────────────────────────────────── + + 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); + } +} + +// ============================================ +// Shared HTTP POST helper — parses ha_callback_url and POSTs JSON. +// Uses a 3-second timeout so an unreachable HA server never blocks the loop. +// ============================================ + +bool postJsonToHA(const String& json) { + if (!ha_registered || strlen(ha_callback_url) == 0) return false; + + String url_str = String(ha_callback_url); + int protocol_end = url_str.indexOf("://"); + if (protocol_end < 0) return false; + + int host_start = protocol_end + 3; + int port_sep = url_str.indexOf(":", host_start); + int path_start = url_str.indexOf("/", host_start); + if (path_start < 0) path_start = url_str.length(); + if (port_sep < 0 || port_sep > path_start) port_sep = -1; + + String host = url_str.substring(host_start, (port_sep >= 0) ? port_sep : path_start); + int port = 80; + if (port_sep >= 0) { + int colon_end = url_str.indexOf("/", port_sep); + if (colon_end < 0) colon_end = url_str.length(); + port = url_str.substring(port_sep + 1, colon_end).toInt(); + } + String path = url_str.substring(path_start); + + WiFiClient client; + client.setTimeout(3000); // 3-second connect/read timeout + if (!client.connect(host.c_str(), port)) { + Serial.printf("HA POST: failed to connect to %s:%d\n", host.c_str(), port); + return false; + } + 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); + + unsigned long deadline = millis() + 3000; + while (client.connected() && millis() < deadline) { + if (client.available()) client.read(); + else delay(1); + } + client.stop(); + return true; +} + +void postInputEvent(int input_num, bool state) { + if (!ha_registered || strlen(ha_callback_url) == 0) return; + bool pressed = !state; + Serial.printf("Input %d event: %s (raw_state=%d) - POSTing to HA\n", + input_num, pressed ? "input_on" : "input_off", state); + String json = "{\"input\":" + String(input_num) + + ",\"state\":" + (pressed ? "true" : "false") + "}"; + if (postJsonToHA(json)) + Serial.printf("Input %d event posted successfully\n", input_num); +} + +// ============================================ +// NFC Status API +// ============================================ + +// ============================================ +// NFC Enable/Disable POST /nfc/enable?state=0|1 +// ============================================ + +void handleNFCEnable() { + if (!verifyAPIRequest()) return; + if (!server.hasArg("state")) { + server.send(400, "application/json", "{\"error\":\"Missing state (0 or 1)\"}"); + return; + } + nfc_enabled = (server.arg("state").toInt() != 0); + // When disabling while a card is granted, close the relay and reset state + if (!nfc_enabled) { + if (strcmp(nfc_access_state, "granted") == 0) { + int pin = nfcRelayPin(nfc_relay_num); + if (pin >= 0) { + digitalWrite(pin, LOW); + switch (nfc_relay_num) { + case 1: relay1_state = false; break; + case 2: relay2_state = false; break; + case 3: relay3_state = false; break; + case 4: relay4_state = false; break; + } + } + } + strcpy(nfc_access_state, "idle"); + nfc_last_uid = ""; + nfc_miss_count = 0; + } + prefs.begin("nfc_cfg", false); + prefs.putBool("enabled", nfc_enabled); + prefs.end(); + Serial.printf("NFC access control: %s\n", nfc_enabled ? "ENABLED" : "DISABLED"); + server.send(200, "application/json", + String("{\"status\":\"ok\",\"nfc_enabled\":") + (nfc_enabled ? "true" : "false") + "}"); +} + +void handleNFCStatus() { + if (!verifyAPIRequest()) return; + bool present = nfc_initialized && nfc_enabled && (strcmp(nfc_access_state, "granted") == 0); + String json = "{"; + json += "\"initialized\":" + String(nfc_initialized ? "true" : "false") + ","; + json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false") + ","; + json += "\"card_present\":" + String(present ? "true" : "false") + ","; + json += "\"last_uid\":\"" + nfc_last_uid + "\","; + json += "\"access_state\":\"" + String(nfc_access_state) + "\","; + json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\","; + json += "\"relay_num\":" + String(nfc_relay_num) + ","; + json += "\"pulse_ms\":" + String(nfc_pulse_ms); + json += "}"; + server.send(200, "application/json", json); +} + +// ============================================ +// NFC Webhook — POST card event to Home Assistant +// ============================================ + +void postNFCEvent(const String& uid) { + if (!ha_registered || strlen(ha_callback_url) == 0) return; + Serial.printf("NFC: posting UID %s to HA\n", uid.c_str()); + String json = "{\"type\":\"nfc_card\",\"uid\":\"" + uid + + "\",\"uptime\":" + String(millis() / 1000) + "}"; + if (postJsonToHA(json)) + Serial.printf("NFC: event posted for UID %s\n", uid.c_str()); +} + +// ============================================ +// NFC Helper: resolve relay number to GPIO pin +// ============================================ + +int nfcRelayPin(int rnum) { + switch (rnum) { + case 1: return RELAY_1_PIN; + case 2: return RELAY_2_PIN; + case 3: return RELAY_3_PIN; + case 4: return RELAY_4_PIN; + default: return -1; + } +} + +// ============================================ +// NFC Config API GET /nfc/config +// POST /nfc/config +// ============================================ + +void handleNFCConfigGet() { + if (!verifyAPIRequest()) return; + String json = "{"; + json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\","; + json += "\"relay_num\":" + String(nfc_relay_num) + ","; + json += "\"pulse_ms\":" + String(nfc_pulse_ms) + ","; + json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false"); + json += "}"; + server.send(200, "application/json", json); +} + +void handleNFCConfigSet() { + if (!verifyAPIRequest()) return; + if (server.hasArg("auth_uid")) { + String u = server.arg("auth_uid"); + u.trim(); + u.toUpperCase(); + if (u.length() < sizeof(nfc_auth_uid)) { + u.toCharArray(nfc_auth_uid, sizeof(nfc_auth_uid)); + } else { + server.send(400, "application/json", "{\"error\":\"auth_uid too long (max 31 chars)\"}"); + return; + } + } + if (server.hasArg("relay")) { + int r = server.arg("relay").toInt(); + if (r >= 1 && r <= 4) { + nfc_relay_num = r; + } else { + server.send(400, "application/json", "{\"error\":\"relay must be 1-4\"}"); + return; + } + } + if (server.hasArg("pulse_ms")) { + long p = server.arg("pulse_ms").toInt(); + if (p >= 100 && p <= 60000) { + nfc_pulse_ms = (unsigned long)p; + } else { + server.send(400, "application/json", "{\"error\":\"pulse_ms range: 100-60000\"}"); + return; + } + } + Serial.printf("NFC config: auth='%s' relay=%d pulse=%lu ms\n", + nfc_auth_uid, nfc_relay_num, nfc_pulse_ms); + // ── Persist to NVS so settings survive power cycles ────────────────────── + prefs.begin("nfc_cfg", false); // read-write namespace + prefs.putString("auth_uid", nfc_auth_uid); + prefs.putInt("relay_num", nfc_relay_num); + prefs.putULong("pulse_ms", nfc_pulse_ms); + prefs.end(); + // ───────────────────────────────────────────────────────────────────────── + String json = "{\"status\":\"ok\"," + "\"auth_uid\":\"" + String(nfc_auth_uid) + "\"," + "\"relay_num\":" + String(nfc_relay_num) + "," + "\"pulse_ms\":" + String(nfc_pulse_ms) + "}"; + server.send(200, "application/json", json); +} + +// ============================================ +// Debug endpoint GET /debug +// Returns plain-text system info for diagnosing +// connectivity without needing a browser. +// ============================================ + +void handleDebug() { + if (!requireAuth()) return; + + String out = "=== ESP32-C6 Debug ===\n"; + out += "Uptime: " + String(millis() / 1000) + " s\n"; + out += "Free heap: " + String(ESP.getFreeHeap()) + " bytes\n"; + out += "WiFi status: "; + switch (WiFi.status()) { + case WL_CONNECTED: out += "CONNECTED\n"; break; + case WL_NO_SSID_AVAIL: out += "NO SSID\n"; break; + case WL_CONNECT_FAILED: out += "FAILED\n"; break; + case WL_DISCONNECTED: out += "DISCONNECTED\n"; break; + default: out += String(WiFi.status()) + "\n"; + } + out += "IP: " + WiFi.localIP().toString() + "\n"; + out += "SSID: " + WiFi.SSID() + "\n"; + out += "RSSI: " + String(WiFi.RSSI()) + " dBm\n"; + out += "MAC: " + WiFi.macAddress() + "\n"; + out += "\n"; + out += "NFC init: " + String(nfc_initialized ? "YES" : "NO") + "\n"; + out += "NFC last UID:" + String(nfc_last_uid.length() > 0 ? nfc_last_uid : "(none)") + "\n"; + out += "NFC state: " + String(nfc_access_state) + "\n"; + out += "NFC relay: " + String(nfc_relay_num) + "\n"; + out += "NFC auth: " + String(strlen(nfc_auth_uid) > 0 ? nfc_auth_uid : "(any)") + "\n"; + out += "\n"; + out += "Relay states: R1=" + String(relay1_state) + " R2=" + String(relay2_state) + + " R3=" + String(relay3_state) + " R4=" + String(relay4_state) + "\n"; + server.send(200, "text/plain", out); +} diff --git a/olimex_ESP32-C6-EVB/esp32_arduino/location_managemet.code-workspace b/olimex_ESP32-C6-EVB/esp32_arduino/location_managemet.code-workspace new file mode 100644 index 0000000..187135c --- /dev/null +++ b/olimex_ESP32-C6-EVB/esp32_arduino/location_managemet.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../../../location_managemet" + }, + { + "path": "../.." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/olimex_ESP32-C6-EVB/esp32_arduino/secrets.h.example b/olimex_ESP32-C6-EVB/esp32_arduino/secrets.h.example new file mode 100644 index 0000000..c4b3a90 --- /dev/null +++ b/olimex_ESP32-C6-EVB/esp32_arduino/secrets.h.example @@ -0,0 +1,15 @@ +// ── Board secrets (EXAMPLE — copy to secrets.h and fill in your values) ─────── +// DO NOT commit secrets.h to version control. +// +// API_SECRET — shared secret for HMAC-SHA256 API request authentication. +// Generate a strong random value with: +// python3 -c "import secrets; print(secrets.token_hex(32))" +// Then paste the same value into the board's Edit page in the Location +// Management server. +// +// WEB_USER / WEB_PASSWORD — credentials for the browser control panel. +// ───────────────────────────────────────────────────────────────────────────── + +#define API_SECRET "REPLACE_WITH_OUTPUT_OF_python3_-c_import_secrets_token_hex_32" +#define WEB_USER "your_username" +#define WEB_PASSWORD "your_password"