diff --git a/design_doc/solution_record.md b/design_doc/solution_record.md index 0ca7831..25524eb 100644 --- a/design_doc/solution_record.md +++ b/design_doc/solution_record.md @@ -194,3 +194,31 @@ m/offset_method/distance_method:标记本次用的算法路径(triangle / ye 你现在的“环数计算”实际依赖关系 最好路径(快+稳):三角形 → 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,,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 \ No newline at end of file diff --git a/design_doc/todo.md b/design_doc/todo.md index 7c8a2b9..0fa3c7f 100644 --- a/design_doc/todo.md +++ b/design_doc/todo.md @@ -24,8 +24,8 @@ r = hardware_manager.at_client.send(f'AT+MSSLCFG="auth",{ssl_id},0', "OK", 3000) 这些在 config.py 是明文: SERVER_IP = "stcp.shelingxingqiu.com"SERVER_PORT = 50005HTTP_URL = "http://ws.shelingxingqiu.com"HTTP_API_PATH = "/home/shoot/device_fire/arrow/fire" 注意:即使你把它们藏进 .so,攻击者仍可能通过抓包/观察 DNS/SNI/流量拿到域名或 IP,所以“隐藏”只能降低静态分析风险,不能替代鉴权/签名。 -2.2 WiFi 凭证落盘位置 -你会把 SSID/密码写到 /boot/wifi.ssid 和 /boot/wifi.pass(network.py/wifi.py 都有)。拿到设备存储就能读到明文密码,这属于设备侧安全问题。 + + 2.3 日志/调试信息泄露 你仓库里 .cursor/debug.log* 已经记录了完整的 AT+MIPOPEN ... host:port 等信息;如果这些被打进包或被用户导出,也会泄露通信细节。 你发布包里“真正会被带走的敏感内容” @@ -37,5 +37,9 @@ files: - app.yaml - at_client.py - camera_manager.py - config.py - hardware 把“password=device_id+.” 改为真正的 per-device secret(设备出厂烧录,服务端保存;或服务端下发公钥,设备用私钥签名) 所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用) OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用) -TLS 必须做证书校验/最好做 pinning(至少别用 auth=0) -如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。 \ No newline at end of file + +如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。 + + + +https://wiki.sipeed.com/maixpy/doc/zh/pro/compile_os.html \ No newline at end of file diff --git a/network.py b/network.py index 0569212..18cdd63 100644 --- a/network.py +++ b/network.py @@ -1406,6 +1406,112 @@ class NetworkManager: self.logger.error(f"[LOG_UPLOAD] 上传异常: {e}") self.safe_enqueue({"result": "log_upload_failed", "reason": str(e)[:100]}, 2) + def _upload_image_file(self, image_path, upload_url, upload_token, key, shoot_id, outlink): + """上传图片文件到指定URL(自动检测网络类型,WiFi使用requests,4G使用AT HTTP命令) + + Args: + image_path: 本地图片文件路径 + upload_url: 上传目标URL,例如 "https://upload.qiniup.com" + upload_token: 上传token + key: 文件key,例如 "shootPic/123456.bmp" + shoot_id: 射击ID + outlink: 外链域名(可选,用于构建访问URL) + """ + # 自动检测网络类型,选择上传路径 + if self._network_type == "wifi" and self.is_wifi_connected(): + mode = "wifi" + else: + mode = "4g" + + self.logger.info(f"[IMAGE_UPLOAD] Using {mode} path, image: {image_path}") + + try: + if mode == "wifi": + # ---- WiFi path: 使用 requests 库上传 ---- + import requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + with open(image_path, 'rb') as f: + files = {'file': (os.path.basename(image_path), f, 'application/octet-stream')} + data = {'token': upload_token, 'key': key} + # 测试:将HTTPS转为HTTP + wifi_upload_url = upload_url.replace('https://', 'http://', 1) + self.logger.info(f"[IMAGE_UPLOAD] WiFi upload URL: {wifi_upload_url}") + response = requests.post(wifi_upload_url, files=files, data=data, timeout=120, verify=False) + response.raise_for_status() + result_json = response.json() + uploaded_key = result_json.get('key', key) + + self.logger.info(f"[IMAGE_UPLOAD] WiFi upload ok: key={uploaded_key}") + + access_url = None + if outlink: + access_url = f"https://{outlink}/{uploaded_key}" + + response_data = { + "result": "image_upload_ok", + "shootId": shoot_id, + "key": uploaded_key, + "via": "wifi", + } + if access_url: + response_data["url"] = access_url + + self.safe_enqueue(response_data, 2) + + else: + # ---- 4G path: 使用 FourGUploadManager AT命令上传 ---- + import importlib.util + spec = importlib.util.spec_from_file_location( + "four_g_upload_manager", + os.path.join(os.path.dirname(__file__), "4g_upload_manager.py") + ) + upload_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(upload_module) + FourGUploadManager = upload_module.FourGUploadManager + + # 实例化4G上传管理器 + uploader = FourGUploadManager(hardware_manager.at_client) + + # 执行上传 + result = uploader.upload_image(image_path, upload_url, upload_token, key) + + if result.get("success"): + uploaded_key = result.get("key", key) + self.logger.info(f"[IMAGE_UPLOAD] 4G upload ok: key={uploaded_key}") + + access_url = None + if outlink: + access_url = f"https://{outlink}/{uploaded_key}" + + response_data = { + "result": "image_upload_ok", + "shootId": shoot_id, + "key": uploaded_key, + "via": "4g", + } + if access_url: + response_data["url"] = access_url + + self.safe_enqueue(response_data, 2) + else: + error_msg = result.get("error", "unknown_error") + self.logger.error(f"[IMAGE_UPLOAD] 4G upload failed: {error_msg}") + self.safe_enqueue({ + "result": "image_upload_failed", + "shootId": shoot_id, + "reason": error_msg[:100] + }, 2) + + except Exception as e: + self.logger.error(f"[IMAGE_UPLOAD] upload exception ({mode}): {e}") + self.safe_enqueue({ + "result": "image_upload_failed", + "shootId": shoot_id, + "reason": str(e)[:100] + }, 2) + def generate_token(self, device_id): """生成用于 HTTP 接口鉴权的 Token(HMAC-SHA256)""" SALT = "shootMessageFire" @@ -1592,6 +1698,64 @@ class NetworkManager: self.safe_enqueue({'data':{'l': len(self._raw_line_data), 'v': v}, 'cmd': 41}) self.logger.info(f"已下载{len(self._raw_line_data)} 全部:{t} 版本:{v}") + elif logged_in and msg_type == 100: + self.logger.info(f"[IMAGE_UPLOAD] 收到图片上传命令 {body}") + if isinstance(body, dict): + + upload_url = body.get("uploadUrl") + upload_token = body.get("token") + shoot_id = body.get("shootId") + outlink = body.get("outlink", "") + + hardware_manager.start_idle_timer() # 重新计时 + + # 验证必需字段 + if not upload_url or not upload_token or not shoot_id: + self.logger.error("[IMAGE_UPLOAD] 缺少必需参数: uploadUrl, token 或 shootId") + self.safe_enqueue({"result": "image_upload_failed", "reason": "missing_params"}, 2) + else: + self.logger.info(f"[IMAGE_UPLOAD] 收到图片上传命令,shootId: {shoot_id}") + # 查找文件名中包含 shoot_id 的图片文件(文件名格式:shot_{shoot_id}_*.bmp) + image_extensions = ('.bmp', '.jpg', '.jpeg', '.png') + photo_dir = config.PHOTO_DIR + target_image = None + try: + if os.path.isdir(photo_dir): + # 优先查找文件名中包含 shoot_id 的图片 + matched_images = [ + f for f in os.listdir(photo_dir) + if f.lower().endswith(image_extensions) and shoot_id in f + ] + if matched_images: + # 按修改时间排序,取最新的匹配文件 + matched_images.sort( + key=lambda f: os.path.getmtime(os.path.join(photo_dir, f)), + reverse=True + ) + target_image = os.path.join(photo_dir, matched_images[0]) + self.logger.info(f"[IMAGE_UPLOAD] 找到匹配shootId的图片: {matched_images[0]}") + else: + self.logger.warning(f"[IMAGE_UPLOAD] 未找到包含shootId={shoot_id}的图片文件") + except Exception as e: + self.logger.error(f"[IMAGE_UPLOAD] 查找图片失败: {e}") + + if not target_image: + self.logger.error(f"[IMAGE_UPLOAD] 未找到shootId={shoot_id}对应的图片文件") + self.safe_enqueue({"result": "image_upload_failed", "reason": "no_image_found", "shootId": shoot_id}, 2) + else: + # 构建上传key + ext = os.path.splitext(target_image)[1].lower() + key = f"shootPic/{self.device_id}/{shoot_id}{ext}" + self.logger.info(f"[IMAGE_UPLOAD] 准备上传: {target_image} -> {key}") + + # 在新线程中执行上传,避免阻塞主循环 + import _thread + _thread.start_new_thread( + self._upload_image_file, + (target_image, upload_url, upload_token, key, shoot_id, outlink) + ) + # 立即返回已入队确认 + self.safe_enqueue({"result": "image_upload_queued", "shootId": shoot_id}, 2) # 处理业务指令 elif logged_in and isinstance(body, dict): inner_cmd = None @@ -1753,6 +1917,7 @@ class NetworkManager: self._upload_log_file, (upload_url, wifi_ssid, wifi_password, include_rotated, max_files, archive_format) ) + else: # data的结构不是 dict self.logger.info(f"[NET] body={body}, {time.time()}") else: