Files
IoTManagerWeb/.cursor/plans/iotmanagerweb_refactor_ceb2e1c6.plan.md
2026-02-07 00:38:05 +01:00

82 KiB
Raw Blame History

name, overview, todos, isProject
name overview todos isProject
IoTManagerWeb refactor
id content status
backup-files pending
id content status
inventory-external Инвентаризировать все точки внешнего взаимодействия (HTTP endpoints, WS, Cookies) и зафиксировать список функций/мест замены. pending
id content status
add-http-wrapper Добавить `src/api/http.js` (fetch JSON wrapper), не меняя поведение обработки ok/ошибок. pending
id content status
portal-api Добавить `src/api/portal.js` и перенести туда все `fetch("https://portal.iotmanager.org/...`) из `App.svelte`, `pages/Login.svelte`, `pages/Profile.svelte`, `pages/Config.svelte`. pending
id content status
firmware-api Добавить `src/api/firmware.js` и перенести `getVersionsList` из `App.svelte`. pending
id content status
device-ws-api Добавить `src/api/deviceSocket.js` и перенести туда создание WebSocket, `addEventListener` wiring, `send`/`readyState` проверки; в `App.svelte` оставить протокол/обновление state. pending
id content status
ui-components Вынести header/nav/footer `App.svelte` в новые Svelte-компоненты (`src/components/layout/*`), сохранив классы и события (on:change dropdown и т.п.). pending
id content status
lib-blob-protocol Вынести парсинг blob в `src/lib/blobProtocol.js` (getPayloadAsJson/Txt, getJsonAsJson, parseBlob, parseAllBlob с колбэками для записи в state). App вызывает модуль и передаёт колбэки/сеттеры. pending
id content status
lib-device-list Вынести логику списка устройств в `src/lib/deviceListManager.js` (initDevList, devListOverride, devListCombine, sortList, combineArrays). Без поломки цепочки firstDevListRequest / devlist. pending
id content status
lib-device-connection Вынести всю логику подключения в `src/lib/deviceConnection.js`: getIP, connectToAllDevices, createOpenHandler (onOpen: markDeviceStatus, /devlist| при firstDevListRequest && ws===0, отправка страницы), handleDevListReceived (initDevList + connectToAllDevices). Цепочка «первое устройство → список → подключение ко всем» в одном модуле. pending
id content status
lib-ws-reconnect Вынести реконнект и heartbeat в `src/lib/wsReconnect.js` (wsTestMsgTask, ack). App передаёт deviceList, sendFn, markDeviceStatus и опции. pending
id content status
cleanup-and-verify Убрать дублирующийся код/магические строки (base URLs), прогнать smoke test по маршрутам и исправить регрессии. pending
false

Бэкенд (референс, не менять)

Интерфейс IoTManagerWeb работает в паре с прошивкой устройств — проект IoTManager (backend). При непонятностях по протоколу или форматам данных смотреть бэкенд как референс. Бэкенд в рамках этого рефакторинга не меняем.

  • Путь к проекту: ../IoTManager относительно IoTManagerWeb (или абсолютно: /Users/dmitry/Documents/Database/IoTManagerProject/personal/IoTManager).
  • Главный файл протокола WebSocket: IoTManager/src/WsServer.cpp
    • Обработка входящих текстовых команд (headerStr до |): страницы /|, /config|, /connection|, /list|, /system|, /profile|; команды /params|, /charts|, /gifnoc|, /tuoyal|, /oiranecs|, /sgnittes|, /mqtt|, /scan|, /devlist|, /tsil|, /control|, /tst|, /order| и др.
    • Ответ на /tst|: отправка строки /tstr| (WStype_TEXT) — на фронте обрабатывается как единственное входящее string-сообщение.
    • Отправка данных клиенту: sendFileToWsByFrames(filename, header, ...) — бинарные фреймы с 6-символьным заголовком (itemsj, widget, config, scenar, settin, layout, params, devlis, prfile, otaupd, charta и т.д.); sendStringToWs(header, payload, num) — тот же формат «header|0012|payload» отправляется как binary (sendBIN), не текст. На фронте всё приходит как Blob, кроме явного ответа "/tstr|".
  • Модули логирования (графики): в IoTManager/src/modules/virtual/Loging/, Loging2/, Loging3/, LogingDaily/, LogingHourly/ — формирование и отправка charta (sendFileToWsByFrames) и chartb (sendStringToWs). Формат и порядок полей на фронте должны совпадать с бэкендом.
  • Формат экспорта конфигурации (mark "iotm", разделитель "scenario=>") используется и в примерах конфигов бэкенда: например IoTManager/src/modules/exec/Buzzer/export.json, примеры в modules с scenario=> в JSON.
  • Данные для тестов/сверки: IoTManager/data_svelte/ — config.json, items.json, widgets.json, layout.json, scenario.txt, settings.json и т.д.

При рефакторинге веб-интерфейса сохранять совместимость с тем, что отправляет и ожидает этот бэкенд; при сомнениях сверяться с WsServer.cpp и модулями Loging*.


Контекст (что сейчас важно не сломать)

  • HTTP-запросы разбросаны по App.svelte и страницам:
    • App.svelte: getUser, getModInfo, getProfile (portal/compiler) и getVersionsList (ver.json).
    • pages/Login.svelte, pages/Profile.svelte, pages/Config.svelte.
  • WebSocket к устройствам — многослойная логика в App.svelte (см. раздел «Слои WebSocket» ниже).
  • Архитектурный каркас: App.svelte — shell + роутинг (tinro) + прокидывание props в страницы.

Бэкапы перед рефакторингом (первый шаг)

Перед любыми изменениями создать копии файлов, чтобы при потере контекста можно было сверяться с оригиналом.

  • Папка: _backup_refactor/ в корне проекта (или src/_backup_refactor/), добавить в .gitignore при желании не коммитить бэкапы.
  • Файлы для копирования:
    • src/App.svelte (главный файл с протоколом и состоянием)
    • src/main.js
    • src/pages/Config.svelte, Connection.svelte, Dashboard.svelte, List.svelte, Login.svelte, Profile.svelte, System.svelte (страницы с логикой и/или fetch)
    • src/widgets/Chart.svelte, Input.svelte, Range.svelte, Toggle.svelte, Anydata.svelte
    • src/components/Alarm.svelte, Card.svelte, Modal.svelte, ModalPass.svelte, Progress.svelte
  • Цель: не потерять контекст; при сомнениях сверять логику/форматы с бэкапом.

Критичная цепочка: первое устройство и список устройств (не ломать)

Ключевой сценарий: приложение открыто с текущего устройства (один известный IP); по первому подключению запрашивается список всех устройств, затем подключаемся к каждому из них. Порядок и условия менять нельзя.

Исходное состояние

  • deviceList при инициализации содержит один элемент: { name: "--", id: "--", ip: myip, ws: 0, status: false } (myip = document.location.hostname или при devMode фиксированный IP). [App.svelte L99108]
  • firstDevListRequest = true выставляется в onMount перед вызовом connectToAllDevices(). [L196, L198]

Шаг 1: Подключение только к первому устройству

  • connectToAllDevices() обходит deviceList, для каждого устройства с status === false || undefined вызывает wsConnect(i) и wsEventAdd(i). Изначально в списке только индекс 0 — подключается один WebSocket к myip:81. [L228236]
  • В обработчике open сокета (в wsEventAdd): если firstDevListRequest && ws === 0, отправляется wsSendMsg(ws, "/devlist|"). Запрос списка устройств уходит только один раз и только с первого подключённого сокета (ws === 0). [L297298]

Шаг 2: Приём списка и обновление deviceList

  • Устройство отвечает blob с заголовком "devlis", в payload — JSON-массив устройств (name, id, ip, ws, status, …). [L429434]
  • В parseBlob при header === "devlis": incDeviceList = out.json, затем вызывается initDevList(). [L432436]
  • initDevList(): [L695711]
    • Если firstDevListRequest — вызывается devListOverride(): deviceList полностью заменяется на incDeviceList, sortList(deviceList), затем deviceList[0].status = true (чтобы не переподключаться к уже подключённому первому устройству).
    • Иначе — devListCombine(): слияние deviceList и incDeviceList по IP (combineArrays), sortList.
    • firstDevListRequest = false, затем снова connectToAllDevices().

Шаг 3: Подключение ко всем устройствам из списка

  • После devListOverride/devListCombine в deviceList уже все IP. connectToAllDevices() вызывается снова: для устройств с status === false создаются новые сокеты (индексы 1, 2, …); устройство с индексом 0 пропускается, т.к. deviceList[0].status уже true. [L714718, L228236]

Что при рефакторинге сохранить

  • Условие отправки /devlist| только при firstDevListRequest && ws === 0 в обработчике open.
  • Обработку blob "devlis" только в parseBlob (для выбранного устройства), вызов initDevList() и внутри него devListOverride при первом запросе, devListCombine при последующих.
  • Присвоение deviceList[0].status = true в devListOverride после подстановки списка — без этого произойдёт повторное подключение к первому устройству.
  • Последовательность: onMount → firstDevListRequest=true → connectToAllDevices() → open(ws=0) → /devlist| → devlis → initDevList → devListOverride/devListCombine → connectToAllDevices() снова. Не разрывать цепочку и не менять порядок.

После рефакторинга эта логика должна жить в lib/deviceConnection.js (connectToAllDevices, createOpenHandler, handleDevListReceived) и lib/deviceListManager.js (initDevList, devListOverride, devListCombine); App только передаёт зависимости и вызывает экспорты этих модулей.


Страница списка устройств (List) — не ломать

Страница src/pages/List.svelte отображает deviceList, управляет добавлением/удалением устройств и сохранением списка на устройство. При рефакторинге сохранять поведение и контракт с App.

Данные и пропсы из App

  • deviceList — массив устройств (name, id, ip, ws, status, ping, fv и т.д.); таблица строится по нему, при удалении строки вызывается deviceList.splice и deviceList = deviceList.
  • settingsJson — для режима (udps: авто/ручной), заголовок карточки и кнопка «Добавить устройство» зависят от settingsJson.udps.
  • showInput, newDevice — форма добавления устройства (name, ip, id); при сохранении вызывается addDevInList() (в App добавляет newDevice в incDeviceList, devListCombine, connectToAllDevices), затем saveList() (отправка /tsil| с deviceList).
  • saveList, saveSett, sendToAllDevices, applicationReboot — передаются с App; devListOverride передаётся (резерв/будущее использование).
  • percent — для прогресс-бара (таймер реконнекта).

Критичная логика на странице

  • deleteLineFromDevlist(i) — удаление устройства из списка по индексу (splice), не удалять первую строку (i > 0 в шаблоне для кнопки удаления). [L2332, L9596]
  • onModeChange() — при переключении udps (авто/ручной): saveSett(), applicationReboot(). [L3438, L132]
  • onSaveList() — в ручном режиме: при showInput вызов addDevInList() и при успехе saveList() и applicationReboot(); без showInput — только saveList() и applicationReboot(). В авторежиме — saveList() и alert о переходе в ручной режим. [L4164]
  • saveList() в App отправляет /tsil| с JSON deviceList (со статусами, сброшенными в false). [App.svelte L898906]

Что при рефакторинге сохранить

  • Отображение deviceList в таблице (ws+1 как №, name, ip, id, fv, status, ping) и реактивность при изменении deviceList.
  • Не ломать вызовы addDevInList, saveList, saveSett, sendToAllDevices, applicationReboot и передачу devListOverride. Сохранение списка на устройство (/tsil|) и логика ручной/авто режима (udps) должны работать как сейчас.

Данные не в blob и системные команды

Часть обмена с устройством идёт текстовыми (string) сообщениями, а не бинарным blob. При рефакторинге разделение string/blob и набор команд менять нельзя.

Входящие данные не в blob (WebSocket message, string)

Обработка в socket[ws].addEventListener("message", ...) (App.svelte L312330):

  • Только один тип строки обрабатывается: event.data === "/tstr|".
    • Условие: typeof event.data === "string" и точное совпадение data === "/tstr|".
    • Действие: вызов ack(ws, true) — снятие таймаута ожидания ответа, запись ping. Это ответ устройства на команду /tst| (heartbeat).
  • Все остальные входящие данные обрабатываются только если event.data instanceof Blob (parseBlob/parseAllBlob). Строковые сообщения, отличные от "/tstr|", сейчас игнорируются (не ломать: не добавлять лишнюю обработку и не удалять проверку на "/tstr|").

Исходящие системные команды (что отправляется по WebSocket)

Все отправки через wsSendMsg(ws, msg) или sendToAllDevices(msg). Формат сообщения — строка; для команд с телом после | идёт JSON или текст без изменения формата.

| Команда | Кто вызывает | Назначение |

|---------|----------------|------------|

| currentPageName (напр. /\|, /config|) | handleNavigation, open handler | Запрос данных страницы |

| /devlist| | open (только ws===0, firstDevListRequest) | Запрос списка устройств |

| /tst| | wsTestMsgTask (heartbeat) | Проверка живости; ответ — "/tstr" |

| /params| | sortingLayout(ws) | После layout: запрос params |

| /charts| | updateAllStatuses(ws) | После params: запрос данных графиков |

| /tuoyal| + JSON | saveConfig, saveMqtt | Layout виджетов (reversed "layout") |

| /gifnoc| + JSON | saveConfig | Конфиг (reversed "config") |

| /oiranecs| + scenarioTxt | saveConfig | Сценарий (reversed "scenario") |

| /sgnittes| + JSON | saveSett, saveMqtt | Настройки (reversed "settings") |

| /tsil| + JSON | saveList | Список устройств (reversed "list") |

| /clean| | cleanLogs | Очистка логов на устройстве |

| /mqtt| | saveMqtt | Применение MQTT/настроек |

| /control| + key + "/" + status | wsPush (dashboard) | Управление виджетом по topic key |

| /scan| | ssidClick | Сканирование WiFi |

| /reboot| | rebootEsp | Перезагрузка устройства |

| /update| + path | updateBuild | OTA-обновление по path |

| /rorre| + JSON | cancelAlarm | Сброс ошибки (reversed "error") |

| /order| + JSON | moduleOrder (Config) | Порядок/параметры модуля |

При выносе транспорта в api контракт «строка msg без изменения» сохранить; не менять разделитель | и имена команд.


Экспорт конфигураций (детали формата)

Чтобы при рефакторинге не сломать совместимость файлов и портала.

Экспорт (createExportFile, Config.svelte)

  • Объект: exportJson = { mark: "iotm", config: configJson }. Поле config — текущий массив конфигурационных элементов (как в таблице).
  • Строка для файла: сначала syntaxHighlight(JSON.stringify(exportJson)) — т.е. JSON с отступами (pretty-print), затем конкатенация "\n\nscenario=>" и scenarioTxt (сырой текст сценария, может содержать переносы строк и любые символы).
  • Файл: сохраняется как export.json, MIME при сохранении — application/json (фактически содержимое — JSON часть + литерал \n\nscenario=> + текст). Имя файла и разделитель "scenario=>" не менять.

syntaxHighlight: парсит JSON и переформатирует с отступами (4 пробела), экранирует HTML в строках для безопасного вывода; при экспорте используется только переформатирование. Импорт читает файл как текст и парсит только часть до "scenario=>".

Импорт (реакция на выбор files)

  • Прочитать файл как текст (files[0].text()).
  • Проверки по порядку: (1) в тексте есть подстрока "scenario=>"; (2) часть до "scenario=>" (jsonPart) парсится как JSON (IsJsonParse); (3) в объекте есть mark === "iotm". Иначе — alert и выход.
  • При подтверждении пользователем (window.confirm): configJson = json.config, scenarioTxt = часть текста после "scenario=>" (deleteBeforeDelimiter). Очистка полей перед присвоением (configJson = [], scenarioTxt = "") для реактивности.
  • selectToMarker(str, "scenario=>") — строка до первого вхождения "scenario=>"; deleteBeforeDelimiter(str, "scenario=>") — строка после "scenario=>". Один и тот же разделитель при экспорте и импорте.

Публикация на портал (makePost)

  • Тело: объект с полями category, topic (ru/en), text (ru/en), config, scenario, gallery, type "iotmpost", username. Конфиг и сценарий те же, что в экспорте. Ответ портала: при успехе открывается ссылка с id и token в query. Отдельно от файлового экспорта, но те же config/scenario — не менять структуру полей.

Слои WebSocket (полная инвентаризация)

Перед любым выносом WS в api нужно учитывать все слои и зависимости.

Слой 1 — Транспорт (создание, события, send)

  • Хранение: массив socket[], индекс = device.ws (то же, что индекс в deviceList).
  • wsConnect(ws) (L269277): getIP(ws) по deviceListsocket[ws] = new WebSocket("ws://" + ip + ":81"), затем socket.binaryType = "blob" (сейчас вешается на массив; корректнее socket[ws].binaryType = "blob").
  • getIP(ws) (L280287): обход deviceList, возврат device.ip при device.ws === ws.
  • wsEventAdd(ws) (L290345): вешает на socket[ws]:
    • open: markDeviceStatus(ws, true); при firstDevListRequest && ws === 0wsSendMsg(ws, "/devlist|"); при currentPageName === "/|"wsSendMsg(ws, currentPageName); иначе при ws === selectedWssendCurrentPageNameToSelectedWs().
    • message: строка "/tstr|"ack(ws, true); Blob → если ws === selectedWs то parseBlob(data, ws), если currentPageName === "/|" то parseAllBlob(data, ws).
    • close / error: markDeviceStatus(ws, false).
  • wsSendMsg(ws, msg) (L10851092): если socket[ws] && socket[ws].readyState === 1socket[ws].send(msg).

Зависимости слоя 1 от состояния App: deviceList, selectedWs, currentPageName, firstDevListRequest; вызовы в App: markDeviceStatus, wsSendMsg, sendCurrentPageNameToSelectedWs, parseBlob, parseAllBlob, ack.

Слой 2 — Реконнект и heartbeat

  • wsTestMsgTask() (L10371063): каждую секунду (setTimeout(wsTestMsgTask, 1000)); уменьшает remainingTimeout; при 0 обходит deviceList: если устройство offline → wsConnect(ws), wsEventAdd(ws); иначе → wsSendMsg(ws, "/tst|"), ack(ws, false).
  • ack(ws, st) (L10651083): при st === false — ставит таймаут waitingAckTimeout (18 с), по срабатыванию вызывает markDeviceStatus(ws, false); при st === true — снимает таймаут, считает ping[ws], пишет deviceList[i].ping.

Зависимости: deviceList, socket, reconnectTimeout, remainingTimeout, preventReconnect, rebootOrUpdateProcess, socketConnected, percent, showAwaitingCircle; массивы ackTimeoutsArr, startMillis, ping.

Слой 3 — Бинарный протокол (парсинг Blob)

Формат: 6 байт заголовок (текст), 4 байта размер (текст), затем payload. Вспомогательные функции: getPayloadAsJson(blob, size, out), getPayloadAsTxt(blob, size), getJsonAsJson(blob, size, out) (L565595).

  • parseBlob(blob, ws) (L347474): по заголовку пишет в состояние App и в parsed.*; в конце вызывает onParced().
    • Заголовки: itemsj → itemsJson; widget → widgetsJson; config → configJson; scenar → scenarioTxt; settin → settingsJson; ssidli → ssidJson; errors → errorsJson; devlis → incDeviceList + initDevList(); prfile → flashProfileJson; otaupd → otaJson; corelgaddCoreMsg(txt).
  • parseAllBlob(blob, ws) (L476563): по заголовку обновляет layout/params и вызывает колбэки.
    • statusupdateWidget(statusJson) (обновляет layoutJson[i] по topic).
    • layoutcombineLayoutsInOne(ws, devLayout) → дополняет layoutJson, затем sortingLayout(ws) → в конце wsSendMsg(ws, "/params|").
    • params → мержит в paramsJson, updateAllStatuses(ws) (обход layoutJson + wsSendMsg(ws, "/charts|")), onParced().
    • charta / chartbapdateWidgetByArray(...) (обновление layoutJson по topic, накопление массивов).

Цепочки из парсеров: initDevList() → devListOverride/devListCombine → connectToAllDevices(); sortingLayout → wsSendMsg; updateAllStatuses → wsSendMsg; onParced() → getVersionsList, getModInfo, getProfile, pageReady.*.

Слой 4 — Использование WS на уровне приложения

Вызовы wsSendMsg / sendToAllDevices из: навигация (handleNavigation, sendCurrentPageNameToSelectedWs), saveConfig/saveSett/saveList/saveMqtt/cleanLogs, wsPush (dashboard), rebootEsp, updateBuild, cancelAlarm, ssidClick, moduleOrder, ListPage (sendToAllDevices), wsTestMsgTask, sortingLayout, updateAllStatuses.

Команды, уходящие по WS: currentPageName (страница), /devlist|, /tst|, /params|, /charts|, /tuoyal|..., /gifnoc|..., /oiranecs|..., /sgnittes|..., /tsil|..., /clean|, /mqtt|, /control|key/status, /scan|, /reboot|, /update|path, /rorre|..., /order|....


Графики (charts): данные, протокол, костыли

Полная совместимость с серверной частью обязательна — форматы менять нельзя.

Цепочка запросов

  1. После прихода layout (parseAllBlob): combineLayoutsInOnesortingLayout(ws) → отправка **/params|**.
  2. После прихода params (parseAllBlob): мерж в paramsJson, updateAllStatuses(ws) (подставляет значения из paramsJson в layoutJson[i].status по topic), затем отправка **/charts|**.
  3. Устройство отвечает пакетами с заголовками charta или chartb; данные попадают в layoutJson[i].status по совпадению topic через apdateWidgetByArray (опечатка в имени: «apdate»).

Формат blob для charta (особенности и костыли)

  • Стандартный префикс: байты 06 — заголовок "charta", 711 — размер size (текст).
  • Два фрагмента в одном blob (в отличие от остальных типов):
    • Доп. JSON (метаданные, topic и т.д.): байты 12…size читаются через getJsonAsJson(blob, size, out)blob.slice(12, size) → парсинг как JSON. Это единственное место в проекте, где используется срез 12..size (все остальные — size..length или 0..6, 7..11).
    • Данные графика (массив точек): байты size…length читаются через getPayloadAsTxt(blob, size).
  • Костыль парсинга payload: сервер отдаёт текст, который не является валидным JSON-массивом. Код приводит его к массиву так: txt = "[" + txt.substring(0, txt.length - 1) + "]" — обрезается последний символ (ожидается лишняя запятая или символ в конце) и строка оборачивается в []. Менять эту логику нельзя без изменения сервера.
  • Итог: finalDataJson = { status: chartJson, ...addJson }; в addJson должен быть topic для сопоставления с layoutJson[i].topic; вызов apdateWidgetByArray(finalDataJson) дописывает/объединяет массив в layoutJson[i].status.

Формат blob для chartb

  • Обычный формат: заголовок "chartb", размер, payload — один JSON через getPayloadAsJson(blob, size, out). В payload ожидается объект с полем topic и массивом status; тот же apdateWidgetByArray(status) сливает данные в соответствующий виджет по topic.

Виджет Chart.svelte и структура status

  • В layoutJson у элемента с widget === "chart" поле status — массив точек: [{ x, y1 }, ...] (x — timestamp в секундах, y1 — значение).
  • Chart.svelte (svelte-frappe-charts): читает widget.status как массив; для типа bar — метки по дате (getDDMM), для line — первая точка по дате, остальные по времени getHHMM. Поддерживается widget.maxCount: при maxCount === 0 график очищается (widget.status = []), иначе данные накапливаются.
  • Для не-bar графиков в generateLayout в layout добавляется дополнительный виджет input с типом date и topic ...config.id + "-date" (выбор диапазона дат для запроса с устройства).

Что при рефакторинге не трогать

  • Порядок и размеры срезов blob для charta: 06, 711, 12size (addJson), sizelength (текст массива).
  • Преобразование "[" + txt.substring(0, txt.length - 1) + "]" для charta.
  • Имя функции apdateWidgetByArray (используется в parseAllBlob и при рефакторинге лучше оставить как есть, чтобы не ломать поиск/привязки).
  • Связку: params → updateAllStatuses → отправка /charts| → приход charta/chartb → apdateWidgetByArray по topic.

Страница конфигуратора и таблица элементов

Источники данных (всё с устройства по WS, кроме configurations)

  • itemsJson (blob itemsj): список элементов для выпадающего списка «добавить элемент». Структура: элементы с полями num, name; элементы с полем header выводятся как <optgroup label={item.header}>, без header — как <option value={item.num}>. При выборе элемента вызывается elementsDropdownChange: в configJson пушится копия элемента (без num, name), к element.id добавляется randomInteger(0, 100) во избежание дубликатов id.
  • widgetsJson (blob widget): список виджетов для колонки «Виджет» в таблице. В таблице: <select bind:value={element.widget}> с опциями по select.name и отображаемым select.label.
  • configJson (blob config): массив конфигурационных элементов, bind:configJson между App и Config.svelte. Это единственная привязка конфига к странице конфигуратора.
  • scenarioTxt (blob scenar): текст сценария, textarea; высота строки пересчитывается по количеству строк (scenarioTxt.split("\n").length + 1).

Таблица элементов (Config.svelte)

  • Колонки: Тип (element.subtype), Id (input), Виджет (select из widgetsJson), Вкладка (element.page), Название (element.descr), две кнопки: раскрытие доп. параметров (OpenIcon, переключает element.show) и удаление строки (CrossIcon → deleteLineFromConfig(i)).
  • При element.show === true рендерятся дополнительные строки по Object.entries(element). Исключаются ключи: type, subtype, id, widget, page, descr, show. Специальная обработка ключей, начинающихся с "btn": выводится кнопка с текстом key.substring(4) и при клике вызывается moduleOrder(element.id, key.substring(4), element[key]) (передаётся в App → wsSendMsg /order|...). Остальные ключи — метка + input с bind на element[key].

Экспорт / импорт конфигурации (формат файла — не менять)

  • Экспорт: объект exportJson = { mark: "iotm", config: configJson }; содержимое файла = JSON.stringify(exportJson) (с syntaxHighlight при создании) + строка "\n\nscenario=>" + scenarioTxt. То есть в файле: валидный JSON с полями mark и config, затем литерал \n\nscenario=>, затем произвольный текст сценария.
  • Импорт: проверка template.includes("scenario=>"); часть до "scenario=>" — jsonPart, после — txtPart. jsonPart должен парситься как JSON и содержать mark === "iotm"; затем configJson = json.config, scenarioTxt = txtPart. Без этого формата импорт не применять.

Конфигурации с портала

  • getConfigs() (Config.svelte): GET с портала /api/configurations/get, результат в configurations. Второй dropdown: выбор по индексу configsBind; при смене setConfScen() подставляет configJson = configurations[configsBind].config и scenarioTxt = configurations[configsBind].scenario. Публикация на портал: makePost() — POST /api/configurations/add, тело с config, scenario, userdata; при успехе открывается ссылка с id и token.

Сохранение на устройство (App.svelte)

  • modify(): перед отправкой из каждого элемента configJson удаляется поле show (только для UI), чтобы на устройство не уходило.
  • saveConfig(): отправляет layout (/tuoyal|...), config (/gifnoc|...), scenario (/oiranecs|...), затем clearData и повторный запрос страницы.

Что при рефакторинге сохранить

  • Формат экспорта/импорта (mark "iotm", разделитель "scenario=>", структура config + scenario).
  • Логику elementsDropdownChange (копия элемента, добавление случайного числа к id).
  • Структуру itemsJson (header для optgroup, num/name для option).
  • Удаление show из config при сохранении (modify).
  • Вызов moduleOrder для ключей вида "btn*" и формат /order| на устройство.

Вывод по WebSocket

Слои 24 и протокол (слой 3) плотно завязаны на состояние App (deviceList, layoutJson, paramsJson, parsed, pageReady, таймауты, флаги). Вынос «только транспорта» в deviceSocket.js возможен в виде фабрики: создание WebSocket, установка binaryType, регистрация колбэков (onOpen, onMessage, onClose, onError) и функция send/readyState — при этом все колбэки по-прежнему вызывают функции App (markDeviceStatus, parseBlob, parseAllBlob, ack и т.д.). Полный вынос парсинга и реконнекта в api потребовал бы передачи большого контекста или множества колбеков; разумно делать после стабилизации HTTP и UI-компонентов, отдельным этапом.


Оформление WebSocket (рекомендуемая структура)

Подходящее оформление для таких сокетов (много устройств, один сокет на устройство, бинарный протокол, реконнект снаружи) — пул соединений с колбэками, без переноса протокола и state в api.

Роль модуля

  • Один файл: src/api/deviceSocket.js.
  • Ответственность: создание/хранение WebSocket по индексу, установка binaryType = "blob" на инстансе, привязка событий к колбэкам, отправка и проверка готовности. Никакого парсинга Blob и никакой логики реконнекта/ack внутри api — это остаётся в App.svelte.

Контракт API (что экспортировать)

  • createConnection(wsIndex, ip, callbacks) — создаёт new WebSocket("ws://" + ip + ":81"), ставит socket.binaryType = "blob", вешает на сокет open / message / close / error и вызывает соответствующие колбэки с wsIndex. Сохраняет сокет во внутренней структуре по wsIndex.
  • send(wsIndex, msg) — если сокет по wsIndex есть и readyState === 1, вызывает socket.send(msg); иначе без вызова (логирование по желанию в App).
  • isOpen(wsIndex) — возвращает true, если сокет существует и readyState === 1.
  • getSocket(wsIndex) — опционально, если нужно сохранить текущую семантику «массив socket[]» и доступ снаружи (например, для отладки). Иначе можно не экспортировать и хранить сокеты только внутри модуля.

Колбэки передаются одним объектом, например:

createConnection(wsIndex, ip, {
  onOpen(ws) { /* markDeviceStatus(ws, true); запрос /devlist| или страницы */ },
  onMessage(ws, data) { /* string -> ack(ws, true); Blob -> parseBlob/parseAllBlob */ },
  onClose(ws) { /* markDeviceStatus(ws, false) */ },
  onError(ws) { /* markDeviceStatus(ws, false) */ }
});

Все функции в колбэках — из App.svelte (markDeviceStatus, wsSendMsg, parseBlob, parseAllBlob, ack и т.д.). Реконнект (wsTestMsgTask) и ack остаются в App и вызывают createConnection/send из api.

Именование и аналоги в индустрии

  • Пул по индексу — аналог «connection pool keyed by id» (как в паттернах с несколькими WS).
  • Колбэки вместо store — явный контракт, без скрытого state в api; подходит, когда state живёт в корне приложения (как у вас).
  • Один модуль на транспорт — как в примерах с ReconnectingWebSocket/обёртками: отдельный слой только для создания сокета и событий.

Схема взаимодействия

flowchart LR
  subgraph App_svelte
    Reconnect[wsTestMsgTask]
    Ack[ack]
    Parse[parseBlob / parseAllBlob]
    Mark[markDeviceStatus]
    SendUI[saveConfig, wsPush, ...]
  end
  subgraph deviceSocket_api
    Create[createConnection]
    Send[send]
    Pool[(sockets by wsIndex)]
  end
  subgraph Native
    WS[WebSocket]
  end
  Reconnect --> Create
  SendUI --> Send
  Create --> WS
  Send --> Pool --> WS
  WS -->|onOpen/Message/Close/Error| Create
  Create -->|callbacks| Mark
  Create -->|callbacks| Parse
  Create -->|callbacks| Ack

Итог: сокеты в проекте оформлять как **пул устройственных соединений в src/api/deviceSocket.js** с фабрикой по wsIndex + ip и колбэками для событий; протокол и state — в App.


Ключевые фрагменты, которые будем «оборачивать», а не переписывать:

  • HTTP в App.svelte:
  const getUser = async () => {
    try {
      const JWT = Cookies.get("token_iotm2");
      let res = await fetch("https://portal.iotmanager.org/api/user/email", {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${JWT}`,
        },
        mode: "cors",
        method: "GET",
      });
      if (res.ok) {
        userdata = await res.json();
        serverOnline = true;
      } else {
        console.log("error", res.statusText);
        serverOnline = true;
      }
    } catch (e) {
      console.log("error", e);
      serverOnline = false;
    }
  };
  • WS создание/настройка:
  function wsConnect(ws) {
    let ip = getIP(ws);
    if (ip === "error") {
      if (debug) console.log("[e]", "device list wrong");
    } else {
      socket[ws] = new WebSocket("ws://" + ip + ":81");
      socket.binaryType = "blob";
      if (debug) console.log("[i]", ip, ws, "started connecting...");
    }
  }

  function wsEventAdd(ws) {
    if (socket[ws]) {
      let ip = getIP(ws);
      socket[ws].addEventListener("open", function (event) {
        markDeviceStatus(ws, true);
        if (firstDevListRequest && ws === 0) wsSendMsg(ws, "/devlist|");
        if (currentPageName === "/|") {
          wsSendMsg(ws, currentPageName);
        } else {
          if (ws === selectedWs) {
            sendCurrentPageNameToSelectedWs();
          }
        }
      });
      // ... message/close/error handlers ...
    }
  }
  • Рендер shell и прокидывание страниц (важно сохранить API props):
<div class="flex flex-col h-screen bg-gray-50">
  <!-- header + nav -->
  <main class="flex-1 overflow-y-auto p-0 {opened === true && !preventMove ? 'ml-36' : 'ml-0'}">
    {#if !socketConnected && currentPageName != "/|"}
      <Alarm title="Подключение через {remainingTimeout} сек." />
    {:else}
      <Route path="/">
        <DashboardPage ... />
      </Route>
      <Route path="/config">
        <ConfigPage ... />
      </Route>
      <!-- ... остальные Route ... -->
    {/if}
  </main>
</div>

Принцип узкого взаимодействия модулей (обязательно)

Структура должна быть такой, чтобы не было глобальных логических модулей, взаимодействующих между собой сложно (широко). Взаимодействие только узкое: один модуль завершает свою работу и передаёт другому минимальный контракт; дальше про первый можно «забыть».

Правила

  1. Один модуль — одна зона ответственности. Входы и выходы явные; после выхода модуль не участвует в дальнейшем потоке.
  2. Узкая точка = граница (handoff). Модуль A отдаёт модулю B только то, что нужно по контракту (данные или событие). B не знает внутренностей A; A не знает, что B сделает дальше.
  3. Модули создавать под эти границы. Не создавать модули, которые «знают» про несколько доменов или обновляют общее состояние в разных местах. Если два блока логики обмениваются только одной структурой в одном месте — это одна узкая точка; если они читают/пишут десятки переменных друг у друга — это сложное взаимодействие, его нужно заменить на один чёткий контракт.

Глобальные логические части и их узкие границы

| Логическая часть | Вход | Выход (узкая точка) | После handoff |

|------------------|------|---------------------|----------------|

| Подключение (первое устройство → список → подключение ко всем) | onMount: «запустить цепочку» | «Готово: deviceList актуален, сокеты созданы/подключаются» | Про подключение забываем; дальше только отправка/приём и парсинг. Внутри цепочки: парсинг отдаёт «пришёл blob devlis» → только вызов connectToAllDevices (один контракт). |

| Парсинг blob | Blob + ws | Запись в Json, parsed.; вызов onParced (или колбэка «данные готовы») | Про парсинг для этого blob забываем; дальше только pageReady/UI и опционально HTTP. |

| Роутинг / запрос данных | Смена маршрута или устройства | Решение «кому и что отправить» → один вызов send (всем или selectedWs) | Про роутинг в этом тике забываем; дальше транспорт и парсинг ответов. |

| WebSocket транспорт | «Подключить/отправить/событие» | open → один вызов «что отправить при открытии»; message → передача blob/строки парсеру или ack; close/error → «статус offline» | Транспорт только передаёт события; не знает про страницы и протокол. |

| HTTP (портал/прошивки) | Запрос (getUser, getVersionsList, …) | Результат запроса (данные или ошибка) | Про запрос забываем; дальше только UI/логика страницы. |

| Dashboard (агрегация) | Blob при currentPageName === "/" | Обновлённые layoutJson, paramsJson | Дальше только виджеты читают layout/params. |

| Сохранение на устройство | Действие пользователя | Команда сформирована и отправлена по WS | Дальше устройство и парсинг ответа. |

Следствия для структуры файлов

  • api/deviceSocket.js — только транспорт: создание сокета, события (open/message/close/error), send. Не содержит логики «когда /devlist|», «что парсить» — только вызов переданных колбэков с (ws, data).
  • lib/deviceConnection.js — только жизненный цикл подключения: getIP, connectToAllDevices, createOpenHandler, handleDevListReceived. Вход: deviceList, firstDevListRequest, currentPageName, selectedWs. Выход: обновлённый deviceList (через колбэк), вызов createConnection для каждого устройства. Не знает про парсинг blob; про «devlis» знает только то, что handleDevListReceived вызывается при получении списка (вызов извне).
  • lib/deviceListManager.js — только данные списка: initDevList, devListOverride, devListCombine, sortList. Вход: deviceList, incDeviceList, firstDevListRequest. Выход: новый deviceList (или мутация через колбэк). Не знает про сокеты и страницы.
  • lib/blobProtocol.js — только разбор blob: заголовок, payload, вызов переданных колбэков по типу (itemsj → setItemsJson, devlis → onDevList(incDeviceList), …). Не знает про pageReady и currentPageName; вызов onParced (или «все данные для страницы готовы») — отдельный колбэк, который решает App.
  • lib/wsReconnect.js — только таймер и ack: интервал, отправка /tst|, таймаут ответа, вызов markDeviceStatus. Вход: deviceList, send, markDeviceStatus. Не импортирует deviceConnection; getIP/createConnection передаются снаружи.
  • App.svelte — единственное место, где связываются модули: передаёт в deviceSocket колбэки, в которых вызывает deviceConnection.createOpenHandler и parseBlob/parseAllBlob; при devlis вызывает deviceConnection.handleDevListReceived; onParced в App. Таким образом стыки между модулями проходят в App, а не «модуль вызывает модуль напрямую» без контракта.
flowchart LR
  subgraph Connection [Connection]
    C_in[deviceList, firstDevListRequest]
    C_out[deviceList updated, sockets created]
  end
  subgraph Parsing [Parsing]
    P_in[Blob, ws]
    P_out[parsed data, onParced]
  end
  subgraph Transport [Transport]
    T_in[connect, send]
    T_out[open, message, close]
  end
  App[App.svelte]
  C_in --> Connection
  Connection --> C_out
  C_out --> App
  App --> T_in
  Transport --> T_out
  T_out --> App
  App --> P_in
  Parsing --> P_out
  P_out --> App

Итог: модули создавать так, чтобы каждый общался с «миром» только через узкий контракт (вход/выход или колбэки); сложные перекрёстные зависимости не допускаются.


Целевая структура (минимально инвазивная)

  • HTTP (приоритет 1):
    • src/api/http.js — обёртка над fetch (JSON, ошибки, заголовки).
    • src/api/portal.js — все вызовы portal.iotmanager.org (auth/user/config/compiler).
    • src/api/firmware.js — загрузка ver.json по settingsJson.serverip.
  • WebSocket (приоритет 2): src/api/deviceSocket.js — создание сокетов, колбэки, send/isOpen. Исправить баг: binaryType на инстансе сокета.
  • Логика, вынесенная из App (приоритет 34): цель — разгрузить App.svelte, перенести максимум логики в модули, не ломая сценарии.
    • src/lib/blobProtocol.js — парсинг blob (getPayloadAsJson, getPayloadAsTxt, getJsonAsJson), parseBlob/parseAllBlob с колбэками для записи результата в state (App передаёт сеттеры/объект state).
    • src/lib/deviceListManager.js — initDevList, devListOverride, devListCombine, sortList, combineArrays; принимает deviceList, incDeviceList, firstDevListRequest и колбэк connectToAllDevices, возвращает новый deviceList и флаг.
    • **src/lib/deviceConnection.js**вся логика подключения: подключение к первому устройству, запрос списка, подключение ко всем из списка. Содержит: getIP(ws, deviceList); connectToAllDevices(deviceList, createConnection) — обход списка и создание сокетов для устройств с status false; createOpenHandler(options) — возвращает onOpen(ws): markDeviceStatus(ws, true), при firstDevListRequest && ws === 0 отправка /devlist|, при currentPageName/selectedWs отправка имени страницы; handleDevListReceived(incDeviceList, …) — вызов initDevList из deviceListManager и затем connectToAllDevices. Цепочка «первое устройство → /devlist| → devlis → обновление списка → подключение ко всем» живёт в этом модуле; App только передаёт зависимости (deviceList, send, createConnection, markDeviceStatus, currentPageName, selectedWs и т.д.) и вызывает экспорты.
    • src/lib/wsReconnect.js — wsTestMsgTask, ack (таймауты, heartbeat); принимает deviceList, sendFn, markDeviceStatus и опции (reconnectTimeout, waitingAckTimeout).
    • src/lib/dashboardLayout.js (опционально) — sortingLayout, updateWidget, apdateWidgetByArray, generateLayout, getInput, modify; принимают/возвращают layoutJson, paramsJson, pages, configJson и т.д., без хранения state.
  • UI (приоритет 3): src/components/layout/* — header, nav, footer.

Финальная архитектура и организация проекта

Цель: разгрузить App.svelte — вынести логику в модули (api + lib), в App оставить только состояние, оркестрацию вызовов, роутинг и сборку UI. Делать по шагам, без поломок.

Дерево каталогов (целевое)

src/
  api/                    # Внешнее взаимодействие
    http.js
    portal.js
    firmware.js
    deviceSocket.js
  lib/                    # Логика, вынесенная из App
    blobProtocol.js       # Парсинг blob: getPayloadAsJson/Txt, getJsonAsJson, parseBlob, parseAllBlob
    deviceListManager.js  # initDevList, devListOverride, devListCombine, sortList, combineArrays
    deviceConnection.js   # Вся логика подключения: getIP, connectToAllDevices, createOpenHandler, handleDevListReceived
    wsReconnect.js        # wsTestMsgTask, ack (heartbeat и таймауты)
    dashboardLayout.js    # (опционально) sortingLayout, updateWidget, apdateWidgetByArray, generateLayout
  components/
    layout/
      AppHeader.svelte
      AppNav.svelte
      AppFooter.svelte
    Alarm.svelte
    Card.svelte
    ...
  pages/
  widgets/
  svg/
  i18n.js
  lang.js
  main.js
  App.svelte              # Только: state, роутинг, разметка, onMount/колбэки → вызовы api + lib

Бэкапы — в _backup_refactor/.

Разделение ответственности

| Слой | Где | Ответственность |

|------|-----|------------------|

| Транспорт HTTP | api/http.js, api/portal.js, api/firmware.js | fetch, URL, заголовки, возврат данных. |

| Транспорт WebSocket | api/deviceSocket.js | Пул сокетов, createConnection, send, isOpen, колбэки событий. |

| Протокол blob | lib/blobProtocol.js | Парсинг blob (заголовок, размер, payload); parseBlob/parseAllBlob вызывают переданные колбэки для записи в state (App передаёт сеттеры или объект с полями). Форматы и костыли charta/chartb не менять. |

| Список устройств | lib/deviceListManager.js | initDevList, devListOverride, devListCombine, sortList, combineArrays. Вход: deviceList, incDeviceList, firstDevListRequest; выход/колбэк: новый deviceList, connectToAllDevices(). |

| Подключение к устройствам | lib/deviceConnection.js | Вся логика подключения: getIP(ws, deviceList); connectToAllDevices(deviceList, createConnection) — обход списка, создание сокетов для устройств с status false; createOpenHandler(...) — возвращает onOpen(ws): markDeviceStatus(ws, true), при firstDevListRequest && ws === 0 отправка /devlist|, при currentPageName/selectedWs отправка имени страницы; handleDevListReceived(incDeviceList, deviceList, firstDevListRequest, setState, connectToAllDevices) — вызов deviceListManager.initDevList и затем connectToAllDevices. Цепочка «первое устройство → /devlist| → devlis → обновление списка → подключение ко всем» целиком в этом файле. |

| Реконнект/heartbeat | lib/wsReconnect.js | wsTestMsgTask (интервал, оставшееся время), ack (таймауты по ws). Принимает deviceList, sendFn, markDeviceStatus, опции. |

| Dashboard layout | lib/dashboardLayout.js (опц.) | sortingLayout, updateWidget, apdateWidgetByArray, generateLayout, getInput, modify — без хранения state, данные передаются аргументами/возвратом. |

| Состояние | App.svelte | Переменные (deviceList, layoutJson, paramsJson, parsed, pageReady, settingsJson, configJson, флаги). Владение state в App; lib и api не хранят состояние. |

| Оркестрация | App.svelte | onMount: вызов api (getUser), создание обработчиков через deviceConnection.createOpenHandler и передача их в api/deviceSocket, вызов deviceConnection.connectToAllDevices; при приходе devlis вызов deviceConnection.handleDevListReceived; обработчики message вызывают parseBlob/parseAllBlob из lib; запуск wsReconnect. Решение «когда /devlist|» и логика подключения — в lib/deviceConnection.js, не в App. |

| Роутинг и UI | App.svelte | tinro Route, handleNavigation, разметка (layout + Route), передача props страницам и layout. |

| Страницы / виджеты / layout | pages/*, widgets/*, components/layout/* | Без изменений контракта; данные и колбэки из App. |

Каждый слой общается с остальными только через узкий контракт (см. «Принцип узкого взаимодействия модулей»): не допускать сложного перекрёстного взаимодействия между модулями.

Риски размазывания логики (чего избегать)

Модули должны взаимодействовать только узко (см. раздел «Принцип узкого взаимодействия модулей»). Избегать: один модуль знает про другой «изнутри»; общее состояние обновляется из разных модулей без единого контракта; два модуля таскают друг другу десятки параметров.

Конкретно:

  • Каталог реакций на blob — соответствие «заголовок → действие» держать в одном месте в App: один объект/набор колбэков (например blobHandlers = { itemsj: setItemsJson, devlis: (data) => deviceConnection.handleDevListReceived(data, …), layout: (data, ws) => …, ... }), который передаётся в blobProtocol.parseBlob/parseAllBlob. Тогда «что при каком blob вызывается» читается в App, а не ищется по разным модулям.
  • getIP — единственное место определения: lib/deviceConnection.js. В wsReconnect не дублировать; App передаёт getIP (из deviceConnection) в wsReconnect как зависимость. Иначе логика «как по ws взять ip» окажется в двух файлах.
  • onParced и pageReady — логику «после парсинга что делать» (установка pageReady.*, вызов getVersionsList/getModInfo/getProfile) оставить в App как одну функцию/блок. Не разносить по lib: иначе «когда показывать страницу» будет размазано между blobProtocol (вызов колбэка) и непонятно где (кто ставит pageReady).
  • wsReconnect — не импортировать deviceConnection внутри wsReconnect. Передавать снаружи: deviceList, send, markDeviceStatus, createConnection, getIP (getIP из deviceConnection). Так зависимость «реконнект нуждается в getIP» явная и в одном направлении (App → deviceConnection, App → wsReconnect).
  • deviceListManager и deviceConnection — граница чёткая: deviceListManager только данные списка (merge, sort); deviceConnection — жизненный цикл подключений (connect, onOpen, handleDevListReceived). handleDevListReceived вызывает deviceListManager.initDevList, зависимость одна. Если при реализации окажется, что постоянно таскаете одни и те же параметры между двумя модулями — можно рассмотреть объединение в один device.js с подразделами; по умолчанию два файла оставляем.

Итог: одна точка сборки «кто на что реагирует» — App (объект blob-обработчиков, onMount, вызовы lib); одна реализация getIP — deviceConnection; одна точка «что после парсинга» — onParced в App.

Большое количество переменных состояния в App

В App.svelte сейчас десятки top-level переменных (deviceList, layoutJson, paramsJson, parsed, pageReady, settingsJson, configJson, selectedWs, currentPageName, firstDevListRequest, socket, socketConnected, и т.д.). Это затрудняет обзор и увеличивает риск случайно добавить ещё «глобалы». Варианты без поломки потоков:

  • Группировка в объекты (минимальный риск, можно в рамках рефакторинга): не менять способ передачи данных, только сгруппировать объявления в App в несколько логических объектов, например:
    • connectiondeviceList, selectedWs, selectedDeviceData, socketConnected, firstDevListRequest, currentPageName, myip, таймауты реконнекта (reconnectTimeout, remainingTimeout, percent), ackTimeoutsArr, startMillis, ping, флаги preventReconnect, rebootOrUpdateProcess, showAwaitingCircle.
    • dataitemsJson, widgetsJson, configJson, scenarioTxt, settingsJson, ssidJson, errorsJson, layoutJson, paramsJson, incDeviceList, flashProfileJson, otaJson, pages; плюс parsed, pageReady.
    • portaluserdata, allmodeinfo, profile, serverOnline.
    • uiopened, preventMove, showDropdown, showInput, versionsList, choosingVersion, newDevice, coreMessages, и т.п.

Обращения в коде тогда вида connection.deviceList, data.layoutJson. Реактивность Svelte сохраняется при присвоении connection = { ...connection, deviceList: newList } или мутациях полей с последующим присвоением самого объекта. Страницы и lib по-прежнему получают то, что нужно, через props или колбэки (App передаёт connection.deviceList и т.д.). Новые «глобальные» сущности не плодить — по возможности держать в параметрах/возвращаемых значениях lib.

  • Svelte stores (опционально, после стабилизации): вынести часть состояния в src/stores/ (например deviceStore, dataStore) и подписываться в компонентах через $store. Тогда меньше прокидывания props из App, но нужно сохранить порядок обновлений (например devlis → initDevList → set(deviceList) → connectToAllDevices) и не разорвать цепочку первого устройства и списка. Имеет смысл только после того, как вынос в api/lib и проверка сценариев уже сделаны.
  • Оставить плоский список, но упорядочить: если группировка в объекты пока не делается — хотя бы жёстко разбить объявления в App на секции (константы; состояние подключения; данные с устройства; данные портала; UI-флаги) и не добавлять новые переменные без необходимости; новые данные по возможности инкапсулировать в lib и передавать через аргументы/колбэки, а не поднимать в App.

Рекомендация: в рамках текущего рефакторинга не менять контракт страниц (они по-прежнему получают те же props). Группировку переменных в 24 объекта в App можно делать по шагам (сначала connection, потом data), с проверкой после каждого шага. Stores — отдельный, последующий этап при желании уменьшить prop drilling.

Глобальные части проекта и узкие точки взаимодействия

Ниже — глобальные сущности (общее состояние/точки входа) и где они обновляются или откуда читаются. Это узкие места: при рефакторинге их не размазывать без явного контракта.

| Глобальная часть | Где объявлена | Кто пишет (узкая точка) | Кто читает |

|------------------|---------------|--------------------------|------------|

| firstDevListRequest | App | onMount (true); только initDevList (false) | open-обработчик (условие /devlist), initDevList |

| deviceList | App | Изначально один элемент (L100); devListOverride (L715); devListCombine (L724); markDeviceStatus (мутация status); ack (мутация ping); jsonArrWrite в saveSett (L889); триггер реактивности deviceList = deviceList (L262, L704, L1081) | connectToAllDevices, getIP, wsEventAdd, markDeviceStatus, sendToAllDevices, ListPage, dropdown, ack, wsTestMsgTask |

| currentPageName | App | Только handleNavigation (L158159): $router.path + "|" | open (что отправить), onMessage (parseBlob vs parseAllBlob), onParced (какой pageReady выставить), devicesDropdownChange |

| parsed (флаги по типам blob) | App | Только parseBlob/parseAllBlob (и clearParcedFlags) | onParced (условия pageReady) |

| pageReady | App | Только onParced (true для страницы); clearData (все false) | Условия show в Route для каждой страницы |

| socket[] | App | Только wsConnect: socket[ws] = new WebSocket(...) | wsSendMsg, wsEventAdd, wsTestMsgTask (реконнект) |

| socketConnected | App | selectedDeviceDataRefresh (L1137); rebootEsp, updateBuild (false) | UI (CloudIcon), условие показа Alarm vs Route |

| selectedWs | App | bind в dropdown; originalWs в devicesDropdownChange | Почти везде: getIP, send, какой blob обрабатывать, какой сокет выбран |

| selectedDeviceData | App | Только getSelectedDeviceData(selectedWs) (L11571161) | selectedDeviceDataRefresh, devicesDropdownChange, UI |

| layoutJson | App | parseAllBlob (combineLayoutsInOne, updateWidget, apdateWidgetByArray); deleteWidget; sortingLayout; clearData | Dashboard (props), updateAllStatuses, updateWidget, apdateWidgetByArray, generateLayout |

| paramsJson | App | parseAllBlob (header params); clearData | updateAllStatuses |

| incDeviceList | App | Только parseBlob при header devlis (L432433) | initDevList (devListOverride/devListCombine); addDevInList (push newDevice) |

| clearData / clearParcedFlags | App | clearData вызывается из handleNavigation, saveConfig, saveSett, saveMqtt, devicesDropdownChange | — единая точка сброса данных при смене страницы/устройства/сохранении |

| handleNavigation | App | Подписка router.subscribe (L155); вызов из devicesDropdownChange (L1147) | — единственная точка реакции на смену маршрута; внутри: clearData, sendToAllDevices или sendCurrentPageNameToSelectedWs |

| onParced | App | Вызывается из parseBlob (в конце) и из parseAllBlob (при params) | — единственная точка «данные распаршены → выставить pageReady, возможно вызвать getVersionsList/getModInfo/getProfile» |

| markDeviceStatus | App | Вызывается из open, close, error (сокет), ack (таймаут) | Обновляет deviceList[].status, вызывает selectedDeviceDataRefresh, deleteWidget, sortingLayout |

| ack / ackTimeoutsArr, startMillis, ping | App | ack(ws, true) из onMessage ("/tstr|"); ack(ws, false) из wsTestMsgTask | Таймаут ack вызывает markDeviceStatus(ws, false); ping пишется в deviceList[].ping |

| configJson, settingsJson (и др. JSON с устройства) | App | parseBlob (по заголовку); clearData; Config/Connection мутируют через bind | Страницы Config, Connection, List, System; saveConfig, saveSett, generateLayout |

Узкие точки в одном предложении:

  • firstDevListRequest — сбрасывается только в initDevList; читается в onOpen и в initDevList.
  • currentPageName — задаётся только в handleNavigation; от него зависят что парсить, что отправить, какой pageReady ставить.
  • parsed / pageReady — пишутся только в parseBlob/parseAllBlob и onParced (и сбрасываются в clearData); от них зависит показ страниц.
  • deviceList — пишется в devListOverride/devListCombine, markDeviceStatus, ack; читается везде (подключения, UI, отправка).
  • socket[] — создаётся только в wsConnect; отправка и реконнект только через этот массив.
  • Роутинг — единственная точка входа: handleNavigation (router.subscribe + вызов при смене устройства).

При выносе в lib/api сохранять эти узкие точки: один модуль/функция — один ответственный за запись; чтение через явные параметры или колбэки из App.

Поток данных

flowchart TB
  subgraph api [api]
    http[http.js]
    portal[portal.js]
    firmware[firmware.js]
    deviceSocket[deviceSocket.js]
  end
  subgraph lib [lib]
    blobProtocol[blobProtocol.js]
    deviceListMgr[deviceListManager.js]
    deviceConnection[deviceConnection.js]
    wsReconnect[wsReconnect.js]
    dashboardLayout[dashboardLayout.js]
  end
  subgraph App [App.svelte]
    state[State]
    orchestration[Orchestration]
    routing[Routing and UI]
  end
  App -->|calls| api
  App -->|calls with state/ callbacks| lib
  lib -->|updates via callbacks| state
  deviceSocket -->|onOpen, onMessage, etc.| App
  App -->|props| pages
  pages --> portal
  state --> orchestration
  orchestration --> routing
  • App держит state и передаёт в lib колбэки/объекты для записи (например «при распарсенном itemsj вызови setItemsJson»). Логика парсинга и списка устройств — в lib; App только связывает вызовы и обновление переменных.
  • Реконнект: App создаёт таск через wsReconnect.start(deviceList, send, markDeviceStatus, …); внутри lib — setInterval и таймауты ack, вызов переданного markDeviceStatus.

Что в итоге остаётся в App.svelte (минимум)

  • Объявление переменных состояния (deviceList, layoutJson, paramsJson, parsed, pageReady, settingsJson, configJson, selectedWs, currentPageName, firstDevListRequest, таймеры/процент и т.д.).
  • Инициализация в onMount: getUser (api), вызов deviceConnection.connectToAllDevices (передаётся createConnection из api/deviceSocket); обработчик onOpen берётся из deviceConnection.createOpenHandler(…) и передаётся в deviceSocket (внутри него: markDeviceStatus, при firstDevListRequest && ws===0 отправка /devlist|, отправка имени страницы); запуск wsReconnect (lib).
  • Обработчики для deviceSocket: onMessage вызывает parseBlob/parseAllBlob из lib; при заголовке devlis вызывается deviceConnection.handleDevListReceived(incDeviceList, …), который внутри вызывает deviceListManager.initDevList и затем connectToAllDevices.
  • Тонкие обёртки для страниц: saveConfig, saveSett, saveList, wsPush, rebootEsp и т.д. — по возможности делегируют в lib (например generateLayout из dashboardLayout), но вызов send и обновление state остаются в App.
  • Роутинг и разметка: handleNavigation, Route, передача props в layout и страницы.

Всю тяжёлую логику переносим в lib: подключение к устройствам (deviceConnection: connectToAllDevices, onOpen с /devlist|, handleDevListReceived), парсинг blob, слияние списков устройств (deviceListManager), реконнект/ack, сортировка layout и обновление виджетов. App только передаёт данные и зависимости в lib и записывает результаты обратно в свои переменные через колбэки/сеттеры.

Стратегия «не сломать»

  • Выносить одну связку за раз: сначала HTTP, потом WS-обвязку, потом UI.
  • Не менять формат сообщений, blob-протокол, deviceList[i].ws индексацию и текущие props страниц.
  • В новых файлах/правках писать комментарии только на английском.

Тест-план (ручной smoke, без автотестов)

  • Запуск npm run dev и проход по маршрутам.
  • Логин → переход в /profile.
  • Подключение к устройству(ам): смена dropdown, проверка статуса CloudIcon.
  • Dashboard: получение layout/status/params.
  • System: подгрузка ver.json.
  • Config: загрузка конфигураций с портала, создание/экспорт/импорт.

Примечания по реализации

  • Порядок работ и цель: разгрузить App.svelte. Последовательность: (1) HTTP (api) + smoke test; (2) deviceSocket.js + binaryType; (3) UI layout-компоненты; (4) вынос логики в lib — deviceConnection (логика подключения: первый устройство, список, подключение ко всем), deviceListManager, blobProtocol, wsReconnect (и при желании dashboardLayout). После каждого шага — проверка, что сценарии (первое устройство → devlist, List, графики, конфигуратор) не сломаны.
  • Графики и конфигуратор: при любом рефакторинге сохранять форматы и костыли, описанные в разделах «Графики (charts)» и «Страница конфигуратора и таблица элементов» (формат blob charta/chartb, экспорт/импорт с "scenario=>", modify/show, moduleOrder, elementsDropdownChange). Серверную часть не меняем — полная обратная совместимость.
  • Бэкенд IoTManager: не изменяем; при неясностях по протоколу (заголовки blob, команды, формат charta/chartb) смотреть проект ../IoTManager, в первую очередь src/WsServer.cpp и модули modules/virtual/Loging*.
  • Первое устройство и список устройств: цепочка (подключение к ws=0 → /devlist| при firstDevListRequest → devlis → initDevList → devListOverride/devListCombine → connectToAllDevices) и страница List (deviceList, saveList, addDevInList, udps, /tsil|) — ключевые сценарии, не ломать (см. разделы выше).
  • Узкое взаимодействие: структура должна быть такой, чтобы не было глобальных логических модулей, взаимодействующих сложно (широко). Каждый модуль — одна зона ответственности; стыки только через явный контракт (вход/выход или колбэки); после handoff про предыдущий шаг «забываем» (см. раздел «Принцип узкого взаимодействия модулей»).
  • Не размазывать логику: каталог реакций на blob — один объект в App; getIP только в deviceConnection; onParced в App; wsReconnect не импортирует deviceConnection, получает getIP параметром (см. раздел «Риски размазывания логики»).
  • Глобальные переменные: по желанию сгруппировать в App в объекты (connection, data, portal, ui), не меняя контракт страниц; либо оставить плоский список с чёткими секциями. Stores — только после стабилизации рефакторинга (см. раздел «Большое количество переменных состояния в App»).
  • Комментарии в коде — только на английском.