Compare commits
7
Commits
2.17.0
...
8d35bf3068
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d35bf3068 | ||
|
|
d1a1d43bd8 | ||
|
|
496085cb93 | ||
|
|
2459bb3b67 | ||
|
|
e6726f087c | ||
|
|
b8d000f5de | ||
|
|
2834f5b9b7 |
@@ -1,6 +1,6 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 2.17.15
|
version: 2.18.10
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
@@ -18,8 +18,8 @@ files:
|
|||||||
- laser_manager.py
|
- laser_manager.py
|
||||||
- logger_manager.py
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
- model_317828.cvimodel
|
- model_326162.cvimodel
|
||||||
- model_317828.mud
|
- model_326162.mud
|
||||||
- network.py
|
- network.py
|
||||||
- ota_curl.sh
|
- ota_curl.sh
|
||||||
- ota_manager.py
|
- ota_manager.py
|
||||||
@@ -28,6 +28,7 @@ files:
|
|||||||
- shoot_manager.py
|
- shoot_manager.py
|
||||||
- shot_id_generator.py
|
- shot_id_generator.py
|
||||||
- target_roi_yolo.py
|
- target_roi_yolo.py
|
||||||
|
- tcp_messages_pb2.py
|
||||||
- time_sync.py
|
- time_sync.py
|
||||||
- triangle_positions.json
|
- triangle_positions.json
|
||||||
- triangle_target.py
|
- triangle_target.py
|
||||||
|
|||||||
Binary file not shown.
+1
-26
@@ -8,15 +8,6 @@ import threading
|
|||||||
import config
|
import config
|
||||||
from logger_manager import logger_manager
|
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:
|
class CameraManager:
|
||||||
"""相机管理器(单例)"""
|
"""相机管理器(单例)"""
|
||||||
@@ -110,23 +101,7 @@ class CameraManager:
|
|||||||
with self._camera_lock:
|
with self._camera_lock:
|
||||||
if self._camera is None:
|
if self._camera is None:
|
||||||
self.init_camera()
|
self.init_camera()
|
||||||
frame = self._camera.read()
|
return self._camera.read()
|
||||||
if frame is not None and _USE_CV:
|
|
||||||
try:
|
|
||||||
v_flip = getattr(config, 'CAMERA_V_FLIP', False)
|
|
||||||
h_mirror = getattr(config, 'CAMERA_H_MIRROR', False)
|
|
||||||
if v_flip or h_mirror:
|
|
||||||
img_cv = _maix_image.image2cv(frame, False, False)
|
|
||||||
if v_flip and h_mirror:
|
|
||||||
img_cv = cv2.flip(img_cv, -1)
|
|
||||||
elif v_flip:
|
|
||||||
img_cv = cv2.flip(img_cv, 0)
|
|
||||||
elif h_mirror:
|
|
||||||
img_cv = cv2.flip(img_cv, 1)
|
|
||||||
frame = _maix_image.cv2image(img_cv, False, False)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return frame
|
|
||||||
|
|
||||||
def show(self, image):
|
def show(self, image):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ LOCAL_FILENAME = APP_DIR + "/main_tmp.py"
|
|||||||
# 相机初始化分辨率(CameraManager / main.py 使用)
|
# 相机初始化分辨率(CameraManager / main.py 使用)
|
||||||
CAMERA_WIDTH = 640
|
CAMERA_WIDTH = 640
|
||||||
CAMERA_HEIGHT = 480
|
CAMERA_HEIGHT = 480
|
||||||
CAMERA_V_FLIP = True # 摄像头垂直翻转(上下颠倒时设为 True)
|
|
||||||
CAMERA_H_MIRROR = True # 摄像头水平镜像(左右反了时设为 True)
|
|
||||||
|
|
||||||
# 三角形检测缩图比例:默认按相机最长边缩到 1/2(性能更稳;可按需调整)
|
# 三角形检测缩图比例:默认按相机最长边缩到 1/2(性能更稳;可按需调整)
|
||||||
# 取值范围建议 (0.25 ~ 1.0];1.0 表示不缩图
|
# 取值范围建议 (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) 配置 =====
|
# ===== TCP over SSL(TLS) 配置 =====
|
||||||
USE_TCP_SSL = True # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL
|
USE_TCP_SSL = True # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL
|
||||||
TCP_LINK_ID = 2 #
|
TCP_LINK_ID = 2 #
|
||||||
TCP_SSL_PORT = 50006 # TLS 端口(不一定必须 443,以服务器为准)
|
TCP_SSL_PORT = 50007 # TLS 端口(不一定必须 443,以服务器为准)
|
||||||
|
|
||||||
# SSL profile
|
# SSL profile
|
||||||
SSL_ID = 1 # ssl_id=1
|
SSL_ID = 1 # ssl_id=1
|
||||||
@@ -264,14 +262,25 @@ TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
|||||||
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||||
|
|
||||||
# YOLO target size classification: class 0=20cm, class 1=40cm.
|
# YOLO target size classification: class_id → label,下标即 class_id:0=40cm, 1=0(靶心), 2=20cm.
|
||||||
TARGET_CLASS_YOLO_ENABLE = True
|
TARGET_CLASS_YOLO_ENABLE = True
|
||||||
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_317828.mud"
|
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_326162.mud"
|
||||||
TARGET_CLASS_YOLO_LABELS = (20, 40)
|
TARGET_CLASS_YOLO_LABELS = (40, 0, 20)
|
||||||
TARGET_CLASS_YOLO_CONF_TH = 0.66
|
# 只有映射结果落在该集合内才用于上报(如 class 1→label 0 直接忽略)
|
||||||
TARGET_CLASS_YOLO_IOU_TH = 0.45
|
TARGET_CLASS_YOLO_VALID_LABELS = (20, 40)
|
||||||
TARGET_CLASS_YOLO_RETRY_ON_EMPTY = False
|
# 解析阶段直接丢弃的 class_id(1=靶心),不参与任何选择/上报
|
||||||
|
TARGET_CLASS_YOLO_IGNORE_CLASS_IDS = (1,)
|
||||||
|
TARGET_CLASS_YOLO_CONF_TH = 0.5
|
||||||
|
# 首次检测 0 结果时用该阈值重新检测一次(≥CONF_TH 时按 CONF_TH 同参重跑)
|
||||||
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
|
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
|
||||||
|
# 检出靶框后裁剪放大再 detect 一次,与全图结果互相印证(不一致以复检为准)
|
||||||
|
TARGET_CLASS_YOLO_RECHECK_CROP = True
|
||||||
|
TARGET_CLASS_YOLO_RECHECK_MARGIN_FRAC = 0.1
|
||||||
|
# 复检的 detect 阈值与接受门槛:置信度大于该值即采纳复检结果
|
||||||
|
TARGET_CLASS_YOLO_RECHECK_CONF_TH = 0.25
|
||||||
|
# 全图结果(无复检时)的上报置信度门槛,None/0 表示不启用
|
||||||
|
TARGET_CLASS_YOLO_MIN_REPORT_CONF = 0.5
|
||||||
|
TARGET_CLASS_YOLO_IOU_TH = 0.45
|
||||||
TARGET_CLASS_YOLO_PRELOAD_ON_BOOT = True
|
TARGET_CLASS_YOLO_PRELOAD_ON_BOOT = True
|
||||||
|
|
||||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||||
@@ -329,16 +338,16 @@ MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下
|
|||||||
|
|
||||||
# ==================== 图像保存配置 ====================
|
# ==================== 图像保存配置 ====================
|
||||||
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
||||||
SAVE_IMAGE_ON_FAILURE = False # 检测失败时是否强制保存图像(供调试测试用)
|
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
|
||||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
MAX_IMAGES = 1000
|
MAX_IMAGES = 1000
|
||||||
SAVE_RAW_IMAGE_ENABLED = True # 额外保存完整原始帧(不画框、不画点、不裁剪)
|
SAVE_RAW_IMAGE_ENABLED = False # 额外保存完整原始帧(不画框、不画点、不裁剪)
|
||||||
RAW_IMAGE_DIR = PHOTO_DIR + "/raw"
|
RAW_IMAGE_DIR = PHOTO_DIR + "/raw"
|
||||||
RAW_IMAGE_MAX_IMAGES = MAX_IMAGES
|
RAW_IMAGE_MAX_IMAGES = MAX_IMAGES
|
||||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||||
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
||||||
|
|
||||||
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
||||||
|
|
||||||
# ==================== OTA配置 ====================
|
# ==================== OTA配置 ====================
|
||||||
MAX_BACKUPS = 5
|
MAX_BACKUPS = 5
|
||||||
@@ -357,7 +366,22 @@ PIN_MAPPINGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ==================== 电源配置 ====================
|
# ==================== 电源配置 ====================
|
||||||
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
AUTO_POWER_OFF_IN_SECONDS = 100 * 60 # 自动关机时间(秒),0表示不自动关机
|
||||||
|
# 充电时自动关机暂时禁用;需要恢复时改为 True。
|
||||||
|
CHARGING_AUTO_POWER_OFF_ENABLED = False
|
||||||
|
|
||||||
|
# 一代电源控制:A24 由电源板负责按键/关机信号,软件关机时输出高电平。
|
||||||
|
|
||||||
|
# 电源状态指示灯
|
||||||
|
STATUS_LED_GREEN_GPIO = "GPIOA25"
|
||||||
|
STATUS_LED_RED_GPIO = "GPIOA23"
|
||||||
|
STATUS_LED_GREEN_ENABLED = True
|
||||||
|
STATUS_LED_RED_ENABLED = True
|
||||||
|
STATUS_LED_ACTIVE_LEVEL = 1
|
||||||
|
STATUS_LED_LOW_BATTERY_PERCENT = 10
|
||||||
|
STATUS_LED_FULL_BATTERY_PERCENT = 90
|
||||||
|
STATUS_LED_CHARGING_BLINK_MS = 500
|
||||||
|
STATUS_LED_POLL_MS = 1000
|
||||||
|
|
||||||
BATTERY_SOC_LPF_ALPHA = 0.5
|
BATTERY_SOC_LPF_ALPHA = 0.5
|
||||||
BATTERY_SOC_AVG_WINDOW = 5
|
BATTERY_SOC_AVG_WINDOW = 5
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from maix import camera, display, image, app, time, uart, pinmap, i2c
|
|||||||
from maix.peripheral import adc
|
from maix.peripheral import adc
|
||||||
import _thread
|
import _thread
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
import time as wall_time
|
import time as wall_time
|
||||||
|
|
||||||
# 导入新模块
|
# 导入新模块
|
||||||
@@ -252,6 +254,55 @@ def cmd_str():
|
|||||||
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
||||||
network_manager.read_device_id()
|
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. 创建照片存储目录(如果启用图像保存或检测失败时强制保存)
|
# 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存)
|
||||||
if (
|
if (
|
||||||
config.SAVE_IMAGE_ENABLED
|
config.SAVE_IMAGE_ENABLED
|
||||||
@@ -292,6 +343,7 @@ def cmd_str():
|
|||||||
trigger_adc_val = 0 # 触发时的气压值,气压需降回此值以下才能再次触发
|
trigger_adc_val = 0 # 触发时的气压值,气压需降回此值以下才能再次触发
|
||||||
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
|
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
|
||||||
enable_check = True
|
enable_check = True
|
||||||
|
_should_reboot = False
|
||||||
try:
|
try:
|
||||||
last_adc_val = hardware_manager.adc_obj.read()
|
last_adc_val = hardware_manager.adc_obj.read()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -347,6 +399,14 @@ def cmd_str():
|
|||||||
time.sleep_ms(250)
|
time.sleep_ms(250)
|
||||||
continue
|
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 状态下,检测是否空闲足够长,自动关机
|
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
||||||
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
||||||
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
||||||
@@ -385,11 +445,11 @@ def cmd_str():
|
|||||||
pressure_max = adc_val
|
pressure_max = adc_val
|
||||||
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
||||||
_flush_pressure_buf("batch")
|
_flush_pressure_buf("batch")
|
||||||
# 突变增量检测:压力增量大于300时触发
|
# 突变增量检测:压力增量大于400时触发
|
||||||
# 触发后需等气压降到触发值以下才重新检测增量
|
# 触发后需等气压降到触发值以下才重新检测增量
|
||||||
if adc_val < trigger_adc_val :
|
if adc_val < trigger_adc_val :
|
||||||
enable_check = True
|
enable_check = True
|
||||||
if (adc_val - last_adc_val) > 500 and enable_check:
|
if (adc_val - last_adc_val) > 200 and enable_check:
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
last_adc_trigger = current_time
|
last_adc_trigger = current_time
|
||||||
trigger_adc_val = adc_val # 记录触发时的气压值
|
trigger_adc_val = adc_val # 记录触发时的气压值
|
||||||
@@ -432,10 +492,62 @@ def cmd_str():
|
|||||||
_flush_pressure_buf("exception")
|
_flush_pressure_buf("exception")
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep_ms(1000) # 等待1秒后继续
|
time.sleep_ms(1000) # 等待1秒后 continue
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 主循环退出后,如果是由 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.
@@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
[basic]
|
|
||||||
type = cvimodel
|
|
||||||
model = model_317189.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.
@@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
[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.
@@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
[basic]
|
|
||||||
type = cvimodel
|
|
||||||
model = model_317704.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 = 40, circle, triangle
|
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
[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
|
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[basic]
|
[basic]
|
||||||
type = cvimodel
|
type = cvimodel
|
||||||
model = model_317423.cvimodel
|
model = model_326162.cvimodel
|
||||||
|
|
||||||
[extra]
|
[extra]
|
||||||
model_type = yolov5
|
model_type = yolov5
|
||||||
@@ -9,5 +9,5 @@ input_type = rgb
|
|||||||
mean = 0, 0, 0
|
mean = 0, 0, 0
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
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
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
labels = 20, 10, 40
|
labels = 40, 00, 20
|
||||||
|
|
||||||
+446
-298
@@ -13,6 +13,7 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import ujson
|
import ujson
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import socket
|
import socket
|
||||||
import config
|
import config
|
||||||
@@ -21,7 +22,13 @@ from hardware import hardware_manager
|
|||||||
from power import get_bus_voltage, voltage_to_percent, is_charging
|
from power import get_bus_voltage, voltage_to_percent, is_charging
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from wifi import wifi_manager
|
from wifi import wifi_manager
|
||||||
import subprocess
|
|
||||||
|
# protobuf 支持(纯 proto 协议,必须可用)
|
||||||
|
try:
|
||||||
|
import tcp_messages_pb2 as pb
|
||||||
|
except ImportError:
|
||||||
|
print("[NET] tcp_messages_pb2 not found, protobuf disabled")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _wifi_tls_would_block(exc):
|
def _wifi_tls_would_block(exc):
|
||||||
@@ -69,9 +76,18 @@ class NetworkManager:
|
|||||||
self._uart4g_lock = threading.Lock()
|
self._uart4g_lock = threading.Lock()
|
||||||
self._device_id = None
|
self._device_id = None
|
||||||
self._password = None
|
self._password = None
|
||||||
self._raw_line_data = []
|
|
||||||
self._manual_trigger_flag = False
|
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_lock = threading.Lock()
|
||||||
self._cmd_thread_count = 0
|
self._cmd_thread_count = 0
|
||||||
@@ -190,13 +206,7 @@ class NetworkManager:
|
|||||||
return self._normal_send_queue.pop(0)
|
return self._normal_send_queue.pop(0)
|
||||||
return None
|
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):
|
def get_uart_lock(self):
|
||||||
"""获取UART锁(用于with语句)"""
|
"""获取UART锁(用于with语句)"""
|
||||||
@@ -615,53 +625,113 @@ class NetworkManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[LASER] cmd200 检测异常: {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):
|
def _cmd300_ota(self, data_obj):
|
||||||
"""后台线程执行 cmd300 OTA,避免阻塞主循环"""
|
"""后台线程执行 cmd300 OTA,避免阻塞主循环
|
||||||
|
流程:检查WiFi → 下载ZIP → 解压覆盖项目 → 重启程序
|
||||||
|
"""
|
||||||
hardware_manager.start_idle_timer()
|
hardware_manager.start_idle_timer()
|
||||||
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
||||||
self.logger.info(f"[New Ota] cmd300 , data: {inner_data}")
|
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")
|
ota_res_url = inner_data.get("url")
|
||||||
try:
|
|
||||||
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
|
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:
|
try:
|
||||||
os.remove(_f)
|
pkt = self._make_send_packet(2, {"cmd": 300, "result": "ota skip", "reason": f"cooldown {remaining}s"})
|
||||||
except OSError:
|
self.tcp_send_raw(pkt)
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
w = network.wifi.Wifi()
|
return
|
||||||
e = w.connect(ssid, password, wait=True, timeout=15)
|
|
||||||
err.check_raise(e, "connect wifi failed")
|
if not wifi_manager.is_wifi_connected():
|
||||||
if self.logger:
|
self.logger.warning("[ota] cmd300 当前未连接WiFi,拒绝OTA")
|
||||||
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
|
self.safe_enqueue({"cmd": 300, "result": "ota fail", "reason": "wifi not connected"}, 2)
|
||||||
self.safe_enqueue(
|
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, {
|
||||||
"cmd": 300,
|
"cmd": 300,
|
||||||
"result": "ota start...",
|
"result": f"ota {phase}",
|
||||||
"wifi": w.get_ip(),
|
"progress": progress,
|
||||||
},
|
"phase": phase,
|
||||||
2,
|
})
|
||||||
)
|
ok = self.tcp_send_raw(pkt)
|
||||||
subprocess.run(
|
if not ok:
|
||||||
["sh", "/maixapp/apps/t11/ota_curl.sh", ota_res_url])
|
self.logger.warning(f"[ota] 进度发送失败: phase={phase} progress={progress}")
|
||||||
self.safe_enqueue(
|
except Exception as e:
|
||||||
{
|
self.logger.error(f"[ota] 发送进度异常: {e}")
|
||||||
"cmd": 300,
|
|
||||||
"result": "success",
|
try:
|
||||||
"wifi": w.get_ip(),
|
from ota_manager import ota_manager
|
||||||
},
|
ok, msg = ota_manager.perform_ota(ota_res_url, progress_callback=_ota_progress)
|
||||||
2,
|
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:
|
except Exception as e:
|
||||||
self.logger.error(f"[ota] cmd300 失败: {e}")
|
self.logger.error(f"[ota] cmd300 异常: {e}")
|
||||||
self.safe_enqueue(
|
self._last_ota_time = time.time()
|
||||||
{
|
try:
|
||||||
"cmd": 300,
|
pkt = self._make_send_packet(2, {"cmd": 300, "result": "ota fail", "reason": str(e)})
|
||||||
"result": "ota fail",
|
self.tcp_send_raw(pkt)
|
||||||
"reason": str(e),
|
except Exception as ex:
|
||||||
},
|
self.logger.error(f"[ota] 发送失败结果异常: {ex}")
|
||||||
2,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _cmd600_conn_wifi(self, data_obj):
|
def _cmd600_conn_wifi(self, data_obj):
|
||||||
hardware_manager.start_idle_timer()
|
hardware_manager.start_idle_timer()
|
||||||
@@ -669,6 +739,7 @@ class NetworkManager:
|
|||||||
self.logger.info(f"[conn wifi] cmd600 , data: {inner_data}")
|
self.logger.info(f"[conn wifi] cmd600 , data: {inner_data}")
|
||||||
ssid = inner_data.get("ssid")
|
ssid = inner_data.get("ssid")
|
||||||
password = inner_data.get("password")
|
password = inner_data.get("password")
|
||||||
|
prev_network_type = self._network_type
|
||||||
# 停止旧的WiFi质量监测(无论当前是WiFi还是4G连接)
|
# 停止旧的WiFi质量监测(无论当前是WiFi还是4G连接)
|
||||||
self._stop_wifi_quality_monitor()
|
self._stop_wifi_quality_monitor()
|
||||||
try:
|
try:
|
||||||
@@ -678,18 +749,10 @@ class NetworkManager:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
w = network.wifi.Wifi()
|
w = network.wifi.Wifi()
|
||||||
e = w.connect(ssid, password, wait=True, timeout=15)
|
e = w.connect(ssid, password, wait=True, timeout=5)
|
||||||
err.check_raise(e, "connect wifi failed")
|
err.check_raise(e, "connect wifi failed")
|
||||||
if self.logger:
|
if self.logger:
|
||||||
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
|
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._session_force_4g = False
|
||||||
self.disconnect_server()
|
self.disconnect_server()
|
||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
@@ -697,20 +760,186 @@ class NetworkManager:
|
|||||||
self.logger.info("[conn wifi] WiFi已连接,等待主循环重新登录")
|
self.logger.info("[conn wifi] WiFi已连接,等待主循环重新登录")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"cmd600 失败: {e}")
|
self.logger.error(f"cmd600 失败: {e}")
|
||||||
self.safe_enqueue(
|
# 同步发送失败结果(旧连接仍存活时直接发送)
|
||||||
{
|
if prev_network_type == "4g":
|
||||||
"cmd": 600,
|
pkt = self._make_send_packet(2, {"cmd": 600, "result": "conn fail", "reason": str(e)})
|
||||||
"result": "conn fail",
|
self.tcp_send_raw(pkt)
|
||||||
"reason": str(e),
|
else:
|
||||||
},
|
self.safe_enqueue(
|
||||||
2,
|
{
|
||||||
)
|
"cmd": 600,
|
||||||
self._switch_to_4g_due_to_poor_wifi()
|
"result": "conn fail",
|
||||||
|
"reason": str(e),
|
||||||
|
},
|
||||||
|
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):
|
def safe_enqueue(self, data_dict, msg_type=2, high=False):
|
||||||
"""线程安全地将消息加入队列(公共方法)"""
|
"""线程安全地将消息加入队列(公共方法)"""
|
||||||
self._enqueue((msg_type, data_dict), high)
|
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):
|
def connect_server(self):
|
||||||
"""
|
"""
|
||||||
连接到服务器(自动选择WiFi或4G)
|
连接到服务器(自动选择WiFi或4G)
|
||||||
@@ -1833,6 +2062,11 @@ class NetworkManager:
|
|||||||
time.sleep_ms(200)
|
time.sleep_ms(200)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# OTA 完成后需要重启,从主循环退出(由 main.py 执行重启)
|
||||||
|
if self.ota_restart_pending:
|
||||||
|
self.logger.info("[ota] 主循环退出,准备重启...")
|
||||||
|
break
|
||||||
|
|
||||||
if not self.connect_server():
|
if not self.connect_server():
|
||||||
time.sleep_ms(1000)
|
time.sleep_ms(1000)
|
||||||
continue
|
continue
|
||||||
@@ -1848,8 +2082,7 @@ class NetworkManager:
|
|||||||
}
|
}
|
||||||
iccid_pending_marker = self._maybe_add_iccid_to_login(login_data)
|
iccid_pending_marker = self._maybe_add_iccid_to_login(login_data)
|
||||||
print(f"login_data: {login_data}")
|
print(f"login_data: {login_data}")
|
||||||
# if not self.tcp_send_raw(self.make_packet(1, login_data)):
|
if not self.tcp_send_raw(self._make_send_packet(1, login_data)):
|
||||||
if not self.tcp_send_raw(self._netcore.make_packet(1, login_data)):
|
|
||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
try:
|
try:
|
||||||
self.disconnect_server()
|
self.disconnect_server()
|
||||||
@@ -1880,6 +2113,10 @@ class NetworkManager:
|
|||||||
time.sleep_ms(200)
|
time.sleep_ms(200)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# OTA 完成后需要重启,跳出内层循环
|
||||||
|
if self.ota_restart_pending:
|
||||||
|
break
|
||||||
|
|
||||||
# 接收数据(根据网络类型选择接收方式)
|
# 接收数据(根据网络类型选择接收方式)
|
||||||
# WiFi 粘包:一次 recv 可能含多条完整包;也可能缓冲里已有完整包但本轮 recv 超时为空
|
# WiFi 粘包:一次 recv 可能含多条完整包;也可能缓冲里已有完整包但本轮 recv 超时为空
|
||||||
rx_items = []
|
rx_items = []
|
||||||
@@ -1926,11 +2163,11 @@ class NetworkManager:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# msg_type, body = self.parse_packet(payload)
|
# msg_type, body = self.parse_packet(payload)
|
||||||
msg_type, body = self._netcore.parse_packet(payload)
|
msg_type, body = self._parse_recv(payload)
|
||||||
|
|
||||||
# 处理登录响应
|
# 处理登录响应
|
||||||
if not logged_in and msg_type == 1:
|
if not logged_in and msg_type == 1:
|
||||||
if body and body.get("cmd") == 1 and body.get("data") == "登录成功":
|
if body and body.get("cmd") == 0 and body.get("data") == "登录成功":
|
||||||
logged_in = True
|
logged_in = True
|
||||||
last_heartbeat_ack_time = time.ticks_ms()
|
last_heartbeat_ack_time = time.ticks_ms()
|
||||||
self.logger.info("登录成功")
|
self.logger.info("登录成功")
|
||||||
@@ -1961,32 +2198,7 @@ class NetworkManager:
|
|||||||
last_heartbeat_ack_time = time.ticks_ms()
|
last_heartbeat_ack_time = time.ticks_ms()
|
||||||
self.logger.debug("✅ 收到心跳确认")
|
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:
|
elif logged_in and msg_type == 100:
|
||||||
self.logger.info(f"[IMAGE_UPLOAD] 收到图片上传命令 {body}")
|
self.logger.info(f"[IMAGE_UPLOAD] 收到图片上传命令 {body}")
|
||||||
@@ -2079,215 +2291,151 @@ class NetworkManager:
|
|||||||
)
|
)
|
||||||
# 立即返回已入队确认
|
# 立即返回已入队确认
|
||||||
self.safe_enqueue({"result": "log_upload_queued"}, 2)
|
self.safe_enqueue({"result": "log_upload_queued"}, 2)
|
||||||
elif logged_in and msg_type == 201:
|
# 处理业务指令(纯 proto: cmd 在 body 顶层)
|
||||||
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):
|
elif logged_in and isinstance(body, dict):
|
||||||
inner_cmd = None
|
cmd = body.get("cmd")
|
||||||
data_obj = body.get("data")
|
data_obj = body.get("data") or {}
|
||||||
if isinstance(data_obj, dict):
|
if cmd == 2: # AimRequest 开启激光并校准
|
||||||
inner_cmd = data_obj.get("cmd")
|
from laser_manager import laser_manager
|
||||||
if inner_cmd == 2: # 开启激光并校准
|
if not laser_manager.calibration_active:
|
||||||
from laser_manager import laser_manager
|
laser_manager.turn_on_laser()
|
||||||
if not laser_manager.calibration_active:
|
time.sleep_ms(100)
|
||||||
laser_manager.turn_on_laser()
|
hardware_manager.stop_idle_timer()
|
||||||
time.sleep_ms(100)
|
if not config.HARDCODE_LASER_POINT:
|
||||||
hardware_manager.stop_idle_timer() # 停表
|
laser_manager.start_calibration()
|
||||||
if not config.HARDCODE_LASER_POINT:
|
self.safe_enqueue({"result": "calibrating"}, 2)
|
||||||
laser_manager.start_calibration()
|
|
||||||
self.safe_enqueue({"result": "calibrating"}, 2)
|
|
||||||
else:
|
|
||||||
# 写死的逻辑,不需要校准激光点
|
|
||||||
self.safe_enqueue({"result": "laser pos set by hard code"}, 2)
|
|
||||||
elif inner_cmd == 3: # 关闭激光
|
|
||||||
from laser_manager import laser_manager
|
|
||||||
laser_manager.turn_off_laser()
|
|
||||||
laser_manager.stop_calibration()
|
|
||||||
hardware_manager.start_idle_timer() # 开表
|
|
||||||
self.safe_enqueue({"result": "laser_off"}, 2)
|
|
||||||
elif inner_cmd == 4: # 上报电量
|
|
||||||
voltage = get_bus_voltage()
|
|
||||||
battery_percent = voltage_to_percent(voltage)
|
|
||||||
battery_data = {
|
|
||||||
"battery": battery_percent,
|
|
||||||
"voltage": round(float(voltage), 3),
|
|
||||||
"netType": self.network_type,
|
|
||||||
}
|
|
||||||
self.safe_enqueue(battery_data, 2)
|
|
||||||
self.logger.info(f"电量上报: {battery_percent}% 充电: {is_charging()}")
|
|
||||||
if is_charging():
|
|
||||||
self.safe_enqueue(
|
|
||||||
{
|
|
||||||
"cmd": 700,
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
elif inner_cmd == 700:
|
|
||||||
self.logger.warning("服务器下发关机!!!")
|
|
||||||
exit(-1)
|
|
||||||
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")
|
|
||||||
self.safe_enqueue({"result": "missing_url"}, 2)
|
|
||||||
_rx_skip_tcp_iteration = True
|
|
||||||
break
|
|
||||||
|
|
||||||
from ota_manager import ota_manager
|
|
||||||
if ota_manager.update_thread_started:
|
|
||||||
self.safe_enqueue({"result": "update_already_started"}, 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() # 停表,注意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 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") # 刷新文件系统缓存到磁盘,防止数据丢失
|
|
||||||
time.sleep_ms(500)
|
|
||||||
# os.system("poweroff")
|
|
||||||
hardware_manager.power_off()
|
|
||||||
return
|
|
||||||
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() # 重新计时
|
|
||||||
|
|
||||||
if not upload_url:
|
|
||||||
self.logger.error("[LOG_UPLOAD] 缺少 url 参数")
|
|
||||||
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_url"},
|
|
||||||
2)
|
|
||||||
else:
|
else:
|
||||||
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
self.safe_enqueue({"result": "laser pos set by hard code"}, 2)
|
||||||
# 在新线程中执行上传,避免阻塞主循环
|
elif cmd == 3: # CloseAimRequest 关闭激光
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
laser_manager.turn_off_laser()
|
||||||
|
laser_manager.stop_calibration()
|
||||||
|
hardware_manager.start_idle_timer()
|
||||||
|
self.safe_enqueue({"result": "laser_off"}, 2)
|
||||||
|
elif cmd == 4: # GetBatteryRequest 上报电量
|
||||||
|
voltage = get_bus_voltage()
|
||||||
|
battery_percent = voltage_to_percent(voltage)
|
||||||
|
charging = is_charging()
|
||||||
|
self.safe_enqueue({
|
||||||
|
"cmd": 4,
|
||||||
|
"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.logger.warning("服务器下发关机!!!")
|
||||||
|
exit(-1)
|
||||||
|
elif cmd == 5: # OtaRequest OTA 升级
|
||||||
|
ota_url = data_obj.get("url", "")
|
||||||
|
|
||||||
|
if not ota_url:
|
||||||
|
self.logger.error("ota missing_url")
|
||||||
|
self.safe_enqueue({"result": "missing_url"}, 2)
|
||||||
|
_rx_skip_tcp_iteration = True
|
||||||
|
break
|
||||||
|
|
||||||
|
from ota_manager import ota_manager
|
||||||
|
if ota_manager.update_thread_started:
|
||||||
|
self.safe_enqueue({"result": "update_already_started"}, 2)
|
||||||
|
_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
|
||||||
|
|
||||||
|
hardware_manager.stop_idle_timer()
|
||||||
|
self._spawn_cmd_thread(self._cmd5_ota, (ota_url,))
|
||||||
|
elif cmd == 41: # Ota4gSubCodeRequest 射箭触发
|
||||||
|
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 关机命令
|
||||||
|
self.logger.info("[SHUTDOWN] 收到TCP关机命令,准备关机...")
|
||||||
|
self.safe_enqueue({"result": "shutdown_ack"}, 2)
|
||||||
|
time.sleep_ms(1000)
|
||||||
|
self.disconnect_server()
|
||||||
|
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")
|
||||||
|
time.sleep_ms(500)
|
||||||
|
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")
|
||||||
|
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
||||||
self._spawn_cmd_thread(
|
self._spawn_cmd_thread(
|
||||||
self._upload_log_file,
|
self._upload_log_file,
|
||||||
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
|
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
|
||||||
archive_format)
|
archive_format)
|
||||||
)
|
)
|
||||||
elif inner_cmd == 200:
|
elif cmd == 200: # GenericResult "init_center_point" 触发激光检测
|
||||||
self.logger.info("[LASER] cmd200 在后台线程执行检测")
|
self.logger.info("[LASER] cmd200 在后台线程执行检测")
|
||||||
self._spawn_cmd_thread(self._cmd200_detect_laser, ())
|
self._spawn_cmd_thread(self._cmd200_detect_laser, ())
|
||||||
elif inner_cmd == 300:
|
elif cmd == 201: # SetCenterPointRequest 设置中心点
|
||||||
self.logger.info("[New Ota] cmd300 在后台线程执行OTA")
|
if self.logger:
|
||||||
self._spawn_cmd_thread(self._cmd300_ota, (data_obj,))
|
self.logger.info(f"[LASER] cmd201:{body}")
|
||||||
elif inner_cmd == 600:
|
raw_x = data_obj.get("x")
|
||||||
self.logger.info("[conn wifi] cmd600 在后台线程执行连接wifi: {data_obj}")
|
raw_y = data_obj.get("y")
|
||||||
self._spawn_cmd_thread(self._cmd600_conn_wifi, (data_obj,))
|
try:
|
||||||
elif inner_cmd == 601:
|
from laser_manager import laser_manager
|
||||||
pass
|
ix, iy = laser_manager.set_hardcoded_laser_point(raw_x, raw_y)
|
||||||
else: # data的结构不是 dict
|
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
|
||||||
|
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:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
self.logger.info(f"[NET] body={body}, {time.time()}")
|
self.logger.info(f"[NET] body={body}, {time.time()}")
|
||||||
else:
|
|
||||||
self.logger.info(f"[NET] 未知数据 {body}, {time.time()}")
|
|
||||||
if _rx_login_fail:
|
if _rx_login_fail:
|
||||||
break
|
break
|
||||||
if _rx_skip_tcp_iteration:
|
if _rx_skip_tcp_iteration:
|
||||||
@@ -2310,7 +2458,7 @@ class NetworkManager:
|
|||||||
|
|
||||||
if item:
|
if item:
|
||||||
msg_type, data_dict = item
|
msg_type, data_dict = item
|
||||||
pkt = self._netcore.make_packet(msg_type, data_dict)
|
pkt = self._make_send_packet(msg_type, data_dict)
|
||||||
if not self.tcp_send_raw(pkt):
|
if not self.tcp_send_raw(pkt):
|
||||||
# 发送失败:将消息放回队首(队列满则丢弃)
|
# 发送失败:将消息放回队首(队列满则丢弃)
|
||||||
with self.get_queue_lock():
|
with self.get_queue_lock():
|
||||||
@@ -2339,8 +2487,8 @@ class NetworkManager:
|
|||||||
current_time = time.ticks_ms()
|
current_time = time.ticks_ms()
|
||||||
if logged_in and current_time - last_heartbeat_send_time > config.HEARTBEAT_INTERVAL * 1000:
|
if logged_in and current_time - last_heartbeat_send_time > config.HEARTBEAT_INTERVAL * 1000:
|
||||||
vol_val = get_bus_voltage()
|
vol_val = get_bus_voltage()
|
||||||
if not self.tcp_send_raw(
|
heartbeat_pkt = self._make_send_packet(4, {"vol": vol_val, "vol_per": voltage_to_percent(vol_val)})
|
||||||
self._netcore.make_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.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
|
send_hartbeat_fail_count += 1
|
||||||
# 短暂波动可能导致一次发送失败:连续失败达到阈值才重连,避免重连风暴
|
# 短暂波动可能导致一次发送失败:连续失败达到阈值才重连,避免重连风暴
|
||||||
|
|||||||
+136
-1162
File diff suppressed because it is too large
Load Diff
+31
-7
@@ -59,6 +59,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"""
|
"""
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
yellow_algorithm_ms = 0.0
|
||||||
|
|
||||||
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
||||||
laser_point_method = None
|
laser_point_method = None
|
||||||
@@ -74,7 +75,11 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
|
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
|
||||||
else:
|
else:
|
||||||
# 动态模式:先做一次无激光点检测以估算距离,再推算激光点
|
# 动态模式:先做一次无激光点检测以估算距离,再推算激光点
|
||||||
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
|
_t_yellow = time_std.perf_counter()
|
||||||
|
try:
|
||||||
|
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
|
||||||
|
finally:
|
||||||
|
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
|
||||||
distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None
|
distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None
|
||||||
if distance_m_first and distance_m_first > 0:
|
if distance_m_first and distance_m_first > 0:
|
||||||
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
|
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
|
||||||
@@ -119,6 +124,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
||||||
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
|
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
|
||||||
"distance_method": "yellow_radius",
|
"distance_method": "yellow_radius",
|
||||||
|
"yellow_algorithm_ms": float(yellow_algorithm_ms),
|
||||||
}
|
}
|
||||||
if yolo_roi_xyxy is not None:
|
if yolo_roi_xyxy is not None:
|
||||||
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
||||||
@@ -126,9 +132,12 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
|
|
||||||
if not use_tri:
|
if not use_tri:
|
||||||
# 三角形未配置,直接跑圆形检测
|
# 三角形未配置,直接跑圆形检测
|
||||||
return _build_circle_result(
|
_t_yellow = time_std.perf_counter()
|
||||||
detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
try:
|
||||||
)
|
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
||||||
|
finally:
|
||||||
|
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
|
||||||
|
return _build_circle_result(cdata)
|
||||||
|
|
||||||
# ── Step 4: 先独占跑三角形,超时或失败后再跑圆形(不与圆心并行,避免抢 CPU)──
|
# ── Step 4: 先独占跑三角形,超时或失败后再跑圆形(不与圆心并行,避免抢 CPU)──
|
||||||
roi_xyxy = None
|
roi_xyxy = None
|
||||||
@@ -281,6 +290,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
||||||
"offset_method": tri.get("offset_method") or "triangle_homography",
|
"offset_method": tri.get("offset_method") or "triangle_homography",
|
||||||
"distance_method": tri.get("distance_method") or "pnp_triangle",
|
"distance_method": tri.get("distance_method") or "pnp_triangle",
|
||||||
|
"yellow_algorithm_ms": float(yellow_algorithm_ms),
|
||||||
"tri_markers": tri.get("markers", []),
|
"tri_markers": tri.get("markers", []),
|
||||||
"tri_markers_completed": tri.get("markers_completed", []),
|
"tri_markers_completed": tri.get("markers_completed", []),
|
||||||
"tri_homography": tri.get("homography"),
|
"tri_homography": tri.get("homography"),
|
||||||
@@ -301,7 +311,11 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
|
|
||||||
# 三角形超时或失败 → 跑圆心;圆心跑完后再检查三角形是否已结束
|
# 三角形超时或失败 → 跑圆心;圆心跑完后再检查三角形是否已结束
|
||||||
try:
|
try:
|
||||||
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
_t_yellow = time_std.perf_counter()
|
||||||
|
try:
|
||||||
|
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
||||||
|
finally:
|
||||||
|
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[CIRCLE] 圆形检测异常: {e}")
|
logger.error(f"[CIRCLE] 圆形检测异常: {e}")
|
||||||
cdata = (frame, None, None, None, None, None)
|
cdata = (frame, None, None, None, None, None)
|
||||||
@@ -337,10 +351,15 @@ def process_shot(adc_val):
|
|||||||
|
|
||||||
# Classify only the current shot frame; never reuse a previous result.
|
# Classify only the current shot frame; never reuse a previous result.
|
||||||
target_class_result = None
|
target_class_result = None
|
||||||
|
yolo_target_ms = 0.0
|
||||||
try:
|
try:
|
||||||
from target_roi_yolo import try_get_target_class_from_yolo
|
from target_roi_yolo import try_get_target_class_from_yolo
|
||||||
|
|
||||||
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
|
_t_yolo_target = time_std.perf_counter()
|
||||||
|
try:
|
||||||
|
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
|
||||||
|
finally:
|
||||||
|
yolo_target_ms = (time_std.perf_counter() - _t_yolo_target) * 1000.0
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[YOLO-TARGET] 当前箭业务结果: {target_class_result}")
|
logger.info(f"[YOLO-TARGET] 当前箭业务结果: {target_class_result}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -370,6 +389,7 @@ def process_shot(adc_val):
|
|||||||
laser_point_method = analysis_result["laser_point_method"]
|
laser_point_method = analysis_result["laser_point_method"]
|
||||||
offset_method = analysis_result.get("offset_method", "yellow_circle")
|
offset_method = analysis_result.get("offset_method", "yellow_circle")
|
||||||
distance_method = analysis_result.get("distance_method", "yellow_radius")
|
distance_method = analysis_result.get("distance_method", "yellow_radius")
|
||||||
|
yellow_algorithm_ms = float(analysis_result.get("yellow_algorithm_ms", 0.0) or 0.0)
|
||||||
tri_markers = analysis_result.get("tri_markers", [])
|
tri_markers = analysis_result.get("tri_markers", [])
|
||||||
tri_markers_completed = analysis_result.get("tri_markers_completed", [])
|
tri_markers_completed = analysis_result.get("tri_markers_completed", [])
|
||||||
tri_homography = analysis_result.get("tri_homography")
|
tri_homography = analysis_result.get("tri_homography")
|
||||||
@@ -419,7 +439,9 @@ def process_shot(adc_val):
|
|||||||
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
||||||
"target_class": target_label,
|
"target_class": target_label,
|
||||||
"target_class_confidence": (
|
"target_class_confidence": (
|
||||||
float(target_confidence) if target_confidence is not None else None
|
round(float(target_confidence), 2)
|
||||||
|
if target_confidence is not None
|
||||||
|
else None
|
||||||
),
|
),
|
||||||
"d": round((distance_m or 0.0) * 100),
|
"d": round((distance_m or 0.0) * 100),
|
||||||
"d_laser": round((laser_distance_m or 0.0) * 100),
|
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||||
@@ -431,6 +453,8 @@ def process_shot(adc_val):
|
|||||||
"target_y": float(y),
|
"target_y": float(y),
|
||||||
"offset_method": offset_method,
|
"offset_method": offset_method,
|
||||||
"distance_method": distance_method,
|
"distance_method": distance_method,
|
||||||
|
"yellow_algorithm_ms": round(yellow_algorithm_ms, 2),
|
||||||
|
"yolo_target_ms": round(float(yolo_target_ms), 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
if ellipse_params:
|
if ellipse_params:
|
||||||
|
|||||||
+221
-43
@@ -310,7 +310,10 @@ def _det_obj_score(o):
|
|||||||
|
|
||||||
|
|
||||||
def try_get_target_class_from_yolo(maix_frame, logger=None):
|
def try_get_target_class_from_yolo(maix_frame, logger=None):
|
||||||
"""Classify the current target as 20cm or 40cm; return None if unknown."""
|
"""调用 YOLO 检测:class_id∈IGNORE_CLASS_IDS(默认 1) 的框在解析时直接过滤;
|
||||||
|
detections 输出其余全部框(不按置信度过滤);
|
||||||
|
上报以「检出框裁剪放大后的复检结果」为主,复检不可用时用全图结果;
|
||||||
|
有效靶规格(20/40)中取置信度最高的一条,且低于 MIN_REPORT_CONF 不上报类别。"""
|
||||||
try:
|
try:
|
||||||
import config as cfg
|
import config as cfg
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -333,64 +336,239 @@ def try_get_target_class_from_yolo(maix_frame, logger=None):
|
|||||||
|
|
||||||
conf_th = float(getattr(cfg, "TARGET_CLASS_YOLO_CONF_TH", 0.5))
|
conf_th = float(getattr(cfg, "TARGET_CLASS_YOLO_CONF_TH", 0.5))
|
||||||
iou_th = float(getattr(cfg, "TARGET_CLASS_YOLO_IOU_TH", 0.45))
|
iou_th = float(getattr(cfg, "TARGET_CLASS_YOLO_IOU_TH", 0.45))
|
||||||
labels = getattr(cfg, "TARGET_CLASS_YOLO_LABELS", (20, 40))
|
|
||||||
|
# class_id → 靶规格标签,下标即 class_id:0→40, 1→0, 2→20
|
||||||
|
labels = getattr(cfg, "TARGET_CLASS_YOLO_LABELS", (40, 0, 20))
|
||||||
if isinstance(labels, str):
|
if isinstance(labels, str):
|
||||||
labels = tuple(x.strip() for x in labels.split(",") if x.strip())
|
labels = tuple(x.strip() for x in labels.split(",") if x.strip())
|
||||||
labels = tuple(labels)
|
labels = tuple(labels)
|
||||||
|
|
||||||
def _detect(threshold):
|
# 只有映射到这些标签的检测才算有效靶规格(class 1→label 0 直接忽略)
|
||||||
|
valid_labels = getattr(cfg, "TARGET_CLASS_YOLO_VALID_LABELS", (20, 40))
|
||||||
|
if isinstance(valid_labels, str):
|
||||||
|
valid_labels = tuple(
|
||||||
|
int(float(x.strip())) for x in valid_labels.split(",") if x.strip()
|
||||||
|
)
|
||||||
|
valid_labels = frozenset(int(float(v)) for v in valid_labels)
|
||||||
|
|
||||||
|
# 解析阶段就丢弃这些 class_id(1=靶心),不进入 detections,也不参与上报选择
|
||||||
|
ignore_class_ids = getattr(cfg, "TARGET_CLASS_YOLO_IGNORE_CLASS_IDS", (1,))
|
||||||
|
if isinstance(ignore_class_ids, int):
|
||||||
|
ignore_class_ids = (ignore_class_ids,)
|
||||||
|
if isinstance(ignore_class_ids, str):
|
||||||
|
ignore_class_ids = tuple(
|
||||||
|
int(float(x.strip())) for x in ignore_class_ids.split(",") if x.strip()
|
||||||
|
)
|
||||||
|
ignore_class_ids = frozenset(int(float(v)) for v in ignore_class_ids)
|
||||||
|
n_ignored = [0]
|
||||||
|
|
||||||
|
def _run(threshold):
|
||||||
try:
|
try:
|
||||||
raw = detector.detect(maix_frame, conf_th=threshold, iou_th=iou_th)
|
return detector.detect(maix_frame, conf_th=threshold, iou_th=iou_th)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if logger:
|
if logger:
|
||||||
logger.warning(f"[YOLO-TARGET] detect 异常: {exc}")
|
logger.warning(f"[YOLO-TARGET] detect 异常: {exc}")
|
||||||
return []
|
return None
|
||||||
return _normalize_objs(raw if raw is not None else [])
|
|
||||||
|
|
||||||
def _candidates(objs):
|
def _obj_box(o):
|
||||||
found = []
|
try:
|
||||||
for obj in objs:
|
return (
|
||||||
class_id = _det_obj_class_id(obj)
|
round(float(o.x), 1),
|
||||||
if class_id is None or class_id < 0 or class_id >= len(labels):
|
round(float(o.y), 1),
|
||||||
continue
|
round(float(o.w), 1),
|
||||||
try:
|
round(float(o.h), 1),
|
||||||
label = int(float(labels[class_id]))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if label in (20, 40):
|
|
||||||
found.append((label, class_id, _det_obj_score(obj)))
|
|
||||||
return found
|
|
||||||
|
|
||||||
objects = _detect(conf_th)
|
|
||||||
candidates = _candidates(objects)
|
|
||||||
if logger and objects:
|
|
||||||
logger.info(
|
|
||||||
"[YOLO-TARGET] 原始框=%d, 解析类别=%s"
|
|
||||||
% (
|
|
||||||
len(objects),
|
|
||||||
[(_det_obj_class_id(o), _det_obj_score(o)) for o in objects[:8]],
|
|
||||||
)
|
)
|
||||||
)
|
except (TypeError, ValueError, AttributeError):
|
||||||
if not candidates and bool(
|
return None
|
||||||
getattr(cfg, "TARGET_CLASS_YOLO_RETRY_ON_EMPTY", False)
|
|
||||||
):
|
|
||||||
retry_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RETRY_CONF_TH", conf_th))
|
|
||||||
if 0 < retry_th < conf_th:
|
|
||||||
candidates = _candidates(_detect(retry_th))
|
|
||||||
|
|
||||||
if not candidates:
|
def _parse(raw_result):
|
||||||
|
out = []
|
||||||
|
n_ignored[0] = 0
|
||||||
|
for obj in _normalize_objs(raw_result if raw_result is not None else []):
|
||||||
|
class_id = _det_obj_class_id(obj)
|
||||||
|
if class_id is None or class_id in ignore_class_ids:
|
||||||
|
n_ignored[0] += 1
|
||||||
|
continue
|
||||||
|
label = None
|
||||||
|
if 0 <= class_id < len(labels):
|
||||||
|
try:
|
||||||
|
label = int(float(labels[class_id]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
label = None
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"class_id": class_id,
|
||||||
|
"label": label,
|
||||||
|
"confidence": round(_det_obj_score(obj), 4),
|
||||||
|
"box_xywh": _obj_box(obj),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# 第 1 次检测
|
||||||
|
raw = _run(conf_th)
|
||||||
|
detections = _parse(raw)
|
||||||
|
|
||||||
|
# 0 结果时重新检测一次(阈值取更小值,仍为 0 则同参数重跑)
|
||||||
|
if not detections:
|
||||||
|
retry_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RETRY_CONF_TH", 0.25) or 0)
|
||||||
|
if not (0 < retry_th < conf_th):
|
||||||
|
retry_th = conf_th
|
||||||
if logger:
|
if logger:
|
||||||
logger.warning("[YOLO-TARGET] 当前帧未识别到 20/40,按未知处理")
|
logger.info(f"[YOLO-TARGET] 0 结果,重新检测 conf_th={retry_th}")
|
||||||
return None
|
raw_retry = _run(retry_th)
|
||||||
|
detections_retry = _parse(raw_retry)
|
||||||
|
if detections_retry:
|
||||||
|
detections = detections_retry
|
||||||
|
|
||||||
label, class_id, confidence = max(candidates, key=lambda item: item[2])
|
|
||||||
result = {"label": label, "class_id": class_id, "confidence": confidence}
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[YOLO-TARGET] 当前帧分类={label}, class_id={class_id}, "
|
f"[YOLO-TARGET] 检测结果={detections}"
|
||||||
f"conf={confidence:.3f}"
|
f"(已忽略 class_id∈{tuple(sorted(ignore_class_ids))} 共{n_ignored[0]} 条)"
|
||||||
)
|
)
|
||||||
return result
|
|
||||||
|
if not detections:
|
||||||
|
if logger:
|
||||||
|
logger.warning("[YOLO-TARGET] 未检测到目标(已重检 1 次)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _best_valid(dets):
|
||||||
|
valid = [d for d in dets if d["label"] in valid_labels]
|
||||||
|
if not valid:
|
||||||
|
return None
|
||||||
|
return max(valid, key=lambda d: d["confidence"])
|
||||||
|
|
||||||
|
def _crop_target(box, margin_frac):
|
||||||
|
"""按检出框(带 margin)裁出放大 ROI;返回 Maix image,失败返回 None。"""
|
||||||
|
if not box or len(box) != 4:
|
||||||
|
return None
|
||||||
|
x, y, w, h = [float(v) for v in box]
|
||||||
|
try:
|
||||||
|
fw, fh = int(maix_frame.width()), int(maix_frame.height())
|
||||||
|
except Exception:
|
||||||
|
fw = fh = 0
|
||||||
|
mx, my = w * margin_frac, h * margin_frac
|
||||||
|
x0, y0 = int(max(0, x - mx)), int(max(0, y - my))
|
||||||
|
x1 = int(min(fw, x + w + mx)) if fw else int(x + w + mx)
|
||||||
|
y1 = int(min(fh, y + h + my)) if fh else int(y + h + my)
|
||||||
|
if (x1 - x0) < 32 or (y1 - y0) < 32:
|
||||||
|
if logger:
|
||||||
|
logger.warning(
|
||||||
|
f"[YOLO-TARGET] 复检裁剪过小 [{x0},{y0},{x1},{y1}]"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cropped = maix_frame.crop(x0, y0, x1 - x0, y1 - y0)
|
||||||
|
if cropped is not None:
|
||||||
|
return cropped
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from maix import image as maix_image
|
||||||
|
|
||||||
|
arr = maix_frame.to_numpy()
|
||||||
|
slab = np.ascontiguousarray(arr[y0:y1, x0:x1], dtype=np.uint8)
|
||||||
|
if slab.size == 0:
|
||||||
|
return None
|
||||||
|
return maix_image.cv2image(slab, False, True)
|
||||||
|
except Exception as exc:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-TARGET] 复检裁剪失败: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 上报口径:只在有效靶规格(20/40)里取置信度最高的一条,class_id=1 已在解析阶段过滤
|
||||||
|
full_best = _best_valid(detections)
|
||||||
|
|
||||||
|
# 二次裁剪复检:把全图检出的靶框裁剪放大后再 detect 一次,与全图结果互相印证
|
||||||
|
# 复检用更低阈值 RECHECK_CONF_TH,置信度大于该值即接受
|
||||||
|
recheck_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_CONF_TH", 0.25))
|
||||||
|
crop_best = None
|
||||||
|
recheck_status = "off"
|
||||||
|
if full_best is not None and bool(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_CROP", True)
|
||||||
|
):
|
||||||
|
recheck_status = "fail"
|
||||||
|
margin_frac = float(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_MARGIN_FRAC", 0.1)
|
||||||
|
)
|
||||||
|
crop_img = _crop_target(full_best["box_xywh"], margin_frac)
|
||||||
|
if crop_img is not None:
|
||||||
|
try:
|
||||||
|
raw_crop = detector.detect(
|
||||||
|
crop_img, conf_th=recheck_th, iou_th=iou_th
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raw_crop = None
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-TARGET] 复检 detect 异常: {exc}")
|
||||||
|
if raw_crop is not None:
|
||||||
|
crop_dets = _parse(raw_crop)
|
||||||
|
recheck_status = "ok"
|
||||||
|
crop_best = _best_valid(crop_dets)
|
||||||
|
if crop_best is not None and crop_best["confidence"] <= recheck_th:
|
||||||
|
crop_best = None
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-TARGET] 复检(conf_th={recheck_th}, "
|
||||||
|
f"裁剪框={full_best['box_xywh']}) 结果={crop_dets}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 合并:以复检(裁剪放大)结果为主;复检无结果才用全图
|
||||||
|
picked = full_best
|
||||||
|
picked_from_crop = False
|
||||||
|
note = ""
|
||||||
|
if recheck_status == "ok":
|
||||||
|
if crop_best is None:
|
||||||
|
note = f"复检无 conf>{recheck_th} 的有效框,沿用全图"
|
||||||
|
else:
|
||||||
|
picked = crop_best
|
||||||
|
picked_from_crop = True
|
||||||
|
if crop_best["label"] == full_best["label"]:
|
||||||
|
note = f"复检一致({crop_best['label']}),以复检为准"
|
||||||
|
else:
|
||||||
|
note = (
|
||||||
|
f"复检与全图不一致(全图={full_best['label']}@{full_best['confidence']}, "
|
||||||
|
f"裁剪={crop_best['label']}@{crop_best['confidence']}),以复检为准"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 置信度门槛:低于阈值不上报类别,避免硬猜(复检结果只需 > RECHECK_CONF_TH)
|
||||||
|
if picked is not None:
|
||||||
|
if picked_from_crop:
|
||||||
|
min_report_conf = recheck_th
|
||||||
|
below = picked["confidence"] <= min_report_conf
|
||||||
|
else:
|
||||||
|
min_report_conf = float(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_MIN_REPORT_CONF", 0.5)
|
||||||
|
)
|
||||||
|
below = picked["confidence"] < min_report_conf
|
||||||
|
if below:
|
||||||
|
note = (note + "; " if note else "") + (
|
||||||
|
f"conf={picked['confidence']} 未达门槛{min_report_conf},置信度不足"
|
||||||
|
)
|
||||||
|
picked = None
|
||||||
|
|
||||||
|
if picked is None:
|
||||||
|
label, class_id, confidence = None, None, None
|
||||||
|
else:
|
||||||
|
label = picked["label"]
|
||||||
|
class_id = picked["class_id"]
|
||||||
|
confidence = picked["confidence"]
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-TARGET] 上报={label}, class_id={class_id}, conf={confidence}, "
|
||||||
|
f"全图候选={[(d['label'], d['confidence']) for d in detections if d['label'] in valid_labels]}, "
|
||||||
|
f"复检={recheck_status} {note}"
|
||||||
|
)
|
||||||
|
if note:
|
||||||
|
logger.warning(f"[YOLO-TARGET] {note}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"label": label,
|
||||||
|
"class_id": class_id,
|
||||||
|
"confidence": confidence,
|
||||||
|
"detections": detections,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# test_bluetooth.py
|
||||||
|
# 参考: https://wiki.sipeed.com/maixpy/doc/zh/modules/bluetooth.html
|
||||||
|
#
|
||||||
|
# 依赖:
|
||||||
|
# 1. 使能蓝牙: bluetoothctl power on
|
||||||
|
# (可选开机自启: echo "bluetoothctl power on" >> /etc/rc.local)
|
||||||
|
# 2. 安装依赖: pip install bleak
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bleak import BleakScanner, BleakClient
|
||||||
|
except ImportError:
|
||||||
|
print("ModuleNotFoundError: No module named 'bleak'")
|
||||||
|
print("请先在设备上安装依赖:")
|
||||||
|
print(" pip install bleak")
|
||||||
|
print("并使能蓝牙:")
|
||||||
|
print(" bluetoothctl power on")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
MODEL_NBR_UUID = "1A2A"
|
||||||
|
|
||||||
|
BLEDev = namedtuple("BLEDev", ["address", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_devices(timeout=5.0):
|
||||||
|
"""扫描周围的蓝牙设备"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Scanning BLE devices...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
found = {}
|
||||||
|
|
||||||
|
def _cb(device, adv):
|
||||||
|
found[device.address] = adv.local_name or device.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
await BleakScanner.discover(timeout=timeout, detection_callback=_cb)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Scan failed: {e}")
|
||||||
|
print("Possible causes:")
|
||||||
|
print(" 1. Bluetooth not powered on: bluetoothctl power on")
|
||||||
|
print(" 2. bleak not installed: pip install bleak")
|
||||||
|
print(" 3. BLE hardware/driver not available")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
print("No devices found.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
for address, name in found.items():
|
||||||
|
devices.append(BLEDev(address, name))
|
||||||
|
print(f" {address} {name}")
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_device(address, timeout=20.0, retries=3):
|
||||||
|
"""连接蓝牙设备并尝试读取特征值"""
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Connecting to {address} ...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
async with BleakClient(address, timeout=timeout) as client:
|
||||||
|
print(f"Connected: {client.is_connected}")
|
||||||
|
|
||||||
|
# 遍历服务和特征值
|
||||||
|
for service in client.services:
|
||||||
|
print(f" Service: {service.uuid}")
|
||||||
|
for char in service.characteristics:
|
||||||
|
print(f" Char: {char.uuid} props={list(char.properties)}")
|
||||||
|
|
||||||
|
# 尝试读取 Model Number 特征
|
||||||
|
try:
|
||||||
|
value = await client.read_gatt_char(MODEL_NBR_UUID)
|
||||||
|
print(f"Model Number: {value.decode(errors='replace')}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Read {MODEL_NBR_UUID} failed (device may not have it): {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[attempt {attempt}/{retries}] Connect failed: {e}")
|
||||||
|
if attempt < retries:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
print("\nAll attempts failed. 请检查:")
|
||||||
|
print(" 1. forrily 是否已连到手机/其它主机(BLE 外设通常只允许一连接),")
|
||||||
|
print(" 先在手机上断开它")
|
||||||
|
print(" 2. 清除 BlueZ 缓存后重试:")
|
||||||
|
print(f" bluetoothctl remove {address}")
|
||||||
|
print(" systemctl restart bluetooth")
|
||||||
|
print(" bluetoothctl power on")
|
||||||
|
print(" 3. 升级依赖:")
|
||||||
|
print(" pip install -U bleak dbus-fast")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
target_name = "forrily"
|
||||||
|
|
||||||
|
devices = await scan_devices(timeout=5.0)
|
||||||
|
if not devices:
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
address = sys.argv[1]
|
||||||
|
else:
|
||||||
|
# 按名字查找目标设备,不连第一个
|
||||||
|
target = None
|
||||||
|
for d in devices:
|
||||||
|
if d.name and d.name.strip().lower() == target_name.lower():
|
||||||
|
target = d
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
for d in devices:
|
||||||
|
if d.name and target_name.lower() in d.name.strip().lower():
|
||||||
|
target = d
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
print(f"\nDevice named '{target_name}' not found!")
|
||||||
|
print("Scanned devices are listed above.")
|
||||||
|
return
|
||||||
|
address = target.address
|
||||||
|
print(f"\nFound '{target_name}': {address}")
|
||||||
|
|
||||||
|
await connect_device(address)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# test_bluetooth_peripheral.py
|
||||||
|
# 设备(MaixCAM)作为 BLE 从机(Peripheral/GATT Server)广播给手机连接。
|
||||||
|
# 参考: https://wiki.sipeed.com/maixpy/doc/zh/modules/bluetooth.html
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# python test_bluetooth_peripheral.py [广播名字] # 默认 shoot
|
||||||
|
#
|
||||||
|
# 依赖:
|
||||||
|
# 1. 使能蓝牙: bluetoothctl power on
|
||||||
|
# 2. 安装依赖: pip install bless
|
||||||
|
# 3. 部分 BlueZ 需要 experimental 模式才支持 GATT Server:
|
||||||
|
# 编辑 /etc/bluetooth/main.conf 保证有 Experimental = true
|
||||||
|
# 然后 systemctl restart bluetooth && bluetoothctl power on
|
||||||
|
#
|
||||||
|
# 手机端: 用 nRF Connect / 任意 BLE 调试 App 搜索广播名连接,
|
||||||
|
# 读写 characteristic 并订阅 notify,每 2 秒会收到计数通知。
|
||||||
|
#
|
||||||
|
# 实现:
|
||||||
|
# - GATT 服务由 bless 注册到 BlueZ
|
||||||
|
# - 广播由常驻 bluetoothctl 子进程控制 (menu advertise / name / advertise on),
|
||||||
|
# 这是已验证手机能搜到的路径; 若 bluetoothctl 不可用则回退到 btmgmt adv on
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bless import (
|
||||||
|
BlessServer,
|
||||||
|
GATTCharacteristicProperties,
|
||||||
|
GATTAttributePermissions,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
print("ModuleNotFoundError: No module named 'bless'")
|
||||||
|
print("请先在设备上安装依赖:")
|
||||||
|
print(" pip install bless")
|
||||||
|
print("并使能蓝牙:")
|
||||||
|
print(" bluetoothctl power on")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
DEVICE_NAME = sys.argv[1] if len(sys.argv) > 1 else "shoot"
|
||||||
|
SERVICE_UUID = "A07498CA-AD5B-474E-940D-16F1FBE7E8CD"
|
||||||
|
CHAR_UUID = "51FF12BB-3ED8-46E5-B4F9-D64E2FEC021B"
|
||||||
|
BTCTL_LOG = "/tmp/btctl_adv.log"
|
||||||
|
|
||||||
|
# 不同 bless 版本枚举名不同: writeable / writable
|
||||||
|
try:
|
||||||
|
WRITE_PERMISSION = GATTAttributePermissions.writeable
|
||||||
|
except AttributeError:
|
||||||
|
WRITE_PERMISSION = GATTAttributePermissions.writable
|
||||||
|
|
||||||
|
_btctl = None
|
||||||
|
|
||||||
|
|
||||||
|
def on_read(characteristic, request):
|
||||||
|
print(f"[GATT] read {characteristic.uuid}")
|
||||||
|
return characteristic.value
|
||||||
|
|
||||||
|
|
||||||
|
def on_write(characteristic, value, request):
|
||||||
|
characteristic.value = value
|
||||||
|
print(f"[GATT] write {characteristic.uuid} = {bytes(value)}")
|
||||||
|
|
||||||
|
|
||||||
|
def on_subscribe(characteristic, session):
|
||||||
|
print(f"[GATT] client subscribed: {characteristic.uuid}")
|
||||||
|
|
||||||
|
|
||||||
|
def on_unsubscribe(characteristic, session):
|
||||||
|
print(f"[GATT] client unsubscribed: {characteristic.uuid}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
out = ((r.stdout or "") + (r.stderr or "")).strip()
|
||||||
|
if out:
|
||||||
|
print(f"[adv] {' '.join(cmd)} -> {out}")
|
||||||
|
return r.returncode == 0
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"[adv] {cmd[0]} not found")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[adv] {' '.join(cmd)} failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def adv_start(name):
|
||||||
|
"""启动广播: 优先 bluetoothctl (常驻子进程), 失败则回退 btmgmt"""
|
||||||
|
global _btctl
|
||||||
|
|
||||||
|
if shutil.which("bluetoothctl") is not None:
|
||||||
|
try:
|
||||||
|
log = open(BTCTL_LOG, "w")
|
||||||
|
_btctl = subprocess.Popen(
|
||||||
|
["bluetoothctl"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=log,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[adv] start bluetoothctl failed: {e}")
|
||||||
|
_btctl = None
|
||||||
|
|
||||||
|
if _btctl is not None:
|
||||||
|
for cmd in (
|
||||||
|
"menu advertise",
|
||||||
|
f"name {name}",
|
||||||
|
"discoverable on",
|
||||||
|
"menu main",
|
||||||
|
"advertise on",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
_btctl.stdin.write(cmd + "\n")
|
||||||
|
_btctl.stdin.flush()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[adv] write '{cmd}' failed: {e}")
|
||||||
|
break
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
time.sleep(1.0)
|
||||||
|
if _btctl.poll() is None:
|
||||||
|
print(f"[adv] bluetoothctl advertising on (log: {BTCTL_LOG})")
|
||||||
|
return True
|
||||||
|
print("[adv] bluetoothctl exited early, fallback to btmgmt")
|
||||||
|
|
||||||
|
# 回退: mgmt 传统广播
|
||||||
|
if shutil.which("btmgmt") is None:
|
||||||
|
print("[adv] bluetoothctl/btmgmt 均不可用")
|
||||||
|
return False
|
||||||
|
_run(["btmgmt", "le", "on"])
|
||||||
|
_run(["btmgmt", "connectable", "on"])
|
||||||
|
return _run(["btmgmt", "adv", "on"])
|
||||||
|
|
||||||
|
|
||||||
|
def adv_stop():
|
||||||
|
global _btctl
|
||||||
|
if _btctl is not None:
|
||||||
|
try:
|
||||||
|
_btctl.stdin.write("advertise off\n")
|
||||||
|
_btctl.stdin.flush()
|
||||||
|
time.sleep(0.3)
|
||||||
|
_btctl.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_btctl = None
|
||||||
|
elif shutil.which("btmgmt") is not None:
|
||||||
|
_run(["btmgmt", "adv", "off"])
|
||||||
|
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
gatt = {
|
||||||
|
SERVICE_UUID: {
|
||||||
|
CHAR_UUID: {
|
||||||
|
"Properties": (
|
||||||
|
GATTCharacteristicProperties.read
|
||||||
|
| GATTCharacteristicProperties.write
|
||||||
|
| GATTCharacteristicProperties.notify
|
||||||
|
),
|
||||||
|
"Permissions": (
|
||||||
|
GATTAttributePermissions.readable | WRITE_PERMISSION
|
||||||
|
),
|
||||||
|
"Value": bytearray(b"hello from maixcam"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server = BlessServer(
|
||||||
|
name=DEVICE_NAME,
|
||||||
|
on_read=on_read,
|
||||||
|
on_write=on_write,
|
||||||
|
on_subscribe=on_subscribe,
|
||||||
|
on_unsubscribe=on_unsubscribe,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Adding GATT ...")
|
||||||
|
await server.add_gatt(gatt)
|
||||||
|
|
||||||
|
print(f"Starting BLE peripheral, name = '{DEVICE_NAME}' ...")
|
||||||
|
try:
|
||||||
|
await server.start()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"start() failed: {e}")
|
||||||
|
print("排查:")
|
||||||
|
print(" 1. 蓝牙是否上电: bluetoothctl power on (bluetoothctl show 看 Powered)")
|
||||||
|
print(" 2. /etc/bluetooth/main.conf 加 Experimental = true 后重启:")
|
||||||
|
print(" systemctl restart bluetooth && bluetoothctl power on")
|
||||||
|
print(" 3. 依赖: pip install -U bless dbus-next bleak")
|
||||||
|
return
|
||||||
|
|
||||||
|
adv = await server.is_advertising()
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"'{DEVICE_NAME}' advertising status: {adv}")
|
||||||
|
|
||||||
|
print("Starting advertising ...")
|
||||||
|
if not adv_start(DEVICE_NAME):
|
||||||
|
print("广播启动失败,请手动执行: bluetoothctl -> advertise on")
|
||||||
|
|
||||||
|
print("手机用 nRF Connect 等 App 搜索并连接,")
|
||||||
|
print(f"服务 {SERVICE_UUID}")
|
||||||
|
print(f"特征 {CHAR_UUID} (read/write/notify)")
|
||||||
|
print("Ctrl+C 退出")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 每 2 秒向订阅者推送一次计数
|
||||||
|
counter = 0
|
||||||
|
while True:
|
||||||
|
counter += 1
|
||||||
|
try:
|
||||||
|
char = server.get_characteristic(CHAR_UUID)
|
||||||
|
if char is not None:
|
||||||
|
char.value = bytearray(str(counter).encode())
|
||||||
|
server.update_value(SERVICE_UUID, CHAR_UUID)
|
||||||
|
except Exception as e:
|
||||||
|
# 没有客户端订阅时可能失败,忽略
|
||||||
|
print(f"notify failed: {e}")
|
||||||
|
|
||||||
|
if counter % 10 == 0:
|
||||||
|
try:
|
||||||
|
print(f"[status] advertising={await server.is_advertising()} "
|
||||||
|
f"connected={await server.is_connected()}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
finally:
|
||||||
|
adv_stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.")
|
||||||
+1
-1
@@ -4,6 +4,6 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '2.17.15'
|
VERSION = '2.18.10'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user