diff --git a/4g_download_manager.py b/4g_download_manager.py new file mode 100644 index 0000000..8ba8e85 --- /dev/null +++ b/4g_download_manager.py @@ -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_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() diff --git a/config.py b/config.py index fd9b515..94a78ed 100644 --- a/config.py +++ b/config.py @@ -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 diff --git a/design_doc/solution_record.md b/design_doc/solution_record.md index 1e7629d..8dcbb82 100644 --- a/design_doc/solution_record.md +++ b/design_doc/solution_record.md @@ -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. \ No newline at end of file +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)(接收用非阻塞) + 4G:AT+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 diff --git a/hardware.py b/hardware.py index 90c5de7..e87da62 100644 --- a/hardware.py +++ b/hardware.py @@ -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 diff --git a/laser_manager.py b/laser_manager.py index 49bdde0..b05f2c5 100644 --- a/laser_manager.py +++ b/laser_manager.py @@ -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: diff --git a/main.py b/main.py index b0deeff..21cbc5c 100644 --- a/main.py +++ b/main.py @@ -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,7 +339,8 @@ def cmd_str(): # 检测靶心 result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point) - camera_manager.show(result_img) + if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING: + camera_manager.show(result_img) # 计算偏移与距离(如果检测到靶心) if center and radius: @@ -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 center and radius: - logger.info(f"射箭事件已加入发送队列(已检测到靶心),ID: {shot_id}") - else: - logger.info(f"射箭事件已加入发送队列(未检测到靶心,已保存图像),ID: {shot_id}") - # 闪一下激光(射箭反馈) - laser_manager.flash_laser(1000) + 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}") + + time.sleep_ms(100) except Exception as e: diff --git a/network.py b/network.py index 1970dfc..cc351bc 100644 --- a/network.py +++ b/network.py @@ -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秒后重连...") diff --git a/ota_manager.py b/ota_manager.py index 451ff34..35e4a3e 100644 --- a/ota_manager.py +++ b/ota_manager.py @@ -115,10 +115,7 @@ class OTAManager: """设置OTA模式(内部方法)""" 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, @@ -766,7 +763,7 @@ class OTAManager: return False, "bad_url (no host)" # 很多 ML307R 的 MHTTP 对 https 不稳定;对已知域名做降级 - + if isinstance(url, str) and url.startswith("https://static.shelingxingqiu.com/"): base_url = "https://static.shelingxingqiu.com" # TODO:使用https,看看是否能成功 @@ -862,7 +859,7 @@ class OTAManager: downgraded_base_url = base_url.replace("https://", "http://") resp = hardware_manager.at_client.send(f'AT+MHTTPCREATE="{downgraded_base_url}"', "OK", 8000) hid = _parse_httpid(resp) - + return hid, resp def _fetch_range_into_buf(start, want_len, out_buf, full_reset=False): @@ -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: diff --git a/version.py b/version.py index 1904b68..36f71de 100644 --- a/version.py +++ b/version.py @@ -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)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段 +