- /register endpoint no longer requires verifyAPIRequest() - it is the bootstrap handshake and api_secret cannot be set before it runs - Add api_secret field to the Add Board form (hardware boards only) with a cryptographic Generate button, same as the Edit form - Save api_secret from the add-board POST so the driver can sign requests immediately after registration
363 lines
14 KiB
HTML
363 lines
14 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Add Board – Location Management{% endblock %}
|
||
|
||
{% block content %}
|
||
<nav aria-label="breadcrumb" class="mb-4">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||
<li class="breadcrumb-item active">Add Board</li>
|
||
</ol>
|
||
</nav>
|
||
|
||
{# ── Step indicator ──────────────────────────────────────────────────────── #}
|
||
<div class="d-flex align-items-center mb-4" id="step-indicator" style="max-width:420px">
|
||
<div class="step-bubble active" id="bubble-1">1</div>
|
||
<span class="small fw-semibold ms-2 me-3" id="lbl-1">Choose Type</span>
|
||
<div class="step-line" id="step-line"></div>
|
||
<div class="step-bubble ms-3 me-2" id="bubble-2">2</div>
|
||
<span class="small text-muted" id="lbl-2">Configure</span>
|
||
</div>
|
||
|
||
{# ══════════════════════════════════════════════════════════
|
||
STEP 1 – pick a board / integration type
|
||
══════════════════════════════════════════════════════════ #}
|
||
<div id="step1">
|
||
<h5 class="fw-bold mb-1">What are you adding?</h5>
|
||
<p class="text-muted small mb-4">Choose the hardware board or cloud service you want to connect.</p>
|
||
|
||
<p class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.07em">
|
||
<i class="bi bi-cloud me-1"></i>Cloud Gateways
|
||
</p>
|
||
<div class="row g-3 mb-4">
|
||
|
||
<div class="col-12 col-sm-6">
|
||
<button type="button" class="type-card w-100 text-start" data-type="sonoff_ewelink">
|
||
<div class="d-flex align-items-start gap-3">
|
||
<div class="type-icon" style="background:rgba(255,210,0,.12);color:#ffd200">
|
||
<i class="bi bi-cloud-lightning-fill"></i>
|
||
</div>
|
||
<div>
|
||
<div class="fw-semibold">Sonoff eWeLink</div>
|
||
<div class="small text-muted mt-1">Control Sonoff devices via the eWeLink cloud.</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="col-12 col-sm-6">
|
||
<button type="button" class="type-card w-100 text-start" data-type="tuya_cloud">
|
||
<div class="d-flex align-items-start gap-3">
|
||
<div class="type-icon" style="background:rgba(32,201,151,.12);color:#20c997">
|
||
<i class="bi bi-cloud-fill"></i>
|
||
</div>
|
||
<div>
|
||
<div class="fw-semibold">Tuya Cloud</div>
|
||
<div class="small text-muted mt-1">Control Tuya / Smart Life devices via QR login.</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<p class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.07em">
|
||
<i class="bi bi-cpu me-1"></i>Hardware Boards
|
||
</p>
|
||
<div class="row g-3">
|
||
{% for d in drivers %}
|
||
{% if d.DRIVER_ID not in ("sonoff_ewelink", "tuya_cloud") %}
|
||
<div class="col-12 col-sm-6 col-lg-4">
|
||
<button type="button" class="type-card w-100 text-start" data-type="{{ d.DRIVER_ID }}">
|
||
<div class="d-flex align-items-start gap-3">
|
||
<div class="type-icon" style="background:rgba(88,166,255,.12);color:#58a6ff">
|
||
<i class="bi bi-cpu-fill"></i>
|
||
</div>
|
||
<div>
|
||
<div class="fw-semibold">{{ d.DRIVER_ID | replace('_',' ') | title }}</div>
|
||
<div class="small text-muted mt-1">{{ d.DESCRIPTION }}</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{# ══════════════════════════════════════════════════════════
|
||
STEP 2 – configuration form
|
||
══════════════════════════════════════════════════════════ #}
|
||
<div id="step2" class="d-none" style="max-width:560px">
|
||
|
||
<button type="button" class="btn btn-link btn-sm text-muted ps-0 mb-3" id="btn-back">
|
||
<i class="bi bi-arrow-left me-1"></i>Back
|
||
</button>
|
||
|
||
{# Selected type badge #}
|
||
<div class="d-flex align-items-center gap-3 mb-4">
|
||
<div class="type-icon-sm" id="s2-icon-wrap"></div>
|
||
<div>
|
||
<div class="fw-bold fs-6" id="s2-type-label"></div>
|
||
<div class="small text-muted" id="s2-type-desc"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<form method="POST" id="add-form">
|
||
<input type="hidden" name="board_type" id="f-board-type">
|
||
|
||
{# Name – always shown #}
|
||
<div class="mb-4">
|
||
<label class="form-label fw-semibold">Board Name</label>
|
||
<input type="text" name="name" id="f-name" class="form-control"
|
||
placeholder="e.g. Living Room Gateway" required autofocus>
|
||
<div class="form-text text-muted">A friendly name to identify this board.</div>
|
||
</div>
|
||
|
||
{# ── Gateway info box (Sonoff / Tuya) ── #}
|
||
<div id="f-gw-section" class="d-none mb-4">
|
||
<div class="alert py-3 d-flex gap-3 align-items-start" id="f-gw-alert">
|
||
<span id="f-gw-icon" class="fs-4 mt-1"></span>
|
||
<div id="f-gw-text"></div>
|
||
</div>
|
||
</div>
|
||
|
||
{# ── Hardware-only fields ── #}
|
||
<div id="f-hw-section" class="d-none">
|
||
<div class="row g-3 mb-3">
|
||
<div class="col-8">
|
||
<label class="form-label fw-semibold">IP Address / Hostname</label>
|
||
<input type="text" name="host" id="f-host" class="form-control font-monospace"
|
||
placeholder="192.168.1.100">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label fw-semibold">Port</label>
|
||
<input type="number" name="port" class="form-control" value="80" min="1" max="65535">
|
||
</div>
|
||
</div>
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-6">
|
||
<label class="form-label fw-semibold">Relay Outputs</label>
|
||
<input type="number" name="num_relays" id="f-relays" class="form-control"
|
||
value="4" min="0" max="32">
|
||
</div>
|
||
<div class="col-6">
|
||
<label class="form-label fw-semibold">Digital Inputs</label>
|
||
<input type="number" name="num_inputs" id="f-inputs" class="form-control"
|
||
value="4" min="0" max="32">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="f-secret-section" class="d-none mb-4">
|
||
<label class="form-label fw-semibold">API Secret <span class="text-secondary fw-normal small">(HMAC-SHA256 shared secret)</span></label>
|
||
<div class="input-group">
|
||
<input type="text" name="api_secret" id="f-api-secret" class="form-control font-monospace"
|
||
placeholder="Leave empty to disable API authentication">
|
||
<button class="btn btn-outline-secondary" type="button" onclick="genSecret()">
|
||
<i class="bi bi-shuffle me-1"></i>Generate
|
||
</button>
|
||
</div>
|
||
<div class="form-text">Must match <code>API_SECRET</code> in the board's <code>secrets.h</code>. You can also set this later in the board's Edit page.</div>
|
||
</div>
|
||
|
||
<div class="d-flex gap-2">
|
||
<button type="submit" class="btn btn-primary px-4">
|
||
<i class="bi bi-check-lg me-1"></i>Add Board
|
||
</button>
|
||
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<style>
|
||
.type-card {
|
||
background: #161b22;
|
||
border: 1px solid #30363d;
|
||
border-radius: .75rem;
|
||
padding: 1rem 1.1rem;
|
||
cursor: pointer;
|
||
transition: border-color .15s, background .15s, box-shadow .15s;
|
||
color: inherit;
|
||
}
|
||
.type-card:hover, .type-card:focus {
|
||
border-color: #58a6ff;
|
||
background: rgba(88,166,255,.06);
|
||
box-shadow: 0 0 0 3px rgba(88,166,255,.12);
|
||
outline: none;
|
||
}
|
||
.type-icon {
|
||
width: 44px; height: 44px; border-radius: .55rem;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 1.4rem; flex-shrink: 0;
|
||
}
|
||
.type-icon-sm {
|
||
width: 38px; height: 38px; border-radius: .45rem;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 1.25rem; flex-shrink: 0;
|
||
}
|
||
.step-bubble {
|
||
width: 28px; height: 28px; border-radius: 50%;
|
||
background: #30363d; color: #8b949e;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: .8rem; font-weight: 700; flex-shrink: 0;
|
||
transition: background .2s, color .2s;
|
||
}
|
||
.step-bubble.active { background: #1f6feb; color: #fff; }
|
||
.step-bubble.done { background: #238636; color: #fff; }
|
||
.step-line {
|
||
flex: 1; height: 2px; background: #30363d;
|
||
min-width: 32px; max-width: 60px;
|
||
transition: background .2s;
|
||
}
|
||
.step-line.done { background: #238636; }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
(function () {
|
||
const DRIVERS = {
|
||
{% for d in drivers %}
|
||
"{{ d.DRIVER_ID }}": {
|
||
relays: {{ d.DEFAULT_NUM_RELAYS }},
|
||
inputs: {{ d.DEFAULT_NUM_INPUTS }},
|
||
desc: "{{ d.DESCRIPTION | replace('"', '\\"') }}",
|
||
},
|
||
{% endfor %}
|
||
};
|
||
|
||
const GW_META = {
|
||
sonoff_ewelink: {
|
||
label: "Sonoff eWeLink",
|
||
iconHtml: '<i class="bi bi-cloud-lightning-fill"></i>',
|
||
iconBg: "rgba(255,210,0,.15)",
|
||
iconColor: "#ffd200",
|
||
alertCls: "alert-warning",
|
||
alertIcon: '<i class="bi bi-cloud-lightning-fill text-warning"></i>',
|
||
alertBody: '<strong>Sonoff eWeLink Gateway</strong><br>'
|
||
+ '<span class="small">After adding, you\'ll enter your eWeLink '
|
||
+ 'account credentials to discover and control all your Sonoff devices '
|
||
+ 'automatically.</span>',
|
||
},
|
||
tuya_cloud: {
|
||
label: "Tuya Cloud",
|
||
iconHtml: '<i class="bi bi-cloud-fill"></i>',
|
||
iconBg: "rgba(32,201,151,.15)",
|
||
iconColor: "#20c997",
|
||
alertCls: "alert-info",
|
||
alertIcon: '<i class="bi bi-qr-code text-info"></i>',
|
||
alertBody: '<strong>Tuya / Smart Life Cloud Gateway</strong><br>'
|
||
+ '<span class="small">After adding, you\'ll scan a QR code with '
|
||
+ 'the <strong>Smart Life</strong> or <strong>Tuya Smart</strong> app '
|
||
+ 'to link your account and discover all your Tuya devices automatically.</span>',
|
||
},
|
||
};
|
||
|
||
const GATEWAY_TYPES = Object.keys(GW_META);
|
||
|
||
// DOM
|
||
const step1 = document.getElementById("step1");
|
||
const step2 = document.getElementById("step2");
|
||
const btnBack = document.getElementById("btn-back");
|
||
const bubble1 = document.getElementById("bubble-1");
|
||
const bubble2 = document.getElementById("bubble-2");
|
||
const stepLine = document.getElementById("step-line");
|
||
const lbl1 = document.getElementById("lbl-1");
|
||
const lbl2 = document.getElementById("lbl-2");
|
||
|
||
const fType = document.getElementById("f-board-type");
|
||
const fName = document.getElementById("f-name");
|
||
const fHost = document.getElementById("f-host");
|
||
const fRelays = document.getElementById("f-relays");
|
||
const fInputs = document.getElementById("f-inputs");
|
||
const hwSec = document.getElementById("f-hw-section");
|
||
const gwSec = document.getElementById("f-gw-section");
|
||
const gwAlert = document.getElementById("f-gw-alert");
|
||
const gwIcon = document.getElementById("f-gw-icon");
|
||
const gwText = document.getElementById("f-gw-text");
|
||
const s2Icon = document.getElementById("s2-icon-wrap");
|
||
const s2Label = document.getElementById("s2-type-label");
|
||
const s2Desc = document.getElementById("s2-type-desc");
|
||
|
||
// Card clicks
|
||
document.querySelectorAll(".type-card").forEach(function (card) {
|
||
card.addEventListener("click", function () { goStep2(card.dataset.type); });
|
||
});
|
||
|
||
// Back
|
||
btnBack.addEventListener("click", function () {
|
||
step2.classList.add("d-none");
|
||
step1.classList.remove("d-none");
|
||
bubble1.classList.replace("done", "active");
|
||
bubble2.classList.remove("active");
|
||
stepLine.classList.remove("done");
|
||
lbl1.classList.remove("text-success");
|
||
lbl2.classList.add("text-muted");
|
||
lbl2.classList.remove("fw-semibold");
|
||
});
|
||
|
||
function goStep2(type) {
|
||
const drv = DRIVERS[type] || { relays: 4, inputs: 4, desc: "" };
|
||
const meta = GW_META[type] || null;
|
||
const isGw = GATEWAY_TYPES.includes(type);
|
||
|
||
fType.value = type;
|
||
fRelays.value = drv.relays;
|
||
fInputs.value = drv.inputs;
|
||
fName.value = "";
|
||
|
||
// Header icon + label
|
||
if (meta) {
|
||
s2Icon.innerHTML = meta.iconHtml;
|
||
s2Icon.style.background = meta.iconBg;
|
||
s2Icon.style.color = meta.iconColor;
|
||
s2Label.textContent = meta.label;
|
||
} else {
|
||
s2Icon.innerHTML = '<i class="bi bi-cpu-fill"></i>';
|
||
s2Icon.style.background = "rgba(88,166,255,.15)";
|
||
s2Icon.style.color = "#58a6ff";
|
||
s2Label.textContent = type.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||
}
|
||
s2Desc.textContent = drv.desc;
|
||
|
||
// Section visibility
|
||
if (isGw) {
|
||
hwSec.classList.add("d-none");
|
||
fHost.required = false;
|
||
gwSec.classList.remove("d-none");
|
||
gwAlert.className = "alert py-3 d-flex gap-3 align-items-start " + meta.alertCls;
|
||
gwIcon.innerHTML = meta.alertIcon;
|
||
gwText.innerHTML = meta.alertBody;
|
||
document.getElementById("f-secret-section").classList.add("d-none");
|
||
} else {
|
||
hwSec.classList.remove("d-none");
|
||
fHost.required = true;
|
||
gwSec.classList.add("d-none");
|
||
document.getElementById("f-secret-section").classList.remove("d-none");
|
||
}
|
||
|
||
// Step indicator
|
||
step1.classList.add("d-none");
|
||
step2.classList.remove("d-none");
|
||
bubble1.classList.remove("active");
|
||
bubble1.classList.add("done");
|
||
bubble2.classList.add("active");
|
||
stepLine.classList.add("done");
|
||
lbl1.classList.add("text-success");
|
||
lbl2.classList.remove("text-muted");
|
||
lbl2.classList.add("fw-semibold");
|
||
|
||
fName.focus();
|
||
}
|
||
|
||
window.genSecret = function () {
|
||
const buf = new Uint8Array(32);
|
||
crypto.getRandomValues(buf);
|
||
document.getElementById('f-api-secret').value =
|
||
Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
};
|
||
}());
|
||
</script>
|
||
{% endblock %}
|