Files

611 lines
28 KiB
HTML
Raw Permalink 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.
{% extends "base.html" %}
{% block title %}{% if device %}Edit Device {{ device.name }}{% else %}New Device{% endif %}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('devices.list_devices') }}">Devices</a></li>
<li class="breadcrumb-item active">
{% if device %}Edit {{ device.name }}{% else %}New Device{% endif %}
</li>
</ol>
</nav>
<div class="d-flex align-items-start gap-3 mb-4">
<div>
<h2 class="fw-bold mb-1">
<i class="bi bi-hdd-stack me-2 text-info"></i>
{% if device %}Edit Device{% else %}New Device{% endif %}
</h2>
<p class="text-secondary mb-0">
{% if device %}
Update identity or change the hardware binding below.
{% else %}
Define the device identity first — hardware binding is optional and can be added later.
{% endif %}
</p>
</div>
</div>
<form method="POST" id="deviceForm">
<!-- Hidden binding fields submitted with the form -->
<input type="hidden" id="hBoardId" name="board_id" value="{{ device.board_id if device and device.board_id else '' }}" />
<input type="hidden" id="hEntityType" name="entity_type" value="{{ device.entity_type if device and device.entity_type else '' }}" />
<input type="hidden" id="hEntityNum" name="entity_num" value="{{ device.entity_num if device and device.entity_num else '' }}" />
<input type="hidden" id="hHardwareDeviceId" name="hardware_device_id" value="{{ device.hardware_device_id if device and device.hardware_device_id else '' }}" />
<!-- ─── Step 1 header ────────────────────────────────────────────────────── -->
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge rounded-pill bg-primary px-3 py-2" style="font-size:.8rem">
<i class="bi bi-1-circle me-1"></i>Device Identity
</span>
<span class="text-secondary small">Required — the virtual device exists independent of any hardware</span>
</div>
<div class="row g-4 mb-4">
<!-- ══════════════════ LEFT: identity ══════════════════════ -->
<div class="col-lg-5">
<!-- Identity -->
<div class="card border-0 rounded-4 h-100">
<div class="card-body p-4">
<h5 class="fw-semibold mb-3"><i class="bi bi-tag me-1 text-info"></i> Details</h5>
<div class="mb-3">
<label class="form-label" for="name">Device name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
value="{{ device.name if device else '' }}"
placeholder="e.g. Outdoor Light 1 Courtyard"
maxlength="128" required />
</div>
<!-- App ID -->
<div class="mb-3">
<label class="form-label" for="appIdField">
Application ID
<span class="badge text-bg-info ms-1" style="font-size:.65rem">auto-generated</span>
</label>
<div class="input-group">
<span class="input-group-text" style="font-family:monospace;font-size:.85rem">
<i class="bi bi-hash me-1 text-info"></i>
</span>
<input type="text" class="form-control font-monospace" id="appIdField" name="app_id"
value="{{ device.app_id if device and device.app_id else '' }}"
placeholder="auto" maxlength="64"
style="font-size:.85rem" />
{% if device and device.app_id %}
<button type="button" class="btn btn-outline-secondary copy-app-id"
data-appid="{{ device.app_id }}" title="Copy to clipboard">
<i class="bi bi-clipboard"></i>
</button>
{% endif %}
</div>
<div class="form-text">
Stable identifier for layouts &amp; automations. Leave blank to keep current or auto-generate.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="description">Description <span class="text-secondary small">(optional)</span></label>
<input type="text" class="form-control" id="description" name="description"
value="{{ device.description if device and device.description else '' }}"
placeholder="e.g. Controls the outdoor courtyard lighting" maxlength="256" />
</div>
<div class="mb-0">
<label class="form-label" for="area">Area / Location <span class="text-secondary small">(optional)</span></label>
<input type="text" class="form-control" id="area" name="area"
value="{{ device.area if device and device.area else '' }}"
placeholder="e.g. Courtyard, Living Room, Garage"
maxlength="64" />
</div>
</div>
</div>
</div>
<!-- ══════════════════ RIGHT: type + icon ══════════════════ -->
<div class="col-lg-7">
<!-- Device type -->
<div class="card border-0 rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-semibold mb-1"><i class="bi bi-grid-3x3-gap me-1 text-success"></i> Device Type</h5>
<p class="text-secondary small mb-3">Defines default icon, state labels and colours.</p>
<div id="relay-types-wrap">
<div class="d-flex flex-wrap gap-2">
{% for tkey, tdef in relay_types.items() %}
<label class="entity-type-pill" title="{{ tdef.label }}" data-icon="{{ tdef.icon }}">
<input type="radio" name="device_class" value="{{ tkey }}" hidden
{% if (device and device.device_class == tkey) or (not device and tkey == 'switch') %}checked{% endif %} />
<i class="bi {{ tdef.icon }}"></i>
<span>{{ tdef.label }}</span>
</label>
{% endfor %}
</div>
</div>
<div id="input-types-wrap" style="display:none">
<div class="d-flex flex-wrap gap-2">
{% for tkey, tdef in input_types.items() %}
<label class="entity-type-pill" title="{{ tdef.label }}" data-icon="{{ tdef.icon }}">
<input type="radio" name="device_class" value="{{ tkey }}" hidden
{% if device and device.device_class == tkey and device.entity_type == 'input' %}checked{% endif %} />
<i class="bi {{ tdef.icon }}"></i>
<span>{{ tdef.label }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Custom icon -->
<div class="card border-0 rounded-4">
<div class="card-body p-4">
<h5 class="fw-semibold mb-3"><i class="bi bi-palette me-1 text-warning"></i> Custom Icon</h5>
<div class="d-flex align-items-center gap-3 mb-3">
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
style="width:58px;height:58px;background:var(--bs-secondary-bg)">
<i id="icon-preview" class="bi {{ (device.icon or device.effective_icon) if device else 'bi-toggles' }}"
style="font-size:2rem"></i>
</div>
<div class="flex-grow-1">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="iconInput" name="icon"
value="{{ device.icon if device and device.icon else '' }}"
placeholder="bi-lightbulb-fill" />
<button type="button" class="btn btn-outline-secondary" id="iconClearBtn"><i class="bi bi-x"></i></button>
</div>
<div class="form-text">Leave blank to use device type default.</div>
</div>
</div>
<div class="d-flex flex-wrap gap-1" id="iconPalette">
{% for icon_cls, icon_name in icon_palette %}
<button type="button" class="btn btn-sm btn-outline-secondary p-1"
title="{{ icon_name }}" data-icon="{{ icon_cls }}">
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
</button>
{% endfor %}
</div>
</div>
</div>
</div>
</div><!-- /row step-1 -->
<!-- ─── Step 2 header ────────────────────────────────────────────────────── -->
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge rounded-pill bg-secondary px-3 py-2" style="font-size:.8rem">
<i class="bi bi-2-circle me-1"></i>Hardware Binding
</span>
<span class="badge text-bg-warning" style="font-size:.7rem">Optional</span>
<span class="text-secondary small">Link to a physical relay, sensor, Tuya or Sonoff channel</span>
</div>
<div class="card border-0 rounded-4 mb-4">
<div class="card-body p-4">
<p class="text-secondary small mb-4">
Select a board, then click a channel to bind this device to physical hardware.
Leave unbound to use the device as a virtual entity in layouts and automations.
Once bound, the device can be toggled ON/OFF and shows live state.
</p>
<!-- Board selector -->
<div class="mb-4" style="max-width:480px">
<label class="form-label fw-semibold" for="boardSelect">
<i class="bi bi-motherboard me-1 text-primary"></i> Board
</label>
<select class="form-select" id="boardSelect">
<option value="">— None (virtual / unlinked device) —</option>
{% for board in boards %}
<option value="{{ board.id }}" data-name="{{ board.name }}"
{% if device and device.board_id == board.id %}selected{% endif %}>
{{ board.name }} · {{ board.board_type }}
</option>
{% endfor %}
</select>
</div>
<!-- Entity panel -->
<div id="entityPanel">
<div id="entityLoading" class="text-center py-4 text-secondary" style="display:none">
<div class="spinner-border spinner-border-sm me-2"></div>Loading channels…
</div>
<div id="entityPlaceholder" class="text-center py-5 text-secondary">
<i class="bi bi-arrow-up-circle display-5 d-block mb-2 opacity-25"></i>
Select a board above to see its relay and sensor channels.
</div>
<!-- Relays -->
<div id="relaySection" style="display:none">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-lightning-charge text-warning me-2"></i>
<span class="fw-semibold">Relay Outputs</span>
<span class="badge text-bg-secondary ms-2" id="relayCount"></span>
<span class="text-secondary small ms-2"> controllable (ON / OFF)</span>
</div>
<div class="row g-2 mb-4" id="relayGrid"></div>
</div>
<!-- Inputs -->
<div id="inputSection" style="display:none">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-arrow-down-circle text-success me-2"></i>
<span class="fw-semibold">Digital Inputs / Sensors</span>
<span class="badge text-bg-secondary ms-2" id="inputCount"></span>
<span class="text-secondary small ms-2"> read-only</span>
</div>
<div class="row g-2" id="inputGrid"></div>
</div>
<!-- Selection summary -->
<div id="bindSummary" class="alert alert-info d-flex align-items-center gap-3 mt-3"
style="display:{% if device and device.board and device.entity_num %}flex{% else %}none{% endif %}">
<i class="bi bi-check-circle-fill fs-5"></i>
<div>
<strong>Bound to:</strong>
<span id="bindSummaryText">
{% if device and device.board %}
{{ device.board.name }} · {{ device.name }}
{% if device.hardware_device_id %}
<span class="font-monospace text-secondary" style="font-size:.75rem">[{{ device.hardware_device_id }}]</span>
{% endif %}
{% endif %}
</span>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="unbindBtn">
<i class="bi bi-x me-1"></i>Remove binding
</button>
</div>
</div>
</div>
</div>
<!-- ── Submit ────────────────────────────────────────────────────────────── -->
<div class="d-flex gap-2 mt-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>
{% if device %}Save Changes{% else %}Create Device{% endif %}
</button>
<a href="{{ url_for('devices.list_devices') }}" class="btn btn-outline-secondary">
<i class="bi bi-x me-1"></i>Cancel
</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
(function () {
const INIT = {
boardId: {{ (device.board_id if device and device.board_id else none) | tojson }},
entityType: {{ (device.entity_type if device and device.entity_type else none) | tojson }},
entityNum: {{ (device.entity_num if device and device.entity_num else none) | tojson }},
devClass: {{ (device.device_class if device else 'switch') | tojson }},
};
const boardSel = document.getElementById("boardSelect");
const hBoardId = document.getElementById("hBoardId");
const hEntityType = document.getElementById("hEntityType");
const hEntityNum = document.getElementById("hEntityNum");
const hHardwareDeviceId = document.getElementById("hHardwareDeviceId");
const entityLoading = document.getElementById("entityLoading");
const entityPh = document.getElementById("entityPlaceholder");
const relaySection = document.getElementById("relaySection");
const inputSection = document.getElementById("inputSection");
const relayGrid = document.getElementById("relayGrid");
const inputGrid = document.getElementById("inputGrid");
const relayCount = document.getElementById("relayCount");
const inputCount = document.getElementById("inputCount");
const bindSummary = document.getElementById("bindSummary");
const bindSummaryTxt = document.getElementById("bindSummaryText");
const unbindBtn = document.getElementById("unbindBtn");
const iconInput = document.getElementById("iconInput");
const iconPreview = document.getElementById("icon-preview");
const iconClearBtn = document.getElementById("iconClearBtn");
const relayTypesWrap = document.getElementById("relay-types-wrap");
const inputTypesWrap = document.getElementById("input-types-wrap");
// ── icon preview ──────────────────────────────────────────────────────────
function updateIconPreview() {
const val = iconInput.value.trim();
if (val) { iconPreview.className = "bi " + val; return; }
const checked = document.querySelector(
"#relay-types-wrap input[name=device_class]:checked, " +
"#input-types-wrap input[name=device_class]:checked"
);
if (checked) {
const lbl = checked.closest("label");
if (lbl) iconPreview.className = "bi " + lbl.dataset.icon;
}
}
iconInput.addEventListener("input", updateIconPreview);
iconClearBtn.addEventListener("click", () => { iconInput.value = ""; updateIconPreview(); });
// ── live app_id slug preview ──────────────────────────────────────────────
// Mirrors the server-side Device._make_slug() logic in JavaScript.
// If the user hasn't manually edited the app_id field, auto-fill it from name.
const appIdField = document.getElementById("appIdField");
const nameField = document.getElementById("name");
let appIdManual = !!(appIdField.value.trim()); // true if pre-filled on edit page
function makeSlug(str) {
return str.toLowerCase().trim()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.substring(0, 60) || "device";
}
appIdField.addEventListener("input", () => {
appIdManual = appIdField.value.trim() !== "";
});
nameField.addEventListener("input", () => {
if (!appIdManual) {
appIdField.value = makeSlug(nameField.value);
}
});
document.getElementById("iconPalette").addEventListener("click", e => {
const btn = e.target.closest("[data-icon]");
if (!btn) return;
iconInput.value = btn.dataset.icon;
updateIconPreview();
});
document.querySelectorAll("label.entity-type-pill input").forEach(r =>
r.addEventListener("change", updateIconPreview)
);
// ── app_id copy button ────────────────────────────────────────────────────
document.addEventListener("click", e => {
const btn = e.target.closest(".copy-app-id");
if (!btn) return;
navigator.clipboard.writeText(btn.dataset.appid).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check2"></i>';
setTimeout(() => { btn.innerHTML = orig; }, 1200);
});
});
// ── device type tab switch ────────────────────────────────────────────────
function showRelayTypes(preselect) {
relayTypesWrap.style.display = "";
inputTypesWrap.style.display = "none";
inputTypesWrap.querySelectorAll("input").forEach(r => r.checked = false);
if (preselect) {
const m = relayTypesWrap.querySelector(`input[value="${preselect}"]`);
if (m) m.checked = true;
}
if (!relayTypesWrap.querySelector("input:checked")) {
const f = relayTypesWrap.querySelector("input"); if (f) f.checked = true;
}
updateIconPreview();
}
function showInputTypes(preselect) {
inputTypesWrap.style.display = "";
relayTypesWrap.style.display = "none";
relayTypesWrap.querySelectorAll("input").forEach(r => r.checked = false);
if (preselect) {
const m = inputTypesWrap.querySelector(`input[value="${preselect}"]`);
if (m) m.checked = true;
}
if (!inputTypesWrap.querySelector("input:checked")) {
const f = inputTypesWrap.querySelector("input"); if (f) f.checked = true;
}
updateIconPreview();
}
// ── entity card factory ──────────────────────────────────────────────────
function makeCard(item, kind) {
const actualKind = item.entity_type || kind;
const isSelected = hEntityType.value === actualKind && parseInt(hEntityNum.value) === item.num;
const stateColor = kind === "relay"
? (item.state ? item.on_color : item.off_color)
: (item.active ? item.active_color : item.idle_color);
// For gateway devices the item.name is already the full device name;
// only show the numeric channel label for hardware relay/input boards.
const subLabel = (actualKind === "tuya" || actualKind === "sonoff")
? item.type
: (kind === "relay" ? `Relay ${item.num}` : `Input ${item.num}`);
const col = document.createElement("div");
col.className = "col-6 col-md-4 col-xl-3";
col.innerHTML = `
<div class="card entity-pick-card border-2 rounded-3 h-100
${isSelected ? "border-primary bg-primary bg-opacity-10" : "border-transparent"}"
style="cursor:pointer;transition:all .15s"
data-kind="${actualKind}" data-num="${item.num}">
<div class="card-body p-3 text-center">
<div class="rounded-3 d-flex align-items-center justify-content-center mx-auto mb-2"
style="width:48px;height:48px;background:var(--bs-secondary-bg)">
<i class="bi ${item.icon} entity-card-icon"
style="font-size:1.5rem;${isSelected ? "color:var(--bs-primary)" : ""}"></i>
</div>
<div class="fw-semibold small text-truncate" title="${item.name}">${item.name}</div>
<div class="text-secondary" style="font-size:.7rem">${subLabel}</div>
<span class="badge text-bg-${stateColor} mt-1" style="font-size:.65rem">${item.state_label}</span>
${isSelected ? '<div class="mt-1"><i class="bi bi-check-circle-fill text-primary"></i></div>' : ''}
</div>
</div>`;
col.querySelector(".entity-pick-card").addEventListener("click", () => selectEntity(item, actualKind));
return col;
}
// ── select entity ─────────────────────────────────────────────────────────
function selectEntity(item, kind) {
hEntityType.value = kind;
hEntityNum.value = item.num;
hHardwareDeviceId.value = item.device_id || "";
document.querySelectorAll(".entity-pick-card").forEach(c => {
const mine = c.dataset.kind === kind && parseInt(c.dataset.num) === item.num;
c.classList.toggle("border-primary", mine);
c.classList.toggle("bg-primary", mine);
c.classList.toggle("bg-opacity-10", mine);
c.classList.toggle("border-transparent", !mine);
c.classList.remove("bg-secondary");
const ico = c.querySelector(".entity-card-icon");
if (ico) ico.style.color = mine ? "var(--bs-primary)" : "";
// show/hide checkmark
let ck = c.querySelector(".entity-check");
if (!ck && mine) {
ck = document.createElement("div");
ck.className = "entity-check mt-1";
ck.innerHTML = '<i class="bi bi-check-circle-fill text-primary"></i>';
c.querySelector(".card-body").appendChild(ck);
} else if (ck && !mine) {
ck.remove();
}
});
kind === "input" ? showInputTypes(item.type) : showRelayTypes(item.type);
// Auto-fill name if blank; also update app_id preview
const nameIn = document.getElementById("name");
if (!nameIn.value.trim()) {
nameIn.value = item.name;
if (!appIdManual) {
appIdField.value = makeSlug(item.name);
}
}
// Summary banner
const boardName = boardSel.options[boardSel.selectedIndex]?.dataset.name || "Board";
const hwIdLabel = item.device_id ? ` <span class="font-monospace text-secondary" style="font-size:.75rem">[${item.device_id}]</span>` : "";
bindSummaryTxt.innerHTML = `${boardName} · ${item.name}${hwIdLabel}`;
bindSummary.style.display = "flex";
}
unbindBtn.addEventListener("click", () => {
hEntityType.value = "";
hEntityNum.value = "";
hBoardId.value = "";
hHardwareDeviceId.value = "";
boardSel.value = "";
document.querySelectorAll(".entity-pick-card").forEach(c => {
c.classList.remove("border-primary", "bg-primary", "bg-opacity-10");
c.classList.add("border-transparent");
const ico = c.querySelector(".entity-card-icon");
if (ico) ico.style.color = "";
c.querySelector(".entity-check")?.remove();
});
relaySection.style.display = "none";
inputSection.style.display = "none";
relayGrid.innerHTML = "";
inputGrid.innerHTML = "";
entityPh.style.display = "";
bindSummary.style.display = "none";
});
// ── load board ────────────────────────────────────────────────────────────
function loadBoard(boardId) {
entityPh.style.display = "none";
relaySection.style.display = "none";
inputSection.style.display = "none";
bindSummary.style.display = "none";
relayGrid.innerHTML = "";
inputGrid.innerHTML = "";
if (!boardId) {
hBoardId.value = "";
entityPh.style.display = "";
return;
}
hBoardId.value = boardId;
entityLoading.style.display = "";
fetch(`/devices/api/boards/${boardId}/entities`)
.then(r => {
if (!r.ok) throw new Error(`Server returned ${r.status}`);
return r.json();
})
.then(data => {
entityLoading.style.display = "none";
// Rename section headings for gateway boards
if (data.board_type === "tuya_cloud" || data.board_type === "sonoff_ewelink") {
const label = data.board_type === "tuya_cloud" ? "Tuya Cloud Devices" : "Sonoff Devices";
const note = data.board_type === "tuya_cloud" ? " controllable via Tuya Cloud" : " controllable via eWeLink";
const relayTitle = document.querySelector("#relaySection .fw-semibold");
if (relayTitle) relayTitle.textContent = label;
const relayNote = document.querySelector("#relaySection .text-secondary.small");
if (relayNote) relayNote.textContent = note;
}
if (data.relays && data.relays.length) {
relayCount.textContent = data.relays.length;
data.relays.forEach(item => relayGrid.appendChild(makeCard(item, "relay")));
relaySection.style.display = "";
}
if (data.inputs && data.inputs.length) {
inputCount.textContent = data.inputs.length;
data.inputs.forEach(item => inputGrid.appendChild(makeCard(item, "input")));
inputSection.style.display = "";
}
if (!data.relays.length && !data.inputs.length) {
entityPh.innerHTML = `<i class="bi bi-exclamation-circle display-5 d-block mb-2 opacity-25"></i>
This board has no relay or input channels configured.`;
entityPh.style.display = "";
}
// Restore selection when editing
if (INIT.entityType && INIT.entityNum) {
const all = (INIT.entityType === "relay" || INIT.entityType === "tuya" || INIT.entityType === "sonoff") ? data.relays : data.inputs;
const match = all.find(i => i.num === INIT.entityNum);
if (match) selectEntity(match, INIT.entityType);
}
})
.catch(err => {
entityLoading.style.display = "none";
entityPh.innerHTML = `<i class="bi bi-exclamation-triangle text-warning me-2"></i>Failed to load board channels: ${err.message || err}`;
entityPh.style.display = "";
});
}
boardSel.addEventListener("change", () => {
hEntityType.value = "";
hEntityNum.value = "";
hHardwareDeviceId.value = "";
loadBoard(boardSel.value || null);
});
// ── init ─────────────────────────────────────────────────────────────────
if (INIT.entityType === "input") {
showInputTypes(INIT.devClass);
} else {
showRelayTypes(INIT.devClass);
}
updateIconPreview();
// Use INIT.boardId on the edit page; fall back to boardSel.value on the add
// page in case the browser silently restored a previous selection without
// firing a change event (common on page reload / back navigation).
const initialBoardId = INIT.boardId || boardSel.value || null;
if (initialBoardId) {
loadBoard(initialBoardId);
} else {
entityPh.style.display = "";
}
})();
</script>
<style>
.entity-pick-card:hover { background: var(--bs-secondary-bg) !important; transform: translateY(-2px); }
.border-transparent { border-color: transparent !important; }
</style>
{% endblock %}