Files
location_managemet/app/static/js/layout_builder.js
ske087 1e89323035 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
2026-02-27 16:06:48 +02:00

1145 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Layout Builder powered by Konva.js
*
* Three modes:
* structure draw walls, rooms, doors, windows, fences, text
* devices drag board entities (relays/inputs) onto the canvas
* view live read-only view; relays are clickable for toggling
*
* Persistence schema (canvas_json):
* {
* structure: [ { id, tool, x, y, w, h, rotation, text, stroke, fill }, … ],
* devices: [ { id, boardId, entityType, entityNum, x, y }, … ]
* }
*/
(function () {
"use strict";
const CFG = window.LB_CONFIG;
// ── constants ──────────────────────────────────────────────────────────────
const GRID = 20;
const SNAP = true;
// Colour palette for structure tools
const TOOL_STYLE = {
wall: { fill: "#4a4a52", stroke: "#6e7681", strokeWidth: 2 },
room: { fill: "rgba(79,140,205,.07)", stroke: "#4f8ccd", strokeWidth: 1.5, dash: [] },
door: { fill: "transparent", stroke: "#f0883e", strokeWidth: 2 },
window: { fill: "transparent", stroke: "#79c0ff", strokeWidth: 2, dash: [8, 4] },
fence: { fill: "transparent", stroke: "#8b5cf6", strokeWidth: 1.5, dash: [4, 4] },
};
// ── state ──────────────────────────────────────────────────────────────────
let mode = "structure"; // structure | devices | view
let activeTool = "select"; // select | pan | wall | room | door | window | fence | text
let isDrawing = false;
let drawStart = null;
let tempShape = null;
let selectedNode = null;
let selectedDeviceGroup = null;
let transformer = null;
let saveTimer = null;
let isDirty = false;
// incremental ID
let nextId = Date.now();
const uid = () => `s${nextId++}`;
// ── stage & layers ─────────────────────────────────────────────────────────
const container = document.getElementById("konva-container");
const stageWrap = document.getElementById("lb-stage");
const stage = new Konva.Stage({
container: "konva-container",
width: stageWrap.clientWidth,
height: stageWrap.clientHeight,
});
const gridLayer = new Konva.Layer({ listening: false });
const structureLayer = new Konva.Layer();
const deviceLayer = new Konva.Layer();
const uiLayer = new Konva.Layer(); // transformer lives here
stage.add(gridLayer);
stage.add(structureLayer);
stage.add(deviceLayer);
stage.add(uiLayer);
// ── Transformer (selection handles) ───────────────────────────────────────
transformer = new Konva.Transformer({
rotateAnchorOffset: 20,
enabledAnchors: ["top-left","top-right","bottom-left","bottom-right",
"middle-left","middle-right","top-center","bottom-center"],
boundBoxFunc: (old, nw) => ({
...nw,
x: snap(nw.x), y: snap(nw.y),
width: Math.max(GRID, nw.width), height: Math.max(GRID, nw.height),
}),
});
uiLayer.add(transformer);
// ── helpers ────────────────────────────────────────────────────────────────
function snap(v) { return SNAP ? Math.round(v / GRID) * GRID : v; }
function stagePos(e) {
const pt = stage.getPointerPosition();
const sc = stage.scaleX();
const off = stage.position();
return {
x: (pt.x - off.x) / sc,
y: (pt.y - off.y) / sc,
};
}
function setDirty() {
isDirty = true;
setSaveStatus("Unsaved");
clearTimeout(saveTimer);
saveTimer = setTimeout(() => LB.save(), 8000); // auto-save after 8 s idle
}
function setSaveStatus(msg, cls = "") {
const el = document.getElementById("lb-save-status");
el.textContent = msg;
el.className = cls;
}
function deselect() {
transformer.nodes([]);
selectedNode = null;
uiLayer.batchDraw();
}
function colorForState(isOn, onColor = "warning", offColor = "secondary") {
const map = {
success : "#3fb950", warning: "#f0883e", danger: "#f85149",
info : "#58a6ff", primary: "#4f8ccd", secondary: "#484f58", dark: "#30363d",
};
return isOn ? (map[onColor] || "#3fb950") : (map[offColor] || "#484f58");
}
// ── grid ───────────────────────────────────────────────────────────────────
function drawGrid() {
gridLayer.destroyChildren();
const sc = stage.scaleX();
const off = stage.position();
const W = stage.width();
const H = stage.height();
const step = GRID * sc;
const startX = ((-off.x / sc) - ((-off.x / sc) % GRID));
const startY = ((-off.y / sc) - ((-off.y / sc) % GRID));
for (let x = startX; x < (-off.x + W) / sc; x += GRID) {
gridLayer.add(new Konva.Line({
points: [x, -off.y / sc, x, (-off.y + H) / sc],
stroke: "#1c2129", strokeWidth: 1 / sc, listening: false,
}));
}
for (let y = startY; y < (-off.y + H) / sc; y += GRID) {
gridLayer.add(new Konva.Line({
points: [-off.x / sc, y, (-off.x + W) / sc, y],
stroke: "#1c2129", strokeWidth: 1 / sc, listening: false,
}));
}
gridLayer.batchDraw();
}
// ── draw structure shapes ──────────────────────────────────────────────────
function shapeForTool(tool, x, y, w, h) {
const st = TOOL_STYLE[tool] || TOOL_STYLE.wall;
if (tool === "door") {
// arc that represents a door swing
return new Konva.Arc({
x, y,
innerRadius: 0, outerRadius: Math.max(Math.abs(w), Math.abs(h)),
angle: 90,
fill: st.fill, stroke: st.stroke, strokeWidth: st.strokeWidth,
rotation: w < 0 ? 90 : 0,
id: uid(), name: "structure-shape", toolType: tool,
});
}
if (tool === "window" || tool === "fence") {
return new Konva.Rect({
x: Math.min(x, x + w), y: Math.min(y, y + h),
width: Math.abs(w), height: Math.abs(h),
fill: st.fill, stroke: st.stroke, strokeWidth: st.strokeWidth,
dash: st.dash ?? [8, 4],
id: uid(), name: "structure-shape", toolType: tool,
draggable: false,
});
}
// wall and room
return new Konva.Rect({
x: Math.min(x, x + w), y: Math.min(y, y + h),
width: Math.abs(w), height: Math.abs(h),
fill: st.fill, stroke: st.stroke, strokeWidth: st.strokeWidth,
id: uid(), name: "structure-shape", toolType: tool,
draggable: false,
});
}
// ── device widget ──────────────────────────────────────────────────────────
function deviceIcon(iconClass) {
// Map Bootstrap Icon class to a unicode character for Konva.Text
// We embed the Bootstrap Icons font directly via CSS; Konva can render it
// because Konva.Text uses the page's loaded fonts.
// Strip "bi-" prefix and build the proper text character:
// We'll fall back to simple label if the font isn't available.
return iconClass.replace("bi-", "");
}
function createDeviceGroup(opts) {
/*
* opts: { id, boardId, entityType, entityNum, x, y,
* name, icon, stateColor, isRelay }
*/
const W = 120, H = 52;
const group = new Konva.Group({
id: opts.id,
x: snap(opts.x - W / 2),
y: snap(opts.y - H / 2),
draggable: true,
name: "device-group",
// store binding in attrs
boardId: opts.boardId,
entityType: opts.entityType,
entityNum: opts.entityNum,
});
// background card
const bg = new Konva.Rect({
width: W, height: H, cornerRadius: 8,
fill: "#1c2129", stroke: "#30363d", strokeWidth: 1,
shadowColor: "#000", shadowBlur: 8, shadowOpacity: .4, shadowOffset: {x:0,y:2},
name: "device-bg",
});
// colored state indicator dot
const dot = new Konva.Circle({
x: W - 14, y: 14, radius: 5,
fill: opts.stateColor || "#484f58",
name: "state-dot",
});
// icon (Bootstrap Icons via font)
const ico = new Konva.Text({
x: 10, y: H / 2 - 10,
text: "\uf4a5", // fallback lightning bolt overridden below
fontSize: 18,
fontFamily: "'bootstrap-icons'",
fill: opts.stateColor || "#8b949e",
name: "device-icon",
});
// label
const lbl = new Konva.Text({
x: 36, y: 10,
width: W - 46,
text: opts.name || "Device",
fontSize: 11, fontFamily: "system-ui, sans-serif",
fill: "#c9d1d9", ellipsis: true, wrap: "none",
name: "device-label",
});
// board name sub-label
const sub = new Konva.Text({
x: 36, y: 26,
width: W - 46,
text: opts.boardName || "",
fontSize: 9.5, fontFamily: "system-ui, sans-serif",
fill: "#484f58", ellipsis: true, wrap: "none",
name: "device-sub",
});
// 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"
: opts.entityType === "tuya" ? "#20c997"
: "#3fb950",
name: "device-type",
});
group.add(bg, dot, ico, lbl, sub, badge);
// Also store extra fields in group attrs
group.setAttr('deviceId', opts.deviceId || null);
group.setAttr('channel', opts.channel != null ? opts.channel : null);
// hover highlight
group.on("mouseenter", () => { bg.stroke("#4f8ccd"); uiLayer.batchDraw(); deviceLayer.batchDraw(); });
group.on("mouseleave", () => { bg.stroke("#30363d"); uiLayer.batchDraw(); deviceLayer.batchDraw(); });
// drag snap
group.on("dragmove", () => {
group.x(snap(group.x()));
group.y(snap(group.y()));
deviceLayer.batchDraw();
setDirty();
});
// right-click = delete in structure / devices mode
group.on("contextmenu", (e) => {
if (mode === "view") return;
e.evt.preventDefault();
if (confirm(`Remove "${opts.name}" from layout?`)) {
group.destroy();
deviceLayer.batchDraw();
setDirty();
}
});
// click = select in devices mode; toggle relay in view mode
group.on("click tap", async (e) => {
if (mode === "devices") {
e.cancelBubble = true; // prevent stage click from immediately deselecting
selectDevice(group);
return;
}
if (mode !== "view") return;
let url = null;
if (opts.entityType === "relay") {
url = CFG.toggleBase
.replace("{boardId}", opts.boardId)
.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
}
try {
await fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" },
});
} catch (_) {}
});
return group;
}
function updateDeviceState(group, isOn, onColor, offColor) {
const color = colorForState(isOn, onColor, offColor);
const dot = group.findOne(".state-dot");
const ico = group.findOne(".device-icon");
if (dot) dot.fill(color);
if (ico) ico.fill(isOn ? color : "#484f58");
deviceLayer.batchDraw();
}
// ── load saved canvas ──────────────────────────────────────────────────────
function loadCanvas(data) {
if (!data) return;
// Structure shapes
(data.structure || []).forEach(s => {
if (s.tool === "text") {
const t = new Konva.Text({
id: s.id, x: s.x, y: s.y,
text: s.text || "Label", fontSize: 14,
fontFamily: "system-ui, sans-serif",
fill: "#e6edf3",
draggable: false,
name: "structure-shape", toolType: "text",
rotation: s.rotation || 0,
});
makeStructureDraggable(t);
structureLayer.add(t);
} else {
let sh;
if (s.tool === "door") {
// restore Arc using saved outerRadius (or fall back to w/h for old saves)
const st = TOOL_STYLE.door;
const radius = s.r != null
? s.r
: Math.max(Math.abs(s.w || GRID), Math.abs(s.h || GRID));
sh = new Konva.Arc({
id: s.id, x: s.x, y: s.y,
innerRadius: 0, outerRadius: radius,
angle: 90,
fill: st.fill, stroke: st.stroke, strokeWidth: st.strokeWidth,
rotation: s.rotation || 0,
name: "structure-shape", toolType: "door",
});
} else {
sh = shapeForTool(s.tool, s.x, s.y, s.w, s.h);
sh.id(s.id);
if (s.rotation) sh.rotation(s.rotation);
}
// restore Transformer scale if shape was resized
if (s.sx != null) sh.scaleX(s.sx);
if (s.sy != null) sh.scaleY(s.sy);
makeStructureDraggable(sh);
structureLayer.add(sh);
}
});
// Devices
(data.devices || []).forEach(d => {
const board = CFG.boards.find(b => b.id === d.boardId);
if (!board) return;
let entityInfo = null;
let isOn = false;
let onColor = "warning";
let offColor = "secondary";
if (d.entityType === "sonoff") {
// Match by deviceId + channel stored in sonoff_channels
entityInfo = board.sonoff_channels &&
board.sonoff_channels.find(sc => sc.deviceId === d.deviceId && sc.channel === d.channel);
if (!entityInfo) return;
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;
isOn = entityInfo.isOn;
onColor = entityInfo.onColor;
offColor = entityInfo.offColor;
} else {
entityInfo = board.inputs.find(i => i.num === d.entityNum);
if (!entityInfo) return;
isOn = !entityInfo.rawState;
onColor = entityInfo.activeColor;
offColor = entityInfo.idleColor;
}
const group = createDeviceGroup({
id: d.id,
boardId: d.boardId,
entityType: d.entityType,
entityNum: d.entityNum || null,
// Sonoff-specific
deviceId: d.deviceId || null,
channel: d.channel != null ? d.channel : null,
x: d.x + 60,
y: d.y + 26,
name: entityInfo.name,
boardName: board.name,
icon: entityInfo.icon,
stateColor: colorForState(isOn, onColor, offColor),
isRelay: d.entityType === "relay" || d.entityType === "sonoff" || d.entityType === "tuya",
});
deviceLayer.add(group);
});
structureLayer.batchDraw();
deviceLayer.batchDraw();
}
// ── persist structure shapes (make selectable + draggable) ─────────────────
function makeStructureDraggable(shape) {
shape.draggable(false); // only move via transformer
shape.on("click tap", () => {
if (mode !== "structure" || activeTool !== "select") return;
deselect();
transformer.nodes([shape]);
selectedNode = shape;
uiLayer.batchDraw();
});
shape.on("dragend", () => setDirty());
}
// ── save ───────────────────────────────────────────────────────────────────
window.LB = window.LB || {};
LB.save = async function () {
setSaveStatus("Saving…", "saving");
const structure = [];
structureLayer.find(".structure-shape").forEach(sh => {
if (sh.className === "Text") {
structure.push({
id: sh.id(), tool: "text",
x: sh.x(), y: sh.y(),
text: sh.text(), rotation: sh.rotation(),
});
} else {
const isArc = sh.className === "Arc";
const entry = {
id: sh.id(), tool: sh.attrs.toolType || "wall",
x: sh.x(), y: sh.y(),
rotation: sh.rotation(),
};
if (isArc) {
entry.r = sh.outerRadius(); // save radius, not bounding-box w/h
} else {
entry.w = sh.width(); entry.h = sh.height();
}
// preserve Transformer-applied scale so resized shapes survive reload
const sx = sh.scaleX(), sy = sh.scaleY();
if (sx !== 1) entry.sx = sx;
if (sy !== 1) entry.sy = sy;
structure.push(entry);
}
});
const devices = [];
deviceLayer.find(".device-group").forEach(g => {
const entry = {
id: g.id(),
boardId: g.attrs.boardId,
entityType: g.attrs.entityType,
entityNum: g.attrs.entityNum || null,
// Sonoff extra fields
deviceId: g.attrs.deviceId || null,
channel: g.attrs.channel != null ? g.attrs.channel : null,
x: g.x(),
y: g.y(),
};
devices.push(entry);
});
const thumbnail = stage.toDataURL({ pixelRatio: 0.4 });
try {
const resp = await fetch(CFG.saveUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ canvas: { structure, devices }, thumbnail }),
});
if (resp.ok) {
isDirty = false;
setSaveStatus("Saved ✓", "saved");
setTimeout(() => setSaveStatus(""), 3000);
} else {
setSaveStatus("Save failed", "saving");
}
} catch (e) {
setSaveStatus("Save failed", "saving");
}
};
// ── zoom ───────────────────────────────────────────────────────────────────
LB.zoom = function (factor) {
const oldScale = stage.scaleX();
const newScale = Math.min(5, Math.max(0.15, oldScale * factor));
const cx = stage.width() / 2;
const cy = stage.height() / 2;
const pos = stage.position();
stage.scale({ x: newScale, y: newScale });
stage.position({
x: cx - (cx - pos.x) * (newScale / oldScale),
y: cy - (cy - pos.y) * (newScale / oldScale),
});
stage.batchDraw();
drawGrid();
};
LB.resetView = function () {
stage.scale({ x: 1, y: 1 });
stage.position({ x: 0, y: 0 });
stage.batchDraw();
drawGrid();
};
// ── clear helpers ──────────────────────────────────────────────────────────
LB.clearStructure = function () {
if (!confirm("Remove all structure shapes? This cannot be undone.")) return;
structureLayer.destroyChildren();
structureLayer.batchDraw();
deselect();
setDirty();
};
LB.clearDevices = function () {
if (!confirm("Remove all devices from canvas? This cannot be undone.")) return;
deselectDevice();
deviceLayer.destroyChildren();
deviceLayer.batchDraw();
setDirty();
};
// ── device selection ───────────────────────────────────────────────────────
function selectDevice(group) {
// un-highlight previous
if (selectedDeviceGroup && selectedDeviceGroup !== group) {
const prevBg = selectedDeviceGroup.findOne(".device-bg");
if (prevBg) prevBg.stroke("#30363d");
}
selectedDeviceGroup = group;
// highlight selected
const bg = group.findOne(".device-bg");
if (bg) bg.stroke("#4f8ccd");
deviceLayer.batchDraw();
// populate sidebar info
const nameEl = document.getElementById("lb-sel-name");
const boardEl = document.getElementById("lb-sel-board");
const typeEl = document.getElementById("lb-sel-type");
const lbl = group.findOne(".device-label");
const sub = group.findOne(".device-sub");
const typ = group.findOne(".device-type");
if (nameEl) nameEl.textContent = lbl ? lbl.text() : "";
if (boardEl) boardEl.textContent = sub ? sub.text() : "";
if (typeEl) typeEl.textContent = typ ? typ.text() : "";
const panel = document.getElementById("lb-device-selected");
const hint = document.getElementById("lb-drag-hint");
if (panel) panel.style.display = "";
if (hint) hint.style.display = "none";
}
function deselectDevice() {
if (selectedDeviceGroup) {
const bg = selectedDeviceGroup.findOne(".device-bg");
if (bg) bg.stroke("#30363d");
deviceLayer.batchDraw();
}
selectedDeviceGroup = null;
const panel = document.getElementById("lb-device-selected");
const hint = document.getElementById("lb-drag-hint");
if (panel) panel.style.display = "none";
if (hint) hint.style.display = "";
}
LB.removeSelectedDevice = function () {
if (!selectedDeviceGroup) return;
const lbl = selectedDeviceGroup.findOne(".device-label");
const name = lbl ? lbl.text() : "device";
if (!confirm(`Remove "${name}" from layout?`)) return;
selectedDeviceGroup.destroy();
deviceLayer.batchDraw();
deselectDevice();
setDirty();
};
LB.deselectDevice = function () { deselectDevice(); };
// ── mode switching ─────────────────────────────────────────────────────────
function applyMode(newMode) {
mode = newMode;
deselect();
deselectDevice();
// toolbar buttons
document.querySelectorAll(".tb-mode").forEach(b =>
b.classList.toggle("active", b.dataset.mode === mode));
// sidebar panels
document.getElementById("lb-structure-palette").style.display =
mode === "structure" ? "" : "none";
document.getElementById("lb-devices-palette").style.display =
mode === "devices" ? "" : "none";
document.getElementById("lb-view-info").style.display =
mode === "view" ? "" : "none";
// clear-devices button visibility
document.getElementById("btn-clear-devices").style.display =
mode === "devices" ? "" : "none";
// cursor
container.style.cursor = mode === "view" ? "default" : "crosshair";
if (mode === "structure") {
selectTool("select");
} else if (mode === "view") {
activeTool = "select";
// make devices clickable, structures not draggable
deviceLayer.listening(true);
structureLayer.listening(false);
}
if (mode !== "view") {
structureLayer.listening(true);
}
}
// ── tool switching ─────────────────────────────────────────────────────────
function selectTool(tool) {
activeTool = tool;
document.querySelectorAll(".lb-tool-btn").forEach(b =>
b.classList.toggle("active", b.dataset.tool === tool));
const isPan = tool === "pan";
stage.draggable(isPan);
container.style.cursor = isPan ? "grab" : tool === "select" ? "default" : "crosshair";
deselect();
}
// ── stage mouse events for drawing ────────────────────────────────────────
stage.on("mousedown touchstart", (e) => {
if (mode !== "structure") return;
if (activeTool === "pan" || activeTool === "select") return;
if (e.target !== stage && e.target.name() === "structure-shape") return;
const pos = stagePos(e);
isDrawing = true;
drawStart = { x: snap(pos.x), y: snap(pos.y) };
if (activeTool === "text") {
isDrawing = false;
const label = prompt("Label text:", "Label");
if (!label) return;
const t = new Konva.Text({
id: uid(), x: snap(pos.x), y: snap(pos.y),
text: label, fontSize: 14,
fontFamily: "system-ui, sans-serif",
fill: "#e6edf3", draggable: false,
name: "structure-shape", toolType: "text",
});
makeStructureDraggable(t);
structureLayer.add(t);
structureLayer.batchDraw();
setDirty();
return;
}
tempShape = shapeForTool(activeTool, snap(pos.x), snap(pos.y), 0, 0);
structureLayer.add(tempShape);
});
stage.on("mousemove touchmove", (e) => {
if (!isDrawing || !tempShape) return;
const pos = stagePos(e);
const w = snap(pos.x) - drawStart.x;
const h = snap(pos.y) - drawStart.y;
if (activeTool === "door") {
tempShape.outerRadius(Math.max(Math.abs(w), Math.abs(h)));
tempShape.rotation(w < 0 && h >= 0 ? 90 : w >= 0 && h < 0 ? -90 : w < 0 && h < 0 ? 180 : 0);
} else {
tempShape.x(Math.min(drawStart.x, drawStart.x + w));
tempShape.y(Math.min(drawStart.y, drawStart.y + h));
tempShape.width(Math.abs(w));
tempShape.height(Math.abs(h));
}
structureLayer.batchDraw();
});
stage.on("mouseup touchend", () => {
if (!isDrawing || !tempShape) return;
isDrawing = false;
const tooSmall = (
tempShape.className !== "Arc" &&
(tempShape.width() < 4 || tempShape.height() < 4)
) || (
tempShape.className === "Arc" && tempShape.outerRadius() < 4
);
if (tooSmall) {
tempShape.destroy();
} else {
tempShape.draggable(false);
makeStructureDraggable(tempShape);
setDirty();
}
tempShape = null;
structureLayer.batchDraw();
});
// click empty stage = deselect
stage.on("click", (e) => {
if (e.target === stage) {
deselect();
deselectDevice();
}
});
// Delete key = remove selected structure shape or device
window.addEventListener("keydown", (e) => {
if (e.key !== "Delete" && e.key !== "Backspace") return;
if (document.activeElement !== document.body) return;
if (selectedNode) {
selectedNode.destroy();
deselect();
structureLayer.batchDraw();
setDirty();
} else if (selectedDeviceGroup) {
LB.removeSelectedDevice();
}
});
// ── mouse-wheel zoom ───────────────────────────────────────────────────────
container.addEventListener("wheel", (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const pointer = stage.getPointerPosition();
const oldScale = stage.scaleX();
const newScale = Math.min(5, Math.max(0.15, oldScale * factor));
const mousePos = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale,
};
stage.scale({ x: newScale, y: newScale });
stage.position({
x: pointer.x - mousePos.x * newScale,
y: pointer.y - mousePos.y * newScale,
});
stage.batchDraw();
drawGrid();
}, { passive: false });
// pan on drag in pan mode
stage.on("dragend", () => drawGrid());
// ── resize ─────────────────────────────────────────────────────────────────
window.addEventListener("resize", () => {
stage.width(stageWrap.clientWidth);
stage.height(stageWrap.clientHeight);
stage.batchDraw();
drawGrid();
});
// ── toolbar mode buttons ───────────────────────────────────────────────────
document.querySelectorAll(".tb-mode").forEach(btn => {
btn.addEventListener("click", () => applyMode(btn.dataset.mode));
});
// ── sidebar tool buttons ──────────────────────────────────────────────────
document.querySelectorAll(".lb-tool-btn[data-tool]").forEach(btn => {
btn.addEventListener("click", () => selectTool(btn.dataset.tool));
});
// ── build device palette sidebar ─────────────────────────────────────────
function buildDevicePalette() {
const list = document.getElementById("lb-device-list");
list.innerHTML = "";
if (!CFG.boards || CFG.boards.length === 0) {
list.innerHTML = '<p class="text-secondary small p-2">No boards configured.</p>';
return;
}
CFG.boards.forEach(board => {
const grp = document.createElement("div");
grp.className = "lb-board-group";
const hdr = document.createElement("div");
hdr.className = "lb-board-name";
hdr.innerHTML = `<span class="chip-dot" style="width:7px;height:7px;border-radius:50%;background:${board.online ? "#3fb950" : "#484f58"};display:inline-block"></span> ${escHtml(board.name)}`;
grp.appendChild(hdr);
let hasAny = false;
board.relays.forEach(r => {
grp.appendChild(makeChip(board, "relay", r));
hasAny = true;
});
board.inputs.forEach(inp => {
grp.appendChild(makeChip(board, "input", inp));
hasAny = true;
});
// ── Sonoff sub-devices ───────────────────────────────────────────
if (board.sonoff_channels && board.sonoff_channels.length) {
board.sonoff_channels.forEach(sc => {
grp.appendChild(makeSonoffChip(board, sc));
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);
});
}
function escHtml(s) {
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function makeChip(board, entityType, entity) {
const chip = document.createElement("div");
chip.className = "lb-device-chip";
chip.draggable = true;
chip.dataset.boardId = board.id;
chip.dataset.entityType = entityType;
chip.dataset.entityNum = entity.num;
chip.dataset.name = entity.name;
chip.dataset.boardName = board.name;
chip.dataset.onColor = entity.onColor || entity.activeColor || "warning";
chip.dataset.offColor = entity.offColor || entity.idleColor || "secondary";
chip.dataset.isOn = entityType === "relay"
? entity.isOn
: !entity.rawState;
const dotColor = colorForState(chip.dataset.isOn === "true",
chip.dataset.onColor, chip.dataset.offColor);
chip.innerHTML = `
<span class="chip-dot" style="background:${dotColor}"></span>
<i class="bi ${escHtml(entity.icon)}" style="font-size:13px"></i>
<span class="text-truncate">${escHtml(entity.name)}</span>
<span class="ms-auto text-secondary" style="font-size:.7rem">${entityType}</span>`;
// ── HTML5 drag from sidebar onto canvas ─────────────────────────────────
chip.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/json", JSON.stringify({
boardId: parseInt(board.id),
entityType,
entityNum: entity.num,
name: entity.name,
boardName: board.name,
onColor: chip.dataset.onColor,
offColor: chip.dataset.offColor,
isOn: chip.dataset.isOn === "true",
}));
});
return chip;
}
function makeSonoffChip(board, sc) {
const chip = document.createElement("div");
chip.className = "lb-device-chip";
chip.draggable = true;
chip.dataset.boardId = board.id;
chip.dataset.entityType = "sonoff";
chip.dataset.deviceId = sc.deviceId;
chip.dataset.channel = sc.channel;
chip.dataset.name = sc.name;
chip.dataset.boardName = board.name;
chip.dataset.isOn = sc.isOn;
const onColor = sc.kind === "light" ? "warning" : "success";
const dotColor = colorForState(sc.isOn, onColor, "secondary");
chip.innerHTML = `
<span class="chip-dot" style="background:${dotColor}"></span>
<i class="bi ${escHtml(sc.icon)}" style="font-size:13px"></i>
<span class="text-truncate">${escHtml(sc.name)}</span>
<span class="ms-auto text-secondary" style="font-size:.7rem">${escHtml(sc.kind || 'switch')}</span>`;
chip.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/json", JSON.stringify({
boardId: parseInt(board.id),
entityType: "sonoff",
entityNum: null,
deviceId: sc.deviceId,
channel: sc.channel,
name: sc.name,
boardName: board.name,
onColor,
offColor: "secondary",
isOn: sc.isOn,
}));
});
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");
container.addEventListener("dragover", (e) => {
if (mode !== "devices") return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
container.classList.add("drag-over");
ghost.style.display = "block";
ghost.style.left = (e.clientX + 14) + "px";
ghost.style.top = (e.clientY + 14) + "px";
ghost.textContent = "+ Drop device here";
});
container.addEventListener("dragleave", () => {
container.classList.remove("drag-over");
ghost.style.display = "none";
});
container.addEventListener("drop", (e) => {
e.preventDefault();
container.classList.remove("drag-over");
ghost.style.display = "none";
if (mode !== "devices") return;
let dragged;
try { dragged = JSON.parse(e.dataTransfer.getData("application/json")); }
catch (_) { return; }
// convert page coords → stage coords
const rect = container.getBoundingClientRect();
const sc = stage.scaleX();
const off = stage.position();
const stageX = (e.clientX - rect.left - off.x) / sc;
const stageY = (e.clientY - rect.top - off.y) / sc;
const stateColor = colorForState(dragged.isOn, dragged.onColor, dragged.offColor);
const group = createDeviceGroup({
id: uid(),
boardId: dragged.boardId,
entityType: dragged.entityType,
entityNum: dragged.entityNum || null,
// Sonoff-specific
deviceId: dragged.deviceId || null,
channel: dragged.channel != null ? dragged.channel : null,
x: stageX,
y: stageY,
name: dragged.name,
boardName: dragged.boardName,
stateColor,
isRelay: dragged.entityType === "relay" || dragged.entityType === "sonoff" || dragged.entityType === "tuya",
});
deviceLayer.add(group);
deviceLayer.batchDraw();
setDirty();
});
// ── socket.io live updates ────────────────────────────────────────────────
// Re-use the socket already created by the base template
if (typeof io !== "undefined") {
const socket = io();
socket.on("board_update", (data) => {
const bid = data.board_id;
deviceLayer.find(".device-group").forEach(group => {
if (group.attrs.boardId !== bid) return;
if (group.attrs.entityType === "relay" && data.relay_states) {
const isOn = data.relay_states[`relay_${group.attrs.entityNum}`];
// find onColor / offColor from CFG
const board = CFG.boards.find(b => b.id === bid);
const relay = board && board.relays.find(r => r.num === group.attrs.entityNum);
if (relay) {
updateDeviceState(group, isOn, relay.onColor, relay.offColor);
}
}
if (group.attrs.entityType === "input" && data.input_states) {
const rawState = data.input_states[`input_${group.attrs.entityNum}`];
const isActive = !rawState; // NC inversion
const board = CFG.boards.find(b => b.id === bid);
const inp = board && board.inputs.find(i => i.num === group.attrs.entityNum);
if (inp) {
updateDeviceState(group, isActive, inp.activeColor, inp.idleColor);
}
}
});
});
// 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;
deviceLayer.find(".device-group").forEach(group => {
if (group.attrs.boardId !== bid) return;
if (group.attrs.entityType !== "sonoff") return;
if (group.attrs.deviceId !== data.device_id) return;
if (group.attrs.channel !== data.channel) return;
const board = CFG.boards.find(b => b.id === bid);
const sc = board && board.sonoff_channels &&
board.sonoff_channels.find(s =>
s.deviceId === data.device_id && s.channel === data.channel);
const onColor = sc ? (sc.kind === "light" ? "warning" : "success") : "success";
updateDeviceState(group, data.state, onColor, "secondary");
});
});
}
// ── init ───────────────────────────────────────────────────────────────────
drawGrid();
buildDevicePalette();
applyMode("structure");
selectTool("select");
// Load persisted data
if (CFG.canvasJson) {
loadCanvas(CFG.canvasJson);
}
setSaveStatus(CFG.canvasJson ? "" : "New layout");
document.getElementById("btn-clear-devices").style.display = "none";
// Warn on close if unsaved
window.addEventListener("beforeunload", (e) => {
if (!isDirty) return;
e.preventDefault();
e.returnValue = "";
});
})();