Add Tuya Cloud integration; 2-step add-board wizard; DP value formatting

- Tuya Cloud Gateway driver (tuya-device-sharing-sdk):
  - QR-code auth flow: user-provided user_code, terminal_id/endpoint
    returned by Tuya login_result (mirrors HA implementation)
  - Device sync, toggle DP, rename, per-device detail page
  - category → kind mapping; detect_switch_dps helper
  - format_dp_value: temperature (÷10 + °C/°F), humidity (+ %)
    registered as 'tuya_dp' Jinja2 filter

- TuyaDevice model (tuya_devices table)

- Templates:
  - tuya/gateway.html: device grid with live-reading sensor cards
    (config/threshold keys hidden from card, shown on detail page)
  - tuya/device.html: full status table with formatted DP values
  - tuya/auth_settings.html: user_code input + QR scan flow

- Add-board wizard refactored to 2-step flow:
  - Step 1: choose board type (Cloud Gateways vs Hardware)
  - Step 2: type-specific fields; gateways skip IP/relay fields

- Layout builder: Tuya chip support (makeTuyaChip, tuya_update socket)

- requirements.txt: tuya-device-sharing-sdk, cryptography
This commit is contained in:
ske087
2026-02-27 16:06:48 +02:00
parent 90cbf4e1f0
commit 1e89323035
19 changed files with 1873 additions and 84 deletions

View File

@@ -2,99 +2,340 @@
{% block title %}Add Board Location Management{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<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>
<div class="card border-0 rounded-4" style="max-width:640px">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-plus-circle me-1 text-primary"></i> Add New Board
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label">Local Name</label>
<input type="text" name="name" class="form-control" placeholder="e.g. Server Room Board" required />
</div>
<div class="mb-3">
<label class="form-label">Board Type</label>
<select name="board_type" class="form-select" id="board_type_select" onchange="updateDefaults(this.value)">
{% for value, label in board_types %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<div class="form-text text-secondary" id="driver_desc"></div>
</div>
{# ── Hardware board fields (hidden for Sonoff gateway) ── #}
<div id="hw_fields">
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label">IP Address / Hostname</label>
<input type="text" name="host" id="host_input" class="form-control font-monospace" placeholder="192.168.1.100" />
</div>
<div class="col-4">
<label class="form-label">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">Number of Relays</label>
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="0" max="32" />
</div>
<div class="col-6">
<label class="form-label">Number of Inputs</label>
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
</div>
</div>
</div>
{# ── 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>
{# ── Sonoff gateway info ── #}
<div id="sonoff_info" class="d-none mb-4">
<div class="alert alert-info d-flex gap-3 align-items-start py-3">
<i class="bi bi-cloud-lightning-fill fs-4 text-warning mt-1"></i>
{# ══════════════════════════════════════════════════════════
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>
<strong>Sonoff eWeLink Gateway</strong><br>
<span class="small">
After adding, you'll be redirected to set up your eWeLink account credentials.
All your Sonoff devices will be discovered automatically from the cloud.
LAN control is used when devices are reachable on the local network.
</span>
<div class="fw-semibold">Sonoff eWeLink</div>
<div class="small text-muted mt-1">Control Sonoff devices via the eWeLink cloud.</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><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>
</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 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>
const driverDefaults = {
{% for d in drivers %}
"{{ d.DRIVER_ID }}": { relays: {{ d.DEFAULT_NUM_RELAYS }}, inputs: {{ d.DEFAULT_NUM_INPUTS }}, desc: "{{ d.DESCRIPTION }}" },
{% endfor %}
};
function updateDefaults(type) {
const d = driverDefaults[type] || { relays: 4, inputs: 4, desc: "" };
document.getElementById("num_relays").value = d.relays;
document.getElementById("num_inputs").value = d.inputs;
document.getElementById("driver_desc").textContent = d.desc;
(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 isGateway = type === "sonoff_ewelink";
document.getElementById("hw_fields").classList.toggle("d-none", isGateway);
document.getElementById("sonoff_info").classList.toggle("d-none", !isGateway);
// host is not required for gateway
document.getElementById("host_input").required = !isGateway;
}
// init on page load
updateDefaults(document.getElementById("board_type_select").value);
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;
} else {
hwSec.classList.remove("d-none");
fHost.required = true;
gwSec.classList.add("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();
}
}());
</script>
{% endblock %}