2026-02-07 00:38:05 +01:00
|
|
|
|
#!/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 (L99–108)
|
|
|
|
|
|
- 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 (L188–200)
|
|
|
|
|
|
- firstDevListRequest = true
|
|
|
|
|
|
- connectToAllDevices() // "сначала подключимся к известному нам ip этого устройства"
|
|
|
|
|
|
|
|
|
|
|
|
3) connectToAllDevices (L226–236)
|
|
|
|
|
|
- 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) (L269–277)
|
|
|
|
|
|
- getIP(ws) from deviceList (device.ws === ws → device.ip)
|
|
|
|
|
|
- new WebSocket("ws://" + ip + ":81")
|
|
|
|
|
|
|
|
|
|
|
|
5) On socket open (L293–308)
|
|
|
|
|
|
- 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 (L347–354): header = blob.slice(0,6).text(), size = blob.slice(7,11).text()
|
|
|
|
|
|
- getPayloadAsJson(blob, size, out) (L578–589): 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" (L429–436): incDeviceList = out.json, await initDevList()
|
|
|
|
|
|
|
|
|
|
|
|
8) initDevList (L695–710)
|
|
|
|
|
|
- If firstDevListRequest: devListOverride() else devListCombine()
|
|
|
|
|
|
- firstDevListRequest = false
|
|
|
|
|
|
- onParced(), selectedDeviceDataRefresh(), connectToAllDevices()
|
|
|
|
|
|
|
|
|
|
|
|
9) devListOverride (L714–719): 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 (L898–905): 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:
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"""Widgets distributed across 3 devices so dashboard shows each widget exactly once (4+4+3)."""
|
2026-02-07 00:38:05 +01:00
|
|
|
|
prefix = f"/mock/d{device_slot}"
|
2026-03-08 22:59:40 +01:00
|
|
|
|
if device_slot == 0:
|
|
|
|
|
|
return [
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/anydata1", "page": "Сенсоры", "widget": "anydata", "descr": "Temperature", "status": "22.5", "after": "°C", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/chart1", "page": "Сенсоры", "widget": "chart", "descr": "Chart", "status": [], "maxCount": 100},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/toggle1", "page": "Сенсоры", "widget": "toggle", "descr": "Toggle", "status": "0", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/range1", "page": "Сенсоры", "widget": "range", "descr": "Range", "status": "50", "min": 0, "max": 100, "after": "%", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/num0", "page": "Управление", "widget": "input-number", "descr": "Number (int)", "status": "50", "mode": "int", "min": 0, "max": 100, "step": 1, "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/date0", "page": "Управление", "widget": "input-date", "descr": "Date", "status": "03.01.2025", "mode": "date", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/time0", "page": "Управление", "widget": "input-date", "descr": "Time", "status": "08:30", "mode": "time", "timePrecision": "hm", "sent": False},
|
|
|
|
|
|
]
|
|
|
|
|
|
if device_slot == 1:
|
|
|
|
|
|
return [
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/input1", "page": "Управление", "widget": "input-number", "descr": "Number (int)", "status": "50", "mode": "int", "min": 0, "max": 100, "step": 1, "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/num2", "page": "Управление", "widget": "input-number", "descr": "Number (float)", "status": "3.14", "mode": "float", "min": 0, "max": 10, "step": 0.1, "decimals": 2, "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/input2", "page": "Управление", "widget": "input", "descr": "Text", "status": "Room", "type": "text", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/input3", "page": "Управление", "widget": "input-date", "descr": "Date", "status": "03.01.2025", "mode": "date", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/input4", "page": "Управление", "widget": "input-date", "descr": "Time", "status": "08:30", "mode": "time", "timePrecision": "hm", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/date2", "page": "Управление", "widget": "input-date", "descr": "Date & time", "status": "03.01.2025 12:00", "mode": "datetime", "timePrecision": "hm", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/btn1", "page": "Управление", "widget": "btn", "descr": "Button", "status": "OK", "sent": False},
|
2026-03-08 23:58:35 +01:00
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/btnswitch1", "page": "Управление", "widget": "btn-switch", "descr": "Btn-switch (toggle)", "status": "0", "mode": "toggle", "sent": False},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/btnswitch2", "page": "Управление", "widget": "btn-switch", "descr": "Btn-switch (momentary)", "status": "0", "mode": "momentary", "sent": False},
|
2026-03-08 22:59:40 +01:00
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/progressline1", "page": "Управление", "widget": "progress-line", "descr": "Progress line", "status": "65", "min": 0, "max": 100, "before": "", "after": "%"},
|
|
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/progressround1", "page": "Управление", "widget": "progress-round", "descr": "Progress round", "status": "75", "min": 0, "max": 100, "after": "%", "semicircle": "1", "stroke": 10, "color": "#6366f1"},
|
|
|
|
|
|
]
|
2026-02-07 00:38:05 +01:00
|
|
|
|
return [
|
2026-03-08 22:59:40 +01:00
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/select1", "page": "Индикаторы", "widget": "select", "descr": "Select", "status": "1", "options": ["Option A", "Option B", "Option C"], "sent": False},
|
2026-03-08 23:58:35 +01:00
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/doughnut1", "page": "Индикаторы", "widget": "doughnut", "descr": "Doughnut", "status": [10, 50, 40], "labels": ["Error", "Success", "Warning"]},
|
2026-03-08 22:59:40 +01:00
|
|
|
|
{"ws": device_slot, "topic": f"{prefix}/fillgauge1", "page": "Индикаторы", "widget": "fillgauge", "descr": "Fill gauge", "status": "40", "min": 0, "max": 100, "before": "", "after": "%", "color": "#3b82f6"},
|
2026-02-07 00:38:05 +01:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Per-slot state for controls; keys = last segment of topic (id). Merged into get_params.
|
|
|
|
|
|
_params_state: dict = {} # slot -> { topic_id: value }
|
|
|
|
|
|
|
2026-02-09 00:06:48 +01:00
|
|
|
|
def _topic_id(topic: str) -> str:
|
|
|
|
|
|
"""Last segment of topic (e.g. /mock/d0/relay1 -> relay1)."""
|
|
|
|
|
|
if not topic or "/" not in topic:
|
|
|
|
|
|
return topic or ""
|
|
|
|
|
|
return topic.rstrip("/").split("/")[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_params_for_layout(slot: int, layout: list) -> None:
|
|
|
|
|
|
"""Add missing topic ids from layout to _params_state[slot] so added widgets get data."""
|
|
|
|
|
|
state = _get_params_state(slot)
|
|
|
|
|
|
for w in layout:
|
|
|
|
|
|
topic = w.get("topic") or ""
|
|
|
|
|
|
wid = _topic_id(topic)
|
|
|
|
|
|
if not wid or wid in state:
|
|
|
|
|
|
continue
|
|
|
|
|
|
widget_type = (w.get("widget") or "").lower()
|
|
|
|
|
|
if "chart" in widget_type:
|
2026-03-08 22:59:40 +01:00
|
|
|
|
# Chart is updated only via chartb; do not add to state so params never overwrite its status
|
|
|
|
|
|
continue
|
2026-02-09 00:06:48 +01:00
|
|
|
|
elif "toggle" in widget_type:
|
|
|
|
|
|
state[wid] = "0"
|
|
|
|
|
|
elif "range" in widget_type:
|
|
|
|
|
|
state[wid] = str(w.get("min", 0) if isinstance(w.get("min"), (int, float)) else 50)
|
2026-03-08 22:59:40 +01:00
|
|
|
|
elif widget_type == "input-number":
|
|
|
|
|
|
state[wid] = str(w.get("status", "0") if w.get("status") not in (None, "") else "0")
|
|
|
|
|
|
elif widget_type == "input-date":
|
|
|
|
|
|
mode = (w.get("mode") or "date").lower()
|
|
|
|
|
|
if mode == "time":
|
|
|
|
|
|
state[wid] = w.get("status") or "08:30"
|
|
|
|
|
|
elif mode == "datetime":
|
|
|
|
|
|
state[wid] = w.get("status") or "01.01.2025 12:00"
|
|
|
|
|
|
else:
|
|
|
|
|
|
state[wid] = w.get("status") or "01.01.2025"
|
2026-03-08 23:58:35 +01:00
|
|
|
|
elif widget_type == "btn-switch":
|
|
|
|
|
|
state[wid] = "0"
|
2026-02-09 00:06:48 +01:00
|
|
|
|
elif "input" in widget_type:
|
|
|
|
|
|
itype = w.get("type") or "number"
|
|
|
|
|
|
if itype == "number":
|
|
|
|
|
|
state[wid] = "20"
|
|
|
|
|
|
elif itype == "text":
|
|
|
|
|
|
state[wid] = "Room"
|
|
|
|
|
|
elif itype == "time":
|
|
|
|
|
|
state[wid] = "08:00"
|
|
|
|
|
|
elif itype == "date":
|
|
|
|
|
|
state[wid] = "2025-01-01"
|
|
|
|
|
|
else:
|
|
|
|
|
|
state[wid] = "0"
|
2026-03-08 22:59:40 +01:00
|
|
|
|
elif "btn" in widget_type:
|
|
|
|
|
|
state[wid] = "OK"
|
|
|
|
|
|
elif "progress-line" in widget_type:
|
|
|
|
|
|
state[wid] = str(w.get("status", 50) if w.get("status") not in (None, "") else 50)
|
|
|
|
|
|
elif "progress-round" in widget_type:
|
|
|
|
|
|
state[wid] = str(w.get("status", 50) if w.get("status") not in (None, "") else 50)
|
|
|
|
|
|
elif "select" in widget_type:
|
|
|
|
|
|
state[wid] = "0"
|
|
|
|
|
|
elif "doughnut" in widget_type:
|
2026-03-08 23:58:35 +01:00
|
|
|
|
st = w.get("status")
|
|
|
|
|
|
state[wid] = st if isinstance(st, list) else [st] if st not in (None, "") else [10, 50, 40]
|
2026-03-08 22:59:40 +01:00
|
|
|
|
elif "fillgauge" in widget_type:
|
|
|
|
|
|
state[wid] = str(w.get("status", 50) if w.get("status") not in (None, "") else 50)
|
|
|
|
|
|
elif "anydata" in widget_type:
|
|
|
|
|
|
state[wid] = str(w.get("status", ""))
|
2026-02-09 00:06:48 +01:00
|
|
|
|
else:
|
|
|
|
|
|
state[wid] = "0"
|
|
|
|
|
|
|
2026-02-07 00:38:05 +01:00
|
|
|
|
|
|
|
|
|
|
def _get_params_state(slot: int) -> dict:
|
|
|
|
|
|
if slot not in _params_state:
|
|
|
|
|
|
_params_state[slot] = {
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"anydata1": "22.5",
|
|
|
|
|
|
"toggle1": "0",
|
|
|
|
|
|
"range1": "50",
|
|
|
|
|
|
"input1": "50",
|
|
|
|
|
|
"num2": "3.14",
|
|
|
|
|
|
"input2": "Room",
|
|
|
|
|
|
"input3": "03.01.2025",
|
|
|
|
|
|
"input4": "08:30",
|
|
|
|
|
|
"date2": "03.01.2025 12:00",
|
|
|
|
|
|
"btn1": "OK",
|
|
|
|
|
|
"progressline1": "65",
|
|
|
|
|
|
"progressround1": "75",
|
|
|
|
|
|
"select1": "1",
|
2026-03-08 23:58:35 +01:00
|
|
|
|
"doughnut1": [10, 50, 40],
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"fillgauge1": "40",
|
2026-02-07 00:38:05 +01:00
|
|
|
|
}
|
|
|
|
|
|
return _params_state[slot]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 00:06:48 +01:00
|
|
|
|
def get_params(device_slot: int, layout: Optional[list] = None) -> dict:
|
|
|
|
|
|
"""Params JSON: topic id -> value (for layout widgets). If layout given, ensure all its topic ids have state."""
|
|
|
|
|
|
state = _get_params_state(device_slot)
|
|
|
|
|
|
if layout:
|
|
|
|
|
|
_ensure_params_for_layout(device_slot, layout)
|
|
|
|
|
|
return dict(state)
|
2026-02-07 00:38:05 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = {
|
2026-02-08 23:38:55 +01:00
|
|
|
|
"name": f"MockDevice-{slot}",
|
2026-02-07 00:38:05 +01:00
|
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-08 23:38:55 +01:00
|
|
|
|
def get_errors(slot: int = 0) -> dict:
|
|
|
|
|
|
"""Errors JSON for System page (per device): bn, bt, bver, wver, timenow, upt, uptm, uptw, rssi, heap, freeBytes, fl, rst."""
|
2026-02-07 00:38:05 +01:00
|
|
|
|
import time
|
2026-02-08 23:38:55 +01:00
|
|
|
|
ts = int(time.time())
|
|
|
|
|
|
# Different system info per device slot so UI shows which device is selected
|
2026-02-07 00:38:05 +01:00
|
|
|
|
return {
|
2026-02-08 23:38:55 +01:00
|
|
|
|
"bn": f"esp32-d{slot}",
|
|
|
|
|
|
"bt": f"2024-0{1 + slot}-0{1 + slot} 12:00",
|
|
|
|
|
|
"bver": f"1.0.{slot}",
|
|
|
|
|
|
"wver": f"4.2.{slot}",
|
|
|
|
|
|
"timenow": str(ts),
|
|
|
|
|
|
"upt": f"{1 + slot}d 0{2 + slot}:{30 + slot * 5}",
|
|
|
|
|
|
"uptm": f"{1 + slot}d 0{2 + slot}:{30 + slot * 5}",
|
|
|
|
|
|
"uptw": f"{1 + slot}d 0{2 + slot}:{30 + slot * 5}",
|
|
|
|
|
|
"rssi": -65 + slot * 10,
|
|
|
|
|
|
"heap": str(120000 - slot * 10000),
|
|
|
|
|
|
"freeBytes": f"{2 - slot * 0.2:.1f}M",
|
|
|
|
|
|
"fl": str(1024 + slot * 512),
|
|
|
|
|
|
"rst": "Software reset" if slot == 0 else "Power-on reset",
|
2026-02-07 00:38:05 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"""Three IoT Manager devices (same firmware); frontend connects to each, each returns same layout protocol."""
|
2026-02-07 00:38:05 +01:00
|
|
|
|
return _normalize_devlist([
|
2026-03-08 22:59:40 +01:00
|
|
|
|
{"name": "IoTManager 1", "ip": host, "id": "iotm-1", "status": False, "fv": "1.0.0", "wg": "group1"},
|
|
|
|
|
|
{"name": "IoTManager 2", "ip": host, "id": "iotm-2", "status": False, "fv": "1.0.0", "wg": "group1"},
|
|
|
|
|
|
{"name": "IoTManager 3", "ip": host, "id": "iotm-3", "status": False, "fv": "1.0.0", "wg": "group1"},
|
2026-02-07 00:38:05 +01:00
|
|
|
|
], host)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Saved device list from /tsil| (reversed "list"). When udps=0 backend returns from "file", else from "heap" (default).
|
|
|
|
|
|
_saved_devlist: Optional[list] = None
|
2026-02-09 00:06:48 +01:00
|
|
|
|
# Configurator: saved layout/config/scenario per slot (from /tuoyal|, /gifnoc|, /oiranecs|)
|
|
|
|
|
|
_saved_layout: dict = {} # slot -> list (layout JSON array)
|
|
|
|
|
|
_saved_config: dict = {} # slot -> list (config JSON array)
|
|
|
|
|
|
_saved_scenario: dict = {} # slot -> str (scenario text)
|
2026-02-07 00:38:05 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_items_json() -> list:
|
2026-02-09 00:06:48 +01:00
|
|
|
|
"""Items list covering all widget types (anydata, chart, toggle, range, input)."""
|
2026-02-07 00:38:05 +01:00
|
|
|
|
return [
|
|
|
|
|
|
{"name": "Выберите элемент", "num": 0},
|
|
|
|
|
|
{"header": "virtual_elments"},
|
2026-02-09 00:06:48 +01:00
|
|
|
|
# anydata
|
|
|
|
|
|
{"global": 0, "name": "Text", "type": "Reading", "subtype": "Variable", "id": "text", "widget": "anydataDef", "page": "Сенсоры", "descr": "Text", "num": 1},
|
|
|
|
|
|
{"global": 0, "name": "Temperature", "type": "Reading", "subtype": "AnalogAdc", "id": "temp", "widget": "anydataTmp", "page": "Сенсоры", "descr": "Temperature", "num": 2},
|
|
|
|
|
|
{"global": 0, "name": "Humidity", "type": "Reading", "subtype": "Variable", "id": "hum", "widget": "anydataDef", "page": "Сенсоры", "descr": "Humidity", "num": 3},
|
|
|
|
|
|
{"global": 0, "name": "Pressure", "type": "Reading", "subtype": "Variable", "id": "pressure", "widget": "anydataDef", "page": "Сенсоры", "descr": "Pressure", "num": 4},
|
|
|
|
|
|
{"global": 0, "name": "Voltage", "type": "Reading", "subtype": "Variable", "id": "voltage", "widget": "anydataDef", "page": "Сенсоры", "descr": "Voltage", "num": 5},
|
|
|
|
|
|
{"global": 0, "name": "Power", "type": "Reading", "subtype": "Variable", "id": "power", "widget": "anydataDef", "page": "Сенсоры", "descr": "Power", "num": 6},
|
|
|
|
|
|
{"global": 0, "name": "RSSI", "type": "Reading", "subtype": "Variable", "id": "rssi", "widget": "anydataDef", "page": "Сенсоры", "descr": "WiFi RSSI", "num": 7},
|
|
|
|
|
|
# chart
|
|
|
|
|
|
{"global": 0, "name": "Chart line", "type": "Writing", "subtype": "Loging", "id": "log", "widget": "chart2", "page": "Графики 1", "descr": "Chart", "num": 10, "points": 100},
|
|
|
|
|
|
{"global": 0, "name": "Chart bar", "type": "Writing", "subtype": "Loging", "id": "logBar", "widget": "chartBar", "page": "Графики 2", "descr": "Bar chart", "num": 11, "points": 100},
|
|
|
|
|
|
# toggle
|
|
|
|
|
|
{"global": 0, "name": "Toggle", "type": "Writing", "subtype": "ButtonOut", "id": "relay", "widget": "toggleDef", "page": "Реле и свет", "descr": "Relay", "num": 20},
|
|
|
|
|
|
{"global": 0, "name": "Light", "type": "Writing", "subtype": "ButtonOut", "id": "light", "widget": "toggleDef", "page": "Реле и свет", "descr": "Light", "num": 21},
|
|
|
|
|
|
{"global": 0, "name": "Fan", "type": "Writing", "subtype": "ButtonOut", "id": "fan", "widget": "toggleDef", "page": "Реле и свет", "descr": "Fan", "num": 22},
|
|
|
|
|
|
# range
|
|
|
|
|
|
{"global": 0, "name": "Dimmer", "type": "Writing", "subtype": "Variable", "id": "dimmer", "widget": "rangeDef", "page": "Параметры", "descr": "Dimmer", "num": 30},
|
|
|
|
|
|
{"global": 0, "name": "Volume", "type": "Writing", "subtype": "Variable", "id": "volume", "widget": "rangeDef", "page": "Параметры", "descr": "Volume", "num": 31},
|
|
|
|
|
|
{"global": 0, "name": "Setpoint", "type": "Writing", "subtype": "Variable", "id": "setpoint", "widget": "rangeDef", "page": "Параметры", "descr": "Setpoint", "num": 32},
|
|
|
|
|
|
# input
|
|
|
|
|
|
{"global": 0, "name": "Input number", "type": "Reading", "subtype": "Variable", "id": "settemp", "widget": "inputNumber", "page": "Параметры", "descr": "Number", "num": 40},
|
|
|
|
|
|
{"global": 0, "name": "Input text", "type": "Reading", "subtype": "Variable", "id": "label", "widget": "inputText", "page": "Параметры", "descr": "Label", "num": 41},
|
|
|
|
|
|
{"global": 0, "name": "Input time", "type": "Reading", "subtype": "Variable", "id": "alarmtime", "widget": "inputTime", "page": "Параметры", "descr": "Time", "num": 42},
|
|
|
|
|
|
{"global": 0, "name": "Input date", "type": "Reading", "subtype": "Variable", "id": "eventdate", "widget": "inputDate", "page": "Параметры", "descr": "Date", "num": 43},
|
2026-02-07 00:38:05 +01:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_widgets_json() -> list:
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"""All frontend widget types. input-number and input-date are separate (same as app collection)."""
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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": "inputText", "label": "Text", "widget": "input", "type": "text", "icon": ""},
|
2026-03-08 22:59:40 +01:00
|
|
|
|
{"name": "inputNumberInt", "label": "Input number (int)", "widget": "input-number", "mode": "int", "min": 0, "max": 100, "step": 1, "icon": ""},
|
|
|
|
|
|
{"name": "inputNumberFloat", "label": "Input number (float)", "widget": "input-number", "mode": "float", "min": 0, "max": 10, "step": 0.1, "decimals": 2, "icon": ""},
|
|
|
|
|
|
{"name": "inputDateDate", "label": "Input date", "widget": "input-date", "mode": "date", "icon": ""},
|
|
|
|
|
|
{"name": "inputDateTime", "label": "Input time", "widget": "input-date", "mode": "time", "timePrecision": "hm", "icon": ""},
|
|
|
|
|
|
{"name": "inputDateDatetime", "label": "Input date & time", "widget": "input-date", "mode": "datetime", "timePrecision": "hm", "icon": ""},
|
2026-03-08 23:58:35 +01:00
|
|
|
|
{"name": "btnSwitchToggle", "label": "Btn-switch (toggle)", "widget": "btn-switch", "mode": "toggle", "icon": ""},
|
|
|
|
|
|
{"name": "btnSwitchMomentary", "label": "Btn-switch (momentary)", "widget": "btn-switch", "mode": "momentary", "icon": ""},
|
2026-02-07 00:38:05 +01:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"""Assign slot 0, 1, 2 for 3 devices so each connection gets a unique layout (no duplicate widgets)."""
|
2026-02-07 00:38:05 +01:00
|
|
|
|
global _next_slot
|
|
|
|
|
|
with _slot_lock:
|
2026-03-08 22:59:40 +01:00
|
|
|
|
s = _next_slot % 3
|
2026-02-07 00:38:05 +01:00
|
|
|
|
_next_slot += 1
|
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_ws_message(ws, message: str, slot: int) -> None:
|
|
|
|
|
|
"""Handle text command from frontend and send responses."""
|
2026-02-09 00:06:48 +01:00
|
|
|
|
global _saved_devlist, _saved_layout, _saved_config, _saved_scenario
|
2026-02-07 00:38:05 +01:00
|
|
|
|
if "|" not in message:
|
|
|
|
|
|
return
|
|
|
|
|
|
cmd = message.split("|")[0] + "|"
|
|
|
|
|
|
|
2026-02-09 00:06:48 +01:00
|
|
|
|
# Heartbeat/ping: same as original IoTManager WsServer.cpp — reply immediately so frontend can measure RTT (device.ping)
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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 == "/|":
|
2026-02-09 00:06:48 +01:00
|
|
|
|
layout = _saved_layout.get(slot) if slot in _saved_layout else get_layout(slot)
|
2026-02-07 00:38:05 +01:00
|
|
|
|
await send_bin("layout", json.dumps(layout))
|
2026-02-09 00:06:48 +01:00
|
|
|
|
# Send params immediately so front has data (incl. for saved-layout widgets)
|
|
|
|
|
|
await send_bin("params", json.dumps(get_params(slot, layout)))
|
2026-02-07 00:38:05 +01:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if cmd == "/params|":
|
2026-02-09 00:06:48 +01:00
|
|
|
|
layout = _saved_layout.get(slot) if slot in _saved_layout else get_layout(slot)
|
|
|
|
|
|
params = get_params(slot, layout)
|
2026-02-07 00:38:05 +01:00
|
|
|
|
await send_bin("params", json.dumps(params))
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if cmd == "/charts|":
|
2026-02-09 00:06:48 +01:00
|
|
|
|
layout = _saved_layout.get(slot) if slot in _saved_layout else get_layout(slot)
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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()))
|
2026-02-09 00:06:48 +01:00
|
|
|
|
config_data = _saved_config[slot] if slot in _saved_config else get_config_json()
|
|
|
|
|
|
await send_bin("config", json.dumps(config_data))
|
|
|
|
|
|
scenario_txt = _saved_scenario.get(slot, "// mock scenario\n")
|
|
|
|
|
|
await send_bin("scenar", scenario_txt if isinstance(scenario_txt, str) else "// mock scenario\n")
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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()))
|
2026-02-08 23:38:55 +01:00
|
|
|
|
await send_bin("errors", json.dumps(get_errors(slot)))
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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|":
|
2026-02-08 23:38:55 +01:00
|
|
|
|
await send_bin("errors", json.dumps(get_errors(slot)))
|
2026-02-07 00:38:05 +01:00
|
|
|
|
await send_bin("settin", json.dumps(get_settings(HTTP_HOST, HTTP_PORT, slot)))
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if cmd == "/dev|":
|
2026-02-08 23:38:55 +01:00
|
|
|
|
await send_bin("errors", json.dumps(get_errors(slot)))
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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
|
2026-02-08 23:38:55 +01:00
|
|
|
|
await send_bin("errors", json.dumps(get_errors(slot)))
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-09 00:06:48 +01:00
|
|
|
|
# Save configurator data (layout, config, scenario) per slot
|
|
|
|
|
|
if cmd == "/tuoyal|":
|
|
|
|
|
|
payload = message[len(cmd) :].strip()
|
|
|
|
|
|
if payload:
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(payload)
|
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
|
_saved_layout[slot] = data
|
|
|
|
|
|
_ensure_params_for_layout(slot, data)
|
|
|
|
|
|
print(f"[mock] layout saved for slot {slot}, {len(data)} widget(s)")
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return
|
|
|
|
|
|
if cmd == "/gifnoc|":
|
|
|
|
|
|
payload = message[len(cmd) :].strip()
|
|
|
|
|
|
if payload:
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(payload)
|
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
|
_saved_config[slot] = data
|
|
|
|
|
|
print(f"[mock] config saved for slot {slot}, {len(data)} item(s)")
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return
|
|
|
|
|
|
if cmd == "/oiranecs|":
|
|
|
|
|
|
payload = message[len(cmd) :].strip()
|
|
|
|
|
|
_saved_scenario[slot] = payload if payload is not None else ""
|
|
|
|
|
|
print(f"[mock] scenario saved for slot {slot}")
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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:
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"""Update read-only widget values in _params_state (anydata, progress, doughnut, fillgauge)."""
|
2026-02-07 00:38:05 +01:00
|
|
|
|
t = time.time()
|
|
|
|
|
|
for slot in list(_params_state.keys()):
|
|
|
|
|
|
s = _params_state[slot]
|
2026-03-08 22:59:40 +01:00
|
|
|
|
if "anydata1" in s:
|
|
|
|
|
|
s["anydata1"] = f"{22 + 3 * math.sin(t * 0.5):.1f}"
|
|
|
|
|
|
if "progressline1" in s:
|
|
|
|
|
|
s["progressline1"] = str(int(50 + 20 * math.sin(t * 0.3)))
|
|
|
|
|
|
if "progressround1" in s:
|
|
|
|
|
|
s["progressround1"] = str(int(50 + 25 * math.sin(t * 0.25)))
|
|
|
|
|
|
if "doughnut1" in s:
|
2026-03-08 23:58:35 +01:00
|
|
|
|
s["doughnut1"] = [
|
|
|
|
|
|
int(10 + 15 * math.sin(t * 0.2)),
|
|
|
|
|
|
int(50 + 20 * math.sin(t * 0.25)),
|
|
|
|
|
|
int(40 + 25 * math.sin(t * 0.3)),
|
|
|
|
|
|
]
|
2026-03-08 22:59:40 +01:00
|
|
|
|
if "fillgauge1" in s:
|
|
|
|
|
|
s["fillgauge1"] = str(int(40 + 25 * math.sin(t * 0.35)))
|
2026-02-07 00:38:05 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Interval (seconds): backend sends status on sensor change (publishStatusWs); we emulate same.
|
|
|
|
|
|
PARAMS_INTERVAL = 3
|
|
|
|
|
|
|
2026-03-08 22:59:40 +01:00
|
|
|
|
# Param keys for widgets that receive status blob (chart1 is updated only via chartb).
|
2026-02-07 00:38:05 +01:00
|
|
|
|
_PARAM_IDS = [
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"anydata1", "toggle1", "range1", "num0", "date0", "time0",
|
2026-03-08 23:58:35 +01:00
|
|
|
|
"input1", "num2", "input2", "input3", "input4", "date2", "btn1", "btnswitch1", "btnswitch2",
|
2026-03-08 22:59:40 +01:00
|
|
|
|
"progressline1", "progressround1", "select1", "doughnut1", "fillgauge1",
|
2026-02-07 00:38:05 +01:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-08 22:59:40 +01:00
|
|
|
|
# Chart points: only slot 0 has chart widget in layout; skip chartb for other slots to avoid "topic not found"
|
2026-02-07 00:38:05 +01:00
|
|
|
|
now = int(time.time())
|
|
|
|
|
|
for (ws, slot) in list(_connections):
|
2026-03-08 22:59:40 +01:00
|
|
|
|
if slot != 0:
|
|
|
|
|
|
continue
|
2026-02-07 00:38:05 +01:00
|
|
|
|
try:
|
|
|
|
|
|
prefix = f"/mock/d{slot}"
|
2026-03-08 22:59:40 +01:00
|
|
|
|
topic = f"{prefix}/chart1"
|
|
|
|
|
|
val = 20.0 + 5.0 * 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))
|
2026-02-07 00:38:05 +01:00
|
|
|
|
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()
|