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:
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