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]) => ``).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 `
${index + 1}
`; } 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("") : '
В этом шаблоне пока нет точек
'; 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);