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

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