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 %}

View 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 %}

View 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 %}

View 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 %}