feat: Python/Java SDK 接入清单与打包文档
对真实 nixmsg 跑 DEVELOPMENT 第 9 节接入清单(跳过仅 JS 跨域),补 README/示例,并修 Paho/HiveMQ 真机联调死锁与鉴权分类。
This commit is contained in:
@@ -11,6 +11,7 @@ import urllib.request
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
from queue import SimpleQueue
|
||||
|
||||
from .errors import ClosedError, NixMsgError, NotConnectedError
|
||||
from .protocol import dumps, down_topic, loads, normalize_mqtt_ws_url, register_url_from_connect, up_topic
|
||||
@@ -133,6 +134,9 @@ class Client:
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
self._wake = threading.Event()
|
||||
self._want_connected = False
|
||||
self._down_q: SimpleQueue = SimpleQueue()
|
||||
self._down_thread = threading.Thread(target=self._down_loop, name="nixmsg-down", daemon=True)
|
||||
self._down_thread.start()
|
||||
|
||||
self._transport.set_handlers(self._on_transport_connected, self._on_transport_disconnected, self._on_down)
|
||||
|
||||
@@ -218,6 +222,10 @@ class Client:
|
||||
self._transport.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._down_q.put(None)
|
||||
except Exception:
|
||||
pass
|
||||
self._wake.set()
|
||||
|
||||
def logout(self) -> None:
|
||||
@@ -632,40 +640,74 @@ class Client:
|
||||
self._wake.set()
|
||||
|
||||
def _on_down(self, payload: bytes) -> None:
|
||||
# resp 必须立即完成 pending(含 auto_ack 等待),不能进 down 队列,否则自死锁。
|
||||
try:
|
||||
frame = loads(payload)
|
||||
except Exception:
|
||||
self._down_q.put(payload)
|
||||
return
|
||||
if frame.get("type") == "resp":
|
||||
self._dispatch_resp(frame)
|
||||
return
|
||||
if threading.current_thread() is self._down_thread:
|
||||
self._dispatch_down_body(frame)
|
||||
return
|
||||
self._down_q.put(payload)
|
||||
|
||||
def _down_loop(self) -> None:
|
||||
while True:
|
||||
payload = self._down_q.get()
|
||||
if payload is None:
|
||||
return
|
||||
try:
|
||||
frame = loads(payload)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
self._dispatch_down_body(frame)
|
||||
except Exception:
|
||||
log.exception("处理下行帧失败")
|
||||
|
||||
def _dispatch_resp(self, frame: dict[str, Any]) -> None:
|
||||
rid = str(frame.get("rid", ""))
|
||||
with self._lock:
|
||||
pending = self._pending.pop(rid, None)
|
||||
if not pending:
|
||||
return
|
||||
pending.response = frame
|
||||
if pending.is_send:
|
||||
with self._lock:
|
||||
self._inflight_sends = max(0, self._inflight_sends - 1)
|
||||
err = (frame.get("error") or {}) if not frame.get("ok") else {}
|
||||
if not frame.get("ok") and str(err.get("code")) == "rate_limited":
|
||||
with self._lock:
|
||||
pending.rid = ""
|
||||
pending.response = None
|
||||
pending.error = None
|
||||
pending.event.clear()
|
||||
self._wake.set()
|
||||
return
|
||||
with self._lock:
|
||||
self._send_queue = [it for it in self._send_queue if it.pending is not pending]
|
||||
if not frame.get("ok"):
|
||||
pending.error = NixMsgError(str(err.get("code", "bad_request")), str(err.get("message", "")))
|
||||
pending.event.set()
|
||||
self._wake.set()
|
||||
else:
|
||||
pending.event.set()
|
||||
|
||||
def _dispatch_down(self, payload: bytes) -> None:
|
||||
try:
|
||||
frame = loads(payload)
|
||||
except Exception:
|
||||
return
|
||||
ftype = frame.get("type")
|
||||
if ftype == "resp":
|
||||
rid = str(frame.get("rid", ""))
|
||||
with self._lock:
|
||||
pending = self._pending.pop(rid, None)
|
||||
if pending:
|
||||
pending.response = frame
|
||||
if pending.is_send:
|
||||
with self._lock:
|
||||
self._inflight_sends = max(0, self._inflight_sends - 1)
|
||||
# rate_limited 重交:清状态后不 set event
|
||||
err = (frame.get("error") or {}) if not frame.get("ok") else {}
|
||||
if not frame.get("ok") and str(err.get("code")) == "rate_limited":
|
||||
with self._lock:
|
||||
pending.rid = ""
|
||||
pending.response = None
|
||||
pending.error = None
|
||||
pending.event.clear()
|
||||
self._wake.set()
|
||||
return
|
||||
# 从发送队列移除
|
||||
with self._lock:
|
||||
self._send_queue = [it for it in self._send_queue if it.pending is not pending]
|
||||
if not frame.get("ok"):
|
||||
pending.error = NixMsgError(str(err.get("code", "bad_request")), str(err.get("message", "")))
|
||||
pending.event.set()
|
||||
self._wake.set()
|
||||
else:
|
||||
pending.event.set()
|
||||
if frame.get("type") == "resp":
|
||||
self._dispatch_resp(frame)
|
||||
return
|
||||
self._dispatch_down_body(frame)
|
||||
|
||||
def _dispatch_down_body(self, frame: dict[str, Any]) -> None:
|
||||
ftype = frame.get("type")
|
||||
if ftype == "msg":
|
||||
self._handle_msg(frame)
|
||||
return
|
||||
|
||||
@@ -8,7 +8,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from paho.mqtt.client import CallbackAPIVersion, Client as PahoClient, MQTT_ERR_SUCCESS
|
||||
from paho.mqtt.client import CallbackAPIVersion, Client as PahoClient, MQTT_ERR_SUCCESS, MQTTv5
|
||||
from paho.mqtt.enums import MQTTErrorCode
|
||||
from paho.mqtt.reasoncodes import ReasonCode
|
||||
|
||||
@@ -186,6 +186,8 @@ class PahoTransport:
|
||||
self._on_down: Optional[DownHandler] = None
|
||||
self._down_topic = ""
|
||||
self._loop_started = False
|
||||
self._sub_event = threading.Event()
|
||||
self._sub_mid: Optional[int] = None
|
||||
|
||||
def set_handlers(
|
||||
self,
|
||||
@@ -199,44 +201,39 @@ class PahoTransport:
|
||||
|
||||
def connect(self, params: ConnectParams) -> None:
|
||||
self.disconnect()
|
||||
url = params.url
|
||||
u = urlparse(url if "://" in url else "ws://" + url)
|
||||
use_tcp = params.use_tcp or u.scheme in ("mqtt", "mqtts")
|
||||
# WebSocket 必须显式 transport=websockets;裸 TCP 走默认。
|
||||
client = PahoClient(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=params.client_id,
|
||||
protocol=PahoClient.MQTTv5,
|
||||
protocol=MQTTv5,
|
||||
transport="tcp" if use_tcp else "websockets",
|
||||
)
|
||||
client.username_pw_set(params.username, params.password)
|
||||
client.on_connect = self._on_connect
|
||||
client.on_disconnect = self._on_disconnect
|
||||
client.on_message = self._on_message
|
||||
client.on_subscribe = self._on_subscribe
|
||||
self._client = client
|
||||
|
||||
url = params.url
|
||||
u = urlparse(url if "://" in url else "ws://" + url)
|
||||
use_tcp = params.use_tcp or u.scheme in ("mqtt", "mqtts")
|
||||
host = u.hostname or "localhost"
|
||||
port = u.port or (8883 if u.scheme in ("wss", "mqtts") else 443 if u.scheme == "wss" else 80)
|
||||
props = None
|
||||
try:
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.packettypes import PacketTypes
|
||||
|
||||
props = Properties(PacketTypes.CONNECT)
|
||||
props.SessionExpiryInterval = params.session_expiry
|
||||
except Exception:
|
||||
props = None
|
||||
if use_tcp:
|
||||
if not u.port:
|
||||
port = 8883 if u.scheme == "mqtts" else 1883
|
||||
tls = u.scheme == "mqtts"
|
||||
if tls:
|
||||
if u.scheme == "mqtts":
|
||||
client.tls_set()
|
||||
props = None
|
||||
try:
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.packettypes import PacketTypes
|
||||
|
||||
props = Properties(PacketTypes.CONNECT)
|
||||
props.SessionExpiryInterval = params.session_expiry
|
||||
except Exception:
|
||||
props = None
|
||||
client.connect(
|
||||
host,
|
||||
port,
|
||||
keepalive=params.keep_alive,
|
||||
clean_start=params.clean_start,
|
||||
properties=props,
|
||||
)
|
||||
else:
|
||||
path = u.path or "/mqtt"
|
||||
if not path.endswith("/mqtt"):
|
||||
@@ -246,30 +243,28 @@ class PahoTransport:
|
||||
if u.scheme == "wss":
|
||||
client.tls_set()
|
||||
client.ws_set_options(path=path, headers={"Sec-WebSocket-Protocol": "mqtt"})
|
||||
props = None
|
||||
try:
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.packettypes import PacketTypes
|
||||
|
||||
props = Properties(PacketTypes.CONNECT)
|
||||
props.SessionExpiryInterval = params.session_expiry
|
||||
except Exception:
|
||||
props = None
|
||||
client.connect(
|
||||
host,
|
||||
port,
|
||||
keepalive=params.keep_alive,
|
||||
clean_start=params.clean_start,
|
||||
properties=props,
|
||||
)
|
||||
client.connect(
|
||||
host,
|
||||
port,
|
||||
keepalive=params.keep_alive,
|
||||
clean_start=params.clean_start,
|
||||
properties=props,
|
||||
)
|
||||
client.loop_start()
|
||||
self._loop_started = True
|
||||
# 等待连接结果由回调驱动;超时由 Client 层处理
|
||||
|
||||
def subscribe(self, topic: str) -> None:
|
||||
self._down_topic = topic
|
||||
if self._client:
|
||||
self._client.subscribe(topic, qos=1)
|
||||
if not self._client:
|
||||
return
|
||||
self._sub_event.clear()
|
||||
result, mid = self._client.subscribe(topic, qos=1)
|
||||
if result != MQTT_ERR_SUCCESS:
|
||||
raise RuntimeError(f"subscribe failed: {result}")
|
||||
self._sub_mid = mid
|
||||
if not self._sub_event.wait(10):
|
||||
raise RuntimeError("subscribe timeout")
|
||||
|
||||
def publish(self, topic: str, payload: bytes) -> None:
|
||||
if not self._client:
|
||||
@@ -296,13 +291,18 @@ class PahoTransport:
|
||||
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
||||
code = _reason_to_int(reason_code)
|
||||
if code == 0:
|
||||
# 不在 loop 线程里同步做 subscribe+等待,否则会卡死 SUBACK
|
||||
if self._on_connected:
|
||||
self._on_connected()
|
||||
threading.Thread(target=self._on_connected, name="nixmsg-on-connected", daemon=True).start()
|
||||
return
|
||||
stop, reason = _classify_connack(code)
|
||||
stop, reason = _classify_connack(code if code is not None else -1)
|
||||
if self._on_disconnected:
|
||||
self._on_disconnected(reason, stop)
|
||||
|
||||
def _on_subscribe(self, client, userdata, mid, reason_codes, properties) -> None:
|
||||
if self._sub_mid is None or mid == self._sub_mid:
|
||||
self._sub_event.set()
|
||||
|
||||
def _on_disconnect(self, client, userdata, flags, reason_code, properties) -> None:
|
||||
code = _reason_to_int(reason_code)
|
||||
if code in (0, None):
|
||||
@@ -329,9 +329,13 @@ def _reason_to_int(reason_code) -> Optional[int]:
|
||||
if isinstance(reason_code, int):
|
||||
return reason_code
|
||||
if isinstance(reason_code, ReasonCode):
|
||||
return int(reason_code)
|
||||
return int(reason_code.value)
|
||||
if isinstance(reason_code, MQTTErrorCode):
|
||||
return int(reason_code)
|
||||
# paho 偶发其它包装
|
||||
val = getattr(reason_code, "value", None)
|
||||
if isinstance(val, int):
|
||||
return val
|
||||
try:
|
||||
return int(reason_code)
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user