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:
@@ -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 %}
|
||||
|
||||
215
app/templates/tuya/auth_settings.html
Normal file
215
app/templates/tuya/auth_settings.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ board.name }} – Tuya Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4" style="max-width:560px">
|
||||
<a href="{{ url_for('tuya.gateway', board_id=board.id) }}" class="btn btn-outline-secondary btn-sm mb-3">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Gateway
|
||||
</a>
|
||||
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-qr-code me-2 text-info"></i>Tuya / Smart Life Account
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} py-2 alert-dismissible fade show" role="alert">
|
||||
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<p class="text-muted small mb-4">
|
||||
Link your <strong>Tuya / Smart Life</strong> account by scanning a QR code with
|
||||
the mobile app. No tokens or passwords are stored in plain text – only the
|
||||
access/refresh token pair returned by SSO is persisted.
|
||||
</p>
|
||||
|
||||
{# Current status #}
|
||||
{% if board.config.get('tuya_token_info') %}
|
||||
<div class="alert alert-success py-2 d-flex align-items-center gap-2" id="linked-status">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>
|
||||
Account linked.
|
||||
<small class="opacity-75">(User code: {{ board.config.get('tuya_user_code', '?') }})</small>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary py-2 d-flex align-items-center gap-2" id="linked-status">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
<span>No account linked yet.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# QR section #}
|
||||
<div id="qr-section">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="input-user-code" class="form-label fw-semibold">Your Tuya User Code</label>
|
||||
<input type="text" id="input-user-code" class="form-control"
|
||||
placeholder="e.g. myname123"
|
||||
value="{{ board.config.get('tuya_user_code', '') }}">
|
||||
<div class="form-text">
|
||||
Choose any short identifier for this installation (letters and numbers, no spaces).
|
||||
You will need to remember it if you ever re-link the account.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-start-qr" class="btn btn-info mb-3">
|
||||
<i class="bi bi-qr-code me-1"></i>Generate QR Code
|
||||
</button>
|
||||
|
||||
<div id="qr-area" class="text-center d-none">
|
||||
<div id="qr-canvas" class="d-inline-block p-2 bg-white rounded mb-3"></div>
|
||||
<p class="text-muted small mb-1" id="qr-instruction">
|
||||
Open the <strong>Smart Life</strong> (or Tuya Smart) app → tap the
|
||||
<i class="bi bi-qr-code-scan"></i> scan icon → scan this QR code.
|
||||
</p>
|
||||
<div class="d-flex align-items-center justify-content-center gap-2 mt-2">
|
||||
<div class="spinner-border spinner-border-sm text-info" role="status" id="qr-spinner">
|
||||
<span class="visually-hidden">Waiting…</span>
|
||||
</div>
|
||||
<span class="text-muted small" id="qr-poll-msg">Waiting for scan…</span>
|
||||
</div>
|
||||
<button id="btn-cancel-qr" class="btn btn-outline-secondary btn-sm mt-3">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- QR code renderer (MIT, CDN) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const BOARD_ID = {{ board.id }};
|
||||
const QR_URL = "{{ url_for('tuya.generate_qr', board_id=board.id) }}";
|
||||
const POLL_URL = "{{ url_for('tuya.poll_login', board_id=board.id) }}";
|
||||
const SAVE_URL = "{{ url_for('tuya.save_auth', board_id=board.id) }}";
|
||||
const GW_URL = "{{ url_for('tuya.gateway', board_id=board.id) }}";
|
||||
|
||||
let pollTimer = null;
|
||||
let userCode = null;
|
||||
let qrToken = null;
|
||||
|
||||
const btnStart = document.getElementById('btn-start-qr');
|
||||
const btnCancel = document.getElementById('btn-cancel-qr');
|
||||
const qrArea = document.getElementById('qr-area');
|
||||
const qrCanvas = document.getElementById('qr-canvas');
|
||||
const spinner = document.getElementById('qr-spinner');
|
||||
const pollMsg = document.getElementById('qr-poll-msg');
|
||||
const statusDiv = document.getElementById('linked-status');
|
||||
const inputCode = document.getElementById('input-user-code');
|
||||
|
||||
btnStart.addEventListener('click', startQr);
|
||||
btnCancel.addEventListener('click', cancelQr);
|
||||
|
||||
function startQr() {
|
||||
const code = inputCode.value.trim();
|
||||
if (!code) {
|
||||
inputCode.focus();
|
||||
inputCode.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
inputCode.classList.remove('is-invalid');
|
||||
|
||||
btnStart.disabled = true;
|
||||
btnStart.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating…';
|
||||
|
||||
fetch(QR_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_code: code }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
if (!data.ok) { showError(data.error); btnStart.disabled = false; btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code'; return; }
|
||||
|
||||
userCode = data.user_code;
|
||||
qrToken = data.qr_token;
|
||||
|
||||
// Render QR code into canvas div
|
||||
qrCanvas.innerHTML = '';
|
||||
QRCode.toCanvas(document.createElement('canvas'), data.qr_data, { width: 220 }, function(err, canvas) {
|
||||
if (err) { showError('QR render failed: ' + err); return; }
|
||||
qrCanvas.appendChild(canvas);
|
||||
});
|
||||
|
||||
qrArea.classList.remove('d-none');
|
||||
btnStart.classList.add('d-none');
|
||||
inputCode.disabled = true;
|
||||
pollMsg.textContent = 'Waiting for scan…';
|
||||
spinner.classList.remove('d-none');
|
||||
|
||||
// Start polling every 3 s
|
||||
pollTimer = setInterval(pollLogin, 3000);
|
||||
})
|
||||
.catch(function(err) { showError(err); btnStart.disabled = false; btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code'; });
|
||||
}
|
||||
|
||||
function pollLogin() {
|
||||
const url = POLL_URL + '?token=' + encodeURIComponent(qrToken) + '&user_code=' + encodeURIComponent(userCode);
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
clearInterval(pollTimer);
|
||||
pollMsg.textContent = '✓ Scanned! Saving…';
|
||||
spinner.classList.add('d-none');
|
||||
saveTokens(data.info);
|
||||
} else if (data.error) {
|
||||
clearInterval(pollTimer);
|
||||
pollMsg.textContent = 'Error: ' + data.error;
|
||||
spinner.classList.add('d-none');
|
||||
}
|
||||
// else data.pending: keep waiting
|
||||
})
|
||||
.catch(function() { /* network hiccup – keep polling */ });
|
||||
}
|
||||
|
||||
function saveTokens(info) {
|
||||
fetch(SAVE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ info: info, user_code: userCode }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
pollMsg.textContent = '✓ Account linked successfully! Redirecting…';
|
||||
setTimeout(function() { window.location.href = data.redirect || GW_URL; }, 1500);
|
||||
} else {
|
||||
showError(data.error || 'Save failed');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { showError(err); });
|
||||
}
|
||||
|
||||
function cancelQr() {
|
||||
clearInterval(pollTimer);
|
||||
qrArea.classList.add('d-none');
|
||||
btnStart.classList.remove('d-none');
|
||||
btnStart.disabled = false;
|
||||
btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code';
|
||||
qrCanvas.innerHTML = '';
|
||||
inputCode.disabled = false;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'alert alert-danger py-2 mt-2';
|
||||
el.textContent = msg;
|
||||
qrCanvas.after(el);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
165
app/templates/tuya/device.html
Normal file
165
app/templates/tuya/device.html
Normal file
@@ -0,0 +1,165 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ device.name }} – Tuya Device{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4" style="max-width:640px">
|
||||
|
||||
{# ── Breadcrumb ──────────────────────────────────────────────────── #}
|
||||
<nav aria-label="breadcrumb" class="small 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('tuya.gateway', board_id=board.id) }}">{{ board.name }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ device.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{# ── Device card ─────────────────────────────────────────────────── #}
|
||||
{% set icon_cls = {
|
||||
'switch': 'bi-toggles',
|
||||
'light': 'bi-lightbulb-fill',
|
||||
'fan': 'bi-fan',
|
||||
'sensor': 'bi-thermometer-half',
|
||||
'cover': 'bi-door-open',
|
||||
}.get(device.kind, 'bi-plug') %}
|
||||
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header d-flex align-items-center justify-content-between py-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi {{ icon_cls }} fs-5 text-info"></i>
|
||||
<span class="fw-bold">{{ device.name }}</span>
|
||||
</div>
|
||||
{% if device.is_online %}
|
||||
<span class="badge bg-success">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Offline</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-5 text-muted">Device ID</dt>
|
||||
<dd class="col-7 font-monospace text-break">{{ device.device_id }}</dd>
|
||||
<dt class="col-5 text-muted">Category</dt>
|
||||
<dd class="col-7">{{ device.category }} ({{ device.kind }})</dd>
|
||||
{% if device.product_name %}
|
||||
<dt class="col-5 text-muted">Product</dt>
|
||||
<dd class="col-7">{{ device.product_name }}</dd>
|
||||
{% endif %}
|
||||
{% if device.last_seen %}
|
||||
<dt class="col-5 text-muted">Last seen</dt>
|
||||
<dd class="col-7">{{ device.last_seen.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Controls ────────────────────────────────────────────────────── #}
|
||||
{% if device.switch_dps %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header py-2"><strong>Controls</strong></div>
|
||||
<div class="card-body d-flex flex-wrap gap-3">
|
||||
{% for dp in device.switch_dps %}
|
||||
{% set ch_on = device.status.get(dp, False) %}
|
||||
<div class="d-flex align-items-center gap-2 border border-secondary rounded px-3 py-2"
|
||||
id="ctrl-dp-{{ dp }}">
|
||||
<span class="text-muted small">
|
||||
{% if device.num_channels == 1 %}Power{% else %}CH{{ loop.index }}{% endif %}
|
||||
<span class="font-monospace opacity-50">({{ dp }})</span>
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm px-3 tuya-dp-btn {{ 'btn-info' if ch_on else 'btn-outline-info' }}"
|
||||
data-url="{{ url_for('tuya.toggle_dp', board_id=board.id, device_id=device.device_id, dp_code=dp) }}"
|
||||
data-dp="{{ dp }}"
|
||||
>{{ 'ON' if ch_on else 'OFF' }}</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Full status ─────────────────────────────────────────────────── #}
|
||||
{% if device.status %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header py-2 d-flex align-items-center justify-content-between">
|
||||
<strong>Full Status</strong>
|
||||
<small class="text-muted">All data points</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-sm table-striped mb-0">
|
||||
<thead><tr><th>DP Code</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
{% for k, v in device.status.items() %}
|
||||
{% set fv = v | tuya_dp(k, device.status) %}
|
||||
<tr>
|
||||
<td class="font-monospace text-muted">{{ k }}</td>
|
||||
<td>
|
||||
{% if fv is sameas true %}
|
||||
<span class="badge bg-success">true</span>
|
||||
{% elif fv is sameas false %}
|
||||
<span class="badge bg-secondary">false</span>
|
||||
{% else %}
|
||||
{{ fv }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Rename ──────────────────────────────────────────────────────── #}
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header py-2"><strong>Rename Device</strong></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('tuya.rename_device', board_id=board.id, device_id=device.device_id) }}"
|
||||
class="d-flex gap-2">
|
||||
<input type="text" name="name" class="form-control form-control-sm"
|
||||
value="{{ device.name }}" required style="max-width:300px">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-pencil me-1"></i>Rename
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.querySelectorAll('.tuya-dp-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const url = btn.dataset.url;
|
||||
const dp = btn.dataset.dp;
|
||||
btn.disabled = true;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
const wrap = document.getElementById('ctrl-dp-' + dp);
|
||||
if (!wrap) return;
|
||||
const b = wrap.querySelector('.tuya-dp-btn');
|
||||
if (!b) return;
|
||||
b.disabled = false;
|
||||
if (data.state) {
|
||||
b.textContent = 'ON';
|
||||
b.classList.replace('btn-outline-info', 'btn-info');
|
||||
} else {
|
||||
b.textContent = 'OFF';
|
||||
b.classList.replace('btn-info', 'btn-outline-info');
|
||||
}
|
||||
})
|
||||
.catch(function() { btn.disabled = false; });
|
||||
});
|
||||
});
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}{% for cat, msg in messages %}{# consumed by parent template #}{% endfor %}{% endif %}
|
||||
{% endwith %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
214
app/templates/tuya/gateway.html
Normal file
214
app/templates/tuya/gateway.html
Normal file
@@ -0,0 +1,214 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ board.name }} – Tuya Cloud Gateway{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
{# ── Page header ──────────────────────────────────────────────────── #}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h4 class="mb-0 fw-bold">
|
||||
<i class="bi bi-cloud-fill text-info me-2"></i>{{ board.name }}
|
||||
</h4>
|
||||
<small class="text-muted">Tuya Cloud Gateway
|
||||
{% if board.is_online %}
|
||||
<span class="badge bg-success ms-2">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-2">Offline</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{% if current_user.is_admin() %}
|
||||
<form method="post" action="{{ url_for('tuya.sync_devices', board_id=board.id) }}">
|
||||
<button class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Sync Devices
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('tuya.auth_settings', board_id=board.id) }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-key me-1"></i>Tuya Settings
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Flash messages ───────────────────────────────────────────────── #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show py-2" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{# ── No token warning ─────────────────────────────────────────────── #}
|
||||
{% if not has_token %}
|
||||
<div class="alert alert-warning d-flex align-items-center gap-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
|
||||
<div>
|
||||
<strong>Tuya account not linked.</strong>
|
||||
<a href="{{ url_for('tuya.auth_settings', board_id=board.id) }}" class="alert-link">
|
||||
Scan a QR code with the Smart Life app
|
||||
</a> to discover and control devices.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Device grid ──────────────────────────────────────────────────── #}
|
||||
{% if devices %}
|
||||
<div class="row g-3">
|
||||
{% for dev in devices %}
|
||||
{% set icon_cls = {
|
||||
'switch': 'bi-toggles',
|
||||
'light': 'bi-lightbulb-fill',
|
||||
'fan': 'bi-fan',
|
||||
'sensor': 'bi-thermometer-half',
|
||||
'cover': 'bi-door-open',
|
||||
}.get(dev.kind, 'bi-plug') %}
|
||||
<div class="col-12 col-md-6 col-xl-4" id="tuya-card-{{ dev.device_id }}">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-header d-flex align-items-center justify-content-between py-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi {{ icon_cls }} text-info"></i>
|
||||
<span class="fw-semibold text-truncate" style="max-width:160px"
|
||||
title="{{ dev.name }}">{{ dev.name }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if dev.is_online %}
|
||||
<span class="badge bg-success">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Offline</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('tuya.device_detail', board_id=board.id, device_id=dev.device_id) }}"
|
||||
class="btn btn-outline-secondary btn-sm px-2 py-0">
|
||||
<i class="bi bi-sliders"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="text-muted small mb-2">
|
||||
{{ dev.kind | capitalize }}
|
||||
{% if dev.product_name %}
|
||||
– {{ dev.product_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Switch / light / fan toggles ─────────────────────────── #}
|
||||
{% if dev.kind in ('switch', 'light', 'fan') and dev.switch_dps %}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for dp in dev.switch_dps %}
|
||||
{% set ch_idx = loop.index0 %}
|
||||
{% set ch_on = dev.status.get(dp, False) %}
|
||||
<div class="d-flex align-items-center gap-2 border border-secondary rounded px-2 py-1"
|
||||
id="tuya-dp-{{ dev.device_id }}-{{ dp }}">
|
||||
<span class="small text-muted">
|
||||
{% if dev.num_channels == 1 %}Power{% else %}CH{{ loop.index }}{% endif %}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm px-2 py-0 tuya-dp-btn {{ 'btn-info' if ch_on else 'btn-outline-info' }}"
|
||||
data-url="{{ url_for('tuya.toggle_dp', board_id=board.id, device_id=dev.device_id, dp_code=dp) }}"
|
||||
data-device="{{ dev.device_id }}" data-dp="{{ dp }}"
|
||||
title="Toggle {{ dp }}"
|
||||
>{{ 'ON' if ch_on else 'OFF' }}</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ── Sensor – show formatted live readings ────────────────── #}
|
||||
{% elif dev.kind == 'sensor' %}
|
||||
{# Hide config/threshold keys that clutter the card #}
|
||||
{% set _skip_suffixes = ['_set', '_alarm', '_convert', '_mode'] %}
|
||||
<dl class="row mb-0 small">
|
||||
{% for k, v in dev.status.items() %}
|
||||
{% set _hide = namespace(val=false) %}
|
||||
{% for sfx in _skip_suffixes %}
|
||||
{% if k.endswith(sfx) %}{% set _hide.val = true %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if not _hide.val %}
|
||||
{% set fv = v | tuya_dp(k, dev.status) %}
|
||||
<dt class="col-6 text-muted text-truncate" title="{{ k }}">{{ k }}</dt>
|
||||
<dd class="col-6 mb-1">
|
||||
{% if fv is sameas true %}<span class="badge bg-success">on</span>
|
||||
{% elif fv is sameas false %}<span class="badge bg-secondary">off</span>
|
||||
{% else %}{{ fv }}{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-cloud-slash fs-1 d-block mb-3 opacity-50"></i>
|
||||
<p>No Tuya devices found.</p>
|
||||
{% if has_token %}
|
||||
<form method="post" action="{{ url_for('tuya.sync_devices', board_id=board.id) }}" class="d-inline">
|
||||
<button class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Sync now
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
/* ── AJAX toggles ─────────────────────────────────────────────────────────── */
|
||||
document.querySelectorAll('.tuya-dp-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const url = btn.dataset.url;
|
||||
const device = btn.dataset.device;
|
||||
const dp = btn.dataset.dp;
|
||||
btn.disabled = true;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json'},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(data) {
|
||||
updateDpButton(device, dp, data.state);
|
||||
})
|
||||
.catch(function() { btn.disabled = false; });
|
||||
});
|
||||
});
|
||||
|
||||
function updateDpButton(deviceId, dp, state) {
|
||||
const wrap = document.getElementById('tuya-dp-' + deviceId + '-' + dp);
|
||||
if (!wrap) return;
|
||||
const btn = wrap.querySelector('.tuya-dp-btn');
|
||||
if (!btn) return;
|
||||
btn.disabled = false;
|
||||
if (state) {
|
||||
btn.textContent = 'ON';
|
||||
btn.classList.replace('btn-outline-info', 'btn-info');
|
||||
} else {
|
||||
btn.textContent = 'OFF';
|
||||
btn.classList.replace('btn-info', 'btn-outline-info');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Socket.IO live updates ───────────────────────────────────────────────── */
|
||||
if (typeof io !== 'undefined') {
|
||||
const socket = io();
|
||||
socket.on('tuya_update', function(data) {
|
||||
if (data.board_id !== {{ board.id }}) return;
|
||||
updateDpButton(data.device_id, data.dp_code, data.state);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user