video_monitor/app/utils/GB28181SipServer.py
2026-09-04 18:16:14 +08:00

3218 lines
144 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.

# -*- coding: utf-8 -*-
"""
GB28181 服务器 - Python版本
支持多个摄像头设备注册和被动接收推流
"""
import socket
import uuid
import time
import hashlib
import json
import random
import threading
import os
import re
import requests
from datetime import datetime
from typing import Dict, Optional
from queue import Queue, Empty
from concurrent.futures import ThreadPoolExecutor
import xml.etree.ElementTree as ET
import traceback
from app.security import build_internal_auth_headers
def _safe_xml_parse(body):
"""安全解析XML兼容带encoding声明的Unicode字符串GB28181设备可能用GB2312编码声明
ET.fromstring() 对含 encoding 声明的 Unicode 字符串会报错:
ValueError: Unicode strings with encoding declaration are not supported.
此函数先移除 encoding 声明再解析。
"""
# 移除XML声明中的encoding属性如 encoding="GB2312"、encoding="UTF-8" 等)
cleaned = re.sub(r'encoding\s*=\s*["\'][^"\']*["\']', '', body, count=1, flags=re.IGNORECASE)
return ET.fromstring(cleaned)
class Device:
"""GB28181设备类"""
def __init__(self, device_id, ip=None, port=None):
self.device_id = device_id
self.ip = ip
self.port = port
self.registered = False
self.register_time = None
self.channels = []
self.call_id = None
self.from_tag = None
self.to_tag = None
self.last_register_time = 0 # 毫秒时间戳
self.last_keepalive_time = 0 # 毫秒时间戳(设备级心跳时间,即使无通道也能保活)
self.name = "" # 设备名称通过DeviceInfo查询获取
self.manufacturer = "" # 厂商
self.model = "" # 型号
self.firmware_version = "" # 固件版本
class Channel:
"""GB28181通道类支持多级目录结构"""
def __init__(self, channel_id, name="", device=None,logger=None):
self.channel_id = channel_id
self.name = name
self.device = device # 关联的Device对象
self.device_id = device.device_id if device else None
self.logger = logger
self.status = "OFF" # ON/OFF
self.stream_url = ""
# 多级目录支持
self.parental = 0 # 0:叶子通道, 1:目录节点
self.parent_id = "" # 父节点ID
self.device_type = "" # 设备类型IPC/DVR/NVR等
self.safety_way = 0 # 安全方式
self.register_way = 0 # 注册方式
self.secrecy = 0 # 保密属性
self.children = [] # 子通道列表
# Catalog信息
self.sum_num = 0 # 通道总数
self.manufacturer = "" # 厂商
self.model = "" # 型号
self.owner = "" # 所有者
self.civil_code = "" # 行政区划
# INVITE会话状态
self.rtp_port = 0 # ZLM实际分配的RTP端口用于SDP和BYE
self.allocated_rtp_port = 0 # 从端口管理器分配的原始端口(用于释放)
self.call_id = "" # INVITE会话的Call-ID
self.from_tag = "" # INVITE会话的From tag服务器端生成
self.to_tag = "" # INVITE 200 OK响应中的To tag设备端返回
self.dialog_id = "" # dialog标识
self.streaming = False # 是否正在推流
self.inviting = False # 是否正在INVITE中防止重复INVITE
self.sn = 0 # 序列号用于PTZ等请求
# 同步状态
self.forward_state = 0 # 0:未转发 1:转发中
self.last_keepalive_time = 0 # 毫秒时间戳
self.last_register_time = 0 # 毫秒时间戳
def update_admin(self, server):
"""同步通道信息到rebekah_admin数据库与C++版本逻辑一致)"""
admin_host = server.admin_host
if not admin_host:
return False, "admin_host not configured"
url = f"{admin_host}/inner/on_media_update_stream"
# Bug 9修复从server获取最新设备信息
with server.lock:
device = server.devices.get(self.device_id)
if device:
device_ip = device.ip
device_port = device.port
client_id = device.device_id
else:
device_ip = ""
device_port = 0
client_id = ""
# 构建请求参数与C++版本完全一致)
params = {
"forwardState": self.forward_state,
"app": "rtp", # GB28181固定使用rtp
"name": self.channel_id, # 流name=channelId
"ip": device_ip,
"port": device_port,
"clientId": client_id or "",
"parentID": self.parent_id or "",
"rtpServerPort": self.rtp_port,
"rtpPort": 0,
"pullStreamType": 21, # 21=GB28181
"pullStreamUrl": "rtp://__", # GB28181占位字段
"cameraSumNum": self.sum_num,
"cameraName": self.name or "",
"cameraManufacturer": self.manufacturer or "unknown",
"cameraModel": self.model or "",
"cameraOwner": self.owner or "",
"cameraCivilCode": self.civil_code or "",
"lastKeepaliveTime": self.last_keepalive_time,
"lastRegisterTime": self.last_register_time,
"rtpTransferMode": server.rtp_transfer_mode, # 使用服务器配置
"rtpTransferAudioType": server.rtp_transfer_audio_type # 使用服务器配置
}
# 发送POST请求
body = json.dumps(params, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
headers = {"Content-Type": "application/json; charset=utf-8"}
headers.update(build_internal_auth_headers("POST", "/inner/on_media_update_stream", body))
# 重试机制admin服务启动可能晚于SIP服务连接拒绝时重试
max_retries = 3
retry_interval = 1.0
for attempt in range(1, max_retries + 1):
try:
response = requests.post(url, data=body, headers=headers, timeout=5)
if response.status_code == 200:
result = response.json()
if result.get('code') == 1000:
return True, "success"
else:
self.logger.debug(f"[GSS] [通道同步失败] {self.channel_id}: {result.get('msg')}")
return False, result.get('msg', 'unknown error')
else:
self.logger.debug(f"[GSS] [通道同步HTTP错误] {self.channel_id}: HTTP {response.status_code}")
return False, f"HTTP {response.status_code}"
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
# 连接错误/超时admin服务可能未就绪重试
if attempt < max_retries:
self.logger.debug(f"[GSS] [通道同步重试 {attempt}/{max_retries}] {self.channel_id}: {str(e)[:100]}")
time.sleep(retry_interval)
else:
self.logger.debug(f"[GSS] [通道同步异常] {self.channel_id}: 重试{max_retries}次后仍失败: {str(e)[:100]}")
return False, str(e)
except Exception as e:
self.logger.debug(f"[GSS] [通道同步异常] {self.channel_id}: {str(e)}")
return False, str(e)
return False, "max retries exceeded"
class RTPPortManager:
"""RTP端口管理器与C++版本逻辑一致)"""
def __init__(self, min_port=20002, max_port=30000):
self.min_port = min_port
self.max_port = max_port
self.current_port = min_port
self.lock = threading.Lock()
self.allocated_ports = {} # {port: channel_id}
def allocate(self, channel_id):
"""
分配RTP端口
Args:
channel_id: 通道ID
Returns:
int: 分配的端口号0表示失败
"""
with self.lock:
# 循环查找可用端口
for _ in range((self.max_port - self.min_port) // 2):
if self.current_port >= self.max_port:
self.current_port = self.min_port
port = self.current_port
self.current_port += 2 # RTP使用偶数端口
# 检查端口是否可用
if port not in self.allocated_ports:
if self._is_port_available(port):
self.allocated_ports[port] = channel_id
return port
return 0 # 无可用端口
def release(self, port):
"""释放RTP端口"""
with self.lock:
if port in self.allocated_ports:
del self.allocated_ports[port]
def _is_port_available(self, port):
"""检查端口是否可用"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', port))
sock.close()
return True
except OSError:
return False
class GB28181SipServer:
"""GB28181 SIP服务器"""
def __init__(self, server_ip, server_port, server_id, realm, password,
sip_server_timeout=120, sip_server_expiry=60,
sip_transfer_mode=0, rtp_transfer_mode=0, rtp_transfer_audio_type=0,
auto_invite_after_rec_cate_log=True,
admin_host=None, zlm=None, logger=None
):
"""
初始化GB28181服务器
Args:
server_ip: SIP服务器IP公网IP用于SIP消息对外暴露
server_port: SIP服务器端口
server_id: 服务器ID
realm: SIP域
password: 注册密码
sip_server_timeout: SIP服务器超时时间
sip_server_expiry: SIP注册过期时间
sip_transfer_mode: SIP信令传输模式0=UDP, 1=TCP
rtp_transfer_mode: RTP媒体流传输模式0=UDP被动, 1=TCP被动
rtp_transfer_audio_type: RTP音频传输类型0=静音, 1=原始音频)
auto_invite_after_rec_cate_log: 收到CateLog响应后是否主动发起Invite
admin_host: monitor_admin服务地址如 http://127.0.0.1:10001
zlm: ZLMediaKitApi 实例
logger: 日志记录器
"""
self.server_ip = server_ip # 公网IP用于SIP消息对外暴露
self.server_port = server_port
self.server_id = server_id
self.realm = realm
self.password = password
# SIP服务器配置参数
self.sip_server_timeout = sip_server_timeout # 超时时间(秒)
self.sip_server_expiry = sip_server_expiry # 注册过期时间(秒)
self.sip_transfer_mode = sip_transfer_mode # SIP信令传输模式0=UDP, 1=TCP
self.rtp_transfer_mode = rtp_transfer_mode # RTP传输模式0=UDP被动, 1=TCP被动
self.rtp_transfer_audio_type = rtp_transfer_audio_type # RTP音频传输类型
# 本地监听地址公网IP不能bind必须用0.0.0.0
self._listen_ip = "0.0.0.0"
# 自动查询设备目录
self.auto_query_catalog = True
# 收到CateLog回复后是否自动对所有叶子节点发起Invite请求
self.auto_invite_after_rec_cate_log = auto_invite_after_rec_cate_log
self.admin_host = admin_host # rebekah_admin服务地址
self.zlm = zlm # ZLMediaKitApi 实例
self.logger = logger
self.logger.debug("GB28181SipServer.__init__()")
self.sock = None
self.running = False
self.devices: Dict[str, Device] = {}
self.lock = threading.Lock()
# 分进程模式下web进程无实时注册表外发控制指令用的事件序号兜底
self._fallback_sn = 0
# TCP连接池SIP信令TCP模式使用
self.tcp_connections: Dict[tuple, socket.socket] = {} # {(ip, port): client_socket}
self.tcp_lock = threading.Lock() # 保护tcp_connections
# 消息队列(异步处理)
self.message_queue = Queue(maxsize=10000) # 最多缓存10000条消息
# Catalog查询防抖防止短时间内重复查询
self.last_catalog_query_time: Dict[str, float] = {} # {device_id: timestamp}
self.catalog_query_debounce_seconds = 10 # 10秒内不重复查询
# 跟踪进行中的INVITE防止catalog刷新丢失状态
self.pending_invites: Dict[str, dict] = {} # {channel_id: {rtp_port, call_id, device_id}}
self.pending_invites_lock = threading.Lock()
# Catalog响应处理锁per-device串行化防止并发Catalog响应导致重复update_admin
self.catalog_locks: Dict[str, threading.Lock] = {}
self.catalog_locks_guard = threading.Lock()
# 线程池(控制并发数)
self.worker_pool = ThreadPoolExecutor(
max_workers=50, # 最大50个工作线程
thread_name_prefix='GB28181-Worker'
)
# Catalog查询限流
self.catalog_query_semaphore = threading.Semaphore(10) # 最多10个并发查询
# RTP端口管理器
self.rtp_port_mgr = RTPPortManager()
# 统计信息
self.stats = {
'total_registers': 0,
'total_invites': 0,
'total_byes': 0,
'total_messages': 0,
'message_types': {
'Keepalive': 0, # 心跳消息
'Catalog': 0, # 设备目录
'DeviceInfo': 0, # 设备信息
'Other': 0 # 其他消息
}
}
def _send_sip_response(self, response_msg, addr):
"""智能发送SIP响应自动选择UDP或TCP"""
success = self._send_data(response_msg, addr)
if success:
self.log_sip_packet('TX', response_msg, addr)
return success
def _send_data(self, data, addr):
"""
发送SIP数据根据传输模式自动选择UDP或TCP
Args:
data: 要发送的字符串数据
addr: 目标地址 (ip, port)
"""
encoded_data = data.encode('utf-8')
if self.sip_transfer_mode == 1:
# TCP模式从连接池查找对应的socket
with self.tcp_lock:
client_sock = self.tcp_connections.get(addr)
if client_sock:
try:
client_sock.sendall(encoded_data)
return True
except Exception as e:
self.logger.error(f"[GSS] TCP发送失败 {addr}: {e}")
# 移除失效连接
with self.tcp_lock:
self.tcp_connections.pop(addr, None)
return False
else:
self.logger.error(f"[GSS] TCP连接不存在 {addr},无法发送数据")
return False
else:
# UDP模式直接sendto
try:
if self.sock is None:
# 分进程模式本进程未绑定SIP端口如web进程
# 为外发设备控制指令(MESSAGE)临时创建UDP socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as tmp_sock:
tmp_sock.sendto(encoded_data, addr)
else:
self.sock.sendto(encoded_data, addr)
return True
except Exception as e:
self.logger.error(f"[GSS] UDP发送失败 {addr}: {e}")
return False
def _get_sip_transport(self):
"""获取SIP传输协议UDP或TCP"""
return "TCP" if self.sip_transfer_mode == 1 else "UDP"
def log_sip_packet(self, direction, data, addr=None):
"""
记录SIP数据报完整内容用于诊断设备接入问题
Args:
direction: 'RX' (接收) 或 'TX' (发送)
data: SIP消息原始数据
addr: 地址元组 (ip, port)
"""
addr_str = f"{addr[0]}:{addr[1]}" if addr else "?"
lines = data.split('\r\n')
first_line = lines[0] if lines else ''
# 记录完整SIP消息DEBUG级别
self.logger.debug(f"[GSS] [{direction}] {addr_str} | {first_line}")
# 记录完整消息体含SDP/XML
self.logger.debug(f"[GSS] [{direction}] 完整消息:\n{data}")
def start(self):
"""启动服务器"""
self.logger.debug("[GSS] start...")
# 根据配置创建socket支持UDP或TCP
if self.sip_transfer_mode == 1:
# TCP模式
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind((self._listen_ip, self.server_port))
self.sock.listen(5) # 开始监听,等待连接
self.logger.debug("[GSS] SIP服务器已启动: TCP {listen}:{external}".format(
listen="{ip}:{port}".format(ip=self._listen_ip, port=self.server_port),
external="{ip}:{port}".format(ip=self.server_ip, port=self.server_port)))
else:
# UDP模式默认
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind((self._listen_ip, self.server_port))
self.sock.settimeout(1.0)
self.logger.debug("[GSS] SIP服务器已启动: UDP {listen}:{external}".format(
listen="{ip}:{port}".format(ip=self._listen_ip, port=self.server_port),
external="{ip}:{port}".format(ip=self.server_ip, port=self.server_port)))
self.running = True
# 启动接收线程TCP和UDP使用不同的处理方式
if self.sip_transfer_mode == 1:
# TCP模式需要accept连接
receive_thread = threading.Thread(target=self._tcp_receive_loop, daemon=True)
else:
# UDP模式直接接收数据报
receive_thread = threading.Thread(target=self._receive_loop, daemon=True)
receive_thread.start()
# 启动消息处理工作线程池
for i in range(3): # 3个工作线程处理队列
worker = threading.Thread(target=self._process_message_queue, daemon=True, name=f'MsgWorker-{i}')
worker.start()
# 启动设备状态检查线程
check_thread = threading.Thread(target=self._check_devices_loop, daemon=True)
check_thread.start()
return receive_thread, check_thread
def stop(self):
"""停止服务器"""
self.logger.debug("[GSS] stop...")
self.running = False
# 【关键修复】停止所有通道的推流并清理资源
active_stream_count = 0
with self.lock:
for device in self.devices.values():
for channel in device.channels:
if channel.streaming or channel.forward_state == 1:
channel_id = channel.channel_id
rtp_port = channel.allocated_rtp_port
# 关闭ZLM RTP服务器
try:
self._close_rtp_server(channel_id)
except Exception as e:
self.logger.error(f"[GSS] ✗ Failed to close RTP server for {channel_id}: {e}")
# 释放RTP端口
if rtp_port > 0:
try:
self.rtp_port_mgr.release(rtp_port)
except Exception as e:
self.logger.error(f"[GSS] ✗ Failed to release port {rtp_port}: {e}")
active_stream_count += 1
if active_stream_count > 0:
self.logger.debug(f"[GSS] 🛑 Stopped {active_stream_count} active streams")
# 清理pending invites
with self.pending_invites_lock:
pending_count = len(self.pending_invites)
for channel_id, pending in list(self.pending_invites.items()):
try:
# 释放RTP端口
self.rtp_port_mgr.release(pending['rtp_port'])
# 关闭ZLM RTP服务器
self._close_rtp_server(channel_id)
except Exception as e:
self.logger.error(f"[GSS] ✗ Failed to clean pending invite {channel_id}: {e}")
self.pending_invites.clear()
if pending_count > 0:
self.logger.debug(f"[GSS] 🧹 Cleaned {pending_count} pending invites")
# 清空消息队列
if hasattr(self, 'message_queue'):
while not self.message_queue.empty():
try:
self.message_queue.get_nowait()
except:
break
self.logger.debug("[GSS] ✓ 消息队列已清空")
# 关闭线程池(等待正在执行的任务完成)
if hasattr(self, 'worker_pool'):
self.worker_pool.shutdown(wait=True)
self.logger.debug("[GSS] ✓ 线程池已关闭")
if self.sock:
self.sock.close()
self.logger.debug("[GSS] stop finish")
def _receive_loop(self):
"""UDP接收消息循环异步入队"""
while self.running:
try:
data, addr = self.sock.recvfrom(65535)
# 修复优先UTF-8解码失败则使用GBK兼容GB2312编码的小众GB28181设备避免中文乱码
try:
message = data.decode('utf-8')
except UnicodeDecodeError:
message = data.decode('gbk', errors='replace')
self.log_sip_packet('RX', message, addr)
# 快速入队,不阻塞
try:
self.message_queue.put_nowait((message, addr))
except:
self.logger.debug("[GSS] 消息队列已满,丢弃消息")
except socket.timeout:
continue
except Exception as e:
if self.running:
self.logger.error(f"[GSS] 接收消息异常: {e}")
def _process_message_queue(self):
"""消息处理工作线程(从队列取消息处理)"""
while self.running:
try:
message, addr = self.message_queue.get(timeout=1)
# 提交到线程池处理
self.worker_pool.submit(self._handle_message, message, addr)
except Empty:
continue
except Exception as e:
if self.running:
self.logger.error(f"[GSS] 处理消息队列异常: {e}")
def _tcp_receive_loop(self):
"""TCP接收消息循环支持多连接"""
while self.running:
try:
# 接受新连接
client_sock, addr = self.sock.accept()
self.logger.debug(f"[GSS] TCP新连接: {addr[0]}:{addr[1]}")
# 为每个连接创建处理线程
client_thread = threading.Thread(target=self._handle_tcp_client, args=(client_sock, addr), daemon=True)
client_thread.start()
except socket.timeout:
continue
except Exception as e:
if self.running:
self.logger.error(f"[GSS] TCP接受连接异常: {e}")
def _handle_tcp_client(self, client_sock, addr):
"""处理单个TCP客户端连接"""
buffer = b''
# 注册TCP连接到连接池
with self.tcp_lock:
self.tcp_connections[addr] = client_sock
try:
while self.running:
data = client_sock.recv(65535)
if not data:
break # 连接关闭
buffer += data
# 查找完整的SIP消息以\r\n\r\n分隔头部和体部
while b'\r\n\r\n' in buffer:
# 提取头部找到Content-Length头部为ASCIIUTF-8解码安全
header_bytes = buffer.split(b'\r\n\r\n', 1)[0]
header_part = header_bytes.decode('utf-8', errors='ignore')
# 解析Content-Length
content_length = 0
for line in header_part.split('\r\n'):
if line.lower().startswith('content-length:'):
try:
content_length = int(line.split(':', 1)[1].strip())
except:
pass
# 计算完整消息长度(使用原始字节长度,避免编码转换误差)
complete_msg_len = len(header_bytes) + 4 + content_length
if len(buffer) >= complete_msg_len:
# 提取完整消息
# 修复优先UTF-8解码失败则使用GBK兼容GB2312编码的小众GB28181设备避免中文乱码
try:
message = buffer[:complete_msg_len].decode('utf-8')
except UnicodeDecodeError:
message = buffer[:complete_msg_len].decode('gbk', errors='replace')
buffer = buffer[complete_msg_len:]
self.log_sip_packet('RX', message, addr)
self._handle_message(message, addr)
else:
break # 等待更多数据
except Exception as e:
if self.running:
self.logger.error(f"[GSS] TCP客户端处理异常: {e}")
finally:
# 从连接池移除
with self.tcp_lock:
self.tcp_connections.pop(addr, None)
self.logger.debug(f"[GSS] TCP连接断开: {addr[0]}:{addr[1]}")
client_sock.close()
def _check_devices_loop(self):
"""检查设备状态循环"""
catalog_check_counter = 0
catalog_check_interval_cycles = 5 # 5分钟检查一次Catalog每次循环60秒5*60=300秒
while self.running:
time.sleep(60) # 每分钟检查一次
self._check_expired_devices()
# 每5分钟自动查询所有设备的Catalog获取最新通道列表
catalog_check_counter += 1
if catalog_check_counter >= catalog_check_interval_cycles:
catalog_check_counter = 0
try:
with self.lock:
device_ids = list(self.devices.keys())
for device_id in device_ids:
self.query_catalog(device_id)
except Exception as e:
self.logger.error(f"[GSS] 定时Catalog查询失败: {e}")
def _handle_message(self, message, addr):
"""
处理接收到的SIP消息
Args:
message: SIP消息字符串
addr: (ip, port) 元组
"""
try:
lines = message.split('\r\n')
if not lines:
return
first_line = lines[0]
# 记录SIP接收日志DEBUG级别便于诊断设备接入问题
self.logger.debug(f"[GSS] [RX] {addr[0]}:{addr[1]} | {first_line}")
# 计数统计
with self.lock:
if first_line.startswith('REGISTER'):
self.stats['total_registers'] += 1
elif first_line.startswith('INVITE'):
self.stats['total_invites'] += 1
elif first_line.startswith('MESSAGE'):
self.stats['total_messages'] += 1
# 解析消息类型
if first_line.startswith('REGISTER'):
self._handle_register(message, addr)
elif first_line.startswith('INVITE'):
self._handle_invite(message, addr)
elif first_line.startswith('MESSAGE'):
self._handle_message_request(message, addr)
elif first_line.startswith('NOTIFY'):
self._handle_notify(message, addr)
elif first_line.startswith('BYE'):
self._handle_bye(message, addr)
elif first_line.startswith('ACK'):
self._handle_ack(message, addr)
else:
# 可能是响应消息
self._handle_response(message, addr)
except Exception as e:
self.logger.error(f"[GSS] _handle_message 异常: {e}", exc_info=True)
self.logger.error(f"[GSS] 消息第一行: {lines[0] if lines else '空消息'}")
def _parse_sip_headers(self, message):
"""解析SIP消息头"""
headers = {}
lines = message.split('\r\n')
# 跳过第一行(请求行或状态行)
for line in lines[1:]:
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
elif line == '':
break
return headers
def _parse_request_line(self, first_line):
"""解析SIP请求行"""
parts = first_line.split(' ')
if len(parts) >= 3:
return {
'method': parts[0],
'uri': parts[1],
'version': parts[2]
}
return {}
def _generate_nonce(self):
"""生成nonce"""
return hashlib.md5(f"{time.time()}:{random.random()}".encode()).hexdigest()
def _calculate_response(self, username, realm, password, nonce, method, uri, qop='', cnonce='', nc=''):
"""
计算Digest认证response支持qop
Args:
username: 用户名
realm: 域
password: 密码
nonce: 服务器生成的随机数
method: SIP方法
uri: 请求URI
qop: 保护质量 (auth 或 auth-int)
cnonce: 客户端生成的随机数
nc: 请求计数器
Returns:
str: 计算出的response
"""
# HA1 = MD5(username:realm:password)
ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()
# HA2 = MD5(method:uri)
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
if qop:
# 带qop的计算方式: response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
response = hashlib.md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()).hexdigest()
else:
# 不带qop的计算方式: response = MD5(HA1:nonce:HA2)
response = hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest()
return response
def _send_debug_raw_message(self, raw_message, addr):
"""发送原始SIP消息用于调试"""
return self._send_sip_response(raw_message, addr)
def _handle_register(self, message, addr):
"""
处理REGISTER请求
Args:
message: SIP消息
addr: 发送方地址
"""
try:
headers = self._parse_sip_headers(message)
# REGISTER处理仅保留关键日志
request_info = self._parse_request_line(message.split('\r\n')[0])
# 提取设备ID (从From头)
from_header = headers.get('From', '')
device_id = ''
if '<sip:' in from_header:
start = from_header.find('<sip:') + 5
end = from_header.find('>', start)
if end > start:
device_id = from_header[start:end].split('@')[0]
if not device_id:
self.logger.error("[GSS] [REGISTER] 无法提取设备ID")
return
# 检查是否有Authorization头
auth_header = headers.get('Authorization', '')
if not auth_header:
self._send_401_unauthorized(message, addr)
else:
if self._verify_auth(auth_header, request_info.get('uri', ''), 'REGISTER', device_id):
# 更新设备信息支持NAT穿透
received_ip = self._extract_received_ip(message)
rport = self._extract_rport(message)
if received_ip and rport > 0:
addr = (received_ip, rport)
# 初始化,防止首次注册(新设备)时未定义
channels_to_bye = []
with self.lock:
if device_id not in self.devices:
self.devices[device_id] = Device(device_id, addr[0], addr[1])
else:
# 设备重新注册只更新IP/端口,不清理推流资源
# 某些小厂设备会频繁重注册每3-10秒如果每次都清理推流会导致
# INVITE推流刚建立就被BYE终止视频流永远无法上线
# 推流资源只在设备真正离线(心跳超时)时才清理
device = self.devices[device_id]
# 更新设备联系地址NAT穿透后地址可能变化
device.ip = addr[0]
device.port = addr[1]
device = self.devices[device_id]
device.registered = True
device.register_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
device.last_register_time = int(time.time() * 1000) # 记录注册时间戳
device.from_tag = headers.get('From', '').split('tag=')[-1] if 'tag=' in headers.get('From', '') else None
device.call_id = headers.get('Call-ID', '')
devices_count = len(self.devices)
# 在锁外发送BYE避免持锁阻塞网络IO
for bye_info in channels_to_bye:
self._send_bye_for_channel(
addr[0], addr[1],
bye_info['channel_id'],
bye_info['call_id'],
bye_info['from_tag'],
bye_info['to_tag']
)
self._send_200_ok(message, addr, device)
self.logger.info(f"[GSS] [REGISTER] 设备 {device_id} 注册成功 (共{devices_count}台)")
# 注册成功后自动查询设备通道列表防抖10秒内不重复查询
if self.auto_query_catalog:
now = time.time()
last_query = self.last_catalog_query_time.get(device_id, 0)
if now - last_query >= self.catalog_query_debounce_seconds:
self.last_catalog_query_time[device_id] = now
self.logger.debug(f"[GSS] [CATALOG] 准备查询设备 {device_id} 的通道列表")
threading.Thread(target=self.query_catalog, args=(device_id,), daemon=True).start()
# 同时发送DeviceInfo查询获取设备名称
threading.Thread(target=self.query_device_info, args=(device_id,), daemon=True).start()
else:
self.logger.debug(f"[GSS] [REGISTER] 跳过Catalog查询距上次查询{int(now - last_query)}秒,防抖{self.catalog_query_debounce_seconds}秒)")
else:
self.logger.info(f"[GSS] [REGISTER] 设备 {device_id} 认证失败")
self._send_401_unauthorized(message, addr)
except Exception as e:
self.logger.error(f"[GSS] _handle_register 异常: {e}", exc_info=True)
# 即使出错也要回复401否则客户端会一直等待
try:
self._send_401_unauthorized(message, addr)
except:
pass
def _handle_invite(self, message, addr):
"""处理INVITE请求推流请求"""
try:
headers = self._parse_sip_headers(message)
# NAT穿透
# 提取设备ID和通道ID
from_header = headers.get('From', '')
to_header = headers.get('To', '')
# NAT穿透优先使用 received+rport
received_ip = self._extract_received_ip(message)
rport = self._extract_rport(message)
if received_ip and rport > 0:
addr = (received_ip, rport)
# 发送100 Trying
self._send_100_trying(message, addr)
# 发送200 OK (被动接收模式)
self._send_200_ok_invite(message, addr, headers)
except Exception as e:
self.logger.error(f"[GSS] _handle_invite 异常: {e}", exc_info=True)
# Bug 8修复发送错误响应
try:
self._send_response(message, addr, 500, "Internal Server Error")
except:
pass
def _handle_message_request(self, message, addr):
"""处理MESSAGE请求通常是心跳或目录查询"""
try:
headers = self._parse_sip_headers(message)
# 提取消息体
body = ''
if '\r\n\r\n' in message:
body = message.split('\r\n\r\n')[1]
# 提取DeviceID
device_id = ''
match = re.search(r'<DeviceID>(.*?)</DeviceID>', body)
if match:
device_id = match.group(1)
# 检查设备是否已注册
with self.lock:
device = self.devices.get(device_id)
is_registered = device and device.registered
# NAT穿透优先使用 received+rport
received_ip = self._extract_received_ip(message)
rport = self._extract_rport(message)
if received_ip and rport > 0:
addr = (received_ip, rport)
# 解析消息类型
if 'Keepalive' in body:
with self.lock:
self.stats['message_types']['Keepalive'] += 1
if not is_registered:
self.logger.debug(f"[GSS] [MESSAGE] 未注册设备 {device_id} 发送心跳,忽略")
return
# 更新设备联系地址NAT穿透
# 优先使用 received+rport否则使用原始 addr
if received_ip and rport > 0:
self._update_device_contact(device_id, received_ip, rport, message)
else:
self._update_device_contact(device_id, addr[0], addr[1], message)
# 更新设备通道的心跳时间
with self.lock:
device = self.devices.get(device_id)
if device:
now_ms = int(time.time() * 1000)
# 更新设备级心跳时间(即使无通道也能保活,防止误判超时)
device.last_keepalive_time = now_ms
# 更新所有通道的心跳时间
for channel in device.channels:
channel.last_keepalive_time = now_ms
self._send_response(message, addr, 200, "OK")
elif 'Catalog' in body:
with self.lock:
self.stats['message_types']['Catalog'] += 1
# 先解析XML获取通道数量和编号信息
channel_count = 0
channel_ids = []
channel_types = []
try:
root = _safe_xml_parse(body)
item_list = root.findall('.//Item')
if item_list:
channel_count = len(item_list)
for item in item_list:
ch_id_elem = item.find('DeviceID')
parental_elem = item.find('Parental')
if ch_id_elem is not None:
ch_id = ch_id_elem.text or ''
parental = int(parental_elem.text) if parental_elem is not None and parental_elem.text else 0
channel_ids.append(ch_id)
channel_types.append('目录' if parental == 1 else '叶子')
except Exception as e:
self.logger.debug(f"[GSS] [MESSAGE] 解析Catalog XML失败: {e}")
# 打印详细日志
if channel_count > 0:
channels_info = ', '.join([f"{ch_id}({ch_type})" for ch_id, ch_type in zip(channel_ids[:5], channel_types[:5])])
if channel_count > 5:
channels_info += f" ... 等{channel_count}"
self.logger.debug(f"[GSS] [MESSAGE] 收到Catalog消息: 设备={device_id}, 通道数={channel_count}, 通道=[{channels_info}]")
else:
self.logger.debug(f"[GSS] [MESSAGE] 收到Catalog消息: 设备={device_id}, 长度={len(body)}")
# 放宽注册检查允许刚注册的设备立即发送Catalog响应某些摄像头行为
if not is_registered:
# 检查设备是否存在(可能正在注册过程中)
with self.lock:
device_exists = device_id in self.devices
if not device_exists:
self.logger.debug(f"[GSS] [MESSAGE] 未知设备 {device_id} 发送目录请求,拒绝")
self._send_response(message, addr, 403, "Forbidden")
return
else:
self.logger.debug(f"[GSS] [MESSAGE] 设备 {device_id} 正在注册中接受Catalog响应")
# NAT穿透更新设备联系地址与Keepalive一致
if received_ip and rport > 0:
self._update_device_contact(device_id, received_ip, rport, message)
else:
self._update_device_contact(device_id, addr[0], addr[1], message)
# 设备主动发送的Catalog响应包含通道列表
if '<Response>' in body or '<Item>' in body:
# 提取响应中的DeviceID可能是子目录ID
resp_device_id_match = re.search(r'<DeviceID>(.*?)</DeviceID>', body)
resp_device_id = resp_device_id_match.group(1) if resp_device_id_match else device_id
self.logger.debug(f"[GSS] [MESSAGE] 收到目录响应: 设备={device_id}, 父ID={resp_device_id if resp_device_id != device_id else ''}")
self.parse_catalog_response(body, device_id, parent_id=resp_device_id if resp_device_id != device_id else "")
else:
self.logger.debug(f"[GSS] [MESSAGE] Catalog消息格式不正确缺少<Response>或<Item>标签")
self.logger.debug(f"[GSS] [MESSAGE] Catalog消息内容: {body[:500]}")
self._send_response(message, addr, 200, "OK")
elif 'DeviceInfo' in body:
with self.lock:
self.stats['message_types']['DeviceInfo'] += 1
if not is_registered:
self.logger.debug(f"[GSS] [MESSAGE] 未注册设备 {device_id} 发送信息查询,拒绝")
self._send_response(message, addr, 403, "Forbidden")
return
# 解析DeviceInfo响应获取设备名称等信息
try:
root = _safe_xml_parse(body)
device_name_elem = root.find('DeviceName')
manufacturer_elem = root.find('Manufacturer')
model_elem = root.find('Model')
firmware_elem = root.find('FirmwareVersion')
device_name = device_name_elem.text if device_name_elem is not None and device_name_elem.text else ''
manufacturer = manufacturer_elem.text if manufacturer_elem is not None and manufacturer_elem.text else ''
model = model_elem.text if model_elem is not None and model_elem.text else ''
firmware = firmware_elem.text if firmware_elem is not None and firmware_elem.text else ''
self.logger.debug(f"[GSS] [DEVICEINFO] 设备 {device_id} 信息: 名称={device_name}, 厂商={manufacturer}, 型号={model}, 固件={firmware}")
# 更新Device对象的名称等信息
channels_to_update = []
with self.lock:
device = self.devices.get(device_id)
if device:
if device_name:
device.name = device_name
if manufacturer:
device.manufacturer = manufacturer
if model:
device.model = model
if firmware:
device.firmware_version = firmware
# 如果设备已有通道(自动创建的),更新通道名称并标记需要重新同步
for ch in device.channels:
if device_name and not ch.name:
old_name = ch.name
ch.name = device_name
if manufacturer:
ch.manufacturer = manufacturer
if model:
ch.model = model
self.logger.debug(f"[GSS] [DEVICEINFO] 通道 {ch.channel_id} 名称更新: '{old_name}''{ch.name}'")
channels_to_update.append(ch)
# 在锁外重新同步通道到admin数据库更新名称
if channels_to_update and self.admin_host:
for ch in channels_to_update:
threading.Thread(
target=ch.update_admin,
args=(self,),
daemon=True
).start()
self.logger.debug(f"[GSS] [DEVICEINFO] 通道 {ch.channel_id} 已重新同步到admin数据库名称更新为'{ch.name}'")
except ET.ParseError as e:
self.logger.debug(f"[GSS] [DEVICEINFO] 解析DeviceInfo响应XML失败: {e}")
self.logger.debug(f"[GSS] [DEVICEINFO] 消息内容: {body[:500]}")
except Exception as e:
self.logger.error(f"[GSS] [DEVICEINFO] 处理DeviceInfo响应异常: {e}", exc_info=True)
self._send_response(message, addr, 200, "OK")
else:
with self.lock:
self.stats['message_types']['Other'] += 1
self._send_response(message, addr, 200, "OK")
except Exception as e:
self.logger.error(f"[GSS] _handle_message_request 异常: {e}", exc_info=True)
def _handle_bye(self, message, addr):
"""处理BYE请求结束推流"""
try:
# 提取Call-ID用于查找通道
call_id = self._get_header(message, 'Call-ID')
# 释放通道资源RTP端口、更新状态、关闭ZLM RTP服务器
if call_id:
self._release_channel_by_callid(call_id)
else:
self.logger.debug(f"[GSS] ⚠️ BYE请求中未找到Call-ID无法释放通道资源")
self._send_200_ok(message, addr, None)
except Exception as e:
self.logger.error(f"[GSS] _handle_bye 异常: {e}", exc_info=True)
# Bug 7修复即使出错也要回复响应
try:
self._send_response(message, addr, 500, "Internal Server Error")
except:
pass
def _handle_ack(self, message, addr):
"""处理ACK请求"""
# ACK日志已精简避免刷屏
def _handle_notify(self, message, addr):
"""
处理NOTIFY请求某些设备通过NOTIFY推送Catalog目录变更
Args:
message: SIP消息
addr: 发送方地址
"""
try:
headers = self._parse_sip_headers(message)
# NAT穿透优先使用 received+rport
received_ip = self._extract_received_ip(message)
rport = self._extract_rport(message)
if received_ip and rport > 0:
addr = (received_ip, rport)
# 提取消息体
body = ''
if '\r\n\r\n' in message:
body = message.split('\r\n\r\n')[1]
# 提取DeviceID
device_id = ''
match = re.search(r'<DeviceID>(.*?)</DeviceID>', body)
if match:
device_id = match.group(1)
# 先回复200 OK防止设备重发
self._send_response(message, addr, 200, "OK")
if not body:
self.logger.debug(f"[GSS] [NOTIFY] 设备 {device_id} 空消息体")
return
self.logger.debug(f"[GSS] [NOTIFY] 收到设备 {device_id} 的NOTIFY通知")
# 处理Catalog通知某些设备通过NOTIFY推送目录变更
if 'Catalog' in body:
if '<Response>' in body or '<Item>' in body:
self.logger.debug(f"[GSS] [NOTIFY] 设备 {device_id} 通过NOTIFY推送Catalog目录")
self.parse_catalog_response(body, device_id, parent_id="")
else:
self.logger.debug(f"[GSS] [NOTIFY] Catalog消息格式不正确缺少<Response>或<Item>标签")
elif 'Keepalive' in body:
# NOTIFY心跳更新设备级心跳时间
with self.lock:
device = self.devices.get(device_id)
if device and device.registered:
now_ms = int(time.time() * 1000)
device.last_keepalive_time = now_ms
for channel in device.channels:
channel.last_keepalive_time = now_ms
else:
# 其他NOTIFY类型如媒体存在通知
self.logger.debug(f"[GSS] [NOTIFY] 设备 {device_id} 未识别的通知类型: {body[:200]}")
except Exception as e:
self.logger.error(f"[GSS] _handle_notify 异常: {e}", exc_info=True)
try:
self._send_response(message, addr, 500, "Internal Server Error")
except:
pass
def _handle_response(self, message, addr):
"""处理响应消息设备对我们的INVITE/BYE的回复"""
lines = message.split('\r\n')
if not lines:
return
first_line = lines[0]
# 解析状态码
parts = first_line.split(' ', 2)
if len(parts) < 2:
return
try:
status_code = int(parts[1])
except ValueError:
return
headers = self._parse_sip_headers(message)
call_id = headers.get('Call-ID', '')
cseq = headers.get('CSeq', '')
# 处理INVITE响应
if 'INVITE' in cseq:
self._handle_invite_response(status_code, message, headers, call_id, addr)
# 处理BYE响应
elif 'BYE' in cseq:
self._handle_bye_response(status_code, message, headers, call_id, addr)
# 处理MESSAGE响应如Catalog查询的200 OK
elif 'MESSAGE' in cseq:
if status_code != 200:
self.logger.debug(f"[GSS] MESSAGE响应状态码非200: {status_code}, addr={addr}")
else:
self.logger.debug(f"[GSS] [RX] MESSAGE 200 OK (Catalog查询确认) addr={addr}")
def _handle_invite_response(self, status_code, message, headers, call_id, addr):
"""
处理设备对INVITE的响应
Args:
status_code: SIP状态码200=成功)
message: 完整SIP消息
headers: 解析后的头字段
call_id: Call-ID
addr: 设备地址
"""
if status_code == 200:
# 提取SDP如果有
if '\r\n\r\n' in message:
sdp = message.split('\r\n\r\n', 1)[1]
if sdp:
self.logger.debug(f"[GSS] 设备SDP:\n{sdp[:200]}")
# 提取To tag并存储到通道用于后续BYE构造
to_header = headers.get('To', '')
to_tag = ''
if 'tag=' in to_header:
to_tag = to_header.split('tag=')[-1].strip()
# 更新通道状态为推流中
ch = self._update_channel_streaming_by_callid(call_id, streaming=True, forward_state=1, inviting=False, to_tag=to_tag)
# 同步 forward_state=1 回 DB前端播放按钮依赖此字段
if ch and self.admin_host:
threading.Thread(target=ch.update_admin, args=(self,), daemon=True).start()
# 发送ACK
self._send_ack(message, addr)
elif status_code == 100:
pass # 正常流程,不需要日志
elif status_code == 180:
pass # 正常流程,不需要日志
elif status_code == 486:
self.logger.debug(f"[GSS] ❌ INVITE 486 Busy Here - 设备忙尝试发送BYE终止旧会话")
# 尝试发送BYE终止设备上的旧会话以便下次INVITE能成功
self._try_send_bye_on_486(call_id, headers, addr)
# 释放端口和资源
self._release_channel_by_callid(call_id)
elif status_code == 488:
self.logger.debug(f"[GSS] ❌ INVITE 488 Not Acceptable Here - 媒体格式不被接受SDP协商失败")
# 释放端口并重置通道状态
self._release_channel_by_callid(call_id)
elif 100 <= status_code < 200:
pass # 1xx临时响应101/181/182/183等等待最终响应不释放资源
else:
self.logger.debug(f"[GSS] ⚠️ INVITE {status_code} - 未知响应")
# 释放端口
self._release_channel_by_callid(call_id)
def _handle_bye_response(self, status_code, message, headers, call_id, addr):
"""
处理设备对BYE的响应
Args:
status_code: SIP状态码
message: 完整SIP消息
headers: 解析后的头字段
call_id: Call-ID
addr: 设备地址
"""
if status_code == 200:
pass # 正常流程,不需要日志
else:
self.logger.debug(f"[GSS] ⚠️ BYE {status_code} - 未知响应")
def _send_ack(self, invite_response, addr):
"""
发送ACK确认对INVITE 200 OK的回复
Args:
invite_response: 收到的INVITE 200 OK响应
addr: 设备地址
"""
headers = self._parse_sip_headers(invite_response)
call_id = headers.get('Call-ID', '')
from_header = headers.get('From', '')
to_header = headers.get('To', '')
via = headers.get('Via', '')
# 提取To tag
to_tag = ''
if 'tag=' in to_header:
to_tag = to_header.split('tag=')[-1].strip()
# 构建ACK
branch = f"z9hG4bK{random.randint(100000000, 999999999)}"
ack_msg = (
f"ACK sip:{addr[0]}:{addr[1]} SIP/2.0\r\n"
f"Via: SIP/2.0/{self._get_sip_transport()} {self.server_ip}:{self.server_port};rport;branch={branch}\r\n"
f"From: {from_header}\r\n"
f"To: {to_header}\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: 1 ACK\r\n"
f"Max-Forwards: 70\r\n"
f"Content-Length: 0\r\n"
f"\r\n"
)
try:
self._send_sip_response(ack_msg, addr)
except Exception as e:
self.logger.error(f"[GSS] 发送ACK失败: {e}")
def _send_bye_for_channel(self, device_ip, device_port, channel_id, call_id, from_tag, to_tag=""):
"""
发送BYE请求终止指定通道的INVITE会话
Args:
device_ip: 设备IP
device_port: 设备SIP端口
channel_id: 通道ID
call_id: INVITE会话的Call-ID
from_tag: INVITE中的From tag服务器端
to_tag: INVITE 200 OK中的To tag设备端可选
"""
if not call_id:
return False
branch = f"z9hG4bK{random.randint(100000000, 999999999)}"
to_header = f"<sip:{channel_id}@{self.realm}>"
if to_tag:
to_header = f"<sip:{channel_id}@{self.realm}>;tag={to_tag}"
bye_msg = (
f"BYE sip:{channel_id}@{device_ip}:{device_port} SIP/2.0\r\n"
f"Via: SIP/2.0/{self._get_sip_transport()} {self.server_ip}:{self.server_port};rport;branch={branch}\r\n"
f"From: <sip:{self.server_id}@{self.realm}>;tag={from_tag}\r\n"
f"To: {to_header}\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: 2 BYE\r\n"
f"Max-Forwards: 70\r\n"
f"Content-Length: 0\r\n"
f"\r\n"
)
try:
addr = (device_ip, device_port)
success = self._send_sip_response(bye_msg, addr)
if success:
self.logger.debug(f"[GSS] 📤 已发送BYE终止旧会话: {channel_id}, call_id={call_id[:16]}...")
return success
except Exception as e:
self.logger.error(f"[GSS] ❌ 发送BYE失败: {channel_id}, error={e}")
return False
def _try_send_bye_on_486(self, call_id, headers, addr):
"""
收到486 Busy Here时尝试发送BYE终止设备上的旧会话
486表示设备认为自己仍在旧会话中需要发送BYE释放旧会话后才能重新INVITE。
此方法会从pending_invites和通道中查找旧会话信息来构造BYE。
Args:
call_id: 当前INVITE的Call-ID被拒绝的新INVITE
headers: 486响应的SIP头
addr: 设备地址 (ip, port)
"""
# 从pending_invites中查找旧会话信息
old_call_id = ""
old_from_tag = ""
old_to_tag = ""
found_channel_id = ""
device_ip = addr[0]
device_port = addr[1]
with self.pending_invites_lock:
for ch_id, pending in list(self.pending_invites.items()):
if pending['call_id'] == call_id:
old_call_id = pending.get('old_call_id', '')
old_from_tag = pending.get('old_from_tag', '')
old_to_tag = pending.get('old_to_tag', '')
found_channel_id = ch_id
break
# 如果pending_invites中没有旧会话信息尝试从通道中查找
if not old_call_id and found_channel_id:
with self.lock:
for device in self.devices.values():
channel = self._find_channel(device.channels, found_channel_id)
if channel:
# 通道的call_id可能已被新INVITE覆盖但from_tag/to_tag可能还在
if channel.from_tag:
old_from_tag = channel.from_tag
if channel.to_tag:
old_to_tag = channel.to_tag
device_ip = device.ip
device_port = device.port
break
if old_call_id:
self.logger.debug(f"[GSS] 📤 486恢复发送BYE终止旧会话: channel={found_channel_id}, old_call_id={old_call_id[:16]}...")
self._send_bye_for_channel(device_ip, device_port, found_channel_id, old_call_id, old_from_tag, old_to_tag)
else:
# 没有旧会话信息尝试发送一个无dialog匹配的BYE部分设备会接受
if found_channel_id:
self.logger.debug(f"[GSS] ⚠️ 486恢复无旧会话dialog信息尝试发送通用BYE: channel={found_channel_id}")
# 使用From头中的tag作为from_tag这是新INVITE的tag但某些设备可能接受
from_header = headers.get('From', '')
fallback_from_tag = ''
if 'tag=' in from_header:
fallback_from_tag = from_header.split('tag=')[-1].strip()
self._send_bye_for_channel(device_ip, device_port, found_channel_id, call_id, fallback_from_tag, "")
def _release_channel_by_callid(self, call_id):
"""
根据Call-ID释放通道资源
Returns:
被释放的 Channel 对象;未找到时返回 None
"""
released_channel = None
# 清理pending invite
channel_id_to_clean = None
with self.pending_invites_lock:
for ch_id, pending in list(self.pending_invites.items()):
if pending['call_id'] == call_id:
channel_id_to_clean = ch_id
break
if channel_id_to_clean:
with self.pending_invites_lock:
pending = self.pending_invites.pop(channel_id_to_clean, None)
if pending:
# 释放RTP端口
self.rtp_port_mgr.release(pending['rtp_port'])
# 关闭ZLM RTP服务器
self._close_rtp_server(channel_id_to_clean)
self.logger.debug(f"[GSS] 🧹 清理pending INVITE: {channel_id_to_clean}")
# 同时重置通道状态防止catalog刷新后丢失call_id导致状态不清理
with self.lock:
device = self.devices.get(pending['device_id'])
if device:
channel = self._find_channel(device.channels, channel_id_to_clean)
if channel:
channel.rtp_port = 0
channel.allocated_rtp_port = 0
channel.call_id = ""
channel.from_tag = ""
channel.to_tag = ""
channel.streaming = False
channel.forward_state = 0
channel.inviting = False
released_channel = channel
self.logger.debug(f"[GSS] ✓ 已重置通道状态: {channel_id_to_clean}")
if released_channel is None:
with self.lock:
for device in self.devices.values():
for channel in device.channels:
if channel.call_id == call_id:
channel_id = channel.channel_id
rtp_port = channel.allocated_rtp_port # 使用分配的端口
# 释放RTP端口
if rtp_port > 0:
self.rtp_port_mgr.release(rtp_port)
# 关闭ZLM RTP服务器
if channel_id:
self._close_rtp_server(channel_id)
# 重置通道状态
channel.rtp_port = 0
channel.allocated_rtp_port = 0
channel.call_id = ""
channel.from_tag = ""
channel.to_tag = ""
channel.streaming = False
channel.forward_state = 0
channel.inviting = False # 清除inviting标志
released_channel = channel
break
if released_channel:
break
# 同步 forward_state=0 回 DB停止推流后前端播放按钮应恢复为未启用态
if released_channel and self.admin_host:
threading.Thread(target=released_channel.update_admin, args=(self,), daemon=True).start()
return released_channel
def _update_channel_streaming_by_callid(self, call_id, streaming=False, forward_state=0, inviting=None, to_tag=None):
"""
根据Call-ID更新通道推流状态
Returns:
更新后的 Channel 对象;未找到时返回 None
"""
with self.lock:
for device in self.devices.values():
for channel in device.channels:
if channel.call_id == call_id:
channel.streaming = streaming
channel.forward_state = forward_state
if inviting is not None:
channel.inviting = inviting
if to_tag is not None:
channel.to_tag = to_tag
return channel
return None
def _send_401_unauthorized(self, request, addr):
"""发送401 Unauthorized响应"""
nonce = self._generate_nonce()
response = (
"SIP/2.0 401 Unauthorized\r\n"
f"Via: {self._get_header(request, 'Via')}\r\n"
f"From: {self._get_header(request, 'From')}\r\n"
f"To: {self._get_header(request, 'To')}\r\n"
f"CSeq: {self._get_header(request, 'CSeq')}\r\n"
f"Call-ID: {self._get_header(request, 'Call-ID')}\r\n"
f'WWW-Authenticate: Digest realm="{self.realm}", '
f'nonce="{nonce}", '
f'algorithm=MD5, '
f'qop="auth"\r\n'
"Content-Length: 0\r\n"
"\r\n"
)
self._send_sip_response(response, addr)
def _send_200_ok(self, request, addr, device):
"""发送200 OK响应"""
to_header = self._get_header(request, 'To')
# 添加tag
if ';tag=' not in to_header:
tag = str(random.randint(10000000, 99999999))
to_header = to_header.replace('>', f';tag={tag}>')
response = (
"SIP/2.0 200 OK\r\n"
f"Via: {self._get_header(request, 'Via')}\r\n"
f"From: {self._get_header(request, 'From')}\r\n"
f"To: {to_header}\r\n"
f"CSeq: {self._get_header(request, 'CSeq')}\r\n"
f"Call-ID: {self._get_header(request, 'Call-ID')}\r\n"
f"Contact: <sip:{self.server_id}@{self.server_ip}:{self.server_port}>\r\n"
f"Expires: {self.sip_server_expiry}\r\n"
"Content-Length: 0\r\n"
"\r\n"
)
self._send_sip_response(response, addr)
def _send_100_trying(self, request, addr):
"""发送100 Trying响应"""
response = (
"SIP/2.0 100 Trying\r\n"
f"Via: {self._get_header(request, 'Via')}\r\n"
f"From: {self._get_header(request, 'From')}\r\n"
f"To: {self._get_header(request, 'To')}\r\n"
f"CSeq: {self._get_header(request, 'CSeq')}\r\n"
f"Call-ID: {self._get_header(request, 'Call-ID')}\r\n"
"Content-Length: 0\r\n"
"\r\n"
)
self._send_sip_response(response, addr)
def _build_sdp_media_line(self, media_type, port, payload_types, is_rtp=True):
"""
构建SDP媒体行根据rtp_transfer_mode自动选择UDP或TCP被动
Args:
media_type: 媒体类型video/audio
port: 端口号
payload_types: 负载类型列表,如 [96, 98, 97]
is_rtp: 是否为RTPTrue=RTP/AVP或TCP/RTP/AVPFalse=其他)
Returns:
tuple: (media_line, attributes_list)
"""
attrs = []
if self.rtp_transfer_mode == 0:
# UDP模式不包含setup和connection属性
media_line = f"m={media_type} {port} RTP/AVP {' '.join(map(str, payload_types))}"
elif self.rtp_transfer_mode == 1:
# TCP被动模式
media_line = f"m={media_type} {port} TCP/RTP/AVP {' '.join(map(str, payload_types))}"
attrs.append("a=setup:passive")
attrs.append("a=connection:new")
else:
# 未知模式默认UDP
media_line = f"m={media_type} {port} RTP/AVP {' '.join(map(str, payload_types))}"
return media_line, attrs
def _build_sdp(self, media_type, port, payload_types, attrs_map, include_audio=False):
"""
构建完整的SDP根据rtp_transfer_mode自动选择UDP或TCP
Args:
media_type: 主媒体类型video
port: 端口号
payload_types: 主媒体负载类型列表
attrs_map: 属性映射 {payload_type: rtpmap_string}
include_audio: 是否包含音频Python版本UDP和TCP模式均支持
Returns:
str: SDP字符串
"""
# 构建主媒体行
media_line, tcp_attrs = self._build_sdp_media_line(media_type, port, payload_types)
sdp = (
f"v=0\r\n"
f"o={self.server_id} 0 0 IN IP4 {self.server_ip}\r\n"
f"s=Play\r\n"
f"c=IN IP4 {self.server_ip}\r\n"
f"t=0 0\r\n"
f"{media_line}\r\n"
)
# 与C++版本一致先添加setup和connection属性再添加rtpmap
for attr in tcp_attrs:
sdp += f"{attr}\r\n"
# 添加rtpmap
for pt, rtpmap in attrs_map.items():
sdp += f"a=rtpmap:{pt} {rtpmap}\r\n"
# 与C++版本一致INVITE场景使用a=recvonly服务器接收RTP流
sdp += f"a=recvonly\r\n"
# Python版本特殊扩展UDP模式下rtpTransferAudioType=1也支持音频C++版本仅TCP支持
if include_audio:
audio_payloads = [8, 0, 9, 18, 97, 98] # PCMA/PCMU/G722/G729/G726/AAC
audio_media_line, audio_tcp_attrs = self._build_sdp_media_line("audio", port, audio_payloads)
sdp += f"{audio_media_line}\r\n"
for attr in audio_tcp_attrs:
sdp += f"{attr}\r\n"
sdp += f"a=recvonly\r\n"
sdp += f"a=rtpmap:8 PCMA/8000\r\n"
sdp += f"a=rtpmap:0 PCMU/8000\r\n"
sdp += f"a=rtpmap:9 G722/16000\r\n"
sdp += f"a=rtpmap:18 G729/8000\r\n"
sdp += f"a=rtpmap:97 G726-32/8000\r\n"
sdp += f"a=rtpmap:98 mpeg4-generic/8000/1\r\n"
sdp += f"a=fmtp:98 profile-level-id=1;mode=AAC-hbr;config=1210;sizeLength=13;indexLength=3;indexDeltaLength=3\r\n"
sdp += f"a=ssrc:87654321\r\n"
sdp += f"a=y:0100000002\r\n"
# 添加ssrc某些设备需要此字段
sdp += f"a=ssrc:12345678\r\n"
sdp += f"a=y:0100000001\r\n"
return sdp
def _send_200_ok_invite(self, request, addr, headers):
"""发送200 OK响应INVITE"""
# 根据rtp_transfer_audio_type决定是否包含音频
include_audio = (self.rtp_transfer_audio_type == 1)
# 使用_build_sdp构建标准SDP与C++版本保持一致)
sdp = self._build_sdp(
media_type="video",
port=0, # 端口在设备SDP中指定
payload_types=[96, 98, 97], # PS/H264/MPEG4与C++版本一致
attrs_map={96: "PS/90000", 98: "H264/90000", 97: "MPEG4/90000"},
include_audio=include_audio
)
to_header = self._get_header(request, 'To')
if ';tag=' not in to_header:
tag = str(random.randint(10000000, 99999999))
to_header = to_header.replace('>', f';tag={tag}>')
response = (
"SIP/2.0 200 OK\r\n"
f"Via: {self._get_header(request, 'Via')}\r\n"
f"From: {self._get_header(request, 'From')}\r\n"
f"To: {to_header}\r\n"
f"CSeq: {self._get_header(request, 'CSeq')}\r\n"
f"Call-ID: {self._get_header(request, 'Call-ID')}\r\n"
f"Contact: <sip:{self.server_id}@{self.server_ip}:{self.server_port}>\r\n"
f"Content-Type: application/sdp\r\n"
f"Content-Length: {len(sdp)}\r\n"
"\r\n"
f"{sdp}"
)
self._send_sip_response(response, addr)
def _send_response(self, request, addr, code, reason):
"""发送通用SIP响应"""
response = (
f"SIP/2.0 {code} {reason}\r\n"
f"Via: {self._get_header(request, 'Via')}\r\n"
f"From: {self._get_header(request, 'From')}\r\n"
f"To: {self._get_header(request, 'To')}\r\n"
f"CSeq: {self._get_header(request, 'CSeq')}\r\n"
f"Call-ID: {self._get_header(request, 'Call-ID')}\r\n"
"Content-Length: 0\r\n"
"\r\n"
)
self._send_sip_response(response, addr)
def _send_200_ok_message(self, request, addr):
"""发送200 OK响应MESSAGE"""
response = (
"SIP/2.0 200 OK\r\n"
f"Via: {self._get_header(request, 'Via')}\r\n"
f"From: {self._get_header(request, 'From')}\r\n"
f"To: {self._get_header(request, 'To')}\r\n"
f"CSeq: {self._get_header(request, 'CSeq')}\r\n"
f"Call-ID: {self._get_header(request, 'Call-ID')}\r\n"
"Content-Length: 0\r\n"
"\r\n"
)
self._send_sip_response(response, addr)
def _get_header(self, message, header_name):
"""获取SIP消息头"""
lines = message.split('\r\n')
for line in lines[1:]:
if line.lower().startswith(header_name.lower() + ':'):
return line.split(':', 1)[1].strip()
return ''
def _verify_auth(self, auth_header, uri, method, device_id=''):
"""验证Digest认证严格密码校验"""
# 去除"Digest "前缀
if auth_header.startswith('Digest '):
auth_header = auth_header[7:]
# 解析Authorization参数兼容带引号和不带引号的值
auth_params = {}
for match in re.finditer(r'(\w+)="([^"]*)"|(\w+)=(\w+)', auth_header):
key = (match.group(1) or match.group(3)).lower()
value = match.group(2) if match.group(2) is not None else match.group(4)
auth_params[key] = value
username = auth_params.get('username', '')
realm = auth_params.get('realm', '')
nonce = auth_params.get('nonce', '')
response = auth_params.get('response', '')
uri_val = auth_params.get('uri', '')
qop = auth_params.get('qop', '').strip('"') # 去除可能的引号
cnonce = auth_params.get('cnonce', '')
nc = auth_params.get('nc', '')
if not all([username, realm, nonce, response, uri_val]):
self.logger.error("[GSS] 认证参数不完整")
self.logger.debug(f"[GSS] 解析结果: {auth_params}")
return False
# 计算期望的response
expected_response = self._calculate_response(
username, realm, self.password, nonce, method, uri_val,
qop=qop, cnonce=cnonce, nc=nc
)
# Bug 16修复加锁访问self.devices防止竞态条件
with self.lock:
device = self.devices.get(device_id)
is_first_register = (device_id not in self.devices) or (device and not device.registered)
# 详细调试日志(设备首次注册时打印,方便排查密码问题)
if is_first_register:
self.logger.debug(f"[GSS] 🔐 认证参数详情:")
self.logger.debug(f"[GSS] 设备用户名: {username}")
self.logger.debug(f"[GSS] Realm: {realm}")
self.logger.debug(f"[GSS] 服务器密码: {self.password}")
self.logger.debug(f"[GSS] qop: {qop}, nc: {nc}, cnonce: {cnonce}")
self.logger.debug(f"[GSS] 设备返回: {response}")
self.logger.debug(f"[GSS] 服务器计算: {expected_response}")
# 严格比对
if response == expected_response:
return True
else:
self.logger.error(f"[GSS] ❌ 认证失败: 密码错误或计算方式不匹配")
return False
def _extract_received_ip(self, message):
"""
从SIP消息的Via头中提取received参数NAT穿透关键
Args:
message: SIP消息字符串
Returns:
str: 公网IP地址如果不存在则返回空字符串
"""
# 匹配: Via: SIP/2.0/UDP 192.168.1.100:5060;received=公网IP;rport=端口
match = re.search(r'Via:\s+SIP/2\.0/\w+\s+[^;]+;received=([\d\.]+)', message)
if match:
return match.group(1)
return ""
def _extract_rport(self, message):
"""
从SIP消息的Via头中提取rport参数NAT穿透关键
Args:
message: SIP消息字符串
Returns:
int: 公网端口号,如果不存在则返回-1
"""
# Bug 6修复只匹配第一个Via头中的rport
lines = message.split('\r\n')
for line in lines:
if line.startswith('Via:'):
match = re.search(r'rport=(\d+)', line)
if match:
return int(match.group(1))
break
return -1
def _update_device_contact(self, device_id, new_ip, new_port, message=''):
"""
更新设备的联系地址支持NAT穿透
Args:
device_id: 设备ID
new_ip: 新IP地址
new_port: 新端口
message: SIP消息用于提取received和rport
Returns:
bool: 是否更新了地址
"""
with self.lock:
device = self.devices.get(device_id)
if not device:
return False
# 优先使用Via头的received和rportNAT环境下的公网地址
if message:
received_ip = self._extract_received_ip(message)
rport = self._extract_rport(message)
if received_ip and rport > 0:
new_ip = received_ip
new_port = rport
# 检查地址是否有变化
if device.ip == new_ip and device.port == new_port:
return False
old_ip = device.ip
old_port = device.port
device.ip = new_ip
device.port = new_port
self.logger.debug(f"[GSS] 地址更新: {old_ip}:{old_port} -> {new_ip}:{new_port}")
return True
def _check_expired_devices(self):
"""检查过期设备(基于心跳时间和注册过期时间)"""
now_ms = int(time.time() * 1000)
# 心跳超时使用配置的sip_server_timeout秒→毫秒最小180秒防止配置过小
expired_timeout = max(self.sip_server_timeout, 180) * 1000
# 注册过期超时使用配置的sip_server_expiry秒→毫秒无心跳时按注册过期判断
register_expiry_ms = max(self.sip_server_expiry, 600) * 1000
cleanup_timeout = 1800000 # 30分钟无活动则清理设备
# 第一阶段:在锁内收集需要处理的设备和通道信息
expired_devices_info = [] # [(device_id, channels_to_stop)]
devices_to_cleanup = [] # 需要清理的设备ID列表
with self.lock:
expired_devices = []
for device_id, device in list(self.devices.items()): # 使用list()避免遍历时修改字典
if device.registered:
# 优先检查设备级心跳时间(即使无通道也能保活)
has_recent_keepalive = False
if device.last_keepalive_time > 0:
dev_diff = now_ms - device.last_keepalive_time
if dev_diff < expired_timeout:
has_recent_keepalive = True
# 再检查通道级心跳时间
if not has_recent_keepalive:
for channel in device.channels:
if channel.last_keepalive_time > 0:
time_diff = now_ms - channel.last_keepalive_time
if time_diff < expired_timeout:
has_recent_keepalive = True
break
# 如果所有通道都超时,且没有新的注册
if not has_recent_keepalive:
# 检查注册时间:无心跳时,按注册过期时间判断(而非心跳超时)
if device.last_register_time > 0:
reg_diff = now_ms - device.last_register_time
if reg_diff > register_expiry_ms:
expired_devices.append(device_id)
else:
# 没有注册时间记录,保守处理
pass
else:
# 设备已离线,检查是否需要清理
if device.last_register_time > 0:
offline_duration = now_ms - device.last_register_time
if offline_duration > cleanup_timeout:
devices_to_cleanup.append(device_id)
self.logger.debug(f"[GSS] 🧹 清理长时间离线设备: {device_id} (离线{offline_duration//60000}分钟)")
# 收集需要停止推流的通道信息
for device_id in expired_devices:
device = self.devices[device_id]
channels_to_stop = []
for channel in device.channels:
if channel.streaming:
channels_to_stop.append({
'channel_id': channel.channel_id,
'rtp_port': channel.allocated_rtp_port # 使用分配的端口
})
expired_devices_info.append((device_id, channels_to_stop))
# 立即更新状态(在锁内)
device.registered = False
for channel in device.channels:
channel.streaming = False
channel.rtp_port = 0
channel.allocated_rtp_port = 0
channel.call_id = ""
channel.forward_state = 0
# Bug 3修复清理inviting状态
channel.inviting = False
# 清理长时间离线的设备
for device_id in devices_to_cleanup:
device = self.devices.pop(device_id, None)
if device:
# 确保清理所有资源
for channel in device.channels:
if channel.streaming or channel.allocated_rtp_port > 0:
self._close_rtp_server(channel.channel_id)
if channel.allocated_rtp_port > 0:
self.rtp_port_mgr.release(channel.allocated_rtp_port)
# 第二阶段在锁外执行HTTP请求和端口释放避免持锁阻塞
for device_id, channels_to_stop in expired_devices_info:
self.logger.debug(f"[GSS] ⚠️ 设备 {device_id} 心跳超时,标记为离线")
for ch_info in channels_to_stop:
self.logger.debug(f"[GSS] ⏹️ 停止过期设备的推流: {ch_info['channel_id']}")
# 关闭ZLM RTP服务器HTTP请求可能阻塞
self._close_rtp_server(ch_info['channel_id'])
# 释放RTP端口
self.rtp_port_mgr.release(ch_info['rtp_port'])
if expired_devices_info:
self.logger.debug(f"[GSS] 发现 {len(expired_devices_info)} 个过期设备")
def log_status(self):
"""打印服务器状态"""
self.logger.debug("[GSS] -----------GB28181SipServer.log_status start----------")
# 在锁内获取设备快照,避免遍历时字典被修改
with self.lock:
devices_snapshot = list(self.devices.values())
devices_count = len(self.devices)
self.logger.debug(f"[GSS] 服务器: {self.server_ip}:{self.server_port}")
self.logger.debug(f"[GSS] 已注册设备: {devices_count}")
self.logger.debug(f"[GSS] 统计信息:")
self.logger.debug(f"[GSS] - 总注册数: {self.stats['total_registers']}")
self.logger.debug(f"[GSS] - 总邀请数: {self.stats['total_invites']}")
self.logger.debug(f"[GSS] - 总消息数: {self.stats['total_messages']}")
if devices_snapshot:
self.logger.debug(f"[GSS] \n设备列表:")
for device in devices_snapshot:
self.logger.debug(f"[GSS] - {device.device_id}: {device.ip}:{device.port} "
f"({'在线' if device.registered else '离线'}) "
f"注册于 {device.register_time}")
self.logger.debug("[GSS] -----------GB28181SipServer.log_status end----------")
def send_invite(self, device_id, channel_id):
"""
发送INVITE请求到设备主动推流
Args:
device_id: 设备ID
channel_id: 通道ID
Returns:
bool: 是否成功发送
"""
# 在锁内获取设备信息和通道状态
with self.lock:
if device_id not in self.devices:
self.logger.error(f"[GSS] 设备 {device_id} 不存在")
return False
device = self.devices[device_id]
if not device.registered:
self.logger.error(f"[GSS] 设备 {device_id} 未注册")
return False
device_ip = device.ip
device_port = device.port
# 检查通道是否已在推流或正在INVITE中防止重复INVITE
channel = self._find_channel(device.channels, channel_id)
if channel and (channel.streaming or channel.forward_state == 1 or channel.inviting):
self.logger.debug(f"[GSS] 通道 {channel_id} 已在推流中或正在INVITE跳过重复请求")
return False
# 设置inviting标志在锁内防止竞态条件
if channel:
channel.inviting = True
# 从RTPPortManager统一分配端口
rtp_port = self.rtp_port_mgr.allocate(channel_id)
self.logger.debug(f"[GSS] 🔧 send_invite分配RTP端口: {rtp_port}, channel_id={channel_id}")
if rtp_port == 0:
self.logger.error(f"[GSS] RTP端口分配失败: {channel_id}")
# 清除inviting标志
with self.lock:
device = self.devices.get(device_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
return False
# 调用ZLMediaKit openRtpServer创建RTP服务器
zlm_port = 0
try:
success, msg, zlm_port = self.zlm.openRtpServer(
port=rtp_port,
tcp_mode=2 if self.rtp_transfer_mode == 1 else 0,
stream_id=channel_id
)
if not success:
# 如果stream已存在先关闭再重试
if "already exists" in msg:
self.logger.debug(f"[GSS] ⚠️ RTP服务器已存在先关闭再重试: {channel_id}")
self._close_rtp_server(channel_id)
time.sleep(0.5) # 等待关闭完成
# 重试创建
success, msg, zlm_port = self.zlm.openRtpServer(
port=rtp_port,
tcp_mode=2 if self.rtp_transfer_mode == 1 else 0,
stream_id=channel_id
)
if not success:
self.logger.error(f"[GSS] ZLM openRtpServer重试失败: {msg}")
# 释放已分配的端口
self.rtp_port_mgr.release(rtp_port)
# 清除inviting标志
with self.lock:
device = self.devices.get(device_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
return False
else:
self.logger.error(f"[GSS] ZLM openRtpServer失败: {msg}")
# 释放已分配的端口
self.rtp_port_mgr.release(rtp_port)
# 清除inviting标志
with self.lock:
device = self.devices.get(device_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
return False
# 获取ZLM实际分配的端口可能与请求的不同
self.logger.debug(f"[GSS] ✓ RTP端口分配: {rtp_port}, ZLM实际端口: {zlm_port}, stream_id={channel_id}")
except Exception as e:
self.logger.error(f"[GSS] ZLM openRtpServer异常: {e}")
# 异常时释放已分配的端口
self.rtp_port_mgr.release(rtp_port)
# 清除inviting标志
with self.lock:
device = self.devices.get(device_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
return False
# 构建INVITE请求与C++版本一致)
call_id = str(uuid.uuid4()).replace('-', '')
from_tag = str(random.randint(100000000, 999999999))
branch = f"z9hG4bK{random.randint(100000000, 999999999)}"
# Subject: {channel_id}:0,{server_id}:0GB28181必须字段
subject = f"{channel_id}:0,{self.server_id}:0"
# SDP内容与C++版本SipServer.cpp保持一致
include_audio = (self.rtp_transfer_audio_type == 1)
# 使用ZLM实际分配的端口可能与请求的不同
final_port = zlm_port if zlm_port > 0 else rtp_port
sdp = self._build_sdp(
media_type="video",
port=final_port,
payload_types=[96, 98, 97], # PS/H264/MPEG4与C++版本一致
attrs_map={96: "PS/90000", 98: "H264/90000", 97: "MPEG4/90000"},
include_audio=include_audio
)
invite = (
f"INVITE sip:{channel_id}@{device_ip}:{device_port} SIP/2.0\r\n"
f"Via: SIP/2.0/{self._get_sip_transport()} {self.server_ip}:{self.server_port};rport;branch={branch}\r\n"
f"From: <sip:{self.server_id}@{self.realm}>;tag={from_tag}\r\n"
f"To: <sip:{channel_id}@{self.realm}>\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: 1 INVITE\r\n"
f"Max-Forwards: 70\r\n"
f"Contact: <sip:{self.server_id}@{self.server_ip}:{self.server_port}>\r\n"
f"User-Agent: monitor\r\n"
f"Subject: {subject}\r\n"
f"Session-Expires: {self.sip_server_timeout};refresher=uas\r\n"
f"Supported: timer\r\n"
f"Content-Type: application/sdp\r\n"
f"Content-Length: {len(sdp)}\r\n"
f"\r\n"
f"{sdp}"
)
# Debug: 打印INVITE完整内容用于诊断488错误已禁用
# if self.log_debug:
# self.logger.debug(f"[GSS] ===== INVITE Message =====")
# for i, line in enumerate(invite.split('\r\n')):
# self.logger.debug(f"[GSS] [{i}] {line}")
# self.logger.debug(f"[GSS] ===== End INVITE =====")
# 注册pending invite在发送前防止catalog刷新丢失状态
with self.pending_invites_lock:
self.pending_invites[channel_id] = {
'rtp_port': rtp_port, # 使用分配的端口不是ZLM返回的端口
'call_id': call_id,
'device_id': device_id
}
try:
addr = (device_ip, device_port)
self._send_sip_response(invite, addr)
# 记录call_id和rtp_port用于后续状态更新不立即设置forward_state
# 真正的状态更新在收到200 OK响应时进行
with self.lock:
device = self.devices.get(device_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.rtp_port = final_port # ZLM实际返回的端口
channel.allocated_rtp_port = rtp_port # 保存分配的端口
channel.call_id = call_id
channel.from_tag = from_tag # 保存from_tag用于后续BYE构造
# 不清除inviting标志等待200 OK或错误响应
self.logger.debug(f"[GSS] ✓ INVITE已发送等待200 OK响应: {channel_id}")
else:
# 通道已不存在可能被catalog刷新或设备重新注册移除但保留pending状态
self.logger.debug(f"[GSS] ⚠️ 通道 {channel_id} 在INVITE发送后不在内存中pending状态已保留")
# 不清理资源pending状态会在catalog刷新后恢复
return True # 返回True等待200 OK响应
else:
# Bug 14修复设备已不存在极端情况保留pending状态
self.logger.debug(f"[GSS] ⚠️ 设备 {device_id} 在INVITE发送后已不存在等待catalog刷新恢复")
return True # 返回True等待200 OK响应
self.logger.debug(f"[GSS] 📡 INVITE已发送: {channel_id} -> {device_ip}:{device_port}, RTP port={rtp_port}")
return True
except Exception as e:
self.logger.error(f"[GSS] ✗ 发送INVITE失败: {e}")
# 发送失败时释放已分配的端口
self.rtp_port_mgr.release(rtp_port)
# 关闭ZLM服务器
self._close_rtp_server(channel_id)
# 清除inviting标志并重置通道状态
with self.lock:
device = self.devices.get(device_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
channel.rtp_port = 0
channel.allocated_rtp_port = 0
channel.call_id = ""
channel.streaming = False
channel.forward_state = 0
return False
def query_device_info(self, device_id):
"""
查询设备信息DeviceInfo获取设备名称、厂商、型号等
注册后自动调用与Catalog查询并行
Args:
device_id: 设备ID
"""
with self.lock:
if device_id not in self.devices:
return False
device = self.devices[device_id]
device_ip = device.ip
device_port = device.port
call_id = str(uuid.uuid4()).replace('-', '')
from_tag = str(random.randint(100000000, 999999999))
branch = f"z9hG4bK{random.randint(100000000, 999999999)}"
sn = random.randint(1, 9999)
body = (
f"<?xml version=\"1.0\"?>\r\n"
f"<Query>\r\n"
f"<CmdType>DeviceInfo</CmdType>\r\n"
f"<SN>{sn}</SN>\r\n"
f"<DeviceID>{device_id}</DeviceID>\r\n"
f"</Query>\r\n"
)
msg = (
f"MESSAGE sip:{device_id}@{device_ip}:{device_port} SIP/2.0\r\n"
f"Via: SIP/2.0/{self._get_sip_transport()} {self.server_ip}:{self.server_port};rport;branch={branch}\r\n"
f"From: <sip:{self.server_id}@{self.realm}>;tag={from_tag}\r\n"
f"To: <sip:{device_id}@{self.realm}>\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: 1 MESSAGE\r\n"
f"Max-Forwards: 70\r\n"
f"Contact: <sip:{self.server_id}@{self.server_ip}:{self.server_port}>\r\n"
f"Content-Type: Application/MANSCDP+xml\r\n"
f"Content-Length: {len(body)}\r\n"
f"\r\n"
f"{body}"
)
try:
addr = (device_ip, device_port)
self._send_sip_response(msg, addr)
self.logger.debug(f"[GSS] [DEVICEINFO] 已发送DeviceInfo查询到设备 {device_id} @ {device_ip}:{device_port}")
return True
except Exception as e:
self.logger.error(f"[GSS] [DEVICEINFO] 发送DeviceInfo查询失败: {e}")
return False
def query_catalog(self, device_id):
"""
查询设备通道目录(注册后自动调用)
Args:
device_id: 设备ID
"""
# 在锁内获取设备信息
with self.lock:
if device_id not in self.devices:
return False
device = self.devices[device_id]
device_ip = device.ip
device_port = device.port
call_id = str(uuid.uuid4()).replace('-', '')
from_tag = str(random.randint(100000000, 999999999))
branch = f"z9hG4bK{random.randint(100000000, 999999999)}"
sn = random.randint(1, 9999)
body = (
f"<?xml version=\"1.0\"?>\r\n"
f"<Query>\r\n"
f"<CmdType>Catalog</CmdType>\r\n"
f"<SN>{sn}</SN>\r\n"
f"<DeviceID>{device_id}</DeviceID>\r\n"
f"</Query>\r\n"
)
msg = (
f"MESSAGE sip:{device_id}@{device_ip}:{device_port} SIP/2.0\r\n"
f"Via: SIP/2.0/{self._get_sip_transport()} {self.server_ip}:{self.server_port};rport;branch={branch}\r\n"
f"From: <sip:{self.server_id}@{self.realm}>;tag={from_tag}\r\n"
f"To: <sip:{device_id}@{self.realm}>\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: 1 MESSAGE\r\n"
f"Max-Forwards: 70\r\n"
f"Contact: <sip:{self.server_id}@{self.server_ip}:{self.server_port}>\r\n"
f"Content-Type: Application/MANSCDP+xml\r\n"
f"Content-Length: {len(body)}\r\n"
f"\r\n"
f"{body}"
)
try:
addr = (device_ip, device_port)
self._send_sip_response(msg, addr)
self.logger.debug(f"[GSS] [CATALOG] 已发送Catalog查询到设备 {device_id} @ {device_ip}:{device_port}")
return True
except Exception as e:
self.logger.error(f"[GSS] [CATALOG] 发送Catalog查询失败: {e}")
return False
def _get_catalog_lock(self, device_id):
"""获取per-device的Catalog处理锁串行化同一设备的Catalog响应处理防止并发导致重复update_admin"""
with self.catalog_locks_guard:
if device_id not in self.catalog_locks:
self.catalog_locks[device_id] = threading.Lock()
return self.catalog_locks[device_id]
def remove_channel(self, channel_id, device_id=None):
"""
从内存中删除指定通道当用户在UI删除流时调用
防止parse_catalog_response的old_channel_map缓存抑制update_admin
导致下级重新注册后上级看不到通道数据。
幂等操作,通道不存在时不报错。
Args:
channel_id: 通道IDGB28181 channelId
device_id: 可选,指定从哪台设备下删除该通道。
当多台设备共享同一 channel_id非法但会发生
仅删指定 device_id 下的通道,不影响其他设备的同名通道。
"""
if not channel_id:
return
removed_count = 0
with self.lock:
target_devices = [self.devices[device_id]] if device_id and device_id in self.devices else list(self.devices.values())
for device in target_devices:
# 递归清理通道(含子目录)
def _remove_from_list(channels):
nonlocal removed_count
kept = []
for ch in channels:
if ch.channel_id == channel_id:
removed_count += 1
continue
if ch.children:
ch.children = _remove_from_list(ch.children)
kept.append(ch)
return kept
device.channels = _remove_from_list(device.channels)
if removed_count > 0:
self.logger.debug(f"[GSS] 已从内存清理通道: {channel_id}" + (f" (device={device_id})" if device_id else "") + f" (共{removed_count}处)")
def parse_catalog_response(self, body, device_id, parent_id=""):
"""
解析设备返回的Catalog响应支持多级目录结构
Args:
body: XML响应体
device_id: 设备ID
parent_id: 父节点ID用于递归查询子目录
"""
try:
root = _safe_xml_parse(body)
item_list = root.findall('.//Item')
if not item_list:
self.logger.debug(f"[GSS] ⚠️ Catalog响应中没有通道项")
return []
# per-device锁串行化同一设备的Catalog处理防止并发Catalog响应导致重复update_admin
catalog_lock = self._get_catalog_lock(device_id)
with catalog_lock:
channels = []
seen_channel_ids = set() # 去重集合防止Catalog响应含重复DeviceID导致通道重复
with self.lock:
device = self.devices.get(device_id)
# 【关键修复】在循环前构建旧通道映射,用于判断通道是否已存在
# 防止多次Catalog响应并发触发update_admin导致重复插入av_stream
old_channel_map = {}
if device:
def _build_ch_map(chs):
m = {}
for ch in chs:
m[ch.channel_id] = ch
if ch.children:
m.update(_build_ch_map(ch.children))
return m
with self.lock:
old_channel_map = _build_ch_map(device.channels)
for item in item_list:
ch_id_elem = item.find('DeviceID')
name_elem = item.find('Name')
status_elem = item.find('Status')
parental_elem = item.find('Parental')
parent_id_elem = item.find('ParentID')
device_type_elem = item.find('DeviceType')
manufacturer_elem = item.find('Manufacturer')
model_elem = item.find('Model')
owner_elem = item.find('Owner')
civil_code_elem = item.find('CivilCode')
sum_num_elem = item.find('SumNum')
if ch_id_elem is not None:
ch_id = ch_id_elem.text or ''
# 去重跳过已处理的channel_id防止重复通道
if ch_id in seen_channel_ids:
self.logger.debug(f"[GSS] ⚠️ Catalog响应含重复通道ID {ch_id},已跳过")
continue
seen_channel_ids.add(ch_id)
name = name_elem.text if name_elem is not None else ''
status = status_elem.text if status_elem is not None else 'OFF'
parental = int(parental_elem.text) if parental_elem is not None and parental_elem.text else 0
pid = parent_id_elem.text if parent_id_elem is not None else (parent_id if parent_id else device_id)
dev_type = device_type_elem.text if device_type_elem is not None else ''
manufacturer = manufacturer_elem.text if manufacturer_elem is not None else ''
model = model_elem.text if model_elem is not None else ''
owner = owner_elem.text if owner_elem is not None else ''
civil_code = civil_code_elem.text if civil_code_elem is not None else ''
sum_num = int(sum_num_elem.text) if sum_num_elem is not None and sum_num_elem.text else 0
# 创建Channel并关联device与C++版本一致)
channel = Channel(ch_id, name, device=device if device else None,logger=self.logger)
channel.status = status
channel.parental = parental
channel.parent_id = pid
channel.device_type = dev_type
channel.manufacturer = manufacturer
channel.model = model
channel.owner = owner
channel.civil_code = civil_code
channel.sum_num = sum_num
# 设置时间戳
channel.last_register_time = int(time.time() * 1000) if device else 0
if parental == 1:
# 目录节点
self.logger.debug(f"[GSS] 📁 目录: {ch_id} ({name})")
# 递归查询子目录
threading.Thread(
target=self.query_sub_catalog,
args=(device_id, ch_id),
daemon=True
).start()
else:
# 叶子通道
type_icon = {'IPC': '📷', 'DVR': '📼', 'NVR': '🖥️'}.get(dev_type, '📹')
self.logger.debug(f"[GSS] {type_icon} 通道: {ch_id} ({name}) [{status}]")
# 自动同步到rebekah_admin与C++版本逻辑一致)
# 【关键修复 v3】
# a) 已存在通道:保留旧的推流态(forward_state/streaming/to_tag)防止Catalog刷新把 DB 里"转发中"覆盖回 0
# b) 每次 Catalog 都调 update_admin幂等安全让 DB 里的 forward_state 与 Channel 内存态一致
existing_ch = old_channel_map.get(ch_id)
if existing_ch:
# 保留推流相关状态,不要被新 Channel 的 forward_state=0 覆盖
channel.streaming = existing_ch.streaming
channel.forward_state = existing_ch.forward_state
channel.call_id = existing_ch.call_id
channel.from_tag = existing_ch.from_tag
channel.to_tag = existing_ch.to_tag
channel.inviting = existing_ch.inviting
channel.invite_error = getattr(existing_ch, 'invite_error', None)
channel.auto_invited = existing_ch.auto_invited
if self.admin_host:
threading.Thread(
target=channel.update_admin,
args=(self,),
daemon=True
).start()
channels.append(channel)
# 如果是子目录查询将通道添加到父目录的children中
if parent_id:
with self.lock:
device = self.devices.get(device_id)
if device:
parent_ch = self._find_channel(device.channels, parent_id)
if parent_ch:
# 保留子目录通道的状态
old_children = parent_ch.children if parent_ch.children else []
# 递归构建旧子通道映射
def build_child_map(old_channels):
ch_map = {}
for ch in old_channels:
ch_map[ch.channel_id] = ch
if ch.children:
ch_map.update(build_child_map(ch.children))
return ch_map
old_child_map = build_child_map(old_children)
# 递归保留状态
def preserve_child_states(new_channels):
for new_ch in new_channels:
if new_ch.channel_id in old_child_map:
old_ch = old_child_map[new_ch.channel_id]
new_ch.streaming = old_ch.streaming
new_ch.rtp_port = old_ch.rtp_port
new_ch.allocated_rtp_port = old_ch.allocated_rtp_port # 同步复制分配的端口
new_ch.call_id = old_ch.call_id
new_ch.from_tag = old_ch.from_tag # 保留dialog信息
new_ch.to_tag = old_ch.to_tag # 保留dialog信息
# ⚠️ 不保留inviting状态inviting是临时状态
new_ch.inviting = False
new_ch.forward_state = old_ch.forward_state
if new_ch.children:
preserve_child_states(new_ch.children)
preserve_child_states(channels)
parent_ch.children = channels
self.logger.debug(f"[GSS] ↳ 子目录 {parent_id} 包含 {len(channels)}个通道/目录")
# 子目录也需要自动发起INVITE推流
if self.auto_invite_after_rec_cate_log:
for new_ch in channels:
if new_ch.parental == 0 and new_ch.forward_state == 0: # 只处理叶子通道
if not new_ch.streaming and not new_ch.inviting:
self.logger.debug(f"[GSS] 🚀 子目录自动发起INVITE推流: {new_ch.channel_id}")
threading.Thread(
target=self.send_invite,
args=(device_id, new_ch.channel_id),
daemon=True
).start()
else:
self.logger.debug(f"[GSS] ⚠️ 未找到父目录 {parent_id}")
else:
# 更新设备的通道列表(如果是根目录查询)
# 注意:不主动清理 channel_id == device_id 的回退通道,避免推流会话被中断
with self.lock:
if device_id in self.devices:
# old_channel_map已在循环前构建此处直接复用
# 递归为新通道保留状态
def preserve_states(new_channels):
for new_ch in new_channels:
if new_ch.channel_id in old_channel_map:
old_ch = old_channel_map[new_ch.channel_id]
# 保留持久状态streaming、rtp_port等
new_ch.streaming = old_ch.streaming
new_ch.rtp_port = old_ch.rtp_port
new_ch.allocated_rtp_port = old_ch.allocated_rtp_port # 同步复制分配的端口
new_ch.call_id = old_ch.call_id
new_ch.from_tag = old_ch.from_tag # 保留dialog信息
new_ch.to_tag = old_ch.to_tag # 保留dialog信息
# ⚠️ 不保留inviting状态inviting是临时状态设备重新注册后应重置
new_ch.inviting = False
new_ch.forward_state = old_ch.forward_state
new_ch.last_keepalive_time = old_ch.last_keepalive_time
new_ch.last_register_time = old_ch.last_register_time
new_ch.sn = old_ch.sn
# 递归处理子目录
if new_ch.children:
preserve_states(new_ch.children)
preserve_states(channels)
self.devices[device_id].channels = channels
self.logger.debug(f"[GSS] ✓ 设备 {device_id} 通道列表已更新: {len(channels)}个通道/目录")
# 自动发起INVITE推流在通道列表更新后执行
if self.auto_invite_after_rec_cate_log:
for new_ch in channels:
if new_ch.parental == 0 and new_ch.forward_state == 0: # 只处理叶子通道
if not new_ch.streaming and not new_ch.inviting:
self.logger.debug(f"[GSS] 🚀 自动发起INVITE推流: {new_ch.channel_id}")
threading.Thread(
target=self.send_invite,
args=(device_id, new_ch.channel_id),
daemon=True
).start()
return channels
except ET.ParseError as e:
self.logger.error(f"[GSS] 解析Catalog XML失败: {e}")
except Exception as e:
self.logger.error(f"[GSS] 处理Catalog响应异常: {e}")
return []
def _find_channel(self, channels, channel_id):
"""递归查找通道"""
for ch in channels:
if ch.channel_id == channel_id:
return ch
if ch.children:
found = self._find_channel(ch.children, channel_id)
if found:
return found
return None
def query_sub_catalog(self, device_id, catalog_id):
"""
查询子目录(递归)
Args:
device_id: 设备ID
catalog_id: 目录ID
"""
time.sleep(1) # 稍作延迟,等待父目录添加到列表
# 在锁内保存设备信息,避免后续访问时设备已被注销
with self.lock:
if device_id not in self.devices:
self.logger.debug(f"[GSS] ⚠️ 设备 {device_id} 已不存在,取消子目录查询")
return
device = self.devices[device_id]
device_ip = device.ip
device_port = device.port
call_id = str(uuid.uuid4()).replace('-', '')
from_tag = str(random.randint(100000000, 999999999))
branch = f"z9hG4bK{random.randint(100000000, 999999999)}"
sn = random.randint(1, 9999)
body = (
f"<?xml version=\"1.0\"?>\r\n"
f"<Query>\r\n"
f"<CmdType>Catalog</CmdType>\r\n"
f"<SN>{sn}</SN>\r\n"
f"<DeviceID>{catalog_id}</DeviceID>\r\n"
f"</Query>\r\n"
)
msg = (
f"MESSAGE sip:{catalog_id}@{device_ip}:{device_port} SIP/2.0\r\n"
f"Via: SIP/2.0/{self._get_sip_transport()} {self.server_ip}:{self.server_port};rport;branch={branch}\r\n"
f"From: <sip:{self.server_id}@{self.realm}>;tag={from_tag}\r\n"
f"To: <sip:{catalog_id}@{self.realm}>\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: 1 MESSAGE\r\n"
f"Max-Forwards: 70\r\n"
f"Contact: <sip:{self.server_id}@{self.server_ip}:{self.server_port}>\r\n"
f"Content-Type: Application/MANSCDP+xml\r\n"
f"Content-Length: {len(body)}\r\n"
f"\r\n"
f"{body}"
)
try:
addr = (device_ip, device_port)
self._send_sip_response(msg, addr)
self.logger.debug(f"[GSS] 📂 已发送子目录查询: {catalog_id}")
except Exception as e:
self.logger.error(f"[GSS] 发送子目录查询失败: {e}")
def _find_channel_by_id(self, channel_id):
"""
根据channel_id查找通道遍历所有设备
Args:
channel_id: 通道ID
Returns:
tuple: (device, channel) 或 (None, None)
"""
with self.lock:
for device in self.devices.values():
channel = self._find_channel(device.channels, channel_id)
if channel:
return device, channel
return None, None
def _open_rtp_server(self, port, channel_id):
"""
调用ZLMediaKit API开启RTP服务器
Args:
port: 期望的RTP端口
channel_id: 通道ID作为stream_id
Returns:
int: 实际开启的RTP端口失败返回0
"""
try:
# Bug 4修复使用与send_invite一致的tcp_mode参数
tcp_mode = 2 if self.rtp_transfer_mode == 1 else 0
self.logger.debug(f"[GSS] 🔧 创建RTP服务器: 请求端口={port}, tcp_mode={tcp_mode}, stream_id={channel_id}")
success, msg, actual_port = self.zlm.openRtpServer(
port=port,
tcp_mode=tcp_mode,
stream_id=channel_id
)
if success:
self.logger.debug(f"[GSS] ✅ RTP服务器创建成功: 实际端口={actual_port}, stream_id={channel_id}")
return actual_port
else:
self.logger.error(f"[GSS] ❌ ZLM openRtpServer失败: {msg}")
return 0
except Exception as e:
self.logger.error(f"[GSS] ❌ ZLM openRtpServer异常: {e}")
return 0
def _close_rtp_server(self, channel_id):
"""
调用ZLMediaKit API关闭RTP服务器
Args:
channel_id: 通道IDstream_id
Returns:
bool: 是否成功
"""
try:
hit, msg = self.zlm.closeRtpServer(channel_id)
if hit == 1:
pass
else:
self.logger.debug(f"[GSS] ⚠️ ZLM closeRtpServer: {msg}")
return hit == 1
except Exception as e:
self.logger.error(f"[GSS] ❌ ZLM closeRtpServer异常: {e}")
return False
def request_invite(self, client_id, channel_id, force=False):
"""
发起INVITE推流请求与C++版本逻辑一致)
Args:
client_id: 设备ID
channel_id: 通道ID
force: 是否强制重新INVITE为True时即使通道标记为推流中也会先发BYE再重新INVITE
Returns:
tuple: (success: bool, msg: str)
"""
self.logger.debug(f"[GSS] 🔍 开始INVITE请求: client_id={client_id}, channel_id={channel_id}, force={force}")
try:
# 查找设备和通道,并在锁内检查状态(避免竞态条件)
with self.lock:
device = self.devices.get(client_id)
if not device:
self.logger.debug(f"[GSS] ❌ 设备未注册: {client_id}")
return False, "client not registered"
channel = self._find_channel(device.channels, channel_id)
if not channel:
# 通道不存在创建临时通道对象兼容不响应Catalog的摄像头
self.logger.debug(f"[GSS] ⚠️ 通道 {channel_id} 不在内存中,创建临时通道")
channel = Channel(channel_id, channel_id, device=device, logger=self.logger)
channel.parental = 0 # 叶子通道
channel.forward_state = 0
device.channels.append(channel)
# 异步查询Catalog以更新通道信息
threading.Thread(target=self.query_catalog, args=(client_id,), daemon=True).start()
# 保存旧会话信息用于发送BYE终止旧会话
old_call_id = channel.call_id
old_from_tag = channel.from_tag
old_to_tag = channel.to_tag
old_allocated_rtp_port = channel.allocated_rtp_port
old_device_ip = device.ip
old_device_port = device.port
# 检查是否已经在推流或正在INVITE中必须在锁内检查
if channel.streaming or channel.forward_state == 1:
if not force:
self.logger.debug(f"[GSS] ⚠️ 通道已在推流中: {channel_id},直接返回成功")
return True, "already streaming"
else:
# force=True流可能已断开但状态未更新需要先BYE再重新INVITE
self.logger.debug(f"[GSS] 🔄 通道 {channel_id} 标记为推流中但force=True先发BYE终止旧会话")
# 如果存在旧会话先发送BYE终止防止486 Busy Here
need_send_bye = False
if old_call_id:
need_send_bye = True
# 立即重置通道状态防止重复INVITE
channel.streaming = False
channel.forward_state = 0
channel.call_id = ""
channel.from_tag = ""
channel.to_tag = ""
channel.rtp_port = 0
channel.allocated_rtp_port = 0
# 如果inviting为True可能是之前会话的残留状态强制清除
if channel.inviting:
self.logger.debug(f"[GSS] ⚠️ 通道 {channel_id} inviting标志为True可能是残留状态强制清除")
channel.inviting = False
# 设置inviting标志
channel.inviting = True
# 保存设备信息
device_ip = device.ip
device_port = device.port
# 在锁外发送BYE避免持锁阻塞
if need_send_bye:
self._send_bye_for_channel(old_device_ip, old_device_port, channel_id, old_call_id, old_from_tag, old_to_tag)
# 关闭旧的ZLM RTP服务器
self._close_rtp_server(channel_id)
# 释放旧的RTP端口
if old_allocated_rtp_port > 0:
self.rtp_port_mgr.release(old_allocated_rtp_port)
time.sleep(0.5) # 等待BYE被设备处理
self.logger.debug(f"[GSS] 找到设备和通道: {device.device_id}, {channel.channel_id}")
# 分配RTP端口
rtp_port = self.rtp_port_mgr.allocate(channel_id)
if rtp_port == 0:
self.logger.error(f"[GSS] ❌ 无法分配RTP端口")
# 清除inviting标志
with self.lock:
device = self.devices.get(client_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
return False, "no available RTP port"
self.logger.debug(f"[GSS] 🔧 request_invite分配RTP端口: {rtp_port}, channel_id={channel_id}")
# 调用ZLMediaKit openRtpServer创建RTP服务器与send_invite保持一致
zlm_rtp_port = 0
try:
tcp_mode = 2 if self.rtp_transfer_mode == 1 else 0
self.logger.debug(f"[GSS] 🔧 创建RTP服务器: 请求端口={rtp_port}, tcp_mode={tcp_mode}, stream_id={channel_id}")
success, msg, zlm_rtp_port = self.zlm.openRtpServer(
port=rtp_port,
tcp_mode=tcp_mode,
stream_id=channel_id
)
if not success:
# 如果stream已存在直接使用现有RTP服务器
if "already exists" in msg:
self.logger.debug(f"[GSS] ⚠️ RTP服务器已存在使用现有服务器: {channel_id}")
# stream已存在说明之前已经开启过使用分配的端口
# 注意这里使用rtp_port是因为openRtpServer失败时zlm_rtp_port可能为0
zlm_rtp_port = rtp_port
else:
self.logger.error(f"[GSS] ❌ ZLM openRtpServer失败: {msg}")
self.rtp_port_mgr.release(rtp_port)
with self.lock:
device = self.devices.get(client_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
return False, f"failed to open RTP server on ZLM: {msg}"
except Exception as e:
self.logger.error(f"[GSS] ❌ ZLM openRtpServer异常: {e}")
self.rtp_port_mgr.release(rtp_port)
with self.lock:
device = self.devices.get(client_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
return False, f"failed to open RTP server on ZLM: {str(e)}"
self.logger.debug(f"[GSS] ✅ RTP服务器创建成功: 请求端口={rtp_port}, ZLM实际端口={zlm_rtp_port}, stream_id={channel_id}")
# 构建SDP消息体与C++版本SipServer.cpp保持一致
include_audio = (self.rtp_transfer_audio_type == 1)
sdp_body = self._build_sdp(
media_type="video",
port=zlm_rtp_port,
payload_types=[96, 98, 97], # PS/H264/MPEG4与C++版本一致
attrs_map={96: "PS/90000", 98: "H264/90000", 97: "MPEG4/90000"},
include_audio=include_audio
)
# 构建INVITE请求
call_id = str(uuid.uuid4()).replace('-', '')
from_tag = str(random.randint(100000000, 999999999))
branch = f"z9hG4bK{random.randint(100000000, 999999999)}"
invite_msg = (
f"INVITE sip:{channel_id}@{device_ip}:{device_port} SIP/2.0\r\n"
f"Via: SIP/2.0/{self._get_sip_transport()} {self.server_ip}:{self.server_port};rport;branch={branch}\r\n"
f"From: <sip:{self.server_id}@{self.realm}>;tag={from_tag}\r\n"
f"To: <sip:{channel_id}@{self.realm}>\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: 1 INVITE\r\n"
f"Max-Forwards: 70\r\n"
f"Contact: <sip:{self.server_id}@{self.server_ip}:{self.server_port}>\r\n"
f"Content-Type: application/sdp\r\n"
f"Content-Length: {len(sdp_body)}\r\n"
f"\r\n"
f"{sdp_body}"
)
# 发送INVITE
addr = (device_ip, device_port)
self._send_sip_response(invite_msg, addr)
# 注册pending invite保存旧会话信息用于486恢复
with self.pending_invites_lock:
self.pending_invites[channel_id] = {
'rtp_port': rtp_port,
'call_id': call_id,
'device_id': client_id,
'old_call_id': old_call_id if old_call_id else '',
'old_from_tag': old_from_tag if old_from_tag else '',
'old_to_tag': old_to_tag if old_to_tag else '',
}
with self.lock:
device = self.devices.get(client_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.rtp_port = zlm_rtp_port # ZLM实际返回的端口
channel.allocated_rtp_port = rtp_port # 保存最初分配的端口(用于释放)
channel.call_id = call_id
channel.from_tag = from_tag # 保存from_tag用于后续BYE构造
# Bug 17修复不立即设置forward_state和streaming等待设备200 OK响应
# forward_state和streaming状态在收到200 OK响应时由_handle_invite_response更新
channel.inviting = False # 清除inviting标志
# 清理pending_invitesINVITE已成功发送
with self.pending_invites_lock:
self.pending_invites.pop(channel_id, None)
self.stats['total_invites'] += 1
else:
# 通道已不存在,清理已分配的资源!
self.logger.debug(f"[GSS] ⚠️ 通道 {channel_id} 在INVITE成功后已不存在清理资源")
self.rtp_port_mgr.release(rtp_port)
self._close_rtp_server(channel_id)
return False, "channel not found after INVITE sent"
else:
# Bug 15修复设备已不存在极端情况清理资源
self.logger.debug(f"[GSS] ⚠️ 设备 {client_id} 在INVITE成功后已不存在清理资源")
self.rtp_port_mgr.release(rtp_port)
self._close_rtp_server(channel_id)
return False, "device not found after INVITE sent"
return True, "success"
except Exception as e:
self.logger.error(f"[GSS] ❌ INVITE请求异常: {e}")
self.logger.error(f"[GSS] 异常堆栈: {traceback.format_exc()}")
# 异常时释放资源(必须清理所有可能已分配的资源)
try:
# 释放已分配的RTP端口使用局部变量rtp_port而不是channel.rtp_port
if 'rtp_port' in locals() and rtp_port > 0:
self.rtp_port_mgr.release(rtp_port)
# 关闭ZLM RTP服务器无论端口多少通过channel_id关闭
self._close_rtp_server(channel_id)
# 重置通道状态
with self.lock:
device = self.devices.get(client_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if channel:
channel.inviting = False
channel.rtp_port = 0
channel.allocated_rtp_port = 0
channel.call_id = ""
channel.streaming = False
channel.forward_state = 0
except Exception as cleanup_e:
self.logger.error(f"[GSS] ❌ 清理INVITE资源异常: {cleanup_e}")
return False, f"invite error: {str(e)}"
def request_bye(self, client_id, channel_id):
"""
发送BYE停止推流
Args:
client_id: 设备ID
channel_id: 通道ID
Returns:
tuple: (success: bool, msg: str)
"""
try:
# 查找设备和通道,并在锁内检查状态(避免竞态条件)
with self.lock:
device = self.devices.get(client_id)
if not device:
return False, "client not registered"
channel = self._find_channel(device.channels, channel_id)
if not channel:
# 通道不存在但仍需清理ZLM资源和端口兼容临时通道或已清除的通道
self.logger.debug(f"[GSS] ⚠️ 通道 {channel_id} 不在内存中,尝试清理资源")
self._close_rtp_server(channel_id)
# 从 pending_invites 中清理
with self.pending_invites_lock:
pending = self.pending_invites.pop(channel_id, None)
if pending:
self.rtp_port_mgr.release(pending['rtp_port'])
return True, "resource cleaned"
# 检查是否在推流(必须在锁内检查)
if not channel.streaming:
self.logger.debug(f"[GSS] ⚠️ 通道 {channel_id} 未在推流,但仍需清理资源")
# 即使未推流,也要清理可能的残留资源
self._close_rtp_server(channel_id)
with self.pending_invites_lock:
pending = self.pending_invites.pop(channel_id, None)
if pending:
self.rtp_port_mgr.release(pending['rtp_port'])
# 重置状态
channel.rtp_port = 0
channel.allocated_rtp_port = 0
channel.call_id = ""
channel.forward_state = 0
channel.inviting = False
return True, "resource cleaned"
# 保存需要的信息
call_id = channel.call_id
from_tag = channel.from_tag # 使用存储的from_tagINVITE时保存
to_tag = channel.to_tag # 使用存储的to_tag200 OK时保存
device_ip = device.ip
device_port = device.port
allocated_rtp_port = channel.allocated_rtp_port # 使用最初分配的端口
# 立即更新状态防止重复BYE
channel.rtp_port = 0
channel.allocated_rtp_port = 0
channel.call_id = ""
channel.from_tag = "" # 清除dialog信息
channel.to_tag = ""
channel.streaming = False
channel.forward_state = 0
channel.inviting = False # Bug 10修复重置inviting状态
# 使用_send_bye_for_channel发送BYE使用正确的dialog信息
self._send_bye_for_channel(device_ip, device_port, channel_id, call_id, from_tag, to_tag)
# 关闭ZLM RTP服务器与C++版本一致)
self._close_rtp_server(channel_id)
# 释放RTP端口使用最初分配的端口而不是ZLM返回的端口
if allocated_rtp_port > 0:
self.rtp_port_mgr.release(allocated_rtp_port)
with self.lock:
self.stats['total_byes'] += 1
return True, "success"
except Exception as e:
self.logger.error(f"[GSS] BYE请求异常: {e}")
# 异常时仍需确保资源释放状态已在锁内更新只需清理ZLM和端口
try:
# 关闭ZLM RTP服务器
self._close_rtp_server(channel_id)
# 释放RTP端口使用保存的allocated_rtp_port变量
if 'allocated_rtp_port' in locals() and allocated_rtp_port > 0:
self.rtp_port_mgr.release(allocated_rtp_port)
except Exception as cleanup_e:
self.logger.error(f"[GSS] ❌ 清理BYE资源异常: {cleanup_e}")
return False, f"bye error: {str(e)}"
def request_ptz(self, client_id, channel_id, ptz_type, val, device_ip=None, device_port=None):
"""
发送PTZ云台控制指令
Args:
client_id: 设备ID
channel_id: 通道ID
ptz_type: PTZ类型0-停止, 1-上, 2-下, 3-左, 4-右,
5-左上, 6-右上, 7-左下, 8-右下,
9-变倍+/放大, 10-变倍-/缩小,
11-聚焦+调远, 12-聚焦-调近, 13-光圈+放大, 14-光圈-缩小)
val: 速度/值 (0-255方向取水平/垂直速度变倍按1-15档折算)
device_ip: 设备IP可选分进程模式下本进程无实时注册表时由调用方传入
device_port: 设备SIP端口可选配合 device_ip
Returns:
tuple: (success: bool, msg: str)
"""
try:
# 查找设备和通道并在锁内更新SN避免竞态条件
with self.lock:
device = self.devices.get(client_id)
if device:
channel = self._find_channel(device.channels, channel_id)
if not channel:
return False, "channel not found"
# 在锁内更新SN
channel.sn += 1
sn = channel.sn
# 保存设备信息
if device.ip:
device_ip = device.ip
if device.port:
device_port = device.port
else:
# 分进程兜底:本进程无实时注册表(如 web 进程),
# 使用调用方从数据库传入的设备地址pull_stream_ip/port
if not device_ip or not device_port:
return False, "client not registered"
self._fallback_sn += 1
sn = self._fallback_sn
# 构建PTZ指令GB28181标准Hex编码
ptz_cmd = self._build_ptz_command(ptz_type, val)
# 构建XML消息体
body = (
f"<?xml version=\"1.0\"?>\r\n"
f"<ControlInfo>\r\n"
f"<CmdType>DeviceControl</CmdType>\r\n"
f"<SN>{sn}</SN>\r\n"
f"<DeviceID>{channel_id}</DeviceID>\r\n"
f"<PTZCmd>{ptz_cmd}</PTZCmd>\r\n"
f"</ControlInfo>\r\n"
)
# 构建MESSAGE请求
call_id = str(uuid.uuid4()).replace('-', '')
from_tag = str(random.randint(100000000, 999999999))
branch = f"z9hG4bK{random.randint(100000000, 999999999)}"
msg = (
f"MESSAGE sip:{channel_id}@{device_ip}:{device_port} SIP/2.0\r\n"
f"Via: SIP/2.0/{self._get_sip_transport()} {self.server_ip}:{self.server_port};rport;branch={branch}\r\n"
f"From: <sip:{self.server_id}@{self.realm}>;tag={from_tag}\r\n"
f"To: <sip:{channel_id}@{self.realm}>\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: 1 MESSAGE\r\n"
f"Max-Forwards: 70\r\n"
f"Contact: <sip:{self.server_id}@{self.server_ip}:{self.server_port}>\r\n"
f"Content-Type: Application/MANSCDP+xml\r\n"
f"Content-Length: {len(body)}\r\n"
f"\r\n"
f"{body}"
)
# 发送PTZ指令
addr = (device_ip, device_port)
send_ok = self._send_sip_response(msg, addr)
ptz_names = {0: '停止', 1: '', 2: '', 3: '', 4: '', 5: '左上',
6: '右上', 7: '左下', 8: '右下', 9: '放大/变倍+', 10: '缩小/变倍-',
11: '聚焦+', 12: '聚焦-', 13: '光圈+', 14: '光圈-'}
ptz_name = ptz_names.get(ptz_type, f'类型{ptz_type}')
if send_ok:
self.logger.debug(f"[GSS] 🎮 已发送PTZ指令: {channel_id} - {ptz_name} (val={val}) ptzCmd={ptz_cmd} -> {addr[0]}:{addr[1]}")
return True, "success"
self.logger.error(f"[GSS] 🎮 PTZ指令发送失败: {channel_id} - {ptz_name} -> {addr[0]}:{addr[1]}")
return False, f"ptz message send failed: {channel_id}"
except Exception as e:
self.logger.error(f"[GSS] PTZ请求异常: {e}")
return False, f"ptz error: {str(e)}"
def _build_ptz_command(self, ptz_type, val):
"""
构建PTZ控制指令GB/T 28181 PTZCmd 8字节Hex格式
帧格式: A5 0F 01 <指令码> <水平速度> <垂直速度> <变倍速度> <校验码>
校验码 = 第1~7字节累加后取低8位含 0xA5
指令码(字节4):
方向位: 右=0x01 左=0x02 下=0x04 上=0x08斜向为位或组合
变倍: 放大=0x10 缩小=0x20
聚焦/光圈(0x40族, 是否响应取决于设备固件):
聚焦+=0x44 聚焦-=0x48 光圈+=0x41 光圈-=0x42
速度: 字节5=水平速度(左右), 字节6=垂直速度(上下),
字节7高4位=变倍速度(0~15), 无动作的方向速度填0
说明: 方向/变倍均为“持续动作”,需另发 停止(0) 指令结束。
Args:
ptz_type: PTZ类型与 request_ptz 编号一致)
val: 速度/值 (0-255)
Returns:
str: 大写Hex编码的PTZ指令
"""
speed = min(max(int(val or 0), 0), 255) # 水平/垂直速度 0-255
# 变倍速度折算到高4位等级 1~15
zoom_level = min(max((speed + 7) // 8, 1), 15) if speed > 0 else 0
# 指令码映射
dir_cmd = {1: 0x08, 2: 0x04, 3: 0x02, 4: 0x01,
5: 0x0A, 6: 0x09, 7: 0x06, 8: 0x05} # 上/下/左/右/左上/右上/左下/右下
zoom_cmd = {9: 0x10, 10: 0x20} # 放大/缩小
fi_cmd = {11: 0x44, 12: 0x48, 13: 0x41, 14: 0x42} # 聚焦+/聚焦-/光圈+/光圈-
if ptz_type == 0:
cmd_byte, pan_speed, tilt_speed, zoom_byte = 0x00, 0, 0, 0x00
elif ptz_type in dir_cmd:
cmd_byte = dir_cmd[ptz_type]
pan_speed = speed if cmd_byte & 0x03 else 0 # 含左右方向才给水平速度
tilt_speed = speed if cmd_byte & 0x0C else 0 # 含上下方向才给垂直速度
zoom_byte = 0x00
elif ptz_type in zoom_cmd:
cmd_byte = zoom_cmd[ptz_type]
pan_speed, tilt_speed = 0, 0
zoom_byte = zoom_level << 4
elif ptz_type in fi_cmd:
cmd_byte = fi_cmd[ptz_type]
pan_speed, tilt_speed, zoom_byte = 0, 0, 0x00
else:
# 未知类型按停止处理,避免误动作
cmd_byte, pan_speed, tilt_speed, zoom_byte = 0x00, 0, 0, 0x00
ptz_bytes = bytes([0xA5, 0x0F, 0x01, cmd_byte, pan_speed, tilt_speed, zoom_byte])
# 计算校验和含帧头0xA5
ptz_bytes += bytes([sum(ptz_bytes) & 0xFF])
# 转换为大写Hex字符串
return ptz_bytes.hex().upper()