From 1152f93a0098bffbb92c39b55674087ffb4e3726 Mon Sep 17 00:00:00 2001 From: scheianu Date: Sun, 15 Mar 2026 09:41:01 +0200 Subject: [PATCH] Add Olimex ESP32-C6-EVB + PN532 NFC driver and web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New driver: app/drivers/olimex_esp32_c6_evb_pn532/ - Full relay/input control (inherits base behaviour) - get_nfc_status(), get_nfc_config(), set_nfc_config() methods - manifest.json with NFC hardware metadata (UEXT1 pins, card standard) - NFC management routes (boards.py): - GET /boards//nfc — management page - GET /boards//nfc/status_json — live JSON (polled every 3 s) - POST /boards//nfc/config — save auth UID / relay / timeout - POST /boards//nfc/enroll — enrol last-seen card with one click - New template: templates/boards/nfc.html - Live reader status (PN532 ready, access state, last UID) - Quick Enroll: present card → Refresh → Enrol in one click - Manual Settings: type/paste UID, pick relay, set absence timeout - detail.html: NFC Access Control button shown for pn532 board type --- .../olimex_esp32_c6_evb_pn532/__init__.py | 1 + .../olimex_esp32_c6_evb_pn532/driver.py | 184 ++++++++++++ .../olimex_esp32_c6_evb_pn532/manifest.json | 28 ++ app/routes/boards.py | 100 +++++++ app/templates/boards/detail.html | 5 + app/templates/boards/nfc.html | 276 ++++++++++++++++++ 6 files changed, 594 insertions(+) create mode 100644 app/drivers/olimex_esp32_c6_evb_pn532/__init__.py create mode 100644 app/drivers/olimex_esp32_c6_evb_pn532/driver.py create mode 100644 app/drivers/olimex_esp32_c6_evb_pn532/manifest.json create mode 100644 app/templates/boards/nfc.html diff --git a/app/drivers/olimex_esp32_c6_evb_pn532/__init__.py b/app/drivers/olimex_esp32_c6_evb_pn532/__init__.py new file mode 100644 index 0000000..eed333d --- /dev/null +++ b/app/drivers/olimex_esp32_c6_evb_pn532/__init__.py @@ -0,0 +1 @@ +"""Olimex ESP32-C6-EVB with PN532 NFC reader driver package.""" diff --git a/app/drivers/olimex_esp32_c6_evb_pn532/driver.py b/app/drivers/olimex_esp32_c6_evb_pn532/driver.py new file mode 100644 index 0000000..88923b5 --- /dev/null +++ b/app/drivers/olimex_esp32_c6_evb_pn532/driver.py @@ -0,0 +1,184 @@ +"""Olimex ESP32-C6-EVB + PN532 NFC reader board driver. + +Hardware +-------- +- 4 relays (outputs) +- 4 digital inputs +- PN532 NFC reader connected via UEXT1 (UART/HSU mode) + UEXT1 pin 3 = GPIO4 → PN532 RXD + UEXT1 pin 4 = GPIO5 → PN532 TXD + DIP1 = 0, DIP2 = 0 (HSU mode) +- HTTP REST API served directly by the board on port 80 + +API endpoints +------------- +POST /relay/on?relay= → {"state": true} +POST /relay/off?relay= → {"state": false} +POST /relay/toggle?relay= → {"state": } +GET /relay/status?relay= → {"state": } +GET /input/status?input= → {"state": } +POST /register?callback_url= → {"status": "ok"} +GET /nfc/status → {"initialized": bool, "card_present": bool, + "last_uid": str, "access_state": str, + "auth_uid": str, "relay_num": int, + "pulse_ms": int} +GET /nfc/config → {"auth_uid": str, "relay_num": int, "pulse_ms": int} +POST /nfc/config?auth_uid=&relay=&pulse_ms= → {"status": "ok", ...} + +Webhook (board → server) +------------------------ +The board POSTs to the registered callback_url whenever an input changes: + POST + {"input": , "state": } + +The board also POSTs an NFC card event on every card detection: + POST + {"type": "nfc_card", "uid": "", "uptime": } +""" +from __future__ import annotations + +import logging +import urllib.parse +import requests +from typing import TYPE_CHECKING + +from app.drivers.base import BoardDriver + +if TYPE_CHECKING: + from app.models.board import Board + +logger = logging.getLogger(__name__) +_TIMEOUT = 3 + + +def _get(url: str) -> dict | None: + try: + r = requests.get(url, timeout=_TIMEOUT) + r.raise_for_status() + return r.json() + except Exception as exc: + logger.debug("GET %s → %s", url, exc) + return None + + +def _post(url: str) -> dict | None: + try: + r = requests.post(url, timeout=_TIMEOUT) + r.raise_for_status() + return r.json() + except Exception as exc: + logger.debug("POST %s → %s", url, exc) + return None + + +class OlimexESP32C6EVBPn532Driver(BoardDriver): + """Driver for the Olimex ESP32-C6-EVB board with PN532 NFC reader.""" + + DRIVER_ID = "olimex_esp32_c6_evb_pn532" + DISPLAY_NAME = "Olimex ESP32-C6-EVB + PN532 NFC" + DESCRIPTION = "4 relays · 4 digital inputs · PN532 NFC reader (UEXT1/HSU) · HTTP REST + webhook callbacks" + DEFAULT_NUM_RELAYS = 4 + DEFAULT_NUM_INPUTS = 4 + FIRMWARE_URL = "https://github.com/OLIMEX/ESP32-C6-EVB" + + # ── relay control ───────────────────────────────────────────────────────── + + def get_relay_status(self, board: "Board", relay_num: int) -> bool | None: + data = _get(f"{board.base_url}/relay/status?relay={relay_num}") + return bool(data["state"]) if data is not None else None + + def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool: + action = "on" if state else "off" + return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None + + def toggle_relay(self, board: "Board", relay_num: int) -> bool | None: + data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}") + return bool(data["state"]) if data is not None else None + + # ── poll ────────────────────────────────────────────────────────────────── + + def poll(self, board: "Board") -> dict: + _offline = {"relay_states": {}, "input_states": {}, "is_online": False} + + relay_states: dict = {} + input_states: dict = {} + + if board.num_relays > 0: + probe = _get(f"{board.base_url}/relay/status?relay=1") + if probe is None: + return _offline + relay_states["relay_1"] = bool(probe.get("state", False)) + elif board.num_inputs > 0: + probe = _get(f"{board.base_url}/input/status?input=1") + if probe is None: + return _offline + input_states["input_1"] = bool(probe.get("state", False)) + else: + return _offline + + for n in range(2, board.num_relays + 1): + data = _get(f"{board.base_url}/relay/status?relay={n}") + if data is not None: + relay_states[f"relay_{n}"] = bool(data.get("state", False)) + + input_start = 2 if (board.num_relays == 0 and board.num_inputs > 0) else 1 + for n in range(input_start, board.num_inputs + 1): + data = _get(f"{board.base_url}/input/status?input={n}") + if data is not None: + input_states[f"input_{n}"] = bool(data.get("state", True)) + + return { + "relay_states": relay_states, + "input_states": input_states, + "is_online": True, + } + + # ── webhook registration ────────────────────────────────────────────────── + + def register_webhook(self, board: "Board", callback_url: str) -> bool: + url = f"{board.base_url}/register?callback_url={callback_url}" + ok = _post(url) is not None + if ok: + logger.info("Webhook registered on board '%s' → %s", board.name, callback_url) + else: + logger.warning("Webhook registration failed for board '%s'", board.name) + return ok + + # ── NFC access control ──────────────────────────────────────────────────── + + def get_nfc_status(self, board: "Board") -> dict | None: + """Return current NFC reader status (last UID, access_state, auth config).""" + return _get(f"{board.base_url}/nfc/status") + + def get_nfc_config(self, board: "Board") -> dict | None: + """Return current NFC access-control configuration from the board.""" + return _get(f"{board.base_url}/nfc/config") + + def set_nfc_config( + self, + board: "Board", + auth_uid: str = "", + relay_num: int = 1, + pulse_ms: int = 3000, + ) -> bool: + """Push NFC access-control config to the board. + + auth_uid: authorized card UID (e.g. "04:AB:CD:EF"); empty = no card authorized. + relay_num: which relay to open on a matching card (1-4). + pulse_ms: absence timeout — relay closes this many ms after card is removed (100-60000). + """ + url = ( + f"{board.base_url}/nfc/config" + f"?auth_uid={urllib.parse.quote(auth_uid.upper())}" + f"&relay={relay_num}" + f"&pulse_ms={pulse_ms}" + ) + result = _post(url) + if result: + logger.info( + "NFC config pushed to board '%s': uid='%s' relay=%d pulse=%dms", + board.name, auth_uid, relay_num, pulse_ms, + ) + else: + logger.warning("NFC config push failed for board '%s'", board.name) + return result is not None diff --git a/app/drivers/olimex_esp32_c6_evb_pn532/manifest.json b/app/drivers/olimex_esp32_c6_evb_pn532/manifest.json new file mode 100644 index 0000000..7e1d44f --- /dev/null +++ b/app/drivers/olimex_esp32_c6_evb_pn532/manifest.json @@ -0,0 +1,28 @@ +{ + "driver_id": "olimex_esp32_c6_evb_pn532", + "display_name": "Olimex ESP32-C6-EVB + PN532 NFC", + "description": "Olimex ESP32-C6-EVB board with 4 relays, 4 digital inputs, and a PN532 NFC reader connected via UEXT1 (HSU/UART mode). Communicates via HTTP REST API with webhook callbacks. Supports NFC card access control.", + "manufacturer": "Olimex", + "chip": "ESP32-C6 WROOM-1", + "default_num_relays": 4, + "default_num_inputs": 4, + "firmware_url": "https://github.com/OLIMEX/ESP32-C6-EVB", + "nfc": { + "reader": "NXP PN532", + "interface": "UART/HSU via UEXT1", + "uext1_pin3_gpio": 4, + "uext1_pin4_gpio": 5, + "card_standard": "ISO14443A / Mifare" + }, + "api": { + "relay_on": "POST /relay/on?relay={n}", + "relay_off": "POST /relay/off?relay={n}", + "relay_toggle": "POST /relay/toggle?relay={n}", + "relay_status": "GET /relay/status?relay={n}", + "input_status": "GET /input/status?input={n}", + "register": "POST /register?callback_url={url}", + "nfc_status": "GET /nfc/status", + "nfc_config": "GET /nfc/config", + "nfc_config_set": "POST /nfc/config?auth_uid={uid}&relay={n}&pulse_ms={ms}" + } +} diff --git a/app/routes/boards.py b/app/routes/boards.py index dccdddd..6737a03 100644 --- a/app/routes/boards.py +++ b/app/routes/boards.py @@ -9,6 +9,8 @@ from app.services.board_service import poll_board, register_webhook, set_relay from app.drivers.registry import registry from flask import current_app +_NFC_DRIVER_ID = "olimex_esp32_c6_evb_pn532" + boards_bp = Blueprint("boards", __name__) @@ -276,3 +278,101 @@ def edit_entities(board_id: int): @login_required def edit_labels(board_id: int): return redirect(url_for("boards.edit_entities", board_id=board_id)) + + +# ── NFC management (PN532 boards only) ──────────────────────────────────────── + +@boards_bp.route("//nfc") +@login_required +def nfc_management(board_id: int): + board = db.get_or_404(Board, board_id) + if board.board_type != _NFC_DRIVER_ID: + abort(404) + driver = registry.get(_NFC_DRIVER_ID) + nfc_status = driver.get_nfc_status(board) if driver else None + return render_template( + "boards/nfc.html", + board=board, + nfc=nfc_status or {}, + ) + + +@boards_bp.route("//nfc/status_json") +@login_required +def nfc_status_json(board_id: int): + board = db.get_or_404(Board, board_id) + if board.board_type != _NFC_DRIVER_ID: + abort(404) + driver = registry.get(_NFC_DRIVER_ID) + data = driver.get_nfc_status(board) if driver else None + if data is None: + return jsonify({"error": "Board unreachable"}), 502 + return jsonify(data) + + +@boards_bp.route("//nfc/config", methods=["POST"]) +@login_required +def nfc_config_save(board_id: int): + if not current_user.is_admin(): + abort(403) + board = db.get_or_404(Board, board_id) + if board.board_type != _NFC_DRIVER_ID: + abort(404) + driver = registry.get(_NFC_DRIVER_ID) + if not driver: + flash("NFC driver not available.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) + + auth_uid = request.form.get("auth_uid", "").strip().upper() + relay_num = int(request.form.get("relay_num", 1)) + pulse_ms = int(request.form.get("pulse_ms", 3000)) + + if relay_num < 1 or relay_num > 4: + flash("Relay number must be 1–4.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) + if pulse_ms < 100 or pulse_ms > 60000: + flash("Absence timeout must be between 100 and 60 000 ms.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) + + ok = driver.set_nfc_config(board, auth_uid=auth_uid, relay_num=relay_num, pulse_ms=pulse_ms) + if ok: + uid_display = auth_uid if auth_uid else "(any card)" + flash(f"NFC config saved — authorized UID: {uid_display}, relay: {relay_num}, timeout: {pulse_ms} ms", "success") + else: + flash("Failed to push NFC config — board unreachable.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) + + +@boards_bp.route("//nfc/enroll", methods=["POST"]) +@login_required +def nfc_enroll(board_id: int): + """Read the last-seen UID from the board and immediately authorize it.""" + if not current_user.is_admin(): + abort(403) + board = db.get_or_404(Board, board_id) + if board.board_type != _NFC_DRIVER_ID: + abort(404) + driver = registry.get(_NFC_DRIVER_ID) + if not driver: + flash("NFC driver not available.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) + + status = driver.get_nfc_status(board) + if not status: + flash("Board unreachable — could not read card UID.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) + + uid = (status.get("last_uid") or "").strip().upper() + if not uid: + flash("No card has been presented to the reader yet.", "warning") + return redirect(url_for("boards.nfc_management", board_id=board_id)) + + relay_num = int(request.form.get("relay_num", status.get("relay_num", 1))) + pulse_ms = int(request.form.get("pulse_ms", status.get("pulse_ms", 3000))) + + ok = driver.set_nfc_config(board, auth_uid=uid, relay_num=relay_num, pulse_ms=pulse_ms) + if ok: + flash(f"Card enrolled — UID: {uid}, relay: {relay_num}, timeout: {pulse_ms} ms", "success") + else: + flash("Card read OK but failed to push config to board.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) diff --git a/app/templates/boards/detail.html b/app/templates/boards/detail.html index 72126e8..04ccb50 100644 --- a/app/templates/boards/detail.html +++ b/app/templates/boards/detail.html @@ -20,6 +20,11 @@ {% if current_user.is_admin() %}
+ {% if board.board_type == 'olimex_esp32_c6_evb_pn532' %} + + NFC Access Control + + {% endif %} Configure Entities diff --git a/app/templates/boards/nfc.html b/app/templates/boards/nfc.html new file mode 100644 index 0000000..6101aae --- /dev/null +++ b/app/templates/boards/nfc.html @@ -0,0 +1,276 @@ +{% extends "base.html" %} +{% block title %}NFC Access Control – {{ board.name }}{% endblock %} + +{% block content %} + + +
+
+

NFC Access Control

+ {{ board.name }} — {{ board.host }}:{{ board.port }} +
+ +
+ +
+ + +
+
+
+ Reader Status +
+
+ + +
+ + {% if nfc.get('initialized') %} + PN532 Ready + {% else %} + PN532 Not Detected + {% endif %} + +
+ + +
+
Access state
+
+ {% set as = nfc.get('access_state','idle') %} + {% if as == 'granted' %}ACCESS GRANTED + {% elif as == 'denied' %}ACCESS DENIED + {% else %}Idle — waiting for card + {% endif %} +
+
+ + +
+
Last detected card UID
+
+ + {{ nfc.get('last_uid') or '—' }} + + {% if current_user.is_admin() %} + + {% endif %} +
+
+ + +
+
Authorized card UID
+ {% if nfc.get('auth_uid') %} + {{ nfc.get('auth_uid') }} + {% else %} + None — no card enrolled yet + {% endif %} +
+ + +
+
Trigger relay
+
Relay {{ nfc.get('relay_num', 1) }}
+
Absence timeout
+
{{ nfc.get('pulse_ms', 3000) }} ms
+
+ +
+
+
+ + +
+ + {% if current_user.is_admin() %} + +
+
+ Quick Enroll +
+
+

+ Present a card to the reader, click Refresh until the UID appears, then click Enroll card. + The UID will be set as the authorized card with the relay and timeout below. +

+
+
+
+ + +
+
+ + +
+
+ +
+
+ {% if nfc.get('last_uid') %} +
+ Will enroll UID: {{ nfc.get('last_uid') }} +
+ {% else %} +
+ No card detected yet — present a card to the reader and refresh. +
+ {% endif %} +
+
+
+ + +
+
+ Manual Settings +
+
+
+
+
+ + +
+
+ + +
+
+ + +
Relay closes this many ms after the card is removed (100 – 60 000).
+
+
+ + {% if nfc.get('auth_uid') %} + + {% endif %} +
+
+
+
+
+ {% endif %}{# is_admin #} + +
{# right col #} +
{# row #} +{% endblock %} + +{% block scripts %} + +{% endblock %}