cj-tauri 0.8.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/cjpm.toml +52 -25
- package/cli/cjpm.toml +1 -1
- package/cli/src/doctor.cj +304 -0
- package/cli/src/main.cj +33 -7
- package/cli/src/project.cj +59 -1
- package/cli/src/resolve.cj +348 -304
- package/cli/src/scaffold.cj +139 -22
- package/cli/templates/ohos-pc/AppScope/app.json5 +13 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/element/string.json +8 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/background.png +0 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/foreground.png +0 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/layered_image.json +6 -0
- package/cli/templates/ohos-pc/README.md +79 -0
- package/cli/templates/ohos-pc/build-hap.sh +190 -0
- package/cli/templates/ohos-pc/build-profile.json5 +46 -0
- package/cli/templates/ohos-pc/entry/build-profile.json5 +26 -0
- package/cli/templates/ohos-pc/entry/cjpm.toml +43 -0
- package/cli/templates/ohos-pc/entry/hvigorfile.ts +6 -0
- package/cli/templates/ohos-pc/entry/oh-package.json5 +9 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/ability_stage.cj +17 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/app_host.cj +94 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +167 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/main_ability.cj +139 -0
- package/cli/templates/ohos-pc/entry/src/main/module.json5 +49 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/element/color.json +8 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/element/string.json +16 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/background.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/foreground.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/layered_image.json +6 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/profile/main_pages.json +2 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/capabilities.json +6 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/index.html +92 -0
- package/cli/templates/ohos-pc/hvigor/hvigor-config.json5 +5 -0
- package/cli/templates/ohos-pc/hvigorfile.ts +6 -0
- package/cli/templates/ohos-pc/oh-package.json5 +10 -0
- package/native/build_linux.sh +30 -2
- package/native/build_win.bat +62 -61
- package/native/core/bridge_core.c +1626 -0
- package/native/{bridge_core.h → core/bridge_core.h} +246 -6
- package/native/platforms/harmonyos/README.md +106 -0
- package/native/platforms/linux/bridge_linux.c +2485 -0
- package/native/platforms/windows/bridge_win.c +3248 -0
- package/native/tests/test_bridge_core.c +528 -1
- package/package.json +12 -3
- package/prebuilt/win32-x64/cj-tauri.exe +0 -0
- package/src/api_system.cj +10 -2
- package/src/app.cj +248 -85
- package/src/arkweb_proxy.cj +56 -0
- package/src/arkweb_script.cj +154 -0
- package/src/capability.cj +140 -0
- package/src/clipboard_bridge.cj +55 -0
- package/src/dragdrop.cj +26 -0
- package/src/embedded_assets.cj +98 -0
- package/src/hexutil.cj +53 -0
- package/src/host.cj +197 -26
- package/src/host_arkweb.cj +740 -0
- package/src/host_factory.cj +48 -0
- package/src/host_webkit.cj +202 -26
- package/src/host_webview2.cj +176 -3
- package/src/hotkey.cj +66 -0
- package/src/ipc_hub.cj +49 -0
- package/src/ipc_message.cj +2 -1
- package/src/jsutil.cj +33 -0
- package/src/log_bridge.cj +107 -0
- package/src/logging.cj +127 -0
- package/src/menu.cj +197 -196
- package/src/plugin.cj +40 -0
- package/src/plugins/clipboard/plugin_clipboard.cj +144 -0
- package/src/{plugin_dialog.cj → plugins/dialog/plugin_dialog.cj} +74 -17
- package/src/plugins/fs/plugin_fs.cj +194 -0
- package/src/plugins/http/plugin_http.cj +459 -0
- package/src/plugins/log/plugin_log.cj +105 -0
- package/src/{plugin_menu.cj → plugins/menu/plugin_menu.cj} +4 -2
- package/src/plugins/mqtt/mqtt_codec.cj +661 -0
- package/src/plugins/mqtt/plugin_mqtt.cj +1446 -0
- package/src/plugins/notification/plugin_notification.cj +146 -0
- package/src/plugins/plugins.cj +5 -0
- package/src/plugins/redis/plugin_redis.cj +2409 -0
- package/src/plugins/redis/redis_codec.cj +556 -0
- package/src/{plugin_serial.cj → plugins/serial/plugin_serial.cj} +442 -489
- package/src/{serial_bridge.cj → plugins/serial/serial_bridge.cj} +185 -184
- package/src/{plugin_shell.cj → plugins/shell/plugin_shell.cj} +230 -228
- package/src/plugins/sqlite/plugin_sqlite.cj +697 -0
- package/src/plugins/sqlite/sqlite_ffi.cj +206 -0
- package/src/plugins/store/plugin_store.cj +287 -0
- package/src/plugins/ws/plugin_ws.cj +470 -0
- package/src/process_hidden.cj +174 -0
- package/src/scope.cj +391 -0
- package/src/shell_event.cj +69 -68
- package/src/tests/capability_test.cj +53 -0
- package/src/tests/fake_host.cj +85 -20
- package/src/tests/ipc_hub_test.cj +13 -0
- package/src/tests/jsutil_test.cj +62 -0
- package/src/tests/logging_test.cj +62 -0
- package/src/tests/mqtt_codec_test.cj +405 -0
- package/src/tests/plugin_clipboard_test.cj +222 -0
- package/src/tests/plugin_dialog_test.cj +98 -4
- package/src/tests/plugin_http_test.cj +124 -0
- package/src/tests/plugin_menu_test.cj +1 -0
- package/src/tests/plugin_notification_test.cj +205 -0
- package/src/tests/plugin_serial_test.cj +269 -267
- package/src/tests/plugin_shell_test.cj +189 -188
- package/src/tests/plugin_sqlite_test.cj +230 -0
- package/src/tests/plugin_store_test.cj +260 -0
- package/src/tests/plugin_test.cj +542 -524
- package/src/tests/plugin_ws_test.cj +40 -0
- package/src/tests/process_hidden_test.cj +129 -0
- package/src/tests/redis_codec_test.cj +396 -0
- package/src/tests/scope_test.cj +350 -0
- package/src/tests/shell_event_test.cj +0 -1
- package/src/tests/system_host_test.cj +81 -79
- package/src/tests/urlutil_test.cj +53 -0
- package/src/tests/version_test.cj +75 -65
- package/src/tray.cj +90 -0
- package/src/urlutil.cj +159 -0
- package/src/version.cj +23 -2
- package/src/window.cj +51 -42
- package/native/bridge_core.c +0 -797
- package/native/bridge_linux.c +0 -1268
- package/native/bridge_win.c +0 -1633
- package/src/plugin_fs.cj +0 -118
- /package/native/{bridge_js.h → core/bridge_js.h} +0 -0
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
package cjTauri.plugins.mqtt
|
|
2
|
+
|
|
3
|
+
import cjTauri.*
|
|
4
|
+
|
|
5
|
+
import std.collection.*
|
|
6
|
+
import std.net.*
|
|
7
|
+
import std.sync.*
|
|
8
|
+
import std.time.*
|
|
9
|
+
import stdx.encoding.json.*
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 官方插件 mqtt:MQTT 3.1.1 客户端(调试工具用)
|
|
13
|
+
*
|
|
14
|
+
* 设计取舍见 `docs/设计决策记录.md` 的 **D-014**(以插件交付、协议用 `std.net` 自实现,不进 C 桥)
|
|
15
|
+
* 与 **D-015**(收报文同时给事件通道与可拉取缓冲)。
|
|
16
|
+
*
|
|
17
|
+
* 命令:`mqtt:connect` / `subscribe` / `unsubscribe` / `publish` / `disconnect` / `status` / `recent`
|
|
18
|
+
* 事件:`mqtt:message`(每条收/发报文)+ `mqtt:state`(连接状态变化)
|
|
19
|
+
* 权限:**不自动放行**,应用要在 capabilities 里显式声明(明文 `"commands"` 或命名集 `"permissions": ["mqtt:debug"]`)
|
|
20
|
+
* 前端:`window.__CJ_TAURI__.mqtt.connect({ host: "127.0.0.1", port: 1883 })`
|
|
21
|
+
*
|
|
22
|
+
* 协议编解码在 `src/plugins/mqtt_codec.cj`(纯函数 + 单测),本文件只管 socket、状态机与缓冲。
|
|
23
|
+
*
|
|
24
|
+
* **边界如实(D-014)**:只做 MQTT 3.1.1(协议级别 4)的明文 TCP。不做 TLS / MQTTS、不做 WebSocket
|
|
25
|
+
* 传输、不做 MQTT 5.0 的属性与原因码;**连不上就给明确错误,不静默重试**——调试工具最怕
|
|
26
|
+
* 「看着连上了其实没有」。
|
|
27
|
+
*
|
|
28
|
+
* 线程模型:socket 的**读侧由读线程独占**(`mqttReaderThread`),命令侧只写。两侧共用的状态
|
|
29
|
+
* 一律在 `MqttSession.lock` 下访问;两条线程都不会调 GTK / WebKit——事件经 `app.emit()` → 桥的
|
|
30
|
+
* jsSink 回投(两平台桥的 runJs 都是「加锁入队 + 投递到宿主 UI 线程」,任意线程调用都安全,
|
|
31
|
+
* 见 `src/ipc_hub.cj` 的线程模型注释)。读侧用 `readTimeout` 当**时基**:每 tick 醒来一次做保活与
|
|
32
|
+
* 下线检查,因此不必额外引入 select(D-014)。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** `mqtt:recent` 环形缓冲上限(条):超限丢最旧并计入 dropped(D-015) */
|
|
36
|
+
let MQTT_RECENT_LIMIT: Int64 = 500
|
|
37
|
+
|
|
38
|
+
/** 单条正文进缓冲 / 事件前的截断上限:256 KiB(D-015)。超了照实报 `truncated`,不假装全量 */
|
|
39
|
+
let MQTT_RECENT_BODY_LIMIT: Int64 = 262144
|
|
40
|
+
|
|
41
|
+
/** 读线程的读超时(毫秒):它就是保活与下线检查的时基(D-014) */
|
|
42
|
+
let MQTT_READ_TICK_MS: Int64 = 500
|
|
43
|
+
|
|
44
|
+
/** 建连超时(毫秒):死地址不能把命令 worker 挂住 */
|
|
45
|
+
let MQTT_CONNECT_TIMEOUT_MS: Int64 = 5000
|
|
46
|
+
|
|
47
|
+
/** 一条报文从首字节到收齐的最长时长(毫秒):半条报文不许进状态机(凑不齐就判连接坏) */
|
|
48
|
+
let MQTT_PACKET_DEADLINE_MS: Int64 = 10000
|
|
49
|
+
|
|
50
|
+
/** 默认保活(秒);0 = 关闭保活 */
|
|
51
|
+
let MQTT_DEFAULT_KEEPALIVE_SEC: Int64 = 60
|
|
52
|
+
|
|
53
|
+
/** 等回执(SUBACK / PUBACK 等)的轮询步长与上限(毫秒):命令 worker 上的**有界**等待 */
|
|
54
|
+
let MQTT_WAIT_STEP_MS: Int64 = 20
|
|
55
|
+
let MQTT_WAIT_TIMEOUT_MS: Int64 = 5000
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 插件的全部命令短名(`permissions()` 的 `debug` 集就是它,别再抄第二份)。
|
|
59
|
+
*
|
|
60
|
+
* 用 `let` 而不是 `const`:仓颉的 `const` 只收编译期常量表达式,`Array` 字面量编译不过(AGENTS §4)。
|
|
61
|
+
*/
|
|
62
|
+
let MQTT_PLUGIN_COMMANDS: Array<String> = [
|
|
63
|
+
"connect", "disconnect", "subscribe", "unsubscribe", "publish", "status", "recent"
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 一条 MQTT 连接的全部可变状态(**所有字段都在 lock 下访问**——读线程与命令 worker 是两个线程)。
|
|
68
|
+
*
|
|
69
|
+
* 为什么不做成「每个命令各自持有 socket」:MQTT 是**有状态**的长连接协议,报文标识符、订阅表、
|
|
70
|
+
* QoS 待确认表都是连接级的;拆开写等于让每条命令自己维护一半状态机。
|
|
71
|
+
*/
|
|
72
|
+
class MqttSession {
|
|
73
|
+
let lock: Mutex = Mutex()
|
|
74
|
+
|
|
75
|
+
/** 连接的 socket(None = 未连接);读侧由读线程独占 */
|
|
76
|
+
var sock: Option<TcpSocket> = None
|
|
77
|
+
|
|
78
|
+
/** idle / connecting / connected / closed / error:页面直接显示,所以用字符串而不是枚举 */
|
|
79
|
+
var state: String = "idle"
|
|
80
|
+
var host: String = ""
|
|
81
|
+
var port: Int64 = 1883
|
|
82
|
+
var clientId: String = ""
|
|
83
|
+
var keepaliveSec: Int64 = MQTT_DEFAULT_KEEPALIVE_SEC
|
|
84
|
+
var cleanSession: Bool = true
|
|
85
|
+
|
|
86
|
+
/** 读线程是否在跑;stopRequested 是「请读线程退出」的旗标 */
|
|
87
|
+
var readerAlive: Bool = false
|
|
88
|
+
var stopRequested: Bool = false
|
|
89
|
+
|
|
90
|
+
/** 序号与计数:seq 给页面去重(同一条消息事件一次、recent 一次,D-015),其余供 status 对账 */
|
|
91
|
+
var seq: Int64 = 0
|
|
92
|
+
var rxCount: Int64 = 0
|
|
93
|
+
var txCount: Int64 = 0
|
|
94
|
+
var dropped: Int64 = 0
|
|
95
|
+
var lastError: String = ""
|
|
96
|
+
|
|
97
|
+
/** 最近一次收发时刻:保活判定用(「半个周期内没有任何发出」才发 PINGREQ) */
|
|
98
|
+
var lastTxAt: DateTime = DateTime.now()
|
|
99
|
+
var lastRxAt: DateTime = DateTime.now()
|
|
100
|
+
|
|
101
|
+
/** 下一个报文标识符(1..65535;0 是非法值,回绕时跳过) */
|
|
102
|
+
var nextPacketId: Int64 = 1
|
|
103
|
+
|
|
104
|
+
/** 出站 QoS 1/2 的待确认表:报文标识符 → 阶段(puback / pubrec / pubcomp) */
|
|
105
|
+
var pending: HashMap<Int64, String> = HashMap<Int64, String>()
|
|
106
|
+
|
|
107
|
+
/** 已订阅的过滤器 → 授予的 QoS(SUBACK 到达时回填;-2 表示「还没回执」) */
|
|
108
|
+
var subs: HashMap<String, Int64> = HashMap<String, Int64>()
|
|
109
|
+
|
|
110
|
+
/** SUBACK 结果:报文标识符 → 授予码列表(`subscribe` 命令的有界等待读它) */
|
|
111
|
+
var subacks: HashMap<Int64, ArrayList<Int64>> = HashMap<Int64, ArrayList<Int64>>()
|
|
112
|
+
|
|
113
|
+
/** 环形缓冲(元素是给前端的 JSON);满了丢最旧并累计 dropped */
|
|
114
|
+
var recent: ArrayList<JsonValue> = ArrayList<JsonValue>()
|
|
115
|
+
|
|
116
|
+
/** app 引用:`MqttPlugin.setup()` 装配进来,事件回投要用它 */
|
|
117
|
+
var app: Option<TauriApp> = None
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 取一个新的报文标识符(调用方须持锁):0 非法,65535 之后回绕到 1 */
|
|
121
|
+
func mqttTakePacketIdLocked(sess: MqttSession): Int64 {
|
|
122
|
+
let id = sess.nextPacketId
|
|
123
|
+
sess.nextPacketId += 1
|
|
124
|
+
if (sess.nextPacketId > 65535) {
|
|
125
|
+
sess.nextPacketId = 1
|
|
126
|
+
}
|
|
127
|
+
return id
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 记一条报文:**赋 seq → 入缓冲(超限丢最旧并计 dropped)→ 推事件**,两条路在这里收口。
|
|
132
|
+
*
|
|
133
|
+
* 顺序刻意固定:先入缓冲再推事件——清单没声明 `events` 时事件会被框架丢弃(打一行 stderr),
|
|
134
|
+
* 而 `recent` 照旧拿得到,这正是 D-015 要的那条兜底。
|
|
135
|
+
*
|
|
136
|
+
* 调用方**不要**持有 `sess.lock`(本函数自己加锁)。
|
|
137
|
+
*/
|
|
138
|
+
func mqttRecord(sess: MqttSession, obj: JsonObject): Unit {
|
|
139
|
+
var app: Option<TauriApp> = None
|
|
140
|
+
sess.lock.lock()
|
|
141
|
+
sess.seq += 1
|
|
142
|
+
obj.put("seq", JsonInt(sess.seq))
|
|
143
|
+
obj.put("ts", JsonString(DateTime.now().toString()))
|
|
144
|
+
app = sess.app
|
|
145
|
+
sess.recent.add(obj)
|
|
146
|
+
// 超限:整块搬一次最旧的 N 条,代价 O(上限),500 条量级可忽略;
|
|
147
|
+
// 不用集合层的删除重载是为了让「丢的是什么」在这段代码里一眼可见(dropped 必须如实对得上)
|
|
148
|
+
if (sess.recent.size > MQTT_RECENT_LIMIT) {
|
|
149
|
+
let from = sess.recent.size - MQTT_RECENT_LIMIT
|
|
150
|
+
let keep = ArrayList<JsonValue>()
|
|
151
|
+
var i: Int64 = from
|
|
152
|
+
while (i < sess.recent.size) {
|
|
153
|
+
keep.add(sess.recent[i])
|
|
154
|
+
i += 1
|
|
155
|
+
}
|
|
156
|
+
sess.dropped += from
|
|
157
|
+
sess.recent = keep
|
|
158
|
+
eprintln("[cj-tauri] mqtt: recent 缓冲超限,丢弃最旧 ${from} 条(累计 dropped=${sess.dropped})")
|
|
159
|
+
}
|
|
160
|
+
sess.lock.unlock()
|
|
161
|
+
match (app) {
|
|
162
|
+
case Some(a) => a.emit("mqtt:message", obj)
|
|
163
|
+
case None => ()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 推 `mqtt:state`:连接状态是页面最需要**立刻**知道的事(被拒 / 掉线不能只躺在缓冲里)。
|
|
169
|
+
* 调用方不要持锁。
|
|
170
|
+
*/
|
|
171
|
+
func mqttEmitState(sess: MqttSession): Unit {
|
|
172
|
+
var app: Option<TauriApp> = None
|
|
173
|
+
let obj = JsonObject()
|
|
174
|
+
var line = ""
|
|
175
|
+
sess.lock.lock()
|
|
176
|
+
app = sess.app
|
|
177
|
+
obj.put("state", JsonString(sess.state))
|
|
178
|
+
obj.put("host", JsonString(sess.host))
|
|
179
|
+
obj.put("port", JsonInt(sess.port))
|
|
180
|
+
obj.put("clientId", JsonString(sess.clientId))
|
|
181
|
+
obj.put("keepaliveSec", JsonInt(sess.keepaliveSec))
|
|
182
|
+
obj.put("error", JsonString(sess.lastError))
|
|
183
|
+
obj.put("rx", JsonInt(sess.rxCount))
|
|
184
|
+
obj.put("tx", JsonInt(sess.txCount))
|
|
185
|
+
obj.put("dropped", JsonInt(sess.dropped))
|
|
186
|
+
// 状态变迁同时落一行 stderr:事件通道页面没接住时(没声明 events / 页面在重载),
|
|
187
|
+
// 「连上过没有、什么时候掉的」仍能端到端对账——这与框架其余能力的取证口径一致(AGENTS §1)
|
|
188
|
+
line = "state=${sess.state} host=${sess.host}:${sess.port} rx=${sess.rxCount} tx=${sess.txCount}" +
|
|
189
|
+
" dropped=${sess.dropped}"
|
|
190
|
+
sess.lock.unlock()
|
|
191
|
+
eprintln("[cj-tauri] mqtt: ${line}")
|
|
192
|
+
match (app) {
|
|
193
|
+
case Some(a) => a.emit("mqtt:state", obj)
|
|
194
|
+
case None => ()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 置会话为错误态:停读线程、关 socket、记原因、推 `mqtt:state`。
|
|
200
|
+
*
|
|
201
|
+
* 读线程与命令 worker 都可能调它(读失败 / 写失败 / 握手失败),所以必须**幂等**:
|
|
202
|
+
* 谁先到谁把原因写进去,后到的看到 socket 已经是 None 就只收尾。
|
|
203
|
+
*/
|
|
204
|
+
func mqttFailSession(sess: MqttSession, reason: String): Unit {
|
|
205
|
+
var sock: Option<TcpSocket> = None
|
|
206
|
+
var first = false
|
|
207
|
+
sess.lock.lock()
|
|
208
|
+
sock = sess.sock
|
|
209
|
+
sess.sock = None
|
|
210
|
+
if (sess.state != "error") {
|
|
211
|
+
first = true
|
|
212
|
+
sess.state = "error"
|
|
213
|
+
sess.lastError = reason
|
|
214
|
+
}
|
|
215
|
+
sess.stopRequested = true
|
|
216
|
+
sess.readerAlive = false
|
|
217
|
+
sess.lock.unlock()
|
|
218
|
+
match (sock) {
|
|
219
|
+
case Some(s) =>
|
|
220
|
+
try {
|
|
221
|
+
s.close()
|
|
222
|
+
} catch (e: Exception) {
|
|
223
|
+
let _ = e
|
|
224
|
+
}
|
|
225
|
+
case None => ()
|
|
226
|
+
}
|
|
227
|
+
if (first) {
|
|
228
|
+
eprintln("[cj-tauri] mqtt: ${reason}")
|
|
229
|
+
}
|
|
230
|
+
mqttEmitState(sess)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 写一条报文(lock 内写 socket + 更新 lastTxAt / txCount),**不记日志**。
|
|
235
|
+
*
|
|
236
|
+
* 需要按 PUBLISH 语义记日志的调用方(发布命令)用它,再自己调 `mqttLogPublish`——
|
|
237
|
+
* 一条报文只该在日志里出现一次,记两次会让页面把同一次发送看成两件事。
|
|
238
|
+
*
|
|
239
|
+
* 写失败一律置会话错误态并抛出 `CommandException`——调试工具宁可当场报错,也不留一个
|
|
240
|
+
* 「看起来还在连」的会话(AGENTS §1 的如实交付口径)。
|
|
241
|
+
*/
|
|
242
|
+
func mqttSendBytes(sess: MqttSession, sock: TcpSocket, raw: Array<UInt8>): Unit {
|
|
243
|
+
sess.lock.lock()
|
|
244
|
+
try {
|
|
245
|
+
sock.write(raw)
|
|
246
|
+
sess.lastTxAt = DateTime.now()
|
|
247
|
+
sess.txCount += 1
|
|
248
|
+
} catch (e: Exception) {
|
|
249
|
+
sess.lock.unlock()
|
|
250
|
+
mqttFailSession(sess, "写入失败:${e.message}")
|
|
251
|
+
throw CommandException("mqtt: 写入失败(${e.message})")
|
|
252
|
+
}
|
|
253
|
+
sess.lock.unlock()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 写一条报文 + 记一条原始日志(控制类报文用的就是它) */
|
|
257
|
+
func mqttSend(sess: MqttSession, sock: TcpSocket, raw: Array<UInt8>, typeName: String,
|
|
258
|
+
note: String): Unit {
|
|
259
|
+
mqttSendBytes(sess, sock, raw)
|
|
260
|
+
mqttLogRaw(sess, "tx", raw, typeName, note)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 把一条**原始报文**拼成日志记录(tx 侧与 rx 侧共用)。
|
|
265
|
+
*
|
|
266
|
+
* 正文按 `MQTT_RECENT_BODY_LIMIT` 截断:超长时只发前 256 KiB 的十六进制,并置 `truncated`——
|
|
267
|
+
* 页面照实显示「已截断」,不让人以为手里是完整报文(D-015 的代价一节)。
|
|
268
|
+
*/
|
|
269
|
+
func mqttLogRaw(sess: MqttSession, dir: String, raw: Array<UInt8>, typeName: String,
|
|
270
|
+
note: String): Unit {
|
|
271
|
+
let obj = JsonObject()
|
|
272
|
+
let shown = if (raw.size > MQTT_RECENT_BODY_LIMIT) { MQTT_RECENT_BODY_LIMIT } else { raw.size }
|
|
273
|
+
obj.put("dir", JsonString(dir))
|
|
274
|
+
obj.put("typeName", JsonString(typeName))
|
|
275
|
+
obj.put("note", JsonString(note))
|
|
276
|
+
obj.put("bytes", JsonInt(raw.size))
|
|
277
|
+
obj.put("truncated", JsonBool(shown < raw.size))
|
|
278
|
+
obj.put("hex", JsonString(bytesToHex(raw[0..shown])))
|
|
279
|
+
mqttRecord(sess, obj)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 读满 n 字节。**超时不算错**:每 tick 醒来做保活与停止检查,直到凑齐或超过整包时限。
|
|
284
|
+
*
|
|
285
|
+
* @param startedAt 本包首字节的到达时刻(整包时限从它算起)
|
|
286
|
+
* @param allowTick true(读线程)= 超时做保活;false(建连握手,读线程还没起)= 超时只对时针
|
|
287
|
+
* @return None 表示连接已经不可用(已置状态、已停读线程),调用方直接收摊
|
|
288
|
+
*/
|
|
289
|
+
func mqttReadExact(sess: MqttSession, sock: TcpSocket, n: Int64, startedAt: DateTime,
|
|
290
|
+
allowTick: Bool): Option<Array<UInt8>> {
|
|
291
|
+
let out = Array<UInt8>(n, repeat: 0)
|
|
292
|
+
var got: Int64 = 0
|
|
293
|
+
while (got < n) {
|
|
294
|
+
// 临时缓冲再拷贝:socket.read 只会从缓冲区头部开始填,把 out 的切片交给它,
|
|
295
|
+
// 切片若是拷贝语义就白读了(examples/modbus-poll 同款处理)
|
|
296
|
+
let tmp = Array<UInt8>(n - got, repeat: 0)
|
|
297
|
+
var c: Int64 = 0
|
|
298
|
+
try {
|
|
299
|
+
c = sock.read(tmp)
|
|
300
|
+
} catch (e: SocketTimeoutException) {
|
|
301
|
+
if (allowTick && !mqttKeepaliveTick(sess, sock)) {
|
|
302
|
+
return None
|
|
303
|
+
}
|
|
304
|
+
if ((DateTime.now() - startedAt).toMilliseconds() > MQTT_PACKET_DEADLINE_MS) {
|
|
305
|
+
mqttFailSession(sess, "报文收齐超时(已收 ${got}/${n} 字节,判连接不可用)")
|
|
306
|
+
return None
|
|
307
|
+
}
|
|
308
|
+
continue
|
|
309
|
+
} catch (e: Exception) {
|
|
310
|
+
mqttFailSession(sess, "读取失败:${e.message}")
|
|
311
|
+
return None
|
|
312
|
+
}
|
|
313
|
+
if (c <= 0) {
|
|
314
|
+
mqttFailSession(sess, "对端关闭了连接")
|
|
315
|
+
return None
|
|
316
|
+
}
|
|
317
|
+
var i: Int64 = 0
|
|
318
|
+
while (i < c) {
|
|
319
|
+
out[got + i] = tmp[i]
|
|
320
|
+
i += 1
|
|
321
|
+
}
|
|
322
|
+
got += c
|
|
323
|
+
sess.lock.lock()
|
|
324
|
+
sess.lastRxAt = DateTime.now()
|
|
325
|
+
sess.lock.unlock()
|
|
326
|
+
}
|
|
327
|
+
return Some(out)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 读到的剩余长度:值 + 占用的字节(原样留着,拼整条报文时要用) */
|
|
331
|
+
class MqttReadLength {
|
|
332
|
+
var value: Int64 = 0
|
|
333
|
+
var raw: Array<UInt8> = Array<UInt8>()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 从流里读变长剩余长度(最多 4 字节)。读不齐 / 凑不成 → None(调用方收摊)。
|
|
338
|
+
* 这里**不重复实现解码规则**:拿到字节后交给 `mqttDecodeRemainingLength` 判合法。
|
|
339
|
+
*/
|
|
340
|
+
func mqttReadRemainingLength(sess: MqttSession, sock: TcpSocket,
|
|
341
|
+
startedAt: DateTime): Option<MqttReadLength> {
|
|
342
|
+
let bytes = ArrayList<UInt8>()
|
|
343
|
+
var i: Int64 = 0
|
|
344
|
+
while (i < 4) {
|
|
345
|
+
match (mqttReadExact(sess, sock, 1, startedAt, true)) {
|
|
346
|
+
case None => return None
|
|
347
|
+
case Some(one) => bytes.add(one[0])
|
|
348
|
+
}
|
|
349
|
+
let last = bytes[bytes.size - 1]
|
|
350
|
+
if ((Int64(last) & 0x80) == 0) {
|
|
351
|
+
let raw = Array<UInt8>(bytes.size, repeat: 0)
|
|
352
|
+
var j: Int64 = 0
|
|
353
|
+
while (j < bytes.size) {
|
|
354
|
+
raw[j] = bytes[j]
|
|
355
|
+
j += 1
|
|
356
|
+
}
|
|
357
|
+
match (mqttDecodeRemainingLength(raw, 0)) {
|
|
358
|
+
case None => return None
|
|
359
|
+
case Some(rl) =>
|
|
360
|
+
let out = MqttReadLength()
|
|
361
|
+
out.value = rl.value
|
|
362
|
+
out.raw = raw
|
|
363
|
+
return Some(out)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
i += 1
|
|
367
|
+
}
|
|
368
|
+
mqttFailSession(sess, "剩余长度超过 4 字节(畸形报文,判连接不可用)")
|
|
369
|
+
return None
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 读线程每 tick 调一次:到点发 PINGREQ,并检查停止请求。
|
|
374
|
+
* 返回 false = 读线程该退出了(收到了停止请求)。
|
|
375
|
+
*/
|
|
376
|
+
func mqttKeepaliveTick(sess: MqttSession, sock: TcpSocket): Bool {
|
|
377
|
+
var keepalive: Int64 = 0
|
|
378
|
+
var stopped = false
|
|
379
|
+
var lastTx = DateTime.now()
|
|
380
|
+
sess.lock.lock()
|
|
381
|
+
keepalive = sess.keepaliveSec
|
|
382
|
+
// 停止信号**只看 stopRequested**:readerAlive 是「读线程在跑」的事实描述,拿它当停止条件的话,
|
|
383
|
+
// 建连握手期(读线程还没起、readerAlive 还是 false)的一次读超时会被误判成「连接完了」——
|
|
384
|
+
// 于是 CONNACK 永远等不到,连接却报「握手失败」。停读线程一律走 stopRequested。
|
|
385
|
+
stopped = sess.stopRequested
|
|
386
|
+
lastTx = sess.lastTxAt
|
|
387
|
+
sess.lock.unlock()
|
|
388
|
+
if (stopped) {
|
|
389
|
+
return false
|
|
390
|
+
}
|
|
391
|
+
if (keepalive <= 0) {
|
|
392
|
+
return true
|
|
393
|
+
}
|
|
394
|
+
// 半个保活周期内一个字节都没发过才发心跳:协议要求「不超过 1.5 倍保活时间内必须有报文」,
|
|
395
|
+
// 半个周期发一次留足余量,也避免正常发布流量下多发无用心跳
|
|
396
|
+
if ((DateTime.now() - lastTx).toMilliseconds() >= (keepalive * 1000) / 2) {
|
|
397
|
+
try {
|
|
398
|
+
mqttSend(sess, sock, mqttEncodePingreq(), "PINGREQ", "保活心跳(${keepalive}s 周期)")
|
|
399
|
+
} catch (e: CommandException) {
|
|
400
|
+
let _ = e
|
|
401
|
+
return false
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return true
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------- 收侧:报文分派与读线程
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 清理已完结的表项(调用方持锁):`pending` 里标了 done 的、`subacks` 里已被取走的空列表,
|
|
411
|
+
* 整块搬到新表。**不做单条删除**(与环形缓冲同一套做法:用哪条路一眼可见,也少依赖集合层重载)。
|
|
412
|
+
*/
|
|
413
|
+
func mqttCompactLocked(sess: MqttSession): Unit {
|
|
414
|
+
if (sess.pending.size > 256) {
|
|
415
|
+
let keep = HashMap<Int64, String>()
|
|
416
|
+
for ((id, stage) in sess.pending) {
|
|
417
|
+
if (stage != "done") {
|
|
418
|
+
keep.add(id, stage)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
sess.pending = keep
|
|
422
|
+
}
|
|
423
|
+
if (sess.subacks.size > 64) {
|
|
424
|
+
let keep = HashMap<Int64, ArrayList<Int64>>()
|
|
425
|
+
for ((id, granted) in sess.subacks) {
|
|
426
|
+
if (granted.size > 0) {
|
|
427
|
+
keep.add(id, granted)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
sess.subacks = keep
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 记一条 PUBLISH(收发两侧共用):主题 / 位域 / 载荷全摊开,页面直接渲染 */
|
|
435
|
+
func mqttLogPublish(sess: MqttSession, dir: String, raw: Array<UInt8>, pub: MqttPublish,
|
|
436
|
+
note: String): Unit {
|
|
437
|
+
let obj = JsonObject()
|
|
438
|
+
let shown = if (raw.size > MQTT_RECENT_BODY_LIMIT) { MQTT_RECENT_BODY_LIMIT } else { raw.size }
|
|
439
|
+
let payloadShown = if (pub.payload.size > MQTT_RECENT_BODY_LIMIT) {
|
|
440
|
+
MQTT_RECENT_BODY_LIMIT
|
|
441
|
+
} else {
|
|
442
|
+
pub.payload.size
|
|
443
|
+
}
|
|
444
|
+
obj.put("dir", JsonString(dir))
|
|
445
|
+
obj.put("typeName", JsonString("PUBLISH"))
|
|
446
|
+
obj.put("topic", JsonString(pub.topic))
|
|
447
|
+
obj.put("qos", JsonInt(pub.qos))
|
|
448
|
+
obj.put("retain", JsonBool(pub.retain))
|
|
449
|
+
obj.put("dup", JsonBool(pub.dup))
|
|
450
|
+
obj.put("packetId", JsonInt(pub.packetId))
|
|
451
|
+
obj.put("bytes", JsonInt(raw.size))
|
|
452
|
+
obj.put("payloadBytes", JsonInt(pub.payload.size))
|
|
453
|
+
obj.put("truncated", JsonBool(shown < raw.size || payloadShown < pub.payload.size))
|
|
454
|
+
obj.put("hex", JsonString(bytesToHex(raw[0..shown])))
|
|
455
|
+
obj.put("payloadHex", JsonString(bytesToHex(pub.payload[0..payloadShown])))
|
|
456
|
+
// 载荷是任意二进制:能当文本看就给文本,不能就给 null(键始终在,页面按 null 判断改用十六进制)
|
|
457
|
+
match (mqttBytesToText(pub.payload[0..payloadShown])) {
|
|
458
|
+
case Some(t) => obj.put("payloadText", JsonString(t))
|
|
459
|
+
case None => obj.put("payloadText", JsonNull())
|
|
460
|
+
}
|
|
461
|
+
obj.put("note", JsonString(note))
|
|
462
|
+
mqttRecord(sess, obj)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 收到的 PUBLISH 按 QoS 自动应答。**这不是可选项**:QoS 1 不回 PUBACK、QoS 2 不回 PUBREC,
|
|
467
|
+
* broker 就会一直重发,页面看到的现象是「同一条消息刷屏」——协议层的自动应答在插件里做掉,
|
|
468
|
+
* 页面不必自己实现协议。
|
|
469
|
+
*/
|
|
470
|
+
func mqttAckInbound(sess: MqttSession, sock: TcpSocket, pub: MqttPublish): Unit {
|
|
471
|
+
if (!pub.hasPacketId) {
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
if (pub.qos == 1) {
|
|
475
|
+
mqttSend(sess, sock, mqttEncodeAck(MQTT_PKT_PUBACK, pub.packetId), "PUBACK",
|
|
476
|
+
"自动应答(QoS 1:确认收到报文标识符 ${pub.packetId})")
|
|
477
|
+
} else if (pub.qos == 2) {
|
|
478
|
+
mqttSend(sess, sock, mqttEncodeAck(MQTT_PKT_PUBREC, pub.packetId), "PUBREC",
|
|
479
|
+
"自动应答(QoS 2 第一步:已记录报文标识符 ${pub.packetId})")
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 收到的确认类报文(PUBACK / PUBREC / PUBREL / PUBCOMP):更新待确认表,QoS 2 按协议续下一步 */
|
|
484
|
+
func mqttHandleAck(sess: MqttSession, sock: TcpSocket, p: MqttPacket, raw: Array<UInt8>): Unit {
|
|
485
|
+
let typeName = mqttPacketTypeName(p.typeCode)
|
|
486
|
+
let id = mqttParsePacketId(p)
|
|
487
|
+
if (id < 0) {
|
|
488
|
+
mqttLogRaw(sess, "rx", raw, typeName, "缺报文标识符,已忽略")
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
var note = ""
|
|
492
|
+
var needPubrel = false
|
|
493
|
+
var needPubcomp = false
|
|
494
|
+
sess.lock.lock()
|
|
495
|
+
match (sess.pending.get(id)) {
|
|
496
|
+
case Some(stage) =>
|
|
497
|
+
if (p.typeCode == MQTT_PKT_PUBACK) {
|
|
498
|
+
sess.pending.add(id, "done")
|
|
499
|
+
note = "QoS 1 投递完成(${stage} → done)"
|
|
500
|
+
} else if (p.typeCode == MQTT_PKT_PUBREC) {
|
|
501
|
+
sess.pending.add(id, "pubcomp")
|
|
502
|
+
note = "QoS 2 第一步回执,正发 PUBREL"
|
|
503
|
+
needPubrel = true
|
|
504
|
+
} else if (p.typeCode == MQTT_PKT_PUBREL) {
|
|
505
|
+
note = "QoS 2 对端释放(我方回 PUBCOMP)"
|
|
506
|
+
needPubcomp = true
|
|
507
|
+
} else {
|
|
508
|
+
sess.pending.add(id, "done")
|
|
509
|
+
note = "QoS 2 投递完成(${stage} → done)"
|
|
510
|
+
}
|
|
511
|
+
case None =>
|
|
512
|
+
// 没登记过的标识符:不猜它是谁的,如实记一条(可能是上一轮连接的残留 / 对端重发)
|
|
513
|
+
if (p.typeCode == MQTT_PKT_PUBREL) {
|
|
514
|
+
note = "未登记的 PUBREL(无上下文,仍按协议回 PUBCOMP)"
|
|
515
|
+
needPubcomp = true
|
|
516
|
+
} else {
|
|
517
|
+
note = "未登记的确认报文(报文标识符 ${id}),已忽略"
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
sess.lock.unlock()
|
|
521
|
+
mqttLogRaw(sess, "rx", raw, typeName, note)
|
|
522
|
+
if (needPubrel) {
|
|
523
|
+
mqttSend(sess, sock, mqttEncodeAck(MQTT_PKT_PUBREL, id), "PUBREL",
|
|
524
|
+
"QoS 2 第二步:释放报文标识符 ${id}")
|
|
525
|
+
}
|
|
526
|
+
if (needPubcomp) {
|
|
527
|
+
mqttSend(sess, sock, mqttEncodeAck(MQTT_PKT_PUBCOMP, id), "PUBCOMP",
|
|
528
|
+
"QoS 2 收官:报文标识符 ${id} 已完成")
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 收到的 SUBACK / UNSUBACK:SUBACK 的授予码列表回填 `subacks`(`subscribe` 命令的有界等待读它),
|
|
534
|
+
* 返回给日志用的一句说明。**UNSUBACK 在协议里不带原因**,记一条即止。
|
|
535
|
+
*/
|
|
536
|
+
func mqttHandleSuback(sess: MqttSession, p: MqttPacket, raw: Array<UInt8>): Unit {
|
|
537
|
+
let typeName = mqttPacketTypeName(p.typeCode)
|
|
538
|
+
if (p.typeCode == MQTT_PKT_UNSUBACK) {
|
|
539
|
+
let id = mqttParsePacketId(p)
|
|
540
|
+
mqttLogRaw(sess, "rx", raw, typeName, "退订回执(报文标识符 ${id})")
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
match (mqttParseSuback(p)) {
|
|
544
|
+
case None =>
|
|
545
|
+
mqttLogRaw(sess, "rx", raw, typeName, "SUBACK 结构非法(缺报文标识符或授予码),已忽略")
|
|
546
|
+
case Some(granted) =>
|
|
547
|
+
let id = mqttParsePacketId(p)
|
|
548
|
+
var text = ""
|
|
549
|
+
var i: Int64 = 0
|
|
550
|
+
while (i < granted.size) {
|
|
551
|
+
if (text.size > 0) {
|
|
552
|
+
text += ", "
|
|
553
|
+
}
|
|
554
|
+
if (granted[i] == 0x80) {
|
|
555
|
+
text += "订阅失败(0x80)"
|
|
556
|
+
} else {
|
|
557
|
+
text += "QoS ${granted[i]}"
|
|
558
|
+
}
|
|
559
|
+
i += 1
|
|
560
|
+
}
|
|
561
|
+
sess.lock.lock()
|
|
562
|
+
sess.subacks.add(id, granted)
|
|
563
|
+
sess.lock.unlock()
|
|
564
|
+
mqttLogRaw(sess, "rx", raw, typeName, "订阅回执(报文标识符 ${id},授予 ${text})")
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** 其余报文的日志说明:只有少数几种会在本插件的状态机里出现 */
|
|
569
|
+
func mqttPlainNote(p: MqttPacket): String {
|
|
570
|
+
if (p.typeCode == MQTT_PKT_PINGRESP) {
|
|
571
|
+
return "心跳应答"
|
|
572
|
+
}
|
|
573
|
+
if (p.typeCode == MQTT_PKT_CONNACK) {
|
|
574
|
+
return "重复 CONNACK(握手期间已处理,这里只记日志)"
|
|
575
|
+
}
|
|
576
|
+
return ""
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* 处理一条收到的报文(读线程上调用)。**先记日志再动作**——顺序固定,日志里就是线上顺序,
|
|
581
|
+
* 排查时不会出现「响应排在了请求前面」这种自相矛盾的时间线。
|
|
582
|
+
*/
|
|
583
|
+
func mqttHandlePacket(sess: MqttSession, sock: TcpSocket, p: MqttPacket, raw: Array<UInt8>): Unit {
|
|
584
|
+
sess.lock.lock()
|
|
585
|
+
sess.rxCount += 1
|
|
586
|
+
sess.lock.unlock()
|
|
587
|
+
if (p.typeCode == MQTT_PKT_PUBLISH) {
|
|
588
|
+
match (mqttParsePublish(p)) {
|
|
589
|
+
case None =>
|
|
590
|
+
// 位域非法(QoS=3 这种保留值):记一条「拒收」,不让脏报文进页面冒充数据
|
|
591
|
+
mqttLogRaw(sess, "rx", raw, "PUBLISH", "非法 PUBLISH(QoS 保留值或缺字段),已丢弃")
|
|
592
|
+
case Some(pub) =>
|
|
593
|
+
var note = "收到消息(QoS ${pub.qos})"
|
|
594
|
+
if (pub.retain) {
|
|
595
|
+
note += " · retain"
|
|
596
|
+
}
|
|
597
|
+
if (pub.dup) {
|
|
598
|
+
note += " · dup"
|
|
599
|
+
}
|
|
600
|
+
mqttLogPublish(sess, "rx", raw, pub, note)
|
|
601
|
+
mqttAckInbound(sess, sock, pub)
|
|
602
|
+
}
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
if (p.typeCode == MQTT_PKT_SUBACK || p.typeCode == MQTT_PKT_UNSUBACK) {
|
|
606
|
+
mqttHandleSuback(sess, p, raw)
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
if (p.typeCode == MQTT_PKT_PUBACK || p.typeCode == MQTT_PKT_PUBREC ||
|
|
610
|
+
p.typeCode == MQTT_PKT_PUBREL || p.typeCode == MQTT_PKT_PUBCOMP) {
|
|
611
|
+
mqttHandleAck(sess, sock, p, raw)
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
mqttLogRaw(sess, "rx", raw, mqttPacketTypeName(p.typeCode), mqttPlainNote(p))
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 把已读到的三段(固定头首字节 / 剩余长度字节 / body)拼回整条报文:decode 要的是完整字节 */
|
|
618
|
+
func mqttConcatFrame(head: Array<UInt8>, lenBytes: Array<UInt8>, body: Array<UInt8>): Array<UInt8> {
|
|
619
|
+
let out = Array<UInt8>(1 + lenBytes.size + body.size, repeat: 0)
|
|
620
|
+
out[0] = head[0]
|
|
621
|
+
var i: Int64 = 0
|
|
622
|
+
while (i < lenBytes.size) {
|
|
623
|
+
out[1 + i] = lenBytes[i]
|
|
624
|
+
i += 1
|
|
625
|
+
}
|
|
626
|
+
var j: Int64 = 0
|
|
627
|
+
while (j < body.size) {
|
|
628
|
+
out[1 + lenBytes.size + j] = body[j]
|
|
629
|
+
j += 1
|
|
630
|
+
}
|
|
631
|
+
return out
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* 读回来的「一条报文」:raw 是拼好的整条帧(含长度字段),packet 是解码结果。
|
|
636
|
+
* 解码失败时 `packet` 为 None 而 `raw` 仍然留着——畸形报文也是证据,不能因为解不出就丢字节。
|
|
637
|
+
*/
|
|
638
|
+
class MqttReadPacket {
|
|
639
|
+
var raw: Array<UInt8> = Array<UInt8>()
|
|
640
|
+
var packet: Option<MqttPacket> = None
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* 从流里读齐一条完整报文并解码:固定头首字节 → 变长剩余长度 → body → 拼回整帧 → `mqttDecodePacket`。
|
|
645
|
+
* 返回 None = 连接已不可用(读错 / 收齐超时 / 对端关闭,状态已置好),调用方直接收摊。
|
|
646
|
+
*
|
|
647
|
+
* 握手(等 CONNACK)与读线程用**同一个**函数:读一条报文的规则只该有一份。
|
|
648
|
+
*/
|
|
649
|
+
func mqttReadOne(sess: MqttSession, sock: TcpSocket): Option<MqttReadPacket> {
|
|
650
|
+
let started = DateTime.now()
|
|
651
|
+
var head = Array<UInt8>()
|
|
652
|
+
match (mqttReadExact(sess, sock, 1, started, true)) {
|
|
653
|
+
case None => return None
|
|
654
|
+
case Some(h) => head = h
|
|
655
|
+
}
|
|
656
|
+
var rlRaw = Array<UInt8>()
|
|
657
|
+
var rlValue: Int64 = 0
|
|
658
|
+
match (mqttReadRemainingLength(sess, sock, started)) {
|
|
659
|
+
case None => return None
|
|
660
|
+
case Some(rl) =>
|
|
661
|
+
rlRaw = rl.raw
|
|
662
|
+
rlValue = rl.value
|
|
663
|
+
}
|
|
664
|
+
var body = Array<UInt8>()
|
|
665
|
+
if (rlValue > 0) {
|
|
666
|
+
match (mqttReadExact(sess, sock, rlValue, started, true)) {
|
|
667
|
+
case None => return None
|
|
668
|
+
case Some(b) => body = b
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
let out = MqttReadPacket()
|
|
672
|
+
out.raw = mqttConcatFrame(head, rlRaw, body)
|
|
673
|
+
out.packet = mqttDecodePacket(out.raw)
|
|
674
|
+
return Some(out)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 读线程主体:独占 socket 读侧,直到连接结束(对端关闭 / 读错 / 命令要求停止)。
|
|
679
|
+
*
|
|
680
|
+
* **所有出口都走同一个收口**(置状态、关 socket、推 `mqtt:state`):分开写的话迟早漏一条,
|
|
681
|
+
* 页面就会停在一个「已经死了但状态还写着 connected」的会话上。停止信号只看 `stopRequested`。
|
|
682
|
+
*/
|
|
683
|
+
func mqttReaderThread(sess: MqttSession, sock: TcpSocket): Unit {
|
|
684
|
+
while (true) {
|
|
685
|
+
sess.lock.lock()
|
|
686
|
+
let stop = sess.stopRequested
|
|
687
|
+
sess.lock.unlock()
|
|
688
|
+
if (stop) {
|
|
689
|
+
break
|
|
690
|
+
}
|
|
691
|
+
var rp = MqttReadPacket()
|
|
692
|
+
var exit = false
|
|
693
|
+
match (mqttReadOne(sess, sock)) {
|
|
694
|
+
case None => exit = true
|
|
695
|
+
case Some(v) => rp = v
|
|
696
|
+
}
|
|
697
|
+
if (exit) {
|
|
698
|
+
break
|
|
699
|
+
}
|
|
700
|
+
match (rp.packet) {
|
|
701
|
+
case None =>
|
|
702
|
+
// 老实按字节读回来的帧也过一遍解码器:长度对不上就当畸形报文记一条,不猜它的意图
|
|
703
|
+
mqttLogRaw(sess, "rx", rp.raw, "MALFORMED", "长度字段与实收字节对不上,已丢弃")
|
|
704
|
+
case Some(p) => mqttHandlePacket(sess, sock, p, rp.raw)
|
|
705
|
+
}
|
|
706
|
+
// 表项清理搭在收包计数上(每 64 条一次):不另起定时器,也不让两张表随长连接无限长大
|
|
707
|
+
sess.lock.lock()
|
|
708
|
+
let rx = sess.rxCount
|
|
709
|
+
if (rx > 0 && rx % 64 == 0) {
|
|
710
|
+
mqttCompactLocked(sess)
|
|
711
|
+
}
|
|
712
|
+
sess.lock.unlock()
|
|
713
|
+
}
|
|
714
|
+
// 收口:先判「是被要求停(disconnect)还是自己死的(error / 对端断开)」,再统一推状态
|
|
715
|
+
var sockOpt: Option<TcpSocket> = None
|
|
716
|
+
sess.lock.lock()
|
|
717
|
+
sess.readerAlive = false
|
|
718
|
+
if (sess.state == "connected") {
|
|
719
|
+
sess.state = "closed"
|
|
720
|
+
}
|
|
721
|
+
sockOpt = sess.sock
|
|
722
|
+
sess.sock = None
|
|
723
|
+
sess.lock.unlock()
|
|
724
|
+
match (sockOpt) {
|
|
725
|
+
case Some(s) =>
|
|
726
|
+
try {
|
|
727
|
+
s.close()
|
|
728
|
+
} catch (e: Exception) {
|
|
729
|
+
let _ = e
|
|
730
|
+
}
|
|
731
|
+
case None => ()
|
|
732
|
+
}
|
|
733
|
+
mqttEmitState(sess)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---------------------------------------------------------------- 命令侧:参数解析与各命令实现
|
|
737
|
+
|
|
738
|
+
/** 重连前等旧读线程收尾的上限(毫秒):等不到就照直打一行 stderr 再继续,不无限期挂在命令里 */
|
|
739
|
+
let MQTT_READER_EXIT_WAIT_MS: Int64 = 1000
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* mqtt 插件的命令处理器:七个命令共用一份,按全名分派(与 SerialCommands 同形)。
|
|
743
|
+
*
|
|
744
|
+
* **每次 connect 都换一份新会话**,不复用上一份状态:旧连接的读线程只会碰它自己那份会话,
|
|
745
|
+
* 于是永远不会误关新连接的 socket、也不会覆盖新连接的状态。重连路径额外做一件事——
|
|
746
|
+
* 先让旧读线程收尾(`closePrevious`),否则它退出时推的那条「closed」可能落在新连接的
|
|
747
|
+
* 「connected」之后,页面就莫名其妙显成断线。改这块时别把这两条拆掉。
|
|
748
|
+
*/
|
|
749
|
+
class MqttCommands <: CommandHandler {
|
|
750
|
+
/** 保护 current 指针:命令在 worker 线程上执行,页面连点 / 多条命令同时飞都是常态 */
|
|
751
|
+
private let lock: Mutex = Mutex()
|
|
752
|
+
private var current: MqttSession = MqttSession()
|
|
753
|
+
private var app: Option<TauriApp> = None
|
|
754
|
+
|
|
755
|
+
public init() {}
|
|
756
|
+
|
|
757
|
+
/** 由 MqttPlugin.setup() 调用:拿 app 才能推事件(mqtt:message / mqtt:state) */
|
|
758
|
+
public func attachApp(a: TauriApp): Unit {
|
|
759
|
+
this.app = Some(a)
|
|
760
|
+
this.lock.lock()
|
|
761
|
+
this.current.app = this.app
|
|
762
|
+
this.lock.unlock()
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
|
|
766
|
+
let _ = ipc
|
|
767
|
+
match (cmd) {
|
|
768
|
+
case "mqtt:connect" => this.connectCmd(args)
|
|
769
|
+
case "mqtt:disconnect" => this.disconnectCmd(args)
|
|
770
|
+
case "mqtt:subscribe" => this.subscribeCmd(args)
|
|
771
|
+
case "mqtt:unsubscribe" => this.unsubscribeCmd(args)
|
|
772
|
+
case "mqtt:publish" => this.publishCmd(args)
|
|
773
|
+
case "mqtt:status" => this.statusCmd(args)
|
|
774
|
+
case "mqtt:recent" => this.recentCmd(args)
|
|
775
|
+
case _ => throw CommandException("unknown mqtt command: ${cmd}")
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** 取当前会话(拿的是引用;字段读写由调用方自己加锁) */
|
|
780
|
+
private func session(): MqttSession {
|
|
781
|
+
this.lock.lock()
|
|
782
|
+
let s = this.current
|
|
783
|
+
this.lock.unlock()
|
|
784
|
+
return s
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* 就绪检查:没连上就明确报错,不进后续流程。
|
|
789
|
+
* 调试工具最怕「命令看着发出去了,其实没连」——所以 publish/subscribe 一律先过这道门。
|
|
790
|
+
*/
|
|
791
|
+
private func requireSocket(sess: MqttSession): TcpSocket {
|
|
792
|
+
sess.lock.lock()
|
|
793
|
+
let st = sess.state
|
|
794
|
+
let sock = sess.sock
|
|
795
|
+
sess.lock.unlock()
|
|
796
|
+
if (st != "connected") {
|
|
797
|
+
throw CommandException("mqtt: 未连接(当前状态 ${st}),请先 mqtt:connect")
|
|
798
|
+
}
|
|
799
|
+
match (sock) {
|
|
800
|
+
case Some(s) => s
|
|
801
|
+
case None => throw CommandException("mqtt: 连接已断开(socket 已回收),请重新 connect")
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* 重连之前先收掉上一条连接:不先收,页面上反复点「连接」会攒下一堆没人管的连接。
|
|
807
|
+
*
|
|
808
|
+
* 顺序是刻意的:**先置停止旗标 → 发 DISCONNECT(尽力而为)→ 有界等旧读线程退出 → 再关 socket**。
|
|
809
|
+
* 旧读线程退出时必然会推一条状态事件,等它落定之后再建新连接,页面就不会被那条旧事件
|
|
810
|
+
* 覆盖成「断线」(这一条比省 100~500ms 重要)。
|
|
811
|
+
*/
|
|
812
|
+
private func closePrevious(): Unit {
|
|
813
|
+
let old = this.session()
|
|
814
|
+
var sockOpt: Option<TcpSocket> = None
|
|
815
|
+
old.lock.lock()
|
|
816
|
+
sockOpt = old.sock
|
|
817
|
+
old.stopRequested = true
|
|
818
|
+
if (old.state == "connected" || old.state == "connecting") {
|
|
819
|
+
old.state = "closed"
|
|
820
|
+
}
|
|
821
|
+
old.lock.unlock()
|
|
822
|
+
match (sockOpt) {
|
|
823
|
+
case Some(sock) =>
|
|
824
|
+
// 协议层面的正常下线:broker 收到 DISCONNECT 就不会替我们代发遗嘱,也会主动关连接
|
|
825
|
+
// (对端一关,旧读线程立刻从 read 回来,不必等满一个读超时)
|
|
826
|
+
try {
|
|
827
|
+
sock.write(mqttEncodeDisconnect())
|
|
828
|
+
} catch (e: Exception) {
|
|
829
|
+
let _ = e
|
|
830
|
+
}
|
|
831
|
+
case None => ()
|
|
832
|
+
}
|
|
833
|
+
this.waitReaderExit(old)
|
|
834
|
+
match (sockOpt) {
|
|
835
|
+
case Some(sock) =>
|
|
836
|
+
try {
|
|
837
|
+
sock.close()
|
|
838
|
+
} catch (e: Exception) {
|
|
839
|
+
let _ = e
|
|
840
|
+
}
|
|
841
|
+
case None => ()
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** 有界等旧读线程收尾:等不到就如实打一行 stderr 再继续(不把命令无限期挂住) */
|
|
846
|
+
private func waitReaderExit(sess: MqttSession): Unit {
|
|
847
|
+
var waited: Int64 = 0
|
|
848
|
+
while (waited < MQTT_READER_EXIT_WAIT_MS) {
|
|
849
|
+
sess.lock.lock()
|
|
850
|
+
let alive = sess.readerAlive
|
|
851
|
+
sess.lock.unlock()
|
|
852
|
+
if (!alive) {
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
sleep(Duration.millisecond * MQTT_WAIT_STEP_MS)
|
|
856
|
+
waited += MQTT_WAIT_STEP_MS
|
|
857
|
+
}
|
|
858
|
+
eprintln("[cj-tauri] mqtt: 上一条连接的读线程未在 ${MQTT_READER_EXIT_WAIT_MS}ms 内收尾,继续建新连接")
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** 取会话记下的失败原因(没有就用 fallback)——失败原因只有会话知道,别让页面去猜 */
|
|
862
|
+
private func errorOf(sess: MqttSession, fallback: String): String {
|
|
863
|
+
sess.lock.lock()
|
|
864
|
+
let e = sess.lastError
|
|
865
|
+
sess.lock.unlock()
|
|
866
|
+
if (e.size == 0) {
|
|
867
|
+
return fallback
|
|
868
|
+
}
|
|
869
|
+
return e
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** CONNACK 的日志说明:返回码翻成人话,页面不必自己攒一张表 */
|
|
873
|
+
private func connackNote(p: MqttPacket): String {
|
|
874
|
+
match (mqttParseConnack(p)) {
|
|
875
|
+
case Some(c) =>
|
|
876
|
+
"返回码 ${c.returnCode}(${mqttReturnCodeText(c.returnCode)}),sessionPresent=${c.sessionPresent}"
|
|
877
|
+
case None => "结构非法(不是 2 字节的变长头)"
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private func requireString(args: JsonObject, key: String): String {
|
|
882
|
+
match (args.get(key)) {
|
|
883
|
+
case Some(v) => match (v.kind()) {
|
|
884
|
+
case JsonKind.JsString => v.asString().getValue()
|
|
885
|
+
case _ => throw CommandException("mqtt: argument \"${key}\" must be a string")
|
|
886
|
+
}
|
|
887
|
+
case None => throw CommandException("mqtt: missing argument \"${key}\"")
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** 可选字符串参数:缺失 / null 都按空串 */
|
|
892
|
+
private func optionalString(args: JsonObject, key: String): String {
|
|
893
|
+
match (args.get(key)) {
|
|
894
|
+
case Some(v) => match (v.kind()) {
|
|
895
|
+
case JsonKind.JsString => v.asString().getValue()
|
|
896
|
+
case JsonKind.JsNull => ""
|
|
897
|
+
case _ => throw CommandException("mqtt: argument \"${key}\" must be a string")
|
|
898
|
+
}
|
|
899
|
+
case None => ""
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** 可选字符串参数(要区分「没给」与「给了空串」时用它,例如 username / password) */
|
|
904
|
+
private func optionalStringValue(args: JsonObject, key: String): Option<String> {
|
|
905
|
+
match (args.get(key)) {
|
|
906
|
+
case Some(v) => match (v.kind()) {
|
|
907
|
+
case JsonKind.JsString => Some(v.asString().getValue())
|
|
908
|
+
case JsonKind.JsNull => None
|
|
909
|
+
case _ => throw CommandException("mqtt: argument \"${key}\" must be a string")
|
|
910
|
+
}
|
|
911
|
+
case None => None
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** 可选整数参数:缺失 / null 都取 fallback;JSON 侧 numeric 可能是整数也可能是浮点 */
|
|
916
|
+
private func optionalInt(args: JsonObject, key: String, fallback: Int64): Int64 {
|
|
917
|
+
match (args.get(key)) {
|
|
918
|
+
case Some(v) => match (v.kind()) {
|
|
919
|
+
case JsonKind.JsInt => v.asInt().getValue()
|
|
920
|
+
case JsonKind.JsFloat =>
|
|
921
|
+
// 浮点转整数先夹范围:越界会抛异常把整条命令带走(AGENTS §4 的那条坑)
|
|
922
|
+
mqttClampInt(Int64(v.asFloat().getValue()), -65536, 65536)
|
|
923
|
+
case JsonKind.JsNull => fallback
|
|
924
|
+
case _ => throw CommandException("mqtt: argument \"${key}\" must be a number")
|
|
925
|
+
}
|
|
926
|
+
case None => fallback
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** 可选布尔参数:缺失 / null 都取 fallback */
|
|
931
|
+
private func optionalBool(args: JsonObject, key: String, fallback: Bool): Bool {
|
|
932
|
+
match (args.get(key)) {
|
|
933
|
+
case Some(v) => match (v.kind()) {
|
|
934
|
+
case JsonKind.JsBool => v.asBool().getValue()
|
|
935
|
+
case JsonKind.JsNull => fallback
|
|
936
|
+
case _ => throw CommandException("mqtt: argument \"${key}\" must be a boolean")
|
|
937
|
+
}
|
|
938
|
+
case None => fallback
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* `mqtt:connect { host, port?, clientId?, keepaliveSec?, cleanSession?, username?, password? }`
|
|
944
|
+
*
|
|
945
|
+
* 握手在**本命令线程上同步做**(发 CONNECT → 等 CONNACK → 起读线程):CONNACK 的结果随命令结果
|
|
946
|
+
* 直接回给前端,页面不用猜「到底连上没有」;读线程晚一步启动,两侧永远不会同时读同一个 socket。
|
|
947
|
+
*
|
|
948
|
+
* 不支持的(如实报错,不假装支持):TLS / WebSocket 传输、遗嘱报文、MQTT 5.0。
|
|
949
|
+
*/
|
|
950
|
+
private func connectCmd(args: JsonObject): JsonValue {
|
|
951
|
+
let host = this.requireString(args, "host")
|
|
952
|
+
let port = this.optionalInt(args, "port", 1883)
|
|
953
|
+
let clientIdRaw = this.optionalString(args, "clientId")
|
|
954
|
+
let keepalive = this.optionalInt(args, "keepaliveSec", MQTT_DEFAULT_KEEPALIVE_SEC)
|
|
955
|
+
let clean = this.optionalBool(args, "cleanSession", true)
|
|
956
|
+
let username = this.optionalStringValue(args, "username")
|
|
957
|
+
let password = this.optionalStringValue(args, "password")
|
|
958
|
+
if (!mqttValidHost(host)) {
|
|
959
|
+
throw CommandException("mqtt: host 非法(只收 IP / 主机名的安全字符 [A-Za-z0-9._-],长度 1..253)")
|
|
960
|
+
}
|
|
961
|
+
if (!mqttValidPort(port)) {
|
|
962
|
+
throw CommandException("mqtt: port 非法(须在 1..65535)")
|
|
963
|
+
}
|
|
964
|
+
if (keepalive < 0 || keepalive > 65535) {
|
|
965
|
+
throw CommandException("mqtt: keepaliveSec 非法(0..65535;0 表示关闭保活)")
|
|
966
|
+
}
|
|
967
|
+
this.closePrevious()
|
|
968
|
+
// 新会话(不复用旧状态):旧读线程只碰它自己那份,见类注释
|
|
969
|
+
let sess = MqttSession()
|
|
970
|
+
sess.host = host
|
|
971
|
+
sess.port = port
|
|
972
|
+
sess.clientId = if (clientIdRaw.size > 0) { clientIdRaw } else { mqttDefaultClientId(port) }
|
|
973
|
+
sess.keepaliveSec = keepalive
|
|
974
|
+
sess.cleanSession = clean
|
|
975
|
+
sess.state = "connecting"
|
|
976
|
+
this.lock.lock()
|
|
977
|
+
this.current = sess
|
|
978
|
+
sess.app = this.app
|
|
979
|
+
this.lock.unlock()
|
|
980
|
+
|
|
981
|
+
let sock = TcpSocket(host, UInt16(port))
|
|
982
|
+
try {
|
|
983
|
+
sock.connect(timeout: Duration.millisecond * MQTT_CONNECT_TIMEOUT_MS)
|
|
984
|
+
} catch (e: Exception) {
|
|
985
|
+
mqttFailSession(sess, "连接 ${host}:${port} 失败:${e.message}")
|
|
986
|
+
throw CommandException("mqtt: 连接 ${host}:${port} 失败(${e.message})")
|
|
987
|
+
}
|
|
988
|
+
sock.noDelay = true
|
|
989
|
+
sock.writeTimeout = Duration.millisecond * MQTT_CONNECT_TIMEOUT_MS
|
|
990
|
+
// 读超时就是读线程的时基(每 tick 醒来做保活与停止检查)——别改成「不超时」,那会让保活失效
|
|
991
|
+
sock.readTimeout = Duration.millisecond * MQTT_READ_TICK_MS
|
|
992
|
+
sess.lock.lock()
|
|
993
|
+
sess.sock = Some(sock)
|
|
994
|
+
sess.lock.unlock()
|
|
995
|
+
|
|
996
|
+
let opts = MqttConnectOptions()
|
|
997
|
+
opts.host = host
|
|
998
|
+
opts.port = port
|
|
999
|
+
opts.clientId = sess.clientId
|
|
1000
|
+
opts.username = username
|
|
1001
|
+
opts.password = password
|
|
1002
|
+
opts.keepaliveSec = keepalive
|
|
1003
|
+
opts.cleanSession = clean
|
|
1004
|
+
mqttSend(sess, sock, mqttEncodeConnect(opts), "CONNECT",
|
|
1005
|
+
"建立连接(clientId=${sess.clientId},keepalive=${keepalive}s,cleanSession=${clean})")
|
|
1006
|
+
|
|
1007
|
+
// 等 CONNACK:`mqttReadOne` 失败时自己已经置好错误态与原因,这里把它搬给页面
|
|
1008
|
+
var ack: Option<MqttConnack> = None
|
|
1009
|
+
match (mqttReadOne(sess, sock)) {
|
|
1010
|
+
case None => throw CommandException("mqtt: 握手失败(${this.errorOf(sess, "未收到 CONNACK")})")
|
|
1011
|
+
case Some(rp) => match (rp.packet) {
|
|
1012
|
+
case None =>
|
|
1013
|
+
mqttFailSession(sess, "握手期间收到畸形报文(长度字段与实收字节对不上)")
|
|
1014
|
+
throw CommandException("mqtt: 握手失败(对端第一帧畸形)")
|
|
1015
|
+
case Some(p) =>
|
|
1016
|
+
if (p.typeCode != MQTT_PKT_CONNACK) {
|
|
1017
|
+
let name = mqttPacketTypeName(p.typeCode)
|
|
1018
|
+
mqttFailSession(sess, "握手期间收到的第一帧不是 CONNACK(是 ${name})")
|
|
1019
|
+
throw CommandException("mqtt: 握手失败(对端第一帧是 ${name},不是 CONNACK)")
|
|
1020
|
+
}
|
|
1021
|
+
mqttLogRaw(sess, "rx", rp.raw, "CONNACK", "连接应答(${this.connackNote(p)})")
|
|
1022
|
+
ack = mqttParseConnack(p)
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
match (ack) {
|
|
1026
|
+
case None =>
|
|
1027
|
+
mqttFailSession(sess, "CONNACK 结构非法(不是 2 字节的变长头)")
|
|
1028
|
+
throw CommandException("mqtt: 握手失败(CONNACK 结构非法)")
|
|
1029
|
+
case Some(c) =>
|
|
1030
|
+
if (c.returnCode != 0) {
|
|
1031
|
+
let text = mqttReturnCodeText(c.returnCode)
|
|
1032
|
+
mqttFailSession(sess, "连接被拒:${text}(返回码 ${c.returnCode})")
|
|
1033
|
+
throw CommandException("mqtt: ${host}:${port} 拒绝连接——${text}(返回码 ${c.returnCode})")
|
|
1034
|
+
}
|
|
1035
|
+
sess.lock.lock()
|
|
1036
|
+
sess.state = "connected"
|
|
1037
|
+
sess.readerAlive = true
|
|
1038
|
+
sess.stopRequested = false
|
|
1039
|
+
sess.lock.unlock()
|
|
1040
|
+
// 先推状态再起读线程:事件顺序「connected → 之后的报文」与页面看到的时序一致
|
|
1041
|
+
mqttEmitState(sess)
|
|
1042
|
+
spawn { => mqttReaderThread(sess, sock) }
|
|
1043
|
+
let out = JsonObject()
|
|
1044
|
+
out.put("state", JsonString("connected"))
|
|
1045
|
+
out.put("host", JsonString(host))
|
|
1046
|
+
out.put("port", JsonInt(port))
|
|
1047
|
+
out.put("clientId", JsonString(sess.clientId))
|
|
1048
|
+
out.put("sessionPresent", JsonBool(c.sessionPresent))
|
|
1049
|
+
out.put("keepaliveSec", JsonInt(keepalive))
|
|
1050
|
+
out.put("cleanSession", JsonBool(clean))
|
|
1051
|
+
out.put("note", JsonString("已连上;收到的报文推 mqtt:message 事件,也可用 mqtt:recent 拉取"))
|
|
1052
|
+
out
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* `mqtt:disconnect`:发 DISCONNECT(协议上的正常下线,broker 不会替我们代发遗嘱)后收掉连接。
|
|
1058
|
+
* **没有连接时不报错**——页面收尾时无脑调一次是常态,如实回「本来就是断的」即可。
|
|
1059
|
+
*/
|
|
1060
|
+
private func disconnectCmd(args: JsonObject): JsonValue {
|
|
1061
|
+
let _ = args
|
|
1062
|
+
let sess = this.session()
|
|
1063
|
+
var sockOpt: Option<TcpSocket> = None
|
|
1064
|
+
var wasConnected = false
|
|
1065
|
+
sess.lock.lock()
|
|
1066
|
+
sockOpt = sess.sock
|
|
1067
|
+
wasConnected = sess.state == "connected" || sess.state == "connecting"
|
|
1068
|
+
sess.stopRequested = true
|
|
1069
|
+
sess.state = "closed"
|
|
1070
|
+
sess.lock.unlock()
|
|
1071
|
+
var sent = false
|
|
1072
|
+
match (sockOpt) {
|
|
1073
|
+
case Some(sock) =>
|
|
1074
|
+
if (wasConnected) {
|
|
1075
|
+
try {
|
|
1076
|
+
mqttSend(sess, sock, mqttEncodeDisconnect(), "DISCONNECT",
|
|
1077
|
+
"主动断开(不让 broker 代发遗嘱)")
|
|
1078
|
+
sent = true
|
|
1079
|
+
} catch (e: CommandException) {
|
|
1080
|
+
// 发不出去就照直说,不把「关掉了」讲成「好好告别了」
|
|
1081
|
+
let _ = e
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
try {
|
|
1085
|
+
sock.close()
|
|
1086
|
+
} catch (e: Exception) {
|
|
1087
|
+
let _ = e
|
|
1088
|
+
}
|
|
1089
|
+
case None => ()
|
|
1090
|
+
}
|
|
1091
|
+
mqttEmitState(sess)
|
|
1092
|
+
let out = JsonObject()
|
|
1093
|
+
out.put("state", JsonString("closed"))
|
|
1094
|
+
out.put("wasConnected", JsonBool(wasConnected))
|
|
1095
|
+
out.put("sentDisconnect", JsonBool(sent))
|
|
1096
|
+
sess.lock.lock()
|
|
1097
|
+
out.put("rx", JsonInt(sess.rxCount))
|
|
1098
|
+
out.put("tx", JsonInt(sess.txCount))
|
|
1099
|
+
out.put("dropped", JsonInt(sess.dropped))
|
|
1100
|
+
sess.lock.unlock()
|
|
1101
|
+
out
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* 有界等 SUBACK(命令 worker 上的轮询):超时返回 None,**不假装成功**。
|
|
1106
|
+
* 取到的授予码列表非空才算数;取走后放一个空列表占位(`mqttCompactLocked` 会把它整块搬走)。
|
|
1107
|
+
*/
|
|
1108
|
+
private func waitSuback(sess: MqttSession, packetId: Int64,
|
|
1109
|
+
timeoutMs: Int64): Option<ArrayList<Int64>> {
|
|
1110
|
+
var waited: Int64 = 0
|
|
1111
|
+
while (waited <= timeoutMs) {
|
|
1112
|
+
var hit: Option<ArrayList<Int64>> = None
|
|
1113
|
+
sess.lock.lock()
|
|
1114
|
+
hit = sess.subacks.get(packetId)
|
|
1115
|
+
match (hit) {
|
|
1116
|
+
case Some(granted) =>
|
|
1117
|
+
if (granted.size > 0) {
|
|
1118
|
+
sess.subacks.add(packetId, ArrayList<Int64>())
|
|
1119
|
+
}
|
|
1120
|
+
case None => ()
|
|
1121
|
+
}
|
|
1122
|
+
sess.lock.unlock()
|
|
1123
|
+
match (hit) {
|
|
1124
|
+
case Some(granted) =>
|
|
1125
|
+
if (granted.size > 0) {
|
|
1126
|
+
return Some(granted)
|
|
1127
|
+
}
|
|
1128
|
+
case None => ()
|
|
1129
|
+
}
|
|
1130
|
+
sleep(Duration.millisecond * MQTT_WAIT_STEP_MS)
|
|
1131
|
+
waited += MQTT_WAIT_STEP_MS
|
|
1132
|
+
}
|
|
1133
|
+
return None
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* `mqtt:subscribe { filter, qos?, waitMs? }`
|
|
1138
|
+
*
|
|
1139
|
+
* 有界等 SUBACK:拿到就把授予的 QoS 回给页面;等不到如实回 `pending`(已发出、回执未到),
|
|
1140
|
+
* 不假装订阅成功——SUBACK 到达时会作为一条 `mqtt:message` 事件出现,页面照样看得到。
|
|
1141
|
+
*/
|
|
1142
|
+
private func subscribeCmd(args: JsonObject): JsonValue {
|
|
1143
|
+
let filter = this.requireString(args, "filter")
|
|
1144
|
+
let qos = mqttClampQos(this.optionalInt(args, "qos", 0))
|
|
1145
|
+
let waitMs = this.optionalInt(args, "waitMs", MQTT_WAIT_TIMEOUT_MS)
|
|
1146
|
+
if (!mqttValidTopicFilter(filter)) {
|
|
1147
|
+
throw CommandException("mqtt: 订阅过滤器非法(通配符必须独占一整层:sensor/+/temp、sensor/#)")
|
|
1148
|
+
}
|
|
1149
|
+
if (waitMs < 0 || waitMs > 60000) {
|
|
1150
|
+
throw CommandException("mqtt: waitMs 非法(0..60000)")
|
|
1151
|
+
}
|
|
1152
|
+
let sess = this.session()
|
|
1153
|
+
let sock = this.requireSocket(sess)
|
|
1154
|
+
sess.lock.lock()
|
|
1155
|
+
let packetId = mqttTakePacketIdLocked(sess)
|
|
1156
|
+
sess.lock.unlock()
|
|
1157
|
+
mqttSend(sess, sock, mqttEncodeSubscribe(packetId, filter, qos), "SUBSCRIBE",
|
|
1158
|
+
"订阅 ${filter}(请求 QoS ${qos})")
|
|
1159
|
+
let out = JsonObject()
|
|
1160
|
+
out.put("filter", JsonString(filter))
|
|
1161
|
+
out.put("requestedQos", JsonInt(qos))
|
|
1162
|
+
out.put("packetId", JsonInt(packetId))
|
|
1163
|
+
match (this.waitSuback(sess, packetId, waitMs)) {
|
|
1164
|
+
case Some(granted) =>
|
|
1165
|
+
var first: Int64 = -2
|
|
1166
|
+
var text = ""
|
|
1167
|
+
let grantedJson = JsonArray()
|
|
1168
|
+
var i: Int64 = 0
|
|
1169
|
+
while (i < granted.size) {
|
|
1170
|
+
if (text.size > 0) {
|
|
1171
|
+
text += ", "
|
|
1172
|
+
}
|
|
1173
|
+
if (granted[i] == 0x80) {
|
|
1174
|
+
text += "订阅失败(0x80)"
|
|
1175
|
+
} else {
|
|
1176
|
+
text += "QoS ${granted[i]}"
|
|
1177
|
+
}
|
|
1178
|
+
grantedJson.add(JsonInt(granted[i]))
|
|
1179
|
+
i += 1
|
|
1180
|
+
}
|
|
1181
|
+
if (granted.size > 0) {
|
|
1182
|
+
first = granted[0]
|
|
1183
|
+
}
|
|
1184
|
+
sess.lock.lock()
|
|
1185
|
+
sess.subs.add(filter, first)
|
|
1186
|
+
sess.lock.unlock()
|
|
1187
|
+
let rejected = first == 0x80
|
|
1188
|
+
out.put("state", JsonString(if (rejected) { "rejected" } else { "subscribed" }))
|
|
1189
|
+
out.put("granted", grantedJson)
|
|
1190
|
+
out.put("grantedText", JsonString(text))
|
|
1191
|
+
out.put("note", JsonString(if (rejected) {
|
|
1192
|
+
"broker 拒绝了这次订阅(授予码 0x80)"
|
|
1193
|
+
} else {
|
|
1194
|
+
"订阅已生效"
|
|
1195
|
+
}))
|
|
1196
|
+
case None =>
|
|
1197
|
+
out.put("state", JsonString("pending"))
|
|
1198
|
+
out.put("note", JsonString(
|
|
1199
|
+
"SUBSCRIBE 已发出,SUBACK 未在 ${waitMs}ms 内到达;回执到达时会作为 mqtt:message 事件出现"))
|
|
1200
|
+
}
|
|
1201
|
+
out
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* `mqtt:unsubscribe { filter }`:发 UNSUBSCRIBE 后把订阅表里这一条标成「已退订」(-1)。
|
|
1206
|
+
* UNSUBACK 在协议里只有报文标识符、不带原因,所以这里不等回执——它到了会作为一条事件出现。
|
|
1207
|
+
*/
|
|
1208
|
+
private func unsubscribeCmd(args: JsonObject): JsonValue {
|
|
1209
|
+
let filter = this.requireString(args, "filter")
|
|
1210
|
+
if (!mqttValidTopicFilter(filter)) {
|
|
1211
|
+
throw CommandException("mqtt: 过滤器非法(通配符必须独占一整层)")
|
|
1212
|
+
}
|
|
1213
|
+
let sess = this.session()
|
|
1214
|
+
let sock = this.requireSocket(sess)
|
|
1215
|
+
sess.lock.lock()
|
|
1216
|
+
let packetId = mqttTakePacketIdLocked(sess)
|
|
1217
|
+
sess.lock.unlock()
|
|
1218
|
+
mqttSend(sess, sock, mqttEncodeUnsubscribe(packetId, filter), "UNSUBSCRIBE", "退订 ${filter}")
|
|
1219
|
+
sess.lock.lock()
|
|
1220
|
+
let was = sess.subs.get(filter)
|
|
1221
|
+
// -1 语义是「已退订」:state 事件与 status 都会照直带出去,页面想过滤就自己过滤
|
|
1222
|
+
sess.subs.add(filter, -1)
|
|
1223
|
+
sess.lock.unlock()
|
|
1224
|
+
let out = JsonObject()
|
|
1225
|
+
out.put("filter", JsonString(filter))
|
|
1226
|
+
out.put("packetId", JsonInt(packetId))
|
|
1227
|
+
out.put("state", JsonString("unsubscribed"))
|
|
1228
|
+
match (was) {
|
|
1229
|
+
case Some(q) => out.put("previousQos", JsonInt(q))
|
|
1230
|
+
case None => out.put("previousQos", JsonNull())
|
|
1231
|
+
}
|
|
1232
|
+
out
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* `mqtt:publish { topic, text? | hex?, qos?, retain? }`
|
|
1237
|
+
*
|
|
1238
|
+
* 载荷两种给法:`text`(按 UTF-8 编码)或 `hex`(二进制)。**不做隐式优先级**:两个都给就报错;
|
|
1239
|
+
* 都不给是合法调用(空载荷——主题上的「事件通知」常见)。
|
|
1240
|
+
* QoS > 0 时登记待确认表,回执(PUBACK,或 PUBREC + PUBCOMP)会在日志里逐条可见。
|
|
1241
|
+
*/
|
|
1242
|
+
private func publishCmd(args: JsonObject): JsonValue {
|
|
1243
|
+
let topic = this.requireString(args, "topic")
|
|
1244
|
+
let qos = mqttClampQos(this.optionalInt(args, "qos", 0))
|
|
1245
|
+
let retain = this.optionalBool(args, "retain", false)
|
|
1246
|
+
let text = this.optionalString(args, "text")
|
|
1247
|
+
let hex = this.optionalString(args, "hex")
|
|
1248
|
+
if (!mqttValidPublishTopic(topic)) {
|
|
1249
|
+
throw CommandException("mqtt: 发布主题非法(非空、不超过 65535 字节、不含 + 与 #)")
|
|
1250
|
+
}
|
|
1251
|
+
var payload = Array<UInt8>()
|
|
1252
|
+
if (hex.size > 0) {
|
|
1253
|
+
if (text.size > 0) {
|
|
1254
|
+
throw CommandException("mqtt: 载荷只能给 text 或 hex 之一(含糊比报错更糟)")
|
|
1255
|
+
}
|
|
1256
|
+
match (hexToBytes(hex)) {
|
|
1257
|
+
case Some(b) => payload = b
|
|
1258
|
+
case None => throw CommandException("mqtt: hex 载荷非法(须为偶数长度的十六进制串)")
|
|
1259
|
+
}
|
|
1260
|
+
} else if (text.size > 0) {
|
|
1261
|
+
payload = text.toArray()
|
|
1262
|
+
}
|
|
1263
|
+
let sess = this.session()
|
|
1264
|
+
let sock = this.requireSocket(sess)
|
|
1265
|
+
var packetId: Int64 = 0
|
|
1266
|
+
if (qos > 0) {
|
|
1267
|
+
sess.lock.lock()
|
|
1268
|
+
packetId = mqttTakePacketIdLocked(sess)
|
|
1269
|
+
sess.pending.add(packetId, if (qos == 1) { "puback" } else { "pubrec" })
|
|
1270
|
+
sess.lock.unlock()
|
|
1271
|
+
}
|
|
1272
|
+
let raw = mqttEncodePublish(topic, payload, qos, retain, false, packetId)
|
|
1273
|
+
mqttSendBytes(sess, sock, raw)
|
|
1274
|
+
// 发布的日志走 PUBLISH 语义(带主题与载荷文本),与收到的报文在页面里同形
|
|
1275
|
+
let pub = MqttPublish()
|
|
1276
|
+
pub.topic = topic
|
|
1277
|
+
pub.payload = payload
|
|
1278
|
+
pub.qos = qos
|
|
1279
|
+
pub.retain = retain
|
|
1280
|
+
pub.packetId = packetId
|
|
1281
|
+
pub.hasPacketId = qos > 0
|
|
1282
|
+
mqttLogPublish(sess, "tx", raw, pub, if (qos == 0) {
|
|
1283
|
+
"发布到 ${topic}(QoS 0,发后即忘)"
|
|
1284
|
+
} else {
|
|
1285
|
+
"发布到 ${topic}(QoS ${qos},等回执:报文标识符 ${packetId})"
|
|
1286
|
+
})
|
|
1287
|
+
let out = JsonObject()
|
|
1288
|
+
out.put("topic", JsonString(topic))
|
|
1289
|
+
out.put("qos", JsonInt(qos))
|
|
1290
|
+
out.put("retain", JsonBool(retain))
|
|
1291
|
+
out.put("bytes", JsonInt(raw.size))
|
|
1292
|
+
out.put("payloadBytes", JsonInt(payload.size))
|
|
1293
|
+
out.put("packetId", JsonInt(packetId))
|
|
1294
|
+
out
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* `mqtt:status`:一份可对账的连接快照(页面顶栏就是它)。
|
|
1299
|
+
* subscriptions 的取值语义写进返回体里,免得页面猜:0..2 = 已授予,-1 = 已退订,-2 = 已发出、回执未到。
|
|
1300
|
+
*/
|
|
1301
|
+
private func statusCmd(args: JsonObject): JsonValue {
|
|
1302
|
+
let _ = args
|
|
1303
|
+
let sess = this.session()
|
|
1304
|
+
let out = JsonObject()
|
|
1305
|
+
sess.lock.lock()
|
|
1306
|
+
out.put("state", JsonString(sess.state))
|
|
1307
|
+
out.put("host", JsonString(sess.host))
|
|
1308
|
+
out.put("port", JsonInt(sess.port))
|
|
1309
|
+
out.put("clientId", JsonString(sess.clientId))
|
|
1310
|
+
out.put("keepaliveSec", JsonInt(sess.keepaliveSec))
|
|
1311
|
+
out.put("cleanSession", JsonBool(sess.cleanSession))
|
|
1312
|
+
out.put("readerAlive", JsonBool(sess.readerAlive))
|
|
1313
|
+
out.put("rx", JsonInt(sess.rxCount))
|
|
1314
|
+
out.put("tx", JsonInt(sess.txCount))
|
|
1315
|
+
out.put("dropped", JsonInt(sess.dropped))
|
|
1316
|
+
out.put("buffered", JsonInt(sess.recent.size))
|
|
1317
|
+
out.put("pendingAcks", JsonInt(sess.pending.size))
|
|
1318
|
+
out.put("lastError", JsonString(sess.lastError))
|
|
1319
|
+
out.put("lastTxAt", JsonString(sess.lastTxAt.toString()))
|
|
1320
|
+
out.put("lastRxAt", JsonString(sess.lastRxAt.toString()))
|
|
1321
|
+
let subsObj = JsonObject()
|
|
1322
|
+
for ((filter, q) in sess.subs) {
|
|
1323
|
+
subsObj.put(filter, JsonInt(q))
|
|
1324
|
+
}
|
|
1325
|
+
sess.lock.unlock()
|
|
1326
|
+
out.put("subscriptions", subsObj)
|
|
1327
|
+
out.put("note", JsonString(
|
|
1328
|
+
"subscriptions 的 qos:0..2 已授予 / -1 已退订 / -2 已发出、回执未到;dropped = 缓冲满被丢弃的报文数"))
|
|
1329
|
+
out
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* `mqtt:recent { limit? }`:把缓冲里的报文**取走**(读后即清,D-015 的第二条通道)。
|
|
1334
|
+
*
|
|
1335
|
+
* 为什么读后即清:这是一条「拉取」通道而不是无限日志——同一条报文本来就已经走过 `mqtt:message`
|
|
1336
|
+
* 事件(页面按报文里的 `seq` 去重即可),不清的话每次轮询都要页面自己算增量。
|
|
1337
|
+
*/
|
|
1338
|
+
private func recentCmd(args: JsonObject): JsonValue {
|
|
1339
|
+
let limit = this.optionalInt(args, "limit", MQTT_RECENT_LIMIT)
|
|
1340
|
+
let sess = this.session()
|
|
1341
|
+
let items = JsonArray()
|
|
1342
|
+
var total: Int64 = 0
|
|
1343
|
+
var dropped: Int64 = 0
|
|
1344
|
+
sess.lock.lock()
|
|
1345
|
+
total = sess.recent.size
|
|
1346
|
+
dropped = sess.dropped
|
|
1347
|
+
let take = mqttClampInt(limit, 0, total)
|
|
1348
|
+
var i: Int64 = 0
|
|
1349
|
+
while (i < take) {
|
|
1350
|
+
items.add(sess.recent[i])
|
|
1351
|
+
i += 1
|
|
1352
|
+
}
|
|
1353
|
+
// 取走前 take 条:剩下的整块搬进新表(与环形缓冲同一套做法,不做逐条删除)
|
|
1354
|
+
let keep = ArrayList<JsonValue>()
|
|
1355
|
+
var j: Int64 = take
|
|
1356
|
+
while (j < sess.recent.size) {
|
|
1357
|
+
keep.add(sess.recent[j])
|
|
1358
|
+
j += 1
|
|
1359
|
+
}
|
|
1360
|
+
sess.recent = keep
|
|
1361
|
+
sess.lock.unlock()
|
|
1362
|
+
let out = JsonObject()
|
|
1363
|
+
out.put("messages", items)
|
|
1364
|
+
out.put("count", JsonInt(items.size()))
|
|
1365
|
+
out.put("bufferedBefore", JsonInt(total))
|
|
1366
|
+
out.put("droppedTotal", JsonInt(dropped))
|
|
1367
|
+
out.put("note", JsonString("取走即清;同一条报文也会走 mqtt:message 事件,按 seq 去重"))
|
|
1368
|
+
out
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* 官方插件 mqtt:MQTT 3.1.1 客户端调试工具(形态与实现层的取舍见 `docs/设计决策记录.md` D-014)
|
|
1374
|
+
*
|
|
1375
|
+
* 命令 `mqtt:connect/disconnect/subscribe/unsubscribe/publish/status/recent`,事件 `mqtt:message` /
|
|
1376
|
+
* `mqtt:state`;**只声明「我提供什么」,不自动放行**——应用要在 `capabilities` 目录的 json 里
|
|
1377
|
+
* 明文声明命令,或引用命名集 `"permissions": ["mqtt:debug"]`(只观不控用 `mqtt:readonly`)。
|
|
1378
|
+
*/
|
|
1379
|
+
public class MqttPlugin <: Plugin {
|
|
1380
|
+
private let handler: MqttCommands = MqttCommands()
|
|
1381
|
+
|
|
1382
|
+
public init() {}
|
|
1383
|
+
|
|
1384
|
+
public func name(): String {
|
|
1385
|
+
return "mqtt"
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
public func commands(): HashMap<String, CommandHandler> {
|
|
1389
|
+
let m = HashMap<String, CommandHandler>()
|
|
1390
|
+
m.add("connect", this.handler)
|
|
1391
|
+
m.add("disconnect", this.handler)
|
|
1392
|
+
m.add("subscribe", this.handler)
|
|
1393
|
+
m.add("unsubscribe", this.handler)
|
|
1394
|
+
m.add("publish", this.handler)
|
|
1395
|
+
m.add("status", this.handler)
|
|
1396
|
+
m.add("recent", this.handler)
|
|
1397
|
+
return m
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* 命名权限集:`debug` = 调试台需要的全部;`readonly` = 只看不发(状态 + 拉取),
|
|
1402
|
+
* 想在别人的 broker 上「只观不控」时用它。
|
|
1403
|
+
*/
|
|
1404
|
+
public func permissions(): HashMap<String, ArrayList<String>> {
|
|
1405
|
+
let m = HashMap<String, ArrayList<String>>()
|
|
1406
|
+
let debug = ArrayList<String>()
|
|
1407
|
+
for (name in MQTT_PLUGIN_COMMANDS) {
|
|
1408
|
+
debug.add(name)
|
|
1409
|
+
}
|
|
1410
|
+
m.add("debug", debug)
|
|
1411
|
+
let readonly = ArrayList<String>()
|
|
1412
|
+
readonly.add("status")
|
|
1413
|
+
readonly.add("recent")
|
|
1414
|
+
m.add("readonly", readonly)
|
|
1415
|
+
return m
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** 事件只声明短名(框架拼成 `mqtt:message` / `mqtt:state`),投递的落点在 mqttRecord / mqttEmitState */
|
|
1419
|
+
public func events(): ArrayList<String> {
|
|
1420
|
+
let e = ArrayList<String>()
|
|
1421
|
+
e.add("message")
|
|
1422
|
+
e.add("state")
|
|
1423
|
+
return e
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* 前端 shim:往桥对象上挂一个 mqtt 命名空间(与 dialog / notification 同款)。
|
|
1428
|
+
* 由框架经 `addInitScript` 在 document-start 注入,因此页面脚本执行时它已经在。
|
|
1429
|
+
*/
|
|
1430
|
+
public func jsShim(): String {
|
|
1431
|
+
return "(function () { var t = window.__CJ_TAURI__ || (window.__CJ_TAURI__ = {});" +
|
|
1432
|
+
" t.mqtt = {" +
|
|
1433
|
+
" connect: function (o) { return window.__CJ_TAURI__.invoke('mqtt:connect', o || {}); }," +
|
|
1434
|
+
" disconnect: function (o) { return window.__CJ_TAURI__.invoke('mqtt:disconnect', o || {}); }," +
|
|
1435
|
+
" subscribe: function (o) { return window.__CJ_TAURI__.invoke('mqtt:subscribe', o || {}); }," +
|
|
1436
|
+
" unsubscribe: function (o) { return window.__CJ_TAURI__.invoke('mqtt:unsubscribe', o || {}); }," +
|
|
1437
|
+
" publish: function (o) { return window.__CJ_TAURI__.invoke('mqtt:publish', o || {}); }," +
|
|
1438
|
+
" status: function (o) { return window.__CJ_TAURI__.invoke('mqtt:status', o || {}); }," +
|
|
1439
|
+
" recent: function (o) { return window.__CJ_TAURI__.invoke('mqtt:recent', o || {}); }" +
|
|
1440
|
+
" }; })();\n"
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
public func setup(app: TauriApp): Unit {
|
|
1444
|
+
this.handler.attachApp(app)
|
|
1445
|
+
}
|
|
1446
|
+
}
|