304 lines
10 KiB
JavaScript
304 lines
10 KiB
JavaScript
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) };
|
|
}
|
|
|
|
const AIR_METER_STOPS = [
|
|
[400, [35, 143, 83]],
|
|
[700, [46, 166, 99]],
|
|
[820, [82, 181, 111]],
|
|
[900, [190, 153, 55]],
|
|
[1200, [214, 128, 45]],
|
|
[1500, [215, 83, 55]],
|
|
[2000, [188, 48, 72]],
|
|
];
|
|
|
|
const interpolateChannel = (start, end, amount) => Math.round(start + (end - start) * amount);
|
|
|
|
export function airMeterPalette(co2) {
|
|
const value = Number(co2);
|
|
if (!Number.isFinite(value)) {
|
|
return {
|
|
color: "rgb(105, 137, 158)",
|
|
glow: "rgba(83, 123, 150, .2)",
|
|
};
|
|
}
|
|
|
|
const bounded = Math.max(AIR_METER_STOPS[0][0], Math.min(AIR_METER_STOPS.at(-1)[0], value));
|
|
let lower = AIR_METER_STOPS[0];
|
|
let upper = AIR_METER_STOPS.at(-1);
|
|
|
|
for (let index = 1; index < AIR_METER_STOPS.length; index += 1) {
|
|
if (bounded <= AIR_METER_STOPS[index][0]) {
|
|
lower = AIR_METER_STOPS[index - 1];
|
|
upper = AIR_METER_STOPS[index];
|
|
break;
|
|
}
|
|
}
|
|
|
|
const amount = upper[0] === lower[0] ? 0 : (bounded - lower[0]) / (upper[0] - lower[0]);
|
|
const [red, green, blue] = lower[1].map((channel, index) => interpolateChannel(channel, upper[1][index], amount));
|
|
|
|
return {
|
|
color: `rgb(${red}, ${green}, ${blue})`,
|
|
glow: `rgba(${red}, ${green}, ${blue}, .26)`,
|
|
};
|
|
}
|
|
|
|
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();
|
|
}
|