From e5fd3645d1fddf701b0a986ad7521e7769a839e3 Mon Sep 17 00:00:00 2001 From: ske087 Date: Thu, 11 Jun 2026 00:42:59 +0300 Subject: [PATCH] Initial commit: ESP32-C5-EVB and ESP32-C6-EVB Arduino firmware + HA custom components --- .gitignore | 18 + olimex_ESP32-C5-EVB/NFC_DEBUG_GUIDE.md | 161 ++ olimex_ESP32-C5-EVB/README.md | 186 +++ olimex_ESP32-C5-EVB/board_verify.py | 235 +++ olimex_ESP32-C5-EVB/build_and_deploy.sh | 133 ++ .../olimex_esp32_c5/__init__.py | 117 ++ .../olimex_esp32_c5/binary_sensor.py | 90 ++ .../olimex_esp32_c5/config_flow.py | 112 ++ .../olimex_esp32_c5/const.py | 28 + .../olimex_esp32_c5/manifest.json | 11 + .../olimex_esp32_c5/py.typed | 0 .../olimex_esp32_c5/strings.json | 19 + .../olimex_esp32_c5/switch.py | 117 ++ .../olimex_esp32_c5/webhook.py | 29 + .../esp32_arduino/DEPLOYMENT_GUIDE.md | 299 ++++ .../esp32_arduino/esp32_arduino.ino | 1418 +++++++++++++++++ .../olimex_esp32_c5.code-workspace | 20 + .../esp32_arduino/secrets.h.example | 40 + olimex_ESP32-C5-EVB/nfc_diagnostics.py | 76 + olimex_ESP32-C6-EVB/.gitignore | 15 + olimex_ESP32-C6-EVB/README.md | 29 + olimex_ESP32-C6-EVB/board_test/board_test.ino | 310 ++++ olimex_ESP32-C6-EVB/board_verify.py | 252 +++ .../olimex_esp32_c6/README.md | 107 ++ .../olimex_esp32_c6/SETUP.md | 203 +++ .../olimex_esp32_c6/__init__.py | 117 ++ .../olimex_esp32_c6/binary_sensor.py | 90 ++ .../olimex_esp32_c6/config_flow.py | 112 ++ .../olimex_esp32_c6/const.py | 28 + .../olimex_esp32_c6/manifest.json | 11 + .../olimex_esp32_c6/olimex.png | Bin 0 -> 55690 bytes .../olimex_esp32_c6/sensor.py | 122 ++ .../olimex_esp32_c6/sensor_updater.py | 85 + .../olimex_esp32_c6/strings.json | 49 + .../olimex_esp32_c6/switch.py | 117 ++ .../olimex_esp32_c6/webhook.py | 29 + .../esp32_arduino/DEPLOYMENT_GUIDE.md | 270 ++++ olimex_ESP32-C6-EVB/esp32_arduino/README.md | 166 ++ .../esp32_arduino/esp32_arduino.ino | 1390 ++++++++++++++++ .../location_managemet.code-workspace | 11 + .../esp32_arduino/secrets.h.example | 15 + 41 files changed, 6637 insertions(+) create mode 100644 .gitignore create mode 100644 olimex_ESP32-C5-EVB/NFC_DEBUG_GUIDE.md create mode 100644 olimex_ESP32-C5-EVB/README.md create mode 100644 olimex_ESP32-C5-EVB/board_verify.py create mode 100755 olimex_ESP32-C5-EVB/build_and_deploy.sh create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/__init__.py create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/binary_sensor.py create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/config_flow.py create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/const.py create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/manifest.json create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/py.typed create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/strings.json create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/switch.py create mode 100644 olimex_ESP32-C5-EVB/custom_components/olimex_esp32_c5/webhook.py create mode 100644 olimex_ESP32-C5-EVB/esp32_arduino/DEPLOYMENT_GUIDE.md create mode 100644 olimex_ESP32-C5-EVB/esp32_arduino/esp32_arduino.ino create mode 100644 olimex_ESP32-C5-EVB/esp32_arduino/olimex_esp32_c5.code-workspace create mode 100644 olimex_ESP32-C5-EVB/esp32_arduino/secrets.h.example create mode 100644 olimex_ESP32-C5-EVB/nfc_diagnostics.py create mode 100644 olimex_ESP32-C6-EVB/.gitignore create mode 100644 olimex_ESP32-C6-EVB/README.md create mode 100644 olimex_ESP32-C6-EVB/board_test/board_test.ino create mode 100644 olimex_ESP32-C6-EVB/board_verify.py create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/README.md create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/SETUP.md create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/__init__.py create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/binary_sensor.py create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/config_flow.py create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/const.py create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/manifest.json create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/olimex.png create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/sensor.py create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/sensor_updater.py create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/strings.json create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/switch.py create mode 100644 olimex_ESP32-C6-EVB/custom_components/olimex_esp32_c6/webhook.py create mode 100644 olimex_ESP32-C6-EVB/esp32_arduino/DEPLOYMENT_GUIDE.md create mode 100644 olimex_ESP32-C6-EVB/esp32_arduino/README.md create mode 100644 olimex_ESP32-C6-EVB/esp32_arduino/esp32_arduino.ino create mode 100644 olimex_ESP32-C6-EVB/esp32_arduino/location_managemet.code-workspace create mode 100644 olimex_ESP32-C6-EVB/esp32_arduino/secrets.h.example 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 0000000000000000000000000000000000000000..5b4e72aeb35cffeecfb8b7413d20db9888e401b4 GIT binary patch literal 55690 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 None: + """Set up Olimex ESP32-C6-EVB sensors.""" + host = entry.data["host"] + port = entry.data.get("port", 80) + + coordinator = OlimexDataUpdateCoordinator(hass, host, port) + await coordinator.async_config_entry_first_refresh() + + sensors = [ + OlimexTemperatureSensor(coordinator, entry), + OlimexWiFiSignalSensor(coordinator, entry), + ] + + async_add_entities(sensors) + +class OlimexDataUpdateCoordinator(DataUpdateCoordinator): + """Data coordinator for Olimex ESP32-C6-EVB.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self.host = host + self.port = port + + async def _async_update_data(self): + """Fetch data from ESP32-C6.""" + try: + async with aiohttp.ClientSession() as session: + # TODO: Update this URL based on your ESP32 firmware API + async with session.get( + f"http://{self.host}:{self.port}/api/status", + timeout=aiohttp.ClientTimeout(total=10), + ) as response: + if response.status == 200: + return await response.json() + raise UpdateFailed(f"Error fetching data: {response.status}") + except aiohttp.ClientError as err: + raise UpdateFailed(f"Error communicating with device: {err}") + +class OlimexTemperatureSensor(CoordinatorEntity, SensorEntity): + """Temperature sensor for ESP32-C6.""" + + def __init__(self, coordinator, entry): + """Initialize the sensor.""" + super().__init__(coordinator) + self._entry = entry + self._attr_name = "Temperature" + self._attr_unique_id = f"{entry.entry_id}_temperature" + self._attr_native_unit_of_measurement = "°C" + self._attr_device_class = "temperature" + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": f"Olimex ESP32-C6 ({self._entry.data['host']})", + **DEVICE_INFO, + } + + @property + def native_value(self): + """Return the temperature value.""" + if self.coordinator.data: + return self.coordinator.data.get("temperature") + return None + +class OlimexWiFiSignalSensor(CoordinatorEntity, SensorEntity): + """WiFi signal sensor for ESP32-C6.""" + + def __init__(self, coordinator, entry): + """Initialize the sensor.""" + super().__init__(coordinator) + self._entry = entry + self._attr_name = "WiFi Signal" + self._attr_unique_id = f"{entry.entry_id}_wifi_signal" + self._attr_native_unit_of_measurement = "dBm" + self._attr_device_class = "signal_strength" + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": f"Olimex ESP32-C6 ({self._entry.data['host']})", + **DEVICE_INFO, + } + + @property + def native_value(self): + """Return the WiFi signal strength.""" + if self.coordinator.data: + return self.coordinator.data.get("wifi_rssi") + return None diff --git a/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"