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:
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 %}
|
||||
Reference in New Issue
Block a user