Files
ClimatController/web/js/panel.js
T

477 lines
24 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.
import { api, airMeterPalette, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, setRangeProgress, showToast } from "./api.js";
const $ = selector => document.querySelector(selector);
configureTheme($("#theme-button"));
let state = null;
let busy = false;
let scheduleDraft = null;
let activeTemplate = null;
const weekdays = [
["mon", "Понедельник"],
["tue", "Вторник"],
["wed", "Среда"],
["thu", "Четверг"],
["fri", "Пятница"],
["sat", "Суббота"],
["sun", "Воскресенье"],
];
const templateNames = { workday: "Будний", weekend: "Выходной" };
const clone = value => JSON.parse(JSON.stringify(value));
function templateLabel(name) {
return templateNames[name] || name;
}
if (isDemo) $("#demo-note").hidden = false;
function text(selector, value) { $(selector).textContent = value; }
function setPill(selector, label, tone) { const element = $(selector); element.textContent = label; element.className = `pill ${tone}`; }
function toggle(selector, active, label = "") {
const button = $(selector);
button.setAttribute("aria-pressed", String(Boolean(active)));
button.textContent = `${label}${label ? " " : ""}${active ? "ON" : "OFF"}`;
}
function setRange(selector, value) {
const input = $(selector);
if (Number.isFinite(Number(value))) input.value = value;
setRangeProgress(input);
}
function updateSpeedVisual(value) {
const speed = Math.max(1, Math.min(6, Number(value) || 1));
text("#speed-value", speed);
setRange("#speed-range", speed);
document.querySelectorAll(".speed-segments button").forEach((segment, index) => {
segment.classList.toggle("active", index < speed);
segment.setAttribute("aria-pressed", String(index + 1 === speed));
});
}
function updateTemperatureVisual(value) {
const temperature = Math.max(5, Math.min(30, Number(value) || 5));
text("#temperature-value", temperature);
setRange("#temperature-range", temperature);
}
function actionLabel(value) {
return ({ set: "Настройка режима", auto: "Автоматический режим", off: "Выключение" })[value] || "Точка расписания";
}
function renderAir(sensor, tion) {
const quality = airQuality(sensor.co2);
const airTone = ["good", "warn", "bad"].includes(quality.tone) ? quality.tone : "muted";
const airPalette = airMeterPalette(sensor.co2);
const airCard = $(".co2-card");
text("#co2", numberOrDash(sensor.co2));
text("#air-label", quality.label);
text("#air-advice", quality.tone === "good" ? "Проветривание работает нормально" : quality.tone === "warn" ? "Автоматика при необходимости повысит скорость" : quality.tone === "bad" ? "Рекомендуется усилить вентиляцию" : "Ожидаем показания датчика");
$("#air-label").className = `quality-chip ${airTone}`;
airCard.dataset.airTone = airTone;
airCard.style.setProperty("--air-color", airPalette.color);
airCard.style.setProperty("--air-glow", airPalette.glow);
$("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`;
setPill("#sensor-status", sensor.online ? "Online" : "Offline", sensor.online ? "good" : "bad");
text("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1));
text("#humidity", numberOrDash(sensor.humidity));
text("#pm25", numberOrDash(sensor.pm25));
text("#pm10", numberOrDash(sensor.pm10));
const pm25 = Number(sensor.pm25);
const pm10 = Number(sensor.pm10);
text("#pm25-quality", Number.isFinite(pm25) ? (pm25 <= 15 ? "Отлично" : pm25 <= 35 ? "Норма" : "Повышено") : "—");
text("#pm10-quality", Number.isFinite(pm10) ? (pm10 <= 30 ? "Отлично" : pm10 <= 60 ? "Норма" : "Повышено") : "—");
}
function renderSchedule(status) {
const schedule = status.schedule || {};
const mode = modeInfo(status);
text("#current-time", schedule.current_time || status.tion?.device_time || "—");
text("#next-time", schedule.next_time || "—");
text("#current-action", actionLabel(schedule.current_action));
text("#next-action", actionLabel(schedule.next_action));
text("#mode-title", mode.label);
text("#mode-detail", mode.detail);
setPill("#schedule-state", schedule.paused ? "Пауза" : schedule.enabled ? "Активно" : "Выкл", schedule.paused ? "warn" : schedule.enabled ? "info" : "muted");
const headerMode = schedule.override_active && schedule.override_until_time
? `Ручной до ${schedule.override_until_time}`
: mode.label;
setPill("#mode-pill", headerMode, mode.tone);
$("#mode-banner").className = `mode-banner ${mode.tone === "info" ? "info" : mode.tone === "muted" ? "muted" : "warn"}`;
$("#auto-button").disabled = !schedule.available || (!schedule.override_active && !schedule.paused);
$("#pause-button strong").textContent = schedule.paused ? "Возобновить расписание" : "Пауза расписания";
$("#pause-button small").textContent = schedule.paused ? "Продолжить по расписанию" : "Временно приостановить";
}
function render(status) {
state = status;
const tion = status.tion || {};
const sensor = status.qingping || {};
setPill("#connection", status.online ? "Online" : "Offline", status.online ? "good" : "bad");
updateSpeedVisual(tion.fan_speed);
updateTemperatureVisual(tion.target_temp);
text("#speed-note", status.auto?.state === "active" && status.auto?.target_speed ? `AUTO выбрал скорость ${status.auto.target_speed}` : `Ступень ${numberOrDash(tion.fan_speed)} из 6`);
text("#temperature-note", tion.heating ? "Сейчас нагревает входящий воздух" : "Желаемая температура в помещении");
toggle("#power-toggle", tion.power);
toggle("#heater-toggle", tion.heater);
toggle("#sound-toggle", tion.sound, "Звук");
toggle("#light-toggle", tion.light, "Свет");
text("#power-caption", tion.power ? "Устройство включено" : "Устройство выключено");
text("#heater-caption", tion.heating ? "Сейчас нагревает воздух" : "Поддержание температуры");
$("#mode-outside").classList.toggle("active", tion.mode === "outside");
$("#mode-recirculation").classList.toggle("active", tion.mode === "recirculation");
text("#mode-name", tion.mode === "recirculation" ? "Рециркуляция" : "Приточная вентиляция");
text("#outside-temp", numberOrDash(tion.out_temp));
text("#filter-remain", numberOrDash(tion.filter_remain));
const filterPercent = Math.max(0, Math.min(100, Number(tion.filter_remain || 0) / 2));
$("#filter-progress").style.width = `${filterPercent}%`;
text("#filter-percent", `${Math.round(filterPercent)}%`);
text("#model", tion.model || "—");
text("#battery", numberOrDash(sensor.battery));
renderAir(sensor, tion);
renderSchedule(status);
text("#updated-at", `Обновлено ${new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`);
}
async function refresh(silent = false) {
try { render(await api.status()); }
catch (error) {
setPill("#connection", "Ошибка связи", "bad");
if (!silent) showToast(error.message, true);
}
}
async function act(path, source) {
if (busy) return;
busy = true;
source?.setAttribute("disabled", "");
source?.setAttribute("aria-busy", "true");
try { await api.post(path); await refresh(true); }
catch (error) { showToast(error.message, true); await refresh(true); }
finally { busy = false; source?.removeAttribute("disabled"); source?.removeAttribute("aria-busy"); }
}
function stepped(kind, delta, source) {
if (busy) return;
const isSpeed = kind === "speed";
const current = Number(isSpeed ? state?.tion?.fan_speed : state?.tion?.target_temp);
const min = isSpeed ? 1 : 5;
const max = isSpeed ? 6 : 30;
const value = Math.min(max, Math.max(min, current + delta));
if (Number.isFinite(value) && value !== current) {
if (isSpeed) {
state.tion.fan_speed = value;
updateSpeedVisual(value);
} else {
state.tion.target_temp = value;
updateTemperatureVisual(value);
}
act(`/api/tion/${isSpeed ? "speed" : "temperature"}/${value}`, source);
}
}
function optionList(values, current, emptyLabel = "Не менять") {
const items = emptyLabel === null ? [] : [["", emptyLabel]];
items.push(...values);
return items.map(([value, label]) => `<option value="${value}"${String(current ?? "") === String(value) ? " selected" : ""}>${label}</option>`).join("");
}
function booleanSetting(value) {
return value === true ? "on" : value === false ? "off" : "";
}
function pointMarkup(point, index) {
const action = point?.action || {};
const type = action.type === "auto" ? "auto" : "set";
const speedOptions = Array.from({ length: 6 }, (_, item) => [String(item + 1), String(item + 1)]);
const booleanOptions = [["on", "Включить"], ["off", "Выключить"]];
return `
<article class="point-editor ${type}" data-index="${index}">
<div class="point-header">
<span class="point-number">${index + 1}</span>
<label>Время<input data-field="time" type="time" value="${point?.time || ""}" required></label>
<label class="point-action">Действие<select data-field="type"><option value="set"${type === "set" ? " selected" : ""}>Задать параметры</option><option value="auto"${type === "auto" ? " selected" : ""}>AUTO по воздуху</option></select></label>
<button class="remove-point" data-remove-point="${index}" type="button" aria-label="Удалить точку">×</button>
</div>
<div class="point-settings">
<label class="point-setting">Скорость<select data-setting="speed">${optionList(speedOptions, action.speed, "Не менять")}</select></label>
<label class="point-setting">Температура<input data-setting="target_temp" type="number" min="5" max="30" step="1" value="${action.target_temp ?? ""}" placeholder="—"></label>
<label class="point-setting" data-set-only>Питание<select data-setting="power">${optionList(booleanOptions, booleanSetting(action.power))}</select></label>
<label class="point-setting" data-set-only>Обогрев<select data-setting="heater">${optionList(booleanOptions, booleanSetting(action.heater))}</select></label>
<label class="point-setting" data-set-only>Воздух<select data-setting="mode">${optionList([["outside", "С улицы"], ["recirculation", "Рециркуляция"]], action.mode)}</select></label>
<label class="point-setting" data-set-only>Звук<select data-setting="sound">${optionList(booleanOptions, booleanSetting(action.sound))}</select></label>
<label class="point-setting" data-set-only>Подсветка<select data-setting="light">${optionList(booleanOptions, booleanSetting(action.light))}</select></label>
</div>
</article>`;
}
function readRenderedPoints(strict = false) {
const points = [...document.querySelectorAll(".point-editor")].map(card => {
const time = card.querySelector('[data-field="time"]').value;
const type = card.querySelector('[data-field="type"]').value;
const action = { type };
card.querySelectorAll("[data-setting]").forEach(input => {
const field = input.dataset.setting;
const raw = input.value;
if (raw === "" || (type === "auto" && !["speed", "target_temp"].includes(field))) return;
if (["speed", "target_temp"].includes(field)) action[field] = Number(raw);
else if (["power", "heater", "sound", "light"].includes(field)) action[field] = raw === "on";
else action[field] = raw;
});
return { time, action };
});
if (strict) validateTemplate(activeTemplate, points);
return points;
}
function validateTemplate(name, points) {
const label = templateLabel(name);
if (!Array.isArray(points) || !points.length) throw new Error(`Шаблон «${label}» не может быть пустым`);
const times = new Set();
points.forEach((point, index) => {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(point.time || "")) throw new Error(`Проверьте время в точке ${index + 1} шаблона «${label}»`);
if (times.has(point.time)) throw new Error(`В шаблоне «${label}» время ${point.time} указано дважды`);
times.add(point.time);
const action = point.action || {};
if (!["set", "auto"].includes(action.type)) throw new Error(`Неизвестное действие в шаблоне «${label}»`);
if (action.speed !== undefined && (!Number.isInteger(action.speed) || action.speed < 1 || action.speed > 6)) throw new Error(`Скорость в шаблоне «${label}» должна быть от 1 до 6`);
if (action.target_temp !== undefined && (!Number.isInteger(action.target_temp) || action.target_temp < 5 || action.target_temp > 30)) throw new Error(`Температура в шаблоне «${label}» должна быть от 5 до 30°`);
if (action.type === "auto" && action.speed === undefined) throw new Error(`Для AUTO в шаблоне «${label}» укажите базовую скорость`);
if (action.type === "set" && Object.keys(action).length === 1) throw new Error(`В точке ${point.time} шаблона «${label}» не выбран ни один параметр`);
});
}
function validateScheduleDraft() {
const names = Object.keys(scheduleDraft?.templates || {});
if (!names.length) throw new Error("В расписании должен быть хотя бы один шаблон");
names.forEach(name => validateTemplate(name, scheduleDraft.templates[name]));
weekdays.forEach(([day, label]) => {
if (!names.includes(scheduleDraft.days?.[day])) throw new Error(`Для дня «${label}» не выбран шаблон`);
});
}
function commitActiveTemplate(strict = false) {
if (scheduleDraft && activeTemplate) scheduleDraft.templates[activeTemplate] = readRenderedPoints(strict);
}
function renderTemplateUsage() {
const usedBy = weekdays.filter(([day]) => scheduleDraft.days?.[day] === activeTemplate).map(([, label]) => label.toLowerCase());
text("#template-usage", usedBy.length ? `Используют: ${usedBy.join(", ")}` : "Пока не назначен ни одному дню");
}
function renderTemplateSelector() {
const select = $("#template-select");
select.replaceChildren(...Object.keys(scheduleDraft.templates).map(name => new Option(templateLabel(name), name, false, name === activeTemplate)));
$("#delete-template").disabled = Object.keys(scheduleDraft.templates).length <= 1;
}
function renderDayAssignments() {
const host = $("#day-assignments");
const templateNames = Object.keys(scheduleDraft.templates);
host.replaceChildren(...weekdays.map(([day, label]) => {
const wrapper = document.createElement("label");
wrapper.className = "day-assignment";
wrapper.textContent = label;
const select = document.createElement("select");
select.dataset.day = day;
select.replaceChildren(...templateNames.map(name => new Option(templateLabel(name), name, false, scheduleDraft.days?.[day] === name)));
wrapper.append(select);
return wrapper;
}));
}
function renderSchedulePoints() {
const points = scheduleDraft.templates[activeTemplate] || [];
$("#schedule-points").innerHTML = points.length
? points.map(pointMarkup).join("")
: '<div class="empty-points">В этом шаблоне пока нет точек</div>';
renderTemplateUsage();
}
function setEditorStatus(message = "", error = false) {
const status = $("#schedule-editor-status");
status.textContent = message;
status.classList.toggle("error", error);
}
async function openScheduleEditor() {
const source = $("#schedule-edit-button");
source.disabled = true;
source.setAttribute("aria-busy", "true");
try {
scheduleDraft = clone(await api.scheduleConfig());
const names = Object.keys(scheduleDraft.templates || {});
if (!names.length) throw new Error("В расписании нет шаблонов");
const dayKeys = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
activeTemplate = scheduleDraft.days?.[dayKeys[new Date().getDay()]] || names[0];
$("#schedule-enabled").checked = scheduleDraft.enabled !== false;
renderTemplateSelector();
renderDayAssignments();
renderSchedulePoints();
setEditorStatus();
$("#schedule-dialog").showModal();
} catch (error) {
showToast(`Не удалось открыть редактор: ${error.message}`, true);
} finally {
source.disabled = false;
source.removeAttribute("aria-busy");
}
}
function closeScheduleEditor() {
$("#schedule-dialog").close();
scheduleDraft = null;
activeTemplate = null;
}
async function saveScheduleEditor(event) {
event.preventDefault();
if (!scheduleDraft) return;
const saveButton = $("#schedule-save-button");
try {
commitActiveTemplate(true);
scheduleDraft.enabled = $("#schedule-enabled").checked;
validateScheduleDraft();
Object.values(scheduleDraft.templates).forEach(points => points.sort((left, right) => left.time.localeCompare(right.time)));
setEditorStatus("Сохраняем…");
saveButton.disabled = true;
saveButton.setAttribute("aria-busy", "true");
const result = await api.saveScheduleConfig(scheduleDraft);
$("#schedule-dialog").close();
scheduleDraft = null;
activeTemplate = null;
showToast(result.restart_required ? "Расписание сохранено. Перезапустите сервис для применения" : "Расписание сохранено и применено");
await refresh(true);
} catch (error) {
setEditorStatus(error.message, true);
} finally {
saveButton.disabled = false;
saveButton.removeAttribute("aria-busy");
}
}
document.querySelectorAll("[data-step]").forEach(button => button.addEventListener("click", () => stepped(button.dataset.step, Number(button.dataset.delta), button)));
document.querySelectorAll(".speed-segments button").forEach(button => button.addEventListener("click", () => {
if (busy || !state?.tion) return;
const value = Number(button.dataset.speed);
state.tion.fan_speed = value;
updateSpeedVisual(value);
act(`/api/tion/speed/${value}`, button);
}));
$("#speed-range").addEventListener("input", event => updateSpeedVisual(event.target.value));
$("#speed-range").addEventListener("change", event => act(`/api/tion/speed/${event.target.value}`, event.target));
$("#temperature-range").addEventListener("input", event => { setRangeProgress(event.target); text("#temperature-value", event.target.value); });
$("#temperature-range").addEventListener("change", event => act(`/api/tion/temperature/${event.target.value}`, event.target));
$("#power-toggle").addEventListener("click", event => act(`/api/tion/power/${state?.tion?.power ? "off" : "on"}`, event.currentTarget));
$("#heater-toggle").addEventListener("click", event => act(`/api/tion/heater/${state?.tion?.heater ? "off" : "on"}`, event.currentTarget));
$("#sound-toggle").addEventListener("click", event => act(`/api/tion/sound/${state?.tion?.sound ? "off" : "on"}`, event.currentTarget));
$("#light-toggle").addEventListener("click", event => act(`/api/tion/light/${state?.tion?.light ? "off" : "on"}`, event.currentTarget));
$("#mode-outside").addEventListener("click", event => act("/api/tion/mode/outside", event.currentTarget));
$("#mode-recirculation").addEventListener("click", event => act("/api/tion/mode/recirculation", event.currentTarget));
$("#auto-button").addEventListener("click", event => act(state?.schedule?.paused ? "/api/schedule/resume" : "/api/schedule/override/clear", event.currentTarget));
$("#pause-button").addEventListener("click", event => act(state?.schedule?.paused ? "/api/schedule/resume" : "/api/schedule/pause", event.currentTarget));
$("#refresh-button").addEventListener("click", event => { event.currentTarget.animate([{ transform: "rotate(0)" }, { transform: "rotate(360deg)" }], { duration: 450 }); refresh(); });
$("#schedule-edit-button").addEventListener("click", openScheduleEditor);
$("#schedule-close-button").addEventListener("click", closeScheduleEditor);
$("#schedule-cancel-button").addEventListener("click", closeScheduleEditor);
$("#schedule-form").addEventListener("submit", saveScheduleEditor);
$("#schedule-dialog").addEventListener("close", () => { scheduleDraft = null; activeTemplate = null; });
$("#template-select").addEventListener("change", event => {
if (!scheduleDraft) return;
commitActiveTemplate(false);
activeTemplate = event.target.value;
renderSchedulePoints();
});
$("#duplicate-template").addEventListener("click", () => {
if (!scheduleDraft || !activeTemplate) return;
commitActiveTemplate(false);
const base = `${activeTemplate}-copy`;
let name = base;
let suffix = 2;
while (scheduleDraft.templates[name]) name = `${base}-${suffix++}`;
scheduleDraft.templates[name] = clone(scheduleDraft.templates[activeTemplate]);
activeTemplate = name;
renderTemplateSelector();
renderDayAssignments();
renderSchedulePoints();
setEditorStatus(`Создан шаблон «${templateLabel(name)}». Назначьте его нужным дням`);
});
$("#delete-template").addEventListener("click", () => {
if (!scheduleDraft || Object.keys(scheduleDraft.templates).length <= 1) return;
if (!window.confirm(`Удалить шаблон «${templateLabel(activeTemplate)}»?`)) return;
const removed = activeTemplate;
delete scheduleDraft.templates[removed];
activeTemplate = Object.keys(scheduleDraft.templates)[0];
weekdays.forEach(([day]) => {
if (scheduleDraft.days[day] === removed) scheduleDraft.days[day] = activeTemplate;
});
renderTemplateSelector();
renderDayAssignments();
renderSchedulePoints();
setEditorStatus(`Шаблон «${templateLabel(removed)}» удалён`);
});
$("#day-assignments").addEventListener("change", event => {
const select = event.target.closest("[data-day]");
if (!select || !scheduleDraft) return;
scheduleDraft.days[select.dataset.day] = select.value;
renderTemplateUsage();
});
$("#schedule-points").addEventListener("change", event => {
if (event.target.dataset.field !== "type") return;
const card = event.target.closest(".point-editor");
const isAuto = event.target.value === "auto";
card.classList.toggle("auto", isAuto);
card.classList.toggle("set", !isAuto);
const speed = card.querySelector('[data-setting="speed"]');
if (isAuto && !speed.value) speed.value = "2";
});
$("#schedule-points").addEventListener("click", event => {
const button = event.target.closest("[data-remove-point]");
if (!button || !scheduleDraft) return;
commitActiveTemplate(false);
scheduleDraft.templates[activeTemplate].splice(Number(button.dataset.removePoint), 1);
renderSchedulePoints();
});
$("#add-schedule-point").addEventListener("click", () => {
if (!scheduleDraft) return;
commitActiveTemplate(false);
const points = scheduleDraft.templates[activeTemplate];
const used = new Set(points.map(point => point.time));
const existingTimes = points.map(point => {
const [hours, minute] = String(point.time || "").split(":").map(Number);
return hours * 60 + minute;
}).filter(Number.isFinite);
let minutes = existingTimes.length ? (Math.max(...existingTimes) + 60) % 1440 : 8 * 60;
for (let attempt = 0; attempt < 48; attempt += 1) {
const candidate = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
if (!used.has(candidate)) {
points.push({ time: candidate, action: { type: "set", speed: 2 } });
break;
}
minutes = (minutes + 30) % 1440;
}
points.sort((left, right) => left.time.localeCompare(right.time));
renderSchedulePoints();
$("#schedule-points .point-editor:last-of-type")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
});
setRangeProgress($("#speed-range"));
setRangeProgress($("#temperature-range"));
refresh();
setInterval(() => !document.hidden && !busy && refresh(true), 3000);