dpmp 0.1.1__py3-none-any.whl

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.
dpmp/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DPMP — Dual-Punch Multi-Path Protocol.
3
+
4
+ P2P 连接与传输底层库。
5
+
6
+ 对外分层:
7
+ dpmp.protocol 协议常量与编解码
8
+ dpmp.link 连接层(发现 / 信令 / 双打洞 / UDP-RTP / 路径调度 / 连接管理)
9
+ dpmp.stream 传输层(统一字节流通道 + 多径选路)
10
+ dpmp.util 工具层(网络探测 / 设备标识)
11
+
12
+ 典型用法(远程控制 / 投屏 / 游戏房间通用):
13
+
14
+ from dpmp.link.manager import LinkManager
15
+ from dpmp.link.signaling import SignalingClient
16
+
17
+ lm = LinkManager(sig, local_tcp_port=9998)
18
+ lm.on_socket_ready = lambda pid, sock, m: start_my_protocol(sock)
19
+ # 之后用 lm.get_send_channel(pid) 拿到可用通道发送字节流
20
+ """
21
+
22
+ from .config import Config, DEFAULT_CONFIG
23
+ from .defaults import (DEFAULT_SERVER, DEFAULT_SERVER_EXPIRES,
24
+ default_servers, check_default_server_expiry)
25
+
26
+ __version__ = "0.1.1"
27
+ __protocol__ = "DPMP/1.0"
dpmp/config.py ADDED
@@ -0,0 +1,74 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DPMP 运行时配置(调优参数)。
3
+
4
+ 协议常量(消息类型 / 包类型 / 字段格式)见 dpmp.protocol.constants,
5
+ **故意不可配**——它们是对端约定,改了就不兼容。
6
+
7
+ 本模块只放**不影响兼容性**的调优参数,支持按实例覆盖:
8
+
9
+ from dpmp import Config
10
+ cfg = Config()
11
+ cfg.rtp_window = 128
12
+ sig = SignalingClient(..., config=cfg)
13
+
14
+ 全局默认:dpmp.config.DEFAULT_CONFIG(改它影响所有未显式传 config 的实例)。
15
+ """
16
+
17
+ from .protocol import constants as C
18
+
19
+
20
+ class Config:
21
+ """DPMP 调优参数集合。所有字段都有默认值,可逐个覆盖。"""
22
+
23
+ def __init__(self, **kw):
24
+ self.reset()
25
+ for k, v in kw.items():
26
+ if hasattr(self, k):
27
+ setattr(self, k, v)
28
+
29
+ def reset(self):
30
+ """恢复全部默认值。"""
31
+ # ---- UDP-RTP ----
32
+ self.rtp_max_payload = C.RTP_MAX_PAYLOAD
33
+ self.rtp_window = C.RTP_WINDOW # 初始值(兼容名)
34
+ self.rtp_window_min = C.RTP_WINDOW_MIN # 自适应窗口下限
35
+ self.rtp_window_max = C.RTP_WINDOW_MAX # 自适应窗口上限
36
+ self.rtp_window_init = C.RTP_WINDOW_INIT
37
+ self.rtp_rto_ms = C.RTP_RTO_MS
38
+ self.rtp_rtx_interval = C.RTP_RTX_INTERVAL
39
+ self.rtp_keepalive_interval = C.RTP_KEEPALIVE_INTERVAL
40
+ self.rtp_peer_dead_timeout = C.RTP_PEER_DEAD_TIMEOUT
41
+ # ---- SYNC(TCP 打洞时刻对齐)----
42
+ self.sync_sample_count = C.SYNC_SAMPLE_COUNT
43
+ self.sync_timeout = C.SYNC_TIMEOUT
44
+ self.sync_commit_delay_ms = C.SYNC_COMMIT_DELAY_MS
45
+ self.sync_commit_resend = C.SYNC_COMMIT_RESEND
46
+ self.sync_commit_resend_interval = C.SYNC_COMMIT_RESEND_INTERVAL
47
+ # ---- 打洞 ----
48
+ self.punch_connect_timeout = C.PUNCH_CONNECT_TIMEOUT
49
+ self.punch_concurrency = C.PUNCH_CONCURRENCY
50
+ self.punch_candidate_concurrency = C.PUNCH_CANDIDATE_CONCURRENCY
51
+ self.punch_retry = C.PUNCH_RETRY
52
+ self.punch_retry_backoff = C.PUNCH_RETRY_BACKOFF
53
+ self.punch_port_predict_range = tuple(C.PUNCH_PORT_PREDICT_RANGE)
54
+ # ---- 信令 ----
55
+ self.heartbeat_interval = C.HEARTBEAT_INTERVAL
56
+ self.recv_timeout = C.RECV_TIMEOUT
57
+ self.udp_probe_interval = C.UDP_PROBE_INTERVAL
58
+ self.udp_probe_duration = C.UDP_PROBE_DURATION
59
+ # ---- 路径调度(梯度冗余多路径)----
60
+ self.proto_floor = dict(C.PROTO_FLOOR)
61
+ self.proto_safe_cap = dict(C.PROTO_SAFE_CAP)
62
+ self.role_factor = dict(C.ROLE_FACTOR)
63
+ self.proto_priority = dict(C.PROTO_PRIORITY)
64
+ # ---- 连接管理 ----
65
+ self.rebuild_cooldown_sec = 30.0
66
+ self.keepalive_tick = 1.0
67
+ self.idle_factor_tiers = [(60, 1), (600, 4), (3600, 16)]
68
+ self.idle_factor_max = 64
69
+ # ---- 局域网发现 ----
70
+ self.discover_interval = C.HEARTBEAT_INTERVAL
71
+
72
+
73
+ # 全局默认配置(未显式传 config 的实例都用它)
74
+ DEFAULT_CONFIG = Config()
dpmp/defaults.py ADDED
@@ -0,0 +1,62 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DPMP 应用默认值(非协议常量)。
3
+
4
+ 协议常量见 dpmp.protocol.constants(与对端约定,不可配)。
5
+ 本模块放**部署相关的默认值**——默认信令服务器等,方便开箱即用。
6
+ 这些值随时可被使用者的参数覆盖,且**不应被协议层依赖**。
7
+
8
+ ⚠️ 默认服务器是「便利」,不是「依赖」:
9
+ · 到期后客户端会明确提示,使用者应改用自建/备用服务器;
10
+ · 通过 SignalingClient(servers=[...]) 可完全绕过默认值;
11
+ · 局域网模式完全不使用服务器。
12
+ """
13
+
14
+ from .protocol import constants as C
15
+
16
+ # ============================================================
17
+ # 默认信令服务器(便利入口,非强制)
18
+ # ============================================================
19
+ DEFAULT_SERVER = "42.194.133.132"
20
+ DEFAULT_SERVER_PORT = C.DEFAULT_SERVER_PORT # 3336
21
+ DEFAULT_SERVER_TCP_PORT = C.DEFAULT_SERVER_TCP_PORT # 3337
22
+ DEFAULT_NAT_PROBE_PORT = C.NAT_PROBE_PORT # 3338
23
+
24
+ # 默认服务器到期日(ISO 日期)。到期/临近时客户端会提示。
25
+ # 使用者应在此日期后改用自建服务器或备用服务器。
26
+ DEFAULT_SERVER_EXPIRES = "2026-11-01"
27
+
28
+ # 临近到期的提醒阈值(天)
29
+ EXPIRY_WARN_DAYS = 7
30
+
31
+
32
+ def default_servers():
33
+ """返回默认服务器候选列表(单个)。"""
34
+ return [(DEFAULT_SERVER, DEFAULT_SERVER_PORT,
35
+ DEFAULT_SERVER_TCP_PORT, DEFAULT_NAT_PROBE_PORT)]
36
+
37
+
38
+ def check_default_server_expiry(today=None):
39
+ """检查默认服务器的到期状态。
40
+
41
+ 返回 dict:
42
+ {"status": "ok"|"soon"|"expired"|"unknown",
43
+ "days_left": int|None,
44
+ "expires": "YYYY-MM-DD"}
45
+ """
46
+ import datetime
47
+ if today is None:
48
+ today = datetime.date.today()
49
+ try:
50
+ exp = datetime.date.fromisoformat(DEFAULT_SERVER_EXPIRES)
51
+ except Exception:
52
+ return {"status": "unknown", "days_left": None,
53
+ "expires": DEFAULT_SERVER_EXPIRES}
54
+ days_left = (exp - today).days
55
+ if days_left < 0:
56
+ status = "expired"
57
+ elif days_left <= EXPIRY_WARN_DAYS:
58
+ status = "soon"
59
+ else:
60
+ status = "ok"
61
+ return {"status": status, "days_left": days_left,
62
+ "expires": DEFAULT_SERVER_EXPIRES}
dpmp/link/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DPMP 连接层。
3
+
4
+ 负责「找到对端 + 建立可用通道」:
5
+ discovery 局域网 UDP 发现(广播 / 扫描 / 心跳)
6
+ signaling 公网信令客户端(加入房间 / 心跳 / 成员事件)
7
+ puncher 双打洞核心(TCP 同时打开 + UDP 端口预测)
8
+ rtp UDP 可靠通道(UDP-RTP)
9
+ path 梯度冗余多路径调度
10
+ manager 连接管理(全连接 + 长连接 + 入站接管 + 保活)
11
+ """
12
+
13
+ from .discovery import Discovery
14
+ from .signaling import SignalingClient
15
+ from .puncher import HolePuncher, PunchResult
16
+ from .rtp import UdpReliableSocket
17
+ from .path import PeerPathScheduler, Path, KeepaliveScheduler
18
+ from .manager import LinkManager, Conn
19
+
20
+ __all__ = [
21
+ "Discovery",
22
+ "SignalingClient",
23
+ "HolePuncher", "PunchResult",
24
+ "UdpReliableSocket",
25
+ "PeerPathScheduler", "Path", "KeepaliveScheduler",
26
+ "LinkManager", "Conn",
27
+ ]
dpmp/link/discovery.py ADDED
@@ -0,0 +1,376 @@
1
+ # -*- coding: utf-8 -*-
2
+ """局域网设备发现:UDP 广播 + 主动扫描 + 心跳。
3
+
4
+ 节点按 device_id 去重(IP 变化不产生重复条目)。发现完全依赖
5
+ 「请求 → 响应」:收到广播/扫描请求 → 回 reply;收到 reply → 登记节点。
6
+
7
+ 端口:
8
+ · 9998 UDP 广播 / 心跳
9
+ · 9997 UDP 主动扫描(与收发隔离,避免扫描影响传输)
10
+ """
11
+
12
+ import json
13
+ import socket
14
+ import threading
15
+ import time
16
+
17
+ from ..protocol import constants as C
18
+ from ..util.net import (get_all_local_ips, get_broadcast_addrs,
19
+ get_subnet_for_ip, get_mac_address)
20
+
21
+
22
+ class Node:
23
+ """一个已发现的对端设备(dict 形式存储于 Discovery.nodes)。"""
24
+
25
+
26
+ class Discovery:
27
+ """局域网设备发现。
28
+
29
+ 参数:
30
+ device_id:本机稳定标识(用于去重,不显示自己)
31
+ hostname:本机显示名
32
+ log:日志回调
33
+ on_new_node(ip, msg):发现新节点时回调
34
+ on_node_gone(ip):节点下线时回调
35
+
36
+ 对外:
37
+ nodes {ip: node_dict}
38
+ start() / stop()
39
+ broadcast_search() 广播搜索
40
+ scan_subnet(cidr) 扫描子网
41
+ add_manual_node(ip) 手动添加
42
+ """
43
+
44
+ def __init__(self, device_id, hostname, log=None,
45
+ on_new_node=None, on_node_gone=None,
46
+ udp_port=None, scan_port=None, config=None):
47
+ if config is None:
48
+ from ..config import DEFAULT_CONFIG
49
+ config = DEFAULT_CONFIG
50
+ self.cfg = config
51
+ self.discover_interval = config.discover_interval
52
+ self.device_id = device_id or ""
53
+ self.hostname = hostname or socket.gethostname()
54
+ self.mac = get_mac_address()
55
+ self.log = log or (lambda m: None)
56
+ self.on_new_node = on_new_node
57
+ self.on_node_gone = on_node_gone
58
+ # 局域网端口(默认 9998 发现 / 9997 扫描,可自定义)
59
+ self.udp_port = udp_port or C.LAN_UDP_PORT
60
+ self.scan_port = scan_port or C.SCAN_PORT
61
+
62
+ self.my_ips = get_all_local_ips(ipv6=False)
63
+ self.my_ips_v6 = get_all_local_ips(ipv6=True)
64
+ self.broadcast_addrs = get_broadcast_addrs()
65
+
66
+ self.nodes = {}
67
+ self.lock = threading.Lock()
68
+ self.running = False
69
+ self.broadcast_sockets = []
70
+ self.listen_sockets = []
71
+ self.auto_scan_enabled = False
72
+ self.scanning = False
73
+ self.pausing_network = False # 传输时暂停广播/扫描/心跳
74
+
75
+ # ---------- 生命周期 ----------
76
+
77
+ def start(self):
78
+ self.running = True
79
+ threading.Thread(target=self._udp_listener, daemon=True).start()
80
+ threading.Thread(target=self._scan_listener, daemon=True).start()
81
+ self._start_broadcasters()
82
+ self.log("本机 IPv4: %s" % ", ".join(self.my_ips))
83
+ if self.my_ips_v6:
84
+ self.log("本机 IPv6: %s" % ", ".join(self.my_ips_v6))
85
+ self.log("广播地址: %s" % ", ".join(self.broadcast_addrs))
86
+
87
+ def stop(self):
88
+ self.running = False
89
+ for s in self.broadcast_sockets + self.listen_sockets:
90
+ try:
91
+ s.close()
92
+ except Exception:
93
+ pass
94
+ self._send_bye()
95
+
96
+ # ---------- 消息构造 ----------
97
+
98
+ def _build_msg(self, **extra):
99
+ msg = {
100
+ C.LAN_F_HOSTNAME: self.hostname,
101
+ C.LAN_F_DEVICE_ID: self.device_id,
102
+ C.LAN_F_MAC: self.mac,
103
+ }
104
+ msg.update(extra)
105
+ return json.dumps(msg).encode("utf-8")
106
+
107
+ def _upsert_node(self, remote_ip, msg, source="udp"):
108
+ """按 device_id 去重地插入/更新节点。返回 True 表示是新节点。"""
109
+ device_id = msg.get(C.LAN_F_DEVICE_ID, "") or ""
110
+ hostname = msg.get(C.LAN_F_HOSTNAME, remote_ip) or remote_ip
111
+ mac = msg.get(C.LAN_F_MAC, "") or ""
112
+ is_new = False
113
+ with self.lock:
114
+ if device_id:
115
+ for old_ip in list(self.nodes.keys()):
116
+ if old_ip == remote_ip:
117
+ continue
118
+ if self.nodes[old_ip].get(C.LAN_F_DEVICE_ID) == device_id:
119
+ del self.nodes[old_ip]
120
+ if remote_ip in self.nodes:
121
+ self.nodes[remote_ip][C.LAN_F_HOSTNAME] = hostname
122
+ self.nodes[remote_ip][C.LAN_F_DEVICE_ID] = device_id
123
+ self.nodes[remote_ip][C.LAN_F_MAC] = mac
124
+ self.nodes[remote_ip]["last_seen"] = time.time()
125
+ self.nodes[remote_ip]["heartbeat_fail"] = 0
126
+ else:
127
+ self.nodes[remote_ip] = {
128
+ C.LAN_F_HOSTNAME: hostname,
129
+ C.LAN_F_DEVICE_ID: device_id,
130
+ C.LAN_F_MAC: mac,
131
+ "last_seen": time.time(),
132
+ "source": source,
133
+ "heartbeat_fail": 0,
134
+ }
135
+ is_new = True
136
+ return is_new
137
+
138
+ def get_nodes(self):
139
+ with self.lock:
140
+ return dict(self.nodes)
141
+
142
+ # ---------- 广播 ----------
143
+
144
+ def _start_broadcasters(self):
145
+ for ip_str in self.my_ips:
146
+ try:
147
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
148
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
149
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
150
+ sock.bind((ip_str, 0))
151
+ self.broadcast_sockets.append(sock)
152
+ threading.Thread(target=self._udp_broadcaster_on_sock,
153
+ args=(sock, ip_str), daemon=True).start()
154
+ except Exception as e:
155
+ self.log("[警告] 无法为 %s 创建广播 socket: %s" % (ip_str, e))
156
+ try:
157
+ global_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
158
+ global_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
159
+ global_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
160
+ self.broadcast_sockets.append(global_sock)
161
+ threading.Thread(target=self._udp_broadcaster_on_sock,
162
+ args=(global_sock, None), daemon=True).start()
163
+ except Exception as e:
164
+ self.log("[警告] 全局广播 socket 创建失败: %s" % e)
165
+
166
+ def _udp_broadcaster_on_sock(self, sock, bind_ip):
167
+ """周期性广播搜索:前 5 秒每 0.2s,之后每 20s。"""
168
+ start = time.time()
169
+ while self.running:
170
+ if not self.pausing_network:
171
+ try:
172
+ sock.sendto(self._build_msg(**{C.LAN_K_DISCOVERY: True}),
173
+ ("255.255.255.255", self.udp_port))
174
+ except Exception:
175
+ pass
176
+ elapsed = time.time() - start
177
+ if elapsed < 5:
178
+ time.sleep(0.2)
179
+ else:
180
+ time.sleep(self.discover_interval)
181
+
182
+ def broadcast_search(self):
183
+ """广播搜索:向所有广播地址爆发式发送探测包,等待设备回复。"""
184
+ self.log("[广播搜索] 发送广播探测,等待设备回复...")
185
+ msg = self._build_msg(**{C.LAN_K_DISCOVERY: True})
186
+ for _ in range(3):
187
+ for bcast_addr in self.broadcast_addrs:
188
+ try:
189
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
190
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
191
+ s.settimeout(1)
192
+ s.sendto(msg, (bcast_addr, self.udp_port))
193
+ s.close()
194
+ except Exception:
195
+ pass
196
+ time.sleep(0.2)
197
+ self.log("[广播搜索] 完成")
198
+
199
+ # ---------- 接收 ----------
200
+
201
+ def _udp_listener(self):
202
+ sock4 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
203
+ sock4.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
204
+ sock4.bind(("", self.udp_port))
205
+ sock4.settimeout(1.0)
206
+ self.listen_sockets.append(sock4)
207
+ try:
208
+ sock6 = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
209
+ sock6.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
210
+ sock6.bind(("::", self.udp_port))
211
+ sock6.settimeout(1.0)
212
+ self.listen_sockets.append(sock6)
213
+ except Exception:
214
+ sock6 = None
215
+
216
+ def listen_sock(sock, is_ipv6=False):
217
+ while self.running:
218
+ try:
219
+ data, addr = sock.recvfrom(1024)
220
+ msg = json.loads(data.decode("utf-8"))
221
+ remote_ip = addr[0]
222
+ if remote_ip in self.my_ips or remote_ip in self.my_ips_v6:
223
+ continue
224
+ if remote_ip in ("127.0.0.1", "::1", "0.0.0.0"):
225
+ continue
226
+ if msg.get(C.LAN_F_DEVICE_ID) and msg.get(C.LAN_F_DEVICE_ID) == self.device_id:
227
+ continue
228
+ if msg.get(C.LAN_K_BYE):
229
+ with self.lock:
230
+ removed = remote_ip in self.nodes
231
+ self.nodes.pop(remote_ip, None)
232
+ if removed and self.on_node_gone:
233
+ try:
234
+ self.on_node_gone(remote_ip)
235
+ except Exception:
236
+ pass
237
+ continue
238
+ if msg.get(C.LAN_K_HEARTBEAT):
239
+ with self.lock:
240
+ if remote_ip in self.nodes:
241
+ self.nodes[remote_ip]["last_seen"] = time.time()
242
+ reply = self._build_msg(**{C.LAN_K_HEARTBEAT: True, C.LAN_K_ACK: True})
243
+ sock.sendto(reply, (remote_ip, self.udp_port))
244
+ continue
245
+ is_new = self._upsert_node(remote_ip, msg, source="udp")
246
+ if is_new:
247
+ hn = msg.get(C.LAN_F_HOSTNAME, remote_ip)
248
+ self.log("[发现] 新设备 %s (%s)" % (hn, remote_ip))
249
+ if self.on_new_node:
250
+ try:
251
+ self.on_new_node(remote_ip, msg)
252
+ except Exception:
253
+ pass
254
+ if not msg.get(C.LAN_K_REPLY):
255
+ sock.sendto(self._build_msg(**{C.LAN_K_REPLY: True}),
256
+ (remote_ip, self.udp_port))
257
+ except socket.timeout:
258
+ continue
259
+ except OSError:
260
+ break
261
+ except Exception:
262
+ pass
263
+ try:
264
+ sock.close()
265
+ except Exception:
266
+ pass
267
+
268
+ threading.Thread(target=listen_sock, args=(sock4, False), daemon=True).start()
269
+ if sock6:
270
+ threading.Thread(target=listen_sock, args=(sock6, True), daemon=True).start()
271
+
272
+ # ---------- 扫描 ----------
273
+
274
+ def _scan_listener(self):
275
+ """扫描端口监听:收到扫描探测则回 scan_reply。"""
276
+ try:
277
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
278
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
279
+ sock.bind(("", self.scan_port))
280
+ sock.settimeout(1.0)
281
+ self.listen_sockets.append(sock)
282
+ except Exception as e:
283
+ self.log("[扫描] 监听失败: %s" % e)
284
+ return
285
+ while self.running:
286
+ try:
287
+ data, addr = sock.recvfrom(1024)
288
+ msg = json.loads(data.decode("utf-8"))
289
+ remote_ip = addr[0]
290
+ if msg.get(C.LAN_F_DEVICE_ID) and msg.get(C.LAN_F_DEVICE_ID) == self.device_id:
291
+ continue
292
+ if remote_ip in self.my_ips or remote_ip in ("127.0.0.1", "::1"):
293
+ continue
294
+ sock.sendto(self._build_msg(**{C.LAN_K_SCAN_REPLY: True}),
295
+ (remote_ip, self.scan_port))
296
+ is_new = self._upsert_node(remote_ip, msg, source="scan")
297
+ if is_new and self.on_new_node:
298
+ try:
299
+ self.on_new_node(remote_ip, msg)
300
+ except Exception:
301
+ pass
302
+ except socket.timeout:
303
+ continue
304
+ except OSError:
305
+ break
306
+ except Exception:
307
+ pass
308
+ try:
309
+ sock.close()
310
+ except Exception:
311
+ pass
312
+
313
+ def scan_subnet(self, network_cidr, callback=None):
314
+ """扫描一个子网(IPv4),发现的对端通过 on_new_node 回调。"""
315
+ import ipaddress
316
+ try:
317
+ net = ipaddress.IPv4Network(network_cidr, strict=False)
318
+ except Exception:
319
+ return
320
+ self.scanning = True
321
+ msg = self._build_msg(**{C.LAN_K_SCAN_REPLY: True})
322
+ try:
323
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
324
+ sock.settimeout(0.3)
325
+ for host in net.hosts():
326
+ if not self.running:
327
+ break
328
+ try:
329
+ sock.sendto(msg, (str(host), self.scan_port))
330
+ except Exception:
331
+ pass
332
+ except Exception as e:
333
+ self.log("[扫描] 子网 %s 扫描异常: %s" % (network_cidr, e))
334
+ finally:
335
+ try:
336
+ sock.close()
337
+ except Exception:
338
+ pass
339
+ self.scanning = False
340
+
341
+ def add_manual_node(self, ip, hostname=None):
342
+ """手动添加节点,并主动发探测让对方也发现我们。"""
343
+ with self.lock:
344
+ self.nodes[ip] = {
345
+ "hostname": hostname or ip,
346
+ "last_seen": time.time(),
347
+ "source": "manual",
348
+ "heartbeat_fail": 0,
349
+ }
350
+ self._send_probe_to(ip)
351
+ self.log("[手动添加] 已向 %s 发送探测" % ip)
352
+
353
+ def _send_probe_to(self, ip):
354
+ try:
355
+ family = socket.AF_INET6 if ":" in ip else socket.AF_INET
356
+ s = socket.socket(family, socket.SOCK_DGRAM)
357
+ s.settimeout(2)
358
+ s.sendto(self._build_msg(), (ip, self.udp_port))
359
+ try:
360
+ s.sendto(self._build_msg(**{C.LAN_K_SCAN_REPLY: True}), (ip, self.scan_port))
361
+ except Exception:
362
+ pass
363
+ s.close()
364
+ except Exception as e:
365
+ self.log("[手动添加] 向 %s 发送探测失败: %s" % (ip, e))
366
+
367
+ # ---------- 下线 ----------
368
+
369
+ def _send_bye(self):
370
+ try:
371
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
372
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
373
+ s.sendto(self._build_msg(**{C.LAN_K_BYE: True}), ("255.255.255.255", self.udp_port))
374
+ s.close()
375
+ except Exception:
376
+ pass