318 lines
10 KiB
Python
318 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
OTA管理器模块
|
||
流程:下载ZIP → 解压覆盖项目 → 重启应用程序
|
||
"""
|
||
import binascii
|
||
import hashlib
|
||
import threading
|
||
import os
|
||
import shutil
|
||
|
||
import requests
|
||
import config
|
||
from logger_manager import logger_manager
|
||
|
||
|
||
class OTAManager:
|
||
"""OTA升级管理器(单例)"""
|
||
_instance = None
|
||
|
||
def __new__(cls):
|
||
if cls._instance is None:
|
||
cls._instance = super(OTAManager, cls).__new__(cls)
|
||
cls._instance._initialized = False
|
||
return cls._instance
|
||
|
||
def __init__(self):
|
||
if self._initialized:
|
||
return
|
||
self._ota_in_progress = 0
|
||
self._ota_url = None
|
||
self._lock = threading.Lock()
|
||
self._initialized = True
|
||
|
||
@property
|
||
def logger(self):
|
||
return logger_manager.logger
|
||
|
||
@property
|
||
def ota_in_progress(self):
|
||
with self._lock:
|
||
return self._ota_in_progress > 0
|
||
|
||
@property
|
||
def update_thread_started(self):
|
||
return self._ota_in_progress > 0
|
||
|
||
@property
|
||
def ota_url(self):
|
||
return self._ota_url
|
||
|
||
def _begin_ota(self, url=None):
|
||
with self._lock:
|
||
self._ota_in_progress += 1
|
||
if url:
|
||
self._ota_url = url
|
||
|
||
def _end_ota(self):
|
||
with self._lock:
|
||
self._ota_in_progress = max(0, self._ota_in_progress - 1)
|
||
|
||
def _set_ota_url(self, url):
|
||
with self._lock:
|
||
self._ota_url = url
|
||
|
||
def _start_update_thread(self):
|
||
with self._lock:
|
||
if self._ota_in_progress > 0:
|
||
return False
|
||
self._ota_in_progress += 1
|
||
return True
|
||
|
||
def _stop_update_thread(self):
|
||
self._end_ota()
|
||
|
||
# ==================== 核心方法 ====================
|
||
|
||
def perform_ota(self, url, progress_callback=None):
|
||
"""
|
||
完整OTA流程:下载ZIP → 解压覆盖项目
|
||
调用方负责重启程序(os.execv)
|
||
|
||
Args:
|
||
url: 固件下载地址
|
||
progress_callback: 进度回调 fn(phase, progress),phase="downloading"/"installing",progress=0-100
|
||
|
||
Returns:
|
||
(success: bool, message: str)
|
||
"""
|
||
if not url:
|
||
return False, "missing_url"
|
||
|
||
self._begin_ota(url)
|
||
try:
|
||
tmp_path = f"{config.APP_DIR}/ota_tmp.zip"
|
||
|
||
self.logger.info(f"[OTA] 开始下载: {url}")
|
||
if progress_callback:
|
||
progress_callback("downloading", 0)
|
||
ok, msg = self._download_zip(url, tmp_path, progress_callback)
|
||
if not ok:
|
||
self.logger.error(f"[OTA] 下载失败: {msg}")
|
||
return False, msg
|
||
self.logger.info(f"[OTA] 下载完成: {msg}")
|
||
if progress_callback:
|
||
progress_callback("downloading", 50)
|
||
|
||
self.logger.info("[OTA] 开始应用更新...")
|
||
if progress_callback:
|
||
progress_callback("installing", 50)
|
||
ok, msg = self._apply_update(tmp_path, progress_callback)
|
||
if not ok:
|
||
self.logger.error(f"[OTA] 应用更新失败: {msg}")
|
||
return False, msg
|
||
self.logger.info(f"[OTA] 更新应用成功,共更新 {msg} 个文件")
|
||
if progress_callback:
|
||
progress_callback("installing", 51)
|
||
|
||
return True, "success"
|
||
except Exception as e:
|
||
self.logger.error(f"[OTA] 异常: {e}")
|
||
return False, str(e)
|
||
finally:
|
||
self._end_ota()
|
||
|
||
def _download_zip(self, url, save_path, progress_callback=None):
|
||
"""
|
||
下载ZIP文件(流式分块下载,支持进度回调)
|
||
|
||
Args:
|
||
url: 下载地址
|
||
save_path: 保存路径
|
||
progress_callback: 进度回调 fn(phase, progress),progress=0-80
|
||
|
||
Returns:
|
||
(success: bool, message: str)
|
||
"""
|
||
try:
|
||
response = requests.get(url, timeout=120, stream=True)
|
||
response.raise_for_status()
|
||
|
||
total_size = int(response.headers.get('Content-Length', 0))
|
||
chunk_size = 8192
|
||
downloaded = 0
|
||
md5_hash = hashlib.md5()
|
||
|
||
with open(save_path, 'wb') as f:
|
||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||
if chunk:
|
||
f.write(chunk)
|
||
md5_hash.update(chunk)
|
||
downloaded += len(chunk)
|
||
if progress_callback and total_size > 0:
|
||
percent = min(int(downloaded / total_size * 50), 49)
|
||
progress_callback("downloading", percent)
|
||
|
||
try:
|
||
os.sync()
|
||
except:
|
||
pass
|
||
|
||
md5_b64_expected = None
|
||
if 'Content-Md5' in response.headers:
|
||
md5_b64_expected = response.headers['Content-Md5'].strip()
|
||
|
||
if md5_b64_expected:
|
||
md5_b64_got = binascii.b2a_base64(md5_hash.digest()).decode().strip()
|
||
if md5_b64_got != md5_b64_expected:
|
||
return False, f"MD5校验失败"
|
||
self.logger.info("[OTA] MD5校验通过")
|
||
|
||
return True, f"size={downloaded}"
|
||
except requests.exceptions.RequestException as e:
|
||
return False, f"网络错误: {e}"
|
||
except OSError as e:
|
||
return False, f"写入错误: {e}"
|
||
|
||
def _apply_update(self, zip_path, progress_callback=None):
|
||
"""
|
||
解压ZIP到临时目录,重启后由主程序移动到实际目录
|
||
|
||
Returns:
|
||
(success: bool, message: str)
|
||
"""
|
||
if not os.path.exists(zip_path):
|
||
return False, f"文件不存在: {zip_path}"
|
||
|
||
try:
|
||
with open(zip_path, "rb") as f:
|
||
header = f.read(4)
|
||
if header[:2] != b'PK':
|
||
return False, f"不是ZIP文件: {header.hex()}"
|
||
except Exception as e:
|
||
return False, f"读取ZIP失败: {e}"
|
||
|
||
staging_dir = f"{config.APP_DIR}/ota_staging"
|
||
try:
|
||
os.makedirs(staging_dir, exist_ok=True)
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
self.logger.info(f"[OTA] 开始解压: {zip_path} -> {staging_dir}")
|
||
ret = os.system(f"unzip -q -o '{zip_path}' -d '{staging_dir}' 2>&1")
|
||
if ret != 0:
|
||
return False, f"解压失败: exit={ret}"
|
||
self.logger.info("[OTA] 解压完成")
|
||
except Exception as e:
|
||
return False, f"解压异常: {e}"
|
||
|
||
file_count = 0
|
||
for _, _, files in os.walk(staging_dir):
|
||
file_count += len(files)
|
||
|
||
if file_count == 0:
|
||
return False, "ZIP中无文件"
|
||
|
||
try:
|
||
os.sync()
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
os.remove(zip_path)
|
||
except:
|
||
pass
|
||
|
||
self.logger.info(f"[OTA] 已解压 {file_count} 个文件到临时目录,重启后生效")
|
||
return True, file_count
|
||
|
||
def restore_from_backup(self, backup_dir_path=None):
|
||
"""
|
||
从备份目录恢复所有文件到应用目录
|
||
|
||
Args:
|
||
backup_dir_path: 备份目录路径,None则自动查找最新备份
|
||
|
||
Returns:
|
||
bool: 是否成功恢复
|
||
"""
|
||
backup_base = config.BACKUP_BASE
|
||
|
||
try:
|
||
if backup_dir_path is None:
|
||
if not os.path.exists(backup_base):
|
||
self.logger.error(f"[RESTORE] 备份目录不存在: {backup_base}")
|
||
return False
|
||
|
||
backup_dirs = []
|
||
for item in os.listdir(backup_base):
|
||
if item == ".counter":
|
||
continue
|
||
item_path = os.path.join(backup_base, item)
|
||
if os.path.isdir(item_path) and item.startswith("backup_"):
|
||
try:
|
||
dir_num = int(item.replace("backup_", ""))
|
||
backup_dirs.append((item, dir_num))
|
||
except:
|
||
pass
|
||
|
||
if not backup_dirs:
|
||
self.logger.error("[RESTORE] 没有找到备份目录")
|
||
return False
|
||
|
||
backup_dirs.sort(key=lambda x: x[1], reverse=True)
|
||
backup_dir_path = os.path.join(backup_base, backup_dirs[0][0])
|
||
|
||
if not os.path.exists(backup_dir_path):
|
||
self.logger.error(f"[RESTORE] 备份目录不存在: {backup_dir_path}")
|
||
return False
|
||
|
||
self.logger.info(f"[RESTORE] 开始从备份恢复: {backup_dir_path}")
|
||
|
||
restored_files = []
|
||
for root, dirs, files in os.walk(backup_dir_path):
|
||
for f in files:
|
||
src = os.path.join(root, f)
|
||
rel = os.path.relpath(src, backup_dir_path)
|
||
dest = os.path.join(config.APP_DIR, rel)
|
||
dest_dir = os.path.dirname(dest)
|
||
if dest_dir:
|
||
os.makedirs(dest_dir, exist_ok=True)
|
||
try:
|
||
shutil.copy2(src, dest)
|
||
restored_files.append(rel)
|
||
except Exception as e:
|
||
self.logger.error(f"[RESTORE] 恢复 {rel} 失败: {e}")
|
||
|
||
if restored_files:
|
||
self.logger.info(f"[RESTORE] 成功恢复 {len(restored_files)} 个文件")
|
||
return True
|
||
else:
|
||
self.logger.info("[RESTORE] 没有文件被恢复")
|
||
return False
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"[RESTORE] 恢复过程出错: {e}")
|
||
return False
|
||
|
||
|
||
# 全局单例
|
||
ota_manager = OTAManager()
|
||
|
||
# ==================== 向后兼容接口 ====================
|
||
|
||
def apply_ota_and_reboot(ota_url=None, downloaded_file=None):
|
||
return ota_manager.perform_ota(ota_url)
|
||
|
||
def direct_ota_download_via_4g(ota_url):
|
||
return ota_manager.perform_ota(ota_url)
|
||
|
||
def handle_wifi_and_update(ssid, password, ota_url):
|
||
return ota_manager.perform_ota(ota_url)
|
||
|
||
def restore_from_backup(backup_dir_path=None):
|
||
return ota_manager.restore_from_backup(backup_dir_path)
|