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