Files
2026-06-25 17:25:41 +03:00

1641 lines
53 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});