This commit is contained in:
gcw_4spBpAfv
2026-02-10 17:52:55 +08:00
parent 573c0a3385
commit 592dc6ceb1
9 changed files with 626 additions and 136 deletions

399
4g_download_manager.py Normal file
View File

@@ -0,0 +1,399 @@
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_bufbytearray长度=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 headerinclusive
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()

View File

@@ -103,6 +103,9 @@ LASER_CAMERA_OFFSET_CM = 1.4 # 激光在摄像头下方的物理距离(厘米
IMAGE_CENTER_X = 320 # 图像中心 X 坐标
IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
FLASH_LASER_WHILE_SHOOTING = True # 是否在拍摄时闪一下激光True=闪False=不闪)
FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒)
# ==================== 显示配置 ====================
LASER_COLOR = (0, 255, 0) # RGB颜色
LASER_THICKNESS = 1
@@ -113,7 +116,7 @@ SAVE_IMAGE_ENABLED = True # 是否保存图像True=保存False=不保存
PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像True=显示False=不显示建议在连着USB测试过程中打开
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像True=显示False=不显示建议在连着USB测试过程中打开
# ==================== OTA配置 ====================
MAX_BACKUPS = 5

View File

@@ -1,13 +1,76 @@
1. OTA 下载的时候,为什么使用十六进制下载,读取 URC 事件?
1. 4G OTA 下载的时候,为什么使用十六进制下载,读取 URC 事件?
因为使用二进制下载的时候,经常会出现错误,并且会失败?然后最稳定传输的办法,是每次传输的时候,是分块,而且每次分块都要“删/建”http实例。推测原因是因为我们现在是直接传输文件的源代码代码中含有了一些字符串可能和 AT指令重复导致了 AT 模块在解释的时候出错。而使用 16 进制的方式,可以避免这个问题。因为十六进制直接把数据先转成了字符串,然后在设备端再把字符串转成数据,这样就不可能出现 AT的指令从而减少了麻烦。
2. OTA 下载的时候,为什么不用 AT 模块里 HTTPDLFILE 的指令?
2. 4G OTA 下载的时候,为什么不用 AT 模块里 HTTPDLFILE 的指令?
因为在测试中发现,使用 HTTPDLFILE其实是下载到了 4G 模块内部,需要重新从模块内部转到存储卡,而且 4G 模块的存储较小,大概只有 40k所以还需要分块来下载和转存比较麻烦于是最终使用了使用读取串口事件的模式。
3. OTA 下载的时候,为什么不用 AT 模块里 HTTPREAD 的指令?
3. 4G OTA 下载的时候,为什么不用 AT 模块里 HTTPREAD 的指令?
因为之前测试发现READ模式其实是需要多步
3.1. AT+MHTTPCREATE
3.2. AT+MHTTPCFG
3.3. AT+MHTTPREQUEST
3.4. AT+MHTTPREAD
它其实也是把数据下载到 4g 模块的缓存里,然后再从缓存里读取出来。所以也是比较繁琐的,还不如 HTTPDLFILE 简单。
4.
4.
4. WiFi OTA 流程ota_manager.handle_wifi_and_update()
* 解析 ota_url 得到 host:port
* 调用 network_manager.connect_wifi(ssid, password, verify_host=host, verify_port=port, persist=True)
* 只有“能连上 WiFi 且能访问 OTA host:port”才会把新凭证保留在 /boot
* 连接成功后开始下载 OTA 文件download_file()
* 下载成功则 apply_ota_and_reboot()
5. TCP 通信
1) 平时 TCP 通信主流程network_manager.tcp_main()
外层无限循环:一直尝试保持与服务器的 TCP 会话。
每轮开始:
如果 OTA 正在进行:暂停(避免抢占资源/串口)。
connect_server():建立 TCP 连接(自动选 WiFi 或 4G
发送“登录包”msg_type=1等待服务器返回“登录成功”。
登录成功后进入内层循环:
接收数据:
WiFi非阻塞 recv();没数据返回 b"";有数据进入缓冲区拼包解析。
4G从 ATClient 的队列 pop_tcp_payload() 取数据。
处理命令/ACK
登录响应、心跳 ACK、OTA 命令、关机命令、日志上传命令等。
发送业务队列:
从高优/普通队列取 1 条,发送失败会放回队首,并断线重连(不再丢消息)。
发送心跳:
按 HEARTBEAT_INTERVAL 发心跳包。
心跳失败会计数(当前为连续失败到阈值才重连)。
任何发送/接收致命失败:
关闭 socket/断开连接 → 跳出内层循环 → 外层等待一会儿后重新 connect_server() → 重新登录。
6. “WiFi 连接/验证”
TCP 连接建立与网络选择connect_server() / select_network()
* select_network()WiFi 优先,但要求:
is_wifi_connected() 为 True系统层面有 WiFi IP 或 Maix WLAN connected
且能连到 TCP 服务器 SERVER_IP:SERVER_PORT
否则回退到 4G
* connect_server()
若已有连接WiFi 会做 _check_wifi_connection() 轻量检查4G 直接认为 OK由 AT 层维护)。
否则按网络类型走:
WiFi创建 socket → connect → setblocking(False)(接收用非阻塞)
4GAT+MIPOPEN 建链
WiFi 链接connect_wifi()
当前 connect_wifi() 的关键特点是:必须让 /etc/init.d/S30wifi restart 真正用新 SSID 去连,所以会临时写 /boot/wifi.ssid 和 /boot/wifi.pass失败自动回滚。
流程是:
(1) 备份旧配置
* /boot/wifi.ssid、/boot/wifi.pass
* /etc/wpa_supplicant.conf尽量备份
(2) 写入新凭证
* 把新 ssid/pass 写到 /boot/*
-(同时尽量写 /etc/wpa_supplicant.conf但不强依赖
(3) 重启 WiFi 服务:/etc/init.d/S30wifi restart
(4) 等待获取 IP默认 20 秒,可调)
(5) 验证可用性,连到 verify_host:verify_port
(6) 成功
* persist=True保留 /boot/*(持久化)
* persist=False回滚 /boot/* 到旧值(不重启,当前连接仍可继续)
(7) 失败
* 回滚 /boot/* + 回滚 /etc/wpa_supplicant.conf如果有备份
* 再 S30wifi restart 恢复旧网络
* 返回错误
7. 日志上传inner_cmd == 43当前只支持 wifi 上传日志
命令带 ssid/password/url 时:
* 若 WiFi 未连接:先 connect_wifi(..., verify_host=upload_host, verify_port=upload_port, persist=True)
上传内容:
* sync # 把日志从内存同步到文件
* 快照 app.log* 到 /tmp staging
* 打包成 tar.gz默认或 zip
* 以 multipart/form-data 的 file 字段 POST 到 url

View File

@@ -25,7 +25,6 @@ class HardwareManager:
# 私有硬件对象
self._uart4g = None # 4G模块UART
self._distance_serial = None # 激光测距串口
self._bus = None # I2C总线
self._adc_obj = None # ADC对象
self._at_client = None # AT客户端
@@ -39,11 +38,6 @@ class HardwareManager:
"""4G模块UART只读"""
return self._uart4g
@property
def distance_serial(self):
"""激光测距串口(只读)"""
return self._distance_serial
@property
def bus(self):
"""I2C总线只读"""
@@ -71,19 +65,6 @@ class HardwareManager:
self._uart4g = uart.UART(device, baudrate)
return self._uart4g
def init_distance_serial(self, device=None, baudrate=None):
"""初始化激光测距串口(激光控制)"""
from maix import uart
if device is None:
device = config.DISTANCE_SERIAL_DEVICE
if baudrate is None:
baudrate = config.DISTANCE_SERIAL_BAUDRATE
print(f"[HW] 初始化激光串口: device={device}, baudrate={baudrate}")
self._distance_serial = uart.UART(device, baudrate)
print(f"[HW] 激光串口初始化完成: {self._distance_serial}")
return self._distance_serial
def init_bus(self, bus_num=None):
"""初始化I2C总线"""
from maix import i2c

View File

@@ -29,6 +29,7 @@ class LaserManager:
return
# 私有状态
self._serial = None # 激光串口,由 laser_manager 自己持有
self._calibration_active = False
self._calibration_result = None
self._calibration_lock = threading.Lock()
@@ -65,6 +66,38 @@ class LaserManager:
"""
return self._last_frame_with_ellipse
# ==================== 初始化方法 ====================
def init(self, serial_device=None, baudrate=None):
"""
初始化激光模块(包括串口)
初始化完成后主动发送关闭命令,防止 UART 初始化噪声误触发激光
Args:
serial_device: 串口设备路径,默认使用 config.DISTANCE_SERIAL_DEVICE
baudrate: 波特率,默认使用 config.DISTANCE_SERIAL_BAUDRATE
"""
from maix import uart
device = serial_device or config.DISTANCE_SERIAL_DEVICE
baud = baudrate or config.DISTANCE_SERIAL_BAUDRATE
self._serial = uart.UART(device, baud)
print(f"[LASER] 激光串口初始化完成: device={device}, baudrate={baud}")
# 等待串口稳定后主动关闭激光,防止初始化噪声误触发
time.sleep_ms(100)
try:
self._serial.read(-1) # 清空接收缓冲区
except Exception:
pass
self._serial.write(config.LASER_OFF_CMD)
time.sleep_ms(60)
try:
self._serial.read(-1) # 清空回包
except Exception:
pass
print("[LASER] 已发送关闭命令(防止开机误触发)")
# ==================== 业务方法 ====================
def load_laser_point(self):
@@ -117,10 +150,8 @@ class LaserManager:
def turn_on_laser(self):
"""发送指令开启激光,并读取回包(部分模块支持)"""
from hardware import hardware_manager
if hardware_manager.distance_serial is None:
self.logger.error("[LASER] distance_serial 未初始化")
if self._serial is None:
self.logger.error("[LASER] 激光串口未初始化,请先调用 init()")
return None
# 打印调试信息
@@ -128,32 +159,31 @@ class LaserManager:
# 清空接收缓冲区
try:
hardware_manager.distance_serial.read(-1) # 清空缓冲区
self._serial.read(-1) # 清空缓冲区
except:
pass
# 发送命令
written = hardware_manager.distance_serial.write(config.LASER_ON_CMD)
written = self._serial.write(config.LASER_ON_CMD)
self.logger.info(f"[LASER] 写入字节数: {written}")
time.sleep_ms(60)
# 读取回包
resp = hardware_manager.distance_serial.read(len=20,timeout=10)
resp = self._serial.read(len=20, timeout=10)
if resp:
self.logger.info(f"[LASER] 收到回包 ({len(resp)}字节): {resp.hex()}")
if resp == config.LASER_ON_CMD:
self.logger.info("✅ 激光开启指令已确认")
else:
self.logger.warning("🔇 无回包(可能正常或模块不支持回包)")
self._laser_turned_on = True
return resp
def turn_off_laser(self):
"""发送指令关闭激光"""
from hardware import hardware_manager
if hardware_manager.distance_serial is None:
self.logger.error("[LASER] distance_serial 未初始化")
if self._serial is None:
self.logger.error("[LASER] 激光串口未初始化,请先调用 init()")
return None
# 打印调试信息
@@ -161,32 +191,35 @@ class LaserManager:
# 清空接收缓冲区
try:
hardware_manager.distance_serial.read(-1)
self._serial.read(-1)
except:
pass
# 发送命令
written = hardware_manager.distance_serial.write(config.LASER_OFF_CMD)
written = self._serial.write(config.LASER_OFF_CMD)
self.logger.info(f"[LASER] 写入字节数: {written}")
time.sleep_ms(60)
# 读取回包
resp = hardware_manager.distance_serial.read(20)
resp = self._serial.read(20)
if resp:
self.logger.info(f"[LASER] 收到回包 ({len(resp)}字节): {resp.hex()}")
else:
self.logger.warning("🔇 无回包")
self._laser_turned_on = False
return resp
# 不用读回包
# return None
def flash_laser(self, duration_ms=1000):
"""闪一下激光(用于射箭反馈)"""
"""闪一下激光(用于射箭反馈),如果射箭前激光已亮则保持亮"""
try:
was_on = self._laser_turned_on # 记住射箭前的激光状态
self.turn_on_laser()
time.sleep_ms(duration_ms)
self.turn_off_laser()
if not was_on:
self.turn_off_laser() # 射箭前是灭的,才关闭
else:
self.logger.info("[LASER] 射箭前激光已亮(调瞄中),保持激光开启")
except Exception as e:
self.logger.error(f"闪激光失败: {e}")
@@ -956,15 +989,14 @@ class LaserManager:
"""发送测距指令并返回距离(米)和信号质量
返回: (distance_m, signal_quality) 元组,失败返回 (0.0, 0)
"""
from hardware import hardware_manager
if hardware_manager.distance_serial is None:
self.logger.error("[LASER] distance_serial 未初始化")
if self._serial is None:
self.logger.error("[LASER] 激光串口未初始化,请先调用 init()")
return (0.0, 0)
try:
# 清空缓冲区
try:
hardware_manager.distance_serial.read(-1)
self._serial.read(-1)
except:
pass
# 打开激光
@@ -973,7 +1005,7 @@ class LaserManager:
self._laser_turned_on = True
# time.sleep_ms(500) # 需要一定时间让激光稳定
# 发送测距查询命令
hardware_manager.distance_serial.write(config.DISTANCE_QUERY_CMD)
self._serial.write(config.DISTANCE_QUERY_CMD)
# time.sleep_ms(500) # 测试结果:这里的等待没有用!
self.turn_off_laser()
self._laser_turned_on = False
@@ -993,7 +1025,7 @@ class LaserManager:
return (0.0, 0)
# 尝试读取数据
response = hardware_manager.distance_serial.read(config.DISTANCE_RESPONSE_LEN)
response = self._serial.read(config.DISTANCE_RESPONSE_LEN)
# 如果读到完整数据,立即返回
if response and len(response) == config.DISTANCE_RESPONSE_LEN:

44
main.py
View File

@@ -88,11 +88,13 @@ def cmd_str():
# 2. 初始化硬件对象UART、I2C、ADC
hardware_manager.init_uart4g()
hardware_manager.init_distance_serial()
hardware_manager.init_bus()
hardware_manager.init_adc()
hardware_manager.init_at_client()
# 3. 初始化激光模块(串口 + 开机关闭激光防误触发)
laser_manager.init()
# 3. 初始化 INA226 电量监测芯片
init_ina226()
@@ -109,6 +111,9 @@ def cmd_str():
logger_manager.init_logging(log_level=logging.DEBUG)
logger = logger_manager.logger
# 补充:因为初始化的时候,激光会亮,先关了它
# laser_manager.turn_off_laser()
# 2. 从4G模块同步系统时间需要 at_client 已初始化)
sync_system_time_from_4g()
@@ -334,6 +339,7 @@ def cmd_str():
# 检测靶心
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
camera_manager.show(result_img)
# 计算偏移与距离(如果检测到靶心)
@@ -375,21 +381,7 @@ def cmd_str():
from shot_id_generator import shot_id_generator
shot_id = shot_id_generator.generate_id() # 不需要使用device_id
if logger:
logger.info(f"[MAIN] 射箭ID: {shot_id}")
# 保存图像(无论是否检测到靶心都保存):放入队列由 worker 异步保存,不阻塞主循环
enqueue_save_shot(
result_img,
center,
radius,
method,
ellipse_params,
(x, y),
distance_m,
shot_id=shot_id,
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
)
# 构造上报数据
inner_data = {
@@ -426,14 +418,30 @@ def cmd_str():
report_data = {"cmd": 1, "data": inner_data}
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
if logger:
# 闪一下激光(射箭反馈)
if config.FLASH_LASER_WHILE_SHOOTING:
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
# 保存图像(无论是否检测到靶心都保存):放入队列由 worker 异步保存,不阻塞主循环
enqueue_save_shot(
result_img,
center,
radius,
method,
ellipse_params,
(x, y),
distance_m,
shot_id=shot_id,
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
)
if center and radius:
logger.info(f"射箭事件已加入发送队列已检测到靶心ID: {shot_id}")
else:
logger.info(f"射箭事件已加入发送队列未检测到靶心已保存图像ID: {shot_id}")
# 闪一下激光(射箭反馈)
laser_manager.flash_laser(1000)
time.sleep_ms(100)
except Exception as e:

View File

@@ -42,6 +42,7 @@ class NetworkManager:
self._high_send_queue = []
self._normal_send_queue = []
self._queue_lock = threading.Lock()
self._send_event = threading.Event()
self._uart4g_lock = threading.Lock()
self._device_id = None
self._password = None
@@ -150,6 +151,7 @@ class NetworkManager:
self._high_send_queue.append(item)
else:
self._normal_send_queue.append(item)
self._send_event.set()
def _dequeue(self):
"""线程安全地从队列取出(内部方法)"""
@@ -493,6 +495,8 @@ class NetworkManager:
# 设置非阻塞模式(用于接收数据)
self._wifi_socket.setblocking(False)
# 加快消息发送
self._wifi_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self._tcp_connected = True
self.logger.info("[WIFI-TCP] TCP连接已建立")
@@ -1317,29 +1321,29 @@ class NetworkManager:
except:
ip = "error_getting_ip"
self.safe_enqueue({"result": "current_ip", "ip": ip}, 2)
elif inner_cmd == 7:
from ota_manager import ota_manager
if ota_manager.update_thread_started:
self.safe_enqueue({"result": "update_already_started"}, 2)
continue
# elif inner_cmd == 7:
# from ota_manager import ota_manager
# if ota_manager.update_thread_started:
# self.safe_enqueue({"result": "update_already_started"}, 2)
# continue
try:
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
except:
ip = None
# try:
# ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
# except:
# ip = None
if not ip:
self.safe_enqueue({"result": "ota_rejected", "reason": "no_wifi_ip"}, 2)
else:
# 注意direct_ota_download 需要 ota_url 参数
# 如果 ota_manager.ota_url 为 None需要从其他地方获取
ota_url_to_use = ota_manager.ota_url
if not ota_url_to_use:
self.logger.error("[OTA] cmd=7 但 OTA_URL 未设置")
self.safe_enqueue({"result": "ota_failed", "reason": "ota_url_not_set"}, 2)
else:
ota_manager._start_update_thread()
_thread.start_new_thread(ota_manager.direct_ota_download, (ota_url_to_use,))
# if not ip:
# self.safe_enqueue({"result": "ota_rejected", "reason": "no_wifi_ip"}, 2)
# else:
# # 注意direct_ota_download 需要 ota_url 参数
# # 如果 ota_manager.ota_url 为 None需要从其他地方获取
# ota_url_to_use = ota_manager.ota_url
# if not ota_url_to_use:
# self.logger.error("[OTA] cmd=7 但 OTA_URL 未设置")
# self.safe_enqueue({"result": "ota_failed", "reason": "ota_url_not_set"}, 2)
# else:
# ota_manager._start_update_thread()
# _thread.start_new_thread(ota_manager.direct_ota_download, (ota_url_to_use,))
elif inner_cmd == 41:
self.logger.info("[TEST] 收到TCP射箭触发命令")
self._manual_trigger_flag = True
@@ -1467,7 +1471,8 @@ class NetworkManager:
self.logger.error("十分钟无心跳ACK重连")
break
time.sleep_ms(50)
self._send_event.wait(timeout=0.05) # 0.05秒 = 50ms
self._send_event.clear()
self._tcp_connected = False
self.logger.error("连接异常2秒后重连...")

View File

@@ -116,9 +116,6 @@ class OTAManager:
with self._lock:
self._ota_mode = mode
# ==================== 业务方法 ====================
# 注意:这些方法会调用 ota.py 中的实际实现函数
# 为了保持向后兼容,实际的实现仍然在 ota.py 中
def is_archive_file(self, filename):
"""
@@ -628,7 +625,7 @@ class OTAManager:
download_dir = self.get_download_timestamp_dir()
return f"{download_dir}/{default_name}"
def download_file(self, url, filename):
def download_file_via_wifi(self, url, filename):
"""从指定 URL 下载文件根据文件类型自动选择文本或二进制模式并支持MD5校验"""
try:
self.logger.info(f"正在从 {url} 下载文件...")
@@ -694,47 +691,47 @@ class OTAManager:
except Exception as e:
return f"下载失败!发生未知错误: {e}"
def direct_ota_download(self, ota_url):
"""直接执行 OTA 下载(假设已有网络)"""
# def direct_ota_download(self, ota_url):
# """直接执行 OTA 下载(假设已有网络)"""
self._set_ota_url(ota_url)
self._start_update_thread()
# self._set_ota_url(ota_url)
# self._start_update_thread()
try:
if not ota_url:
from network import safe_enqueue
safe_enqueue({"result": "ota_failed", "reason": "missing_url"}, 2)
return
# try:
# if not ota_url:
# from network import safe_enqueue
# safe_enqueue({"result": "ota_failed", "reason": "missing_url"}, 2)
# return
parsed_url = urlparse(ota_url)
host = parsed_url.hostname
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
# parsed_url = urlparse(ota_url)
# host = parsed_url.hostname
# port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
if not network_manager.is_server_reachable(host, port, timeout=8):
from network import safe_enqueue
safe_enqueue({"result": "ota_failed", "reason": f"无法连接 {host}:{port}"}, 2)
return
# if not network_manager.is_server_reachable(host, port, timeout=8):
# from network import safe_enqueue
# safe_enqueue({"result": "ota_failed", "reason": f"无法连接 {host}:{port}"}, 2)
# return
downloaded_filename = self.get_filename_from_url(ota_url, default_name="main_tmp")
self.logger.info(f"[OTA] 下载文件将保存为: {downloaded_filename}")
self.logger.info(f"[OTA] 开始下载: {ota_url}")
result_msg = self.download_file(ota_url, downloaded_filename)
self.logger.info(f"[OTA] {result_msg}")
# downloaded_filename = self.get_filename_from_url(ota_url, default_name="main_tmp")
# self.logger.info(f"[OTA] 下载文件将保存为: {downloaded_filename}")
# self.logger.info(f"[OTA] 开始下载: {ota_url}")
# result_msg = self.download_file(ota_url, downloaded_filename)
# self.logger.info(f"[OTA] {result_msg}")
if "成功" in result_msg or "下载成功" in result_msg:
if self.apply_ota_and_reboot(ota_url, downloaded_filename):
return
else:
from network import safe_enqueue
safe_enqueue({"result": result_msg}, 2)
# if "成功" in result_msg or "下载成功" in result_msg:
# if self.apply_ota_and_reboot(ota_url, downloaded_filename):
# return
# else:
# from network import safe_enqueue
# safe_enqueue({"result": result_msg}, 2)
except Exception as e:
error_msg = f"OTA 异常: {str(e)}"
self.logger.error(error_msg)
from network import safe_enqueue
safe_enqueue({"result": "ota_failed", "reason": error_msg}, 2)
finally:
self._stop_update_thread()
# except Exception as e:
# error_msg = f"OTA 异常: {str(e)}"
# self.logger.error(error_msg)
# from network import safe_enqueue
# safe_enqueue({"result": "ota_failed", "reason": error_msg}, 2)
# finally:
# self._stop_update_thread()
def download_file_via_4g(self, url, filename,
total_timeout_ms=600000,
@@ -1221,7 +1218,7 @@ class OTAManager:
self.logger.info(f"[OTA] 下载文件将保存为: {downloaded_filename}")
self.logger.info(f"[NET] 已确认可访问 {host}:{port},开始下载...")
result = self.download_file(ota_url, downloaded_filename)
result = self.download_file_via_wifi(ota_url, downloaded_filename)
self.logger.info(result)
if "成功" in result or "下载成功" in result:

View File

@@ -14,6 +14,8 @@ VERSION = '1.2.7'
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
# 1.2.8 1 加快 wifi 下数据传输的速度。2 调整射箭时处理的逻辑优先上报数据再存照片之类的操作。3假如是用户打开激光的射箭触发后不再关闭激光因为是调瞄阶段