Files
scheianu 1152f93a00 Add Olimex ESP32-C6-EVB + PN532 NFC driver and web UI
- 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/<id>/nfc            — management page
  - GET  /boards/<id>/nfc/status_json — live JSON (polled every 3 s)
  - POST /boards/<id>/nfc/config     — save auth UID / relay / timeout
  - POST /boards/<id>/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
2026-03-15 09:41:01 +02:00

277 lines
12 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}NFC Access Control {{ board.name }}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
<li class="breadcrumb-item active">NFC Access Control</li>
</ol>
</nav>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-0"><i class="bi bi-credit-card-2-front me-2 text-primary"></i>NFC Access Control</h2>
<span class="text-secondary small font-monospace">{{ board.name }} — {{ board.host }}:{{ board.port }}</span>
</div>
<button class="btn btn-outline-secondary" id="refresh-btn" onclick="loadStatus()">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
</div>
<div class="row g-4">
<!-- ── Live Reader Status ──────────────────────────────────────────────── -->
<div class="col-lg-5">
<div class="card border-0 rounded-4 h-100">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-broadcast me-1 text-info"></i> Reader Status
</div>
<div class="card-body">
<!-- Hardware status -->
<div class="mb-3 d-flex align-items-center gap-3">
<span id="hw-badge" class="badge fs-6
{% if nfc.get('initialized') %}text-bg-success{% else %}text-bg-danger{% endif %}">
{% if nfc.get('initialized') %}
<i class="bi bi-check-circle me-1"></i>PN532 Ready
{% else %}
<i class="bi bi-x-circle me-1"></i>PN532 Not Detected
{% endif %}
</span>
</div>
<!-- Access state indicator -->
<div class="mb-4">
<div class="small text-secondary mb-1">Access state</div>
<div id="access-state-badge" class="badge fs-5
{% set as = nfc.get('access_state','idle') %}
{% if as == 'granted' %}text-bg-success
{% elif as == 'denied' %}text-bg-danger
{% else %}text-bg-secondary{% endif %}">
{% set as = nfc.get('access_state','idle') %}
{% if as == 'granted' %}<i class="bi bi-unlock me-1"></i>ACCESS GRANTED
{% elif as == 'denied' %}<i class="bi bi-lock me-1"></i>ACCESS DENIED
{% else %}<i class="bi bi-hourglass-split me-1"></i>Idle — waiting for card
{% endif %}
</div>
</div>
<!-- Last detected UID -->
<div class="mb-3">
<div class="small text-secondary mb-1">Last detected card UID</div>
<div class="d-flex align-items-center gap-2">
<code id="last-uid" class="fs-5 fw-bold text-primary">
{{ nfc.get('last_uid') or '—' }}
</code>
{% if current_user.is_admin() %}
<button id="use-uid-btn" class="btn btn-sm btn-outline-primary"
onclick="useLastUID()"
{% if not nfc.get('last_uid') %}disabled{% endif %}>
<i class="bi bi-arrow-down-circle me-1"></i>Use as authorized
</button>
{% endif %}
</div>
</div>
<!-- Current authorized UID -->
<div class="mb-3">
<div class="small text-secondary mb-1">Authorized card UID</div>
{% if nfc.get('auth_uid') %}
<code id="auth-uid-display" class="fs-6 fw-bold text-success">{{ nfc.get('auth_uid') }}</code>
{% else %}
<span id="auth-uid-display" class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>None — no card enrolled yet</span>
{% endif %}
</div>
<!-- Current relay & timeout -->
<dl class="row mb-0 small">
<dt class="col-5 text-secondary">Trigger relay</dt>
<dd class="col-7 fw-semibold" id="relay-display">Relay {{ nfc.get('relay_num', 1) }}</dd>
<dt class="col-5 text-secondary">Absence timeout</dt>
<dd class="col-7 fw-semibold" id="pulse-display">{{ nfc.get('pulse_ms', 3000) }} ms</dd>
</dl>
</div>
</div>
</div>
<!-- ── Right column: enroll + config ──────────────────────────────────── -->
<div class="col-lg-7 d-flex flex-column gap-4">
{% if current_user.is_admin() %}
<!-- ── Quick Enroll ──────────────────────────────────────────────────── -->
<div class="card border-0 rounded-4">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-person-badge me-1 text-success"></i> Quick Enroll
</div>
<div class="card-body">
<p class="text-secondary small mb-3">
Present a card to the reader, click <strong>Refresh</strong> until the UID appears, then click <strong>Enroll card</strong>.
The UID will be set as the authorized card with the relay and timeout below.
</p>
<form method="POST" action="{{ url_for('boards.nfc_enroll', board_id=board.id) }}">
<div class="row g-3 align-items-end">
<div class="col-sm-4">
<label class="form-label small text-secondary mb-1">Trigger relay</label>
<select name="relay_num" id="enroll-relay" class="form-select">
{% for r in range(1, 5) %}
<option value="{{ r }}" {% if nfc.get('relay_num', 1) == r %}selected{% endif %}>Relay {{ r }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-4">
<label class="form-label small text-secondary mb-1">Absence timeout (ms)</label>
<input type="number" name="pulse_ms" id="enroll-pulse"
class="form-control" min="100" max="60000"
value="{{ nfc.get('pulse_ms', 3000) }}">
</div>
<div class="col-sm-4">
<button type="submit" class="btn btn-success w-100"
{% if not nfc.get('last_uid') %}disabled{% endif %}
id="enroll-btn">
<i class="bi bi-person-plus me-1"></i>Enroll card
</button>
</div>
</div>
{% if nfc.get('last_uid') %}
<div class="mt-2 small text-secondary">
Will enroll UID: <code class="text-primary">{{ nfc.get('last_uid') }}</code>
</div>
{% else %}
<div class="mt-2 small text-warning">
<i class="bi bi-exclamation-triangle me-1"></i>No card detected yet — present a card to the reader and refresh.
</div>
{% endif %}
</form>
</div>
</div>
<!-- ── Manual Config ────────────────────────────────────────────────── -->
<div class="card border-0 rounded-4">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-sliders me-1 text-warning"></i> Manual Settings
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('boards.nfc_config_save', board_id=board.id) }}">
<div class="row g-3">
<div class="col-12">
<label class="form-label small text-secondary mb-1">Authorized card UID <span class="text-muted">(leave empty to clear authorization)</span></label>
<input type="text" name="auth_uid" id="manual-uid"
class="form-control font-monospace text-uppercase"
placeholder="e.g. 04:AB:CD:EF"
value="{{ nfc.get('auth_uid', '') }}"
maxlength="31"
oninput="this.value=this.value.toUpperCase()">
</div>
<div class="col-sm-6">
<label class="form-label small text-secondary mb-1">Trigger relay</label>
<select name="relay_num" class="form-select">
{% for r in range(1, 5) %}
<option value="{{ r }}" {% if nfc.get('relay_num', 1) == r %}selected{% endif %}>Relay {{ r }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6">
<label class="form-label small text-secondary mb-1">Absence timeout (ms)</label>
<input type="number" name="pulse_ms" class="form-control"
min="100" max="60000" value="{{ nfc.get('pulse_ms', 3000) }}">
<div class="form-text">Relay closes this many ms after the card is removed (100 60 000).</div>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i>Save config
</button>
{% if nfc.get('auth_uid') %}
<button type="submit" class="btn btn-outline-danger"
onclick="document.getElementById('manual-uid').value=''">
<i class="bi bi-x-circle me-1"></i>Clear authorization
</button>
{% endif %}
</div>
</div>
</form>
</div>
</div>
{% endif %}{# is_admin #}
</div>{# right col #}
</div>{# row #}
{% endblock %}
{% block scripts %}
<script>
const STATUS_URL = "{{ url_for('boards.nfc_status_json', board_id=board.id) }}";
function loadStatus() {
const btn = document.getElementById('refresh-btn');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Loading…'; }
fetch(STATUS_URL)
.then(r => r.json())
.then(d => {
// hardware badge
const hwBadge = document.getElementById('hw-badge');
if (hwBadge) {
hwBadge.className = 'badge fs-6 ' + (d.initialized ? 'text-bg-success' : 'text-bg-danger');
hwBadge.innerHTML = d.initialized
? '<i class="bi bi-check-circle me-1"></i>PN532 Ready'
: '<i class="bi bi-x-circle me-1"></i>PN532 Not Detected';
}
// access state
const asBadge = document.getElementById('access-state-badge');
if (asBadge) {
const as = d.access_state || 'idle';
const cls = as === 'granted' ? 'text-bg-success' : as === 'denied' ? 'text-bg-danger' : 'text-bg-secondary';
const icon = as === 'granted' ? 'bi-unlock' : as === 'denied' ? 'bi-lock' : 'bi-hourglass-split';
const txt = as === 'granted' ? 'ACCESS GRANTED' : as === 'denied' ? 'ACCESS DENIED' : 'Idle — waiting for card';
asBadge.className = 'badge fs-5 ' + cls;
asBadge.innerHTML = `<i class="bi ${icon} me-1"></i>${txt}`;
}
// last UID
const uidEl = document.getElementById('last-uid');
if (uidEl) uidEl.textContent = d.last_uid || '—';
const useBtn = document.getElementById('use-uid-btn');
if (useBtn) useBtn.disabled = !d.last_uid;
const enrollBtn = document.getElementById('enroll-btn');
if (enrollBtn) enrollBtn.disabled = !d.last_uid;
// authorized UID display
const authEl = document.getElementById('auth-uid-display');
if (authEl) {
if (d.auth_uid) {
authEl.className = 'fs-6 fw-bold text-success';
authEl.textContent = d.auth_uid;
} else {
authEl.className = 'text-danger small';
authEl.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>None — no card enrolled yet';
}
}
// relay & timeout summary
const relayDisp = document.getElementById('relay-display');
if (relayDisp) relayDisp.textContent = 'Relay ' + (d.relay_num || 1);
const pulseDisp = document.getElementById('pulse-display');
if (pulseDisp) pulseDisp.textContent = (d.pulse_ms || 3000) + ' ms';
})
.catch(() => {})
.finally(() => {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Refresh'; }
});
}
function useLastUID() {
const uid = document.getElementById('last-uid').textContent.trim();
if (uid && uid !== '—') {
const manualUid = document.getElementById('manual-uid');
if (manualUid) {
manualUid.value = uid;
manualUid.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
// Auto-refresh every 3 s while the page is open
setInterval(loadStatus, 3000);
</script>
{% endblock %}