1 Commits
Author SHA1 Message Date
yrx ffeb82cf8f 18 2026-09-01 11:48:26 +08:00
33 changed files with 1640 additions and 1196 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -4
View File
@@ -1,6 +1,6 @@
id: t11
name: t11
version: 3.1.16
version: 2.17.14
author: t11
icon: ''
desc: t11
@@ -18,8 +18,12 @@ files:
- laser_manager.py
- logger_manager.py
- main.py
- model_285484.cvimodel
- model_285484.mud
- model_317828.cvimodel
- model_317828.mud
- model_317211.cvimodel
- model_317211.mud
- model_270820.cvimodel
- model_270820.mud
- network.py
- ota_curl.sh
- ota_manager.py
@@ -29,7 +33,6 @@ files:
- shoot_manager.py
- shot_id_generator.py
- target_roi_yolo.py
- tcp_messages_pb2.py
- time_sync.py
- triangle_positions.json
- triangle_target.py
Binary file not shown.
+1 -17
View File
@@ -8,15 +8,6 @@ import threading
import config
from logger_manager import logger_manager
_USE_CV = False
try:
import cv2
import numpy as np
from maix import image as _maix_image
_USE_CV = True
except ImportError:
pass
class CameraManager:
"""相机管理器(单例)"""
@@ -66,12 +57,6 @@ class CameraManager:
with self._camera_lock:
if self._camera is None:
self._camera = camera.Camera(width, height)
v_flip = getattr(config, 'CAMERA_V_FLIP', False)
h_mirror = getattr(config, 'CAMERA_H_MIRROR', False)
if v_flip:
self._camera.vflip(1)
if h_mirror:
self._camera.hmirror(1)
return self._camera
@@ -116,8 +101,7 @@ class CameraManager:
with self._camera_lock:
if self._camera is None:
self.init_camera()
frame = self._camera.read()
return frame
return self._camera.read()
def show(self, image):
"""
+11 -10
View File
@@ -15,8 +15,6 @@ LOCAL_FILENAME = APP_DIR + "/main_tmp.py"
# 相机初始化分辨率(CameraManager / main.py 使用)
CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
CAMERA_V_FLIP = True # 摄像头垂直翻转(上下颠倒时设为 True)
CAMERA_H_MIRROR = True # 摄像头水平镜像(左右反了时设为 True)
# 三角形检测缩图比例:默认按相机最长边缩到 1/2(性能更稳;可按需调整)
# 取值范围建议 (0.25 ~ 1.0];1.0 表示不缩图
@@ -50,7 +48,7 @@ WIFI_CONFIG_AP_IP = "192.168.66.1" # 与 MaixPy Wifi.start_ap 默认一
# ===== TCP over SSL(TLS) 配置 =====
USE_TCP_SSL = True # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL
TCP_LINK_ID = 2 #
TCP_SSL_PORT = 50007 # TLS 端口(不一定必须 443,以服务器为准)
TCP_SSL_PORT = 50006 # TLS 端口(不一定必须 443,以服务器为准)
# SSL profile
SSL_ID = 1 # ssl_id=1
@@ -241,10 +239,10 @@ TRIANGLE_BLACKHAT_KERNEL_FRAC = 0.018 # 核大小 ≈ min(h,w)*frac,取奇数
# ── YOLO(NPU) 靶环 ROI → 裁剪后再跑三角形(减小 CPU 处理面积)──────────────────
# 日志里 net_in=W×H 来自 .mud 模型(det.input_width/height),不是这里配置的。
TRIANGLE_YOLO_ROI_ENABLE = True
TRIANGLE_YOLO_MODEL_PATH = APP_DIR + "/model_270139.mud"
TRIANGLE_YOLO_MODEL_PATH = APP_DIR + "/model_317211.mud"
# 参与 ROI 的类别:多类时只填「整靶/靶环」的 id;不要填角标类,否则 union 仍可对,但 largest 会偏小。
TRIANGLE_YOLO_RING_CLASS_IDS = (0,)
TRIANGLE_YOLO_CONF_TH = 0.7
TRIANGLE_YOLO_CONF_TH = 0.9
TRIANGLE_YOLO_IOU_TH = 0.45
# YOLO 首次/临界帧可能在高阈值下 0 框;启用后仅在 0 候选时用较低阈值重试一次。
# 后续仍会经过 min_box_side、ROI aspect、三角形几何校验,避免直接放大假阳性。
@@ -271,9 +269,9 @@ TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
# YOLO target size classification: class 0=20cm, class 1=40cm.
TARGET_CLASS_YOLO_ENABLE = True
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_285484.mud"
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_317828.mud"
TARGET_CLASS_YOLO_LABELS = (20, 40)
TARGET_CLASS_YOLO_CONF_TH = 0.50
TARGET_CLASS_YOLO_CONF_TH = 0.66
TARGET_CLASS_YOLO_IOU_TH = 0.45
TARGET_CLASS_YOLO_RETRY_ON_EMPTY = False
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
@@ -333,14 +331,17 @@ LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
# ==================== 图像保存配置 ====================
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
SAVE_IMAGE_ON_FAILURE = False # 检测失败时是否强制保存图像(供调试测试用)
PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000
SAVE_RAW_IMAGE_ENABLED = False # 原图保存功能保留,但当前关闭
RAW_IMAGE_DIR = PHOTO_DIR + "/raw"
RAW_IMAGE_MAX_IMAGES = MAX_IMAGES
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 关闭拍摄实时显示
# ==================== OTA配置 ====================
MAX_BACKUPS = 5
-7
View File
@@ -57,16 +57,9 @@ PYBIND11_MODULE(archery_netcore, m) {
"Pack TCP packet: header (len+type+checksum) + JSON body",
py::arg("msg_type"), py::arg("body_dict"));
m.def("make_packet_pb", &netcore::make_packet_pb,
"Pack TCP packet: header (len+type+checksum) + raw bytes body (for protobuf)",
py::arg("msg_type"), py::arg("body_bytes"));
m.def("parse_packet", &netcore::parse_packet,
"Parse TCP packet, return (msg_type, body_dict)");
m.def("parse_packet_raw", &netcore::parse_packet_raw,
"Parse TCP packet, return (msg_type, body_bytes) without JSON parsing");
m.def("get_config", &get_config, "Get system configuration");
m.def(
-59
View File
@@ -51,43 +51,6 @@ namespace netcore {
return py::bytes(reinterpret_cast<const char*>(packet.data()), packet.size());
}
// 打包 TCP 数据包 (raw bytes body, 用于 protobuf)
py::bytes make_packet_pb(int msg_type, py::bytes body_bytes) {
netcore::log_debug(std::string("make_packet_pb msg_type=") + std::to_string(msg_type));
py::buffer_info buf = py::buffer(body_bytes).request();
uint32_t body_len = buf.size;
uint32_t checksum = body_len + msg_type;
std::vector<uint8_t> packet;
packet.reserve(12 + body_len);
// body_len (big-endian, 4 bytes)
packet.push_back((body_len >> 24) & 0xFF);
packet.push_back((body_len >> 16) & 0xFF);
packet.push_back((body_len >> 8) & 0xFF);
packet.push_back(body_len & 0xFF);
// msg_type (big-endian, 4 bytes)
packet.push_back((msg_type >> 24) & 0xFF);
packet.push_back((msg_type >> 16) & 0xFF);
packet.push_back((msg_type >> 8) & 0xFF);
packet.push_back(msg_type & 0xFF);
// checksum (big-endian, 4 bytes)
packet.push_back((checksum >> 24) & 0xFF);
packet.push_back((checksum >> 16) & 0xFF);
packet.push_back((checksum >> 8) & 0xFF);
packet.push_back(checksum & 0xFF);
// 追加 body bytes
const uint8_t* body_ptr = static_cast<const uint8_t*>(buf.ptr);
packet.insert(packet.end(), body_ptr, body_ptr + body_len);
netcore::log_debug(std::string("make_packet_pb done bytes=") + std::to_string(packet.size()));
return py::bytes(reinterpret_cast<const char*>(packet.data()), packet.size());
}
// 解析 TCP 数据包
py::tuple parse_packet(py::bytes data) {
// 1) 转换为 bytes view
@@ -147,26 +110,4 @@ namespace netcore {
return py::make_tuple(py::int_(msg_type), raw_dict);
}
}
// 解析 TCP 数据包 -> (msg_type, body_bytes) 不做 JSON 解析
py::tuple parse_packet_raw(py::bytes data) {
py::buffer_info buf = py::buffer(data).request();
if (buf.size < 12) {
return py::make_tuple(py::none(), py::none());
}
const uint8_t* ptr = static_cast<const uint8_t*>(buf.ptr);
uint32_t body_len = (ptr[0] << 24) | (ptr[1] << 16) | (ptr[2] << 8) | ptr[3];
uint32_t msg_type = (ptr[4] << 24) | (ptr[5] << 16) | (ptr[6] << 8) | ptr[7];
uint32_t expected_len = 12 + body_len;
if (buf.size < expected_len) {
return py::make_tuple(py::none(), py::none());
}
// 返回原始 body bytes(不做 JSON 解析)
py::bytes body_bytes(reinterpret_cast<const char*>(ptr + 12), body_len);
return py::make_tuple(py::int_(msg_type), body_bytes);
}
}
+2 -9
View File
@@ -7,15 +7,8 @@ namespace py = pybind11;
namespace netcore {
// 打包 TCP 数据包 (JSON body)
// 打包 TCP 数据包
py::bytes make_packet(int msg_type, py::dict body_dict);
// 打包 TCP 数据包 (raw bytes body, 用于 protobuf)
py::bytes make_packet_pb(int msg_type, py::bytes body_bytes);
// 解包 TCP 数据包 -> (msg_type, body_dict)
// 解包 TCP 数据包
py::tuple parse_packet(py::bytes data);
// 解包 TCP 数据包 -> (msg_type, body_bytes) 不做 JSON 解析
py::tuple parse_packet_raw(py::bytes data);
}
View File
+18 -125
View File
@@ -13,9 +13,7 @@ from maix import camera, display, image, app, time, uart, pinmap, i2c
from maix.peripheral import adc
import _thread
import os
import sys
import json
import shutil
import time as wall_time
# 导入新模块
@@ -138,7 +136,6 @@ def cmd_str():
sync_system_time_from_4g()
# 2.1 WiFi 热点配网兜底:仅当 STA 与 4G 均不可用时起 AP + HTTP;提交后删 /boot/wifi.ap、建 wifi.sta 并 reboot
_ota_pending_path = f"{config.APP_DIR}/ota_pending.json"
try:
from wifi_config_httpd import maybe_start_wifi_ap_fallback
@@ -174,8 +171,10 @@ def cmd_str():
and bool(getattr(config, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True))
)
_preload_yolo = _preload_yolo or _need_black_preload or _need_target_preload
if _preload_yolo:
if _preload_yolo and not os.path.exists(f"{config.APP_DIR}/ota_pending.json"):
preload_yolo_detector(logger)
elif _preload_yolo and logger:
logger.warning("[YOLO] ota_pending.json found; skip model preload until rollback check")
except Exception as e:
if logger:
logger.warning(f"[YOLO-ROI] 启动预加载异常(不影响后续射箭): {e}")
@@ -256,57 +255,12 @@ def cmd_str():
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
network_manager.read_device_id()
# 4.1 检查是否有 OTA 待更新文件(从临时目录移动到实际目录)
staging_dir = f"{config.APP_DIR}/ota_staging"
if os.path.exists(staging_dir):
try:
moved_count = 0
for root, dirs, files in os.walk(staging_dir):
for f in files:
src = os.path.join(root, f)
rel = os.path.relpath(src, staging_dir)
dest = os.path.join(config.APP_DIR, rel)
dest_dir = os.path.dirname(dest)
if dest_dir:
try:
os.makedirs(dest_dir, exist_ok=True)
except:
pass
try:
shutil.copy2(src, dest)
moved_count += 1
except Exception as e:
if logger:
logger.error(f"[OTA] 移动文件失败 {rel}: {e}")
# 删除临时目录
try:
shutil.rmtree(staging_dir, ignore_errors=True)
except:
pass
if logger:
logger.info(f"[OTA] 已从临时目录更新 {moved_count} 个文件,重启应用...")
# 清理硬件资源,然后重启应用
try:
laser_manager.turn_off_laser()
except:
pass
try:
camera_manager.release()
except:
pass
try:
os.sync()
except:
pass
import sys
os.execv(sys.executable, [sys.executable, os.path.join(config.APP_DIR, "main.py")])
return
except Exception as e:
if logger:
logger.error(f"[OTA] 处理临时目录失败: {e}")
# 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存)
if config.SAVE_IMAGE_ENABLED or getattr(config, "SAVE_IMAGE_ON_FAILURE", False):
if (
config.SAVE_IMAGE_ENABLED
or getattr(config, "SAVE_IMAGE_ON_FAILURE", False)
or getattr(config, "SAVE_RAW_IMAGE_ENABLED", False)
):
photo_dir = config.PHOTO_DIR
if photo_dir not in os.listdir("/root"):
try:
@@ -339,9 +293,8 @@ def cmd_str():
last_adc_trigger = 0
trigger_adc_val = 0 # 触发时的气压值,气压需降回此值以下才能再次触发
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
enable_check = True
_should_reboot = False
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
try:
last_adc_val = hardware_manager.adc_obj.read()
except Exception:
@@ -397,14 +350,6 @@ def cmd_str():
time.sleep_ms(250)
continue
# OTA 完成后需要重启,从主循环退出(由启动时 staging 检测处理重启)
if network_manager.ota_restart_pending:
network_manager.ota_restart_pending = False
_should_reboot = True
if logger:
logger.info("[MAIN] OTA重启标志已设置,退出主循环...")
break
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
@@ -443,15 +388,15 @@ def cmd_str():
pressure_max = adc_val
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
_flush_pressure_buf("batch")
# 突变增量检测:压力增量大于300时触发
# 触发后需等气压降到触发值以下才重新检测增量
if adc_val < trigger_adc_val :
# 突变增量检测:压力增量大于500时触发。
# 触发后需等气压降回触发点以下,才重新允许下一次触发。
if adc_val < trigger_adc_val:
enable_check = True
if (adc_val - last_adc_val) > 200 and enable_check:
if (adc_val - last_adc_val) > 500 and enable_check:
hardware_manager.start_idle_timer() # 重新计时
last_adc_trigger = current_time
trigger_adc_val = adc_val # 记录触发时的气压值
last_adc_val = adc_val # 更新基准值,防止连续增量误触发
trigger_adc_val = adc_val # 记录触发点
last_adc_val = adc_val # 更新基准,防止连续增量重复触发
enable_check = False
_flush_pressure_buf("before_trigger")
@@ -490,62 +435,10 @@ def cmd_str():
_flush_pressure_buf("exception")
except:
pass
time.sleep_ms(1000) # 等待1秒后 continue
time.sleep_ms(1000) # 等待1秒后继续
# 主循环退出后,如果是由 OTA 触发的,移动 staging 文件并重启应用
if _should_reboot:
staging_dir = f"{config.APP_DIR}/ota_staging"
if os.path.exists(staging_dir):
try:
moved_count = 0
for root, dirs, files in os.walk(staging_dir):
for f in files:
src = os.path.join(root, f)
rel = os.path.relpath(src, staging_dir)
dest = os.path.join(config.APP_DIR, rel)
dest_dir = os.path.dirname(dest)
if dest_dir:
try:
os.makedirs(dest_dir, exist_ok=True)
except:
pass
try:
shutil.copy2(src, dest)
moved_count += 1
except Exception as e:
if logger:
logger.error(f"[OTA] 移动文件失败 {rel}: {e}")
try:
shutil.rmtree(staging_dir, ignore_errors=True)
except:
pass
if logger:
logger.info(f"[MAIN] OTA 更新完成,已应用 {moved_count} 个文件,重启应用...")
except Exception as e:
if logger:
logger.error(f"[OTA] 处理 staging 目录失败: {e}")
else:
if logger:
logger.info("[MAIN] OTA 更新完成,重启应用...")
# 清理硬件资源,然后重启应用(不重启设备)
try:
laser_manager.turn_off_laser()
except:
pass
try:
camera_manager.release()
except:
pass
try:
hardware_manager.stop_idle_timer()
except:
pass
try:
os.sync()
except:
pass
import sys
os.execv(sys.executable, [sys.executable, os.path.join(config.APP_DIR, "main.py")])
# 主程序入口
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
[basic]
type = cvimodel
model = model_270139.cvimodel
[extra]
model_type = yolov5
input_type = rgb
mean = 0, 0, 0
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
labels = 黑三角和圆环
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
[basic]
type = cvimodel
model = model_270820.cvimodel
[extra]
model_type = yolov5
input_type = rgb
mean = 0, 0, 0
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
labels = triangle
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
[basic]
type = cvimodel
model = model_317211.cvimodel
[extra]
model_type = yolov5
input_type = rgb
mean = 0, 0, 0
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
labels = circle, triangle
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
[basic]
type = cvimodel
model = model_317828.cvimodel
[extra]
model_type = yolov5
input_type = rgb
mean = 0, 0, 0
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
labels = 20, 40
+230 -378
View File
@@ -13,7 +13,6 @@ import hmac
import hashlib
import ujson
import os
import sys
import threading
import socket
import config
@@ -22,13 +21,7 @@ from hardware import hardware_manager
from power import get_bus_voltage, voltage_to_percent, is_charging
from logger_manager import logger_manager
from wifi import wifi_manager
# protobuf 支持(纯 proto 协议,必须可用)
try:
import tcp_messages_pb2 as pb
except ImportError:
print("[NET] tcp_messages_pb2 not found, protobuf disabled")
raise
import subprocess
def _wifi_tls_would_block(exc):
@@ -76,18 +69,9 @@ class NetworkManager:
self._uart4g_lock = threading.Lock()
self._device_id = None
self._password = None
self._raw_line_data = []
self._manual_trigger_flag = False
# OTA 防重复:上次 OTA 完成时间戳,30秒内不重复 OTA
self._last_ota_time = 0
self._ota_cooldown_sec = 30
# OTA 重启标志:OTA线程设置,主循环检测到后从主循环退出再重启
self.ota_restart_pending = False
# protobuf 协议(纯 proto,无 JSON 兼容)
# 限制并发命令线程数
self._cmd_thread_lock = threading.Lock()
self._cmd_thread_count = 0
@@ -206,7 +190,13 @@ class NetworkManager:
return self._normal_send_queue.pop(0)
return None
def _set_raw_line_data(self, data):
"""设置原始行数据(内部方法)"""
self._raw_line_data = data
def _get_raw_line_data(self):
"""获取原始行数据(内部方法)"""
return self._raw_line_data
def get_uart_lock(self):
"""获取UART锁(用于with语句)"""
@@ -625,113 +615,53 @@ class NetworkManager:
except Exception as e:
self.logger.error(f"[LASER] cmd200 检测异常: {e}")
def _cmd5_ota(self, ota_url):
"""后台线程执行 cmd5 OTA"""
hardware_manager.start_idle_timer()
self.logger.info(f"[Ota] cmd5 开始OTA: {ota_url}")
self.safe_enqueue({"result": "ota start..."}, 2)
try:
from ota_manager import ota_manager
ok, msg = ota_manager.perform_ota(ota_url)
if ok:
self.safe_enqueue({"result": "success"}, 2)
time.sleep_ms(500)
os.execv(sys.executable, [sys.executable, os.path.join(config.APP_DIR, "main.py")])
else:
self.logger.error(f"[ota] cmd5 失败: {msg}")
self.safe_enqueue({"result": "ota fail", "reason": msg}, 2)
except Exception as e:
self.logger.error(f"[ota] cmd5 异常: {e}")
self.safe_enqueue({"result": "ota fail", "reason": str(e)}, 2)
def _cmd300_ota(self, data_obj):
"""后台线程执行 cmd300 OTA,避免阻塞主循环
流程:检查WiFi → 下载ZIP → 解压覆盖项目 → 重启程序
"""
"""后台线程执行 cmd300 OTA,避免阻塞主循环"""
hardware_manager.start_idle_timer()
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
self.logger.info(f"[New Ota] cmd300 , data: {inner_data}")
ssid = inner_data.get("ssid")
password = inner_data.get("password")
ota_res_url = inner_data.get("url")
if not ota_res_url:
self.logger.error("[ota] cmd300 缺少 url 参数")
self.safe_enqueue({"cmd": 300, "result": "ota fail", "reason": "missing url"}, 2)
return
# OTA 冷却期检查:防止服务器重复下发导致无限 OTA 循环
now = time.time()
if self._last_ota_time > 0:
elapsed = int(now - self._last_ota_time)
if elapsed < self._ota_cooldown_sec:
remaining = self._ota_cooldown_sec - elapsed
self.logger.warning(f"[ota] cmd300 冷却期内,跳过 (剩余 {remaining}s)")
try:
pkt = self._make_send_packet(2, {"cmd": 300, "result": "ota skip", "reason": f"cooldown {remaining}s"})
self.tcp_send_raw(pkt)
except Exception:
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
try:
os.remove(_f)
except OSError:
pass
return
if not wifi_manager.is_wifi_connected():
self.logger.warning("[ota] cmd300 当前未连接WiFi,拒绝OTA")
self.safe_enqueue({"cmd": 300, "result": "ota fail", "reason": "wifi not connected"}, 2)
return
self.logger.info(f"[ota] WiFi已连接,开始OTA: {ota_res_url}")
self.safe_enqueue({"cmd": 300, "result": "ota start..."}, 2)
def _ota_progress(phase, progress):
"""OTA进度回调,通过tcp_send_raw直接发送(绕过被暂停的发送队列)"""
try:
if not self._tcp_connected:
self.logger.warning(f"[ota] 进度发送跳过: tcp未连接 phase={phase} progress={progress}")
return
pkt = self._make_send_packet(2, {
w = network.wifi.Wifi()
e = w.connect(ssid, password, wait=True, timeout=15)
err.check_raise(e, "connect wifi failed")
if self.logger:
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
self.safe_enqueue(
{
"cmd": 300,
"result": f"ota {phase}",
"progress": progress,
"phase": phase,
})
ok = self.tcp_send_raw(pkt)
if not ok:
self.logger.warning(f"[ota] 进度发送失败: phase={phase} progress={progress}")
"result": "ota start...",
"wifi": w.get_ip(),
},
2,
)
subprocess.run(
["sh", "/maixapp/apps/t11/ota_curl.sh", ota_res_url])
self.safe_enqueue(
{
"cmd": 300,
"result": "success",
"wifi": w.get_ip(),
},
2,
)
except Exception as e:
self.logger.error(f"[ota] 发送进度异常: {e}")
try:
from ota_manager import ota_manager
ok, msg = ota_manager.perform_ota(ota_res_url, progress_callback=_ota_progress)
self._last_ota_time = time.time()
if ok:
self.logger.info("[ota] OTA成功,准备重启程序...")
# 直接通过tcp发送success,不走发送队列(主循环可能未drain)
try:
pkt = self._make_send_packet(2, {"cmd": 300, "result": "success", "progress": 51, "phase": "rebooting"})
ok_send = self.tcp_send_raw(pkt)
self.logger.info(f"[ota] success包发送结果: {ok_send}, tcp_connected={self._tcp_connected}")
except Exception as e:
self.logger.error(f"[ota] 发送success失败: {e}")
# 设置重启标志,由主循环检测到后从主循环退出再重启
# (后台线程调 os.execv 会导致 ISP 线程残留,新进程摄像头初始化失败)
self.logger.info("[ota] 设置重启标志,等待主循环退出...")
self.ota_restart_pending = True
else:
self.logger.error(f"[ota] cmd300 失败: {msg}")
self._last_ota_time = time.time()
try:
pkt = self._make_send_packet(2, {"cmd": 300, "result": "ota fail", "reason": msg})
self.tcp_send_raw(pkt)
except Exception as e:
self.logger.error(f"[ota] 发送失败结果异常: {e}")
except Exception as e:
self.logger.error(f"[ota] cmd300 异常: {e}")
self._last_ota_time = time.time()
try:
pkt = self._make_send_packet(2, {"cmd": 300, "result": "ota fail", "reason": str(e)})
self.tcp_send_raw(pkt)
except Exception as ex:
self.logger.error(f"[ota] 发送失败结果异常: {ex}")
self.logger.error(f"[ota] cmd300 失败: {e}")
self.safe_enqueue(
{
"cmd": 300,
"result": "ota fail",
"reason": str(e),
},
2,
)
def _cmd600_conn_wifi(self, data_obj):
hardware_manager.start_idle_timer()
@@ -739,7 +669,6 @@ class NetworkManager:
self.logger.info(f"[conn wifi] cmd600 , data: {inner_data}")
ssid = inner_data.get("ssid")
password = inner_data.get("password")
prev_network_type = self._network_type
# 停止旧的WiFi质量监测(无论当前是WiFi还是4G连接)
self._stop_wifi_quality_monitor()
try:
@@ -749,10 +678,18 @@ class NetworkManager:
except OSError:
pass
w = network.wifi.Wifi()
e = w.connect(ssid, password, wait=True, timeout=5)
e = w.connect(ssid, password, wait=True, timeout=15)
err.check_raise(e, "connect wifi failed")
if self.logger:
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
self.safe_enqueue(
{
"cmd": 600,
"result": "success",
"wifi": w.get_ip(),
},
2,
)
self._session_force_4g = False
self.disconnect_server()
self._tcp_connected = False
@@ -760,11 +697,6 @@ class NetworkManager:
self.logger.info("[conn wifi] WiFi已连接,等待主循环重新登录")
except Exception as e:
self.logger.error(f"cmd600 失败: {e}")
# 同步发送失败结果(旧连接仍存活时直接发送)
if prev_network_type == "4g":
pkt = self._make_send_packet(2, {"cmd": 600, "result": "conn fail", "reason": str(e)})
self.tcp_send_raw(pkt)
else:
self.safe_enqueue(
{
"cmd": 600,
@@ -773,173 +705,12 @@ class NetworkManager:
},
2,
)
# 当前是4G在线,旧连接未断,无需切换;当前是WiFi,旧WiFi已被w.connect()断开,需回退4G
if prev_network_type == "wifi":
self._switch_to_4g_due_to_poor_wifi()
def safe_enqueue(self, data_dict, msg_type=2, high=False):
"""线程安全地将消息加入队列(公共方法)"""
self._enqueue((msg_type, data_dict), high)
def _make_send_packet(self, msg_type, data_dict):
"""使用 protobuf 构造发送数据包"""
return self._make_proto_packet(msg_type, data_dict)
def _build_logic_body(self, cmd, data_dict):
"""根据 cmd 构造对应的 LogicBody oneof payload"""
d = data_dict.get("data", data_dict) if isinstance(data_dict.get("data"), dict) else data_dict
if cmd == 1:
return pb.LogicBody(
cmd=cmd,
shoot_data=pb.ShootData(
shot_id=d.get("shot_id", ""),
x=d.get("x", 0.0),
y=d.get("y", 0.0),
r=d.get("r", 0.0),
d=d.get("d", 0.0),
adc=d.get("adc", 0.0),
target_class=str(d.get("target_class", "")),
target_class_confidence=d.get("target_class_confidence", 0.0),
d_laser=d.get("d_laser", 0.0),
d_laser_quality=d.get("d_laser_quality", 0.0),
m=d.get("m", ""),
laser_method=d.get("laser_method", ""),
target_x=d.get("target_x", 0.0),
target_y=d.get("target_y", 0.0),
offset_method=d.get("offset_method", ""),
distance_method=d.get("distance_method", ""),
)
)
elif cmd == 4:
return pb.LogicBody(
cmd=cmd,
battery_report=pb.BatteryReport(
battery=d.get("battery", 0.0),
voltage=d.get("voltage", 0.0),
net_type=d.get("netType", ""),
charging=d.get("charging", False),
)
)
elif cmd == 200:
return pb.LogicBody(
cmd=cmd,
center_point_result=pb.CenterPointResult(
result=d.get("result", ""),
x=d.get("x", 0.0),
y=d.get("y", 0.0),
)
)
elif cmd == 201:
return pb.LogicBody(
cmd=cmd,
center_point_set=pb.CenterPointSet(
x=d.get("x", 0.0),
y=d.get("y", 0.0),
)
)
elif cmd == 300:
return pb.LogicBody(
cmd=cmd,
ota_result=pb.OtaResult(
result=d.get("result", ""),
url=d.get("wifi", ""),
progress=d.get("progress", 0),
phase=d.get("phase", ""),
)
)
elif cmd == 700:
return pb.LogicBody(
cmd=cmd,
charging_report=pb.ChargingReport(),
)
else:
result_str = d.get("result", "")
if isinstance(result_str, dict):
import ujson
result_str = ujson.dumps(result_str)
elif not isinstance(result_str, str):
result_str = str(result_str)
return pb.LogicBody(
cmd=cmd,
generic_result=pb.GenericResult(result=result_str),
)
def _make_proto_packet(self, msg_type, data_dict):
"""使用 protobuf 序列化构造数据包"""
if msg_type == 1:
msg = pb.LoginRequest(
device_id=data_dict.get("deviceId", ""),
password=data_dict.get("password", ""),
if_admin=data_dict.get("ifAdmin", False),
version=data_dict.get("version", ""),
vol=data_dict.get("vol", 0),
vol_per=data_dict.get("vol_per", 0),
iccid=data_dict.get("iccid", ""),
)
elif msg_type == 4:
msg = pb.Heartbeat(
t=data_dict.get("t", 0),
vol=data_dict.get("vol", 0),
vol_per=data_dict.get("vol_per", 0),
)
elif msg_type == 2:
cmd = data_dict.get("cmd", 0)
msg = self._build_logic_body(cmd, data_dict)
else:
return b""
body_bytes = msg.SerializeToString()
return self._netcore.make_packet_pb(msg_type, body_bytes)
def _parse_recv(self, payload):
"""解析接收的数据包,返回 (msg_type, body_dict)"""
msg_type, body_bytes = self._netcore.parse_packet_raw(payload)
if msg_type is None:
return None, None
try:
body_dict = self._parse_proto_body(msg_type, body_bytes)
return msg_type, body_dict
except Exception as e:
self.logger.error(f"[NET] protobuf 反序列化失败: {e}")
return None, None
def _parse_proto_body(self, msg_type, body_bytes):
"""将 protobuf body bytes 反序列化为 dict"""
if msg_type == 1:
msg = pb.LoginResponse()
msg.ParseFromString(body_bytes)
return {"cmd": msg.code, "data": msg.msg}
elif msg_type == 4:
return {}
elif msg_type == 2:
msg = pb.LogicBody()
msg.ParseFromString(body_bytes)
result = {"cmd": msg.cmd}
payload_name = msg.WhichOneof('payload')
if payload_name:
payload_msg = getattr(msg, payload_name)
data = {}
for field in payload_msg.DESCRIPTOR.fields:
val = getattr(payload_msg, field.name)
if isinstance(val, bytes):
val = val.hex()
data[field.name] = val
result["data"] = data
return result
elif msg_type == 100:
msg = pb.ImageUploadCommand()
msg.ParseFromString(body_bytes)
return {"uploadUrl": msg.upload_url, "token": msg.token, "shootId": msg.shoot_id, "outlink": msg.outlink}
elif msg_type == 101:
msg = pb.LogUploadCommand()
msg.ParseFromString(body_bytes)
return {"uploadUrl": msg.upload_url, "token": msg.token, "key": msg.key, "outlink": msg.outlink, "archive": msg.archive}
else:
return {"raw": body_bytes.hex()}
def connect_server(self):
"""
连接到服务器(自动选择WiFi或4G)
@@ -2062,11 +1833,6 @@ class NetworkManager:
time.sleep_ms(200)
continue
# OTA 完成后需要重启,从主循环退出(由 main.py 执行重启)
if self.ota_restart_pending:
self.logger.info("[ota] 主循环退出,准备重启...")
break
if not self.connect_server():
time.sleep_ms(1000)
continue
@@ -2082,7 +1848,8 @@ class NetworkManager:
}
iccid_pending_marker = self._maybe_add_iccid_to_login(login_data)
print(f"login_data: {login_data}")
if not self.tcp_send_raw(self._make_send_packet(1, login_data)):
# if not self.tcp_send_raw(self.make_packet(1, login_data)):
if not self.tcp_send_raw(self._netcore.make_packet(1, login_data)):
self._tcp_connected = False
try:
self.disconnect_server()
@@ -2113,10 +1880,6 @@ class NetworkManager:
time.sleep_ms(200)
continue
# OTA 完成后需要重启,跳出内层循环
if self.ota_restart_pending:
break
# 接收数据(根据网络类型选择接收方式)
# WiFi 粘包:一次 recv 可能含多条完整包;也可能缓冲里已有完整包但本轮 recv 超时为空
rx_items = []
@@ -2163,11 +1926,11 @@ class NetworkManager:
pass
# msg_type, body = self.parse_packet(payload)
msg_type, body = self._parse_recv(payload)
msg_type, body = self._netcore.parse_packet(payload)
# 处理登录响应
if not logged_in and msg_type == 1:
if body and body.get("cmd") == 0 and body.get("data") == "登录成功":
if body and body.get("cmd") == 1 and body.get("data") == "登录成功":
logged_in = True
last_heartbeat_ack_time = time.ticks_ms()
self.logger.info("登录成功")
@@ -2198,7 +1961,32 @@ class NetworkManager:
last_heartbeat_ack_time = time.ticks_ms()
self.logger.debug("✅ 收到心跳确认")
# 处理命令40(分片下载)
elif logged_in and msg_type == 40:
if isinstance(body, dict):
t = body.get('t', 0)
v = body.get('v')
# 如果是第一个分片,清空之前的缓存
if len(self._raw_line_data) == 0 or (
len(self._raw_line_data) > 0 and self._raw_line_data[0].get('v') != v):
self._raw_line_data.clear()
# 或者更简单:每次收到命令40时,如果版本号不同,清空缓存
if len(self._raw_line_data) > 0:
first_v = self._raw_line_data[0].get('v')
if first_v and first_v != v:
self._raw_line_data.clear()
self._raw_line_data.append(body)
if len(self._raw_line_data) >= int(t):
self.logger.info(f"下载完成")
from ota_manager import ota_manager
stock_array = list(map(lambda x: x.get('d'), self._raw_line_data))
local_filename = config.LOCAL_FILENAME
with open(local_filename, 'w', encoding='utf-8') as file:
file.write("\n".join(stock_array))
ota_manager.apply_ota_and_reboot(None, local_filename)
else:
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}")
@@ -2291,44 +2079,89 @@ class NetworkManager:
)
# 立即返回已入队确认
self.safe_enqueue({"result": "log_upload_queued"}, 2)
# 处理业务指令(纯 proto: cmd 在 body 顶层)
elif logged_in and msg_type == 201:
if self.logger:
self.logger.info(f"[LASER] cmd201:{body}")
raw_x = body.get("x")
raw_y = body.get("y")
try:
from laser_manager import laser_manager
ix, iy = laser_manager.set_hardcoded_laser_point(
raw_x, raw_y
)
self.safe_enqueue(
{
"cmd": 201,
"result": "laser_point_set",
"x": ix,
"y": iy,
},
2,
)
self.logger.info(
f"[LASER] cmd201 硬编码激光点=({ix}, {iy})"
)
except Exception as e:
self.logger.error(f"[LASER] cmd201 失败: {e}")
self.safe_enqueue(
{
"cmd": 201,
"result": "laser_point_set_failed",
"reason": str(e),
},
2,
)
hardware_manager.start_idle_timer()
# 处理业务指令
elif logged_in and isinstance(body, dict):
cmd = body.get("cmd")
data_obj = body.get("data") or {}
if cmd == 2: # AimRequest 开启激光并校准
inner_cmd = None
data_obj = body.get("data")
if isinstance(data_obj, dict):
inner_cmd = data_obj.get("cmd")
if inner_cmd == 2: # 开启激光并校准
from laser_manager import laser_manager
if not laser_manager.calibration_active:
laser_manager.turn_on_laser()
time.sleep_ms(100)
hardware_manager.stop_idle_timer()
hardware_manager.stop_idle_timer() # 停表
if not config.HARDCODE_LASER_POINT:
laser_manager.start_calibration()
self.safe_enqueue({"result": "calibrating"}, 2)
else:
# 写死的逻辑,不需要校准激光点
self.safe_enqueue({"result": "laser pos set by hard code"}, 2)
elif cmd == 3: # CloseAimRequest 关闭激光
elif inner_cmd == 3: # 关闭激光
from laser_manager import laser_manager
laser_manager.turn_off_laser()
laser_manager.stop_calibration()
hardware_manager.start_idle_timer()
hardware_manager.start_idle_timer() # 开表
self.safe_enqueue({"result": "laser_off"}, 2)
elif cmd == 4: # GetBatteryRequest 上报电量
elif inner_cmd == 4: # 上报电量
voltage = get_bus_voltage()
battery_percent = voltage_to_percent(voltage)
charging = is_charging()
self.safe_enqueue({
"cmd": 4,
battery_data = {
"battery": battery_percent,
"voltage": round(float(voltage), 3),
"netType": self.network_type,
"charging": charging,
}, 2)
self.logger.info(f"电量上报: {battery_percent}% 充电: {charging}")
elif cmd == 700:
}
self.safe_enqueue(battery_data, 2)
self.logger.info(f"电量上报: {battery_percent}% 充电: {is_charging()}")
if getattr(config, "CHARGING_AUTO_POWER_OFF_ENABLED", False) and is_charging():
self.safe_enqueue(
{
"cmd": 700,
},
2,
)
elif inner_cmd == 700:
self.logger.warning("服务器下发关机!!!")
exit(-1)
elif cmd == 5: # OtaRequest OTA 升级
ota_url = data_obj.get("url", "")
elif inner_cmd == 5: # OTA 升级
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
ssid = inner_data.get("ssid")
password = inner_data.get("password")
ota_url = inner_data.get("url")
mode = (inner_data.get("mode") or "").strip().lower()
if not ota_url:
self.logger.error("ota missing_url")
@@ -2342,100 +2175,119 @@ class NetworkManager:
_rx_skip_tcp_iteration = True
break
if not wifi_manager.is_wifi_connected():
self.logger.warning("[ota] cmd5 当前未连接WiFi,拒绝OTA")
self.safe_enqueue({"result": "ota fail", "reason": "wifi not connected"}, 2)
_rx_skip_tcp_iteration = True
break
# 自动判断模式:如果没有明确指定,根据WiFi连接状态和凭证决定
if mode not in ("4g", "wifi"):
self.logger.info("ota missing mode, auto-detecting...")
# 若本次会话已锁定 4G,则 OTA 自动也走 4G,避免后续回切导致体验不一致
if self._session_force_4g:
mode = "4g"
self.logger.info("ota auto-selected: 4g (session locked on 4g)")
else:
# 只有同时满足:WiFi已连接 且 提供了WiFi凭证,才使用WiFi
if self.is_wifi_connected() and ssid and password:
mode = "wifi"
self.logger.info(
"ota auto-selected: wifi (WiFi connected and credentials provided)")
else:
mode = "4g"
self.logger.info(
"ota auto-selected: 4g (WiFi not available or no credentials)")
hardware_manager.stop_idle_timer()
self._spawn_cmd_thread(self._cmd5_ota, (ota_url,))
elif cmd == 41: # Ota4gSubCodeRequest 射箭触发
hardware_manager.stop_idle_timer() # 停表,注意OTA停表之后,就没有再开表,因为OTA后面会重启,会重新开表
if mode == "4g":
ota_manager._set_ota_url(ota_url) # 记录 OTA URL,供命令7使用
ota_manager._start_update_thread()
self._spawn_cmd_thread(ota_manager.direct_ota_download_via_4g, (ota_url,))
else: # mode == "wifi"
if not ssid or not password:
self.logger.error("ota wifi mode requires ssid and password")
self.safe_enqueue({"result": "missing_ssid_or_password"}, 2)
else:
self.logger.info(f"ssid: {ssid}")
self.logger.info(f"password: {password}")
ota_manager._start_update_thread()
self._spawn_cmd_thread(ota_manager.handle_wifi_and_update,
(ssid, password, ota_url))
elif inner_cmd == 6:
try:
ip = os.popen(
"ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
ip = ip if ip else "no_ip"
except:
ip = "error_getting_ip"
self.safe_enqueue({"result": "current_ip", "ip": ip}, 2)
elif inner_cmd == 44: # 读 4G 本机号码(AT+CNUM)
cnum = self.get_4g_phone_number()
self.logger.info(f"4G 本机号码: {cnum}")
self.safe_enqueue(
{"result": "cnum", "number": cnum if cnum is not None else ""}, 2)
elif inner_cmd == 45: # 读 MCCID(AT+MCCID)
mccid = self.get_4g_mccid()
self.logger.info(f"4G MCCID: {mccid}")
self.safe_enqueue(
{"result": "mccid", "mccid": mccid if mccid is not None else ""}, 2)
elif inner_cmd == 41:
self.logger.info(f"[TEST] 收到TCP射箭触发命令, {time.time()}")
self._manual_trigger_flag = True
self.safe_enqueue({"result": "trigger_ack"}, 2)
hardware_manager.start_idle_timer()
elif cmd == 42: # ShutdownCommand 关机命令
hardware_manager.start_idle_timer() # 重新计时
elif inner_cmd == 42: # 关机命令
self.logger.info("[SHUTDOWN] 收到TCP关机命令,准备关机...")
self.safe_enqueue({"result": "shutdown_ack"}, 2)
time.sleep_ms(1000)
self.disconnect_server()
# 尝试关闭4G模块
try:
with self.get_uart_lock():
hardware_manager.at_client.send("AT+CFUN=0", "OK", 5000)
except:
pass
time.sleep_ms(2000)
os.system("sync")
os.system("sync") # 刷新文件系统缓存到磁盘,防止数据丢失
time.sleep_ms(500)
# os.system("poweroff")
hardware_manager.power_off()
return
elif cmd == 44: # 读 4G 本机号码
cnum = self.get_4g_phone_number()
self.logger.info(f"4G 本机号码: {cnum}")
self.safe_enqueue(
{"result": "cnum", "number": cnum if cnum is not None else ""}, 2)
elif cmd == 45: # 读 MCCID
mccid = self.get_4g_mccid()
self.logger.info(f"4G MCCID: {mccid}")
self.safe_enqueue(
{"result": "mccid", "mccid": mccid if mccid is not None else ""}, 2)
elif cmd == 43: # 上传日志命令
upload_url = data_obj.get("url")
wifi_ssid = data_obj.get("ssid")
wifi_password = data_obj.get("password")
include_rotated = data_obj.get("include_rotated", True)
max_files = data_obj.get("max_files")
archive_format = data_obj.get("archive", "tgz")
elif inner_cmd == 43: # 上传日志命令
# 格式: {"cmd":43, "data":{"ssid":"xxx","password":"xxx","url":"xxx", ...}}
inner_data = data_obj.get("data", {})
upload_url = inner_data.get("url")
wifi_ssid = inner_data.get("ssid")
wifi_password = inner_data.get("password")
include_rotated = inner_data.get("include_rotated", True)
max_files = inner_data.get("max_files")
archive_format = inner_data.get("archive", "tgz") # tgz 或 zip
hardware_manager.start_idle_timer()
hardware_manager.start_idle_timer() # 重新计时
if not upload_url:
self.logger.error("[LOG_UPLOAD] 缺少 url 参数")
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_url"}, 2)
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_url"},
2)
else:
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
# 在新线程中执行上传,避免阻塞主循环
self._spawn_cmd_thread(
self._upload_log_file,
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
archive_format)
)
elif cmd == 200: # GenericResult "init_center_point" 触发激光检测
elif inner_cmd == 200:
self.logger.info("[LASER] cmd200 在后台线程执行检测")
self._spawn_cmd_thread(self._cmd200_detect_laser, ())
elif cmd == 201: # SetCenterPointRequest 设置中心点
if self.logger:
self.logger.info(f"[LASER] cmd201:{body}")
raw_x = data_obj.get("x")
raw_y = data_obj.get("y")
try:
from laser_manager import laser_manager
ix, iy = laser_manager.set_hardcoded_laser_point(raw_x, raw_y)
self.safe_enqueue({
"cmd": 201,
"result": "laser_point_set",
"x": ix,
"y": iy,
}, 2)
self.logger.info(f"[LASER] cmd201 硬编码激光点=({ix}, {iy})")
except Exception as e:
self.logger.error(f"[LASER] cmd201 失败: {e}")
self.safe_enqueue({
"cmd": 201,
"result": "laser_point_set_failed",
"reason": str(e),
}, 2)
hardware_manager.start_idle_timer()
elif cmd == 300: # OtaRequest 新版OTA
elif inner_cmd == 300:
self.logger.info("[New Ota] cmd300 在后台线程执行OTA")
self._spawn_cmd_thread(self._cmd300_ota, ({"data": data_obj},))
elif cmd == 600: # WifiConnectRequest 连接wifi
self.logger.info(f"[conn wifi] cmd600 在后台线程执行连接wifi: {data_obj}")
self._spawn_cmd_thread(self._cmd600_conn_wifi, ({"data": data_obj},))
elif cmd == 601:
self._spawn_cmd_thread(self._cmd300_ota, (data_obj,))
elif inner_cmd == 600:
self.logger.info("[conn wifi] cmd600 在后台线程执行连接wifi: {data_obj}")
self._spawn_cmd_thread(self._cmd600_conn_wifi, (data_obj,))
elif inner_cmd == 601:
pass
else:
else: # data的结构不是 dict
self.logger.info(f"[NET] body={body}, {time.time()}")
else:
self.logger.info(f"[NET] 未知数据 {body}, {time.time()}")
if _rx_login_fail:
break
if _rx_skip_tcp_iteration:
@@ -2458,7 +2310,7 @@ class NetworkManager:
if item:
msg_type, data_dict = item
pkt = self._make_send_packet(msg_type, data_dict)
pkt = self._netcore.make_packet(msg_type, data_dict)
if not self.tcp_send_raw(pkt):
# 发送失败:将消息放回队首(队列满则丢弃)
with self.get_queue_lock():
@@ -2487,8 +2339,8 @@ class NetworkManager:
current_time = time.ticks_ms()
if logged_in and current_time - last_heartbeat_send_time > config.HEARTBEAT_INTERVAL * 1000:
vol_val = get_bus_voltage()
heartbeat_pkt = self._make_send_packet(4, {"vol": vol_val, "vol_per": voltage_to_percent(vol_val)})
if not self.tcp_send_raw(heartbeat_pkt):
if not self.tcp_send_raw(
self._netcore.make_packet(4, {"vol": vol_val, "vol_per": voltage_to_percent(vol_val)})):
# if not self.tcp_send_raw(self.make_packet(4, {"vol": vol_val, "vol_per": voltage_to_percent(vol_val)})):
send_hartbeat_fail_count += 1
# 短暂波动可能导致一次发送失败:连续失败达到阈值才重连,避免重连风暴
+1172 -146
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -8,7 +8,7 @@ from laser_manager import laser_manager
from logger_manager import logger_manager
from network import network_manager
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot, enqueue_save_raw_shot
from maix import image, time
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
@@ -322,6 +322,11 @@ def process_shot(adc_val):
try:
frame = camera_manager.read_frame()
# 在任何检测和绘图之前复制原始帧;默认由配置关闭,不增加量产开销。
from shot_id_generator import shot_id_generator
shot_id = shot_id_generator.generate_id()
enqueue_save_raw_shot(frame, shot_id)
# 网络事件移到拍照之后,避免阻塞拍照
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
@@ -380,10 +385,6 @@ def process_shot(adc_val):
if dx is None and dy is None and logger:
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
# 生成射箭ID
from shot_id_generator import shot_id_generator
shot_id = shot_id_generator.generate_id()
if logger:
logger.info(f"[MAIN] 射箭ID: {shot_id}")
@@ -441,6 +442,13 @@ def process_shot(adc_val):
inner_data["ellipse_center_x"] = None
inner_data["ellipse_center_y"] = None
upload_time_ms = int(time_std.time() * 1000)
upload_time_sec, upload_time_millis = divmod(upload_time_ms, 1000)
inner_data["upload_time"] = (
time_std.strftime("%Y-%m-%d %H:%M:%S", time_std.localtime(upload_time_sec))
+ f".{upload_time_millis:03d}"
)
report_data = {"cmd": 1, "data": inner_data}
if logger:
logger.info(
-355
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+4
View File
@@ -31,3 +31,7 @@
# 2.15.18 wifi连接成功重新登录
# 2.16.4 优化射箭延迟
# 2.17.0 yolo标靶类别识别
# 2.17.1 26-08-19 17:39 压力传感修改 增量方式
# 2.17.2 26-08-24 17:56 靶纸识别模型更替
# 2.17.3 26-08-25 9:57 原图拍摄开关
# 2.17.4 26-08-25 14:57 模型修改
+1 -1
View File
@@ -4,6 +4,6 @@
应用版本号
每次 OTA 更新时,只需要更新这个文件中的版本号
"""
VERSION = '3.1.16'
VERSION = '2.17.14'
+50 -1
View File
@@ -902,12 +902,15 @@ def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
def _save_worker_loop():
"""存图 worker:从队列取任务并调用 _save_shot_image_impl。"""
"""存图 worker:处理标注图和可选的纯原图任务。"""
while True:
try:
item = _save_queue.get()
if item is None:
break
if isinstance(item, dict) and item.get("kind") == "raw":
_save_raw_image_impl(item["img_cv"], item["shot_id"], item["photo_dir"])
else:
_save_shot_image_impl(*item)
except Exception as e:
logger = logger_manager.logger
@@ -936,6 +939,52 @@ def start_save_shot_worker():
logger.info("[VISION] 存图 worker 线程已启动")
def _save_raw_image_impl(img_cv, shot_id, photo_dir):
"""保存未标注、未裁剪的完整原始帧。"""
logger = logger_manager.logger
try:
os.makedirs(photo_dir, exist_ok=True)
filename = os.path.join(photo_dir, f"shot_{shot_id}_raw.jpg")
image.cv2image(img_cv, False, False).save(filename)
prune_old_images_in_dir(
photo_dir,
getattr(config, "RAW_IMAGE_MAX_IMAGES", config.MAX_IMAGES),
logger,
"[VISION-RAW]",
)
if logger:
logger.info(f"[VISION-RAW] 已保存纯原图: {filename}")
return filename
except Exception as e:
if logger:
logger.error(f"[VISION-RAW] 保存纯原图失败: {e}")
return None
def enqueue_save_raw_shot(frame, shot_id, photo_dir=None):
"""复制并异步保存原始帧;由 SAVE_RAW_IMAGE_ENABLED 控制是否启用。"""
if not getattr(config, "SAVE_RAW_IMAGE_ENABLED", False):
return
if photo_dir is None:
photo_dir = getattr(config, "RAW_IMAGE_DIR", os.path.join(config.PHOTO_DIR, "raw"))
try:
img_copy = np.copy(image.image2cv(frame, False, False))
_save_queue.put_nowait({
"kind": "raw",
"img_cv": img_copy,
"shot_id": shot_id,
"photo_dir": photo_dir,
})
except queue.Full:
logger = logger_manager.logger
if logger:
logger.warning("[VISION-RAW] 存图队列已满,跳过本次纯原图保存")
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[VISION-RAW] 复制纯原图失败: {e}")
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None,
yolo_roi_xyxy=None, force_save=False):