1641 lines
53 KiB
JavaScript
1641 lines
53 KiB
JavaScript
const SENSOR_COUNT = 16;
|
||
const VALVE_COUNT = 32;
|
||
const DEFAULT_OPEN_DEGREES_MAX = 90;
|
||
const CHANNEL_LOCATIONS = [
|
||
"DUO прав",
|
||
"DUO лев",
|
||
"TRIO",
|
||
"SOLO",
|
||
"ОСНОВА 7",
|
||
"ОСНОВА 6",
|
||
"ОСНОВА 5",
|
||
"ОСНОВА 4",
|
||
"ОСНОВА 3",
|
||
"ОСНОВА 2",
|
||
"ОСНОВА 1",
|
||
];
|
||
|
||
function defaultChannelLocation(index) {
|
||
return CHANNEL_LOCATIONS[index % CHANNEL_LOCATIONS.length];
|
||
}
|
||
|
||
function makeDefaultSensors() {
|
||
return Array.from({ length: SENSOR_COUNT }, (_, index) => {
|
||
const number = index + 1;
|
||
return {
|
||
id: `zone_${number}`,
|
||
name: `Датчик ${number}`,
|
||
value: 0,
|
||
setpoint: 28,
|
||
unit: "°C",
|
||
zone: String(number),
|
||
location: defaultChannelLocation(index),
|
||
ds18b20Id: `28-00-00-00-00-00-00-${number.toString(16).toUpperCase().padStart(2, "0")}`,
|
||
};
|
||
});
|
||
}
|
||
|
||
function makeDefaultValves() {
|
||
return Array.from({ length: VALVE_COUNT }, (_, index) => {
|
||
const number = index + 1;
|
||
const zone = (index % SENSOR_COUNT) + 1;
|
||
return {
|
||
id: `valve_${number}`,
|
||
name: `Клапан ${number}`,
|
||
zone: String(zone),
|
||
mode: "auto",
|
||
position: 0,
|
||
targetTemp: 28,
|
||
isOpen: false,
|
||
openDegrees: 0,
|
||
openDegreesMax: DEFAULT_OPEN_DEGREES_MAX,
|
||
};
|
||
});
|
||
}
|
||
|
||
const defaultSensors = makeDefaultSensors();
|
||
const defaultValves = makeDefaultValves();
|
||
|
||
const API_PATHS = {
|
||
sensorsRead: ["/api/sensors", "/sensors", "/api/data", "/state"],
|
||
valvesRead: ["/api/valves", "/valves", "/api/data", "/state"],
|
||
sensorWrite: [
|
||
(id) => `/api/sensors/${encodeURIComponent(id)}`,
|
||
(id) => `/sensors/${encodeURIComponent(id)}`,
|
||
() => "/api/sensors",
|
||
() => "/sensors",
|
||
],
|
||
valveWrite: [
|
||
(id) => `/api/valves/${encodeURIComponent(id)}`,
|
||
(id) => `/valves/${encodeURIComponent(id)}`,
|
||
() => "/api/valves",
|
||
() => "/valves",
|
||
],
|
||
valveCalibrate: [
|
||
(id) => `/api/valves/${encodeURIComponent(id)}/calibrate`,
|
||
(id) => `/valves/${encodeURIComponent(id)}/calibrate`,
|
||
],
|
||
valvesCalibrateAll: ["/api/valves/calibrate-all", "/api/calibration/all", "/calibration/all"],
|
||
};
|
||
|
||
const SERIAL_API_PATHS = {
|
||
ports: [
|
||
"/api/serial/ports",
|
||
"/api/ports",
|
||
"/ports",
|
||
"/serial/ports",
|
||
"/status/ports",
|
||
],
|
||
status: [
|
||
"/api/serial/status",
|
||
"/api/state",
|
||
"/state",
|
||
"/serial/status",
|
||
],
|
||
connect: [
|
||
"/api/serial/connect",
|
||
"/api/connect",
|
||
"/connect",
|
||
"/serial/connect",
|
||
],
|
||
disconnect: [
|
||
"/api/serial/disconnect",
|
||
"/api/disconnect",
|
||
"/disconnect",
|
||
"/serial/disconnect",
|
||
],
|
||
};
|
||
|
||
const state = {
|
||
sensors: [...defaultSensors],
|
||
valves: [...defaultValves],
|
||
apiBase: "",
|
||
timer: null,
|
||
};
|
||
|
||
let serialConnected = false;
|
||
let selectedPort = "";
|
||
|
||
const sensorsEl = document.getElementById("sensors");
|
||
const valvesEl = document.getElementById("valves");
|
||
const statusEl = document.getElementById("status");
|
||
const globalStatus = document.getElementById("globalStatus");
|
||
const apiInput = document.getElementById("apiBase");
|
||
const refreshBtn = document.getElementById("refreshBtn");
|
||
const saveApiBtn = document.getElementById("saveApiBtn");
|
||
const refreshPortsBtn = document.getElementById("refreshPortsBtn");
|
||
const connectPortBtn = document.getElementById("connectPortBtn");
|
||
const comPortSelect = document.getElementById("comPortSelect");
|
||
const modbusTransport = document.getElementById("modbusTransport");
|
||
const tcpHost = document.getElementById("tcpHost");
|
||
const tcpPort = document.getElementById("tcpPort");
|
||
const modbusSlaveId = document.getElementById("modbusSlaveId");
|
||
const serialStatus = document.getElementById("serialStatus");
|
||
const calibrateAllBtn = document.getElementById("calibrateAllBtn");
|
||
|
||
function clamp(value, min, max) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
function parseGuiNumber(value, fallback = 0) {
|
||
const parsed = Number(String(value ?? "").replace(",", "."));
|
||
return Number.isFinite(parsed) ? parsed : fallback;
|
||
}
|
||
|
||
function storageGet(name, fallback) {
|
||
const raw = localStorage.getItem(name);
|
||
if (!raw) return fallback;
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function storageSet(name, value) {
|
||
localStorage.setItem(name, JSON.stringify(value));
|
||
}
|
||
|
||
function getModbusTransport() {
|
||
return (modbusTransport?.value || storageGet("modbusTransport", "rtu") || "rtu").toLowerCase();
|
||
}
|
||
|
||
function applyModbusTransportView() {
|
||
const transport = getModbusTransport();
|
||
const isTcp = transport === "tcp";
|
||
|
||
document.querySelectorAll(".rtu-field").forEach((element) => {
|
||
element.classList.toggle("hidden", isTcp);
|
||
});
|
||
document.querySelectorAll(".tcp-field").forEach((element) => {
|
||
element.classList.toggle("hidden", !isTcp);
|
||
});
|
||
|
||
if (refreshPortsBtn) {
|
||
refreshPortsBtn.disabled = isTcp;
|
||
}
|
||
if (serialStatus && !serialConnected) {
|
||
serialStatus.textContent = isTcp ? "Modbus TCP: не подключен" : "COM: не подключен";
|
||
}
|
||
}
|
||
|
||
function initModbusTransportControls() {
|
||
if (modbusTransport) {
|
||
modbusTransport.value = storageGet("modbusTransport", "rtu");
|
||
modbusTransport.addEventListener("change", () => {
|
||
storageSet("modbusTransport", modbusTransport.value);
|
||
applyModbusTransportView();
|
||
});
|
||
}
|
||
if (tcpHost) {
|
||
tcpHost.value = storageGet("tcpHost", tcpHost.value || "192.168.0.10");
|
||
tcpHost.addEventListener("change", () => storageSet("tcpHost", tcpHost.value.trim()));
|
||
}
|
||
if (tcpPort) {
|
||
tcpPort.value = storageGet("tcpPort", tcpPort.value || "502");
|
||
tcpPort.addEventListener("change", () => storageSet("tcpPort", tcpPort.value || "502"));
|
||
}
|
||
if (modbusSlaveId) {
|
||
modbusSlaveId.value = storageGet("modbusSlaveId", modbusSlaveId.value || "3");
|
||
modbusSlaveId.addEventListener("change", () => storageSet("modbusSlaveId", modbusSlaveId.value || "3"));
|
||
}
|
||
applyModbusTransportView();
|
||
}
|
||
|
||
function normalizeApiUrl(value) {
|
||
const trimmed = (value || "").trim();
|
||
if (trimmed && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||
return normalizeApiUrl(`http://${trimmed}`);
|
||
}
|
||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||
try {
|
||
const parsed = new URL(trimmed);
|
||
return `${parsed.protocol}//${parsed.host}`;
|
||
} catch {
|
||
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
||
}
|
||
}
|
||
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
||
}
|
||
|
||
function ensureApiBase() {
|
||
if (state.apiBase) {
|
||
return state.apiBase;
|
||
}
|
||
if (window.location?.protocol?.startsWith("http") && window.location.host) {
|
||
state.apiBase = `${window.location.protocol}//${window.location.host}`;
|
||
} else {
|
||
state.apiBase = "http://127.0.0.1:8080";
|
||
}
|
||
if (apiInput) {
|
||
apiInput.value = state.apiBase;
|
||
}
|
||
storageSet("apiBase", state.apiBase);
|
||
return state.apiBase;
|
||
}
|
||
|
||
function endpoint(path) {
|
||
if (!state.apiBase) {
|
||
throw new Error("demo");
|
||
}
|
||
return `${state.apiBase}${path}`;
|
||
}
|
||
|
||
async function parseResponseBody(response) {
|
||
const body = await response.text();
|
||
if (!body) {
|
||
return {};
|
||
}
|
||
try {
|
||
return JSON.parse(body);
|
||
} catch {
|
||
return body;
|
||
}
|
||
}
|
||
|
||
async function apiGet(path) {
|
||
const response = await fetch(endpoint(path), {
|
||
method: "GET",
|
||
headers: { accept: "application/json" },
|
||
});
|
||
if (!response.ok) {
|
||
const body = await response.text();
|
||
throw new Error(`HTTP ${response.status}: ${body}`);
|
||
}
|
||
return parseResponseBody(response);
|
||
}
|
||
|
||
async function apiPut(path, payload) {
|
||
const response = await fetch(endpoint(path), {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json", accept: "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!response.ok) {
|
||
const body = await response.text();
|
||
throw new Error(`HTTP ${response.status}: ${body}`);
|
||
}
|
||
return parseResponseBody(response);
|
||
}
|
||
|
||
async function apiPost(path, payload) {
|
||
const response = await fetch(endpoint(path), {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", accept: "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!response.ok) {
|
||
const body = await response.text();
|
||
throw new Error(`HTTP ${response.status}: ${body}`);
|
||
}
|
||
return parseResponseBody(response);
|
||
}
|
||
|
||
function extractPorts(payload) {
|
||
if (Array.isArray(payload)) return payload;
|
||
if (Array.isArray(payload?.ports)) return payload.ports;
|
||
return [];
|
||
}
|
||
|
||
function extractSelectedPort(payload) {
|
||
if (typeof payload?.selected === "string") return payload.selected;
|
||
if (typeof payload?.port === "string") return payload.port;
|
||
return "";
|
||
}
|
||
|
||
async function apiGetFallback(paths) {
|
||
let lastError;
|
||
for (const path of paths) {
|
||
try {
|
||
return await apiGet(path);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
throw lastError || new Error("No available endpoint");
|
||
}
|
||
|
||
async function apiPostFallback(paths, payload) {
|
||
let lastError;
|
||
for (const path of paths) {
|
||
try {
|
||
return await apiPost(path, payload);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
throw lastError || new Error("No available endpoint");
|
||
}
|
||
|
||
function degreesFromPosition(position, maxDegrees = DEFAULT_OPEN_DEGREES_MAX) {
|
||
const maxValue = Number(maxDegrees) > 0 ? Number(maxDegrees) : DEFAULT_OPEN_DEGREES_MAX;
|
||
return Math.round(clamp(position, 0, 100) * maxValue / 100);
|
||
}
|
||
|
||
function positionFromDegrees(degrees, maxDegrees = DEFAULT_OPEN_DEGREES_MAX) {
|
||
const maxValue = Number(maxDegrees) > 0 ? Number(maxDegrees) : DEFAULT_OPEN_DEGREES_MAX;
|
||
return Math.round(clamp(degrees, 0, maxValue) * 100 / maxValue);
|
||
}
|
||
|
||
async function fetchComPorts() {
|
||
if (!state.apiBase) {
|
||
return { ports: [], selected: "" };
|
||
}
|
||
const payload = await apiGetFallback(SERIAL_API_PATHS.ports);
|
||
return {
|
||
ports: extractPorts(payload),
|
||
selected: extractSelectedPort(payload),
|
||
};
|
||
}
|
||
|
||
function applyPortOptions(ports, preferredPort = "") {
|
||
if (!comPortSelect) return;
|
||
const current = comPortSelect.value;
|
||
comPortSelect.innerHTML = "<option value=\"\">Порт не выбран</option>";
|
||
for (const item of ports) {
|
||
const entry = typeof item === "string" ? { device: item, description: "" } : item;
|
||
const option = document.createElement("option");
|
||
const value = entry.device || entry.port || "";
|
||
if (!value) continue;
|
||
option.value = value;
|
||
option.textContent = `${entry.description ? `${entry.description} (${value})` : value}`;
|
||
option.dataset.hwid = entry.hwid || "";
|
||
comPortSelect.appendChild(option);
|
||
}
|
||
const targetPort = preferredPort || current;
|
||
if (ports.find((item) => (typeof item === "string" ? item : item.device) === targetPort)) {
|
||
comPortSelect.value = targetPort;
|
||
} else if (current) {
|
||
comPortSelect.value = "";
|
||
}
|
||
}
|
||
|
||
async function refreshPortsLegacy(silent = false) {
|
||
const payload = await fetchComPorts();
|
||
const ports = payload?.ports || [];
|
||
applyPortOptions(ports, payload?.selected || "");
|
||
if (!silent) {
|
||
updateStatus(ports.length ? "Доступные порты обновлены" : "Порты не найдены", ports.length ? "ok" : "warn");
|
||
}
|
||
}
|
||
|
||
async function connectSelectedPortLegacyOld() {
|
||
if (!state.apiBase) {
|
||
updateStatus("Укажите API endpoint, чтобы работать с COM-портами", "warn");
|
||
return;
|
||
}
|
||
const value = comPortSelect.value;
|
||
if (!value) {
|
||
updateStatus("Выберите COM порт", "warn");
|
||
return;
|
||
}
|
||
selectedPort = value;
|
||
try {
|
||
const payload = { port: value, baud: 115200, baudrate: 115200, parity: "N", stopbits: 1, bytesize: 8, timeout: 0.3 };
|
||
await apiPostFallback(SERIAL_API_PATHS.connect, payload);
|
||
serialConnected = true;
|
||
serialStatus.textContent = `COM: подключён (${value})`;
|
||
serialStatus.className = "status status-ok";
|
||
updateStatus(`COM ${value} подключён`, "ok");
|
||
} catch (error) {
|
||
serialConnected = false;
|
||
serialStatus.textContent = "COM: ошибка подключения";
|
||
serialStatus.className = "status status-error";
|
||
updateStatus(`Ошибка подключения COM: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function disconnectPortLegacy() {
|
||
if (!state.apiBase) return;
|
||
try {
|
||
await apiPostFallback(SERIAL_API_PATHS.disconnect, {});
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
serialConnected = false;
|
||
serialStatus.textContent = "COM: не подключён";
|
||
serialStatus.className = "status status-warn";
|
||
selectedPort = "";
|
||
}
|
||
}
|
||
|
||
function normalizeSensor(raw = {}) {
|
||
const rawId = raw.id ?? raw.sensorId ?? raw.key ?? raw.code;
|
||
const zone = String(raw.zone ?? raw.channel ?? raw.sensorZone ?? "").trim() || String(rawId ?? "").replace(/\D+/g, "");
|
||
const fallbackZone = zone ? `zone_${zone}` : "zone_1";
|
||
const id = rawId || fallbackZone;
|
||
return {
|
||
id: String(id),
|
||
name: String(raw.name ?? raw.label ?? `Термопара ${zone || 1}`),
|
||
zone: String(zone || "1"),
|
||
value: Number(raw.value ?? raw.current ?? raw.temperature ?? raw.temp ?? 0),
|
||
setpoint: Number(raw.setpoint ?? raw.target ?? raw.targetTemp ?? raw.tSet ?? 0),
|
||
unit: raw.unit ?? raw.units ?? "°C",
|
||
};
|
||
}
|
||
|
||
function normalizeValve(raw = {}) {
|
||
const rawId = raw.id ?? raw.valveId ?? raw.key ?? raw.code;
|
||
const zone = String(raw.zone ?? raw.channel ?? raw.controlZone ?? "").trim() || String(rawId ?? "").replace(/\D+/g, "");
|
||
const id = rawId || `valve_${zone || "1"}`;
|
||
const mode = String(raw.mode ?? raw.workMode ?? "auto").toLowerCase() === "manual" ? "manual" : "auto";
|
||
const maxOpenDegrees = Number(raw.openDegreesMax ?? raw.maxOpenDegrees ?? raw.degMax ?? DEFAULT_OPEN_DEGREES_MAX);
|
||
const maxDegrees = Number.isFinite(maxOpenDegrees) && maxOpenDegrees > 0 ? maxOpenDegrees : DEFAULT_OPEN_DEGREES_MAX;
|
||
let position = Number(raw.position ?? raw.pos ?? raw.percent ?? raw.value);
|
||
let openDegrees = Number(raw.openDegrees ?? raw.degree ?? raw.posDeg ?? raw.opening ?? raw.openAngle ?? raw.angle);
|
||
|
||
if (Number.isNaN(position)) {
|
||
if (Number.isNaN(openDegrees)) {
|
||
position = 0;
|
||
openDegrees = 0;
|
||
} else {
|
||
position = positionFromDegrees(openDegrees, maxDegrees);
|
||
}
|
||
} else if (Number.isNaN(openDegrees)) {
|
||
openDegrees = degreesFromPosition(position, maxDegrees);
|
||
} else {
|
||
position = positionFromDegrees(openDegrees, maxDegrees);
|
||
}
|
||
return {
|
||
id: String(id),
|
||
name: String(raw.name ?? raw.label ?? `Клапан ${zone || 1}`),
|
||
zone: String(zone || "1"),
|
||
mode,
|
||
position: clamp(position, 0, 100),
|
||
openDegrees: clamp(Math.round(openDegrees), 0, maxDegrees),
|
||
openDegreesMax: maxDegrees,
|
||
targetTemp: Number(raw.targetTemp ?? raw.targetTemperature ?? raw.setpoint ?? raw.tSet ?? 0),
|
||
isOpen: Boolean(raw.isOpen ?? raw.open ?? position > 0),
|
||
};
|
||
}
|
||
|
||
function extractCollection(payload, key) {
|
||
if (!payload) return null;
|
||
if (Array.isArray(payload)) return payload;
|
||
if (Array.isArray(payload[key])) return payload[key];
|
||
if (Array.isArray(payload.data?.[key])) return payload.data[key];
|
||
if (Array.isArray(payload.result?.[key])) return payload.result[key];
|
||
if (Array.isArray(payload.state?.[key])) return payload.state[key];
|
||
if (Array.isArray(payload.data?.[`temperature_${key}`])) return payload.data[`temperature_${key}`];
|
||
return null;
|
||
}
|
||
|
||
async function fetchCollection(paths, key, normalizer) {
|
||
let lastError;
|
||
for (const path of paths) {
|
||
try {
|
||
const payload = await apiGet(path);
|
||
const collection = extractCollection(payload, key);
|
||
if (!collection || !Array.isArray(collection)) continue;
|
||
return collection.map(normalizer);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
throw lastError || new Error(`No available endpoint for ${key}`);
|
||
}
|
||
|
||
async function sendWithFallback(paths, id, payload) {
|
||
let lastError;
|
||
for (const resolvePath of paths) {
|
||
try {
|
||
const route = typeof resolvePath === "function" ? resolvePath(id) : resolvePath;
|
||
return await apiPut(route, payload);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
throw lastError || new Error("No available write endpoint");
|
||
}
|
||
|
||
async function postWithFallback(paths, id, payload) {
|
||
let lastError;
|
||
for (const resolvePath of paths) {
|
||
try {
|
||
const route = typeof resolvePath === "function" ? resolvePath(id) : resolvePath;
|
||
return await apiPost(route, payload);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
throw lastError || new Error("No available write endpoint");
|
||
}
|
||
|
||
function writePayloadBase(id, type = "sensor") {
|
||
const sensor = findSensorById(id);
|
||
const valve = findValveById(id);
|
||
const entity = type === "valve" ? valve : sensor;
|
||
return {
|
||
id,
|
||
...(entity ? { zone: entity.zone, name: entity.name } : {}),
|
||
};
|
||
}
|
||
|
||
function mergeDefaults(stored, defaults, normalizer = (item) => item) {
|
||
const source = Array.isArray(stored) ? stored : [];
|
||
const byId = new Map(source.map((item) => [String(item.id), normalizer(item)]));
|
||
return defaults.map((item) => ({
|
||
...item,
|
||
...(byId.get(String(item.id)) || {}),
|
||
}));
|
||
}
|
||
|
||
function buildDemos() {
|
||
state.sensors = mergeDefaults(storageGet("sensorState", []), defaultSensors, normalizeSensor);
|
||
state.valves = mergeDefaults(storageGet("valveState", []), defaultValves, normalizeValve);
|
||
storageSet("sensorState", state.sensors);
|
||
storageSet("valveState", state.valves);
|
||
}
|
||
|
||
function updateStatus(message, type = "ok") {
|
||
globalStatus.textContent = message;
|
||
globalStatus.className = `status-${type}`;
|
||
}
|
||
|
||
function setConnectButtonText() {
|
||
if (!connectPortBtn) {
|
||
return;
|
||
}
|
||
connectPortBtn.textContent = serialConnected ? "Отключить" : "Подключить";
|
||
}
|
||
|
||
function setSerialBusyState(isBusy, message) {
|
||
if (refreshPortsBtn) {
|
||
refreshPortsBtn.disabled = isBusy;
|
||
}
|
||
if (connectPortBtn) {
|
||
connectPortBtn.disabled = isBusy;
|
||
connectPortBtn.textContent = isBusy ? "..." : (serialConnected ? "Отключить" : "Подключить");
|
||
}
|
||
if (calibrateAllBtn) {
|
||
calibrateAllBtn.disabled = isBusy || !serialConnected;
|
||
}
|
||
if (message) {
|
||
serialStatus.textContent = message;
|
||
}
|
||
}
|
||
|
||
function applySerialStateFromPayload(payload = {}) {
|
||
const connected = Boolean(payload?.connected);
|
||
const transport = String(payload?.transport || getModbusTransport()).toLowerCase();
|
||
const isTcp = transport === "tcp";
|
||
const tcpAddress = payload?.address || (payload?.host || payload?.ip ? `${payload?.host || payload?.ip}:${payload?.tcpPort || payload?.tcp_port || 502}` : "");
|
||
const port = isTcp ? (tcpAddress || payload?.port || "") : (payload?.port || payload?.selected || payload?.com || "");
|
||
serialConnected = connected;
|
||
selectedPort = connected && port ? port : "";
|
||
if (comPortSelect && selectedPort) {
|
||
comPortSelect.value = selectedPort;
|
||
}
|
||
if (serialStatus) {
|
||
if (connected && selectedPort) {
|
||
serialStatus.textContent = isTcp ? `Modbus TCP: connected (${selectedPort})` : `COM: connected (${selectedPort})`;
|
||
serialStatus.className = "status status-ok";
|
||
} else {
|
||
serialStatus.textContent = isTcp ? "Modbus TCP: disconnected" : "COM: disconnected";
|
||
serialStatus.className = "status status-warn";
|
||
}
|
||
}
|
||
if (calibrateAllBtn) {
|
||
calibrateAllBtn.disabled = !serialConnected;
|
||
}
|
||
setConnectButtonText();
|
||
storageSet("comPort", selectedPort || "");
|
||
}
|
||
|
||
async function loadState() {
|
||
if (!state.apiBase) {
|
||
buildDemos();
|
||
simulateSensorPhysics();
|
||
statusEl.textContent = "Режим: демо (без API)";
|
||
statusEl.className = "status status-warn";
|
||
globalStatus.textContent = "Демо: все изменения сохраняются в браузере";
|
||
return;
|
||
}
|
||
const [sensors, valves] = await Promise.all([
|
||
fetchCollection(API_PATHS.sensorsRead, "sensors", normalizeSensor),
|
||
fetchCollection(API_PATHS.valvesRead, "valves", normalizeValve),
|
||
]);
|
||
state.sensors = sensors;
|
||
state.valves = valves;
|
||
statusEl.textContent = "Подключено к API";
|
||
statusEl.className = "status status-ok";
|
||
globalStatus.textContent = `API подключен: ${state.apiBase}`;
|
||
}
|
||
|
||
function findSensorByZone(zone) {
|
||
return state.sensors.find((s) => String(s.zone) === String(zone));
|
||
}
|
||
|
||
function findValveByZone(zone) {
|
||
return state.valves.find((v) => String(v.zone) === String(zone));
|
||
}
|
||
|
||
function findSensorById(id) {
|
||
return state.sensors.find((s) => s.id === id);
|
||
}
|
||
|
||
function findValveById(id) {
|
||
return state.valves.find((v) => v.id === id);
|
||
}
|
||
|
||
function renderSensors() {
|
||
const sensorsRoot = document.getElementById("sensors");
|
||
if (!sensorsRoot) return;
|
||
sensorsRoot.innerHTML = "";
|
||
const sensorPanel = sensorsRoot.closest(".panel");
|
||
if (sensorPanel) {
|
||
sensorPanel.hidden = false;
|
||
const title = sensorPanel.querySelector("h2");
|
||
if (title) title.textContent = "Каналы обработки датчиков";
|
||
}
|
||
}
|
||
|
||
function renderValves() {
|
||
const separateValvesRoot = document.getElementById("valves");
|
||
if (separateValvesRoot) {
|
||
separateValvesRoot.innerHTML = "";
|
||
const separateValvesPanel = separateValvesRoot.closest(".panel");
|
||
if (separateValvesPanel) separateValvesPanel.hidden = true;
|
||
}
|
||
|
||
const valvesRoot = document.getElementById("sensors");
|
||
if (!valvesRoot) return;
|
||
valvesRoot.innerHTML = "";
|
||
const valvePanel = valvesRoot.closest(".panel");
|
||
if (valvePanel) {
|
||
valvePanel.hidden = false;
|
||
const title = valvePanel.querySelector("h2");
|
||
if (title) title.textContent = "Каналы обработки датчиков";
|
||
}
|
||
|
||
const sensorLocations = storageGet("sensorLocations", {});
|
||
state.sensors.forEach((sensor, index) => {
|
||
const channelNumber = index + 1;
|
||
const openValve = state.valves[index * 2] || {};
|
||
const closeValve = state.valves[index * 2 + 1] || {};
|
||
const valve = openValve.id ? openValve : closeValve;
|
||
const card = document.createElement("article");
|
||
card.className = "item compact-item valve-item channel-card full-channel-card";
|
||
card.dataset.id = valve.id || `valve_${channelNumber * 2 - 1}`;
|
||
|
||
const rawTemp = Number(sensor.value);
|
||
const temp = Number.isFinite(rawTemp) ? rawTemp : 0;
|
||
const tempText = Number.isFinite(rawTemp) ? temp.toFixed(1) : "--";
|
||
let setpoint = Number(valve.targetTemp ?? sensor.setpoint ?? 28);
|
||
const positionRaw = Number(valve.position ?? 0);
|
||
const position = Number.isFinite(positionRaw) ? Math.max(0, Math.min(100, Math.round(positionRaw))) : 0;
|
||
const maxDegreesRaw = Number(valve.openDegreesMax ?? DEFAULT_OPEN_DEGREES_MAX);
|
||
const maxDegrees = Number.isFinite(maxDegreesRaw) && maxDegreesRaw > 0 ? maxDegreesRaw : DEFAULT_OPEN_DEGREES_MAX;
|
||
const angleRaw = Number(valve.openDegrees ?? ((position / 100) * maxDegrees));
|
||
const openDegrees = Number.isFinite(angleRaw) ? Math.max(0, Math.min(maxDegrees, Math.round(angleRaw))) : 0;
|
||
const openActive = Boolean(openValve.isOpen || position > 0 || openDegrees > 0);
|
||
const closeActive = Boolean(closeValve.isOpen || position <= 0);
|
||
const connected = Boolean(valve.connected ?? valve.isConnected ?? sensor.connected ?? state.connected);
|
||
const tempFill = Math.max(0, Math.min(100, ((temp + 5) / 55) * 100));
|
||
const mode = valve.mode === "manual" ? "manual" : "auto";
|
||
const modeText = mode === "manual" ? "ручной" : "авто";
|
||
const sensorName = sensor.name || `Датчик ${channelNumber}`;
|
||
const sensorId = sensor.id || `zone_${channelNumber}`;
|
||
const setpointDrafts = storageGet("setpointDrafts", {});
|
||
const draftValue = setpointDrafts[sensorId] ?? setpointDrafts[valve.id];
|
||
if (draftValue !== undefined) {
|
||
setpoint = parseGuiNumber(draftValue, setpoint);
|
||
}
|
||
const ds18b20Id = sensor.ds18b20Id || sensor.romId || sensor.rom || sensor.address || "--";
|
||
const location = sensorLocations[sensorId] || sensor.location || defaultChannelLocation(index);
|
||
const locationOptions = CHANNEL_LOCATIONS.map((name) => (
|
||
`<option value="${name}" ${name === location ? "selected" : ""}>${name}</option>`
|
||
)).join("");
|
||
const openValveId = openValve.id || `valve_${channelNumber * 2 - 1}`;
|
||
const closeValveId = closeValve.id || `valve_${channelNumber * 2}`;
|
||
const openHex = (Number(String(openValveId).replace(/\D/g, "")) || channelNumber * 2 - 1).toString(16).toUpperCase().padStart(2, "0");
|
||
const closeHex = (Number(String(closeValveId).replace(/\D/g, "")) || channelNumber * 2).toString(16).toUpperCase().padStart(2, "0");
|
||
const setpointText = Number.isFinite(setpoint) ? setpoint.toFixed(1) : "--";
|
||
const delta = Number.isFinite(rawTemp) && Number.isFinite(setpoint) ? temp - setpoint : NaN;
|
||
const deltaText = Number.isFinite(delta) ? `${delta > 0 ? "+" : ""}${delta.toFixed(1)}°C` : "--";
|
||
const deltaClass = !Number.isFinite(delta) || Math.abs(delta) <= 0.5 ? "ok" : delta > 0 ? "hot" : "cold";
|
||
const stateText = openActive ? "открытие" : closeActive ? "закрытие" : "стоп";
|
||
|
||
card.innerHTML = `
|
||
<div class="channel-head">
|
||
<div class="channel-title">
|
||
<strong>Канал ${channelNumber}</strong>
|
||
<small>${sensorName} · ${location} · зона ${sensor.zone || channelNumber}</small>
|
||
</div>
|
||
<span>связь <i class="channel-lamp ${connected ? "on" : "alarm"}"></i></span>
|
||
</div>
|
||
|
||
<div class="channel-id-grid full-channel-id-grid">
|
||
<label>уставка
|
||
<input class="targetTemp" type="number" step="0.5" value="${setpoint}">
|
||
</label>
|
||
<label>Расположение
|
||
<select class="sensorLocation" data-id="${sensorId}">
|
||
${locationOptions}
|
||
</select>
|
||
</label>
|
||
<label>ID DS18B20
|
||
<input readonly value="${ds18b20Id}">
|
||
</label>
|
||
</div>
|
||
|
||
<div class="channel-body full-channel-body">
|
||
<div class="temperature-widget" aria-label="Температура канала ${channelNumber}">
|
||
<div class="temp-scale">
|
||
<span>50</span><span>40</span><span>30</span><span>20</span><span>10</span><span>0</span><span>-5</span>
|
||
</div>
|
||
<div class="temp-bar"><b style="height: ${tempFill}%"></b></div>
|
||
<div class="temp-now">${tempText}°C</div>
|
||
</div>
|
||
|
||
<div class="channel-workarea">
|
||
<div class="top-metrics-row">
|
||
<div class="angle-panel">
|
||
<span>угол открытия</span>
|
||
<strong>${openDegrees}°</strong>
|
||
<small>максимум ${Math.round(maxDegrees)}°</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="channel-data-grid full-channel-data-grid">
|
||
<div><span>температура</span><strong>${tempText}°C</strong></div>
|
||
<div><span>уставка</span><strong>${setpointText}°C</strong></div>
|
||
<div><span>отклонение</span><strong class="delta ${deltaClass}">${deltaText}</strong></div>
|
||
<div><span>расположение</span><strong>${location}</strong></div>
|
||
<div><span>ID DS18B20</span><strong>${ds18b20Id}</strong></div>
|
||
<div><span>режим</span><strong>${modeText}</strong></div>
|
||
<div><span>угол/max</span><strong>${openDegrees}° / ${Math.round(maxDegrees)}°</strong></div>
|
||
<div><span>команда</span><strong>${stateText}</strong></div>
|
||
<div><span>связь</span><strong>${connected ? "есть" : "нет"}</strong></div>
|
||
<div><span>канал</span><strong>${channelNumber}</strong></div>
|
||
</div>
|
||
|
||
<label class="range-control channel-position">положение заслонки
|
||
<input class="position" type="range" min="0" max="100" value="${position}">
|
||
<strong>${position}%</strong>
|
||
</label>
|
||
|
||
<div class="valve-state-row full-state-row">
|
||
<span><i class="channel-lamp ${openActive ? "on" : "off"}"></i> клапан откр ${channelNumber}</span>
|
||
<span><i class="channel-lamp ${closeActive ? "on" : "off"}"></i> клапан закр ${channelNumber}</span>
|
||
</div>
|
||
|
||
<div class="channel-actions">
|
||
<button class="quickPosition" data-position="100" type="button">откр ${channelNumber}</button>
|
||
<button class="quickPosition" data-position="0" type="button">закр ${channelNumber}</button>
|
||
</div>
|
||
|
||
<div class="channel-actions channel-mode-line">
|
||
<div class="toggle channel-mode">
|
||
<button class="modeBtn valveAuto ${mode === "auto" ? "active" : ""}" data-mode="auto" type="button">авто</button>
|
||
<button class="modeBtn valveManual ${mode === "manual" ? "active" : ""}" data-mode="manual" type="button">ручное</button>
|
||
</div>
|
||
<button class="applyTarget mini-btn" type="button">SP</button>
|
||
<button class="applyManual mini-btn" type="button">OK</button>
|
||
<button class="calibrateValve mini-btn" type="button">CAL</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
valvesRoot.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function render() {
|
||
renderSensors();
|
||
renderValves();
|
||
}
|
||
|
||
async function applySetpoint(sensorId) {
|
||
const sensor = findSensorById(sensorId);
|
||
const input = document.querySelector(`.setpoint[data-id="${sensorId}"]`);
|
||
const value = Number(input.value);
|
||
if (Number.isNaN(value)) {
|
||
updateStatus("Некорректное значение уставки", "warn");
|
||
return;
|
||
}
|
||
|
||
if (!state.apiBase) {
|
||
sensor.setpoint = value;
|
||
storageSet("sensorState", state.sensors);
|
||
render();
|
||
updateStatus(`Демо: уставка ${sensor.name} = ${value.toFixed(1)} °C`, "ok");
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
...writePayloadBase(sensorId, "sensor"),
|
||
setpoint: value,
|
||
id: sensorId,
|
||
};
|
||
|
||
try {
|
||
await sendWithFallback(API_PATHS.sensorWrite, sensorId, payload);
|
||
await loadState();
|
||
render();
|
||
updateStatus(`Уставка ${sensor.name} обновлена`, "ok");
|
||
} catch (error) {
|
||
updateStatus(`Ошибка уставки ${sensor.name}: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function applyValveMode(valveId, mode) {
|
||
const valve = findValveById(valveId);
|
||
if (!valve) return;
|
||
valve.mode = mode;
|
||
|
||
if (!state.apiBase) {
|
||
storageSet("valveState", state.valves);
|
||
render();
|
||
updateStatus(`Демо: ${valve.name} → ${mode}`, "ok");
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
...writePayloadBase(valveId, "valve"),
|
||
mode,
|
||
id: valveId,
|
||
};
|
||
|
||
try {
|
||
await sendWithFallback(API_PATHS.valveWrite, valveId, payload);
|
||
await loadState();
|
||
render();
|
||
updateStatus(`Режим ${valve.name}: ${mode}`, "ok");
|
||
} catch (error) {
|
||
updateStatus(`Ошибка режима ${valve.name}: ${error.message}`, "error");
|
||
await loadState();
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function applyValveTarget(valveId) {
|
||
const valve = findValveById(valveId);
|
||
const input = document.querySelector(`.targetTemp[data-id="${valveId}"]`);
|
||
const value = Number(input.value);
|
||
if (Number.isNaN(value)) {
|
||
updateStatus("Некорректная целевая температура", "warn");
|
||
return;
|
||
}
|
||
|
||
if (!state.apiBase) {
|
||
valve.targetTemp = value;
|
||
storageSet("valveState", state.valves);
|
||
updateStatus(`Демо: цель ${valve.name} = ${value.toFixed(1)} °C`, "ok");
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
...writePayloadBase(valveId, "valve"),
|
||
targetTemp: value,
|
||
id: valveId,
|
||
};
|
||
|
||
try {
|
||
await sendWithFallback(API_PATHS.valveWrite, valveId, payload);
|
||
await loadState();
|
||
render();
|
||
updateStatus(`Целевая температура ${valve.name} обновлена`, "ok");
|
||
} catch (error) {
|
||
updateStatus(`Ошибка цели ${valve.name}: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function applyValvePosition(valveId) {
|
||
const valve = findValveById(valveId);
|
||
const input = document.querySelector(`.position[data-id="${valveId}"]`);
|
||
const value = Number(input.value);
|
||
if (Number.isNaN(value)) {
|
||
updateStatus("Некорректная позиция клапана", "warn");
|
||
return;
|
||
}
|
||
valve.position = clamp(Math.round(value), 0, 100);
|
||
valve.openDegrees = degreesFromPosition(valve.position, valve.openDegreesMax);
|
||
valve.isOpen = valve.position > 0;
|
||
|
||
if (!state.apiBase) {
|
||
storageSet("valveState", state.valves);
|
||
render();
|
||
updateStatus(`Демо: ручная позиция ${valve.name} = ${value}%`, "ok");
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
...writePayloadBase(valveId, "valve"),
|
||
mode: "manual",
|
||
position: valve.position,
|
||
openDegrees: valve.openDegrees,
|
||
openDegreesMax: valve.openDegreesMax,
|
||
id: valveId,
|
||
};
|
||
|
||
try {
|
||
await sendWithFallback(API_PATHS.valveWrite, valveId, payload);
|
||
await loadState();
|
||
render();
|
||
updateStatus(`Позиция ${valve.name} обновлена`, "ok");
|
||
} catch (error) {
|
||
updateStatus(`Ошибка позиции ${valve.name}: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function calibrateValve(valveId) {
|
||
const valve = findValveById(valveId);
|
||
if (!valve) return;
|
||
|
||
if (!state.apiBase) {
|
||
valve.position = 0;
|
||
valve.openDegrees = 0;
|
||
valve.isOpen = false;
|
||
storageSet("valveState", state.valves);
|
||
render();
|
||
updateStatus(`Демо: калибровка ${valve.name} выполнена`, "ok");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await postWithFallback(API_PATHS.valveCalibrate, valveId, {});
|
||
const updated = response?.valve || response;
|
||
if (updated && updated.id) {
|
||
const index = state.valves.findIndex((item) => item.id === valveId);
|
||
if (index !== -1) {
|
||
state.valves[index] = normalizeValve(updated);
|
||
} else {
|
||
await loadState();
|
||
}
|
||
} else {
|
||
await loadState();
|
||
}
|
||
render();
|
||
updateStatus(`Калибровка ${valve.name} выполнена`, "ok");
|
||
} catch (error) {
|
||
updateStatus(`Ошибка калибровки ${valve.name}: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function calibrateAllValves(silent = false) {
|
||
if (!state.apiBase) {
|
||
state.valves = state.valves.map((valve) => ({
|
||
...valve,
|
||
position: 0,
|
||
openDegrees: 0,
|
||
isOpen: false,
|
||
}));
|
||
render();
|
||
if (!silent) {
|
||
updateStatus("Демо: калибровка всех клапанов выполнена", "ok");
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiPostFallback(API_PATHS.valvesCalibrateAll, {});
|
||
const payload = Array.isArray(response?.valves) ? response.valves : null;
|
||
if (payload) {
|
||
state.valves = payload.map(normalizeValve);
|
||
} else {
|
||
await loadState();
|
||
}
|
||
render();
|
||
if (!silent) {
|
||
updateStatus("Калибровка всех клапанов выполнена", "ok");
|
||
}
|
||
} catch (error) {
|
||
if (!silent) {
|
||
updateStatus(`Ошибка калибровки всех клапанов: ${error.message}`, "error");
|
||
}
|
||
}
|
||
}
|
||
|
||
function simulateSensorPhysics() {
|
||
if (state.timer) clearInterval(state.timer);
|
||
state.timer = setInterval(() => {
|
||
for (const sensor of state.sensors) {
|
||
const valve = findValveByZone(sensor.zone);
|
||
const v = valve || {};
|
||
let target;
|
||
if (v.mode === "manual") {
|
||
target = 20 + (clamp(v.position ?? 0, 0, 100) / 100) * 70;
|
||
} else {
|
||
target = v.targetTemp ?? sensor.setpoint ?? 30;
|
||
}
|
||
const drift = target - sensor.value;
|
||
const noise = (Math.random() - 0.5) * 0.2;
|
||
sensor.value = clamp(sensor.value + drift * 0.08 + noise, -40, 150);
|
||
sensor.value = Number(sensor.value.toFixed(2));
|
||
}
|
||
for (const valve of state.valves) {
|
||
const maxOpenDegrees = Number(valve.openDegreesMax) > 0 ? Number(valve.openDegreesMax) : DEFAULT_OPEN_DEGREES_MAX;
|
||
valve.openDegrees = Math.round(degreesFromPosition(valve.position ?? 0, maxOpenDegrees));
|
||
valve.isOpen = (valve.position ?? 0) > 0;
|
||
}
|
||
storageSet("sensorState", state.sensors);
|
||
storageSet("valveState", state.valves);
|
||
render();
|
||
}, 2500);
|
||
}
|
||
|
||
function attachEvents() {
|
||
refreshBtn.addEventListener("click", () => {
|
||
const url = normalizeApiUrl(apiInput.value);
|
||
state.apiBase = url;
|
||
storageSet("apiBase", state.apiBase);
|
||
refreshAll(true);
|
||
refreshPorts(false);
|
||
});
|
||
|
||
refreshPortsBtn.addEventListener("click", () => {
|
||
refreshPorts();
|
||
});
|
||
|
||
connectPortBtn.addEventListener("click", async () => {
|
||
if (serialConnected) {
|
||
await disconnectPort();
|
||
setConnectButtonText();
|
||
return;
|
||
}
|
||
await connectSelectedPort();
|
||
setConnectButtonText();
|
||
});
|
||
|
||
if (calibrateAllBtn) {
|
||
calibrateAllBtn.addEventListener("click", () => {
|
||
calibrateAllValves(false);
|
||
});
|
||
}
|
||
|
||
saveApiBtn.addEventListener("click", () => {
|
||
const url = normalizeApiUrl(apiInput.value);
|
||
state.apiBase = url;
|
||
storageSet("apiBase", url);
|
||
if (!url) {
|
||
updateStatus("API отключён; переход в демо", "warn");
|
||
statusEl.textContent = "Режим: демо (без API)";
|
||
statusEl.className = "status status-warn";
|
||
} else {
|
||
updateStatus(`Сохранён API: ${state.apiBase}`, "ok");
|
||
statusEl.textContent = "Сохранён адрес API";
|
||
statusEl.className = "status status-ok";
|
||
}
|
||
render();
|
||
refreshPorts(false);
|
||
if (!url) {
|
||
serialStatus.textContent = "COM: не подключён";
|
||
serialStatus.className = "status status-warn";
|
||
connectPortBtn.textContent = "Подключить";
|
||
selectedPort = "";
|
||
serialConnected = false;
|
||
}
|
||
});
|
||
|
||
sensorsEl.addEventListener("click", (event) => {
|
||
const target = event.target;
|
||
if (target.classList.contains("applySetpoint")) {
|
||
applySetpoint(target.dataset.id);
|
||
}
|
||
});
|
||
|
||
valvesEl.addEventListener("click", (event) => {
|
||
const target = event.target;
|
||
if (target.classList.contains("modeBtn")) {
|
||
const button = target.closest(".toggle");
|
||
const valveId = button.dataset.id;
|
||
const mode = target.dataset.mode;
|
||
const card = button.closest(".item");
|
||
if (!card) return;
|
||
const autoBlock = card.querySelector(".valveAuto");
|
||
const manualBlock = card.querySelector(".valveManual");
|
||
const applyManualBtn = card.querySelector(".applyManual");
|
||
|
||
button.querySelectorAll(".modeBtn").forEach((b) => b.classList.remove("active"));
|
||
target.classList.add("active");
|
||
if (mode === "auto") {
|
||
autoBlock.classList.remove("hidden");
|
||
manualBlock.classList.add("hidden");
|
||
applyManualBtn.classList.add("hidden");
|
||
} else {
|
||
autoBlock.classList.add("hidden");
|
||
manualBlock.classList.remove("hidden");
|
||
applyManualBtn.classList.remove("hidden");
|
||
}
|
||
applyValveMode(valveId, mode);
|
||
}
|
||
if (target.classList.contains("applyTarget")) {
|
||
applyValveTarget(target.dataset.id);
|
||
}
|
||
if (target.classList.contains("applyManual")) {
|
||
applyValvePosition(target.dataset.id);
|
||
}
|
||
if (target.classList.contains("calibrateValve")) {
|
||
calibrateValve(target.dataset.id);
|
||
}
|
||
});
|
||
|
||
valvesEl.addEventListener("input", (event) => {
|
||
const target = event.target;
|
||
if (target.classList.contains("position")) {
|
||
const label = target.closest("label");
|
||
const span = label ? label.querySelector("strong") : null;
|
||
if (span) span.textContent = `${target.value}%`;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function refreshAll(silent = false) {
|
||
try {
|
||
await loadState();
|
||
render();
|
||
if (!silent) updateStatus("Данные обновлены", "ok");
|
||
} catch (error) {
|
||
if (state.apiBase) {
|
||
statusEl.textContent = `Ошибка API: ${error.message}`;
|
||
statusEl.className = "status status-error";
|
||
globalStatus.textContent = "Не удалось получить данные с API. Откат в демо";
|
||
globalStatus.className = "status-warn";
|
||
await refreshPorts();
|
||
render();
|
||
} else {
|
||
updateStatus(`Демо инициализирован: ${error.message}`, "warn");
|
||
}
|
||
}
|
||
}
|
||
|
||
async function connectSelectedPortLegacy() {
|
||
if (!state.apiBase) {
|
||
updateStatus("Укажите API endpoint для работы с COM-портами", "warn");
|
||
return;
|
||
}
|
||
const value = comPortSelect.value;
|
||
if (!value) {
|
||
updateStatus("Выберите COM порт", "warn");
|
||
return;
|
||
}
|
||
const payload = { port: value, baud: 115200, baudrate: 115200, parity: "N", stopbits: 1, bytesize: 8, timeout: 0.3 };
|
||
try {
|
||
await apiPostFallback(SERIAL_API_PATHS.connect, payload);
|
||
selectedPort = value;
|
||
serialConnected = true;
|
||
storageSet("comPort", value);
|
||
serialStatus.textContent = `COM: подключен (${value})`;
|
||
serialStatus.className = "status status-ok";
|
||
connectPortBtn.textContent = "Отключить";
|
||
updateStatus(`COM ${value} подключен`, "ok");
|
||
} catch (error) {
|
||
serialConnected = false;
|
||
serialStatus.textContent = "COM: ошибка подключения";
|
||
serialStatus.className = "status status-error";
|
||
connectPortBtn.textContent = "Подключить";
|
||
storageSet("comPort", "");
|
||
selectedPort = "";
|
||
updateStatus(`Ошибка подключения COM: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function disconnectPortLegacy() {
|
||
if (!state.apiBase) return;
|
||
try {
|
||
await apiPostFallback(SERIAL_API_PATHS.disconnect, {});
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
serialConnected = false;
|
||
selectedPort = "";
|
||
storageSet("comPort", "");
|
||
serialStatus.textContent = "COM: не подключен";
|
||
serialStatus.className = "status status-warn";
|
||
connectPortBtn.textContent = "Подключить";
|
||
}
|
||
}
|
||
|
||
async function restoreSerialUiLegacy() {
|
||
if (!state.apiBase) return;
|
||
try {
|
||
const status = await apiGetFallback(SERIAL_API_PATHS.status);
|
||
serialConnected = Boolean(status?.connected);
|
||
const port = status?.port || status?.selected;
|
||
if (serialConnected && port) {
|
||
selectedPort = port;
|
||
comPortSelect.value = selectedPort;
|
||
serialStatus.textContent = `COM: подключен (${selectedPort})`;
|
||
serialStatus.className = "status status-ok";
|
||
connectPortBtn.textContent = "Отключить";
|
||
storageSet("comPort", selectedPort);
|
||
} else {
|
||
selectedPort = "";
|
||
storageSet("comPort", "");
|
||
serialStatus.textContent = "COM: не подключен";
|
||
serialStatus.className = "status status-warn";
|
||
connectPortBtn.textContent = "Подключить";
|
||
}
|
||
} catch {
|
||
// no-op
|
||
}
|
||
}
|
||
|
||
async function refreshPorts(silent = false) {
|
||
if (!state.apiBase) {
|
||
ensureApiBase();
|
||
}
|
||
if (!state.apiBase) {
|
||
if (!silent) {
|
||
updateStatus("Set API base URL first", "warn");
|
||
}
|
||
return;
|
||
}
|
||
setSerialBusyState(true, "Scanning COM ports...");
|
||
try {
|
||
const payload = await fetchComPorts();
|
||
const ports = Array.isArray(payload?.ports) ? payload.ports : [];
|
||
const preferredPort = payload?.selected || selectedPort || storageGet("comPort", "");
|
||
applyPortOptions(ports, preferredPort);
|
||
if (!silent) {
|
||
updateStatus(
|
||
ports.length ? `Found COM ports: ${ports.length}` : "No COM ports found",
|
||
ports.length ? "ok" : "warn"
|
||
);
|
||
}
|
||
} catch (error) {
|
||
applyPortOptions([], "");
|
||
updateStatus(`Port scan error: ${error.message}`, "error");
|
||
} finally {
|
||
setSerialBusyState(false);
|
||
}
|
||
}
|
||
|
||
async function connectSelectedPort() {
|
||
if (!state.apiBase) {
|
||
updateStatus("Set API base URL first", "warn");
|
||
return;
|
||
}
|
||
const requestedPort = comPortSelect.value;
|
||
if (!requestedPort) {
|
||
updateStatus("Select COM port", "warn");
|
||
return;
|
||
}
|
||
setSerialBusyState(true, `Connecting ${requestedPort}...`);
|
||
try {
|
||
const payload = {
|
||
port: requestedPort,
|
||
baud: 115200,
|
||
baudrate: 115200,
|
||
parity: "N",
|
||
stopBits: 1,
|
||
stopbits: 1,
|
||
byteSize: 8,
|
||
bytesize: 8,
|
||
timeout: 0.3,
|
||
};
|
||
const status = await apiPostFallback(SERIAL_API_PATHS.connect, payload);
|
||
applySerialStateFromPayload({
|
||
connected: true,
|
||
port: status?.port || requestedPort,
|
||
selected: status?.selected || requestedPort,
|
||
});
|
||
await calibrateAllValves(true);
|
||
updateStatus(`COM ${requestedPort} connected`, "ok");
|
||
await refreshPorts(true);
|
||
} catch (error) {
|
||
applySerialStateFromPayload({ connected: false });
|
||
updateStatus(`COM connect error: ${error.message}`, "error");
|
||
} finally {
|
||
setSerialBusyState(false);
|
||
}
|
||
}
|
||
|
||
async function connectRtuPort() {
|
||
if (!state.apiBase) {
|
||
updateStatus("Укажи API endpoint для Modbus RTU", "warn");
|
||
return;
|
||
}
|
||
|
||
const value = comPortSelect?.value || selectedPort;
|
||
if (!value) {
|
||
updateStatus("Выберите COM порт", "warn");
|
||
return;
|
||
}
|
||
|
||
const unitId = Number(modbusSlaveId?.value || 3);
|
||
storageSet("modbusTransport", "rtu");
|
||
storageSet("comPort", value);
|
||
storageSet("modbusSlaveId", String(unitId));
|
||
setSerialBusyState(true, `Modbus RTU: подключение ${value}, slave ${unitId}...`);
|
||
try {
|
||
const status = await apiPostFallback(SERIAL_API_PATHS.connect, {
|
||
transport: "rtu",
|
||
mode: "rtu",
|
||
port: value,
|
||
baud: 115200,
|
||
baudrate: 115200,
|
||
parity: "N",
|
||
stopbits: 1,
|
||
bytesize: 8,
|
||
timeout: 0.8,
|
||
unitId,
|
||
slaveId: unitId,
|
||
});
|
||
applySerialStateFromPayload({
|
||
...(status || {}),
|
||
connected: Boolean(status?.connected ?? status?.ok),
|
||
transport: "rtu",
|
||
port: value,
|
||
});
|
||
updateStatus(`Modbus RTU connected: ${value}, slave ${unitId}`, "ok");
|
||
} catch (error) {
|
||
applySerialStateFromPayload({ connected: false, transport: "rtu" });
|
||
updateStatus(`Modbus RTU error: ${error.message}`, "error");
|
||
} finally {
|
||
setSerialBusyState(false);
|
||
applyModbusTransportView();
|
||
}
|
||
}
|
||
|
||
async function connectTcpPort() {
|
||
if (!state.apiBase) {
|
||
updateStatus("Укажи API endpoint для Modbus TCP", "warn");
|
||
return;
|
||
}
|
||
|
||
const host = (tcpHost?.value || "").trim();
|
||
const port = Number(tcpPort?.value || 502);
|
||
const unitId = Number(modbusSlaveId?.value || 3);
|
||
if (!host) {
|
||
updateStatus("Укажи IP адрес Modbus TCP", "warn");
|
||
return;
|
||
}
|
||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||
updateStatus("Некорректный TCP порт", "warn");
|
||
return;
|
||
}
|
||
|
||
storageSet("modbusTransport", "tcp");
|
||
storageSet("tcpHost", host);
|
||
storageSet("tcpPort", String(port));
|
||
storageSet("modbusSlaveId", String(unitId));
|
||
setSerialBusyState(true, `Modbus TCP: подключение ${host}:${port}, slave ${unitId}...`);
|
||
try {
|
||
const status = await apiPostFallback(SERIAL_API_PATHS.connect, {
|
||
transport: "tcp",
|
||
mode: "tcp",
|
||
host,
|
||
ip: host,
|
||
tcpPort: port,
|
||
tcp_port: port,
|
||
port,
|
||
unitId,
|
||
slaveId: unitId,
|
||
timeout: 0.8,
|
||
});
|
||
applySerialStateFromPayload({
|
||
...(status || {}),
|
||
connected: Boolean(status?.connected ?? status?.ok),
|
||
transport: "tcp",
|
||
host,
|
||
tcpPort: port,
|
||
address: `${host}:${port}`,
|
||
});
|
||
updateStatus(`Modbus TCP connected: ${host}:${port}, slave ${unitId}`, "ok");
|
||
} catch (error) {
|
||
applySerialStateFromPayload({ connected: false, transport: "tcp" });
|
||
updateStatus(`Modbus TCP error: ${error.message}`, "error");
|
||
} finally {
|
||
setSerialBusyState(false);
|
||
applyModbusTransportView();
|
||
}
|
||
}
|
||
|
||
if (connectPortBtn) {
|
||
connectPortBtn.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
|
||
if (serialConnected) {
|
||
await disconnectPort();
|
||
setConnectButtonText();
|
||
applyModbusTransportView();
|
||
return;
|
||
}
|
||
|
||
if (getModbusTransport() === "tcp") {
|
||
await connectTcpPort();
|
||
} else {
|
||
await connectRtuPort();
|
||
}
|
||
setConnectButtonText();
|
||
}, true);
|
||
}
|
||
|
||
async function disconnectPort() {
|
||
if (!state.apiBase) {
|
||
applySerialStateFromPayload({ connected: false });
|
||
return;
|
||
}
|
||
setSerialBusyState(true, "Disconnecting...");
|
||
try {
|
||
const status = await apiPostFallback(SERIAL_API_PATHS.disconnect, {});
|
||
applySerialStateFromPayload(status || { connected: false });
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
applySerialStateFromPayload({ connected: false });
|
||
setSerialBusyState(false);
|
||
}
|
||
}
|
||
|
||
async function restoreSerialUi() {
|
||
if (!state.apiBase) {
|
||
return;
|
||
}
|
||
try {
|
||
const status = await apiGetFallback(SERIAL_API_PATHS.status);
|
||
applySerialStateFromPayload(status);
|
||
if (status?.connected) {
|
||
await calibrateAllValves(true);
|
||
}
|
||
} catch {
|
||
applySerialStateFromPayload({ connected: false });
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// Channel location selector
|
||
document.addEventListener("change", (event) => {
|
||
const select = event.target.closest(".sensorLocation");
|
||
if (!select) return;
|
||
|
||
const sensorId = select.dataset.id;
|
||
const sensor = state.sensors.find((item) => item.id === sensorId);
|
||
if (sensor) {
|
||
sensor.location = select.value;
|
||
storageSet("sensorState", state.sensors);
|
||
}
|
||
|
||
const locations = storageGet("sensorLocations", {});
|
||
locations[sensorId] = select.value;
|
||
storageSet("sensorLocations", locations);
|
||
|
||
render();
|
||
});
|
||
|
||
// Controls inside sensor processing channel cards
|
||
document.addEventListener("input", (event) => {
|
||
const targetInput = event.target.closest(".full-channel-card .targetTemp");
|
||
if (targetInput) {
|
||
const card = targetInput.closest(".full-channel-card");
|
||
const valveId = card?.dataset.id;
|
||
const targetTemp = parseGuiNumber(targetInput.value, 0);
|
||
const valve = state.valves.find((item) => item.id === valveId);
|
||
if (valve) {
|
||
valve.targetTemp = targetTemp;
|
||
}
|
||
|
||
const valveNumber = Number(String(valveId || "").replace(/\D/g, "")) || 1;
|
||
const sensorIndex = Math.max(0, Math.floor((valveNumber - 1) / 2));
|
||
const sensor = state.sensors[sensorIndex];
|
||
if (sensor) {
|
||
sensor.setpoint = targetTemp;
|
||
}
|
||
|
||
const setpointDrafts = storageGet("setpointDrafts", {});
|
||
if (sensor?.id) {
|
||
setpointDrafts[sensor.id] = targetInput.value;
|
||
}
|
||
if (valveId) {
|
||
setpointDrafts[valveId] = targetInput.value;
|
||
}
|
||
storageSet("setpointDrafts", setpointDrafts);
|
||
storageSet("sensorState", state.sensors);
|
||
storageSet("valveState", state.valves);
|
||
return;
|
||
}
|
||
|
||
const positionInput = event.target.closest(".full-channel-card .position");
|
||
if (!positionInput) return;
|
||
const label = positionInput.closest("label");
|
||
const value = Number(positionInput.value || 0);
|
||
const strong = label?.querySelector("strong");
|
||
if (strong) strong.textContent = `${value}%`;
|
||
});
|
||
|
||
document.addEventListener("click", async (event) => {
|
||
const button = event.target.closest(".full-channel-card button");
|
||
if (!button) return;
|
||
|
||
const card = button.closest(".full-channel-card");
|
||
const valveId = card?.dataset.id;
|
||
if (!card || !valveId) return;
|
||
|
||
if (button.classList.contains("modeBtn")) {
|
||
const mode = button.dataset.mode === "manual" ? "manual" : "auto";
|
||
card.querySelectorAll(".modeBtn").forEach((item) => item.classList.remove("active"));
|
||
button.classList.add("active");
|
||
const valve = state.valves.find((item) => item.id === valveId);
|
||
if (valve) valve.mode = mode;
|
||
storageSet("valveState", state.valves);
|
||
await sendWithFallback(API_PATHS.valveWrite, valveId, {
|
||
...writePayloadBase(valveId, "valve"),
|
||
id: valveId,
|
||
mode,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (button.classList.contains("applyTarget")) {
|
||
const targetInput = card.querySelector(".targetTemp");
|
||
const targetTemp = parseGuiNumber(targetInput?.value, 0);
|
||
const valve = state.valves.find((item) => item.id === valveId);
|
||
if (valve) valve.targetTemp = targetTemp;
|
||
const valveNumber = Number(String(valveId || "").replace(/\D/g, "")) || 1;
|
||
const sensorIndex = Math.max(0, Math.floor((valveNumber - 1) / 2));
|
||
const sensor = state.sensors[sensorIndex];
|
||
if (sensor) sensor.setpoint = targetTemp;
|
||
const setpointDrafts = storageGet("setpointDrafts", {});
|
||
if (sensor?.id) {
|
||
setpointDrafts[sensor.id] = targetInput?.value ?? String(targetTemp);
|
||
}
|
||
setpointDrafts[valveId] = targetInput?.value ?? String(targetTemp);
|
||
storageSet("setpointDrafts", setpointDrafts);
|
||
storageSet("sensorState", state.sensors);
|
||
storageSet("valveState", state.valves);
|
||
await sendWithFallback(API_PATHS.valveWrite, valveId, {
|
||
...writePayloadBase(valveId, "valve"),
|
||
id: valveId,
|
||
targetTemp,
|
||
});
|
||
await loadState();
|
||
return;
|
||
}
|
||
|
||
if (button.classList.contains("applyManual")) {
|
||
const positionInput = card.querySelector(".position");
|
||
const position = Number(positionInput?.value || 0);
|
||
const valve = state.valves.find((item) => item.id === valveId);
|
||
if (valve) {
|
||
valve.position = position;
|
||
valve.isOpen = position > 0;
|
||
}
|
||
storageSet("valveState", state.valves);
|
||
await sendWithFallback(API_PATHS.valveWrite, valveId, {
|
||
...writePayloadBase(valveId, "valve"),
|
||
id: valveId,
|
||
position,
|
||
});
|
||
await loadState();
|
||
return;
|
||
}
|
||
|
||
if (button.classList.contains("calibrateValve")) {
|
||
await postWithFallback(API_PATHS.valveCalibrate, valveId, {
|
||
...writePayloadBase(valveId, "valve"),
|
||
id: valveId,
|
||
});
|
||
await loadState();
|
||
}
|
||
});
|
||
|
||
// Quick manual open/close buttons for channel cards
|
||
document.addEventListener("click", (event) => {
|
||
const quickButton = event.target.closest(".quickPosition");
|
||
if (!quickButton) return;
|
||
|
||
const card = quickButton.closest(".valve-item");
|
||
if (!card) return;
|
||
|
||
const manualButton = card.querySelector(".valveManual");
|
||
if (manualButton && !manualButton.classList.contains("active")) {
|
||
manualButton.click();
|
||
}
|
||
|
||
const positionInput = card.querySelector(".position");
|
||
if (positionInput) {
|
||
positionInput.value = quickButton.dataset.position || "0";
|
||
positionInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||
}
|
||
|
||
const applyButton = card.querySelector(".applyManual");
|
||
if (applyButton) applyButton.click();
|
||
});
|
||
window.addEventListener("load", async () => {
|
||
apiInput.value = storageGet("apiBase", "");
|
||
state.apiBase = normalizeApiUrl(apiInput.value);
|
||
initModbusTransportControls();
|
||
ensureApiBase();
|
||
selectedPort = storageGet("comPort", "");
|
||
attachEvents();
|
||
await refreshAll(true);
|
||
if (getModbusTransport() !== "tcp") {
|
||
await refreshPorts(true);
|
||
}
|
||
render();
|
||
if (selectedPort) {
|
||
comPortSelect.value = selectedPort;
|
||
}
|
||
await restoreSerialUi();
|
||
setInterval(() => {
|
||
if (state.apiBase) {
|
||
loadState().then(render).catch(() => {});
|
||
}
|
||
}, 3000);
|
||
});
|