1.  4G OTA 下载的时候，为什么使用十六进制下载，读取 URC 事件？
    因为使用二进制下载的时候，经常会出现错误，并且会失败？然后最稳定传输的办法，是每次传输的时候，是分块，而且每次分块都要“删/建”http实例。推测原因是因为我们现在是直接传输文件的源代码，代码中含有了一些字符串可能和 AT指令重复，导致了 AT 模块在解释的时候出错。而使用 16 进制的方式，可以避免这个问题。因为十六进制直接把数据先转成了字符串，然后在设备端再把字符串转成数据，这样就不可能出现 AT的指令，从而减少了麻烦。
2.  4G OTA 下载的时候，为什么不用 AT 模块里 HTTPDLFILE 的指令？
    因为在测试中发现，使用 HTTPDLFILE，其实是下载到了 4G 模块内部，需要重新从模块内部转到存储卡，而且 4G 模块的存储较小，大概只有 40k，所以还需要分块来下载和转存，比较麻烦，于是最终使用了使用读取串口事件的模式。
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.  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

8. 自动关机：
    hardware中设定了开停表，然后再增加了获取idle的时间。
    自动关机的时机： 超过配置的idle时长，
    禁止自动关机的情况：1.校准中，2.OTA中
    重启计时的时机：1.校准完成，2.命令触发射箭，3.真实触发射箭，4.初始化完成
9. Wifi网络监控：
有两次发现wifi网络下，有些消息发送很慢，但具体是什么缘故还不清楚，现在增加了wifi网络下的检测，并一旦发现wifi的网络质量差，就会切换到4G。
WiFi 连接成功
    ↓
启动后台监测线程
    ↓
每 5 秒循环：
    测量 RTT (1 样本，600ms timeout)
    获取 RSSI
    更新缓存
    判断是否差：
        - RTT >= 600ms → 差
        - RTT >= 350ms 且 RSSI <= -80dBm → 差
    ↓
如果质量差：
    快速重试2次，如果其中任意一次网络恢复了，继续使用wifi。否则，
    调用 _switch_to_4g_due_to_poor_wifi()
    关闭 WiFi socket
    重置连接状态
    尝试切换到 4G
    ↓
上层检测到连接断开：
    重新 connect_server() → 自动选择 4G

10. 现在使用的相机，其实是支持更大的分辨率的，比如说1920*1280，但是由于我们的图像处理，拍照处理之后很容易触发OOM。

11. 环数计算流程：
现在设备侧的目标是：算出箭点相对靶心的偏移(dx,dy)，单位是物理厘米（cm），然后把它作为 x,y 上报给后端；后端再去算环。
设备侧本身不直接算环数，它算的是偏移与距离，并上报。

算法流程（一次射箭从触发到上报）
1) 触发后取一帧图
    在 process_shot() 里读取相机帧并调用 analyze_shot(frame)
2) 确定激光点（laser_point） 

    analyze_shot() 第一步先确定激光点 (x,y)（像素坐标）：

   硬编码：config.HARDCODE_LASER_POINT=True → 用 laser_manager.laser_point
   已校准：laser_manager.has_calibrated_point() → 用校准值
   动态模式：先 detect_circle_v3(frame, None) 粗估距离，再根据距离反推激光点
   代码在：

   if config.HARDCODE_LASER_POINT:
       ...
   elif laser_manager.has_calibrated_point():
       ...
   else:
       _, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
       distance_m_first = estimate_distance(best_radius1_temp) ...
       laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
3) 优先走三角形路径（成功就直接用于上报 x/y）
如果 config.USE_TRIANGLE_OFFSET=True，先尝试识别靶面四角三角形标记：

if getattr(config, "USE_TRIANGLE_OFFSET", False):
    K, dist_coef, pos = _get_triangle_calib()
    img_rgb = image.image2cv(frame, False, False)
    tri = try_triangle_scoring(img_rgb, (x, y), pos, K, dist_coef, ...)
    if tri.get("ok"):
        return {... "dx": tri["dx_cm"], "dy": tri["dy_cm"], "distance_m": tri.get("distance_m"), ...}
这一步里 try_triangle_scoring() 做了两件事（都在 triangle_target.py）：

单应性（homography）：把激光点从图像坐标映射到靶面坐标系，得到(dx,dy)（cm）
PnP：用识别到的角点与相机标定，估算 相机到靶的距离 distance_m
关键代码：

ok_h, tx, ty, _H = homography_calibration(...)
out["dx_cm"] = tx
out["dy_cm"] = -ty
out["distance_m"] = dist_m
out["distance_method"] = "pnp_triangle"
注意：这里 dy_cm 取了负号，是为了和现网约定一致（laser_manager.compute_laser_position 的坐标方向）。

4) 三角形失败 → 回退圆形/椭圆靶心检测（兜底）
如果三角形不可用或识别失败，就走传统靶心检测：

detect_circle_v3(frame, laser_point) 找黄心/红心、半径、椭圆参数
用 laser_manager.compute_laser_position() 把像素偏移换算成厘米偏移(dx,dy)
在 shoot_manager.py：

result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
if center and radius:
    dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
    distance_m = estimate_distance(best_radius1) ...
在 laser_manager.compute_laser_position()（核心换算逻辑）：

r = radius * 5
target_x = (lx-cx)/r*100
target_y = (ly-cy)/r*100
return (target_x, -target_y)
这里 (像素差)/(radius*5)*100 是你们旧约定下的“像素→厘米”比例模型（并且 y 方向同样取负号）。

5) 上报数据：把(dx,dy) 作为 x/y 发给后端
最终上报发生在 process_shot()，直接把 dx,dy 填到 inner_data["x"],["y"]：

srv_x = round(float(dx), 4) if dx is not None else 200.0
srv_y = round(float(dy), 4) if dy is not None else 200.0
inner_data = {
    "x": srv_x,
    "y": srv_y,
    "d": round((distance_m or 0.0) * 100),
    "m": method if method else "no_target",
    "offset_method": offset_method,
    "distance_method": distance_method,
    ...
}
network_manager.safe_enqueue(...)
x,y：物理厘米（cm）
d：相机到靶距离（m→cm，乘 100；三角形成功时来自 PnP）
m/offset_method/distance_method：标记本次用的算法路径（triangle / yellow / pnp 等）
后端收到 x,y 后，再用你之前给的 Go 公式 CalculateRingNumber(x,y,tenRingRadius) 计算环数。

你现在的“环数计算”实际依赖关系
最好路径（快+稳）：三角形 → dx,dy（单应性） + distance_m（PnP）
兜底路径：圆/椭圆靶心 → dx,dy（基于黄心半径比例/透视校正） + distance_m（黄心半径估距）

12. 4g模块上传文件：

Upload images from MaixCam to Qiniu cloud via ML307R 4G module's AT commands. The HTTP body requires multipart/form-data with real CR/LF bytes (0x0D 0x0A) in boundaries.
Methods Tried
#	Method	AT Commands	Result	Root Cause
1	Raw binary, no encoding	MHTTPCONTENT with raw bytes + length param	ERROR at first chunk	CR/LF in binary data terminates AT command parser
2	Encoding mode 2 (escape)	MHTTPCFG="encoding",0,2 + \r\n escapes	Server 400 Bad Request	Module sends literal text \r\n to server, NOT actual 0x0D 0x0A bytes. Multipart body is garbled
3	Encoding mode 1 (hex)	MHTTPCFG="encoding",0,1 + hex-encoded data	CME ERROR: 650/50	Firmware doesn't properly support hex mode for MHTTPCONTENT
4	No chunked mode	Skip MHTTPCFG="chunked"	CME ERROR: 65	Module requires chunked mode to accept MHTTPCONTENT at all
5	Single large MHTTPCONTENT	All data in one command (2793 bytes)	+MHTTPURC: "err",0,5 (timeout)	Possible buffer limit; module hangs then times out
6	Per-chunk HTTP instance (OTA style)	CREATE→POST→DELETE per chunk	Not feasible	Each instance = separate HTTP request; Qiniu needs complete body in single POST
Conclusion: AT HTTP layer (MHTTPCONTENT) is fundamentally broken for binary uploads.
The Solution: Raw TCP Socket (MIPOPEN + MIPSEND)
Bypass the AT HTTP layer entirely. Open a raw TCP connection and send a hand-crafted HTTP POST:
plaintext
AT+MIPCLOSE=3                                    // Clean up old socket
AT+MIPOPEN=3,"TCP","upload.qiniup.com",80        // Raw TCP connection
AT+MIPSEND=3,1024  →  ">"  →  [raw bytes]  →  OK  // Binary-safe!
AT+MIPSEND=3,1024  →  ">"  →  [raw bytes]  →  OK
AT+MIPSEND=3,766   →  ">"  →  [raw bytes]  →  OK
// Response: +MIPURC: "rtcp",3,<len>,HTTP/1.1 200 OK...
AT+MIPCLOSE=3
Why it works:
MIPSEND enters prompt mode (>) — after the >, the AT parser treats ALL bytes as data, including CR/LF
We construct the complete HTTP request ourselves (headers + Content-Length + multipart body) with real CRLF bytes

Key bug found during integration: _send_chunk() wrapped calls in self.at._cmd_lock, but self.at.send() also acquires the same lock internally — threading.Lock() is not reentrant, causing deadlock. Fixed by removing the outer lock (the network_manager.get_uart_lock() already provides thread safety).Trade-off: UART is locked during the entire upload, so heartbeats pause. For small JPEG files (~2-80KB), this is 5-20 seconds — acceptable if server heartbeat timeout is generous


13. 算环数算法1：「黄心 + 红心」椭圆/圆：主要在 vision.py 的 detect_circle_v3() 里完成：颜色先用 HSV 做掩码，再在轮廓上做面积、圆度筛选，黄圈用椭圆拟合，红圈预先筛成候选，最后用几何关系配对。

    1. 黄色怎么判、范围是什么？
    图像先转 HSV（cv2.COLOR_RGB2HSV，注意输入是 RGB）。
    饱和度 S 整体乘 1.1 并限制在 0–255（让黄色更「显」一点）。
    黄色 inRange（OpenCV HSV，H 多为 0–179）：
    通道	下限	上限
    H       7       32
    S       80      255
    V       0       255
    在黄掩码上找轮廓后，还要满足：面积 > 50，圆度 > 0.7（circularity = 4π·面积/周长²），且点数 ≥5 才 fitEllipse 当黄心椭圆。

    2. 红色怎么判、范围是什么？
    红色在 HSV 里跨 0°，所以用 两段 H 做并集：
    两段分别是：
    H 0–10，S 80–255，V 0–255
    H 170–180，S 80–255，V 0–255
    红轮廓候选：面积 > 50，圆度 > 0.6（比黄略松），再拟合椭圆或最小外接圆得到圆心和半径。

    3. 「黄心」和「红心」怎样算一对？（几何范围）
    对每个黄圈，在红色候选里找第一个满足：

    两圆心距离 dist_centers < yellow_radius * 1.5
    红半径 red_radius > yellow_radius * 0.8（红在外圈、略大）
                dist_centers = math.hypot(ddx, ddy)
                if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
    小结：黄色 = HSV H∈[7,32]、S≥80（且 S 放大 1.1）+ 形态学闭运算 + 面积/圆度；红色 = 两段 H（0–10 与 170–180）、S≥80 + 闭运算 + 面积/圆度；配对用 同心/包含 的距离与半径比例阈值。若你还关心 laser_manager.py 里「激光红点」的另一套阈值（LASER_*），那是另一条链路，和靶心黄/红 HSV 可以分开看。

14. 算环数算法2：
    使用单应性矩阵计算：镜头中心点（照片中心像素）到虚拟平面的转换。它不需要知道相机在 3D 空间中的具体位置，直接通过单应性矩阵 H的逆运算，将 2D 像素“翻译”成虚拟平面上的 2D 坐标。

    一、转换的本质：2D 到 2D 的“查字典”
    单应性变换（Homography）是平面到平面的映射。它不处理 3D 空间中的“投影线”，而是直接建立图像像素 (u,v)​ 与虚拟平面坐标 (x,y)​ 的一一对应关系。
    你可以把单应性矩阵 H想象成一本“翻译字典”：
    正变换 H：已知靶纸上的真实位置 (x,y)，查字典得到它在照片上哪个像素 (u,v)。
    逆变换 H−1：已知照片上的像素 (u,v)（如镜头中心点），查字典反推它在靶纸上的真实位置 (x,y)。
    这个“虚拟平面”就是你的靶纸平面（Z=0 的世界坐标系）。算法没有在物理上移动任何点，只是在做坐标系的换算。

    二、详细步骤：镜头中心点如何“落地”

    相机分辨率是 640x480，镜头中心点（光轴与图像的交点）通常是 (u0,v0)=(320,240)。
    1. 输入：镜头中心点（像素）
    2. 核心运算：乘以逆矩阵
    通过 4 个黑色三角形的角点（已知真实坐标）计算出了单应性矩阵 H。现在使用它的逆矩阵 H−1
    3. 输出：虚拟平面上的落点（物理坐标）
    计算后，你会得到：(xhit,yhit)
    这就是镜头中心点对应的靶纸上的真实位置（单位：毫米）。
    4. 计算环数
    由于虚拟平面原点 (0,0)就是靶纸圆心，直接计算欧氏距离。​
    这个 d就是箭着点偏离圆心的真实物理距离，直接用于环数判定。