chore: import R0nY3n/LTE_manager main snapshot

This commit is contained in:
2026-07-20 16:35:15 +00:00
commit b8438bba61
48 changed files with 15676 additions and 0 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+849
View File
@@ -0,0 +1,849 @@
import serial
import serial.tools.list_ports
import sounddevice as sd
import numpy as np
import threading
import queue
import time
import re
import sys
import logging
import struct
from PyQt5.QtCore import QObject, pyqtSignal, QTimer
# 配置日志记录
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("PCM_Audio")
# PCM 音频参数 (SIM7600CE-T 使用的标准 PCM 格式)
# 按照文档说明: "USB audio PCM data format is 8K sample rate, 16 bit linear"
# 可以通过AT+CPCMFRM=1设置为16K采样率,但请确保模块和音频处理使用相同的采样率
PCM_SAMPLE_RATE = 8000 # 8kHz (默认模式,可通过AT+CPCMFRM=1设置为16kHz)
PCM_CHANNELS = 1 # 单声道
PCM_DTYPE = np.int16 # 16-bit 线性PCM
CHUNK_SIZE = 160 # 每次读取的样本数 (20ms @ 8kHz,更小的块大小可降低延迟)
BUFFER_SIZE = 8 # 增加缓冲区大小,提高音频稳定性
class PCMAudio(QObject):
status_changed = pyqtSignal(str) # 状态变化信号
def __init__(self):
super().__init__()
self.audio_port = None
self.audio_thread = None
self.play_thread = None
self.record_thread = None
self.terminating = False # 新增终止标志
self.is_running = False
self.call_active = False
self.port_name = None # 存储当前使用的端口名称
self.shutdown_requested = False # 替代QTimer的关闭请求标志
# 音频数据队列
self.play_queue = queue.Queue(maxsize=BUFFER_SIZE) # 播放队列
self.record_queue = queue.Queue(maxsize=BUFFER_SIZE) # 录音队列
# 音频流
self.output_stream = None
self.input_stream = None
def find_audio_port(self):
"""查找SIM7600CE的Audio端口 (通常是Audio 9001端口)"""
logger.info("正在查找SIM7600CE Audio端口...")
self.status_changed.emit("正在查找音频端口...")
ports = list(serial.tools.list_ports.comports())
for port in ports:
# 检查描述或设备ID中是否包含"Audio"和"9001"
if ('audio' in port.description.lower() or
'audio' in port.device.lower() or
'9001' in port.description):
logger.info(f"找到疑似音频端口: {port.device} - {port.description}")
self.status_changed.emit(f"找到音频端口: {port.device}")
return port.device
logger.warning("未找到SIM7600CE音频端口! 请确保设备已连接且驱动已安装。")
self.status_changed.emit("未找到音频端口, 通话将没有音频")
return None
def open_audio_port(self, port=None):
"""打开SIM7600CE的Audio端口"""
# 重置终止标志
self.terminating = False
# 先关闭之前可能打开的端口
if self.audio_port and self.audio_port.is_open:
try:
self.audio_port.close()
logger.info("关闭先前打开的音频端口")
except Exception as e:
logger.error(f"关闭先前端口时出错: {str(e)}")
self.audio_port = None
if port:
audio_port_name = port
else:
audio_port_name = self.find_audio_port()
if not audio_port_name:
logger.error("无法打开音频端口: 未找到端口")
self.status_changed.emit("无法打开音频端口")
return False
try:
# 使用更高的波特率921600以确保音频数据传输顺畅
self.audio_port = serial.Serial(
port=audio_port_name,
baudrate=921600, # 提高波特率到921600
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.1, # 非阻塞读取
rtscts=True, # 启用硬件流控制
write_timeout=0.5 # 设置写入超时
)
self.port_name = audio_port_name # 存储端口名称
logger.info(f"成功打开音频端口: {audio_port_name}, 波特率: 921600")
self.status_changed.emit(f"音频端口已打开: {audio_port_name}")
# 清空可能已有的数据
self.audio_port.reset_input_buffer()
self.audio_port.reset_output_buffer()
return True
except Exception as e:
logger.error(f"打开音频端口失败: {str(e)}")
self.status_changed.emit(f"打开音频端口失败: {str(e)[:50]}")
return False
def start_audio_processing(self):
"""启动音频处理"""
if not self.audio_port:
logger.error("未打开音频端口,无法启动音频处理")
self.status_changed.emit("未打开音频端口,无法启动音频处理")
return False
if self.is_running:
logger.warning("音频处理已经在运行")
return True
# 重置终止标志
self.terminating = False
# 初始化音频设备
try:
# 获取默认设备信息
devices = sd.query_devices()
default_output = sd.default.device[1]
default_input = sd.default.device[0]
logger.info(f"使用默认输出设备: {devices[default_output]['name']}")
logger.info(f"使用默认输入设备: {devices[default_input]['name']}")
self.status_changed.emit(f"使用音频设备: {devices[default_output]['name']}")
# 清空旧数据
if self.audio_port and self.audio_port.is_open:
self.audio_port.reset_input_buffer()
self.audio_port.reset_output_buffer()
# 清空队列
self._clear_audio_queues()
# 打开音频流
self.output_stream = sd.OutputStream(
samplerate=PCM_SAMPLE_RATE,
channels=PCM_CHANNELS,
dtype=PCM_DTYPE,
callback=self._audio_output_callback,
blocksize=CHUNK_SIZE,
latency='low' # 设置低延迟
)
self.input_stream = sd.InputStream(
samplerate=PCM_SAMPLE_RATE,
channels=PCM_CHANNELS,
dtype=PCM_DTYPE,
callback=self._audio_input_callback,
blocksize=CHUNK_SIZE,
latency='low' # 设置低延迟
)
# 启动音频流
self.output_stream.start()
self.input_stream.start()
# 设置运行标志
self.is_running = True
# 启动处理线程
self.audio_thread = threading.Thread(target=self._audio_port_thread, daemon=True)
self.audio_thread.name = "PCMAudioPortThread"
self.audio_thread.start()
# 启动播放线程
self.play_thread = threading.Thread(target=self._play_thread, daemon=True)
self.play_thread.name = "PCMAudioPlayThread"
self.play_thread.start()
# 启动录音线程
self.record_thread = threading.Thread(target=self._record_thread, daemon=True)
self.record_thread.name = "PCMAudioRecordThread"
self.record_thread.start()
logger.info("音频处理已启动")
self.status_changed.emit("音频处理已启动")
return True
except Exception as e:
logger.error(f"启动音频处理失败: {str(e)}")
self.status_changed.emit(f"启动音频处理失败: {str(e)[:50]}")
self._cleanup_resources()
return False
def _clear_audio_queues(self):
"""清空音频队列"""
# 清空播放队列
while not self.play_queue.empty():
try:
self.play_queue.get_nowait()
except Exception as e:
logger.error(f"清空播放队列出错: {str(e)}")
break
# 清空录音队列
while not self.record_queue.empty():
try:
self.record_queue.get_nowait()
except Exception as e:
logger.error(f"清空录音队列出错: {str(e)}")
break
logger.info("已清空音频队列")
def _cleanup_resources(self):
"""清理所有资源(在关闭或错误时调用)"""
# 停止和关闭音频流
if self.output_stream:
try:
self.output_stream.stop()
self.output_stream.close()
except Exception as e:
logger.error(f"关闭输出流出错: {str(e)}")
self.output_stream = None
if self.input_stream:
try:
self.input_stream.stop()
self.input_stream.close()
except Exception as e:
logger.error(f"关闭输入流出错: {str(e)}")
self.input_stream = None
# 清空队列
self._clear_audio_queues()
# 关闭音频端口
if self.audio_port and self.audio_port.is_open:
try:
self.audio_port.reset_input_buffer()
self.audio_port.reset_output_buffer()
self.audio_port.close()
logger.info(f"已关闭音频端口: {self.port_name}")
except Exception as e:
logger.error(f"关闭音频端口出错: {str(e)}")
self.audio_port = None
# 重置状态
self.is_running = False
self.call_active = False
def stop_audio_processing(self):
"""停止音频处理"""
if not self.is_running:
logger.info("音频处理已经停止,无需再次停止")
return
logger.info("正在停止音频处理...")
self.status_changed.emit("正在停止音频处理...")
# 设置终止标志,通知所有线程停止
self.terminating = True
self.call_active = False
self.is_running = False
self.shutdown_requested = False # 取消可能的关闭请求
# 立即清理所有资源,不再等待线程正常结束
self._cleanup_resources()
# 只在资源清理后尝试关闭线程
# 等待线程结束 - 使用更短的超时以防止阻塞
threads_to_wait = []
if self.audio_thread and self.audio_thread.is_alive():
logger.info("等待音频端口线程结束...")
threads_to_wait.append(('音频端口线程', self.audio_thread))
if self.play_thread and self.play_thread.is_alive():
logger.info("等待播放线程结束...")
threads_to_wait.append(('播放线程', self.play_thread))
if self.record_thread and self.record_thread.is_alive():
logger.info("等待录音线程结束...")
threads_to_wait.append(('录音线程', self.record_thread))
# 等待所有线程结束,每个线程最多等待0.5秒
for thread_name, thread in threads_to_wait:
thread.join(timeout=0.5)
if thread.is_alive():
logger.warning(f"{thread_name}未能正常结束")
# 重置线程变量
self.audio_thread = None
self.play_thread = None
self.record_thread = None
logger.info("音频处理已停止")
self.status_changed.emit("音频处理已停止")
def set_call_active(self, active):
"""设置通话状态"""
prev_state = self.call_active
self.call_active = active
if prev_state != active: # 只有状态改变时才记录和通知
logger.info(f"通话状态: {'活动' if active else '非活动'}")
self.status_changed.emit(f"通话音频状态: {'活动' if active else '非活动'}")
if active:
# 当状态从非活动变为活动时,清空缓冲区
if self.audio_port and self.audio_port.is_open:
try:
self.audio_port.reset_input_buffer()
self.audio_port.reset_output_buffer()
except Exception as e:
logger.error(f"重置音频缓冲区出错: {str(e)}")
# 清空音频队列
self._clear_audio_queues()
else:
# 状态从活动变为非活动时,开始直接关闭处理,不使用延迟机制
logger.info("通话状态变为非活动,准备关闭音频处理")
self.shutdown_requested = True # 设置关闭请求标志,用于代替QTimer
# 启动单独的关闭线程,避免在当前线程中执行可能阻塞的操作
shutdown_thread = threading.Thread(target=self._delayed_shutdown_thread, daemon=True)
shutdown_thread.start()
def _delayed_shutdown_thread(self):
"""在单独线程中执行延迟关闭,避免阻塞主线程"""
try:
# 等待短暂时间,确保所有挂起的操作都有时间完成
time.sleep(0.5)
# 检查是否仍然需要关闭
if not self.call_active and self.shutdown_requested:
logger.info("执行延迟关闭音频处理")
# 执行停止处理,但不在线程中调用self.stop_audio_processing,而是发送信号
self.status_changed.emit("音频处理关闭中...")
# 设置所有状态标志
self.terminating = True
# 等待安全时间后强制清理资源
time.sleep(0.5)
logger.info("关闭音频流和端口")
# 关闭和清理资源
if self.output_stream:
try:
self.output_stream.stop()
self.output_stream.close()
self.output_stream = None
except Exception as e:
logger.error(f"关闭输出流出错: {str(e)}")
if self.input_stream:
try:
self.input_stream.stop()
self.input_stream.close()
self.input_stream = None
except Exception as e:
logger.error(f"关闭输入流出错: {str(e)}")
# 重置状态
self.is_running = False
self.shutdown_requested = False
logger.info("延迟关闭完成")
self.status_changed.emit("音频处理已关闭")
except Exception as e:
logger.error(f"延迟关闭线程出错: {str(e)}")
def _audio_output_callback(self, outdata, frames, time, status):
"""音频输出回调(从队列获取PCM数据并输出到扬声器)"""
if status:
logger.warning(f"音频输出状态: {status}")
if not self.call_active or self.terminating:
# 如果没有通话或正在终止,输出静音
outdata.fill(0)
return
try:
if not self.play_queue.empty():
# 从队列获取PCM数据
data = self.play_queue.get_nowait()
# 确保数据长度匹配
if len(data) < frames:
# 数据不足,补零
padding = np.zeros((frames - len(data), PCM_CHANNELS), dtype=PCM_DTYPE)
data = np.vstack((data, padding))
elif len(data) > frames:
# 数据过多,截断
data = data[:frames]
# 复制到输出缓冲区
outdata[:] = data
else:
# 队列为空,输出静音
outdata.fill(0)
except Exception as e:
logger.error(f"音频输出错误: {str(e)}")
outdata.fill(0)
def _audio_input_callback(self, indata, frames, time, status):
"""音频输入回调(从麦克风获取PCM数据并发送到队列)"""
if status:
logger.warning(f"音频输入状态: {status}")
if not self.call_active or self.terminating:
# 如果没有通话或正在终止,不处理输入
return
try:
# 将麦克风数据放入录音队列
if not self.record_queue.full():
self.record_queue.put_nowait(indata.copy())
except Exception as e:
logger.error(f"音频输入错误: {str(e)}")
def _audio_port_thread(self):
"""音频端口处理线程(读取PCM数据 - 模块到扬声器)"""
# PCM数据解析缓冲区
buffer = bytearray()
bytes_per_frame = CHUNK_SIZE * PCM_CHANNELS * 2 # 16-bit = 2 bytes
last_log_time = 0
frame_count = 0
last_buffer_check_time = 0
processed_frames_total = 0
last_data_received_time = time.time()
silent_frames_count = 0
frame_sync_attempts = 0
recovered_frames = 0
# 设置调试计数器
debug_frame_counter = 0
debug_signal_detection = False
signal_level_history = []
max_signal_level = 0
# 设置基准音量和增益值 - 增加接收增益确保清晰听到对方声音
base_gain = 5.0 # 更高的基准增益,确保足够听到对方声音
noise_threshold = 30 # 降低噪声阈值以确保不会过滤掉有效信号
# 设置静音帧阈值,超过该数量未收到有效数据帧时发出警告
SILENT_FRAMES_THRESHOLD = 50
logger.info("音频端口处理线程已启动")
logger.info(f"PCM参数: 采样率={PCM_SAMPLE_RATE}Hz, 通道数={PCM_CHANNELS}, 每帧字节数={bytes_per_frame}")
logger.info(f"音频输出设置: 基准增益={base_gain}x,噪声阈值={noise_threshold}")
# 发送模式测试数据
try:
# 向模块发送一些测试数据,验证发送通道
if self.audio_port and self.audio_port.is_open:
test_data = np.zeros((CHUNK_SIZE, PCM_CHANNELS), dtype=np.int16)
test_data[:10, 0] = 16000 # 前10个样本设置为16000,形成短脉冲
test_bytes = test_data.tobytes()
self.audio_port.write(test_bytes)
logger.info(f"已发送测试音频数据: {len(test_bytes)}字节")
except Exception as e:
logger.error(f"发送测试数据出错: {str(e)}")
while self.is_running and not self.terminating:
try:
if not self.audio_port or not self.audio_port.is_open:
time.sleep(0.1)
continue
# 如果不在通话状态,快速检查并继续循环
if not self.call_active:
# 清空缓冲区并睡眠
if buffer:
buffer = bytearray()
time.sleep(0.1)
continue
# 读取串口数据
try:
available = self.audio_port.in_waiting
if available > 0:
# 读取所有可用数据
data = self.audio_port.read(available)
if data:
# 更新最后接收数据时间
last_data_received_time = time.time()
silent_frames_count = 0 # 重置静音帧计数
# 添加到缓冲区
buffer.extend(data)
# 每1000帧记录一次调试信息
debug_frame_counter += 1
if debug_frame_counter >= 1000:
# 记录详细状态信息
logger.info(f"[读取] 音频缓冲区: {len(buffer)}字节, 可用数据: {available}字节")
logger.info(f"[读取] 已处理总帧数: {processed_frames_total}, 缓冲区状态: {len(buffer)/bytes_per_frame:.1f}")
if signal_level_history:
avg_level = sum(signal_level_history) / len(signal_level_history)
logger.info(f"[读取] 平均信号电平: {avg_level:.2f}, 最大信号电平: {max_signal_level:.2f}")
if avg_level > 0:
logger.info(f"[读取] 检测到音频信号,增益设置为{base_gain}x")
debug_frame_counter = 0
else:
# 长时间未收到数据,可能需要检查通信状态
current_time = time.time()
if current_time - last_data_received_time > 0.5: # 半秒未收到数据
silent_frames_count += 1
if silent_frames_count > SILENT_FRAMES_THRESHOLD and self.call_active:
logger.warning("[读取] 长时间未收到音频数据,检查通信状态")
silent_frames_count = 0 # 重置计数,避免重复警告
# 尝试重置串口缓冲区,重新启动数据流
if self.audio_port and self.audio_port.is_open:
try:
# 先发送一些数据,可能帮助触发接收
test_data = np.zeros((CHUNK_SIZE, PCM_CHANNELS), dtype=np.int16)
test_data[:10, 0] = 16000 # 前10个样本设置为16000
test_bytes = test_data.tobytes()
self.audio_port.write(test_bytes)
# 重置输入缓冲区
self.audio_port.reset_input_buffer()
logger.info("[读取] 已重置音频输入缓冲区并发送测试数据")
except Exception as e:
logger.error(f"[读取] 重置音频缓冲区出错: {str(e)}")
# 定期检查缓冲区大小,避免缓冲区无限增长
current_time = time.time()
if current_time - last_buffer_check_time > 1.0: # 每秒检查一次
# 检查缓冲区大小
buffer_size = len(buffer)
# 如果缓冲区不是帧大小的整数倍,尝试帧同步
remainder = buffer_size % bytes_per_frame
if remainder != 0 and buffer_size > bytes_per_frame:
# 尝试通过查找头部模式同步帧
frame_sync_attempts += 1
if frame_sync_attempts % 10 == 0: # 每10次尝试记录一次
logger.warning(f"[读取] 帧同步尝试: {frame_sync_attempts}次, 缓冲区大小: {buffer_size}字节, 余数: {remainder}字节")
# 丢弃余数字节或补齐帧
if remainder < bytes_per_frame / 2:
# 余数小于半帧,丢弃余数
buffer = buffer[:-remainder]
else:
# 余数大于半帧,补齐为完整帧(用0填充)
padding_size = bytes_per_frame - remainder
buffer.extend(bytes(padding_size))
recovered_frames += 1
buffer_frames = len(buffer) / bytes_per_frame
if buffer_size > bytes_per_frame * 30: # 如果缓冲区积累太多数据
logger.warning(f"[读取] 缓冲区积累过多数据 ({buffer_size} 字节, {buffer_frames:.1f} 帧), 保留最后10帧数据")
# 只保留最后部分数据
buffer = buffer[-bytes_per_frame * 10:]
# 如果音频缓冲区长时间为空并且通话活动,记录警告
if buffer_size == 0 and self.call_active and processed_frames_total > 0:
logger.warning("[读取] 音频缓冲区为空但通话仍在进行,可能缺少音频数据")
last_buffer_check_time = current_time
# 当缓冲区数据足够时处理
processed = 0
while len(buffer) >= bytes_per_frame and self.call_active and not self.terminating:
# 提取一帧数据
frame_data = buffer[:bytes_per_frame]
buffer = buffer[bytes_per_frame:]
processed += 1
processed_frames_total += 1
try:
# 将SIM7600CE的PCM数据转换为音频数据
pcm_data = np.frombuffer(frame_data, dtype=np.int16).reshape(-1, PCM_CHANNELS)
# 计算信号电平用于自动增益和检测有效信号
signal_level = np.abs(pcm_data).mean()
# 在首次接收到高于阈值的信号时记录
if signal_level > noise_threshold and not signal_level_history:
logger.info(f"[读取] 首次检测到信号: 电平={signal_level:.2f}")
# 过滤掉异常值,确保数据有效
if signal_level < 32000: # 有效PCM数据不应超过此值
# 更新信号历史
signal_level_history.append(signal_level)
if len(signal_level_history) > 50: # 保留50帧的历史
signal_level_history.pop(0)
# 记录最大信号电平(用于调试)
if signal_level > max_signal_level:
max_signal_level = signal_level
if not debug_signal_detection and signal_level > 100:
logger.info(f"[读取] 检测到新的最大信号电平: {max_signal_level:.2f}")
debug_signal_detection = True
# 噪声消除 - 如果信号电平低于噪声阈值,视为噪声
if signal_level < noise_threshold:
# 对于非常低的信号(噪声),应用非常小的增益
# 但仍保留一部分,以保持连续性
pcm_data = pcm_data * 0.05 # 保留5%的信号
else:
# 为了确保足够的音量,使用较高的基准增益
# 让对方的声音更加清晰
pcm_data = np.clip(pcm_data * base_gain, -32700, 32700).astype(np.int16)
# 放入播放队列
if not self.play_queue.full() and not self.terminating:
self.play_queue.put_nowait(pcm_data)
frame_count += 1
else:
# 信号电平异常,可能是帧同步问题
logger.warning(f"[读取] 异常信号电平: {signal_level}, 可能帧同步问题")
# 每隔一段时间记录一次性能日志
current_time = time.time()
if current_time - last_log_time > 5.0: # 每5秒记录一次
avg_signal = np.mean(signal_level_history) if signal_level_history else 0
logger.info(f"[读取] 已处理 {frame_count} 帧PCM数据,平均信号电平: {avg_signal:.2f}")
last_log_time = current_time
frame_count = 0
except Exception as e:
logger.error(f"[读取] 处理PCM数据帧错误: {str(e)}")
# 出错时清空缓冲区,避免继续处理错误数据
buffer = bytearray()
break
except Exception as e:
logger.error(f"[读取] 读取音频端口数据出错: {str(e)}")
time.sleep(0.1)
# 如果当前没有更多数据可读,短暂休眠避免CPU占用
if available == 0:
time.sleep(0.01) # 10ms延迟,提供更好的响应性
else:
# 有数据处理时使用更短的延迟
time.sleep(0.001)
except Exception as e:
logger.error(f"[读取] 音频端口处理错误: {str(e)}")
time.sleep(0.1)
# 线程结束前清空缓冲区及统计数据
buffer = bytearray()
signal_level_history = []
logger.info(f"[读取] 音频端口处理线程已退出,总处理帧数: {processed_frames_total}, 恢复帧: {recovered_frames}")
def _play_thread(self):
"""播放线程(处理PCM数据队列)"""
logger.info("播放线程已启动")
while self.is_running and not self.terminating:
try:
# 线程主要工作在回调中完成,这里只需要保持线程运行
time.sleep(0.1)
except Exception as e:
logger.error(f"播放线程错误: {str(e)}")
time.sleep(0.1)
logger.info("播放线程已退出")
def _record_thread(self):
"""录音线程(发送PCM数据到串口 - 麦克风到模块)"""
logger.info("[发送] 录音线程已启动")
# 记录最近的数据包大小,用于调试
packet_sizes = []
last_log_time = 0
sent_packets_count = 0
total_bytes_sent = 0
last_packet_sent_time = time.time()
# 引入随机数生成器用于加入测试音频
np.random.seed()
# 采样率和块大小
sample_rate = PCM_SAMPLE_RATE # 8kHz
chunk_size = CHUNK_SIZE # 160个样本,即20ms@8kHz
# 初始化降噪参数
noise_floor = 80 # 噪声阈值 - 降低以确保捕获更多人声
voice_gain = 4.0 # 人声增益 - 增加以确保声音传输清晰
noise_gate_enabled = True # 启用噪声门控
logger.info(f"[发送] 麦克风设置: 启用噪声门控={noise_gate_enabled}, 噪声阈值={noise_floor}, 人声增益={voice_gain}x")
# 创建测试音频信号(1kHz正弦波)用于向模块发送
test_audio_enabled = False # 默认关闭测试音频
test_tone_freq = 1000 # 1kHz
test_tone_samples = np.arange(chunk_size)
test_tone = (16000 * np.sin(2 * np.pi * test_tone_freq * test_tone_samples / sample_rate)).astype(np.int16)
test_tone = test_tone.reshape(-1, PCM_CHANNELS)
# 强制发送计时器,确保即使麦克风无输入,仍定期发送数据包
force_send_interval = 0.020 # 20ms,确保平滑音频
zero_frame = np.zeros((chunk_size, PCM_CHANNELS), dtype=np.int16)
# 加入启动时的初始测试音频
try:
if self.audio_port and self.audio_port.is_open:
# 发送测试音频波形序列
for i in range(5): # 发送5帧测试音频
self.audio_port.write(test_tone.tobytes())
sent_packets_count += 1
time.sleep(0.02) # 20ms间隔
logger.info(f"[发送] 已发送初始测试音频: 5帧")
except Exception as e:
logger.error(f"[发送] 发送初始测试音频出错: {str(e)}")
while self.is_running and not self.terminating:
try:
if not self.call_active or not self.audio_port or not self.audio_port.is_open or self.terminating:
time.sleep(0.1)
continue
current_time = time.time()
# 是否应该强制发送(超过定期发送间隔)
should_force_send = (current_time - last_packet_sent_time) > force_send_interval
# 从录音队列获取数据
try:
# 使用短超时,避免长时间阻塞
pcm_data = None
try:
pcm_data = self.record_queue.get(timeout=0.01)
except queue.Empty:
# 队列为空,如果需要强制发送则生成静音帧
if should_force_send:
if test_audio_enabled:
# 使用测试音频而不是静音
pcm_data = test_tone.copy()
logger.debug("[发送] 生成测试音频帧发送")
else:
# 使用静音帧
pcm_data = zero_frame.copy()
logger.debug("[发送] 生成静音帧发送")
else:
continue
# 如果还没有数据,跳过当前循环
if pcm_data is None:
continue
# 计算当前音量级别
volume_level = np.abs(pcm_data).mean()
# 偶尔发送测试音频以确保通信通道开放
if sent_packets_count % 1000 == 0: # 每1000个包发送一次测试音频
# 临时替换为测试音频
pcm_data = test_tone.copy()
logger.info(f"[发送] 发送测试音频帧: #{sent_packets_count}")
# 应用噪声门控和增益处理
if noise_gate_enabled:
if volume_level < noise_floor:
# 低于阈值的信号视为背景噪音,强烈抑制但不完全消除
# 这有助于减少背景噪音传输到对方
pcm_data = pcm_data * 0.02 # 只保留2%原始信号
else:
# 高于阈值的信号应用更高增益提升人声清晰度
# 确保声音传输到对方足够清晰
pcm_data = np.clip(pcm_data * voice_gain, -32700, 32700).astype(np.int16)
else:
# 如果不启用噪声门控,仍然应用增益
pcm_data = np.clip(pcm_data * voice_gain, -32700, 32700).astype(np.int16)
# 将PCM数据转换为字节发送到串口(确保使用小端字节序)
bytes_data = pcm_data.astype(np.int16).tobytes()
# 更新发送计时
last_packet_sent_time = current_time
# 记录数据包大小用于调试
packet_sizes.append(len(bytes_data))
if len(packet_sizes) > 20:
packet_sizes.pop(0)
# 每5秒记录一次发送数据统计
if current_time - last_log_time > 5.0:
if packet_sizes:
avg_size = sum(packet_sizes) / len(packet_sizes)
logger.info(f"[发送] 音频发送: 平均数据包大小 {avg_size:.2f} 字节, 已发送 {sent_packets_count} 个数据包 ({total_bytes_sent/1024:.2f} KB)")
last_log_time = current_time
# 检查连接和终止状态
if self.audio_port and self.audio_port.is_open and not self.terminating:
# 确保立即发送数据
bytes_sent = self.audio_port.write(bytes_data)
sent_packets_count += 1
total_bytes_sent += bytes_sent
# 调试:检查发送的字节数
if bytes_sent != len(bytes_data):
logger.warning(f"[发送] 音频数据发送不完整: {bytes_sent}/{len(bytes_data)}字节")
# 确保数据立即发送
self.audio_port.flush()
except Exception as e:
logger.error(f"[发送] 发送PCM数据错误: {str(e)}")
time.sleep(0.01)
except Exception as e:
logger.error(f"[发送] 录音线程错误: {str(e)}")
time.sleep(0.1)
# 清理记录数据
packet_sizes = []
logger.info(f"[发送] 录音线程已退出,总发送数据包: {sent_packets_count}, 总发送字节: {total_bytes_sent/1024:.2f} KB")
# 单独测试功能
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication
import sys
app = QApplication(sys.argv)
# 测试音频功能
audio = PCMAudio()
port = audio.find_audio_port()
if port:
print(f"找到音频端口: {port}")
if audio.open_audio_port(port):
print("成功打开音频端口")
audio.start_audio_processing()
print("按Enter键模拟通话开始...")
input()
audio.set_call_active(True)
print("通话已开始,现在可以说话...按Enter键结束通话")
input()
audio.set_call_active(False)
print("通话已结束")
# 等待延迟关闭完成
time.sleep(4)
sys.exit(0)
else:
print("未找到音频端口")
sys.exit(1)
+226
View File
@@ -0,0 +1,226 @@
import os
import sys
import subprocess
import shutil
import glob
import time
from PIL import Image, ImageDraw
def build_executable():
"""Build executable with PyInstaller"""
print("Building LTE Manager executable...")
# 尝试终止可能正在运行的LTE_Manager.exe进程
try:
print("尝试终止可能正在运行的LTE_Manager.exe进程...")
subprocess.call("taskkill /F /IM LTE_Manager.exe", shell=True)
# 等待进程完全终止
time.sleep(2)
except Exception as e:
print(f"终止进程时出错 (这可能是正常的,如果进程不存在): {str(e)}")
# 保存数据库文件
db_backup_dir = "_db_backup_temp"
db_files = []
# 查找用户主目录下的数据库文件
user_home = os.path.expanduser('~')
lte_db_dir = os.path.join(user_home, '.LTE')
user_db_path = os.path.join(lte_db_dir, 'lte_data.db')
if os.path.exists("dist") or os.path.exists(user_db_path):
print("备份数据库文件...")
# 创建临时备份目录
if not os.path.exists(db_backup_dir):
os.makedirs(db_backup_dir)
# 查找所有SQLite数据库文件
dist_db_files = glob.glob("dist/*.db") if os.path.exists("dist") else []
# 备份找到的dist目录中的文件
for db_file in dist_db_files:
db_filename = os.path.basename(db_file)
backup_path = os.path.join(db_backup_dir, db_filename)
print(f"备份数据库 (旧版位置): {db_filename}")
shutil.copy2(db_file, backup_path)
# 备份用户主目录中的数据库
if os.path.exists(user_db_path):
backup_path = os.path.join(db_backup_dir, 'lte_data.db')
print(f"备份数据库 (用户目录): {user_db_path}")
shutil.copy2(user_db_path, backup_path)
# Check if PyInstaller is installed
try:
import PyInstaller
except ImportError:
print("PyInstaller not found. Installing...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "pyinstaller"])
# Create spec file
spec_content = """
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('default.png', '.'),
('running.png', '.'),
('error.png', '.'),
('README.md', '.'),
('incoming_call.py', '.'),
('audio.py', '.')
],
hiddenimports=['PyQt5.sip', 'sounddevice', 'numpy'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='LTE_Manager',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='default.png',
)
"""
with open("lte_manager.spec", "w") as f:
f.write(spec_content)
# Run PyInstaller
print("Running PyInstaller...")
try:
# 等待一秒确保所有进程都释放了文件
time.sleep(1)
# 先检查目标EXE文件是否存在并可以被删除
target_exe = os.path.join("dist", "LTE_Manager.exe")
if os.path.exists(target_exe):
try:
# 尝试先重命名文件,这是检查文件是否被锁定的一种方法
temp_name = os.path.join("dist", f"LTE_Manager_old_{int(time.time())}.exe")
os.rename(target_exe, temp_name)
# 然后删除重命名后的文件
os.remove(temp_name)
print("成功删除旧的可执行文件")
except Exception as rename_error:
print(f"无法删除旧的可执行文件: {str(rename_error)}")
print("尝试使用Python 3.10来构建...")
# 尝试使用Python 3.10,如果可用
python310_path = r"G:\Python310\python.exe"
if os.path.exists(python310_path):
subprocess.check_call([
python310_path,
"-m",
"PyInstaller",
"lte_manager.spec",
"--clean"
])
print("使用Python 3.10构建完成")
build_success = True
return
# 运行PyInstaller
subprocess.check_call([
sys.executable,
"-m",
"PyInstaller",
"lte_manager.spec",
"--clean"
])
print("PyInstaller执行完成")
build_success = True
except Exception as e:
print(f"构建过程中出错: {str(e)}")
build_success = False
# 恢复数据库文件
if os.path.exists(db_backup_dir):
print("恢复数据库文件...")
backups = glob.glob(os.path.join(db_backup_dir, "*.db"))
for backup_file in backups:
db_filename = os.path.basename(backup_file)
# 确保用户目录存在
if not os.path.exists(lte_db_dir):
os.makedirs(lte_db_dir)
# 恢复到用户主目录
user_db_path = os.path.join(lte_db_dir, db_filename)
print(f"恢复数据库到用户目录: {user_db_path}")
shutil.copy2(backup_file, user_db_path)
# 为了向后兼容,也恢复到dist目录
if os.path.exists("dist"):
dist_path = os.path.join("dist", db_filename)
print(f"恢复数据库到dist目录: {dist_path}")
shutil.copy2(backup_file, dist_path)
# 删除临时备份目录
shutil.rmtree(db_backup_dir)
# Create a simple README file (if it doesn't exist)
if not os.path.exists("README.md"):
with open("README.md", "w") as f:
f.write("""# LTE Manager
一个用于管理LTE模块的应用程序,支持电话和短信功能。
## 功能
- 电话呼叫管理(拨打/接听电话)
- 短信管理(发送/接收/解码短信,包括中文)
- 模块配置和状态监控
- 串口配置
- 来电接听对话框
- PCM音频通话支持
## 使用方法
1. 连接LTE模块到计算机
2. 在设置选项卡中配置串口设置
3. 连接到模块
4. 使用电话和短信功能
## 系统托盘
应用程序可以最小化到系统托盘。双击托盘图标可以显示/隐藏主窗口。
""")
if build_success:
print("Executable built successfully!")
print("You can find it in the 'dist' folder.")
else:
print("构建过程未完成,请检查错误信息。")
if __name__ == "__main__":
build_executable()
+1
View File
@@ -0,0 +1 @@
+195
View File
@@ -0,0 +1,195 @@
import sqlite3
import os
from datetime import datetime
class LTEDatabase:
def __init__(self, db_path=None):
"""初始化数据库连接
Args:
db_path: 数据库文件路径,如未指定则使用用户目录下的.LTE/lte_data.db
"""
if db_path is None:
# 默认使用用户主目录下的.LTE文件夹
user_home = os.path.expanduser('~')
lte_dir = os.path.join(user_home, '.LTE')
if not os.path.exists(lte_dir):
os.makedirs(lte_dir)
self.db_path = os.path.join(lte_dir, 'lte_data.db')
else:
self.db_path = db_path
print(f"使用数据库: {self.db_path}")
self.conn = None
self.cursor = None
self.connect()
self.create_tables()
def connect(self):
"""Connect to the database"""
try:
# Create database directory if it doesn't exist
db_dir = os.path.dirname(self.db_path)
if db_dir and not os.path.exists(db_dir):
os.makedirs(db_dir)
# Connect to database
self.conn = sqlite3.connect(self.db_path)
self.cursor = self.conn.cursor()
return True
except Exception as e:
print(f"Database connection error: {str(e)}")
return False
def close(self):
"""Close database connection"""
if self.conn:
self.conn.close()
def create_tables(self):
"""Create necessary tables if they don't exist"""
try:
# Call history table
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS call_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_number TEXT NOT NULL,
call_type TEXT NOT NULL,
duration INTEGER DEFAULT 0,
timestamp TEXT NOT NULL,
notes TEXT
)
''')
# SMS history table
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS sms_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_number TEXT NOT NULL,
message TEXT NOT NULL,
sms_type TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'sent'
)
''')
self.conn.commit()
return True
except Exception as e:
print(f"Table creation error: {str(e)}")
return False
def add_call(self, phone_number, call_type, duration=0, notes=None):
"""Add call record to database
call_type: 'incoming', 'outgoing', 'missed'
"""
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"添加通话记录: {phone_number}, 类型: {call_type}, 持续时间: {duration}秒, 备注: {notes}")
self.cursor.execute(
"INSERT INTO call_history (phone_number, call_type, duration, timestamp, notes) VALUES (?, ?, ?, ?, ?)",
(phone_number, call_type, duration, timestamp, notes)
)
self.conn.commit()
return self.cursor.lastrowid
except Exception as e:
print(f"添加通话记录出错: {str(e)}")
return None
def add_sms(self, phone_number, message, sms_type, status='sent'):
"""Add SMS record to database
sms_type: 'incoming', 'outgoing'
status: 'sent', 'failed', 'received', 'read'
"""
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.cursor.execute(
"INSERT INTO sms_history (phone_number, message, sms_type, timestamp, status) VALUES (?, ?, ?, ?, ?)",
(phone_number, message, sms_type, timestamp, status)
)
self.conn.commit()
return self.cursor.lastrowid
except Exception as e:
print(f"Add SMS error: {str(e)}")
return None
def get_call_history(self, limit=50, offset=0, phone_number=None):
"""Get call history from database"""
try:
query = "SELECT * FROM call_history"
params = []
if phone_number:
query += " WHERE phone_number = ?"
params.append(phone_number)
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
self.cursor.execute(query, params)
return self.cursor.fetchall()
except Exception as e:
print(f"Get call history error: {str(e)}")
return []
def get_sms_history(self, limit=50, offset=0, phone_number=None, sms_type=None):
"""Get SMS history from database"""
try:
query = "SELECT * FROM sms_history"
params = []
conditions = []
if phone_number:
conditions.append("phone_number = ?")
params.append(phone_number)
if sms_type:
conditions.append("sms_type = ?")
params.append(sms_type)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
self.cursor.execute(query, params)
return self.cursor.fetchall()
except Exception as e:
print(f"Get SMS history error: {str(e)}")
return []
def update_sms_status(self, sms_id, status):
"""Update SMS status"""
try:
self.cursor.execute(
"UPDATE sms_history SET status = ? WHERE id = ?",
(status, sms_id)
)
self.conn.commit()
return True
except Exception as e:
print(f"Update SMS status error: {str(e)}")
return False
def delete_call(self, call_id):
"""Delete call record"""
try:
self.cursor.execute("DELETE FROM call_history WHERE id = ?", (call_id,))
self.conn.commit()
return True
except Exception as e:
print(f"Delete call error: {str(e)}")
return False
def delete_sms(self, sms_id):
"""Delete SMS record"""
try:
self.cursor.execute("DELETE FROM sms_history WHERE id = ?", (sms_id,))
self.conn.commit()
return True
except Exception as e:
print(f"Delete SMS error: {str(e)}")
return False
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+524
View File
@@ -0,0 +1,524 @@
import os
import subprocess
import threading
import time
import serial
import serial.tools.list_ports
import logging
import tempfile
from PyQt5.QtCore import QObject, pyqtSignal
# 配置日志记录
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("FFmpeg_Audio")
# PCM 音频参数
PCM_SAMPLE_RATE = 8000 # 8kHz (默认模式,可通过AT+CPCMFRM=1设置为16kHz)
PCM_CHANNELS = 1 # 单声道
FFMPEG_PATH = "D:\\ffmpeg\\ffmpeg.exe" # FFmpeg可执行文件路径
FFPLAY_PATH = "D:\\ffmpeg\\ffplay.exe" # FFPlay可执行文件路径
class FFmpegAudio(QObject):
status_changed = pyqtSignal(str) # 状态变化信号
def __init__(self):
super().__init__()
self.audio_port = None
self.port_name = None
self.is_running = False
self.call_active = False
self.terminating = False
# FFmpeg进程
self.ffmpeg_input_process = None # 从串口读取到扬声器
self.ffmpeg_output_process = None # 从麦克风到串口
# 管理线程
self.monitor_thread = None
# 临时文件
self.temp_dir = tempfile.mkdtemp()
logger.info(f"创建临时目录: {self.temp_dir}")
def find_audio_port(self):
"""查找SIM7600CE的Audio端口 (通常是Audio 9001端口)"""
logger.info("正在查找SIM7600CE Audio端口...")
self.status_changed.emit("正在查找音频端口...")
ports = list(serial.tools.list_ports.comports())
for port in ports:
# 检查描述或设备ID中是否包含"Audio"和"9001"
if ('audio' in port.description.lower() or
'audio' in port.device.lower() or
'9001' in port.description):
logger.info(f"找到疑似音频端口: {port.device} - {port.description}")
self.status_changed.emit(f"找到音频端口: {port.device}")
return port.device
logger.warning("未找到SIM7600CE音频端口! 请确保设备已连接且驱动已安装。")
self.status_changed.emit("未找到音频端口, 通话将没有音频")
return None
def open_audio_port(self, port=None):
"""打开SIM7600CE的Audio端口"""
# 重置终止标志
self.terminating = False
# 先关闭之前可能打开的端口
if self.audio_port and self.audio_port.is_open:
try:
self.audio_port.close()
logger.info("关闭先前打开的音频端口")
except Exception as e:
logger.error(f"关闭先前端口时出错: {str(e)}")
self.audio_port = None
if port:
audio_port_name = port
else:
audio_port_name = self.find_audio_port()
if not audio_port_name:
logger.error("无法打开音频端口: 未找到端口")
self.status_changed.emit("无法打开音频端口")
return False
try:
# 使用更高的波特率921600以确保音频数据传输顺畅
self.audio_port = serial.Serial(
port=audio_port_name,
baudrate=921600, # 提高波特率到921600
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.1, # 非阻塞读取
rtscts=True, # 启用硬件流控制
write_timeout=0.5 # 设置写入超时
)
self.port_name = audio_port_name # 存储端口名称
logger.info(f"成功打开音频端口: {audio_port_name}, 波特率: 921600")
self.status_changed.emit(f"音频端口已打开: {audio_port_name}")
# 清空可能已有的数据
self.audio_port.reset_input_buffer()
self.audio_port.reset_output_buffer()
return True
except Exception as e:
logger.error(f"打开音频端口失败: {str(e)}")
self.status_changed.emit(f"打开音频端口失败: {str(e)[:50]}")
return False
def _cleanup_resources(self):
"""清理所有资源(在关闭或错误时调用)"""
logger.info("清理音频资源...")
# 停止FFmpeg进程
try:
if self.ffmpeg_input_process:
logger.info("正在停止FFmpeg输入进程...")
self.ffmpeg_input_process.terminate()
self.ffmpeg_input_process.wait(timeout=1)
self.ffmpeg_input_process = None
except Exception as e:
logger.error(f"停止FFmpeg输入进程出错: {str(e)}")
try:
if self.ffmpeg_output_process:
logger.info("正在停止FFmpeg输出进程...")
self.ffmpeg_output_process.terminate()
self.ffmpeg_output_process.wait(timeout=1)
self.ffmpeg_output_process = None
except Exception as e:
logger.error(f"停止FFmpeg输出进程出错: {str(e)}")
# 关闭音频端口
if self.audio_port and self.audio_port.is_open:
try:
self.audio_port.reset_input_buffer()
self.audio_port.reset_output_buffer()
self.audio_port.close()
logger.info(f"已关闭音频端口: {self.port_name}")
except Exception as e:
logger.error(f"关闭音频端口出错: {str(e)}")
self.audio_port = None
# 重置状态
self.is_running = False
self.call_active = False
logger.info("音频资源清理完成")
def stop_audio_processing(self):
"""停止音频处理"""
if not self.is_running:
logger.info("音频处理已经停止,无需再次停止")
return
logger.info("正在停止音频处理...")
self.status_changed.emit("正在停止音频处理...")
# 设置终止标志
self.terminating = True
self.call_active = False
self.is_running = False
# 清理资源
self._cleanup_resources()
# 等待监控线程结束
if self.monitor_thread and self.monitor_thread.is_alive():
self.monitor_thread.join(timeout=1)
self.monitor_thread = None
logger.info("音频处理已停止")
self.status_changed.emit("音频处理已停止")
def set_call_active(self, active):
"""设置通话状态"""
prev_state = self.call_active
self.call_active = active
if prev_state != active: # 只有状态改变时才记录和通知
logger.info(f"通话状态: {'活动' if active else '非活动'}")
self.status_changed.emit(f"通话音频状态: {'活动' if active else '非活动'}")
if active:
# 当状态从非活动变为活动时,确保FFmpeg进程在运行
self._ensure_ffmpeg_running()
else:
# 状态从活动变为非活动时,停止FFmpeg进程
logger.info("通话状态变为非活动,准备关闭音频处理")
# 启动单独的关闭线程,避免在当前线程中执行可能阻塞的操作
threading.Thread(target=self._delayed_shutdown, daemon=True).start()
def _delayed_shutdown(self):
"""延迟关闭处理"""
try:
# 等待短暂时间确保所有挂起的操作完成
time.sleep(0.5)
if not self.call_active and not self.terminating:
logger.info("执行延迟关闭音频处理")
self._cleanup_resources()
except Exception as e:
logger.error(f"延迟关闭出错: {str(e)}")
def _ensure_ffmpeg_running(self):
"""确保FFmpeg进程在运行"""
if not self.is_running or not self.audio_port or not self.audio_port.is_open:
logger.warning("音频处理未启动或端口未打开,无法确保FFmpeg运行")
return
try:
# 检查并启动FFmpeg进程
if not self.ffmpeg_input_process or self.ffmpeg_input_process.poll() is not None:
self._start_ffmpeg_input()
if not self.ffmpeg_output_process or self.ffmpeg_output_process.poll() is not None:
self._start_ffmpeg_output()
except Exception as e:
logger.error(f"确保FFmpeg运行时出错: {str(e)}")
def _start_ffmpeg_input(self):
"""启动从串口到扬声器的FFmpeg进程"""
if not self.audio_port or not self.audio_port.is_open:
logger.error("音频端口未打开,无法启动FFmpeg输入进程")
return False
try:
# 配置FFmpeg命令行
# 从串口读取PCM数据并播放到扬声器
input_pipe_path = os.path.join(self.temp_dir, "input_pipe.pcm")
# 检查并确保管道创建
if os.path.exists(input_pipe_path):
try:
os.remove(input_pipe_path)
except:
pass
# 创建命令行
cmd = [
FFPLAY_PATH,
"-f", "s16le", # 16位有符号整数,小端序
"-ar", str(PCM_SAMPLE_RATE), # 采样率
"-ac", str(PCM_CHANNELS), # 通道数
"-i", "pipe:0", # 从标准输入读取
"-loglevel", "warning", # 只显示警告和错误
"-af", "volume=5", # 增加音量
"-nodisp" # 不显示视频窗口
]
logger.info(f"启动FFmpeg输入进程: {' '.join(cmd)}")
# 启动FFmpeg进程
self.ffmpeg_input_process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
bufsize=0 # 无缓冲
)
# 启动从串口读取数据的线程
threading.Thread(
target=self._read_from_serial_to_ffmpeg,
daemon=True
).start()
logger.info("FFmpeg输入进程启动成功")
return True
except Exception as e:
logger.error(f"启动FFmpeg输入进程失败: {str(e)}")
return False
def _read_from_serial_to_ffmpeg(self):
"""从串口读取数据并发送到FFmpeg"""
buffer_size = 320 # 每次读取的字节数 (160个16位样本)
# 用于统计的变量
bytes_read = 0
last_log_time = time.time()
frames_sent = 0
logger.info("[读取] 开始从串口读取PCM数据到FFmpeg")
while self.is_running and self.call_active and not self.terminating:
try:
if not self.audio_port or not self.audio_port.is_open:
time.sleep(0.1)
continue
if not self.ffmpeg_input_process or self.ffmpeg_input_process.poll() is not None:
logger.warning("[读取] FFmpeg输入进程已结束,停止读取")
break
# 读取数据
if self.audio_port.in_waiting > 0:
data = self.audio_port.read(min(buffer_size, self.audio_port.in_waiting))
if data:
bytes_read += len(data)
frames_sent += 1
# 发送到FFmpeg
try:
self.ffmpeg_input_process.stdin.write(data)
self.ffmpeg_input_process.stdin.flush()
except Exception as e:
logger.error(f"[读取] 发送数据到FFmpeg出错: {str(e)}")
break
# 输出统计信息
current_time = time.time()
if current_time - last_log_time > 5.0: # 每5秒记录一次
logger.info(f"[读取] 已读取 {bytes_read/1024:.2f} KB PCM数据,发送 {frames_sent}")
last_log_time = current_time
# 检查是否有长时间未收到数据
if self.audio_port.in_waiting == 0:
time.sleep(0.01) # 短暂休眠
except Exception as e:
logger.error(f"[读取] 从串口读取数据出错: {str(e)}")
time.sleep(0.1)
# 关闭FFmpeg输入
try:
if self.ffmpeg_input_process and self.ffmpeg_input_process.stdin:
self.ffmpeg_input_process.stdin.close()
except:
pass
logger.info(f"[读取] 停止从串口读取,总计读取 {bytes_read/1024:.2f} KB PCM数据")
def _start_ffmpeg_output(self):
"""启动从麦克风到串口的FFmpeg进程"""
if not self.audio_port or not self.audio_port.is_open:
logger.error("音频端口未打开,无法启动FFmpeg输出进程")
return False
try:
# 配置FFmpeg命令行
# 从麦克风录制PCM数据并发送到串口
cmd = [
FFMPEG_PATH,
"-f", "dshow", # DirectShow输入
"-i", "audio=@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\\wave_{DFDF5B7D-7597-4E7C-84D6-CFF1F7379E35}", # 默认麦克风
"-ar", str(PCM_SAMPLE_RATE), # 采样率
"-ac", str(PCM_CHANNELS), # 通道数
"-loglevel", "warning", # 只显示警告和错误
"-af", "volume=4,highpass=f=200,lowpass=f=3000,compand=0.3:0.8:-90/-60:-60/-40:-40/-30:-20/-20:0/-10:0.2", # 音频处理
"-f", "s16le", # 16位有符号整数,小端序
"pipe:1" # 输出到标准输出
]
logger.info(f"启动FFmpeg输出进程: {' '.join(cmd)}")
# 启动FFmpeg进程
self.ffmpeg_output_process = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0 # 无缓冲
)
# 启动将FFmpeg输出写入串口的线程
threading.Thread(
target=self._write_from_ffmpeg_to_serial,
daemon=True
).start()
logger.info("FFmpeg输出进程启动成功")
return True
except Exception as e:
logger.error(f"启动FFmpeg输出进程失败: {str(e)}")
return False
def _write_from_ffmpeg_to_serial(self):
"""从FFmpeg读取数据并写入串口"""
buffer_size = 320 # 每次读取/写入的字节数
# 用于统计的变量
bytes_written = 0
last_log_time = time.time()
frames_sent = 0
logger.info("[发送] 开始从FFmpeg发送PCM数据到串口")
while self.is_running and self.call_active and not self.terminating:
try:
if not self.audio_port or not self.audio_port.is_open:
time.sleep(0.1)
continue
if not self.ffmpeg_output_process or self.ffmpeg_output_process.poll() is not None:
logger.warning("[发送] FFmpeg输出进程已结束,停止写入")
break
# 读取数据
data = self.ffmpeg_output_process.stdout.read(buffer_size)
if data:
# 写入串口
self.audio_port.write(data)
self.audio_port.flush()
bytes_written += len(data)
frames_sent += 1
# 输出统计信息
current_time = time.time()
if current_time - last_log_time > 5.0: # 每5秒记录一次
logger.info(f"[发送] 已发送 {bytes_written/1024:.2f} KB PCM数据,发送 {frames_sent}")
last_log_time = current_time
# 短暂休眠,避免CPU占用过高
time.sleep(0.01)
except Exception as e:
logger.error(f"[发送] 写入数据到串口出错: {str(e)}")
time.sleep(0.1)
logger.info(f"[发送] 停止写入串口,总计发送 {bytes_written/1024:.2f} KB PCM数据")
def start_audio_processing(self):
"""启动音频处理"""
if not self.audio_port:
logger.error("未打开音频端口,无法启动音频处理")
self.status_changed.emit("未打开音频端口,无法启动音频处理")
return False
if self.is_running:
logger.warning("音频处理已经在运行")
return True
# 重置终止标志
self.terminating = False
try:
# 检查FFmpeg是否存在
if not os.path.exists(FFMPEG_PATH) or not os.path.exists(FFPLAY_PATH):
logger.error(f"找不到FFmpeg可执行文件: {FFMPEG_PATH}{FFPLAY_PATH}")
self.status_changed.emit("找不到FFmpeg可执行文件")
return False
# 设置运行标志
self.is_running = True
# 启动监控线程
self.monitor_thread = threading.Thread(target=self._monitor_thread, daemon=True)
self.monitor_thread.name = "FFmpegMonitorThread"
self.monitor_thread.start()
logger.info("音频处理已启动")
self.status_changed.emit("音频处理已启动")
return True
except Exception as e:
logger.error(f"启动音频处理失败: {str(e)}")
self.status_changed.emit(f"启动音频处理失败: {str(e)[:50]}")
self._cleanup_resources()
return False
def _monitor_thread(self):
"""监控线程,确保FFmpeg进程正常运行"""
logger.info("启动FFmpeg监控线程")
while self.is_running and not self.terminating:
try:
# 如果通话活动且FFmpeg进程需要启动
if self.call_active:
self._ensure_ffmpeg_running()
# 检查进程状态
if self.ffmpeg_input_process and self.ffmpeg_input_process.poll() is not None:
logger.warning(f"FFmpeg输入进程已退出,状态码: {self.ffmpeg_input_process.poll()}")
if self.call_active and not self.terminating:
logger.info("尝试重启FFmpeg输入进程")
self._start_ffmpeg_input()
if self.ffmpeg_output_process and self.ffmpeg_output_process.poll() is not None:
logger.warning(f"FFmpeg输出进程已退出,状态码: {self.ffmpeg_output_process.poll()}")
if self.call_active and not self.terminating:
logger.info("尝试重启FFmpeg输出进程")
self._start_ffmpeg_output()
# 短暂休眠
time.sleep(1.0)
except Exception as e:
logger.error(f"监控线程出错: {str(e)}")
time.sleep(1.0)
logger.info("FFmpeg监控线程退出")
# 单独测试功能
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication
import sys
app = QApplication(sys.argv)
# 测试FFmpeg音频功能
audio = FFmpegAudio()
port = audio.find_audio_port()
if port:
print(f"找到音频端口: {port}")
if audio.open_audio_port(port):
print("成功打开音频端口")
audio.start_audio_processing()
print("按Enter键模拟通话开始...")
input()
audio.set_call_active(True)
print("通话已开始,现在可以说话...按Enter键结束通话")
input()
audio.set_call_active(False)
print("通话已结束")
# 等待延迟关闭完成
time.sleep(4)
sys.exit(0)
else:
print("未找到音频端口")
sys.exit(1)
+233
View File
@@ -0,0 +1,233 @@
import sys
from PyQt5.QtWidgets import QApplication, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QSize
from PyQt5.QtGui import QPixmap, QIcon
import winsound
import threading
import time
import os
class IncomingCallDialog(QDialog):
# 定义信号
answer_signal = pyqtSignal()
reject_signal = pyqtSignal()
def __init__(self, phone_number, caller_name=None, parent=None):
super().__init__(parent)
self.phone_number = phone_number
self.caller_name = caller_name or "未知联系人"
self.display_name = caller_name or phone_number
self.init_ui()
self.setAttribute(Qt.WA_DeleteOnClose, True)
# 初始化时间计时器
self.start_time = time.time()
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_time)
self.timer.start(500) # 每500毫秒更新一次
# 15秒后自动关闭对话框(如果未接听),模拟未接来电
self.auto_close_timer = QTimer(self)
self.auto_close_timer.timeout.connect(self.auto_reject)
self.auto_close_timer.setSingleShot(True)
self.auto_close_timer.start(15000) # 15秒后自动关闭
# 设置窗口标志
self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint)
# 记录对话框状态
self.answer_clicked = False
self.reject_clicked = False
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电对话框已创建: {self.display_name}")
def init_ui(self):
self.setWindowTitle("来电")
self.setStyleSheet("""
QDialog {
background-color: #F5F5F5;
border: 1px solid #CCCCCC;
border-radius: 10px;
}
QLabel {
color: #333333;
font-size: 14px;
}
QPushButton {
border-radius: 20px;
font-size: 14px;
font-weight: bold;
padding: 10px;
}
QPushButton#answer {
background-color: #4CAF50;
color: white;
}
QPushButton#answer:hover {
background-color: #45a049;
}
QPushButton#reject {
background-color: #f44336;
color: white;
}
QPushButton#reject:hover {
background-color: #d32f2f;
}
""")
# 主布局
layout = QVBoxLayout(self)
# 联系人头像
avatar_label = QLabel()
avatar_path = os.path.join("assets", "user.png") # 默认头像
if os.path.exists(avatar_path):
pixmap = QPixmap(avatar_path)
avatar_label.setPixmap(pixmap.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation))
avatar_label.setAlignment(Qt.AlignCenter)
layout.addWidget(avatar_label)
# 显示标题(来电)
title_label = QLabel("来电")
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #333333;")
layout.addWidget(title_label)
# 显示来电号码或联系人名称
self.name_label = QLabel(self.display_name)
self.name_label.setAlignment(Qt.AlignCenter)
self.name_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 10px;")
layout.addWidget(self.name_label)
# 显示号码(如果有联系人名称)
if self.caller_name:
number_label = QLabel(self.phone_number)
number_label.setAlignment(Qt.AlignCenter)
number_label.setStyleSheet("font-size: 12px; color: #555555;")
layout.addWidget(number_label)
# 显示振铃时间
self.time_label = QLabel("正在响铃...")
self.time_label.setAlignment(Qt.AlignCenter)
self.time_label.setStyleSheet("font-size: 12px; color: #555555; margin-top: 5px;")
layout.addWidget(self.time_label)
# 按钮布局
button_layout = QHBoxLayout()
# 接听按钮
self.answer_button = QPushButton()
self.answer_button.setIcon(QIcon(os.path.join("assets", "answer.png")))
self.answer_button.setIconSize(QSize(30, 30))
self.answer_button.setObjectName("answer")
self.answer_button.setFixedSize(60, 60)
self.answer_button.clicked.connect(self.accept_call)
button_layout.addWidget(self.answer_button)
# 拒绝按钮
self.reject_button = QPushButton()
self.reject_button.setIcon(QIcon(os.path.join("assets", "hangup.png")))
self.reject_button.setIconSize(QSize(30, 30))
self.reject_button.setObjectName("reject")
self.reject_button.setFixedSize(60, 60)
self.reject_button.clicked.connect(self.reject_call)
button_layout.addWidget(self.reject_button)
layout.addLayout(button_layout)
self.setLayout(layout)
self.setFixedSize(300, 350)
# 将对话框移到屏幕中央
screen_geometry = self.screen().availableGeometry()
x = (screen_geometry.width() - self.width()) // 2
y = (screen_geometry.height() - self.height()) // 2
self.move(x, y)
def update_time(self):
"""更新来电持续时间"""
elapsed = int(time.time() - self.start_time)
minutes, seconds = divmod(elapsed, 60)
self.time_label.setText(f"已振铃 {minutes:02d}:{seconds:02d}")
def accept_call(self):
"""处理接听按钮点击"""
if self.answer_clicked: # 防止重复点击
return
self.answer_clicked = True
self.auto_close_timer.stop() # 停止自动拒绝计时器
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 用户点击接听按钮: {self.display_name}")
# 发送接听信号
self.answer_signal.emit()
self.timer.stop()
self.accept() # 关闭对话框
def reject_call(self):
"""处理拒绝按钮点击"""
if self.reject_clicked: # 防止重复点击
return
self.reject_clicked = True
self.auto_close_timer.stop() # 停止自动拒绝计时器
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 用户点击拒绝按钮: {self.display_name}")
# 发送拒绝信号
self.reject_signal.emit()
self.timer.stop()
self.reject() # 关闭对话框
def auto_reject(self):
"""自动拒绝来电(未接听超时)"""
if not self.answer_clicked and not self.reject_clicked:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电自动拒绝(超时): {self.display_name}")
self.reject_clicked = True
# 发送拒绝信号
self.reject_signal.emit()
self.timer.stop()
self.reject() # 关闭对话框
def closeEvent(self, event):
"""处理对话框关闭事件"""
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电对话框关闭: {self.display_name}")
self.timer.stop()
self.auto_close_timer.stop()
# 如果是通过"X"按钮关闭的,没有点击任何按钮,则视为拒绝
if not self.answer_clicked and not self.reject_clicked:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 窗口被直接关闭,视为拒绝")
self.reject_signal.emit()
super().closeEvent(event)
def show_incoming_call(caller_number):
"""显示来电对话框并返回用户选择(True表示接听,False表示拒绝)"""
app = QApplication.instance()
if not app:
app = QApplication(sys.argv)
# 确保没有遗留的来电对话框
for widget in QApplication.topLevelWidgets():
if isinstance(widget, IncomingCallDialog):
print(f"发现正在显示的来电对话框,关闭它: {widget.display_name}")
widget.close()
widget.deleteLater()
# 创建并显示新的来电对话框
dialog = IncomingCallDialog(caller_number)
result = dialog.exec_()
# 记录用户选择
user_choice = dialog.answer_clicked
# 确保对话框被释放
dialog.deleteLater()
return user_choice
if __name__ == "__main__":
# 测试来电对话框
result = show_incoming_call("+8613800138000")
print(f"用户选择了{'接听' if result else '拒绝'}来电")
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+916
View File
@@ -0,0 +1,916 @@
import sys
import os
import time
import threading
import re
from PyQt5.QtWidgets import (QApplication, QMainWindow, QTabWidget, QWidget,
QVBoxLayout, QHBoxLayout, QLabel, QStatusBar, QMessageBox,
QSystemTrayIcon, QMenu, QAction)
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QColor
import traceback
# 使用PyInstaller打包时的资源文件注意事项:
# ----------------------------------
# 1. 图标文件应在spec文件中添加为附加数据:
# a = Analysis(...,
# datas=[
# ('default.png', '.'),
# ('running.png', '.'),
# ('error.png', '.')
# ],
# ...)
#
# 2. 数据库文件将自动保存在用户主目录的.LTE文件夹中
#
# 3. 其他资源文件也应通过datas参数添加,例如声音文件等
from phone_sms_tab import PhoneSmsTab
from settings_tab import SettingsTab
from lte_manager import LTEManager
from database import LTEDatabase
from sound_utils import SoundManager
from audio import PCMAudio
from ffmpeg_audio import FFmpegAudio # 导入新的FFmpeg音频处理类
from incoming_call import show_incoming_call
class LTEToolApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("LTE Tool")
self.resize(800, 600)
start_time = time.strftime('%Y-%m-%d %H:%M:%S')
print(f"{start_time} - LTE Tool 应用程序启动")
# 添加更新周期计数器,用于控制不同信息的更新频率
# 初始化为1,确保首次连接时执行完整的信息获取
self.update_counter = 1
# 添加应用退出标志,用于区分最小化到托盘和退出程序
self.is_exiting = False
# 添加来电对话框标志,防止重复显示来电界面
self.incoming_call_dialog_visible = False
self.current_incoming_call_number = None
self._incoming_call_dialog = None
# 加载图标文件
self.load_icons()
# 设置应用图标
self.setWindowIcon(self.default_icon)
# 创建系统托盘图标
self.tray_icon = QSystemTrayIcon(self)
self.tray_icon.setIcon(self.default_icon) # 初始使用默认图标
self.tray_icon.setToolTip("LTE Tool - 未连接")
# 判断是否使用FFmpeg
self.use_ffmpeg = False # 设置为False,禁用所有音频处理
# PCM音频处理器(已禁用)
self.audio_processor = None
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 已禁用PCM音频处理")
# 设置系统托盘菜单和显示图标
self.setup_tray_icon()
# 打印系统托盘状态信息
print("系统托盘可用:", QSystemTrayIcon.isSystemTrayAvailable())
print("托盘图标可见:", self.tray_icon.isVisible())
# 创建 LTE 管理器
self.lte_manager = LTEManager()
# 创建数据库路径 - 使用用户主目录下的.LTE文件夹
user_home = os.path.expanduser('~')
lte_dir = os.path.join(user_home, '.LTE')
if not os.path.exists(lte_dir):
os.makedirs(lte_dir)
db_path = os.path.join(lte_dir, 'lte_data.db')
print(f"数据库路径: {db_path}")
# 创建数据库
self.database = LTEDatabase(db_path=db_path)
# 创建声音管理器
self.sound_manager = SoundManager()
# 创建主窗口部件和布局
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.main_layout = QVBoxLayout(self.central_widget)
# 创建标签页部件
self.tab_widget = QTabWidget()
self.main_layout.addWidget(self.tab_widget)
# 创建标签页
self.phone_sms_tab = PhoneSmsTab(self.lte_manager, self.database, self.sound_manager)
self.settings_tab = SettingsTab(self.lte_manager)
# 创建GitHub链接标签页
self.github_tab = QWidget()
github_layout = QVBoxLayout(self.github_tab)
# 添加GitHub链接标签
github_label = QLabel("访问GitHub项目页面获取最新版本和更新:")
github_label.setAlignment(Qt.AlignCenter)
github_layout.addWidget(github_label)
# 添加GitHub链接按钮
github_link = QLabel('<a href="https://github.com/R0nY3n/LTE_manager">https://github.com/R0nY3n/LTE_manager</a>')
github_link.setAlignment(Qt.AlignCenter)
github_link.setOpenExternalLinks(True) # 允许打开外部链接
github_link.setTextInteractionFlags(Qt.TextBrowserInteraction) # 允许文本交互
github_layout.addWidget(github_link)
# 添加说明文本
info_label = QLabel("欢迎在GitHub上提交问题、建议或贡献代码!")
info_label.setAlignment(Qt.AlignCenter)
github_layout.addWidget(info_label)
# 添加空白区域
github_layout.addStretch()
# 添加标签页
self.tab_widget.addTab(self.phone_sms_tab, "电话和短信")
self.tab_widget.addTab(self.settings_tab, "设置")
self.tab_widget.addTab(self.github_tab, "GitHub")
# 状态栏部件
self.status_carrier = QLabel("运营商: 未连接")
self.status_phone = QLabel("电话: 不可用")
self.status_network = QLabel("网络: 未连接")
self.status_signal = QLabel("信号: 不可用")
self.audio_status_label = QLabel("音频: 未初始化")
self.call_status_label = QLabel("通话: 无通话") # 添加通话状态标签
# 添加部件到状态栏
self.statusBar().addWidget(self.status_carrier)
self.statusBar().addWidget(self.status_phone)
self.statusBar().addWidget(self.status_network)
self.statusBar().addWidget(self.status_signal)
self.statusBar().addWidget(self.audio_status_label)
self.statusBar().addWidget(self.call_status_label) # 添加到状态栏
# 现在可以安全地更新连接状态(初始为未连接)
self.update_connection_status(False)
# 更新状态计时器 - 增加更长的更新间隔
self.status_timer = QTimer()
self.status_timer.timeout.connect(self.update_status_bar)
self.status_timer.start(10000) # 增加到10秒更新一次(从5秒改为10秒)
# 通话状态检查计时器 - 修改为不定期检查模式
self.call_status_timer = QTimer()
self.call_status_timer.timeout.connect(self.check_call_status)
# 不再固定间隔调用check_call_status
# self.call_status_timer.start(1000) # 每秒检查一次通话状态
# 添加通话状态检查标志,用于控制何时检查通话状态
self.should_check_call_status = False
self.call_check_count = 0
self.max_call_checks = 3 # 最多连续检查3次
# 连接信号
self.lte_manager.status_changed.connect(self.on_status_changed)
# 连接短信接收信号以显示通知
self.lte_manager.sms_received.connect(self.on_sms_received_notification)
self.lte_manager.call_received.connect(self.on_call_received_notification)
# 连接通话结束信号
self.lte_manager.call_ended.connect(self.on_call_ended)
# 连接PCM音频状态信号
self.lte_manager.pcm_audio_status.connect(self.on_pcm_audio_status_changed)
# 尝试自动连接(如果启用)
QTimer.singleShot(1000, self.try_auto_connect)
def initialize_audio_processor(self):
"""初始化PCM音频处理器(已禁用实际处理)"""
try:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 音频处理已禁用,仅创建空壳")
# 不再实际初始化音频处理器,但保留接口兼容性
self.audio_processor = None
self.audio_status_label.setText("音频: 已禁用")
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 初始化音频处理器出错: {str(e)}")
self.audio_processor = None
# 确保异常处理中的状态更新也是安全的
try:
self.audio_status_label.setText("音频: 初始化失败")
except:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 无法更新音频状态标签")
def on_pcm_audio_status_changed(self, registered):
"""处理PCM音频注册状态变化"""
try:
# PCM音频已注册,只记录状态但不处理音频
if registered:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频已注册,但不执行音频处理(已禁用)")
self.audio_status_label.setText("音频: PCM已注册(处理已禁用)")
else:
# PCM音频已取消注册,只记录状态
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频已注销")
self.audio_status_label.setText("音频: 非活动")
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频状态变化处理出错: {str(e)}")
try:
self.audio_status_label.setText("音频: 错误")
except:
pass
def try_auto_connect(self):
"""尝试自动连接到LTE模块"""
# 调用设置标签页的自动连接方法
if hasattr(self, 'settings_tab') and self.settings_tab:
self.settings_tab.try_auto_connect()
def load_icons(self):
"""加载应用图标和状态图标"""
# 获取应用程序资源目录
if getattr(sys, 'frozen', False):
# 如果是打包后的可执行文件
base_dir = os.path.dirname(sys.executable)
# 创建临时资源目录用于PyInstaller
temp_dir = getattr(sys, '_MEIPASS', base_dir)
resource_dir = temp_dir
else:
# 如果是开发环境
resource_dir = os.path.dirname(os.path.abspath(__file__))
# 加载状态图标
self.default_icon = None # 默认图标 - 未连接时使用
self.running_icon = None # 运行图标 - 连接成功时使用
self.error_icon = None # 错误图标 - 连接错误时使用
# 定义图标路径
default_icon_path = os.path.join(resource_dir, "default.png")
running_icon_path = os.path.join(resource_dir, "running.png")
error_icon_path = os.path.join(resource_dir, "error.png")
# 加载默认图标 (default.png)
if os.path.exists(default_icon_path):
self.default_icon = QIcon(default_icon_path)
self.app_icon = self.default_icon # 默认应用图标
print(f"成功加载默认图标: {default_icon_path}")
else:
# 创建默认图标作为备用
print(f"找不到默认图标文件: {default_icon_path},使用内置图标")
default_pixmap = QPixmap(32, 32)
default_pixmap.fill(QColor(100, 149, 237)) # 康乃馨蓝色
self.default_icon = QIcon(default_pixmap)
self.app_icon = self.default_icon
# 加载运行图标 (running.png)
if os.path.exists(running_icon_path):
self.running_icon = QIcon(running_icon_path)
print(f"成功加载运行图标: {running_icon_path}")
else:
# 创建运行图标作为备用
print(f"找不到运行图标文件: {running_icon_path},使用内置图标")
running_pixmap = QPixmap(32, 32)
running_pixmap.fill(QColor(60, 179, 113)) # 中等海洋绿
self.running_icon = QIcon(running_pixmap)
# 加载错误图标 (error.png)
if os.path.exists(error_icon_path):
self.error_icon = QIcon(error_icon_path)
print(f"成功加载错误图标: {error_icon_path}")
else:
# 创建错误图标作为备用
print(f"找不到错误图标文件: {error_icon_path},使用内置图标")
error_pixmap = QPixmap(32, 32)
error_pixmap.fill(QColor(220, 20, 60)) # 猩红色
self.error_icon = QIcon(error_pixmap)
def setup_tray_icon(self):
"""设置系统托盘图标和菜单"""
# 创建托盘菜单
tray_menu = QMenu()
# 添加操作
show_action = QAction("显示", self)
show_action.triggered.connect(self.show)
tray_menu.addAction(show_action)
hide_action = QAction("隐藏", self)
hide_action.triggered.connect(self.hide)
tray_menu.addAction(hide_action)
tray_menu.addSeparator()
# 添加音频已禁用的通知项
audio_disabled_action = QAction("音频处理已禁用", self)
audio_disabled_action.setEnabled(False) # 不可点击
tray_menu.addAction(audio_disabled_action)
tray_menu.addSeparator()
# 连接状态操作(不可点击)
self.connection_status_action = QAction("未连接", self)
self.connection_status_action.setEnabled(False)
tray_menu.addAction(self.connection_status_action)
tray_menu.addSeparator()
exit_action = QAction("退出", self)
exit_action.triggered.connect(self._exit_application)
tray_menu.addAction(exit_action)
# 设置托盘菜单
self.tray_icon.setContextMenu(tray_menu)
# 连接信号
self.tray_icon.activated.connect(self.on_tray_icon_activated)
# 显示托盘图标
self.tray_icon.show()
def on_tray_icon_activated(self, reason):
"""处理托盘图标激活"""
if reason == QSystemTrayIcon.DoubleClick:
if self.isVisible():
self.hide()
else:
self.show()
self.activateWindow()
def on_sms_received_notification(self, sender, timestamp, message):
"""收到短信时显示通知"""
if self.tray_icon.isVisible():
# 如果消息太长则截断
display_message = message[:50] + "..." if len(message) > 50 else message
self.tray_icon.showMessage(
"新短信",
f"发件人: {sender}\n{display_message}",
QSystemTrayIcon.Information,
5000 # 显示5秒
)
def on_call_received_notification(self, caller_number):
"""收到来电时显示通知和接听选项"""
try:
# 检查是否已有来电对话框正在显示,避免重复显示
if self.incoming_call_dialog_visible:
# 如果是同一个号码的来电,忽略此次通知
if self.current_incoming_call_number == caller_number:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 已有来电对话框显示中,忽略重复通知: {caller_number}")
return
else:
# 如果是新号码,可能是之前的通知没有正确清理
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到新来电,但旧对话框未关闭,强制清理: {self.current_incoming_call_number} -> {caller_number}")
# 继续处理新来电,旧对话框会在接听或拒绝时自动关闭
print(f"收到来电: {caller_number}")
# 设置当前来电号码和对话框状态
self.current_incoming_call_number = caller_number
self.incoming_call_dialog_visible = True
# 确保应用程序窗口可见
self.show()
self.activateWindow()
# 播放来电铃声
self.sound_manager.play_incoming_call()
# 显示系统通知
if self.tray_icon.isVisible():
self.tray_icon.showMessage(
"来电",
f"号码: {caller_number}",
QSystemTrayIcon.Information,
5000 # 显示5秒
)
# 立即在数据库中记录来电
self.database.add_call(caller_number, None, "未接来电", 0)
# 立即显示来电对话框 - 不再使用QTimer延迟
self._show_incoming_call_dialog(caller_number)
# 设置应当检查通话状态的标志,并启动计时器
self.should_check_call_status = True
self.call_check_count = 0
if not self.call_status_timer.isActive():
self.call_status_timer.start(1000) # 开始每秒检查一次通话状态
except Exception as e:
print(f"处理来电通知时出错: {str(e)}")
# 确保铃声停止
self.sound_manager.stop_incoming_call()
# 重置来电对话框状态
self.incoming_call_dialog_visible = False
self.current_incoming_call_number = None
def _show_incoming_call_dialog(self, phone_number, caller_name=None):
"""显示来电对话框"""
try:
# 如果当前已经有来电对话框,先关闭它
if self._incoming_call_dialog is not None and self._incoming_call_dialog.isVisible():
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 关闭已有的来电对话框")
self._ensure_ringtone_stopped()
self._incoming_call_dialog.close()
self._incoming_call_dialog = None
# 检查通话状态,确保确实有来电
calls = self.lte_manager.get_call_status()
has_incoming_call = False
for call in calls:
if call.get('stat') == 4 and call.get('dir') == 1: # 来电中(MT)
has_incoming_call = True
break
if not has_incoming_call:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 没有检测到来电,取消显示对话框")
self._ensure_ringtone_stopped()
return
# 查找联系人信息
if caller_name is None:
contact = self.phone_sms_tab.contacts_tab.find_contact_by_number(phone_number)
caller_name = contact["name"] if contact else None
# 记录通话信息到数据库
call_type = "未接来电" # 初始设置为未接,后续根据用户操作修改
self.database.add_call(phone_number, caller_name, call_type, 0)
# 播放来电铃声
self.sound_manager.play_incoming_call()
# 创建并显示对话框
self._incoming_call_dialog = IncomingCallDialog(
phone_number,
caller_name,
parent=self
)
# 连接信号到槽
self._incoming_call_dialog.answer_signal.connect(
lambda: self._on_answer_call(phone_number, caller_name)
)
self._incoming_call_dialog.reject_signal.connect(
lambda: self._on_reject_call(phone_number, caller_name)
)
# 连接对话框关闭信号,确保铃声停止
self._incoming_call_dialog.finished.connect(self._ensure_ringtone_stopped)
# 显示对话框
self._incoming_call_dialog.show()
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 显示来电对话框出错: {str(e)}")
traceback.print_exc()
# 确保在异常情况下也停止铃声
self._ensure_ringtone_stopped()
# 重置对话框状态
self._incoming_call_dialog = None
def _on_answer_call(self, phone_number, caller_name=None):
"""处理接听来电"""
try:
# 1. 立即停止铃声
self._ensure_ringtone_stopped()
# 2. 尝试接听电话
result = self.lte_manager.answer_call()
# 设置应当检查通话状态的标志,并启动计时器
self.should_check_call_status = True
self.call_check_count = 0
if not self.call_status_timer.isActive():
self.call_status_timer.start(1000) # 开始每秒检查一次通话状态
# 3. 检查通话状态,确认是否实际接通(即使API返回失败)
time.sleep(0.5) # 给模块一点时间更新状态
calls = self.lte_manager.get_call_status()
call_connected = False
for call in calls:
if call.get('stat') in [0, 1] and call.get('dir') == 1: # 活动或保持的呼入通话
call_connected = True
break
if result or call_connected:
# 4. 修改数据库中的通话记录类型为"已接来电"
self.database.update_call_type(phone_number, "已接来电")
# 5. 更新UI状态
self.phone_sms_tab.add_to_call_log(f"已接听来电: {phone_number}")
self.phone_sms_tab.refresh_call_log()
# 6. 再次检查通话状态,确认通话是否仍然活跃
calls = self.lte_manager.get_call_status()
is_call_active = False
for call in calls:
if call.get('stat') in [0, 1]: # 活动或保持状态
is_call_active = True
break
if not is_call_active:
# 如果通话已结束,确保再次停止铃声
self._ensure_ringtone_stopped()
else:
# 通知接听失败
QMessageBox.warning(self, "通话错误", "接听来电失败")
self.sound_manager.play_error()
self._ensure_ringtone_stopped() # 再次确保铃声停止
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 接听来电出错: {str(e)}")
traceback.print_exc()
self._ensure_ringtone_stopped() # 确保在异常情况下也停止铃声
def _on_reject_call(self, phone_number, caller_name=None):
"""处理拒接来电"""
try:
# 1. 立即停止铃声
self._ensure_ringtone_stopped()
# 设置应当检查通话状态的标志,并启动计时器
self.should_check_call_status = True
self.call_check_count = 0
if not self.call_status_timer.isActive():
self.call_status_timer.start(1000) # 开始每秒检查一次通话状态
# 2. 尝试挂断电话
if self.lte_manager.end_call():
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 已拒绝来电: {phone_number}")
# 3. 数据库中的通话记录类型保持为"未接来电"
# 4. 更新UI状态
self.phone_sms_tab.add_to_call_log(f"已拒绝来电: {phone_number}")
self.phone_sms_tab.refresh_call_log()
else:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 拒绝来电失败: {phone_number}")
# 5. 再次确保铃声停止
self._ensure_ringtone_stopped()
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 拒绝来电出错: {str(e)}")
traceback.print_exc()
self._ensure_ringtone_stopped() # 确保在异常情况下也停止铃声
def _ensure_ringtone_stopped(self):
"""确保所有铃声已停止"""
try:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 确保所有铃声已停止")
self.sound_manager.stop_ringtone()
self.sound_manager.stop_incoming_call()
# 额外尝试停止系统声音
try:
import winsound
winsound.PlaySound(None, winsound.SND_PURGE)
except:
pass
# 如果还有声音线程在运行,给它们时间结束
time.sleep(0.2)
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 铃声停止过程完成")
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止铃声出错: {str(e)}")
traceback.print_exc()
def on_call_ended(self, duration):
"""处理通话结束事件"""
# 确保来电对话框状态被重置
self.incoming_call_dialog_visible = False
self.current_incoming_call_number = None
# 记录通话结束信息
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 接收到通话结束信号,持续时间: {duration}")
# 设置应当检查通话状态的标志,并启动计时器确认通话已结束
self.should_check_call_status = True
self.call_check_count = 0
if not self.call_status_timer.isActive():
self.call_status_timer.start(1000) # 开始每秒检查一次通话状态
# 使用状态栏显示消息
if duration.isdigit():
# 格式化持续时间(秒 -> 分:秒)
seconds = int(duration)
minutes = seconds // 60
remaining_seconds = seconds % 60
formatted_duration = f"{minutes}:{remaining_seconds:02d}"
self.statusBar().showMessage(f"通话结束,持续时间: {formatted_duration}", 5000)
else:
# 如果不是数字(例如"Call ended"或"Missed"
self.statusBar().showMessage(f"通话结束: {duration}", 5000)
# 更新数据库中的通话记录
if self.lte_manager.call_number:
try:
# 将持续时间转换为秒
if duration.isdigit():
duration_seconds = int(duration)
else:
duration_seconds = 0
# 查找最近的与此号码相关的通话记录
calls = self.database.get_call_history(limit=1, phone_number=self.lte_manager.call_number)
if calls:
# 更新现有记录
call_id = calls[0][0] # 第一列是ID
# 更新持续时间和备注
self.database.cursor.execute(
"UPDATE call_history SET duration = ?, notes = NULL WHERE id = ?",
(duration_seconds, call_id)
)
self.database.conn.commit()
print(f"更新通话记录ID {call_id},持续时间 {duration_seconds}")
else:
# 如果找不到记录,添加一个新记录(这应该是不常见的情况)
self.database.add_call(
self.lte_manager.call_number,
None,
"未接来电" if duration == "Missed" or duration_seconds == 0 else "已接来电",
duration_seconds
)
print(f"新增通话记录,号码 {self.lte_manager.call_number},持续时间 {duration_seconds}")
except Exception as e:
print(f"更新通话记录出错: {str(e)}")
def _reset_audio_processor_state(self):
"""重置音频处理器状态(已简化为空操作)"""
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 重置音频处理器状态(空操作,处理已禁用)")
def _stop_audio_with_timeout(self):
"""停止音频处理(已简化为空操作)"""
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止音频处理(空操作,处理已禁用)")
def _cleanup_audio_resources(self):
"""清理所有音频相关资源(已简化为仅日志记录)"""
# 更新状态
try:
self.audio_status_label.setText("音频: 非活动")
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 音频资源清理(空操作,处理已禁用)")
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 清理音频资源出错: {str(e)}")
# 确保停止任何正在播放的声音
try:
self.sound_manager.stop_ringtone()
self.sound_manager.stop_incoming_call()
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止声音时出错: {str(e)}")
def check_call_status(self):
"""定期检查通话状态并更新UI"""
if not self.lte_manager.is_connected():
# 如果未连接,停止检查
self.call_status_timer.stop()
self.should_check_call_status = False
return
# 检查是否需要进行通话状态检查
if not self.should_check_call_status:
# 如果不需要继续检查,停止定时器
self.call_status_timer.stop()
return
# 增加检查计数
self.call_check_count += 1
try:
# 获取当前通话状态文本
call_state = self.lte_manager.get_call_state_text()
# 更新状态栏
self.call_status_label.setText(f"通话: {call_state}")
# 根据通话状态更新通话按钮状态
calls = self.lte_manager.get_call_status()
# 检查是否有应该显示的来电提示
if calls and not self.incoming_call_dialog_visible:
for call in calls:
if call.get('stat') == 4 and call.get('dir') == 1: # 来电中(MT)
number = call.get('number', '未知号码')
# 不在通知中直接显示来电对话框,因为呼叫信号会通过call_received正常触发
break
# 更新UI以反映当前的通话状态
self.phone_sms_tab.update_call_ui_state(bool(calls))
# 如果到达最大检查次数或没有活跃通话,停止定期检查
if self.call_check_count >= self.max_call_checks or not calls:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话状态检查完成,停止定期检查")
self.should_check_call_status = False
self.call_status_timer.stop()
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检查通话状态出错: {str(e)}")
# 出错时也停止检查
self.should_check_call_status = False
self.call_status_timer.stop()
# 在phone_sms_tab.py中调用dial_button和end_call_button点击时也需要手动触发通话状态检查
def update_status_bar(self):
"""更新状态栏显示的信息"""
if not self.lte_manager.is_connected():
return
try:
# 递增更新计数器,用于控制不同信息的更新频率
self.update_counter += 1
# 每次都更新信号强度
signal_info = self.lte_manager.get_signal_strength()
if signal_info:
try:
if isinstance(signal_info, tuple) and len(signal_info) == 2:
signal_text, signal_desc = signal_info
if signal_desc:
self.status_signal.setText(f"信号: {signal_text} ({signal_desc})")
else:
self.status_signal.setText(f"信号: {signal_text}")
else:
self.status_signal.setText(f"信号: {signal_info}")
except Exception as e:
print(f"处理信号强度信息出错: {str(e)}")
self.status_signal.setText(f"信号: {signal_info}")
# 仅在首次连接或每10个周期更新一次运营商信息和电话号码
if self.update_counter == 1 or self.update_counter % 10 == 0:
# 更新运营商信息
carrier_info = self.lte_manager.get_carrier_info()
if carrier_info:
try:
if isinstance(carrier_info, tuple) and len(carrier_info) == 2:
carrier, network_type = carrier_info
self.status_carrier.setText(f"运营商: {carrier} ({network_type})")
else:
self.status_carrier.setText(f"运营商: {carrier_info}")
except Exception as e:
print(f"处理运营商信息出错: {str(e)}")
self.status_carrier.setText(f"运营商: {carrier_info}")
# 更新电话号码
phone_number = self.lte_manager.get_phone_number()
if phone_number:
self.status_phone.setText(f"电话: {phone_number}")
# 如果计数器达到30,重置它
if self.update_counter >= 30:
self.update_counter = 0
except Exception as e:
print(f"更新状态栏时出错: {str(e)}")
traceback.print_exc()
def on_status_changed(self, status):
"""处理状态变化"""
try:
# 在状态栏显示消息
self.statusBar().showMessage(status, 5000)
# 更新托盘图标中的连接状态
if "Connected to LTE module" in status:
self.update_connection_status(True)
# 连接成功后强制立即进行第一次状态更新(使用延时确保连接流程完成后再更新)
self.update_counter = 0 # 重置计数器
QTimer.singleShot(500, self.update_status_bar) # 0.5秒后更新状态栏
elif "Disconnected from LTE module" in status:
self.update_connection_status(False)
# 立即更新状态栏为未连接状态
self.status_carrier.setText("运营商: 未连接")
self.status_phone.setText("电话: 不可用")
self.status_network.setText("网络: 未连接")
self.status_signal.setText("信号: 不可用")
self.call_status_label.setText("通话: 无通话")
elif "error" in status.lower() or "失败" in status or "failed" in status.lower():
# 检测到错误状态
self.show_error_status(status)
# 出错时可能需要重新获取某些信息,强制下次执行完整更新
self.update_counter = 9
except Exception as e:
print(f"状态更新出错: {str(e)}")
self.show_error_status(f"状态更新出错: {str(e)}")
def closeEvent(self, event):
"""处理应用关闭事件"""
# 如果是通过退出菜单触发的关闭,直接关闭应用
if self.is_exiting:
self._cleanup_and_exit(event)
return
# 显示确认对话框,询问是否退出或最小化到托盘
reply = QMessageBox.question(
self,
'关闭确认',
'您希望退出程序还是最小化到系统托盘?\n\n点击""退出程序\n点击""最小化到系统托盘',
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
# 用户选择退出
self._cleanup_and_exit(event)
else:
# 用户选择最小化到托盘
event.ignore()
self.hide()
self.tray_icon.showMessage(
"LTE Tool",
"应用程序已最小化到系统托盘。双击图标可恢复窗口。",
QSystemTrayIcon.Information,
2000
)
def _cleanup_and_exit(self, event):
"""清理资源并退出应用"""
# 停止所有声音
self.sound_manager.stop_ringtone()
self.sound_manager.stop_incoming_call()
# 关闭数据库连接
self.database.close()
# 断开LTE模块连接
if self.lte_manager.is_connected():
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 正在关闭LTE模块连接...")
self.lte_manager.disconnect()
# 移除托盘图标
if self.tray_icon.isVisible():
self.tray_icon.hide()
# 接受关闭事件
event.accept()
def _exit_application(self):
"""退出应用程序"""
if self.lte_manager and self.lte_manager.is_connected():
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 正在关闭LTE模块连接...")
self.lte_manager.disconnect()
self.is_exiting = True
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - LTE Tool 应用程序退出")
QApplication.quit()
def update_connection_status(self, connected):
"""更新托盘图标中的连接状态"""
try:
if connected:
# 使用运行图标表示连接成功
self.tray_icon.setIcon(self.running_icon)
self.tray_icon.setToolTip("LTE Tool - 已连接")
self.connection_status_action.setText("已连接")
# 在状态栏显示连接指示器
self.statusBar().setStyleSheet("QStatusBar { background-color: rgba(60, 179, 113, 30); }")
self.setWindowIcon(self.running_icon) # 更新窗口图标
else:
# 使用默认图标表示未连接状态
self.tray_icon.setIcon(self.default_icon)
self.tray_icon.setToolTip("LTE Tool - 未连接")
self.connection_status_action.setText("未连接")
# 在状态栏显示未连接指示器
self.statusBar().setStyleSheet("QStatusBar { background-color: rgba(100, 149, 237, 30); }")
self.setWindowIcon(self.default_icon) # 更新窗口图标
except Exception as e:
# 发生错误时使用错误图标
print(f"更新连接状态出错: {str(e)}")
try:
self.tray_icon.setIcon(self.error_icon)
self.tray_icon.setToolTip("LTE Tool - 连接错误")
self.connection_status_action.setText("连接错误")
self.statusBar().setStyleSheet("QStatusBar { background-color: rgba(220, 20, 60, 30); }")
self.setWindowIcon(self.error_icon) # 更新窗口图标
except:
print("无法设置错误图标状态")
def show_error_status(self, error_message):
"""显示错误状态并更新图标"""
try:
self.statusBar().showMessage(f"错误: {error_message}", 5000)
self.tray_icon.setIcon(self.error_icon)
self.tray_icon.setToolTip(f"LTE Tool - 错误: {error_message[:30]}")
self.setWindowIcon(self.error_icon)
# 显示托盘通知
self.tray_icon.showMessage(
"LTE Tool 错误",
error_message,
QSystemTrayIcon.Warning,
3000
)
except Exception as e:
print(f"显示错误状态时出错: {str(e)}")
if __name__ == "__main__":
app = QApplication(sys.argv)
# 不再设置QuitOnLastWindowClosed为False,让应用在窗口关闭时可以正常退出
# app.setQuitOnLastWindowClosed(False)
window = LTEToolApp()
window.show()
sys.exit(app.exec_())
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+787
View File
@@ -0,0 +1,787 @@
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QLineEdit, QTextEdit, QGroupBox, QTabWidget, QListWidget,
QListWidgetItem, QMessageBox, QSplitter, QComboBox,
QTableWidget, QTableWidgetItem, QHeaderView, QSizePolicy)
from PyQt5.QtCore import Qt, pyqtSlot, QDateTime, QSize
import time
class PhoneSmsTab(QWidget):
def __init__(self, lte_manager, database, sound_manager):
super().__init__()
self.lte_manager = lte_manager
self.database = database
self.sound_manager = sound_manager
# Connect signals
self.lte_manager.call_received.connect(self.on_call_received)
self.lte_manager.call_ended.connect(self.on_call_ended)
self.lte_manager.sms_received.connect(self.on_sms_received)
self.lte_manager.dtmf_received.connect(self.on_dtmf_received)
self.lte_manager.status_changed.connect(self.on_status_changed)
self.init_ui()
def init_ui(self):
# Main layout
main_layout = QVBoxLayout(self)
# Create inner tab widget for phone and SMS
inner_tab_widget = QTabWidget()
main_layout.addWidget(inner_tab_widget)
# Phone tab
phone_widget = QWidget()
phone_layout = QVBoxLayout(phone_widget)
# Create a splitter for phone controls and call history
phone_splitter = QSplitter(Qt.Vertical)
phone_layout.addWidget(phone_splitter)
# Top widget for phone controls
phone_top_widget = QWidget()
phone_top_layout = QVBoxLayout(phone_top_widget)
# Phone controls
phone_group = QGroupBox("电话控制")
phone_controls_layout = QVBoxLayout()
# 通话状态显示
self.call_status_display = QLabel("通话状态: 无通话")
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #f0f0f0; border-radius: 3px;")
self.call_status_display.setAlignment(Qt.AlignCenter)
phone_controls_layout.addWidget(self.call_status_display)
# Number input
number_layout = QHBoxLayout()
number_layout.addWidget(QLabel("电话号码:"))
self.phone_number_input = QLineEdit()
self.phone_number_input.setPlaceholderText("输入电话号码")
number_layout.addWidget(self.phone_number_input)
phone_controls_layout.addLayout(number_layout)
# Call buttons
call_buttons_layout = QHBoxLayout()
self.call_button = QPushButton("拨号")
self.call_button.setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 6px; } QPushButton:disabled { background-color: #cccccc; }")
self.call_button.clicked.connect(self.on_call_button_clicked)
call_buttons_layout.addWidget(self.call_button)
self.answer_button = QPushButton("接听")
self.answer_button.setStyleSheet("QPushButton { background-color: #2196F3; color: white; padding: 6px; } QPushButton:disabled { background-color: #cccccc; }")
self.answer_button.clicked.connect(self.on_answer_button_clicked)
self.answer_button.setEnabled(False)
call_buttons_layout.addWidget(self.answer_button)
self.hangup_button = QPushButton("挂断")
self.hangup_button.setStyleSheet("QPushButton { background-color: #f44336; color: white; padding: 6px; } QPushButton:disabled { background-color: #cccccc; }")
self.hangup_button.clicked.connect(self.on_hangup_button_clicked)
self.hangup_button.setEnabled(False)
call_buttons_layout.addWidget(self.hangup_button)
phone_controls_layout.addLayout(call_buttons_layout)
phone_group.setLayout(phone_controls_layout)
phone_top_layout.addWidget(phone_group)
# DTMF tones received
dtmf_group = QGroupBox("DTMF拨号音")
dtmf_layout = QVBoxLayout()
self.dtmf_display = QLineEdit()
self.dtmf_display.setReadOnly(True)
dtmf_layout.addWidget(self.dtmf_display)
# 添加DTMF拨号键盘
dtmf_keyboard_layout = QVBoxLayout()
# 添加拨号键盘行
dtmf_rows = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['*', '0', '#']
]
for row in dtmf_rows:
row_layout = QHBoxLayout()
for key in row:
btn = QPushButton(key)
btn.setStyleSheet("QPushButton { font-size: 14px; padding: 10px; }")
btn.clicked.connect(lambda checked, k=key: self.send_dtmf(k))
row_layout.addWidget(btn)
dtmf_keyboard_layout.addLayout(row_layout)
dtmf_layout.addLayout(dtmf_keyboard_layout)
dtmf_group.setLayout(dtmf_layout)
phone_top_layout.addWidget(dtmf_group)
# Add phone top widget to splitter
phone_splitter.addWidget(phone_top_widget)
# Bottom widget for call history
phone_bottom_widget = QWidget()
phone_bottom_layout = QVBoxLayout(phone_bottom_widget)
# Call log
call_log_group = QGroupBox("Call History")
call_log_layout = QVBoxLayout()
# Call log controls
call_log_controls = QHBoxLayout()
self.refresh_call_log_button = QPushButton("Refresh")
self.refresh_call_log_button.clicked.connect(self.refresh_call_log)
call_log_controls.addWidget(self.refresh_call_log_button)
self.clear_call_log_button = QPushButton("Clear Selected")
self.clear_call_log_button.clicked.connect(self.clear_selected_call)
call_log_controls.addWidget(self.clear_call_log_button)
call_log_layout.addLayout(call_log_controls)
# Call log table
self.call_log_table = QTableWidget()
self.call_log_table.setColumnCount(4)
self.call_log_table.setHorizontalHeaderLabels(["Time", "Number", "Type", "Duration"])
self.call_log_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.call_log_table.setMinimumHeight(200) # Set minimum height
call_log_layout.addWidget(self.call_log_table)
call_log_group.setLayout(call_log_layout)
phone_bottom_layout.addWidget(call_log_group)
# Add phone bottom widget to splitter
phone_splitter.addWidget(phone_bottom_widget)
# Set initial sizes for splitter
phone_splitter.setSizes([200, 400])
# SMS tab
sms_widget = QWidget()
sms_layout = QVBoxLayout(sms_widget)
# Create a splitter for SMS tab
sms_splitter = QSplitter(Qt.Vertical)
sms_layout.addWidget(sms_splitter)
# Top widget for SMS sending
sms_top_widget = QWidget()
sms_top_layout = QVBoxLayout(sms_top_widget)
# SMS controls
sms_group = QGroupBox("Send SMS")
sms_controls_layout = QVBoxLayout()
# Number input
sms_number_layout = QHBoxLayout()
sms_number_layout.addWidget(QLabel("To:"))
self.sms_number_input = QLineEdit()
self.sms_number_input.setPlaceholderText("Enter recipient number")
sms_number_layout.addWidget(self.sms_number_input)
sms_controls_layout.addLayout(sms_number_layout)
# Message input
sms_controls_layout.addWidget(QLabel("Message:"))
self.sms_message_input = QTextEdit()
self.sms_message_input.setPlaceholderText("Type your message here")
self.sms_message_input.setMinimumHeight(100)
sms_controls_layout.addWidget(self.sms_message_input)
# Send button
self.send_sms_button = QPushButton("Send SMS")
self.send_sms_button.clicked.connect(self.on_send_sms_button_clicked)
sms_controls_layout.addWidget(self.send_sms_button)
sms_group.setLayout(sms_controls_layout)
sms_top_layout.addWidget(sms_group)
# Add SMS top widget to splitter
sms_splitter.addWidget(sms_top_widget)
# Middle widget for SMS inbox
sms_middle_widget = QWidget()
sms_middle_layout = QVBoxLayout(sms_middle_widget)
# SMS inbox
sms_inbox_group = QGroupBox("SMS Messages")
sms_inbox_layout = QVBoxLayout()
# SMS list and controls
sms_list_controls = QHBoxLayout()
self.sms_type_combo = QComboBox()
self.sms_type_combo.addItems(["All", "Unread", "Read", "Sent", "Unsent"])
sms_list_controls.addWidget(QLabel("Show:"))
sms_list_controls.addWidget(self.sms_type_combo)
self.refresh_sms_button = QPushButton("Refresh")
self.refresh_sms_button.clicked.connect(self.refresh_sms_list)
sms_list_controls.addWidget(self.refresh_sms_button)
self.delete_sms_button = QPushButton("Delete Selected")
self.delete_sms_button.clicked.connect(self.delete_selected_sms)
sms_list_controls.addWidget(self.delete_sms_button)
sms_inbox_layout.addLayout(sms_list_controls)
# Create a horizontal splitter for SMS list and content
sms_content_splitter = QSplitter(Qt.Horizontal)
# SMS list
self.sms_list = QListWidget()
self.sms_list.itemClicked.connect(self.on_sms_item_clicked)
self.sms_list.setMinimumHeight(150) # Set minimum height
sms_content_splitter.addWidget(self.sms_list)
# SMS content
sms_content_widget = QWidget()
sms_content_layout = QVBoxLayout(sms_content_widget)
sms_content_layout.addWidget(QLabel("Message Content:"))
self.sms_content = QTextEdit()
self.sms_content.setReadOnly(True)
sms_content_layout.addWidget(self.sms_content)
sms_content_splitter.addWidget(sms_content_widget)
# Set initial sizes for content splitter
sms_content_splitter.setSizes([300, 300])
sms_inbox_layout.addWidget(sms_content_splitter)
sms_inbox_group.setLayout(sms_inbox_layout)
sms_middle_layout.addWidget(sms_inbox_group)
# Add SMS middle widget to splitter
sms_splitter.addWidget(sms_middle_widget)
# Bottom widget for SMS history
sms_bottom_widget = QWidget()
sms_bottom_layout = QVBoxLayout(sms_bottom_widget)
# SMS history
sms_history_group = QGroupBox("SMS History")
sms_history_layout = QVBoxLayout()
# SMS history controls
sms_history_controls = QHBoxLayout()
self.refresh_sms_history_button = QPushButton("Refresh History")
self.refresh_sms_history_button.clicked.connect(self.refresh_sms_history)
sms_history_controls.addWidget(self.refresh_sms_history_button)
self.clear_sms_history_button = QPushButton("Clear Selected")
self.clear_sms_history_button.clicked.connect(self.clear_selected_sms_history)
sms_history_controls.addWidget(self.clear_sms_history_button)
sms_history_layout.addLayout(sms_history_controls)
# SMS history table
self.sms_history_table = QTableWidget()
self.sms_history_table.setColumnCount(4)
self.sms_history_table.setHorizontalHeaderLabels(["Time", "Number", "Type", "Message"])
self.sms_history_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.sms_history_table.itemClicked.connect(self.on_sms_history_item_clicked)
self.sms_history_table.setMinimumHeight(150) # Set minimum height
sms_history_layout.addWidget(self.sms_history_table)
sms_history_group.setLayout(sms_history_layout)
sms_bottom_layout.addWidget(sms_history_group)
# Add SMS bottom widget to splitter
sms_splitter.addWidget(sms_bottom_widget)
# Set initial sizes for SMS splitter
sms_splitter.setSizes([200, 300, 300])
# Add tabs to inner tab widget
inner_tab_widget.addTab(phone_widget, "Phone")
inner_tab_widget.addTab(sms_widget, "SMS")
# Status display
self.status_display = QTextEdit()
self.status_display.setReadOnly(True)
self.status_display.setMaximumHeight(100)
main_layout.addWidget(QLabel("Status:"))
main_layout.addWidget(self.status_display)
# Load initial data
self.refresh_call_log()
self.refresh_sms_history()
def update_call_ui_state(self, in_call=False):
"""根据当前通话状态更新UI"""
try:
# 获取最新通话状态
if self.lte_manager.is_connected():
call_state = self.lte_manager.get_call_state_text()
self.call_status_display.setText(f"通话状态: {call_state}")
# 获取当前通话
calls = self.lte_manager.get_call_status()
if calls:
# 有通话存在
call = calls[0]
stat = call.get('stat', -1)
direction = call.get('dir', 0)
# 根据通话状态更新按钮状态
if stat == 4 and direction == 1: # 来电中(MT)
# 来电振铃中
self.call_button.setEnabled(False)
self.answer_button.setEnabled(True)
self.hangup_button.setEnabled(True)
# 设置不同的样式以提示用户
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #FFF9C4; color: #E65100; border-radius: 3px;")
elif stat in [0, 1, 2, 3]: # 活动、保持、拨号中、振铃中
# 通话活动中
self.call_button.setEnabled(False)
self.answer_button.setEnabled(False)
self.hangup_button.setEnabled(True)
if stat == 0: # 活动通话
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #C8E6C9; color: #2E7D32; border-radius: 3px;")
elif stat == 1: # 保持通话
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #BBDEFB; color: #1565C0; border-radius: 3px;")
else: # 拨号中、振铃中
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #E1BEE7; color: #6A1B9A; border-radius: 3px;")
else:
# 未知状态
self.call_button.setEnabled(True)
self.answer_button.setEnabled(False)
self.hangup_button.setEnabled(False)
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #f0f0f0; border-radius: 3px;")
else:
# 无通话
self.call_button.setEnabled(True)
self.answer_button.setEnabled(False)
self.hangup_button.setEnabled(False)
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #f0f0f0; border-radius: 3px;")
else:
# 未连接
self.call_status_display.setText("通话状态: 未连接")
self.call_button.setEnabled(False)
self.answer_button.setEnabled(False)
self.hangup_button.setEnabled(False)
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #FFCCBC; color: #BF360C; border-radius: 3px;")
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 更新通话UI状态出错: {str(e)}")
# 出错时重置为安全状态
self.call_button.setEnabled(True)
self.answer_button.setEnabled(False)
self.hangup_button.setEnabled(False)
def send_dtmf(self, tone):
"""发送DTMF拨号音"""
if not self.lte_manager.is_connected() or not self.lte_manager.is_call_connected():
# 只有在通话活动时才能发送DTMF音
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 无法发送DTMF: 当前无活动通话")
self.sound_manager.play_error()
QMessageBox.warning(self, "DTMF错误", "只有在通话接通时才能发送拨号音")
return
try:
# 发送AT+VTS命令发送DTMF音
response = self.lte_manager.send_at_command(f"AT+VTS={tone}")
if "OK" in response:
# 发送成功,更新DTMF显示
current_text = self.dtmf_display.text()
self.dtmf_display.setText(current_text + tone)
self.sound_manager.play_dtmf() # 播放提示音
else:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送DTMF音失败: {response}")
self.sound_manager.play_error()
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送DTMF音出错: {str(e)}")
self.sound_manager.play_error()
def on_call_button_clicked(self):
"""处理拨号按钮点击"""
number = self.phone_number_input.text().strip()
if not number:
return
# 添加到拨号记录,无论成功与否
self.call_log_history.append(f"拨打电话: {number}")
self.update_call_log_display()
# 尝试拨打电话
if self.lte_manager.make_call(number):
self.update_call_ui_state(True) # 拨号中,更新UI状态
# 触发主窗口进行通话状态检查
if hasattr(self.parent(), 'should_check_call_status'):
self.parent().should_check_call_status = True
self.parent().call_check_count = 0
if not self.parent().call_status_timer.isActive():
self.parent().call_status_timer.start(1000)
# 记录到数据库
self.database.add_call(number, "outgoing")
else:
# 拨号失败,提示用户
self.call_status_display.setText("拨号失败")
self.call_log_history.append(f"拨号失败: {number}")
self.update_call_log_display()
def on_answer_button_clicked(self):
"""处理接听按钮点击"""
# 先停止所有铃声,确保不会有铃声继续播放
try:
self.parent().sound_manager.stop_ringtone()
self.parent().sound_manager.stop_incoming_call()
except:
pass
# 尝试接听电话
if self.lte_manager.answer_call():
self.call_log_history.append("已接听来电")
self.update_call_log_display()
self.update_call_ui_state(True) # 通话中,更新UI状态
# 触发主窗口进行通话状态检查
if hasattr(self.parent(), 'should_check_call_status'):
self.parent().should_check_call_status = True
self.parent().call_check_count = 0
if not self.parent().call_status_timer.isActive():
self.parent().call_status_timer.start(1000)
# 检查通话状态,确认通话是否实际连接
time.sleep(0.5) # 给模块一点时间更新状态
calls = self.lte_manager.get_call_status()
call_connected = False
for call in calls:
if call.get('stat') in [0, 1]: # 活动或保持状态
call_connected = True
self.call_status_display.setText(f"通话中: {call.get('number', '未知')}")
break
if not call_connected:
self.call_status_display.setText("接听失败或通话已结束")
else:
self.call_log_history.append("接听失败")
self.update_call_log_display()
self.call_status_display.setText("接听失败")
def on_hangup_button_clicked(self):
"""处理挂断按钮点击"""
if self.lte_manager.end_call():
self.call_log_history.append("通话结束")
self.update_call_log_display()
self.update_call_ui_state(False) # 通话结束,更新UI状态
# 触发主窗口进行通话状态检查
if hasattr(self.parent(), 'should_check_call_status'):
self.parent().should_check_call_status = True
self.parent().call_check_count = 0
if not self.parent().call_status_timer.isActive():
self.parent().call_status_timer.start(1000)
else:
self.call_log_history.append("挂断失败")
self.update_call_log_display()
def on_send_sms_button_clicked(self):
"""Handle send SMS button click"""
number = self.sms_number_input.text().strip()
message = self.sms_message_input.toPlainText().strip()
if not number:
QMessageBox.warning(self, "Input Error", "Please enter a recipient number")
return
if not message:
QMessageBox.warning(self, "Input Error", "Please enter a message")
return
if self.lte_manager.send_sms(number, message):
self.sms_message_input.clear()
self.add_status_message(f"SMS sent to {number}")
# Play success sound
self.sound_manager.play_success()
# Add to database
self.database.add_sms(number, message, "outgoing", "sent")
# Refresh SMS list and history
self.refresh_sms_list()
self.refresh_sms_history()
else:
QMessageBox.warning(self, "SMS Error", "Failed to send SMS")
# Play error sound
self.sound_manager.play_error()
# Add to database as failed
self.database.add_sms(number, message, "outgoing", "failed")
def on_call_received(self, number):
"""Handle incoming call"""
self.answer_button.setEnabled(True)
self.call_button.setEnabled(False)
self.hangup_button.setEnabled(True)
self.add_to_call_log(f"Incoming call from {number}")
# Play ringtone
self.sound_manager.play_ringtone()
# Note: Call recording in database is now handled in the main window
# to ensure it's recorded exactly once when the notification appears
def on_call_ended(self, duration):
"""处理通话结束事件"""
self.sound_manager.play_call_end()
self.call_button.setEnabled(True)
self.answer_button.setEnabled(False)
self.hangup_button.setEnabled(False)
self.add_to_call_log(f"通话结束,持续时间: {duration}")
# 停止所有铃声
self._stop_all_ringtones()
# 清除DTMF显示
self.dtmf_display.clear()
# 更新通话状态
self.call_status_display.setText("通话状态: 无通话")
self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #f0f0f0; border-radius: 3px;")
# 刷新通话记录
self.refresh_call_log()
def _stop_all_ringtones(self):
"""停止所有铃声,确保彻底停止"""
try:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止所有铃声")
self.sound_manager.stop_ringtone()
self.sound_manager.stop_incoming_call()
# 额外尝试停止系统声音
try:
import winsound
winsound.PlaySound(None, winsound.SND_PURGE)
except:
pass
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止铃声出错: {str(e)}")
def on_sms_received(self, sender, timestamp, message):
"""Handle SMS received"""
self.add_status_message(f"SMS received from {sender}")
# Play message received sound - play three beeps
self.sound_manager.play_message_received()
self.sound_manager.play_message_received()
self.sound_manager.play_message_received()
# Add to database
self.database.add_sms(sender, message, "incoming", "received")
# Refresh SMS list and history
self.refresh_sms_list()
self.refresh_sms_history()
# Update the SMS content display directly
self.sms_content.setText(f"From: {sender}\nTime: {timestamp}\n\n{message}")
# Show a message box to alert the user
QMessageBox.information(self, "New SMS", f"New message from {sender}\n\n{message[:100]}" + ("..." if len(message) > 100 else ""))
def on_dtmf_received(self, tone):
"""Handle DTMF tone received"""
current_text = self.dtmf_display.text()
self.dtmf_display.setText(current_text + tone)
def on_status_changed(self, status):
"""Handle status change"""
self.add_status_message(status)
def add_to_call_log(self, message):
"""Add message to status display"""
timestamp = QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss")
self.status_display.append(f"{timestamp} - {message}")
self.status_display.ensureCursorVisible()
def add_status_message(self, message):
"""Add message to status display"""
timestamp = QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss")
self.status_display.append(f"{timestamp} - {message}")
self.status_display.ensureCursorVisible()
def refresh_sms_list(self):
"""Refresh SMS list from module"""
if not self.lte_manager.is_connected():
return
self.sms_list.clear()
self.sms_content.clear()
# Get SMS type filter
sms_type = self.sms_type_combo.currentText()
if sms_type == "All":
status = "ALL"
elif sms_type == "Unread":
status = "REC UNREAD"
elif sms_type == "Read":
status = "REC READ"
elif sms_type == "Sent":
status = "STO SENT"
elif sms_type == "Unsent":
status = "STO UNSENT"
# Get SMS list
messages = self.lte_manager.get_sms_list(status)
# Add messages to list
for msg in messages:
item = QListWidgetItem(f"{msg['index']} - From: {msg['sender']} - {msg['timestamp']}")
item.setData(Qt.UserRole, msg)
self.sms_list.addItem(item)
# If no messages from module, show a message
if self.sms_list.count() == 0:
self.add_status_message("No messages found on the module. Check SMS history tab for stored messages.")
# Try to get messages from database to show in the content area
db_messages = self.database.get_sms_history(limit=1)
if db_messages:
# Format: id, phone_number, message, sms_type, timestamp, status
_, _, message, _, _, _ = db_messages[0]
self.sms_content.setText("Last message from database:\n\n" + message)
def on_sms_item_clicked(self, item):
"""Handle SMS item click"""
msg = item.data(Qt.UserRole)
if msg:
self.sms_content.setText(msg['content'])
def delete_selected_sms(self):
"""Delete selected SMS from module"""
selected_items = self.sms_list.selectedItems()
if not selected_items:
QMessageBox.warning(self, "Selection Error", "Please select an SMS to delete")
return
for item in selected_items:
msg = item.data(Qt.UserRole)
if msg:
if self.lte_manager.delete_sms(msg['index']):
self.add_status_message(f"Deleted SMS at index {msg['index']}")
else:
self.add_status_message(f"Failed to delete SMS at index {msg['index']}")
self.refresh_sms_list()
def refresh_call_log(self):
"""Refresh call log from database"""
# Get call history from database
calls = self.database.get_call_history()
# Clear table
self.call_log_table.setRowCount(0)
# Add calls to table
for call in calls:
row = self.call_log_table.rowCount()
self.call_log_table.insertRow(row)
# Format: id, phone_number, call_type, duration, timestamp, notes
call_id, phone_number, call_type, duration, timestamp, notes = call
# Format duration
if duration:
duration_str = f"{duration}s"
else:
duration_str = ""
# Add items to row
self.call_log_table.setItem(row, 0, QTableWidgetItem(timestamp))
self.call_log_table.setItem(row, 1, QTableWidgetItem(phone_number))
self.call_log_table.setItem(row, 2, QTableWidgetItem(call_type))
self.call_log_table.setItem(row, 3, QTableWidgetItem(duration_str))
# Store call ID in first column
self.call_log_table.item(row, 0).setData(Qt.UserRole, call_id)
def clear_selected_call(self):
"""Clear selected call from database"""
selected_items = self.call_log_table.selectedItems()
if not selected_items:
QMessageBox.warning(self, "Selection Error", "Please select a call to delete")
return
# Get unique rows
rows = set()
for item in selected_items:
rows.add(item.row())
# Delete each selected call
for row in rows:
call_id = self.call_log_table.item(row, 0).data(Qt.UserRole)
if self.database.delete_call(call_id):
self.add_status_message(f"Deleted call record {call_id}")
else:
self.add_status_message(f"Failed to delete call record {call_id}")
# Refresh call log
self.refresh_call_log()
def refresh_sms_history(self):
"""Refresh SMS history from database"""
# Get SMS history from database
messages = self.database.get_sms_history()
# Clear table
self.sms_history_table.setRowCount(0)
# Add messages to table
for msg in messages:
row = self.sms_history_table.rowCount()
self.sms_history_table.insertRow(row)
# Format: id, phone_number, message, sms_type, timestamp, status
sms_id, phone_number, message, sms_type, timestamp, status = msg
# Add items to row
self.sms_history_table.setItem(row, 0, QTableWidgetItem(timestamp))
self.sms_history_table.setItem(row, 1, QTableWidgetItem(phone_number))
self.sms_history_table.setItem(row, 2, QTableWidgetItem(f"{sms_type} ({status})"))
# Truncate message if too long
if len(message) > 50:
display_message = message[:47] + "..."
else:
display_message = message
self.sms_history_table.setItem(row, 3, QTableWidgetItem(display_message))
# Store full message and SMS ID
self.sms_history_table.item(row, 3).setData(Qt.UserRole, message)
self.sms_history_table.item(row, 0).setData(Qt.UserRole, sms_id)
def on_sms_history_item_clicked(self, item):
"""Handle SMS history item click"""
# If clicked on message column, show full message
if item.column() == 3:
full_message = item.data(Qt.UserRole)
if full_message:
self.sms_content.setText(full_message)
def clear_selected_sms_history(self):
"""Clear selected SMS from history database"""
selected_items = self.sms_history_table.selectedItems()
if not selected_items:
QMessageBox.warning(self, "Selection Error", "Please select an SMS to delete")
return
# Get unique rows
rows = set()
for item in selected_items:
rows.add(item.row())
# Delete each selected SMS
for row in rows:
sms_id = self.sms_history_table.item(row, 0).data(Qt.UserRole)
if self.database.delete_sms(sms_id):
self.add_status_message(f"Deleted SMS record {sms_id}")
else:
self.add_status_message(f"Failed to delete SMS record {sms_id}")
# Refresh SMS history
self.refresh_sms_history()
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+362
View File
@@ -0,0 +1,362 @@
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QLineEdit, QTextEdit, QGroupBox, QFormLayout, QComboBox,
QGridLayout, QMessageBox, QSpinBox, QCheckBox)
from PyQt5.QtCore import Qt, pyqtSlot, QTimer
import serial.tools.list_ports
import os
import json
import time
class SettingsTab(QWidget):
def __init__(self, lte_manager):
"""初始化设置标签页"""
super().__init__()
self.lte_manager = lte_manager
self.settings = {}
self.settings_file = os.path.join(os.path.expanduser('~'), '.LTE', 'settings.json')
# 加载设置
self.settings = {
"at_port": "",
"at_baudrate": "115200",
"nmea_port": "None",
"nmea_baudrate": "9600",
"auto_connect": False
}
self.load_settings()
# 初始化UI
self.init_ui()
# 注册状态变化的信号处理
self.lte_manager.status_changed.connect(self.on_status_changed)
# 如果启用了自动连接,尝试连接
if self.settings.get("auto_connect", False):
QTimer.singleShot(1000, self.try_auto_connect)
def init_ui(self):
"""初始化用户界面"""
main_layout = QVBoxLayout()
self.setLayout(main_layout)
# 端口设置组
port_group = QGroupBox("端口设置")
port_layout = QFormLayout()
# AT端口
at_port_layout = QHBoxLayout()
self.at_port_combo = QComboBox()
at_port_layout.addWidget(self.at_port_combo)
self.refresh_ports_button = QPushButton("刷新")
self.refresh_ports_button.clicked.connect(self.refresh_ports)
at_port_layout.addWidget(self.refresh_ports_button)
port_layout.addRow("AT Port:", at_port_layout)
# AT波特率
self.at_baudrate_combo = QComboBox()
self.at_baudrate_combo.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "460800", "921600"])
self.at_baudrate_combo.setCurrentText(self.settings["at_baudrate"]) # 从保存的设置设置
port_layout.addRow("AT Baudrate:", self.at_baudrate_combo)
# NMEA端口
nmea_port_layout = QHBoxLayout()
self.nmea_port_combo = QComboBox()
self.nmea_port_combo.addItem("None")
nmea_port_layout.addWidget(self.nmea_port_combo)
port_layout.addRow("NMEA Port:", nmea_port_layout)
# NMEA波特率
self.nmea_baudrate_combo = QComboBox()
self.nmea_baudrate_combo.addItems(["4800", "9600", "19200", "38400", "57600", "115200"])
self.nmea_baudrate_combo.setCurrentText(self.settings["nmea_baudrate"]) # 从保存的设置设置
port_layout.addRow("NMEA Baudrate:", self.nmea_baudrate_combo)
# 自动连接复选框
self.auto_connect_checkbox = QCheckBox("自动连接")
self.auto_connect_checkbox.setChecked(self.settings.get("auto_connect", False))
self.auto_connect_checkbox.stateChanged.connect(self.on_auto_connect_changed)
port_layout.addRow("启动选项:", self.auto_connect_checkbox)
# 刷新端口列表
self.refresh_ports()
# 连接/断开按钮
buttons_layout = QHBoxLayout()
self.connect_button = QPushButton("连接")
self.connect_button.clicked.connect(self.on_connect_button_clicked)
buttons_layout.addWidget(self.connect_button)
self.disconnect_button = QPushButton("断开")
self.disconnect_button.clicked.connect(self.on_disconnect_button_clicked)
self.disconnect_button.setEnabled(False)
buttons_layout.addWidget(self.disconnect_button)
port_layout.addRow("", buttons_layout)
port_group.setLayout(port_layout)
main_layout.addWidget(port_group)
# 模块信息组
module_group = QGroupBox("模块信息")
module_layout = QVBoxLayout()
# 添加信息文本显示区域
self.info_text = QTextEdit()
self.info_text.setReadOnly(True)
self.info_text.setMinimumHeight(150)
module_layout.addWidget(self.info_text)
# 刷新按钮
self.refresh_info_button = QPushButton("刷新信息")
self.refresh_info_button.clicked.connect(self.refresh_module_info)
self.refresh_info_button.setEnabled(False)
module_layout.addWidget(self.refresh_info_button)
module_group.setLayout(module_layout)
main_layout.addWidget(module_group)
# AT命令控制台
console_group = QGroupBox("AT命令控制台")
console_layout = QVBoxLayout()
# 命令输入
command_layout = QHBoxLayout()
self.command_input = QLineEdit()
self.command_input.setPlaceholderText("输入AT命令")
command_layout.addWidget(self.command_input)
self.send_command_button = QPushButton("发送")
self.send_command_button.clicked.connect(self.on_send_command_button_clicked)
self.send_command_button.setEnabled(False)
command_layout.addWidget(self.send_command_button)
console_layout.addLayout(command_layout)
# 响应显示
console_layout.addWidget(QLabel("响应:"))
self.response_display = QTextEdit()
self.response_display.setReadOnly(True)
console_layout.addWidget(self.response_display)
console_group.setLayout(console_layout)
main_layout.addWidget(console_group)
# 状态显示
self.status_display = QTextEdit()
self.status_display.setReadOnly(True)
self.status_display.setMaximumHeight(100)
main_layout.addWidget(QLabel("状态:"))
main_layout.addWidget(self.status_display)
def get_available_ports(self):
"""Get list of available serial ports"""
ports = []
for port in serial.tools.list_ports.comports():
ports.append(port.device)
return ports
def refresh_ports(self):
"""Refresh available serial ports"""
self.at_port_combo.clear()
self.nmea_port_combo.clear()
self.nmea_port_combo.addItem("None")
ports = self.get_available_ports()
for port in ports:
self.at_port_combo.addItem(port)
self.nmea_port_combo.addItem(port)
# Set saved ports if available
if self.settings["at_port"] in ports:
self.at_port_combo.setCurrentText(self.settings["at_port"])
if self.settings["nmea_port"] in ports or self.settings["nmea_port"] == "None":
self.nmea_port_combo.setCurrentText(self.settings["nmea_port"])
def on_connect_button_clicked(self):
"""Handle connect button click"""
at_port = self.at_port_combo.currentText()
at_baudrate = int(self.at_baudrate_combo.currentText())
nmea_port = self.nmea_port_combo.currentText()
if nmea_port == "None":
nmea_port = ""
nmea_baudrate = int(self.nmea_baudrate_combo.currentText())
# Save settings
self.settings["at_port"] = at_port
self.settings["at_baudrate"] = self.at_baudrate_combo.currentText()
self.settings["nmea_port"] = nmea_port if nmea_port else "None"
self.settings["nmea_baudrate"] = self.nmea_baudrate_combo.currentText()
self.settings["auto_connect"] = self.auto_connect_checkbox.isChecked()
self.save_settings()
if self.lte_manager.connect(at_port, at_baudrate, nmea_port, nmea_baudrate):
self.connect_button.setEnabled(False)
self.disconnect_button.setEnabled(True)
self.refresh_info_button.setEnabled(True)
self.send_command_button.setEnabled(True)
self.add_status_message("Connected to LTE module")
self.refresh_module_info()
else:
self.add_status_message("Failed to connect to LTE module")
def on_disconnect_button_clicked(self):
"""Handle disconnect button click"""
self.lte_manager.disconnect()
self.connect_button.setEnabled(True)
self.disconnect_button.setEnabled(False)
self.refresh_info_button.setEnabled(False)
self.send_command_button.setEnabled(False)
self.add_status_message("Disconnected from LTE module")
# Reset module information
self.info_text.clear()
self.refresh_info_button.setEnabled(False)
def on_send_command_button_clicked(self):
"""Handle send command button click"""
command = self.command_input.text().strip()
if not command:
return
self.add_status_message(f"Sending command: {command}")
response = self.lte_manager.send_at_command(command)
self.response_display.setText(response)
self.command_input.clear()
def refresh_module_info(self):
"""刷新模块信息显示"""
if not self.lte_manager.is_connected():
self.add_status_message("请先连接模块")
return
try:
self.info_text.clear()
self.add_status_message("正在获取模块信息...")
# 获取模块信息
module_info = self.lte_manager.get_module_info()
if not module_info:
self.add_status_message("获取模块信息失败")
return
# 显示模块信息
self.info_text.append("<b>模块信息:</b>")
for key, value in module_info.items():
if value: # 只显示有值的项目
self.info_text.append(f"<b>{key}:</b> {value}")
# 获取运营商信息
carrier_info = self.lte_manager.get_carrier_info()
if carrier_info:
if isinstance(carrier_info, tuple) and len(carrier_info) == 2:
carrier, network = carrier_info
self.info_text.append(f"<b>运营商:</b> {carrier}")
self.info_text.append(f"<b>网络类型:</b> {network}")
else:
self.info_text.append(f"<b>运营商:</b> {carrier_info}")
# 获取电话号码
phone_number = self.lte_manager.get_phone_number()
if phone_number:
self.info_text.append(f"<b>电话号码:</b> {phone_number}")
# 获取信号强度
signal_info = self.lte_manager.get_signal_strength()
if signal_info:
if isinstance(signal_info, tuple) and len(signal_info) == 2:
signal_text, signal_desc = signal_info
self.info_text.append(f"<b>信号强度:</b> {signal_text} ({signal_desc})")
else:
self.info_text.append(f"<b>信号强度:</b> {signal_info}")
# 获取网络信息
network_info = self.lte_manager.get_network_info()
if network_info:
self.info_text.append("<b>网络信息:</b>")
for key, value in network_info.items():
if value: # 只显示有值的项目
self.info_text.append(f"<b>{key}:</b> {value}")
# 添加时间戳
self.info_text.append(f"<i>更新时间: {time.strftime('%Y-%m-%d %H:%M:%S')}</i>")
self.add_status_message("模块信息已更新")
except Exception as e:
self.add_status_message(f"刷新模块信息出错: {str(e)}")
import traceback
traceback.print_exc()
def on_status_changed(self, status):
"""Handle status change"""
self.add_status_message(status)
def add_status_message(self, message):
"""Add message to status display"""
self.status_display.append(message)
self.status_display.ensureCursorVisible()
def on_auto_connect_changed(self, state):
"""Handle auto connect checkbox state change"""
self.settings["auto_connect"] = bool(state)
self.save_settings()
def try_auto_connect(self):
"""Try to automatically connect using saved settings"""
if not self.settings.get("auto_connect", False):
return False
at_port = self.settings.get("at_port", "")
if not at_port:
self.add_status_message("Auto-connect: No saved port")
return False
# Check if the saved port is available
available_ports = self.get_available_ports()
if at_port not in available_ports:
self.add_status_message(f"Auto-connect: Port {at_port} not available")
return False
# Get other settings
at_baudrate = int(self.settings.get("at_baudrate", "115200"))
nmea_port = self.settings.get("nmea_port", "None")
if nmea_port == "None":
nmea_port = ""
nmea_baudrate = int(self.settings.get("nmea_baudrate", "9600"))
# Try to connect
self.add_status_message(f"Auto-connect: Trying to connect to {at_port}")
if self.lte_manager.connect(at_port, at_baudrate, nmea_port, nmea_baudrate):
self.connect_button.setEnabled(False)
self.disconnect_button.setEnabled(True)
self.refresh_info_button.setEnabled(True)
self.send_command_button.setEnabled(True)
self.add_status_message("Auto-connect: Connected to LTE module")
self.refresh_module_info()
return True
else:
self.add_status_message("Auto-connect: Failed to connect")
return False
def load_settings(self):
"""Load settings from file"""
try:
if os.path.exists(self.settings_file):
with open(self.settings_file, 'r') as f:
loaded_settings = json.load(f)
# Update settings with loaded values
for key, value in loaded_settings.items():
self.settings[key] = value
except Exception as e:
print(f"Error loading settings: {str(e)}")
def save_settings(self):
"""Save settings to file"""
try:
with open(self.settings_file, 'w') as f:
json.dump(self.settings, f, indent=4)
except Exception as e:
print(f"Error saving settings: {str(e)}")
+181
View File
@@ -0,0 +1,181 @@
import binascii
def text_to_ucs2(text):
"""Convert text to UCS2 (UTF-16BE) hex string for SMS sending"""
try:
# Encode text to UTF-16BE bytes
utf16be_bytes = text.encode('utf-16be')
# Convert bytes to hex string
hex_str = binascii.hexlify(utf16be_bytes).decode('ascii').upper()
return hex_str
except Exception as e:
print(f"UCS2 encoding error: {str(e)}")
return None
def ucs2_to_text(hex_str):
"""Convert UCS2 (UTF-16BE) hex string to text for SMS display"""
try:
# Remove spaces if any
hex_str = hex_str.replace(" ", "")
# Make sure we have a valid hex string
if not all(c in "0123456789ABCDEFabcdef" for c in hex_str):
return hex_str # Not a hex string, return as is
# Make sure the length is even (each character is 2 bytes in UCS2)
if len(hex_str) % 2 != 0:
hex_str = hex_str + "0" # Pad with zero if needed
# 针对特定格式长短信的处理(以62117ED94F6053D14E86957F6587672C开头)
if hex_str.startswith("62117ED94F6053D14E86957F6587672C"):
# 这是一种特定格式的长短信,尝试提取关键信息
# 通常格式是:固定标记 + "003A"(冒号) + URL内容
parts = hex_str.split("003A", 1)
if len(parts) > 1 and parts[1]:
try:
# 提取并解码URL部分
url_hex = "003A" + parts[1] # 加回冒号
url_bytes = binascii.unhexlify(url_hex)
url_text = url_bytes.decode('utf-16be', errors='replace')
return url_text
except Exception as url_error:
print(f"URL extraction error: {str(url_error)}")
# 如果提取失败,尝试完整解码
# For phone numbers in UCS2 format (e.g., 002B00380036...)
if hex_str.startswith("002B") or all(c in "0123456789ABCDEF" for c in hex_str):
# Check if it's likely a phone number (starts with +)
if hex_str.startswith("002B"): # "+" in UCS2
try:
# Convert hex string to bytes
utf16be_bytes = binascii.unhexlify(hex_str)
# Decode bytes to text
text = utf16be_bytes.decode('utf-16be')
return text
except:
# If it fails, try to extract the phone number directly
phone = ""
i = 0
while i < len(hex_str):
if i + 4 <= len(hex_str):
chunk = hex_str[i:i+4]
if chunk == "002B": # "+"
phone += "+"
elif chunk.startswith("00") and chunk[2:4].isdigit():
phone += chunk[2:4]
i += 4
else:
break
if phone:
return phone
# Try multiple decoding approaches
try:
# Standard UCS2 decoding
utf16be_bytes = binascii.unhexlify(hex_str)
text = utf16be_bytes.decode('utf-16be', errors='replace')
return text
except Exception as e1:
print(f"Primary UCS2 decoding failed: {str(e1)}")
try:
# Try with different endianness
utf16le_bytes = binascii.unhexlify(hex_str)
text = utf16le_bytes.decode('utf-16le', errors='replace')
return text
except Exception as e2:
print(f"Secondary UCS2 decoding failed: {str(e2)}")
try:
# 尝试以每4位(2字节)为单位解析,移除非ASCII字符
result = ""
i = 0
while i < len(hex_str):
if i + 4 <= len(hex_str):
chunk = hex_str[i:i+4]
try:
# 检查是否可能是ASCII字符(大多数ASCII UCS2编码格式为00xx
if chunk.startswith("00") and 32 <= int(chunk[2:4], 16) <= 126:
char = chr(int(chunk[2:4], 16))
result += char
# 对于非ASCII字符,尝试直接解码
else:
char_bytes = binascii.unhexlify(chunk)
char = char_bytes.decode('utf-16be', errors='ignore')
if char:
result += char
except:
pass
i += 4
else:
break
# 检测结果中的URL
url_match = None
if "http" in result:
url_match = result[result.find("http"):]
# 截断到第一个不合法URL字符处
for i, c in enumerate(url_match):
if c.isspace() or c in '",\'<>()[]{}':
url_match = url_match[:i]
break
# 如果找到URL,返回它
if url_match and len(url_match) > 10: # 确保URL足够长
return url_match
# 否则返回处理的结果
if result:
return result
except Exception as e3:
print(f"Chunk-by-chunk decoding failed: {str(e3)}")
# 如果所有解码方法都失败,最后尝试查找URL模式
try:
# 查找HTTP URL的常见模式
http_pattern = "00680074007400700073003A002F002F" # "https://"
http_alt = "00680074007400700073003a002f002f" # 小写冒号和斜杠
if http_pattern in hex_str or http_alt in hex_str:
start_idx = hex_str.find(http_pattern) if http_pattern in hex_str else hex_str.find(http_alt)
if start_idx >= 0:
url_hex = hex_str[start_idx:]
try:
url_bytes = binascii.unhexlify(url_hex)
url_text = url_bytes.decode('utf-16be', errors='replace')
return url_text
except:
pass
except:
pass
# 如果所有方法都失败,返回原始十六进制字符串
return f"[Hex: {hex_str[:30]}...]"
except Exception as e:
print(f"UCS2 decoding error: {str(e)}")
return f"[Decode error: {hex_str[:30]}...]"
def is_chinese_text(text):
"""Check if text contains Chinese characters"""
for char in text:
if '\u4e00' <= char <= '\u9fff':
return True
return False
def format_phone_number(number):
"""Format phone number for SMS sending (add +86 if needed)"""
# Remove any spaces, dashes, or parentheses
clean_number = ''.join(c for c in number if c.isdigit() or c == '+')
# If it's a Chinese number without country code, add +86
if clean_number.startswith('1') and len(clean_number) == 11:
return f"+86{clean_number}"
# If it doesn't have a + prefix, add it
if not clean_number.startswith('+'):
return f"+{clean_number}"
return clean_number
+205
View File
@@ -0,0 +1,205 @@
import winsound
import threading
import time
class SoundManager:
def __init__(self):
"""Initialize sound manager"""
self.is_ringing = False
self.ring_thread = None
self.incoming_call_active = False
self.incoming_call_thread = None
def play_ringtone(self):
"""Play ringtone for incoming call"""
if self.is_ringing:
return
self.is_ringing = True
self.ring_thread = threading.Thread(target=self._ring_loop)
self.ring_thread.daemon = True
self.ring_thread.start()
def _ring_loop(self):
"""Ring loop for incoming call"""
try:
while self.is_ringing:
# Play ringtone (1000Hz for 500ms)
winsound.Beep(1000, 500)
time.sleep(1.0)
except Exception as e:
print(f"Ringtone error: {str(e)}")
finally:
self.is_ringing = False
def stop_ringtone(self):
"""停止普通铃声"""
# 首先设置停止标志
self.is_ringing = False
# 尝试立即停止系统音效
try:
winsound.PlaySound(None, winsound.SND_PURGE)
except:
pass
# 尝试等待铃声线程结束,使用较短的超时时间
if self.ring_thread and self.ring_thread.is_alive():
try:
self.ring_thread.join(timeout=0.5)
except:
pass
# 如果线程仍在运行,创建新的线程引用使旧线程成为孤立线程
if self.ring_thread.is_alive():
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 铃声线程没有正常停止,强制释放")
self.ring_thread = None
# 再次确认停止标志已设置
self.is_ringing = False
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 普通铃声停止完成")
def play_incoming_call(self):
"""播放来电铃声"""
# 如果已经在播放,不重复启动
if self.incoming_call_active:
return
# 确保任何之前的铃声线程都已经停止
self.stop_incoming_call()
# 启动新的铃声
self.incoming_call_active = True
self.incoming_call_thread = threading.Thread(target=self._incoming_call_loop)
self.incoming_call_thread.daemon = True
self.incoming_call_thread.start()
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 开始播放来电铃声")
def _incoming_call_loop(self):
"""来电铃声循环"""
try:
# 记录启动时间,避免铃声线程持续太久
start_time = time.time()
max_duration = 120 # 最长播放时间(秒)
while self.incoming_call_active and (time.time() - start_time) < max_duration:
# 播放来电铃声,使用系统铃声
try:
winsound.PlaySound("SystemExclamation", winsound.SND_ALIAS)
except:
# 如果系统铃声不可用,使用传统铃声
try:
winsound.Beep(1200, 300)
time.sleep(0.2)
winsound.Beep(1000, 300)
except:
# 如果Beep也失败,只等待
time.sleep(1.0)
# 每次播放后检查是否应该停止
if not self.incoming_call_active:
break
# 铃声间隔
time.sleep(1.0)
# 如果退出是因为超时,打印日志
if (time.time() - start_time) >= max_duration:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电铃声已达到最长播放时间,自动停止")
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电铃声错误: {str(e)}")
finally:
# 确保停止标志被设置
self.incoming_call_active = False
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电铃声线程正常退出")
def stop_incoming_call(self):
"""停止来电铃声"""
# 首先设置停止标志
prev_state = self.incoming_call_active
self.incoming_call_active = False
# 打印状态日志,帮助调试
if prev_state:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 正在停止来电铃声(之前状态:激活)")
else:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 正在停止来电铃声(之前状态:已停止)")
# 尝试立即停止系统声音
try:
winsound.PlaySound(None, winsound.SND_PURGE)
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止系统声音时出错: {str(e)}")
# 等待线程结束
if self.incoming_call_thread and self.incoming_call_thread.is_alive():
try:
# 使用较短的超时时间,避免长时间等待
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 等待来电铃声线程结束...")
self.incoming_call_thread.join(timeout=0.5)
# 检查线程是否已结束
if not self.incoming_call_thread.is_alive():
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电铃声线程已正常结束")
else:
# 如果线程仍然活动,创建新线程以避免阻塞
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电铃声线程未能正常停止,强制释放")
# 创建并设置新线程引用,使旧线程成为孤立线程(将被Python垃圾回收)
self.incoming_call_thread = None
except Exception as e:
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 等待来电铃声线程时出错: {str(e)}")
# 重置线程引用
self.incoming_call_thread = None
# 确保标志重置为False(多重保障)
self.incoming_call_active = False
# 确认声音已停止
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电铃声停止流程完成")
def play_call_end(self):
"""Play call end sound"""
try:
# Play call end sound (800Hz for 200ms, then 600Hz for 300ms)
winsound.Beep(800, 200)
winsound.Beep(600, 300)
except Exception as e:
print(f"Call end sound error: {str(e)}")
def play_message_received(self):
"""Play message received sound"""
try:
# Play message received sound (three beeps at 1200Hz for 200ms)
winsound.Beep(1200, 200)
time.sleep(0.1)
winsound.Beep(1200, 200)
time.sleep(0.1)
winsound.Beep(1200, 200)
except Exception as e:
print(f"Message sound error: {str(e)}")
def play_error(self):
"""Play error sound"""
try:
# Play error sound (400Hz for 400ms)
winsound.Beep(400, 400)
except Exception as e:
print(f"Error sound error: {str(e)}")
def play_success(self):
"""Play success sound"""
try:
# Play success sound (1000Hz for 200ms, then 1200Hz for 200ms)
winsound.Beep(1000, 200)
winsound.Beep(1200, 200)
except Exception as e:
print(f"Success sound error: {str(e)}")
def play_dtmf(self):
"""播放DTMF按键提示音"""
try:
# 模拟DTMF音,播放简短高频音
winsound.Beep(1400, 100)
except Exception as e:
print(f"DTMF音播放错误: {str(e)}")
+134
View File
@@ -0,0 +1,134 @@
import sys
import time
from PyQt5.QtWidgets import QApplication
from incoming_call import show_incoming_call
from sound_utils import SoundManager
import serial
def simulate_incoming_call():
"""模拟来电测试程序"""
print("来电模拟器")
print("-" * 50)
# 测试来电对话框
print("测试1: 显示来电对话框")
app = QApplication(sys.argv)
# 创建声音管理器
sound_manager = SoundManager()
# 播放来电铃声
sound_manager.play_incoming_call()
print("播放来电铃声...")
# 显示来电对话框
caller_number = "+8613800138000"
print(f"显示来电: {caller_number}")
result = show_incoming_call(caller_number)
# 停止来电铃声
sound_manager.stop_incoming_call()
# 显示结果
print(f"用户选择了: {'接听' if result else '拒绝'}")
# 测试完成
print("=" * 50)
print("测试完成")
def test_module_ring_function():
"""测试模块的来电功能"""
print("LTE模块来电测试")
print("-" * 50)
# 获取COM口
port = input("请输入AT命令COM口: ")
if not port:
print("未提供COM口,测试结束")
return
# 打开串口
try:
ser = serial.Serial(port, 115200, timeout=1)
print(f"成功打开COM口 {port}")
except Exception as e:
print(f"无法打开COM口: {str(e)}")
return
# 发送AT命令检查模块
try:
ser.write(b"AT\r")
time.sleep(0.5)
response = ser.read(ser.in_waiting).decode('utf-8', errors='replace')
print(f"AT响应: {response}")
if "OK" not in response:
print("模块没有响应AT命令,测试结束")
ser.close()
return
except Exception as e:
print(f"发送AT命令失败: {str(e)}")
ser.close()
return
# 主菜单
while True:
print("\n选择测试选项:")
print("1. 模拟来电 (向模块发送AT+CLIP命令)")
print("2. 等待实际来电")
print("3. 退出")
choice = input("您的选择: ")
if choice == '1':
# 模拟来电
phone_number = input("请输入要模拟的来电号码: ")
if not phone_number:
phone_number = "+8613800138000"
# 发送RING和CLIP命令
print(f"模拟来电: {phone_number}")
ser.write(b"AT+CLIP=1\r")
time.sleep(0.5)
ser.read(ser.in_waiting) # 清除响应
# 发送RING和CLIP
ser.write(b"RING\r\n")
time.sleep(0.5)
clip_cmd = f'AT+CLIP: "{phone_number}",129,"",0,"",0\r\n'
ser.write(clip_cmd.encode())
print("模拟来电信号已发送,查看应用是否有响应")
elif choice == '2':
# 等待实际来电
print("请使用另一部手机拨打模块的电话号码...")
print("按Enter键停止等待")
input()
elif choice == '3':
# 退出
break
else:
print("无效选择,请重试")
# 关闭串口
ser.close()
print("测试结束")
if __name__ == "__main__":
print("来电功能测试工具")
print("=" * 50)
print("1. 测试来电对话框")
print("2. 测试LTE模块来电功能")
choice = input("请选择测试类型: ")
if choice == '1':
simulate_incoming_call()
elif choice == '2':
test_module_ring_function()
else:
print("无效选择,测试结束")
+71
View File
@@ -0,0 +1,71 @@
import sys
import os
from PyQt5.QtWidgets import QApplication, QMainWindow, QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtCore import Qt
class TrayIconTest(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Tray Icon Test")
self.resize(300, 200)
# Create a simple colored icon
pixmap = QPixmap(32, 32)
pixmap.fill(Qt.green)
self.icon = QIcon(pixmap)
# Set window icon
self.setWindowIcon(self.icon)
# Create system tray icon
self.tray_icon = QSystemTrayIcon(self)
self.tray_icon.setIcon(self.icon)
self.tray_icon.setToolTip("Tray Icon Test")
# Create tray menu
tray_menu = QMenu()
# Add actions
show_action = QAction("Show", self)
show_action.triggered.connect(self.show)
tray_menu.addAction(show_action)
exit_action = QAction("Exit", self)
exit_action.triggered.connect(self.close)
tray_menu.addAction(exit_action)
# Set tray menu
self.tray_icon.setContextMenu(tray_menu)
# Connect signals
self.tray_icon.activated.connect(self.on_tray_icon_activated)
# Show tray icon
self.tray_icon.show()
print("Tray icon visible:", self.tray_icon.isVisible())
print("System tray available:", QSystemTrayIcon.isSystemTrayAvailable())
def on_tray_icon_activated(self, reason):
"""Handle tray icon activation"""
if reason == QSystemTrayIcon.DoubleClick:
if self.isVisible():
self.hide()
else:
self.show()
self.activateWindow()
def closeEvent(self, event):
"""Handle close event"""
# Remove tray icon
if self.tray_icon.isVisible():
self.tray_icon.hide()
event.accept()
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
window = TrayIconTest()
window.show()
sys.exit(app.exec_())