39 Commits
Author SHA1 Message Date
gcw_4spBpAfv ead2060ab3 wifi config while no 4g and wifi 2026-04-07 17:29:24 +08:00
gcw_4spBpAfv bdc3254ed2 fix wifi 2 pkg issue 2026-04-03 15:40:07 +08:00
gcw_4spBpAfv 685dce2519 remove rtt from wifi monitoring 2026-04-03 11:24:29 +08:00
gcw_4spBpAfv ec80107128 refind network and monitor wifi connection 2026-04-02 18:02:34 +08:00
gcw_4spBpAfv fffca13941 command update 2026-04-02 11:56:23 +08:00
gcw_4spBpAfv 760b43cc68 finetune the laser loc 2026-03-31 11:54:58 +08:00
gcw_4spBpAfv 3bc48598cd 'debug.md' 2026-03-25 18:25:45 +08:00
gcw_4spBpAfv 704b20cde1 add ArUco but no activated 2026-03-24 10:18:48 +08:00
gcw_4spBpAfv d1ae364dbd laser non-blocking flash 2026-03-23 11:49:56 +08:00
gcw_4spBpAfv 75def0ff38 auto_poweroff 2026-03-11 18:19:17 +08:00
gcw_4spBpAfv ff629e596d 一般时候不预览照片 2026-02-10 17:54:11 +08:00
gcw_4spBpAfv 592dc6ceb1 v1.2.8 2026-02-10 17:52:55 +08:00
gcw_4spBpAfv 573c0a3385 v1.2.7 2026-02-09 11:24:46 +08:00
gcw_4spBpAfv 8aea76d99b v1.2.5 2026-02-07 17:09:39 +08:00
gcw_4spBpAfv 61096ba190 'v1.2.3' 2026-02-05 12:45:52 +08:00
gcw_4spBpAfv f476545172 v1.2.2 2026-01-24 15:50:25 +08:00
gcw_4spBpAfv aae97f6ce9 v1.2.2 2026-01-24 15:45:32 +08:00
gcw_4spBpAfv 8ce8831315 v1.2.2 2026-01-24 11:05:03 +08:00
gcw_4spBpAfv 28fb62e5d6 v1.2.1 2026-01-23 11:28:40 +08:00
gcw_4spBpAfv 42bfdd033c invole c++ 2026-01-22 17:55:11 +08:00
gcw_4spBpAfv 945077a453 refind logger 2026-01-20 18:40:54 +08:00
gcw_4spBpAfv 0ce140a210 v1.1.5 2026-01-20 11:25:17 +08:00
huangzhenwei2 83fe0776eb update laser cabration 2026-01-13 00:01:39 +08:00
huangzhenwei2 a0019b8b0e fix the laser point x,y 2026-01-12 20:53:23 +08:00
huangzhenwei2 2a0534ac62 update laser estismate 2026-01-12 18:53:01 +08:00
huangzhenwei2 3c45fba0f5 update distance estismate by laser, both distance value are uploaded 2026-01-12 18:06:04 +08:00
huangzhenwei2 708925ab41 refine the code to different part 2026-01-12 11:39:27 +08:00
huangzhenwei2 92ad32bb8e refine ota 2025-12-30 16:40:01 +08:00
huangzhenwei2 669d032f96 ota with 4g 2025-12-30 16:23:17 +08:00
linyimin b37c492930 feat: 4g模块按行升级 2025-12-30 15:49:16 +08:00
huangzhenwei2 46757e848f update ota and tcp msg control 2025-12-30 09:21:58 +08:00
huangzhenwei2 201de84ad0 conflict read 2025-12-28 18:41:36 +08:00
huangzhenwei2 85a5ff9ff0 conflict merge 2025-12-28 16:30:11 +08:00
huangzhenwei2 e712e11ea0 ota update 2025-12-28 16:22:41 +08:00
linyimin b552d20a46 fix:测距 2025-12-28 16:19:00 +08:00
linyimin 21cec260b8 fix: 修改电量不固定 2025-12-26 15:12:47 +08:00
linyimin 5a98bf2e85 pref: 计算环数代码 2025-12-26 14:04:43 +08:00
huangzhenwei2 f11b31c09c update hearbeat 2025-12-26 11:47:33 +08:00
linyimin 0b18ec353c temp: 2025-12-25 16:08:42 +08:00
46 changed files with 36322 additions and 1635 deletions
+3
View File
@@ -0,0 +1,3 @@
/cpp_ext/build/
/.cursor/
/dist/
+399
View File
@@ -0,0 +1,399 @@
import re
import hashlib
import binascii
from maix import time
from power import get_bus_voltage, voltage_to_percent
from urllib.parse import urlparse
from hardware import hardware_manager
class DownloadManager4G:
"""4g下载管理器(单例)"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(DownloadManager4G, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# 私有状态
self.FRAG_SIZE = 1024
self.FRAG_DELAY = 10
self._initialized = True
def _log(self, *a):
if debug:
self.logger.debug(" ".join(str(x) for x in a))
def _pwr_log(self, prefix=""):
"""debug 用:输出电压/电量"""
if not debug:
return
try:
v = get_bus_voltage()
p = voltage_to_percent(v)
self.logger.debug(f"[PWR]{prefix} v={v:.3f}V p={p}%")
except Exception as e:
try:
self.logger.debug(f"[PWR]{prefix} read_failed: {e}")
except:
pass
def _clear_http_events(self):
if hardware_manager.at_client:
while hardware_manager.at_client.pop_http_event() is not None:
pass
def _parse_httpid(self, resp: str):
m = re.search(r"\+MHTTPCREATE:\s*(\d+)", resp)
return int(m.group(1)) if m else None
def _get_ip(self, ):
r = hardware_manager.at_client.send("AT+CGPADDR=1", "OK", 3000)
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
return m.group(1) if m else ""
def _ensure_pdp(self, ):
ip = self._get_ip()
if ip and ip != "0.0.0.0":
return True, ip
hardware_manager.at_client.send("AT+MIPCALL=1,1", "OK", 15000)
for _ in range(10):
ip = self._get_ip()
if ip and ip != "0.0.0.0":
return True, ip
time.sleep(1)
return False, ip
def _extract_hdr_fields(self, hdr_text: str):
mlen = re.search(r"Content-Length:\s*(\d+)", hdr_text, re.IGNORECASE)
clen = int(mlen.group(1)) if mlen else None
mmd5 = re.search(r"Content-Md5:\s*([A-Za-z0-9+/=]+)", hdr_text, re.IGNORECASE)
md5_b64 = mmd5.group(1).strip() if mmd5 else None
return clen, md5_b64
def _extract_content_range(self, hdr_text: str):
m = re.search(r"Content-Range:\s*bytes\s*(\d+)\s*-\s*(\d+)\s*/\s*(\d+)", hdr_text, re.IGNORECASE)
if not m:
return None, None, None
try:
return int(m.group(1)), int(m.group(2)), int(m.group(3))
except:
return None, None, None
def _hard_reset_http(self, ):
"""模块进入"坏状态"时的保守清场"""
self._clear_http_events()
for i in range(0, 6):
try:
hardware_manager.at_client.send(f"AT+MHTTPDEL={i}", "OK", 1200)
except:
pass
self._clear_http_events()
def _create_httpid(self, full_reset=False):
self._clear_http_events()
if hardware_manager.at_client:
hardware_manager.at_client.flush()
if full_reset:
self._hard_reset_http()
resp = hardware_manager.at_client.send(f'AT+MHTTPCREATE="{base_url}"', "OK", 8000)
hid = self._parse_httpid(resp)
if self._is_https:
resp = hardware_manager.at_client.send(f'AT+MHTTPCFG="ssl",{hid},1,1', "OK", 2000)
if "ERROR" in resp or "CME ERROR" in resp:
self.logger.error(f"MHTTPCFG SSL failed: {resp}")
# 尝试https 降级到http
downgraded_base_url = base_url.replace("https://", "http://")
resp = hardware_manager.at_client.send(f'AT+MHTTPCREATE="{downgraded_base_url}"', "OK", 8000)
hid = self._parse_httpid(resp)
return hid, resp
def _fetch_range_into_buf(self, start, want_len, out_buf, path, full_reset=False):
"""
请求 Range [start, start+want_len),写入 out_bufbytearray,长度=want_len
返回 (ok, msg, total_len, md5_b64, got_len)
"""
end_incl = start + want_len - 1
hid, cresp = self._create_httpid(full_reset=full_reset)
if hid is None:
return False, f"MHTTPCREATE failed: {cresp}", None, None, 0
# 降低 URC 压力(分片/延迟)
hardware_manager.at_client.send(f'AT+MHTTPCFG="fragment",{hid},{self.FRAG_SIZE},{self.FRAG_DELAY}', "OK", 1500)
# 设置 Range headerinclusive
hardware_manager.at_client.send(f'AT+MHTTPCFG="header",{hid},"Range: bytes={start}-{end_incl}"', "OK", 3000)
req = hardware_manager.at_client.send(f'AT+MHTTPREQUEST={hid},1,0,"{path}"', "OK", 15000)
if "ERROR" in req or "CME ERROR" in req:
hardware_manager.at_client.send(f"AT+MHTTPDEL={hid}", "OK", 2000)
return False, f"MHTTPREQUEST failed: {req}", None, None, 0
# 等 header + content
hdr_text = None
hdr_accum = ""
code = None
resp_total = None
total_len = None
md5_b64 = None
got_ranges = set()
last_sum = 0
t0 = time.ticks_ms()
timeout_ms = 9000
logged_hdr = False
while time.ticks_ms() - t0 < timeout_ms:
ev = hardware_manager.at_client.pop_http_event() if hardware_manager.at_client else None
if not ev:
time.sleep_ms(5)
continue
if ev[0] == "header":
_, ehid, ecode, ehdr = ev
if ehid != hid:
continue
code = ecode
hdr_text = ehdr
if ehdr:
hdr_accum = (hdr_accum + "\n" + ehdr) if hdr_accum else ehdr
resp_total_tmp, md5_tmp = self._extract_hdr_fields(hdr_accum)
if md5_tmp:
md5_b64 = md5_tmp
cr_s, cr_e, cr_total = self._extract_content_range(hdr_accum)
if cr_total is not None:
total_len = cr_total
if resp_total_tmp is not None:
resp_total = resp_total_tmp
elif resp_total is None and (cr_s is not None) and (cr_e is not None) and (cr_e >= cr_s):
resp_total = (cr_e - cr_s + 1)
if (not logged_hdr) and (resp_total is not None or total_len is not None):
self._log(f"[HDR] id={hid} code={code} clen={resp_total} cr={cr_s}-{cr_e}/{cr_total}")
logged_hdr = True
continue
if ev[0] == "content":
_, ehid, _total, _sum, _cur, payload = ev
if ehid != hid:
continue
if resp_total is None:
resp_total = _total
if resp_total is None or resp_total <= 0:
continue
start_rel = _sum - _cur
end_rel = _sum
if start_rel < 0 or start_rel >= resp_total:
continue
if end_rel > resp_total:
end_rel = resp_total
actual_len = min(len(payload), end_rel - start_rel)
if actual_len <= 0:
continue
out_buf[start_rel:start_rel + actual_len] = payload[:actual_len]
got_ranges.add((start_rel, start_rel + actual_len))
if _sum > last_sum:
last_sum = _sum
if debug and (last_sum >= resp_total or (last_sum % 512 == 0)):
self._log(f"[CHUNK] {start}+{last_sum}/{resp_total}")
if last_sum >= resp_total:
break
# 清理实例(快路径:只删当前 hid)
try:
hardware_manager.at_client.send(f"AT+MHTTPDEL={hid}", "OK", 2000)
except:
pass
if resp_total is None:
return False, "no_header_or_total", total_len, md5_b64, 0
# 计算实际填充长度
merged = sorted(got_ranges)
merged2 = []
for s, e in merged:
if not merged2 or s > merged2[-1][1]:
merged2.append((s, e))
else:
merged2[-1] = (merged2[-1][0], max(merged2[-1][1], e))
filled = sum(e - s for s, e in merged2)
if filled < resp_total:
return False, f"incomplete_chunk got={filled} expected={resp_total} code={code}", total_len, md5_b64, filled
got_len = resp_total
return True, "OK", total_len, md5_b64, got_len
def download_file_via_4g(self, url, filename,
total_timeout_ms=600000,
retries=3,
debug=False):
"""
ML307R HTTP 下载(更稳的"固定小块 Range 顺序下载",基于main109.py):
- 只依赖 +MHTTPURC:"header"/"content"(不依赖 MHTTPREAD/cached
- 每次只请求一个小块 Range(默认 10240B),失败就重试同一块,必要时缩小块大小
- 每个 chunk 都重新 MHTTPCREATE/MHTTPREQUEST,避免卡在"206 header 但不吐 content"的坏状态
- 使用二进制模式下载,确保文件完整性
"""
# 小块策略(与main109.py保持一致)
CHUNK_MAX = 10240
CHUNK_MIN = 128
CHUNK_RETRIES = 12
t_func0 = time.ticks_ms()
parsed = urlparse(url)
host = parsed.hostname
path = parsed.path or "/"
if not host:
return False, "bad_url (no host)"
if isinstance(url, str) and url.startswith("https://static.shelingxingqiu.com/"):
base_url = "https://static.shelingxingqiu.com"
# TODO:使用https,看看是否能成功
self._is_https = True
else:
base_url = f"http://{host}"
self._is_https = False
try:
self._begin_ota()
except:
pass
from network import network_manager
with network_manager.get_uart_lock():
try:
ok_pdp, ip = self._ensure_pdp()
if not ok_pdp:
return False, f"PDP not ready (ip={ip})"
# 先清空旧事件,避免串台
self._clear_http_events()
# 为了支持随机写入,先创建空文件
try:
with open(filename, "wb") as f:
f.write(b"")
except Exception as e:
return False, f"open_file_failed: {e}"
total_len = None
expect_md5_b64 = None
offset = 0
chunk = CHUNK_MAX
t_start = time.ticks_ms()
last_progress_ms = t_start
STALL_TIMEOUT_MS = 60000
last_pwr_ms = t_start
self._pwr_log(prefix=" ota_start")
bad_http_state = 0
while True:
now = time.ticks_ms()
if debug and time.ticks_diff(now, last_pwr_ms) >= 5000:
last_pwr_ms = now
self._pwr_log(prefix=f" off={offset}/{total_len or '?'}")
if time.ticks_diff(now, t_start) > total_timeout_ms:
return False, f"timeout overall after {total_timeout_ms}ms offset={offset} total={total_len}"
if time.ticks_diff(now, last_progress_ms) > STALL_TIMEOUT_MS:
return False, f"timeout stalled {STALL_TIMEOUT_MS}ms offset={offset} total={total_len}"
if total_len is not None and offset >= total_len:
break
want = chunk
if total_len is not None:
remain = total_len - offset
if remain <= 0:
break
if want > remain:
want = remain
# 本 chunk 的 buffer(长度=want
buf = bytearray(want)
success = False
last_err = "unknown"
md5_seen = None
got_len = 0
for k in range(1, CHUNK_RETRIES + 1):
do_full_reset = (bad_http_state >= 2)
ok, msg, tlen, md5_b64, got = self._fetch_range_into_buf(offset, want, buf, base_url, path, full_reset=do_full_reset)
last_err = msg
if tlen is not None and total_len is None:
total_len = tlen
if md5_b64 and not expect_md5_b64:
expect_md5_b64 = md5_b64
if ok:
success = True
got_len = got
bad_http_state = 0
break
try:
if ("no_header_or_total" in msg) or ("MHTTPREQUEST failed" in msg) or (
"MHTTPCREATE failed" in msg):
bad_http_state += 1
else:
bad_http_state = max(0, bad_http_state - 1)
except:
pass
if chunk > CHUNK_MIN:
chunk = max(CHUNK_MIN, chunk // 2)
want = min(chunk, want)
buf = bytearray(want)
self._log(f"[RETRY] off={offset} want={want} try={k} err={msg}")
self._pwr_log(prefix=f" retry{k} off={offset}")
time.sleep_ms(120)
if not success:
return False, f"chunk_failed off={offset} want={want} err={last_err} total={total_len}"
# 写入文件(二进制模式)
try:
with open(filename, "r+b") as f:
f.seek(offset)
f.write(bytes(buf))
except Exception as e:
return False, f"write_failed off={offset}: {e}"
offset += len(buf)
last_progress_ms = time.ticks_ms()
chunk = CHUNK_MAX
if debug:
self._log(f"[OK] offset={offset}/{total_len or '?'}")
# MD5 校验
if expect_md5_b64 and hashlib is not None:
try:
with open(filename, "rb") as f:
data = f.read()
digest = hashlib.md5(data).digest()
got_b64 = binascii.b2a_base64(digest).decode().strip()
if got_b64 != expect_md5_b64:
return False, f"md5_mismatch got={got_b64} expected={expect_md5_b64}"
self.logger.debug(f"[4G-DL] MD5 verified: {got_b64}")
except Exception as e:
return False, f"md5_check_failed: {e}"
t_cost = time.ticks_diff(time.ticks_ms(), t_func0)
self.logger.info(f"[4G-DL] download complete: size={offset} ip={ip} cost_ms={t_cost}")
return True, f"OK size={offset} ip={ip} cost_ms={t_cost}"
finally:
self._end_ota()
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# /etc/init.d/S99archery
# 系统启动时处理致命错误恢复(仅处理无法启动的情况)
# 注意:应用的启动由系统自动启动机制处理(通过 auto_start.txt
# 功能:
# 1. 处理致命错误(无法启动)- 恢复 main.py
# 2. 如果重启次数超过阈值,恢复 main.py 并重启系统
APP_DIR="/maixapp/apps/t11"
MAIN_PY="$APP_DIR/main.py"
PENDING_FILE="$APP_DIR/ota_pending.json"
BACKUP_BASE="$APP_DIR/backups"
# 进入应用目录
cd "$APP_DIR" || exit 0
# 检查 pending 文件,如果存在且超过重启次数,恢复 main.py(处理致命错误)
if [ -f "$PENDING_FILE" ]; then
echo "[S99] 检测到 ota_pending.json,检查重启计数..."
# 尝试从JSON中提取重启计数(使用grep简单提取)
RESTART_COUNT=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"restart_count":[0-9]*' | grep -o '[0-9]*' || echo "0")
MAX_RESTARTS=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"max_restarts":[0-9]*' | grep -o '[0-9]*' || echo "3")
if [ -n "$RESTART_COUNT" ] && [ "$RESTART_COUNT" -ge "$MAX_RESTARTS" ]; then
echo "[S99] 检测到重启次数 ($RESTART_COUNT) 超过阈值 ($MAX_RESTARTS),恢复 main.py..."
# 尝试从JSON中提取备份目录
BACKUP_DIR=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"backup_dir":"[^"]*"' | grep -o '/[^"]*' || echo "")
if [ -n "$BACKUP_DIR" ] && [ -f "$BACKUP_DIR/main.py" ]; then
# 使用指定的备份目录
echo "[S99] 从备份目录恢复: $BACKUP_DIR/main.py"
cp "$BACKUP_DIR/main.py" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
else
# 查找最新的备份目录
LATEST_BACKUP=$(ls -dt "$BACKUP_BASE"/backup_* 2>/dev/null | head -1)
if [ -n "$LATEST_BACKUP" ] && [ -f "$LATEST_BACKUP/main.py" ]; then
echo "[S99] 从最新备份恢复: $LATEST_BACKUP/main.py"
cp "$LATEST_BACKUP/main.py" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
else
# 如果没有备份目录,尝试使用 main.py.bak
if [ -f "$APP_DIR/main.py.bak" ]; then
echo "[S99] 从 main.py.bak 恢复"
cp "$APP_DIR/main.py.bak" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
fi
fi
fi
# 恢复后重置重启计数,避免循环恢复
# 注意:不在这里删除 pending 文件,让 main.py 在心跳成功后删除
# 但是重置重启计数,以便恢复后的版本可以重新开始计数
python3 -c "
import json, os
try:
pending_path = '$PENDING_FILE'
if os.path.exists(pending_path):
with open(pending_path, 'r', encoding='utf-8') as f:
d = json.load(f)
d['restart_count'] = 0 # 重置重启计数
with open(pending_path, 'w', encoding='utf-8') as f:
json.dump(d, f)
print('[S99] 已重置重启计数为 0')
except Exception as e:
print(f'[S99] 重置重启计数失败: {e}')
" 2>/dev/null || echo "[S99] 无法重置重启计数(可能需要Python支持)"
echo "[S99] 已恢复 main.py,重启系统..."
echo "[S99] 注意:pending 文件将在心跳成功后由 main.py 删除"
sleep 2
reboot
exit 0
fi
fi
# 不启动应用,让系统自动启动机制处理
# 这个脚本只负责处理致命错误恢复
exit 0
+18 -1
View File
@@ -1,8 +1,25 @@
id: t11 id: t11
name: t11 name: t11
version: 1.0.2 version: 1.2.10
author: t11 author: t11
icon: '' icon: ''
desc: t11 desc: t11
files: files:
- app.yaml
- archery_netcore.cpython-311-riscv64-linux-gnu.so
- at_client.py
- camera_manager.py
- config.py
- hardware.py
- laser_manager.py
- logger_manager.py
- main.py - main.py
- network.py
- ota_manager.py
- power.py
- shoot_manager.py
- shot_id_generator.py
- time_sync.py
- version.py
- vision.cpython-311-riscv64-linux-gnu.so
- wifi.py
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ArUco标记检测模块
提供基于ArUco标记的靶心标定和激光点定位功能
"""
import cv2
import numpy as np
import math
import config
from logger_manager import logger_manager
class ArUcoDetector:
"""ArUco标记检测器"""
def __init__(self):
self.logger = logger_manager.logger
# 创建ArUco字典和检测器参数
self.aruco_dict = cv2.aruco.getPredefinedDictionary(config.ARUCO_DICT_TYPE)
self.detector_params = cv2.aruco.DetectorParameters()
# 设置检测参数
self.detector_params.minMarkerPerimeterRate = config.ARUCO_MIN_MARKER_PERIMETER_RATE
self.detector_params.cornerRefinementMethod = config.ARUCO_CORNER_REFINEMENT_METHOD
# 创建检测器
self.detector = cv2.aruco.ArucoDetector(self.aruco_dict, self.detector_params)
# 预定义靶纸上的标记位置(物理坐标,毫米)
self.marker_positions_mm = config.ARUCO_MARKER_POSITIONS_MM
self.marker_ids = config.ARUCO_MARKER_IDS
self.marker_size_mm = config.ARUCO_MARKER_SIZE_MM
self.target_paper_size_mm = config.TARGET_PAPER_SIZE_MM
# 靶心偏移(相对于靶纸中心)
self.target_center_offset_mm = config.TARGET_CENTER_OFFSET_MM
if self.logger:
self.logger.info(f"[ARUCO] ArUco检测器初始化完成,字典类型: {config.ARUCO_DICT_TYPE}")
def detect_markers(self, frame):
"""
检测图像中的ArUco标记
Args:
frame: MaixPy图像帧对象
Returns:
(corners, ids, rejected) - 检测到的标记角点、ID列表、被拒绝的候选
如果检测失败返回 (None, None, None)
"""
try:
# 转换为OpenCV格式
from maix import image
img_cv = image.image2cv(frame, False, False)
# 转换为灰度图(ArUco检测需要)
if len(img_cv.shape) == 3:
gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
else:
gray = img_cv
# 检测标记
corners, ids, rejected = self.detector.detectMarkers(gray)
if self.logger and ids is not None:
self.logger.debug(f"[ARUCO] 检测到 {len(ids)} 个标记: {ids.flatten().tolist()}")
return corners, ids, rejected
except Exception as e:
if self.logger:
self.logger.error(f"[ARUCO] 标记检测失败: {e}")
return None, None, None
def get_target_center_from_markers(self, corners, ids):
"""
从检测到的ArUco标记计算靶心位置
Args:
corners: 标记角点列表
ids: 标记ID列表
Returns:
(center_x, center_y, radius, ellipse_params) 或 (None, None, None, None)
center_x, center_y: 靶心像素坐标
radius: 估计的靶心半径(像素)
ellipse_params: 椭圆参数用于透视校正
"""
if ids is None or len(ids) < 3:
if self.logger:
self.logger.debug(f"[ARUCO] 检测到的标记数量不足: {len(ids) if ids is not None else 0} < 3")
return None, None, None, None
try:
# 将ID转换为列表便于查找
detected_ids = ids.flatten().tolist()
# 收集检测到的标记中心点和对应的物理坐标
image_points = [] # 图像坐标 (像素)
object_points = [] # 物理坐标 (毫米)
marker_centers = {} # 存储每个标记的中心
for i, marker_id in enumerate(detected_ids):
if marker_id not in self.marker_ids:
continue
# 计算标记中心(四个角的平均值)
corner = corners[i][0] # shape: (4, 2)
center_x = np.mean(corner[:, 0])
center_y = np.mean(corner[:, 1])
marker_centers[marker_id] = (center_x, center_y)
# 添加到点列表
image_points.append([center_x, center_y])
object_points.append(self.marker_positions_mm[marker_id])
if len(image_points) < 3:
if self.logger:
self.logger.debug(f"[ARUCO] 有效标记数量不足: {len(image_points)} < 3")
return None, None, None, None
# 转换为numpy数组
image_points = np.array(image_points, dtype=np.float32)
object_points = np.array(object_points, dtype=np.float32)
# 计算单应性矩阵(Homography
# 这建立了物理坐标到图像坐标的映射
H, status = cv2.findHomography(object_points, image_points, cv2.RANSAC, 5.0)
if H is None:
if self.logger:
self.logger.warning("[ARUCO] 无法计算单应性矩阵")
return None, None, None, None
# 计算靶心在图像中的位置
# 靶心物理坐标 = 靶纸中心 + 偏移
target_center_mm = np.array([[self.target_center_offset_mm[0],
self.target_center_offset_mm[1]]], dtype=np.float32)
target_center_mm = target_center_mm.reshape(-1, 1, 2)
# 使用单应性矩阵投影到图像坐标
target_center_img = cv2.perspectiveTransform(target_center_mm, H)
center_x = target_center_img[0][0][0]
center_y = target_center_img[0][0][1]
# 计算靶心半径(像素)
# 使用已知物理距离和像素距离的比例
# 选择两个标记计算比例尺
if len(marker_centers) >= 2:
# 使用对角线上的标记计算比例尺
if 0 in marker_centers and 2 in marker_centers:
p1_img = np.array(marker_centers[0])
p2_img = np.array(marker_centers[2])
p1_mm = np.array(self.marker_positions_mm[0])
p2_mm = np.array(self.marker_positions_mm[2])
elif 1 in marker_centers and 3 in marker_centers:
p1_img = np.array(marker_centers[1])
p2_img = np.array(marker_centers[3])
p1_mm = np.array(self.marker_positions_mm[1])
p2_mm = np.array(self.marker_positions_mm[3])
else:
# 使用任意两个标记
keys = list(marker_centers.keys())
p1_img = np.array(marker_centers[keys[0]])
p2_img = np.array(marker_centers[keys[1]])
p1_mm = np.array(self.marker_positions_mm[keys[0]])
p2_mm = np.array(self.marker_positions_mm[keys[1]])
pixel_distance = np.linalg.norm(p1_img - p2_img)
mm_distance = np.linalg.norm(p1_mm - p2_mm)
if mm_distance > 0:
pixels_per_mm = pixel_distance / mm_distance
# 标准靶心半径:10环半径约1.22cm = 12.2mm
# 但这里我们返回一个估计值,实际环数计算在laser_manager中
radius_mm = 122.0 # 整个靶纸的半径约200mm,但靶心区域较小
radius = int(radius_mm * pixels_per_mm)
else:
radius = 100 # 默认值
else:
radius = 100 # 默认值
# 计算椭圆参数(用于透视校正)
# 从单应性矩阵可以推导出透视变形
ellipse_params = self._compute_ellipse_params(H, center_x, center_y)
if self.logger:
self.logger.info(f"[ARUCO] 靶心计算成功: 中心=({center_x:.1f}, {center_y:.1f}), "
f"半径={radius}px, 检测到{len(marker_centers)}个标记")
return (int(center_x), int(center_y)), radius, "aruco", ellipse_params
except Exception as e:
if self.logger:
self.logger.error(f"[ARUCO] 计算靶心失败: {e}")
import traceback
self.logger.error(traceback.format_exc())
return None, None, None, None
def _compute_ellipse_params(self, H, center_x, center_y):
"""
从单应性矩阵计算椭圆参数,用于透视校正
Args:
H: 单应性矩阵 (3x3)
center_x, center_y: 靶心图像坐标
Returns:
ellipse_params: ((center_x, center_y), (width, height), angle)
"""
try:
# 在物理坐标系中画一个圆,投影到图像中看变成什么形状
# 物理圆:半径10mm
r_mm = 10.0
angles = np.linspace(0, 2*np.pi, 16)
circle_mm = np.array([[self.target_center_offset_mm[0] + r_mm * np.cos(a),
self.target_center_offset_mm[1] + r_mm * np.sin(a)]
for a in angles], dtype=np.float32)
circle_mm = circle_mm.reshape(-1, 1, 2)
# 投影到图像
circle_img = cv2.perspectiveTransform(circle_mm, H)
circle_img = circle_img.reshape(-1, 2)
# 拟合椭圆
if len(circle_img) >= 5:
ellipse = cv2.fitEllipse(circle_img.astype(np.float32))
return ellipse
else:
# 从单应性矩阵近似估计
# 提取缩放和旋转
# H = K * [R|t] 的近似
# 这里简化处理:假设没有严重变形
scale_x = np.linalg.norm(H[0, :2])
scale_y = np.linalg.norm(H[1, :2])
avg_scale = (scale_x + scale_y) / 2
width = r_mm * 2 * scale_x
height = r_mm * 2 * scale_y
angle = np.degrees(np.arctan2(H[1, 0], H[0, 0]))
return ((center_x, center_y), (width, height), angle)
except Exception as e:
if self.logger:
self.logger.debug(f"[ARUCO] 计算椭圆参数失败: {e}")
return None
def transform_laser_point(self, laser_point, corners, ids):
"""
将激光点从图像坐标转换到物理坐标(毫米),再计算相对于靶心的偏移
Args:
laser_point: (x, y) 激光点在图像中的坐标
corners: 检测到的标记角点
ids: 检测到的标记ID
Returns:
(dx_mm, dy_mm) 激光点相对于靶心的偏移(毫米),或 (None, None)
"""
if laser_point is None or ids is None or len(ids) < 3:
return None, None
try:
# 重新计算单应性矩阵(可以优化为缓存)
detected_ids = ids.flatten().tolist()
image_points = []
object_points = []
for i, marker_id in enumerate(detected_ids):
if marker_id not in self.marker_ids:
continue
corner = corners[i][0]
center_x = np.mean(corner[:, 0])
center_y = np.mean(corner[:, 1])
image_points.append([center_x, center_y])
object_points.append(self.marker_positions_mm[marker_id])
if len(image_points) < 3:
return None, None
image_points = np.array(image_points, dtype=np.float32)
object_points = np.array(object_points, dtype=np.float32)
H, _ = cv2.findHomography(object_points, image_points, cv2.RANSAC, 5.0)
if H is None:
return None, None
# 求逆矩阵,将图像坐标转换到物理坐标
H_inv = np.linalg.inv(H)
laser_img = np.array([[laser_point[0], laser_point[1]]], dtype=np.float32)
laser_img = laser_img.reshape(-1, 1, 2)
laser_mm = cv2.perspectiveTransform(laser_img, H_inv)
laser_x_mm = laser_mm[0][0][0]
laser_y_mm = laser_mm[0][0][1]
# 计算相对于靶心的偏移
# 注意:Y轴方向可能需要翻转(图像Y向下,物理Y通常向上)
dx_mm = laser_x_mm - self.target_center_offset_mm[0]
dy_mm = -(laser_y_mm - self.target_center_offset_mm[1]) # 翻转Y轴
if self.logger:
self.logger.debug(f"[ARUCO] 激光点转换: 图像({laser_point[0]:.1f}, {laser_point[1]:.1f}) -> "
f"物理({laser_x_mm:.1f}, {laser_y_mm:.1f}) -> "
f"偏移({dx_mm:.1f}, {dy_mm:.1f})mm")
return dx_mm, dy_mm
except Exception as e:
if self.logger:
self.logger.error(f"[ARUCO] 激光点转换失败: {e}")
return None, None
def draw_debug_info(self, frame, corners, ids, target_center=None, laser_point=None):
"""
在图像上绘制调试信息
Args:
frame: MaixPy图像帧
corners: 标记角点
ids: 标记ID
target_center: 计算的靶心位置
laser_point: 激光点位置
Returns:
绘制后的图像
"""
try:
from maix import image
img_cv = image.image2cv(frame, False, False).copy()
# 绘制检测到的标记
if ids is not None:
cv2.aruco.drawDetectedMarkers(img_cv, corners, ids)
# 绘制标记ID和中心
for i, marker_id in enumerate(ids.flatten()):
corner = corners[i][0]
center_x = int(np.mean(corner[:, 0]))
center_y = int(np.mean(corner[:, 1]))
# 绘制中心点
cv2.circle(img_cv, (center_x, center_y), 5, (0, 255, 0), -1)
# 绘制ID
cv2.putText(img_cv, f"ID:{marker_id}",
(center_x + 10, center_y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
# 绘制靶心
if target_center:
cv2.circle(img_cv, target_center, 8, (255, 0, 0), -1)
cv2.circle(img_cv, target_center, 50, (255, 0, 0), 2)
cv2.putText(img_cv, "TARGET", (target_center[0] + 15, target_center[1] - 15),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 0, 0), 2)
# 绘制激光点
if laser_point:
cv2.circle(img_cv, (int(laser_point[0]), int(laser_point[1])), 6, (0, 0, 255), -1)
cv2.putText(img_cv, "LASER", (int(laser_point[0]) + 10, int(laser_point[1]) - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
# 转换回MaixPy图像
return image.cv2image(img_cv, False, False)
except Exception as e:
if self.logger:
self.logger.error(f"[ARUCO] 绘制调试信息失败: {e}")
return frame
# 创建全局单例实例
aruco_detector = ArUcoDetector()
def detect_target_with_aruco(frame, laser_point=None):
"""
使用ArUco标记检测靶心的便捷函数
Args:
frame: MaixPy图像帧
laser_point: 激光点坐标(可选)
Returns:
(result_img, center, radius, method, best_radius1, ellipse_params)
与detect_circle_v3保持相同的返回格式
"""
detector = aruco_detector
# 检测ArUco标记
corners, ids, rejected = detector.detect_markers(frame)
# 计算靶心
center, radius, method, ellipse_params = detector.get_target_center_from_markers(corners, ids)
# 绘制调试信息
result_img = detector.draw_debug_info(frame, corners, ids, center, laser_point)
# 返回与detect_circle_v3相同的格式
# best_radius1用于距离估算,这里用radius代替
return result_img, center, radius, method, radius, ellipse_params
def compute_laser_offset_aruco(laser_point, corners, ids):
"""
使用ArUco计算激光点相对于靶心的偏移(毫米)
Args:
laser_point: (x, y) 激光点图像坐标
corners: ArUco标记角点
ids: ArUco标记ID
Returns:
(dx_mm, dy_mm) 偏移量(毫米),或 (None, None)
"""
return aruco_detector.transform_laser_point(laser_point, corners, ids)
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AT客户端模块
负责4G模块的AT命令通信和URC解析
"""
import _thread
from maix import time
import re
import threading
class ATClient:
"""
单读者 AT/URC 客户端:唯一读取 uart4g,避免 tcp_main/at()/OTA 抢读导致 EOF / 丢包。
- send(cmd, expect, timeout_ms) : 发送 AT 并等待 expect
- pop_tcp_payload() : 获取 +MIPURC:"rtcp" 的 payload(已按长度裁剪)
- pop_http_event() : 获取 +MHTTPURC 事件(header/content
"""
def __init__(self, uart_obj):
self.uart = uart_obj
self._cmd_lock = threading.Lock()
self._q_lock = threading.Lock()
self._rx = b""
self._tcp_payloads = []
self._http_events = []
# 当前命令等待状态(仅允许单命令 in-flight)
self._waiting = False
self._expect = b"OK"
self._resp = b""
self._running = False
def start(self):
if self._running:
return
self._running = True
_thread.start_new_thread(self._reader_loop, ())
def stop(self):
self._running = False
def flush(self):
"""清空内部缓存与队列(用于 OTA/异常恢复)"""
with self._q_lock:
self._rx = b""
self._tcp_payloads.clear()
self._http_events.clear()
self._resp = b""
def pop_tcp_payload(self):
with self._q_lock:
if self._tcp_payloads:
return self._tcp_payloads.pop(0)
return None
def pop_http_event(self):
with self._q_lock:
if self._http_events:
return self._http_events.pop(0)
return None
def _push_tcp_payload(self, payload: bytes):
# 注意:在 _reader_loop 内部解析 URC 时已经持有 _q_lock
# 这里不要再次 acquire(锁不可重入,会死锁)。
self._tcp_payloads.append(payload)
def _push_http_event(self, ev):
# 同上:避免在 _reader_loop 持锁期间二次 acquire
self._http_events.append(ev)
def send(self, cmd: str, expect: str = "OK", timeout_ms: int = 2000):
"""
发送 AT 命令并等待 expect(子串匹配)。
注意:expect=">" 用于等待 prompt。
"""
expect_b = expect.encode() if isinstance(expect, str) else expect
with self._cmd_lock:
# 初始化等待
self._waiting = True
self._expect = expect_b
self._resp = b""
# 发送
if cmd:
# 注意:这里不要再用 uart4g_lock(否则外层已经持锁时会死锁)。
# 写入由 _cmd_lock 串行化即可。
self.uart.write((cmd + "\r\n").encode())
t0 = time.ticks_ms()
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
if (not self._waiting) or (self._expect in self._resp):
self._waiting = False
break
time.sleep_ms(5)
# 超时也返回已收集内容(便于诊断)
self._waiting = False
try:
return self._resp.decode(errors="ignore")
except:
return str(self._resp)
def _find_urc_tag(self, tag: bytes):
"""
只在"真正的 URC 边界"查找 tag,避免误命中 HTTP payload 内容。
规则:tag 必须出现在 buffer 开头,或紧跟在 b"\\r\\n" 后面。
"""
try:
i = 0
rx = self._rx
while True:
j = rx.find(tag, i)
if j < 0:
return -1
if j == 0:
return 0
if j >= 2 and rx[j - 2:j] == b"\r\n":
return j
i = j + 1
except:
return -1
def _parse_mipurc_rtcp(self):
"""
解析:+MIPURC: "rtcp",<link_id>,<len>,<payload...>
之前硬编码 link_id=0 会导致在多连接/重连场景下收不到数据。
"""
prefix = b'+MIPURC: "rtcp",'
i = self._find_urc_tag(prefix)
if i < 0:
return False
# 丢掉前置噪声
if i > 0:
self._rx = self._rx[i:]
i = 0
j = len(prefix)
# 解析 link_id
k = j
while k < len(self._rx) and 48 <= self._rx[k] <= 57:
k += 1
if k == j or k >= len(self._rx):
return False
if self._rx[k:k+1] != b",":
self._rx = self._rx[1:]
return True
try:
link_id = int(self._rx[j:k].decode())
except:
self._rx = self._rx[1:]
return True
# 解析 len
j2 = k + 1
k2 = j2
while k2 < len(self._rx) and 48 <= self._rx[k2] <= 57:
k2 += 1
if k2 == j2 or k2 >= len(self._rx):
return False
if self._rx[k2:k2+1] != b",":
self._rx = self._rx[1:]
return True
try:
n = int(self._rx[j2:k2].decode())
except:
self._rx = self._rx[1:]
return True
payload_start = k2 + 1
payload_end = payload_start + n
if len(self._rx) < payload_end:
return False # payload 未收齐
payload = self._rx[payload_start:payload_end]
# 把 link_id 一起带上,便于上层过滤(如果需要)
self._push_tcp_payload((link_id, payload))
self._rx = self._rx[payload_end:]
return True
def _parse_mhttpurc_header(self):
tag = b'+MHTTPURC: "header",'
i = self._find_urc_tag(tag)
if i < 0:
return False
if i > 0:
self._rx = self._rx[i:]
i = 0
# header: +MHTTPURC: "header",<id>,<code>,<hdr_len>,<hdr_text...>
j = len(tag)
comma_count = 0
k = j
while k < len(self._rx) and comma_count < 3:
if self._rx[k:k+1] == b",":
comma_count += 1
k += 1
if comma_count < 3:
return False
prefix = self._rx[:k]
m = re.search(rb'\+MHTTPURC: "header",\s*(\d+),\s*(\d+),\s*(\d+),', prefix)
if not m:
self._rx = self._rx[1:]
return True
urc_id = int(m.group(1))
code = int(m.group(2))
hdr_len = int(m.group(3))
text_start = k
text_end = text_start + hdr_len
if len(self._rx) < text_end:
return False
hdr_text = self._rx[text_start:text_end].decode("utf-8", "ignore")
self._push_http_event(("header", urc_id, code, hdr_text))
self._rx = self._rx[text_end:]
return True
def _parse_mhttpurc_content(self):
tag = b'+MHTTPURC: "content",'
i = self._find_urc_tag(tag)
if i < 0:
return False
if i > 0:
self._rx = self._rx[i:]
i = 0
# content: +MHTTPURC: "content",<id>,<total>,<sum>,<cur>,<payload...>
j = len(tag)
comma_count = 0
k = j
while k < len(self._rx) and comma_count < 4:
if self._rx[k:k+1] == b",":
comma_count += 1
k += 1
if comma_count < 4:
return False
prefix = self._rx[:k]
m = re.search(rb'\+MHTTPURC: "content",\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+),', prefix)
if not m:
self._rx = self._rx[1:]
return True
urc_id = int(m.group(1))
total_len = int(m.group(2))
sum_len = int(m.group(3))
cur_len = int(m.group(4))
payload_start = k
payload_end = payload_start + cur_len
if len(self._rx) < payload_end:
return False
payload = self._rx[payload_start:payload_end]
self._push_http_event(("content", urc_id, total_len, sum_len, cur_len, payload))
self._rx = self._rx[payload_end:]
return True
def _reader_loop(self):
while self._running:
# 关键:UART 驱动偶发 read failed,必须兜住,否则线程挂了 OTA/TCP 都会卡死
try:
d = self.uart.read(4096) # 8192 在一些驱动上更容易触发 read failed
except Exception as e:
try:
print("[ATClient] uart read failed:", e)
except:
pass
time.sleep_ms(50)
continue
if not d:
time.sleep_ms(1)
continue
with self._q_lock:
self._rx += d
if self._waiting:
self._resp += d
while True:
progressed = (
self._parse_mipurc_rtcp()
or self._parse_mhttpurc_header()
or self._parse_mhttpurc_content()
)
if not progressed:
break
# 使用 ota_manager 访问 ota_in_progress
try:
from ota_manager import ota_manager
ota_flag = ota_manager.ota_in_progress
except:
ota_flag = False
has_http_hint = (b"+MHTTP" in self._rx) or (b"+MHTTPURC" in self._rx)
if ota_flag or has_http_hint:
if len(self._rx) > 512 * 1024:
self._rx = self._rx[-256 * 1024:]
else:
if len(self._rx) > 16384:
self._rx = self._rx[-4096:]
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
相机管理器模块
提供相机和显示的统一管理和线程安全访问
"""
import threading
import config
from logger_manager import logger_manager
class CameraManager:
"""相机管理器(单例)"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(CameraManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# 私有对象
self._camera = None
self._display = None
# 线程安全锁
self._camera_lock = threading.Lock()
self._display_lock = threading.Lock()
# 相机配置
self._camera_width = 640
self._camera_height = 480
self._initialized = True
# ==================== 初始化方法 ====================
@property
def logger(self):
"""获取 logger 对象"""
return logger_manager.logger
def init_camera(self, width=640, height=480):
"""初始化相机"""
if self._camera is not None:
return self._camera
from maix import camera
self._camera_width = width
self._camera_height = height
with self._camera_lock:
if self._camera is None:
self._camera = camera.Camera(width, height)
return self._camera
def init_display(self):
"""初始化显示"""
if self._display is not None:
return self._display
from maix import display
with self._display_lock:
if self._display is None:
self._display = display.Display()
return self._display
# ==================== 访问方法 ====================
@property
def camera(self):
"""获取相机实例(懒加载)"""
if self._camera is None:
self.init_camera()
return self._camera
@property
def display(self):
"""获取显示实例(懒加载)"""
if self._display is None:
self.init_display()
return self._display
# ==================== 业务方法 ====================
def read_frame(self):
"""
线程安全地读取一帧图像
Returns:
frame: 图像帧对象
"""
with self._camera_lock:
if self._camera is None:
self.init_camera()
return self._camera.read()
def show(self, image):
"""
线程安全地显示图像
Args:
image: 要显示的图像对象
"""
with self._display_lock:
if self._display is None:
self.init_display()
self._display.show(image)
def release(self):
"""释放相机和显示资源(如果需要)"""
with self._camera_lock:
if self._camera is not None:
# MaixPy 的 Camera 可能不需要显式释放,但可以在这里清理
self._camera = None
with self._display_lock:
if self._display is not None:
# MaixPy 的 Display 可能不需要显式释放
self._display = None
# 创建全局单例实例
camera_manager = CameraManager()
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
系统配置常量
这些值在程序运行期间基本不变,或只在配置时改变
"""
from version import VERSION
# ==================== 应用配置 ====================
APP_VERSION = VERSION
APP_DIR = "/maixapp/apps/t11"
LOCAL_FILENAME = "/maixapp/apps/t11/main_tmp.py"
# ==================== 服务器配置 ====================
# SERVER_IP = "stcp.shelingxingqiu.com"
SERVER_IP = "www.shelingxingqiu.com"
SERVER_PORT = 50005
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
WIFI_QUALITY_RTT_BAD_MS = 600.0 # 中位数超过此值认为延迟过高
WIFI_QUALITY_RTT_WARN_MS = 350.0 # 与 RSSI 联合:超过此值且信号弱也判为差
WIFI_QUALITY_RSSI_BAD_DBM = -80.0 # 低于此 dBm(更负更差)视为信号弱
WIFI_QUALITY_USE_RSSI = True # 是否把 RSSI 纳入综合判定(False 则仅看 RTT)
# WiFi 热点配网(手机连设备 AP,浏览器提交路由器 SSID/密码;仅 GET/POST,标准库 socket
WIFI_CONFIG_AP_FALLBACK = True # # WiFi 配网失败时,是否退回热点模式,并等待重新配网
WIFI_AP_FALLBACK_WAIT_SEC = 5 # 等待5秒后再检测STA/4G
WIFI_CONFIG_AP_TIMEOUT = 5 # 热点模式超时时间(秒)
WIFI_CONFIG_AP_ENABLED = True # True=启动时开热点并起迷你 HTTP 配网服务
WIFI_CONFIG_AP_SSID = "ArcherySetup" # 设备发出的热点名称
WIFI_CONFIG_AP_PASSWORD = "12345678" # 热点密码(WPA2 通常至少 8 位)
WIFI_CONFIG_HTTP_HOST = "0.0.0.0" # HTTP 监听地址
WIFI_CONFIG_HTTP_PORT = 8080 # 默认 8080,避免占用 80 需 root
WIFI_CONFIG_AP_IP = "192.168.66.1" # 与 MaixPy Wifi.start_ap 默认一致,手机访问 http://192.168.66.1:8080/
# ===== TCP over SSL(TLS) 配置 =====
USE_TCP_SSL = False # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL
TCP_LINK_ID = 2 #
TCP_SSL_PORT = 443 # TLS 端口(不一定必须 443,以服务器为准)
# SSL profile
SSL_ID = 1 # ssl_id=1
SSL_AUTH_MODE = 0 # 1=单向认证(验证服务器),2=双向
SSL_VERIFY_MODE = 1 # 0=不验(仅测试用);1=写入并使用 CA 证书
SSL_CERT_FILENAME = "www.shelingxingqiu.com.crt" # 模组里证书名(MSSLCERTWR / MSSLCFG="cert" 用)
SSL_CERT_PATH = "/root/www.shelingxingqiu.com.crt" # 设备文件系统里 CA 证书路径(你自己放进去)
# MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留
MIPOPEN_TAIL = ",,0"
# ==================== 文件路径配置 ====================
CONFIG_FILE = "/root/laser_config.json"
LOG_FILE = "/maixapp/apps/t11/app.log"
BACKUP_BASE = "/maixapp/apps/t11/backups"
# ==================== 硬件配置 ====================
# WiFi模块开关(True=有WiFi模块,False=无WiFi模块)
HAS_WIFI_MODULE = True # 根据实际硬件情况设置
# UART配置
UART4G_DEVICE = "/dev/ttyS2"
UART4G_BAUDRATE = 115200
DISTANCE_SERIAL_DEVICE = "/dev/ttyS1"
DISTANCE_SERIAL_BAUDRATE = 9600
# I2C配置(根据WiFi模块开关自动选择)
# 无WiFi模块:I2C_BUS_NUM = 1,引脚:P18(I2C1_SCL), P21(I2C1_SDA)
# 有WiFi模块:I2C_BUS_NUM = 5,引脚:A15(I2C5_SCL), A27(I2C5_SDA)
I2C_BUS_NUM = 5 if HAS_WIFI_MODULE else 1
INA226_ADDR = 0x40
REG_CONFIGURATION = 0x00
REG_BUS_VOLTAGE = 0x02
REG_CURRENT = 0x04 # 电流寄存器
REG_CALIBRATION = 0x05
CALIBRATION_VALUE = 0x1400
# ==================== 空气传感器配置 ====================
ADC_TRIGGER_THRESHOLD = 2700 # TODO:4096只是用于测试,因为最大值是4095,这个值是永远不会触发的,最终需要改为正常值
AIR_PRESSURE_lOG = False # TODO: 在正式环境中关闭
AIR_PRESSURE_HARDWARE_MAX = 10
# ADC配置
ADC_CHANNEL = 0
ADC_LASER_THRESHOLD = 3000
# ==================== 激光配置 ====================
MODULE_ADDR = 0x00
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
LASER_OFF_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
DISTANCE_QUERY_CMD = bytes([0xAA, MODULE_ADDR, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x21]) # 激光测距查询命令
DISTANCE_RESPONSE_LEN = 13 # 激光测距响应数据长度(字节)
DEFAULT_LASER_POINT = (320, 245) # 默认激光中心点
# 硬编码激光点配置
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值)
HARDCODE_LASER_POINT_VALUE = (320, 245) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y)
# 激光点检测配置
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150)
LASER_RED_RATIO = 1.5 # 红色相对于绿色/蓝色的倍数要求(默认1.5,可调整,范围建议:1.3-2.0)
LASER_SEARCH_RADIUS = 50 # 搜索半径(像素),从图像中心开始搜索(默认20,限制激光点不能偏离中心太远)
LASER_MAX_DISTANCE_FROM_CENTER = 50 # 激光点距离中心的最大允许距离(像素),超过此距离则拒绝(默认20)
LASER_OVEREXPOSED_THRESHOLD = 200 # 过曝红色判断阈值(默认200,接近白色时的阈值)
LASER_OVEREXPOSED_DIFF = 10 # 过曝红色时,r 与 g/b 的最小差值(默认10)
LASER_REQUIRE_IN_ELLIPSE = False # 是否要求激光点必须在黄心椭圆内(True=必须,False=不要求)
LASER_USE_ELLIPSE_FITTING = True # 是否使用椭圆拟合方法查找激光点(True=椭圆拟合更准确,False=最亮点方法)
LASER_MIN_AREA = 5 # 激光点区域的最小面积(像素),小于此值认为是噪声(默认5)
LASER_DRAW_ELLIPSE = True # 是否在图像上绘制激光点的拟合椭圆(True=绘制,False=不绘制)
# ==================== 视觉检测配置 ====================
FOCAL_LENGTH_PIX = 2250.0 # 焦距(像素)
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
# 图像清晰度检测配置
IMAGE_SHARPNESS_THRESHOLD = 100.0 # 清晰度阈值,低于此值认为图像模糊
# 清晰图像通常 > 200,模糊图像通常 < 100
# 激光与摄像头物理位置配置
LASER_CAMERA_OFFSET_CM = 1.4 # 激光在摄像头下方的物理距离(厘米),正值表示激光在摄像头下方
IMAGE_CENTER_X = 320 # 图像中心 X 坐标
IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
FLASH_LASER_WHILE_SHOOTING = True # 是否在拍摄时闪一下激光(True=闪,False=不闪)
FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒)
# ==================== 显示配置 ====================
LASER_COLOR = (0, 255, 0) # RGB颜色
LASER_THICKNESS = 1
LASER_LENGTH = 2
# ==================== 图像保存配置 ====================
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
# ==================== OTA配置 ====================
MAX_BACKUPS = 5
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
LOG_BACKUP_COUNT = 5
# ==================== 引脚映射配置 ====================
# 无WiFi模块的引脚映射(I2C1)
PIN_MAPPINGS_NO_WIFI = {
"A18": "UART1_RX",
"A19": "UART1_TX",
"A29": "UART2_RX",
"A28": "UART2_TX",
"P18": "I2C1_SCL",
"P21": "I2C1_SDA",
}
# 有WiFi模块的引脚映射(I2C5)
PIN_MAPPINGS_WITH_WIFI = {
"A18": "UART1_RX",
"A19": "UART1_TX",
"A29": "UART2_RX",
"A28": "UART2_TX",
"A15": "I2C5_SCL",
"A27": "I2C5_SDA",
"A24": "GPIOA24", # 电源板的引脚
}
# 根据WiFi模块开关选择引脚映射
PIN_MAPPINGS = PIN_MAPPINGS_WITH_WIFI if HAS_WIFI_MODULE else PIN_MAPPINGS_NO_WIFI
# ==================== ArUco标定配置 ====================
USE_ARUCO = False # 是否使用ArUco标定(True=使用ArUcoFalse=使用传统黄色靶心检测)
# ArUco标记配置
if USE_ARUCO:
import cv2
ARUCO_DICT_TYPE = cv2.aruco.DICT_4X4_50 # ArUco字典类型
ARUCO_MARKER_SIZE_MM = 40 # ArUco标记边长(毫米)
ARUCO_MARKER_IDS = [0, 1, 2, 3] # 四个角的ArUco标记ID
# 靶纸物理尺寸(毫米)
TARGET_PAPER_SIZE_MM = 400 # 靶纸边长 400mm x 400mm
# ArUco标记在靶纸上的中心坐标(毫米,以靶纸中心为原点)
# 靶纸坐标系:中心(0,0),X向右,Y向下(图像坐标系)
# 四个角位置:(20,20), (20,380), (380,380), (380,20)
# 转换为以中心为原点的坐标:
# 左上角(0): (-180, -180) -> 实际(20,20)相对于中心(200,200) = (-180,-180)
# 右上角(1): (180, -180) -> 实际(380,20)相对于中心 = (180,-180)
# 右下角(2): (180, 180) -> 实际(380,380)相对于中心 = (180,180)
# 左下角(3): (-180, 180) -> 实际(20,380)相对于中心 = (-180,180)
ARUCO_MARKER_POSITIONS_MM = {
0: (-180, -180), # 左上角
1: (180, -180), # 右上角
2: (180, 180), # 右下角
3: (-180, 180), # 左下角
}
# 靶心(黄心)在靶纸上的位置(毫米,相对于靶纸中心)
# 标准靶纸靶心就在正中心
TARGET_CENTER_OFFSET_MM = (0, 0)
# ArUco检测参数
ARUCO_MIN_MARKER_PERIMETER_RATE = 0.03 # 最小标记周长比例(相对于图像)
ARUCO_CORNER_REFINEMENT_METHOD = cv2.aruco.CORNER_REFINE_SUBPIX # 角点精修方法
# ==================== 电源配置 ====================
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
+71
View File
@@ -0,0 +1,71 @@
cmake_minimum_required(VERSION 3.16)
project(archery_netcore CXX)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR riscv64)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
if(NOT DEFINED PY_INCLUDE_DIR)
message(FATAL_ERROR "PY_INCLUDE_DIR not set")
endif()
if(NOT DEFINED PY_LIB)
message(FATAL_ERROR "PY_LIB not set")
endif()
if(NOT DEFINED PY_EXT_SUFFIX)
message(FATAL_ERROR "PY_EXT_SUFFIX not set")
endif()
if(NOT DEFINED MAIXCDK_PATH)
message(FATAL_ERROR "MAIXCDK_PATH not set (need components/3rd_party/pybind11)")
endif()
add_library(archery_netcore MODULE
archery_netcore.cpp
native_logger.cpp
utils.cpp
decrypt_ota_file.cpp
msg_handler.cpp
)
target_include_directories(archery_netcore PRIVATE
"${PY_INCLUDE_DIR}"
"${MAIXCDK_PATH}/components/3rd_party/pybind11/pybind11/include"
"${MAIXCDK_PATH}/components/3rd_party/openssl/include"
"${CMAKE_CURRENT_SOURCE_DIR}/third_party" # 添加 nlohmann/json 路径
)
# 尽量减少 .so 体积并增加逆向成本
target_compile_options(archery_netcore PRIVATE
-Os
-ffunction-sections
-fdata-sections
-fvisibility=hidden
-fvisibility-inlines-hidden
)
target_link_options(archery_netcore PRIVATE
-Wl,--gc-sections
-Wl,-s
)
set_target_properties(archery_netcore PROPERTIES
PREFIX ""
SUFFIX "${PY_EXT_SUFFIX}"
)
# OpenSSL (for AES-256-GCM decrypt)
# 使用 MaixCDK 提供的 OpenSSL 库(在 so/maixcam 目录下)
set(OPENSSL_LIB_DIR "${MAIXCDK_PATH}/components/3rd_party/openssl/so/maixcam")
if(EXISTS "${OPENSSL_LIB_DIR}/libcrypto.so")
target_link_directories(archery_netcore PRIVATE "${OPENSSL_LIB_DIR}")
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" crypto ssl)
message(STATUS "Using OpenSSL from MaixCDK: ${OPENSSL_LIB_DIR}")
else()
# Fallback: 尝试 find_package 或系统库
find_package(OpenSSL QUIET)
if(OpenSSL_FOUND)
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" OpenSSL::Crypto OpenSSL::SSL)
else()
message(WARNING "OpenSSL not found in MaixCDK, trying system libraries (may fail)")
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" crypto ssl)
endif()
endif()
+108
View File
@@ -0,0 +1,108 @@
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
#include <nlohmann/json.hpp>
#include <cstring>
#include <cstdint>
#include <vector>
#include <string>
#include <fstream>
#include <array>
#include "msg_handler.hpp"
#include "native_logger.hpp"
#include "decrypt_ota_file.hpp"
#include "utils.hpp"
namespace py = pybind11;
using json = nlohmann::json;
namespace {
// 配置项
const std::string _cfg_server_ip = "www.shelingxingqiu.com";
const int _cfg_server_port = 50005;
const std::string _cfg_device_id_file = "/device_key";
}
// 定义获取配置的函数
py::dict get_config() {
py::dict config;
config["SERVER_IP"] = _cfg_server_ip;
config["SERVER_PORT"] = _cfg_server_port;
return config;
}
PYBIND11_MODULE(archery_netcore, m) {
m.doc() = "Archery net core (native, pybind11).";
// Optional: configure native logger from Python.
// Default log file: /maixapp/apps/t11/netcore.log
m.def("set_log_file", [](const std::string& path) { netcore::set_log_file(path); }, py::arg("path"));
m.def("set_log_level", [](int level) {
if (level < 0) level = 0;
if (level > 3) level = 3;
netcore::set_log_level(static_cast<netcore::LogLevel>(level));
}, py::arg("level"));
m.def("log_test", [](const std::string& msg) {
netcore::log_info(std::string("log_test: ") + msg);
}, py::arg("msg"));
m.def("make_packet", &netcore::make_packet,
"Pack TCP packet: header (len+type+checksum) + JSON body",
py::arg("msg_type"), py::arg("body_dict"));
m.def("parse_packet", &netcore::parse_packet,
"Parse TCP packet, return (msg_type, body_dict)");
m.def("get_config", &get_config, "Get system configuration");
m.def(
"decrypt_ota_file",
[](const std::string& input_path, const std::string& output_zip_path) {
netcore::log_info(std::string("decrypt_ota_file in=") + input_path + " out=" + output_zip_path);
return netcore::decrypt_ota_file_impl(input_path, output_zip_path);
},
py::arg("input_path"),
py::arg("output_zip_path"),
"Decrypt OTA encrypted file (MAGIC|nonce|ciphertext|tag) to plaintext zip."
);
// Minimal demo: return actions for inner_cmd=41 (manual trigger + ack)
m.def("actions_for_inner_cmd", [](int inner_cmd) {
py::list actions;
if (inner_cmd == 41) {
// 1) set manual trigger flag
{
py::dict a;
a["type"] = "SET_FLAG";
py::dict args;
args["name"] = "manual_trigger_flag";
args["value"] = true;
a["args"] = args;
actions.append(a);
}
// 2) enqueue trigger_ack
{
py::dict a;
a["type"] = "ENQUEUE";
py::dict args;
args["msg_type"] = 2;
args["high"] = false;
py::dict body;
body["result"] = "trigger_ack";
args["body"] = body;
a["args"] = args;
actions.append(a);
}
}
return actions;
});
}
+135
View File
@@ -0,0 +1,135 @@
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
#include <nlohmann/json.hpp>
#include <cstring>
#include <cstdint>
#include <vector>
#include <string>
#include <fstream>
#include <array>
#include <openssl/evp.h>
#include <algorithm>
#include "native_logger.hpp"
namespace netcore{
// OTA AEAD format: MAGIC(7) | nonce(12) | ciphertext(N) | tag(16)
constexpr const char* kOtaMagic = "AROTAE1";
constexpr size_t kOtaMagicLen = 7;
constexpr size_t kGcmNonceLen = 12;
constexpr size_t kGcmTagLen = 16;
// 固定 32-byte AES-256-GCM key(提高被直接查看的成本;不是绝对安全)
// 注意:需要与打包端传入的 --aead-key-hex 保持一致。
static std::array<uint8_t, 32> ota_key_bytes() {
// 简单拆分混淆:key = a XOR b
static const std::array<uint8_t, 32> a = {
0x92,0x99,0x4d,0x06,0x6f,0xb6,0xa6,0x3d,0x85,0x08,0xbe,0x73,0x5e,0x73,0x4d,0x8a,
0x53,0x88,0xe6,0x99,0xfc,0x10,0x29,0xb9,0x16,0x9b,0xe7,0x0c,0x65,0x21,0x1c,0xce
};
static const std::array<uint8_t, 32> b = {
0xcf,0x60,0xa2,0xc2,0x32,0x7a,0x61,0xb0,0x4c,0x8e,0x8a,0x62,0x31,0xc7,0x82,0xff,
0xec,0xac,0xa1,0x04,0x2a,0x4d,0xaa,0xf2,0xb0,0x5b,0x39,0x2b,0xf4,0xb3,0xad,0xad
};
std::array<uint8_t, 32> k{};
for (size_t i = 0; i < k.size(); i++) k[i] = static_cast<uint8_t>(a[i] ^ b[i]);
return k;
}
static bool read_file_all(const std::string& path, std::vector<uint8_t>& out) {
std::ifstream ifs(path, std::ios::binary);
if (!ifs) return false;
ifs.seekg(0, std::ios::end);
std::streampos size = ifs.tellg();
if (size <= 0) return false;
ifs.seekg(0, std::ios::beg);
out.resize(static_cast<size_t>(size));
if (!ifs.read(reinterpret_cast<char*>(out.data()), size)) return false;
return true;
}
static bool write_file_all(const std::string& path, const uint8_t* data, size_t len) {
std::ofstream ofs(path, std::ios::binary | std::ios::trunc);
if (!ofs) return false;
ofs.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(len));
return static_cast<bool>(ofs);
}
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path) {
std::vector<uint8_t> in;
if (!netcore::read_file_all(input_path, in)) {
netcore::log_error(std::string("decrypt_ota_file: read failed: ") + input_path);
return false;
}
const size_t min_len = kOtaMagicLen + kGcmNonceLen + kGcmTagLen + 1;
if (in.size() < min_len) {
netcore::log_error("decrypt_ota_file: too short");
return false;
}
if (!std::equal(in.begin(), in.begin() + kOtaMagicLen, reinterpret_cast<const uint8_t*>(kOtaMagic))) {
netcore::log_error("decrypt_ota_file: bad magic");
return false;
}
const uint8_t* nonce = in.data() + kOtaMagicLen;
const uint8_t* ct_and_tag = in.data() + kOtaMagicLen + kGcmNonceLen;
const size_t ct_and_tag_len = in.size() - (kOtaMagicLen + kGcmNonceLen);
if (ct_and_tag_len <= kGcmTagLen) {
netcore::log_error("decrypt_ota_file: no ciphertext");
return false;
}
const size_t ciphertext_len = ct_and_tag_len - kGcmTagLen;
const uint8_t* ciphertext = ct_and_tag;
const uint8_t* tag = ct_and_tag + ciphertext_len;
std::vector<uint8_t> plain(ciphertext_len);
int out_len1 = 0;
int out_len2 = 0;
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (!ctx) {
netcore::log_error("decrypt_ota_file: EVP_CIPHER_CTX_new failed");
return false;
}
bool ok = false;
auto key = ota_key_bytes();
do {
if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr)) {
netcore::log_error("decrypt_ota_file: DecryptInit failed");
break;
}
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast<int>(kGcmNonceLen), nullptr)) {
netcore::log_error("decrypt_ota_file: set ivlen failed");
break;
}
if (1 != EVP_DecryptInit_ex(ctx, nullptr, nullptr, key.data(), nonce)) {
netcore::log_error("decrypt_ota_file: set key/iv failed");
break;
}
if (1 != EVP_DecryptUpdate(ctx, plain.data(), &out_len1, ciphertext, static_cast<int>(ciphertext_len))) {
netcore::log_error("decrypt_ota_file: update failed");
break;
}
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(kGcmTagLen), const_cast<uint8_t*>(tag))) {
netcore::log_error("decrypt_ota_file: set tag failed");
break;
}
if (1 != EVP_DecryptFinal_ex(ctx, plain.data() + out_len1, &out_len2)) {
netcore::log_error("decrypt_ota_file: final failed (auth tag mismatch?)");
break;
}
const size_t plain_len = static_cast<size_t>(out_len1 + out_len2);
if (!netcore::write_file_all(output_zip_path, plain.data(), plain_len)) {
netcore::log_error(std::string("decrypt_ota_file: write failed: ") + output_zip_path);
break;
}
ok = true;
} while (false);
EVP_CIPHER_CTX_free(ctx);
return ok;
}
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <string>
namespace netcore{
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path);
}
+113
View File
@@ -0,0 +1,113 @@
#include <nlohmann/json.hpp>
#include <string>
#include <cstring>
#include <cstdint>
#include <vector>
#include "native_logger.hpp"
#include "msg_handler.hpp"
#include "utils.hpp"
namespace py = pybind11;
using json = nlohmann::json;
namespace netcore {
// 打包 TCP 数据包
py::bytes make_packet(int msg_type, py::dict body_dict) {
netcore::log_debug(std::string("make_packet msg_type=") + std::to_string(msg_type));
// 1) 将 py::dict 转为 JSON 字符串
json j = netcore::py_dict_to_json(body_dict);
std::string body_str = j.dump();
// 2) 计算 body_len 和 checksum
uint32_t body_len = body_str.size();
uint32_t checksum = body_len + msg_type;
// 3) 打包头部(大端序)
std::vector<uint8_t> packet;
packet.reserve(12 + body_len);
// body_len (big-endian, 4 bytes)
packet.push_back((body_len >> 24) & 0xFF);
packet.push_back((body_len >> 16) & 0xFF);
packet.push_back((body_len >> 8) & 0xFF);
packet.push_back(body_len & 0xFF);
// msg_type (big-endian, 4 bytes)
packet.push_back((msg_type >> 24) & 0xFF);
packet.push_back((msg_type >> 16) & 0xFF);
packet.push_back((msg_type >> 8) & 0xFF);
packet.push_back(msg_type & 0xFF);
// checksum (big-endian, 4 bytes)
packet.push_back((checksum >> 24) & 0xFF);
packet.push_back((checksum >> 16) & 0xFF);
packet.push_back((checksum >> 8) & 0xFF);
packet.push_back(checksum & 0xFF);
// 4) 追加 body
packet.insert(packet.end(), body_str.begin(), body_str.end());
netcore::log_debug(std::string("make_packet done bytes=") + std::to_string(packet.size()));
return py::bytes(reinterpret_cast<const char*>(packet.data()), packet.size());
}
// 解析 TCP 数据包
py::tuple parse_packet(py::bytes data) {
// 1) 转换为 bytes view
py::buffer_info buf = py::buffer(data).request();
if (buf.size < 12) {
netcore::log_error(std::string("parse_packet too_short len=") + std::to_string(buf.size));
return py::make_tuple(py::none(), py::none());
}
const uint8_t* ptr = static_cast<const uint8_t*>(buf.ptr);
// 2) 解析头部(大端序)
uint32_t body_len = (ptr[0] << 24) | (ptr[1] << 16) | (ptr[2] << 8) | ptr[3];
uint32_t msg_type = (ptr[4] << 24) | (ptr[5] << 16) | (ptr[6] << 8) | ptr[7];
uint32_t checksum = (ptr[8] << 24) | (ptr[9] << 16) | (ptr[10] << 8) | ptr[11];
// 3) 校验 checksum(可选,你现有代码不强制校验)
// if (checksum != (body_len + msg_type)) {
// return py::make_tuple(py::none(), py::none());
// }
// 4) 检查长度
uint32_t expected_len = 12 + body_len;
if (buf.size < expected_len) {
// 半包
netcore::log_warn(std::string("parse_packet incomplete got=") + std::to_string(buf.size) +
" expected=" + std::to_string(expected_len));
return py::make_tuple(py::none(), py::none());
}
// 5) 防御性检查:如果 data 比预期长,说明可能有粘包
// (只解析第一个包,忽略多余数据)
if (buf.size > expected_len) {
netcore::log_warn(std::string("parse_packet concat got=") + std::to_string(buf.size) +
" expected=" + std::to_string(expected_len) +
" body_len=" + std::to_string(body_len) +
" msg_type=" + std::to_string(msg_type));
}
// 6) 提取 body 并解析 JSON
std::string body_str(reinterpret_cast<const char*>(ptr + 12), body_len);
try {
json j = json::parse(body_str);
py::dict body_dict = netcore::json_to_py_dict(j);
return py::make_tuple(py::int_(msg_type), body_dict);
} catch (const json::parse_error& e) {
// JSON 解析失败,返回 raw(兼容你现有的逻辑)
netcore::log_error(std::string("parse_packet json_parse_error: ") + e.what());
py::dict raw_dict;
raw_dict["raw"] = body_str;
return py::make_tuple(py::int_(msg_type), raw_dict);
} catch (const std::exception& e) {
netcore::log_error(std::string("parse_packet json_parse_error: ") + e.what());
py::dict raw_dict;
raw_dict["raw"] = body_str;
return py::make_tuple(py::int_(msg_type), raw_dict);
}
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
namespace py = pybind11;
namespace netcore {
// 打包 TCP 数据包
py::bytes make_packet(int msg_type, py::dict body_dict);
// 解包 TCP 数据包
py::tuple parse_packet(py::bytes data);
}
+100
View File
@@ -0,0 +1,100 @@
#include "native_logger.hpp"
#include <cerrno>
#include <cstring>
#include <mutex>
#include <string>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
namespace netcore {
static std::mutex g_mu;
static int g_fd = -1;
static std::string g_path = "netcore.log";
static LogLevel g_level = LogLevel::kDebug; //LogLevel::kInfo;
static const char* level_name(LogLevel lvl) {
switch (lvl) {
case LogLevel::kError: return "E";
case LogLevel::kWarn: return "W";
case LogLevel::kInfo: return "I";
case LogLevel::kDebug: return "D";
default: return "?";
}
}
static void ensure_open_locked() {
if (g_path.empty()) return;
if (g_fd >= 0) return;
g_fd = ::open(g_path.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0644);
}
void set_log_file(const std::string& path) {
std::lock_guard<std::mutex> lk(g_mu);
g_path = path;
if (g_fd >= 0) {
::close(g_fd);
g_fd = -1;
}
ensure_open_locked();
}
void set_log_level(LogLevel level) {
std::lock_guard<std::mutex> lk(g_mu);
g_level = level;
}
void log(LogLevel level, const std::string& msg) {
std::lock_guard<std::mutex> lk(g_mu);
if (static_cast<int>(level) > static_cast<int>(g_level)) return;
if (g_path.empty()) return;
ensure_open_locked();
if (g_fd < 0) {
// Last resort: stderr (avoid any Python APIs)
::write(STDERR_FILENO, msg.c_str(), msg.size());
::write(STDERR_FILENO, "\n", 1);
return;
}
// Timestamp: epoch milliseconds (simple and cheap)
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
// long long ms = (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
// 1. 将秒数转换为本地时间结构体 struct tm
struct tm *tm_info = localtime(&ts.tv_sec);
// 2. 准备一个缓冲区来存储时间字符串
char buffer[30];
// 3. 格式化秒的部分
// 格式: 年-月-日 时:分:秒
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", tm_info);
// 4. 计算毫秒部分并追加到字符串中
// ts.tv_nsec 是纳秒,除以 1,000,000 得到毫秒
char ms_buffer[8];
snprintf(ms_buffer, sizeof(ms_buffer), ".%03ld", ts.tv_nsec / 1000000);
// Build one line to keep writes atomic-ish
char head[256];
int n = ::snprintf(head, sizeof(head), "[%s%s] [%s] ", buffer, ms_buffer, level_name(level));
if (n < 0) n = 0;
::write(g_fd, head, (size_t)n);
::write(g_fd, msg.c_str(), msg.size());
::write(g_fd, "\n", 1);
}
void log_debug(const std::string& msg) { log(LogLevel::kDebug, msg); }
void log_info (const std::string& msg) { log(LogLevel::kInfo, msg); }
void log_warn (const std::string& msg) { log(LogLevel::kWarn, msg); }
void log_error(const std::string& msg) { log(LogLevel::kError, msg); }
} // namespace netcore
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <string>
namespace netcore {
enum class LogLevel : int {
kError = 0,
kWarn = 1,
kInfo = 2,
kDebug = 3,
};
// Set log file path. If empty, logging is disabled.
void set_log_file(const std::string& path);
// Set minimum log level to write (default: kInfo).
void set_log_level(LogLevel level);
// Log helpers (thread-safe, never calls into Python).
void log(LogLevel level, const std::string& msg);
void log_debug(const std::string& msg);
void log_info(const std::string& msg);
void log_warn(const std::string& msg);
void log_error(const std::string& msg);
} // namespace netcore
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
#include <fstream>
#include <cstring>
#include <cstdint>
#include <string>
#include <fstream>
#include "utils.hpp"
namespace netcore {
// 辅助函数:将 py::dict 转为 nlohmann::json
json py_dict_to_json(py::dict d) {
json j;
for (auto item : d) {
std::string key = py::str(item.first);
py::object val = py::reinterpret_borrow<py::object>(item.second);
if (py::isinstance<py::dict>(val)) {
j[key] = py_dict_to_json(py::cast<py::dict>(val));
} else if (py::isinstance<py::list>(val)) {
py::list py_list = py::cast<py::list>(val);
json arr = json::array();
for (auto elem : py_list) {
py::object elem_obj = py::reinterpret_borrow<py::object>(elem);
if (py::isinstance<py::dict>(elem_obj)) {
arr.push_back(py_dict_to_json(py::cast<py::dict>(elem_obj)));
} else if (py::isinstance<py::int_>(elem_obj)) {
arr.push_back(py::cast<int64_t>(elem_obj));
} else if (py::isinstance<py::float_>(elem_obj)) {
arr.push_back(py::cast<double>(elem_obj));
} else {
arr.push_back(py::str(elem_obj));
}
}
j[key] = arr;
} else if (py::isinstance<py::int_>(val)) {
j[key] = py::cast<int64_t>(val);
} else if (py::isinstance<py::float_>(val)) {
j[key] = py::cast<double>(val);
} else if (py::isinstance<py::bool_>(val)) {
j[key] = py::cast<bool>(val);
} else if (val.is_none()) {
j[key] = nullptr;
} else {
j[key] = py::str(val);
}
}
return j;
}
// 辅助函数:将 nlohmann::json 转为 py::dict
py::dict json_to_py_dict(const json& j) {
py::dict d;
if (j.is_object()) {
for (auto& item : j.items()) {
std::string key = item.key();
json val = item.value();
if (val.is_object()) {
d[py::str(key)] = json_to_py_dict(val);
} else if (val.is_array()) {
py::list py_list;
for (auto& elem : val) {
if (elem.is_object()) {
py_list.append(json_to_py_dict(elem));
} else if (elem.is_number_integer()) {
py_list.append(py::int_(elem.get<int64_t>()));
} else if (elem.is_number_float()) {
py_list.append(py::float_(elem.get<double>()));
} else if (elem.is_boolean()) {
py_list.append(py::bool_(elem.get<bool>()));
} else if (elem.is_null()) {
py_list.append(py::none());
} else {
py_list.append(py::str(elem.get<std::string>()));
}
}
d[py::str(key)] = py_list;
} else if (val.is_number_integer()) {
d[py::str(key)] = py::int_(val.get<int64_t>());
} else if (val.is_number_float()) {
d[py::str(key)] = py::float_(val.get<double>());
} else if (val.is_boolean()) {
d[py::str(key)] = py::bool_(val.get<bool>());
} else if (val.is_null()) {
d[py::str(key)] = py::none();
} else {
d[py::str(key)] = py::str(val.get<std::string>());
}
}
}
return d;
}
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
#include <nlohmann/json.hpp>
#include <string>
namespace py = pybind11;
using json = nlohmann::json;
namespace netcore {
json py_dict_to_json(py::dict d);
py::dict json_to_py_dict(const json& j);
}
+38
View File
@@ -0,0 +1,38 @@
1. CPP构建命令:
cd /mnt/d/code/archery/cpp_ext
rm -rf build && mkdir build && cd build
TOOLCHAIN_BIN=/mnt/d/code/MaixCDK/dl/extracted/toolchains/maixcam/host-tools/gcc/riscv64-linux-musl-x86_64/bin
PYDEV=/mnt/d/code/shooting/python3_lib_maixcam_musl_3.11.6
MAIXCDK=/mnt/d/code/MaixCDK
cmake .. -G Ninja \
-DCMAKE_C_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-gcc" \
-DCMAKE_CXX_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-g++" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_FLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" \
-DCMAKE_CXX_FLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" \
-DPY_INCLUDE_DIR="${PYDEV}/include/python3.11" \
-DPY_LIB="${PYDEV}/lib/libpython3.11.so" \
-DPY_EXT_SUFFIX=".cpython-311-riscv64-linux-gnu.so" \
-DMAIXCDK_PATH="${MAIXCDK}"
ninja
2. Maixvision 直接跑项目的时候,是复制到板子上的这个目录:/tmp/maixpy_run
3. 4g 模块的终端测试方法:
3.1 一个窗口 ssh 到maixcam的板子上之后,通过 printf 输入命令到 /dev/ttyS2, 然后另外一个窗口通过 cat /dev/ttyS2 输出
# 1. 确保 PDP 激活
printf 'AT+CGPADDR=1\r\n' > /dev/ttyS2
# 2. 开启日志监听(另一个 SSH 窗口)
cat /dev/ttyS2
# 3. 发送下载命令(原窗口)
printf 'AT+MHTTPDLFILE="http://static.shelingxingqiu.com/shoot/v1/main.py","downloaded.py",5120\r\n' > /dev/ttyS2
4. wifi的启动条件,在 /boot 目录下,看看是否有 wifi.sta 和 wifi.ssid wifi.pass 这些文件。其中 wifi.sta 是开关文件。
如果没有了它就不会启动wifi流程。具体的wifi流程 由 /etc/init.d/S30wifi 控制。它会判断 wifi.sta 是否存在,然后是否启动wifi,还是启动热点。
+42
View File
@@ -0,0 +1,42 @@
1. 问题描述:开机失败,一直遇到Traceback (most recent call last):
File "/tmp/maixpy_run/main.py", line 525, in <module>
cmd_str()
File "/tmp/maixpy_run/main.py", line 102, in cmd_str
camera_manager.init_camera(640, 480)
File "/tmp/maixpy_run/camera_manager.py", line 59, in init_camera
self._camera = camera.Camera(width, height)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
RuntimeError: : Runtime error: mmf vi init failed
解决方案:
根据过往经验,极有可能是摄像头的接线有问题。因为在测试环境,摄像头是通过一个24针转22针的线出来的,然后再通过一个接线中继,连接到一个22针
的fpc线到Maixcam。接线中继如果是24针的,多了两针,需要选好一边然后对连。但这里很容易出错或者松动。可以先用摄像头本身的金色接线直接接到
Maixcam,然后跑test目录下的test_cammera.py,看看能不能正常启动,如果正常,就确定是中继接线的问题。
2. 问题描述:202609 批次的拓展版,在连接 202601 批次的电源板,或者不链接电源板的时候,开机后不久,出错,程序退出,日志是:
[v1.2.10] [INFO] network.py:1078 - [NET] TCP主线程启动
[v1.2.10] [INFO] network.py:406 - [NET] WiFi不可用或无法连接服务器,使用4G网络
[v1.2.10] [INFO] network.py:475 - 连接到服务器,使用4G...
[v1.2.10] [INFO] network.py:527 - [4G-TCP] AT+MIPCLOSE=2 response:
OK
+MIPCLOSE: 2
-- [E] read failed
Trigger signal, code:SIGSEGV(11)!
maix multi-media driver released.
ISP Vipipe(0) Free pa(0x8a52c000) va(0x0x3fbeb5e000)
program exit failed. exit code: 1.
解决方案:
从日志看,就是开始发送登录信息之后就崩溃了。出发了底层的read failed。经过排查,是一定要插上电源板的数据连线,以及电源板要插上电池。这个应该是
登录时需要读电源电压数据,
3. a)问题描述:202609 批次的拓展版,有一块maixcam的蓝灯常亮,询问maixcam的人,他们觉得应该是卡没有插好。但是拓展版上的激光口挡住了数据卡的出口,
没法拔出检查,
解决方案:需要做拓展版的公司(深链鑫创)在做好板子之后,确定系统能正常启动
b)问题描述:2022609 批次的拓展板,有一次maixcam的蓝灯亮的时候很长,不会闪烁,后面把sd卡插进去一点,又恢复正常了,初步怀疑是射箭时没有缓冲,
导致了sd 卡被撞松了
4. 问题描述:4G模块不可用,模块的绿灯没有闪亮
解决方案:有这样的一种情况,就是4G模块的天线,触碰到了旁边的电容,导致短路,所以模块启动失败。需要保证电容和天线的金属头不会触碰
5.
+105
View File
@@ -0,0 +1,105 @@
1. 4G OTA 下载的时候,为什么使用十六进制下载,读取 URC 事件?
因为使用二进制下载的时候,经常会出现错误,并且会失败?然后最稳定传输的办法,是每次传输的时候,是分块,而且每次分块都要“删/建”http实例。推测原因是因为我们现在是直接传输文件的源代码,代码中含有了一些字符串可能和 AT指令重复,导致了 AT 模块在解释的时候出错。而使用 16 进制的方式,可以避免这个问题。因为十六进制直接把数据先转成了字符串,然后在设备端再把字符串转成数据,这样就不可能出现 AT的指令,从而减少了麻烦。
2. 4G OTA 下载的时候,为什么不用 AT 模块里 HTTPDLFILE 的指令?
因为在测试中发现,使用 HTTPDLFILE,其实是下载到了 4G 模块内部,需要重新从模块内部转到存储卡,而且 4G 模块的存储较小,大概只有 40k,所以还需要分块来下载和转存,比较麻烦,于是最终使用了使用读取串口事件的模式。
3. 4G OTA 下载的时候,为什么不用 AT 模块里 HTTPREAD 的指令?
因为之前测试发现,READ模式其实是需要多步:
3.1. AT+MHTTPCREATE
3.2. AT+MHTTPCFG
3.3. AT+MHTTPREQUEST
3.4. AT+MHTTPREAD
它其实也是把数据下载到 4g 模块的缓存里,然后再从缓存里读取出来。所以也是比较繁琐的,还不如 HTTPDLFILE 简单。
4. WiFi OTA 流程(ota_manager.handle_wifi_and_update()
* 解析 ota_url 得到 host:port
* 调用 network_manager.connect_wifi(ssid, password, verify_host=host, verify_port=port, persist=True)
* 只有“能连上 WiFi 且能访问 OTA host:port”才会把新凭证保留在 /boot
* 连接成功后开始下载 OTA 文件(download_file()
* 下载成功则 apply_ota_and_reboot()
5. TCP 通信
1) 平时 TCP 通信主流程(network_manager.tcp_main()
外层无限循环:一直尝试保持与服务器的 TCP 会话。
每轮开始:
如果 OTA 正在进行:暂停(避免抢占资源/串口)。
connect_server():建立 TCP 连接(自动选 WiFi 或 4G)。
发送“登录包”(msg_type=1),等待服务器返回“登录成功”。
登录成功后进入内层循环:
接收数据:
WiFi:非阻塞 recv();没数据返回 b"";有数据进入缓冲区拼包解析。
4G:从 ATClient 的队列 pop_tcp_payload() 取数据。
处理命令/ACK:
登录响应、心跳 ACK、OTA 命令、关机命令、日志上传命令等。
发送业务队列:
从高优/普通队列取 1 条,发送失败会放回队首,并断线重连(不再丢消息)。
发送心跳:
按 HEARTBEAT_INTERVAL 发心跳包。
心跳失败会计数(当前为连续失败到阈值才重连)。
任何发送/接收致命失败:
关闭 socket/断开连接 → 跳出内层循环 → 外层等待一会儿后重新 connect_server() → 重新登录。
6. “WiFi 连接/验证”
TCP 连接建立与网络选择(connect_server() / select_network()
* select_network()WiFi 优先,但要求:
is_wifi_connected() 为 True(系统层面有 WiFi IP 或 Maix WLAN connected
且能连到 TCP 服务器 SERVER_IP:SERVER_PORT
否则回退到 4G
* connect_server()
若已有连接:WiFi 会做 _check_wifi_connection() 轻量检查;4G 直接认为 OK(由 AT 层维护)。
否则按网络类型走:
WiFi:创建 socket → connect → setblocking(False)(接收用非阻塞)
4GAT+MIPOPEN 建链
WiFi 链接(connect_wifi()
当前 connect_wifi() 的关键特点是:必须让 /etc/init.d/S30wifi restart 真正用新 SSID 去连,所以会临时写 /boot/wifi.ssid 和 /boot/wifi.pass,失败自动回滚。
流程是:
(1) 备份旧配置
* /boot/wifi.ssid、/boot/wifi.pass
* /etc/wpa_supplicant.conf(尽量备份)
(2) 写入新凭证
* 把新 ssid/pass 写到 /boot/*
-(同时尽量写 /etc/wpa_supplicant.conf,但不强依赖)
(3) 重启 WiFi 服务:/etc/init.d/S30wifi restart
(4) 等待获取 IP(默认 20 秒,可调)
(5) 验证可用性,连到 verify_host:verify_port
(6) 成功
* persist=True:保留 /boot/*(持久化)
* persist=False:回滚 /boot/* 到旧值(不重启,当前连接仍可继续)
(7) 失败
* 回滚 /boot/* + 回滚 /etc/wpa_supplicant.conf(如果有备份)
* 再 S30wifi restart 恢复旧网络
* 返回错误
7. 日志上传(inner_cmd == 43),当前只支持 wifi 上传日志
命令带 ssid/password/url 时:
* 若 WiFi 未连接:先 connect_wifi(..., verify_host=upload_host, verify_port=upload_port, persist=True)
上传内容:
* sync # 把日志从内存同步到文件
* 快照 app.log* 到 /tmp staging
* 打包成 tar.gz(默认)或 zip
* 以 multipart/form-data 的 file 字段 POST 到 url
8. 自动关机:
hardware中设定了开停表,然后再增加了获取idle的时间。
自动关机的时机: 超过配置的idle时长,
禁止自动关机的情况:1.校准中,2.OTA中
重启计时的时机:1.校准完成,2.命令触发射箭,3.真实触发射箭,4.初始化完成
9. Wifi网络监控:
有两次发现wifi网络下,有些消息发送很慢,但具体是什么缘故还不清楚,现在增加了wifi网络下的检测,并一旦发现wifi的网络质量差,就会切换到4G。
WiFi 连接成功
启动后台监测线程
每 5 秒循环:
测量 RTT (1 样本,600ms timeout)
获取 RSSI
更新缓存
判断是否差:
- RTT >= 600ms → 差
- RTT >= 350ms 且 RSSI <= -80dBm → 差
如果质量差:
快速重试2次,如果其中任意一次网络恢复了,继续使用wifi。否则,
调用 _switch_to_4g_due_to_poor_wifi()
关闭 WiFi socket
重置连接状态
尝试切换到 4G
上层检测到连接断开:
重新 connect_server() → 自动选择 4G
+41
View File
@@ -0,0 +1,41 @@
你现在要防的是“别人拿到设备/拿到代码包后,能伪造请求、刷接口、下发恶意 OTA、甚至劫持通信”。单靠隐藏 Python 源码只能提高门槛,真正的安全要靠协议和密钥设计。结合你仓库里实际内容,建议你重点隐藏/整改这些点(按风险排序)。
1. 必须隐藏/必须整改(高风险)
1.1 登录口令规则太弱(几乎等于明文)
你现在的登录是 password = device_id + "."(见 network.py 读取设备 ID 后直接拼出来),这意味着只要攻击者知道/猜到 device_id,就能直接登录伪装设备。
相关位置:
with open("/device_key", "r") as f: device_id = f.read().strip() ... self._device_id = device_id self._password = device_id + "."
1.2 HTTP 鉴权 token 的盐值是硬编码常量(泄露后可离线伪造)
你 token 是 HMAC-SHA256((SALT+device_id), SALT2),而 SALT/SALT2 是固定字符串:"shootMessageFire" / "shoot"。这类“硬编码盐值 + 可猜/可读的 device_id”意味着:攻击者只要拿到代码包/逆向 .so,就能在自己电脑上批量算 token,伪造 HTTP 请求。
相关位置:
SALT = "shootMessageFire"SALT2 = "shoot"return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
1.3 TLS 配置目前看起来没有做证书校验(容易被中间人攻击)
config.py 虽然 USE_TCP_SSL=True,但你在 network.py 里实际把 MSSLCFG="auth" 固定成 0(不验),且写证书分支被 if False 禁用了。这样“看起来是 TLS”,但仍可能被抓包/篡改/假服务器接入。
相关位置:
r = hardware_manager.at_client.send(f'AT+MSSLCFG="auth",{ssl_id},0', "OK", 3000)...if False: # 写证书/校验被禁用 ...r = hardware_manager.at_client.send(f'AT+MIPCFG="ssl",{link_id},{ssl_id},1', "OK", 3000)
1.4 OTA 下发“url”如果缺少强校验,就是远程代码执行入口
你 OTA 逻辑里从服务器指令拿到 url 就去下载并替换文件/重启(这是正常 OTA),但安全性取决于:
是否只允许白名单域名/路径
是否强制 https 并校验服务器证书
是否对 OTA 包做签名校验(最关键)
你这里能看到固定域名 static.shelingxingqiu.com 的特殊处理(ota_manager.py 里还在纠结 http/https),这块一定要“服务端签名 + 设备端验签”,否则隐藏源码也没用。
2. 建议隐藏(中风险,但很容易被人利用)
2.1 所有服务器地址/端口/API 路径(可被用于扫描、压测、撞库、协议逆向)
这些在 config.py 是明文:
SERVER_IP = "stcp.shelingxingqiu.com"SERVER_PORT = 50005HTTP_URL = "http://ws.shelingxingqiu.com"HTTP_API_PATH = "/home/shoot/device_fire/arrow/fire"
注意:即使你把它们藏进 .so,攻击者仍可能通过抓包/观察 DNS/SNI/流量拿到域名或 IP,所以“隐藏”只能降低静态分析风险,不能替代鉴权/签名。
2.2 WiFi 凭证落盘位置
你会把 SSID/密码写到 /boot/wifi.ssid 和 /boot/wifi.passnetwork.py/wifi.py 都有)。拿到设备存储就能读到明文密码,这属于设备侧安全问题。
2.3 日志/调试信息泄露
你仓库里 .cursor/debug.log* 已经记录了完整的 AT+MIPOPEN ... host:port 等信息;如果这些被打进包或被用户导出,也会泄露通信细节。
你发布包里“真正会被带走的敏感内容”
你现在 app.yaml 的打包清单只包含核心运行文件(不包含 test/、backup_code/、.cursor/ 等),这一点很好:
files: - app.yaml - at_client.py - camera_manager.py - config.py - hardware.py - laser_manager.py - logger_manager.py - main.py - network.py - ota_manager.py - power.py - shot_id_generator.py - time_sync.py - version.py - vision.py
但要注意:你当前最敏感的“盐值/口令规则/HTTP header 逻辑/OTA 策略”都在这些会打包的文件里(尤其是 network.py、ota_manager.py、config.py)。
3. 最有效的“防黑”措施(比隐藏更关键)
把“password=device_id+.” 改为真正的 per-device secret(设备出厂烧录,服务端保存;或服务端下发公钥,设备用私钥签名)
所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用)
OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用)
TLS 必须做证书校验/最好做 pinning(至少别用 auth=0
如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
硬件管理器模块
提供硬件对象的统一管理和访问
"""
from maix import time
import config
from at_client import ATClient
class HardwareManager:
"""硬件管理器(单例)"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(HardwareManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# 私有硬件对象
self._uart4g = None # 4G模块UART
self._bus = None # I2C总线
self._adc_obj = None # ADC对象
self._at_client = None # AT客户端
self._last_active_time = 0 # 用于记录用户的最后一次活跃的时间
self._stop_timer = False # 用于停止定时器的标志
self._initialized = True
# ==================== 硬件访问(只读属性)====================
@property
def uart4g(self):
"""4G模块UART(只读)"""
return self._uart4g
@property
def bus(self):
"""I2C总线(只读)"""
return self._bus
@property
def adc_obj(self):
"""ADC对象(只读)"""
return self._adc_obj
@property
def at_client(self):
"""AT客户端(只读)"""
return self._at_client
# ==================== 初始化方法 ====================
def init_uart4g(self, device=None, baudrate=None):
"""初始化4G模块UART"""
from maix import uart
if device is None:
device = config.UART4G_DEVICE
if baudrate is None:
baudrate = config.UART4G_BAUDRATE
self._uart4g = uart.UART(device, baudrate)
return self._uart4g
def init_bus(self, bus_num=None):
"""初始化I2C总线"""
from maix import i2c
if bus_num is None:
bus_num = config.I2C_BUS_NUM
self._bus = i2c.I2C(bus_num, i2c.Mode.MASTER)
return self._bus
def init_adc(self, channel=None, res_bit=None):
"""初始化ADC"""
from maix.peripheral import adc
if channel is None:
channel = config.ADC_CHANNEL
if res_bit is None:
res_bit = adc.RES_BIT_12
self._adc_obj = adc.ADC(channel, res_bit)
return self._adc_obj
def init_at_client(self, uart_obj=None):
"""初始化AT客户端"""
if uart_obj is None:
if self._uart4g is None:
raise ValueError("uart4g must be initialized before at_client")
uart_obj = self._uart4g
self._at_client = ATClient(uart_obj)
self._at_client.start()
return self._at_client
def power_off(self):
"""关闭电源板"""
try:
# 物理引脚是 A24,对应 GPIO 功能是 GPIOA24
# 注意:这里需要先在 config.PIN_MAPPINGS 中配置好 "A24": "GPIOA24"
from maix import gpio
# 输出高电平关闭
gpio.GPIO("GPIOA24", gpio.Mode.OUT).value(1)
except Exception as e:
print(f"关机失败: {e}")
def start_idle_timer(self):
self._stop_timer = False
self._last_active_time = time.time()
def stop_idle_timer(self):
self._stop_timer = True
def get_idle_time_in_sec(self):
if self._stop_timer:
return 0
diff = time.time() - self._last_active_time
if diff < 0:
# 时间可能被重置了,重新计时
self._last_active_time = time.time()
return 0
return diff
# 创建全局单例实例
hardware_manager = HardwareManager()
+52
View File
@@ -0,0 +1,52 @@
import os
def generate_key_pair():
"""
生成一对新的密钥a和b,使得a XOR b等于原始key
:return: (a, b, key) 元组,每个元素都是32字节的字节数组
"""
# 原始key值
key = bytes([
0x5d, 0xf9, 0xef, 0xc4, 0x5d, 0xcc, 0xc7, 0x8d, 0xc9, 0x86, 0x34, 0x11, 0x6f, 0xb4, 0xcf, 0x75,
0xbf, 0x24, 0x47, 0x9d, 0xd6, 0x5d, 0x83, 0x4b, 0xa6, 0xc0, 0xde, 0x27, 0x91, 0x92, 0xb1, 0x63
])
# 随机生成a
a = os.urandom(32)
# 计算b = key XOR a
b = bytes([key[i] ^ a[i] for i in range(32)])
return a, b, key
def format_hex_array(data):
"""
将字节数组格式化为C++风格的十六进制数组
:param data: 字节数组
:return: 格式化后的字符串
"""
return "{" + ",".join([f"0x{b:02x}" for b in data]) + "}"
def generate_new_key_pair():
"""
生成新的密钥对并打印出来
"""
a, b, key = generate_key_pair()
print("原始key:")
print(format_hex_array(key))
print("\n新的密钥对:")
print("a =", format_hex_array(a))
print("b =", format_hex_array(b))
# 验证a XOR b是否等于key
verify_key = bytes([a[i] ^ b[i] for i in range(32)])
assert verify_key == key, "验证失败:a XOR b 不等于 key"
print("\n验证成功:a XOR b 等于 key")
if __name__ == "__main__":
generate_new_key_pair()
-826
View File
@@ -1,826 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
激光射击系统主程序(激光测距版)
功能:目标检测、激光校准、4G TCP 通信、OTA 升级、M01 激光测距、INA226 电量监测
平台:MaixPy (Sipeed MAIX)
作者:ZZH
最后更新:2025-11-21
"""
from maix import camera, display, image, app, time, key, uart, pinmap, i2c, network, err
import cv2
import numpy as np
import json
import struct
import re
from maix.peripheral import adc
import _thread
import os
import requests
import socket
import binascii
# ==============================
# 全局配置
# ==============================
# OTA 升级地址(建议后续改为动态下发)
url = "https://static.shelingxingqiu.com/shoot/202511031031/main.py"
local_filename = "/maixapp/apps/t11/main.py"
DEVICE_ID = None
PASSWORD = None
SERVER_IP = "www.shelingxingqiu.com"
SERVER_PORT = 50005
HEARTBEAT_INTERVAL = 2 # 心跳间隔(秒)
CONFIG_FILE = "/root/laser_config.json"
DEFAULT_POINT = (640, 480) # 图像中心点
laser_point = DEFAULT_POINT
# HTTP API(当前未使用,保留备用)
URL = "http://ws.shelingxingqiu.com"
API_PATH = "/home/shoot/device_fire/arrow/fire"
# UART 设备初始化
uart4g = uart.UART("/dev/ttyS2", 115200) # 4G 模块(TCP 透传)
distance_serial = uart.UART("/dev/ttyS1", 9600) # M01 激光测距模块
# 消息类型常量
MSG_TYPE_LOGIN_REQ = 1 # 登录请求
MSG_TYPE_STATUS = 2 # 状态上报
MSG_TYPE_HEARTBEAT = 4 # 心跳包
# 引脚功能映射
pinmap.set_pin_function("A18", "UART1_RX")
pinmap.set_pin_function("A19", "UART1_TX")
pinmap.set_pin_function("A29", "UART2_RX")
pinmap.set_pin_function("A28", "UART2_TX")
pinmap.set_pin_function("P18", "I2C1_SCL")
pinmap.set_pin_function("P21", "I2C1_SDA")
# pinmap.set_pin_function("A15", "I2C5_SCL")
# pinmap.set_pin_function("A27", "I2C5_SDA")#ota升级要修改的
# ADC 触发阈值(用于检测扳机/激光触发)
ADC_TRIGGER_THRESHOLD = 3000
ADC_LASER_THRESHOLD = 3000
# 显示参数
color = image.Color(255, 100, 0) # 橙色十字线
thickness = 1
length = 2
# ADC 扳机触发阈值(0~4095
ADC_TRIGGER_THRESHOLD = 3000
# I2C 电源监测(INA226
adc_obj = adc.ADC(0, adc.RES_BIT_12)
bus = i2c.I2C(1, i2c.Mode.MASTER)
# bus = i2c.I2C(5, i2c.Mode.MASTER)#ota升级总线
INA226_ADDR = 0x40
REG_CONFIGURATION = 0x00
REG_BUS_VOLTAGE = 0x02
REG_CALIBRATION = 0x05
CALIBRATION_VALUE = 0x1400
# M01 激光模块指令
MODULE_ADDR = 0x00
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
LASER_OFF_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
DISTANCE_QUERY_CMD = bytes([0xAA, MODULE_ADDR, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x21])
DISTANCE_RESPONSE_LEN = 13
# TCP / 线程状态
tcp_connected = False
send_queue = []
update_thread_started = False # 防止重复 OTA
send_queue_lock = _thread.allocate_lock()
laser_calibration_data_lock = _thread.allocate_lock()
laser_calibration_active = False
laser_calibration_result = None
# ==============================
# 网络工具函数
# ==============================
def is_server_reachable(host, port=80, timeout=5):
"""检查能否连接到指定主机和端口(用于 OTA 前网络检测)"""
try:
addr_info = socket.getaddrinfo(host, port)[0]
s = socket.socket(addr_info[0], addr_info[1], addr_info[2])
s.settimeout(timeout)
s.connect(addr_info[-1])
s.close()
return True
except Exception as e:
print(f"[NET] 无法连接 {host}:{port} - {e}")
return False
def download_file(url, filename):
"""
从指定 URL 下载文件并保存为 UTF-8 文本。
注意:此操作会覆盖本地 main.py!
"""
try:
print(f"[OTA] 正在从 {url} 下载文件...")
response = requests.get(url, timeout=10) # ⏱️ 防止卡死
response.raise_for_status()
response.encoding = 'utf-8'
with open(filename, 'w', encoding='utf-8') as file:
file.write(response.text)
return f"下载成功!文件已保存为: {filename}"
except requests.exceptions.RequestException as e:
return f"下载失败!网络请求错误: {e}"
except OSError as e:
return f"下载失败!文件写入错误: {e}"
except Exception as e:
return f"下载失败!发生未知错误: {e}"
def connect_wifi(ssid, password):
"""
连接 Wi-Fi 并持久化凭证到 /boot/ 目录,使设备重启后自动连接。
返回 (ip, error) 元组。
"""
conf_path = "/etc/wpa_supplicant.conf"
ssid_file = "/boot/wifi.ssid"
pass_file = "/boot/wifi.pass"
try:
# 生成 wpa_supplicant 配置
net_conf = os.popen(f'wpa_passphrase "{ssid}" "{password}"').read()
if "network={" not in net_conf:
return None, "Failed to generate wpa config"
# 写入运行时配置
with open(conf_path, "w") as f:
f.write("ctrl_interface=/var/run/wpa_supplicant\n")
f.write("update_config=1\n\n")
f.write(net_conf)
# 持久化保存(供开机脚本读取)
with open(ssid_file, "w") as f:
f.write(ssid.strip())
with open(pass_file, "w") as f:
f.write(password.strip())
# 重启 Wi-Fi 服务
os.system("/etc/init.d/S30wifi restart")
# 等待获取 IP(最多 20 秒)
for _ in range(20):
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
if ip:
return ip, None
time.sleep(1)
return None, "Timeout: No IP obtained"
except Exception as e:
return None, f"Exception: {str(e)}"
def direct_ota_download():
"""
直接执行 OTA 下载(假设已有网络)
用于 cmd=7 触发
"""
global update_thread_started
try:
# 再次确认网络可达(可选但推荐)
from urllib.parse import urlparse
parsed_url = urlparse(url)
host = parsed_url.hostname
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
if not is_server_reachable(host, port, timeout=8):
safe_enqueue({"result": "ota_failed", "reason": f"无法连接 {host}:{port}"}, MSG_TYPE_STATUS)
return
print(f"[OTA] 开始直接下载固件...")
result_msg = download_file(url, local_filename)
print(f"[OTA] {result_msg}")
safe_enqueue({"result": result_msg}, MSG_TYPE_STATUS)
except Exception as e:
error_msg = f"OTA 异常: {str(e)}"
print(error_msg)
safe_enqueue({"result": "ota_failed", "reason": error_msg}, MSG_TYPE_STATUS)
finally:
update_thread_started = False # 允许下次 OTA
def handle_wifi_and_update(ssid, password):
"""
OTA 更新线程入口。
注意:必须在 finally 中重置 update_thread_started
"""
global update_thread_started
try:
ip, error = connect_wifi(ssid, password)
if error:
safe_enqueue({"result": "wifi_failed", "error": error}, MSG_TYPE_STATUS)
return
safe_enqueue({"result": "wifi_connected", "ip": ip}, MSG_TYPE_STATUS)
from urllib.parse import urlparse
parsed_url = urlparse(url)
host = parsed_url.hostname
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
if not is_server_reachable(host, port, timeout=8):
err_msg = f"网络不通:无法连接 {host}:{port}"
safe_enqueue({"result": err_msg}, MSG_TYPE_STATUS)
return
print(f"[OTA] 已确认可访问 {host}:{port},开始下载...")
try:
cs = download_file(url, local_filename)
except Exception as e:
cs = f"下载失败: {str(e)}"
print(cs)
safe_enqueue({"result": cs}, MSG_TYPE_STATUS)
finally:
# ✅ 关键修复:允许下次 OTA
update_thread_started = False
print("[UPDATE] OTA 线程执行完毕,标志已重置。")
# ==============================
# 工具函数
# ==============================
def read_device_id():
"""从 /device_key 读取设备唯一 ID"""
try:
with open("/device_key", "r") as f:
device_id = f.read().strip()
if device_id:
print(f"[INFO] 从 /device_key 读取到 DEVICE_ID: {device_id}")
return device_id
else:
raise ValueError("文件为空")
except Exception as e:
print(f"[ERROR] 无法读取 /device_key: {e}")
return "DEFAULT_DEVICE_ID"
def safe_enqueue(data_dict, msg_type=MSG_TYPE_STATUS):
"""线程安全地将消息加入发送队列"""
global send_queue, send_queue_lock
with send_queue_lock:
send_queue.append((msg_type, data_dict))
def at(cmd, wait="OK", timeout=2000):
"""向 4G 模块发送 AT 指令并等待响应"""
if cmd:
uart4g.write((cmd + "\r\n").encode())
t0 = time.ticks_ms()
buf = b""
while time.ticks_ms() - t0 < timeout:
data = uart4g.read()
if data:
buf += data
if wait.encode() in buf:
return buf.decode(errors="ignore")
return buf.decode(errors="ignore")
def make_packet(msg_type: int, body_dict: dict) -> bytes:
"""构造二进制数据包:[body_len][msg_type][checksum][body]"""
body = json.dumps(body_dict, ensure_ascii=False).encode('utf-8')
body_len = len(body)
checksum = body_len + msg_type
header = struct.pack(">III", body_len, msg_type, checksum)
return header + body
def parse_packet(data: bytes):
"""解析二进制数据包"""
if len(data) < 12:
return None, None
body_len, msg_type, checksum = struct.unpack(">III", data[:12])
body = data[12:12 + body_len]
try:
# ✅ 显式指定 UTF-8 编码
return msg_type, json.loads(body.decode('utf-8'))
except Exception as e:
print(f"[ERROR] 解析包体失败: {e}")
return msg_type, {"raw": body.decode('utf-8', errors='ignore')}
def tcp_send_raw(data: bytes, max_retries=2) -> bool:
"""通过 4G 模块发送原始 TCP 数据(仅在 tcp_main 线程调用)"""
global tcp_connected
if not tcp_connected:
return False
for attempt in range(max_retries):
cmd = f'AT+MIPSEND=0,{len(data)}'
if ">" not in at(cmd, ">", 1500):
time.sleep_ms(100)
continue
time.sleep_ms(10)
full = data + b"\x1A"
try:
sent = uart4g.write(full)
if sent != len(full):
time.sleep_ms(100)
continue
except:
time.sleep_ms(100)
continue
if "OK" in at("", "OK", 1000):
return True
time.sleep_ms(100)
return False
def load_laser_point():
"""从配置文件加载激光点坐标"""
global laser_point
try:
if "laser_config.json" in os.listdir("/root"):
with open(CONFIG_FILE, "r") as f:
data = json.load(f)
if isinstance(data, list) and len(data) == 2:
laser_point = (int(data[0]), int(data[1]))
print(f"[INFO] 加载激光点: {laser_point}")
else:
raise ValueError
else:
laser_point = DEFAULT_POINT
except:
laser_point = DEFAULT_POINT
def save_laser_point(point):
"""保存激光点坐标到文件"""
global laser_point
try:
with open(CONFIG_FILE, "w") as f:
json.dump([point[0], point[1]], f)
laser_point = point
except:
pass
def turn_on_laser():
"""发送激光开启指令"""
distance_serial.write(LASER_ON_CMD)
time.sleep_ms(10)
resp = distance_serial.read(20)
if resp:
if resp == LASER_ON_CMD:
print("✅ 激光指令已确认")
else:
print("🔇 无回包(正常或模块不支持)")
return resp
# ==============================
# M01 激光测距模块
# ==============================
def parse_bcd_distance(bcd_bytes: bytes) -> float:
"""将 4 字节 BCD 码转换为距离(米)"""
if len(bcd_bytes) != 4:
return 0.0
try:
hex_string = binascii.hexlify(bcd_bytes).decode()
distance_int = int(hex_string)
return distance_int / 1000.0
except Exception as e:
print(f"[ERROR] BCD 解析失败: {e}")
return 0.0
def read_distance_from_laser_sensor():
"""发送测距指令并返回距离(米)"""
global distance_serial
try:
distance_serial.read() # 清空缓冲区
distance_serial.write(DISTANCE_QUERY_CMD)
time.sleep_ms(500)
response = distance_serial.read(DISTANCE_RESPONSE_LEN)
if response and len(response) == DISTANCE_RESPONSE_LEN:
if response[3] != 0x20:
if response[0] == 0xEE:
err_code = (response[7] << 8) | response[8]
print(f"[LASER] 模块错误代码: {hex(err_code)}")
return 0.0
bcd_bytes = response[6:10]
distance_value_m = parse_bcd_distance(bcd_bytes)
signal_quality = (response[10] << 8) | response[11]
print(f"[LASER] 测距成功: {distance_value_m:.3f} m, 信号质量: {signal_quality}")
return distance_value_m
print(f"[LASER] 无效响应: {response.hex() if response else 'None'}")
return 0.0
except Exception as e:
print(f"[ERROR] 读取激光测距失败: {e}")
return 0.0
# ==============================
# 激光点校准
# ==============================
def find_red_laser(frame, threshold=150):
"""在图像中查找最亮的红色点(简单 RGB 判定)"""
w, h = frame.width(), frame.height()
img_bytes = frame.to_bytes()
max_sum = 0
best_pos = None
for y in range(0, h, 2):
for x in range(0, w, 2):
idx = (y * w + x) * 3
r, g, b = img_bytes[idx], img_bytes[idx+1], img_bytes[idx+2]
if r > threshold and r > g * 2 and r > b * 2:
rgb_sum = r + g + b
if rgb_sum > max_sum:
max_sum = rgb_sum
best_pos = (x, y)
return best_pos
def calibrate_laser_position():
"""拍摄一帧并识别激光点位置"""
time.sleep_ms(80)
cam = camera.Camera(640, 480)
frame = cam.read()
pos = find_red_laser(frame)
if pos:
save_laser_point(pos)
return pos
return None
# ==============================
# 电量监测(INA226
# ==============================
def write_register(reg, value):
data = [(value >> 8) & 0xFF, value & 0xFF]
bus.writeto_mem(INA226_ADDR, reg, bytes(data))
def read_register(reg):
data = bus.readfrom_mem(INA226_ADDR, reg, 2)
return (data[0] << 8) | data[1]
def init_ina226():
write_register(REG_CONFIGURATION, 0x4527)
write_register(REG_CALIBRATION, CALIBRATION_VALUE)
def get_bus_voltage():
raw = read_register(REG_BUS_VOLTAGE)
return raw * 1.25 / 1000
def voltage_to_percent(voltage):
points = [
(4.20, 100), (4.10, 95), (4.05, 85), (4.00, 75), (3.95, 65),
(3.90, 55), (3.85, 45), (3.80, 35), (3.75, 25), (3.70, 15),
(3.65, 5), (3.60, 0)
]
if voltage >= points[0][0]: return 100
if voltage <= points[-1][0]: return 0
for i in range(len(points) - 1):
v1, p1 = points[i]; v2, p2 = points[i + 1]
if v2 <= voltage <= v1:
ratio = (voltage - v1) / (v2 - v1)
percent = p1 + (p2 - p1) * ratio
return max(0, min(100, int(round(percent))))
return 0
# ==============================
# 目标检测
# ==============================
def detect_circle(frame):
"""检测靶心圆(清晰/模糊两种模式)"""
img_cv = image.image2cv(frame, False, False)
gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edged = cv2.Canny(blurred, 50, 150)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
best_center = best_radius = method = None
for cnt in contours:
area = cv2.contourArea(cnt)
perimeter = cv2.arcLength(cnt, True)
if perimeter < 100 or area < 100: continue
circularity = 4 * np.pi * area / (perimeter ** 2)
if circularity > 0.75 and len(cnt) >= 5:
center, axes, angle = cv2.fitEllipse(cnt)
radius = (axes[0] + axes[1]) / 4
best_center = (int(center[0]), int(center[1]))
best_radius = int(radius)
method = "清晰"
break
if not best_center:
hsv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2HSV)
h, s, v = cv2.split(hsv)
s = np.clip(s * 2, 0, 255).astype(np.uint8)
hsv = cv2.merge((h, s, v))
lower_yellow = np.array([7, 80, 0])
upper_yellow = np.array([32, 255, 182])
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if contours:
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) > 50:
(x, y), radius = cv2.minEnclosingCircle(largest)
best_center = (int(x), int(y))
best_radius = int(radius)
method = "模糊"
result_img = image.cv2image(img_cv, False, False)
return result_img, best_center, best_radius, method, best_radius
def compute_laser_position(circle_center, laser_point, radius, method):
"""计算激光相对于靶心的偏差(单位:厘米)"""
if not all([circle_center, radius, method]):
return None, None
cx, cy = circle_center
lx, ly = laser_point
# 根据检测模式估算实际半径(单位:像素 → 厘米)
circle_r_cm = (radius / 4.0) * 20.0 if method == "模糊" else (68 / 16.0) * 20.0
dx = lx - cx
dy = ly - cy
scale = circle_r_cm / radius if radius != 0 else 1.0
return dx * scale, -dy * scale
# ==============================
# TCP 通信主线程
# ==============================
def connect_server():
"""连接服务器(通过 4G 模块 AT 指令)"""
global tcp_connected
if tcp_connected:
return True
print("正在连接服务器...")
at("AT+MIPCLOSE=0", "OK", 1000)
res = at(f'AT+MIPOPEN=0,"TCP","{SERVER_IP}",{SERVER_PORT}', "+MIPOPEN", 8000)
if "+MIPOPEN: 0,0" in res:
tcp_connected = True
return True
return False
def tcp_main():
"""TCP 通信主循环(独立线程)"""
global tcp_connected, send_queue, laser_calibration_active, laser_calibration_result,update_thread_started
while not app.need_exit():
if not connect_server():
time.sleep_ms(5000)
continue
login_data = {"deviceId": DEVICE_ID, "password": PASSWORD}
if not tcp_send_raw(make_packet(MSG_TYPE_LOGIN_REQ, login_data)):
tcp_connected = False
time.sleep_ms(2000)
continue
print("➡️ 登录包已发送,等待确认...")
logged_in = False
last_heartbeat_ack_time = time.ticks_ms()
last_heartbeat_send_time = time.ticks_ms()
rx_buf = b""
while True:
data = uart4g.read()
if data:
rx_buf += data
while b'+MIPURC: "rtcp"' in rx_buf:
try:
match = re.search(b'\+MIPURC: "rtcp",0,(\d+),(.+)', rx_buf, re.DOTALL)
if match:
payload_len = int(match.group(1))
payload = match.group(2)[:payload_len]
msg_type, body = parse_packet(payload)
if not logged_in and msg_type == MSG_TYPE_LOGIN_REQ:
if body and body.get("cmd") == 1 and body.get("data") == "登录成功":
logged_in = True
last_heartbeat_ack_time = time.ticks_ms()
print("✅ 登录成功")
else:
break
elif logged_in and msg_type == MSG_TYPE_HEARTBEAT:
last_heartbeat_ack_time = time.ticks_ms()
print("✅ 收到心跳确认")
elif logged_in and isinstance(body, dict):
inner_data = body.get("data", {})
if isinstance(inner_data, dict) and "cmd" in inner_data:
inner_cmd = inner_data["cmd"]
if inner_cmd == 2:
turn_on_laser()
time.sleep_ms(100)
laser_calibration_active = True
safe_enqueue({"result": "calibrating"}, MSG_TYPE_STATUS)
elif inner_cmd == 3:
distance_serial.write(LASER_OFF_CMD)
laser_calibration_active = False
safe_enqueue({"result": "laser_off"}, MSG_TYPE_STATUS)
elif inner_cmd == 4:
voltage = get_bus_voltage()
battery_percent = voltage_to_percent(voltage)
battery_data = {"battery": battery_percent, "voltage": round(voltage, 3)}
safe_enqueue(battery_data, MSG_TYPE_STATUS)
elif inner_cmd == 5:
ssid = inner_data.get("ssid")
password = inner_data.get("password")
if not ssid or not password:
safe_enqueue({"result": "missing_ssid_or_password"}, MSG_TYPE_STATUS)
else:
# global update_thread_started
if not update_thread_started:
update_thread_started = True
_thread.start_new_thread(handle_wifi_and_update, (ssid, password))
else:
safe_enqueue({"result": "update_already_started"}, MSG_TYPE_STATUS)
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"
safe_enqueue({"result": "current_ip", "ip": ip}, MSG_TYPE_STATUS)
elif inner_cmd == 7:
# global update_thread_started
if update_thread_started:
safe_enqueue({"result": "update_already_started"}, MSG_TYPE_STATUS)
continue
# 实时检查是否有 IP
try:
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
except:
ip = None
if not ip:
safe_enqueue({"result": "ota_rejected", "reason": "no_wifi_ip"}, MSG_TYPE_STATUS)
else:
# 启动纯下载线程
update_thread_started = True
_thread.start_new_thread(direct_ota_download, ())
rx_buf = rx_buf[match.end():]
else:
break
except Exception as e:
print(f"[ERROR] 解析/处理数据包失败: {e}")
rx_buf = b""
break
# 发送队列处理
msg_type = None
if logged_in:
with send_queue_lock:
if send_queue:
msg_type, data_dict = send_queue.pop(0)
if msg_type is not None:
pkt = make_packet(msg_type, data_dict)
if not tcp_send_raw(pkt):
print("💔 发送失败,断开重连")
break
# 校准结果上报
if logged_in:
x = y = None
with laser_calibration_data_lock:
if laser_calibration_result is not None:
x, y = laser_calibration_result
laser_calibration_result = None
if x is not None:
safe_enqueue({"result": "ok", "x": x, "y": y}, MSG_TYPE_STATUS)
# 心跳机制
current_time = time.ticks_ms()
if logged_in and current_time - last_heartbeat_send_time > HEARTBEAT_INTERVAL * 1000:
if not tcp_send_raw(make_packet(MSG_TYPE_HEARTBEAT, {"t": int(time.time())})):
print("💔 心跳发送失败")
break
last_heartbeat_send_time = current_time
if logged_in and current_time - last_heartbeat_ack_time > 6000:
print("⏰ 6秒无心跳ACK,重连")
break
time.sleep_ms(50)
tcp_connected = False
time.sleep_ms(2000)
def laser_calibration_worker():
"""后台激光校准线程"""
global laser_calibration_active, laser_calibration_result
while True:
if laser_calibration_active:
result = calibrate_laser_position()
if result and len(result) == 2:
with laser_calibration_data_lock:
laser_calibration_result = result
laser_calibration_active = False
print(f"✅ 后台校准成功: {result}")
else:
time.sleep_ms(80)
else:
time.sleep_ms(50)
# ==============================
# 主程序入口
# ==============================
def cmd_str():
global DEVICE_ID, PASSWORD
DEVICE_ID = read_device_id()
PASSWORD = DEVICE_ID + "."
photo_dir = "/root/phot"
if photo_dir not in os.listdir("/root"):
try:
os.mkdir(photo_dir)
except:
pass
init_ina226()
load_laser_point()
disp = display.Display()
cam = camera.Camera(640, 480)
_thread.start_new_thread(tcp_main, ())
_thread.start_new_thread(laser_calibration_worker, ())
print("系统准备完成...")
while not app.need_exit():
if adc_obj.read() > ADC_TRIGGER_THRESHOLD:
time.sleep_ms(60)
frame = cam.read()
x, y = laser_point
frame.draw_line(int(x - length), int(y), int(x + length), int(y), color, thickness)
frame.draw_line(int(x), int(y - length), int(x), int(y + length), color, thickness)
frame.draw_circle(int(x), int(y), 1, color, thickness)
result_img, center, radius, method, _ = detect_circle(frame)
disp.show(result_img)
dx, dy = compute_laser_position(center, (x, y), radius, method)
distance_m = read_distance_from_laser_sensor()
voltage = get_bus_voltage()
battery_percent = voltage_to_percent(voltage)
try:
jpg_count = len([f for f in os.listdir(photo_dir) if f.endswith('.jpg')])
filename = f"{photo_dir}/{int(x)}_{int(y)}_{round((distance_m or 0.0) * 100)}_{method}_{jpg_count:04d}.jpg"
result_img.save(filename, quality=70)
except Exception as e:
print(f"❌ 保存照片失败: {e}")
inner_data = {
"x": float(dx) if dx is not None else 200.0,
"y": float(dy) if dy is not None else 200.0,
"r": 90.0,
"d": round((distance_m or 0.0) * 100),
"m": method
}
report_data = {"cmd": 1, "data": inner_data}
safe_enqueue(report_data, MSG_TYPE_STATUS)
time.sleep_ms(100)
else:
disp.show(cam.read())
time.sleep_ms(50)
if __name__ == "__main__":
cmd_str()
+1272
View File
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
日志管理器模块
提供异步日志功能(使用 QueueHandler + QueueListener
"""
import logging
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
import queue
import os
import config
from version import VERSION
class LoggerManager:
"""日志管理器(单例)"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(LoggerManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# 私有状态
self._log_queue = None
self._queue_listener = None
self._logger = None
self._initialized = True
# ==================== 状态访问(只读属性)====================
@property
def logger(self):
"""获取logger对象(只读)"""
return self._logger
@property
def log_queue(self):
"""获取日志队列(只读)"""
return self._log_queue
# ==================== 业务方法 ====================
def init_logging(self, log_level=logging.INFO, log_file=None, max_bytes=None, backup_count=None):
"""
初始化异步日志系统(使用 QueueHandler + QueueListener
Args:
log_level: 日志级别,默认 INFO
log_file: 日志文件路径,默认使用 config.LOG_FILE
max_bytes: 单个日志文件最大大小(字节),默认使用 config.LOG_MAX_BYTES
backup_count: 保留的备份文件数量,默认使用 config.LOG_BACKUP_COUNT
"""
if log_file is None:
log_file = config.LOG_FILE
if max_bytes is None:
max_bytes = config.LOG_MAX_BYTES
if backup_count is None:
backup_count = config.LOG_BACKUP_COUNT
try:
# 创建日志队列(无界队列)
self._log_queue = queue.Queue(-1)
# 确保日志文件所在的目录存在
log_dir = os.path.dirname(log_file)
if log_dir: # 如果日志路径包含目录
try:
os.makedirs(log_dir, exist_ok=True)
except Exception as e:
print(f"[WARN] 无法创建日志目录 {log_dir}: {e}")
# 尝试创建文件Handler(带日志轮转)
try:
file_handler = RotatingFileHandler(
log_file,
maxBytes=max_bytes,
backupCount=backup_count,
encoding='utf-8',
mode='a' # 追加模式,确保不覆盖
)
except Exception as e:
# 如果RotatingFileHandler不可用,降级为普通FileHandler
print(f"[WARN] RotatingFileHandler不可用,使用普通FileHandler: {e}")
try:
file_handler = logging.FileHandler(log_file, encoding='utf-8', mode='a')
except Exception as e2:
# 如果文件Handler创建失败,只使用控制台Handler
print(f"[WARN] 无法创建文件Handler,仅使用控制台输出: {e2}")
file_handler = None
# 自定义Formatter,包含版本信息
class CustomFormatter(logging.Formatter):
"""自定义日志格式,包含版本信息和行号"""
def format(self, record):
record.version = VERSION
return super().format(record)
# 如果file_handler存在,设置格式和级别
if file_handler is not None:
file_handler.setFormatter(CustomFormatter(
'%(asctime)s [v%(version)s] [%(levelname)s] %(filename)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
file_handler.setLevel(log_level)
# 创建控制台Handler(保留原有的print输出)
console_handler = logging.StreamHandler()
console_handler.setFormatter(CustomFormatter(
'[v%(version)s] [%(levelname)s] %(filename)s:%(lineno)d - %(message)s'
))
console_handler.setLevel(log_level)
# 创建QueueListener(后台线程处理日志写入)
# 如果file_handler为None,只使用console_handler
handlers = [console_handler]
if file_handler is not None:
handlers.append(file_handler)
self._queue_listener = QueueListener(
self._log_queue,
*handlers,
respect_handler_level=True
)
self._queue_listener.start()
# 创建QueueHandler(用于记录日志)
queue_handler = QueueHandler(self._log_queue)
# 配置根logger
self._logger = logging.getLogger()
self._logger.addHandler(queue_handler)
self._logger.setLevel(log_level)
# 避免日志向上传播到其他logger
self._logger.propagate = False
# 添加启动标记
self._logger.info("=" * 60)
self._logger.info("程序启动 - 日志系统初始化")
self._logger.info(f"版本: {VERSION}")
self._logger.info(f"日志文件: {log_file}")
self._logger.info("=" * 60)
return True
except Exception as e:
# 如果日志初始化失败,至少保证程序能运行
print(f"[ERROR] 日志系统初始化失败: {e}")
import traceback
try:
traceback.print_exc()
except:
pass
return False
def stop_logging(self):
"""停止日志系统(程序退出时调用)"""
try:
if self._logger:
# 确保所有日志都写入
self._logger.info("程序退出,正在保存日志...")
import time as std_time
std_time.sleep(0.5) # 给一点时间让日志写入
if self._queue_listener:
self._queue_listener.stop()
if self._logger:
# 等待队列中的日志处理完成
if self._log_queue:
import time as std_time
timeout = 5
start = std_time.time()
while not self._log_queue.empty() and (std_time.time() - start) < timeout:
std_time.sleep(0.1)
print("[LOG] 日志系统已停止")
except Exception as e:
print(f"[ERROR] 停止日志系统失败: {e}")
# 创建全局单例实例
logger_manager = LoggerManager()
# ==================== 向后兼容的函数接口 ====================
def init_logging(log_level=logging.INFO, log_file=None, max_bytes=None, backup_count=None):
"""初始化日志系统(向后兼容接口)"""
return logger_manager.init_logging(log_level, log_file, max_bytes, backup_count)
def stop_logging():
"""停止日志系统(向后兼容接口)"""
return logger_manager.stop_logging()
def get_logger():
"""
获取全局logger对象(向后兼容接口)
如果日志系统未初始化,返回None(此时可以使用print作为fallback
"""
return logger_manager.logger
+508 -783
View File
File diff suppressed because it is too large Load Diff
+1754
View File
File diff suppressed because it is too large Load Diff
+1338
View File
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
应用打包脚本
根据 app.yaml 中列出的文件,打包成 zip 文件
版本号从 version.py 中读取
"""
import argparse
import os
import yaml
import zipfile
from datetime import datetime
import sys
import secrets
MAGIC = b"AROTAE1" # 7 bytes: Archery OTA Encrypted v1
GCM_NONCE_LEN = 12
GCM_TAG_LEN = 16
# 添加当前目录到路径,以便导入 version 模块
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def load_app_yaml(yaml_path='app.yaml'):
"""加载 app.yaml 文件"""
try:
with open(yaml_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
except Exception as e:
print(f"[ERROR] 读取 {yaml_path} 失败: {e}")
return None
def check_files_exist(files, base_dir='.'):
"""检查文件是否存在"""
missing_files = []
existing_files = []
for file_path in files:
full_path = os.path.join(base_dir, file_path)
if os.path.exists(full_path):
existing_files.append(file_path)
else:
missing_files.append(file_path)
return existing_files, missing_files
def get_version_from_version_py():
"""从 version.py 读取版本号"""
try:
from version import VERSION
return VERSION
except ImportError:
print("[WARNING] 无法导入 version.py,使用默认版本号 1.0.0")
return '1.0.0'
except Exception as e:
print(f"[WARNING] 读取 version.py 失败: {e},使用默认版本号 1.0.0")
return '1.0.0'
def create_zip_package(app_info, files, output_dir='.', base_dir='.'):
"""创建 zip 打包文件"""
# 生成输出文件名:{name}_v{version}_{timestamp}.zip
# 版本号从 version.py 读取,而不是从 app.yaml
app_name = app_info.get('name', 'app')
version = get_version_from_version_py() # 从 version.py 读取版本号
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
zip_filename = f"{app_name}_v{version}_{timestamp}.zip"
zip_path = os.path.join(output_dir, zip_filename)
print(f"[INFO] 开始打包: {zip_filename}")
print(f"[INFO] 包含文件数: {len(files)}")
try:
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file_path in files:
full_path = os.path.join(base_dir, file_path)
# 使用相对路径作为 zip 内的路径
zipf.write(full_path, file_path)
print(f"{file_path}")
# 获取文件大小
file_size = os.path.getsize(zip_path)
file_size_mb = file_size / (1024 * 1024)
print(f"\n[SUCCESS] 打包完成!")
print(f" 文件名: {zip_filename}")
print(f" 文件大小: {file_size_mb:.2f} MB ({file_size:,} 字节)")
print(f" 文件路径: {os.path.abspath(zip_path)}")
return zip_path
except Exception as e:
print(f"[ERROR] 打包失败: {e}")
import traceback
traceback.print_exc()
return None
def _validate_key_hex(key_hex: str) -> bytes:
if not isinstance(key_hex, str):
raise ValueError("aead key must be hex string")
key_hex = key_hex.strip().lower()
if key_hex.startswith("0x"):
key_hex = key_hex[2:]
if len(key_hex) != 64:
raise ValueError("aead key must be 64 hex chars (32 bytes)")
try:
key = bytes.fromhex(key_hex)
except Exception as e:
raise ValueError(f"invalid hex key: {e}")
if len(key) != 32:
raise ValueError("aead key must be 32 bytes")
return key
def encrypt_zip_aead(zip_path: str, key_hex: str, out_ext: str = ".enc") -> str:
"""
Encrypt the whole zip file as one blob:
output format: MAGIC(7) | nonce(12) | ciphertext(N) | tag(16)
using AES-256-GCM (AEAD).
"""
# Lazy import: packaging-only dependency
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except Exception as e:
raise RuntimeError(
"Missing dependency: cryptography. Install with: pip install cryptography. "
f"Import error: {e}"
)
key = _validate_key_hex(key_hex)
with open(zip_path, "rb") as f:
plain = f.read()
nonce = secrets.token_bytes(GCM_NONCE_LEN)
aesgcm = AESGCM(key)
ct_and_tag = aesgcm.encrypt(nonce, plain, None) # ciphertext || tag (16 bytes)
enc_path = zip_path + out_ext if out_ext else (zip_path + ".enc")
with open(enc_path, "wb") as f:
f.write(MAGIC)
f.write(nonce)
f.write(ct_and_tag)
return enc_path
def main():
"""主函数"""
parser = argparse.ArgumentParser(description="打包 app.yaml 文件列表到 zip,并可选进行 AES-256-GCM 加密输出 .enc")
parser.add_argument("--aead-key-hex", default=None, help="AES-256-GCM key (64 hex chars = 32 bytes). If set, output encrypted file.")
parser.add_argument("--keep-zip", action="store_true", help="Keep the plaintext zip when encryption is enabled.")
parser.add_argument("--out-ext", default=".enc", help="Encrypted output extension appended to zip path. Default: .enc (produces *.zip.enc)")
args = parser.parse_args()
print("=" * 60)
print("应用打包脚本")
print("=" * 60)
# 1. 加载 app.yaml
app_info = load_app_yaml('app.yaml')
if app_info is None:
return
# 从 version.py 读取版本号
version = get_version_from_version_py()
print(f"\n[INFO] 应用信息:")
print(f" ID: {app_info.get('id', 'N/A')}")
print(f" 名称: {app_info.get('name', 'N/A')}")
print(f" 版本: {version} (来自 version.py)")
print(f" 作者: {app_info.get('author', 'N/A')}")
if app_info.get('version') != version:
print(f" [注意] app.yaml 中的版本 ({app_info.get('version', 'N/A')}) 与 version.py 不一致")
# 2. 获取文件列表
files = app_info.get('files', [])
if not files:
print("[ERROR] app.yaml 中没有找到 files 列表")
return
print(f"\n[INFO] 文件列表 ({len(files)} 个文件):")
# 3. 检查文件是否存在
existing_files, missing_files = check_files_exist(files)
if missing_files:
print(f"\n[WARNING] 以下文件不存在,将被跳过:")
for f in missing_files:
print(f"{f}")
if not existing_files:
print("\n[ERROR] 没有找到任何有效文件,无法打包")
return
print(f"\n[INFO] 找到 {len(existing_files)} 个有效文件")
# 4. 创建 zip 包
zip_path = create_zip_package(app_info, existing_files)
if zip_path:
enc_path = None
if args.aead_key_hex:
try:
enc_path = encrypt_zip_aead(zip_path, args.aead_key_hex, out_ext=args.out_ext)
enc_size = os.path.getsize(enc_path)
print(f"\n[SUCCESS] AEAD加密完成: {os.path.basename(enc_path)} ({enc_size:,} bytes)")
print(f" 文件路径: {os.path.abspath(enc_path)}")
if not args.keep_zip:
try:
os.remove(zip_path)
print(f"[INFO] 已删除明文zip: {os.path.basename(zip_path)}")
except Exception as e:
print(f"[WARNING] 删除明文zip失败(可忽略): {e}")
except Exception as e:
print(f"\n[ERROR] AEAD加密失败: {e}")
print("[ERROR] 保留明文zip用于排查。")
print("\n" + "=" * 60)
print("打包成功完成!")
print("=" * 60)
else:
print("\n" + "=" * 60)
print("打包失败!")
print("=" * 60)
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
电源管理模块(INA226
提供电压、电流监测和充电状态检测
"""
import config
from logger_manager import logger_manager
def write_register(reg, value):
"""写入INA226寄存器"""
from hardware import hardware_manager
data = [(value >> 8) & 0xFF, value & 0xFF]
hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
def read_register(reg):
"""读取INA226寄存器"""
from hardware import hardware_manager
data = hardware_manager.bus.readfrom_mem(config.INA226_ADDR, reg, 2)
return (data[0] << 8) | data[1]
def init_ina226():
"""初始化 INA226 芯片:配置模式 + 校准值"""
write_register(config.REG_CONFIGURATION, 0x4527)
write_register(config.REG_CALIBRATION, config.CALIBRATION_VALUE)
def get_bus_voltage():
"""读取总线电压(单位:V"""
raw = read_register(config.REG_BUS_VOLTAGE)
return raw * 1.25 / 1000
def get_current():
"""
读取电流(单位:mA
正数表示充电,负数表示放电
INA226 电流计算公式:
Current = (Current Register Value) × Current_LSB
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
"""
try:
raw = read_register(config.REG_CURRENT)
# INA226 电流寄存器是16位有符号整数
# 最高位是符号位:0=正(充电),1=负(放电)
# 计算 Current_LSB(根据 CALIBRATION_VALUE
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
# 处理有符号数:如果最高位为1,转换为负数
if raw & 0x8000: # 最高位为1,表示负数(放电)
signed_raw = raw - 0x10000 # 转换为有符号整数
else: # 最高位为0,表示正数(充电)
signed_raw = raw
# 转换为毫安
current_ma = signed_raw * current_lsb * 1000
return current_ma
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[INA226] 读取电流失败: {e}")
else:
print(f"[INA226] 读取电流失败: {e}")
return 0.0
def is_charging(threshold_ma=10.0):
"""
检测是否在充电(通过电流方向判断)
Args:
threshold_ma: 电流阈值(毫安),超过此值认为在充电,默认10mA
Returns:
True: 正在充电
False: 未充电或读取失败
"""
try:
current = get_current()
is_charge = current > threshold_ma
return is_charge
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[CHARGE] 检测充电状态失败: {e}")
else:
print(f"[CHARGE] 检测充电状态失败: {e}")
return False
def voltage_to_percent(voltage):
"""根据电压估算电池百分比(查表插值)"""
points = [
(4.20, 100), (4.10, 95), (4.05, 85), (4.00, 75), (3.95, 65),
(3.90, 55), (3.85, 45), (3.80, 35), (3.75, 25), (3.70, 15),
(3.65, 5), (3.60, 0)
]
if voltage >= points[0][0]:
return 100
if voltage <= points[-1][0]:
return 0
for i in range(len(points) - 1):
v1, p1 = points[i]
v2, p2 = points[i + 1]
if voltage >= v2:
ratio = (voltage - v1) / (v2 - v1)
percent = p1 + (p2 - p1) * ratio
return max(0, min(100, int(round(percent))))
return 0
+1
View File
@@ -47,6 +47,7 @@ def set_autostart_app(app_id):
if __name__ == "__main__": if __name__ == "__main__":
new_autostart_app_id = "t11" # change to app_id you want to set new_autostart_app_id = "t11" # change to app_id you want to set
# new_autostart_app_id = None # remove autostart # new_autostart_app_id = None # remove autostart
# new_autostart_app_id = "z1222" # change to app_id you want to set
list_apps() list_apps()
print("Before set autostart appid:", get_curr_autostart_app()) print("Before set autostart appid:", get_curr_autostart_app())
+208
View File
@@ -0,0 +1,208 @@
import config
from camera_manager import camera_manager
from laser_manager import laser_manager
from logger_manager import logger_manager
from network import network_manager
from power import get_bus_voltage, voltage_to_percent
from vision import estimate_distance, detect_circle_v3, save_shot_image
from maix import camera, display, image, app, time, uart, pinmap, i2c
def analyze_shot(frame, laser_point=None):
"""
分析射箭结果(算法部分,可迁移到C++)
:param frame: 图像帧
:param laser_point: 激光点坐标 (x, y)
:return: 包含分析结果的字典
"""
logger = logger_manager.logger
# 先检测靶心以获取距离(用于计算激光点)
result_img_temp, center_temp, radius_temp, method_temp, best_radius1_temp, ellipse_params_temp = detect_circle_v3(
frame, None)
# 计算距离
distance_m = estimate_distance(best_radius1_temp) if best_radius1_temp else None
# 根据距离动态计算激光点坐标
laser_point_method = None
if config.HARDCODE_LASER_POINT:
laser_point = laser_manager.laser_point
laser_point_method = "hardcode"
elif laser_manager.has_calibrated_point():
laser_point = laser_manager.laser_point
laser_point_method = "calibrated"
if logger:
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
elif distance_m and distance_m > 0:
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m)
laser_point_method = "dynamic"
if logger:
logger.info(f"[算法] 使用比例尺: {laser_point}")
else:
laser_point = laser_manager.laser_point
laser_point_method = "default"
if logger:
logger.info(f"[算法] 使用默认值: {laser_point}")
if laser_point is None:
return {
"success": False,
"reason": "laser_point_not_initialized"
}
x, y = laser_point
# 绘制激光十字线
color = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
frame.draw_line(
int(x - config.LASER_LENGTH), int(y),
int(x + config.LASER_LENGTH), int(y),
color, config.LASER_THICKNESS
)
frame.draw_line(
int(x), int(y - config.LASER_LENGTH),
int(x), int(y + config.LASER_LENGTH),
color, config.LASER_THICKNESS
)
frame.draw_circle(int(x), int(y), 1, color, config.LASER_THICKNESS)
# 重新检测靶心(使用计算出的激光点)
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
# 计算偏移与距离
if center and radius:
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
distance_m = estimate_distance(best_radius1)
else:
dx, dy = None, None
distance_m = None
# 返回分析结果
return {
"success": True,
"result_img": result_img,
"center": center,
"radius": radius,
"method": method,
"best_radius1": best_radius1,
"ellipse_params": ellipse_params,
"dx": dx,
"dy": dy,
"distance_m": distance_m,
"laser_point": laser_point,
"laser_point_method": laser_point_method
}
def process_shot(adc_val):
"""
处理射箭事件(逻辑控制部分)
:param adc_val: ADC触发值
:return: None
"""
logger = logger_manager.logger
try:
frame = camera_manager.read_frame()
# 调用算法分析
analysis_result = analyze_shot(frame)
if not analysis_result.get("success"):
reason = analysis_result.get("reason", "unknown")
if logger:
logger.warning(f"[MAIN] 射箭分析失败: {reason}")
time.sleep_ms(100)
return
# 提取分析结果
result_img = analysis_result["result_img"]
center = analysis_result["center"]
radius = analysis_result["radius"]
method = analysis_result["method"]
ellipse_params = analysis_result["ellipse_params"]
dx = analysis_result["dx"]
dy = analysis_result["dy"]
distance_m = analysis_result["distance_m"]
laser_point = analysis_result["laser_point"]
laser_point_method = analysis_result["laser_point_method"]
x, y = laser_point
camera_manager.show(result_img)
if not (center and radius) and logger:
logger.warning("[MAIN] 未检测到靶心,但会保存图像")
# 读取电量
voltage = get_bus_voltage()
battery_percent = voltage_to_percent(voltage)
# 生成射箭ID
from shot_id_generator import shot_id_generator
shot_id = shot_id_generator.generate_id()
if logger:
logger.info(f"[MAIN] 射箭ID: {shot_id}")
# 保存图像
save_shot_image(
result_img,
center,
radius,
method,
ellipse_params,
(x, y),
distance_m,
shot_id=shot_id,
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None
)
# 构造上报数据
inner_data = {
"shot_id": shot_id,
"x": float(dx) if dx is not None else 200.0,
"y": float(dy) if dy is not None else 200.0,
"r": 90.0,
"d": round((distance_m or 0.0) * 100),
"d_laser": 0.0,
"d_laser_quality": 0,
"m": method if method else "no_target",
"adc": adc_val,
"laser_method": laser_point_method,
"target_x": float(x),
"target_y": float(y),
}
if ellipse_params:
(ell_center, (width, height), angle) = ellipse_params
inner_data["ellipse_major_axis"] = float(max(width, height))
inner_data["ellipse_minor_axis"] = float(min(width, height))
inner_data["ellipse_angle"] = float(angle)
inner_data["ellipse_center_x"] = float(ell_center[0])
inner_data["ellipse_center_y"] = float(ell_center[1])
else:
inner_data["ellipse_major_axis"] = None
inner_data["ellipse_minor_axis"] = None
inner_data["ellipse_angle"] = None
inner_data["ellipse_center_x"] = None
inner_data["ellipse_center_y"] = None
report_data = {"cmd": 1, "data": inner_data}
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
if logger:
if center and radius:
logger.info(f"射箭事件已加入发送队列(已检测到靶心),ID: {shot_id}")
else:
logger.info(f"射箭事件已加入发送队列(未检测到靶心,已保存图像),ID: {shot_id}")
# 闪一下激光(射箭反馈)
laser_manager.flash_laser(1000)
time.sleep_ms(100)
except Exception as e:
if logger:
logger.error(f"[MAIN] 图像处理异常: {e}")
import traceback
logger.error(traceback.format_exc())
time.sleep_ms(100)
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
射箭ID生成器
为每次射箭生成唯一ID,格式:{timestamp_ms}_{counter}
"""
from maix import time
import threading
class ShotIDGenerator:
"""射箭ID生成器(单例)"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super(ShotIDGenerator, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._counter = 0
self._last_timestamp_ms = 0
self._lock = threading.Lock()
self._initialized = True
def generate_id(self, device_id=None):
"""
生成唯一的射箭ID
Args:
device_id: 可选的设备ID,如果提供则包含在ID中(格式:{device_id}_{timestamp_ms}_{counter}
如果不提供,则使用简单格式(格式:{timestamp_ms}_{counter}
Returns:
str: 唯一的射箭ID
"""
with self._lock:
current_timestamp_ms = time.ticks_ms()
# 如果时间戳相同,增加计数器;否则重置计数器
if current_timestamp_ms == self._last_timestamp_ms:
self._counter += 1
else:
self._counter = 0
self._last_timestamp_ms = current_timestamp_ms
# 生成ID
if device_id:
shot_id = f"{device_id}_{current_timestamp_ms}_{self._counter}"
else:
shot_id = f"{current_timestamp_ms}_{self._counter}"
return shot_id
def reset(self):
"""重置计数器(通常不需要调用)"""
with self._lock:
self._counter = 0
self._last_timestamp_ms = 0
# 创建全局单例实例
shot_id_generator = ShotIDGenerator()
+17
View File
@@ -0,0 +1,17 @@
# test_camera.py
from maix import camera, display, time
try:
print("Initializing camera...")
cam = camera.Camera(640, 480)
print("Camera initialized successfully!")
disp = display.Display()
while True:
frame = cam.read()
disp.show(frame)
time.sleep_ms(50)
except Exception as e:
print(f"Error: {e}")
+620
View File
@@ -0,0 +1,620 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
离线测试脚本:直接复用 detect_circle 逻辑进行测试
运行环境:MaixPy (Sipeed MAIX)
"""
import sys
import os
# import time
from maix import image,time
import cv2
import numpy as np
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
# ==================== 复制的核心算法 ====================
# 注意:这里直接复制了 detect_circle 的逻辑,避免 import main 导致的冲突
def detect_circle_v3(frame, laser_point=None):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
如果提供 laser_point,会选择最接近激光点的目标
Args:
frame: 图像帧
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
Returns:
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
"""
img_cv = image.image2cv(frame, False, False)
best_center = best_radius = best_radius1 = method = None
ellipse_params = None
# HSV 黄色掩码检测(模糊靶心)
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv)
# 调整饱和度策略:稍微增强,不要过度
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
hsv = cv2.merge((h, s, v))
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
# 调整形态学操作
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 存储所有有效的黄色-红色组合
valid_targets = []
if contours_yellow:
for cnt_yellow in contours_yellow:
area = cv2.contourArea(cnt_yellow)
perimeter = cv2.arcLength(cnt_yellow, True)
# 计算圆度
if perimeter > 0:
circularity = (4 * np.pi * area) / (perimeter * perimeter)
else:
circularity = 0
logger = get_logger()
if area > 50 and circularity > 0.7:
if logger:
logger.info(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
# 尝试拟合椭圆
yellow_center = None
yellow_radius = None
yellow_ellipse = None
if len(cnt_yellow) >= 5:
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
yellow_ellipse = ((x, y), (width, height), angle)
axes_minor = min(width, height)
radius = axes_minor / 2
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
else:
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
yellow_ellipse = None
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
if yellow_center and yellow_radius:
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
# 红色范围1: 0-10度(接近0度的红色)
lower_red1 = np.array([0, 80, 0])
upper_red1 = np.array([10, 255, 255])
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
# 红色范围2: 170-180度(接近180度的红色)
lower_red2 = np.array([170, 80, 0])
upper_red2 = np.array([180, 255, 255])
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
# 合并两个红色掩码
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
# 形态学操作
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
found_valid_red = False
if contours_red:
# 找到所有符合条件的红色圆圈
for cnt_red in contours_red:
area_red = cv2.contourArea(cnt_red)
perimeter_red = cv2.arcLength(cnt_red, True)
if perimeter_red > 0:
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
else:
circularity_red = 0
# 红色圆圈也应该有一定的圆度
if area_red > 50 and circularity_red > 0.6:
# 计算红色圆圈的中心和半径
if len(cnt_red) >= 5:
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
radius_red = min(w_red, h_red) / 2
red_center = (int(x_red), int(y_red))
red_radius = int(radius_red)
else:
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
red_center = (int(x_red), int(y_red))
red_radius = int(radius_red)
# 计算黄色和红色圆心的距离
if red_center:
dx = yellow_center[0] - red_center[0]
dy = yellow_center[1] - red_center[1]
distance = np.sqrt(dx*dx + dy*dy)
# 圆心距离阈值:应该小于黄色半径的某个倍数(比如1.5倍)
max_distance = yellow_radius * 1.5
# 红色圆圈应该比黄色圆圈大(外圈)
if distance < max_distance and red_radius > yellow_radius * 0.8:
found_valid_red = True
logger = get_logger()
if logger:
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
# 记录这个有效目标
valid_targets.append({
'center': yellow_center,
'radius': yellow_radius,
'ellipse': yellow_ellipse,
'area': area
})
break
if not found_valid_red:
logger = get_logger()
if logger:
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
# 从所有有效目标中选择最佳目标
if valid_targets:
if laser_point:
# 如果有激光点,选择最接近激光点的目标
best_target = None
min_distance = float('inf')
for target in valid_targets:
dx = target['center'][0] - laser_point[0]
dy = target['center'][1] - laser_point[1]
distance = np.sqrt(dx*dx + dy*dy)
if distance < min_distance:
min_distance = distance
best_target = target
if best_target:
best_center = best_target['center']
best_radius = best_target['radius']
ellipse_params = best_target['ellipse']
method = "v3_ellipse_red_validated_laser_selected"
best_radius1 = best_radius * 5
else:
# 如果没有激光点,选择面积最大的目标
best_target = max(valid_targets, key=lambda t: t['area'])
best_center = best_target['center']
best_radius = best_target['radius']
ellipse_params = best_target['ellipse']
method = "v3_ellipse_red_validated"
best_radius1 = best_radius * 5
result_img = image.cv2image(img_cv, False, False)
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
def detect_circle(frame):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
img_cv = image.image2cv(frame, False, False)
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
# edged = cv2.Canny(blurred, 50, 150)
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
# best_center = best_radius = best_radius1 = method = None
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
# h, s, v = cv2.split(hsv)
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
# hsv = cv2.merge((h, s, v))
# lower_yellow = np.array([7, 80, 0])
# upper_yellow = np.array([32, 255, 182])
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# if contours:
# largest = max(contours, key=cv2.contourArea)
# if cv2.contourArea(largest) > 50:
# (x, y), radius = cv2.minEnclosingCircle(largest)
# best_center = (int(x), int(y))
# best_radius = int(radius)
# best_radius1 = radius * 5
# method = "v2"
# auto
# R:31 M:v2 D:2.410110127692767
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
# h, s, v = cv2.split(hsv)
# # 1. 增强饱和度(模糊照片需要更强的增强)
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
# # 2. 增强亮度(模糊照片可能偏暗)
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
# hsv = cv2.merge((h, s, v))
# # 3. 放宽HSV颜色范围(特别是模糊照片)
# # 降低饱和度下限,提高亮度上限
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
# upper_yellow = np.array([35, 255, 255])
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# # 4. 增强形态学操作(连接被分割的区域)
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
# # 先开运算去除噪声
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# if contours:
# largest = max(contours, key=cv2.contourArea)
# area = cv2.contourArea(largest)
# if area > 50:
# # 5. 使用面积计算等效半径(更准确)
# equivalent_radius = np.sqrt(area / np.pi)
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
# # 取两者中的较大值,确保不遗漏
# radius = max(equivalent_radius, enclosing_radius)
# best_center = (int(x), int(y))
# best_radius = int(radius)
# best_radius1 = radius * 5
# method = "v2"
# codegee
# R:24 M:v2 D:3.061493895819174
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv)
# 2. 调整饱和度策略:
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
# 这里我们稍微增加一点饱和度,并确保不溢出
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
# v = clahe.apply(v)
hsv = cv2.merge((h, s, v))
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# 4. 调整形态学操作
# 去掉 MORPH_OPEN,因为它会减小面积。
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
# 再进行一次膨胀,确保边缘被包含进来
# mask = cv2.dilate(mask, kernel, iterations=1)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if contours:
largest = max(contours, key=cv2.contourArea)
# 这里可以适当降低面积阈值,或者保持不变
if cv2.contourArea(largest) > 50:
# (x, y), radius = cv2.minEnclosingCircle(largest)
# best_center = (int(x), int(y))
# best_radius = int(radius)
# --- 核心修改开始 ---
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
if len(largest) >= 5:
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
# 2. 计算半径
# 选项A:取长短轴的平均值 (比较稳健)
# radius = (axes_major + axes_minor) / 4
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
radius = axes_minor / 2
best_center = (int(x), int(y))
best_radius = int(radius)
method = "v2_ellipse"
else:
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
(x, y), radius = cv2.minEnclosingCircle(largest)
best_center = (int(x), int(y))
best_radius = int(radius)
method = "v2"
# --- 核心修改结束 ---
# 你的后续逻辑
best_radius1 = radius * 5
# operas 4.5
# R:25 M:v2 D:2.9554872521538527
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
# h, s, v = cv2.split(hsv)
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
# hsv = cv2.merge((h, s, v))
# # 2. 放宽 HSV 阈值范围(关键改动)
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
# lower_yellow = np.array([7, 40, 30])
# upper_yellow = np.array([35, 255, 255])
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# if contours:
# largest = max(contours, key=cv2.contourArea)
# if cv2.contourArea(largest) > 50:
# (x, y), radius = cv2.minEnclosingCircle(largest)
# best_center = (int(x), int(y))
# best_radius = int(radius)
# best_radius1 = radius * 5
# method = "v2"
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
# # 创建一个彩色掩码(红色通道为255,其他为0)
# mask_overlay = np.zeros_like(img_cv)
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
#
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
result_img = image.cv2image(img_cv, False, False)
return result_img, best_center, best_radius, method, best_radius1
def detect_circle_v2(frame):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
global REAL_RADIUS_CM
img_cv = image.image2cv(frame, False, False)
best_center = best_radius = best_radius1 = method = None
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
# HSV 黄色掩码检测(模糊靶心)
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv)
# 调整饱和度策略:稍微增强,不要过度
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
hsv = cv2.merge((h, s, v))
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# 调整形态学操作
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if contours:
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) > 50:
# 尝试拟合椭圆 (需要轮廓点至少为5个)
if len(largest) >= 5:
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
(x, y), (width, height), angle = cv2.fitEllipse(largest)
# 保存椭圆参数(保持原始顺序,用于绘制)
ellipse_params = ((x, y), (width, height), angle)
# 计算半径:使用较小的尺寸作为短轴
axes_minor = min(width, height)
radius = axes_minor / 2
best_center = (int(x), int(y))
best_radius = int(radius)
method = "v2_ellipse"
else:
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
(x, y), radius = cv2.minEnclosingCircle(largest)
best_center = (int(x), int(y))
best_radius = int(radius)
method = "v2"
ellipse_params = None # 圆形,没有椭圆参数
best_radius1 = radius * 5
result_img = image.cv2image(img_cv, False, False)
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
# ==================== 测试逻辑 ====================
def run_offline_test(image_path):
"""读取图片,检测圆,绘制结果,保存图片"""
# 1. 检查文件是否存在
if not os.path.exists(image_path):
print(f"[ERROR] 找不到图片文件: {image_path}")
return
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
try:
# 使用 image.load 读取文件,返回 Image 对象
img = image.load(image_path)
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
except Exception as e:
print(f"[ERROR] 读取图片失败: {e}")
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
return
# 3. 调用 detect_circle_v2 函数
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
start_time = time.ticks_ms()
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
cost_time = time.ticks_ms() - start_time
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
if ellipse_params:
(ell_center, (width, height), angle) = ellipse_params
print(f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
# 4. 绘制辅助线(可选,用于调试)
if center and radius:
# 为了绘制椭圆,需要转换回 cv2 图像
img_cv = image.image2cv(result_img, False, False)
cx, cy = center
# 如果有椭圆参数,绘制椭圆
if ellipse_params:
(ell_center, (width, height), angle) = ellipse_params
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
# 确定长轴和短轴
if width >= height:
# width 是长轴,height 是短轴
axes_major = width
axes_minor = height
major_angle = angle # 长轴角度就是 angle
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
else:
# height 是长轴,width 是短轴
axes_major = height
axes_minor = width
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
minor_angle = angle # 短轴角度就是 angle
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
cv2.ellipse(img_cv,
(cx_ell, cy_ell), # 中心点
(int(width/2), int(height/2)), # 半宽、半高
angle, # 旋转角度(OpenCV需要原始angle
0, 360, # 起始和结束角度
(0, 255, 0), # 绿色 (RGB格式)
2) # 线宽
# 绘制椭圆中心点(红色)
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
import math
# 绘制短轴(蓝色线条)
minor_length = axes_minor / 2
minor_angle_rad = math.radians(minor_angle)
dx_minor = minor_length * math.cos(minor_angle_rad)
dy_minor = minor_length * math.sin(minor_angle_rad)
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
else:
# 如果没有椭圆参数,绘制圆形(红色)
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
# 转换回 maix image
result_img = image.cv2image(img_cv, False, False)
# 定义颜色对象用于文字
try:
color_black = image.Color.from_rgb(0,0,0)
except AttributeError:
color_black = image.Color(0,0,0)
# D. 添加文字信息
FOCAL_LENGTH_PIX = 1900
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
info_str = f"R:{radius} M:{method} D:{d:.2f}"
print(info_str)
# 计算文字位置,防止超出图片边界
r_outer = int(radius * 11.0) if radius else 100
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
# 调用 draw_string
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
# 5. 保存结果图片
output_path = image_path.replace(".bmp", "_result.bmp")
output_path = image_path.replace(".jpg", "_result.jpg")
try:
result_img.save(output_path, quality=100)
print(f"[SUCCESS] 结果已保存至: {output_path}")
except Exception as e:
print(f"[ERROR] 保存图片失败: {e}")
if __name__ == "__main__":
# ================= 配置区域 =================
# 1. 设置要测试的图片路径
# 建议将图片放在与脚本同级目录,或者使用绝对路径
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
# TARGET_DIR = "/root/phot_test2" # 修改为你想要读取的目录路径
# 支持的图片格式
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
# ================= 执行区域 =================
if 'TARGET_DIR' in locals():
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
image_files = []
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
for filename in os.listdir(TARGET_DIR):
# 检查文件扩展名
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
# 过滤掉 _result.jpg 后缀的文件
if not filename.endswith('_result.jpg'):
filepath = os.path.join(TARGET_DIR, filename)
if os.path.isfile(filepath):
image_files.append(filepath)
# 按文件名排序(可选)
image_files.sort()
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
# 处理每张图片
for img_path in image_files:
print(f"\n{'='*10} 开始处理: {img_path} {'='*10}")
run_offline_test(img_path)
else:
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
else:
run_offline_test(TARGET_IMAGE)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# test_i2c_devices.py
import os
from maix import i2c
def list_i2c_devices():
"""List available I2C device nodes"""
print("Available I2C devices:")
# Check /dev directory
try:
dev_files = os.listdir("/dev")
i2c_devices = [f for f in dev_files if "i2c" in f]
if i2c_devices:
for dev in sorted(i2c_devices):
print(f" /dev/{dev}")
else:
print(" No /dev/i2c-* devices found!")
except Exception as e:
print(f" Error listing /dev: {e}")
def try_i2c_bus(bus_num):
"""Try to initialize an I2C bus"""
try:
bus = i2c.I2C(bus_num, i2c.Mode.MASTER)
print(f" I2C bus {bus_num}: OK")
return True
except RuntimeError as e:
print(f" I2C bus {bus_num}: {e}")
return False
except Exception as e:
print(f" I2C bus {bus_num}: Unexpected error: {e}")
return False
def main():
print("=" * 60)
print("I2C Device Diagnostic")
print("=" * 60)
# List kernel devices
list_i2c_devices()
# Try common bus numbers
print("\nTesting I2C buses:")
working_buses = []
for bus_num in range(10):
if try_i2c_bus(bus_num):
working_buses.append(bus_num)
print(f"\nWorking buses: {working_buses}")
if not working_buses:
print("\nERROR: No I2C buses available!")
print("Possible causes:")
print(" 1. I2C kernel driver not loaded")
print(" 2. Device tree doesn't enable I2C")
print(" 3. Different kernel version with different device naming")
if __name__ == "__main__":
main()
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
激光模块测试脚本
用于诊断激光开关问题
使用方法:
python test_laser.py
功能:
1. 初始化串口
2. 循环测试激光开/关
3. 打印详细调试信息
"""
from maix import uart, pinmap, time
# ==================== 配置 ====================
UART_PORT = "/dev/ttyS1" # 激光模块连接的串口(UART1
BAUDRATE = 9600 # 波特率
# 引脚映射(确保与硬件连接一致)
print("=" * 50)
print("🔧 步骤1: 配置引脚映射")
print("=" * 50)
try:
pinmap.set_pin_function("A18", "UART1_RX")
print("✅ A18 -> UART1_RX")
except Exception as e:
print(f"❌ A18 配置失败: {e}")
try:
pinmap.set_pin_function("A19", "UART1_TX")
print("✅ A19 -> UART1_TX")
except Exception as e:
print(f"❌ A19 配置失败: {e}")
# ==================== 激光控制指令 ====================
MODULE_ADDR = 0x00
# 原始命令
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
LASER_OFF_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
# 备用命令格式(如果原始命令不工作,可以尝试这些)
# 格式1: 简化命令
LASER_ON_CMD_ALT1 = bytes([0xAA, 0x01, 0x01])
LASER_OFF_CMD_ALT1 = bytes([0xAA, 0x01, 0x00])
# 格式2: 不同的协议头
LASER_ON_CMD_ALT2 = bytes([0x55, 0xAA, 0x01])
LASER_OFF_CMD_ALT2 = bytes([0x55, 0xAA, 0x00])
print("\n" + "=" * 50)
print("🔧 步骤2: 初始化串口")
print("=" * 50)
print(f"设备: {UART_PORT}")
print(f"波特率: {BAUDRATE}")
try:
laser_uart = uart.UART(UART_PORT, BAUDRATE)
print(f"✅ 串口初始化成功: {laser_uart}")
except Exception as e:
print(f"❌ 串口初始化失败: {e}")
exit(1)
# ==================== 测试函数 ====================
def send_and_check(cmd, name):
"""发送命令并检查回包"""
print(f"\n📤 发送: {name}")
print(f" 命令字节: {cmd.hex()}")
print(f" 命令长度: {len(cmd)} 字节")
# 清空接收缓冲区
try:
old_data = laser_uart.read(-1)
if old_data:
print(f" 清空缓冲区: {len(old_data)} 字节")
except:
pass
# 发送命令
try:
written = laser_uart.write(cmd)
print(f" 写入字节数: {written}")
except Exception as e:
print(f" ❌ 写入失败: {e}")
return None
# 等待响应
time.sleep_ms(100)
# 读取回包
try:
resp = laser_uart.read(50)
if resp:
print(f" 📥 收到回包: {resp.hex()} ({len(resp)} 字节)")
return resp
else:
print(f" ⚠️ 无回包")
return None
except Exception as e:
print(f" ❌ 读取失败: {e}")
return None
def test_laser_cycle(on_cmd, off_cmd, cmd_name="标准命令"):
"""测试一个开关周期"""
print(f"\n{'='*50}")
print(f"🧪 测试 {cmd_name}")
print(f"{'='*50}")
print("\n>>> 测试开启激光")
send_and_check(on_cmd, f"{cmd_name} - 开启")
print(" ⏱️ 等待 2 秒观察激光是否亮起...")
time.sleep(2)
print("\n>>> 测试关闭激光")
send_and_check(off_cmd, f"{cmd_name} - 关闭")
print(" ⏱️ 等待 2 秒观察激光是否熄灭...")
time.sleep(2)
# ==================== 主测试 ====================
print("\n" + "=" * 50)
print("🚀 开始激光测试")
print("=" * 50)
print("\n请观察激光模块的状态变化...")
print("测试将依次尝试不同的命令格式\n")
try:
# 测试1: 标准命令
test_laser_cycle(LASER_ON_CMD, LASER_OFF_CMD, "标准命令")
input("\n按回车继续测试备用命令1...")
# 测试2: 备用命令格式1
test_laser_cycle(LASER_ON_CMD_ALT1, LASER_OFF_CMD_ALT1, "备用命令1 (简化)")
input("\n按回车继续测试备用命令2...")
# 测试3: 备用命令格式2
test_laser_cycle(LASER_ON_CMD_ALT2, LASER_OFF_CMD_ALT2, "备用命令2 (0x55AA头)")
print("\n" + "=" * 50)
print("🏁 测试完成")
print("=" * 50)
print("\n诊断建议:")
print("1. 如果激光始终不亮/始终亮:")
print(" - 检查激光模块的电源连接")
print(" - 检查串口TX/RX是否接反")
print(" - 尝试不同的波特率 (4800/19200)")
print("")
print("2. 如果有回包但激光无反应:")
print(" - 命令格式可能正确但激光硬件问题")
print("")
print("3. 如果某个备用命令有效:")
print(" - 需要更新 config.py 中的命令格式")
except KeyboardInterrupt:
print("\n\n🛑 测试被中断")
# 确保激光关闭
laser_uart.write(LASER_OFF_CMD)
print("✅ 已发送关闭指令")
except Exception as e:
print(f"\n❌ 测试出错: {e}")
import traceback
traceback.print_exc()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
# test_power_with_init.py
from maix import i2c, time
import sys
# INA226 register addresses
INA226_ADDR = 0x40
REG_CONFIGURATION = 0x00
REG_BUS_VOLTAGE = 0x02
REG_CURRENT = 0x04
REG_CALIBRATION = 0x05
# Configuration values
CONFIG_VALUE = 0x4527 # Configuration: 16 averages, 1.1ms conversion time, continuous mode
CALIBRATION_VALUE = 0x1400 # Calibration value
def write_register(bus, reg, value):
"""Write to INA226 register"""
data = [(value >> 8) & 0xFF, value & 0xFF]
bus.writeto_mem(INA226_ADDR, reg, bytes(data))
def read_register(bus, reg):
"""Read from INA226 register"""
data = bus.readfrom_mem(INA226_ADDR, reg, 2)
return (data[0] << 8) | data[1]
def init_ina226(bus):
"""Initialize INA226 chip"""
try:
# Write configuration register
write_register(bus, REG_CONFIGURATION, CONFIG_VALUE)
time.sleep_ms(10)
# Write calibration register
write_register(bus, REG_CALIBRATION, CALIBRATION_VALUE)
time.sleep_ms(10)
# Verify configuration by reading it back
config_read = read_register(bus, REG_CONFIGURATION)
if config_read != CONFIG_VALUE:
print(f" Warning: Config readback mismatch: 0x{config_read:04X} != 0x{CONFIG_VALUE:04X}")
return True
except Exception as e:
print(f" Init failed: {e}")
return False
def read_voltage(bus):
"""Read bus voltage"""
raw = read_register(bus, REG_BUS_VOLTAGE)
voltage = raw * 1.25 / 1000
return voltage
def read_current(bus):
"""Read current"""
raw = read_register(bus, REG_CURRENT)
# Handle signed value
if raw & 0x8000:
raw = raw - 0x10000
current_lsb = 0.001 * CALIBRATION_VALUE / 4096
current = raw * current_lsb * 1000 # mA
return current
def test_i2c_bus(bus_num):
"""Test a single I2C bus with full initialization"""
print(f"\n{'='*60}")
print(f"Testing I2C Bus {bus_num}")
print(f"{'='*60}")
try:
# Step 1: Initialize I2C bus
print(f" 1. Initializing I2C bus...")
bus = i2c.I2C(bus_num, i2c.Mode.MASTER)
print(f" OK")
# Step 2: Initialize INA226
print(f" 2. Initializing INA226...")
if not init_ina226(bus):
print(f" FAILED")
return False
print(f" OK")
# Step 3: Read voltage multiple times
print(f" 3. Reading voltage...")
for i in range(5):
try:
voltage = read_voltage(bus)
current = read_current(bus)
print(f" Read {i+1}: {voltage:.3f}V, {current:.1f}mA")
time.sleep_ms(100)
except Exception as e:
print(f" Read {i+1} failed: {e}")
print(f" SUCCESS")
return True
except Exception as e:
print(f" FAILED: {e}")
import traceback
traceback.print_exc()
return False
def main():
"""Test all I2C buses"""
print("INA226 Test with Proper Initialization")
print("=" * 60)
# Test buses in order of likelihood
test_order = [5, 1, 3, 4, 0, 2]
success_buses = []
for bus_num in test_order:
if test_i2c_bus(bus_num):
success_buses.append(bus_num)
# If we found a working bus, stop testing others
break
print(f"\n{'='*60}")
print(f"Summary:")
print(f" Working buses: {success_buses}")
if not success_buses:
print(f" ERROR: No working I2C bus found!")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
时间同步模块
从4G模块获取时间并同步到系统
"""
import re
import os
from datetime import datetime, timedelta
import config
# from logger_bak import get_logger
from logger_manager import logger_manager
def parse_4g_time(cclk_response, timezone_offset=8):
"""
解析 AT+CCLK? 返回的时间字符串,并转换为本地时间
Args:
cclk_response: AT+CCLK? 的响应字符串
timezone_offset: 时区偏移(小时),默认8(中国时区 UTC+8)
Returns:
datetime 对象(已转换为本地时间),如果解析失败返回 None
"""
try:
# 匹配格式: +CCLK: "YY/MM/DD,HH:MM:SS+TZ"
# 时区单位是四分之一小时(quarters of an hour
match = re.search(r'\+CCLK:\s*"(\d{2})/(\d{2})/(\d{2}),(\d{2}):(\d{2}):(\d{2})([+-]\d{1,3})?"', cclk_response)
if not match:
return None
yy, mm, dd, hh, MM, ss, tz_str = match.groups()
# 年份处理:26 -> 2026
year = 2000 + int(yy)
month = int(mm)
day = int(dd)
hour = int(hh)
minute = int(MM)
second = int(ss)
# 创建 UTC 时间的 datetime 对象
dt_utc = datetime(year, month, day, hour, minute, second)
# 解析时区偏移(单位:四分之一小时)
if tz_str:
try:
# 时区偏移值(四分之一小时)
tz_quarters = int(tz_str)
# 转换为小时(除以4
tz_hours = tz_quarters / 4.0
logger = logger_manager.logger
if logger:
logger.info(f"[TIME] 时区偏移: {tz_str} (四分之一小时) = {tz_hours} 小时")
# 转换为本地时间
dt_local = dt_utc + timedelta(hours=tz_hours)
except ValueError:
# 如果时区解析失败,使用默认值
logger = logger_manager.logger
if logger:
logger.warning(f"[TIME] 时区解析失败: {tz_str},使用默认 UTC+{timezone_offset}")
dt_local = dt_utc + timedelta(hours=timezone_offset)
else:
# 没有时区信息,使用默认值
logger = logger_manager.logger
if logger:
logger.info(f"[TIME] 未找到时区信息,使用默认 UTC+{timezone_offset}")
dt_local = dt_utc + timedelta(hours=timezone_offset)
logger = logger_manager.logger
if logger:
logger.info(f"[TIME] UTC时间: {dt_utc.strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"[TIME] 本地时间: {dt_local.strftime('%Y-%m-%d %H:%M:%S')}")
return dt_local
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[TIME] 解析时间失败: {e}, 响应: {cclk_response}")
else:
print(f"[TIME] 解析时间失败: {e}, 响应: {cclk_response}")
return None
def get_time_from_4g(timezone_offset=8):
"""
通过4G模块获取当前时间(已转换为本地时间)
Args:
timezone_offset: 时区偏移(小时),默认8(中国时区)
Returns:
datetime 对象(本地时间),如果获取失败返回 None
"""
try:
# 发送 AT+CCLK? 命令(延迟导入避免循环依赖)
from hardware import hardware_manager
# 检查 at_client 是否已初始化
if hardware_manager.at_client is None:
logger = logger_manager.logger
if logger:
logger.warning("[TIME] ATClient 尚未初始化,无法获取4G时间")
else:
print("[TIME] ATClient 尚未初始化,无法获取4G时间")
return None
resp = hardware_manager.at_client.send("AT+CCLK?", "OK", 3000)
if not resp or "+CCLK:" not in resp:
logger = logger_manager.logger
if logger:
logger.warning(f"[TIME] 未获取到时间响应: {resp}")
else:
print(f"[TIME] 未获取到时间响应: {resp}")
return None
# 解析并转换时区
dt = parse_4g_time(resp, timezone_offset)
return dt
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[TIME] 获取4G时间异常: {e}")
else:
print(f"[TIME] 获取4G时间异常: {e}")
return None
def sync_system_time_from_4g(timezone_offset=8):
"""
从4G模块同步时间到系统
Args:
timezone_offset: 时区偏移(小时),默认8(中国时区)
Returns:
bool: 是否成功
"""
dt = get_time_from_4g(timezone_offset)
if not dt:
return False
try:
# 转换为系统 date 命令需要的格式
time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
# 设置系统时间
cmd = f'date -s "{time_str}" 2>&1'
result = os.system(cmd)
if result == 0:
logger = logger_manager.logger
if logger:
logger.info(f"[TIME] 系统时间已设置为: {time_str}")
else:
print(f"[TIME] 系统时间已设置为: {time_str}")
# 可选:同步到硬件时钟
try:
os.system('hwclock -w 2>/dev/null')
logger = logger_manager.logger
if logger:
logger.info("[TIME] 已同步到硬件时钟")
except:
pass
return True
else:
logger = logger_manager.logger
if logger:
logger.error(f"[TIME] 设置系统时间失败,退出码: {result}")
else:
print(f"[TIME] 设置系统时间失败,退出码: {result}")
return False
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[TIME] 同步系统时间异常: {e}")
else:
print(f"[TIME] 同步系统时间异常: {e}")
return False
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
应用版本号
每次 OTA 更新时,只需要更新这个文件中的版本号
"""
VERSION = '1.2.10'
# 1.2.0 开始使用C++编译成.so,替换部分代码
# 1.2.1 ota使用加密包
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
# 1.2.9 增加电源板的控制和自动关机的功能
# 1.2.10 config formal
+784
View File
@@ -0,0 +1,784 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
视觉检测模块
提供靶心检测、距离估算、图像保存等功能
"""
import cv2
import numpy as np
import os
import math
import threading
import queue
from maix import image
import config
from logger_manager import logger_manager
# 导入ArUco检测器(如果启用)
if config.USE_ARUCO:
from aruco_detector import detect_target_with_aruco, aruco_detector
# 存图队列 + worker
_save_queue = queue.Queue(maxsize=16)
_save_worker_started = False
_save_worker_lock = threading.Lock()
def check_laser_point_sharpness(frame, laser_point=None, roi_size=30, threshold=100.0, ellipse_params=None):
"""
检测激光点本身的清晰度(不是整个靶子)
Args:
frame: 图像帧对象
laser_point: 激光点坐标 (x, y),如果为None则自动查找
roi_size: ROI区域大小(像素),默认30x30
threshold: 清晰度阈值
ellipse_params: 椭圆参数 ((center_x, center_y), (width, height), angle),用于限制激光点必须在椭圆内
Returns:
(is_sharp, sharpness_score, laser_pos): (是否清晰, 清晰度分数, 激光点坐标)
"""
try:
# 1. 如果没有提供激光点,先查找
if laser_point is None:
from laser_manager import laser_manager
laser_point = laser_manager.find_red_laser(frame, ellipse_params=ellipse_params)
if laser_point is None:
logger_manager.logger.debug(f"未找到激光点")
return False, 0.0, None
x, y = laser_point
# 2. 转换为 OpenCV 格式
img_cv = image.image2cv(frame, False, False)
h, w = img_cv.shape[:2]
# 3. 提取 ROI 区域(激光点周围)
roi_half = roi_size // 2
x_min = max(0, int(x) - roi_half)
x_max = min(w, int(x) + roi_half)
y_min = max(0, int(y) - roi_half)
y_max = min(h, int(y) + roi_half)
roi = img_cv[y_min:y_max, x_min:x_max]
if roi.size == 0:
return False, 0.0, laser_point
# 4. 转换为灰度图(用于清晰度检测)
gray_roi = cv2.cvtColor(roi, cv2.COLOR_RGB2GRAY)
# 5. 方法1:检测点的扩散程度(能量集中度)
# 计算中心区域的能量集中度
center_x, center_y = roi.shape[1] // 2, roi.shape[0] // 2
center_radius = min(5, roi.shape[0] // 4) # 中心区域半径
# 创建中心区域的掩码
y_coords, x_coords = np.ogrid[:roi.shape[0], :roi.shape[1]]
center_mask = (x_coords - center_x)**2 + (y_coords - center_y)**2 <= center_radius**2
# 计算中心区域和周围区域的亮度
center_brightness = gray_roi[center_mask].mean()
outer_mask = ~center_mask
outer_brightness = gray_roi[outer_mask].mean() if np.any(outer_mask) else 0
# 对比度(清晰的点对比度高)
contrast = abs(center_brightness - outer_brightness)
# 6. 方法2:检测点的边缘锐度(使用拉普拉斯)
laplacian = cv2.Laplacian(gray_roi, cv2.CV_64F)
edge_sharpness = abs(laplacian).var()
# 7. 方法3:检测点的能量集中度(方差)
# 清晰的点:能量集中在中心,方差小
# 模糊的点:能量分散,方差大
# 但我们需要的是:清晰的点中心亮度高,周围低,所以梯度大
sobel_x = cv2.Sobel(gray_roi, cv2.CV_64F, 1, 0, ksize=3)
sobel_y = cv2.Sobel(gray_roi, cv2.CV_64F, 0, 1, ksize=3)
gradient = np.sqrt(sobel_x**2 + sobel_y**2)
gradient_sharpness = gradient.var()
# 8. 组合多个指标
# 对比度权重0.3,边缘锐度权重0.4,梯度权重0.3
sharpness_score = (contrast * 0.3 + edge_sharpness * 0.4 + gradient_sharpness * 0.3)
is_sharp = sharpness_score >= threshold
logger = logger_manager.logger
if logger:
logger.debug(f"[VISION] 激光点清晰度: 位置=({x}, {y}), 对比度={contrast:.2f}, 边缘={edge_sharpness:.2f}, 梯度={gradient_sharpness:.2f}, 综合={sharpness_score:.2f}, 是否清晰={is_sharp}")
return is_sharp, sharpness_score, laser_point
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[VISION] 激光点清晰度检测失败: {e}")
import traceback
logger.error(traceback.format_exc())
return False, 0.0, laser_point
def check_image_sharpness(frame, threshold=100.0, save_debug_images=False):
"""
检查图像清晰度(针对圆形靶子优化,基于圆形边缘检测)
检测靶心的圆形边缘,计算边缘区域的梯度清晰度
Args:
frame: 图像帧对象
threshold: 清晰度阈值,低于此值认为图像模糊(默认100.0)
可以根据实际情况调整:
- 清晰图像通常 > 200
- 模糊图像通常 < 100
- 中等清晰度 100-200
save_debug_images: 是否保存调试图像(原始图和边缘图),默认False
Returns:
(is_sharp, sharpness_score): (是否清晰, 清晰度分数)
"""
try:
logger_manager.logger.debug(f"begin")
# 转换为 OpenCV 格式
img_cv = image.image2cv(frame, False, False)
logger_manager.logger.debug(f"after image2cv")
# 转换为 HSV 颜色空间
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv)
logger_manager.logger.debug(f"after HSV conversion")
# 检测黄色区域(靶心)
# 调整饱和度策略:稍微增强,不要过度
s_enhanced = np.clip(s * 1.1, 0, 255).astype(np.uint8)
hsv_enhanced = cv2.merge((h, s_enhanced, v))
# HSV 阈值范围(与 detect_circle_v3 保持一致)
lower_yellow = np.array([7, 80, 0])
upper_yellow = np.array([32, 255, 255])
mask_yellow = cv2.inRange(hsv_enhanced, lower_yellow, upper_yellow)
# 形态学操作,填充小孔洞
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
logger_manager.logger.debug(f"after yellow mask detection")
# 计算边缘区域:扩展黄色区域,然后减去原始区域,得到边缘区域
mask_dilated = cv2.dilate(mask_yellow, kernel, iterations=2)
mask_edge = cv2.subtract(mask_dilated, mask_yellow) # 边缘区域
# 计算边缘区域的像素数量
edge_pixel_count = np.sum(mask_edge > 0)
logger_manager.logger.debug(f"edge pixel count: {edge_pixel_count}")
# 如果检测不到边缘区域,使用全局梯度作为后备方案
if edge_pixel_count < 100:
logger_manager.logger.debug(f"edge region too small, using global gradient")
# 使用 V 通道计算全局梯度
sobel_v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3)
sobel_v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3)
gradient = np.sqrt(sobel_v_x**2 + sobel_v_y**2)
sharpness_score = gradient.var()
logger_manager.logger.debug(f"global gradient variance: {sharpness_score:.2f}")
else:
# 在边缘区域计算梯度清晰度
# 使用 V(亮度)通道计算梯度,因为边缘在亮度上通常很明显
sobel_v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3)
sobel_v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3)
gradient = np.sqrt(sobel_v_x**2 + sobel_v_y**2)
# 只在边缘区域计算清晰度
edge_gradient = gradient[mask_edge > 0]
if len(edge_gradient) > 0:
# 计算边缘梯度的方差(清晰图像的边缘梯度变化大)
sharpness_score = edge_gradient.var()
# 也可以使用均值作为补充指标(清晰图像的边缘梯度均值也较大)
gradient_mean = edge_gradient.mean()
logger_manager.logger.debug(f"edge gradient: mean={gradient_mean:.2f}, var={sharpness_score:.2f}, pixels={len(edge_gradient)}")
else:
# 如果边缘区域没有有效梯度,使用全局梯度
sharpness_score = gradient.var()
logger_manager.logger.debug(f"no edge gradient, using global: {sharpness_score:.2f}")
# 保存调试图像(如果启用)
if save_debug_images:
try:
debug_dir = config.PHOTO_DIR
if debug_dir not in os.listdir("/root"):
try:
os.mkdir(debug_dir)
except:
pass
# 生成文件名
try:
all_images = [f for f in os.listdir(debug_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
img_count = len(all_images)
except:
img_count = 0
# 保存原始图像
img_orig = image.cv2image(img_cv, False, False)
orig_filename = f"{debug_dir}/sharpness_debug_orig_{img_count:04d}.bmp"
img_orig.save(orig_filename)
# # 保存边缘检测结果(可视化)
# # 创建可视化图像:原始图像 + 黄色区域 + 边缘区域
# debug_img = img_cv.copy()
# # 在黄色区域绘制绿色
# debug_img[mask_yellow > 0] = [0, 255, 0] # RGB格式,绿色
# # 在边缘区域绘制红色
# debug_img[mask_edge > 0] = [255, 0, 0] # RGB格式,红色
# debug_img_maix = image.cv2image(debug_img, False, False)
# debug_filename = f"{debug_dir}/sharpness_debug_edge_{img_count:04d}.bmp"
# debug_img_maix.save(debug_filename)
# logger = logger_manager.logger
# if logger:
# logger.info(f"[VISION] 保存调试图像: {orig_filename}, {debug_filename}")
except Exception as e:
logger = logger_manager.logger
if logger:
logger.warning(f"[VISION] 保存调试图像失败: {e}")
import traceback
logger.error(traceback.format_exc())
is_sharp = sharpness_score >= threshold
logger = logger_manager.logger
if logger:
logger.debug(f"[VISION] 清晰度检测: 分数={sharpness_score:.2f}, 边缘像素数={edge_pixel_count}, 是否清晰={is_sharp}, 阈值={threshold}")
return is_sharp, sharpness_score
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[VISION] 清晰度检测失败: {e}")
import traceback
logger.error(traceback.format_exc())
# 出错时返回 False,避免使用模糊图像
return False, 0.0
def save_calibration_image(frame, laser_pos, photo_dir=None):
"""
保存激光校准图像(带标注)
在找到的激光点位置绘制圆圈,便于检查算法是否正确
Args:
frame: 原始图像帧
laser_pos: 找到的激光点坐标 (x, y)
photo_dir: 照片存储目录,如果为None则使用 config.PHOTO_DIR
Returns:
str: 保存的文件路径,如果保存失败则返回 None
"""
# 检查是否启用图像保存
if not config.SAVE_IMAGE_ENABLED:
return None
if photo_dir is None:
photo_dir = config.PHOTO_DIR
try:
# 确保照片目录存在
try:
if photo_dir not in os.listdir("/root"):
os.mkdir(photo_dir)
except:
pass
# 生成文件名
try:
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
img_count = len(all_images)
except:
img_count = 0
x, y = laser_pos
filename = f"{photo_dir}/calibration_{int(x)}_{int(y)}_{img_count:04d}.bmp"
logger = logger_manager.logger
if logger:
logger.info(f"保存校准图像: {filename}, 激光点: ({x}, {y})")
# 转换图像为 OpenCV 格式以便绘制
img_cv = image.image2cv(frame, False, False)
# 绘制激光点圆圈(用绿色圆圈标出找到的激光点)
cv2.circle(img_cv, (int(x), int(y)), 10, (0, 255, 0), 2) # 外圈:绿色,半径10
cv2.circle(img_cv, (int(x), int(y)), 5, (0, 255, 0), 2) # 中圈:绿色,半径5
cv2.circle(img_cv, (int(x), int(y)), 2, (0, 255, 0), -1) # 中心点:绿色实心
# 可选:绘制十字线帮助定位
cv2.line(img_cv,
(int(x - 20), int(y)),
(int(x + 20), int(y)),
(0, 255, 0), 1) # 水平线
cv2.line(img_cv,
(int(x), int(y - 20)),
(int(x), int(y + 20)),
(0, 255, 0), 1) # 垂直线
# 转换回 MaixPy 图像格式并保存
result_img = image.cv2image(img_cv, False, False)
result_img.save(filename)
if logger:
logger.debug(f"校准图像已保存: {filename}")
return filename
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"保存校准图像失败: {e}")
import traceback
logger.error(traceback.format_exc())
return None
def detect_circle_v3(frame, laser_point=None):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
如果提供 laser_point,会选择最接近激光点的目标
Args:
frame: 图像帧
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
Returns:
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
"""
img_cv = image.image2cv(frame, False, False)
best_center = best_radius = best_radius1 = method = None
ellipse_params = None
# HSV 黄色掩码检测(模糊靶心)
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv)
# 调整饱和度策略:稍微增强,不要过度
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
hsv = cv2.merge((h, s, v))
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
# 调整形态学操作
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 存储所有有效的黄色-红色组合
valid_targets = []
if contours_yellow:
for cnt_yellow in contours_yellow:
area = cv2.contourArea(cnt_yellow)
perimeter = cv2.arcLength(cnt_yellow, True)
# 计算圆度
if perimeter > 0:
circularity = (4 * np.pi * area) / (perimeter * perimeter)
else:
circularity = 0
logger = logger_manager.logger
if area > 50 and circularity > 0.7:
if logger:
logger.info(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
# 尝试拟合椭圆
yellow_center = None
yellow_radius = None
yellow_ellipse = None
if len(cnt_yellow) >= 5:
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
yellow_ellipse = ((x, y), (width, height), angle)
axes_minor = min(width, height)
radius = axes_minor / 2
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
else:
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
yellow_ellipse = None
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
if yellow_center and yellow_radius:
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
# 红色范围1: 0-10度(接近0度的红色)
lower_red1 = np.array([0, 80, 0])
upper_red1 = np.array([10, 255, 255])
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
# 红色范围2: 170-180度(接近180度的红色)
lower_red2 = np.array([170, 80, 0])
upper_red2 = np.array([180, 255, 255])
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
# 合并两个红色掩码
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
# 形态学操作
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
found_valid_red = False
if contours_red:
# 找到所有符合条件的红色圆圈
for cnt_red in contours_red:
area_red = cv2.contourArea(cnt_red)
perimeter_red = cv2.arcLength(cnt_red, True)
if perimeter_red > 0:
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
else:
circularity_red = 0
# 红色圆圈也应该有一定的圆度
if area_red > 50 and circularity_red > 0.6:
# 计算红色圆圈的中心和半径
if len(cnt_red) >= 5:
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
radius_red = min(w_red, h_red) / 2
red_center = (int(x_red), int(y_red))
red_radius = int(radius_red)
else:
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
red_center = (int(x_red), int(y_red))
red_radius = int(radius_red)
# 计算黄色和红色圆心的距离
if red_center:
dx = yellow_center[0] - red_center[0]
dy = yellow_center[1] - red_center[1]
distance = np.sqrt(dx*dx + dy*dy)
# 圆心距离阈值:应该小于黄色半径的某个倍数(比如1.5倍)
max_distance = yellow_radius * 1.5
# 红色圆圈应该比黄色圆圈大(外圈)
if distance < max_distance and red_radius > yellow_radius * 0.8:
found_valid_red = True
logger = logger_manager.logger
if logger:
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
# 记录这个有效目标
valid_targets.append({
'center': yellow_center,
'radius': yellow_radius,
'ellipse': yellow_ellipse,
'area': area
})
break
if not found_valid_red:
logger = logger_manager.logger
if logger:
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
# 从所有有效目标中选择最佳目标
if valid_targets:
if laser_point:
# 如果有激光点,选择最接近激光点的目标
best_target = None
min_distance = float('inf')
for target in valid_targets:
dx = target['center'][0] - laser_point[0]
dy = target['center'][1] - laser_point[1]
distance = np.sqrt(dx*dx + dy*dy)
if distance < min_distance:
min_distance = distance
best_target = target
if best_target:
best_center = best_target['center']
best_radius = best_target['radius']
ellipse_params = best_target['ellipse']
method = "v3_ellipse_red_validated_laser_selected"
best_radius1 = best_radius * 5
else:
# 如果没有激光点,选择面积最大的目标
best_target = max(valid_targets, key=lambda t: t['area'])
best_center = best_target['center']
best_radius = best_target['radius']
ellipse_params = best_target['ellipse']
method = "v3_ellipse_red_validated"
best_radius1 = best_radius * 5
result_img = image.cv2image(img_cv, False, False)
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
def estimate_distance(pixel_radius):
"""根据像素半径估算实际距离(单位:米)"""
if not pixel_radius:
return 0.0
return (config.REAL_RADIUS_CM * config.FOCAL_LENGTH_PIX) / pixel_radius / 100.0
def estimate_pixel(physical_distance_cm, target_distance_m):
"""
根据物理距离和目标距离计算对应的像素偏移
Args:
physical_distance_cm: 物理世界中的距离(厘米),例如激光与摄像头的距离
target_distance_m: 目标距离(米),例如到靶心的距离
Returns:
float: 对应的像素偏移
"""
if not target_distance_m or target_distance_m <= 0:
return 0.0
# 公式:像素偏移 = (物理距离_米) * 焦距_像素 / 目标距离_米
return (physical_distance_cm / 100.0) * config.FOCAL_LENGTH_PIX / target_distance_m
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None):
"""
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
由 save_shot_image(同步)和存图 worker(异步)调用。
"""
if not config.SAVE_IMAGE_ENABLED:
return None
if photo_dir is None:
photo_dir = config.PHOTO_DIR
try:
try:
if photo_dir not in os.listdir("/root"):
os.mkdir(photo_dir)
except Exception:
pass
x, y = laser_point
if shot_id:
if center is None or radius is None:
filename = f"{photo_dir}/shot_{shot_id}_no_target.bmp"
else:
method_str = method or "unknown"
filename = f"{photo_dir}/shot_{shot_id}_{method_str}.bmp"
else:
try:
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
img_count = len(all_images)
except Exception:
img_count = 0
if center is None or radius is None:
method_str = "no_target"
distance_str = "000"
else:
method_str = method or "unknown"
distance_str = str(round((distance_m or 0.0) * 100))
filename = f"{photo_dir}/{method_str}_{int(x)}_{int(y)}_{distance_str}_{img_count:04d}.bmp"
logger = logger_manager.logger
if logger:
if shot_id:
logger.info(f"[VISION] 保存射箭图像,ID: {shot_id}, 文件名: {filename}")
if center and radius:
logger.info(f"结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
if ellipse_params:
(ec, (ew, eh), ea) = ellipse_params
logger.info(f"椭圆 -> 中心: ({ec[0]:.1f}, {ec[1]:.1f}), 长轴: {max(ew, eh):.1f}, 短轴: {min(ew, eh):.1f}, 角度: {ea:.1f}°")
else:
logger.info(f"结果 -> 未检测到靶心,保存原始图像(激光点: ({x}, {y})")
laser_color = (config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
cross_thickness = int(max(getattr(config, "LASER_THICKNESS", 1), 1))
cross_length = int(max(getattr(config, "LASER_LENGTH", 10), 10))
cv2.line(img_cv, (int(x - cross_length), int(y)), (int(x + cross_length), int(y)), laser_color, cross_thickness)
cv2.line(img_cv, (int(x), int(y - cross_length)), (int(x), int(y + cross_length)), laser_color, cross_thickness)
cv2.circle(img_cv, (int(x), int(y)), 1, laser_color, cross_thickness)
ring_thickness = 1
cv2.circle(img_cv, (int(x), int(y)), 10, laser_color, ring_thickness)
cv2.circle(img_cv, (int(x), int(y)), 5, laser_color, ring_thickness)
cv2.circle(img_cv, (int(x), int(y)), 2, laser_color, -1)
if center and radius:
cx, cy = center
if ellipse_params:
(ell_center, (width, height), angle) = ellipse_params
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
cv2.ellipse(img_cv, (cx_ell, cy_ell), (int(width / 2), int(height / 2)), angle, 0, 360, (0, 255, 0), 2)
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
minor_length = min(width, height) / 2
minor_angle = angle + 90 if width >= height else angle
minor_angle_rad = math.radians(minor_angle)
dx_minor = minor_length * math.cos(minor_angle_rad)
dy_minor = minor_length * math.sin(minor_angle_rad)
pt1 = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
pt2 = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
cv2.line(img_cv, pt1, pt2, (0, 0, 255), 2)
else:
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
cv2.line(img_cv, (int(x), int(y)), (cx, cy), (255, 255, 0), 1)
out = image.cv2image(img_cv, False, False)
out.save(filename)
if logger:
if center and radius:
logger.debug(f"图像已保存(含靶心标注): {filename}")
else:
logger.debug(f"图像已保存(无靶心,含激光十字线): {filename}")
# 清理旧图片:如果目录下图片超过100张,删除最老的
try:
image_files = []
for f in os.listdir(photo_dir):
if f.endswith(('.bmp', '.jpg', '.jpeg')):
filepath = os.path.join(photo_dir, f)
try:
mtime = os.path.getmtime(filepath)
image_files.append((mtime, filepath, f))
except Exception:
pass
from config import MAX_IMAGES
if len(image_files) > MAX_IMAGES:
image_files.sort(key=lambda x: x[0])
to_delete = len(image_files) - MAX_IMAGES
deleted_count = 0
for _, filepath, fname in image_files[:to_delete]:
try:
os.remove(filepath)
deleted_count += 1
if logger:
logger.debug(f"[VISION] 删除旧图片: {fname}")
except Exception as e:
if logger:
logger.warning(f"[VISION] 删除旧图片失败 {fname}: {e}")
if logger and deleted_count > 0:
logger.info(f"[VISION] 已清理 {deleted_count} 张旧图片,当前剩余 {MAX_IMAGES} 张")
except Exception as e:
if logger:
logger.warning(f"[VISION] 清理旧图片时出错(可忽略): {e}")
return filename
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"保存图像失败: {e}")
import traceback
logger.error(traceback.format_exc())
return None
def _save_worker_loop():
"""存图 worker:从队列取任务并调用 _save_shot_image_impl。"""
while True:
try:
item = _save_queue.get()
if item is None:
break
_save_shot_image_impl(*item)
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[VISION] 存图 worker 异常: {e}")
import traceback
logger.error(traceback.format_exc())
finally:
try:
_save_queue.task_done()
except Exception:
pass
def start_save_shot_worker():
"""启动存图 worker 线程(应在程序初始化时调用一次)。"""
global _save_worker_started
with _save_worker_lock:
if _save_worker_started:
return
_save_worker_started = True
t = threading.Thread(target=_save_worker_loop, daemon=True)
t.start()
logger = logger_manager.logger
if logger:
logger.info("[VISION] 存图 worker 线程已启动")
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None):
"""
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
"""
if not config.SAVE_IMAGE_ENABLED:
return
if photo_dir is None:
photo_dir = config.PHOTO_DIR
try:
img_cv = image.image2cv(result_img, False, False)
img_copy = np.copy(img_cv)
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[VISION] enqueue_save_shot 复制图像失败: {e}")
return
task = (img_copy, center, radius, method, ellipse_params, laser_point, distance_m, shot_id, photo_dir)
try:
_save_queue.put_nowait(task)
except queue.Full:
logger = logger_manager.logger
if logger:
logger.warning("[VISION] 存图队列已满,跳过本次保存")
def save_shot_image(result_img, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None):
"""
保存射击图像(带标注)。同步调用,会阻塞。
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
"""
if not config.SAVE_IMAGE_ENABLED:
return None
if photo_dir is None:
photo_dir = config.PHOTO_DIR
try:
img_cv = image.image2cv(result_img, False, False)
return _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id, photo_dir)
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[VISION] save_shot_image 转换图像失败: {e}")
return None
def detect_target(frame, laser_point=None):
"""
统一的靶心检测接口,根据配置自动选择检测方法
Args:
frame: MaixPy图像帧
laser_point: 激光点坐标(可选)
Returns:
(result_img, center, radius, method, best_radius1, ellipse_params)
与detect_circle_v3保持相同的返回格式
"""
logger = logger_manager.logger
if config.USE_ARUCO:
# 使用ArUco检测
if logger:
logger.debug("[VISION] 使用ArUco标记检测靶心")
# 延迟导入以避免循环依赖
from aruco_detector import detect_target_with_aruco
return detect_target_with_aruco(frame, laser_point)
else:
# 使用传统黄色靶心检测
if logger:
logger.debug("[VISION] 使用传统黄色靶心检测")
return detect_circle_v3(frame, laser_point)
+658
View File
@@ -0,0 +1,658 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WiFi管理模块
提供WiFi连接、网络检测、质量监测等功能
"""
import os
import re
import socket
import threading
import time as std_time
from maix import time
import config
from logger_manager import logger_manager
class WiFiManager:
"""WiFi管理器(单例)"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(WiFiManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# WiFi 相关状态
self._wifi_connected = False
self._wifi_ip = None
self._wifi_socket = None
self._wifi_socket_lock = threading.Lock()
self._prefer_wifi = True # 是否优先使用 WiFi
self._recv_buffer = b"" # TCP 接收缓冲区
# WiFi 质量监测(后台线程)
self._wifi_quality_monitor_thread = None
self._wifi_quality_stop_event = threading.Event()
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
# 服务器相关(用于网络检测)
try:
import archery_netcore as _netcore
self._server_ip = _netcore.get_config().get("SERVER_IP")
self._server_port = _netcore.get_config().get("SERVER_PORT")
except Exception:
self._server_ip = getattr(config, "SERVER_IP", None)
self._server_port = getattr(config, "SERVER_PORT", None)
self._initialized = True
@property
def logger(self):
"""获取 logger 对象"""
return logger_manager.logger
@property
def wifi_connected(self):
"""WiFi是否已连接"""
return self._wifi_connected
@property
def wifi_ip(self):
"""WiFi IP地址"""
return self._wifi_ip
@property
def wifi_socket(self):
"""WiFi socket对象"""
return self._wifi_socket
@wifi_socket.setter
def wifi_socket(self, value):
"""设置WiFi socket对象"""
self._wifi_socket = value
@property
def wifi_socket_lock(self):
"""获取WiFi socket锁"""
return self._wifi_socket_lock
@property
def prefer_wifi(self):
"""是否优先使用WiFi"""
return self._prefer_wifi
@prefer_wifi.setter
def prefer_wifi(self, value):
"""设置是否优先使用WiFi"""
self._prefer_wifi = value
@property
def last_wifi_rtt_ms(self):
"""最近一次测量的RTT"""
return self._last_wifi_rtt_ms
@property
def last_wifi_rssi_dbm(self):
"""最近一次测量的RSSI"""
return self._last_wifi_rssi_dbm
@property
def recv_buffer(self):
"""TCP接收缓冲区"""
return self._recv_buffer
@recv_buffer.setter
def recv_buffer(self, value):
"""设置TCP接收缓冲区"""
self._recv_buffer = value
# ==================== WiFi 连接方法 ====================
def is_sta_associated(self):
"""
是否作为 STA 已关联到上游 AP(用于与 AP 模式区分:AP 模式下 wlan0 可能有 IP 但 iw link 为 Not connected)。
"""
try:
out = os.popen("iw dev wlan0 link 2>/dev/null").read()
if not out.strip():
return False
if "Not connected" in out:
return False
return "Connected to" in out
except Exception:
return False
def is_wifi_connected(self):
"""检查WiFi是否已连接"""
# AP 模式下 wlan0 也可能有 IP(如 192.168.66.1),但这不代表已作为 STA 连上路由器。
# 业务侧(选网/TCP)只应在 STA 已关联到上游 AP 时认为 WiFi 可用。
if not self.is_sta_associated():
self._wifi_connected = False
return False
# 优先用 MaixPy network(如果可用)
try:
from maix import network
wifi = network.wifi.Wifi()
if wifi.is_connected():
self._wifi_connected = True
return True
except:
self.logger.warning("Failed to check WiFi connection using MaixPy network", exc_info=True)
# 兜底:看系统 wlan0 有没有 IP
try:
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
if ip:
self._wifi_connected = True
self._wifi_ip = ip
return True
except:
self.logger.warning("Failed to check WiFi connection using system command", exc_info=True)
self._wifi_connected = False
return False
def connect_wifi(self, ssid, password, verify_callback=None, persist=True, timeout_s=20):
"""
连接 Wi-Fi(先用新凭证尝试连接并验证可用性;失败自动回滚;成功后再决定是否落盘)
重要:系统的 /etc/init.d/S30wifi 通常会读取 /boot/wifi.ssid 与 /boot/wifi.pass 来连接 WiFi。
因此要"真正尝试连接新 WiFi",必须临时写入 /boot/ 触发重启;若失败则把旧值写回去(回滚)。
Args:
ssid: WiFi SSID
password: WiFi密码
verify_callback: 验证回调函数,接收 (ip) 参数,返回 (success: bool, error: str)
persist: 是否持久化保存凭证
timeout_s: 连接超时时间(秒)
Returns:
(ip, error): IP地址和错误信息(成功时error为None)
"""
# 配置文件路径定义
conf_path = "/etc/wpa_supplicant.conf"
ssid_file = "/boot/wifi.ssid"
pass_file = "/boot/wifi.pass"
def _read_text(path: str):
try:
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return None
return None
def _write_text(path: str, content: str):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def _restore_boot(old_ssid: str | None, old_pass: str | None):
# 还原 /boot 凭证:原来没有就删除,原来有就写回
try:
if old_ssid is None:
if os.path.exists(ssid_file):
os.remove(ssid_file)
else:
_write_text(ssid_file, old_ssid)
except Exception:
pass
try:
if old_pass is None:
if os.path.exists(pass_file):
os.remove(pass_file)
else:
_write_text(pass_file, old_pass)
except Exception:
pass
old_conf = _read_text(conf_path)
old_boot_ssid = _read_text(ssid_file)
old_boot_pass = _read_text(pass_file)
try:
# 生成 wpa_supplicant 配置(写 /etc 作为辅助,具体是否生效取决于 S30wifi 脚本)
net_conf = os.popen(f'wpa_passphrase "{ssid}" "{password}"').read()
if "network={" not in net_conf:
raise RuntimeError("Failed to generate wpa config")
try:
_write_text(
conf_path,
"ctrl_interface=/var/run/wpa_supplicant\n"
"update_config=1\n\n"
+ net_conf,
)
except Exception:
# 不强制要求写 /etc 成功(某些系统只用 /boot)
pass
# ====== 临时写入 /boot 凭证,触发 WiFi 服务真正尝试连接新 SSID ======
_write_text(ssid_file, ssid.strip())
_write_text(pass_file, password.strip())
# 重启 Wi-Fi 服务
os.system("/etc/init.d/S30wifi restart")
# 等待获取 IP
wait_s = int(timeout_s) if timeout_s and timeout_s > 0 else 20
wait_s = min(max(wait_s, 5), 60)
for _ in range(wait_s):
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
if ip:
# 拿到 IP 不代表可上网/可访问目标;继续做可达性验证
self._wifi_connected = True
self._wifi_ip = ip
self.logger.info(f"[WIFI] 已连接,IP: {ip},开始验证网络可用性...")
# 验证能访问指定目标(通过回调函数)
if verify_callback:
success, error = verify_callback(ip)
if not success:
raise RuntimeError(error or "Verification failed")
# ====== 验证通过 ======
if not persist:
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
_restore_boot(old_boot_ssid, old_boot_pass)
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
else:
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
return ip, None
std_time.sleep(1)
raise RuntimeError("Timeout: No IP obtained")
except Exception as e:
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
_restore_boot(old_boot_ssid, old_boot_pass)
try:
if old_conf is not None:
_write_text(conf_path, old_conf)
except Exception:
pass
try:
os.system("/etc/init.d/S30wifi restart")
except Exception:
pass
self._wifi_connected = False
self._wifi_ip = None
self.logger.error(f"[WIFI] 连接/验证失败,已回滚: {e}")
return None, str(e)
def persist_sta_credentials(self, ssid: str, password: str, restart_service: bool = True):
"""
仅写入 STA 凭证(/etc/wpa_supplicant.conf + /boot/wifi.ssid|pass),
可选是否立即 /etc/init.d/S30wifi restart。
不做可达性验证。用于热点配网页提交后切换到连接指定路由器。
password 为空时按开放网络(key_mgmt=NONE)写入。
Returns:
(ok: bool, err_msg: str)
"""
ssid = (ssid or "").strip()
password = (password or "").strip()
if not ssid:
return False, "SSID 为空"
conf_path = "/etc/wpa_supplicant.conf"
ssid_file = "/boot/wifi.ssid"
pass_file = "/boot/wifi.pass"
def _write_text(path: str, content: str):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
try:
if password:
net_conf = os.popen(f'wpa_passphrase "{ssid}" "{password}"').read()
if "network={" not in net_conf:
return False, "wpa_passphrase 失败"
else:
esc = ssid.replace("\\", "\\\\").replace('"', '\\"')
net_conf = (
"network={\n"
f' ssid="{esc}"\n'
" key_mgmt=NONE\n"
"}\n"
)
_write_text(
conf_path,
"ctrl_interface=/var/run/wpa_supplicant\n"
"update_config=1\n\n"
+ net_conf,
)
except Exception as e:
return False, str(e)
try:
_write_text(ssid_file, ssid)
_write_text(pass_file, password)
except Exception as e:
return False, str(e)
if restart_service:
try:
os.system("/etc/init.d/S30wifi restart")
except Exception as e:
return False, str(e)
self.logger.info(f"[WIFI] persist_sta_credentials: 已写入并重启 S30wifi, ssid={ssid!r}")
else:
self.logger.info(f"[WIFI] persist_sta_credentials: 已写入凭证(未重启 S30wifi, ssid={ssid!r}")
return True, ""
def disconnect_wifi(self):
"""断开WiFi连接并清理资源"""
if self._wifi_socket:
try:
self._wifi_socket.close()
except Exception:
pass
finally:
self._wifi_socket = None
self._wifi_connected = False
self._wifi_ip = None
# ==================== WiFi 质量监测 ====================
def _get_wifi_rssi_dbm(self):
"""
获取 WiFi 信号强度(dBm,越大越好;比如 -40 比 -80 好)
由于不同固件实现差异,这里做多策略兜底,失败返回 None
"""
# 1) 优先使用:iw dev wlan0 link
# 你提供的输出示例包含:signal: -58 dBm
try:
out = os.popen("iw dev wlan0 link 2>/dev/null").read()
if out:
m = re.search(r"signal:\s*(-?\d+(?:\.\d+)?)\s*dBm", out, re.IGNORECASE)
if m:
v = float(m.group(1))
# 合理范围兜底
if -120.0 <= v <= 0.0:
return v
m2 = re.search(r"signal:\s*(-?\d+(?:\.\d+)?)", out, re.IGNORECASE)
if m2:
v = float(m2.group(1))
if -120.0 <= v <= 0.0:
return v
except Exception:
pass
# 2) 兜底:iwconfig
try:
out = os.popen("iwconfig wlan0 2>/dev/null").read()
m = re.search(r"Signal level[=:]\s*(-?\d+(?:\.\d+)?)\s*dBm", out, re.IGNORECASE)
if m:
v = float(m.group(1))
if -120.0 <= v <= 0.0:
return v
m2 = re.search(r"Signal level[=:]\s*(-?\d+(?:\.\d+)?)", out, re.IGNORECASE)
if m2:
v = float(m2.group(1))
if -120.0 <= v <= 0.0:
return v
except Exception:
pass
return None
def _measure_wifi_tcp_rtt_ms(self, host, port, samples=3, per_sample_timeout_ms=900):
"""
测量:在当前 WiFi 下,TCP 建连耗时(RTT 的近似)
Args:
host: 目标主机
port: 目标端口
samples: 采样次数
per_sample_timeout_ms: 每次采样超时时间(毫秒)
Returns:
(median_rtt_ms, reachable_bool)
"""
rtts = []
reachable = False
addr = None
# 先解析一次地址,避免每次样本都做 DNS
try:
addr_info = socket.getaddrinfo(host, port)[0]
addr = (addr_info[0], addr_info[1], addr_info[2], addr_info[-1])
except Exception:
return float("inf"), False
for _ in range(max(1, int(samples or 1))):
s = None
try:
s = socket.socket(addr[0], addr[1], addr[2])
s.settimeout(max(0.1, float(per_sample_timeout_ms) / 1000.0))
t0 = time.ticks_ms()
s.connect(addr[-1])
elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), t0))
rtts.append(float(elapsed_ms))
reachable = True
except Exception:
# 单个样本失败不影响整体,只要有成功样本就继续
pass
finally:
try:
if s:
s.close()
except Exception:
pass
# 小间隔,避免过度占用
try:
time.sleep_ms(100)
except Exception:
pass
if not rtts:
return float("inf"), False
rtts_sorted = sorted(rtts)
mid = len(rtts_sorted) // 2
if len(rtts_sorted) % 2 == 1:
median = rtts_sorted[mid]
else:
median = (rtts_sorted[mid - 1] + rtts_sorted[mid]) / 2.0
return median, reachable
def _is_wifi_quality_bad(self, wifi_rtt_ms, wifi_rssi_dbm):
"""
综合判断 WiFi 质量是否差:
- RTT中位数超过阈值 -> bad
- 若启用 RSSI:信号弱(RSSI更差于阈值) 且 RTT 也偏高 -> bad
"""
if wifi_rtt_ms >= config.WIFI_QUALITY_RTT_BAD_MS:
return True
if not getattr(config, "WIFI_QUALITY_USE_RSSI", False):
return False
if wifi_rssi_dbm is None:
return False
# "rtt_warn + rssi_bad" 联合条件
if wifi_rtt_ms >= config.WIFI_QUALITY_RTT_WARN_MS and wifi_rssi_dbm <= config.WIFI_QUALITY_RSSI_BAD_DBM:
return True
return False
def get_wifi_quality_status(self):
"""
获取当前 WiFi 质量状态(用于调试或显示)
Returns:
dict: {"rtt_ms": float, "rssi_dbm": float, "is_bad": bool}
"""
rtt = self._last_wifi_rtt_ms
rssi = self._last_wifi_rssi_dbm
is_bad = False
if rtt is not None and rtt != float("inf"):
is_bad = self._is_wifi_quality_bad(rtt, rssi)
return {
"rtt_ms": rtt if rtt is not None and rtt != float("inf") else None,
"rssi_dbm": rssi,
"is_bad": is_bad
}
# ==================== 后台质量监测线程 ====================
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
"""
启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI)
只在 WiFi 连接时运行,不影响业务发送性能
Args:
network_type_callback: 获取当前网络类型的回调函数
on_poor_quality_callback: WiFi质量差时的回调函数
"""
if self._wifi_quality_monitor_thread is not None:
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
return
self._network_type_callback = network_type_callback
self._on_poor_quality_callback = on_poor_quality_callback
self._wifi_quality_stop_event.clear()
self._wifi_quality_monitor_thread = threading.Thread(
target=self._quality_monitor_loop,
daemon=True,
name="wifi_quality_monitor"
)
self._wifi_quality_monitor_thread.start()
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
def stop_quality_monitor(self):
"""停止 WiFi 质量监测线程"""
if self._wifi_quality_monitor_thread is None:
return
self._wifi_quality_stop_event.set()
try:
self._wifi_quality_monitor_thread.join(timeout=2.0)
except Exception as e:
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
finally:
self._wifi_quality_monitor_thread = None
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
def _quality_monitor_loop(self):
"""
WiFi 质量监测循环(后台线程)
每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
"""
while not self._wifi_quality_stop_event.is_set():
try:
# 只在 WiFi 连接时才测量
network_type = self._network_type_callback()
if network_type == "wifi" and self._wifi_socket:
# # 测量 RTT(1 个样本,快速测量)
# rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
# self._server_ip, self._server_port,
# samples=1, per_sample_timeout_ms=600
# )
# 获取 RSSI
rssi_dbm = self._get_wifi_rssi_dbm()
# 更新缓存
# 不使用 RTT 测量
rtt_ms = 0
reachable = True
self._last_wifi_rtt_ms = rtt_ms if reachable else None
self._last_wifi_rssi_dbm = rssi_dbm
self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={rssi_dbm:.0f}dBm")
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
def _is_bad_now(_reachable, _rtt, _rssi):
if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
return True
return self._is_wifi_quality_bad(_rtt, _rssi)
bad = _is_bad_now(reachable, rtt_ms, rssi_dbm)
if bad:
self.logger.warning("[WiFi Monitor] 质量差,切换前快速重试 2 次(每次间隔1秒)")
for retry_idx in range(2):
time.sleep_ms(1000)
# 不使用 RTT 测量
rtt2 = 0
reachable2 = True
# rtt2, reachable2 = self._measure_wifi_tcp_rtt_ms(
# self._server_ip, self._server_port,
# samples=1, per_sample_timeout_ms=600
# )
rssi2 = self._get_wifi_rssi_dbm()
# 更新缓存,便于外部查看最新状态
self._last_wifi_rtt_ms = rtt2 if reachable2 else None
self._last_wifi_rssi_dbm = rssi2
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
try:
self.logger.info(
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
f"rtt={rtt2 if rtt2 != float('inf') else -1:.0f}ms, rssi={rssi2}, bad={bad2}"
)
except Exception:
pass
if not bad2:
self.logger.info("[WiFi Monitor] 复测恢复正常,继续保留 WiFi(不切换)")
bad = False
break
if bad:
self.logger.warning("[WiFi Monitor] 复测仍差/不通,尝试切换到 4G")
self._on_poor_quality_callback()
# 休眠 5 秒
time.sleep(5)
except Exception as e:
self.logger.error(f"[WiFi Monitor] 监测异常:{e}")
# 异常后继续循环,避免线程退出
continue
# 全局 WiFi 管理器实例
wifi_manager = WiFiManager()
# ==================== 兼容旧接口的函数 ====================
def is_wifi_connected():
"""尽量判断当前是否有 Wi-Fi(有则走 Wi-Fi OTA,否则走 4G OTA"""
return wifi_manager.is_wifi_connected()
def connect_wifi(ssid, password, verify_callback=None, persist=True, timeout_s=20):
"""
连接 Wi-Fi 并将凭证持久化保存到 /boot/ 目录,
以便设备重启后自动连接。
Args:
ssid: WiFi SSID
password: WiFi密码
verify_callback: 验证回调函数,接收 (ip) 参数,返回 (success: bool, error: str)
persist: 是否持久化保存
timeout_s: 超时时间(秒)
Returns:
(ip, error): IP地址和错误信息(成功时error为None)
"""
return wifi_manager.connect_wifi(ssid, password, verify_callback, persist, timeout_s)
+497
View File
@@ -0,0 +1,497 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WiFi 热点配网:迷你 HTTP 服务器(仅 GET/POST,标准库 socket),独立线程运行。
策略(与 /etc/init.d/S30wifi 一致):
- 仅当 STA 未连上 WiFi 且 4G 也不可用时,写入 /boot/wifi.ap、去掉 /boot/wifi.sta
并重启 S30wifi 由系统起热点;再在本进程起 HTTP。
- 用户 POST 提交路由器 SSID/密码后:仅写凭证、stop S30wifi、删 /boot/wifi.ap、建 /boot/wifi.sta、sync、reboot。
"""
import html
import os
import socket
import threading
import time as std_time
from urllib.parse import parse_qs
import config
from logger_manager import logger_manager
from wifi import wifi_manager
_http_thread = None
_http_stop = threading.Event()
def _http_response(status, body_bytes, content_type="text/html; charset=utf-8"):
head = (
f"HTTP/1.1 {status}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body_bytes)}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode("utf-8")
return head + body_bytes
def _read_http_request(conn, max_total=65536):
"""返回 (method, path, headers_str, body_bytes) 或 None。"""
buf = b""
while b"\r\n\r\n" not in buf and len(buf) < max_total:
chunk = conn.recv(4096)
if not chunk:
break
buf += chunk
if b"\r\n\r\n" not in buf:
return None
idx = buf.index(b"\r\n\r\n")
header_bytes = buf[:idx]
rest = buf[idx + 4 :]
try:
headers_str = header_bytes.decode("utf-8", errors="replace")
except Exception:
headers_str = ""
lines = headers_str.split("\r\n")
if not lines:
return None
parts = lines[0].split()
method = parts[0] if parts else "GET"
path = parts[1] if len(parts) > 1 else "/"
content_length = 0
for line in lines[1:]:
if line.lower().startswith("content-length:"):
try:
content_length = int(line.split(":", 1)[1].strip())
except Exception:
content_length = 0
break
body = rest
while content_length > 0 and len(body) < content_length and len(body) < max_total:
chunk = conn.recv(4096)
if not chunk:
break
body += chunk
body = body[:content_length]
return method, path, headers_str, body
def _page_form(msg_html=""):
# 页面展示的热点名:以 /boot/wifi.ssid 为准(与实际 AP 保持一致)
try:
if os.path.exists("/boot/wifi.ssid"):
with open("/boot/wifi.ssid", "r", encoding="utf-8") as f:
_ssid = f.read().strip()
else:
_ssid = ""
except Exception:
_ssid = ""
ap_ssid = html.escape(_ssid or getattr(config, "WIFI_CONFIG_AP_SSID", "ArcherySetup"))
port = int(getattr(config, "WIFI_CONFIG_HTTP_PORT", 8080))
ap_ip = html.escape(getattr(config, "WIFI_CONFIG_AP_IP", "192.168.66.1"))
body = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>WiFi 配网</title></head><body>
<h1>WiFi 配网</h1>
<p>热点:<b>{ap_ssid}</b> · 端口 <b>{port}</b></p>
<p>请填写要连接的<b>路由器</b> SSID 与密码(用于 STA 上网,不是热点密码)。提交后将关闭热点、保存并<b>重启设备</b>。</p>
{msg_html}
<form method="POST" action="/" accept-charset="utf-8">
<p>SSID<br/><input name="ssid" type="text" style="width:100%;max-width:320px" required/></p>
<p>密码(开放网络可留空)<br/><input name="password" type="password" style="width:100%;max-width:320px"/></p>
<p><button type="submit">保存并重启</button></p>
</form>
<p style="color:#666;font-size:12px">提示:提交后设备会重启;请手机改连路由器 WiFi。</p>
</body></html>"""
return body.encode("utf-8")
def _apply_sta_and_reboot(router_ssid: str, router_password: str):
"""
写路由器 STA 凭证 -> 停 WiFi 服务 -> 删 /boot/wifi.ap -> 建 /boot/wifi.sta -> sync -> reboot
"""
logger = logger_manager.logger
ok, err = wifi_manager.persist_sta_credentials(router_ssid, router_password, restart_service=False)
if not ok:
return False, err
try:
os.system("/etc/init.d/S30wifi stop")
except Exception as e:
logger.warning(f"[WIFI-AP] S30wifi stop: {e}")
ap_flag = "/boot/wifi.ap"
sta_flag = "/boot/wifi.sta"
try:
if os.path.exists(ap_flag):
os.remove(ap_flag)
except Exception as e:
return False, f"删除 {ap_flag} 失败: {e}"
try:
with open(sta_flag, "w", encoding="utf-8") as f:
f.write("")
except Exception as e:
return False, f"创建 {sta_flag} 失败: {e}"
try:
os.system("sync")
except Exception:
pass
logger.info("[WIFI-AP] 已切换为 STA 标志并准备 reboot")
try:
os.system("reboot")
except Exception as e:
return False, f"reboot 调用失败: {e}"
return True, ""
def _handle_client(conn, addr):
logger = logger_manager.logger
try:
conn.settimeout(30.0)
req = _read_http_request(conn)
if not req:
conn.sendall(_http_response("400 Bad Request", b"Bad Request"))
return
method, path, _headers, body = req
path = path.split("?", 1)[0]
if method == "GET" and path in ("/", "/index.html"):
conn.sendall(_http_response("200 OK", _page_form()))
return
if method == "POST" and path in ("/", "/index.html"):
try:
qs = body.decode("utf-8", errors="replace")
except Exception:
qs = ""
fields = parse_qs(qs, keep_blank_values=True)
ssid = (fields.get("ssid") or [""])[0].strip()
password = (fields.get("password") or [""])[0]
ok, err = _apply_sta_and_reboot(ssid, password)
if ok:
msg = '<p style="color:green"><b>已保存,设备正在重启…</b></p>'
else:
msg = f'<p style="color:red"><b>失败:</b>{html.escape(err)}</p>'
conn.sendall(_http_response("200 OK", _page_form(msg)))
return
if method == "GET" and path == "/favicon.ico":
conn.sendall(_http_response("204 No Content", b""))
return
conn.sendall(_http_response("404 Not Found", b"Not Found"))
except Exception as e:
try:
logger.error(f"[WIFI-HTTP] 处理请求异常 {addr}: {e}")
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
def _serve_loop(host, port):
logger = logger_manager.logger
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((host, port))
srv.listen(5)
srv.settimeout(1.0)
logger.info(f"[WIFI-HTTP] 监听 {host}:{port}")
except Exception as e:
logger.error(f"[WIFI-HTTP] bind 失败: {e}")
try:
srv.close()
except Exception:
pass
return
while not _http_stop.is_set():
try:
conn, addr = srv.accept()
except socket.timeout:
continue
except Exception as e:
if _http_stop.is_set():
break
logger.warning(f"[WIFI-HTTP] accept: {e}")
continue
t = threading.Thread(target=_handle_client, args=(conn, addr), daemon=True)
t.start()
try:
srv.close()
except Exception:
pass
logger.info("[WIFI-HTTP] 服务已停止")
def _ensure_hostapd_ssid(ssid: str, logger=None) -> bool:
"""
某些固件会把 SSID 写到 /etc/hostapd.conf 或 /boot/hostapd.conf。
为避免只改 /boot/wifi.ssid 不生效,这里同步更新已存在的 hostapd.conf。
Returns:
bool: 任一文件被修改则 True
"""
if logger is None:
logger = logger_manager.logger
if not ssid:
return False
changed_any = False
for conf_path in ("/etc/hostapd.conf", "/boot/hostapd.conf"):
try:
if not os.path.exists(conf_path):
continue
with open(conf_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
except Exception:
continue
changed = False
out = []
seen = False
for ln in lines:
s = ln.strip()
if s.lower().startswith("ssid="):
seen = True
cur = s.split("=", 1)[1].strip()
if cur != ssid:
out.append(f"ssid={ssid}")
changed = True
else:
out.append(ln)
else:
out.append(ln)
if not seen:
out.append(f"ssid={ssid}")
changed = True
if changed:
try:
with open(conf_path, "w", encoding="utf-8") as f:
f.write("\n".join(out).rstrip() + "\n")
changed_any = True
except Exception as e:
if logger:
logger.warning(f"[WIFI-AP] 写入 {conf_path} 失败: {e}")
if changed_any and logger:
logger.info(f"[WIFI-AP] 已同步热点 SSID 到 hostapd.conf: {ssid}")
return changed_any
def _write_boot_ap_credentials_for_s30wifi():
"""供 S30wifi AP 分支 gen_hostapd 使用的热点 SSID/密码。"""
base = (getattr(config, "WIFI_CONFIG_AP_SSID", "ArcherySetup") or "ArcherySetup").strip()
# 追加设备码,便于区分多台设备(读取 /device_key,失败则不加后缀)
suffix = ""
try:
with open("/device_key", "r", encoding="utf-8") as f:
dev = (f.read() or "").strip()
if dev:
s = dev
# 只保留字母数字,避免 SSID 出现不可见字符
s = "".join([c for c in s if c.isalnum()])
if s:
suffix = s
except Exception:
suffix = ""
ssid = f"{base}_{suffix}" if suffix else base
pwd = getattr(config, "WIFI_CONFIG_AP_PASSWORD", "12345678")
with open("/boot/wifi.ssid", "w", encoding="utf-8") as f:
f.write(ssid.strip())
with open("/boot/wifi.pass", "w", encoding="utf-8") as f:
f.write(pwd.strip())
try:
_ensure_hostapd_ssid(ssid.strip())
except Exception:
pass
def _ensure_hostapd_modern_security(logger=None) -> bool:
"""
确保 AP 使用较新的安全标准(至少 WPA2-PSK + CCMP)。
你现场验证需要的两行:
- wpa_key_mgmt=WPA-PSK
- rsn_pairwise=CCMP
Returns:
bool: 若文件被修改返回 True,否则 False
"""
if logger is None:
logger = logger_manager.logger
conf_path = "/etc/hostapd.conf"
try:
if not os.path.exists(conf_path):
return False
with open(conf_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
except Exception as e:
logger.warning(f"[WIFI-AP] 读取 hostapd.conf 失败: {e}")
return False
wanted = {
"wpa_key_mgmt": "WPA-PSK",
"rsn_pairwise": "CCMP",
}
changed = False
seen = set()
new_lines = []
for ln in lines:
s = ln.strip()
if not s or s.startswith("#") or "=" not in s:
new_lines.append(ln)
continue
k, v = s.split("=", 1)
k = k.strip()
if k in wanted:
seen.add(k)
new_v = wanted[k]
if v.strip() != new_v:
new_lines.append(f"{k}={new_v}")
changed = True
else:
new_lines.append(ln)
continue
new_lines.append(ln)
# 缺的补到末尾
for k, v in wanted.items():
if k not in seen:
new_lines.append(f"{k}={v}")
changed = True
if not changed:
return False
try:
with open(conf_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines).rstrip() + "\n")
logger.info("[WIFI-AP] 已更新 /etc/hostapd.conf 安全参数(WPA-PSK + CCMP")
return True
except Exception as e:
logger.warning(f"[WIFI-AP] 写入 hostapd.conf 失败: {e}")
return False
def _switch_boot_to_ap_mode(logger):
"""
去掉 STA 标志、建立 AP 标志,由 S30wifi 起 hostapd(与 Maix start_ap 二选一,以系统脚本为准)。
"""
try:
sta = "/boot/wifi.sta"
ap = "/boot/wifi.ap"
if os.path.exists(sta):
os.remove(sta)
with open(ap, "w", encoding="utf-8") as f:
f.write("")
os.system("/etc/init.d/S30wifi restart")
# 某些固件生成的 hostapd.conf 缺少新安全参数,导致 Windows 提示“较旧的安全标准”。
# 若本次修改了 hostapd.conf,则再重启一次让 hostapd 重新加载配置。
try:
if _ensure_hostapd_modern_security(logger):
os.system("/etc/init.d/S30wifi restart")
except Exception:
pass
return True
except Exception as e:
logger.error(f"[WIFI-AP] 切换 /boot 为 AP 模式失败: {e}")
return False
def start_http_server_thread():
"""仅启动 HTTP 线程(假定 AP 已由 S30wifi 拉起)。"""
global _http_thread
logger = logger_manager.logger
if _http_thread is not None and _http_thread.is_alive():
logger.warning("[WIFI-HTTP] 配网线程已在运行")
return
_http_stop.clear()
host = getattr(config, "WIFI_CONFIG_HTTP_HOST", "0.0.0.0")
port = int(getattr(config, "WIFI_CONFIG_HTTP_PORT", 8080))
_http_thread = threading.Thread(
target=_serve_loop,
args=(host, port),
daemon=True,
name="wifi_config_httpd",
)
_http_thread.start()
def maybe_start_wifi_ap_fallback(logger=None):
"""
若启用 WIFI_CONFIG_AP_FALLBACK:等待若干秒后检测 STA WiFi 与 4G
仅当二者均不可用时,写热点用的 /boot/wifi.ssid|pass、切到 /boot/wifi.ap 并 restart S30wifi,再启动 HTTP。
"""
if logger is None:
logger = logger_manager.logger
if not getattr(config, "WIFI_CONFIG_AP_FALLBACK", False):
return
from network import network_manager
# 先快速检测一次:若 STA 或 4G 已可用,直接返回,避免不必要的等待
wifi_ok = wifi_manager.is_sta_associated()
g4_ok = network_manager.is_4g_available()
logger.info(f"[WIFI-AP] 兜底检测(quick)sta关联={wifi_ok}, 4g={g4_ok}")
if wifi_ok or g4_ok:
logger.info("[WIFI-AP] STA 或 4G 可用,不启动热点配网")
return
# 两者均不可用:再按配置等待一段时间后复检,避免开机瞬态误判
wait_sec = int(getattr(config, "WIFI_AP_FALLBACK_WAIT_SEC", 10))
wait_sec = max(0, min(wait_sec, 120))
if wait_sec > 0:
logger.info(f"[WIFI-AP] 兜底配网:等待 {wait_sec}s 后再检测 STA/4G…")
std_time.sleep(wait_sec)
# 必须用 STA 关联判断;is_wifi_connected() 在 AP 模式会因 192.168.66.1 误判为已连接
wifi_ok = wifi_manager.is_sta_associated()
g4_ok = network_manager.is_4g_available()
logger.info(f"[WIFI-AP] 兜底检测:sta关联={wifi_ok}, 4g={g4_ok}")
if wifi_ok or g4_ok:
logger.info("[WIFI-AP] STA 或 4G 可用,不启动热点配网")
return
logger.warning("[WIFI-AP] STA 与 4G 均不可用,启动热点配网(/boot/wifi.ap + HTTP")
try:
_write_boot_ap_credentials_for_s30wifi()
except Exception as e:
logger.error(f"[WIFI-AP] 写热点 /boot 凭证失败: {e}")
return
if not _switch_boot_to_ap_mode(logger):
return
std_time.sleep(3)
start_http_server_thread()
p = int(getattr(config, "WIFI_CONFIG_HTTP_PORT", 8080))
ip = getattr(config, "WIFI_CONFIG_AP_IP", "192.168.66.1")
logger.info(f"[WIFI-AP] 请连接热点后访问 http://{ip}:{p}/ (若 IP 以 S30wifi 为准)")
def stop_wifi_config_http():
"""请求停止 HTTP 线程(下次 accept 超时后退出)。"""
_http_stop.set()
# 兼容旧名:不再使用「强制开 AP」逻辑,统一走 maybe_start_wifi_ap_fallback
def start_wifi_config_ap_thread():
maybe_start_wifi_ap_fallback()