work : v1.2.0 добавил web в отслеживание
This commit is contained in:
+261
@@ -0,0 +1,261 @@
|
||||
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();
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
import { api, 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 toneColor(tone) { return `var(--${tone === "bad" ? "red" : tone === "warn" ? "orange" : tone === "good" ? "green" : tone === "info" ? "blue" : "muted"})`; }
|
||||
|
||||
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);
|
||||
text("#co2", numberOrDash(sensor.co2));
|
||||
text("#air-label", quality.label);
|
||||
text("#air-advice", quality.tone === "good" ? "Проветривание работает нормально" : quality.tone === "warn" ? "Автоматика при необходимости повысит скорость" : quality.tone === "bad" ? "Рекомендуется усилить вентиляцию" : "Ожидаем показания датчика");
|
||||
$("#air-label").style.color = toneColor(quality.tone);
|
||||
$("#air-label").style.background = `var(--${quality.tone === "bad" ? "red" : quality.tone === "warn" ? "orange" : quality.tone === "good" ? "green" : "glass"}-soft)`;
|
||||
$("#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);
|
||||
@@ -0,0 +1,166 @@
|
||||
import { api, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, panelUrl, showToast } from "./api.js";
|
||||
|
||||
const requestedLayout = new URLSearchParams(window.location.search).get("layout");
|
||||
const layoutMode = ["horizontal", "vertical"].includes(requestedLayout) ? requestedLayout : null;
|
||||
const wideLayout = window.matchMedia("(min-width: 800px)");
|
||||
|
||||
function applyLayout() {
|
||||
document.documentElement.dataset.widgetLayout = layoutMode || (wideLayout.matches ? "horizontal" : "vertical");
|
||||
}
|
||||
|
||||
applyLayout();
|
||||
wideLayout.addEventListener("change", () => !layoutMode && applyLayout());
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const themeMode = configureTheme($("#theme-button"));
|
||||
let state = null;
|
||||
let busy = false;
|
||||
|
||||
if (isDemo) $("#demo-note").hidden = false;
|
||||
|
||||
function setText(selector, value) { $(selector).textContent = value; }
|
||||
function setPill(element, label, tone) { element.textContent = label; element.className = `pill ${tone}`; }
|
||||
function toneClass(tone) { return ["good", "warn", "bad"].includes(tone) ? tone : "muted"; }
|
||||
|
||||
function updateSpeedVisual(value) {
|
||||
const speed = Math.max(1, Math.min(6, Number(value) || 1));
|
||||
setText("#speed", speed);
|
||||
$("#speed").classList.remove("skeleton");
|
||||
document.querySelectorAll("[data-speed]").forEach(button => {
|
||||
const segment = Number(button.dataset.speed);
|
||||
button.classList.toggle("active", segment <= speed);
|
||||
button.setAttribute("aria-pressed", String(segment === speed));
|
||||
});
|
||||
}
|
||||
|
||||
function updateTemperatureVisual(value) {
|
||||
const temperature = Math.max(5, Math.min(30, Number(value) || 5));
|
||||
setText("#target-temp", temperature);
|
||||
$("#target-temp").classList.remove("skeleton");
|
||||
$("#temperature-range").value = temperature;
|
||||
}
|
||||
|
||||
function drawToggle(selector, active) {
|
||||
const button = $(selector);
|
||||
button.setAttribute("aria-pressed", String(Boolean(active)));
|
||||
button.textContent = active ? "ON" : "OFF";
|
||||
}
|
||||
|
||||
function render(status) {
|
||||
state = status;
|
||||
const tion = status.tion || {};
|
||||
const sensor = status.qingping || {};
|
||||
const schedule = status.schedule || {};
|
||||
const mode = modeInfo(status);
|
||||
const quality = airQuality(sensor.co2);
|
||||
const headerMode = schedule.override_active && schedule.override_until_time
|
||||
? `Ручной до ${schedule.override_until_time}`
|
||||
: mode.label;
|
||||
|
||||
setPill($("#connection"), status.online ? "Online" : "Offline", status.online ? "good" : "bad");
|
||||
setPill($("#mode-pill"), headerMode, mode.tone);
|
||||
setText("#mode-detail", mode.detail);
|
||||
|
||||
updateSpeedVisual(tion.fan_speed);
|
||||
setText("#speed-note", status.auto?.state === "active" && status.auto?.target_speed
|
||||
? `AUTO выбрал скорость ${status.auto.target_speed}`
|
||||
: `Ступень ${numberOrDash(tion.fan_speed)} из 6`);
|
||||
updateTemperatureVisual(tion.target_temp);
|
||||
setText("#heating-note", tion.heating ? "Сейчас нагревает входящий воздух" : tion.heater ? "Обогрев разрешён" : "Желаемая температура воздуха");
|
||||
|
||||
drawToggle("#power-toggle", tion.power);
|
||||
drawToggle("#heater-toggle", tion.heater);
|
||||
setText("#power-detail", tion.power ? "Устройство включено" : "Устройство выключено");
|
||||
setText("#heater-detail", tion.heating ? "Сейчас нагревает" : tion.heater ? "Автоматически" : "Выключен");
|
||||
|
||||
setText("#co2", numberOrDash(sensor.co2));
|
||||
setText("#air-label", quality.label);
|
||||
$("#air-label").className = `quality-chip ${toneClass(quality.tone)}`;
|
||||
$("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`;
|
||||
setText("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1));
|
||||
setText("#humidity", numberOrDash(sensor.humidity));
|
||||
setText("#current-time", schedule.current_time || tion.device_time || "—");
|
||||
setText("#next-time", schedule.next_time || "—");
|
||||
}
|
||||
|
||||
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", "");
|
||||
try { await api.post(path); await refresh(true); }
|
||||
catch (error) { showToast(error.message, true); await refresh(true); }
|
||||
finally { busy = false; source?.removeAttribute("disabled"); }
|
||||
}
|
||||
|
||||
function stepped(kind, delta, source) {
|
||||
if (busy || !state?.tion) 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) return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-step]").forEach(button => button.addEventListener("click", () => {
|
||||
stepped(button.dataset.step, Number(button.dataset.delta), button);
|
||||
}));
|
||||
|
||||
document.querySelectorAll("[data-speed]").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);
|
||||
}));
|
||||
|
||||
$("#temperature-range").addEventListener("input", event => setText("#target-temp", event.target.value));
|
||||
$("#temperature-range").addEventListener("change", event => {
|
||||
if (busy || !state?.tion) return;
|
||||
const value = Number(event.target.value);
|
||||
state.tion.target_temp = value;
|
||||
updateTemperatureVisual(value);
|
||||
act(`/api/tion/temperature/${value}`, event.target);
|
||||
});
|
||||
|
||||
$("#power-toggle").addEventListener("click", event => {
|
||||
if (busy || !state?.tion) return;
|
||||
const value = !state.tion.power;
|
||||
state.tion.power = value;
|
||||
drawToggle("#power-toggle", value);
|
||||
setText("#power-detail", value ? "Устройство включено" : "Устройство выключено");
|
||||
act(`/api/tion/power/${value ? "on" : "off"}`, event.currentTarget);
|
||||
});
|
||||
|
||||
$("#heater-toggle").addEventListener("click", event => {
|
||||
if (busy || !state?.tion) return;
|
||||
const value = !state.tion.heater;
|
||||
state.tion.heater = value;
|
||||
drawToggle("#heater-toggle", value);
|
||||
setText("#heater-detail", value ? "Автоматически" : "Выключен");
|
||||
act(`/api/tion/heater/${value ? "on" : "off"}`, event.currentTarget);
|
||||
});
|
||||
|
||||
$("#open-panel").addEventListener("click", () => window.open(panelUrl(themeMode()), "_blank", "noopener"));
|
||||
$("#air-link").addEventListener("click", () => window.open(panelUrl(themeMode(), "air"), "_blank", "noopener"));
|
||||
$("#schedule-link").addEventListener("click", () => window.open(panelUrl(themeMode(), "schedule"), "_blank", "noopener"));
|
||||
|
||||
refresh();
|
||||
setInterval(() => !document.hidden && !busy && refresh(true), 3000);
|
||||
Reference in New Issue
Block a user