mocup backend for testing

This commit is contained in:
DmitryBorisenko33
2026-02-07 00:38:05 +01:00
parent 6461a2a129
commit 0bd7b59f71
7 changed files with 1929 additions and 2 deletions

View File

@@ -0,0 +1,832 @@
---
name: IoTManagerWeb refactor
overview: ""
todos:
- id: backup-files
content: ""
status: pending
- id: inventory-external
content: Инвентаризировать все точки внешнего взаимодействия (HTTP endpoints, WS, Cookies) и зафиксировать список функций/мест замены.
status: pending
- id: add-http-wrapper
content: Добавить `src/api/http.js` (fetch JSON wrapper), не меняя поведение обработки ok/ошибок.
status: pending
- id: portal-api
content: Добавить `src/api/portal.js` и перенести туда все `fetch("https://portal.iotmanager.org/...`) из `App.svelte`, `pages/Login.svelte`, `pages/Profile.svelte`, `pages/Config.svelte`.
status: pending
- id: firmware-api
content: Добавить `src/api/firmware.js` и перенести `getVersionsList` из `App.svelte`.
status: pending
- id: device-ws-api
content: Добавить `src/api/deviceSocket.js` и перенести туда создание WebSocket, `addEventListener` wiring, `send`/`readyState` проверки; в `App.svelte` оставить протокол/обновление state.
status: pending
- id: ui-components
content: Вынести header/nav/footer `App.svelte` в новые Svelte-компоненты (`src/components/layout/*`), сохранив классы и события (on:change dropdown и т.п.).
status: pending
- id: lib-blob-protocol
content: Вынести парсинг blob в `src/lib/blobProtocol.js` (getPayloadAsJson/Txt, getJsonAsJson, parseBlob, parseAllBlob с колбэками для записи в state). App вызывает модуль и передаёт колбэки/сеттеры.
status: pending
- id: lib-device-list
content: Вынести логику списка устройств в `src/lib/deviceListManager.js` (initDevList, devListOverride, devListCombine, sortList, combineArrays). Без поломки цепочки firstDevListRequest / devlist.
status: pending
- id: lib-device-connection
content: "Вынести всю логику подключения в `src/lib/deviceConnection.js`: getIP, connectToAllDevices, createOpenHandler (onOpen: markDeviceStatus, /devlist| при firstDevListRequest && ws===0, отправка страницы), handleDevListReceived (initDevList + connectToAllDevices). Цепочка «первое устройство → список → подключение ко всем» в одном модуле."
status: pending
- id: lib-ws-reconnect
content: Вынести реконнект и heartbeat в `src/lib/wsReconnect.js` (wsTestMsgTask, ack). App передаёт deviceList, sendFn, markDeviceStatus и опции.
status: pending
- id: cleanup-and-verify
content: Убрать дублирующийся код/магические строки (base URLs), прогнать smoke test по маршрутам и исправить регрессии.
status: pending
isProject: 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](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)` по `deviceList``socket[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 === 0``wsSendMsg(ws, "/devlist|")`; при `currentPageName === "/|"``wsSendMsg(ws, currentPageName)`; иначе при `ws === selectedWs``sendCurrentPageNameToSelectedWs()`.
- **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 === 1``socket[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; `corelg`**addCoreMsg(txt)**.
- **parseAllBlob(blob, ws)** (L476563): по заголовку обновляет layout/params и вызывает колбэки.
- `status`**updateWidget(statusJson)** (обновляет `layoutJson[i]` по topic).
- `layout`**combineLayoutsInOne(ws, devLayout)** → дополняет `layoutJson`, затем **sortingLayout(ws)** → в конце `wsSendMsg(ws, "/params|")`.
- `params` → мержит в `paramsJson`, **updateAllStatuses(ws)** (обход layoutJson + `wsSendMsg(ws, "/charts|")`), **onParced()**.
- `charta` / `chartb`**apdateWidgetByArray(...)** (обновление `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): `combineLayoutsInOne``sortingLayout(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[]» и доступ снаружи (например, для отладки). Иначе можно не экспортировать и хранить сокеты только внутри модуля.
Колбэки передаются одним объектом, например:
```js
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/обёртками: отдельный слой только для создания сокета и событий.
### Схема взаимодействия
```mermaid
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`:
```203:225:/Users/dmitry/Documents/Database/IoTManagerProject/personal/IoTManagerWeb/src/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 создание/настройка:
```269:345:/Users/dmitry/Documents/Database/IoTManagerProject/personal/IoTManagerWeb/src/App.svelte
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):
```1296:1400:/Users/dmitry/Documents/Database/IoTManagerProject/personal/IoTManagerWeb/src/App.svelte
<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**, а не «модуль вызывает модуль напрямую» без контракта.
```mermaid
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 в несколько логических объектов, например:
- **connection** — `deviceList`, `selectedWs`, `selectedDeviceData`, `socketConnected`, `firstDevListRequest`, `currentPageName`, `myip`, таймауты реконнекта (`reconnectTimeout`, `remainingTimeout`, `percent`), `ackTimeoutsArr`, `startMillis`, `ping`, флаги `preventReconnect`, `rebootOrUpdateProcess`, `showAwaitingCircle`.
- **data** — `itemsJson`, `widgetsJson`, `configJson`, `scenarioTxt`, `settingsJson`, `ssidJson`, `errorsJson`, `layoutJson`, `paramsJson`, `incDeviceList`, `flashProfileJson`, `otaJson`, `pages`; плюс `parsed`, `pageReady`.
- **portal** — `userdata`, `allmodeinfo`, `profile`, `serverOnline`.
- **ui** — `opened`, `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.
### Поток данных
```mermaid
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»).
- Комментарии в коде — только на английском.

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/node_modules/
/public/build/
.venv-mock/
.DS_Store

461
plan.md Normal file
View File

@@ -0,0 +1,461 @@
IoTManagerWeb Refactor Plan (Cursor-friendly, zero behavior change)
0) Жёсткие правила (обязательные)
0.1. Backend = reference only
• Backend не меняем.
• При сомнениях: ../IoTManager → IoTManager/src/WsServer.cpp и src/modules/virtual/Loging*.
0.2. Протокол/форматы не менять
• Команды и разделитель | — без изменений.
• Blob протокол: 6-символьный header + размер (4 байта текстом) + payload.
• Единственная входящая строка, которую обрабатываем: event.data === "/tstr|". Всё остальное string — игнор.
0.3. Не “улучшать” странности
Не переименовывать apdateWidgetByArray (опечатка намеренно).
Не менять reverse-команды: /tuoyal|, /gifnoc|, /oiranecs|, /sgnittes|, /tsil|, /rorre| и т.д.
Не “чинить” костыли charta (срезы и substring(0, len-1)).
0.4. Комментарии в новом коде — только EN
В существующих файлах можно не трогать.
1) Инварианты (то, что нельзя сломать)
Сохраняем и проверяем после каждого шага.
1.1. Критичная цепочка: первое устройство → devlist → подключение ко всем
Исходное состояние:
• deviceList на старте содержит 1 элемент: { name:"--", id:"--", ip: myip, ws:0, status:false }.
• firstDevListRequest = true ставится в onMount перед connectToAllDevices().
Шаг 1: подключаем только ws=0
• connectToAllDevices() создаёт сокеты для устройств где status === false || undefined.
• Изначально это только индекс 0 → подключение к myip:81.
Шаг 2: в open handler
• Только когда firstDevListRequest && ws === 0 → отправляем "/devlist|".
• Это должно быть один раз (первый open первого сокета).
Шаг 3: приём devlis
В parseBlob по header === "devlis": incDeviceList = out.json → вызвать initDevList().
Шаг 4: initDevList()
• Если firstDevListRequest:
• devListOverride() полностью заменяет deviceList = incDeviceList, сортирует,
• затем обязательно: deviceList[0].status = true (чтобы не переподключить первый сокет).
• Иначе:
• devListCombine() (combineArrays по IP + sortList).
• Затем: firstDevListRequest = false → снова connectToAllDevices().
Запрещено менять порядок:
onMount → firstDevListRequest=true → connectToAllDevices → open(ws0) → /devlist| → devlis → initDevList → override/combine → connectToAllDevices снова
1.2. Страница List (devices list) — контракт не ломать
• Таблица строится по deviceList.
• Удалять первую строку нельзя (кнопка удаления только при i > 0).
• udps режим:
• onModeChange: saveSett() + applicationReboot().
• onSaveList:
• manual: если showInput → addDevInList() → saveList() → applicationReboot().
• manual без showInput → saveList() → applicationReboot().
• auto → saveList() + alert о переходе в ручной.
• saveList() отправляет /tsil| с JSON deviceList, но статусы сбрасываются в false перед отправкой.
1.3. String vs Blob входящие
В message handler:
• typeof event.data === "string":
• обрабатывается только точное совпадение "/tstr|" → ack(ws, true).
• любые другие строки игнорируем.
Все полезные данные приходят как Blob → только parseBlob/parseAllBlob.
1.4. Отправка данных устройству (WS)
Все команды уходят как строка через wsSendMsg(ws, msg)/sendToAllDevices(msg).
• Формат не менять: "/command|payload" где payload JSON/текст.
На backend sendStringToWs(...) отправляет бинарно, но на фронте это всё равно Blob. Исключение — "/tstr|".
1.5. Charts — строгая совместимость
Цепочка:
• пришёл layout → sortingLayout(ws) → отправить "/params|".
• пришёл params → updateAllStatuses(ws) → отправить "/charts|".
• пришли charta/chartb → apdateWidgetByArray(...) по topic.
charta: особый формат
• header: bytes 0..6 = "charta"
• size: bytes 7..11 (текст)
• addJson: bytes 12..size (JSON) через getJsonAsJson(blob, size, out)
• payload points: bytes size..end (TEXT) через getPayloadAsTxt(blob, size)
• костыль массива: txt = "[" + txt.substring(0, txt.length - 1) + "]" — не менять
chartb:
• обычный JSON payload через getPayloadAsJson(blob, size, out).
1.6. Export/Import config — формат файла не менять
Export:
• exportJson = { mark: "iotm", config: configJson }
• файл: pretty JSON + "\n\nscenario=>" + scenarioTxt
• имя: export.json, MIME application/json
Import:
• читать текст
• проверки по порядку:
1. содержит "scenario=>"
2. jsonPart парсится
3. json.mark === "iotm"
• затем (после confirm):
• configJson = json.config
• scenarioTxt = txtPart
• перед присвоением: configJson = [], scenarioTxt = "" для реактивности
1.7. Backend reference locations (для сверки)
• IoTManager/src/WsServer.cpp — текстовые команды и отправка
• IoTManager/src/modules/virtual/Loging*/ — charta/chartb
• IoTManager/data_svelte/ — test fixtures (items/config/widgets/layout/scenario/settings…)
2) Якоря для поиска в коде (вместо line numbers)
Cursor должен ориентироваться по строкам/сигнатурам.
2.1. devlist chain anchors
• "/devlist|" (только в open handler)
• "devlis" (в parseBlob)
• firstDevListRequest
• deviceList[0].status = true
• connectToAllDevices()
2.2. message handler anchors
• event.data === "/tstr|" (строгая проверка)
• event.data instanceof Blob
2.3. charts anchors
• "charta" / "chartb"
• getJsonAsJson(blob, size, out)
• txt.substring(0, txt.length - 1)
• "/params|", "/charts|"
2.4. export/import anchors
• "scenario=>"
• mark: "iotm"
• export.json
3) Backups (Step 1 — MUST DO)
3.1. Create _backup_refactor/ and copy files
Create folder _backup_refactor/ in project root (or src/_backup_refactor/).
Copy:
• src/App.svelte
• src/main.js
• src/pages/{Config,Connection,Dashboard,List,Login,Profile,System}.svelte
• src/widgets/{Chart,Input,Range,Toggle,Anydata}.svelte
• src/components/{Alarm,Card,Modal,ModalPass,Progress}.svelte
Optional: add _backup_refactor/ to .gitignore.
DoD
• Backup folder exists and contains exact copies.
Quick check
• diff -ru src/App.svelte _backup_refactor/App.svelte shows identical before any edits.
4) Refactor Steps (small, safe, incremental)
Важное правило для Cursor: каждый шаг — отдельный минимальный дифф, после него smoke-check.
Step A — HTTP transport wrapper (priority 1, low risk)
Goal: вынести fetch-логику из App/pages в src/api/.
A1. Create src/api/http.js
• helper requestJson({ url, method, headers, body, mode })
• returns { ok, status, data } or throws with structured error (но не ломаем поведение: лучше возвращать ok=false).
DoD
• New module exists, used by ровно одной функцией (например getUser) без изменения результата.
A2. Create src/api/portal.js
• Move portal calls:
• getUser (/api/user/email, Bearer token token_iotm2)
• getModInfo, getProfile (portal/compiler) — как сейчас
• getConfigs, makePost (Config page) — как сейчас
DoD
В App/pages только импорт и вызов, UI поведение не меняется.
A3. Create src/api/firmware.js
• Move getVersionsList (ver.json from settingsJson.serverip)
DoD
• Версии как раньше отображаются/подгружаются.
Checks
• Логин/профиль/конфиг-портал/версия работают как до.
Step B — WebSocket transport wrapper deviceSocket.js (priority 2, medium risk)
Goal: вынести только создание сокета + send/isOpen. Никакого протокола, никакого parse, никакого reconnect.
B1. Create src/api/deviceSocket.js
Exports:
• createConnection(wsIndex, ip, callbacks)
• const socket = new WebSocket("ws://" + ip + ":81")
• socket.binaryType = "blob" (fix bug: on instance)
• socket.addEventListener("open"...) etc
• store sockets by wsIndex in private map/array
• send(wsIndex, msg) (only if open)
• isOpen(wsIndex)
• optional getSocket(wsIndex) (only if needed for debug parity)
Callbacks object:
{
onOpen(wsIndex, event),
onMessage(wsIndex, data, event),
onClose(wsIndex, event),
onError(wsIndex, event),
}
DoD
• App still controls:
• when to send /devlist|
• whether to parseBlob/parseAllBlob
• ack/reconnect
• Only transport moved.
B2. Replace wsConnect/wsSendMsg usage minimally
• Keep Apps logic structure intact.
• wsConnect(ws) becomes something like:
• compute ip via existing getIP(ws)
• call deviceSocket.createConnection(ws, ip, { callbacks calling existing handlers })
Hard constraints
• open handler must still contain:
• if (firstDevListRequest && ws === 0) wsSendMsg(ws, "/devlist|");
• (или эквивалент через deviceSocket.send)
• message handler must still contain strict /tstr| check and Blob-only parsing.
DoD
• First device → devlist chain works unchanged.
• binaryType is correct on each socket instance.
Checks (manual)
• On first open of ws=0 exactly one /devlist| goes out.
• devlis arrives and triggers initDevList.
• After that, new sockets created for other devices.
Step C — UI layout components (priority 3, low risk)
Goal: split header/nav/footer into components without changing props/logic.
Create:
• src/components/layout/AppHeader.svelte
• src/components/layout/AppNav.svelte
• src/components/layout/AppFooter.svelte (optional)
DoD
• App.svelte becomes smaller but routes/props unchanged.
• No logic moved besides markup.
Step D — Extract logic into lib/ (priority 34, highest risk; do in micro-steps)
Golden rule: move one “узкий контракт” at a time.
D1. src/lib/deviceListManager.js
Exports:
• sortList(list)
• combineArrays(a, b) (merge by IP, preserve ws/index rules)
• devListOverride(deviceList, incDeviceList):
• returns new list or mutates (choose one, but keep App reactive updates)
• MUST set deviceList[0].status = true
• devListCombine(deviceList, incDeviceList)
• initDevList({ deviceList, incDeviceList, firstDevListRequest }):
• chooses override vs combine
• returns { deviceList: newList, firstDevListRequest: false }
DoD
• App still calls initDevList() from devlis branch, and then calls connectToAllDevices() exactly like before.
Checks
• first device is not reconnected (status true for index 0 after override).
D2. src/lib/deviceConnection.js
Exports:
• getIP(wsIndex, deviceList) (single source of truth)
• connectToAllDevices({ deviceList, createConnection })
• createConnection is from api/deviceSocket
• connect only devices with status false/undefined
• createOpenHandler({ firstDevListRequestRef, currentPageNameRef, selectedWsRef, markDeviceStatus, send, sendCurrentPageNameToSelectedWs })
• returns onOpen(wsIndex)
• MUST keep logic:
• markDeviceStatus(ws, true)
• if firstDevListRequest && ws===0 → send(”/devlist|”)
• page request logic as before
• handleDevListReceived({ incDeviceList, getState, setState, initDevList, connectToAllDevices })
• calls deviceListManager.initDevList
• then calls connectToAllDevices again
DoD
• The entire chain “first device → devlis → connect all” remains identical, only moved.
Checks
• same order of events, same number of connects/sends.
D3. src/lib/blobProtocol.js
Goal: move Blob parsing mechanics (not “what to do after parsing”).
Exports:
• getPayloadAsJson, getPayloadAsTxt, getJsonAsJson
• parseBlob(blob, ws, handlers)
• parseAllBlob(blob, ws, handlers)
handlers is a map from header to callback:
• itemsj, widget, config, scenar, settin, ssidli, errors, devlis, prfile, otaupd, corelg
• for devlis, handler must call handleDevListReceived(...) (passed from App)
Charts handling inside parseAllBlob must preserve:
• layout flow triggers /params|
• params flow triggers /charts|
• charta slicing + substring hack
• chartb normal JSON
DoD
• App owns: state vars, onParced, pageReady, and when to clear/reset.
• blobProtocol only parses and calls callbacks.
D4. src/lib/wsReconnect.js
Exports:
• ack(ws, st) with per-ws timeouts and ping calc
• wsTestMsgTask() style function (or start/stop wrapper)
Hard constraints
• ack timeout logic stays identical:
• on false → start waiting timeout (18s) → mark offline
• on true → clear timeout and compute ping
• send heartbeat /tst| for online sockets, reconnect offline sockets.
DoD
• Heartbeat and reconnect behavior unchanged in UI (percent/timers etc).
5) App.svelte end state (what remains)
App should keep:
• All state variables (deviceList/layoutJson/paramsJson/parsed/pageReady/settingsJson/configJson/scenarioTxt/etc).
• Routing (tinro), handleNavigation, UI gating (Alarm vs Routes).
• onParced logic and calls to HTTP functions (getVersionsList/getModInfo/getProfile).
• Wiring: builds handler maps and passes them into deviceSocket, blobProtocol, deviceConnection, wsReconnect.
App should NOT own:
• low-level WS creation (deviceSocket)
• list merge/sort logic (deviceListManager)
• blob slicing/parsing mechanics (blobProtocol)
• reconnect/ack timer internals (wsReconnect)
• connection lifecycle logic (deviceConnection)
6) Smoke Test (manual, but strict)
Run after each step.
6.1. Boot + devlist chain
• Open app with one known IP (myip).
• Confirm:
1. connects only ws=0 initially
2. on open(ws=0) sends /devlist|
3. receives devlis (Blob)
4. initDevList runs, override sets deviceList[0].status = true
5. connects to other devices (ws=1..n)
6.2. Dashboard chain
• On /:
• after layout → sends /params|
• after params → sends /charts|
• receives charta/chartb updates widgets by topic
6.3. List page
• Cannot delete first row.
• Add device in manual mode works.
• Save list sends /tsil| with statuses reset false.
• udps toggle triggers saveSett + reboot.
6.4. Config page
• Export produces JSON + scenario=>.
• Import validates scenario=>, JSON, mark=“iotm”.
• After import config/scenario apply correctly.
• moduleOrder buttons (btn*) still send /order|....
6.5. Heartbeat
• /tst| goes out periodically, /tstr| comes back as string and triggers ack only.
• Any other string message is ignored.
7) “Ripgrep checklist” (after each step)
Cursor должен прогонять поиск и убедиться, что паттерны на месте.
• rg -n 'firstDevListRequest' src/App.svelte src/lib -S
• rg -n '"/devlist\|"' src -S
• rg -n 'event\.data === "/tstr\|"' src -S
• rg -n 'instanceof Blob' src -S
• rg -n 'charta|chartb' src -S
• rg -n 'substring\(0, txt\.length - 1\)' src -S
• rg -n 'scenario=>' src -S
• rg -n 'mark:\s*"iotm"' src -S
• rg -n 'apdateWidgetByArray' src -S
8) Backend reference notes (quick)
• Text commands parsing: IoTManager/src/WsServer.cpp (headerStr до |)
• sendFileToWsByFrames: binary frames with 6-char headers (itemsj/widget/config/scenar/settin/layout/params/devlis/prfile/otaupd/charta…)
• sendStringToWs: header|0012|payload but sent as binary (front receives Blob)
• Exception: /tstr| sent as WStype_TEXT and arrives as string
• Logging modules: IoTManager/src/modules/virtual/Loging*/ produce charta/chartb
• Test fixtures: IoTManager/data_svelte/
9) Target folder tree (after completion)
src/
api/
http.js
portal.js
firmware.js
deviceSocket.js
lib/
blobProtocol.js
deviceListManager.js
deviceConnection.js
wsReconnect.js
dashboardLayout.js # optional
components/layout/
AppHeader.svelte
AppNav.svelte
AppFooter.svelte
App.svelte
pages/
widgets/
...
_backup_refactor/

24
scripts/README-mock.md Normal file
View File

@@ -0,0 +1,24 @@
# Mock backend for IoTManagerWeb (dev)
Emulates the IoTManager ESP backend: WebSocket on port 81, HTTP for `/iotm/ver.json`. Two devices over one WS server (two connections = slot 0 and 1).
## Run
```bash
# From IoTManagerWeb root
python3 -m venv .venv-mock
.venv-mock/bin/pip install -r scripts/requirements-mock.txt
.venv-mock/bin/python scripts/mock_backend.py --host 0.0.0.0 --ws-port 81 --http-port 8081
```
Default HTTP port is 8081 (sirv dev server often uses 8080).
## Frontend
Set `VITE_DEV_WS_HOST=127.0.0.1` in `.env.development` (already set). Then:
```bash
npm run dev
```
Open the app URL (e.g. http://localhost:8080). The app will connect to `ws://127.0.0.1:81` and receive a device list of two mock devices; dashboard and charts will show mock data.

608
scripts/mock_backend.py Normal file
View File

@@ -0,0 +1,608 @@
#!/usr/bin/env python3
"""
Mock backend for IoTManager frontend (dev mode).
Emulates devices over one WebSocket server (port 81) and HTTP for /iotm/ver.json.
Frontend flow (App.svelte) — must match exactly:
1) Initial state (L99108)
- deviceList = [{ name: "--", id: "--", ip: myip, ws: 0, status: false }]
- myip = device from which web loaded (e.g. 127.0.0.1 in dev)
2) onMount (L188200)
- firstDevListRequest = true
- connectToAllDevices() // "сначала подключимся к известному нам ip этого устройства"
3) connectToAllDevices (L226236)
- For each deviceList[i]: deviceList[i].ws = i
- For each with status === false || undefined: wsConnect(i), wsEventAdd(i)
- So initially only ws=0 connects (one WebSocket to myip:81)
4) wsConnect(ws) (L269277)
- getIP(ws) from deviceList (device.ws === ws → device.ip)
- new WebSocket("ws://" + ip + ":81")
5) On socket open (L293308)
- markDeviceStatus(ws, true)
- If firstDevListRequest && ws === 0: wsSendMsg(ws, "/devlist|") // только с первого сокета
- Then send currentPageName (e.g. /| or /list|) to selected or all
6) We respond to /devlist| with blob header "devlis", payload = JSON array of devices.
- Frontend parseBlob (L347354): header = blob.slice(0,6).text(), size = blob.slice(7,11).text()
- getPayloadAsJson(blob, size, out) (L578589): partBlob = blob.slice(size, blob.length) — size coerced to number (payload start offset 12)
- So frame = header(6) + "|0012|"(6) + payload; frontend expects payload from byte 12.
7) On "devlis" (L429436): incDeviceList = out.json, await initDevList()
8) initDevList (L695710)
- If firstDevListRequest: devListOverride() else devListCombine()
- firstDevListRequest = false
- onParced(), selectedDeviceDataRefresh(), connectToAllDevices()
9) devListOverride (L714719): deviceList = incDeviceList; sortList(deviceList); deviceList[0].status = true
- First list entry must be "this device" (same IP). sortList keeps first element first, sorts rest by name.
10) Second connectToAllDevices(): now deviceList has N entries; [0].status already true, so only indices 1..N-1 get wsConnect → we open more sockets (e.g. second device same IP → second connection to mock).
11) saveList (L898905): devListForSave = copy of deviceList, all .status = false, wsSendMsg(selectedWs, "/tsil|" + JSON.stringify(devListForSave))
We store; when udps=0, next /devlist| returns saved list.
"""
import argparse
import asyncio
import json
import math
import socket
import threading
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Optional
try:
import websockets
except ImportError:
raise SystemExit("Install: pip install websockets")
# Payload starts at byte 12 (header|0012| = 12 bytes)
WS_HEADER_PREFIX = "|0012|"
WS_PREFIX_BYTES = 12
def make_binary_frame(header: str, payload: str) -> bytes:
"""Build binary frame: header(6 chars)|0012| + payload (UTF-8). Backend: sendStringToWs sends same as binary."""
if len(header) != 6:
header = (header + " ")[:6]
prefix = (header + WS_HEADER_PREFIX).encode("utf-8")
body = payload.encode("utf-8")
return prefix + body
def get_layout(device_slot: int) -> list:
"""Layout: 4 pages by meaning (Sensors, Charts, Relays/Light, Params). All 5 widget types."""
prefix = f"/mock/d{device_slot}"
return [
# --- 1. Сенсоры: read-only anydata ---
{"ws": device_slot, "topic": f"{prefix}/temp", "page": "Сенсоры", "widget": "anydata", "descr": "Temperature", "status": "22.5", "after": "°C"},
{"ws": device_slot, "topic": f"{prefix}/hum", "page": "Сенсоры", "widget": "anydata", "descr": "Humidity", "status": "45", "after": "%"},
{"ws": device_slot, "topic": f"{prefix}/pressure", "page": "Сенсоры", "widget": "anydata", "descr": "Pressure", "status": "1013", "after": " hPa"},
{"ws": device_slot, "topic": f"{prefix}/voltage", "page": "Сенсоры", "widget": "anydata", "descr": "Voltage", "status": "3.28", "after": " V"},
{"ws": device_slot, "topic": f"{prefix}/power", "page": "Сенсоры", "widget": "anydata", "descr": "Power", "status": "120", "after": " W", "color": [{"level": 0, "value": ""}, {"level": 200, "value": "#009933"}, {"level": 2000, "value": "#FF9900"}]},
{"ws": device_slot, "topic": f"{prefix}/rssi", "page": "Сенсоры", "widget": "anydata", "descr": "WiFi RSSI", "status": "-65", "after": " dBm"},
# --- 2. Графики 1 и 2 (two cards) ---
{"ws": device_slot, "topic": f"{prefix}/log", "page": "Графики 1", "widget": "chart", "descr": "Temperature log", "status": [], "maxCount": 100},
{"ws": device_slot, "topic": f"{prefix}/log2", "page": "Графики 1", "widget": "chart", "descr": "Humidity log", "status": [], "maxCount": 100},
{"ws": device_slot, "topic": f"{prefix}/log3", "page": "Графики 2", "widget": "chart", "descr": "Power log", "status": [], "maxCount": 100},
{"ws": device_slot, "topic": f"{prefix}/logBar", "page": "Графики 2", "widget": "chart", "type": "bar", "descr": "Bar chart", "status": [], "maxCount": 100},
# --- 3. Реле и свет: toggles + dimmer ---
{"ws": device_slot, "topic": f"{prefix}/relay1", "page": "Реле и свет", "widget": "toggle", "descr": "Relay 1", "status": "0", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/relay2", "page": "Реле и свет", "widget": "toggle", "descr": "Relay 2", "status": "1", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/relay3", "page": "Реле и свет", "widget": "toggle", "descr": "Light", "status": "0", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/fan", "page": "Реле и свет", "widget": "toggle", "descr": "Fan", "status": "0", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/dimmer", "page": "Реле и свет", "widget": "range", "descr": "Dimmer", "status": "50", "min": 0, "max": 100, "after": "%", "sent": False},
# --- 4. Параметры: range + inputs ---
{"ws": device_slot, "topic": f"{prefix}/volume", "page": "Параметры", "widget": "range", "descr": "Volume", "status": "70", "min": 0, "max": 100, "after": "%", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/setpoint", "page": "Параметры", "widget": "range", "descr": "Setpoint", "status": "21", "min": 15, "max": 30, "after": "°C", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/settemp", "page": "Параметры", "widget": "input", "descr": "Set temp", "status": "20.5", "type": "number", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/label", "page": "Параметры", "widget": "input", "descr": "Label", "status": "Room 1", "type": "text", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/alarmtime", "page": "Параметры", "widget": "input", "descr": "Alarm time", "status": "08:30", "type": "time", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/eventdate", "page": "Параметры", "widget": "input", "descr": "Event date", "status": "2025-02-06", "type": "date", "sent": False},
]
# Per-slot state for controls; keys = last segment of topic (id). Merged into get_params.
_params_state: dict = {} # slot -> { topic_id: value }
def _get_params_state(slot: int) -> dict:
if slot not in _params_state:
prefix = f"/mock/d{slot}"
_params_state[slot] = {
"temp": "22.5", "hum": "45", "pressure": "1013", "voltage": "3.28", "power": "120", "rssi": "-65",
"relay1": "0", "relay2": "1", "relay3": "0", "fan": "0",
"dimmer": "50", "volume": "70", "setpoint": "21",
"settemp": "20.5", "label": "Room 1", "alarmtime": "08:30", "eventdate": "2025-02-06",
"log": "", "log2": "", "log3": "", "logBar": "",
}
return _params_state[slot]
def get_params(device_slot: int) -> dict:
"""Params JSON: topic id -> value (for layout widgets)."""
return dict(_get_params_state(device_slot))
def get_chart_data(topic: str, points: int = 20, base_val: float = 20.0, amplitude: float = 5.0) -> dict:
"""Generate chart points: status array of {x: unix_sec, y1: number}."""
import time
now = int(time.time())
step = 3600 # 1 hour
status = []
for i in range(points, 0, -1):
t = now - i * step
val = base_val + amplitude * math.sin(i * 0.5) + (points - i) * 0.1
status.append({"x": t, "y1": round(val, 2)})
return {"topic": topic, "maxCount": 100, "status": status}
# Stored settings per slot (for save/load on System and Connection pages)
_settings_store: dict = {} # slot -> dict
def get_settings(http_host: str, http_port: int, slot: int = 0) -> dict:
"""Settings JSON; serverip must point to mock HTTP for ver.json. System page needs timezone, wg, log, mqttin, i2c, pinSCL, pinSDA, i2cFreq."""
base = {
"name": "MockDevice",
"apssid": "IoTmanager",
"appass": "",
"routerssid": "",
"routerpass": "",
"timezone": 2,
"ntp": "pool.ntp.org",
"weblogin": "admin",
"webpass": "admin",
"mqttServer": "",
"mqttPort": 1883,
"mqttPrefix": "/mock",
"mqttUser": "",
"mqttPass": "",
"serverip": f"http://{http_host}:{http_port}",
"serverlocal": f"http://{http_host}:{http_port}",
"log": False,
"udps": 1,
"settings_": "",
"wg": "group1",
"mqttin": False,
"i2c": False,
"pinSCL": 0,
"pinSDA": 0,
"i2cFreq": 100000,
}
if slot in _settings_store:
base.update(_settings_store[slot])
return base
def get_errors() -> dict:
"""Errors JSON for System page: bn, bt, bver, wver, timenow, upt, uptm, uptw, rssi, heap, freeBytes, fl, rst."""
import time
return {
"bn": "esp32",
"bt": "2024-01-01 12:00",
"bver": "1.0.0",
"wver": "4.2.0",
"timenow": str(int(time.time())),
"upt": "1d 02:30",
"uptm": "1d 02:30",
"uptw": "1d 02:30",
"rssi": 5,
"heap": "120000",
"freeBytes": "2.1M",
"fl": "1024",
"rst": "Software reset",
}
def get_ssid_list() -> dict:
"""SSID list for connection page."""
return {"0": "MockNetwork", "1": "OtherNetwork"}
# Backend getThisDevice() fields: wg, ip, id, name, status (bool), fv. Frontend table: ws+1, name, ip, id, fv, status, ping (ws assigned by frontend).
def _normalize_device_entry(entry: dict, default_ip: str = "127.0.0.1") -> dict:
"""Ensure device entry has all fields required by List table and backend contract."""
if not isinstance(entry, dict):
return {"name": "--", "id": "--", "ip": default_ip, "status": False, "fv": "-", "wg": ""}
return {
"name": entry.get("name") if entry.get("name") is not None else "--",
"id": entry.get("id") if entry.get("id") is not None else "--",
"ip": entry.get("ip") if entry.get("ip") is not None else default_ip,
"status": bool(entry.get("status")) if entry.get("status") is not None else False,
"fv": str(entry["fv"]) if entry.get("fv") is not None else "-",
"wg": entry.get("wg", ""),
}
def _normalize_devlist(arr: list, default_ip: str = "127.0.0.1") -> list:
"""Return array of normalized device entries; frontend assigns ws by index."""
if not isinstance(arr, list) or len(arr) == 0:
return []
return [_normalize_device_entry(e, default_ip) for e in arr]
def get_devlist(host: str) -> list:
"""Default device list from 'heap' (udps!=0). First entry = this device (IP we're connected to); frontend sets deviceList[0].status=true. Rest = other devices; frontend connects to each."""
return _normalize_devlist([
{"name": "Mock Device 1", "ip": host, "id": "mock-dev-1", "status": False, "fv": "1.0.0", "wg": "group1"},
{"name": "Mock Device 2", "ip": host, "id": "mock-dev-2", "status": False, "fv": "1.0.0", "wg": "group1"},
], host)
# Saved device list from /tsil| (reversed "list"). When udps=0 backend returns from "file", else from "heap" (default).
_saved_devlist: Optional[list] = None
def get_items_json() -> list:
"""Minimal items list."""
return [
{"name": "Выберите элемент", "num": 0},
{"header": "virtual_elments"},
{"global": 0, "name": "Temp", "type": "Reading", "subtype": "AnalogAdc", "id": "temp", "widget": "anydataTmp", "page": "Сенсоры", "descr": "Temperature", "num": 1},
{"global": 0, "name": "Graph", "type": "Writing", "subtype": "Loging", "id": "log", "widget": "chart2", "page": "Графики 1", "descr": "Temperature", "num": 2, "points": 100},
]
def get_widgets_json() -> list:
"""All frontend widget types: anydata, chart (line + bar), toggle, range, input (number, text, time, date)."""
return [
{"name": "anydataDef", "label": "Text", "widget": "anydata", "icon": ""},
{"name": "anydataTmp", "label": "Temperature", "widget": "anydata", "after": "°C", "icon": "speedometer"},
{"name": "chart2", "label": "Chart", "widget": "chart", "icon": ""},
{"name": "chartBar", "label": "Chart bar", "widget": "chart", "type": "bar", "icon": ""},
{"name": "toggleDef", "label": "Toggle", "widget": "toggle", "icon": ""},
{"name": "rangeDef", "label": "Range", "widget": "range", "min": 0, "max": 100, "icon": ""},
{"name": "inputNumber", "label": "Number", "widget": "input", "type": "number", "icon": ""},
{"name": "inputText", "label": "Text", "widget": "input", "type": "text", "icon": ""},
{"name": "inputTime", "label": "Time", "widget": "input", "type": "time", "icon": ""},
{"name": "inputDate", "label": "Date", "widget": "input", "type": "date", "icon": ""},
]
def get_config_json() -> list:
"""Config (array of module configs)."""
return []
def get_ota_json() -> dict:
"""OTA versions placeholder."""
return {}
def get_profile_json() -> dict:
"""Flash profile placeholder."""
return {"board": "esp32", "version": "1.0.0"}
# Global: HTTP host/port for settings.serverip (set at startup)
HTTP_HOST = "127.0.0.1"
HTTP_PORT = 8080
# Slot counter for WebSocket connections
_next_slot = 0
_slot_lock = threading.Lock()
# Connected clients (ws, slot) for periodic broadcast. Backend: publishStatusWs, periodicWsSend, params on change.
_connections: list = [] # list of (websocket, slot)
def assign_slot() -> int:
global _next_slot
with _slot_lock:
s = _next_slot % 2
_next_slot += 1
return s
async def handle_ws_message(ws, message: str, slot: int) -> None:
"""Handle text command from frontend and send responses."""
global _saved_devlist
if "|" not in message:
return
cmd = message.split("|")[0] + "|"
if cmd == "/tst|":
await ws.send("/tstr|")
return
if cmd == "/pi|":
await ws.send("/po|")
return
# Binary responses
def send_bin(header: str, payload: str) -> asyncio.Future:
return ws.send(make_binary_frame(header, payload))
if cmd == "/|":
layout = get_layout(slot)
await send_bin("layout", json.dumps(layout))
# Send params immediately so front has data (front also requests /params| after layout; both trigger updateAllStatuses)
await send_bin("params", json.dumps(get_params(slot)))
return
if cmd == "/params|":
params = get_params(slot)
await send_bin("params", json.dumps(params))
return
if cmd == "/charts|":
layout = get_layout(slot)
for w in layout:
if w.get("widget") != "chart" or not w.get("topic"):
continue
t = w["topic"]
if "log2" in t:
base_val, amp = 45.0, 15.0
elif "log3" in t:
base_val, amp = 100.0, 50.0
else:
base_val, amp = 20.0, 5.0
chart = get_chart_data(w["topic"], base_val=base_val, amplitude=amp)
await send_bin("chartb", json.dumps(chart))
return
if cmd == "/config|":
await send_bin("itemsj", json.dumps(get_items_json()))
await send_bin("widget", json.dumps(get_widgets_json()))
await send_bin("config", json.dumps(get_config_json()))
await send_bin("scenar", "// mock scenario\n")
await send_bin("settin", json.dumps(get_settings(HTTP_HOST, HTTP_PORT, slot)))
return
if cmd == "/connection|":
await send_bin("widget", json.dumps(get_widgets_json()))
await send_bin("config", json.dumps(get_config_json()))
await send_bin("settin", json.dumps(get_settings(HTTP_HOST, HTTP_PORT, slot)))
await send_bin("ssidli", json.dumps(get_ssid_list()))
await send_bin("errors", json.dumps(get_errors()))
return
if cmd == "/list|":
await send_bin("settin", json.dumps(get_settings(HTTP_HOST, HTTP_PORT, slot)))
return
if cmd == "/devlist|":
host = HTTP_HOST
settings = get_settings(HTTP_HOST, HTTP_PORT, slot)
udps = settings.get("udps", 1)
# Backend: udps!=0 -> list from heap (UDP auto); udps=0 -> list from file (saved). Always normalize for table.
if udps == 0 and _saved_devlist and len(_saved_devlist) > 0:
list_to_send = _normalize_devlist(_saved_devlist, host)
else:
list_to_send = get_devlist(host)
await send_bin("devlis", json.dumps(list_to_send))
return
if cmd == "/system|":
await send_bin("errors", json.dumps(get_errors()))
await send_bin("settin", json.dumps(get_settings(HTTP_HOST, HTTP_PORT, slot)))
return
if cmd == "/dev|":
await send_bin("errors", json.dumps(get_errors()))
await send_bin("settin", json.dumps(get_settings(HTTP_HOST, HTTP_PORT, slot)))
await send_bin("config", json.dumps(get_config_json()))
await send_bin("itemsj", json.dumps(get_items_json()))
return
if cmd == "/profile|":
await send_bin("otaupd", json.dumps(get_ota_json()))
await send_bin("prfile", json.dumps(get_profile_json()))
return
# /control|key/value - frontend sends id (e.g. relay1) and value; update state and echo status
if cmd == "/control|":
payload = message[len(cmd):].strip()
if "/" in payload:
key, value = payload.split("/", 1)
state = _get_params_state(slot)
state[key] = value
topic = f"/mock/d{slot}/{key}"
await send_bin("status", json.dumps({"topic": topic, "status": value}))
return
# /sgnittes| + JSON - save system settings
if cmd == "/sgnittes|":
payload = message[len(cmd):].strip()
if payload:
try:
data = json.loads(payload)
_settings_store[slot] = data
await send_bin("errors", json.dumps(get_errors()))
except json.JSONDecodeError:
pass
return
# /tsil| + JSON array — save device list (reversed "list"). Backend writes to devlist.json; next /devlist| when udps=0 returns this.
if cmd == "/tsil|":
payload = message[len(cmd) :].strip()
if payload:
try:
data = json.loads(payload)
if isinstance(data, list) and len(data) > 0:
_saved_devlist = _normalize_devlist(data, HTTP_HOST)
print(f"[mock] devlist saved, {len(_saved_devlist)} device(s)")
except (json.JSONDecodeError, TypeError):
pass
return
if cmd == "/clean|":
# Clear logs (no-op; could clear chart data in real device)
return
if cmd == "/reboot|":
# Reboot (no-op in mock)
return
# Save commands: no-op
if cmd in ("/gifnoc|", "/tuoyal|", "/oiranecs|"):
return
async def _send_bin(ws, header: str, payload: str) -> None:
"""Send binary frame to one client (same format as backend sendStringToWs). Must send bytes for client to get Blob."""
frame = make_binary_frame(header, payload)
assert isinstance(frame, bytes), "frame must be bytes so browser receives Blob"
await ws.send(frame)
def _tick_sensor_values() -> None:
"""Update read-only sensor values in _params_state (like backend sensors in loop)."""
t = time.time()
for slot in list(_params_state.keys()):
s = _params_state[slot]
# Small variation around base values
s["temp"] = f"{22 + 3 * math.sin(t * 0.5):.1f}"
s["hum"] = str(int(45 + 10 * math.sin(t * 0.3)))
s["pressure"] = str(1012 + int(5 * math.sin(t * 0.2)))
s["voltage"] = f"{3.2 + 0.1 * math.sin(t):.2f}"
s["power"] = str(int(120 + 30 * math.sin(t * 0.4)))
s["rssi"] = str(int(-65 + 5 * math.sin(t * 0.5)))
# Interval (seconds): backend sends status on sensor change (publishStatusWs); we emulate same.
PARAMS_INTERVAL = 3
# Param keys that are widget values (not chart). Front updateWidget(topic, status) matches layout by full topic.
_PARAM_IDS = [
"temp", "hum", "pressure", "voltage", "power", "rssi",
"relay1", "relay2", "relay3", "fan", "dimmer", "volume", "setpoint",
"settemp", "label", "alarmtime", "eventdate",
]
async def _periodic_broadcast() -> None:
"""Backend: widget updates via publishStatusWs(topic, data) -> sendStringToWs('status', json). Front expects
binary blob header 'status', payload JSON { topic, status }; updateWidget() does layoutJson[i] = jsonConcat(...).
We send one 'status' blob per param so UI reacts. Then chartb for charts."""
await asyncio.sleep(1) # first tick soon after connect
while True:
await asyncio.sleep(PARAMS_INTERVAL)
_tick_sensor_values()
n_conn = len(_connections)
if n_conn > 0:
print(f"[WS] broadcast: {n_conn} client(s), status+chartb", flush=True)
for (ws, slot) in list(_connections):
try:
# websockets 12+ has .state (State.OPEN), not .open; skip check, send() raises if closed
prefix = f"/mock/d{slot}"
state = _get_params_state(slot)
# Send one "status" blob per param (like backend publishStatusWs) so front updateWidget() runs and triggers reactivity
for key in _PARAM_IDS:
if key not in state:
continue
topic = f"{prefix}/{key}"
payload = json.dumps({"topic": topic, "status": state[key]})
await _send_bin(ws, "status", payload)
except Exception:
pass
# Chart points: backend Loging::publishValue sends chartb with one point; front apdateWidgetByArray merges into layout
now = int(time.time())
for (ws, slot) in list(_connections):
try:
prefix = f"/mock/d{slot}"
for topic_suf, base, amp in [("log", 20.0, 5.0), ("log2", 45.0, 15.0), ("log3", 100.0, 50.0), ("logBar", 30.0, 10.0)]:
topic = f"{prefix}/{topic_suf}"
val = base + amp * math.sin(now * 0.1)
point = {"topic": topic, "maxCount": 100, "status": [{"x": now, "y1": round(val, 2)}]}
await _send_bin(ws, "chartb", json.dumps(point))
except Exception:
pass
async def ws_handler(websocket):
slot = assign_slot()
peer = websocket.remote_address
_connections.append((websocket, slot))
print(f"[WS] Client connected {peer} -> slot {slot}")
try:
async for message in websocket:
if isinstance(message, str):
await handle_ws_message(websocket, message, slot)
# Binary from client not expected for these commands
except Exception as e:
print(f"[WS] Error slot {slot}: {e}")
finally:
_connections[:] = [(w, s) for w, s in _connections if w != websocket]
print(f"[WS] Client disconnected slot {slot}")
def run_http_server(host: str, port: int) -> None:
"""Run HTTP server in thread for /iotm/ver.json with CORS."""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/iotm/ver.json" or self.path == "/iotm/ver.json/":
body = json.dumps({
"esp32": {"versions": ["1.0.0", "1.0.1"]},
"esp8266": {"versions": ["1.0.0"]},
}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
print(f"[HTTP] {args[0]}")
server = HTTPServer((host, port), Handler)
print(f"[HTTP] Serving on http://{host}:{port}")
server.serve_forever()
def get_local_ip() -> str:
"""Prefer 127.0.0.1 for local dev."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0)
s.connect(("10.255.255.255", 1))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
async def main_async(ws_host: str, ws_port: int, http_host: str, http_port: int):
global HTTP_HOST, HTTP_PORT
# For settings.serverip and devlist use 127.0.0.1 when binding 0.0.0.0 (browser on same machine)
HTTP_HOST = "127.0.0.1" if http_host == "0.0.0.0" else http_host
HTTP_PORT = http_port
# Start HTTP server in daemon thread
t = threading.Thread(target=run_http_server, args=(http_host, http_port), daemon=True)
t.start()
local_ip = get_local_ip()
print("")
print("Mock backend: WS ws://{}:{}, HTTP http://{}:{}".format(ws_host, ws_port, http_host, http_port))
print("Add devices with IP {} (or 127.0.0.1 for local).".format(local_ip))
print("")
async with websockets.serve(ws_handler, ws_host, ws_port, ping_interval=20, ping_timeout=20):
asyncio.create_task(_periodic_broadcast())
await asyncio.Future()
def main():
parser = argparse.ArgumentParser(description="IoTManager mock backend (WS + HTTP)")
parser.add_argument("--host", default="0.0.0.0", help="Bind host for WS and HTTP")
parser.add_argument("--ws-port", type=int, default=81, help="WebSocket port")
parser.add_argument("--http-port", type=int, default=8081, help="HTTP port for /iotm/ver.json (avoid 8080 if frontend sirv uses it)")
args = parser.parse_args()
asyncio.run(main_async(args.host, args.ws_port, args.host, args.http_port))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
websockets>=12.0

View File

@@ -52,14 +52,14 @@
let preventMove = false;
let screenSize;
const blobDebug = false;
const devMode = false;
const devMode = true;
let percent;
//****************************************************variable section**********************************************************/
//******************************************************************************************************************************/
let myip = document.location.hostname;
if (devMode) myip = "192.168.1.247";
if (devMode) myip = "127.0.0.1";
//Flags
let firstDevListRequest = true;