mirror of
https://github.com/IoTManagerProject/IoTManagerWeb.git
synced 2026-03-30 11:59:21 +03:00
Enhance layout handling and debugging in WebSocketManager; update mock_backend and App.svelte for improved widget integration and console logging.
This commit is contained in:
@@ -42,6 +42,8 @@ export default {
|
|||||||
file: "public/build/bundle.js",
|
file: "public/build/bundle.js",
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// Minify and drop console only in production (npm run build); in dev (npm run dev) keep console
|
||||||
|
production &&
|
||||||
terser({
|
terser({
|
||||||
ecma: 2020,
|
ecma: 2020,
|
||||||
mangle: { toplevel: true },
|
mangle: { toplevel: true },
|
||||||
@@ -87,9 +89,6 @@ export default {
|
|||||||
// browser on changes when not in production
|
// browser on changes when not in production
|
||||||
!production && livereload("public"),
|
!production && livereload("public"),
|
||||||
|
|
||||||
// If we're building for production (npm run build
|
|
||||||
// instead of npm run dev), minify
|
|
||||||
production && terser(),
|
|
||||||
],
|
],
|
||||||
watch: {
|
watch: {
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ Emulates the IoTManager ESP backend: WebSocket on port 81, HTTP for `/iotm/ver.j
|
|||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
**Option A — from `scripts/` (venv already in scripts):**
|
||||||
|
```bash
|
||||||
|
cd IoTManagerWeb/scripts
|
||||||
|
.venv/bin/python mock_backend.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — from project root:**
|
||||||
```bash
|
```bash
|
||||||
# From IoTManagerWeb root
|
|
||||||
python3 -m venv .venv-mock
|
python3 -m venv .venv-mock
|
||||||
.venv-mock/bin/pip install -r scripts/requirements-mock.txt
|
.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
|
.venv-mock/bin/python scripts/mock_backend.py --host 0.0.0.0 --ws-port 81 --http-port 8081
|
||||||
|
|||||||
@@ -78,34 +78,34 @@ def make_binary_frame(header: str, payload: str) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def get_layout(device_slot: int) -> list:
|
def get_layout(device_slot: int) -> list:
|
||||||
"""Layout: 4 pages by meaning (Sensors, Charts, Relays/Light, Params). All 5 widget types."""
|
"""Widgets distributed across 3 devices so dashboard shows each widget exactly once (4+4+3)."""
|
||||||
prefix = f"/mock/d{device_slot}"
|
prefix = f"/mock/d{device_slot}"
|
||||||
|
if device_slot == 0:
|
||||||
return [
|
return [
|
||||||
# --- 1. Сенсоры: read-only anydata ---
|
{"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}/temp", "page": "Сенсоры", "widget": "anydata", "descr": "Temperature", "status": "22.5", "after": "°C"},
|
{"ws": device_slot, "topic": f"{prefix}/chart1", "page": "Сенсоры", "widget": "chart", "descr": "Chart", "status": [], "maxCount": 100},
|
||||||
{"ws": device_slot, "topic": f"{prefix}/hum", "page": "Сенсоры", "widget": "anydata", "descr": "Humidity", "status": "45", "after": "%"},
|
{"ws": device_slot, "topic": f"{prefix}/toggle1", "page": "Сенсоры", "widget": "toggle", "descr": "Toggle", "status": "0", "sent": False},
|
||||||
{"ws": device_slot, "topic": f"{prefix}/pressure", "page": "Сенсоры", "widget": "anydata", "descr": "Pressure", "status": "1013", "after": " hPa"},
|
{"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}/voltage", "page": "Сенсоры", "widget": "anydata", "descr": "Voltage", "status": "3.28", "after": " V"},
|
{"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}/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}/date0", "page": "Управление", "widget": "input-date", "descr": "Date", "status": "03.01.2025", "mode": "date", "sent": False},
|
||||||
{"ws": device_slot, "topic": f"{prefix}/rssi", "page": "Сенсоры", "widget": "anydata", "descr": "WiFi RSSI", "status": "-65", "after": " dBm"},
|
{"ws": device_slot, "topic": f"{prefix}/time0", "page": "Управление", "widget": "input-date", "descr": "Time", "status": "08:30", "mode": "time", "timePrecision": "hm", "sent": False},
|
||||||
# --- 2. Графики 1 и 2 (two cards) ---
|
]
|
||||||
{"ws": device_slot, "topic": f"{prefix}/log", "page": "Графики 1", "widget": "chart", "descr": "Temperature log", "status": [], "maxCount": 100},
|
if device_slot == 1:
|
||||||
{"ws": device_slot, "topic": f"{prefix}/log2", "page": "Графики 1", "widget": "chart", "descr": "Humidity log", "status": [], "maxCount": 100},
|
return [
|
||||||
{"ws": device_slot, "topic": f"{prefix}/log3", "page": "Графики 2", "widget": "chart", "descr": "Power log", "status": [], "maxCount": 100},
|
{"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}/logBar", "page": "Графики 2", "widget": "chart", "type": "bar", "descr": "Bar chart", "status": [], "maxCount": 100},
|
{"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},
|
||||||
# --- 3. Реле и свет: toggles + dimmer ---
|
{"ws": device_slot, "topic": f"{prefix}/input2", "page": "Управление", "widget": "input", "descr": "Text", "status": "Room", "type": "text", "sent": False},
|
||||||
{"ws": device_slot, "topic": f"{prefix}/relay1", "page": "Реле и свет", "widget": "toggle", "descr": "Relay 1", "status": "0", "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}/relay2", "page": "Реле и свет", "widget": "toggle", "descr": "Relay 2", "status": "1", "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}/relay3", "page": "Реле и свет", "widget": "toggle", "descr": "Light", "status": "0", "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}/fan", "page": "Реле и свет", "widget": "toggle", "descr": "Fan", "status": "0", "sent": False},
|
{"ws": device_slot, "topic": f"{prefix}/btn1", "page": "Управление", "widget": "btn", "descr": "Button", "status": "OK", "sent": False},
|
||||||
{"ws": device_slot, "topic": f"{prefix}/dimmer", "page": "Реле и свет", "widget": "range", "descr": "Dimmer", "status": "50", "min": 0, "max": 100, "after": "%", "sent": False},
|
{"ws": device_slot, "topic": f"{prefix}/progressline1", "page": "Управление", "widget": "progress-line", "descr": "Progress line", "status": "65", "min": 0, "max": 100, "before": "", "after": "%"},
|
||||||
# --- 4. Параметры: range + inputs ---
|
{"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"},
|
||||||
{"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},
|
return [
|
||||||
{"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}/select1", "page": "Индикаторы", "widget": "select", "descr": "Select", "status": "1", "options": ["Option A", "Option B", "Option C"], "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}/doughnut1", "page": "Индикаторы", "widget": "doughnut", "descr": "Doughnut", "status": "60", "max": 100, "before": "", "after": "%", "stroke": 12, "color": "#10b981"},
|
||||||
{"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}/fillgauge1", "page": "Индикаторы", "widget": "fillgauge", "descr": "Fill gauge", "status": "40", "min": 0, "max": 100, "before": "", "after": "%", "color": "#3b82f6"},
|
||||||
{"ws": device_slot, "topic": f"{prefix}/eventdate", "page": "Параметры", "widget": "input", "descr": "Event date", "status": "2025-02-06", "type": "date", "sent": False},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -129,11 +129,22 @@ def _ensure_params_for_layout(slot: int, layout: list) -> None:
|
|||||||
continue
|
continue
|
||||||
widget_type = (w.get("widget") or "").lower()
|
widget_type = (w.get("widget") or "").lower()
|
||||||
if "chart" in widget_type:
|
if "chart" in widget_type:
|
||||||
state[wid] = ""
|
# Chart is updated only via chartb; do not add to state so params never overwrite its status
|
||||||
|
continue
|
||||||
elif "toggle" in widget_type:
|
elif "toggle" in widget_type:
|
||||||
state[wid] = "0"
|
state[wid] = "0"
|
||||||
elif "range" in widget_type:
|
elif "range" in widget_type:
|
||||||
state[wid] = str(w.get("min", 0) if isinstance(w.get("min"), (int, float)) else 50)
|
state[wid] = str(w.get("min", 0) if isinstance(w.get("min"), (int, float)) else 50)
|
||||||
|
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"
|
||||||
elif "input" in widget_type:
|
elif "input" in widget_type:
|
||||||
itype = w.get("type") or "number"
|
itype = w.get("type") or "number"
|
||||||
if itype == "number":
|
if itype == "number":
|
||||||
@@ -146,19 +157,42 @@ def _ensure_params_for_layout(slot: int, layout: list) -> None:
|
|||||||
state[wid] = "2025-01-01"
|
state[wid] = "2025-01-01"
|
||||||
else:
|
else:
|
||||||
state[wid] = "0"
|
state[wid] = "0"
|
||||||
|
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:
|
||||||
|
state[wid] = str(w.get("status", 50) if w.get("status") not in (None, "") else 50)
|
||||||
|
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", ""))
|
||||||
else:
|
else:
|
||||||
state[wid] = "0"
|
state[wid] = "0"
|
||||||
|
|
||||||
|
|
||||||
def _get_params_state(slot: int) -> dict:
|
def _get_params_state(slot: int) -> dict:
|
||||||
if slot not in _params_state:
|
if slot not in _params_state:
|
||||||
prefix = f"/mock/d{slot}"
|
|
||||||
_params_state[slot] = {
|
_params_state[slot] = {
|
||||||
"temp": "22.5", "hum": "45", "pressure": "1013", "voltage": "3.28", "power": "120", "rssi": "-65",
|
"anydata1": "22.5",
|
||||||
"relay1": "0", "relay2": "1", "relay3": "0", "fan": "0",
|
"toggle1": "0",
|
||||||
"dimmer": "50", "volume": "70", "setpoint": "21",
|
"range1": "50",
|
||||||
"settemp": "20.5", "label": "Room 1", "alarmtime": "08:30", "eventdate": "2025-02-06",
|
"input1": "50",
|
||||||
"log": "", "log2": "", "log3": "", "logBar": "",
|
"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",
|
||||||
|
"doughnut1": "60",
|
||||||
|
"fillgauge1": "40",
|
||||||
}
|
}
|
||||||
return _params_state[slot]
|
return _params_state[slot]
|
||||||
|
|
||||||
@@ -272,10 +306,11 @@ def _normalize_devlist(arr: list, default_ip: str = "127.0.0.1") -> list:
|
|||||||
|
|
||||||
|
|
||||||
def get_devlist(host: str) -> list:
|
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."""
|
"""Three IoT Manager devices (same firmware); frontend connects to each, each returns same layout protocol."""
|
||||||
return _normalize_devlist([
|
return _normalize_devlist([
|
||||||
{"name": "Mock Device 1", "ip": host, "id": "mock-dev-1", "status": False, "fv": "1.0.0", "wg": "group1"},
|
{"name": "IoTManager 1", "ip": host, "id": "iotm-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"},
|
{"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"},
|
||||||
], host)
|
], host)
|
||||||
|
|
||||||
|
|
||||||
@@ -320,7 +355,7 @@ def get_items_json() -> list:
|
|||||||
|
|
||||||
|
|
||||||
def get_widgets_json() -> list:
|
def get_widgets_json() -> list:
|
||||||
"""All frontend widget types: anydata, chart (line + bar), toggle, range, input (number, text, time, date)."""
|
"""All frontend widget types. input-number and input-date are separate (same as app collection)."""
|
||||||
return [
|
return [
|
||||||
{"name": "anydataDef", "label": "Text", "widget": "anydata", "icon": ""},
|
{"name": "anydataDef", "label": "Text", "widget": "anydata", "icon": ""},
|
||||||
{"name": "anydataTmp", "label": "Temperature", "widget": "anydata", "after": "°C", "icon": "speedometer"},
|
{"name": "anydataTmp", "label": "Temperature", "widget": "anydata", "after": "°C", "icon": "speedometer"},
|
||||||
@@ -328,10 +363,12 @@ def get_widgets_json() -> list:
|
|||||||
{"name": "chartBar", "label": "Chart bar", "widget": "chart", "type": "bar", "icon": ""},
|
{"name": "chartBar", "label": "Chart bar", "widget": "chart", "type": "bar", "icon": ""},
|
||||||
{"name": "toggleDef", "label": "Toggle", "widget": "toggle", "icon": ""},
|
{"name": "toggleDef", "label": "Toggle", "widget": "toggle", "icon": ""},
|
||||||
{"name": "rangeDef", "label": "Range", "widget": "range", "min": 0, "max": 100, "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": "inputText", "label": "Text", "widget": "input", "type": "text", "icon": ""},
|
||||||
{"name": "inputTime", "label": "Time", "widget": "input", "type": "time", "icon": ""},
|
{"name": "inputNumberInt", "label": "Input number (int)", "widget": "input-number", "mode": "int", "min": 0, "max": 100, "step": 1, "icon": ""},
|
||||||
{"name": "inputDate", "label": "Date", "widget": "input", "type": "date", "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": ""},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -362,9 +399,10 @@ _connections: list = [] # list of (websocket, slot)
|
|||||||
|
|
||||||
|
|
||||||
def assign_slot() -> int:
|
def assign_slot() -> int:
|
||||||
|
"""Assign slot 0, 1, 2 for 3 devices so each connection gets a unique layout (no duplicate widgets)."""
|
||||||
global _next_slot
|
global _next_slot
|
||||||
with _slot_lock:
|
with _slot_lock:
|
||||||
s = _next_slot % 2
|
s = _next_slot % 3
|
||||||
_next_slot += 1
|
_next_slot += 1
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@@ -551,27 +589,30 @@ async def _send_bin(ws, header: str, payload: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _tick_sensor_values() -> None:
|
def _tick_sensor_values() -> None:
|
||||||
"""Update read-only sensor values in _params_state (like backend sensors in loop)."""
|
"""Update read-only widget values in _params_state (anydata, progress, doughnut, fillgauge)."""
|
||||||
t = time.time()
|
t = time.time()
|
||||||
for slot in list(_params_state.keys()):
|
for slot in list(_params_state.keys()):
|
||||||
s = _params_state[slot]
|
s = _params_state[slot]
|
||||||
# Small variation around base values
|
if "anydata1" in s:
|
||||||
s["temp"] = f"{22 + 3 * math.sin(t * 0.5):.1f}"
|
s["anydata1"] = f"{22 + 3 * math.sin(t * 0.5):.1f}"
|
||||||
s["hum"] = str(int(45 + 10 * math.sin(t * 0.3)))
|
if "progressline1" in s:
|
||||||
s["pressure"] = str(1012 + int(5 * math.sin(t * 0.2)))
|
s["progressline1"] = str(int(50 + 20 * math.sin(t * 0.3)))
|
||||||
s["voltage"] = f"{3.2 + 0.1 * math.sin(t):.2f}"
|
if "progressround1" in s:
|
||||||
s["power"] = str(int(120 + 30 * math.sin(t * 0.4)))
|
s["progressround1"] = str(int(50 + 25 * math.sin(t * 0.25)))
|
||||||
s["rssi"] = str(int(-65 + 5 * math.sin(t * 0.5)))
|
if "doughnut1" in s:
|
||||||
|
s["doughnut1"] = str(int(50 + 30 * math.sin(t * 0.2)))
|
||||||
|
if "fillgauge1" in s:
|
||||||
|
s["fillgauge1"] = str(int(40 + 25 * math.sin(t * 0.35)))
|
||||||
|
|
||||||
|
|
||||||
# Interval (seconds): backend sends status on sensor change (publishStatusWs); we emulate same.
|
# Interval (seconds): backend sends status on sensor change (publishStatusWs); we emulate same.
|
||||||
PARAMS_INTERVAL = 3
|
PARAMS_INTERVAL = 3
|
||||||
|
|
||||||
# Param keys that are widget values (not chart). Front updateWidget(topic, status) matches layout by full topic.
|
# Param keys for widgets that receive status blob (chart1 is updated only via chartb).
|
||||||
_PARAM_IDS = [
|
_PARAM_IDS = [
|
||||||
"temp", "hum", "pressure", "voltage", "power", "rssi",
|
"anydata1", "toggle1", "range1", "num0", "date0", "time0",
|
||||||
"relay1", "relay2", "relay3", "fan", "dimmer", "volume", "setpoint",
|
"input1", "num2", "input2", "input3", "input4", "date2", "btn1",
|
||||||
"settemp", "label", "alarmtime", "eventdate",
|
"progressline1", "progressround1", "select1", "doughnut1", "fillgauge1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -600,14 +641,15 @@ async def _periodic_broadcast() -> None:
|
|||||||
await _send_bin(ws, "status", payload)
|
await _send_bin(ws, "status", payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Chart points: backend Loging::publishValue sends chartb with one point; front apdateWidgetByArray merges into layout
|
# Chart points: only slot 0 has chart widget in layout; skip chartb for other slots to avoid "topic not found"
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
for (ws, slot) in list(_connections):
|
for (ws, slot) in list(_connections):
|
||||||
|
if slot != 0:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
prefix = f"/mock/d{slot}"
|
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}/chart1"
|
||||||
topic = f"{prefix}/{topic_suf}"
|
val = 20.0 + 5.0 * math.sin(now * 0.1)
|
||||||
val = base + amp * math.sin(now * 0.1)
|
|
||||||
point = {"topic": topic, "maxCount": 100, "status": [{"x": now, "y1": round(val, 2)}]}
|
point = {"topic": topic, "maxCount": 100, "status": [{"x": now, "y1": round(val, 2)}]}
|
||||||
await _send_bin(ws, "chartb", json.dumps(point))
|
await _send_bin(ws, "chartb", json.dumps(point))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
console.log("[i]", "mounted");
|
console.log("[i]", "mounted");
|
||||||
|
console.log("[layout] Layout debug ON. Open dashboard (/) to see layout logs.");
|
||||||
const JWT = Cookies.get("token_iotm2");
|
const JWT = Cookies.get("token_iotm2");
|
||||||
const res = await portal.getUser(JWT);
|
const res = await portal.getUser(JWT);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -267,14 +268,14 @@
|
|||||||
}
|
}
|
||||||
/*=============================================card and items inside===============================================*/
|
/*=============================================card and items inside===============================================*/
|
||||||
.crd-itm-psn {
|
.crd-itm-psn {
|
||||||
@apply flex mb-2 h-6 items-center;
|
@apply flex min-h-[2.25rem] mb-4 items-center;
|
||||||
}
|
}
|
||||||
.wgt-dscr-stl {
|
.wgt-dscr-stl {
|
||||||
@apply pr-4 text-gray-500 font-bold;
|
@apply pr-4 text-gray-500 font-bold;
|
||||||
}
|
}
|
||||||
/*====================================================others=====================================================*/
|
/*====================================================others=====================================================*/
|
||||||
.btn-i {
|
.btn-i {
|
||||||
@apply py-2 px-4 bg-indigo-500 text-white font-semibold rounded-lg shadow-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-opacity-75;
|
@apply py-2 px-4 bg-blue-100 hover:bg-blue-200 text-gray-700 font-semibold rounded-lg border border-gray-300 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-opacity-75;
|
||||||
}
|
}
|
||||||
.wgt-adt-stl {
|
.wgt-adt-stl {
|
||||||
@apply text-center text-gray-500 font-bold;
|
@apply text-center text-gray-500 font-bold;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const sockets = new Map();
|
|||||||
export function createConnection(wsIndex, ip, callbacks) {
|
export function createConnection(wsIndex, ip, callbacks) {
|
||||||
const url = "ws://" + ip + ":81";
|
const url = "ws://" + ip + ":81";
|
||||||
const socket = new WebSocket(url);
|
const socket = new WebSocket(url);
|
||||||
socket.binaryType = "blob"; // fix: set on instance, not on array
|
socket.binaryType = "blob";
|
||||||
sockets.set(wsIndex, socket);
|
sockets.set(wsIndex, socket);
|
||||||
|
|
||||||
socket.addEventListener("open", () => {
|
socket.addEventListener("open", () => {
|
||||||
@@ -25,11 +25,15 @@ export function createConnection(wsIndex, ip, callbacks) {
|
|||||||
if (callbacks.onMessage) callbacks.onMessage(wsIndex, event.data);
|
if (callbacks.onMessage) callbacks.onMessage(wsIndex, event.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", (ev) => {
|
||||||
|
if (socket.readyState !== 1) {
|
||||||
|
console.warn("[WS] closed before open", "url:", url, "wsIndex:", wsIndex, "code:", ev.code, "reason:", ev.reason || "");
|
||||||
|
}
|
||||||
if (callbacks.onClose) callbacks.onClose(wsIndex);
|
if (callbacks.onClose) callbacks.onClose(wsIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener("error", () => {
|
socket.addEventListener("error", () => {
|
||||||
|
console.warn("[WS] error connecting to", url, "wsIndex:", wsIndex, "- is mock_backend.py running on port 81?");
|
||||||
if (callbacks.onError) callbacks.onError(wsIndex);
|
if (callbacks.onError) callbacks.onError(wsIndex);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default class WebSocketManager {
|
|||||||
constructor(initialDeviceList, options = {}) {
|
constructor(initialDeviceList, options = {}) {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.debug = options.debug !== false && debug;
|
this.debug = options.debug !== false && debug;
|
||||||
|
console.log("[layout] debug: layout console logs enabled (binary blobs, combineLayoutsInOne)");
|
||||||
|
|
||||||
// State (mirror of App.svelte)
|
// State (mirror of App.svelte)
|
||||||
this.deviceList = Array.isArray(initialDeviceList) ? [...initialDeviceList] : [];
|
this.deviceList = Array.isArray(initialDeviceList) ? [...initialDeviceList] : [];
|
||||||
@@ -208,6 +209,7 @@ export default class WebSocketManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data instanceof Blob) {
|
if (data instanceof Blob) {
|
||||||
|
console.log("[layout] binary blob received", "ws:", ws, "page:", this.currentPageName, "layoutJson.length:", this.layoutJson.length);
|
||||||
if (ws === this.selectedWs) this.parseBlob(data, ws);
|
if (ws === this.selectedWs) this.parseBlob(data, ws);
|
||||||
if (this.currentPageName === "/|") this.parseAllBlob(data, ws);
|
if (this.currentPageName === "/|") this.parseAllBlob(data, ws);
|
||||||
}
|
}
|
||||||
@@ -350,11 +352,17 @@ export default class WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async combineLayoutsInOne(ws, devLayout) {
|
async combineLayoutsInOne(ws, devLayout) {
|
||||||
|
const beforeLen = this.layoutJson.length;
|
||||||
|
const forThisWs = this.layoutJson.filter((item) => item.ws === ws).length;
|
||||||
|
// Replace layout for this device (avoid duplicates when layout is sent more than once)
|
||||||
|
this.layoutJson = this.layoutJson.filter((item) => item.ws !== ws);
|
||||||
|
const afterFilterLen = this.layoutJson.length;
|
||||||
for (let i = 0; i < devLayout.length; i++) {
|
for (let i = 0; i < devLayout.length; i++) {
|
||||||
devLayout[i].ws = ws;
|
devLayout[i].ws = ws;
|
||||||
}
|
}
|
||||||
this.layoutJson = this.layoutJson.concat(devLayout);
|
this.layoutJson = this.layoutJson.concat(devLayout);
|
||||||
console.log("[2]", ws, "devLayout pushed to layout");
|
const topics = (devLayout || []).map((w) => (w.topic || "").split("/").pop()).join(", ");
|
||||||
|
console.log("[layout] combineLayoutsInOne", "ws:", ws, "devLayout.length:", (devLayout || []).length, "| layoutJson: before:", beforeLen, "forThisWs:", forThisWs, "afterFilter:", afterFilterLen, "afterConcat:", this.layoutJson.length, "| topics:", topics);
|
||||||
this.sortingLayout(ws);
|
this.sortingLayout(ws);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,13 +148,18 @@ export async function parseBlob(blob, ws, handlers) {
|
|||||||
*/
|
*/
|
||||||
export async function parseAllBlob(blob, ws, handlers) {
|
export async function parseAllBlob(blob, ws, handlers) {
|
||||||
const { header, size } = await readHeader(blob);
|
const { header, size } = await readHeader(blob);
|
||||||
|
console.log("[layout] parseAllBlob header:", header, "ws:", ws);
|
||||||
const out = {};
|
const out = {};
|
||||||
|
|
||||||
if (header === "status") {
|
if (header === "status") {
|
||||||
if (await getPayloadAsJson(blob, size, out)) handlers.updateWidget(out.json);
|
if (await getPayloadAsJson(blob, size, out)) handlers.updateWidget(out.json);
|
||||||
}
|
}
|
||||||
if (header === "layout") {
|
if (header === "layout") {
|
||||||
if (await getPayloadAsJson(blob, size, out)) handlers.combineLayoutsInOne(ws, out.json);
|
if (await getPayloadAsJson(blob, size, out)) {
|
||||||
|
const arr = Array.isArray(out.json) ? out.json : [];
|
||||||
|
console.log("[layout] blob header=layout", "ws:", ws, "payload length:", arr.length);
|
||||||
|
handlers.combineLayoutsInOne(ws, out.json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (header === "params") {
|
if (header === "params") {
|
||||||
if (await getPayloadAsJson(blob, size, out)) {
|
if (await getPayloadAsJson(blob, size, out)) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
console.log("[layout] IoTManagerWeb loaded — layout debug logs enabled");
|
||||||
|
|
||||||
const app = new App({
|
const app = new App({
|
||||||
target: document.body,
|
target: document.body,
|
||||||
props: {
|
props: {}
|
||||||
name: 'world'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
<script>
|
<script>
|
||||||
import Card from "../components/Card.svelte";
|
import Card from "../components/Card.svelte";
|
||||||
import Input from "../widgets/Input.svelte";
|
import Input from "../widgets/Input.svelte";
|
||||||
|
import InputNumber from "../widgets/InputNumber.svelte";
|
||||||
|
import InputDate from "../widgets/InputDate.svelte";
|
||||||
import Range from "../widgets/Range.svelte";
|
import Range from "../widgets/Range.svelte";
|
||||||
import Chart from "../widgets/Chart.svelte";
|
import Chart from "../widgets/Chart.svelte";
|
||||||
import Toggle from "../widgets/Toggle.svelte";
|
import Toggle from "../widgets/Toggle.svelte";
|
||||||
import Anydata from "../widgets/Anydata.svelte";
|
import Anydata from "../widgets/Anydata.svelte";
|
||||||
|
import Btn from "../widgets/Btn.svelte";
|
||||||
|
import ProgressLine from "../widgets/ProgressLine.svelte";
|
||||||
|
import ProgressRound from "../widgets/ProgressRound.svelte";
|
||||||
|
import Select from "../widgets/Select.svelte";
|
||||||
|
import Doughnut from "../widgets/Doughnut.svelte";
|
||||||
|
import Fillgauge from "../widgets/Fillgauge.svelte";
|
||||||
import Alarm from "../components/Alarm.svelte";
|
import Alarm from "../components/Alarm.svelte";
|
||||||
|
|
||||||
export let layoutJson;
|
export let layoutJson;
|
||||||
@@ -47,6 +55,12 @@
|
|||||||
{#if widget.widget === "input"}
|
{#if widget.widget === "input"}
|
||||||
<Input bind:value={widget.status} widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
<Input bind:value={widget.status} widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if widget.widget === "input-number"}
|
||||||
|
<InputNumber widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
||||||
|
{/if}
|
||||||
|
{#if widget.widget === "input-date"}
|
||||||
|
<InputDate widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
||||||
|
{/if}
|
||||||
{#if widget.widget === "toggle"}
|
{#if widget.widget === "toggle"}
|
||||||
<Toggle bind:value={widget.status} widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
<Toggle bind:value={widget.status} widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -59,6 +73,24 @@
|
|||||||
{#if widget.widget === "chart"}
|
{#if widget.widget === "chart"}
|
||||||
<Chart widget={widget} />
|
<Chart widget={widget} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if widget.widget === "btn"}
|
||||||
|
<Btn widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
||||||
|
{/if}
|
||||||
|
{#if widget.widget === "progress-line"}
|
||||||
|
<ProgressLine widget={widget} />
|
||||||
|
{/if}
|
||||||
|
{#if widget.widget === "progress-round"}
|
||||||
|
<ProgressRound widget={widget} />
|
||||||
|
{/if}
|
||||||
|
{#if widget.widget === "select"}
|
||||||
|
<Select widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
||||||
|
{/if}
|
||||||
|
{#if widget.widget === "doughnut"}
|
||||||
|
<Doughnut widget={widget} />
|
||||||
|
{/if}
|
||||||
|
{#if widget.widget === "fillgauge"}
|
||||||
|
<Fillgauge widget={widget} />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
28
src/widgets/Btn.svelte
Normal file
28
src/widgets/Btn.svelte
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Button widget. On click sends "1" via wsPush (WebSocket control).
|
||||||
|
*/
|
||||||
|
export let widget;
|
||||||
|
export let wsPush = (ws, topic, status) => {};
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
widget.sent = true;
|
||||||
|
widget.status = "1";
|
||||||
|
wsPush(widget.ws, widget.topic, "1");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crd-itm-psn h-auto mb-4">
|
||||||
|
<div class="w-2/3">
|
||||||
|
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end w-1/3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={onClick}
|
||||||
|
class="btn-i {widget.sent ? 'ring-2 ring-red-400' : ''}"
|
||||||
|
>
|
||||||
|
{widget.status !== undefined && widget.status !== null && widget.status !== "" ? widget.status : "OK"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,15 +2,18 @@
|
|||||||
import Chart from "svelte-frappe-charts";
|
import Chart from "svelte-frappe-charts";
|
||||||
export let widget;
|
export let widget;
|
||||||
|
|
||||||
let datachart = {
|
function safeDatachart(labels, values, descr) {
|
||||||
labels: ["0", "0"],
|
const lab = Array.isArray(labels) && labels.length > 0 ? labels : ["0", "0"];
|
||||||
datasets: [
|
let vals = Array.isArray(values) && values.length > 0 ? values : [[0], [0]];
|
||||||
{
|
// Ensure all numbers are finite (frappe-charts throws on NaN)
|
||||||
name: (widget && widget.descr) ? widget.descr : "",
|
vals = vals.map((row) => (Array.isArray(row) ? row : [row]).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0)));
|
||||||
values: [0, 0],
|
if (vals.length === 0) vals = [[0], [0]];
|
||||||
},
|
return {
|
||||||
],
|
labels: lab,
|
||||||
|
datasets: [{ name: descr || "Chart", values: vals }],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
let datachart = safeDatachart([], [], widget && widget.descr);
|
||||||
|
|
||||||
let prevStatus = {};
|
let prevStatus = {};
|
||||||
|
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
|
|
||||||
let axisOptions = { xAxisMode: "tick", xIsSeries: true, xIsSeries: true };
|
let axisOptions = { xAxisMode: "tick", xIsSeries: true, xIsSeries: true };
|
||||||
let lineOptions;
|
let lineOptions;
|
||||||
if (widget.pointRadius == "0") {
|
if (widget && widget.pointRadius == "0") {
|
||||||
lineOptions = { regionFill: 1, hideDots: 1, spline: 1 };
|
lineOptions = { regionFill: 1, hideDots: 1, spline: 1 };
|
||||||
} else {
|
} else {
|
||||||
lineOptions = { regionFill: 1, dotSize: 3, spline: 1 };
|
lineOptions = { regionFill: 1, dotSize: 3, spline: 1 };
|
||||||
@@ -65,15 +68,7 @@
|
|||||||
|
|
||||||
labels = safeLabels;
|
labels = safeLabels;
|
||||||
values = safeValues;
|
values = safeValues;
|
||||||
datachart = {
|
datachart = safeDatachart(labels, values, widget.descr);
|
||||||
labels: labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
name: widget.descr || "",
|
|
||||||
values: values,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firstTime = false;
|
firstTime = false;
|
||||||
@@ -96,12 +91,7 @@
|
|||||||
if (widget) widget.status = [];
|
if (widget) widget.status = [];
|
||||||
labels = [];
|
labels = [];
|
||||||
values = [];
|
values = [];
|
||||||
datachart = {
|
datachart = safeDatachart([], [], widget && widget.descr);
|
||||||
labels: ["0", "0"],
|
|
||||||
datasets: [
|
|
||||||
{ name: (widget && widget.descr) ? widget.descr : "", values: [0, 0] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -109,4 +99,6 @@
|
|||||||
<p class="inline-block italic truncate align-top text-center text-{widget.descrColor ? widget.descrColor : 'gray'}-500 txt-sz">{!widget.descr ? "" : widget.descr}</p>
|
<p class="inline-block italic truncate align-top text-center text-{widget.descrColor ? widget.descrColor : 'gray'}-500 txt-sz">{!widget.descr ? "" : widget.descr}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Chart id={"notes"} data={datachart} type={type} title={""} lineOptions={lineOptions} axisOptions={axisOptions} height="150" padding="0px" margin="0px" />
|
{#if widget && datachart && datachart.datasets && datachart.datasets[0] && Array.isArray(datachart.datasets[0].values) && datachart.datasets[0].values.length > 0}
|
||||||
|
<Chart id={widget.topic || "chart"} data={datachart} type={type} title={""} lineOptions={lineOptions} axisOptions={axisOptions} height={150} />
|
||||||
|
{/if}
|
||||||
|
|||||||
37
src/widgets/Doughnut.svelte
Normal file
37
src/widgets/Doughnut.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Doughnut chart. Read-only. status = number (0..max); optional widget.color.
|
||||||
|
*/
|
||||||
|
export let widget;
|
||||||
|
|
||||||
|
$: numStatus = Number(widget.status);
|
||||||
|
$: numMax = Number(widget.max) ?? 100;
|
||||||
|
$: safeMax = numMax <= 0 ? 100 : numMax;
|
||||||
|
$: value = Math.max(0, Math.min(1, numStatus / safeMax));
|
||||||
|
$: stroke = widget.stroke ?? 12;
|
||||||
|
$: r = 40;
|
||||||
|
$: c = 2 * Math.PI * r;
|
||||||
|
$: dash = value * c;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
|
||||||
|
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
|
||||||
|
<div class="relative inline-flex items-center justify-center" style="width: 90px; height: 90px;">
|
||||||
|
<svg class="w-full h-full -rotate-90" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r={r} fill="none" stroke="#e5e7eb" stroke-width={stroke} />
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={widget.color || "#6366f1"}
|
||||||
|
stroke-width={stroke}
|
||||||
|
stroke-dasharray={c}
|
||||||
|
stroke-dashoffset={c - dash}
|
||||||
|
stroke-linecap="round"
|
||||||
|
style="transition: stroke-dashoffset 0.3s ease;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-gray-600 font-bold text-sm">{widget.before || ""}{numStatus}{widget.after || ""}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
24
src/widgets/Fillgauge.svelte
Normal file
24
src/widgets/Fillgauge.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Fill gauge (liquid/level style). Read-only. status within min..max.
|
||||||
|
*/
|
||||||
|
export let widget;
|
||||||
|
|
||||||
|
$: numStatus = Number(widget.status);
|
||||||
|
$: numMin = Number(widget.min) ?? 0;
|
||||||
|
$: numMax = Number(widget.max) ?? 100;
|
||||||
|
$: safeMax = numMax <= numMin ? numMin + 1 : numMax;
|
||||||
|
$: value = Math.max(0, Math.min(1, (numStatus - numMin) / (safeMax - numMin)));
|
||||||
|
$: pct = Math.round(value * 100);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
|
||||||
|
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
|
||||||
|
<div class="w-full max-w-[120px] h-24 rounded-lg border-2 border-gray-200 overflow-hidden flex flex-col justify-end">
|
||||||
|
<div
|
||||||
|
class="w-full transition-all duration-300 rounded-t"
|
||||||
|
style="height: {pct}%; background: {widget.color || '#6366f1'};"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{widget.before || ""}{numStatus}{widget.after || ""}</p>
|
||||||
|
</div>
|
||||||
117
src/widgets/InputDate.svelte
Normal file
117
src/widgets/InputDate.svelte
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* input-date widget: date/time/datetime with timePrecision h/hm/hms (same spec as app WidgetInputDate).
|
||||||
|
* Status format: date DD.MM.YYYY, time HH | HH:mm | HH:mm:ss, datetime DD.MM.YYYY HH[:mm][:ss].
|
||||||
|
*/
|
||||||
|
export let widget;
|
||||||
|
export let wsPush = (ws, topic, status) => {};
|
||||||
|
|
||||||
|
$: mode = (widget.mode || widget.type || "date").toString().toLowerCase();
|
||||||
|
$: rawPrecision = widget.timePrecision || widget.precision || "hm";
|
||||||
|
$: timePrecision = ["h", "hm", "hms"].includes(String(rawPrecision).toLowerCase()) ? String(rawPrecision).toLowerCase() : "hm";
|
||||||
|
|
||||||
|
// Bind value for native date input (yyyy-mm-dd)
|
||||||
|
let dateInputValue = "";
|
||||||
|
// Bind value for native time input (HH:mm or HH:mm:ss)
|
||||||
|
let timeInputValue = "";
|
||||||
|
|
||||||
|
function dmyToYmd(s) {
|
||||||
|
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(String(s || "").trim());
|
||||||
|
if (!m) return "";
|
||||||
|
return `${m[3]}-${m[2]}-${m[1]}`;
|
||||||
|
}
|
||||||
|
function ymdToDmy(s) {
|
||||||
|
if (!s || s.length < 10) return "";
|
||||||
|
const [y, mo, d] = s.split("-");
|
||||||
|
return `${d}.${mo}.${y}`;
|
||||||
|
}
|
||||||
|
function timeToInput(s) {
|
||||||
|
const t = String(s || "").trim();
|
||||||
|
if (/^\d{2}:\d{2}:\d{2}$/.test(t)) return t;
|
||||||
|
if (/^\d{2}:\d{2}$/.test(t)) return t;
|
||||||
|
if (/^\d{1,2}$/.test(t)) return `${t.padStart(2, "0")}:00`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
function inputTimeToStatus(s) {
|
||||||
|
if (timePrecision === "h") return s ? s.slice(0, 2) : "00";
|
||||||
|
if (timePrecision === "hms") return s && s.length >= 8 ? s : (s || "00:00") + ":00";
|
||||||
|
return s || "00:00";
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (widget && widget.status !== undefined) {
|
||||||
|
const v = String(widget.status).trim();
|
||||||
|
if (mode === "date") {
|
||||||
|
dateInputValue = dmyToYmd(v) || "";
|
||||||
|
} else if (mode === "time") {
|
||||||
|
timeInputValue = timeToInput(v);
|
||||||
|
} else {
|
||||||
|
const space = v.indexOf(" ");
|
||||||
|
if (space > 0) {
|
||||||
|
dateInputValue = dmyToYmd(v.slice(0, space));
|
||||||
|
timeInputValue = timeToInput(v.slice(space + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDate() {
|
||||||
|
const dmy = ymdToDmy(dateInputValue);
|
||||||
|
if (mode === "date") {
|
||||||
|
widget.status = dmy || "01.01.2025";
|
||||||
|
widget.sent = true;
|
||||||
|
wsPush(widget.ws, widget.topic, widget.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function commitTime() {
|
||||||
|
const t = inputTimeToStatus(timeInputValue);
|
||||||
|
if (mode === "time") {
|
||||||
|
widget.status = t;
|
||||||
|
widget.sent = true;
|
||||||
|
wsPush(widget.ws, widget.topic, widget.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function commitDatetime() {
|
||||||
|
const dmy = ymdToDmy(dateInputValue);
|
||||||
|
const t = inputTimeToStatus(timeInputValue);
|
||||||
|
widget.status = `${dmy || "01.01.2025"} ${t}`;
|
||||||
|
widget.sent = true;
|
||||||
|
wsPush(widget.ws, widget.topic, widget.status);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crd-itm-psn">
|
||||||
|
<div class="w-2/3">
|
||||||
|
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{widget.descr || "Date"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end w-1/3 flex-nowrap gap-1 items-center">
|
||||||
|
{#if mode === "date"}
|
||||||
|
<input
|
||||||
|
class={widget.sent ? "ipt-rnd text-right border-red-500" : "ipt-rnd text-right focus:border-indigo-500"}
|
||||||
|
type="date"
|
||||||
|
bind:value={dateInputValue}
|
||||||
|
on:change={commitDate}
|
||||||
|
/>
|
||||||
|
{:else if mode === "time"}
|
||||||
|
<input
|
||||||
|
class={widget.sent ? "ipt-rnd text-right border-red-500" : "ipt-rnd text-right focus:border-indigo-500"}
|
||||||
|
type="time"
|
||||||
|
step={timePrecision === "hms" ? 1 : 60}
|
||||||
|
bind:value={timeInputValue}
|
||||||
|
on:change={commitTime}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
class="shrink-0 max-w-[8.5rem] {widget.sent ? 'ipt-rnd text-right border-red-500' : 'ipt-rnd text-right focus:border-indigo-500'}"
|
||||||
|
type="date"
|
||||||
|
bind:value={dateInputValue}
|
||||||
|
on:change={commitDatetime}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="shrink-0 max-w-[6rem] {widget.sent ? 'ipt-rnd text-right border-red-500' : 'ipt-rnd text-right focus:border-indigo-500'}"
|
||||||
|
type="time"
|
||||||
|
step={timePrecision === "hms" ? 1 : 60}
|
||||||
|
bind:value={timeInputValue}
|
||||||
|
on:change={commitDatetime}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
46
src/widgets/InputNumber.svelte
Normal file
46
src/widgets/InputNumber.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* input-number widget: int/float with min, max, step, decimals (same spec as app WidgetInputNumber).
|
||||||
|
* Publishes value to topic/control (int: "123", float: "123.45").
|
||||||
|
*/
|
||||||
|
export let widget;
|
||||||
|
export let wsPush = (ws, topic, status) => {};
|
||||||
|
|
||||||
|
$: mode = (widget.mode || widget.type || "int").toString().toLowerCase();
|
||||||
|
$: isFloat = mode === "float";
|
||||||
|
$: decimals = Math.max(0, Math.min(6, Math.trunc(Number(widget.decimals ?? widget.precision ?? 0) || 0)));
|
||||||
|
$: min = Number(widget.min);
|
||||||
|
$: max = Number(widget.max);
|
||||||
|
$: step = Number(widget.step);
|
||||||
|
$: numMin = Number.isFinite(min) ? min : (isFloat ? 0 : 0);
|
||||||
|
$: numMax = Number.isFinite(max) ? max : (isFloat ? 100 : 100);
|
||||||
|
$: stepVal = Number.isFinite(step) && step > 0 ? step : (isFloat ? Math.pow(10, -decimals) : 1);
|
||||||
|
$: stepAttr = isFloat && decimals > 0 ? stepVal : Math.max(1, Math.trunc(stepVal));
|
||||||
|
|
||||||
|
function onInputChange() {
|
||||||
|
widget.sent = true;
|
||||||
|
const v = Number(widget.status);
|
||||||
|
const s = Number.isFinite(v)
|
||||||
|
? (isFloat && decimals >= 0 ? v.toFixed(decimals) : String(Math.trunc(v)))
|
||||||
|
: (widget.status != null ? String(widget.status) : "");
|
||||||
|
widget.status = s;
|
||||||
|
wsPush(widget.ws, widget.topic, widget.status);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crd-itm-psn">
|
||||||
|
<div class="w-2/3">
|
||||||
|
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{widget.descr || "Number"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end w-1/3">
|
||||||
|
<input
|
||||||
|
class={widget.sent ? "ipt-rnd text-right border-red-500" : "ipt-rnd text-right focus:border-indigo-500"}
|
||||||
|
type="number"
|
||||||
|
min={numMin}
|
||||||
|
max={numMax}
|
||||||
|
step={stepAttr}
|
||||||
|
bind:value={widget.status}
|
||||||
|
on:change={onInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
29
src/widgets/ProgressLine.svelte
Normal file
29
src/widgets/ProgressLine.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Linear progress bar. Read-only; displays status within min..max.
|
||||||
|
* No wsPush (display only).
|
||||||
|
*/
|
||||||
|
export let widget;
|
||||||
|
|
||||||
|
$: numStatus = Number(widget.status);
|
||||||
|
$: numMin = Number(widget.min) ?? 0;
|
||||||
|
$: numMax = Number(widget.max) ?? 100;
|
||||||
|
$: safeMax = numMax <= numMin ? numMin + 1 : numMax;
|
||||||
|
$: value = Math.max(0, Math.min(1, (numStatus - numMin) / (safeMax - numMin)));
|
||||||
|
$: pct = Math.round(value * 100);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crd-itm-psn flex flex-col items-stretch h-auto mb-4">
|
||||||
|
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full bg-indigo-500 rounded-full transition-all duration-300"
|
||||||
|
style="width: {pct}%"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={numStatus}
|
||||||
|
aria-valuemin={numMin}
|
||||||
|
aria-valuemax={safeMax}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{widget.before || ""}{numStatus} / {safeMax}{widget.after || ""}</p>
|
||||||
|
</div>
|
||||||
47
src/widgets/ProgressRound.svelte
Normal file
47
src/widgets/ProgressRound.svelte
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Circular progress (semicircle or full). Read-only.
|
||||||
|
* Uses widget.status, widget.min, widget.max, widget.after; optional widget.semicircle.
|
||||||
|
*/
|
||||||
|
export let widget;
|
||||||
|
|
||||||
|
$: numStatus = Number(widget.status);
|
||||||
|
$: numMin = Number(widget.min) ?? 0;
|
||||||
|
$: numMax = Number(widget.max) ?? 100;
|
||||||
|
$: safeMax = numMax <= numMin ? numMin + 1 : numMax;
|
||||||
|
$: value = Math.max(0, Math.min(1, (numStatus - numMin) / (safeMax - numMin)));
|
||||||
|
$: deg = widget.semicircle === "1" || widget.semicircle === true ? value * 180 : value * 360;
|
||||||
|
$: stroke = widget.stroke ?? 10;
|
||||||
|
$: r = 45;
|
||||||
|
$: c = 2 * Math.PI * r;
|
||||||
|
$: dash = (deg / 360) * c;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
|
||||||
|
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
|
||||||
|
<div class="relative inline-flex items-center justify-center" style="width: 100px; height: 60px;">
|
||||||
|
<svg class="w-full h-full -rotate-90" viewBox="0 0 100 100" style="overflow: visible;">
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="#e5e7eb"
|
||||||
|
stroke-width={stroke}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={widget.color || "#6366f1"}
|
||||||
|
stroke-width={stroke}
|
||||||
|
stroke-dasharray={c}
|
||||||
|
stroke-dashoffset={c - dash}
|
||||||
|
stroke-linecap="round"
|
||||||
|
style="transition: stroke-dashoffset 0.3s ease;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-gray-600 font-bold text-sm">{widget.before || ""}{numStatus}{widget.after || ""}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
36
src/widgets/Select.svelte
Normal file
36
src/widgets/Select.svelte
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Select (dropdown). options = array of strings; status = selected index (number).
|
||||||
|
* On change sends index via wsPush (WebSocket control).
|
||||||
|
*/
|
||||||
|
export let widget;
|
||||||
|
export let wsPush = (ws, topic, status) => {};
|
||||||
|
|
||||||
|
$: options = Array.isArray(widget.options) ? widget.options : [];
|
||||||
|
$: idx = Number(widget.status);
|
||||||
|
$: selectedIndex = Number.isNaN(idx) || idx < 0 || idx >= options.length ? 0 : idx;
|
||||||
|
|
||||||
|
function onChange(ev) {
|
||||||
|
const i = parseInt(ev.target.value, 10);
|
||||||
|
widget.sent = true;
|
||||||
|
widget.status = String(i);
|
||||||
|
wsPush(widget.ws, widget.topic, String(i));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="crd-itm-psn h-auto mb-4">
|
||||||
|
<div class="w-2/3">
|
||||||
|
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end w-1/3">
|
||||||
|
<select
|
||||||
|
class="slct-lg {widget.sent ? 'border-red-500' : ''}"
|
||||||
|
value={selectedIndex}
|
||||||
|
on:change={onChange}
|
||||||
|
>
|
||||||
|
{#each options as opt, i}
|
||||||
|
<option value={i}>{opt}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user