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 = ""; 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) => ( `` )).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 = `
Канал ${channelNumber} ${sensorName} · ${location} · зона ${sensor.zone || channelNumber}
связь
50403020100-5
${tempText}°C
угол открытия ${openDegrees}° максимум ${Math.round(maxDegrees)}°
температура${tempText}°C
уставка${setpointText}°C
отклонение${deltaText}
расположение${location}
ID DS18B20${ds18b20Id}
режим${modeText}
угол/max${openDegrees}° / ${Math.round(maxDegrees)}°
команда${stateText}
связь${connected ? "есть" : "нет"}
канал${channelNumber}
клапан откр ${channelNumber} клапан закр ${channelNumber}
`; 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); });