257 lines
9.4 KiB
Python
257 lines
9.4 KiB
Python
"""
|
||
工作手机SDK v3.0 - 设备自动发现服务
|
||
支持 UDP 广播 beacon + 局域网扫描 + 外网回退
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import socket
|
||
import logging
|
||
import time
|
||
import struct
|
||
from typing import List, Dict, Optional, Any
|
||
from datetime import datetime
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
BEACON_PORT = 8898
|
||
BEACON_MAGIC = b"WORKPHONE_SDK"
|
||
SCAN_PORTS = [8899, 8080, 8443, 3000, 9000]
|
||
HEALTH_PATH = "/health"
|
||
|
||
|
||
def _get_local_ips() -> List[str]:
|
||
"""获取本机所有局域网 IP"""
|
||
ips = []
|
||
try:
|
||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||
ip = info[4][0]
|
||
if ip.startswith(("192.168.", "10.", "172.16.", "172.17.",
|
||
"172.18.", "172.19.", "172.2", "172.30.", "172.31.")):
|
||
ips.append(ip)
|
||
except Exception:
|
||
pass
|
||
if not ips:
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
s.connect(("8.8.8.8", 80))
|
||
ips.append(s.getsockname()[0])
|
||
s.close()
|
||
except Exception:
|
||
ips.append("127.0.0.1")
|
||
return list(set(ips))
|
||
|
||
|
||
def _extract_subnets(ips: List[str]) -> List[str]:
|
||
"""从 IP 列表提取子网前缀"""
|
||
subnets = set()
|
||
for ip in ips:
|
||
parts = ip.rsplit(".", 1)
|
||
if len(parts) == 2:
|
||
subnets.add(parts[0])
|
||
return list(subnets)
|
||
|
||
|
||
class DiscoveryService:
|
||
"""局域网设备发现与服务广播"""
|
||
|
||
def __init__(self):
|
||
self.server_port: int = 8899
|
||
self.beacon_running: bool = False
|
||
self._beacon_task: Optional[asyncio.Task] = None
|
||
self._scan_results: List[Dict[str, Any]] = []
|
||
self._scan_time: float = 0
|
||
self._discovered_servers: List[Dict[str, Any]] = []
|
||
|
||
async def start_beacon(self, port: int = 8899):
|
||
"""启动 UDP 广播 beacon,让设备端能自动发现服务器"""
|
||
self.server_port = port
|
||
if self.beacon_running:
|
||
return
|
||
self.beacon_running = True
|
||
self._beacon_task = asyncio.create_task(self._beacon_loop())
|
||
logger.info(f"📡 UDP beacon 已启动 (port {BEACON_PORT})")
|
||
|
||
async def stop_beacon(self):
|
||
"""停止 beacon"""
|
||
self.beacon_running = False
|
||
if self._beacon_task:
|
||
self._beacon_task.cancel()
|
||
try:
|
||
await self._beacon_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
logger.info("📡 UDP beacon 已停止")
|
||
|
||
async def _beacon_loop(self):
|
||
"""每 5 秒广播一次服务器信息"""
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||
sock.setblocking(False)
|
||
|
||
local_ips = _get_local_ips()
|
||
beacon_data = {
|
||
"magic": BEACON_MAGIC.decode(),
|
||
"version": "3.0.0",
|
||
"port": self.server_port,
|
||
"ws_path": "/ws/device",
|
||
"ips": local_ips,
|
||
"hostname": socket.gethostname(),
|
||
}
|
||
|
||
while self.beacon_running:
|
||
try:
|
||
beacon_data["ts"] = int(time.time())
|
||
beacon_data["ips"] = _get_local_ips()
|
||
payload = json.dumps(beacon_data).encode()
|
||
loop = asyncio.get_running_loop()
|
||
await loop.run_in_executor(
|
||
None,
|
||
lambda: sock.sendto(payload, ("255.255.255.255", BEACON_PORT))
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"Beacon 发送失败: {e}")
|
||
await asyncio.sleep(5)
|
||
|
||
sock.close()
|
||
|
||
async def scan_lan(self, timeout: float = 3.0) -> List[Dict[str, Any]]:
|
||
"""扫描局域网中的工作手机 SDK 服务器(优化版:先 TCP 探活再 HTTP 验证)"""
|
||
import httpx
|
||
|
||
local_ips = _get_local_ips()
|
||
subnets = _extract_subnets(local_ips)
|
||
if not subnets:
|
||
subnets = ["192.168.1"]
|
||
|
||
results: List[Dict[str, Any]] = []
|
||
my_ips_set = set(local_ips)
|
||
|
||
async def _tcp_check(ip: str, port: int) -> bool:
|
||
"""快速 TCP 连接检测(0.5s 超时)"""
|
||
try:
|
||
_, writer = await asyncio.wait_for(
|
||
asyncio.open_connection(ip, port), timeout=0.5
|
||
)
|
||
writer.close()
|
||
await writer.wait_closed()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
async def _http_verify(ip: str, port: int):
|
||
"""HTTP 验证是否为 SDK 服务器"""
|
||
try:
|
||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||
r = await client.get(f"http://{ip}:{port}{HEALTH_PATH}")
|
||
if r.status_code == 200:
|
||
data = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
||
results.append({
|
||
"ip": ip,
|
||
"port": port,
|
||
"url": f"http://{ip}:{port}",
|
||
"ws_url": f"ws://{ip}:{port}/ws/device",
|
||
"is_self": ip in my_ips_set and port == self.server_port,
|
||
"health": data,
|
||
"discovered_at": datetime.now().isoformat(),
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
# 第一轮:只扫描常用端口 8899,用 TCP 快速探活
|
||
alive_hosts: list = []
|
||
primary_port = 8899
|
||
|
||
async def _quick_probe(ip: str):
|
||
if await _tcp_check(ip, primary_port):
|
||
alive_hosts.append(ip)
|
||
|
||
for subnet in subnets:
|
||
tasks = [_quick_probe(f"{subnet}.{host}") for host in range(1, 255)]
|
||
batch_size = 100
|
||
for i in range(0, len(tasks), batch_size):
|
||
await asyncio.gather(*tasks[i:i + batch_size], return_exceptions=True)
|
||
|
||
# 第二轮:对存活主机做 HTTP 验证
|
||
verify_tasks = []
|
||
for ip in alive_hosts:
|
||
for port in SCAN_PORTS:
|
||
verify_tasks.append(_http_verify(ip, port))
|
||
if verify_tasks:
|
||
await asyncio.gather(*verify_tasks, return_exceptions=True)
|
||
|
||
self._scan_results = results
|
||
self._scan_time = time.time()
|
||
logger.info(f"LAN 扫描完成: {len(alive_hosts)} 台存活, {len(results)} 个 SDK 服务")
|
||
return results
|
||
|
||
async def listen_beacon(self, timeout: float = 10.0) -> List[Dict[str, Any]]:
|
||
"""监听 UDP beacon 来发现服务器"""
|
||
servers: Dict[str, Dict[str, Any]] = {}
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
try:
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||
except AttributeError:
|
||
pass
|
||
sock.bind(("0.0.0.0", BEACON_PORT))
|
||
sock.setblocking(False)
|
||
|
||
loop = asyncio.get_running_loop()
|
||
end_time = time.time() + timeout
|
||
|
||
while time.time() < end_time:
|
||
try:
|
||
data, addr = await asyncio.wait_for(
|
||
loop.run_in_executor(None, lambda: sock.recvfrom(4096)),
|
||
timeout=min(2.0, end_time - time.time())
|
||
)
|
||
try:
|
||
info = json.loads(data.decode())
|
||
if info.get("magic") == BEACON_MAGIC.decode():
|
||
key = f"{addr[0]}:{info.get('port', 8899)}"
|
||
servers[key] = {
|
||
"ip": addr[0],
|
||
"port": info.get("port", 8899),
|
||
"url": f"http://{addr[0]}:{info.get('port', 8899)}",
|
||
"ws_url": f"ws://{addr[0]}:{info.get('port', 8899)}{info.get('ws_path', '/ws/device')}",
|
||
"hostname": info.get("hostname", ""),
|
||
"version": info.get("version", ""),
|
||
"server_ips": info.get("ips", []),
|
||
"discovered_at": datetime.now().isoformat(),
|
||
"method": "beacon",
|
||
}
|
||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||
pass
|
||
except (asyncio.TimeoutError, BlockingIOError):
|
||
continue
|
||
|
||
sock.close()
|
||
self._discovered_servers = list(servers.values())
|
||
return self._discovered_servers
|
||
|
||
def get_server_info(self) -> Dict[str, Any]:
|
||
"""返回本机服务器信息(供发现 API 使用)"""
|
||
local_ips = _get_local_ips()
|
||
return {
|
||
"version": "3.0.0",
|
||
"port": self.server_port,
|
||
"hostname": socket.gethostname(),
|
||
"ips": local_ips,
|
||
"ws_url": f"ws://{local_ips[0] if local_ips else 'localhost'}:{self.server_port}/ws/device",
|
||
"api_url": f"http://{local_ips[0] if local_ips else 'localhost'}:{self.server_port}",
|
||
"beacon_port": BEACON_PORT,
|
||
"beacon_running": self.beacon_running,
|
||
}
|
||
|
||
def get_last_scan(self) -> Dict[str, Any]:
|
||
"""获取上次扫描结果"""
|
||
return {
|
||
"results": self._scan_results,
|
||
"scan_time": self._scan_time,
|
||
"age_seconds": time.time() - self._scan_time if self._scan_time else -1,
|
||
}
|
||
|
||
|
||
discovery_service = DiscoveryService()
|