const query = new URLSearchParams(window.location.search); export const isDemo = query.get("demo") === "1"; const demoState = { online: true, running: true, last_seen: new Date().toISOString(), last_error: null, tion: { power: true, heater: true, heating: false, sound: true, mode: "outside", out_temp: 21, in_temp: 20, target_temp: 21, fan_speed: 3, filter_remain: 136.7, device_time: "22:45", request_error_code: 0, model: "S4", light: true, }, auto: { available: true, state: "inactive", reason: null, target_speed: null, auto_speed: null, target_heater: null, auto_heater: null, temperature: 25.9, temperature_source: "qingping", last_error: null, config_error: null, }, qingping: { online: true, temperature: 25.9, humidity: 69.4, co2: 780, pm25: 0, pm10: 0, battery: 100, last_error: null, }, schedule: { available: true, enabled: true, running: true, paused: false, current_action: "set", current_time: "22:45", next_time: "10:00", next_action: "set", override_until_time: "10:00", auto_active: false, auto_fallback_speed: 2, auto_target_temp: 21, scheduled_settings: { speed: 2, target_temp: 21, heater: true }, override_active: true, override_until: new Date(Date.now() + 8 * 3600_000).toISOString(), override_settings: { speed: 3 }, last_error: null, }, }; const demoScheduleConfig = { version: 1, enabled: true, timezone: "local", templates: { workday: [ { time: "07:30", action: { type: "set", power: true, speed: 3, heater: true, target_temp: 21 } }, { time: "09:00", action: { type: "auto", speed: 2, target_temp: 21 } }, { time: "22:45", action: { type: "set", speed: 1 } }, ], weekend: [ { time: "09:30", action: { type: "set", power: true, speed: 3, heater: true, target_temp: 21 } }, { time: "11:00", action: { type: "auto", speed: 2, target_temp: 21 } }, { time: "23:00", action: { type: "set", speed: 1 } }, ], }, days: { mon: "workday", tue: "workday", wed: "workday", thu: "workday", fri: "workday", sat: "weekend", sun: "weekend" }, }; const copy = value => JSON.parse(JSON.stringify(value)); const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); function mutateDemo(path) { const tion = demoState.tion; const schedule = demoState.schedule; let match; if ((match = path.match(/^\/api\/tion\/speed\/(\d)$/))) { tion.fan_speed = Number(match[1]); schedule.override_active = true; schedule.override_until_time ||= schedule.next_time; schedule.override_settings.speed = tion.fan_speed; } else if ((match = path.match(/^\/api\/tion\/temperature\/(\d+)$/))) { tion.target_temp = Number(match[1]); schedule.override_active = true; schedule.override_until_time ||= schedule.next_time; schedule.override_settings.target_temp = tion.target_temp; } else if ((match = path.match(/^\/api\/tion\/(power|heater|sound|light)\/(on|off)$/))) { tion[match[1]] = match[2] === "on"; schedule.override_active = true; schedule.override_settings[match[1]] = tion[match[1]]; } else if ((match = path.match(/^\/api\/tion\/mode\/(outside|recirculation)$/))) { tion.mode = match[1]; schedule.override_active = true; schedule.override_settings.mode = match[1]; } else if (path === "/api/schedule/override/clear") { schedule.override_active = false; schedule.override_until = null; schedule.override_until_time = null; schedule.override_settings = {}; } else if (path === "/api/schedule/pause") { schedule.paused = true; schedule.override_active = false; schedule.override_until_time = null; } else if (path === "/api/schedule/resume") { schedule.paused = false; } demoState.last_seen = new Date().toISOString(); } async function request(path, options = {}) { if (isDemo) { await wait(options.method === "POST" ? 220 : 80); if (path === "/api/schedule/config") { if (options.method === "PUT") { const replacement = JSON.parse(options.body); Object.keys(demoScheduleConfig).forEach(key => delete demoScheduleConfig[key]); Object.assign(demoScheduleConfig, replacement); return { ok: true, config: copy(demoScheduleConfig) }; } return copy(demoScheduleConfig); } if (options.method === "POST") mutateDemo(path); return copy(demoState); } const response = await fetch(path, { cache: "no-store", headers: { Accept: "application/json" }, ...options, }); if (!response.ok) { let message = `Ошибка ${response.status}`; try { const body = await response.json(); if (body.detail && typeof body.detail === "object") { message = [body.detail.message, body.detail.error].filter(Boolean).join(": ") || message; } else { message = body.detail || body.message || message; } } catch (_) {} throw new Error(typeof message === "string" ? message : JSON.stringify(message)); } return response.json(); } export const api = { status: () => request("/api/status"), post: path => request(path, { method: "POST" }), scheduleConfig: () => request("/api/schedule/config"), saveScheduleConfig: config => request("/api/schedule/config", { method: "PUT", headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify(config), }), }; export function configureTheme(button) { const allowed = ["auto", "light", "dark"]; const explicit = query.get("theme"); let mode = allowed.includes(explicit) ? explicit : (localStorage.getItem("tion-theme") || "auto"); const apply = () => { const dark = mode === "dark" || (mode === "auto" && matchMedia("(prefers-color-scheme: dark)").matches); document.documentElement.dataset.theme = dark ? "dark" : "light"; document.documentElement.dataset.themeMode = mode; if (button) { button.title = `Тема: ${mode === "auto" ? "системная" : mode === "dark" ? "тёмная" : "светлая"}`; button.setAttribute("aria-label", button.title); } }; apply(); matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => mode === "auto" && apply()); if (button) button.addEventListener("click", () => { mode = allowed[(allowed.indexOf(mode) + 1) % allowed.length]; localStorage.setItem("tion-theme", mode); apply(); }); return () => mode; } export function setRangeProgress(input) { const min = Number(input.min); const max = Number(input.max); const value = Number(input.value); input.style.setProperty("--progress", `${((value - min) / (max - min)) * 100}%`); } export function airQuality(co2) { if (!Number.isFinite(Number(co2))) return { label: "Нет данных", tone: "muted", pct: 0 }; const value = Number(co2); if (value < 700) return { label: "Отличный воздух", tone: "good", pct: value / 20 }; if (value < 900) return { label: "Хороший воздух", tone: "good", pct: value / 20 }; if (value < 1300) return { label: "Повышенный CO₂", tone: "warn", pct: value / 20 }; if (value < 1600) return { label: "Душно", tone: "warn", pct: value / 20 }; return { label: "Нужна вентиляция", tone: "bad", pct: Math.min(100, value / 20) }; } export function modeInfo(status) { const schedule = status?.schedule || {}; if (!schedule.available) return { label: "Без расписания", detail: "Недоступно", tone: "muted" }; if (schedule.paused) return { label: "Ручной режим", detail: "Расписание на паузе", tone: "warn" }; if (schedule.override_active) return { label: "Ручное управление", detail: schedule.override_until_time ? `До ${schedule.override_until_time}` : "До следующей точки", tone: "warn", }; if (schedule.auto_active) return { label: "AUTO", detail: "По качеству воздуха", tone: "info" }; return { label: "Расписание", detail: "Активно", tone: "info" }; } export const numberOrDash = (value, digits = 0) => { const number = Number(value); return Number.isFinite(number) ? number.toFixed(digits) : "—"; }; export function showToast(message, error = false) { let region = document.querySelector(".toast-region"); if (!region) { region = document.createElement("div"); region.className = "toast-region"; document.body.append(region); } const toast = document.createElement("div"); toast.className = `toast${error ? " error" : ""}`; toast.textContent = message; region.append(toast); setTimeout(() => toast.remove(), 3200); } export function panelUrl(themeMode = "auto", hash = "") { const url = new URL("/ui/panel", window.location.origin); url.searchParams.set("theme", themeMode); if (isDemo) url.searchParams.set("demo", "1"); url.hash = hash; return url.toString(); }