400 lines
15 KiB
Python
400 lines
15 KiB
Python
|
|
import re
|
|||
|
|
import hashlib
|
|||
|
|
import binascii
|
|||
|
|
from maix import time
|
|||
|
|
from power import get_bus_voltage, voltage_to_percent
|
|||
|
|
from urllib.parse import urlparse
|
|||
|
|
from hardware import hardware_manager
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DownloadManager4G:
|
|||
|
|
"""4g下载管理器(单例)"""
|
|||
|
|
_instance = None
|
|||
|
|
|
|||
|
|
def __new__(cls):
|
|||
|
|
if cls._instance is None:
|
|||
|
|
cls._instance = super(DownloadManager4G, cls).__new__(cls)
|
|||
|
|
cls._instance._initialized = False
|
|||
|
|
return cls._instance
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
if self._initialized:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 私有状态
|
|||
|
|
self.FRAG_SIZE = 1024
|
|||
|
|
self.FRAG_DELAY = 10
|
|||
|
|
self._initialized = True
|
|||
|
|
|
|||
|
|
def _log(self, *a):
|
|||
|
|
if debug:
|
|||
|
|
self.logger.debug(" ".join(str(x) for x in a))
|
|||
|
|
|
|||
|
|
def _pwr_log(self, prefix=""):
|
|||
|
|
"""debug 用:输出电压/电量"""
|
|||
|
|
if not debug:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
v = get_bus_voltage()
|
|||
|
|
p = voltage_to_percent(v)
|
|||
|
|
self.logger.debug(f"[PWR]{prefix} v={v:.3f}V p={p}%")
|
|||
|
|
except Exception as e:
|
|||
|
|
try:
|
|||
|
|
self.logger.debug(f"[PWR]{prefix} read_failed: {e}")
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def _clear_http_events(self):
|
|||
|
|
if hardware_manager.at_client:
|
|||
|
|
while hardware_manager.at_client.pop_http_event() is not None:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def _parse_httpid(self, resp: str):
|
|||
|
|
m = re.search(r"\+MHTTPCREATE:\s*(\d+)", resp)
|
|||
|
|
return int(m.group(1)) if m else None
|
|||
|
|
|
|||
|
|
def _get_ip(self, ):
|
|||
|
|
r = hardware_manager.at_client.send("AT+CGPADDR=1", "OK", 3000)
|
|||
|
|
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
|
|||
|
|
return m.group(1) if m else ""
|
|||
|
|
|
|||
|
|
def _ensure_pdp(self, ):
|
|||
|
|
ip = self._get_ip()
|
|||
|
|
if ip and ip != "0.0.0.0":
|
|||
|
|
return True, ip
|
|||
|
|
hardware_manager.at_client.send("AT+MIPCALL=1,1", "OK", 15000)
|
|||
|
|
for _ in range(10):
|
|||
|
|
ip = self._get_ip()
|
|||
|
|
if ip and ip != "0.0.0.0":
|
|||
|
|
return True, ip
|
|||
|
|
time.sleep(1)
|
|||
|
|
return False, ip
|
|||
|
|
|
|||
|
|
def _extract_hdr_fields(self, hdr_text: str):
|
|||
|
|
mlen = re.search(r"Content-Length:\s*(\d+)", hdr_text, re.IGNORECASE)
|
|||
|
|
clen = int(mlen.group(1)) if mlen else None
|
|||
|
|
mmd5 = re.search(r"Content-Md5:\s*([A-Za-z0-9+/=]+)", hdr_text, re.IGNORECASE)
|
|||
|
|
md5_b64 = mmd5.group(1).strip() if mmd5 else None
|
|||
|
|
return clen, md5_b64
|
|||
|
|
|
|||
|
|
def _extract_content_range(self, hdr_text: str):
|
|||
|
|
m = re.search(r"Content-Range:\s*bytes\s*(\d+)\s*-\s*(\d+)\s*/\s*(\d+)", hdr_text, re.IGNORECASE)
|
|||
|
|
if not m:
|
|||
|
|
return None, None, None
|
|||
|
|
try:
|
|||
|
|
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|||
|
|
except:
|
|||
|
|
return None, None, None
|
|||
|
|
|
|||
|
|
def _hard_reset_http(self, ):
|
|||
|
|
"""模块进入"坏状态"时的保守清场"""
|
|||
|
|
self._clear_http_events()
|
|||
|
|
for i in range(0, 6):
|
|||
|
|
try:
|
|||
|
|
hardware_manager.at_client.send(f"AT+MHTTPDEL={i}", "OK", 1200)
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
self._clear_http_events()
|
|||
|
|
|
|||
|
|
def _create_httpid(self, full_reset=False):
|
|||
|
|
self._clear_http_events()
|
|||
|
|
if hardware_manager.at_client:
|
|||
|
|
hardware_manager.at_client.flush()
|
|||
|
|
if full_reset:
|
|||
|
|
self._hard_reset_http()
|
|||
|
|
resp = hardware_manager.at_client.send(f'AT+MHTTPCREATE="{base_url}"', "OK", 8000)
|
|||
|
|
hid = self._parse_httpid(resp)
|
|||
|
|
if self._is_https:
|
|||
|
|
resp = hardware_manager.at_client.send(f'AT+MHTTPCFG="ssl",{hid},1,1', "OK", 2000)
|
|||
|
|
if "ERROR" in resp or "CME ERROR" in resp:
|
|||
|
|
self.logger.error(f"MHTTPCFG SSL failed: {resp}")
|
|||
|
|
# 尝试https 降级到http
|
|||
|
|
downgraded_base_url = base_url.replace("https://", "http://")
|
|||
|
|
resp = hardware_manager.at_client.send(f'AT+MHTTPCREATE="{downgraded_base_url}"', "OK", 8000)
|
|||
|
|
hid = self._parse_httpid(resp)
|
|||
|
|
|
|||
|
|
return hid, resp
|
|||
|
|
|
|||
|
|
def _fetch_range_into_buf(self, start, want_len, out_buf, path, full_reset=False):
|
|||
|
|
"""
|
|||
|
|
请求 Range [start, start+want_len),写入 out_buf(bytearray,长度=want_len)
|
|||
|
|
返回 (ok, msg, total_len, md5_b64, got_len)
|
|||
|
|
"""
|
|||
|
|
end_incl = start + want_len - 1
|
|||
|
|
hid, cresp = self._create_httpid(full_reset=full_reset)
|
|||
|
|
if hid is None:
|
|||
|
|
return False, f"MHTTPCREATE failed: {cresp}", None, None, 0
|
|||
|
|
|
|||
|
|
# 降低 URC 压力(分片/延迟)
|
|||
|
|
hardware_manager.at_client.send(f'AT+MHTTPCFG="fragment",{hid},{self.FRAG_SIZE},{self.FRAG_DELAY}', "OK", 1500)
|
|||
|
|
# 设置 Range header(inclusive)
|
|||
|
|
hardware_manager.at_client.send(f'AT+MHTTPCFG="header",{hid},"Range: bytes={start}-{end_incl}"', "OK", 3000)
|
|||
|
|
|
|||
|
|
req = hardware_manager.at_client.send(f'AT+MHTTPREQUEST={hid},1,0,"{path}"', "OK", 15000)
|
|||
|
|
if "ERROR" in req or "CME ERROR" in req:
|
|||
|
|
hardware_manager.at_client.send(f"AT+MHTTPDEL={hid}", "OK", 2000)
|
|||
|
|
return False, f"MHTTPREQUEST failed: {req}", None, None, 0
|
|||
|
|
|
|||
|
|
# 等 header + content
|
|||
|
|
hdr_text = None
|
|||
|
|
hdr_accum = ""
|
|||
|
|
code = None
|
|||
|
|
resp_total = None
|
|||
|
|
total_len = None
|
|||
|
|
md5_b64 = None
|
|||
|
|
|
|||
|
|
got_ranges = set()
|
|||
|
|
last_sum = 0
|
|||
|
|
t0 = time.ticks_ms()
|
|||
|
|
timeout_ms = 9000
|
|||
|
|
logged_hdr = False
|
|||
|
|
|
|||
|
|
while time.ticks_ms() - t0 < timeout_ms:
|
|||
|
|
ev = hardware_manager.at_client.pop_http_event() if hardware_manager.at_client else None
|
|||
|
|
if not ev:
|
|||
|
|
time.sleep_ms(5)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if ev[0] == "header":
|
|||
|
|
_, ehid, ecode, ehdr = ev
|
|||
|
|
if ehid != hid:
|
|||
|
|
continue
|
|||
|
|
code = ecode
|
|||
|
|
hdr_text = ehdr
|
|||
|
|
if ehdr:
|
|||
|
|
hdr_accum = (hdr_accum + "\n" + ehdr) if hdr_accum else ehdr
|
|||
|
|
|
|||
|
|
resp_total_tmp, md5_tmp = self._extract_hdr_fields(hdr_accum)
|
|||
|
|
if md5_tmp:
|
|||
|
|
md5_b64 = md5_tmp
|
|||
|
|
cr_s, cr_e, cr_total = self._extract_content_range(hdr_accum)
|
|||
|
|
if cr_total is not None:
|
|||
|
|
total_len = cr_total
|
|||
|
|
if resp_total_tmp is not None:
|
|||
|
|
resp_total = resp_total_tmp
|
|||
|
|
elif resp_total is None and (cr_s is not None) and (cr_e is not None) and (cr_e >= cr_s):
|
|||
|
|
resp_total = (cr_e - cr_s + 1)
|
|||
|
|
if (not logged_hdr) and (resp_total is not None or total_len is not None):
|
|||
|
|
self._log(f"[HDR] id={hid} code={code} clen={resp_total} cr={cr_s}-{cr_e}/{cr_total}")
|
|||
|
|
logged_hdr = True
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if ev[0] == "content":
|
|||
|
|
_, ehid, _total, _sum, _cur, payload = ev
|
|||
|
|
if ehid != hid:
|
|||
|
|
continue
|
|||
|
|
if resp_total is None:
|
|||
|
|
resp_total = _total
|
|||
|
|
if resp_total is None or resp_total <= 0:
|
|||
|
|
continue
|
|||
|
|
start_rel = _sum - _cur
|
|||
|
|
end_rel = _sum
|
|||
|
|
if start_rel < 0 or start_rel >= resp_total:
|
|||
|
|
continue
|
|||
|
|
if end_rel > resp_total:
|
|||
|
|
end_rel = resp_total
|
|||
|
|
actual_len = min(len(payload), end_rel - start_rel)
|
|||
|
|
if actual_len <= 0:
|
|||
|
|
continue
|
|||
|
|
out_buf[start_rel:start_rel + actual_len] = payload[:actual_len]
|
|||
|
|
got_ranges.add((start_rel, start_rel + actual_len))
|
|||
|
|
if _sum > last_sum:
|
|||
|
|
last_sum = _sum
|
|||
|
|
if debug and (last_sum >= resp_total or (last_sum % 512 == 0)):
|
|||
|
|
self._log(f"[CHUNK] {start}+{last_sum}/{resp_total}")
|
|||
|
|
|
|||
|
|
if last_sum >= resp_total:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# 清理实例(快路径:只删当前 hid)
|
|||
|
|
try:
|
|||
|
|
hardware_manager.at_client.send(f"AT+MHTTPDEL={hid}", "OK", 2000)
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
if resp_total is None:
|
|||
|
|
return False, "no_header_or_total", total_len, md5_b64, 0
|
|||
|
|
|
|||
|
|
# 计算实际填充长度
|
|||
|
|
merged = sorted(got_ranges)
|
|||
|
|
merged2 = []
|
|||
|
|
for s, e in merged:
|
|||
|
|
if not merged2 or s > merged2[-1][1]:
|
|||
|
|
merged2.append((s, e))
|
|||
|
|
else:
|
|||
|
|
merged2[-1] = (merged2[-1][0], max(merged2[-1][1], e))
|
|||
|
|
filled = sum(e - s for s, e in merged2)
|
|||
|
|
|
|||
|
|
if filled < resp_total:
|
|||
|
|
return False, f"incomplete_chunk got={filled} expected={resp_total} code={code}", total_len, md5_b64, filled
|
|||
|
|
|
|||
|
|
got_len = resp_total
|
|||
|
|
return True, "OK", total_len, md5_b64, got_len
|
|||
|
|
|
|||
|
|
def download_file_via_4g(self, url, filename,
|
|||
|
|
total_timeout_ms=600000,
|
|||
|
|
retries=3,
|
|||
|
|
debug=False):
|
|||
|
|
"""
|
|||
|
|
ML307R HTTP 下载(更稳的"固定小块 Range 顺序下载",基于main109.py):
|
|||
|
|
- 只依赖 +MHTTPURC:"header"/"content"(不依赖 MHTTPREAD/cached)
|
|||
|
|
- 每次只请求一个小块 Range(默认 10240B),失败就重试同一块,必要时缩小块大小
|
|||
|
|
- 每个 chunk 都重新 MHTTPCREATE/MHTTPREQUEST,避免卡在"206 header 但不吐 content"的坏状态
|
|||
|
|
- 使用二进制模式下载,确保文件完整性
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 小块策略(与main109.py保持一致)
|
|||
|
|
CHUNK_MAX = 10240
|
|||
|
|
CHUNK_MIN = 128
|
|||
|
|
CHUNK_RETRIES = 12
|
|||
|
|
|
|||
|
|
|
|||
|
|
t_func0 = time.ticks_ms()
|
|||
|
|
|
|||
|
|
parsed = urlparse(url)
|
|||
|
|
host = parsed.hostname
|
|||
|
|
path = parsed.path or "/"
|
|||
|
|
if not host:
|
|||
|
|
return False, "bad_url (no host)"
|
|||
|
|
|
|||
|
|
if isinstance(url, str) and url.startswith("https://static.shelingxingqiu.com/"):
|
|||
|
|
base_url = "https://static.shelingxingqiu.com"
|
|||
|
|
# TODO:使用https,看看是否能成功
|
|||
|
|
self._is_https = True
|
|||
|
|
else:
|
|||
|
|
base_url = f"http://{host}"
|
|||
|
|
self._is_https = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
self._begin_ota()
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
from network import network_manager
|
|||
|
|
with network_manager.get_uart_lock():
|
|||
|
|
try:
|
|||
|
|
ok_pdp, ip = self._ensure_pdp()
|
|||
|
|
if not ok_pdp:
|
|||
|
|
return False, f"PDP not ready (ip={ip})"
|
|||
|
|
|
|||
|
|
# 先清空旧事件,避免串台
|
|||
|
|
self._clear_http_events()
|
|||
|
|
|
|||
|
|
# 为了支持随机写入,先创建空文件
|
|||
|
|
try:
|
|||
|
|
with open(filename, "wb") as f:
|
|||
|
|
f.write(b"")
|
|||
|
|
except Exception as e:
|
|||
|
|
return False, f"open_file_failed: {e}"
|
|||
|
|
|
|||
|
|
total_len = None
|
|||
|
|
expect_md5_b64 = None
|
|||
|
|
|
|||
|
|
offset = 0
|
|||
|
|
chunk = CHUNK_MAX
|
|||
|
|
t_start = time.ticks_ms()
|
|||
|
|
last_progress_ms = t_start
|
|||
|
|
STALL_TIMEOUT_MS = 60000
|
|||
|
|
last_pwr_ms = t_start
|
|||
|
|
self._pwr_log(prefix=" ota_start")
|
|||
|
|
bad_http_state = 0
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
now = time.ticks_ms()
|
|||
|
|
if debug and time.ticks_diff(now, last_pwr_ms) >= 5000:
|
|||
|
|
last_pwr_ms = now
|
|||
|
|
self._pwr_log(prefix=f" off={offset}/{total_len or '?'}")
|
|||
|
|
if time.ticks_diff(now, t_start) > total_timeout_ms:
|
|||
|
|
return False, f"timeout overall after {total_timeout_ms}ms offset={offset} total={total_len}"
|
|||
|
|
|
|||
|
|
if time.ticks_diff(now, last_progress_ms) > STALL_TIMEOUT_MS:
|
|||
|
|
return False, f"timeout stalled {STALL_TIMEOUT_MS}ms offset={offset} total={total_len}"
|
|||
|
|
|
|||
|
|
if total_len is not None and offset >= total_len:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
want = chunk
|
|||
|
|
if total_len is not None:
|
|||
|
|
remain = total_len - offset
|
|||
|
|
if remain <= 0:
|
|||
|
|
break
|
|||
|
|
if want > remain:
|
|||
|
|
want = remain
|
|||
|
|
|
|||
|
|
# 本 chunk 的 buffer(长度=want)
|
|||
|
|
buf = bytearray(want)
|
|||
|
|
|
|||
|
|
success = False
|
|||
|
|
last_err = "unknown"
|
|||
|
|
md5_seen = None
|
|||
|
|
got_len = 0
|
|||
|
|
for k in range(1, CHUNK_RETRIES + 1):
|
|||
|
|
do_full_reset = (bad_http_state >= 2)
|
|||
|
|
ok, msg, tlen, md5_b64, got = self._fetch_range_into_buf(offset, want, buf, base_url, path, full_reset=do_full_reset)
|
|||
|
|
last_err = msg
|
|||
|
|
if tlen is not None and total_len is None:
|
|||
|
|
total_len = tlen
|
|||
|
|
if md5_b64 and not expect_md5_b64:
|
|||
|
|
expect_md5_b64 = md5_b64
|
|||
|
|
if ok:
|
|||
|
|
success = True
|
|||
|
|
got_len = got
|
|||
|
|
bad_http_state = 0
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
if ("no_header_or_total" in msg) or ("MHTTPREQUEST failed" in msg) or (
|
|||
|
|
"MHTTPCREATE failed" in msg):
|
|||
|
|
bad_http_state += 1
|
|||
|
|
else:
|
|||
|
|
bad_http_state = max(0, bad_http_state - 1)
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
if chunk > CHUNK_MIN:
|
|||
|
|
chunk = max(CHUNK_MIN, chunk // 2)
|
|||
|
|
want = min(chunk, want)
|
|||
|
|
buf = bytearray(want)
|
|||
|
|
self._log(f"[RETRY] off={offset} want={want} try={k} err={msg}")
|
|||
|
|
self._pwr_log(prefix=f" retry{k} off={offset}")
|
|||
|
|
time.sleep_ms(120)
|
|||
|
|
|
|||
|
|
if not success:
|
|||
|
|
return False, f"chunk_failed off={offset} want={want} err={last_err} total={total_len}"
|
|||
|
|
|
|||
|
|
# 写入文件(二进制模式)
|
|||
|
|
try:
|
|||
|
|
with open(filename, "r+b") as f:
|
|||
|
|
f.seek(offset)
|
|||
|
|
f.write(bytes(buf))
|
|||
|
|
except Exception as e:
|
|||
|
|
return False, f"write_failed off={offset}: {e}"
|
|||
|
|
|
|||
|
|
offset += len(buf)
|
|||
|
|
last_progress_ms = time.ticks_ms()
|
|||
|
|
chunk = CHUNK_MAX
|
|||
|
|
if debug:
|
|||
|
|
self._log(f"[OK] offset={offset}/{total_len or '?'}")
|
|||
|
|
|
|||
|
|
# MD5 校验
|
|||
|
|
if expect_md5_b64 and hashlib is not None:
|
|||
|
|
try:
|
|||
|
|
with open(filename, "rb") as f:
|
|||
|
|
data = f.read()
|
|||
|
|
digest = hashlib.md5(data).digest()
|
|||
|
|
got_b64 = binascii.b2a_base64(digest).decode().strip()
|
|||
|
|
if got_b64 != expect_md5_b64:
|
|||
|
|
return False, f"md5_mismatch got={got_b64} expected={expect_md5_b64}"
|
|||
|
|
self.logger.debug(f"[4G-DL] MD5 verified: {got_b64}")
|
|||
|
|
except Exception as e:
|
|||
|
|
return False, f"md5_check_failed: {e}"
|
|||
|
|
|
|||
|
|
t_cost = time.ticks_diff(time.ticks_ms(), t_func0)
|
|||
|
|
self.logger.info(f"[4G-DL] download complete: size={offset} ip={ip} cost_ms={t_cost}")
|
|||
|
|
return True, f"OK size={offset} ip={ip} cost_ms={t_cost}"
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
self._end_ota()
|