Compare commits
5
Commits
e6726f087c
...
2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cea78523e | ||
|
|
8d35bf3068 | ||
|
|
d1a1d43bd8 | ||
|
|
496085cb93 | ||
|
|
2459bb3b67 |
@@ -1,6 +1,6 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 2.18.0
|
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
|
||||||
|
|||||||
@@ -262,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 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||||
@@ -326,7 +337,7 @@ LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
|
|||||||
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
||||||
|
|
||||||
# ==================== 图像保存配置 ====================
|
# ==================== 图像保存配置 ====================
|
||||||
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
||||||
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
|
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
|
||||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
MAX_IMAGES = 1000
|
MAX_IMAGES = 1000
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ def cmd_str():
|
|||||||
|
|
||||||
# 1. 初始化日志系统(WARNING级别,不打印/写入INFO和DEBUG日志,提高执行流畅度)
|
# 1. 初始化日志系统(WARNING级别,不打印/写入INFO和DEBUG日志,提高执行流畅度)
|
||||||
import logging
|
import logging
|
||||||
logger_manager.init_logging(log_level=logging.DEBUG)
|
logger_manager.init_logging(log_level=logging.WARNING)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||||
|
|||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[basic]
|
[basic]
|
||||||
type = cvimodel
|
type = cvimodel
|
||||||
model = model_317828.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, 40
|
labels = 40, 00, 20
|
||||||
|
|
||||||
+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):
|
||||||
|
|||||||
@@ -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.18.0'
|
VERSION = '2.18.10'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user