добавил gui
This commit is contained in:
1640
john103C6T6NewVer/app.js
Normal file
1640
john103C6T6NewVer/app.js
Normal file
@@ -0,0 +1,1640 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user