commit b8438bba61d27cfe9a6f46b189302c1df13aed95 Author: Hermes Agent Date: Mon Jul 20 16:35:15 2026 +0000 chore: import R0nY3n/LTE_manager main snapshot diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..586c896 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Python bytecode files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg + +# Database files +lte_data.db +dist/lte_data.db + +# PyInstaller +build/ +*.spec + +# Logs +*.log + +# Environment +.env +.venv +env/ +venv/ +ENV/ +ffmpeg/ +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo + +# OS specific files +.DS_Store +Thumbs.db +/lte_manager diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1329ca9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Anti-Capitalist Software License (v 1.4) + +Copyright (C) [Your Name] [Year] + +This is free software. You may use, modify, and distribute it under the +terms of the Anti-Capitalist Software License. + +Purpose: To promote a non-capitalist economy where software is not +exploited for profit. + +Permissions: +- You may use, modify, and share this software freely. + +Restrictions: +- You may NOT use this software for commercial purposes. +- You may NOT sublicense it under terms that allow commercial use. +- You must include this license with all copies of the software. + +Full license text: https://anticapitalist.software/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..61e33bd --- /dev/null +++ b/README.md @@ -0,0 +1,340 @@ +![运行截图](ltetool.png) + +# LTE Tool / LTE 工具 + +A Python+Qt application for managing communications with LTE modules. While specifically optimized for SIM7600CE-T modules, it's compatible with most modules supporting standard AT commands. + +基于Python+Qt开发的LTE模块通信管理工具。虽然专为SIM7600CE-T模块优化,但兼容大多数支持标准AT命令的模块。 + +## Features / 功能特点 + +- Phone call management (make/receive calls, may not be supported by all modules) +- SMS management (send/receive/decode messages, including Chinese) +- Module configuration and status monitoring +- Serial port configuration +- System tray integration with connection status indicator +- Improved sound notifications for incoming messages +- Last used port memory for easier reconnection +- Auto-connect feature at startup +- Enhanced Chinese SMS support + +--- + +- 电话管理(拨打/接听电话,部分模块可能不支持) +- 短信管理(发送/接收/解码消息,支持中文) +- 模块配置和状态监控 +- 串口配置 +- 系统托盘集成,带连接状态指示 +- 改进的来电提示音 +- 记住上次使用的端口,便于重新连接 +- 启动时自动连接功能 +- 增强的中文短信支持 + +## Requirements / 系统要求 + +- Python 3.6+ +- PyQt5 +- pyserial + +## Installation / 安装方法 + +### English: +1. Clone this repository +2. Install dependencies: + ``` + pip install -r requirements.txt + ``` +3. Or use the pre-built executable from the `dist` folder + +### 中文: +1. 克隆此仓库 +2. 安装依赖项: + ``` + pip install -r requirements.txt + ``` +3. 或直接使用`dist`文件夹中的预编译可执行文件 + +## Usage / 使用方法 + +### English: +Run the main application: +``` +python main.py +``` + +Or launch the executable `LTE_Manager.exe` from the `dist` folder. + +### 中文: +运行主应用程序: +``` +python main.py +``` + +或直接从`dist`文件夹启动可执行文件`LTE_Manager.exe`。 + +## Module Configuration / 模块配置 + +### SMS Configuration / 短信配置 +#### English: +The application is configured to work with modules set to automatically push SMS notifications. By default, it supports the `AT+CNMI=2,2,0,0,0` mode, which provides direct SMS content delivery. + +To configure your module for optimal SMS handling: + +1. **Set SMS text mode**: + ``` + AT+CMGF=1 + ``` + +2. **Configure SMS automatic notification**: + ``` + AT+CNMI=2,2,0,0,0 + ``` + + Parameters explanation: + - 2: Enable SMS status reports (immediate notification for new messages) + - 2: New message notifications sent directly to serial port with content + - 0: Disable read status notifications + - 0: Disable cell broadcast + - 0: Disable reporting of unread messages (only report new messages) + +3. **Verify configuration**: + ``` + AT+CNMI? + ``` + + Expected response: + ``` + +CNMI: 2,2,0,0,0 + ``` + +With this configuration, when a new SMS is received, the module will automatically output the complete message content: +``` ++CMT: "+8613812345678","","23/03/13,15:30:00+32" +Hello, this is a test message! +``` + +#### 中文: +应用程序配置为与设置为自动推送短信通知的模块一起工作。默认情况下,它支持`AT+CNMI=2,2,0,0,0`模式,该模式提供直接的短信内容推送。 + +要为您的模块配置最佳短信处理: + +1. **设置短信文本模式**: + ``` + AT+CMGF=1 + ``` + +2. **配置短信自动通知**: + ``` + AT+CNMI=2,2,0,0,0 + ``` + + 参数说明: + - 2: 使能短信状态报告(新消息时直接通知) + - 2: 新消息通知直接发送到串口并包含内容 + - 0: 禁用已读状态通知 + - 0: 关闭小区广播 + - 0: 关闭上报未读短信(只报告新消息) + +3. **验证配置**: + ``` + AT+CNMI? + ``` + + 预期响应: + ``` + +CNMI: 2,2,0,0,0 + ``` + +使用此配置,当收到新短信时,模块将自动输出完整的消息内容: +``` ++CMT: "+8613812345678","","23/03/13,15:30:00+32" +Hello, this is a test message! +``` + +### Alternative SMS Configuration / 替代短信配置 +#### English: +If you prefer to receive only notifications without content and manually read messages, you can use: +``` +AT+CNMI=2,1,0,0,0 +``` + +With this setting, when a new SMS is received, the module will output: +``` ++CMTI: "SM",3 +``` +Where "SM" indicates storage in SIM card and "3" is the message index. + +To read the message content, use: +``` +AT+CMGR=3 +``` +(where 3 is the index from the notification) + +#### 中文: +如果您希望只接收通知而不包含内容,然后手动读取消息,可以使用: +``` +AT+CNMI=2,1,0,0,0 +``` + +使用此设置,当收到新短信时,模块将输出: +``` ++CMTI: "SM",3 +``` +其中"SM"表示存储在SIM卡中,"3"是消息索引。 + +要读取消息内容,请使用: +``` +AT+CMGR=3 +``` +(其中3是通知中的索引) + +## Features Description / 功能描述 + +### Phone & SMS Tab / 电话和短信标签页 +#### English: +- Make and receive phone calls (if supported by your module) +- Send and receive SMS messages (with Chinese support) +- View call and message history +- Manage SMS storage + +#### 中文: +- 拨打和接听电话(如果您的模块支持) +- 发送和接收短信(支持中文) +- 查看通话和短信历史记录 +- 管理短信存储 + +### Settings Tab / 设置标签页 +#### English: +- Configure serial ports (AT and NMEA) +- View module information (IMEI, IMSI, etc.) +- Monitor network status +- Enable auto-connect at startup + +#### 中文: +- 配置串口(AT和NMEA) +- 查看模块信息(IMEI、IMSI等) +- 监控网络状态 +- 启用启动时自动连接 + +### System Tray Features / 系统托盘功能 +#### English: +- The application minimizes to system tray +- Icon indicates connection status (connected/disconnected) +- Right-click menu provides quick access to common functions +- Double-click on the tray icon to restore the application window + +#### 中文: +- 应用程序可最小化到系统托盘 +- 图标指示连接状态(已连接/未连接) +- 右键菜单提供对常用功能的快速访问 +- 双击托盘图标可恢复应用程序窗口 + +## Recent Improvements / 最近改进 +### English: +- Enhanced AT command response parsing to remove command echoes +- Added multiple beep sounds for message notifications +- Implemented port selection memory to remember last used ports +- Added connection status indicator in system tray +- Fixed icon display issues in system tray +- Added auto-connect feature at startup +- Improved Chinese SMS encoding/decoding +- Fixed phone number encoding for Chinese SMS +- Added compatibility with a wider range of AT command modules + +### 中文: +- 增强AT命令响应解析,移除命令回显 +- 添加多次蜂鸣声用于消息通知 +- 实现端口选择记忆功能,记住上次使用的端口 +- 在系统托盘中添加连接状态指示器 +- 修复系统托盘图标显示问题 +- 添加启动时自动连接功能 +- 改进中文短信编码/解码 +- 修复中文短信的电话号码编码问题 +- 增加与更广泛AT命令模块的兼容性 + +## Compatibility / 兼容性 + +### English: +While optimized for SIM7600CE-T modules, this tool is designed to work with most modules that support standard AT commands. The phone functionality may not be available on all modules, but the SMS and configuration features should work on most AT command compatible devices. + +### 中文: +虽然针对SIM7600CE-T模块进行了优化,但此工具设计为可与大多数支持标准AT命令的模块一起使用。电话功能可能并非在所有模块上都可用,但短信和配置功能应该在大多数兼容AT命令的设备上正常工作。 + +## Donation / 打赏支持 + +### English: +If you find this tool helpful, consider supporting the developer: + +### 中文: +如果您觉得这个工具有用,可以考虑打赏支持开发者,谢谢: + +![支付宝收款码](alipay.png) + +## License / 许可证 + +### English: +This project is licensed under the Adaptive Community Source License (ACSL). + +The ACSL is a community-oriented license that allows for free use, modification, and distribution of the software, while encouraging contributions back to the community. Key points: + +1. You can use, modify, and distribute this software freely. +2. If you distribute modified versions, you should make your changes available to the community. +3. Commercial use is permitted, but commercial redistributions should contribute improvements back. +4. No warranty is provided; use at your own risk. + +[Read the full ACSL license](https://anticapitalist.software/) + +### 中文: +本项目采用自适应社区源代码许可证 (ACSL) 授权。 + +ACSL是一种面向社区的许可证,允许自由使用、修改和分发软件,同时鼓励向社区回馈贡献。主要要点: + +1. 您可以自由使用、修改和分发此软件。 +2. 如果您分发修改版本,应将您的更改提供给社区。 +3. 允许商业使用,但商业再分发应将改进回馈给社区。 +4. 不提供任何保证;使用风险自负。 + +[阅读完整license](https://anticapitalist.software/) + +## 数据存储 + +- 数据库文件现在保存在用户主目录下的 `.LTE` 文件夹中 +- 这样可以在应用更新后保留历史记录 +- 数据库路径: `C:\Users\<用户名>\.LTE\lte_data.db` + +## 图标系统 + +应用现在使用三种状态图标: +- default.png: 默认状态/未连接 +- running.png: 连接成功/正在运行 +- error.png: 发生错误 + +## 使用方法 + +1. 连接LTE模块到计算机 +2. 在设置选项卡中配置串口设置 +3. 连接到模块 +4. 使用电话和短信功能 + +## 系统托盘 + +应用程序可以最小化到系统托盘。双击托盘图标可以显示/隐藏主窗口。 + +## 开发者注意事项 + +使用PyInstaller打包时的资源文件注意事项: + +1. 图标文件应在spec文件中添加为附加数据: + ``` + a = Analysis(..., + datas=[ + ('default.png', '.'), + ('running.png', '.'), + ('error.png', '.') + ], + ...) + ``` + +2. 可以使用 `create_icons.py` 脚本生成所需的默认图标文件 + +3. 数据库文件将自动保存在用户主目录的.LTE文件夹中 \ No newline at end of file diff --git a/SIM7100_SIM7500_SIM7600_SIM7800 Series_USB AUDIO_Application Note_V3.00.pdf b/SIM7100_SIM7500_SIM7600_SIM7800 Series_USB AUDIO_Application Note_V3.00.pdf new file mode 100644 index 0000000..3f43388 Binary files /dev/null and b/SIM7100_SIM7500_SIM7600_SIM7800 Series_USB AUDIO_Application Note_V3.00.pdf differ diff --git a/alipay.png b/alipay.png new file mode 100644 index 0000000..32abed1 Binary files /dev/null and b/alipay.png differ diff --git a/audio.py b/audio.py new file mode 100644 index 0000000..80f177f --- /dev/null +++ b/audio.py @@ -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) \ No newline at end of file diff --git a/audio_features.py b/audio_features.py new file mode 100644 index 0000000..9e43c20 --- /dev/null +++ b/audio_features.py @@ -0,0 +1,588 @@ +import os +import time +import logging +from PyQt5.QtCore import QObject, pyqtSignal +import platform + +# 配置日志记录 +logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger("AudioFeatures") + +class AudioFeatures(QObject): + """音频功能模块,提供通话录音和音频播放功能""" + + status_changed = pyqtSignal(str) # 状态变化信号 + + def __init__(self, lte_manager): + super().__init__() + self.lte_manager = lte_manager + self.recording = False + self.playing = False + self.auto_record_calls = False # 是否自动录制通话 + self.auto_play_after_call = False # 是否在通话结束后自动播放录音 + self.auto_play_on_answer = False # 是否在接听电话时自动播放声音 + self.answer_play_audio_file = None # 接听时播放的音频文件 + self.current_recording_file = None # 当前录音文件路径 + + # 获取用户 home 目录 + self.user_home = os.path.expanduser("~") + + # 创建默认存储路径:用户home/.LTE/REC/ + self.base_storage_path = os.path.join(self.user_home, ".LTE") + self.storage_path = os.path.join(self.base_storage_path, "REC") + self.ensure_storage_path() + + # 创建音频文件存储路径 + self.audio_storage_path = os.path.join(self.base_storage_path, "AUDIO") + self.ensure_audio_storage_path() + + # 支持的音频格式 + self.supported_formats = [".amr", ".wav", ".mp3", ".pcm"] + + def ensure_storage_path(self): + """确保存储路径存在""" + try: + if not os.path.exists(self.storage_path): + os.makedirs(self.storage_path) + logger.info(f"已创建存储路径: {self.storage_path}") + except Exception as e: + logger.error(f"创建存储路径失败: {str(e)}") + + def ensure_audio_storage_path(self): + """确保音频文件存储路径存在""" + try: + if not os.path.exists(self.audio_storage_path): + os.makedirs(self.audio_storage_path) + logger.info(f"已创建音频存储路径: {self.audio_storage_path}") + except Exception as e: + logger.error(f"创建音频存储路径失败: {str(e)}") + + def set_storage_path(self, path): + """设置存储路径""" + if os.path.exists(path): + self.storage_path = path + logger.info(f"存储路径已设置为: {path}") + return True + else: + logger.error(f"存储路径不存在: {path}") + return False + + def set_auto_record_calls(self, enabled): + """设置是否自动录制通话""" + self.auto_record_calls = enabled + logger.info(f"自动录制通话功能已{'启用' if enabled else '禁用'}") + return True + + def set_auto_play_after_call(self, enabled): + """设置是否在通话结束后自动播放录音""" + self.auto_play_after_call = enabled + logger.info(f"通话结束后自动播放录音功能已{'启用' if enabled else '禁用'}") + return True + + def set_auto_play_on_answer(self, enabled, audio_file=None): + """ + 设置是否在接听电话时自动播放音频 + + 参数: + - enabled: 是否启用该功能 + - audio_file: 要播放的音频文件路径,如果为None则使用当前设置的文件 + + 返回: + - bool: 是否成功设置 + """ + self.auto_play_on_answer = enabled + + if audio_file: + # 检查文件是否存在且格式支持 + if os.path.exists(audio_file) and any(audio_file.lower().endswith(fmt) for fmt in self.supported_formats): + self.answer_play_audio_file = audio_file + logger.info(f"接听电话自动播放音频功能已{'启用' if enabled else '禁用'}, 音频文件: {os.path.basename(audio_file)}") + return True + else: + logger.error(f"音频文件不存在或格式不支持: {audio_file}") + return False + else: + # 如果未指定文件但已有设置的文件 + if enabled and not self.answer_play_audio_file: + logger.warning("启用接听电话自动播放音频功能,但未指定音频文件") + return False + + logger.info(f"接听电话自动播放音频功能已{'启用' if enabled else '禁用'}") + return True + + def play_on_answer(self, phone_number=None): + """ + 接听电话时自动播放音频 + + 参数: + - phone_number: 可选,来电号码用于日志记录 + + 返回: + - bool: 是否成功播放 + """ + if not self.auto_play_on_answer or not self.answer_play_audio_file: + logger.info("接听电话自动播放功能未启用或未设置音频文件") + return False + + if not os.path.exists(self.answer_play_audio_file): + logger.error(f"接听自动播放音频文件不存在: {self.answer_play_audio_file}") + return False + + # 先停止可能正在播放的音频 + if self.playing: + self.stop_audio() + time.sleep(0.5) # 等待停止完成 + + # 使用远程播放模式,让对方能听到声音 + logger.info(f"接听电话({phone_number}),自动播放音频: {os.path.basename(self.answer_play_audio_file)}") + return self.play_audio(self.answer_play_audio_file, play_path=1) # play_path=1表示远程播放,对方听得到 + + def start_call_recording(self, phone_number=None): + """ + 开始通话录音 + + 参数: + - phone_number: 电话号码,用于文件命名 + + 返回: + - bool: 是否成功开始录音 + - str: 录音文件路径 + """ + if not phone_number: + phone_number = self.lte_manager.call_number if hasattr(self.lte_manager, 'call_number') else "unknown" + + # 创建基于电话号码和时间的文件名 + timestamp = time.strftime("%Y%m%d_%H%M%S") + filename = f"call_{phone_number}_{timestamp}.wav" + + # 完整文件路径 + file_path = os.path.join(self.storage_path, filename) + + # 存储当前录音文件路径用于可能的自动播放 + self.current_recording_file = file_path + + # 调用录音方法,使用双方声音混合录制模式(3) + result = self.start_recording(filename=filename, record_path=3) + + return result, file_path + + def start_recording(self, filename=None, record_path=1): + """ + 开始录音 + + 参数: + - filename: 录音文件名,不包含路径。如果未提供,则使用时间戳命名 + - record_path: 录音路径类型 + 1 = 本地路径 (录制本地麦克风) + 2 = 远程路径 (录制通话对方声音) + 3 = 混合模式 (录制双方声音) + + 返回: + - bool: 是否成功开始录音 + """ + if self.recording: + logger.warning("当前已有录音正在进行") + return False + + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法开始录音") + self.status_changed.emit("未连接到LTE模块,无法开始录音") + return False + + # 如果未提供文件名,使用时间戳命名 + if not filename: + timestamp = time.strftime("%Y%m%d_%H%M%S") + filename = f"recording_{timestamp}.wav" # 默认使用wav格式 + + # 确保文件格式正确 + if not any(filename.lower().endswith(fmt) for fmt in self.supported_formats): + filename += ".wav" # 默认使用wav格式 + + # 组合完整路径 + file_path = os.path.join(self.storage_path, filename) + # 使用模块内路径格式 (c:/ 对应模块内存储) + module_path = f"c:/{os.path.basename(filename)}" + + # 记录当前录音文件路径 + self.current_recording_file = file_path + + # 发送录音命令 + command = f'AT+CREC={record_path},"{module_path}"' + response = self.lte_manager.send_at_command(command) + + if "+CREC: 1" in response or "+CREC: 2" in response or "+CREC: 3" in response: + self.recording = True + logger.info(f"录音已开始: {file_path}") + self.status_changed.emit(f"录音已开始: {os.path.basename(file_path)}") + return True + else: + logger.error(f"开始录音失败: {response}") + self.status_changed.emit(f"开始录音失败") + self.current_recording_file = None + return False + + def stop_recording(self): + """ + 停止录音 + + 返回: + - bool: 是否成功停止录音 + """ + if not self.recording: + logger.warning("当前没有录音正在进行") + return False + + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法停止录音") + self.status_changed.emit("未连接到LTE模块,无法停止录音") + return False + + # 发送停止录音命令 + command = "AT+CREC=0" + response = self.lte_manager.send_at_command(command) + + if "+CREC: 0" in response: + self.recording = False + logger.info("录音已停止") + self.status_changed.emit("录音已停止") + + # 等待模块完成录音处理 + time.sleep(0.5) + + # 检查是否收到录音完成信号 + if "+CREC: crec stop" in response: + logger.info("录音已完成处理") + + # 检查是否需要自动播放录音 + if self.auto_play_after_call and self.current_recording_file: + logger.info(f"准备自动播放录音: {self.current_recording_file}") + # 等待一下以确保录音文件已完成保存 + time.sleep(1) + self.play_audio(self.current_recording_file) + + return True + else: + logger.error(f"停止录音失败: {response}") + self.status_changed.emit("停止录音失败") + return False + + def is_recording(self): + """ + 检查是否正在录音 + + 返回: + - bool: 是否正在录音 + """ + if not self.lte_manager.is_connected(): + return False + + # 查询录音状态 + response = self.lte_manager.send_at_command("AT+CREC?") + + if "+CREC: 1" in response or "+CREC: 2" in response or "+CREC: 3" in response: + self.recording = True + return True + else: + self.recording = False + return False + + def play_audio(self, filename, repeat=0, play_path=0): + """ + 播放音频文件 + + 参数: + - filename: 音频文件名,可以是绝对路径或相对路径 + - repeat: 重复播放次数,0表示只播放一次,1-255表示重复播放的次数 + - play_path: 播放路径 + 0 = 本地播放(默认) + 1 = 远程播放(通话时对方听到) + 2 = 双方都播放(本地和远程) + + 返回: + - bool: 是否成功开始播放 + """ + if self.playing: + logger.warning("当前已有音频正在播放") + self.stop_audio() # 先停止当前播放 + + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法播放音频") + self.status_changed.emit("未连接到LTE模块,无法播放音频") + return False + + # 确保文件存在且格式受支持 + if not os.path.exists(filename): + # 尝试在存储路径中查找 + full_path = os.path.join(self.storage_path, filename) + if not os.path.exists(full_path): + # 尝试在音频路径中查找 + audio_path = os.path.join(self.audio_storage_path, filename) + if not os.path.exists(audio_path): + logger.error(f"音频文件不存在: {filename}") + self.status_changed.emit(f"音频文件不存在: {os.path.basename(filename)}") + return False + filename = audio_path + else: + filename = full_path + + if not any(filename.lower().endswith(fmt) for fmt in self.supported_formats): + logger.error(f"不支持的音频格式: {filename}") + self.status_changed.emit(f"不支持的音频格式: {os.path.basename(filename)}") + return False + + # 转换为模块内路径格式 + module_path = f"c:/{os.path.basename(filename)}" + + # 根据文件类型选择播放命令 (对于wav文件可以使用AT+CCMXPLAYWAV) + if filename.lower().endswith(".wav"): + command = f'AT+CCMXPLAYWAV="{module_path}",{play_path}' + else: + # 其他格式使用AT+CCMXPLAY命令 + command = f'AT+CCMXPLAY="{module_path}",{play_path},{repeat}' + + response = self.lte_manager.send_at_command(command) + + success = False + if "+CCMXPLAY:" in response and "OK" in response: + success = True + elif "+CCMXPLAYWAV:" in response and "OK" in response: + success = True + + if success: + self.playing = True + logger.info(f"开始播放音频: {filename}") + self.status_changed.emit(f"开始播放音频: {os.path.basename(filename)}") + return True + else: + logger.error(f"播放音频失败: {response}") + self.status_changed.emit("播放音频失败") + return False + + def stop_audio(self): + """ + 停止音频播放 + + 返回: + - bool: 是否成功停止播放 + """ + if not self.playing: + logger.info("当前没有音频播放") + return True + + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法停止播放") + self.status_changed.emit("未连接到LTE模块,无法停止播放") + return False + + # 检查是否正在播放WAV文件 + response_wav = self.lte_manager.send_at_command("AT+CCMXSTOPWAV") + response_normal = self.lte_manager.send_at_command("AT+CCMXSTOP") + + success = False + + if "+CCMXSTOPWAV:" in response_wav and "OK" in response_wav: + success = True + elif "+CCMXSTOP:" in response_normal and "OK" in response_normal: + success = True + + if success: + self.playing = False + logger.info("音频播放已停止") + self.status_changed.emit("音频播放已停止") + return True + else: + logger.error(f"停止音频播放失败: {response_wav}, {response_normal}") + self.status_changed.emit("停止音频播放失败") + return False + + def set_ringtone(self, filename): + """ + 设置来电铃声 + + 参数: + - filename: 铃声文件名,可以是绝对路径或相对路径 + + 返回: + - bool: 是否成功设置铃声 + """ + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法设置铃声") + self.status_changed.emit("未连接到LTE模块,无法设置铃声") + return False + + # 确保文件存在且格式受支持 + if not os.path.exists(filename): + # 尝试在存储路径中查找 + full_path = os.path.join(self.storage_path, filename) + if not os.path.exists(full_path): + # 尝试在音频路径中查找 + audio_path = os.path.join(self.audio_storage_path, filename) + if not os.path.exists(audio_path): + logger.error(f"铃声文件不存在: {filename}") + self.status_changed.emit(f"铃声文件不存在: {os.path.basename(filename)}") + return False + filename = audio_path + else: + filename = full_path + + if not any(filename.lower().endswith(fmt) for fmt in self.supported_formats): + logger.error(f"不支持的铃声格式: {filename}") + self.status_changed.emit(f"不支持的铃声格式: {os.path.basename(filename)}") + return False + + # 转换为模块内路径格式 + module_path = f"c:/{os.path.basename(filename)}" + + # 发送设置铃声命令 + command = f'AT+CRINGSET="{module_path}",1' + response = self.lte_manager.send_at_command(command) + + if "OK" in response: + logger.info(f"铃声已设置: {filename}") + self.status_changed.emit(f"铃声已设置: {os.path.basename(filename)}") + return True + else: + logger.error(f"设置铃声失败: {response}") + self.status_changed.emit("设置铃声失败") + return False + + def ring_switch(self, enable=True): + """ + 开启或关闭铃声 + + 参数: + - enable: 是否启用铃声 + + 返回: + - bool: 是否成功切换铃声状态 + """ + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法切换铃声状态") + self.status_changed.emit("未连接到LTE模块,无法切换铃声状态") + return False + + # 发送铃声开关命令 + status = 1 if enable else 0 + command = f"AT+CRTSWITCH={status}" + response = self.lte_manager.send_at_command(command) + + if "OK" in response: + state = "开启" if enable else "关闭" + logger.info(f"铃声已{state}") + self.status_changed.emit(f"铃声已{state}") + return True + else: + logger.error(f"切换铃声状态失败: {response}") + self.status_changed.emit("切换铃声状态失败") + return False + + def generate_dtmf(self, dtmf_string, duration=1, time_base=100): + """ + 生成DTMF音 + + 参数: + - dtmf_string: DTMF字符串,如"1,2,3,4" + - duration: 持续时间因子,1-100 + - time_base: 时间基准,50-500ms + + 返回: + - bool: 是否成功生成DTMF音 + """ + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法生成DTMF音") + self.status_changed.emit("未连接到LTE模块,无法生成DTMF音") + return False + + # 确保DTMF字符串格式正确(数字、字母A-D、*、#,逗号分隔) + valid_chars = set("0123456789ABCD*#,") + if not all(c.upper() in valid_chars for c in dtmf_string): + logger.error(f"无效的DTMF字符串: {dtmf_string}") + self.status_changed.emit("无效的DTMF字符串") + return False + + # 发送DTMF生成命令 + command = f'AT+CLDTMF={duration},"{dtmf_string}",{time_base},0' + response = self.lte_manager.send_at_command(command) + + if "OK" in response: + logger.info(f"DTMF音已生成: {dtmf_string}") + self.status_changed.emit(f"DTMF音已生成") + return True + else: + logger.error(f"生成DTMF音失败: {response}") + self.status_changed.emit("生成DTMF音失败") + return False + + def generate_tone(self, frequency=1000, period_on=200, period_off=200, duration=1000): + """ + 生成特定频率的音调 + + 参数: + - frequency: 频率,20-4000Hz + - period_on: 音调开启周期,50-25500ms + - period_off: 音调关闭周期,0或40-25500ms + - duration: 持续时间,50-500000ms + + 返回: + - bool: 是否成功生成音调 + """ + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法生成音调") + self.status_changed.emit("未连接到LTE模块,无法生成音调") + return False + + # 检查参数范围 + if not (20 <= frequency <= 4000): + logger.error(f"频率超出范围(20-4000Hz): {frequency}") + frequency = max(20, min(frequency, 4000)) + + if not (50 <= period_on <= 25500): + logger.error(f"开启周期超出范围(50-25500ms): {period_on}") + period_on = max(50, min(period_on, 25500)) + + if period_off != 0 and not (40 <= period_off <= 25500): + logger.error(f"关闭周期超出范围(0或40-25500ms): {period_off}") + period_off = max(40, min(period_off, 25500)) + + if not (50 <= duration <= 500000): + logger.error(f"持续时间超出范围(50-500000ms): {duration}") + duration = max(50, min(duration, 500000)) + + # 发送音调生成命令 + command = f"AT+SIMTONE=1,{frequency},{period_on},{period_off},{duration}" + response = self.lte_manager.send_at_command(command) + + if "OK" in response: + logger.info(f"音调已生成: {frequency}Hz") + self.status_changed.emit(f"音调已生成: {frequency}Hz") + return True + else: + logger.error(f"生成音调失败: {response}") + self.status_changed.emit("生成音调失败") + return False + + def stop_tone(self): + """ + 停止音调 + + 返回: + - bool: 是否成功停止音调 + """ + if not self.lte_manager.is_connected(): + logger.error("未连接到LTE模块,无法停止音调") + self.status_changed.emit("未连接到LTE模块,无法停止音调") + return False + + # 发送停止音调命令 + command = "AT+SIMTONE=0" + response = self.lte_manager.send_at_command(command) + + if "OK" in response: + logger.info("音调已停止") + self.status_changed.emit("音调已停止") + return True + else: + logger.error(f"停止音调失败: {response}") + self.status_changed.emit("停止音调失败") + return False \ No newline at end of file diff --git a/backup/alipay.png b/backup/alipay.png new file mode 100644 index 0000000..32abed1 Binary files /dev/null and b/backup/alipay.png differ diff --git a/backup/audio.py b/backup/audio.py new file mode 100644 index 0000000..80f177f --- /dev/null +++ b/backup/audio.py @@ -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) \ No newline at end of file diff --git a/backup/build_exe.py b/backup/build_exe.py new file mode 100644 index 0000000..8d3dd9e --- /dev/null +++ b/backup/build_exe.py @@ -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() \ No newline at end of file diff --git a/backup/create_icons.py b/backup/create_icons.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/backup/create_icons.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backup/database.py b/backup/database.py new file mode 100644 index 0000000..4a3e6b5 --- /dev/null +++ b/backup/database.py @@ -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 \ No newline at end of file diff --git a/backup/default.png b/backup/default.png new file mode 100644 index 0000000..f8bca78 Binary files /dev/null and b/backup/default.png differ diff --git a/backup/error.png b/backup/error.png new file mode 100644 index 0000000..1f76849 Binary files /dev/null and b/backup/error.png differ diff --git a/backup/ffmpeg_audio.py b/backup/ffmpeg_audio.py new file mode 100644 index 0000000..721c5ad --- /dev/null +++ b/backup/ffmpeg_audio.py @@ -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) \ No newline at end of file diff --git a/backup/incoming_call.py b/backup/incoming_call.py new file mode 100644 index 0000000..dfdc263 --- /dev/null +++ b/backup/incoming_call.py @@ -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 '拒绝'}来电") \ No newline at end of file diff --git a/backup/lte_manager.py b/backup/lte_manager.py new file mode 100644 index 0000000..0eb0145 --- /dev/null +++ b/backup/lte_manager.py @@ -0,0 +1,2062 @@ +import sys +import os +import time +import threading +import serial +import re +import platform +import queue +import binascii +from datetime import datetime +from PyQt5.QtCore import QObject, pyqtSignal, QDateTime, QTimer +from sms_utils import text_to_ucs2, ucs2_to_text, is_chinese_text, format_phone_number + +class LTEManager(QObject): + # Signals + sms_received = pyqtSignal(str, str, str) # sender, timestamp, message + call_received = pyqtSignal(str) # caller number + call_ended = pyqtSignal(str) # duration + status_changed = pyqtSignal(str) # status message + dtmf_received = pyqtSignal(str) # DTMF tone + pcm_audio_status = pyqtSignal(bool) # PCM audio registration status (True=registered, False=unregistered) + + def __init__(self): + super().__init__() + self.connected = False + self.running = False + self.at_serial = None + self.nmea_serial = None + self.read_thread = None + self.command_queue = [] + self.command_event = threading.Event() + self.command_lock = threading.Lock() + self.response_buffer = [] + self.last_response_time = 0 + + # 通话状态属性 + self.in_call = False + self.call_number = "" + self.call_connected = False # 标记通话是否已经接通(区分来电振铃和通话接通) + self.call_notification_sent = False # 标记是否已发送来电通知 + self.pcm_registered = False + + # 模块信息属性 + self.manufacturer = "" + self.model = "" + self.imei = "" + self.firmware = "" + self.phone_number = "" + self.carrier = "" + self.network_type = "" + self.signal_strength = "" + + # SMS 相关变量 + self.waiting_for_sms_content = False + self.pending_sms_sender = None + self.pending_sms_timestamp = None + self.concat_sms_parts = {} # 用于存储长短信的各个部分 + self.concat_sms_timeout = 30 # 长短信合并超时时间(秒) + + # 兼容性处理:模拟队列以便兼容旧代码 + self.response_queue = queue.Queue() + + # 通话状态定义 + self.call_states = { + 0: "正在进行", # active + 1: "保持", # hold + 2: "拨号中", # dialing (MO) + 3: "振铃中", # alerting (MO) + 4: "来电中", # incoming (MT) + 5: "等待中" # waiting (MT) + } + + self.cleanup_timer = QTimer() + self.cleanup_timer.timeout.connect(self._cleanup_old_sms_parts) + self.cleanup_timer.start(300000) # 每5分钟清理一次 + + # 添加信息缓存,用于减少AT命令频率 + self.info_cache = { + 'carrier': {'value': None, 'timestamp': 0, 'valid_time': 300}, # 运营商信息缓存5分钟 + 'phone_number': {'value': None, 'timestamp': 0, 'valid_time': 3600}, # 电话号码缓存1小时 + 'network': {'value': None, 'timestamp': 0, 'valid_time': 60}, # 网络信息缓存1分钟 + 'signal': {'value': None, 'timestamp': 0, 'valid_time': 10}, # 信号强度缓存10秒 + 'module_info': {'value': None, 'timestamp': 0, 'valid_time': 3600} # 模块信息缓存1小时 + } + + # 设置日志文件 + self.setup_log_file() + + def setup_log_file(self): + """设置AT命令日志文件""" + try: + # 在用户主目录下的.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) + + # 使用日期作为文件名 + current_date = datetime.now().strftime("%Y-%m-%d") + self.log_file_path = os.path.join(lte_dir, f"at_commands_{current_date}.log") + + # 打开日志文件,使用追加模式 + self.log_file = open(self.log_file_path, 'a', encoding='utf-8') + + # 写入日志文件头 + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.log_file.write(f"\n\n{'='*50}\n") + self.log_file.write(f"LTE Manager AT交互日志 - 会话开始: {timestamp}\n") + self.log_file.write(f"{'='*50}\n\n") + self.log_file.flush() + + print(f"AT命令日志文件已创建: {self.log_file_path}") + return True + except Exception as e: + print(f"创建AT命令日志文件失败: {str(e)}") + self.log_file = None + return False + + def log_at_command(self, is_command, content): + """记录AT命令或响应到日志文件""" + try: + if not hasattr(self, 'log_file') or self.log_file is None: + # 如果日志文件不存在,尝试重新创建 + self.setup_log_file() + if not hasattr(self, 'log_file') or self.log_file is None: + # 如果创建失败,仅打印到控制台 + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + prefix = ">>>" if is_command else "<<<" + print(f"[{timestamp}] {prefix} {content}") + return # 放弃写入日志 + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + if is_command: + log_entry = f"[{timestamp}] >>> {content}\n" + else: + log_entry = f"[{timestamp}] <<< {content}\n" + + self.log_file.write(log_entry) + self.log_file.flush() + except Exception as e: + print(f"写入AT命令日志失败: {str(e)}") + try: + # 尝试重新创建日志 + self.setup_log_file() + except: + # 如果仍然失败,放弃 + pass + + def close_log_file(self): + """关闭AT命令日志文件""" + if self.log_file: + try: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.log_file.write(f"\n{'='*50}\n") + self.log_file.write(f"LTE Manager AT交互日志 - 会话结束: {timestamp}\n") + self.log_file.write(f"{'='*50}\n") + self.log_file.close() + self.log_file = None + print(f"AT命令日志文件已关闭: {self.log_file_path}") + except Exception as e: + print(f"关闭AT命令日志文件失败: {str(e)}") + + def _reset_cache(self, specific_key=None): + """重置缓存,可以重置特定键或所有缓存""" + if specific_key: + if specific_key in self.info_cache: + self.info_cache[specific_key]['value'] = None + self.info_cache[specific_key]['timestamp'] = 0 + else: + # 重置所有缓存 + for key in self.info_cache: + self.info_cache[key]['value'] = None + self.info_cache[key]['timestamp'] = 0 + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 设备信息缓存已重置") + + def connect(self, at_port, at_baudrate=115200, nmea_port="", nmea_baudrate=9600): + """Connect to the LTE module""" + if self.connected: + return False + + try: + # 记录连接参数 + self.log_at_command(False, f"[INFO] 连接到LTE模块 - AT端口: {at_port} 波特率: {at_baudrate}") + if nmea_port: + self.log_at_command(False, f"[INFO] NMEA端口: {nmea_port} 波特率: {nmea_baudrate}") + + # Connect to AT port + self.at_serial = serial.Serial( + port=at_port, + baudrate=at_baudrate, + timeout=1 + ) + + # Set DTR/RTS + self.at_serial.setDTR(True) + self.at_serial.setRTS(True) + + # Connect to NMEA port (if specified) + if nmea_port: + self.nmea_serial = serial.Serial( + port=nmea_port, + baudrate=nmea_baudrate, + timeout=1 + ) + + # Initialize running flag and start read thread + self.running = True + self.read_thread = threading.Thread(target=self._read_serial) + self.read_thread.daemon = True + self.read_thread.start() + + # 重置所有缓存,确保获取最新信息 + self._reset_cache() + + # Initialize module + if not self._initialize_module(): + self.log_at_command(False, f"[ERROR] 初始化模块失败") + self.status_changed.emit("Failed to initialize module") + self.disconnect() # 确保断开连接,释放资源 + return False + + # 等待一段时间以确保模块初始化完成 + time.sleep(1) + + # 获取模块基本信息 + try: + self._update_carrier_info() + except Exception as e: + self.log_at_command(False, f"[WARNING] 获取运营商信息失败: {str(e)}") + print(f"获取运营商信息失败: {str(e)}") + + try: + self._update_phone_number() + except Exception as e: + self.log_at_command(False, f"[WARNING] 获取电话号码失败: {str(e)}") + print(f"获取电话号码失败: {str(e)}") + + try: + self._update_signal_strength() + except Exception as e: + self.log_at_command(False, f"[WARNING] 获取信号强度失败: {str(e)}") + print(f"获取信号强度失败: {str(e)}") + + # Update status + self.connected = True + self.status_changed.emit("Connected to LTE module") + + # 记录连接信息到日志 + self.log_at_command(False, f"[INFO] 已连接到LTE模块") + + # 连接成功后日志中记录基本信息 + self.log_at_command(False, f"[INFO] 运营商: {self.carrier if hasattr(self, 'carrier') and self.carrier else '未知'}") + self.log_at_command(False, f"[INFO] 网络状态: {self.network_type if hasattr(self, 'network_type') and self.network_type else '未知'}") + self.log_at_command(False, f"[INFO] 电话号码: {self.phone_number if hasattr(self, 'phone_number') and self.phone_number else '未知'}") + self.log_at_command(False, f"[INFO] 信号强度: {self.signal_strength if hasattr(self, 'signal_strength') and self.signal_strength else '未知'}") + + return True + except Exception as e: + self.log_at_command(False, f"[ERROR] 连接失败: {str(e)}") + self.status_changed.emit(f"Connection error: {str(e)}") + + # 确保所有资源被释放 + try: + if hasattr(self, 'at_serial') and self.at_serial and self.at_serial.is_open: + self.at_serial.close() + if hasattr(self, 'nmea_serial') and self.nmea_serial and self.nmea_serial.is_open: + self.nmea_serial.close() + self.running = False + self.connected = False + except Exception as cleanup_error: + self.log_at_command(False, f"[ERROR] 清理连接资源时出错: {str(cleanup_error)}") + + return False + + def disconnect(self): + """Disconnect from the LTE module""" + if not self.connected: + return False + + try: + # 记录断开连接信息到日志 + self.log_at_command(False, "[INFO] 正在断开与LTE模块的连接") + + # 重置所有缓存,确保下次连接时获取最新信息 + self._reset_cache() + + # 确保所有通话已结束 + if self.in_call: + self.end_call() + + # 确保PCM音频已取消注册 + if self.pcm_registered: + self._ensure_pcm_audio_unregistered() + + # Stop running flag (will stop read thread) + self.running = False + + # Close AT serial + if self.at_serial and self.at_serial.is_open: + try: + # 正常关闭串口 + self.at_serial.close() + except Exception as e: + self.log_at_command(False, f"[ERROR] 关闭AT串口出错: {str(e)}") + + # Close NMEA serial + if self.nmea_serial and self.nmea_serial.is_open: + try: + self.nmea_serial.close() + except Exception as e: + self.log_at_command(False, f"[ERROR] 关闭NMEA串口出错: {str(e)}") + + # Wait for read thread to end + if self.read_thread and self.read_thread.is_alive(): + self.read_thread.join(timeout=2.0) + + # Update status + self.connected = False + self.in_call = False + self.call_number = "" + self.pcm_registered = False + self.status_changed.emit("Disconnected from LTE module") + + # Stop cleanup timer + self.cleanup_timer.stop() + + # 关闭日志文件 + self.close_log_file() + + return True + except Exception as e: + self.status_changed.emit(f"Disconnection error: {str(e)}") + # 记录断开连接错误 + if self.log_file: + self.log_at_command(False, f"[ERROR] 断开连接出错: {str(e)}") + + # 尝试关闭日志文件,即使发生错误 + self.close_log_file() + + return False + + def is_connected(self): + """Check if connected to the LTE module""" + return self.connected + + def send_at_command(self, command, timeout=5.0, expect_response=True): + """Send AT command to the module and wait for response""" + if not self.connected or not self.at_serial: + if self.log_file: + self.log_at_command(True, f"{command} (未发送 - 模块未连接)") + return None + + with self.command_lock: + # 记录发送的AT命令 + self.log_at_command(True, command) + + # 清空响应缓冲区 + self.response_buffer.clear() + self.last_response_time = time.time() + + # Send command + cmd = command.strip() + if not cmd.endswith('\r'): + cmd += '\r' + + self.at_serial.write(cmd.encode()) + + if not expect_response: + self.log_at_command(False, "(无需等待响应)") + return None + + # Wait for response + start_time = time.time() + response = [] + command_echo_received = False + response_text = "" + + while time.time() - start_time < timeout: + # 检查缓冲区是否有新响应 + if self.response_buffer: + # 获取并移除第一个响应 + line = self.response_buffer.pop(0) + + # 跳过命令回显行 + if not command_echo_received and line.strip() == command.strip(): + command_echo_received = True + continue + + response.append(line) + + # Check if response is complete (ends with OK or ERROR) + if line.strip() in ["OK", "ERROR"] or "ERROR" in line: + break + + # 短暂等待新响应 + time.sleep(0.05) + + # 检查最后响应时间,如果超过一定时间没有新响应且已收到内容,可以提前结束 + if response and time.time() - self.last_response_time > 0.5: + break + + # 构建完整响应文本 + response_text = '\n'.join(response) + + # 如果超时且没有收到任何响应,记录到日志 + if not response: + self.log_at_command(False, f"(命令响应超时,等待时间: {timeout}秒)") + + return response_text + + def _read_serial(self): + """Read data from serial port in a separate thread""" + buffer = "" + + while self.running: + if not self.at_serial or not self.at_serial.is_open: + time.sleep(0.1) + continue + + try: + # Read data from serial port + data = self.at_serial.read(self.at_serial.in_waiting or 1) + if data: + text = data.decode('utf-8', errors='replace') + buffer += text + + # Process complete lines + while '\r\n' in buffer: + line, buffer = buffer.split('\r\n', 1) + line = line.strip() + + if not line: + continue + + # 记录所有接收到的完整行,不论是否是命令回显 + # 这确保了所有AT交互都被记录,包括模块的主动上报 + if not line.startswith("AT"): # 不是命令回显 + self.log_at_command(False, line) + + # Process unsolicited responses + self._process_unsolicited(line) + + # 同时使用缓冲区列表和队列来存储响应,确保兼容性 + self.response_buffer.append(line) + self.response_queue.put(line) # 向旧的队列接口发送数据 + self.last_response_time = time.time() + except Exception as e: + print(f"Serial read error: {str(e)}") + # 记录读取错误 + if self.log_file: + self.log_at_command(False, f"[ERROR] 读取串口数据出错: {str(e)}") + time.sleep(0.1) + + def _process_unsolicited(self, line): + """Process unsolicited responses from the module""" + # Incoming call + if line.startswith("RING"): + self.status_changed.emit("Incoming call") + self.in_call = True + # 设置为未接听状态 + self.call_connected = False + # Reset notification flag on new RING + self.call_notification_sent = False + + + # Caller ID + elif "+CLIP:" in line: + match = re.search(r'\+CLIP: "([^"]+)"', line) + if match: + number = match.group(1) + self.call_number = number + + # Only emit the signal if we haven't sent a notification for this call yet + if not self.call_notification_sent and self.in_call: + self.call_received.emit(number) + self.call_notification_sent = True + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Call notification sent for {number}") + + # Call ended + elif "NO CARRIER" in line: + self.in_call = False + self.call_connected = False + self.call_notification_sent = False # Reset the flag when call ends + self.status_changed.emit("Call ended") + + # 记录通话结束日志,方便调试 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Call ended, NO CARRIER detected") + + # 通话结束时取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + + # 发送通话结束信号 + self.call_ended.emit("Call ended") + + # Voice call begin - 这是通话实际建立的时间点 + elif "VOICE CALL: BEGIN" in line: + # 设置通话状态为活动 + self.in_call = True + # 设置为已接通状态 + self.call_connected = True + + # 记录日志 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话已建立 (VOICE CALL: BEGIN)") + self.status_changed.emit("Call in progress") + + # 先确保任何可能存在的PCM注册已取消 + self._unregister_pcm_audio() + + # 短暂延迟后再注册PCM音频,确保模块已稳定 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 延迟100ms后注册PCM音频") + time.sleep(0.1) # 先延迟一小段时间 + + # 开始注册PCM音频 + self._register_pcm_audio() + + # Voice call end + elif "VOICE CALL: END:" in line: + self.in_call = False + self.call_connected = False + match = re.search(r'VOICE CALL: END: (\d+)', line) + duration = "0" + if match: + duration = match.group(1) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Call ended, duration: {duration}") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Call ended, no duration info") + + # 记录详细日志,包括通话持续时间 + call_minutes = int(duration) // 60 + call_seconds = int(duration) % 60 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话结束,持续时间: {call_minutes}分{call_seconds}秒") + + # 首先取消PCM音频注册,然后才发送通话结束信号 + # 这样可以确保PCM音频在通话结束信号处理前已经被取消 + if self._ensure_pcm_audio_unregistered(): + # 在成功取消注册后发送信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频已取消注册,发送通话结束信号") + # 使用threading.Timer代替QTimer,避免线程问题 + threading.Timer(0.2, lambda: self.call_ended.emit(duration)).start() + else: + # 即使取消注册失败,也要发送通话结束信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频取消注册失败,仍发送通话结束信号") + # 使用threading.Timer代替QTimer,避免线程问题 + threading.Timer(0.2, lambda: self.call_ended.emit(duration)).start() + + # Missed call + elif "MISSED_CALL:" in line: + match = re.search(r'MISSED_CALL: ([^\r\n]+)', line) + if match: + missed_info = match.group(1) + self.status_changed.emit(f"Missed call: {missed_info}") + + # Extract phone number from missed call info + # Format is typically "HH:MMAM/PM PHONENUMBER" + parts = missed_info.strip().split() + if len(parts) >= 2: + missed_number = parts[-1] # Last part should be the phone number + # Signal call ended to stop ringtone + self.call_ended.emit("Missed") + # Also emit missed call signal with the number + self.call_number = missed_number + self.status_changed.emit(f"Missed call from {missed_number}") + + # SMS received (direct content mode) + elif line.startswith("+CMT:"): + # 检查是否为长短信 + if self._is_concatenated_sms(line): + self._handle_concatenated_sms(line) + else: + # 处理普通短信 + self._handle_regular_sms(line) + + elif self.waiting_for_sms_content: + # This is the SMS content line + self.waiting_for_sms_content = False + message = line + + # Check if the content is in UCS2 format (hex string) + if all(c in "0123456789ABCDEFabcdef" for c in line.replace(" ", "")): + try: + # 检查是否为长短信的一部分 + if self._is_part_of_concatenated_sms(line): + # 处理长短信部分 + self._process_concatenated_sms_part(self.pending_sms_sender, self.pending_sms_timestamp, line) + else: + # 普通UCS2短信,直接解码 + message = ucs2_to_text(line) + self.status_changed.emit("Decoded UCS2 message") + + # 发送完整消息 + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + message + ) + except Exception as e: + self.status_changed.emit(f"Failed to decode UCS2: {str(e)}") + # Keep original if decoding fails + message = line + + # 发送原始消息 + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + message + ) + else: + # 非UCS2编码,直接发送 + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + message + ) + + # Clear pending SMS data + self.pending_sms_sender = None + self.pending_sms_timestamp = None + + # SMS received (index mode) + elif line.startswith("+CMTI:"): + match = re.search(r'\+CMTI: "([^"]+)",(\d+)', line) + if match: + storage, index = match.group(1), match.group(2) + self.status_changed.emit(f"New SMS at index {index}") + # Fetch SMS content + self._fetch_sms(storage, index) + + # DTMF tone received + elif "+RXDTMF:" in line: + match = re.search(r'\+RXDTMF: (\d)', line) + if match: + tone = match.group(1) + self.dtmf_received.emit(tone) + + # SMS full + elif "+SMS FULL" in line: + self.status_changed.emit("SMS storage full. Please delete some messages.") + + def _is_concatenated_sms(self, header_line): + """检查是否为长短信""" + # 检查是否包含长短信特征 + # 对于UCS2编码的长短信,通常有特定的格式标识 + if ",145," in header_line and ",0,8," in header_line: + return True + return False + + def _handle_regular_sms(self, header_line): + """处理普通短信""" + # Parse SMS header + header_match = re.search(r'\+CMT: "([^"]*)",[^,]*,"([^"]*)"', header_line) + if header_match: + sender = header_match.group(1) + timestamp = header_match.group(2) + + # Check if sender is in UCS2 format (starts with 00) + if sender.startswith("00"): + try: + sender = ucs2_to_text(sender) + except: + pass # Keep original if decoding fails + + self.pending_sms_sender = sender + self.pending_sms_timestamp = timestamp + self.status_changed.emit(f"SMS received from {sender}") + else: + self.pending_sms_sender = "Unknown" + self.pending_sms_timestamp = QDateTime.currentDateTime().toString("yy/MM/dd,hh:mm:ss") + self.status_changed.emit("SMS received") + + # Next line will contain the SMS content + self.waiting_for_sms_content = True + + def _handle_concatenated_sms(self, header_line): + """处理长短信""" + try: + # 解析长短信头部 + # 格式: +CMT: "sender","","timestamp",145,36,0,8,"service_center",145,64 + parts = header_line.split(',') + if len(parts) < 10: + # 格式不符合预期,作为普通短信处理 + self._handle_regular_sms(header_line) + return + + # 提取发送者和时间戳 + sender = parts[0].replace('+CMT: ', '').strip('"') + timestamp = parts[2].strip('"') + + # 检查发送者是否为UCS2格式 + if sender.startswith("00"): + try: + sender = ucs2_to_text(sender) + except: + pass # 解码失败时保留原始格式 + + # 设置等待内容标志 + self.pending_sms_sender = sender + self.pending_sms_timestamp = timestamp + self.waiting_for_sms_content = True + + # 记录长短信信息,下一行将包含内容 + self.status_changed.emit(f"Concatenated SMS part received from {sender}") + except Exception as e: + self.status_changed.emit(f"Error parsing concatenated SMS header: {str(e)}") + # 出错时尝试作为普通短信处理 + self._handle_regular_sms(header_line) + + def _process_concatenated_sms_part(self, sender, timestamp, content): + """处理长短信的一部分""" + try: + # 移除空格 + content = content.replace(" ", "") + + # 打印原始内容用于调试 + self.status_changed.emit(f"长短信原始内容: {content[:50]}...") + + # 解码内容 + try: + decoded_content = ucs2_to_text(content) + self.status_changed.emit(f"解码后内容: {decoded_content[:50]}...") + except Exception as decode_error: + self.status_changed.emit(f"UCS2解码错误: {str(decode_error)}") + # 尝试不同的解码方法 + try: + # 尝试直接从十六进制转换为字节,然后解码 + hex_bytes = binascii.unhexlify(content) + decoded_content = hex_bytes.decode('utf-16-be', errors='replace') + self.status_changed.emit(f"替代解码方法成功: {decoded_content[:50]}...") + except Exception as alt_error: + self.status_changed.emit(f"替代解码方法错误: {str(alt_error)}") + # 如果解码失败,使用原始内容 + decoded_content = content + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + f"[解码失败] {content[:100]}..." + ) + return + + # 检查特殊格式,尝试直接提取有效负载 + if "62117ED94F6053D14E86957F6587672C" in content: + # 基于固定标记提取后面的有效内容 + # 格式可能是:标记 + 消息前缀 + 003A(冒号) + URL编码 + parts = content.split("003A", 1) # 003A是冒号的UCS2编码 + if len(parts) > 1 and parts[1]: + # 只解码URL部分 + try: + url_part = ucs2_to_text("003A" + parts[1]) # 加回冒号 + self.status_changed.emit(f"提取的URL部分: {url_part}") + + # 直接提取URL + url_match = re.search(r':(https?://[^\s]+)', url_part) + url = url_match.group(1) if url_match else url_part + + # 使用前缀 + URL格式 + prefix = "消息" # 默认前缀 + + # 使用发送者和时间戳的前10个字符作为唯一标识符 + sms_id = f"{sender}_{timestamp[:10]}" + + # 如果是新的长短信,初始化存储 + if sms_id not in self.concat_sms_parts: + self.concat_sms_parts[sms_id] = { + 'sender': sender, + 'timestamp': timestamp, + 'parts': [], + 'urls': [], + 'received_time': time.time(), + 'prefix': prefix + } + + # 存储这一部分 + if url and url not in self.concat_sms_parts[sms_id]['urls']: + self.concat_sms_parts[sms_id]['urls'].append(url) + self.concat_sms_parts[sms_id]['parts'].append(url_part) + + # 更新接收时间 + self.concat_sms_parts[sms_id]['received_time'] = time.time() + + # 记录日志 + part_num = len(self.concat_sms_parts[sms_id]['parts']) + self.status_changed.emit(f"接收到长短信的第{part_num}部分,来自{sender}") + + # 检查是否需要合并 + QTimer.singleShot(2000, lambda: self._check_and_merge_sms(sms_id)) + return + except Exception as url_error: + self.status_changed.emit(f"URL提取错误: {str(url_error)}") + + # 常规处理逻辑(如果特殊处理失败) + # 提取URL部分 + url_match = re.search(r'(https?://[^\s]+)', decoded_content) + url = url_match.group(1) if url_match else "" + + # 提取消息前缀部分(冒号前的内容) + prefix = "" + if ":" in decoded_content: + prefix = decoded_content.split(":", 1)[0].strip() + else: + prefix = "消息" # 默认前缀 + + # 使用发送者和时间戳的前10个字符作为唯一标识符 + sms_id = f"{sender}_{timestamp[:10]}" + + # 如果是新的长短信,初始化存储 + if sms_id not in self.concat_sms_parts: + self.concat_sms_parts[sms_id] = { + 'sender': sender, + 'timestamp': timestamp, + 'parts': [], + 'urls': [], + 'received_time': time.time(), + 'prefix': prefix + } + + # 存储这一部分 + if url and url not in self.concat_sms_parts[sms_id]['urls']: + self.concat_sms_parts[sms_id]['urls'].append(url) + self.concat_sms_parts[sms_id]['parts'].append(decoded_content) + + # 更新接收时间 + self.concat_sms_parts[sms_id]['received_time'] = time.time() + + # 记录日志 + part_num = len(self.concat_sms_parts[sms_id]['parts']) + self.status_changed.emit(f"接收到长短信的第{part_num}部分,来自{sender}") + + # 检查是否需要合并 + QTimer.singleShot(2000, lambda: self._check_and_merge_sms(sms_id)) + else: + # 没有URL或已经存在的URL + # 尝试直接发送完整内容 + self.status_changed.emit(f"无法提取URL或URL重复,尝试直接发送内容") + self.sms_received.emit( + sender, + timestamp, + decoded_content + ) + + except Exception as e: + self.status_changed.emit(f"长短信处理错误: {str(e)}") + # 出错时尝试作为普通短信处理 + try: + decoded = ucs2_to_text(content) if all(c in "0123456789ABCDEFabcdef" for c in content) else content + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + decoded + ) + except: + # 如果解码也失败,发送原始内容 + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + content + ) + + def _check_and_merge_sms(self, sms_id): + """检查并合并长短信""" + if sms_id not in self.concat_sms_parts: + return + + sms_info = self.concat_sms_parts[sms_id] + + # 检查是否已经过了足够的时间 + current_time = time.time() + if current_time - sms_info['received_time'] < 2: # 2秒内收到的部分 + # 还不到合并的时间,可能还有更多部分 + return + + # 合并所有部分 + merged_content = self._merge_sms_parts(sms_id) + + # 发送完整消息 + self.sms_received.emit( + sms_info['sender'], + sms_info['timestamp'], + merged_content + ) + + # 清理已处理的长短信 + del self.concat_sms_parts[sms_id] + + # 记录日志 + self.status_changed.emit(f"Concatenated SMS fully received from {sms_info['sender']}") + + def _merge_sms_parts(self, sms_id): + """合并长短信的所有部分""" + if sms_id not in self.concat_sms_parts: + return "" + + sms_info = self.concat_sms_parts[sms_id] + + # 如果只有一个部分,直接返回 + if len(sms_info['parts']) == 1: + return sms_info['parts'][0] + + # 合并所有部分 + prefix = sms_info.get('prefix', '') + urls = sms_info.get('urls', []) + + if urls: + # 只返回URL列表,每行一个 + if prefix: + merged_content = f"{prefix}:\n" + "\n".join(urls) + else: + merged_content = "\n".join(urls) + else: + # 如果没有提取到URL,直接合并所有部分 + merged_content = "\n".join(sms_info['parts']) + + return merged_content + + def _cleanup_old_sms_parts(self): + """清理超时的长短信部分""" + current_time = time.time() + sms_ids_to_remove = [] + + for sms_id, sms_info in self.concat_sms_parts.items(): + if current_time - sms_info['received_time'] > self.concat_sms_timeout: + sms_ids_to_remove.append(sms_id) + + for sms_id in sms_ids_to_remove: + del self.concat_sms_parts[sms_id] + self.status_changed.emit(f"Removed timeout concatenated SMS {sms_id}") + + def _decode_pdu_message(self, pdu_str): + """Decode PDU format message (including Chinese characters)""" + try: + # Remove spaces and convert to bytes + pdu_str = pdu_str.replace(" ", "") + + # 检查是否为长短信的一部分 + if self._is_part_of_concatenated_sms(pdu_str): + # 可能是长短信的一部分,需要特殊处理 + # 这里需要根据实际的PDU格式进行解析 + pass + + # Try to decode using our utility function + return ucs2_to_text(pdu_str) + except Exception as e: + print(f"PDU decode error: {str(e)}") + # If decoding fails, return the original string + return f"[Decode error: {pdu_str[:30]}...]" + + def _is_part_of_concatenated_sms(self, content): + """检查内容是否为长短信的一部分""" + # 移除空格 + content = content.replace(" ", "") + + # 检查是否为UCS2编码 + if not all(c in "0123456789ABCDEFabcdef" for c in content): + return False + + # 检查内容长度是否足够 + if len(content) < 10: + return False + + # 根据用户提供的示例,检查是否包含特定的模式 + # 示例中的长短信内容以"62117ED94F6053D14E86957F6587672C"开头 + if content.startswith("62117ED94F6053D14E86957F6587672C"): + return True + + return False + + def _initialize_module(self): + """Initialize the LTE module with basic settings""" + try: + # 在日志中标记初始化模块的开始 + self.log_at_command(False, "[INFO] 开始初始化LTE模块") + + # AT echo off + self.send_at_command("ATE0") + + # Set SMS text mode + self.send_at_command("AT+CMGF=1") + + # Set SMS character set to GSM + self.send_at_command('AT+CSCS="GSM"') + + # Enable new SMS notification + self.send_at_command('AT+CNMI=2,1,0,0,0') + + # Enable caller ID notification + self.send_at_command("AT+CLIP=1") + + # Set call progress monitoring URC (Unsolicited Result Code) + self.send_at_command("AT+XCALLSTAT=1") + + # Get IMEI + self.send_at_command("AT+GSN") + + # 在日志中标记初始化模块的完成 + self.log_at_command(False, "[INFO] LTE模块初始化完成") + return True + except Exception as e: + self.status_changed.emit(f"Module initialization error: {str(e)}") + # 记录初始化错误 + self.log_at_command(False, f"[ERROR] 模块初始化错误: {str(e)}") + return False + + def _get_module_info(self): + """Get module information (internal method)""" + if not self.connected: + return {} + + # 尝试从缓存中获取 + cached_value = self._get_cached_value('module_info') + if cached_value is not None: + return cached_value + + # 缓存中没有,需要重新获取 + try: + # 确保基本属性已被初始化 + if not self.manufacturer: + manufacturer_response = self.send_at_command("AT+CGMI") + if "OK" in manufacturer_response: + # 移除命令回显和OK响应,只保留实际内容 + lines = [line.strip() for line in manufacturer_response.split('\n') if line.strip()] + # 过滤掉AT命令回显和OK响应 + content_lines = [line for line in lines if line != "OK" and not line.startswith("AT+")] + self.manufacturer = '\n'.join(content_lines).strip() + else: + self.manufacturer = "Unknown" + + if not self.model: + model_response = self.send_at_command("AT+CGMM") + if "OK" in model_response: + # 移除命令回显和OK响应,只保留实际内容 + lines = [line.strip() for line in model_response.split('\n') if line.strip()] + # 过滤掉AT命令回显和OK响应 + content_lines = [line for line in lines if line != "OK" and not line.startswith("AT+")] + self.model = '\n'.join(content_lines).strip() + else: + self.model = "Unknown" + + if not self.imei: + imei_response = self.send_at_command("AT+CGSN") + if "OK" in imei_response: + # 移除命令回显和OK响应,只保留实际内容 + lines = [line.strip() for line in imei_response.split('\n') if line.strip()] + # 过滤掉AT命令回显和OK响应 + content_lines = [line for line in lines if line != "OK" and not line.startswith("AT+")] + self.imei = '\n'.join(content_lines).strip() + else: + self.imei = "Unknown" + + if not self.firmware: + firmware_response = self.send_at_command("AT+CGMR") + if "OK" in firmware_response: + # 尝试提取特定格式的固件版本 + match = re.search(r'\+CGMR: (.+)', firmware_response) + if match: + self.firmware = match.group(1) + else: + # 移除命令回显和OK响应,只保留实际内容 + lines = [line.strip() for line in firmware_response.split('\n') if line.strip()] + # 过滤掉AT命令回显和OK响应 + content_lines = [line for line in lines if line != "OK" and not line.startswith("AT+")] + self.firmware = '\n'.join(content_lines).strip() + else: + self.firmware = "Unknown" + + # 更新其他信息 + if not self.phone_number: + self._update_phone_number() + + if not self.carrier or not self.network_type: + self._update_carrier_info() + + if not self.signal_strength: + self._update_signal_strength() + + # 组装模块信息字典 + module_info = { + 'manufacturer': self.manufacturer, + 'model': self.model, + 'imei': self.imei, + 'firmware': self.firmware, + 'phone_number': self.phone_number, + 'carrier': self.carrier, + 'network_type': self.network_type, + 'signal_strength': self.signal_strength + } + + # 更新缓存 + self._update_cache('module_info', module_info) + return module_info + except Exception as e: + print(f"获取模块信息出错: {str(e)}") + # 出错时返回带有默认值的字典 + return { + 'manufacturer': 'Error', + 'model': 'Error', + 'imei': 'Error', + 'firmware': 'Error', + 'phone_number': 'Error', + 'carrier': 'Error', + 'network_type': 'Error', + 'signal_strength': 'Error' + } + + def get_module_info(self): + """Get module information""" + if not self.connected: + return {} + + return self._get_module_info() + + def get_carrier_info(self): + """Get carrier information""" + if not self.connected: + return ("未连接", "未连接") + + try: + self._update_carrier_info() + + # 确保carrier和network_type属性存在 + if not hasattr(self, 'carrier') or not self.carrier: + self.carrier = "未知" + if not hasattr(self, 'network_type') or not self.network_type: + self.network_type = "未知" + + return (self.carrier, self.network_type) + except Exception as e: + self.log_at_interaction(f"获取运营商信息失败: {str(e)}") + print(f"获取运营商信息失败: {str(e)}") + return ("获取出错", "获取出错") + + def get_phone_number(self): + """Get phone number""" + if not self.connected: + return "未连接" + + try: + self._update_phone_number() + + # 确保phone_number属性存在 + if not hasattr(self, 'phone_number') or not self.phone_number: + self.phone_number = "未知" + + return self.phone_number + except Exception as e: + self.log_at_interaction(f"获取电话号码失败: {str(e)}") + print(f"获取电话号码失败: {str(e)}") + return "获取出错" + + def _update_carrier_info(self): + """Update carrier information""" + try: + # 初始化默认值 + self.carrier = "未知" + self.network_type = "未知" + + response = self.send_at_command("AT+COPS?") + if response and "+COPS:" in response: + # Try to parse carrier information + match = re.search(r'\+COPS:\s*\d+,\d+,"([^"]+)"', response) + if match: + carrier_name = match.group(1) + # Check if it's a numeric code like "46000" + if re.match(r'^\d+$', carrier_name): + # Map common Chinese carrier codes + carriers = { + "46000": "中国移动", + "46001": "中国联通", + "46002": "中国移动", + "46003": "中国电信", + "46004": "中国移动", + "46005": "中国电信", + "46006": "中国联通", + "46007": "中国移动", + "46008": "中国电信", + "46009": "中国联通", + "46011": "中国电信", + } + self.carrier = carriers.get(carrier_name, carrier_name) + else: + self.carrier = carrier_name + + # Try to get network type + network_response = self.send_at_command("AT+CPSI?") + if network_response and "+CPSI:" in network_response: + parts = network_response.strip().split('\n')[0].replace("+CPSI: ", "").split(',') + if len(parts) >= 2: + self.network_type = f"{parts[0]}/{parts[1]}" + # Add band info if available + if len(parts) >= 7: + self.network_type += f"/{parts[6]}" + + # 更新缓存 - 既缓存单个值,也缓存组合值以便不同的获取方式 + self._update_cache('carrier', self.carrier) + return self.carrier + except Exception as e: + self.log_at_command(False, f"[ERROR] 获取运营商信息失败: {str(e)}") + print(f"获取运营商信息失败: {str(e)}") + self.carrier = "获取出错" + self.network_type = "获取出错" + self._update_cache('carrier', self.carrier) + return self.carrier + + def _update_signal_strength(self): + """Update signal strength information""" + try: + response = self.send_at_command("AT+CSQ") + if not response or "+CSQ:" not in response: + self.signal_strength = ("未知", "无响应") + return self.signal_strength + + match = re.search(r'\+CSQ:\s*(\d+),\s*(\d+)', response) + if match: + rssi = int(match.group(1)) + ber = int(match.group(2)) + + # 计算信号百分比 + if rssi == 99: + percent = 0 + desc = "无信号" + else: + # RSSI范围是0-31,对应-113到-51 dBm + percent = min(100, int((rssi / 31.0) * 100)) + + # 根据信号强度确定描述 + if rssi > 25: # > -67 dBm + desc = "极好" + elif rssi > 19: # > -79 dBm + desc = "很好" + elif rssi > 14: # > -89 dBm + desc = "好" + elif rssi > 9: # > -99 dBm + desc = "一般" + elif rssi > 4: # > -107 dBm + desc = "差" + else: + desc = "很差" + + # 将信号强度保存为元组(信号文本, 描述) + self.signal_strength = (f"{percent}%", desc) + return self.signal_strength + else: + # 如果无法解析信号强度,返回默认值 + self.signal_strength = ("未知", "格式错误") + return self.signal_strength + + except Exception as e: + self.log_at_interaction(f"解析信号强度失败: {str(e)}") + print(f"解析信号强度失败: {str(e)}") + self.signal_strength = ("错误", str(e)[:20]) + return self.signal_strength + + def _fetch_sms(self, storage, index): + """Fetch SMS content by index""" + response = self.send_at_command(f'AT+CMGR={index}') + if response and "+CMGR:" in response: + # Parse SMS header + header_match = re.search(r'\+CMGR: "[^"]*","([^"]*)",[^,]*,"([^"]*)"', response) + if header_match: + sender = header_match.group(1) + timestamp = header_match.group(2) + + # Check if sender is in UCS2 format (starts with 00) + if sender.startswith("00"): + try: + sender = ucs2_to_text(sender) + except: + pass # Keep original if decoding fails + + # Extract message content + lines = response.split('\n') + message = "" + + if len(lines) > 1: + content_line = lines[1].strip() + message = content_line + + # Check if the content is in UCS2 format (hex string) + if all(c in "0123456789ABCDEFabcdef" for c in content_line.replace(" ", "")): + try: + # Try to decode as UCS2 + message = ucs2_to_text(content_line) + self.status_changed.emit("Decoded UCS2 message from storage") + except Exception as e: + self.status_changed.emit(f"Failed to decode UCS2 from storage: {str(e)}") + # Keep original if decoding fails + + self.sms_received.emit(sender, timestamp, message) + + def _register_pcm_audio(self): + """注册PCM音频(用于通话开始时) + 按照文档要求,在VOICE CALL: BEGIN后执行AT+CPCMREG=1 + """ + if not self.connected or not self.at_serial: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册失败:未连接") + return False + + # 如果已经不在通话中了,跳过注册 + if not self.in_call: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 不在通话中,跳过PCM音频注册") + self.status_changed.emit("Not in call, PCM audio registration skipped") + return False + + try: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 开始PCM音频注册过程") + + # 清除可能的额外数据 + if self.at_serial.in_waiting > 0: + self.at_serial.read(self.at_serial.in_waiting) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 清除了缓冲区数据") + + # 设置PCM格式为8K采样率(如需要16K,可更改为AT+CPCMFRM=1) + try: + with self.command_lock: + self.at_serial.write(b'AT+CPCMFRM=0\r') + time.sleep(0.1) + if self.at_serial.in_waiting > 0: + resp = self.at_serial.read(self.at_serial.in_waiting).decode('utf-8', errors='ignore') + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM格式设置响应: {resp}") + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 设置PCM格式出错: {str(e)}") + + # 直接发送PCM音频注册命令,使用更短的超时 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频注册命令") + + # 确保没有另一个命令在发送 + with self.command_lock: + self.at_serial.write(b'AT+CPCMREG=1\r') + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册命令已发送") + + # 等待较短的时间以获取响应 + time.sleep(0.1) + + # 试图读取响应 + response = "" + start_time = time.time() + while time.time() - start_time < 0.5: # 最多等待0.5秒 + if self.at_serial.in_waiting > 0: + response += self.at_serial.read(self.at_serial.in_waiting).decode('utf-8', errors='ignore') + if "OK" in response or "ERROR" in response: + break + time.sleep(0.05) + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册响应: {response}") + + # 记录是否成功 + success = "OK" in response + + # 根据响应结果发送状态更新 + if success: + self.status_changed.emit("PCM audio registered successfully") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册成功") + else: + self.status_changed.emit("PCM audio registration sent") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册状态未知") + + # 无论响应如何,发送激活信号,系统将尝试处理音频 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频激活信号") + self.pcm_audio_status.emit(True) + + # 添加调试记录 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册流程完成") + return True + + except Exception as e: + self.status_changed.emit(f"PCM audio registration error: {str(e)}") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册出错: {str(e)}") + + # 错误发生时,仍然尝试激活音频,保持一致行为 + self.pcm_audio_status.emit(True) + return False + + def _unregister_pcm_audio(self): + """取消注册PCM音频(用于通话结束时) + 按照文档要求,在VOICE CALL: END后执行AT+CPCMREG=0 + """ + if not self.connected or not self.at_serial: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 取消PCM音频注册失败:未连接") + # 即使未连接,也发送停止信号 + self.pcm_audio_status.emit(False) + return False + + try: + # 确保没有另一个命令在发送 + with self.command_lock: + # 清除任何待处理的数据 + if self.at_serial.in_waiting > 0: + self.at_serial.read(self.at_serial.in_waiting) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 清除了PCM音频注销前的缓冲区数据") + + # 使用直接写入代替send_at_command,避免阻塞 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频注销命令") + self.at_serial.write(b'AT+CPCMREG=0\r') + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销命令已发送") + + # 给一点时间让模块响应 + time.sleep(0.1) + + # 尝试读取响应,但不等待过长时间 + response = "" + start_time = time.time() + while time.time() - start_time < 0.3: # 等待最多0.3秒 + if self.at_serial.in_waiting > 0: + response += self.at_serial.read(self.at_serial.in_waiting).decode('utf-8', errors='ignore') + if "OK" in response: + break + time.sleep(0.05) + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销响应: {response}") + success = "OK" in response + + # 根据响应结果更新状态 + if success: + self.status_changed.emit("PCM audio unregistered successfully") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销成功") + else: + self.status_changed.emit("PCM audio unregistration sent") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销状态未知") + + # 无论命令是否成功,都发送停止信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频停止信号") + self.pcm_audio_status.emit(False) + + return True + + except Exception as e: + self.status_changed.emit(f"PCM audio unregistration error: {str(e)}") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销错误: {str(e)}") + + # 出错时也发送停止信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 注销出错,但仍发送停止信号") + self.pcm_audio_status.emit(False) + return False + + def _ensure_pcm_audio_unregistered(self): + """确保PCM音频被取消注册""" + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 确保PCM音频已注销") + + # 首先确保通话状态正确 + self.in_call = False # 强制设置为非通话状态,确保在所有情况下状态一致 + + # 直接取消注册PCM音频 + result = self._unregister_pcm_audio() + if result: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销成功完成") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销可能未完成,但已发送停止信号") + + # 返回实际的操作结果,以便调用者可以适当处理 + return result + + def make_call(self, number): + """Make a phone call (MO - Mobile Originated call)""" + if not self.connected: + return False + + # 如果已经在通话中,先结束当前通话 + if self.in_call: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 已在通话中,先结束当前通话") + self.end_call() + + # 使用循环检查通话状态,而不是固定等待时间 + wait_start = time.time() + while self.in_call and time.time() - wait_start < 3.0: # 最多等待3秒 + time.sleep(0.1) + + if self.in_call: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 无法结束先前通话,放弃拨号") + self.status_changed.emit("Failed to end previous call") + return False + + # 确保PCM音频已经关闭 + self._ensure_pcm_audio_unregistered() + + # 设置模块为语音模式 + try: + self.send_at_command("AT+FCLASS=8") # 设置为语音模式,确保正确处理语音呼叫 + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 设置语音模式出错: {str(e)}") + + # 发起拨号命令 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发起拨打电话到 {number}") + response = self.send_at_command(f"ATD{number};") + + if "OK" in response: + self.call_number = number + self.status_changed.emit(f"Calling {number}") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 正在拨打 {number}") + + # 注意:设置in_call=True应该在收到VOICE CALL: BEGIN之后 + # 这里只记录目标号码,不立即设置呼叫状态 + # 在收到VOICE CALL: BEGIN事件后,会自动设置in_call=True并注册PCM音频 + + return True + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 拨打电话失败: {response}") + self.status_changed.emit(f"Failed to call {number}") + return False + + def answer_call(self): + """Answer an incoming call.""" + # 检查是否有来电 + self.log_at_interaction("尝试接听电话...") + call_status = self.get_call_status() + has_incoming = False + + for call in call_status: + if call.get('state') == 'incoming': + has_incoming = True + break + + if not has_incoming: + self.log_at_interaction("没有检测到来电,无法接听") + return False + + # 确保在接听前停止铃声 + try: + from sound_utils import stop_all_ringtones + stop_all_ringtones() + except Exception as e: + self.log_at_interaction(f"停止铃声时出错: {str(e)}") + + # 先确保PCM音频被注销,避免冲突 + self._ensure_pcm_audio_unregistered() + + # 发送接听命令 + self.log_at_interaction("发送ATA命令接听电话") + response = self.send_at_command("ATA", timeout=10) + + if "OK" in response or "CONNECT" in response: + self.log_at_interaction("电话已接通,准备注册PCM音频") + + # 创建一个延时,给电话接通一点时间后再注册PCM音频 + def register_pcm_with_delay(): + time.sleep(1) # 等待1秒确保电话真正接通 + self._ensure_pcm_audio_registered() + self.log_at_interaction("PCM音频已注册,通话已建立") + + # 使用线程实现延时注册,避免主线程阻塞 + threading.Thread(target=register_pcm_with_delay, daemon=True).start() + + # 再次检查呼叫状态,确认是否真正接通 + time.sleep(2) # 等待足够的时间让状态更新 + new_call_status = self.get_call_status() + for call in new_call_status: + if call.get('state') == 'active': + self.log_at_interaction("确认电话已接通") + return True + + self.log_at_interaction("电话可能未成功接通,请再次检查状态") + return True # 即使状态检查不是active,仍然返回True因为命令执行成功 + else: + self.log_at_interaction(f"接听电话失败: {response}") + return False + + def end_call(self): + """结束当前通话,根据通话状态使用不同的挂断命令""" + if not self.connected: + return False + + # 获取当前通话状态 + calls = self.get_call_status() + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 当前通话状态: {calls}") + + # 根据通话状态选择合适的挂断命令 + if not calls: + # 没有活动通话,但为安全起见仍发送挂断命令 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 无活动通话,但仍发送挂断命令") + response = self.send_at_command("ATH") + else: + # 检查第一个通话的状态 + call = calls[0] + stat = call.get('stat', -1) + + if stat == 4: # 来电中(MT) + # 来电振铃状态,使用 AT+CHUP 命令挂断 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 使用AT+CHUP挂断未接通的来电") + response = self.send_at_command("AT+CHUP") + else: + # 其他状态使用 ATH 命令挂断 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 使用ATH挂断通话,状态: {self.call_states.get(stat, '未知')}") + response = self.send_at_command("ATH") + + if "OK" in response: + self.in_call = False + self.call_connected = False + self.status_changed.emit("通话结束") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话已结束") + + # 通话结束后,立即取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + + return True + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 挂断通话失败,响应: {response}") + return False + + def send_sms(self, number, message): + """Send SMS message""" + if not self.connected: + return False + + # Format the phone number + formatted_number = format_phone_number(number) + + # 清空响应缓冲区 + with self.command_lock: + self.response_buffer.clear() + self.last_response_time = time.time() + + # Set text mode and wait for OK response + response = self.send_at_command("AT+CMGF=1") + if "OK" not in response: + self.status_changed.emit("Failed to set SMS text mode") + return False + + # Add debug message + self.status_changed.emit(f"Sending SMS to {formatted_number}") + + try: + # Check if message contains Chinese characters + if is_chinese_text(message): + # Set character set to UCS2 for Unicode support + response = self.send_at_command('AT+CSCS="UCS2"') + if "OK" not in response: + self.status_changed.emit("Failed to set UCS2 character set") + return False + + # Convert message to UCS2 hex string + hex_message = text_to_ucs2(message) + if not hex_message: + self.status_changed.emit("Failed to encode message") + return False + + # Convert phone number to UCS2 format + hex_number = text_to_ucs2(formatted_number) + if not hex_number: + self.status_changed.emit("Failed to encode phone number") + return False + + # 清空响应缓冲区 + with self.command_lock: + self.response_buffer.clear() + + # Send message command with UCS2 encoded phone number + cmd = f'AT+CMGS="{hex_number}"' + self.at_serial.write((cmd + '\r').encode()) + time.sleep(0.5) # Wait for > prompt + + # Send message content and Ctrl+Z to end + self.at_serial.write(hex_message.encode() + b'\x1A') + self.status_changed.emit("Sending UCS2 encoded message...") + else: + # Set character set to GSM for ASCII support + response = self.send_at_command('AT+CSCS="GSM"') + if "OK" not in response: + self.status_changed.emit("Failed to set GSM character set") + return False + + # 清空响应缓冲区 + with self.command_lock: + self.response_buffer.clear() + + # Send message command + cmd = f'AT+CMGS="{formatted_number}"' + self.at_serial.write((cmd + '\r').encode()) + time.sleep(0.5) # Wait for > prompt + + # Send message content and Ctrl+Z to end + self.at_serial.write(message.encode() + b'\x1A') + self.status_changed.emit("Sending ASCII message...") + + # Wait for response with longer timeout + start_time = time.time() + response_lines = [] + + while time.time() - start_time < 15.0: # Increased timeout to 15 seconds + # 检查是否有新响应 + if self.response_buffer: + with self.command_lock: + # 获取并移除第一个响应 + line = self.response_buffer.pop(0) + + response_lines.append(line) + self.status_changed.emit(f"SMS response: {line}") + + if "+CMGS:" in line: + self.status_changed.emit(f"SMS sent to {formatted_number}") + return True + elif "ERROR" in line or "+CMS ERROR:" in line: + self.status_changed.emit(f"SMS error: {line}") + return False + + # 短暂等待新响应 + time.sleep(0.1) + + # If we get here, we timed out waiting for a response + last_response = response_lines[-1] if response_lines else 'None' + self.status_changed.emit(f"SMS send timeout. Last response: {last_response}") + return False + + except Exception as e: + self.status_changed.emit(f"SMS send exception: {str(e)}") + return False + + def delete_sms(self, index=None, delete_type=None): + """Delete SMS messages + + delete_type: + 0 - Delete message at index + 1 - Delete all read messages + 2 - Delete all read and sent messages + 3 - Delete all read, sent and unsent messages + 4 - Delete all messages + """ + if not self.connected: + return False + + if index is not None and delete_type is not None: + command = f"AT+CMGD={index},{delete_type}" + elif index is not None: + command = f"AT+CMGD={index}" + elif delete_type is not None: + command = f"AT+CMGD=1,{delete_type}" + else: + return False + + response = self.send_at_command(command) + if "OK" in response: + self.status_changed.emit("SMS deleted") + return True + return False + + def get_sms_list(self, status="ALL"): + """Get list of SMS messages + + status: + "REC UNREAD" - Unread messages + "REC READ" - Read messages + "STO UNSENT" - Stored unsent messages + "STO SENT" - Stored sent messages + "ALL" - All messages + """ + if not self.connected: + return [] + + # Set text mode + self.send_at_command("AT+CMGF=1") + + # Get messages + if status == "ALL": + response = self.send_at_command("AT+CMGL") + else: + response = self.send_at_command(f'AT+CMGL="{status}"') + + if not response or "OK" not in response: + return [] + + messages = [] + lines = response.split('\n') + i = 0 + + while i < len(lines): + line = lines[i] + if line.startswith("+CMGL:"): + # Parse header + header_match = re.search(r'\+CMGL: (\d+),"([^"]*)",' + r'"([^"]*)",[^,]*,"([^"]*)"', line) + if header_match: + index = header_match.group(1) + msg_status = header_match.group(2) + sender = header_match.group(3) + timestamp = header_match.group(4) + + # Get message content + if i + 1 < len(lines): + content = lines[i + 1] + + # Check if PDU or text mode + if any(c for c in content if not (c.isalnum() or c.isspace() or c in '+-,.;:!?')): + # Likely PDU data, decode it + content = self._decode_pdu_message(content) + + messages.append({ + 'index': index, + 'status': msg_status, + 'sender': sender, + 'timestamp': timestamp, + 'content': content + }) + + i += 2 # Skip content line + else: + i += 1 + else: + i += 1 + else: + i += 1 + + return messages + + def get_network_info(self): + """获取网络信息(优先使用缓存)""" + # 检查缓存 + cached_value = self._get_cached_value('network') + if cached_value is not None: + return cached_value + + if not self.connected: + return None + + # 缓存无效,重新获取网络信息 + try: + self._update_carrier_info() # 网络信息在_update_carrier_info中更新 + # 更新缓存 + self._update_cache('network', self.network_type) + return self.network_type + except Exception as e: + print(f"获取网络信息出错: {str(e)}") + return None + + def get_signal_strength(self): + """Get signal strength""" + if not self.connected: + return "未连接" + + try: + self._update_signal_strength() + # 确保返回正确格式的信号强度信息 + if hasattr(self, 'signal_strength') and self.signal_strength: + if isinstance(self.signal_strength, tuple) and len(self.signal_strength) == 2: + # 已经是(signal_text, signal_desc)格式 + return self.signal_strength + elif isinstance(self.signal_strength, str): + # 如果是字符串,将其作为信号描述返回 + return (self.signal_strength, "") + else: + # 不支持的格式,转换为字符串 + return (str(self.signal_strength), "") + else: + # 没有信号强度信息 + return ("未知", "信号数据不可用") + except Exception as e: + self.log_at_interaction(f"获取信号强度失败: {str(e)}") + print(f"获取信号强度失败: {str(e)}") + return ("错误", str(e)[:20]) + + def get_call_status(self): + """Get current call status""" + if not self.connected: + return [] + + # 记录CLCC命令调用 + static_var_name = "_clcc_call_count" + if not hasattr(self, static_var_name): + setattr(self, static_var_name, 0) + + # 增加计数器 + current_count = getattr(self, static_var_name) + 1 + setattr(self, static_var_name, current_count) + + # 记录到日志 + self.log_at_command(True, f"AT+CLCC (第{current_count}次调用)") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 调用AT+CLCC检查通话状态 (第{current_count}次)") + + try: + # Send AT+CLCC command to get call status + response = self.send_at_command("AT+CLCC") + + if not response or "OK" not in response: + return [] + + calls = [] + lines = response.split('\n') + + for line in lines: + line = line.strip() + if not line or line == "OK" or not line.startswith("+CLCC:"): + continue + + # Parse +CLCC response + # Format: +CLCC: ,,,,[,,[,]] + parts = line.replace("+CLCC:", "").strip().split(",") + + if len(parts) < 5: + continue + + call = { + 'id': int(parts[0]), + 'dir': int(parts[1]), # 0=MO, 1=MT + 'stat': int(parts[2]), # 0=active, 1=held, 2=dialing, 3=alerting, 4=incoming, 5=waiting + 'mode': int(parts[3]), # 0=voice, 1=data, 2=fax + 'mpty': int(parts[4]) # 0=not multiparty, 1=multiparty + } + + # Check if number is included + if len(parts) >= 7: + # Remove quotes from number + number = parts[5].strip('"') + call['number'] = number + + # Check if alpha is included + if len(parts) >= 8: + alpha = parts[7].strip('"') + call['alpha'] = alpha + + calls.append(call) + + # 如果通话状态发生变化,记录日志 + if not hasattr(self, '_last_call_status') or self._last_call_status != calls: + self._last_call_status = calls + call_desc = self._format_call_status_for_log(calls) + self.log_at_command(False, f"CLCC响应解析: {call_desc}") + + return calls + except Exception as e: + print(f"Error getting call status: {str(e)}") + return [] + + def _format_call_status_for_log(self, calls): + """格式化通话状态用于日志记录""" + if not calls: + return "无通话" + + status_texts = [] + for call in calls: + direction = "呼出" if call.get('dir') == 0 else "呼入" + + status_code = call.get('stat', -1) + if status_code == 0: + status = "活动" + elif status_code == 1: + status = "保持" + elif status_code == 2: + status = "拨号中" + elif status_code == 3: + status = "振铃中" + elif status_code == 4: + status = "来电中" + elif status_code == 5: + status = "等待中" + else: + status = f"未知({status_code})" + + number = call.get('number', '未知号码') + status_texts.append(f"{direction}/{status}/{number}") + + return ", ".join(status_texts) + + def get_call_state_text(self): + """ + 获取当前通话状态的文本描述 + """ + calls = self.get_call_status() + if not calls: + return "无通话" + + # 获取第一个通话的状态描述 + call = calls[0] + stat = call.get('stat', -1) + state_text = self.call_states.get(stat, "未知状态") + + # 添加方向信息 + direction = "呼出" if call.get('dir', 0) == 0 else "呼入" + + # 添加号码信息 + number = call.get('number', '') + number_text = f", 号码: {number}" if number else "" + + return f"{direction}通话, {state_text}{number_text}" + + def is_call_connected(self): + """检查通话是否已接通(不仅仅是振铃状态)""" + # 获取最新通话状态 + calls = self.get_call_status() + + # 如果没有通话,则未接通 + if not calls: + return False + + # 检查第一个通话是否处于活动状态(stat=0) + call = calls[0] + return call.get('stat', -1) == 0 + + def _get_cached_value(self, cache_key): + """获取缓存值,如果有效则返回缓存,否则返回None""" + cache_item = self.info_cache.get(cache_key) + if not cache_item: + return None + + current_time = time.time() + if cache_item['value'] is not None and (current_time - cache_item['timestamp']) < cache_item['valid_time']: + return cache_item['value'] + + return None + + def _update_cache(self, cache_key, value): + """更新缓存值""" + if cache_key in self.info_cache: + self.info_cache[cache_key]['value'] = value + self.info_cache[cache_key]['timestamp'] = time.time() + + def _update_phone_number(self): + """Update phone number information""" + try: + response = self.send_at_command("AT+CNUM") + if not response: + self.phone_number = "无法获取号码" + self._update_cache('phone_number', self.phone_number) + return self.phone_number + + if "+CNUM:" in response: + match = re.search(r'\+CNUM: "[^"]*","([^"]+)"', response) + if match: + self.phone_number = match.group(1) + # 保存到缓存 + self._update_cache('phone_number', self.phone_number) + return self.phone_number + else: + # 如果匹配失败,尝试其他格式 + match = re.search(r'\+CNUM:.*?"([^"]*)"', response) + if match: + self.phone_number = match.group(1) + # 保存到缓存 + self._update_cache('phone_number', self.phone_number) + return self.phone_number + + # 如果无法获取电话号码,尝试查询SIM卡状态 + sim_response = self.send_at_command("AT+CPIN?") + if sim_response and "READY" in sim_response: + # 尝试获取SIM卡IMSI + sim_info = self.send_at_command("AT+CIMI") + if sim_info and "OK" in sim_info: + self.phone_number = "SIM卡已就绪" + # 保存到缓存 + self._update_cache('phone_number', self.phone_number) + return self.phone_number + + # 如果所有尝试都失败 + self.phone_number = "无法获取号码" + # 保存到缓存 + self._update_cache('phone_number', self.phone_number) + return self.phone_number + except Exception as e: + self.log_at_interaction(f"获取电话号码失败: {str(e)}") + print(f"获取电话号码失败: {str(e)}") + self.phone_number = "获取号码出错" + # 保存到缓存 + self._update_cache('phone_number', self.phone_number) + return self.phone_number + + def _ensure_pcm_audio_registered(self): + """确保PCM音频被注册(如果尚未注册)""" + if not self.pcm_registered: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 注册PCM音频") + result = self._register_pcm_audio() + self.pcm_registered = True + return result + return True + + def log_at_interaction(self, message): + """记录AT交互日志(简化的接口,内部使用log_at_command)""" + self.log_at_command(False, f"[INFO] {message}") \ No newline at end of file diff --git a/backup/ltetool.png b/backup/ltetool.png new file mode 100644 index 0000000..319c622 Binary files /dev/null and b/backup/ltetool.png differ diff --git a/backup/main.py b/backup/main.py new file mode 100644 index 0000000..fed1b62 --- /dev/null +++ b/backup/main.py @@ -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('https://github.com/R0nY3n/LTE_manager') + 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_()) \ No newline at end of file diff --git a/backup/phone_done.png b/backup/phone_done.png new file mode 100644 index 0000000..cca5972 Binary files /dev/null and b/backup/phone_done.png differ diff --git a/backup/phone_sms_tab.py b/backup/phone_sms_tab.py new file mode 100644 index 0000000..bd5534b --- /dev/null +++ b/backup/phone_sms_tab.py @@ -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() \ No newline at end of file diff --git a/backup/running.png b/backup/running.png new file mode 100644 index 0000000..b9f51f4 Binary files /dev/null and b/backup/running.png differ diff --git a/backup/settings_tab.py b/backup/settings_tab.py new file mode 100644 index 0000000..66d336c --- /dev/null +++ b/backup/settings_tab.py @@ -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("模块信息:") + for key, value in module_info.items(): + if value: # 只显示有值的项目 + self.info_text.append(f"{key}: {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"运营商: {carrier}") + self.info_text.append(f"网络类型: {network}") + else: + self.info_text.append(f"运营商: {carrier_info}") + + # 获取电话号码 + phone_number = self.lte_manager.get_phone_number() + if phone_number: + self.info_text.append(f"电话号码: {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"信号强度: {signal_text} ({signal_desc})") + else: + self.info_text.append(f"信号强度: {signal_info}") + + # 获取网络信息 + network_info = self.lte_manager.get_network_info() + if network_info: + self.info_text.append("网络信息:") + for key, value in network_info.items(): + if value: # 只显示有值的项目 + self.info_text.append(f"{key}: {value}") + + # 添加时间戳 + self.info_text.append(f"更新时间: {time.strftime('%Y-%m-%d %H:%M:%S')}") + 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)}") \ No newline at end of file diff --git a/backup/sms_utils.py b/backup/sms_utils.py new file mode 100644 index 0000000..f5dfd0c --- /dev/null +++ b/backup/sms_utils.py @@ -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 \ No newline at end of file diff --git a/backup/sound_utils.py b/backup/sound_utils.py new file mode 100644 index 0000000..83707c9 --- /dev/null +++ b/backup/sound_utils.py @@ -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)}") \ No newline at end of file diff --git a/backup/test_call.py b/backup/test_call.py new file mode 100644 index 0000000..08772b4 --- /dev/null +++ b/backup/test_call.py @@ -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("无效选择,测试结束") \ No newline at end of file diff --git a/backup/test_tray_icon.py b/backup/test_tray_icon.py new file mode 100644 index 0000000..49374fa --- /dev/null +++ b/backup/test_tray_icon.py @@ -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_()) \ No newline at end of file diff --git a/build_exe.py b/build_exe.py new file mode 100644 index 0000000..8d3dd9e --- /dev/null +++ b/build_exe.py @@ -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() \ No newline at end of file diff --git a/create_icons.py b/create_icons.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/create_icons.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..4a3e6b5 --- /dev/null +++ b/database.py @@ -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 \ No newline at end of file diff --git a/default.png b/default.png new file mode 100644 index 0000000..f8bca78 Binary files /dev/null and b/default.png differ diff --git a/error.png b/error.png new file mode 100644 index 0000000..1f76849 Binary files /dev/null and b/error.png differ diff --git a/ffmpeg_audio.py b/ffmpeg_audio.py new file mode 100644 index 0000000..721c5ad --- /dev/null +++ b/ffmpeg_audio.py @@ -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) \ No newline at end of file diff --git a/incoming_call.py b/incoming_call.py new file mode 100644 index 0000000..dfdc263 --- /dev/null +++ b/incoming_call.py @@ -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 '拒绝'}来电") \ No newline at end of file diff --git a/lte_manager.py b/lte_manager.py new file mode 100644 index 0000000..fffcee1 --- /dev/null +++ b/lte_manager.py @@ -0,0 +1,2756 @@ +import serial +import threading +import time +import re +import binascii +import queue +import os +from PyQt5.QtCore import QObject, pyqtSignal, QDateTime, QTimer +from sms_utils import text_to_ucs2, ucs2_to_text, is_chinese_text, format_phone_number + +# Import serial.tools.list_ports for port detection +import serial.tools.list_ports + +class LTEManager(QObject): + # Signals + sms_received = pyqtSignal(str, str, str) # sender, timestamp, message + call_received = pyqtSignal(str) # caller number + call_ended = pyqtSignal(str) # duration + status_changed = pyqtSignal(str) # status message + dtmf_received = pyqtSignal(str) # DTMF tone + pcm_audio_status = pyqtSignal(bool) # PCM audio registration status (True=registered, False=unregistered) + + def __init__(self): + super().__init__() + self.at_serial = None + self.nmea_serial = None + self.at_port = "" + self.nmea_port = "" + self.at_baudrate = 115200 + self.nmea_baudrate = 9600 + self.connected = False + self.running = False # Flag to control the read thread + self.read_thread = None + self.response_queue = queue.Queue() + self.lock = threading.Lock() + + # Command cache + self.command_cache = {} + + # Module information + self.imei = "" + self.imsi = "" + self.model = "" + self.manufacturer = "" + self.firmware = "" + self.phone_number = "" + self.carrier = "" + self.network_type = "" + self.signal_strength = "" + + # Call status + self.in_call = False + self.call_connected = False # 标记通话是否已经接通(区分来电振铃和通话接通) + self.call_number = "" + self.call_notification_sent = False # Flag to track if we've already notified about this call + self.call_states = { + 0: "正在进行", # active + 1: "保持", # hold + 2: "拨号中", # dialing (MO) + 3: "振铃中", # alerting (MO) + 4: "来电中", # incoming (MT) + 5: "等待中" # waiting (MT) + } + + # SMS handling + self.waiting_for_sms_content = False + self.pending_sms_sender = None + self.pending_sms_timestamp = None + + # 长短信处理 + self.concat_sms_parts = {} # 用于存储长短信的各个部分 + self.concat_sms_timeout = 30 # 长短信合并超时时间(秒) + + # 启动定期清理超时长短信的定时器 + self.cleanup_timer = QTimer() + self.cleanup_timer.timeout.connect(self._cleanup_old_sms_parts) + self.cleanup_timer.start(10000) # 每10秒清理一次 + + # 添加AT命令日志文件路径 + self.at_log_file = None + self._setup_at_log_file() + + def _setup_at_log_file(self): + """设置AT命令日志文件""" + try: + # 确保.LTE目录存在 + home_dir = os.path.expanduser("~") + lte_dir = os.path.join(home_dir, ".LTE") + if not os.path.exists(lte_dir): + os.makedirs(lte_dir) + + # 清理旧日志文件(保留最近7天的日志) + self._cleanup_old_log_files(lte_dir) + + # 创建基于日期的日志文件 - 使用time模块获取当前日期 + today = time.strftime("%Y-%m-%d") + log_file_path = os.path.join(lte_dir, f"at_commands_{today}.log") + + # 以追加模式打开日志文件 + self.at_log_file = open(log_file_path, "a", encoding="utf-8") + print(f"AT命令日志文件已创建: {log_file_path}") + + # 记录会话开始 - 使用time模块获取当前时间 + timestamp = time.strftime("%Y-%m-%d %H:%M:%S") + self.at_log_file.write(f"\n===== LTE管理器会话开始 {timestamp} =====\n") + self.at_log_file.flush() + + return True + except Exception as e: + print(f"创建AT命令日志文件失败: {str(e)}") + self.at_log_file = None + return False + + def _cleanup_old_log_files(self, log_dir, max_days=7): + """清理旧的日志文件,只保留最近max_days天的日志""" + try: + # 获取当前时间戳 + current_time = time.time() + # 计算max_days天前的时间戳 + max_age = current_time - (max_days * 24 * 60 * 60) + + # 遍历日志目录 + for file in os.listdir(log_dir): + # 只处理AT命令日志文件 + if file.startswith('at_commands_') and file.endswith('.log'): + file_path = os.path.join(log_dir, file) + # 获取文件修改时间 + file_time = os.path.getmtime(file_path) + # 如果文件修改时间早于max_days天前,则删除 + if file_time < max_age: + os.remove(file_path) + print(f"已删除旧日志文件: {file}") + except Exception as e: + print(f"清理旧日志文件时出错: {str(e)}") + + def _log_at_interaction(self, command, response=None): + """记录AT命令交互""" + try: + if self.at_log_file: + # 使用time模块获取时间戳 + timestamp = time.strftime("%Y-%m-%d %H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}" + if command is not None: + # 只记录发送的命令 + self.at_log_file.write(f"{timestamp} >>> {command}\n") + self.at_log_file.flush() + except Exception as e: + print(f"记录AT命令时出错: {str(e)}") + + def _log_response(self, command, response): + """单独记录AT命令的响应,避免重复记录命令""" + try: + if self.at_log_file: + # 使用time模块获取时间戳 + timestamp = time.strftime("%Y-%m-%d %H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}" + if response: + self.at_log_file.write(f"{timestamp} <<< {response}\n") + self.at_log_file.flush() + except Exception as e: + print(f"记录AT命令响应时出错: {str(e)}") + + def _log_unsolicited(self, response): + """记录非请求的响应,使用独立的格式""" + try: + if self.at_log_file: + # 使用time模块获取时间戳 + timestamp = time.strftime("%Y-%m-%d %H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}" + self.at_log_file.write(f"{timestamp} {response}\n") + self.at_log_file.flush() + except Exception as e: + print(f"记录非请求响应时出错: {str(e)}") + + def connect(self, port=None, baudrate=115200): + """Connect to the LTE module""" + try: + # Check if already connected + if self.is_connected(): + print("Already connected") + return True + + # Initialize command cache + self.command_cache = {} + + # 重置连接状态 + self.connected = False + + # Validate port + if not port: + # Try to auto-detect port + port = self._auto_detect_port() + if not port: + self.status_changed.emit("Error: No serial port detected") + return False + + self.status_changed.emit(f"Connecting to {port}...") + print(f"尝试连接到端口: {port}, 波特率: {baudrate}") + + # Make sure any previous connection is properly closed + try: + if hasattr(self, 'at_serial') and self.at_serial: + self.at_serial.close() + time.sleep(0.5) # Increased delay to give OS more time to release the port + print(f"已关闭之前的串口连接,等待500ms") + except Exception as e: + print(f"Warning: Error closing previous serial connection: {str(e)}") + + # Connect to the serial port + try: + print(f"打开串口: {port}") + self.at_serial = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=2.0, # Increased timeout + write_timeout=1.0, + xonxoff=False, + rtscts=False, + dsrdtr=False + ) + print(f"串口已打开,初始化响应队列") + + # Check if the port is open + if not self.at_serial.is_open: + print(f"错误: 串口未能打开") + return False + + # Initialize the response queue + self.response_queue = queue.Queue() + + # Clear any pending data + self.at_serial.reset_input_buffer() + self.at_serial.reset_output_buffer() + print(f"串口缓冲区已重置") + + # Set the running flag before starting the thread + self.running = True + + # Start the read thread + self.read_thread = threading.Thread(target=self._read_thread, daemon=True) + self.read_thread.start() + print(f"读取线程已启动") + + # 确保日志文件已创建 (但不重复创建) + if not self.at_log_file: + print("创建AT命令日志文件...") + self._setup_at_log_file() + + # Wait for the thread to start reading + time.sleep(0.2) + + # 直接尝试写入一个空行到串口,测试是否可用 + try: + self.at_serial.write(b'\r\n') + time.sleep(0.1) + print("发送空行成功") + except Exception as e: + print(f"发送空行失败: {str(e)}") + + # Send AT command multiple times to ensure connection + print(f"发送AT测试命令...") + response = "" + for attempt in range(3): + try: + # 直接使用串口写入,而不是send_at_command + if attempt == 0: + self.at_serial.write(b'AT\r\n') + print("直接写入AT命令") + time.sleep(0.2) + # 读取响应 + data = self.at_serial.read(self.at_serial.in_waiting or 100) + if data: + response = data.decode('utf-8', errors='replace') + print(f"直接读取响应: {response}") + if "OK" in response: + print("直接通信成功!") + self.connected = True + break + else: + # 使用正常方法发送命令 + response = self.send_at_command("AT", timeout=2.0, retries=1) + print(f"AT命令尝试 {attempt+1}/3 响应: {response}") + if "OK" in response or self.connected: + break + except Exception as e: + print(f"AT命令尝试 {attempt+1} 失败: {str(e)}") + time.sleep(0.5) + + if self.connected: # 使用self.connected标志,该标志在send_at_command中设置 + self.status_changed.emit(f"Connected to {port}") + print(f"成功连接到 {port}") + self.port = port + self.baudrate = baudrate + + # Configure the module + print(f"开始配置模块...") + self._configure_module() + + return True + else: + self.running = False # Stop the read thread + self.status_changed.emit("Error: Module not responding") + print(f"错误: 模块未响应, 响应内容: {response}") + if hasattr(self, 'at_serial') and self.at_serial and self.at_serial.is_open: + self.at_serial.close() + return False + + except Exception as e: + self.running = False # Make sure thread stops if an error occurs + self.status_changed.emit(f"Error connecting: {str(e)}") + print(f"连接错误: {str(e)}") + return False + + except Exception as e: + self.status_changed.emit(f"Error in connect: {str(e)}") + print(f"连接过程中发生错误: {str(e)}") + return False + + def disconnect(self): + """Disconnect from the LTE module""" + try: + if self.is_connected(): + # Stop the read thread + self.running = False + if self.read_thread and self.read_thread.is_alive(): + try: + self.read_thread.join(1.0) # Wait for thread to finish, timeout after 1 second + except Exception as e: + print(f"Warning: Error waiting for read thread: {str(e)}") + + # Log disconnection + if self.at_log_file: + # 使用time模块获取时间戳 + timestamp = time.strftime("%Y-%m-%d %H:%M:%S") + self.at_log_file.write(f"\n===== LTE管理器会话结束 {timestamp} =====\n\n") + self.at_log_file.flush() + self.at_log_file.close() + print(f"AT命令日志文件已关闭: {self.at_log_file.name}") + self.at_log_file = None + + # Close the serial port + if hasattr(self, 'at_serial') and self.at_serial: + self.at_serial.close() + + self.connected = False + self.status_changed.emit("Disconnected") + return True + return False + except Exception as e: + self.status_changed.emit(f"Error disconnecting: {str(e)}") + return False + + def is_connected(self): + """Check if connected to the LTE module""" + return self.connected + + def send_at_command(self, command, timeout=2.0, retries=2, use_cache=False): + """发送AT命令并等待响应""" + # 如果使用缓存并且命令有缓存 + if use_cache and command in self.command_cache: + cache_time, cache_result = self.command_cache[command] + # 检查缓存是否过期 (500ms) + if time.time() - cache_time < 0.5: + # 记录使用了缓存 + print(f"使用缓存结果: {command}") + self._log_at_interaction(command, f"[CACHED] {cache_result}") + return cache_result + + # 初始化重试计数器 + retry_count = 0 + + print(f"发送AT命令: {command}, 超时: {timeout}秒, 最大重试次数: {retries}") + + while retry_count < retries: + try: + # 检查串口是否已打开(不检查self.connected标志) + if not hasattr(self, 'at_serial') or not self.at_serial or not self.at_serial.is_open: + error_msg = "ERROR: Serial port not open" + print(f"命令发送失败: {error_msg}") + self._log_at_interaction(command, error_msg) + return error_msg + + # 清空输入缓冲区 + try: + self.at_serial.reset_input_buffer() + print(f"输入缓冲区已清空") + except Exception as e: + print(f"清空输入缓冲区失败: {str(e)}") + + # 记录发送的AT命令 + self._log_at_interaction(command, None) + + # 发送命令 + cmd = command + "\r\n" + bytes_written = self.at_serial.write(cmd.encode()) + print(f"发送命令: {command},已写入 {bytes_written} 字节") + + # 确保命令已发送 + self.at_serial.flush() + print(f"命令已刷新到设备") + + # 等待并读取响应 + response = self._read_serial(timeout) + print(f"收到命令响应: {response}") + + # 检查响应 + if "ERROR" in response: + # 记录错误响应 + self._log_response(command, response) + + # 区分不同类型的错误 + # 1. 查询命令返回ERROR - 这通常表示命令不支持,不需要重试 + if command.endswith("=?") or command.endswith("?"): + print(f"命令不支持: {command} -> {response}") + # 将响应缓存并返回,不重试 + self.command_cache[command] = (time.time(), response) + return response + + # 2. CME ERROR或CMS ERROR - 这是带错误代码的特定错误,说明命令被识别但执行失败 + if "+CME ERROR:" in response or "+CMS ERROR:" in response: + print(f"命令执行错误: {command} -> {response}") + # 如果是特定错误代码,不需要重试 + self.command_cache[command] = (time.time(), response) + return response + + # 3. 普通ERROR - 可能需要重试的通信问题 + print(f"命令执行错误: {command} -> {response}, 重试 {retry_count+1}/{retries}") + retry_count += 1 + time.sleep(0.5) # 出错时延迟后重试 + continue + else: + # 命令成功,记录并缓存响应 + self._log_response(command, response) + print(f"命令执行成功: {command}") + + # 如果这是AT命令并且响应包含OK,则设置connected标志 + if command == "AT" and "OK" in response: + self.connected = True + print("连接状态已设置为已连接") + + # 缓存响应结果 + self.command_cache[command] = (time.time(), response) + return response + + except Exception as e: + print(f"命令 {command} 执行时出错: {str(e)}") + error_msg = f"ERROR: {str(e)}" + retry_count += 1 + time.sleep(0.5) # 出错时延迟后重试 + continue + + # 达到最大重试次数后返回错误 + print(f"命令 {command} 已达到最大重试次数 {retries},放弃执行") + return f"ERROR: Max retries ({retries}) exceeded" + + def _read_thread(self): + """Thread function to continuously read from serial port""" + buffer = "" + + print("Serial read thread started") + while self.running: + if not self.at_serial or not self.at_serial.is_open: + time.sleep(0.1) + continue + + try: + # Read data from serial port + if self.at_serial.in_waiting > 0: + data = self.at_serial.read(self.at_serial.in_waiting) + if data: + text = data.decode('utf-8', errors='replace') + buffer += text + + # Process complete lines + while '\r\n' in buffer: + line, buffer = buffer.split('\r\n', 1) + line = line.strip() + + if not line: + continue + + # Process unsolicited responses + self._process_unsolicited(line) + + # Add to response queue for command responses + self.response_queue.put(line) + else: + # No data available, sleep briefly to reduce CPU usage + time.sleep(0.01) + except Exception as e: + print(f"Serial read error: {str(e)}") + time.sleep(0.1) + + print("Serial read thread stopped") + + def _read_serial(self, timeout=5.0): + """读取串口响应,直到超时或收到完整响应""" + start_time = time.time() + response = [] + command_echo_received = False + + print(f"等待AT命令响应,最大超时时间: {timeout}秒") + + # 等待响应,直到超时 + while time.time() - start_time < timeout: + try: + # 使用queue.get()获取响应行 + line = self.response_queue.get(timeout=0.2) + print(f"收到响应行: {line}") + + # 检查是否是命令回显(某些模块会回显命令) + if not command_echo_received and line.startswith("AT"): + command_echo_received = True + # 可以选择跳过命令回显行,不添加到响应中 + continue + + response.append(line) + + # 检查是否为完整响应的各种情况 + # 1. 标准的OK结束 + if line == "OK": + print("收到完整响应标识,结束等待") + break + + # 2. ERROR结束 + elif line == "ERROR" or "+CMS ERROR:" in line or "+CME ERROR:" in line: + print("收到完整响应标识,结束等待") + break + + # 3. 没有正常的结束标记,但对于某些特殊响应需要特殊处理 + # 例如,对于COPS?命令如果收到了+COPS:行,可能不需要等待OK + elif "+COPS:" in line and "OK" not in response: + # 继续等待OK,不直接退出 + pass + + # 4. 对于返回消息体的命令,等待足够长的时间确保收到完整消息 + # 特别是短信内容、通话状态等 + elif "+CMGR:" in line or "+CMGL:" in line or "+CLCC:" in line: + # 继续等待,这些命令通常有多行响应 + pass + + except queue.Empty: + # 如果队列为空,检查是否已有足够的响应内容 + # 如果已经收到至少一行,且最后一行包含某些特定结束标记,也可以结束等待 + if response: + # 检查最后一行是否是某种结束标记 + last_line = response[-1] + if last_line in ["OK", "ERROR"] or "+CMS ERROR:" in last_line or "+CME ERROR:" in last_line: + print("在队列等待期间确认已收到完整响应") + break + + # 对于某些命令,没有明确结束标记,但收到特定响应后短时间内没有更多响应,也可视为完成 + # 例如,AT+CSQ后只有一行+CSQ:响应,但没有OK + if ("+CSQ:" in last_line or "+CREG:" in last_line or "+CGREG:" in last_line) and \ + time.time() - start_time > 1.0: # 等待至少1秒以确保无更多响应 + print("已收到关键响应行且无后续内容,视为完成") + break + + # 如果没有收到任何响应,返回超时错误 + if not response: + print("读取超时,未收到任何响应") + return "ERROR: Read timeout" + + # 合并所有响应行并返回 + full_response = "\n".join(response) + return full_response + + def _process_unsolicited(self, line): + """处理非请求响应""" + # 不把AT命令及其响应作为unsolicited response处理 + if line.startswith("AT") or line == "OK" or line == "ERROR" or line.startswith("+CSQ") or line.startswith("+CREG") or line.startswith("+CGREG"): + # 跳过可能的命令回显或常见查询响应 + return + + # 对于真正的非请求通知,记录并处理 + self._log_unsolicited(line) + + # Incoming call + if line.startswith("RING"): + self.status_changed.emit("Incoming call") + self.in_call = True + # 设置为未接听状态 + self.call_connected = False + # Reset notification flag on new RING + self.call_notification_sent = False + + # Caller ID + elif "+CLIP:" in line: + match = re.search(r'\+CLIP: "([^"]+)"', line) + if match: + number = match.group(1) + self.call_number = number + + # Only emit the signal if we haven't sent a notification for this call yet + if not self.call_notification_sent and self.in_call: + self.call_received.emit(number) + self.call_notification_sent = True + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Call notification sent for {number}") + + # Call ended + elif "NO CARRIER" in line: + self.in_call = False + self.call_connected = False + self.call_notification_sent = False # Reset the flag when call ends + self.status_changed.emit("Call ended") + + # 记录通话结束日志,方便调试 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Call ended, NO CARRIER detected") + + # 通话结束时取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + + # 发送通话结束信号 + self.call_ended.emit("Call ended") + + # Voice call begin - 这是通话实际建立的时间点 + elif "VOICE CALL: BEGIN" in line: + # 设置通话状态为活动 + self.in_call = True + # 设置为已接通状态 + self.call_connected = True + + # 记录日志 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话已建立 (VOICE CALL: BEGIN)") + self.status_changed.emit("Call in progress") + + # 先确保任何可能存在的PCM注册已取消 + self._unregister_pcm_audio() + + # 短暂延迟后再注册PCM音频,确保模块已稳定 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 延迟100ms后注册PCM音频") + time.sleep(0.1) # 先延迟一小段时间 + + # 开始注册PCM音频 + self._register_pcm_audio() + + # Voice call end + elif "VOICE CALL: END:" in line: + self.in_call = False + self.call_connected = False + match = re.search(r'VOICE CALL: END: (\d+)', line) + duration = "0" + if match: + duration = match.group(1) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Call ended, duration: {duration}") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Call ended, no duration info") + + # 记录详细日志,包括通话持续时间 + call_minutes = int(duration) // 60 + call_seconds = int(duration) % 60 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话结束,持续时间: {call_minutes}分{call_seconds}秒") + + # 首先取消PCM音频注册,然后才发送通话结束信号 + # 这样可以确保PCM音频在通话结束信号处理前已经被取消 + if self._ensure_pcm_audio_unregistered(): + # 在成功取消注册后发送信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频已取消注册,发送通话结束信号") + # 使用threading.Timer代替QTimer,避免线程问题 + threading.Timer(0.2, lambda: self.call_ended.emit(duration)).start() + else: + # 即使取消注册失败,也要发送通话结束信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频取消注册失败,仍发送通话结束信号") + # 使用threading.Timer代替QTimer,避免线程问题 + threading.Timer(0.2, lambda: self.call_ended.emit(duration)).start() + + # Missed call + elif "MISSED_CALL:" in line: + match = re.search(r'MISSED_CALL: ([^\r\n]+)', line) + if match: + missed_info = match.group(1) + self.status_changed.emit(f"Missed call: {missed_info}") + + # Extract phone number from missed call info + # Format is typically "HH:MMAM/PM PHONENUMBER" + parts = missed_info.strip().split() + if len(parts) >= 2: + missed_number = parts[-1] # Last part should be the phone number + # Signal call ended to stop ringtone + self.call_ended.emit("Missed") + # Also emit missed call signal with the number + self.call_number = missed_number + self.status_changed.emit(f"Missed call from {missed_number}") + + # SMS received (direct content mode) + elif line.startswith("+CMT:"): + try: + # 提取发送者号码和时间戳,用于后续匹配和合并短信 + sender_match = re.search(r'\+CMT: "([^"]*)",[^,]*,"([^"]*)"', line) + if sender_match: + sender = sender_match.group(1) + timestamp = sender_match.group(2) + + # 解码发送者号码(如果是UCS2编码) + if sender.startswith("00"): + try: + sender = ucs2_to_text(sender) + except Exception as e: + print(f"解码发送者号码出错: {str(e)}") + + # 保存短信头部信息,用于后续处理 + self.pending_sms_sender = sender + self.pending_sms_timestamp = timestamp + + # 生成SMS ID用于匹配多段短信 + sms_id = f"{sender}_{timestamp[:10]}" + + # 检查是否已有相同ID的短信正在处理中 + is_continuation = False + if sms_id in self.concat_sms_parts: + # 这是已有短信的后续部分 + is_continuation = True + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到后续短信部分: {sms_id}") + self.status_changed.emit(f"检测到后续短信部分,来自 {sender}") + + # 标记等待内容行 + self.waiting_for_sms_content = True + + # 保存当前短信ID,用于内容行处理 + self.current_sms_id = sms_id + self.current_is_continuation = is_continuation + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 收到短信头部,发送者: {sender}, 时间: {timestamp}, ID: {sms_id}") + else: + # 无法解析发送者和时间,使用默认处理方式 + if self._is_concatenated_sms(line): + self._handle_concatenated_sms(line) + else: + self._handle_regular_sms(line) + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 处理短信头部出错: {str(e)}") + # 错误时使用旧方法尝试处理 + if self._is_concatenated_sms(line): + self._handle_concatenated_sms(line) + else: + self._handle_regular_sms(line) + + elif self.waiting_for_sms_content: + # 这是短信内容行 + self.waiting_for_sms_content = False + message = line + + # 检查是否保存了短信ID信息 + sms_id = getattr(self, 'current_sms_id', None) + is_continuation = getattr(self, 'current_is_continuation', False) + + # 清除临时属性 + if hasattr(self, 'current_sms_id'): + del self.current_sms_id + if hasattr(self, 'current_is_continuation'): + del self.current_is_continuation + + try: + # 检查是否是UCS2编码 + if all(c in "0123456789ABCDEFabcdef" for c in line.replace(" ", "")): + # 尝试解码UCS2内容 + decoded_content = None + try: + decoded_content = ucs2_to_text(line) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - UCS2内容解码成功: {decoded_content[:50]}...") + except Exception as decode_error: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - UCS2解码错误: {str(decode_error)}") + + # 尝试替代解码方法 + try: + hex_bytes = binascii.unhexlify(line.replace(" ", "")) + decoded_content = hex_bytes.decode('utf-16-be', errors='replace') + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 替代解码成功: {decoded_content[:50]}...") + except Exception as alt_error: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 替代解码也失败: {str(alt_error)}") + + # 如果是长短信的一部分(根据特定特征判断) + is_long_message_part = False + if "62117ED94F6053D14E86957F6587672C" in line: + is_long_message_part = True + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到长短信特征") + elif decoded_content and "https://" in decoded_content: + is_long_message_part = True + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到URL内容,视为长短信") + + # 处理长短信 + if is_long_message_part or is_continuation: + # 处理为长短信的一部分 + self._process_long_message_part(self.pending_sms_sender, self.pending_sms_timestamp, line, decoded_content, sms_id) + else: + # 常规短信处理,直接发送解码后的内容 + message = decoded_content if decoded_content else line + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + message + ) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送常规短信到UI") + else: + # 非UCS2编码,直接发送 + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + message + ) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送纯文本短信到UI") + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 处理短信内容时出错: {str(e)}") + # 出错时尝试直接发送原始内容 + self.sms_received.emit( + self.pending_sms_sender, + self.pending_sms_timestamp, + f"[解码错误] {message[:100]}..." + ) + + # 清除待处理短信数据 + self.pending_sms_sender = None + self.pending_sms_timestamp = None + + # SMS received (index mode) + elif line.startswith("+CMTI:"): + match = re.search(r'\+CMTI: "([^"]+)",(\d+)', line) + if match: + storage, index = match.group(1), match.group(2) + self.status_changed.emit(f"New SMS at index {index}") + # Fetch SMS content + self._fetch_sms(storage, index) + + # DTMF tone received + elif "+RXDTMF:" in line: + match = re.search(r'\+RXDTMF: (\d)', line) + if match: + tone = match.group(1) + self.dtmf_received.emit(tone) + + # SMS full + elif "+SMS FULL" in line: + self.status_changed.emit("SMS storage full. Please delete some messages.") + + def _process_long_message_part(self, sender, timestamp, content, decoded_content, sms_id): + """处理长短信的一部分,支持追加入库功能""" + try: + # 移除空格 + content = content.replace(" ", "") + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 处理长短信部分,ID: {sms_id}, 内容长度: {len(content)}") + + # 特殊格式检测 + is_special_format = "62117ED94F6053D14E86957F6587672C" in content + + # 如果没有解码后的内容,尝试解码 + if not decoded_content: + try: + decoded_content = ucs2_to_text(content) + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 解码UCS2内容出错: {str(e)}") + try: + # 尝试替代解码方法 + hex_bytes = binascii.unhexlify(content) + decoded_content = hex_bytes.decode('utf-16-be', errors='replace') + except Exception as alt_e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 替代解码方法失败: {str(alt_e)}") + decoded_content = f"[无法解码] {content[:50]}..." + + # 提取URL (如果有) + url = None + if is_special_format and decoded_content: + # 尝试从特殊格式中提取URL + try: + url_match = re.search(r':(https?://[^\s]+)', decoded_content) + if url_match: + url = url_match.group(1) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 从特殊格式中提取URL: {url}") + except Exception as url_e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 从特殊格式提取URL失败: {str(url_e)}") + + # 如果没有提取到URL但有解码后的内容,尝试从普通文本中提取 + if not url and decoded_content: + url_match = re.search(r'(https?://[^\s]+)', decoded_content) + if url_match: + url = url_match.group(1) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 从文本中提取URL: {url}") + + # 初始化或更新长短信记录 + if sms_id not in self.concat_sms_parts: + # 提取消息前缀(如果有) + prefix = "消息" + if decoded_content and ":" in decoded_content: + prefix_match = re.match(r'^([^:]+):', decoded_content) + if prefix_match: + prefix = prefix_match.group(1).strip() + + # 创建新的长短信记录 + self.concat_sms_parts[sms_id] = { + 'sender': sender, + 'timestamp': timestamp, + 'parts': [], + 'urls': [], + 'received_time': time.time(), + 'prefix': prefix, + 'is_processed': False # 标记是否已处理 + } + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 创建新的长短信记录: {sms_id}") + + # 更新长短信记录 + sms_record = self.concat_sms_parts[sms_id] + + # 添加解码后的内容到parts + if decoded_content and decoded_content not in sms_record['parts']: + sms_record['parts'].append(decoded_content) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 添加第 {len(sms_record['parts'])} 部分到长短信记录") + + # 添加URL到urls列表(如果有且不重复) + if url and url not in sms_record['urls']: + sms_record['urls'].append(url) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 添加URL到长短信记录: {url}") + + # 更新接收时间 + sms_record['received_time'] = time.time() + + # 使用定时器,延迟合并处理长短信(等待其他部分到达) + # 如果是分条短信,设置较短的延迟;如果是长短信,设置较长的延迟 + delay = 1.5 if len(sms_record['parts']) > 1 else 3.0 + threading.Timer(delay, lambda: self._check_and_merge_sms(sms_id)).start() + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 设置 {delay} 秒后合并长短信") + + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 处理长短信部分时出错: {str(e)}") + # 出错时尝试直接发送当前部分 + try: + message = decoded_content if decoded_content else content + self.sms_received.emit(sender, timestamp, f"[长短信处理错误] {message[:100]}...") + except Exception as send_e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送错误消息失败: {str(send_e)}") + + def _check_and_merge_sms(self, sms_id): + """检查并合并长短信,支持追加内容到已处理的长短信""" + if sms_id not in self.concat_sms_parts: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 无法找到长短信记录: {sms_id}") + return + + sms_info = self.concat_sms_parts[sms_id] + + # 检查是否已处理过 + if sms_info.get('is_processed', False): + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 长短信 {sms_id} 已处理过,检查是否有新部分") + + # 如果已处理过但有新内容(最近3秒内收到的),则追加处理 + current_time = time.time() + if current_time - sms_info['received_time'] < 3: + # 有新部分,继续等待更多部分 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到新内容,延迟后再次尝试合并") + threading.Timer(2.0, lambda: self._check_and_merge_sms(sms_id)).start() + return + + # 有新内容需要追加,重新合并并发送更新 + merged_content = self._merge_sms_parts(sms_id) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送更新的长短信内容: {merged_content[:50]}...") + + # 发送信号,表示这是更新的内容 + self.status_changed.emit(f"更新长短信内容,来自 {sms_info['sender']}") + self.sms_received.emit( + sms_info['sender'], + sms_info['timestamp'], + merged_content + ) + + # 记录处理状态 + sms_info['is_processed'] = True + + # 更新上次处理时间 + sms_info['last_processed'] = current_time + return + + # 检查是否有有效部分 + if not sms_info.get('parts', []): + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 长短信 {sms_id} 没有有效部分,跳过合并") + return + + # 检查是否收到后续部分的超时(通常1-3秒内应该收到所有部分) + current_time = time.time() + time_since_last_part = current_time - sms_info['received_time'] + + # 如果最近2秒内收到新部分,继续等待 + if time_since_last_part < 2.0: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 最近才收到新部分 ({time_since_last_part:.1f}秒前),继续等待") + return + + # 超过等待时间,进行合并处理 + merged_content = self._merge_sms_parts(sms_id) + + # 发送完整消息 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送合并后的长短信: {merged_content[:50]}...") + self.sms_received.emit( + sms_info['sender'], + sms_info['timestamp'], + merged_content + ) + + # 标记为已处理 + sms_info['is_processed'] = True + sms_info['last_processed'] = current_time + + # 记录日志 + self.status_changed.emit(f"已接收完整长短信,来自 {sms_info['sender']}") + + # 不删除记录,而是保留用于后续追加处理 + # 长短信记录将在清理定时任务中处理 + + def _is_concatenated_sms(self, header_line): + """检查是否为长短信""" + # 检查是否符合长短信格式 + try: + # 长短信格式通常包含更多逗号分隔的字段 + parts = header_line.split(',') + # 普通短信头部通常有3个字段,长短信可能有更多 + if len(parts) >= 6: + return True + return False + except Exception as e: + print(f"检查长短信格式出错: {str(e)}") + return False + + def _handle_regular_sms(self, header_line): + """处理普通短信""" + try: + # 解析SMS头部,格式通常为: +CMT: "sender","","timestamp" + header_match = re.search(r'\+CMT: "([^"]*)",[^,]*,"([^"]*)"', header_line) + if header_match: + sender = header_match.group(1) + timestamp = header_match.group(2) + + # 检查发送者是否为UCS2格式(通常以00开头) + if sender.startswith("00"): + try: + sender = ucs2_to_text(sender) + except Exception as e: + print(f"解码发送者号码失败: {str(e)}") + # 解码失败时保留原始格式 + + # 保存发送者和时间信息,等待下一行接收内容 + self.pending_sms_sender = sender + self.pending_sms_timestamp = timestamp + self.waiting_for_sms_content = True + + # 发送状态更新 + self.status_changed.emit(f"收到来自 {sender} 的短信") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 收到来自 {sender} 的短信") + else: + # 如果头部格式不匹配,使用默认值 + self.pending_sms_sender = "未知号码" + self.pending_sms_timestamp = time.strftime("%y/%m/%d,%H:%M:%S") + self.waiting_for_sms_content = True + + # 发送状态更新 + self.status_changed.emit("收到短信(无法识别发送者)") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 收到短信(头部格式异常:{header_line})") + except Exception as e: + print(f"处理短信头部出错: {str(e)}") + # 出错时使用默认值 + self.pending_sms_sender = "错误" + self.pending_sms_timestamp = time.strftime("%y/%m/%d,%H:%M:%S") + self.waiting_for_sms_content = True + self.status_changed.emit(f"解析短信头部时出错: {str(e)}") + + def _handle_concatenated_sms(self, header_line): + """处理长短信的头部信息""" + try: + # 解析长短信头部,格式可能更复杂 + parts = header_line.split(',') + if len(parts) < 3: + # 格式不符合预期,作为普通短信处理 + self._handle_regular_sms(header_line) + return + + # 尝试提取发送者和时间戳 + sender_part = parts[0].replace('+CMT: ', '').strip('"') + # 时间戳位置可能在第3个位置 + timestamp_part = parts[2].strip('"') if len(parts) > 2 else time.strftime("%y/%m/%d,%H:%M:%S") + + # 检查发送者是否为UCS2格式 + if sender_part.startswith("00"): + try: + sender_part = ucs2_to_text(sender_part) + except Exception as e: + print(f"解码长短信发送者出错: {str(e)}") + # 解码失败时保留原始格式 + + # 保存信息等待后续处理 + self.pending_sms_sender = sender_part + self.pending_sms_timestamp = timestamp_part + self.waiting_for_sms_content = True + + # 发送状态更新 + self.status_changed.emit(f"收到来自 {sender_part} 的长短信部分") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 收到来自 {sender_part} 的长短信部分") + except Exception as e: + # 出错时尝试作为普通短信处理 + print(f"处理长短信头部出错: {str(e)}") + self._handle_regular_sms(header_line) + + def _process_concatenated_sms_part(self, sender, timestamp, content): + """处理长短信的内容部分""" + try: + # 移除空格 + content = content.replace(" ", "") + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 处理长短信内容: {content[:50]}...") + + # 检查是否为特定格式的长短信 + is_special_format = "62117ED94F6053D14E86957F6587672C" in content + + # 尝试解码内容 + try: + decoded_content = ucs2_to_text(content) + print(f"解码内容: {decoded_content[:50]}...") + except Exception as e: + print(f"UCS2解码错误: {str(e)}") + # 如果解码失败,尝试不同方法或直接发送原始内容 + try: + # 尝试直接从十六进制转换为字节,然后解码 + hex_bytes = binascii.unhexlify(content) + decoded_content = hex_bytes.decode('utf-16-be', errors='replace') + print(f"替代解码成功: {decoded_content[:50]}...") + except Exception as alt_e: + print(f"替代解码失败: {str(alt_e)}") + # 如果所有解码方法都失败,发送原始内容 + self.sms_received.emit( + sender, + timestamp, + f"[无法解码的消息] {content[:50]}..." + ) + return + + # 特殊格式处理 - 提取URL + url = None + if is_special_format: + # 尝试从特殊格式中提取URL + parts = content.split("003A", 1) # 003A是冒号的UCS2编码 + if len(parts) > 1: + url_content = "003A" + parts[1] # 加回冒号 + try: + url_text = ucs2_to_text(url_content) + url_match = re.search(r':(https?://[^\s]+)', url_text) + if url_match: + url = url_match.group(1) + print(f"提取URL: {url}") + except Exception as url_e: + print(f"URL提取错误: {str(url_e)}") + + # 如果没有找到URL,尝试从普通文本中提取 + if not url: + url_match = re.search(r'(https?://[^\s]+)', decoded_content) + if url_match: + url = url_match.group(1) + print(f"从普通文本提取URL: {url}") + + # 创建或更新长短信记录 + sms_id = f"{sender}_{timestamp[:10]}" + + if sms_id not in self.concat_sms_parts: + prefix = "消息" + if ":" in decoded_content: + prefix = decoded_content.split(":", 1)[0].strip() + + self.concat_sms_parts[sms_id] = { + 'sender': sender, + 'timestamp': timestamp, + 'parts': [], + 'urls': [], + 'received_time': time.time(), + 'prefix': prefix + } + + # 添加这部分到长短信记录 + if url and url not in self.concat_sms_parts[sms_id]['urls']: + self.concat_sms_parts[sms_id]['urls'].append(url) + + if decoded_content not in self.concat_sms_parts[sms_id]['parts']: + self.concat_sms_parts[sms_id]['parts'].append(decoded_content) + + # 更新接收时间 + self.concat_sms_parts[sms_id]['received_time'] = time.time() + + # 使用定时器,3秒后尝试合并长短信 + threading.Timer(3.0, lambda: self._check_and_merge_sms(sms_id)).start() + + print(f"已保存长短信部分,将在3秒后尝试合并") + + except Exception as e: + print(f"处理长短信内容部分出错: {str(e)}") + # 出错时直接发送解码后的内容 + try: + decoded = ucs2_to_text(content) if all(c in "0123456789ABCDEFabcdef" for c in content) else content + self.sms_received.emit( + sender, + timestamp, + decoded + ) + except Exception as final_e: + print(f"最终解码尝试失败: {str(final_e)}") + self.sms_received.emit( + sender, + timestamp, + f"[消息解码失败] {content[:50]}..." + ) + + def _merge_sms_parts(self, sms_id): + """合并长短信的所有部分""" + if sms_id not in self.concat_sms_parts: + return "" + + sms_info = self.concat_sms_parts[sms_id] + + # 如果只有一个部分,直接返回 + if len(sms_info['parts']) == 1: + return sms_info['parts'][0] + + # 合并所有部分 + prefix = sms_info.get('prefix', '') + urls = sms_info.get('urls', []) + + if urls: + # 如果有URL,优先使用URL格式返回 + if prefix: + merged_content = f"{prefix}:\n" + "\n".join(urls) + else: + merged_content = "\n".join(urls) + else: + # 如果没有URL,合并所有文本部分 + merged_content = "\n".join(sms_info['parts']) + + return merged_content + + def _cleanup_old_sms_parts(self): + """清理超时的长短信部分""" + try: + current_time = time.time() + sms_ids_to_remove = [] + + for sms_id, sms_info in self.concat_sms_parts.items(): + # 检查是否已处理且超过保留时间(10分钟) + if sms_info.get('is_processed', False) and current_time - sms_info.get('last_processed', 0) > 600: + sms_ids_to_remove.append(sms_id) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 清理已处理的长短信: {sms_id}") + # 检查未处理但已超时的长短信(30秒) + elif not sms_info.get('is_processed', False) and current_time - sms_info.get('received_time', 0) > 30: + # 如果有内容但未处理(可能是因为只收到部分内容),尝试合并发送 + if sms_info.get('parts', []): + try: + # 合并可用部分并发送 + merged_content = self._merge_sms_parts(sms_id) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送超时但未处理的长短信: {merged_content[:50]}...") + self.sms_received.emit( + sms_info['sender'], + sms_info['timestamp'], + f"[部分内容] {merged_content}" + ) + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 处理超时长短信时出错: {str(e)}") + + sms_ids_to_remove.append(sms_id) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 清理超时未处理的长短信: {sms_id}") + + # 移除标记的记录 + for sms_id in sms_ids_to_remove: + del self.concat_sms_parts[sms_id] + self.status_changed.emit(f"清理长短信记录: {sms_id}") + + # 打印当前缓存状态 + if self.concat_sms_parts: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 当前有 {len(self.concat_sms_parts)} 条长短信记录在缓存中") + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 清理长短信部分时出错: {str(e)}") + + def _decode_pdu_message(self, pdu_str): + """Decode PDU format message (including Chinese characters)""" + try: + # Remove spaces and convert to bytes + pdu_str = pdu_str.replace(" ", "") + + # 检查是否为长短信的一部分 + if self._is_part_of_concatenated_sms(pdu_str): + # 可能是长短信的一部分,需要特殊处理 + # 这里需要根据实际的PDU格式进行解析 + pass + + # Try to decode using our utility function + return ucs2_to_text(pdu_str) + except Exception as e: + print(f"PDU decode error: {str(e)}") + # If decoding fails, return the original string + return f"[Decode error: {pdu_str[:30]}...]" + + def _is_part_of_concatenated_sms(self, content): + """检查内容是否为长短信的一部分""" + try: + # 移除空格 + content = content.replace(" ", "") + + # 检查是否为UCS2编码 + if not all(c in "0123456789ABCDEFabcdef" for c in content): + return False + + # 检查内容长度是否足够 + if len(content) < 10: + return False + + # 检查是否包含特定模式 + # 1. 检查特定前缀,这是已知的长短信特征 + if content.startswith("62117ED94F6053D14E86957F6587672C"): + print(f"检测到长短信特定前缀: 62117ED94F6053D14E86957F6587672C") + return True + + # 2. 检查内容是否包含URL的UCS2编码 + # https的UCS2编码前缀: 00680074007400700073 + if "00680074007400700073" in content: + print(f"检测到UCS2编码的HTTPS URL") + return True + + # 3. 检查内容长度是否超过标准短信长度限制 + # UCS2编码的短信最多支持70个字符,即140个字节,对应280个十六进制字符 + if len(content) > 280: + print(f"内容长度({len(content)})超过标准短信限制") + return True + + # 4. 尝试解码并检查是否包含特定内容标记 + try: + decoded = ucs2_to_text(content) + # 检查解码后内容是否包含URL + if re.search(r'https?://', decoded): + print(f"检测到包含URL的内容") + return True + except: + pass + + return False + except Exception as e: + print(f"检查长短信内容部分时出错: {str(e)}") + return False + + def _initialize_module(self): + """初始化LTE模块 + + 设置基本配置,包括来电提醒、短信格式等 + 并获取设备信息 + """ + try: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 初始化LTE模块") + + # 每个命令间添加30毫秒延迟 + + # 检查并注销PCM音频,确保通话音频正确处理 + self._stop_pcm_audio() + time.sleep(0.03) # 30毫秒延迟 + + # 检查当前CLIP状态 + clip_status = self.send_at_command("AT+CLIP?") + time.sleep(0.03) # 30毫秒延迟 + + # 只有当CLIP不是1时才启用 + if not clip_status or "+CLIP: 1" not in clip_status: + # 启用来电显示功能 (呼叫线路标识显示) + clip_response = self.send_at_command("AT+CLIP=1") + if "OK" in clip_response: + self.status_changed.emit("来电显示功能已启用") + else: + self.status_changed.emit("启用来电显示功能失败") + time.sleep(0.03) # 30毫秒延迟 + + # 检查当前SMS格式状态 + sms_format = self.send_at_command("AT+CMGF?") + time.sleep(0.03) # 30毫秒延迟 + + # 只有当不是文本模式时才设置 + if not sms_format or "+CMGF: 1" not in sms_format: + # 设置短信为文本模式 + cmgf_response = self.send_at_command("AT+CMGF=1") + if "OK" in cmgf_response: + self.status_changed.emit("短信文本模式已启用") + else: + self.status_changed.emit("启用短信文本模式失败") + time.sleep(0.03) # 30毫秒延迟 + + # 检查新消息指示配置 + cnmi_status = self.send_at_command("AT+CNMI?") + time.sleep(0.03) # 30毫秒延迟 + + # 只有当不是2,2,0,0,0时才设置 + if not cnmi_status or "+CNMI: 2,2,0,0,0" not in cnmi_status: + # 设置新消息指示 + cnmi_response = self.send_at_command("AT+CNMI=2,2,0,0,0") + if "OK" in cnmi_response: + self.status_changed.emit("短信通知已启用") + else: + self.status_changed.emit("启用短信通知失败") + time.sleep(0.03) # 30毫秒延迟 + + # 获取模块信息 (厂商、型号、IMEI等) + self._get_module_info() + time.sleep(0.03) # 30毫秒延迟 + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - LTE模块初始化完成") + + except Exception as e: + self.status_changed.emit(f"初始化模块失败: {str(e)}") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 初始化模块失败: {str(e)}") + + def _get_module_info(self): + """获取模块信息(初始化时调用一次)""" + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 获取设备基本信息") + + # 记录上次更新时间 + self.last_info_update = time.time() + + # 获取制造商信息 + response = self.send_at_command("AT+CGMI") + if response and "OK" in response: + # 移除命令回显和OK响应,只保留实际内容 + lines = [line.strip() for line in response.split('\n') if line.strip()] + # 过滤掉AT命令回显和OK响应 + content_lines = [line for line in lines if line != "OK" and not line.startswith("AT+")] + self.manufacturer = '\n'.join(content_lines).strip() + + # 获取模块型号 + response = self.send_at_command("AT+CGMM") + if response and "OK" in response: + # 移除命令回显和OK响应,只保留实际内容 + lines = [line.strip() for line in response.split('\n') if line.strip()] + # 过滤掉AT命令回显和OK响应 + content_lines = [line for line in lines if line != "OK" and not line.startswith("AT+")] + self.model = '\n'.join(content_lines).strip() + + # 获取IMEI号码 + response = self.send_at_command("AT+CGSN") + if response and "OK" in response: + # 移除命令回显和OK响应,只保留实际内容 + lines = [line.strip() for line in response.split('\n') if line.strip()] + # 过滤掉AT命令回显和OK响应 + content_lines = [line for line in lines if line != "OK" and not line.startswith("AT+")] + self.imei = '\n'.join(content_lines).strip() + + # 获取固件版本 + response = self.send_at_command("AT+CGMR") + if response and "OK" in response: + match = re.search(r'\+CGMR: (.+)', response) + if match: + self.firmware = match.group(1) + else: + # 移除命令回显和OK响应,只保留实际内容 + lines = [line.strip() for line in response.split('\n') if line.strip()] + # 过滤掉AT命令回显和OK响应 + content_lines = [line for line in lines if line != "OK" and not line.startswith("AT+")] + self.firmware = '\n'.join(content_lines).strip() + + # 获取电话号码、运营商和信号强度信息 + self._update_phone_number() + self._update_carrier_info() + self._update_signal_strength() + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 设备基本信息获取完成") + + def _update_phone_number(self): + """更新电话号码信息(缓存30分钟)""" + # 添加缓存检查,减少AT命令交互 + current_time = time.time() + if hasattr(self, 'last_phone_update') and current_time - self.last_phone_update < 1800: # 30分钟缓存 + # 使用缓存的值 + return self.phone_number + + response = self.send_at_command("AT+CNUM") + if response and "+CNUM:" in response: + match = re.search(r'\+CNUM: "[^"]*","([^"]+)"', response) + if match: + self.phone_number = match.group(1) + self.last_phone_update = current_time # 记录更新时间 + return self.phone_number + + # 如果没有找到号码但有之前的值,保留之前的值 + return self.phone_number + + def _update_carrier_info(self): + """更新运营商信息(缓存10分钟)""" + # 添加缓存检查,减少AT命令交互 + current_time = time.time() + if hasattr(self, 'last_carrier_update') and current_time - self.last_carrier_update < 600: # 10分钟缓存 + # 使用缓存的值 + return (self.carrier, self.network_type) + + # 更新运营商信息 + carrier_updated = False + response = self.send_at_command("AT+COPS?") + if response and "+COPS:" in response: + match = re.search(r'\+COPS: \d+,\d+,"([^"]+)"', response) + if match: + self.carrier = match.group(1) + carrier_updated = True + + # 更新网络类型 + network_updated = False + response = self.send_at_command("AT+CPSI?") + if response and "+CPSI:" in response: + # 移除命令回显,只保留+CPSI:部分 + match = re.search(r'\+CPSI:(.+)', response) + if match: + parts = match.group(1).split(',') + if len(parts) > 1: + self.network_type = parts[0].strip() + network_updated = True + else: + parts = response.split(',') + if len(parts) > 1: + self.network_type = parts[0].replace("+CPSI:", "").strip() + network_updated = True + + # 如果有更新,记录更新时间 + if carrier_updated or network_updated: + self.last_carrier_update = current_time + + return (self.carrier, self.network_type) + + def _update_signal_strength(self): + """更新信号强度信息(无缓存,需要实时监控)""" + response = self.send_at_command("AT+CSQ") + if response and "+CSQ:" in response: + match = re.search(r'\+CSQ: (\d+),', response) + if match: + rssi = int(match.group(1)) + if rssi == 99: + self.signal_strength = "Unknown" + else: + # Convert to dBm (-113 to -51 dBm) + dbm = -113 + (2 * rssi) + self.signal_strength = f"{dbm} dBm ({rssi}/31)" + + return self.signal_strength + + def get_carrier_info(self): + """获取运营商信息(使用缓存机制)""" + if not self.connected: + return None + + # 使用缓存的值,不每次都发送AT命令 + if hasattr(self, 'carrier') and self.carrier: + # 只有在超过缓存时间时才更新 + current_time = time.time() + if not hasattr(self, 'last_carrier_update') or current_time - self.last_carrier_update >= 600: # 10分钟缓存 + self._update_carrier_info() + else: + # 第一次请求,直接更新 + self._update_carrier_info() + + return self.carrier + + def get_phone_number(self): + """获取电话号码(使用缓存机制)""" + if not self.connected: + return None + + # 使用缓存的值,不每次都发送AT命令 + if hasattr(self, 'phone_number') and self.phone_number: + # 只有在超过缓存时间时才更新 + current_time = time.time() + if not hasattr(self, 'last_phone_update') or current_time - self.last_phone_update >= 1800: # 30分钟缓存 + self._update_phone_number() + else: + # 第一次请求,直接更新 + self._update_phone_number() + + return self.phone_number + + def get_network_info(self): + """获取网络信息(使用缓存机制)""" + if not self.connected: + return None + + # 使用缓存的值,不每次都发送AT命令 + if hasattr(self, 'network_type') and self.network_type: + # 只有在超过缓存时间时才更新(与运营商信息共享缓存时间) + current_time = time.time() + if not hasattr(self, 'last_carrier_update') or current_time - self.last_carrier_update >= 600: # 10分钟缓存 + self._update_carrier_info() # 这里会同时更新network_type + else: + # 第一次请求,直接更新 + self._update_carrier_info() + + return self.network_type + + def get_signal_strength(self): + """获取信号强度(实时更新)""" + if not self.connected: + return None + + # 信号强度需要实时更新,其他信息则使用缓存 + self._update_signal_strength() + return self.signal_strength + + def get_module_info(self): + """获取模块信息(使用缓存机制)""" + if not self.connected: + return {} + + # 检查是否需要刷新模块信息(默认每小时更新一次) + current_time = time.time() + if not hasattr(self, 'last_info_update') or current_time - self.last_info_update >= 3600: # 1小时缓存 + self._get_module_info() + else: + # 仅更新可能变化的信息:信号强度 + self._update_signal_strength() + + # 适当更新运营商信息(如果缓存过期) + if not hasattr(self, 'last_carrier_update') or current_time - self.last_carrier_update >= 600: + self._update_carrier_info() + + return { + 'manufacturer': self.manufacturer, + 'model': self.model, + 'imei': self.imei, + 'firmware': self.firmware, + 'phone_number': self.phone_number, + 'carrier': self.carrier, + 'network_type': self.network_type, + 'signal_strength': self.signal_strength + } + + def _fetch_sms(self, storage, index): + """Fetch SMS content by index""" + response = self.send_at_command(f'AT+CMGR={index}') + if response and "+CMGR:" in response: + # Parse SMS header + header_match = re.search(r'\+CMGR: "[^"]*","([^"]*)",[^,]*,"([^"]*)"', response) + if header_match: + sender = header_match.group(1) + timestamp = header_match.group(2) + + # Check if sender is in UCS2 format (starts with 00) + if sender.startswith("00"): + try: + sender = ucs2_to_text(sender) + except: + pass # Keep original if decoding fails + + # Extract message content + lines = response.split('\n') + message = "" + + if len(lines) > 1: + content_line = lines[1].strip() + message = content_line + + # Check if the content is in UCS2 format (hex string) + if all(c in "0123456789ABCDEFabcdef" for c in content_line.replace(" ", "")): + try: + # Try to decode as UCS2 + message = ucs2_to_text(content_line) + self.status_changed.emit("Decoded UCS2 message from storage") + except Exception as e: + self.status_changed.emit(f"Failed to decode UCS2 from storage: {str(e)}") + # Keep original if decoding fails + + self.sms_received.emit(sender, timestamp, message) + + def _register_pcm_audio(self): + """注册PCM音频(用于通话开始时) + 按照文档要求,在VOICE CALL: BEGIN后执行AT+CPCMREG=1 + """ + if not self.connected or not self.at_serial: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册失败:未连接") + return False + + # 如果已经不在通话中了,跳过注册 + if not self.in_call: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 不在通话中,跳过PCM音频注册") + self.status_changed.emit("Not in call, PCM audio registration skipped") + return False + + try: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 开始PCM音频注册过程") + + # 清除可能的额外数据 + if self.at_serial.in_waiting > 0: + self.at_serial.read(self.at_serial.in_waiting) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 清除了缓冲区数据") + + # 设置PCM格式为8K采样率(如需要16K,可更改为AT+CPCMFRM=1) + try: + with self.lock: + self.at_serial.write(b'AT+CPCMFRM=0\r') + time.sleep(0.1) + if self.at_serial.in_waiting > 0: + resp = self.at_serial.read(self.at_serial.in_waiting).decode('utf-8', errors='ignore') + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM格式设置响应: {resp}") + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 设置PCM格式出错: {str(e)}") + + # 直接发送PCM音频注册命令,使用更短的超时 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频注册命令") + + # 确保没有另一个命令在发送 + with self.lock: + self.at_serial.write(b'AT+CPCMREG=1\r') + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册命令已发送") + + # 等待较短的时间以获取响应 + time.sleep(0.1) + + # 试图读取响应 + response = "" + start_time = time.time() + while time.time() - start_time < 0.5: # 最多等待0.5秒 + if self.at_serial.in_waiting > 0: + response += self.at_serial.read(self.at_serial.in_waiting).decode('utf-8', errors='ignore') + if "OK" in response or "ERROR" in response: + break + time.sleep(0.05) + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册响应: {response}") + + # 记录是否成功 + success = "OK" in response + + # 根据响应结果发送状态更新 + if success: + self.status_changed.emit("PCM audio registered successfully") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册成功") + else: + self.status_changed.emit("PCM audio registration sent") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册状态未知") + + # 无论响应如何,发送激活信号,系统将尝试处理音频 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频激活信号") + self.pcm_audio_status.emit(True) + + # 添加调试记录 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册流程完成") + return True + + except Exception as e: + self.status_changed.emit(f"PCM audio registration error: {str(e)}") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册出错: {str(e)}") + + # 错误发生时,仍然尝试激活音频,保持一致行为 + self.pcm_audio_status.emit(True) + return False + + def _unregister_pcm_audio(self): + """取消注册PCM音频(用于通话结束时) + 按照文档要求,在VOICE CALL: END后执行AT+CPCMREG=0 + """ + if not self.connected or not self.at_serial: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 取消PCM音频注册失败:未连接") + # 即使未连接,也发送停止信号 + self.pcm_audio_status.emit(False) + return False + + try: + # 确保没有另一个命令在发送 + with self.lock: + # 清除任何待处理的数据 + if self.at_serial.in_waiting > 0: + self.at_serial.read(self.at_serial.in_waiting) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 清除了PCM音频注销前的缓冲区数据") + + # 使用直接写入代替send_at_command,避免阻塞 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频注销命令") + self.at_serial.write(b'AT+CPCMREG=0\r') + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销命令已发送") + + # 给一点时间让模块响应 + time.sleep(0.1) + + # 尝试读取响应,但不等待过长时间 + response = "" + start_time = time.time() + while time.time() - start_time < 0.3: # 等待最多0.3秒 + if self.at_serial.in_waiting > 0: + response += self.at_serial.read(self.at_serial.in_waiting).decode('utf-8', errors='ignore') + if "OK" in response: + break + time.sleep(0.05) + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销响应: {response}") + success = "OK" in response + + # 根据响应结果更新状态 + if success: + self.status_changed.emit("PCM audio unregistered successfully") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销成功") + else: + self.status_changed.emit("PCM audio unregistration sent") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销状态未知") + + # 无论命令是否成功,都发送停止信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频停止信号") + self.pcm_audio_status.emit(False) + + return True + + except Exception as e: + self.status_changed.emit(f"PCM audio unregistration error: {str(e)}") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销错误: {str(e)}") + + # 出错时也发送停止信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 注销出错,但仍发送停止信号") + self.pcm_audio_status.emit(False) + return False + + def _ensure_pcm_audio_unregistered(self): + """确保PCM音频被取消注册""" + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 确保PCM音频已注销") + + # 首先确保通话状态正确 + self.in_call = False # 强制设置为非通话状态,确保在所有情况下状态一致 + + # 直接取消注册PCM音频 + result = self._unregister_pcm_audio() + if result: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销成功完成") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销可能未完成,但已发送停止信号") + + # 返回实际的操作结果,以便调用者可以适当处理 + return result + + def make_call(self, number): + """Make a phone call (MO - Mobile Originated call)""" + if not self.connected: + return False + + # 如果已经在通话中,先结束当前通话 + if self.in_call: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 已在通话中,先结束当前通话") + self.end_call() + + # 使用循环检查通话状态,而不是固定等待时间 + wait_start = time.time() + while self.in_call and time.time() - wait_start < 3.0: # 最多等待3秒 + time.sleep(0.1) + + if self.in_call: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 无法结束先前通话,放弃拨号") + self.status_changed.emit("Failed to end previous call") + return False + + # 确保PCM音频已经关闭 + self._ensure_pcm_audio_unregistered() + + # 设置模块为语音模式 + try: + self.send_at_command("AT+FCLASS=8") # 设置为语音模式,确保正确处理语音呼叫 + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 设置语音模式出错: {str(e)}") + + # 发起拨号命令 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发起拨打电话到 {number}") + response = self.send_at_command(f"ATD{number};") + + if "OK" in response: + self.call_number = number + self.status_changed.emit(f"Calling {number}") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 正在拨打 {number}") + + # 注意:设置in_call=True应该在收到VOICE CALL: BEGIN之后 + # 这里只记录目标号码,不立即设置呼叫状态 + # 在收到VOICE CALL: BEGIN事件后,会自动设置in_call=True并注册PCM音频 + + return True + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 拨打电话失败: {response}") + self.status_changed.emit(f"Failed to call {number}") + return False + + def answer_call(self): + """接听来电""" + try: + # 首先检查是否真的有来电 + calls = self.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: + self.status_changed.emit("当前没有待接听的来电") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 尝试接听来电失败:当前无待接听来电") + return False + + # 停止所有铃声 + self._stop_all_ringtones() + time.sleep(0.03) # 30毫秒延迟 + + # 确保PCM音频已注册 + self._ensure_pcm_audio_registered() + time.sleep(0.03) # 30毫秒延迟 + + # 发送接听命令 + response = self.send_at_command("ATA") + + if "OK" in response or "CONNECT" in response: + # 标记已接通 + self.call_connected = True + self.in_call = True + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话已接通") + return True + else: + # 即使命令返回失败,仍检查通话是否已建立(有时模块会接通但返回错误) + time.sleep(0.5) # 等待一小段时间让通话建立 + + # 再次检查通话状态,确认是否已接通 + calls_after = self.get_call_status() + call_established = False + + for call in calls_after: + if call.get('stat') in [0, 1] and call.get('dir') == 1: # 活动或保持状态的呼入通话 + call_established = True + self.in_call = True + self.call_connected = True + break + + if call_established: + self.status_changed.emit("通话已接通") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话接听成功") + return True + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 接听失败: {response}") + return False + + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 接听电话时出错: {str(e)}") + return False + + def end_call(self): + """结束当前通话,根据通话状态使用不同的挂断命令""" + if not self.connected: + return False + + # 获取当前通话状态 + calls = self.get_call_status() + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 当前通话状态: {calls}") + + # 根据通话状态选择合适的挂断命令 + if not calls: + # 没有活动通话,但为安全起见仍发送挂断命令 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 无活动通话,但仍发送挂断命令") + response = self.send_at_command("ATH") + + # 检查响应中是否包含OK + if "OK" in response: + self.in_call = False + self.call_connected = False + self.status_changed.emit("通话结束") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话已结束") + + # 通话结束后,立即取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + return True + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 挂断通话失败,响应: {response}") + return False + else: + # 检查第一个通话的状态 + call = calls[0] + stat = call.get('stat', -1) + + if stat == 4: # 来电中(MT) + # 来电振铃状态,使用 AT+CHUP 命令挂断 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 使用AT+CHUP挂断未接通的来电") + response = self.send_at_command("AT+CHUP") + + # 对于AT+CHUP命令,检查特殊成功标志 + if "OK" in response or "NO CARRIER" in response or "VOICE CALL: END" in response: + self.in_call = False + self.call_connected = False + self.status_changed.emit("来电已拒绝") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 来电已拒绝") + + # 通话结束后,立即取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + return True + + # 检查响应中是否包含+CLCC行,且状态为6(终止) + # 如: +CLCC: 1,1,6,0,0,"18571797477",129,"" + if "+CLCC:" in response and ",6," in response: + self.in_call = False + self.call_connected = False + self.status_changed.emit("来电已拒绝") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通过CLCC状态确认来电已拒绝") + + # 通话结束后,立即取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + return True + + # 发送CHUP后立即检查通话状态 + time.sleep(0.5) # 短暂延迟,等待模块处理 + after_calls = self.get_call_status() + if not after_calls: + self.in_call = False + self.call_connected = False + self.status_changed.emit("来电已拒绝") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 二次确认来电已拒绝") + + # 通话结束后,立即取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + return True + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 拒绝来电失败,响应: {response}") + return False + else: + # 其他状态使用 ATH 命令挂断 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 使用ATH挂断通话,状态: {self.call_states.get(stat, '未知')}") + response = self.send_at_command("ATH") + + # 检查响应中是否包含OK或其他成功标志 + if "OK" in response or "NO CARRIER" in response or "VOICE CALL: END" in response: + self.in_call = False + self.call_connected = False + self.status_changed.emit("通话结束") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话已结束") + + # 通话结束后,立即取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + return True + + # 发送ATH后立即检查通话状态 + time.sleep(0.5) # 短暂延迟,等待模块处理 + after_calls = self.get_call_status() + if not after_calls: + self.in_call = False + self.call_connected = False + self.status_changed.emit("通话结束") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 二次确认通话已结束") + + # 通话结束后,立即取消PCM音频注册 + self._ensure_pcm_audio_unregistered() + return True + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 挂断通话失败,响应: {response}") + return False + + def send_sms(self, number, message): + """Send SMS message""" + if not self.connected: + return False + + # Format the phone number + formatted_number = format_phone_number(number) + + # Clear any pending responses + while not self.response_queue.empty(): + self.response_queue.get() + + # Set text mode and wait for OK response + response = self.send_at_command("AT+CMGF=1") + if "OK" not in response: + self.status_changed.emit("Failed to set SMS text mode") + return False + + # Add debug message + self.status_changed.emit(f"Sending SMS to {formatted_number}") + + try: + # Check if message contains Chinese characters + if is_chinese_text(message): + # Set character set to UCS2 for Unicode support + response = self.send_at_command('AT+CSCS="UCS2"') + if "OK" not in response: + self.status_changed.emit("Failed to set UCS2 character set") + return False + + # Convert message to UCS2 hex string + hex_message = text_to_ucs2(message) + if not hex_message: + self.status_changed.emit("Failed to encode message") + return False + + # Convert phone number to UCS2 format + hex_number = text_to_ucs2(formatted_number) + if not hex_number: + self.status_changed.emit("Failed to encode phone number") + return False + + # Send message command with UCS2 encoded phone number + cmd = f'AT+CMGS="{hex_number}"' + self.at_serial.write((cmd + '\r').encode()) + time.sleep(0.5) # Wait for > prompt + + # Send message content and Ctrl+Z to end + self.at_serial.write(hex_message.encode() + b'\x1A') + self.status_changed.emit("Sending UCS2 encoded message...") + else: + # Set character set to GSM for ASCII support + response = self.send_at_command('AT+CSCS="GSM"') + if "OK" not in response: + self.status_changed.emit("Failed to set GSM character set") + return False + + # Send message command + cmd = f'AT+CMGS="{formatted_number}"' + self.at_serial.write((cmd + '\r').encode()) + time.sleep(0.5) # Wait for > prompt + + # Send message content and Ctrl+Z to end + self.at_serial.write(message.encode() + b'\x1A') + self.status_changed.emit("Sending ASCII message...") + + # Wait for response with longer timeout + start_time = time.time() + response = [] + + while time.time() - start_time < 15.0: # Increased timeout to 15 seconds + try: + line = self.response_queue.get(timeout=0.5) + response.append(line) + self.status_changed.emit(f"SMS response: {line}") + + if "+CMGS:" in line: + self.status_changed.emit(f"SMS sent to {formatted_number}") + return True + elif "ERROR" in line or "+CMS ERROR:" in line: + self.status_changed.emit(f"SMS error: {line}") + return False + except queue.Empty: + continue + + # If we get here, we timed out waiting for a response + self.status_changed.emit(f"SMS send timeout. Last response: {response[-1] if response else 'None'}") + return False + + except Exception as e: + self.status_changed.emit(f"SMS send exception: {str(e)}") + return False + + def delete_sms(self, index=None, delete_type=None): + """Delete SMS messages + + delete_type: + 0 - Delete message at index + 1 - Delete all read messages + 2 - Delete all read and sent messages + 3 - Delete all read, sent and unsent messages + 4 - Delete all messages + """ + if not self.connected: + return False + + if index is not None and delete_type is not None: + command = f"AT+CMGD={index},{delete_type}" + elif index is not None: + command = f"AT+CMGD={index}" + elif delete_type is not None: + command = f"AT+CMGD=1,{delete_type}" + else: + return False + + response = self.send_at_command(command) + if "OK" in response: + self.status_changed.emit("SMS deleted") + return True + return False + + def get_sms_list(self, status="ALL"): + """Get list of SMS messages + + status: + "REC UNREAD" - Unread messages + "REC READ" - Read messages + "STO UNSENT" - Stored unsent messages + "STO SENT" - Stored sent messages + "ALL" - All messages + """ + if not self.connected: + return [] + + # Set text mode + self.send_at_command("AT+CMGF=1") + + # Get messages + if status == "ALL": + response = self.send_at_command("AT+CMGL") + else: + response = self.send_at_command(f'AT+CMGL="{status}"') + + if not response or "OK" not in response: + return [] + + messages = [] + lines = response.split('\n') + i = 0 + + while i < len(lines): + line = lines[i] + if line.startswith("+CMGL:"): + # Parse header + header_match = re.search(r'\+CMGL: (\d+),"([^"]*)",' + r'"([^"]*)",[^,]*,"([^"]*)"', line) + if header_match: + index = header_match.group(1) + msg_status = header_match.group(2) + sender = header_match.group(3) + timestamp = header_match.group(4) + + # Get message content + if i + 1 < len(lines): + content = lines[i + 1] + + # Check if PDU or text mode + if any(c for c in content if not (c.isalnum() or c.isspace() or c in '+-,.;:!?')): + # Likely PDU data, decode it + content = self._decode_pdu_message(content) + + messages.append({ + 'index': index, + 'status': msg_status, + 'sender': sender, + 'timestamp': timestamp, + 'content': content + }) + + i += 2 # Skip content line + else: + i += 1 + else: + i += 1 + else: + i += 1 + + return messages + + def get_call_status(self): + """ + 获取当前所有通话状态 + + 返回值为通话列表,每个通话包含: + - id: 通话ID + - dir: 方向(0=MO, 1=MT) + - stat: 状态(0=活动, 1=保持, 2=拨号, 3=振铃, 4=来电, 5=等待) + - mode: 模式(0=语音) + - mpty: 多方通话(0=非多方通话) + - number: 电话号码 + """ + if not self.is_connected(): + return [] + + # 检查是否有缓存且在短时间内(500毫秒内) + current_time = time.time() + if hasattr(self, 'last_call_status_check') and hasattr(self, 'cached_call_status'): + if current_time - self.last_call_status_check < 0.5: # 500毫秒内直接使用缓存结果 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 使用缓存的通话状态 ({int((current_time - self.last_call_status_check) * 1000)}ms)") + return self.cached_call_status + + try: + # 记录本次查询时间 + self.last_call_status_check = current_time + + # 发送AT+CLCC查询通话状态命令 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送AT+CLCC查询通话状态") + response = self.send_at_command("AT+CLCC") + calls = [] + + # 检查响应是否有效 + if not response: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - AT+CLCC无响应") + self.cached_call_status = [] + return [] + + # 检查是否有错误响应 + if "ERROR" in response: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - AT+CLCC返回错误: {response}") + self.cached_call_status = [] + return [] + + # 检查响应中是否只有OK(无通话) + if "+CLCC:" not in response: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 无活动通话") + self.cached_call_status = [] + return [] + + # 解析响应 + lines = response.strip().split('\n') + for line in lines: + line = line.strip() + if not line.startswith('+CLCC:'): + continue + + try: + # +CLCC: ,,,,[,,[,]] + parts = line[7:].strip().split(',') + + # 验证是否有足够的字段 + if len(parts) < 5: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - CLCC响应格式不完整: {line}") + continue + + # 尝试解析通话信息 + call = { + 'id': int(parts[0].strip()), + 'dir': int(parts[1].strip()), + 'stat': int(parts[2].strip()), + 'mode': int(parts[3].strip()), + 'mpty': int(parts[4].strip()) + } + + # 判断是否有电话号码字段 + if len(parts) > 5: + number = parts[5].strip() + if number.startswith('"') and number.endswith('"'): + number = number[1:-1] # 移除引号 + call['number'] = number + + # 记录该通话状态的文本描述(用于日志) + state_text = self.call_states.get(call['stat'], "未知状态") + direction = "呼出" if call['dir'] == 0 else "呼入" + number_info = f", 号码: {call.get('number', '未知')}" if 'number' in call else "" + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到{direction}通话: {state_text}{number_info}") + + calls.append(call) + except Exception as parse_error: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 解析CLCC响应行错误: {str(parse_error)}, 行: {line}") + continue + + # 保存缓存结果 + self.cached_call_status = calls + + # 输出通话状态摘要 + if calls: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 当前有 {len(calls)} 个活动通话") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 没有活动通话") + + # 通话状态变化时的特殊处理 + if calls and not self.in_call: + # 之前不在通话,现在有通话 - 进入通话状态 + self.in_call = True + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到新通话,共 {len(calls)} 个通话") + + # 获取最高优先级的通话状态 + highest_priority_call = None + for call in calls: + # 优先级: 活动 > 来电 > 拨号 > 其他 + if highest_priority_call is None or call['stat'] < highest_priority_call['stat']: + highest_priority_call = call + + # 记录主叫号码或被叫号码 + if highest_priority_call and 'number' in highest_priority_call: + self.call_number = highest_priority_call['number'] + else: + self.call_number = "" + + # 如果是状态为0的通话(活动通话),则更新通话已接通标志和时间 + if highest_priority_call and highest_priority_call['stat'] == 0: + self.call_connected = True + self.call_connect_time = time.time() + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话已接通,记录开始时间") + + elif not calls and self.in_call: + # 之前在通话,现在没有通话 - 退出通话状态 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 所有通话已结束") + self.in_call = False + + # 备份通话状态,然后清除 + was_connected = self.call_connected + self.call_connected = False + + # 如果有记录通话号码,生成结束通知 + if self.call_number: + duration = "Missed" # 默认为未接 + + # 根据通话是否曾经接通决定如何计算时长 + if was_connected: + # 计算通话时长(秒) + if hasattr(self, 'call_connect_time'): + call_duration = round(time.time() - self.call_connect_time) + duration = str(call_duration) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话结束,持续时间: {call_duration}秒") + + # 发出通话结束信号 + self.call_ended.emit(duration) + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话结束,号码: {self.call_number},持续时间: {duration}") + + # 清除通话号码记录 + self.call_number = "" + + # 清除连接时间记录 + if hasattr(self, 'call_connect_time'): + del self.call_connect_time + + return calls + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 获取通话状态出错: {str(e)}") + # 出错时返回空列表,并缓存空列表 + self.cached_call_status = [] + return [] + + def get_call_state_text(self): + """ + 获取当前通话状态的文本描述 + """ + calls = self.get_call_status() + if not calls: + return "无通话" + + # 获取第一个通话的状态描述 + call = calls[0] + stat = call.get('stat', -1) + state_text = self.call_states.get(stat, "未知状态") + + # 添加方向信息 + direction = "呼出" if call.get('dir', 0) == 0 else "呼入" + + # 添加号码信息 + number = call.get('number', '') + number_text = f", 号码: {number}" if number else "" + + return f"{direction}通话, {state_text}{number_text}" + + def is_call_connected(self): + """检查通话是否已接通(不仅仅是振铃状态)""" + # 获取最新通话状态 + calls = self.get_call_status() + + # 如果没有通话,则未接通 + if not calls: + return False + + # 检查第一个通话是否处于活动状态(stat=0) + call = calls[0] + return call.get('stat', -1) == 0 + + def _ensure_pcm_audio_registered(self): + """确保PCM音频已注册,用于通话音频处理""" + try: + # 检查当前PCM音频注册状态 + pcm_status = self.send_at_command("AT+CPCMREG?") + time.sleep(0.03) # 30毫秒延迟 + + # 如果PCM没有注册,则进行注册 + if not pcm_status or "+CPCMREG: 1" not in pcm_status: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 注册PCM音频") + reg_response = self.send_at_command("AT+CPCMREG=1") + + if "OK" in reg_response: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册成功") + self.pcm_audio_status.emit(True) + + # 设置PCM音频格式 + time.sleep(0.03) # 30毫秒延迟 + frm_response = self.send_at_command("AT+CPCMFRM=1") + if "OK" in frm_response: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频格式设置成功") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频格式设置失败") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注册失败") + self.pcm_audio_status.emit(False) + else: + # 已经注册,发出信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频已注册") + self.pcm_audio_status.emit(True) + + return True + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 确保PCM音频注册出错: {str(e)}") + self.pcm_audio_status.emit(False) + return False + + def _stop_pcm_audio(self): + """停止PCM音频注册""" + try: + # 检查当前PCM音频注册状态 + pcm_status = self.send_at_command("AT+CPCMREG?") + + # 如果已注册,则注销 + if pcm_status and "+CPCMREG: 1" in pcm_status: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频注销命令") + response = self.send_at_command("AT+CPCMREG=0") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销命令已发送") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销响应: {response}") + + if "OK" in response: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销成功") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频注销状态未知") + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频未注册或读取状态失败") + + # 无论如何,都发送PCM音频已停止信号 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 发送PCM音频停止信号") + self.pcm_audio_status.emit(False) + + return True + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止PCM音频注册出错: {str(e)}") + return False + + def _stop_all_ringtones(self): + """停止所有铃声和声音""" + try: + # 这里不应该发送AT+CLIP=0,那会禁用来电显示 + # 而是通知上层应用停止铃声播放 + self.status_changed.emit("STOP_RINGTONES") + + # 清理任何可能的铃声缓存 + if hasattr(self, 'ringtone_thread') and self.ringtone_thread: + try: + self.ringtone_thread.stop() + self.ringtone_thread = None + except: + pass + + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止铃声信号已发送") + return True + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止铃声出错: {str(e)}") + return False + + def _auto_detect_port(self): + """尝试自动检测LTE模块连接的串口""" + try: + # 获取所有可用串口 + available_ports = [port.device for port in serial.tools.list_ports.comports()] + print(f"检测到可用串口: {available_ports}") + + if not available_ports: + self.status_changed.emit("未检测到任何串口设备") + print("未检测到任何串口设备") + return None + + # 如果只有一个串口,直接返回 + if len(available_ports) == 1: + print(f"只有一个串口可用,直接使用: {available_ports[0]}") + return available_ports[0] + + # 如果有多个串口,尝试连接每个串口并发送AT命令 + print("检测到多个串口,尝试查找LTE模块...") + for port in available_ports: + try: + print(f"尝试在串口 {port} 上查找LTE模块...") + # 尝试打开串口 + test_serial = serial.Serial( + port=port, + baudrate=115200, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1, + write_timeout=1 + ) + + # 清空缓冲区 + test_serial.reset_input_buffer() + test_serial.reset_output_buffer() + + # 发送AT命令并等待响应 + print(f"向 {port} 发送AT命令") + test_serial.write(b'AT\r\n') + time.sleep(0.5) + + # 读取响应 + response = test_serial.read(test_serial.in_waiting or 100).decode('utf-8', errors='replace') + print(f"从 {port} 收到响应: {response}") + + # 关闭测试连接 + test_serial.close() + + # 检查响应是否包含OK + if 'OK' in response: + self.status_changed.emit(f"自动检测到LTE模块连接在 {port}") + print(f"在 {port} 上找到LTE模块") + return port + except Exception as e: + print(f"测试 {port} 时出错: {str(e)}") + try: + # 确保串口已关闭 + if 'test_serial' in locals() and test_serial.is_open: + test_serial.close() + except: + pass + continue + + # 如果没有找到匹配的串口,返回COM6作为默认(如果存在) + if 'COM6' in available_ports: + self.status_changed.emit(f"未能确定LTE模块连接的串口,尝试使用COM6") + print(f"未能确定LTE模块连接的串口,尝试使用COM6") + return 'COM6' + + # 否则返回第一个可用串口 + self.status_changed.emit(f"未能确定LTE模块连接的串口,使用第一个可用串口 {available_ports[0]}") + print(f"未能确定LTE模块连接的串口,使用第一个可用串口 {available_ports[0]}") + return available_ports[0] + except Exception as e: + self.status_changed.emit(f"串口自动检测出错: {str(e)}") + return None + + def _configure_module(self): + """配置LTE模块的初始设置""" + try: + # 日志记录配置开始 + self.status_changed.emit("初始化LTE模块") + + # 禁用回显 - 可选,取决于模块和应用需求 + # self.send_at_command("ATE0") + + # 设置SMS文本模式 + self.send_at_command("AT+CMGF=1") + + # 设置SMS字符集 + self.send_at_command('AT+CSCS="UCS2"') + + # 设置来电显示 + self.send_at_command("AT+CLIP=1") + + # 设置新消息指示 + self.send_at_command('AT+CNMI=2,2,0,0,0') + + # 查询是否有PCM音频注册 + pcm_status = self.send_at_command("AT+ECPCMREG?") + if "+ECPCMREG: 1" in pcm_status: + # PCM已注册,先取消注册 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频已注册,取消注册") + self._unregister_pcm_audio() + else: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - PCM音频未注册或读取状态失败") + # 确保PCM音频处于未注册状态 + self.pcm_audio_status.emit(False) + + # 获取基本信息 + self._update_device_info() + + self.status_changed.emit("LTE模块初始化完成") + return True + except Exception as e: + self.status_changed.emit(f"LTE模块配置失败: {str(e)}") + return False + + def _update_device_info(self): + """获取设备基本信息""" + try: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 获取设备基本信息") + + # 获取IMEI + response = self.send_at_command("AT+GSN") + if response and "ERROR" not in response: + self.imei = response.strip().split("\n")[0] + + # 获取制造商 + response = self.send_at_command("AT+GMI") + if response and "ERROR" not in response: + self.manufacturer = response.strip().split("\n")[0] + + # 获取型号 + response = self.send_at_command("AT+GMM") + if response and "ERROR" not in response: + self.model = response.strip().split("\n")[0] + + # 获取固件版本 + response = self.send_at_command("AT+GMR") + if response and "ERROR" not in response: + self.firmware = response.strip().split("\n")[0] + + # 获取电话号码 + response = self.send_at_command("AT+CNUM") + if response and "+CNUM:" in response: + match = re.search(r'\+CNUM: "([^"]*)",("?[^"]*"?),(\d+)', response) + if match: + self.phone_number = match.group(2).strip('"') + print(f"电话号码: {self.phone_number}") + + # 获取运营商信息 + response = self.send_at_command("AT+COPS?") + if response and "+COPS:" in response: + match = re.search(r'\+COPS: (\d+),(\d+),"([^"]*)"', response) + if match: + self.carrier = match.group(3) + print(f"运营商: {self.carrier}") + + # 检查网络类型值,可能是第4个项目 + if len(match.groups()) >= 4: + # 值映射: 0=GSM, 2=UTRAN, 7=LTE, 13=NR + net_type_map = { + '0': '2G (GSM)', + '2': '3G (UMTS)', + '7': '4G (LTE)', + '13': '5G (NR)' + } + net_type = match.group(4) if len(match.groups()) >= 4 else '7' # 默认LTE + self.network_type = net_type_map.get(net_type, f'Unknown ({net_type})') + else: + # 从response中提取网络类型 + self._update_network_type() + else: + # 如果COPS查询失败,尝试单独更新网络类型 + self._update_network_type() + + # 获取信号强度 + response = self.send_at_command("AT+CSQ") + if response and "+CSQ:" in response: + match = re.search(r'\+CSQ: (\d+),(\d+)', response) + if match: + rssi = int(match.group(1)) + # 转换RSSI为信号格数和dBm值 + if rssi == 99: + self.signal_strength = "无信号" + else: + # RSSI值0-31,对应-113dBm到-51dBm + dbm = -113 + (2 * rssi) + # 信号格数(0-4格) + if rssi >= 16: # >= -81dBm + bars = 4 + elif rssi >= 12: # >= -89dBm + bars = 3 + elif rssi >= 8: # >= -97dBm + bars = 2 + elif rssi >= 4: # >= -105dBm + bars = 1 + else: + bars = 0 + + self.signal_strength = f"{bars}格 ({dbm}dBm)" + print(f"信号强度: {self.signal_strength}") + + # 发送初始化完成的信号 + self.status_changed.emit(f"设备信息已更新: {self.model} {self.carrier}") + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 设备基本信息获取完成") + return True + except Exception as e: + self.status_changed.emit(f"获取设备信息失败: {str(e)}") + return False + + def _update_network_type(self): + """单独更新网络类型信息""" + try: + # 尝试使用AT+CEREG?命令获取LTE网络注册状态 + cereg_response = self.send_at_command("AT+CEREG?") + if "CEREG: " in cereg_response: + # 检查是否有网络注册 + match = re.search(r'\+CEREG: \d+,(\d+)', cereg_response) + if match and match.group(1) in ['1', '5']: # 1=已注册,本地网络; 5=已注册,漫游 + self.network_type = "4G (LTE)" + return + + # 尝试使用AT+CREG?命令获取GSM/UMTS网络注册状态 + creg_response = self.send_at_command("AT+CREG?") + if "CREG: " in creg_response: + match = re.search(r'\+CREG: \d+,(\d+)', creg_response) + if match and match.group(1) in ['1', '5']: + # 进一步检查是2G还是3G + cgreg_response = self.send_at_command("AT+CGREG?") + if "CGREG: " in cgreg_response and re.search(r'\+CGREG: \d+,[15]', cgreg_response): + self.network_type = "3G (UMTS)" + else: + self.network_type = "2G (GSM)" + return + + # 如果以上都失败,使用默认值 + self.network_type = "未知" + except Exception as e: + print(f"更新网络类型失败: {str(e)}") + self.network_type = "更新失败" \ No newline at end of file diff --git a/lte_settings.json b/lte_settings.json new file mode 100644 index 0000000..d1975a9 --- /dev/null +++ b/lte_settings.json @@ -0,0 +1,14 @@ +{ + "at_port": "COM9", + "at_baudrate": "115200", + "nmea_port": "None", + "nmea_baudrate": "9600", + "auto_connect": true, + "audio_enabled": true, + "ringtone_file": "C:/Users/Ron21/.LTE/incommingcall.mp3", + "recording_path": "", + "auto_record_calls": true, + "auto_play_after_call": false, + "auto_play_on_answer": false, + "answer_play_audio_file": "" +} \ No newline at end of file diff --git a/ltetool.png b/ltetool.png new file mode 100644 index 0000000..319c622 Binary files /dev/null and b/ltetool.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..28273b2 --- /dev/null +++ b/main.py @@ -0,0 +1,1033 @@ +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 +from datetime import datetime + +# 使用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, IncomingCallDialog +from audio_features import AudioFeatures + +class LTEToolApp(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("LTE Tool") + self.resize(800, 600) + + # 添加应用退出标志,用于区分最小化到托盘和退出程序 + self.is_exiting = False + + # 添加来电对话框标志,防止重复显示来电界面 + self.incoming_call_dialog_visible = False + self.current_incoming_call_number = 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.audio_features = AudioFeatures(self.lte_manager) + self.audio_features.status_changed.connect(self.on_status_changed) + + # 创建主窗口部件和布局 + 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, self.audio_features) + + # 创建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('https://github.com/R0nY3n/LTE_manager') + 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.carrier_label = QLabel("运营商: 未连接") + self.phone_number_label = QLabel("电话: 不可用") + self.network_label = QLabel("网络: 未连接") + self.signal_label = QLabel("信号: 不可用") + self.audio_status_label = QLabel("音频: 未初始化") + self.call_status_label = QLabel("通话: 无通话") # 添加通话状态标签 + + # 添加部件到状态栏 + self.statusBar().addWidget(self.carrier_label) + self.statusBar().addWidget(self.phone_number_label) + self.statusBar().addWidget(self.network_label) + self.statusBar().addWidget(self.signal_label) + 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(5000) # 每5秒更新一次 + + # 通话状态检查计时器 + self.call_status_timer = QTimer() + self.call_status_timer.timeout.connect(self.check_call_status) + # 不自动启动,只在需要时才启动 + # self.call_status_timer.start(30000) # 每秒检查一次通话状态 + + # 添加标志控制通话状态检查 + self.call_check_enabled = False # 默认不检查通话状态 + self.call_check_counter = 0 # 计数器,用于限制检查次数 + self.max_call_checks = 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) + + # 记录模块信息初始化状态 + self.module_info_initialized = False + # 初始化更新计数器 + self.update_counter = 1 + + # 初始化PCM音频处理状态 + self.pcm_audio_registered = False + + # 初始化标志 + self.incoming_call_dialog = None + self.should_check_call_status = False + self.call_check_count = 0 + + 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, "incoming", 0, "进行中") + + # 立即显示来电对话框 - 不再使用QTimer延迟 + self._show_incoming_call_dialog(caller_number) + + # 启用通话状态检查 + self.call_check_enabled = True + self.call_check_counter = 0 + if not self.call_status_timer.isActive(): + self.call_status_timer.start(2000) # 每2秒检查一次 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 启动通话状态检查定时器 (来电触发)") + 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): + """ + 响应接听来电操作 + + 参数: + - phone_number: 来电电话号码 + - caller_name: 来电联系人名称(如果有) + """ + + try: + # 停止所有铃声 + self._ensure_ringtone_stopped() + + # 保存当前呼叫的号码 + self.lte_manager.call_number = phone_number + + # 检查音频功能是否可用 + has_audio_features = (hasattr(self, 'audio_features') and + self.audio_features is not None and + self.lte_manager.is_connected()) + + # 尝试接听来电 + result = self.lte_manager.answer_call() + + if result: + # 更新通话记录 + current_time = datetime.now() + call_time_str = current_time.strftime("%Y-%m-%d %H:%M:%S") + call_id = self.database.add_call_log( + phone_number, "incoming", call_time_str, caller_name or "", 0 + ) + self.current_call_id = call_id + + self.add_status_message(f"已接听来自 {phone_number} 的来电") + + # 保存当前通话状态 + self.current_call_timer.start(1000) # 启动通话计时器 + self.call_start_time = current_time + + # 如果有音频功能,尝试播放自动接听音频 + if has_audio_features: + # 先尝试自动播放接听音频 + if self.audio_features.auto_play_on_answer: + self.add_status_message("尝试自动播放接听音频...") + play_result = self.audio_features.play_on_answer(phone_number) + if play_result: + self.add_status_message("自动播放接听音频成功") + else: + self.add_status_message("自动播放接听音频失败") + + # 再处理自动录音功能 + if self.audio_features.auto_record_calls: + self.add_status_message("开始自动录制通话...") + record_result, recording_file = self.audio_features.start_call_recording(phone_number) + if record_result: + self.add_status_message(f"自动录音已开始: {os.path.basename(recording_file)}") + else: + self.add_status_message("自动录音启动失败") + else: + self.add_status_message(f"接听来自 {phone_number} 的来电失败") + + # 尝试停止来电铃声(以防接听失败但铃声仍在继续) + self._ensure_ringtone_stopped() + + # 关闭来电对话框 + if hasattr(self, 'incoming_call_dialog') and self.incoming_call_dialog: + self.incoming_call_dialog.accept() + self.incoming_call_dialog = None + + # 更新状态标签 + self.update_status_labels() + + # 启动通话状态检查定时器(确保及时发现通话状态变化) + self.check_call_status_timer.start(1000) # 每秒检查一次 + + except Exception as e: + self.add_status_message(f"处理接听来电时发生错误: {str(e)}") + import traceback + traceback.print_exc() + + # 如果有开始录音,尝试停止 + if has_audio_features and self.audio_features.recording: + self.audio_features.stop_recording() + + # 确保来电对话框关闭 + if hasattr(self, 'incoming_call_dialog') and self.incoming_call_dialog: + self.incoming_call_dialog.accept() + self.incoming_call_dialog = None + + def _on_reject_call(self, phone_number, caller_name=None): + """处理拒接来电""" + try: + # 1. 立即停止铃声 + self._ensure_ringtone_stopped() + + # 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() + + # 6. 确保停止任何可能正在进行的录音 + if hasattr(self, 'audio_features') and self.audio_features.recording: + self.audio_features.stop_recording() + + # 启用通话状态检查(短暂检查以确认通话已结束) + self.call_check_enabled = True + self.call_check_counter = 0 + if not self.call_status_timer.isActive(): + self.call_status_timer.start(2000) # 每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() + self._ensure_ringtone_stopped() # 确保在异常情况下也停止铃声 + + # 确保在异常情况下也停止录音 + if hasattr(self, 'audio_features') and self.audio_features.recording: + try: + self.audio_features.stop_recording() + except: + pass + + 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 + + # 检查是否有录音正在进行,如果有则停止 + if hasattr(self, 'audio_features') and self.audio_features.recording: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 通话结束,停止录音") + self.audio_features.stop_recording() + + # 记录通话结束信息 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 接收到通话结束信号,持续时间: {duration}") + + # 使用状态栏显示消息 + 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, + "missed" if duration == "Missed" or duration_seconds == 0 else "incoming", + duration_seconds + ) + print(f"新增通话记录,号码 {self.lte_manager.call_number},持续时间 {duration_seconds}秒") + except Exception as e: + print(f"更新通话记录出错: {str(e)}") + + # 暂时继续检查通话状态,以确认通话确实已结束 + self.call_check_enabled = True + self.call_check_counter = 0 + if not self.call_status_timer.isActive(): + self.call_status_timer.start(2000) # 每2秒检查一次 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 启动通话状态检查定时器 (通话结束触发)") + + def check_call_status(self): + """定期检查通话状态并更新UI(只在需要时执行)""" + if not self.lte_manager.is_connected() or not self.call_check_enabled: + # 如果未连接或不需要检查,停止定时器 + if self.call_status_timer.isActive(): + self.call_status_timer.stop() + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止通话状态检查定时器") + + # 如果之前在录音,确保停止录音 + if hasattr(self, 'audio_features') and self.audio_features.recording: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到通话已结束,停止录音") + self.audio_features.stop_recording() + return + + try: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检查通话状态 (计数: {self.call_check_counter+1}/{self.max_call_checks})") + + # 获取当前通话状态文本 + call_state = self.lte_manager.get_call_state_text() + + # 更新状态栏 + self.call_status_label.setText(f"通话: {call_state}") + + # 根据通话状态更新通话按钮状态 + calls = self.lte_manager.get_call_status() + + # 更新UI以反映当前的通话状态 + self.phone_sms_tab.update_call_ui_state(bool(calls)) + + # 增加计数器 + self.call_check_counter += 1 + + # 如果达到最大检查次数或没有活跃通话,停止检查 + if self.call_check_counter >= self.max_call_checks or not calls: + self.call_check_enabled = False + self.call_status_timer.stop() + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 停止通话状态检查定时器 (已完成{self.call_check_counter}次检查)") + + # 如果没有活跃通话且之前在录音,停止录音 + if not calls and hasattr(self, 'audio_features') and self.audio_features.recording: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检测到通话已结束,停止录音") + self.audio_features.stop_recording() + + except Exception as e: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 检查通话状态出错: {str(e)}") + # 发生错误时也要停止定时器 + self.call_check_enabled = False + self.call_status_timer.stop() + + # 如果出错且之前在录音,确保录音也被停止 + if hasattr(self, 'audio_features') and self.audio_features.recording: + try: + self.audio_features.stop_recording() + except: + pass + + def update_status_bar(self): + """更新状态栏信息""" + is_connected = self.lte_manager.is_connected() + + # 如果连接状态发生变化,记录并触发更新 + if not hasattr(self, 'last_connection_state'): + self.last_connection_state = is_connected + + # 连接状态变化时,强制刷新所有信息 + if self.last_connection_state != is_connected: + self.last_connection_state = is_connected + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 连接状态变化,刷新所有设备信息") + if is_connected: + # 重置模块信息初始化状态,强制获取新信息 + self.module_info_initialized = False + # 重置更新计数器 + self.update_counter = 1 + # 立即强制更新所有状态信息 + self._update_all_status_info() + + # 未连接时,直接使用默认标签 + if not is_connected: + self.update_status_labels() + return + + try: + # 使用update_counter控制不同类型信息的更新频率 + if not hasattr(self, 'update_counter'): + self.update_counter = 1 + + # 每3次更新一次信号强度(约30秒) + if self.update_counter % 3 == 0: + signal_strength = self.lte_manager.get_signal_strength() + if signal_strength: + self.signal_label.setText(f"信号: {signal_strength}") + + # 每60次更新一次运营商信息(约10分钟) + if self.update_counter % 60 == 0: + carrier = self.lte_manager.get_carrier_info() + if carrier: + self.carrier_label.setText(f"运营商: {carrier}") + + network_info = self.lte_manager.get_network_info() + if network_info: + self.network_label.setText(f"网络: {network_info}") + + # 计数器自增 + self.update_counter += 1 + # 每120次重置一次(避免无限增长) + if self.update_counter > 120: + self.update_counter = 1 + + except Exception as e: + print(f"更新状态栏时出错: {str(e)}") + # 出错时仍更新标签(使用缓存值) + self.update_status_labels() + + def _update_all_status_info(self): + """立即更新所有状态信息""" + try: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 立即更新所有状态信息") + + # 获取电话号码 + phone_number = self.lte_manager.get_phone_number() + if phone_number: + self.phone_number_label.setText(f"电话: {phone_number}") + + # 获取运营商信息 + carrier = self.lte_manager.get_carrier_info() + if carrier: + self.carrier_label.setText(f"运营商: {carrier}") + + # 获取网络信息 + network_info = self.lte_manager.get_network_info() + if network_info: + self.network_label.setText(f"网络: {network_info}") + + # 获取信号强度 + signal_strength = self.lte_manager.get_signal_strength() + if signal_strength: + self.signal_label.setText(f"信号: {signal_strength}") + + # 记录更新完成 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - 状态信息更新完成") + except Exception as e: + print(f"更新全部状态信息时出错: {str(e)}") + + def _on_timer_status_update(self): + """状态定时器更新回调""" + try: + # 更新状态栏 + self.update_status_bar() + + # 获取模块信息(仅在首次启动时) + if not hasattr(self, 'module_info_initialized') or not self.module_info_initialized: + if self.lte_manager.is_connected(): + module_info = self.lte_manager.get_module_info() + if module_info.get('imei'): # 如果有IMEI,认为初始化成功 + self.module_info_initialized = True + # 显式更新所有状态信息 + self._update_all_status_info() + except Exception as e: + print(f"定时状态更新错误: {str(e)}") + + def initialize_timers(self): + """初始化定时器""" + # 状态更新定时器(每10秒更新一次,而不是5秒) + self.status_timer = QTimer() + self.status_timer.timeout.connect(self._on_timer_status_update) + self.status_timer.start(10000) # 10秒更新一次 + + def on_status_changed(self, status): + """处理状态变化事件""" + try: + # 更新状态栏中的消息 + self.statusBar().showMessage(status, 5000) # 显示5秒 + + # 记录日志 + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - {status}") + + # 更新托盘图标中的连接状态 + if "Connected to" in status: + self.update_connection_status(True) + # 模块连接成功后立即更新所有状态信息 + QTimer.singleShot(1000, self._update_all_status_info) + elif "Disconnected" in status: + self.update_connection_status(False) + elif "error" in status.lower() or "失败" in status or "failed" in status.lower(): + # 检测到错误状态 + self.show_error_status(status) + + # 当LTE模块初始化完成时,更新所有状态信息 + if "LTE模块初始化完成" in status: + QTimer.singleShot(500, self._update_all_status_info) + 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(): + self.lte_manager.disconnect() + + # 移除托盘图标 + if self.tray_icon.isVisible(): + self.tray_icon.hide() + + # 接受关闭事件 + event.accept() + + def _exit_application(self): + """完全退出应用程序""" + self.is_exiting = True + self.close() + + 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)}") + + def update_status_labels(self): + """更新状态栏标签内容(使用缓存或默认值)""" + is_connected = self.lte_manager.is_connected() + self.update_connection_status(is_connected) + + if is_connected: + # 尝试使用缓存值,如果没有则使用默认值 + carrier = getattr(self.lte_manager, 'carrier', 'Unknown') + phone = getattr(self.lte_manager, 'phone_number', 'Unknown') + network = getattr(self.lte_manager, 'network_type', 'Unknown') + signal = getattr(self.lte_manager, 'signal_strength', 'Unknown') + + # 更新界面标签 + self.carrier_label.setText(f"运营商: {carrier}") + self.phone_number_label.setText(f"电话: {phone}") + self.network_label.setText(f"网络: {network}") + self.signal_label.setText(f"信号: {signal}") + else: + # 如果未连接,显示默认值 + self.carrier_label.setText("运营商: 未连接") + self.phone_number_label.setText("电话: 不可用") + self.network_label.setText("网络: 未连接") + self.signal_label.setText("信号: 不可用") + self.call_status_label.setText("通话: 无通话") + +if __name__ == "__main__": + app = QApplication(sys.argv) + # 不再设置QuitOnLastWindowClosed为False,让应用在窗口关闭时可以正常退出 + # app.setQuitOnLastWindowClosed(False) + window = LTEToolApp() + window.show() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/phone_done.png b/phone_done.png new file mode 100644 index 0000000..cca5972 Binary files /dev/null and b/phone_done.png differ diff --git a/phone_sms_tab.py b/phone_sms_tab.py new file mode 100644 index 0000000..c25b39a --- /dev/null +++ b/phone_sms_tab.py @@ -0,0 +1,791 @@ +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): + """Make phone call""" + number = self.phone_number_input.text().strip() + if not number: + QMessageBox.warning(self, "输入错误", "请输入电话号码") + return + + if self.lte_manager.make_call(number): + self.call_button.setEnabled(False) + self.answer_button.setEnabled(False) + self.hangup_button.setEnabled(True) + self.add_to_call_log(f"正在拨打 {number}") + + # 更新通话状态 + self.call_status_display.setText(f"通话状态: 呼出通话, 拨号中, 号码: {number}") + self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #E1BEE7; color: #6A1B9A; border-radius: 3px;") + + # Add to database + self.database.add_call(number, "outgoing") + else: + QMessageBox.warning(self, "通话错误", "拨打电话失败") + self.sound_manager.play_error() + + def on_answer_button_clicked(self): + """处理接听按钮点击""" + # 获取通话状态,确认有来电 + calls = self.lte_manager.get_call_status() + has_incoming_call = False + caller_number = "" + + for call in calls: + if call.get('stat') == 4 and call.get('dir') == 1: # 来电中(MT) + has_incoming_call = True + caller_number = call.get('number', self.lte_manager.call_number) + break + + if not has_incoming_call: + QMessageBox.warning(self, "通话错误", "当前没有待接听的来电") + self.sound_manager.play_error() + return + + # 停止所有铃声 + self._stop_all_ringtones() + + # 尝试接听 + answer_result = self.lte_manager.answer_call() + + # 再次检查通话状态,确认是否实际接通(即使API返回失败) + time.sleep(0.5) # 给模块一点时间更新状态 + calls_after = self.lte_manager.get_call_status() + call_established = False + + for call in calls_after: + if call.get('stat') in [0, 1] and call.get('dir') == 1: # 活动或保持的呼入通话 + call_established = True + break + + if answer_result or call_established: + self.call_button.setEnabled(False) + self.answer_button.setEnabled(False) + self.hangup_button.setEnabled(True) + self.add_to_call_log(f"已接听来电: {caller_number}") + + # 更新通话状态 + self.call_status_display.setText(f"通话状态: 呼入通话, 已接通, 号码: {caller_number}") + self.call_status_display.setStyleSheet("font-size: 14px; font-weight: bold; padding: 5px; background-color: #C8E6C9; color: #2E7D32; border-radius: 3px;") + + # 不需要重复添加数据库记录,main.py已经在显示来电对话框时添加 + else: + QMessageBox.warning(self, "通话错误", "接听来电失败") + self.sound_manager.play_error() + + def on_hangup_button_clicked(self): + """处理挂断按钮点击""" + if self.lte_manager.end_call(): + self.call_button.setEnabled(True) + self.answer_button.setEnabled(False) + self.hangup_button.setEnabled(False) + self.add_to_call_log("通话结束") + + # 更新通话状态 + 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.sound_manager.play_call_end() + else: + QMessageBox.warning(self, "通话错误", "挂断电话失败") + self.sound_manager.play_error() + + 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() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..00a48a6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyQt5==5.15.9 +pyserial==3.5 +sounddevice==0.4.6 +numpy==1.26.3 +pyinstaller==6.0.0 +# SQLite is included in Python standard library \ No newline at end of file diff --git a/running.png b/running.png new file mode 100644 index 0000000..b9f51f4 Binary files /dev/null and b/running.png differ diff --git a/settings_tab.py b/settings_tab.py new file mode 100644 index 0000000..7470949 --- /dev/null +++ b/settings_tab.py @@ -0,0 +1,723 @@ +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QLineEdit, QTextEdit, QGroupBox, QFormLayout, QComboBox, + QGridLayout, QMessageBox, QSpinBox, QCheckBox, QFileDialog) +from PyQt5.QtCore import Qt, pyqtSlot +import serial.tools.list_ports +import os +import json + +class SettingsTab(QWidget): + def __init__(self, lte_manager, audio_features=None): + super().__init__() + self.lte_manager = lte_manager + self.audio_features = audio_features + + # Settings file path + self.settings_file = "lte_settings.json" + + # Default settings + self.settings = { + "at_port": "", + "at_baudrate": "115200", + "nmea_port": "None", + "nmea_baudrate": "9600", + "auto_connect": False, + "audio_enabled": True, + "ringtone_file": "", + "recording_path": "", + "auto_record_calls": True, # 自动录制通话 + "auto_play_after_call": False, # 通话结束后自动播放录音 + "auto_play_on_answer": False, # 接听电话时自动播放声音 + "answer_play_audio_file": "", # 接听时播放的音频文件 + } + + # 从文件加载设置 + try: + if os.path.exists(self.settings_file): + with open(self.settings_file, 'r') as f: + saved_settings = json.load(f) + for key, value in saved_settings.items(): + self.settings[key] = value + except Exception as e: + print(f"加载设置失败: {str(e)}") + + # Connect signals + self.lte_manager.status_changed.connect(self.on_status_changed) + if self.audio_features: + self.audio_features.status_changed.connect(self.on_status_changed) + + # Create UI components first + self._create_ui_components() + + # Then setup the UI layout + self.setup_ui() + + # Finally refresh the ports + self.refresh_ports() + + # 应用设置到UI + self._apply_settings_to_ui() + + # 如果初始化时有音频特性模块,立即应用相关设置 + if self.audio_features: + # 设置录音路径 + if self.settings.get("recording_path") and os.path.exists(self.settings.get("recording_path")): + self.audio_features.set_storage_path(self.settings.get("recording_path")) + + # 设置自动录音选项 + self.audio_features.set_auto_record_calls(self.settings.get("auto_record_calls", True)) + + # 设置自动播放选项 + self.audio_features.set_auto_play_after_call(self.settings.get("auto_play_after_call", False)) + + def _apply_settings_to_ui(self): + """将加载的设置应用到UI组件""" + # 加载设置 + self.auto_record_cb.setChecked(self.settings.get('auto_record_calls', True)) + self.auto_play_cb.setChecked(self.settings.get('auto_play_after_call', False)) + self.auto_play_on_answer_cb.setChecked(self.settings.get('auto_play_on_answer', False)) + + recording_path = self.settings.get('recording_path', '') + self.recording_path_edit.setText(recording_path) + + answer_play_file = self.settings.get('answer_play_audio_file', '') + self.answer_play_edit.setText(answer_play_file) + + # 如果音频特性实例存在,应用设置 + if self.audio_features: + self.audio_features.set_auto_record_calls(self.auto_record_cb.isChecked()) + self.audio_features.set_auto_play_after_call(self.auto_play_cb.isChecked()) + + answer_audio_file = self.answer_play_edit.text() + if answer_audio_file and os.path.exists(answer_audio_file): + self.audio_features.set_auto_play_on_answer( + self.auto_play_on_answer_cb.isChecked(), + answer_audio_file + ) + + def _create_ui_components(self): + """创建所有UI组件""" + # 创建串口选择下拉框 + self.at_port_combo = QComboBox() + self.at_port_combo.setMinimumWidth(120) + + self.at_baud_combo = QComboBox() + self.at_baud_combo.setMinimumWidth(100) + self.at_baud_combo.addItems(['115200', '9600', '38400', '57600']) + + self.nmea_port_combo = QComboBox() + self.nmea_port_combo.setMinimumWidth(120) + self.nmea_port_combo.addItem("None") + + self.nmea_baud_combo = QComboBox() + self.nmea_baud_combo.setMinimumWidth(100) + self.nmea_baud_combo.addItems(['9600', '38400', '57600', '115200']) + + # 创建按钮 + self.connect_btn = QPushButton("连接") + self.connect_btn.clicked.connect(self.toggle_connection) + + # 创建AT命令输入框和发送按钮 + self.at_command_input = QLineEdit() + self.at_command_input.setPlaceholderText("输入AT命令") + self.send_btn = QPushButton("发送") + self.send_btn.clicked.connect(self.send_at_command) + + # 创建文本显示区域 + self.at_response_text = QTextEdit() + self.at_response_text.setMaximumHeight(150) + self.at_response_text.setReadOnly(True) + + self.status_text = QTextEdit() + self.status_text.setMinimumHeight(200) + self.status_text.setReadOnly(True) + + # 创建复选框 + self.auto_connect_check = QCheckBox("启动时自动连接") + self.auto_connect_check.setChecked(self.settings.get("auto_connect", False)) + + # 音频功能组件 + self.audio_enabled_check = QCheckBox("启用音频功能") + self.audio_enabled_check.setChecked(self.settings.get("audio_enabled", True)) + + # 自动录音选项 + self.auto_record_calls_check = QCheckBox("自动录制通话") + self.auto_record_calls_check.setChecked(self.settings.get("auto_record_calls", True)) + self.auto_record_calls_check.setToolTip("接听电话时自动开始录音,通话结束时自动停止") + self.auto_record_calls_check.stateChanged.connect(self.on_auto_record_changed) + + # 自动播放录音选项 + self.auto_play_after_call_check = QCheckBox("通话结束后自动播放录音") + self.auto_play_after_call_check.setChecked(self.settings.get("auto_play_after_call", False)) + self.auto_play_after_call_check.setToolTip("通话结束后自动播放刚录制的通话录音") + self.auto_play_after_call_check.stateChanged.connect(self.on_auto_play_changed) + + # 录音路径 + self.recording_path_input = QLineEdit() + # 如果没有设置过录音路径,使用音频特性模块的默认路径 + if self.audio_features and not self.settings.get("recording_path"): + self.settings["recording_path"] = self.audio_features.storage_path + self.recording_path_input.setText(self.settings.get("recording_path", "")) + + self.recording_path_btn = QPushButton("浏览...") + self.recording_path_btn.clicked.connect(self.browse_recording_path) + self.recording_path_reset_btn = QPushButton("重置") + self.recording_path_reset_btn.setToolTip("重置为默认路径") + self.recording_path_reset_btn.clicked.connect(self.reset_recording_path) + + # 铃声设置 + self.ringtone_path_input = QLineEdit() + self.ringtone_path_input.setText(self.settings.get("ringtone_file", "")) + self.ringtone_path_btn = QPushButton("浏览...") + self.ringtone_path_btn.clicked.connect(self.browse_ringtone_file) + self.set_ringtone_btn = QPushButton("设置铃声") + self.set_ringtone_btn.clicked.connect(self.set_ringtone) + + # 录音控制 + self.start_recording_btn = QPushButton("开始录音") + self.start_recording_btn.clicked.connect(self.start_recording) + self.stop_recording_btn = QPushButton("停止录音") + self.stop_recording_btn.clicked.connect(self.stop_recording) + + # 录音类型选择 + self.recording_type_combo = QComboBox() + self.recording_type_combo.addItems(["本地麦克风", "通话对方声音", "双方声音混合"]) + + # 播放音频 + self.play_audio_input = QLineEdit() + self.play_audio_input.setPlaceholderText("输入音频文件名或路径") + self.play_audio_btn = QPushButton("播放") + self.play_audio_btn.clicked.connect(self.play_audio) + self.stop_audio_btn = QPushButton("停止") + self.stop_audio_btn.clicked.connect(self.stop_audio) + self.browse_audio_btn = QPushButton("浏览...") + self.browse_audio_btn.clicked.connect(self.browse_audio_file) + + # 播放类型选择 + self.play_type_combo = QComboBox() + self.play_type_combo.addItems(["本地播放", "远程播放(对方听)", "双方都播放"]) + + # 创建音频控制部分 + self.audio_group = QGroupBox("音频控制") + self.audio_layout = QVBoxLayout() + + # 通话录音部分 + self.recording_section = QGroupBox("通话录音") + recording_layout = QVBoxLayout() + + # 自动录制选项 + self.auto_record_cb = QCheckBox("自动录制通话") + self.auto_record_cb.setToolTip("接听电话时自动开始录音,挂断时自动停止") + self.auto_record_cb.stateChanged.connect(self.on_auto_record_changed) + recording_layout.addWidget(self.auto_record_cb) + + # 录音后自动播放选项 + self.auto_play_cb = QCheckBox("录音后自动播放") + self.auto_play_cb.setToolTip("通话结束后自动播放录音") + self.auto_play_cb.stateChanged.connect(self.on_auto_play_changed) + recording_layout.addWidget(self.auto_play_cb) + + # 录音路径选择 + recording_path_layout = QHBoxLayout() + self.recording_path_label = QLabel("录音存储路径:") + self.recording_path_edit = QLineEdit() + self.recording_path_edit.setReadOnly(True) + self.browse_recording_btn = QPushButton("浏览...") + self.browse_recording_btn.clicked.connect(self.browse_recording_path) + self.reset_recording_btn = QPushButton("重置") + self.reset_recording_btn.clicked.connect(self.reset_recording_path) + + recording_path_layout.addWidget(self.recording_path_label) + recording_path_layout.addWidget(self.recording_path_edit) + recording_path_layout.addWidget(self.browse_recording_btn) + recording_path_layout.addWidget(self.reset_recording_btn) + recording_layout.addLayout(recording_path_layout) + + # 录音控制按钮 + recording_control_layout = QHBoxLayout() + self.start_recording_btn = QPushButton("开始录音") + self.start_recording_btn.clicked.connect(self.start_recording) + self.stop_recording_btn = QPushButton("停止录音") + self.stop_recording_btn.clicked.connect(self.stop_recording) + + recording_control_layout.addWidget(self.start_recording_btn) + recording_control_layout.addWidget(self.stop_recording_btn) + recording_layout.addLayout(recording_control_layout) + + self.recording_section.setLayout(recording_layout) + self.audio_layout.addWidget(self.recording_section) + + # 音频播放部分 + self.playback_section = QGroupBox("音频播放") + playback_layout = QVBoxLayout() + + # 自动接听播放选项 + self.auto_play_on_answer_cb = QCheckBox("接听电话时自动播放音频") + self.auto_play_on_answer_cb.setToolTip("接听电话时自动向对方播放指定音频文件") + self.auto_play_on_answer_cb.stateChanged.connect(self.on_auto_play_on_answer_changed) + playback_layout.addWidget(self.auto_play_on_answer_cb) + + # 接听播放音频选择 + answer_play_layout = QHBoxLayout() + self.answer_play_label = QLabel("接听播放音频:") + self.answer_play_edit = QLineEdit() + self.answer_play_edit.setReadOnly(True) + self.browse_answer_play_btn = QPushButton("浏览...") + self.browse_answer_play_btn.clicked.connect(self.browse_answer_play_file) + + answer_play_layout.addWidget(self.answer_play_label) + answer_play_layout.addWidget(self.answer_play_edit) + answer_play_layout.addWidget(self.browse_answer_play_btn) + playback_layout.addLayout(answer_play_layout) + + # 音频文件选择 + audio_file_layout = QHBoxLayout() + self.audio_file_label = QLabel("音频文件:") + self.audio_file_edit = QLineEdit() + self.audio_file_edit.setReadOnly(True) + self.browse_audio_btn = QPushButton("浏览...") + self.browse_audio_btn.clicked.connect(self.browse_audio_file) + + audio_file_layout.addWidget(self.audio_file_label) + audio_file_layout.addWidget(self.audio_file_edit) + audio_file_layout.addWidget(self.browse_audio_btn) + playback_layout.addLayout(audio_file_layout) + + # 播放控制按钮 + playback_control_layout = QHBoxLayout() + self.play_audio_btn = QPushButton("播放音频") + self.play_audio_btn.clicked.connect(self.play_audio) + self.stop_audio_btn = QPushButton("停止播放") + self.stop_audio_btn.clicked.connect(self.stop_audio) + + playback_control_layout.addWidget(self.play_audio_btn) + playback_control_layout.addWidget(self.stop_audio_btn) + playback_layout.addLayout(playback_control_layout) + + self.playback_section.setLayout(playback_layout) + self.audio_layout.addWidget(self.playback_section) + + # 铃声设置部分 + self.ringtone_section = QGroupBox("铃声设置") + ringtone_layout = QVBoxLayout() + + # 铃声文件选择 + ringtone_file_layout = QHBoxLayout() + self.ringtone_file_label = QLabel("铃声文件:") + self.ringtone_file_edit = QLineEdit() + self.ringtone_file_edit.setReadOnly(True) + self.browse_ringtone_btn = QPushButton("浏览...") + self.browse_ringtone_btn.clicked.connect(self.browse_ringtone_file) + + ringtone_file_layout.addWidget(self.ringtone_file_label) + ringtone_file_layout.addWidget(self.ringtone_file_edit) + ringtone_file_layout.addWidget(self.browse_ringtone_btn) + ringtone_layout.addLayout(ringtone_file_layout) + + # 铃声设置按钮 + self.set_ringtone_btn = QPushButton("设置铃声") + self.set_ringtone_btn.clicked.connect(self.set_ringtone) + ringtone_layout.addWidget(self.set_ringtone_btn) + + self.ringtone_section.setLayout(ringtone_layout) + self.audio_layout.addWidget(self.ringtone_section) + + self.audio_group.setLayout(self.audio_layout) + + def setup_ui(self): + """设置UI布局""" + layout = QVBoxLayout(self) + + # 串口设置组 + serial_group = QGroupBox("串口设置") + serial_layout = QGridLayout() + + # 第一行:AT串口和波特率 + serial_layout.addWidget(QLabel("AT串口:"), 0, 0) + serial_layout.addWidget(self.at_port_combo, 0, 1) + + serial_layout.addWidget(QLabel("AT波特率:"), 0, 2) + serial_layout.addWidget(self.at_baud_combo, 0, 3) + + # 第二行:NMEA串口和波特率 + serial_layout.addWidget(QLabel("NMEA串口:"), 1, 0) + serial_layout.addWidget(self.nmea_port_combo, 1, 1) + + serial_layout.addWidget(QLabel("NMEA波特率:"), 1, 2) + serial_layout.addWidget(self.nmea_baud_combo, 1, 3) + + # 第三行:刷新按钮和自动连接选项 + refresh_btn = QPushButton("刷新串口") + refresh_btn.clicked.connect(self.refresh_ports) + serial_layout.addWidget(refresh_btn, 2, 0, 1, 2) + + serial_layout.addWidget(self.auto_connect_check, 2, 2, 1, 2) + + serial_group.setLayout(serial_layout) + layout.addWidget(serial_group) + + # 连接按钮 + layout.addWidget(self.connect_btn) + + # 添加音频控制组 + layout.addWidget(self.audio_group) + + # AT命令区域 + at_group = QGroupBox("AT命令") + at_layout = QVBoxLayout() + + # AT命令输入区域 + at_input_layout = QHBoxLayout() + at_input_layout.addWidget(self.at_command_input) + at_input_layout.addWidget(self.send_btn) + at_layout.addLayout(at_input_layout) + + # AT响应显示区域 + at_layout.addWidget(self.at_response_text) + + at_group.setLayout(at_layout) + layout.addWidget(at_group) + + # 状态信息区域 + status_group = QGroupBox("状态信息") + status_layout = QVBoxLayout() + status_layout.addWidget(self.status_text) + status_group.setLayout(status_layout) + layout.addWidget(status_group) + + def on_auto_record_changed(self, state): + """处理自动录制通话选项变更""" + is_checked = state == Qt.Checked + if self.audio_features: + self.audio_features.set_auto_record_calls(is_checked) + self.settings["auto_record_calls"] = is_checked + self.save_settings() + + def on_auto_play_changed(self, state): + """处理自动播放录音选项变更""" + is_checked = state == Qt.Checked + if self.audio_features: + self.audio_features.set_auto_play_after_call(is_checked) + self.settings["auto_play_after_call"] = is_checked + self.save_settings() + + def reset_recording_path(self): + """重置录音路径为默认值""" + if self.audio_features: + # 重置为音频模块的默认路径 + default_path = self.audio_features.storage_path + self.recording_path_edit.setText(default_path) + self.audio_features.set_storage_path(default_path) + self.settings["recording_path"] = default_path + self.save_settings() + self.add_status_message(f"录音路径已重置为: {default_path}") + + 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 toggle_connection(self): + """切换连接状态""" + if not self.lte_manager.is_connected(): + self.connect() + else: + self.disconnect() + + def connect(self): + """连接到LTE模块""" + at_port = self.at_port_combo.currentText() + at_baud = int(self.at_baud_combo.currentText()) + + # 旧代码保留在注释中,以便后续可能的恢复 + # nmea_port = self.nmea_port_combo.currentText() + # nmea_baud = int(self.nmea_baud_combo.currentText()) + # if nmea_port == "None": + # nmea_port = "" + + # 使用新的连接方法,只传递AT端口和波特率 + if self.lte_manager.connect(port=at_port, baudrate=at_baud): + self.connect_btn.setText("断开") + self.save_settings() + + # 更新音频控件状态 + if self.audio_features: + self.update_audio_controls_state() + + # 如果有设置铃声,自动应用 + if self.settings.get("ringtone_file") and os.path.exists(self.settings.get("ringtone_file")): + self.set_ringtone() + + # 如果有设置录音路径,自动应用 + if self.settings.get("recording_path") and os.path.exists(self.settings.get("recording_path")): + self.audio_features.set_storage_path(self.settings.get("recording_path")) + + # 应用自动录音和自动播放设置 + self.audio_features.set_auto_record_calls(self.auto_record_cb.isChecked()) + self.audio_features.set_auto_play_after_call(self.auto_play_cb.isChecked()) + else: + QMessageBox.warning(self, "连接错误", "无法连接到LTE模块") + + def disconnect(self): + """断开LTE模块连接""" + self.lte_manager.disconnect() + self.connect_btn.setText("连接") + + # 更新音频控件状态 + if self.audio_features: + self.update_audio_controls_state() + + def send_at_command(self): + """发送AT命令""" + if not self.lte_manager.is_connected(): + QMessageBox.warning(self, "错误", "未连接到LTE模块") + return + + command = self.at_command_input.text().strip() + if not command: + return + + self.at_response_text.append(f">>> {command}") + response = self.lte_manager.send_at_command(command) + if response: + self.at_response_text.append(response) + self.at_command_input.clear() + + 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_text.append(message) + self.status_text.ensureCursorVisible() + + def save_settings(self): + """保存设置到文件""" + try: + # 更新设置字典 + self.settings["at_port"] = self.at_port_combo.currentText() + self.settings["at_baudrate"] = self.at_baud_combo.currentText() + self.settings["nmea_port"] = self.nmea_port_combo.currentText() + self.settings["nmea_baudrate"] = self.nmea_baud_combo.currentText() + self.settings["auto_connect"] = self.auto_connect_check.isChecked() + self.settings["recording_path"] = self.recording_path_edit.text() + self.settings["auto_record_calls"] = self.auto_record_cb.isChecked() + self.settings["auto_play_after_call"] = self.auto_play_cb.isChecked() + self.settings["auto_play_on_answer"] = self.auto_play_on_answer_cb.isChecked() + self.settings["answer_play_audio_file"] = self.answer_play_edit.text() + + with open(self.settings_file, 'w') as f: + json.dump(self.settings, f, indent=4) + + print("设置已保存到", self.settings_file) + except Exception as e: + print(f"保存设置失败: {str(e)}") + + def try_auto_connect(self): + """尝试自动连接""" + if self.auto_connect_check.isChecked(): + self.connect() + + # 音频功能相关方法 + def update_audio_controls_state(self): + """更新音频控制按钮的状态""" + if not hasattr(self, 'audio_features') or not self.audio_features: + return + + connected = self.lte_manager.is_connected() + recording = self.audio_features.is_recording() if connected else False + playing = self.audio_features.playing if connected else False + + # 启用/禁用录音控制按钮 + self.start_recording_btn.setEnabled(connected and not recording) + self.stop_recording_btn.setEnabled(connected and recording) + + # 启用/禁用播放控制按钮 + self.play_audio_btn.setEnabled(connected and not playing) + self.stop_audio_btn.setEnabled(connected and playing) + + # 铃声设置按钮 + self.set_ringtone_btn.setEnabled(connected) + + # 自动功能控制 + self.auto_record_cb.setEnabled(connected) + self.auto_play_cb.setEnabled(connected) + self.auto_play_on_answer_cb.setEnabled(connected) + + # 路径设置按钮 + self.browse_recording_btn.setEnabled(True) # 这个不依赖连接状态 + self.reset_recording_btn.setEnabled(True) # 这个不依赖连接状态 + self.browse_audio_btn.setEnabled(connected) + self.browse_ringtone_btn.setEnabled(connected) + self.browse_answer_play_btn.setEnabled(connected) + + def browse_recording_path(self): + """浏览并选择录音存储路径""" + current_path = self.recording_path_edit.text() or os.path.expanduser("~") + + dir_path = QFileDialog.getExistingDirectory( + self, "选择录音存储路径", current_path + ) + + if dir_path: + self.recording_path_edit.setText(dir_path) + if self.audio_features: + self.audio_features.set_storage_path(dir_path) + self.save_settings() + self.add_status_message(f"录音存储路径已设置为: {dir_path}") + + def browse_ringtone_file(self): + """浏览并选择铃声文件""" + home_dir = os.path.expanduser("~") + file_dialog = QFileDialog() + file_path, _ = file_dialog.getOpenFileName( + self, "选择铃声文件", home_dir, + "音频文件 (*.mp3 *.wav *.amr);;所有文件 (*)" + ) + + if file_path: + self.ringtone_file_edit.setText(file_path) + self.save_settings() + + def set_ringtone(self): + """设置铃声""" + if not self.audio_features or not self.lte_manager.is_connected(): + return + + ringtone_file = self.ringtone_file_edit.text() + if not ringtone_file: + QMessageBox.warning(self, "设置铃声", "请先选择铃声文件") + return + + if self.audio_features.set_ringtone(ringtone_file): + QMessageBox.information(self, "设置铃声", "铃声设置成功") + else: + QMessageBox.warning(self, "设置铃声", "铃声设置失败") + + def start_recording(self): + """开始录音""" + if not self.audio_features or not self.lte_manager.is_connected(): + return + + record_type = self.recording_type_combo.currentIndex() + 1 # 1=本地, 2=远程, 3=混合 + + if self.audio_features.start_recording(record_path=record_type): + self.update_audio_controls_state() + self.add_status_message("录音已开始") + else: + QMessageBox.warning(self, "录音", "开始录音失败") + + def stop_recording(self): + """停止录音""" + if not self.audio_features or not self.lte_manager.is_connected(): + return + + if self.audio_features.stop_recording(): + self.update_audio_controls_state() + self.add_status_message("录音已停止") + else: + QMessageBox.warning(self, "录音", "停止录音失败") + + def browse_audio_file(self): + """浏览并选择音频文件""" + home_dir = os.path.expanduser("~") + file_path, _ = QFileDialog.getOpenFileName( + self, "选择音频文件", home_dir, + "音频文件 (*.mp3 *.wav *.amr *.pcm);;所有文件 (*)" + ) + + if file_path: + self.audio_file_edit.setText(file_path) + + def play_audio(self): + """播放选择的音频文件""" + if not self.lte_manager.is_connected() or not self.audio_features: + QMessageBox.warning(self, "播放音频", "未连接到LTE模块或音频功能未启用") + return + + audio_file = self.audio_file_edit.text() + if not audio_file: + # 如果未选择文件,提示用户 + self.browse_audio_file() + audio_file = self.audio_file_edit.text() + if not audio_file: + return + + play_type = self.play_type_combo.currentIndex() + + success = self.audio_features.play_audio(audio_file, play_type) + if success: + self.add_status_message(f"开始播放: {os.path.basename(audio_file)}") + else: + QMessageBox.warning(self, "播放音频", "播放音频失败") + + def stop_audio(self): + """停止播放音频""" + if not self.audio_features or not self.lte_manager.is_connected(): + return + + if self.audio_features.stop_audio(): + self.update_audio_controls_state() + self.add_status_message("音频播放已停止") + else: + QMessageBox.warning(self, "播放音频", "停止播放失败") + + def on_auto_play_on_answer_changed(self, state): + """处理接听电话自动播放音频选项变更""" + enabled = state == Qt.Checked + + if self.audio_features: + # 如果启用但未设置音频文件,提示用户选择 + if enabled and not self.answer_play_edit.text(): + self.browse_answer_play_file() + # 如果用户取消了选择,则取消勾选 + if not self.answer_play_edit.text(): + self.auto_play_on_answer_cb.setChecked(False) + return + + audio_file = self.answer_play_edit.text() if self.answer_play_edit.text() else None + self.audio_features.set_auto_play_on_answer(enabled, audio_file) + + self.save_settings() + + def browse_answer_play_file(self): + """浏览并选择接听时要播放的音频文件""" + home_dir = os.path.expanduser("~") + file_dialog = QFileDialog() + file_path, _ = file_dialog.getOpenFileName( + self, "选择音频文件", home_dir, + "音频文件 (*.amr *.wav *.mp3 *.pcm);;所有文件 (*)" + ) + + if file_path: + self.answer_play_edit.setText(file_path) + + # 如果音频特性实例存在,更新设置 + if self.audio_features: + self.audio_features.set_auto_play_on_answer( + self.auto_play_on_answer_cb.isChecked(), + file_path + ) + + self.save_settings() \ No newline at end of file diff --git a/sms_utils.py b/sms_utils.py new file mode 100644 index 0000000..f5dfd0c --- /dev/null +++ b/sms_utils.py @@ -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 \ No newline at end of file diff --git a/sound_utils.py b/sound_utils.py new file mode 100644 index 0000000..83707c9 --- /dev/null +++ b/sound_utils.py @@ -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)}") \ No newline at end of file diff --git a/test_call.py b/test_call.py new file mode 100644 index 0000000..08772b4 --- /dev/null +++ b/test_call.py @@ -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("无效选择,测试结束") \ No newline at end of file diff --git a/test_tray_icon.py b/test_tray_icon.py new file mode 100644 index 0000000..49374fa --- /dev/null +++ b/test_tray_icon.py @@ -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_()) \ No newline at end of file