Files
workphone-sdk/sdk/app/services/discovery_service.py

257 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
工作手机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()