- 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
1145 lines
42 KiB
JavaScript
1145 lines
42 KiB
JavaScript
/**
|
||
* 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,"&").replace(/</g,"<").replace(/>/g,">");
|
||
}
|
||
|
||
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 = "";
|
||
});
|
||
|
||
})();
|