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

@@ -257,11 +257,15 @@
// relay type badge
const typeText = opts.entityType === "relay" ? "relay"
: opts.entityType === "sonoff" ? "sonoff"
: opts.entityType === "tuya" ? "tuya"
: "input";
const badge = new Konva.Text({
x: 36, y: 38,
text: typeText, fontSize: 9, fontFamily: "system-ui, sans-serif",
fill: opts.entityType === "relay" ? "#f0883e" : opts.entityType === "sonoff" ? "#58a6ff" : "#3fb950",
fill: opts.entityType === "relay" ? "#f0883e"
: opts.entityType === "sonoff" ? "#58a6ff"
: opts.entityType === "tuya" ? "#20c997"
: "#3fb950",
name: "device-type",
});
@@ -310,6 +314,8 @@
.replace("{relayNum}", opts.entityNum);
} else if (opts.entityType === "sonoff") {
url = `/sonoff/${opts.boardId}/device/${encodeURIComponent(opts.deviceId)}/ch/${opts.channel}/toggle`;
} else if (opts.entityType === "tuya") {
url = `/tuya/${opts.boardId}/device/${encodeURIComponent(opts.deviceId)}/dp/${encodeURIComponent(opts.channel)}/toggle`;
} else {
return; // inputs are read-only
}
@@ -399,6 +405,14 @@
isOn = entityInfo.isOn;
onColor = entityInfo.kind === "light" ? "warning" : "success";
offColor = "secondary";
} else if (d.entityType === "tuya") {
// Match by deviceId + channel (dp_code) stored in tuya_channels
entityInfo = board.tuya_channels &&
board.tuya_channels.find(tc => tc.deviceId === d.deviceId && tc.channel === d.channel);
if (!entityInfo) return;
isOn = entityInfo.isOn;
onColor = entityInfo.kind === "light" ? "warning" : "success";
offColor = "secondary";
} else if (d.entityType === "relay") {
entityInfo = board.relays.find(r => r.num === d.entityNum);
if (!entityInfo) return;
@@ -427,7 +441,7 @@
boardName: board.name,
icon: entityInfo.icon,
stateColor: colorForState(isOn, onColor, offColor),
isRelay: d.entityType === "relay" || d.entityType === "sonoff",
isRelay: d.entityType === "relay" || d.entityType === "sonoff" || d.entityType === "tuya",
});
deviceLayer.add(group);
});
@@ -837,6 +851,13 @@
hasAny = true;
});
}
// ── Tuya sub-devices ─────────────────────────────────────────────
if (board.tuya_channels && board.tuya_channels.length) {
board.tuya_channels.forEach(tc => {
grp.appendChild(makeTuyaChip(board, tc));
hasAny = true;
});
}
if (hasAny) list.appendChild(grp);
});
@@ -928,6 +949,46 @@
return chip;
}
function makeTuyaChip(board, tc) {
const chip = document.createElement("div");
chip.className = "lb-device-chip";
chip.draggable = true;
chip.dataset.boardId = board.id;
chip.dataset.entityType = "tuya";
chip.dataset.deviceId = tc.deviceId;
chip.dataset.channel = tc.channel; // dp_code string
chip.dataset.name = tc.name;
chip.dataset.boardName = board.name;
chip.dataset.isOn = tc.isOn;
const onColor = tc.kind === "light" ? "warning" : "success";
const dotColor = colorForState(tc.isOn, onColor, "secondary");
chip.innerHTML = `
<span class="chip-dot" style="background:${dotColor}"></span>
<i class="bi ${escHtml(tc.icon)}" style="font-size:13px"></i>
<span class="text-truncate">${escHtml(tc.name)}</span>
<span class="ms-auto text-secondary" style="font-size:.7rem">${escHtml(tc.kind || 'switch')}</span>`;
chip.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/json", JSON.stringify({
boardId: parseInt(board.id),
entityType: "tuya",
entityNum: null,
deviceId: tc.deviceId,
channel: tc.channel,
name: tc.name,
boardName: board.name,
onColor,
offColor: "secondary",
isOn: tc.isOn,
}));
});
return chip;
}
// ── canvas drop target ────────────────────────────────────────────────────
const ghost = document.getElementById("lb-drag-ghost");
@@ -979,7 +1040,7 @@
name: dragged.name,
boardName: dragged.boardName,
stateColor,
isRelay: dragged.entityType === "relay" || dragged.entityType === "sonoff",
isRelay: dragged.entityType === "relay" || dragged.entityType === "sonoff" || dragged.entityType === "tuya",
});
deviceLayer.add(group);
@@ -1020,6 +1081,25 @@
});
});
// Tuya devices emit a "tuya_update" event
socket.on("tuya_update", (data) => {
const bid = data.board_id;
deviceLayer.find(".device-group").forEach(group => {
if (group.attrs.boardId !== bid) return;
if (group.attrs.entityType !== "tuya") return;
if (group.attrs.deviceId !== data.device_id) return;
if (group.attrs.channel !== data.dp_code) return;
const board = CFG.boards.find(b => b.id === bid);
const tc = board && board.tuya_channels &&
board.tuya_channels.find(t =>
t.deviceId === data.device_id && t.channel === data.dp_code);
const onColor = tc ? (tc.kind === "light" ? "warning" : "success") : "success";
updateDeviceState(group, data.state, onColor, "secondary");
});
});
// Sonoff sub-devices emit a separate "sonoff_update" event
socket.on("sonoff_update", (data) => {
const bid = data.board_id;