feat: PLC调试页面 + WebSocket测试套件 + curd:add立即推送修复

新增:
- web_root/js/plc_debug.js: PLC 调试页面(LED/按键绑定out信号、增量刷新)
- requirements.toml: Codex 项目权限预批准配置
- mimo/webserver测试工程师/: WebSocket 前后端测试套件(8脚本+README)
  · test_ws_raw.py — 最简 WebSocket 连通性测试
  · test_debug_dc.py — datacenter 命令调试
  · test_verify_push.py — 推送验证
  · test_verify_immediate_push.py — 验证 curd:add 立即推送修复
  · test_ws_dc_out/v2/v3.py — 数据中心→信号页端到端测试
  · test_regression_final.py — 全面回归测试

修改:
- ws_method.cpp: curd:add 后立即推送信号列表(+30行)
- packed_fs.c: 打包新版 web 资源(PLC调试页+样式更新)
- pages.js: 重构信号管理页渲染逻辑、新增PLC调试页路由
- app.js: 注册PLC调试页路由
- style.css: 新增PLC调试页样式(+106行)
- index.html: 加载 plc_debug.js
- plc.cpp: 微调修复
This commit is contained in:
ypc 2026-07-02 15:13:06 +08:00
parent b176d957df
commit fd34c6815f
18 changed files with 7130 additions and 3407 deletions

View File

@ -0,0 +1,198 @@
# WebServer 前端测试程序
本目录存放 RTU 项目 WebServer 前端的 Python 自动化测试脚本。
## 📁 目录结构
```
mimo/webserver测试工程师/
├── README.md # 本文件
├── test_ws_raw.py # 最简 WebSocket 连通性测试
├── test_debug_dc.py # datacenter out 命令调试
├── test_verify_push.py # 推送验证(添加后检查 out 推送)
├── test_verify_immediate_push.py # 验证 curd:add 立即推送修复
├── test_ws_dc_out.py # 数据中心→信号管理页 端到端测试 (v1)
├── test_ws_dc_out_v2.py # 数据中心→信号管理页 端到端测试 (v2)
├── test_ws_dc_out_v3.py # 数据中心→信号管理页 端到端测试 (v3)
└── test_regression_final.py # 全面回归测试(多信号+立即推送)
```
## 🔧 前置条件
1. **RTU 已运行**:目标设备上 RTU 进程已启动WebSocket 服务已监听 `8000` 端口
2. **目标 IP**:默认 `198.120.0.100`RK3568可在脚本中修改 `IP` 变量
3. **Python 3**:所有脚本仅依赖 Python 标准库(`socket`, `json`, `struct`, `base64`, `os`, `time`, `sys`, `hashlib`),无需额外安装
4. **网络可达**:测试机与目标 RTU 之间网络互通ping 通)
## 📋 测试程序说明
### 1. test_ws_raw.py — 最简 WebSocket 连通性测试
**功能**:验证与 RTU 的 WebSocket 基本通信是否正常。
**流程**
1. TCP 连接 `198.120.0.100:8000`
2. 执行 WebSocket 握手升级
3. 发送 `get_cmds` 请求
4. 接收并解析所有 WebSocket 帧(区分 text/binary/close/ping/pong
5. 打印响应内容
**使用方法**
```bash
python3 mimo/webserver测试工程师/test_ws_raw.py
```
**适用场景**:首次排查连通性问题,验证 WebSocket 协议握手是否成功。
---
### 2. test_debug_dc.py — datacenter out 命令调试
**功能**:调试 `datacenter out` 命令的行为,排查命令是否正常执行。
**流程**
1. WebSocket 握手后先接收初始数据cmd_list
2. 发送 `get_cmds` 获取可用命令列表
3. 发送 `{"type":"cmd","cmd":"datacenter out"}` 注册数据中心 out 信号
4. 逐帧解析响应,识别 `dc_data` 类型的 JSON 消息
5. 统计各类信号数量out/in/yk/ao/param
**使用方法**
```bash
python3 mimo/webserver测试工程师/test_debug_dc.py
```
**适用场景**:诊断 `datacenter out` 命令无响应问题时使用。
---
### 3. test_verify_push.py — 推送验证测试
**功能**:注册数据中心信号后,精确验证 WebSocket 推送是否包含所有信号。
**流程**
1. 发送 `datacenter out` 注册信号
2. 添加指定信号 `sys.ch.tcp_s0_if` 到 out 配置页
3. 等待 3 秒后收集所有 WebSocket 推送
4. 打印 `out` 推送中的信号列表saddr + val
5. 清理:删除测试信号,关闭连接
**使用方法**
```bash
python3 mimo/webserver测试工程师/test_verify_push.py
```
**适用场景**:验证特定信号的注册和推送是否正常。
---
### 4. test_verify_immediate_push.py — curd:add 立即推送验证
**功能**:验证 `curd:add` 操作后服务端是否立即推送(而非等 ws_task 定时轮询)。
**流程**
1. WebSocket 握手连接
2. 发送 `curd:add` 注册测试信号
3. 立即监听收发,检查服务端是否在 add 后立刻推送 out 数据
4. 对比定时推送和立即推送的延迟差异
5. 清理测试数据
**使用方法**
```bash
python3 mimo/webserver测试工程师/test_verify_immediate_push.py
```
**适用场景**:验证 `ws_method.cpp``curd:add` 的立即推送逻辑修复。
---
### 5. test_ws_dc_out.py — 端到端测试 v1初版
**功能**:完整测试"数据中心注册信号 → 信号管理页添加 → 推送验证 → 清理"全流程。
**流程**
1. 依次执行 `datacenter out/in/yk/ao/param` 注册所有类型信号
2. 从 `dc_data` 中选择 out 信号(优先选 ctrl_type=1/2 的直控/选控信号)
3. 发送 `curd:add` 添加信号2 秒间隔模拟真人操作)
4. 等待 ws_task 定时推送 out 数据(最多 10 秒)
5. 验证添加的信号是否出现在推送列表中
6. 清理:发送 `curd:del` 删除测试信号
**技术特点**
- 使用 `drain_sock` 逐帧接收(区分 text/binary/close/ping 帧)
- 关键发现文档dc_data 通过二进制帧发送curd:add 不会立即推送
- `ws_recv` 函数支持 126/127 长度扩展和掩码处理
**使用方法**
```bash
python3 mimo/webserver测试工程师/test_ws_dc_out.py
```
---
### 6. test_ws_dc_out_v2.py — 端到端测试 v2改进版
**功能**:与 v1 相同,但改进了接收方式,使用 `ws_recv_all` 原始帧集中接收。
**改进点**
- 增加了初始 `get_cmds` 握手,打印可用命令列表
- 信号选择逻辑更宽松(直接从 out_signals 选前 3 个)
- 增加了 fallback当没有 out 信号时尝试添加已知信号
- 等待超时延长到 15 秒
**使用方法**
```bash
python3 mimo/webserver测试工程师/test_ws_dc_out_v2.py
```
---
### 7. test_ws_dc_out_v3.py — 端到端测试 v3修复版
**功能**:与 v1/v2 相同,但**修复了终端命令的 JSON 格式**。
**关键修复**
- v1/v2 使用 `{"type":"cmd","cmd":"datacenter out"}`(错误)
- v3 使用 `{"type":"cmd","data":"datacenter out"}`(正确)
- 终端命令消息体中命令内容放在 `"data"` 字段,而非 `"cmd"` 字段
**使用方法**
```bash
python3 mimo/webserver测试工程师/test_ws_dc_out_v3.py
```
> **推荐使用 v3**,因为命令格式与前端实际发送的格式一致。
---
### 8. test_regression_final.py — 全面回归测试
**功能**:数据中心→信号管理页的全面回归测试,覆盖多信号添加、立即推送验证、删除清理全流程。
**流程**
1. WebSocket 握手连接
2. 注册 datacenter out/in/yk/ao/param 所有信号类型
3. 从 dc_data 中批量选取多个 out 信号
4. 逐个添加信号并验证立即推送的完整性
5. 统计成功/失败比例
6. 批量删除测试信号并清理
**使用方法**
```bash
python3 mimo/webserver测试工程师/test_regression_final.py
```
**适用场景**:代码修改后的完整性回归验证,确保信号 CRUD 全流程无退化。
---
## 🔄 消息协议要点
| 要点 | 说明 |
|------|------|
| dc_data 帧类型 | **二进制帧**opcode=0x02非文本帧 |
| curd:add 行为 | 添加后**不会立即推送**,需等 ws_task 定时轮询检测 has_change |
| ws_task 周期 | 约 1 秒 |
| 终端命令格式 | `{"type":"cmd","data":"<命令>"}` |
| curd 消息格式 | `{"saddr":...,"signal_type":"out","curd":"add","setting_zone":"0","signal_data":""}` |
| 操作间隔 | 添加/删除建议间隔 2 秒,模拟真人操作 |

View File

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""调试 datacenter out 命令"""
import socket, base64, os, struct, json, time
IP = '198.120.0.100'; PORT = 8000
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((IP, PORT))
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /ws HTTP/1.1\r\nHost: {IP}:{PORT}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
sock.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
resp += sock.recv(4096)
print(f"[✓] 握手: {resp.split(b'\r\n')[0].decode()}")
# 先收 cmd_list
time.sleep(0.5)
sock.settimeout(1.0)
all_data = b""
try:
while True:
c = sock.recv(65536)
if not c: break
all_data += c
except socket.timeout:
pass
print(f"[i] 初始数据: {len(all_data)} 字节")
# 发送 get_cmds
msg = json.dumps({"type": "get_cmds"})
L = len(msg.encode())
mask = os.urandom(4)
payload = msg.encode()
masked = bytes(payload[i] ^ mask[i % 4] for i in range(L))
frame = bytearray([0x81, 0x80 | L])
frame.extend(mask); frame.extend(masked)
sock.sendall(bytes(frame))
print(f"[→] get_cmds")
time.sleep(0.5)
# 收 cmd_list
all_data = b""
try:
while True:
c = sock.recv(65536)
if not c: break
all_data += c
except socket.timeout:
pass
print(f"[i] get_cmds 响应: {len(all_data)} 字节")
if all_data:
# 跳过 2 字节 WS 头
if len(all_data) > 2:
L2 = all_data[1] & 0x7F
print(f" 帧长度={L2}, payload={all_data[2:2+L2].decode(errors='replace')[:500]}")
# 现在测试 datacenter out
cmd = json.dumps({"type": "cmd", "cmd": "datacenter out"})
payload = cmd.encode()
L = len(payload)
mask = os.urandom(4)
masked = bytes(payload[i] ^ mask[i % 4] for i in range(L))
frame = bytearray([0x81, 0x80 | L])
frame.extend(mask); frame.extend(masked)
sock.sendall(bytes(frame))
print(f"[→] datacenter out")
time.sleep(2.0)
# 收响应
all_data = b""
try:
while True:
c = sock.recv(65536)
if not c: break
all_data += c
print(f"[i] 收到 {len(c)} 字节")
except socket.timeout:
print(f"[i] 超时,共 {len(all_data)} 字节")
print(f"\n总响应: {len(all_data)} 字节")
if len(all_data) == 0:
print("[✗] datacenter out 无响应!可能命令不存在或执行失败")
else:
# 解析所有帧
offset = 0
while offset + 2 <= len(all_data):
b0, b1 = all_data[offset], all_data[offset+1]
opcode = b0 & 0x0F
length = b1 & 0x7F
offset += 2
if length == 126:
length = struct.unpack('>H', all_data[offset:offset+2])[0]; offset += 2
elif length == 127:
length = struct.unpack('>Q', all_data[offset:offset+8])[0]; offset += 8
p = all_data[offset:offset+length]; offset += length
try:
j = json.loads(p.decode('utf-8'))
print(f" [{opcode}] JSON: type={j.get('type','?')}")
if j.get('type') == 'dc_data':
for k in ['out','in','yk','ao','param']:
print(f" {k}: {len(j.get(k,[]))} 个信号")
except:
txt = p.decode('utf-8', errors='replace')
print(f" [{opcode}] {txt[:300]}")
sock.close()

View File

@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""全面回归测试: 数据中心→out 多信号添加+立即推送验证"""
import socket, base64, os, struct, json, time
IP = '198.120.0.100'; PORT = 8000
def ws_handshake(sock):
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /ws HTTP/1.1\r\nHost: {IP}:{PORT}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
sock.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
c = sock.recv(4096)
if not c: break
resp += c
if b"101" not in resp.split(b"\r\n")[0]: raise Exception("握手失败")
return resp
def ws_send(sock, msg):
if isinstance(msg, dict): msg = json.dumps(msg, ensure_ascii=False)
p = msg.encode(); L = len(p); mask = os.urandom(4)
m = bytes(p[i] ^ mask[i%4] for i in range(L))
f = bytearray([0x81])
if L<126: f.append(0x80|L)
elif L<65536: f.append(0x80|126); f.extend(struct.pack('>H',L))
else: f.append(0x80|127); f.extend(struct.pack('>Q',L))
f.extend(mask); f.extend(m); sock.sendall(bytes(f))
def ws_recv_all(sock, t=2.0):
sock.settimeout(t); data=b""
try:
while True:
c=sock.recv(65536)
if not c: break
data+=c
except socket.timeout: pass
msgs=[]; offset=0
while offset+2<=len(data):
b0,b1=data[offset],data[offset+1]; op=b0&0xF; L=b1&0x7F; offset+=2
if L==126: L=struct.unpack('>H',data[offset:offset+2])[0]; offset+=2
elif L==127: L=struct.unpack('>Q',data[offset:offset+8])[0]; offset+=8
if op in (1,2) and offset+L<=len(data):
p=data[offset:offset+L]
try: msgs.append(json.loads(p.decode()))
except: msgs.append(p.decode(errors='replace'))
offset+=L
return msgs
def find_out_push(msgs):
for msg in msgs:
if isinstance(msg, dict) and isinstance(msg.get('out'), list):
return msg['out']
return None
print("="*70)
print(" 全面回归测试 — 数据中心→out 添加信号")
print("="*70)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10); sock.connect((IP,PORT))
ws_handshake(sock); print("[✓] 连接成功")
# 1. 注册 dc 信号
ws_send(sock, {"type":"cmd","data":"datacenter out"})
time.sleep(1); ws_recv_all(sock,1)
# 2. 选择 3 个不同前缀的信号测试
targets = [
"sys.ch.tcp_s0_if",
"sys.ch.uart0_if",
"iec.run_cnt"
]
expected = set(targets)
# 3. 逐个添加,验证每个都能收到立即推送
print(f"\n--- 添加 {len(targets)} 个信号 ---")
all_ok = True
for i, t in enumerate(targets):
ws_send(sock, {"saddr":t,"signal_type":"out","curd":"add","setting_zone":"0","signal_data":""})
time.sleep(0.4)
msgs = ws_recv_all(sock, 2)
out_data = find_out_push(msgs)
if out_data:
saddr_set = {o['saddr'] for o in out_data}
ok = t in saddr_set
status = "" if ok else ""
print(f" [{i+1}] {t} add → 推送 {len(out_data)}个信号 {status}")
if not ok:
all_ok = False
print(f" 推送内容: {saddr_set}")
else:
print(f" [{i+1}] {t} add → ❌ 无推送")
all_ok = False
# 4. 最终验证 — 检查所有 3 个是否都在列表中
print(f"\n--- 最终验证 ---")
ws_send(sock, {"type":"cmd","data":"datacenter out"})
time.sleep(1); msgs = ws_recv_all(sock, 2)
# 忽略 dc_data看有没有 out 推送
out_data = None
for msg in msgs:
if isinstance(msg, dict) and isinstance(msg.get('out'), list):
out_data = msg['out']
if not out_data:
# 再等 ws_task
time.sleep(3)
msgs = ws_recv_all(sock, 3)
out_data = find_out_push(msgs)
if out_data:
saddr_set = {o['saddr'] for o in out_data}
all_found = expected.issubset(saddr_set)
print(f" out已注册: {len(out_data)}个 → 目标全部找到: {'' if all_found else ''}")
if not all_found:
print(f" 缺少: {expected - saddr_set}")
else:
print(" ❌ 未收到 out 推送")
# 5. 清理
print(f"\n--- 清理 ---")
for t in targets:
ws_send(sock, {"saddr":t,"signal_type":"out","curd":"del","setting_zone":"0","signal_data":""})
time.sleep(0.3)
msgs = ws_recv_all(sock, 1)
out_data = find_out_push(msgs)
cnt = len(out_data) if out_data else '?'
print(f" del {t} → 剩余 {cnt}")
print(f"\n{'✅ 全部通过!' if all_ok else '❌ 存在失败'}")
try: sock.sendall(b'\x88\x80'+os.urandom(4))
except: pass
sock.close()

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""验证 curd:add 立即推送修复"""
import socket, base64, os, struct, json, time
IP = '198.120.0.100'; PORT = 8000
def ws_handshake(sock):
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /ws HTTP/1.1\r\nHost: {IP}:{PORT}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
sock.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
c = sock.recv(4096)
if not c: break
resp += c
return resp
def ws_send(sock, msg):
if isinstance(msg, dict): msg = json.dumps(msg, ensure_ascii=False)
p = msg.encode(); L = len(p); mask = os.urandom(4)
m = bytes(p[i] ^ mask[i%4] for i in range(L))
f = bytearray([0x81])
if L<126: f.append(0x80|L)
elif L<65536: f.append(0x80|126); f.extend(struct.pack('>H',L))
else: f.append(0x80|127); f.extend(struct.pack('>Q',L))
f.extend(mask); f.extend(m); sock.sendall(bytes(f))
def ws_recv_all(sock, t=2.0):
sock.settimeout(t); data=b""
try:
while True:
c=sock.recv(65536)
if not c: break
data+=c
except socket.timeout: pass
msgs=[]; offset=0
while offset+2<=len(data):
b0,b1=data[offset],data[offset+1]; op=b0&0xF; L=b1&0x7F; offset+=2
if L==126: L=struct.unpack('>H',data[offset:offset+2])[0]; offset+=2
elif L==127: L=struct.unpack('>Q',data[offset:offset+8])[0]; offset+=8
if op in (1,2) and offset+L<=len(data):
p=data[offset:offset+L]
try: msgs.append(json.loads(p.decode()))
except: msgs.append(p.decode(errors='replace'))
offset+=L
return msgs
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10); sock.connect((IP,PORT))
ws_handshake(sock); print("[✓] 握手成功")
# 注册 dc out
ws_send(sock, {"type":"cmd","data":"datacenter out"})
time.sleep(1); ws_recv_all(sock,1)
# 测试立即推送
target = "sys.ch.tcp_s0_if"
print(f"\n[→] curd:add saddr={target}")
ws_send(sock, {"saddr":target,"signal_type":"out","curd":"add","setting_zone":"0","signal_data":""})
# 立即检查推送100ms 内应有立即推送)
time.sleep(0.3)
msgs = ws_recv_all(sock, 2)
found = False
for msg in msgs:
if isinstance(msg, dict) and 'out' in msg and isinstance(msg['out'], list):
elapsed = "立即" # curd:add后直接推送
print(f"[✓] add后即收到 out 推送 ({elapsed}): {len(msg['out'])} 个信号")
for o in msg['out']:
print(f" saddr={o.get('saddr')} val={o.get('val')}")
found = True
if not found:
print("[✗] add后未立即收到推送可能需要等待 ws_task 轮询")
time.sleep(2)
msgs = ws_recv_all(sock, 3)
for msg in msgs:
if isinstance(msg, dict) and 'out' in msg:
print(f"[i] 延迟收到推送: {len(msg['out'])} 个信号")
# 清理
ws_send(sock, {"saddr":target,"signal_type":"out","curd":"del","setting_zone":"0","signal_data":""})
time.sleep(0.5); ws_recv_all(sock, 1)
try: sock.sendall(b'\x88\x80'+os.urandom(4))
except: pass
sock.close()
print("\n[✓] 测试完成")

View File

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""精确验证: 添加后检查 out 推送是否包含所有信号"""
import socket, base64, os, struct, json, time
IP = '198.120.0.100'; PORT = 8000
def ws_handshake(sock):
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /ws HTTP/1.1\r\nHost: {IP}:{PORT}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
sock.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
c = sock.recv(4096)
if not c: break
resp += c
return resp
def ws_send(sock, msg):
if isinstance(msg, dict): msg = json.dumps(msg, ensure_ascii=False)
p = msg.encode(); L = len(p); mask = os.urandom(4)
m = bytes(p[i] ^ mask[i%4] for i in range(L))
f = bytearray([0x81])
if L<126: f.append(0x80|L)
elif L<65536: f.append(0x80|126); f.extend(struct.pack('>H',L))
else: f.append(0x80|127); f.extend(struct.pack('>Q',L))
f.extend(mask); f.extend(m); sock.sendall(bytes(f))
def ws_recv_all(sock, t=2.0):
sock.settimeout(t); data=b""
try:
while True:
c=sock.recv(65536)
if not c: break
data+=c
except socket.timeout: pass
msgs=[]; offset=0
while offset+2<=len(data):
b0,b1=data[offset],data[offset+1]; op=b0&0xF; L=b1&0x7F; offset+=2
if L==126: L=struct.unpack('>H',data[offset:offset+2])[0]; offset+=2
elif L==127: L=struct.unpack('>Q',data[offset:offset+8])[0]; offset+=8
if op in (1,2) and offset+L<=len(data):
p=data[offset:offset+L]
try: msgs.append(json.loads(p.decode()))
except: msgs.append(p.decode(errors='replace'))
offset+=L
return msgs
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10); sock.connect((IP,PORT))
ws_handshake(sock)
# 先注册 dc out 信号
ws_send(sock, {"type":"cmd","data":"datacenter out"})
time.sleep(1); ws_recv_all(sock,1)
# 选择一个信号添加
target = "sys.ch.tcp_s0_if"
print(f"添加信号: {target}")
ws_send(sock, {"saddr":target,"signal_type":"out","curd":"add","setting_zone":"0","signal_data":""})
# 等待推送
time.sleep(3)
msgs = ws_recv_all(sock, 5)
for msg in msgs:
if isinstance(msg, dict) and 'out' in msg and isinstance(msg['out'], list):
print(f"out 推送: {len(msg['out'])} 个信号")
for o in msg['out']:
print(f" saddr={o.get('saddr')} val={o.get('val')}")
# 清理
ws_send(sock, {"saddr":target,"signal_type":"out","curd":"del","setting_zone":"0","signal_data":""})
time.sleep(0.5); ws_recv_all(sock, 1)
try: sock.sendall(b'\x88\x80'+os.urandom(4))
except: pass
sock.close()
print("done")

View File

@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""
数据中心 信号管理页面 (out) 添加信号功能测试
测试 RK3568 远端 RTU WebSocket 前端功能
关键发现
- dc_data 通过二进制 WebSocket 帧发送ws_send_binary
- curd:add 后不会立即推送需要等 ws_task 定时轮询 1 秒周期
- curd:del 后会立即推送
"""
import socket, base64, hashlib, struct, json, os, time, sys
IP = '198.120.0.100'
PORT = 8000
# =========================== WebSocket 基础工具 ===========================
def ws_handshake(sock):
"""执行 WebSocket 握手"""
key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET /ws HTTP/1.1\r\n"
f"Host: {IP}:{PORT}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"\r\n"
)
sock.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
chunk = sock.recv(4096)
if not chunk:
raise Exception("Handshake failed: no response")
resp += chunk
if b"101" not in resp.split(b"\r\n")[0]:
raise Exception(f"Handshake failed: {resp.split(b'\r\n')[0].decode()}")
print("[✓] WebSocket 握手成功")
def ws_send(sock, msg):
"""发送带掩码的 WebSocket 文本帧"""
if isinstance(msg, dict):
msg = json.dumps(msg)
b = msg.encode()
L = len(b)
mask = os.urandom(4)
masked = bytes(b[i] ^ mask[i % 4] for i in range(L))
frame = bytearray([0x81]) # FIN + text opcode
if L < 126:
frame.append(0x80 | L)
elif L < 65536:
frame.append(0x80 | 126)
frame.extend(struct.pack('>H', L))
else:
frame.append(0x80 | 127)
frame.extend(struct.pack('>Q', L))
frame.extend(mask)
frame.extend(masked)
sock.sendall(bytes(frame))
def ws_recv(sock, timeout=5.0):
"""接收一个 WebSocket 帧,返回 (opcode, payload_bytes)"""
sock.settimeout(timeout)
try:
header = sock.recv(2)
if len(header) < 2:
return None, None
opcode = header[0] & 0x0F
masked = (header[1] & 0x80) != 0
length = header[1] & 0x7F
if length == 126:
length = struct.unpack('>H', sock.recv(2))[0]
elif length == 127:
length = struct.unpack('>Q', sock.recv(8))[0]
if masked:
mask_key = sock.recv(4)
payload = b""
while len(payload) < length:
chunk = sock.recv(min(length - len(payload), 65536))
if not chunk:
break
payload += chunk
if masked:
payload = bytes(payload[i] ^ mask_key[i % 4] for i in range(len(payload)))
return opcode, payload
except socket.timeout:
return None, None
except Exception as e:
print(f" [!] recv error: {e}")
return None, None
def try_parse_json(payload):
"""尝试将 payload 解析为 JSON。dc_data 是二进制帧中嵌入的 JSON"""
if payload is None:
return None
try:
text = payload.decode('utf-8')
return json.loads(text)
except:
return None
def drain_sock(sock, timeout=1.0):
"""排空 socket 缓冲区中的所有待处理帧,返回所有解析后的消息"""
msgs = []
while True:
opcode, payload = ws_recv(sock, timeout=timeout)
if opcode is None:
break
# 文本帧
if opcode == 0x01 and payload:
obj = try_parse_json(payload)
if obj:
msgs.append(('text', obj))
else:
msgs.append(('text', payload.decode('utf-8', errors='replace')))
# 二进制帧dc_data 在这里)
elif opcode == 0x02 and payload:
obj = try_parse_json(payload)
if obj:
msgs.append(('binary', obj))
else:
msgs.append(('binary', payload))
elif opcode == 0x08:
msgs.append(('close', None))
break
elif opcode == 0x09: # ping
pass
return msgs
# =========================== 测试主流程 ===========================
def main():
print("=" * 70)
print(" RTU 数据中心 → 信号管理页(out) 添加信号功能测试")
print(f" 目标: {IP}:{PORT}")
print("=" * 70)
# ---- 连接 ----
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
try:
sock.connect((IP, PORT))
print(f"[✓] TCP 连接成功 → {IP}:{PORT}")
except Exception as e:
print(f"[✗] TCP 连接失败: {e}")
sys.exit(1)
ws_handshake(sock)
# 原始 socket 级别检查是否有数据
print("[i] 检查原始 socket 是否有数据到达...")
sock.settimeout(3.0)
try:
raw_data = sock.recv(65536)
if raw_data:
print(f"[i] 收到原始数据: {len(raw_data)} 字节")
print(f" 前 50 字节(hex): {raw_data[:50].hex()}")
print(f" 前 200 字节(str): {raw_data[:200]}")
# 尝试解析 WebSocket 帧
if len(raw_data) >= 2:
b0, b1 = raw_data[0], raw_data[1]
opcode = b0 & 0x0F
fin = (b0 >> 7) & 1
print(f" FIN={fin} opcode={opcode} ({'text' if opcode==1 else 'binary' if opcode==2 else 'close' if opcode==8 else 'other'})")
else:
print("[i] 原始 socket 无数据")
except socket.timeout:
print("[i] 原始 socket 超时,无数据")
sock.settimeout(10)
# 等待初始推送 (cmd_list 等)
time.sleep(1.0)
msgs = drain_sock(sock, timeout=2.0)
print(f"[i] 初始消息数: {len(msgs)}")
for kind, msg in msgs:
if isinstance(msg, dict):
print(f" [{kind}] type={msg.get('type','?')}, keys={list(msg.keys())}")
if msg.get('type') == 'cmd_list':
print(f" [i] 收到 cmd_list命令数: {len(msg.get('cmds', []))}")
elif isinstance(msg, str):
print(f" [{kind}] str: {msg[:200]}")
elif isinstance(msg, bytes):
print(f" [{kind}] bytes: {len(msg)}B, 前200字节: {msg[:200]}")
# ---- 步骤 1: 通过终端命令注册 dc_data 信号 ----
print("\n" + "=" * 70)
print(" 步骤 1: 通过 datacenter 命令注册所有类型的信号")
print("=" * 70)
types_cn = {'out': '注册', 'in': '链接注册', 'yk': '遥控', 'ao': '参数', 'param': '定值'}
all_dc_data = {}
for st in ['out', 'in', 'yk', 'ao', 'param']:
print(f"\n [→] 执行: datacenter {st}")
ws_send(sock, {"type": "cmd", "cmd": f"datacenter {st}"})
time.sleep(2.0) # 等待更长时间
# dc_data 通过二进制帧返回
msgs = drain_sock(sock, timeout=3.0)
print(f" [i] 收到 {len(msgs)} 条消息")
for kind, msg in msgs:
if isinstance(msg, dict):
print(f" [{kind}] type={msg.get('type','?')}")
if msg.get('type') == 'dc_data':
all_dc_data[st] = msg
for sig_type in ['out', 'in', 'yk', 'ao', 'param']:
count = len(msg.get(sig_type, []))
if count > 0:
print(f" {sig_type}: {count} 个信号")
for k in msg:
if k != 'type' and isinstance(msg[k], list):
print(f" {k}: {len(msg[k])} 个信号")
for item in msg[k][:3]:
print(f" - saddr={item.get('saddr','?')} desc={item.get('desc','?')}")
elif isinstance(msg, str):
print(f" [{kind}] str({len(msg)}): {msg[:300]}")
elif isinstance(msg, bytes):
print(f" [{kind}] bytes({len(msg)}): {msg[:200]}")
# ---- 步骤 2: 检查 dc_data 并选择 out 信号 ----
print("\n" + "=" * 70)
print(" 步骤 2: 从 dc_data 选择 out 信号添加到配置页")
print("=" * 70)
dc_out = all_dc_data.get('out', {})
out_signals = dc_out.get('out', [])
if not out_signals:
print(" [!] 没有可用的 out 信号!")
print(" 可能原因: 服务端 datacenter out 未注册任何信号")
sock.close()
return
# 选择前 3 个 out 信号(只选择直控或选控类型用于测试)
test_signals = [s for s in out_signals if s.get('ctrl_type', 0) in [1, 2]]
if not test_signals:
test_signals = out_signals[:3] # 降级:选前 3 个
test_signals = test_signals[:3]
print(f"{len(out_signals)} 个 out 信号中选择 {len(test_signals)} 个测试:")
for i, sig in enumerate(test_signals):
saddr = sig.get('saddr', '')
print(f" [{i+1}] saddr={saddr} desc={sig.get('desc','?')} ctrl_type={sig.get('ctrl_type','?')}")
# 先排空已有消息
drain_sock(sock, timeout=0.5)
# ---- 步骤 3: 执行 curd:add 添加信号 ----
print("\n" + "=" * 70)
print(" 步骤 3: 发送 curd:add 添加信号到配置页(out)")
print("=" * 70)
added_saddrs = set()
for i, sig in enumerate(test_signals):
saddr = sig.get('saddr', '')
add_msg = {
"saddr": saddr,
"signal_type": "out",
"curd": "add",
"setting_zone": "0",
"signal_data": ""
}
print(f"\n [{i+1}] 添加: saddr={saddr}")
ws_send(sock, add_msg)
added_saddrs.add(saddr)
time.sleep(2.0) # 2 秒间隔
# ---- 步骤 4: 等待 ws_task 推送 out 数据 ----
print("\n" + "=" * 70)
print(" 步骤 4: 等待 ws_task 定时推送 out 数据(最多 10 秒)")
print("=" * 70)
out_push_received = False
out_signals_list = []
start = time.time()
while time.time() - start < 10.0:
msgs = drain_sock(sock, timeout=2.0)
for kind, msg in msgs:
if isinstance(msg, dict):
out_data = msg.get('out')
if out_data is not None and isinstance(out_data, list) and len(out_data) > 0:
out_push_received = True
out_signals_list = out_data
elapsed = time.time() - start
print(f"\n [✓] 收到 out 数据推送! (耗时 {elapsed:.1f}s, {kind}帧)")
print(f" out 当前注册信号数: {len(out_data)}")
for j, o in enumerate(out_data):
marker = " ← 新添加" if o.get('saddr') in added_saddrs else ""
print(f" [{j+1}] saddr={o.get('saddr','?')} val={o.get('val','?')} desc={o.get('desc','?')}{marker}")
break
elif msg.get('type') == 'dc_data':
pass # 忽略 dc_data 推送
elif msg.get('type'):
pass
elif isinstance(msg, str) and len(msg) > 5:
pass # 可能是 cmd 输出
if out_push_received:
break
# ---- 步骤 5: 验证结果 ----
print("\n" + "=" * 70)
print(" 步骤 5: 验证结果")
print("=" * 70)
if not out_push_received:
print(" [✗] 未收到 out 数据推送")
print(" 可能原因:")
print(" 1. ws_task 定时器未触发(等待时间不足)")
print(" 2. add_signal 失败saddr 无效或信号已存在)")
print(" 3. 服务端没有 has_change新信号初始值与实际值相同")
else:
registered_saddrs = {o.get('saddr') for o in out_signals_list}
all_found = added_saddrs.issubset(registered_saddrs)
missing = added_saddrs - registered_saddrs
if all_found:
print(f" [✓] 所有 {len(added_saddrs)} 个测试信号已成功注册到 out!")
else:
print(f" [✗] 以下 {len(missing)} 个信号未能在 out 中找到: {missing}")
if missing:
print(f"\n 当前 out 中注册的信号 saddr: {registered_saddrs}")
# ---- 步骤 6: 清理 — 删除测试添加的信号 ----
print("\n" + "=" * 70)
print(" 步骤 6: 清理 — 删除测试添加的信号")
print("=" * 70)
for sig in test_signals:
saddr = sig.get('saddr', '')
del_msg = {
"saddr": saddr,
"signal_type": "out",
"curd": "del",
"setting_zone": "0",
"signal_data": ""
}
print(f" [→] 删除: saddr={saddr}")
ws_send(sock, del_msg)
time.sleep(0.5)
# del 后立即推送,检查删除结果
msgs = drain_sock(sock, timeout=2.0)
for kind, msg in msgs:
if isinstance(msg, dict) and msg.get('out') is not None:
print(f" [i] 删除后 out 信号数: {len(msg.get('out', []))}")
# ---- 总结 ----
print("\n" + "=" * 70)
print(" 测试总结")
print("=" * 70)
print(f" 数据中心 out 信号数: {len(out_signals)}")
print(f" 测试添加信号数: {len(test_signals)}")
if out_push_received:
print(f" out 推送接收: ✅")
print(f" 信号注册成功: {'✅ 全部通过' if all_found else f'❌ 缺少 {len(missing)}'}")
else:
print(f" out 推送接收: ❌ 未收到")
print("=" * 70)
# 发送关闭帧
close_frame = struct.pack('>H', 1000)
frame = bytearray([0x88, 0x02])
frame.extend(close_frame)
try:
sock.sendall(bytes(frame))
except:
pass
sock.close()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""
数据中心 信号管理页面 (out) 添加信号功能测试 (v2)
使用原始帧接收方式已验证可用
"""
import socket, base64, os, struct, json, time, sys
IP = '198.120.0.100'
PORT = 8000
def ws_handshake(sock):
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /ws HTTP/1.1\r\nHost: {IP}:{PORT}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
sock.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
c = sock.recv(4096)
if not c: break
resp += c
if b"101" not in resp.split(b"\r\n")[0]:
raise Exception(f"Handshake failed: {resp[:200]}")
return resp
def ws_send_text(sock, msg):
"""发送文本帧"""
if isinstance(msg, dict):
msg = json.dumps(msg, ensure_ascii=False)
payload = msg.encode()
L = len(payload)
mask = os.urandom(4)
masked = bytes(payload[i] ^ mask[i % 4] for i in range(L))
frame = bytearray([0x81])
if L < 126:
frame.append(0x80 | L)
elif L < 65536:
frame.append(0x80 | 126)
frame.extend(struct.pack('>H', L))
else:
frame.append(0x80 | 127)
frame.extend(struct.pack('>Q', L))
frame.extend(mask)
frame.extend(masked)
sock.sendall(bytes(frame))
def ws_recv_all(sock, timeout=2.0):
"""接收所有可用的 WebSocket 帧,解析后返回消息列表"""
sock.settimeout(timeout)
all_data = b""
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
all_data += chunk
except socket.timeout:
pass
msgs = []
offset = 0
while offset + 2 <= len(all_data):
b0 = all_data[offset]
b1 = all_data[offset + 1]
opcode = b0 & 0x0F
length = b1 & 0x7F
offset += 2
if length == 126:
if offset + 2 > len(all_data): break
length = struct.unpack('>H', all_data[offset:offset+2])[0]
offset += 2
elif length == 127:
if offset + 8 > len(all_data): break
length = struct.unpack('>Q', all_data[offset:offset+8])[0]
offset += 8
if offset + length > len(all_data):
break
payload = all_data[offset:offset+length]
offset += length
if opcode == 0x01: # text
try:
msgs.append(json.loads(payload.decode('utf-8')))
except:
msgs.append(payload.decode('utf-8', errors='replace'))
elif opcode == 0x02: # binary (dc_data 在这里)
try:
msgs.append(json.loads(payload.decode('utf-8')))
except:
msgs.append(payload)
elif opcode == 0x08: # close
pass
return msgs
def main():
print("=" * 70)
print(" RTU 数据中心 → 信号管理页(out) 添加信号功能测试")
print(f" 目标: {IP}:{PORT}")
print("=" * 70)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((IP, PORT))
print(f"[✓] TCP 连接成功")
ws_handshake(sock)
print(f"[✓] WebSocket 握手成功")
# ---- 0. 初始握手:获取 cmd_list ----
ws_send_text(sock, {"type": "get_cmds"})
time.sleep(0.5)
msgs = ws_recv_all(sock, timeout=2.0)
for msg in msgs:
if isinstance(msg, dict) and msg.get('type') == 'cmd_list':
print(f"[✓] cmd_list: {len(msg.get('cmds', []))} 个命令")
for cmd in msg.get('cmds', []):
print(f" - {cmd.get('name')}: {cmd.get('desc')}")
# ---- 1. 注册 dc_data 信号 ----
print("\n" + "=" * 70)
print(" 步骤 1: 通过 datacenter 命令注册所有信号")
print("=" * 70)
all_dc_data = {}
for st in ['out', 'in', 'yk', 'ao', 'param']:
print(f"\n [→] datacenter {st}")
ws_send_text(sock, {"type": "cmd", "cmd": f"datacenter {st}"})
time.sleep(1.5)
msgs = ws_recv_all(sock, timeout=3.0)
for msg in msgs:
if isinstance(msg, dict):
if msg.get('type') == 'dc_data':
all_dc_data[st] = msg
cnt = len(msg.get(st, []))
print(f" [✓] dc_data: {st} = {cnt} 个信号")
if cnt > 0:
for item in msg.get(st, [])[:5]:
print(f" saddr={item.get('saddr','?')} desc={item.get('desc','?')} ctrl={item.get('ctrl_type','?')}")
if cnt > 5:
print(f" ... 还有 {cnt-5}")
elif msg.get('type'):
pass # 忽略
elif isinstance(msg, str) and len(msg) < 300:
print(f" [cmd输出] {msg.strip()[:200]}")
# ---- 2. 选择 out 信号 ----
print("\n" + "=" * 70)
print(" 步骤 2: 选择 out 信号并添加到配置页")
print("=" * 70)
dc_out = all_dc_data.get('out', {})
out_signals = dc_out.get('out', [])
if not out_signals:
print(" [!] 没有可用的 out 信号!")
print(" 尝试直接添加一个已知信号测试...")
# 尝试添加一个可能存在的信号
test_saddr = "iec.self_check"
print(f" [→] 尝试添加: {test_saddr}")
ws_send_text(sock, {"saddr": test_saddr, "signal_type": "out", "curd": "add", "setting_zone": "0", "signal_data": ""})
time.sleep(3.0)
msgs = ws_recv_all(sock, timeout=3.0)
print(f" 收到 {len(msgs)} 条消息")
for msg in msgs:
if isinstance(msg, dict):
print(f" type={msg.get('type','?')}")
out_data = msg.get('out')
if out_data is not None:
print(f" out 信号数: {len(out_data) if isinstance(out_data, list) else '?'}")
if isinstance(out_data, list):
for o in out_data:
print(f" saddr={o.get('saddr','?')} val={o.get('val','?')}")
else:
# 选择前3个信号
test_signals = out_signals[:3]
test_saddrs = {s.get('saddr') for s in test_signals}
print(f"{len(out_signals)} 个信号中选择 {len(test_signals)} 个:")
for i, sig in enumerate(test_signals):
print(f" [{i+1}] saddr={sig.get('saddr')} desc={sig.get('desc','?')}")
# ---- 3. 执行 curd:add ----
print("\n" + "=" * 70)
print(" 步骤 3: 执行 curd:add")
print("=" * 70)
for i, sig in enumerate(test_signals):
saddr = sig.get('saddr')
print(f"\n [{i+1}] curd:add saddr={saddr}")
ws_send_text(sock, {"saddr": saddr, "signal_type": "out", "curd": "add", "setting_zone": "0", "signal_data": ""})
time.sleep(2.0)
# ---- 4. 等待 ws_task 推送 out 数据 ----
print("\n" + "=" * 70)
print(" 步骤 4: 等待 ws_task 推送 out 数据最多15秒")
print("=" * 70)
out_received = False
out_data = []
start = time.time()
while time.time() - start < 15.0:
msgs = ws_recv_all(sock, timeout=3.0)
for msg in msgs:
if isinstance(msg, dict):
out_arr = msg.get('out')
if isinstance(out_arr, list) and len(out_arr) > 0:
out_data = out_arr
out_received = True
break
if out_received:
break
if msgs:
print(f" [i] 等待中... ({time.time()-start:.0f}s)")
# ---- 5. 验证 ----
print("\n" + "=" * 70)
print(" 步骤 5: 验证结果")
print("=" * 70)
if out_received:
print(f" [✓] 收到 out 数据: {len(out_data)} 个信号")
registered = {o.get('saddr') for o in out_data}
for o in out_data:
marker = " ← 新添加" if o.get('saddr') in test_saddrs else ""
print(f" saddr={o.get('saddr','?')} val={o.get('val','?')} desc={o.get('desc','?')}{marker}")
all_found = test_saddrs.issubset(registered)
missing = test_saddrs - registered
if all_found:
print(f"\n [✓] 所有 {len(test_saddrs)} 个信号已成功注册到 out!")
else:
print(f"\n [✗] 缺少 {len(missing)} 个: {missing}")
print(f" 当前已注册: {registered}")
# ---- 6. 清理 ----
print("\n" + "=" * 70)
print(" 步骤 6: 清理 — 删除测试信号")
print("=" * 70)
for sig in test_signals:
saddr = sig.get('saddr')
print(f" [→] del saddr={saddr}")
ws_send_text(sock, {"saddr": saddr, "signal_type": "out", "curd": "del", "setting_zone": "0", "signal_data": ""})
time.sleep(0.3)
else:
print(" [✗] 未收到 out 数据推送")
print(" 可能原因: add_signal 失败,或 ws_task 未推送变化")
# 清理 - 发送关闭帧
try:
sock.sendall(b'\x88\x80' + os.urandom(4)) # close frame
except:
pass
sock.close()
print("\n[✓] 测试完成")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
数据中心 信号管理页面 (out) 添加信号功能测试 (v3)
修复: 终端命令用 "data" 字段而非 "cmd"
"""
import socket, base64, os, struct, json, time, sys
IP = '198.120.0.100'; PORT = 8000
def ws_handshake(sock):
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /ws HTTP/1.1\r\nHost: {IP}:{PORT}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
sock.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
c = sock.recv(4096)
if not c: break
resp += c
if b"101" not in resp.split(b"\r\n")[0]:
raise Exception(f"Handshake failed: {resp[:200]}")
return resp
def ws_send(sock, msg):
if isinstance(msg, dict):
msg = json.dumps(msg, ensure_ascii=False)
payload = msg.encode()
L = len(payload)
mask = os.urandom(4)
masked = bytes(payload[i] ^ mask[i % 4] for i in range(L))
frame = bytearray([0x81])
if L < 126:
frame.append(0x80 | L)
elif L < 65536:
frame.append(0x80 | 126)
frame.extend(struct.pack('>H', L))
else:
frame.append(0x80 | 127)
frame.extend(struct.pack('>Q', L))
frame.extend(mask)
frame.extend(masked)
sock.sendall(bytes(frame))
def ws_recv_frames(sock, timeout=2.0):
"""接收所有可用帧,解析后返回消息列表"""
sock.settimeout(timeout)
all_data = b""
try:
while True:
chunk = sock.recv(65536)
if not chunk: break
all_data += chunk
except socket.timeout:
pass
msgs = []
offset = 0
while offset + 2 <= len(all_data):
b0, b1 = all_data[offset], all_data[offset + 1]
opcode = b0 & 0x0F
length = b1 & 0x7F
offset += 2
if length == 126:
if offset + 2 > len(all_data): break
length = struct.unpack('>H', all_data[offset:offset+2])[0]
offset += 2
elif length == 127:
if offset + 8 > len(all_data): break
length = struct.unpack('>Q', all_data[offset:offset+8])[0]
offset += 8
if offset + length > len(all_data):
break
payload = all_data[offset:offset+length]
offset += length
if opcode in (0x01, 0x02):
try:
msgs.append(json.loads(payload.decode('utf-8')))
except:
msgs.append(payload.decode('utf-8', errors='replace'))
elif opcode == 0x08:
pass
return msgs
def main():
print("=" * 70)
print(" RTU 数据中心 → 信号管理页(out) 添加信号功能测试")
print(f" 目标: {IP}:{PORT}")
print("=" * 70)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((IP, PORT))
print(f"[✓] TCP 连接成功")
ws_handshake(sock)
print("[✓] WebSocket 握手成功")
# ---- 步骤 1: 注册 dc_data 信号 ----
# 注意: 终端命令格式为 {"type":"cmd","data":"datacenter out"}
print("\n" + "=" * 70)
print(" 步骤 1: 通过 datacenter 命令注册所有类型信号")
print("=" * 70)
all_dc_data = {}
for st in ['out', 'in', 'yk', 'ao', 'param']:
print(f"\n [→] datacenter {st}")
ws_send(sock, {"type": "cmd", "data": f"datacenter {st}"})
time.sleep(1.5)
msgs = ws_recv_frames(sock, timeout=3.0)
print(f" [i] 收到 {len(msgs)} 条消息")
for msg in msgs:
if isinstance(msg, dict):
if msg.get('type') == 'dc_data':
all_dc_data[st] = msg
cnt = len(msg.get(st, []))
print(f" [✓] dc_data: {st} = {cnt} 个信号")
for item in msg.get(st, [])[:5]:
print(f" saddr={item.get('saddr','?')} desc={item.get('desc','?')} ctrl={item.get('ctrl_type','?')}")
if cnt > 5:
print(f" ... 还有 {cnt-5}")
elif isinstance(msg, str) and len(msg) < 500:
print(f" [cmd输出] {msg.strip()[:200]}")
# ---- 步骤 2: 选择 out 信号 ----
print("\n" + "=" * 70)
print(" 步骤 2: 选择 out 信号并添加到配置页")
print("=" * 70)
dc_out = all_dc_data.get('out', {})
out_signals = dc_out.get('out', [])
if not out_signals:
print(" [!] 没有可用的 out 信号")
print(" 原因: datacenter 命令未注册任何 out 信号到数据中心")
print(" 可能需要在配置文件中添加信号定义")
sock.close()
return
test_signals = out_signals[:3]
test_saddrs = {s.get('saddr') for s in test_signals}
print(f"{len(out_signals)} 个信号中选择 {len(test_signals)} 个:")
for i, sig in enumerate(test_signals):
print(f" [{i+1}] saddr={sig.get('saddr')} desc={sig.get('desc','?')}")
# 排空之前的消息
ws_recv_frames(sock, timeout=0.5)
# ---- 步骤 3: 执行 curd:add ----
print("\n" + "=" * 70)
print(" 步骤 3: 执行 curd:add (模拟前端「添加到配置页」)")
print("=" * 70)
for i, sig in enumerate(test_signals):
saddr = sig.get('saddr')
print(f"\n [{i+1}] curd:add saddr={saddr}")
ws_send(sock, {
"saddr": saddr,
"signal_type": "out",
"curd": "add",
"setting_zone": "0",
"signal_data": ""
})
time.sleep(2.0) # 2秒间隔模拟真人操作
# ---- 步骤 4: 等待 ws_task 推送 out 数据 ----
print("\n" + "=" * 70)
print(" 步骤 4: 等待 ws_task 周期性推送 out 数据")
print(" (add 操作不会立即推送,需要等 ws_task 检测到 has_change)")
print("=" * 70)
out_received = False
out_data = []
start = time.time()
while time.time() - start < 15.0:
msgs = ws_recv_frames(sock, timeout=3.0)
for msg in msgs:
if isinstance(msg, dict):
out_arr = msg.get('out')
if isinstance(out_arr, list) and len(out_arr) > 0:
out_data = out_arr
out_received = True
break
if out_received:
break
elapsed = time.time() - start
if elapsed > 2:
print(f" [i] 等待中... ({elapsed:.0f}s)")
# ---- 步骤 5: 验证结果 ----
print("\n" + "=" * 70)
print(" 步骤 5: 验证结果")
print("=" * 70)
if out_received:
print(f" [✓] 收到 out 数据推送: {len(out_data)} 个注册信号")
registered_saddrs = {o.get('saddr') for o in out_data}
for o in out_data:
marker = " ← 新添加" if o.get('saddr') in test_saddrs else ""
print(f" saddr={o.get('saddr','?')} val={o.get('val','?')} desc={o.get('desc','?')}{marker}")
all_found = test_saddrs.issubset(registered_saddrs)
missing = test_saddrs - registered_saddrs
if all_found:
print(f"\n ✅ 所有 {len(test_saddrs)} 个信号成功注册到 out!")
else:
print(f"\n ❌ 缺少 {len(missing)} 个信号: {missing}")
else:
print(" ❌ 未收到 out 数据推送")
print(" 说明: add_signal 后服务端没有通过 ws_task 推送变化")
print(" 可能原因: has_change 为 false (新信号初始值可能与 last_val 相同)")
# ---- 步骤 6: 清理 ----
print("\n" + "=" * 70)
print(" 步骤 6: 清理 — 删除测试添加的信号")
print("=" * 70)
for sig in test_signals:
saddr = sig.get('saddr')
print(f" [→] del saddr={saddr}")
ws_send(sock, {
"saddr": saddr, "signal_type": "out",
"curd": "del", "setting_zone": "0", "signal_data": ""
})
time.sleep(0.3)
# del 后立即推送
msgs = ws_recv_frames(sock, timeout=2.0)
for msg in msgs:
if isinstance(msg, dict) and msg.get('out') is not None:
print(f" [i] 删除后 out 信号数: {len(msg['out'])}")
# 发送关闭帧
try:
sock.sendall(b'\x88\x80' + os.urandom(4))
except:
pass
sock.close()
print("\n[✓] 测试完成")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""最简 WebSocket 测试 - 验证基本通信"""
import socket, base64, os, struct, json, time, sys
IP = '198.120.0.100'
PORT = 8000
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((IP, PORT))
print(f"[✓] TCP 连接成功")
# WebSocket 握手
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /ws HTTP/1.1\r\nHost: {IP}:{PORT}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
sock.sendall(req.encode())
# 接收 HTTP 响应
resp = b""
while b"\r\n\r\n" not in resp:
chunk = sock.recv(4096)
if not chunk: break
resp += chunk
print(f"[✓] HTTP 响应: {resp.split(b'\r\n')[0].decode()}")
# 检查响应码
if b"101" not in resp.split(b"\r\n")[0]:
print(f"[✗] 升级失败: {resp.decode(errors='replace')[:500]}")
sys.exit(1)
# 检查是否有额外的数据WebSocket 帧紧跟在 HTTP 响应后)
headers_end = resp.find(b"\r\n\r\n") + 4
extra = resp[headers_end:]
if extra:
print(f"[i] HTTP 响应后有额外 {len(extra)} 字节: {extra.hex()}")
# 手动构造 WebSocket 文本帧发送 get_cmds
msg = json.dumps({"type": "get_cmds"})
payload = msg.encode()
L = len(payload)
mask = os.urandom(4)
masked = bytes(payload[i] ^ mask[i % 4] for i in range(L))
frame = bytearray([0x81]) # FIN + text
if L < 126:
frame.append(0x80 | L)
else:
frame.append(0x80 | 126)
frame.extend(struct.pack('>H', L))
frame.extend(mask)
frame.extend(masked)
sock.sendall(bytes(frame))
print(f"[→] 发送 get_cmds ({L} 字节)")
# 等待接收
sock.settimeout(3.0)
all_data = b""
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
all_data += chunk
print(f"[i] recv chunk: {len(chunk)} 字节")
except socket.timeout:
print(f"[i] recv 超时")
print(f"\n总共接收到: {len(all_data)} 字节")
if len(all_data) == 0:
print("[✗] 未收到任何数据!服务器可能未响应")
sys.exit(1)
# 解析 WebSocket 帧
offset = 0
while offset < len(all_data):
if offset + 2 > len(all_data): break
b0 = all_data[offset]
b1 = all_data[offset + 1]
opcode = b0 & 0x0F
length = b1 & 0x7F
offset += 2
if length == 126:
if offset + 2 > len(all_data): break
length = struct.unpack('>H', all_data[offset:offset+2])[0]
offset += 2
elif length == 127:
if offset + 8 > len(all_data): break
length = struct.unpack('>Q', all_data[offset:offset+8])[0]
offset += 8
if offset + length > len(all_data):
print(f" [W] 帧不完整: 需要 {length} 字节但只剩 {len(all_data)-offset}")
break
payload = all_data[offset:offset+length]
offset += length
if opcode == 0x01: # text
print(f" [text帧] {payload.decode('utf-8', errors='replace')[:500]}")
elif opcode == 0x02: # binary
try:
obj = json.loads(payload.decode('utf-8'))
print(f" [binary帧] JSON: type={obj.get('type','?')}, keys={list(obj.keys())}")
if obj.get('type') == 'cmd_list':
print(f" cmd_list: {len(obj.get('cmds',[]))} 个命令")
except:
print(f" [binary帧] {len(payload)} 字节: {payload[:200]}")
elif opcode == 0x08:
print(f" [close帧]")
elif opcode == 0x09:
print(f" [ping帧]")
elif opcode == 0x0A:
print(f" [pong帧]")
else:
print(f" [opcode={opcode}帧] {len(payload)} 字节")
sock.close()

18
requirements.toml Normal file
View File

@ -0,0 +1,18 @@
"# Codex 项目权限配置 — 预批准 WSL 环境下操作 RTU 项目的命令"
"# 这是安全的关键文件,请谨慎添加规则"
""
"[commands]"
"# RTU 项目根目录操作"
"allow = ["
" [""find"", ""/home/ypc/RTU/RTU/""],"
" [""cat"", ""/home/ypc/RTU/RTU/""],"
" [""head"", ""/home/ypc/RTU/RTU/""],"
" [""tail"", ""/home/ypc/RTU/RTU/""],"
" [""cd"", ""/home/ypc/RTU/RTU""],"
" [""git"", ""-C"", ""/home/ypc/RTU/RTU""],"
" [""make"", ""-C"", ""/home/ypc/RTU/RTU/release""],"
" [""./release/build.sh""],"
" [""./test/RTU""],"
" [""ls"", ""-la"", ""/home/ypc/RTU/""],"
"]"
""

View File

@ -631,7 +631,7 @@ int PLC_Logic(void) {
extract_hw_points_from_graphs(graphs, graph_count, &hw, 0); extract_hw_points_from_graphs(graphs, graph_count, &hw, 0);
#ifdef DEBUG #ifndef DEBUG
printf("==================== 文件解析结果 ====================\n"); printf("==================== 文件解析结果 ====================\n");
printf("共解析到 %d 个逻辑图\n", graph_count); printf("共解析到 %d 个逻辑图\n", graph_count);
for (int i = 0; i < graph_count; i++) { for (int i = 0; i < graph_count; i++) {

File diff suppressed because it is too large Load Diff

View File

@ -927,10 +927,40 @@ void ws_recv(struct mg_connection *c, const char* p_rx, uint16_t rx_len)
if(0 == curd_str.compare("add")) if(0 == curd_str.compare("add"))
{ {
method.add_signal(s, saddr_str); method.add_signal(s, saddr_str);
/* 添加后立即推送当前信号列表,前端无需等 ws_task 定时轮询 */
cJSON *push = cJSON_CreateObject();
if(push)
{
bool dummy = false;
method.make_signal_json(s, push, dummy);
char *p_tx = cJSON_Print(push);
if(p_tx)
{
ws_send_one(c->id, p_tx, strlen(p_tx));
free(p_tx);
}
cJSON_Delete(push);
}
} }
else if(0 == curd_str.compare("del")) else if(0 == curd_str.compare("del"))
{ {
method.del_signal(s, saddr_str); method.del_signal(s, saddr_str);
/* 删除后立即推送当前信号列表,前端无需等 ws_task 定时轮询 */
cJSON *push = cJSON_CreateObject();
if(push)
{
bool dummy = false;
method.make_signal_json(s, push, dummy);
char *p_tx = cJSON_Print(push);
if(p_tx)
{
ws_send_one(c->id, p_tx, strlen(p_tx));
free(p_tx);
}
cJSON_Delete(push);
}
} }
else if(0 == curd_str.compare("set")) else if(0 == curd_str.compare("set"))
{ {

View File

@ -738,3 +738,109 @@ body.login-page #main-content { margin-left: 0; display: flex; align-items: cent
cursor: pointer; cursor: pointer;
accent-color: var(--primary); accent-color: var(--primary);
} }
/* === PLC Debug Page === */
.plc-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.plc-actions {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.plc-grid {
display: flex;
gap: 24px;
}
.plc-col {
flex: 1;
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 16px;
min-height: 200px;
}
.plc-col h3 {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary);
font-size: 16px;
}
.plc-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 8px;
border-bottom: 1px solid #f0f0f0;
}
.plc-led {
font-size: 28px;
cursor: pointer;
opacity: 0.3;
transition: all 0.3s;
filter: grayscale(100%);
}
.plc-led-on {
opacity: 1;
filter: none;
text-shadow: 0 0 10px #4CAF50, 0 0 20px #4CAF50;
}
.plc-btn-icon {
font-size: 28px;
cursor: pointer;
transition: transform 0.15s;
}
.plc-btn-icon:active {
transform: scale(0.85);
}
.plc-info {
flex: 1;
font-size: 13px;
}
.plc-saddr {
color: var(--primary);
font-weight: 600;
font-family: monospace;
}
.plc-val {
font-size: 16px;
font-weight: 700;
color: var(--info);
min-width: 40px;
text-align: right;
}
/* === Modal === */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center;
z-index: 999;
}
.modal-box {
background: var(--card-bg);
border-radius: var(--radius);
padding: 24px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
min-width: 380px;
}
.modal-box h3 { margin-bottom: 8px; }
.plc-sig-row:hover { background: #f0f8ff !important; }
/* === Batch ops === */
.batch-ops { display: flex; align-items: center; gap: 8px; }
/* === Dashboard plc card === */
.dash-card.plc { border-top: 4px solid #FF9800; }

View File

@ -33,6 +33,7 @@
<script src="js/monitor.js"></script> <script src="js/monitor.js"></script>
<script src="js/ws-client.js"></script> <script src="js/ws-client.js"></script>
<script src="js/pages.js"></script> <script src="js/pages.js"></script>
<script src="js/plc_debug.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
</body> </body>
</html> </html>

View File

@ -21,6 +21,7 @@ var App = (function() {
{ hash: '#ao', label: '🔧 参数 (ao)' }, { hash: '#ao', label: '🔧 参数 (ao)' },
{ hash: '#param', label: '⚙ 定值 (param)' }, { hash: '#param', label: '⚙ 定值 (param)' },
{ hash: '#datacenter', label: '📊 数据中心' }, { hash: '#datacenter', label: '📊 数据中心' },
{ hash: '#plc_debug', label: '🔌 PLC调试' },
{ hash: '#monitor', label: '📋 数据监控' }, { hash: '#monitor', label: '📋 数据监控' },
{ hash: '#terminal', label: '💻 命令终端' } { hash: '#terminal', label: '💻 命令终端' }
]; ];
@ -112,7 +113,7 @@ var App = (function() {
} }
// 页面进入后立即推送缓存数据(如有) // 页面进入后立即推送缓存数据(如有)
var stMap = { '#out': 'out', '#in': 'in', '#yk': 'yk', '#ao': 'ao', '#param': 'param', '#datacenter': 'dc_data', '#terminal': 'cmd_list' }; var stMap = { '#out': 'out', '#in': 'in', '#yk': 'yk', '#ao': 'ao', '#param': 'param', '#datacenter': 'dc_data', '#plc_debug': 'out', '#terminal': 'cmd_list' };
var st = stMap[hash]; var st = stMap[hash];
if (st && page.onData && lastDataCache[st]) { if (st && page.onData && lastDataCache[st]) {
page.onData(lastDataCache[st]); page.onData(lastDataCache[st]);
@ -201,7 +202,8 @@ var App = (function() {
'#in': 'in', '#in': 'in',
'#yk': 'yk', '#yk': 'yk',
'#ao': 'ao', '#ao': 'ao',
'#param': 'param' '#param': 'param',
'#plc_debug': 'out'
}; };
var signalType = signalTypeMap[currentHash]; var signalType = signalTypeMap[currentHash];
@ -221,6 +223,7 @@ var App = (function() {
registerPage('#ao', AoPage); registerPage('#ao', AoPage);
registerPage('#param', ParamPage); registerPage('#param', ParamPage);
registerPage('#datacenter', DatacenterPage); registerPage('#datacenter', DatacenterPage);
registerPage('#plc_debug', PlcDebugPage);
registerPage('#monitor', MonitorPage); registerPage('#monitor', MonitorPage);
registerPage('#terminal', TerminalPage); registerPage('#terminal', TerminalPage);

View File

@ -13,6 +13,20 @@
var CTRL_TYPE_MAP = { 0: '只读', 1: '直控', 2: '选控' }; var CTRL_TYPE_MAP = { 0: '只读', 1: '直控', 2: '选控' };
/* 每种信号类型的最大注册数量 */
var MAX_SIGNALS_PER_TYPE = 500;
/**
* 获取指定信号类型已注册的数量
* @param {string} signalType - out/in/yk/ao/param
* @returns {number}
*/
function getRegisteredCount(signalType) {
var data = (typeof App !== 'undefined' && App.getSignalData)
? App.getSignalData(signalType) : null;
return (data && Array.isArray(data)) ? data.length : 0;
}
/* ======================================================================== /* ========================================================================
* 共用工具 * 共用工具
* ======================================================================== */ * ======================================================================== */
@ -201,9 +215,9 @@ function importAllFromXml() {
input.click(); input.click();
} }
/* ======================================================================== /* ------------------------------------------------------------------------
* 共用工具栏 HTML * 共用工具栏 HTML
* ======================================================================== */ * ------------------------------------------------------------------------ */
function makeToolbar(signalType, showSet) { function makeToolbar(signalType, showSet) {
var typeLabel = signalType === 'ao' ? '参数' : (signalType === 'param' ? '定值' : signalType); var typeLabel = signalType === 'ao' ? '参数' : (signalType === 'param' ? '定值' : signalType);
return '' return ''
@ -239,6 +253,13 @@ function bindToolbar(signalType, getDataFn) {
alert('请输入短地址 saddr'); alert('请输入短地址 saddr');
return; return;
} }
if (curd === 'add') {
var cur = getRegisteredCount(signalType);
if (cur >= MAX_SIGNALS_PER_TYPE) {
alert(signalType + ' 已注册 ' + cur + ' 个,最多允许 ' + MAX_SIGNALS_PER_TYPE + ' 个,请先删除旧信号');
return;
}
}
if (!WsClient.send({ saddr: saddr, signal_type: signalType, curd: curd, setting_zone: '0', signal_data: '' })) { if (!WsClient.send({ saddr: saddr, signal_type: signalType, curd: curd, setting_zone: '0', signal_data: '' })) {
alert('WebSocket 未连接'); alert('WebSocket 未连接');
} }
@ -291,7 +312,8 @@ var DashboardPage = {
{ cls: 'ao', icon: '🔧', title: '参数', desc: 'ao — 模拟输出参数设定', hash: '#ao' }, { cls: 'ao', icon: '🔧', title: '参数', desc: 'ao — 模拟输出参数设定', hash: '#ao' },
{ cls: 'param', icon: '⚙', title: '定值', desc: 'param — 多定值区参数管理', hash: '#param' }, { cls: 'param', icon: '⚙', title: '定值', desc: 'param — 多定值区参数管理', hash: '#param' },
{ cls: 'monitor', icon: '📋', title: '数据监控', desc: '实时查看 WebSocket 交互数据', hash: '#monitor' }, { cls: 'monitor', icon: '📋', title: '数据监控', desc: '实时查看 WebSocket 交互数据', hash: '#monitor' },
{ cls: 'terminal', icon: '💻', title: '命令终端', desc: '实时命令行操作 RTU 系统', hash: '#terminal' } { cls: 'terminal', icon: '💻', title: '命令终端', desc: '实时命令行操作 RTU 系统', hash: '#terminal' },
{ cls: 'plc', icon: '🔌', title: 'PLC调试', desc: '指示灯/按键绑定 out 信号调试', hash: '#plc_debug' }
]; ];
var html = '<div class="page-header"><h2>功能导航</h2><p>选择需要管理的信号类型进入对应功能页</p></div>'; var html = '<div class="page-header"><h2>功能导航</h2><p>选择需要管理的信号类型进入对应功能页</p></div>';
html += '<div class="dashboard-grid">'; html += '<div class="dashboard-grid">';
@ -324,17 +346,38 @@ var DashboardPage = {
* ======================================================================== */ * ======================================================================== */
var OutPage = (function() { var OutPage = (function() {
var data = []; var data = [];
var selected = {}; /* {idx: true} 行选中状态 */
function getCheckedItems() {
var items = [];
for (var k in selected) {
if (selected.hasOwnProperty(k) && data[parseInt(k)]) {
items.push(data[parseInt(k)]);
}
}
return items;
}
function updateDelBtn() {
var btn = document.getElementById('out-del-btn');
if (!btn) return;
var checked = document.querySelectorAll('#out-tbody .row-cb:checked');
var cnt = checked.length;
btn.textContent = '🗑 删除选中' + (cnt > 0 ? '(' + cnt + ')' : '');
btn.disabled = (cnt === 0);
}
function renderTableHtml() { function renderTableHtml() {
if (data.length === 0) return ''; if (data.length === 0) return '';
var rows = ''; var rows = '';
for (var i = 0; i < data.length; i++) { for (var i = 0; i < data.length; i++) {
var item = data[i]; var item = data[i];
var checked = !!selected[i];
rows += '<tr>' rows += '<tr>'
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
+ '<td>' + (i + 1) + '</td>' + '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>' + '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>' + '<td>' + fmtVal(item.desc) + '</td>'
+ '<td>' + fmtVal(item.type) + '</td>' + '<td>' + fmtVal(item.type) + '</td>'
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>' + '<td class="val-cell"><span id="out-val-' + i + '">' + fmtVal(item.val) + '</span></td>'
+ '<td><input class="val-input" id="mod-' + i + '" placeholder="修改值"></td>' + '<td><input class="val-input" id="mod-' + i + '" placeholder="修改值"></td>'
+ '<td><button class="btn btn-sm btn-info" data-idx="' + i + '">修改</button></td>' + '<td><button class="btn btn-sm btn-info" data-idx="' + i + '">修改</button></td>'
+ '</tr>'; + '</tr>';
@ -353,26 +396,60 @@ var OutPage = (function() {
WsClient.send({ curd:'set', signal_type:'out', saddr:item.saddr, signal_data:val, setting_zone:'0' }); WsClient.send({ curd:'set', signal_type:'out', saddr:item.saddr, signal_data:val, setting_zone:'0' });
}; };
} }
/* 绑定行复选框 */
var cbs = document.querySelectorAll('#out-tbody .row-cb');
for (var j = 0; j < cbs.length; j++) {
cbs[j].onclick = function(e) {
e.stopPropagation();
var idx = this.getAttribute('data-idx');
if (this.checked) selected[idx] = true; else delete selected[idx];
updateDelBtn();
};
}
}
function doDeleteChecked() {
var items = getCheckedItems();
if (items.length === 0) { alert('请先勾选要删除的信号'); return; }
if (!confirm('确定删除 ' + items.length + ' 个信号?')) return;
for (var i = 0; i < items.length; i++) {
WsClient.send({ curd:'del', signal_type:'out', saddr:items[i].saddr, setting_zone:'0', signal_data:'' });
}
selected = {};
updateDelBtn();
} }
return { return {
render: function(container) { render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val', '修改值', '修改']; selected = {};
var headers = ['', '#', 'saddr', 'desc', '数据类型', 'val', '修改值', '修改'];
container.innerHTML = '' container.innerHTML = ''
+ '<div class="page-header"><h2>注册管理</h2><p>out — 状态量信号,注册后支持添加、删除、修改值</p></div>' + '<div class="page-header"><h2>注册管理</h2><p>out — 状态量信号,注册后支持添加、删除、修改值</p></div>'
+ makeToolbar('out') + makeToolbar('out')
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="out-del-btn" disabled>🗑 删除选中</button></div>'
+ renderTable('out-tbody', headers, renderTableHtml(), '暂无注册数据,请先添加信号'); + renderTable('out-tbody', headers, renderTableHtml(), '暂无注册数据,请先添加信号');
bindToolbar('out', function() { return data; }); bindToolbar('out', function() { return data; });
bindModifyButtons(); bindModifyButtons();
var delBtn = document.getElementById('out-del-btn');
if (delBtn) delBtn.onclick = doDeleteChecked;
updateDelBtn();
}, },
onEnter: function() {}, onEnter: function() {},
onLeave: function() { data = []; }, onLeave: function() { data = []; selected = {}; },
onData: function(arr) { onData: function(arr) {
data = arr || []; data = arr || [];
var tbody = document.getElementById('out-tbody'); var tbody = document.getElementById('out-tbody');
if (!tbody) return; if (!tbody) return;
if (data.length === 0) { tbody.innerHTML = '<tr class="empty-row"><td colspan="7">暂无注册数据,请先添加信号</td></tr>'; return; } if (data.length === 0) { tbody.innerHTML = '<tr class="empty-row"><td colspan="8">暂无注册数据,请先添加信号</td></tr>'; updateDelBtn(); return; }
tbody.innerHTML = renderTableHtml(); var oldRowCnt = tbody.querySelectorAll('tr:not(.empty-row)').length;
bindModifyButtons(); if (oldRowCnt !== data.length) {
tbody.innerHTML = renderTableHtml();
bindModifyButtons();
} else {
var valCells = tbody.querySelectorAll('.val-cell');
for (var v = 0; v < data.length && v < valCells.length; v++) {
valCells[v].textContent = fmtVal(data[v].val);
}
}
updateDelBtn();
} }
}; };
})(); })();
@ -382,43 +459,86 @@ var OutPage = (function() {
* ======================================================================== */ * ======================================================================== */
var InPage = (function() { var InPage = (function() {
var data = []; var data = [];
var selected = {};
function getCheckedItems() {
var items = [];
for (var k in selected) {
if (selected.hasOwnProperty(k) && data[parseInt(k)]) items.push(data[parseInt(k)]);
}
return items;
}
function updateDelBtn() {
var btn = document.getElementById('in-del-btn');
if (!btn) return;
var cnt = 0;
for (var k in selected) { if (selected.hasOwnProperty(k)) cnt++; }
btn.textContent = '🗑 删除选中' + (cnt > 0 ? '(' + cnt + ')' : '');
btn.disabled = (cnt === 0);
}
function renderTableHtml() { function renderTableHtml() {
if (data.length === 0) return ''; if (data.length === 0) return '';
var rows = ''; var rows = '';
for (var i = 0; i < data.length; i++) { for (var i = 0; i < data.length; i++) {
var item = data[i]; var item = data[i];
var checked = !!selected[i];
rows += '<tr>' rows += '<tr>'
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
+ '<td>' + (i + 1) + '</td>' + '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>' + '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>' + '<td>' + fmtVal(item.desc) + '</td>'
+ '<td>' + fmtVal(item.type) + '</td>' + '<td>' + fmtVal(item.type) + '</td>'
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>' + '<td class="val-cell"><span id="in-val-' + i + '">' + fmtVal(item.val) + '</span></td>'
+ '</tr>'; + '</tr>';
} }
return rows; return rows;
} }
function bindCb() {
var cbs = document.querySelectorAll('#in-tbody .row-cb');
for (var j = 0; j < cbs.length; j++) {
cbs[j].onclick = function(e) {
e.stopPropagation();
var idx = this.getAttribute('data-idx');
if (this.checked) selected[idx] = true; else delete selected[idx];
updateDelBtn();
};
}
}
function doDeleteChecked() {
var items = getCheckedItems();
if (items.length === 0) { alert('请先勾选要删除的信号'); return; }
if (!confirm('确定删除 ' + items.length + ' 个信号?')) return;
for (var i = 0; i < items.length; i++) {
WsClient.send({ curd:'del', signal_type:'in', saddr:items[i].saddr, setting_zone:'0', signal_data:'' });
}
selected = {};
updateDelBtn();
}
return { return {
render: function(container) { render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val']; selected = {};
var headers = ['', '#', 'saddr', 'desc', '数据类型', 'val'];
container.innerHTML = '' container.innerHTML = ''
+ '<div class="page-header"><h2>链接注册管理</h2><p>in — 测量量信号,链接注册后仅支持添加和删除,不支持修改</p></div>' + '<div class="page-header"><h2>链接注册管理</h2><p>in — 测量量信号,链接注册后仅支持添加和删除,不支持修改</p></div>'
+ makeToolbar('in') + makeToolbar('in')
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="in-del-btn" disabled>🗑 删除选中</button></div>'
+ renderTable('in-tbody', headers, renderTableHtml(), '暂无链接注册数据,请先添加信号'); + renderTable('in-tbody', headers, renderTableHtml(), '暂无链接注册数据,请先添加信号');
bindToolbar('in', function() { return data; }); bindToolbar('in', function() { return data; });
bindCb();
var delBtn = document.getElementById('in-del-btn');
if (delBtn) delBtn.onclick = doDeleteChecked;
updateDelBtn();
}, },
onEnter: function() {}, onEnter: function() {},
onLeave: function() { data = []; }, onLeave: function() { data = []; selected = {}; },
onData: function(arr) { onData: function(arr) {
data = arr || []; data = arr || [];
var tbody = document.getElementById('in-tbody'); var tbody = document.getElementById('in-tbody');
if (!tbody) return; if (!tbody) return;
if (data.length === 0) { if (data.length === 0) { tbody.innerHTML = '<tr class="empty-row"><td colspan="6">暂无链接注册数据,请先添加信号</td></tr>'; updateDelBtn(); return; }
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">暂无链接注册数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml(); tbody.innerHTML = renderTableHtml();
bindCb();
updateDelBtn();
} }
}; };
})(); })();
@ -428,27 +548,46 @@ var InPage = (function() {
* ======================================================================== */ * ======================================================================== */
var YkPage = (function() { var YkPage = (function() {
var data = []; var data = [];
var selected = {};
function getCheckedItems() {
var items = [];
for (var k in selected) { if (selected.hasOwnProperty(k) && data[parseInt(k)]) items.push(data[parseInt(k)]); }
return items;
}
function updateDelBtn() {
var btn = document.getElementById('yk-del-btn');
if (!btn) return;
var cnt = 0;
for (var k in selected) { if (selected.hasOwnProperty(k)) cnt++; }
btn.textContent = '🗑 删除选中' + (cnt > 0 ? '(' + cnt + ')' : '');
btn.disabled = (cnt === 0);
}
function renderTableHtml() { function renderTableHtml() {
if (data.length === 0) return ''; if (data.length === 0) return '';
var rows = ''; var rows = '';
for (var i = 0; i < data.length; i++) { for (var i = 0; i < data.length; i++) {
var item = data[i]; var item = data[i];
var checked = !!selected[i];
var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type; var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type;
rows += '<tr>' rows += '<tr>'
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
+ '<td>' + (i + 1) + '</td>' + '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>' + '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>' + '<td>' + fmtVal(item.desc) + '</td>'
+ '<td>' + fmtVal(item.type) + '</td>' + '<td>' + fmtVal(item.type) + '</td>'
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>' + '<td class="val-cell">' + fmtVal(item.val) + '</td>'
+ '<td>' + ctrlLabel + '</td>' + '<td>' + ctrlLabel + '</td>'
+ '<td><button class="btn btn-sm btn-success" data-idx="' + i + '" data-val="1">控合</button></td>' + '<td>' + fmtVal(item.min) + '</td>'
+ '<td><button class="btn btn-sm btn-danger" data-idx="' + i + '" data-val="0">控分</button></td>' + '<td>' + fmtVal(item.max) + '</td>'
+ '<td>' + fmtVal(item.step) + '</td>'
+ '<td>' + fmtVal(item.unit) + '</td>'
+ '<td><input class="val-input" id="yk-mod-' + i + '" placeholder="修改值"></td>'
+ '<td><button class="btn btn-sm btn-info" data-idx="' + i + '">修改</button></td>'
+ '</tr>'; + '</tr>';
} }
return rows; return rows;
} }
function bindButtons() { function bindButtons() {
var buttons = document.querySelectorAll('#yk-tbody .btn-sm'); var buttons = document.querySelectorAll('#yk-tbody .btn-sm');
for (var k = 0; k < buttons.length; k++) { for (var k = 0; k < buttons.length; k++) {
@ -456,36 +595,50 @@ var YkPage = (function() {
var idx = parseInt(this.getAttribute('data-idx')); var idx = parseInt(this.getAttribute('data-idx'));
var val = this.getAttribute('data-val'); var val = this.getAttribute('data-val');
var item = data[idx]; var item = data[idx];
WsClient.send({ WsClient.send({ curd: 'set', signal_type: 'yk', saddr: item.saddr, signal_data: val, setting_zone: '0' });
curd: 'set', signal_type: 'yk', saddr: item.saddr,
signal_data: val, setting_zone: '0'
});
}; };
} }
var cbs = document.querySelectorAll('#yk-tbody .row-cb');
for (var j = 0; j < cbs.length; j++) {
cbs[j].onclick = function(e) { e.stopPropagation(); var idx = this.getAttribute('data-idx'); if (this.checked) selected[idx] = true; else delete selected[idx]; updateDelBtn(); };
}
}
function doDeleteChecked() {
var items = getCheckedItems();
if (items.length === 0) { alert('请先勾选要删除的信号'); return; }
if (!confirm('确定删除 ' + items.length + ' 个信号?')) return;
for (var i = 0; i < items.length; i++) {
WsClient.send({ curd:'del', signal_type:'yk', saddr:items[i].saddr, setting_zone:'0', signal_data:'' });
}
selected = {};
updateDelBtn();
} }
return { return {
render: function(container) { render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val', 'ctrl_type', '控合', '控分']; selected = {};
var headers = ['', '#', 'saddr', 'desc', '数据类型', 'val', 'ctrl_type', 'min', 'max', 'step', 'unit', '修改值', '修改'];
container.innerHTML = '' container.innerHTML = ''
+ '<div class="page-header"><h2>遥控管理</h2><p>yk — 遥控分合闸控制,控合=1(闭合),控分=0(断开)</p></div>' + '<div class="page-header"><h2>遥控管理</h2><p>yk — 遥控分合闸控制,控合=1(闭合),控分=0(断开)</p></div>'
+ makeToolbar('yk') + makeToolbar('yk')
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="yk-del-btn" disabled>🗑 删除选中</button></div>'
+ renderTable('yk-tbody', headers, renderTableHtml(), '暂无遥控数据,请先添加信号'); + renderTable('yk-tbody', headers, renderTableHtml(), '暂无遥控数据,请先添加信号');
bindToolbar('yk', function() { return data; }); bindToolbar('yk', function() { return data; });
bindButtons(); bindButtons();
var delBtn = document.getElementById('yk-del-btn');
if (delBtn) delBtn.onclick = doDeleteChecked;
updateDelBtn();
}, },
onEnter: function() {}, onEnter: function() {},
onLeave: function() { data = []; }, onLeave: function() { data = []; selected = {}; },
onData: function(arr) { onData: function(arr) {
data = arr || []; data = arr || [];
// 不重置 selected保留用户勾选
var tbody = document.getElementById('yk-tbody'); var tbody = document.getElementById('yk-tbody');
if (!tbody) return; if (!tbody) return;
if (data.length === 0) { if (data.length === 0) { tbody.innerHTML = '<tr class="empty-row"><td colspan="9">暂无遥控数据,请先添加信号</td></tr>'; updateDelBtn(); return; }
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">暂无遥控数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml(); tbody.innerHTML = renderTableHtml();
bindButtons(); bindButtons();
updateDelBtn();
} }
}; };
})(); })();
@ -495,14 +648,18 @@ var YkPage = (function() {
* ======================================================================== */ * ======================================================================== */
var AoPage = (function() { var AoPage = (function() {
var data = []; var data = [];
var selected = {};
function getCheckedItems() { var items=[]; for(var k in selected){if(selected.hasOwnProperty(k)&&data[parseInt(k)])items.push(data[parseInt(k)]);} return items; }
function updateDelBtn() { var btn=document.getElementById('ao-del-btn'); if(!btn)return; var cnt=0; for(var k in selected){if(selected.hasOwnProperty(k))cnt++;} btn.textContent='🗑 删除选中'+(cnt>0?'('+cnt+')':''); btn.disabled=(cnt===0); }
function renderTableHtml() { function renderTableHtml() {
if (data.length === 0) return ''; if (data.length === 0) return '';
var rows = ''; var rows = '';
for (var i = 0; i < data.length; i++) { for (var i = 0; i < data.length; i++) {
var item = data[i]; var item = data[i]; var checked = !!selected[i];
var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type; var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type;
rows += '<tr>' rows += '<tr>'
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
+ '<td>' + (i + 1) + '</td>' + '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>' + '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>' + '<td>' + fmtVal(item.desc) + '</td>'
@ -519,46 +676,39 @@ var AoPage = (function() {
} }
return rows; return rows;
} }
function bindAll() {
function bindModifyButtons() {
var buttons = document.querySelectorAll('#ao-tbody .btn-info'); var buttons = document.querySelectorAll('#ao-tbody .btn-info');
for (var k = 0; k < buttons.length; k++) { for (var k = 0; k < buttons.length; k++) { buttons[k].onclick=function(){var idx=parseInt(this.getAttribute('data-idx'));var input=document.getElementById('ao-mod-'+idx);var val=input.value.trim();if(val===''){alert('请输入修改值');return;}var item=data[idx]; WsClient.send({curd:'set',signal_type:'ao',saddr:item.saddr,signal_data:val,setting_zone:'0'});};}
buttons[k].onclick = function() { var cbs = document.querySelectorAll('#ao-tbody .row-cb');
var idx = parseInt(this.getAttribute('data-idx')); for (var j = 0; j < cbs.length; j++) { cbs[j].onclick=function(e){e.stopPropagation();var idx=this.getAttribute('data-idx');if(this.checked)selected[idx]=true;else delete selected[idx];updateDelBtn();};}
var input = document.getElementById('ao-mod-' + idx);
var val = input.value.trim();
if (val === '') { alert('请输入修改值'); return; }
var item = data[idx];
WsClient.send({
curd: 'set', signal_type: 'ao', saddr: item.saddr,
signal_data: val, setting_zone: '0'
});
};
}
} }
function doDeleteChecked() { var items=getCheckedItems(); if(items.length===0){alert('请先勾选要删除的信号');return;} if(!confirm('确定删除 '+items.length+' 个信号?'))return; for(var i=0;i<items.length;i++){WsClient.send({curd:'del',signal_type:'ao',saddr:items[i].saddr,setting_zone:'0',signal_data:''});} selected={}; updateDelBtn(); }
return { return {
render: function(container) { render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val', 'ctrl_type', 'min', 'max', 'step', 'unit', '修改值', '修改']; selected = {};
var headers = ['', '#', 'saddr', 'desc', '数据类型', 'val', 'ctrl_type', 'min', 'max', 'step', 'unit', '修改值', '修改'];
container.innerHTML = '' container.innerHTML = ''
+ '<div class="page-header"><h2>参数管理</h2><p>ao — 模拟输出参数设定,支持单值修改</p></div>' + '<div class="page-header"><h2>参数管理</h2><p>ao — 模拟输出参数设定,支持单值修改</p></div>'
+ makeToolbar('ao') + makeToolbar('ao')
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="ao-del-btn" disabled>🗑 删除选中</button></div>'
+ renderTable('ao-tbody', headers, renderTableHtml(), '暂无参数数据,请先添加信号'); + renderTable('ao-tbody', headers, renderTableHtml(), '暂无参数数据,请先添加信号');
bindToolbar('ao', function() { return data; }); bindToolbar('ao', function() { return data; });
bindModifyButtons(); bindAll();
var delBtn = document.getElementById('ao-del-btn');
if (delBtn) delBtn.onclick = doDeleteChecked;
updateDelBtn();
}, },
onEnter: function() {}, onEnter: function() {},
onLeave: function() { data = []; }, onLeave: function() { data = []; selected = {}; },
onData: function(arr) { onData: function(arr) {
data = arr || []; data = arr || [];
// 不重置 selected保留用户勾选
var tbody = document.getElementById('ao-tbody'); var tbody = document.getElementById('ao-tbody');
if (!tbody) return; if (!tbody) return;
if (data.length === 0) { if (data.length === 0) { tbody.innerHTML = '<tr class="empty-row"><td colspan="13">暂无参数数据,请先添加信号</td></tr>'; updateDelBtn(); return; }
tbody.innerHTML = '<tr class="empty-row"><td colspan="12">暂无参数数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml(); tbody.innerHTML = renderTableHtml();
bindModifyButtons(); bindAll();
updateDelBtn();
} }
}; };
})(); })();
@ -568,7 +718,11 @@ var AoPage = (function() {
* ======================================================================== */ * ======================================================================== */
var ParamPage = (function() { var ParamPage = (function() {
var data = []; var data = [];
var expandedRows = {}; // 记录展开状态 var expandedRows = {};
var selected = {};
function getCheckedItems() { var items=[]; for(var k in selected){if(selected.hasOwnProperty(k)&&data[parseInt(k)])items.push(data[parseInt(k)]);} return items; }
function updateDelBtn() { var btn=document.getElementById('param-del-btn'); if(!btn)return; var cnt=0; for(var k in selected){if(selected.hasOwnProperty(k))cnt++;} btn.textContent='🗑 删除选中'+(cnt>0?'('+cnt+')':''); btn.disabled=(cnt===0); }
function renderTableHtml() { function renderTableHtml() {
if (data.length === 0) return ''; if (data.length === 0) return '';
@ -581,8 +735,10 @@ var ParamPage = (function() {
var isOpen = expandedRows[i] === true; var isOpen = expandedRows[i] === true;
var icon = isOpen ? '▼' : '▶'; var icon = isOpen ? '▼' : '▶';
var checked = !!selected[i];
// 主行(可点击展开) // 主行(可点击展开)
rows += '<tr class="expand-row" data-pidx="' + i + '">' rows += '<tr class="expand-row" data-pidx="' + i + '">'
+ '<td><input type="checkbox" class="row-cb" data-idx="' + i + '"' + (checked ? ' checked' : '') + '></td>'
+ '<td>' + (i + 1) + '</td>' + '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>' + '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>' + '<td>' + fmtVal(item.desc) + '</td>'
@ -594,7 +750,7 @@ var ParamPage = (function() {
// 展开的定值区子表 // 展开的定值区子表
if (isOpen) { if (isOpen) {
rows += '<tr class="zone-sub-row" id="zone-sub-' + i + '"><td colspan="7">' rows += '<tr class="zone-sub-row" id="zone-sub-' + i + '"><td colspan="8">'
+ '<table class="zone-sub-table">' + '<table class="zone-sub-table">'
+ '<thead><tr>' + '<thead><tr>'
+ '<th>定值区 ID</th><th>val</th><th>default_val</th>' + '<th>定值区 ID</th><th>val</th><th>default_val</th>'
@ -624,67 +780,59 @@ var ParamPage = (function() {
} }
function bindEvents() { function bindEvents() {
// 展开/收起
var expandRows = document.querySelectorAll('#param-tbody .expand-row'); var expandRows = document.querySelectorAll('#param-tbody .expand-row');
for (var k = 0; k < expandRows.length; k++) { for (var k = 0; k < expandRows.length; k++) {
expandRows[k].onclick = function() { expandRows[k].onclick = function(e) {
if (e.target.tagName === 'INPUT') return; // 不拦截勾选框
var idx = parseInt(this.getAttribute('data-pidx')); var idx = parseInt(this.getAttribute('data-pidx'));
expandedRows[idx] = !expandedRows[idx]; expandedRows[idx] = !expandedRows[idx];
refreshTable(); refreshTable();
}; };
} }
// 修改按钮
var modButtons = document.querySelectorAll('#param-tbody .btn-info'); var modButtons = document.querySelectorAll('#param-tbody .btn-info');
for (var m = 0; m < modButtons.length; m++) { for (var m = 0; m < modButtons.length; m++) {
modButtons[m].onclick = function(e) { modButtons[m].onclick = function(e) { e.stopPropagation(); var pidx=parseInt(this.getAttribute('data-pidx')); var zidx=parseInt(this.getAttribute('data-zidx')); var input=document.getElementById('pm-'+pidx+'-'+zidx); var val=input.value.trim(); if(val===''){alert('请输入修改值');return;} var item=data[pidx]; var zoneId=item.setting_zone_list[zidx].id; WsClient.send({curd:'set',signal_type:'param',saddr:item.saddr,signal_data:val,setting_zone:zoneId});};
e.stopPropagation(); }
var pidx = parseInt(this.getAttribute('data-pidx')); var cbs = document.querySelectorAll('#param-tbody .row-cb');
var zidx = parseInt(this.getAttribute('data-zidx')); for (var j = 0; j < cbs.length; j++) {
var input = document.getElementById('pm-' + pidx + '-' + zidx); cbs[j].onclick = function(e) { e.stopPropagation(); var idx = this.getAttribute('data-idx'); if(this.checked)selected[idx]=true; else delete selected[idx]; updateDelBtn(); };
var val = input.value.trim();
if (val === '') { alert('请输入修改值'); return; }
var item = data[pidx];
var zoneId = item.setting_zone_list[zidx].id;
WsClient.send({
curd: 'set', signal_type: 'param', saddr: item.saddr,
signal_data: val, setting_zone: zoneId
});
};
} }
} }
function doDeleteChecked() { var items=getCheckedItems(); if(items.length===0){alert('请先勾选要删除的信号');return;} if(!confirm('确定删除 '+items.length+' 个信号?'))return; for(var i=0;i<items.length;i++){WsClient.send({curd:'del',signal_type:'param',saddr:items[i].saddr,setting_zone:'0',signal_data:''});} selected={}; expandedRows={}; updateDelBtn(); refreshTable(); }
function refreshTable() { function refreshTable() {
var tbody = document.getElementById('param-tbody'); var tbody = document.getElementById('param-tbody');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = renderTableHtml(); tbody.innerHTML = renderTableHtml();
bindEvents(); bindEvents();
} }
return { return {
render: function(container) { render: function(container) {
expandedRows = {}; expandedRows = {};
var headers = ['#', 'saddr', 'desc', '数据类型', 'ctrl_type', '定值区数', '展开']; selected = {};
var headers = ['', '#', 'saddr', 'desc', '数据类型', 'ctrl_type', '定值区数', '展开'];
container.innerHTML = '' container.innerHTML = ''
+ '<div class="page-header"><h2>定值管理</h2><p>param — 多定值区参数管理,点击行展开查看各定值区详情并可修改</p></div>' + '<div class="page-header"><h2>定值管理</h2><p>param — 多定值区参数管理,点击行展开查看各定值区详情并可修改</p></div>'
+ makeToolbar('param') + makeToolbar('param')
+ '<div class="batch-ops" style="margin-bottom:12px"><button class="btn btn-danger" id="param-del-btn" disabled>🗑 删除选中</button></div>'
+ renderTable('param-tbody', headers, renderTableHtml(), '暂无定值数据,请先添加信号'); + renderTable('param-tbody', headers, renderTableHtml(), '暂无定值数据,请先添加信号');
bindToolbar('param', function() { return data; }); bindToolbar('param', function() { return data; });
bindEvents(); bindEvents();
var delBtn = document.getElementById('param-del-btn');
if (delBtn) delBtn.onclick = doDeleteChecked;
updateDelBtn();
}, },
onEnter: function() {}, onEnter: function() {},
onLeave: function() { data = []; expandedRows = {}; }, onLeave: function() { data = []; expandedRows = {}; selected = {}; },
onData: function(arr) { onData: function(arr) {
data = arr || []; data = arr || [];
expandedRows = {}; // 新数据到达时重置展开状态 expandedRows = {};
// 不重置 selected保留用户勾选
var tbody = document.getElementById('param-tbody'); var tbody = document.getElementById('param-tbody');
if (!tbody) return; if (!tbody) return;
if (data.length === 0) { if (data.length === 0) { tbody.innerHTML = '<tr class="empty-row"><td colspan="8">暂无定值数据,请先添加信号</td></tr>'; updateDelBtn(); return; }
tbody.innerHTML = '<tr class="empty-row"><td colspan="7">暂无定值数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml(); tbody.innerHTML = renderTableHtml();
bindEvents(); bindEvents();
updateDelBtn();
} }
}; };
})(); })();
@ -1065,6 +1213,12 @@ var DatacenterPage = (function() {
var typeLabels = { out:'注册', in:'链接注册', yk:'遥控', ao:'参数', param:'定值' }; var typeLabels = { out:'注册', in:'链接注册', yk:'遥控', ao:'参数', param:'定值' };
var label = typeLabels[activeTab] || activeTab; var label = typeLabels[activeTab] || activeTab;
var cur = getRegisteredCount(activeTab);
if (cur + items.length > MAX_SIGNALS_PER_TYPE) {
alert(label + ' 已有 ' + cur + ' 个,本次选择 ' + items.length + ' 个,合计 ' + (cur + items.length) + ' 个,超过上限 ' + MAX_SIGNALS_PER_TYPE + ' 个。\n请先删除旧信号或减少选择');
return;
}
if (!confirm('确定将 ' + items.length + ' 个信号添加到 ' + label + '')) return; if (!confirm('确定将 ' + items.length + ' 个信号添加到 ' + label + '')) return;
var succ = 0; var succ = 0;

View File

@ -0,0 +1,275 @@
/* ========================================================================
* PLC 调试页面 指示灯/按键 + out 绑定 + 勾选删除 + 增量刷新
* ======================================================================== */
var PlcDebugLeds = []; // [{saddr:'', desc:'', val:0}]
var PlcDebugButtons = []; // [{saddr:'', desc:'', val:0}]
var PlcDebugPage = (function() {
var outSignals = [];
var ledChecked = {}; // {idx: true}
var btnChecked = {}; // {idx: true}
function getOutSignals() {
if (typeof App !== 'undefined' && App.getSignalData) {
var out = App.getSignalData('out');
if (Array.isArray(out) && out.length > 0) return out;
}
return outSignals;
}
function showSignalPicker(callback) {
var sigs = getOutSignals();
if (sigs.length === 0) { alert('暂无 out 已注册信号,请先在注册(out)页面添加信号'); return; }
var html = '<div class="modal-overlay" id="plc-modal"><div class="modal-box" style="max-width:550px">'
+ '<h3>绑定 out 信号</h3>'
+ '<div style="max-height:300px;overflow-y:auto;margin:12px 0">';
for (var i = 0; i < sigs.length; i++) {
var s = sigs[i];
html += '<div class="plc-sig-row" data-saddr="' + fmtVal(s.saddr) + '" data-desc="' + fmtVal(s.desc || '') + '" style="padding:8px;cursor:pointer;border-bottom:1px solid #eee">'
+ '<strong>' + fmtVal(s.saddr) + '</strong>'
+ (s.desc ? ' <span style="color:#999">' + fmtVal(s.desc) + '</span>' : '')
+ '<span style="float:right;color:#2196F3;font-weight:700">' + (s.val !== undefined ? fmtVal(s.val) : '-') + '</span>'
+ '</div>';
}
html += '</div><button class="btn btn-outline" id="plc-modal-cancel">取消</button></div></div>';
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
document.getElementById('plc-modal-cancel').onclick = closeModal;
var rows = document.querySelectorAll('.plc-sig-row');
for (var j = 0; j < rows.length; j++) {
rows[j].onclick = function() {
var saddr = this.getAttribute('data-saddr');
var desc = this.getAttribute('data-desc') || '';
closeModal();
if (confirm('确定绑定信号 ' + saddr + ' 吗?')) { callback(saddr, desc); }
};
}
}
function closeModal() { var m = document.getElementById('plc-modal'); if (m) m.remove(); }
function refreshOutData() {
var sigs = getOutSignals();
if (Array.isArray(sigs)) outSignals = sigs;
for (var i = 0; i < PlcDebugLeds.length; i++) {
var found = findSignal(PlcDebugLeds[i].saddr);
PlcDebugLeds[i].val = found ? found.val : 0;
}
for (var j = 0; j < PlcDebugButtons.length; j++) {
var found2 = findSignal(PlcDebugButtons[j].saddr);
PlcDebugButtons[j].val = found2 ? found2.val : 0;
}
}
function findSignal(saddr) {
for (var i = 0; i < outSignals.length; i++) {
if (outSignals[i].saddr === saddr) return outSignals[i];
}
return null;
}
function countChecked(obj) { var n = 0; for (var k in obj) { if (obj.hasOwnProperty(k)) n++; } return n; }
function updateDelBtn() {
var ledCnt = countChecked(ledChecked);
var btnCnt = countChecked(btnChecked);
var total = ledCnt + btnCnt;
var el = document.getElementById('plc-del-btn');
if (el) {
el.textContent = '🗑 删除选中' + (total > 0 ? '(' + total + ')' : '');
el.disabled = (total === 0);
}
}
/* ---- 增量刷新: 只更新 val 文本, 不重建 DOM (add/del 时才全重建) ---- */
function incrRefresh() {
refreshOutData();
// 指示灯: 逐个更新 val 文本和样式
for (var i = 0; i < PlcDebugLeds.length; i++) {
var valEl = document.getElementById('led-val-' + i);
var iconEl = document.getElementById('led-icon-' + i);
var on = (parseFloat(PlcDebugLeds[i].val) > 0);
if (valEl) valEl.textContent = '(' + PlcDebugLeds[i].val + ')';
if (valEl) valEl.style.color = on ? '#4CAF50' : '#999';
if (iconEl) { iconEl.className = 'plc-led' + (on ? ' plc-led-on' : ''); }
}
// 按键: 逐个更新 val 文本
for (var j = 0; j < PlcDebugButtons.length; j++) {
var bValEl = document.getElementById('btn-val-' + j);
if (bValEl) bValEl.textContent = '(' + PlcDebugButtons[j].val + ')';
}
}
function renderAll() {
refreshOutData();
var html = '';
html += '<div class="plc-toolbar">'
+ '<button class="btn btn-warn" id="plc-manual-set">⚙ 手动置数</button>'
+ '<button class="btn btn-outline" id="plc-refresh">🔄 刷新</button>'
+ '<span style="flex:1"></span>'
+ '<button class="btn btn-danger" id="plc-del-btn" disabled>🗑 删除选中</button>'
+ '<button class="btn btn-outline" id="plc-exit-set">🚪 退出置数</button>'
+ '</div>';
html += '<div class="plc-actions">'
+ '<button class="btn btn-primary" id="plc-add-led">💡 增加指示灯</button>'
+ '<span style="flex:1"></span>'
+ '<button class="btn btn-info" id="plc-add-btn">🔘 增加按键</button>'
+ '</div>';
html += '<div class="plc-grid">';
// 指示灯
html += '<div class="plc-col"><h3>💡 指示灯</h3>';
for (var i = 0; i < PlcDebugLeds.length; i++) {
var led = PlcDebugLeds[i];
var on = (parseFloat(led.val) > 0);
var chk = ledChecked[i] ? ' checked' : '';
html += '<div class="plc-item">'
+ '<input type="checkbox" class="plc-cb" data-led-idx="' + i + '"' + chk + '>'
+ '<span class="plc-led ' + (on ? 'plc-led-on' : '') + '" id="led-icon-' + i + '" data-led-idx="' + i + '">💡</span>'
+ '<span class="plc-info">'
+ (led.saddr ? '<span class="plc-saddr">' + fmtVal(led.saddr) + ' <span id="led-val-' + i + '" style="color:' + (on ? '#4CAF50' : '#999') + '">(' + fmtVal(led.val) + ')</span>'
+ (led.desc ? ' <span style="color:#888;font-size:12px">' + fmtVal(led.desc) + '</span>' : '')
: '<span style="color:#999">未绑定</span>')
+ '</span></div>';
}
html += '</div>';
// 按键
html += '<div class="plc-col"><h3>🔘 按键</h3>';
for (var j = 0; j < PlcDebugButtons.length; j++) {
var btn = PlcDebugButtons[j];
var chk2 = btnChecked[j] ? ' checked' : '';
html += '<div class="plc-item">'
+ '<input type="checkbox" class="plc-cb" data-btn-idx="' + j + '"' + chk2 + '>'
+ '<span class="plc-btn-icon" id="btn-icon-' + j + '" data-btn-idx="' + j + '">🔘</span>'
+ '<span class="plc-info">'
+ (btn.saddr ? '<span class="plc-saddr">' + fmtVal(btn.saddr) + ' <span id="btn-val-' + j + '" style="color:#2196F3">(' + fmtVal(btn.val) + ')</span>'
+ (btn.desc ? ' <span style="color:#888;font-size:12px">' + fmtVal(btn.desc) + '</span>' : '')
: '<span style="color:#999">未绑定</span>')
+ '</span></div>';
}
html += '</div></div>';
document.getElementById('plc-content').innerHTML = html;
// ---- 事件绑定 ----
document.getElementById('plc-add-led').onclick = function() {
PlcDebugLeds.push({ saddr: '', desc: '', val: 0 });
renderAll();
};
document.getElementById('plc-add-btn').onclick = function() {
PlcDebugButtons.push({ saddr: '', desc: '', val: 0 });
renderAll();
};
// 删除选中
document.getElementById('plc-del-btn').onclick = function() {
var delLeds = [], delBtns = [];
for (var k in ledChecked) { if (ledChecked.hasOwnProperty(k)) delLeds.push(parseInt(k)); }
for (var k in btnChecked) { if (btnChecked.hasOwnProperty(k)) delBtns.push(parseInt(k)); }
var total = delLeds.length + delBtns.length;
if (total === 0) return;
if (!confirm('确定删除 ' + total + ' 个控件?')) return;
delLeds.sort(function(a,b){return b-a;});
delBtns.sort(function(a,b){return b-a;});
for (var a = 0; a < delLeds.length; a++) PlcDebugLeds.splice(delLeds[a], 1);
for (var b = 0; b < delBtns.length; b++) PlcDebugButtons.splice(delBtns[b], 1);
ledChecked = {}; btnChecked = {};
renderAll();
};
// LED 点击
var ledIcons = document.querySelectorAll('.plc-led');
for (var k = 0; k < ledIcons.length; k++) {
ledIcons[k].onclick = function() {
var idx = parseInt(this.getAttribute('data-led-idx'));
if (PlcDebugLeds[idx].saddr) {
if (!confirm('当前绑定: ' + PlcDebugLeds[idx].saddr + ', 是否修改绑定?')) return;
}
showSignalPicker(function(saddr, desc) {
PlcDebugLeds[idx].saddr = saddr;
PlcDebugLeds[idx].desc = desc || '';
renderAll();
});
};
}
// 按钮点击
var btnIcons = document.querySelectorAll('.plc-btn-icon');
for (var m = 0; m < btnIcons.length; m++) {
btnIcons[m].onclick = function() {
var idx = parseInt(this.getAttribute('data-btn-idx'));
if (!PlcDebugButtons[idx].saddr) {
showSignalPicker(function(saddr, desc) {
PlcDebugButtons[idx].saddr = saddr;
PlcDebugButtons[idx].desc = desc || '';
PlcDebugButtons[idx].val = 0;
renderAll();
});
return;
}
var newVal = (PlcDebugButtons[idx].val == 1) ? 0 : 1;
PlcDebugButtons[idx].val = newVal;
WsClient.send({ curd:'set', signal_type:'out', saddr:PlcDebugButtons[idx].saddr, signal_data:String(newVal), setting_zone:'0' });
showToast('✅ 已发送翻转: ' + PlcDebugButtons[idx].saddr + ' → ' + newVal);
incrRefresh();
};
}
// 勾选框
var allCbs = document.querySelectorAll('.plc-cb');
for (var n = 0; n < allCbs.length; n++) {
allCbs[n].onclick = function(e) {
e.stopPropagation();
var ledIdx = this.getAttribute('data-led-idx');
var btnIdx = this.getAttribute('data-btn-idx');
if (ledIdx !== null) {
if (this.checked) ledChecked[ledIdx] = true; else delete ledChecked[ledIdx];
} else if (btnIdx !== null) {
if (this.checked) btnChecked[btnIdx] = true; else delete btnChecked[btnIdx];
}
updateDelBtn();
};
}
document.getElementById('plc-refresh').onclick = function() {
refreshOutData();
renderAll();
showToast('🔄 已刷新');
};
var manualBtn = document.getElementById('plc-manual-set');
var exitBtn = document.getElementById('plc-exit-set');
if (manualBtn) manualBtn.onclick = function() { alert('手动置数功能待实现'); };
if (exitBtn) exitBtn.onclick = function() { alert('退出置数功能待实现'); };
}
function showToast(msg) {
var t = document.getElementById('plc-toast');
if (!t) {
t = document.createElement('div');
t.id = 'plc-toast';
t.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 24px;border-radius:6px;z-index:9999;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,0.3);transition:opacity 0.3s;pointer-events:none';
document.body.appendChild(t);
}
t.textContent = msg; t.style.opacity = '1';
clearTimeout(t._timer);
t._timer = setTimeout(function() { t.style.opacity = '0'; }, 1200);
}
return {
render: function(container) {
ledChecked = {}; btnChecked = {};
container.innerHTML = ''
+ '<div class="page-header"><h2>PLC 调试</h2><p>指示灯绑定 out 已注册信号(>0 亮/≤0 灭),按键绑定 out 已注册信号(点击翻转 0↔1)</p></div>'
+ '<div id="plc-content"></div>';
renderAll();
},
onEnter: function() { refreshOutData(); renderAll(); },
onLeave: function() {},
onData: function(data) {
if (Array.isArray(data)) { outSignals = data; }
incrRefresh();
}
};
})();