108 Commits
Author SHA1 Message Date
linyimin e1f12ae609 fix: 网络连接 2026-08-12 18:02:09 +08:00
linyimin 56fa8fc2a1 fix: 修改版本号 2026-08-12 17:16:15 +08:00
linyimin d3c8a26854 fix: 检测充电关机 2026-08-12 17:10:29 +08:00
yrx eae7da7291 重覆盖 2026-08-12 15:42:38 +08:00
yrx dc5da0294f 2.15.23 2026-08-12 14:59:13 +08:00
linyimin 165eeff64e fix: 拍照优先 2026-08-11 14:38:45 +08:00
linyimin a184ff7d55 fix: 修改版本号 2026-08-11 14:33:03 +08:00
linyimin 054e9e6d90 fix: 修改日志级别 2026-08-11 14:21:05 +08:00
linyimin ae339889c2 fix: 靶子检测 2026-08-11 14:17:21 +08:00
linyimin b94b0f2e55 fix: 靶子检测 2026-08-11 14:15:21 +08:00
linyimin e82941a161 pref: 删除无引用方法调用 2026-08-11 13:52:53 +08:00
yrx 6556cfcf74 2.15.26 2026-08-11 13:38:27 +08:00
linyimin abbf30d7c0 pref: wifi连接成功重新登录 2026-06-25 12:17:58 +08:00
linyimin 5cf752bb3f fix: maixcam wifi连接 2026-06-25 12:02:39 +08:00
linyimin 6d8de56bfa fix: maixcam wifi连接 2026-06-25 11:02:19 +08:00
linyimin aee1a92760 fix: wifi连接 2026-06-25 10:06:36 +08:00
linyimin c34efed6f9 fix: wifi连接 2026-06-22 12:05:23 +08:00
linyimin 226394d3ed fix: wifi连接 2026-06-22 12:00:08 +08:00
linyimin b169618b16 fix: 2026-06-16 15:18:38 +08:00
linyimin 5ab4ef2944 fix: 2026-06-10 10:15:11 +08:00
linyimin 577ff02c04 fix:20cm靶的兼容 2026-06-09 18:31:01 +08:00
linyimin 82d0008257 fix: 2026-06-09 11:53:22 +08:00
linyimin 373eeb786a fix: 2026-06-09 10:30:03 +08:00
linyimin 4500e62647 fix: 2026-06-08 17:56:21 +08:00
linyimin 49a84e80e1 fix: 2026-06-08 17:52:53 +08:00
linyimin 9654b79cec fix: 2026-06-08 17:50:31 +08:00
linyimin 1ea8c64a40 feat: conn wifi 2026-06-08 16:46:56 +08:00
linyimin 9dd6fef6f8 fix: 不保存图片 2026-06-08 13:55:37 +08:00
linyimin 860f9c84c3 pref: 20 cm adapter 2026-06-04 15:58:07 +08:00
linyimin 1a0bfd54f7 fix: rm yolo 2026-06-04 09:00:10 +08:00
linyimin c46cf5c567 test: 2026-06-03 18:11:58 +08:00
linyimin 0d69a01a1f pref: 版本说明 2026-06-03 16:02:39 +08:00
linyimin 583748fda3 pref: 版本说明 2026-06-03 14:09:11 +08:00
linyimin d508478c73 fix: 新版本ota 2026-06-03 14:00:28 +08:00
linyimin 30c7200a7a feat: 新版本ota 2026-06-03 13:21:06 +08:00
linyimin 959635f461 feat: 新版本ota 2026-06-03 13:20:46 +08:00
linyimin 86cd8cd46e pref: 2026-06-02 18:24:18 +08:00
linyimin 26ed3c1523 pref: laser find center point 2026-06-02 16:03:18 +08:00
linyimin aa16676c74 pref: laser find center point 2026-06-02 10:32:24 +08:00
linyimin 99614fe321 pref: clean code format 2026-06-02 09:56:59 +08:00
linyimin 2ad2836d77 fix: camera change to camera_manager 2026-06-02 09:55:36 +08:00
linyimin 801453fbdb feat: 根据激光测算中心坐标 2026-06-01 22:42:55 +08:00
yrx c754dff4ad 修改command record cpp 编译部分,docker环境 2026-05-15 16:00:53 +08:00
yrx 47018fcd69 Merge branch 'dev' of https://git.shelingxingqiu.com/ZZH000829/archery into dev 2026-05-15 15:56:06 +08:00
yrx afa99f598b 分片解密,版本号修改 2026-05-15 15:53:19 +08:00
gcw_4spBpAfv e90ea5154c 增加cpp的代码 2026-05-15 14:44:20 +08:00
yrx b895ea819c Merge remote-tracking branch 'refs/remotes/origin/dev' into dev 2026-05-15 09:59:34 +08:00
yrx 1a1dac6b8f 修改了4g分片下载,改了版本号约定 最后数字是模型版本号 2026-05-15 09:53:43 +08:00
gcw_4spBpAfv 541418fd60 增加训练yolo的代码 2026-05-15 09:35:53 +08:00
yrx dff5096164 修改了icc登录部分注释 2026-05-14 09:08:33 +08:00
yrx 8b580fc732 iccx提交循环 2026-05-14 09:00:54 +08:00
yrx f9123889f2 iccx提交循环 2026-05-14 08:53:31 +08:00
yrx 9fd1c961e4 always send iccid 2026-05-13 18:46:22 +08:00
gcw_4spBpAfv 4ea15567c2 wpa_supplicant_conf.py 2026-05-13 16:38:24 +08:00
gcw_4spBpAfv ef16c7e037 4g_upload_manager 2026-05-13 16:23:21 +08:00
gcw_4spBpAfv 4b94e03413 更新wifi连接的代码,改三角形的连接为1秒超时 2026-05-13 16:08:00 +08:00
gcw_4spBpAfv 0a1c7cff5c update config 2026-05-11 18:05:37 +08:00
gcw_4spBpAfv bd5ebdaa43 target_roi_yolo.py 2026-05-11 16:26:05 +08:00
gcw_4spBpAfv a090579db9 update power estimation and upload 2 models of yolo 2026-05-08 23:41:42 +08:00
gcw_4spBpAfv 5e7db5e271 fix ip none issue 2026-04-28 17:06:09 +08:00
gcw_4spBpAfv 4a3b111ce4 refine power module 2026-04-28 16:58:51 +08:00
gcw_4spBpAfv fe3e26e21d triangle algo refind 2026-04-24 18:38:03 +08:00
gcw_4spBpAfv 8efe1ae5c5 upload log file to qiqiu 2026-04-23 17:53:21 +08:00
gcw_4spBpAfv 12fac4ea1c remove unseed code 2026-04-23 11:14:08 +08:00
gcw_4spBpAfv 1bace88f37 refine the triangle algo 2026-04-21 21:14:12 +08:00
gcw_4spBpAfv ba5ca7e0b3 upload img to qiniu 2026-04-20 19:03:20 +08:00
gcw_4spBpAfv e030f3a194 triangle algo 2026-04-18 09:33:37 +08:00
gcw_4spBpAfv 43e7e0ba17 new shoot algo 2026-04-17 18:31:44 +08:00
gcw_4spBpAfv 0ee970d8bd wifi support tsl 2026-04-14 09:02:41 +08:00
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
37 changed files with 14040 additions and 2198 deletions
-224
View File
@@ -1,224 +0,0 @@
# from maix import app, key, uart, pinmap, time
# import hashlib
# import hmac
# import ujson
# # 配置 UART2(用于 HTTP 上传)
# pinmap.set_pin_function("A29", "UART2_RX")
# pinmap.set_pin_function("A28", "UART2_TX")
# http_serial = uart.UART("/dev/ttyS2", 115200, uart.BITS.BITS_8,
# uart.PARITY.PARITY_NONE, uart.STOP.STOP_1)
# # 按键初始化
# key_triggered = False
# def on_key_event(key_id, state):
# global key_triggered
# if state == key.State.KEY_PRESSED:
# key_triggered = True
# key_obj = key.Key(on_key_event)
# # Token生成
# def generate_token(device_id):
# SALT = "shootMessageFire"
# SALT2 = "shoot"
# return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
# # 发送AT命令
# http_instance_id = -1
# def send_cmd(cmd_str, expect_create_id=False):
# global http_instance_id
# print("[AT指令] =>", cmd_str)
# http_serial.write((cmd_str + "\r\n").encode())
# buffer = b""
# start = time.ticks_ms()
# while time.ticks_ms() - start < 3000:
# data = http_serial.read(128)
# if data:
# buffer += data
# try:
# decoded = buffer.decode()
# print("返回:", decoded.strip())
# if expect_create_id and "+MHTTPCREATE:" in decoded:
# http_instance_id = int(decoded.split(":")[1].split("\r")[0].strip())
# if "OK" in decoded: return True
# if "+CME ERROR" in decoded or "ERROR" in decoded: return False
# except:
# print("解码异常")
# time.sleep_ms(10)
# return False
# def create_http_instance(url):
# return send_cmd(f'AT+MHTTPCREATE="{url}"', True) and http_instance_id != -1
# def send_data(instance_id, token, api_path, json_data):
# # 设置Header(使用统一的AT+MHTTPCFG="header"
# send_cmd(f'AT+MHTTPCFG="header",{instance_id},"Content-Type: application/json"')
# send_cmd(f'AT+MHTTPCFG="header",{instance_id},"Authorization: {token}"')
# send_cmd(f'AT+MHTTPCFG="header",{instance_id},"DeviceId: {device_id}"')
# # 发送Body数据
# json_str = ujson.dumps(json_data)
# send_cmd(f'AT+MHTTPCONTENT={instance_id},0,0,"{json_str}"')
# send_cmd(f'AT+MHTTPREQUEST={instance_id},2,0,"{api_path}"')
# def read_response(timeout_ms=5000):
# start = time.ticks_ms()
# while time.ticks_ms() - start < timeout_ms:
# data = http_serial.read(128)
# if data:
# try:
# print("响应:", data.decode("utf-8").strip())
# except:
# print("响应(raw):", data)
# time.sleep_ms(50)
# # 参数配置
# device_id = "wZhC7kAZ" #需要根据实际硬件ID来测试
# url = "http://ws.shelingxingqiu.com"
# api_path = "/home/shoot/device_fire/arrow/fire"
# token = generate_token(device_id)
# print("生成Token:", token)
# # 主循环:仅监听按键并上传模拟数据
# while not app.need_exit():
# if key_triggered:
# key_triggered = False
# print("按键按下,准备上传...")
# # 模拟数据(可替换为实际测量值)
# timestamp = int(time.time() * 1000)
# json_data = {
# "id": timestamp,
# "DeviceId": device_id,
# "x": 12.34,
# "y": -5.67,
# "rag": 90.0,
# "time": timestamp,
# "dst": 234.5,
# "battery": 80,
# "errorCode": 0
# }
# if http_instance_id == -1 and not create_http_instance(url):
# print("创建 HTTP 实例失败")
# elif http_instance_id != -1:
# send_data(http_instance_id, token, api_path, json_data)
# read_response()
# else:
# time.sleep_ms(100)
from maix import app, uart, pinmap, time
import hashlib
import hmac
import ujson
# ========== 配置 ==========
# UART2 for HTTP
pinmap.set_pin_function("A29", "UART2_RX")
pinmap.set_pin_function("A28", "UART2_TX")
http_serial = uart.UART("/dev/ttyS2", 115200, uart.BITS.BITS_8,
uart.PARITY.PARITY_NONE, uart.STOP.STOP_1)
# 设备参数
device_id = "wZhC7kAZ"
url = "http://ws.shelingxingqiu.com"
api_path = "/home/shoot/device_fire/arrow/fire"
# ========== 工具函数 ==========
def generate_token(device_id):
SALT = "shootMessageFire"
SALT2 = "shoot"
return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
def send_cmd(cmd_str, timeout_ms=3000):
"""发送 AT 指令并等待 OK / ERROR"""
print("[AT] =>", cmd_str)
http_serial.write((cmd_str + "\r\n").encode())
buffer = b""
start = time.ticks_ms()
while time.ticks_ms() - start < timeout_ms:
data = http_serial.read(128)
if data:
buffer += data
try:
decoded = buffer.decode()
print("<= ", decoded.strip())
if "OK" in decoded:
return True
if "+CME ERROR" in decoded or "ERROR" in decoded:
return False
except:
pass
time.sleep_ms(10)
return False
def create_http_instance(url):
cmd = f'AT+MHTTPCREATE="{url}"'
if send_cmd(cmd):
# 尝试提取 instance ID(如果模块返回)
# 注意:部分模块不会返回 ID,可忽略,直接用 0 或 1
return True
return False
def send_http_request(url, api_path, token, device_id, json_data):
# 1. 创建 HTTP 实例
if not create_http_instance(url):
print("❌ 创建 HTTP 实例失败")
return False
# 2. 设置 Headers(假设实例 ID 为 0,或根据模块默认)
instance_id = 0 # 大多数模块默认实例为 0;若支持多实例,需解析返回值
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"Content-Type: application/json"')
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"Authorization: {token}"')
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"DeviceId: {device_id}"')
# 3. 发送 Body
json_str = ujson.dumps(json_data)
send_cmd(f'AT+MHTTPCONTENT={instance_id},0,0,"{json_str}"')
# 4. 发起 POST 请求
if send_cmd(f'AT+MHTTPREQUEST={instance_id},2,0,"{api_path}"'):
print("✅ HTTP 请求已发送")
return True
else:
print("❌ 发送请求失败")
return False
def read_response(timeout_ms=5000):
print("⏳ 等待响应...")
start = time.ticks_ms()
while time.ticks_ms() - start < timeout_ms:
data = http_serial.read(128)
if data:
try:
print("📡 响应:", data.decode("utf-8").strip())
except:
print("📡 响应(raw):", data)
time.sleep_ms(100)
# ========== 主程序:直接上传 ==========
print("🚀 启动直接上传流程...")
token = generate_token(device_id)
print("🔑 Token:", token)
# 构造模拟数据
timestamp = int(time.time() * 1000)
json_data = {
"id": timestamp,
"DeviceId": device_id,
"x": 12.34,
"y": -5.67,
"rag": 90.0,
"time": timestamp,
"dst": 234.5,
"battery": 80,
"errorCode": 0
}
# 执行上传
if send_http_request(url, api_path, token, device_id, json_data):
read_response()
else:
print("💥 上传流程失败")
print("🔚 程序结束")
+403
View File
@@ -0,0 +1,403 @@
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 parsed.query:
path = f"{path}?{parsed.query}"
if parsed.fragment:
path = f"{path}#{parsed.fragment}"
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()
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
4G Image Upload Manager
Uploads images to Qiniu cloud via ML307R 4G module TCP socket (MIPOPEN + MIPSEND).
AT Command Sequence (ML307R TCP socket POST):
AT+MIPCALL=1,1 // Ensure PDP context active
AT+MIPCLOSE=<id> // Close old socket (ignore error)
AT+MIPOPEN=<id>,"TCP","<host>",80 // Open TCP socket
// Wait for +MIPOPEN: <id>,0 (success)
AT+MIPSEND=<id>,<len> // Send data
// Wait for ">" prompt, then write raw bytes
// Repeat MIPSEND for all chunks
// Wait for +MIPURC: "rtcp" response
AT+MIPCLOSE=<id> // Close socket
"""
import re
import os
import json
from maix import time
from urllib.parse import urlparse
from logger_manager import logger_manager
from hardware import hardware_manager
# Multipart form boundary (simple alphanumeric to avoid AT command parser issues)
BOUNDARY = "QiniuFormBoundary" + hex(int(time.time()))[2:]
# Chunk size for MIPSEND (max 1024 to avoid AT line buffer limits)
SEND_CHUNK = 1024
# Socket ID for upload (dedicated to avoid conflict with main app TCP)
UPLOAD_SOCK_ID = 3
class FourGUploadManager:
"""4G image upload manager using ML307R TCP socket (MIPOPEN + MIPSEND)"""
def __init__(self, at_client):
"""Initialize with AT client instance"""
self.at = at_client
self.logger = logger_manager.logger
# ------------------------------------------------------------------ logging
def _log(self, msg):
try:
self.logger.debug("[4G-UL] " + msg)
except Exception:
print("[4G-UL] " + msg)
def _log_info(self, msg):
try:
self.logger.info("[4G-UL] " + msg)
except Exception:
print("[4G-UL] " + msg)
def _log_error(self, msg):
try:
self.logger.error("[4G-UL] " + msg)
except Exception:
print("[4G-UL] " + msg)
# --------------------------------------------------------------- helpers
def _ensure_pdp(self):
"""Ensure PDP context is active; returns (ok, ip)"""
r = self.at.send("AT+CGPADDR=1", "OK", 3000)
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
ip = m.group(1) if m else ""
if ip and ip != "0.0.0.0":
return True, ip
self.at.send("AT+MIPCALL=1,1", "OK", 15000)
for _ in range(10):
r = self.at.send("AT+CGPADDR=1", "OK", 3000)
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
ip = m.group(1) if m else ""
if ip and ip != "0.0.0.0":
return True, ip
time.sleep(1)
return False, ip
def _is_error(self, resp):
"""Check AT response for any error indicators"""
return "ERROR" in resp or "CME ERROR" in resp
# --------------------------------------------------------- multipart body
def _build_multipart_body(self, image_path, upload_token, key):
"""
Build multipart/form-data body as bytes for Qiniu upload.
Fields:
- token : Qiniu upload token
- key : object key in bucket
- file : binary image data
"""
boundary = BOUNDARY.encode()
with open(image_path, "rb") as f:
file_data = f.read()
filename = os.path.basename(image_path)
ext = os.path.splitext(image_path)[1].lower()
ct_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".bmp": "image/bmp",
".webp": "image/webp",
}
content_type = ct_map.get(ext, "application/octet-stream")
body = bytearray()
# -- token field --
body += b"--" + boundary + b"\r\n"
body += b'Content-Disposition: form-data; name="token"\r\n'
body += b"\r\n"
body += upload_token.encode("utf-8") + b"\r\n"
# -- key field --
body += b"--" + boundary + b"\r\n"
body += b'Content-Disposition: form-data; name="key"\r\n'
body += b"\r\n"
body += key.encode("utf-8") + b"\r\n"
# -- file field --
body += b"--" + boundary + b"\r\n"
body += (
b'Content-Disposition: form-data; name="file"; filename="'
+ filename.encode("utf-8")
+ b'"\r\n'
)
body += b"Content-Type: " + content_type.encode("utf-8") + b"\r\n"
body += b"\r\n"
body += file_data + b"\r\n"
# -- closing boundary --
body += b"--" + boundary + b"--\r\n"
return bytes(body)
# --------------------------------------------------- TCP socket helpers
def _close_socket(self, sock_id):
"""Close socket, ignore CME ERROR 55 (already closed)"""
try:
resp = self.at.send("AT+MIPCLOSE=" + str(sock_id), "OK", 5000)
self._log("socket " + str(sock_id) + " closed: " + resp)
except Exception as e:
# Ignore CME ERROR 55 (socket not open)
self._log("socket close (may already be closed): " + str(e))
def _open_socket(self, sock_id, host, port):
"""
Open TCP socket to host:port.
Returns (success, error_msg)
"""
cmd = 'AT+MIPOPEN=' + str(sock_id) + ',"TCP","' + host + '",' + str(port)
resp = self.at.send(cmd, "OK", 15000)
if self._is_error(resp):
return False, "MIPOPEN failed: " + resp
# Wait for +MIPOPEN: <id>,0 (success) or +MIPOPEN: <id>,<error_code>
# The URC may come in the same response or separately
mipopen_pattern = r"\+MIPOPEN:\s*" + str(sock_id) + r",(\d+)"
m = re.search(mipopen_pattern, resp)
if m:
result_code = int(m.group(1))
if result_code == 0:
return True, ""
else:
return False, "MIPOPEN error code: " + str(result_code)
# If not in initial response, wait for URC
try:
urc_resp = self.at.send("", "+MIPOPEN:", 15000)
m = re.search(mipopen_pattern, urc_resp)
if m:
result_code = int(m.group(1))
if result_code == 0:
return True, ""
else:
return False, "MIPOPEN error code: " + str(result_code)
except Exception as e:
return False, "MIPOPEN URC timeout: " + str(e)
return False, "MIPOPEN no response"
def _send_chunk(self, sock_id, chunk):
"""
Send a single chunk via MIPSEND.
Thread safety is provided by the outer network_manager.get_uart_lock().
NOTE: Do NOT add self.at._cmd_lock here — self.at.send() already
acquires it internally and threading.Lock is not reentrant.
Returns (success, error_msg)
"""
chunk_len = len(chunk)
# Step 1: Send AT+MIPSEND command and wait for ">" prompt
cmd = "AT+MIPSEND=" + str(sock_id) + "," + str(chunk_len)
try:
resp = self.at.send(cmd, ">", 3000)
if ">" not in resp:
return False, "MIPSEND no > prompt: " + resp
except Exception as e:
return False, "MIPSEND > prompt error: " + str(e)
# Step 2: Write raw binary bytes directly to UART
# Must be done immediately after ">" prompt, no lock re-acquisition
try:
self.at.uart.write(chunk)
except Exception as e:
return False, "MIPSEND write error: " + str(e)
# Step 3: Wait for OK or SEND OK confirmation
try:
confirm_resp = self.at.send("", "OK", 8000)
if self._is_error(confirm_resp):
return False, "MIPSEND confirmation error: " + confirm_resp
except Exception as e:
return False, "MIPSEND confirmation timeout: " + str(e)
return True, ""
def _send_data(self, sock_id, data):
"""
Send data in chunks via MIPSEND.
Returns (success, error_msg)
"""
total_len = len(data)
offset = 0
chunk_num = 0
while offset < total_len:
end = min(offset + SEND_CHUNK, total_len)
chunk = data[offset:end]
ok, err = self._send_chunk(sock_id, chunk)
if not ok:
return False, "Chunk " + str(chunk_num) + " failed: " + err
chunk_num += 1
offset = end
if chunk_num % 10 == 0 or offset >= total_len:
self._log(
"send progress: "
+ str(offset) + "/" + str(total_len)
+ " bytes (" + str(chunk_num) + " chunks)"
)
self._log("all data sent: " + str(chunk_num) + " chunks, " + str(total_len) + " bytes")
return True, ""
def _wait_for_response(self, sock_id, timeout_ms=30000):
"""
Wait for +MIPURC: "rtcp" response.
Returns (success, status_code, body, error_msg)
"""
pattern = r'\+MIPURC:\s*"rtcp",\s*' + str(sock_id) + r',\s*(\d+),'
t0 = time.ticks_ms()
while time.ticks_diff(time.ticks_ms(), t0) < timeout_ms:
try:
# Try to get response with short timeout
resp = self.at.send("", "+MIPURC:", 1000)
m = re.search(pattern, resp)
if m:
data_len = int(m.group(1))
# Extract HTTP response data after the URC header
# Format: +MIPURC: "rtcp",<sock_id>,<len>,<data>
urc_end = resp.find("+MIPURC:")
if urc_end >= 0:
# Find the data after the length field
match_end = m.end()
http_data = resp[match_end:match_end + data_len]
# Parse HTTP status line
status_match = re.search(r"HTTP/\d\.\d\s+(\d+)", http_data)
status_code = int(status_match.group(1)) if status_match else None
# Extract body (after headers)
header_end = http_data.find("\r\n\r\n")
if header_end >= 0:
body = http_data[header_end + 4:]
else:
body = http_data
return True, status_code, body, ""
except Exception:
pass
time.sleep_ms(100)
return False, None, "", "Response timeout"
def _build_http_request(self, host, body_bytes):
"""
Build full HTTP POST request as bytes.
"""
headers = (
"POST / HTTP/1.1\r\n"
"Host: " + host + "\r\n"
"Content-Type: multipart/form-data; boundary=" + BOUNDARY + "\r\n"
"Content-Length: " + str(len(body_bytes)) + "\r\n"
"Connection: close\r\n"
"\r\n"
)
return headers.encode("utf-8") + body_bytes
# ============================================================ public API
def upload_file(self, file_path, upload_url, upload_token, key):
"""Generic file upload to Qiniu cloud via 4G TCP socket POST.
Args:
file_path: Local path to any file
upload_url: Qiniu upload URL
upload_token: Qiniu upload token
key: File key in Qiniu bucket
Returns:
dict with 'success' bool and 'key'/'error' fields
"""
return self.upload_image(file_path, upload_url, upload_token, key)
def upload_image(self, image_path, upload_url, upload_token, key):
"""
Upload image to Qiniu cloud via 4G TCP socket POST.
Args:
image_path: Local path to image file
upload_url: Qiniu upload URL (e.g., "https://upload.qiniup.com")
upload_token: Qiniu upload token
key: File key in Qiniu (e.g., "shootPic/device01/shoot01.png")
Returns:
dict with 'success' bool and 'key'/'error' fields
"""
if not self.at:
return {"success": False, "error": "AT client not available"}
if not os.path.exists(image_path):
return {"success": False, "error": "Image file not found: " + image_path}
# Force HTTP for 4G module (extract hostname, use port 80)
parsed = urlparse(upload_url)
host = parsed.hostname
if not host:
return {"success": False, "error": "Invalid upload URL: " + upload_url}
if upload_url.lower().startswith("https://"):
self._log_info("Converted HTTPS->HTTP for 4G module")
file_size = os.path.getsize(image_path)
self._log_info(
"upload: " + image_path + " (" + str(file_size) + "B) -> "
+ host + " key=" + key
)
from network import network_manager
with network_manager.get_uart_lock():
try:
# ---- Step 1: Ensure PDP context ----
ok_pdp, ip = self._ensure_pdp()
if not ok_pdp:
return {"success": False, "error": "PDP not ready (ip=" + str(ip) + ")"}
# ---- Step 2: Close old socket ----
self._close_socket(UPLOAD_SOCK_ID)
# ---- Step 3: Open TCP socket ----
ok, err = self._open_socket(UPLOAD_SOCK_ID, host, 80)
if not ok:
return {"success": False, "error": "Socket open failed: " + err}
try:
# ---- Step 4: Build multipart body and HTTP request ----
body = self._build_multipart_body(image_path, upload_token, key)
http_request = self._build_http_request(host, body)
self._log("HTTP request size: " + str(len(http_request)) + " bytes")
# ---- Step 5: Send data via MIPSEND ----
ok, err = self._send_data(UPLOAD_SOCK_ID, http_request)
if not ok:
return {"success": False, "error": "Send failed: " + err}
# ---- Step 6: Wait for response ----
ok, status_code, resp_body, err = self._wait_for_response(UPLOAD_SOCK_ID)
if not ok:
return {"success": False, "error": "Response error: " + err}
# ---- Step 7: Parse response ----
if status_code is None:
return {"success": False, "error": "No HTTP status in response"}
if 200 <= status_code < 300:
try:
resp_json = json.loads(resp_body)
resp_key = resp_json.get("key", key)
self._log_info("upload success: key=" + resp_key + " code=" + str(status_code))
return {"success": True, "key": resp_key}
except Exception as e:
self._log_error("response parse error: " + str(e))
return {
"success": True,
"key": key,
"raw": resp_body,
}
else:
self._log_error(
"HTTP error: code=" + str(status_code) + " body=" + resp_body[:200]
)
return {
"success": False,
"error": "HTTP " + str(status_code),
"response": resp_body,
}
finally:
# ---- Step 8: Always close socket ----
self._close_socket(UPLOAD_SOCK_ID)
except Exception as e:
self._log_error("upload exception: " + str(e))
return {"success": False, "error": str(e)}
# ====================================================================== demo
if __name__ == "__main__":
# Demo usage — requires actual ML307R 4G module hardware to run.
print("FourGUploadManager - requires ML307R 4G module hardware")
print()
print("Usage:")
print(" from hardware import hardware_manager")
print(" from at_client import ATClient")
print(" from maix import uart")
print()
print(" # Initialize UART and AT client (normally done in hardware init)")
print(" uart4g = uart.UART('/dev/ttyS1', 115200, ...)")
print(" at_client = ATClient(uart4g)")
print(" at_client.start()")
print()
print(" # Upload image to Qiniu")
print(" uploader = FourGUploadManager(at_client)")
print(" result = uploader.upload_image(")
print(" image_path='/maixapp/apps/t11/shoot.png',")
print(" upload_url='https://upload.qiniup.com',")
print(" upload_token='<qiniu_upload_token>',")
print(" key='shootPic/device01/shoot01.png'")
print(" )")
print(" print('Upload result:', result)")
-88
View File
@@ -1,88 +0,0 @@
from maix import i2c, pinmap, time
# 配置 I2C1 引脚(请根据实际连接修改)
pinmap.set_pin_function("P18", "I2C1_SCL")
pinmap.set_pin_function("P21", "I2C1_SDA")
bus = i2c.I2C(1, i2c.Mode.MASTER)
INA226_ADDR = 0x40
# 寄存器定义
REG_CONFIGURATION = 0x00
REG_BUS_VOLTAGE = 0x02
REG_CALIBRATION = 0x05
# 校准值(不读取电流/功率时也可以省略)
CALIBRATION_VALUE = 0x1400
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 # 单位 V
def voltage_to_percent(voltage):
if voltage >= 4.20:
return 100
elif voltage >= 4.15:
return 95
elif voltage >= 4.10:
return 90
elif voltage >= 4.05:
return 85
elif voltage >= 4.00:
return 80
elif voltage >= 3.95:
return 75
elif voltage >= 3.90:
return 70
elif voltage >= 3.85:
return 65
elif voltage >= 3.80:
return 60
elif voltage >= 3.75:
return 55
elif voltage >= 3.70:
return 50
elif voltage >= 3.65:
return 45
elif voltage >= 3.60:
return 40
elif voltage >= 3.55:
return 35
elif voltage >= 3.50:
return 30
elif voltage >= 3.45:
return 25
elif voltage >= 3.40:
return 20
elif voltage >= 3.35:
return 15
elif voltage >= 3.30:
return 10
elif voltage >= 3.20:
return 5
else:
return 0
# 初始化 INA226
init_ina226()
# 主循环,只显示电量百分比
while True:
voltage = get_bus_voltage()
battery_percent = voltage_to_percent(voltage)
print(f"当前电压: {voltage:.3f} V")
print(f"估算电池电量: {battery_percent} %\n")
time.sleep(2000)
-120
View File
@@ -1,120 +0,0 @@
# 🎯 激光射击系统(双版本)
适用于 **MaixPy** 平台,支持远程控制、电池监测、Wi-Fi 连接及 OTA 升级。
提供两个独立实现版本,共享相同网络协议与 OTA 机制,便于统一部署管理:
- `main.py`**视觉测距版**
- `laser.py`**激光测距版**
---
## 📁 项目结构
```
laser_shooting_system/
├── README.md
├── main.py # 视觉测距版主程序
└── laser.py # 激光测距版主程序
```
---
## ⚙️ 硬件依赖
| 版本 | 必需硬件 |
|------------|----------------------------------------|
| `main.py` | Maix 系列开发板 + 摄像头 + 其他硬件 |
| `laser.py` | Maix 系列开发板 + 激光测距模块(I²C) + 摄像头 + 其他硬件) |
> 💡 **注意:引脚复用风险**
> Maix 开发板部分 GPIO 兼容多协议(如 Wi-Fi / I²C 复用 A15/A27)。
> **Wi-Fi 初始化前禁止提前配置 I²C 引脚!**
### ❗ 关键提示
| 问题场景 | 后果 |
|---------|------|
| 提前初始化 I²C | Wi-Fi 初始化失败、OTA 中断、系统重启 |
**正确做法:**
- **`main.py`(视觉版)&`laser.py`(激光版)**
使用wifi时启用下面代码,注释与WiFi复用的:
```python
# 以下代码(如有,请启用):
# pinmap.set_pin_function("A15", "I2C5_SCL")
# pinmap.set_pin_function("A27", "I2C5_SDA")
```
## 📡 网络通信协议(TCP / JSON)
设备上电后自动连预设服务器,支持以下指令:
```json
{"data": {"cmd": N, "ssid": "...", "password": "..."}}
```
| `cmd` | 参数 | 功能说明 |
|-------|---------------------|------------------------------|
| 2 | — | 开启激光校准模式 |
| 3 | — | 关闭激光 |
| 4 | — | 查询电池电量 & 电压 |
| 5 | `ssid`, `password` | 配置 Wi-Fi + 触发 OTA 升级 |
| 6 | — | 返回当前 IP 地址 |
| 7 | — | 已联网时,直接执行 OTA 下载 |
### 示例交互
▶️ 下发指令(服务器 → 设备):
```json
{"data": {"cmd": 6}}
```
◀️ 设备响应(设备 → 服务器):
```json
{"result": "current_ip", "ip": "192.168.1.105"}
```
---
## 🛠️ 部署步骤
1. **选择版本**
- 固定场景 / 低成本 → `main.py`
- 高精度需求 → `laser.py`
2. **烧录程序**
- 将选定文件重命名为 `main.py`,或通过 MaixPy IDE 直接运行
3. **首次配置 Wi-Fi**
- 串口下发,或服务器推送 `cmd=5`
```json
{"data": {"cmd": 5, "ssid": "YourWiFi", "password": "12345678"}}
```
4. **后续 OTA 升级**
- 确保设备在线后,下发 `cmd=7` 即可触发 OTA
---
## 📝 注意事项
- 🔗 **OTA 地址**:由全局变量 `url` 定义,部署前务必修改为实际地址
- 🧵 **线程安全**:通过 `update_thread_started` 标志防止 OTA 并发下载
- ☀️ **视觉版**:光照敏感,建议在均匀光源环境使用
- 📏 **激光版**:确认模块 I²C 地址(默认 `0x29`),避免长线干扰
- 🌐 **网络操作**:均在子线程执行,主线程保持实时响应
## 🔧 打包步骤(命令行)
- 以t11的名称打包,或者修改代码升级路径
---
> 文档版本:v1.2
> 更新时间:2025-11-21
> 维护人:ZZH
```
-17
View File
@@ -1,17 +0,0 @@
from maix.peripheral import adc
from maix import time
a = adc.ADC(0, adc.RES_BIT_12)
while True:
raw_data = a.read()
print(f"ADC raw data:{raw_data}")
# if raw_data > 2450:
# print(f"ADC raw data:{raw_data}")
# elif raw_data < 2000:
# print(f"ADC raw data:{raw_data}")
# time.sleep_ms(50)
# vol = a.read_vol()
# print(f"ADC vol:{vol}")
+30 -1
View File
@@ -1,8 +1,37 @@
id: t11 id: t11
name: t11 name: t11
version: 1.0.2 version: 2.15.35
author: t11 author: t11
icon: '' icon: ''
desc: t11 desc: t11
files: files:
- 4g_download_manager.py
- 4g_upload_manager.py
- app.yaml
- archery_netcore.cpython-311-riscv64-linux-gnu.so
- at_client.py
- camera_manager.py
- cameraParameters.xml
- charging_exit.sh
- config.py
- hardware.py
- laser_detector.py
- laser_manager.py
- logger_manager.py
- main.py - main.py
- network.py
- ota_curl.sh
- ota_manager.py
- power.py
- server.pem
- shoot_manager.py
- shot_id_generator.py
- target_roi_yolo.py
- time_sync.py
- triangle_positions.json
- triangle_target.py
- version.py
- vision.py
- wifi_config_httpd.py
- wifi.py
- wpa_supplicant_conf.py
Binary file not shown.
+344
View File
@@ -0,0 +1,344 @@
#!/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, abort_event=None):
"""
发送 AT 命令并等待 expect(子串匹配)。
注意:expect=">" 用于等待 prompt。
"""
expect_b = expect.encode() if isinstance(expect, str) else expect
with self._cmd_lock:
with self._q_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 abort_event is not None and abort_event.is_set():
self._waiting = False
break
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 send_raw_and_wait(self, data: bytes, expect: str = "OK", timeout_ms: int = 1000,
suffix: bytes = b""):
"""Register the response waiter before writing raw UART data."""
expect_b = expect.encode() if isinstance(expect, str) else expect
with self._cmd_lock:
with self._q_lock:
self._waiting = True
self._expect = expect_b
self._resp = b""
total = 0
while total < len(data):
n = self.uart.write(data[total:])
if not n or n < 0:
time.sleep_ms(1)
continue
total += n
if suffix:
self.uart.write(suffix)
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) > 32768:
self._rx = self._rx[-16384:]
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0"?>
<opencv_storage>
<calibrationDate>"Sat Apr 11 12:05:27 2026"</calibrationDate>
<framesCount>29</framesCount>
<cameraResolution>
640 480</cameraResolution>
<camera_matrix type_id="opencv-matrix">
<rows>3</rows>
<cols>3</cols>
<dt>d</dt>
<data>
2207.9058323074869 0. 328.90661220953149 0. 2207.9058323074869
205.49515894111076 0. 0. 1.</data></camera_matrix>
<camera_matrix_std_dev type_id="opencv-matrix">
<rows>4</rows>
<cols>1</cols>
<dt>d</dt>
<data>
0. 11.687428265309892 3.6908895632668468 3.597571733110271</data></camera_matrix_std_dev>
<distortion_coefficients type_id="opencv-matrix">
<rows>1</rows>
<cols>5</cols>
<dt>d</dt>
<data>
-0.63036604771649651 3.3832710000807449 0. 0. -0.45113389267675552</data></distortion_coefficients>
<distortion_coefficients_std_dev type_id="opencv-matrix">
<rows>5</rows>
<cols>1</cols>
<dt>d</dt>
<data>
0.025002349846111244 1.0651877135605927 0. 0. 0.04021252864120229</data></distortion_coefficients_std_dev>
<avg_reprojection_error>0.28992233810828955</avg_reprojection_error>
</opencv_storage>
+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()
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# The application supplies its own PID. Refuse broad or malformed targets.
TARGET_PID="$1"
LASER_DEVICE="${2:-/dev/ttyS1}"
LASER_BAUD="${3:-9600}"
turn_off_laser() {
if [ ! -c "$LASER_DEVICE" ]; then
echo "[CHARGE] laser serial device not found: $LASER_DEVICE" >&2
return 1
fi
stty -F "$LASER_DEVICE" "$LASER_BAUD" raw -echo 2>/dev/null || return 1
printf '\252\000\001\276\000\001\000\000\300' > "$LASER_DEVICE"
}
case "$TARGET_PID" in
''|*[!0-9]*)
echo "[CHARGE] invalid application pid: $TARGET_PID" >&2
exit 2
;;
esac
if [ "$TARGET_PID" -le 1 ]; then
echo "[CHARGE] refusing to terminate pid: $TARGET_PID" >&2
exit 2
fi
# First request laser-off while the application still owns the initialized UART.
turn_off_laser || true
kill -TERM "$TARGET_PID" 2>/dev/null || true
# Wait up to two seconds for a graceful exit, then force termination.
WAIT_COUNT=0
while kill -0 "$TARGET_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
sleep 0.1
WAIT_COUNT=$((WAIT_COUNT + 1))
done
if kill -0 "$TARGET_PID" 2>/dev/null; then
kill -KILL "$TARGET_PID" 2>/dev/null || true
sleep 0.1
fi
# Send laser-off again after the application releases the UART.
turn_off_laser || true
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
系统配置常量
这些值在程序运行期间基本不变,或只在配置时改变
"""
from version import VERSION
# ==================== 应用配置 ====================
APP_VERSION = VERSION
APP_DIR = "/maixapp/apps/t11"
LOCAL_FILENAME = APP_DIR + "/main_tmp.py"
# ==================== 相机配置 ====================
# 相机初始化分辨率(CameraManager / main.py 使用)
CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
# 三角形检测缩图比例:默认按相机最长边缩到 1/2(性能更稳;可按需调整)
# 取值范围建议 (0.25 ~ 1.0]1.0 表示不缩图
TRIANGLE_DETECT_SCALE = 0.4
# ==================== 服务器配置 ====================
# SERVER_IP = "stcp.shelingxingqiu.com"
SERVER_IP = "www.shelingxingqiu.com"
SERVER_PORT = 50005
HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
# 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 纳入综合判定
# WiFi 热点配网(手机连设备 AP,浏览器提交路由器 SSID/密码;仅 GET/POST,标准库 socket
WIFI_CONFIG_AP_FALLBACK = False # # WiFi 配网失败时,是否退回热点模式,并等待重新配网
WIFI_AP_FALLBACK_WAIT_SEC = 5 # 等待5秒后再检测STA/4G
WIFI_CONFIG_AP_TIMEOUT = 5 # 热点模式超时时间(秒)
WIFI_CONFIG_AP_ENABLED = False # 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/
# 这个地址需要和 /boot/wifi.ipv4_prefix 配合,才能正确访问。
# 比如说 /boot/wifi.ipv4_prefix 需要写成 192.168.66
# ===== TCP over SSL(TLS) 配置 =====
USE_TCP_SSL = True # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL
TCP_LINK_ID = 2 #
TCP_SSL_PORT = 50006 # TLS 端口(不一定必须 443,以服务器为准)
# SSL profile
SSL_ID = 1 # ssl_id=1
SSL_AUTH_MODE = 1 # 1=单向认证(验证服务器),2=双向
SSL_VERIFY_MODE = 1 # 0=不验(仅测试用);1=写入并使用 CA 证书
SSL_CERT_FILENAME = "server.pem" # 模组里证书名(MSSLCERTWR / MSSLCFG="cert" 用)
SSL_CERT_PATH = APP_DIR + "/server.pem" # 设备文件系统里 CA 证书路径(你自己放进去)
# MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留
MIPOPEN_TAIL = ",,0"
# ==================== 文件路径配置 ====================
CONFIG_FILE = "/root/laser_config.json"
LOG_FILE = APP_DIR + "/app.log"
BACKUP_BASE = APP_DIR + "/backups"
# ==================== 硬件配置 ====================
# UART配置
UART4G_DEVICE = "/dev/ttyS2"
UART4G_BAUDRATE = 115200
DISTANCE_SERIAL_DEVICE = "/dev/ttyS1"
DISTANCE_SERIAL_BAUDRATE = 9600
# I2C:板载 WiFi 方案固定 I2C5,引脚 A15(SCL) / A27(SDA),供 INA226 等
I2C_BUS_NUM = 5
INA226_ADDR = 0x40
# False=完全不访问 INA226(无电源计量板或未供电时避免 ~2.5s writeto 重试与底层 write failed 日志);量产有芯片时设为 True
INA226_ENABLE = True
# True=整总线 I2C scan 探测 INA226(在部分平台上极慢,可达 ~90s+);False=仅对 INA226_ADDR 快速探测(writeto 空写)
INA226_PROBE_FULL_BUS_SCAN = False
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, 296) # 硬编码的激光点坐标(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 坐标
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
USE_TRIANGLE_OFFSET = False # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
TRIANGLE_SIZE_RANGE = (8, 500)
# PnP 距离合理性检查(可选):超出范围时认为本次检测有误,回退圆心算法
# 设为 0 表示不启用(主要防线是单应矩阵 sx/sy 比值检查,无需提前知道距离)
# 如果射箭距离很固定,可设具体范围(如 min=2.5, max=6.0)作为额外保险
TRIANGLE_DISTANCE_MIN_M = 0.0 # 0=不启用下限检查
TRIANGLE_DISTANCE_MAX_M = 0.0 # 0=不启用上限检查
# 三角形检测兜底增强:CLAHE(更鲁棒但更慢)。颜色阈值修复后通常不需要,保持关闭以优先速度。
TRIANGLE_ENABLE_CLAHE_FALLBACK = False
# 三角形检测调试:保存 Otsu 二值化图像(临时调试用,定位后关闭)
TRIANGLE_SAVE_DEBUG_IMAGE = False
# 三角形颜色过滤阈值(三角形内部灰度判定)
# 如果三角形标记印刷较浅/环境较亮,可放宽:
# max_interior_gray: 三角形内部平均灰度上限(越大越宽松,90→130 适应浅色印刷)
# dark_pixel_gray: "暗像素"灰度判定阈值(越大越宽松,80→130)
# min_dark_ratio: 暗像素占比下限(越小越宽松,0.70→0.30)
TRIANGLE_MAX_INTERIOR_GRAY = 130
TRIANGLE_DARK_PIXEL_GRAY = 130
TRIANGLE_MIN_DARK_RATIO = 0.30
# 三角形相对对比度阈值:内部比周围暗多少灰度值才认为有效(0=禁用相对对比度)
TRIANGLE_MIN_CONTRAST_DIFF = 15
# 三角形形状约束容差(等腰直角判定松紧度)
# 增大可容忍轮廓轻微变形(印刷不均、阴影局部切角),减少"差一点点就失败"的漏检
# 建议范围:0.20(原始/严格) ~ 0.30(宽松);超过 0.35 容易误检非三角形
TRIANGLE_SHAPE_LEG_TOLERANCE = 0.25 # 两直角边长度比例容差(原 0.20)
TRIANGLE_SHAPE_HYP_TOLERANCE = 0.25 # 斜边与期望长度比例容差(原 0.20)
TRIANGLE_SHAPE_COS_TOLERANCE = 0.25 # 直角余弦绝对值上限(原 0.20,越小越严格)
# 三角形检测主超时(毫秒):join 等待子线程的最长时间。
# 整段 try_triangle_scoring 含「多路径二值化 + C(n,4) 四角评分 + 单应性 + PnP」,往往比黄心圆检测慢。
# 建议设为实测最坏耗时的 1.2 倍;超时后圆心检测仍会并行跑完,跑完后若三角形已结束则优先用三角形。
TRIANGLE_TIMEOUT_MS = 1000
# True=打印各阶段耗时(ms),用于定位瓶颈;稳定后可 False 减少日志
TRIANGLE_TIMING_LOG = True
# True=Stage2 每个子框内传统三角失败时打一条统计(Otsu/Adaptive 下轮廓数与各拒绝原因计数)
TRIANGLE_LOG_STAGE2_PATCH_REJECT = True
# 仅检出 3 个真实三角时:是否在预测位置附近做小 ROI(Otsu/adaptive)再搜第 4 个真实三角。
# False=跳过该搜索,直接用几何推算的虚拟第 4 点(offset_method=triangle_homography_3pt),省 ~10~120ms;若实测偏移可接受可关。
TRIANGLE_FOURTH_ROI_SEARCH_ENABLE = False
# ── 轻量锐化(Unsharp Mask)──────────────────────────────────────────────────
# 目的:轻度/中度模糊时增强边缘,让三角形轮廓更易被 approxPolyDP 检出。
# 严重运动模糊时反而会放大噪声,建议搭配 sharpness 检测自动触发(见下)。
# YOLO 裁切后图已较清晰时可 False,省去 Unsharp 开销并减轻振铃。
TRIANGLE_SHARPEN_ENABLE = False # False=关闭锐化(彻底跳过计算,最省时)
# 仅当帧清晰度(Laplacian 方差)低于此值时才锐化;高于此值说明图片本身够清晰,不动
# 0=总是锐化;建议 50~150;对应日志中 [TRI] sharpness=xxx
TRIANGLE_SHARPEN_THRESHOLD = 0.0 # 0=总是锐化(不做 Laplacian 判断,省去计算)
# Unsharp Mask 高斯核 sigma(越大锐化越强,通常 1.0~3.0)
TRIANGLE_SHARPEN_SIGMA = 2.0
# Unsharp Mask 强度系数(越大锐化越猛,通常 1.2~2.0;>2 易产生振铃)
TRIANGLE_SHARPEN_STRENGTH = 1.5
# 三角形检测用灰度来源(ROI 裁切、缩放到 img_det 之后;与 vision 一致按 RGB 输入)
# rgb — 常规 cv2.cvtColor RGB2GRAY
# v_suppress — HSV 的 V:亮度 >= TRIANGLE_HSV_V_SUPPRESS_ABOVE 的像素灰度强制为 255,压制黄/红/蓝等亮环后再走原有 Otsu 流水线
# fallback_v_suppress — 先用 rgb 跑 detect;若检出三角形 <3,再用 v_suppress 重跑一遍(省平均耗时,坏帧可多救一点)
# try_both — rgb 与 v_suppress 各完整跑一遍 detect_triangle_markers,取检出数更多一侧(平局保留 rgb);耗时约 2 倍,用于对比效果
TRIANGLE_GRAY_MODE = "v_suppress"
TRIANGLE_HSV_V_SUPPRESS_ABOVE = 200 # 0~255;偏高则环残留多,偏低则可能伤到暗三角边缘,建议 180~220 扫一圈
# 三角形检测性能/鲁棒性参数(偏向速度的默认值)
# 说明:
# - Otsu 是最快的全局阈值;adaptiveThreshold 更鲁棒但更慢
# - filtered 候选过多时,枚举 C(n,4) 会变慢,需限幅
TRIANGLE_EARLY_EXIT_CANDIDATES = 3 # 找到3个候选即停(第4个由几何推算);原来4需跑完全adaptive
TRIANGLE_ADAPTIVE_BLOCK_SIZES = (11,) # 只用1个block_size;原(11,21)跑两遍adaptive
TRIANGLE_MAX_FILTERED_FOR_COMBO = 10 # 参与四点组合评分的最大候选数(超过则截断到最可能的一部分)
# ROI 局部阈值:四个象限各自 Otsu+ 可选 ROI 内 adaptive),再合并候选。
# 顺序:紧接在全局 Otsu 之后、整图 adaptive 之前(见 triangle_target.detect_triangle_markers)。
# 用途:阴阳脸/大阴影下往往比「先整图 adaptive」更省时间且更稳;整图 adaptive 最慢,作补充。
#
# YOLO 已裁到靶区时,整幅小图上单一全局 Otsu 容易把环与四角揉在一个阈值里;可跳过第一轮「全局轮廓提取」,
# 直接进入下面四象限 ROI Otsu(仍会算全局 b_otsu 供 relaxed approxPolyDP 回退)。整图模式勿开。
TRIANGLE_SKIP_GLOBAL_OTSU_EXTRACT_ON_YOLO_ROI = True
TRIANGLE_ROI_ENABLED = False
TRIANGLE_ROI_MIN_CANDIDATES = 3 # 候选数低于此值时启用 ROI 局部阈值(需至少 3 个点才能三角解算)
TRIANGLE_ROI_OVERLAP_RATIO = 0.08 # 象限 ROI 的重叠比例(避免角标落在分割边界被切断)
TRIANGLE_ROI_USE_ADAPTIVE = False # ROI 内关闭 adaptive(只跑ROI Otsu,省去4×adaptive);遇到阴阳脸再开
# 多路径融合:不同二值化路径若得到相近中心(dedup 格点),累加 path_votes,后续优先参与四点组合。
TRIANGLE_MULTI_PATH_VOTE = True
# 失败回退(仍不足 TRIANGLE_FALLBACK_MIN_CANDIDATES 时按序尝试,每条仅在前序仍不足时执行)
TRIANGLE_FALLBACK_MIN_CANDIDATES = 3
# 对同一幅 Otsu 二值图用更宽松的 approxPolyDP,找回被“切角”的轮廓
TRIANGLE_FALLBACK_RELAXED_EPS = True
TRIANGLE_RELAXED_POLY_EPS_SCALE = 1.65
# Black-hat(顶帽逆):突出比周围暗的斑块,再 Otsu;对阴影/照度不均往往有效,略慢于纯 Otsu
TRIANGLE_FALLBACK_BLACKHAT = True
TRIANGLE_BLACKHAT_KERNEL_FRAC = 0.018 # 核大小 ≈ min(h,w)*frac,取奇数,范围约 [7, 31]
# ── YOLO(NPU) 靶环 ROI → 裁剪后再跑三角形(减小 CPU 处理面积)──────────────────
# 日志里 net_in=W×H 来自 .mud 模型(det.input_width/height),不是这里配置的。
TRIANGLE_YOLO_ROI_ENABLE = True
TRIANGLE_YOLO_MODEL_PATH = APP_DIR + "/model_270139.mud"
# 参与 ROI 的类别:多类时只填「整靶/靶环」的 id;不要填角标类,否则 union 仍可对,但 largest 会偏小。
TRIANGLE_YOLO_RING_CLASS_IDS = (0,)
TRIANGLE_YOLO_CONF_TH = 0.7
TRIANGLE_YOLO_IOU_TH = 0.45
# YOLO 首次/临界帧可能在高阈值下 0 框;启用后仅在 0 候选时用较低阈值重试一次。
# 后续仍会经过 min_box_side、ROI aspect、三角形几何校验,避免直接放大假阳性。
TRIANGLE_YOLO_RETRY_ON_EMPTY = True
TRIANGLE_YOLO_RETRY_CONF_TH = 0.5
TRIANGLE_YOLO_ROI_MARGIN_FRAC = 0.11
# union: 所有候选框外接矩形(一类多框:环+四角);largest: 只取面积最大的框
TRIANGLE_YOLO_ROI_MERGE_MODE = "union"
# native: Maix 已将框映射到相机分辨率;letterbox: 框在网络输入坐标需逆变换(重复映射会出细条 ROI)
TRIANGLE_YOLO_COORD_MODE = "native"
# 参与 ROI 合并前丢弃过小的框(低 conf 时边角 1×1 假阳性)
TRIANGLE_YOLO_MIN_BOX_SIDE_PX = 8
TRIANGLE_YOLO_REJECT_BAD_ROI = True
# try_triangle_scoring 收到 ROI 后裁剪的最小边长(像素),过小则退回整图
TRIANGLE_CROP_ROI_MIN_SIDE_PX = 64
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
TRIANGLE_YOLO_DRAW_ROI_ON_SHOT = True
# 物方采样调试:以靶心为中心,取半径 15cm 的圆周样本点,用于黑/白颜色对比
TRIANGLE_SAMPLE_RADIUS_CM = 15.0
TRIANGLE_SAMPLE_ANGLES_DEG = (0, 90, 180, 270)
TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
# 开机阶段预加载 YOLO detectordetect 使用 dual_buff=False,避免返回上一帧结果。
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
# "yolo" — 调 Stage2 黑三角模型得子框,再子框内传统提取(需 TRIANGLE_BLACK_YOLO_ENABLE=True)。
# "traditional" — 不调 Stage2 模型;仅在 Stage1 ROI 整幅上跑传统 detect_triangle_markers(与 yolo 路径对比用)。
TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE = "traditional"
# True 时每箭另打一枪端到端耗时:yolo_ring + yolo_black + try_triangle_scoring 墙钟(毫秒)
TRIANGLE_LOG_E2E_TIMING = True
TRIANGLE_BLACK_YOLO_ENABLE = True
TRIANGLE_BLACK_YOLO_MODEL_PATH = APP_DIR + "/model_270820.mud"
TRIANGLE_BLACK_YOLO_CLASS_IDS = (0,)
TRIANGLE_BLACK_YOLO_CONF_TH = 0.5
TRIANGLE_BLACK_YOLO_IOU_TH = 0.45
# Maix YOLOv5 detect 返回的框已映射到传入的 Stage1 裁切图坐标;contain/letterbox 是模型内部预处理。
TRIANGLE_BLACK_YOLO_COORD_MODE = "native"
# 子框相对 YOLO 框的扩展(在靶环裁切图坐标系下),利于传统算法取边
TRIANGLE_BLACK_YOLO_BOX_MARGIN_FRAC = 0.08
TRIANGLE_BLACK_YOLO_MIN_BOX_SIDE_PX = 6.0
# 子框传统检测不足 3 个时是否回退为「整幅靶环 ROI」上的原 detect_triangle_markers
TRIANGLE_BLACK_YOLO_FALLBACK_ON_PATCH_FAIL = True
# Stage2 子框内传统提取使用的灰度(有缩略时默认在 Stage1 全分辨率灰度上切片):
# "rgb" — 仅用 RGB→灰度(不再做 Unsharp、不做 V 抑制),最省 CPU(推荐子框已对准黑三角时)。
# "global" — 与整幅 ROI 三角流程同一张 gray(含 TRIANGLE_GRAY_MODE 的 v_suppress 与锐化);更稳但更耗时。
TRIANGLE_BLACK_YOLO_PATCH_GRAY_SOURCE = "rgb"
# Stage2 子框内轮廓→三角形:approxPolyDP 的 ε=周长×FRAC×mult。边模糊时略增大 FRAC 或保留多级 mult。
TRIANGLE_PATCH_APPROXPOLY_FRAC = 0.055
TRIANGLE_PATCH_APPROXPOLY_RELAX_MULTS = (1.0, 1.3, 1.65)
# Otsu/Adaptive 前对子框灰度轻模糊:0=关闭;3 或 5=Gaussian ksize(须为奇数),压锯齿利于收成 3 顶点
TRIANGLE_PATCH_PRE_BLUR_KSIZE = 0
TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT = True
# 每箭是否在日志中打印黑三角 detect 统计(raw/类过滤/是否在环内);调通后可 False 减日志
TRIANGLE_BLACK_YOLO_LOG_EACH_SHOT = True
# True=每次射箭将 Stage1 裁切图(黑三角模型输入)存为 JPEG;调试用,量产请 False
TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP = True
# 存盘目录;空字符串表示使用 PHOTO_DIR + "/stage2_roi"
TRIANGLE_BLACK_YOLO_ROI_CROP_DIR = ""
# 存盘 JPEG 上绘制 Stage2(黑三角 YOLO)最终子框(绿框 + s2_0… 标签)
TRIANGLE_BLACK_YOLO_SAVE_ROI_DRAW_BOXES = True
FLASH_LASER_WHILE_SHOOTING = False # 是否在拍摄时闪一下激光(True=闪,False=不闪)
FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒)
# ==================== 显示配置 ====================
LASER_COLOR = (0, 255, 0) # RGB颜色
LASER_THICKNESS = 1
LASER_LENGTH = 2
# ==================== 队列大小限制(防止内存泄漏) ====================
MAX_SEND_QUEUE_SIZE = 500 # 发送队列上限
MAX_TCP_PAYLOADS = 500 # AT TCP 载荷缓存上限
MAX_HTTP_EVENTS = 200 # AT HTTP 事件缓存上限
LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
# ==================== 图像保存配置 ====================
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
# ==================== OTA配置 ====================
MAX_BACKUPS = 5
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
LOG_BACKUP_COUNT = 5
# ==================== 引脚映射配置(板载 WiFiI2C5====================
PIN_MAPPINGS = {
"A18": "UART1_RX",
"A19": "UART1_TX",
"A29": "UART2_RX",
"A28": "UART2_TX",
"A15": "I2C5_SCL",
"A27": "I2C5_SDA",
"A24": "GPIOA24", # 电源板关机控制
}
# ==================== 电源配置 ====================
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
# 实机数据:正常放电约为正电流,插入充电线后约为负电流。
CHARGING_SHUTDOWN_ENABLED = True # True=充电时退出应用,False=关闭充电关机功能
CHARGING_DIAGNOSTIC_LOG_ENABLED = False
CHARGING_CHECK_INTERVAL_MS = 3000
CHARGING_CURRENT_THRESHOLD_MA = 100.0
CHARGING_CONFIRM_COUNT = 2
CHARGING_NOTIFY_TIMEOUT_MS = 30000
CHARGING_4G_UART_LOCK_TIMEOUT_SEC = 2.5
CHARGING_4G_PROMPT_TIMEOUT_MS = 1500
CHARGING_4G_CONFIRM_TIMEOUT_MS = 1000
CHARGING_EXIT_SCRIPT = APP_DIR + "/charging_exit.sh"
BATTERY_SOC_LPF_ALPHA = 0.5
BATTERY_SOC_AVG_WINDOW = 5
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
激光模块开关测试脚本
平台:MaixPy (Sipeed MAIX)
功能:每2秒循环开启/关闭激光,验证硬件是否正常响应
作者:ZZH
"""
from maix import uart, pinmap, time
# === 配置 ===
UART_PORT = "/dev/ttyS1" # 激光模块连接的串口(通常是 UART1)
BAUDRATE = 9600 # 波特率(根据你的模块调整)
# 引脚映射(根据你硬件连接修改)
pinmap.set_pin_function("A18", "UART1_RX") # RX
pinmap.set_pin_function("A19", "UART1_TX") # TX
# 激光控制指令(根据你的模块协议)
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])
# === 初始化串口 ===
print("🔧 正在初始化激光串口...")
laser_uart = uart.UART(UART_PORT, BAUDRATE)
# === 辅助函数 ===
def send_laser_cmd(cmd, name):
"""发送激光指令并尝试读取回包"""
print(f"➡️ 发送指令: {name}")
laser_uart.write(cmd)
time.sleep_ms(50) # 等待模块处理
# 尝试读取回包(非必须,部分模块无返回)
resp = laser_uart.read(20)
if resp:
print(f"✅ 收到回包 ({len(resp)}字节): {resp.hex()}")
else:
print("🔇 无回包(正常或模块不支持)")
# === 主测试循环 ===
print("\n🚀 开始激光开关测试(按 Ctrl+C 停止)")
print("周期:开1秒 → 关1秒\n")
try:
while True:
# 开启激光
send_laser_cmd(LASER_ON_CMD, "LASER ON")
time.sleep(1.0) # 持续开启 1 秒
# 关闭激光
send_laser_cmd(LASER_OFF_CMD, "LASER OFF")
time.sleep(1.0) # 关闭 1 秒
except KeyboardInterrupt:
print("\n🛑 测试被用户中断")
# 最终确保激光关闭
laser_uart.write(LASER_OFF_CMD)
print("✅ 已发送最终关闭指令")
+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()
-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()
+248
View File
@@ -0,0 +1,248 @@
from maix import image, time
from logger_manager import logger_manager
from camera_manager import camera_manager
_USE_CV = False
try:
import cv2
import numpy as np
_USE_CV = True
except ImportError:
pass
WIDTH = 640
HEIGHT = 480
THRESHOLD = 100
RED_RATIO = 1.5
SEARCH_RADIUS = 80
TRACK_RADIUS = 30
MIN_PIXELS = 3
COARSE_STEP = 2
STABLE_COUNT = 2
MAX_SKIP_FRAMES = 5
# Temporal smoothing
_EMA_ALPHA = 0.35
_GATE_PX = 10
_FRAME_INTERVAL_MS = 50
_prev_smoothed = None
def _red_weighted_centroid(r_ch, g_ch, b_ch, mask, x0, y0):
y_ids, x_ids = np.where(mask)
if len(y_ids) == 0:
return None
r_vals = r_ch[y_ids, x_ids].astype(np.float64)
g_vals = g_ch[y_ids, x_ids].astype(np.float64)
b_vals = b_ch[y_ids, x_ids].astype(np.float64)
w = r_vals - np.maximum(g_vals, b_vals)
w = np.clip(w, 0, None)
w = w * w
total_w = w.sum()
if total_w < 1e-6:
return None
cx = (x_ids.astype(np.float64) * w).sum() / total_w + x0
cy = (y_ids.astype(np.float64) * w).sum() / total_w + y0
return (float(cx), float(cy))
def find_ellipse(img_cv, cx, cy, roi_r, th, ratio):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
roi = img_cv[y1:y2, x1:x2]
if roi.size == 0:
return None
r = roi[:, :, 0].astype(np.int32)
g = roi[:, :, 1].astype(np.int32)
b = roi[:, :, 2].astype(np.int32)
mask = (r > th) & (r > g * ratio) & (r > b * ratio)
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
combined = (mask | oe).astype(np.uint8) * 255
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) < 5:
return None
cnt = largest.copy()
for pt in cnt:
pt[0][0] += x1
pt[0][1] += y1
ellipse_valid = len(cnt) >= 5
if ellipse_valid:
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
return _red_weighted_centroid(
img_cv[:, :, 0], img_cv[:, :, 1], img_cv[:, :, 2],
mask_ellipse > 0, 0, 0
)
M = cv2.moments(cnt)
if M["m00"] > 0:
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
return None
def is_red(r, g, b, th, ratio):
if r > th and r > g * ratio and r > b * ratio:
return True
if (r > 200 and g > 200 and b > 200 and r >= g and r >= b
and (r - g) > 10 and (r - b) > 10):
return True
return False
def find_brightest_bytes(frame, cx, cy, roi_r, th, ratio):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
data = frame.to_bytes()
best_score = 0
best_x = (x1 + x2) // 2
best_y = (y1 + y2) // 2
found_any = False
for y in range(y1, y2, COARSE_STEP):
for x in range(x1, x2, COARSE_STEP):
idx = (y * WIDTH + x) * 3
r = data[idx]
g = data[idx + 1]
b = data[idx + 2]
if is_red(r, g, b, th, ratio):
score = r + g + b
dx = x - cx
dy = y - cy
dist_decay = max(0.5, 1.0 - ((dx * dx + dy * dy) ** 0.5 / roi_r) * 0.5)
score *= dist_decay
if score > best_score:
best_score = score
best_x = x
best_y = y
found_any = True
if not found_any:
return None
sf = 4
fx1 = max(x1, best_x - sf)
fx2 = min(x2, best_x + sf + 1)
fy1 = max(y1, best_y - sf)
fy2 = min(y2, best_y + sf + 1)
sum_x = 0.0
sum_y = 0.0
total_w = 0.0
count = 0
for y in range(fy1, fy2):
for x in range(fx1, fx2):
idx = (y * WIDTH + x) * 3
r = data[idx]
g = data[idx + 1]
b = data[idx + 2]
if is_red(r, g, b, th, ratio):
w = r + g + b
sum_x += x * w
sum_y += y * w
total_w += w
count += 1
if count < MIN_PIXELS:
return (float(best_x), float(best_y))
return (float(sum_x / total_w), float(sum_y / total_w))
def _ema_filter(pos, alpha=_EMA_ALPHA):
global _prev_smoothed
if _prev_smoothed is None:
_prev_smoothed = pos
return pos
sx = alpha * pos[0] + (1 - alpha) * _prev_smoothed[0]
sy = alpha * pos[1] + (1 - alpha) * _prev_smoothed[1]
_prev_smoothed = (sx, sy)
return _prev_smoothed
def _gated(pos, gate_px=_GATE_PX):
global _prev_smoothed
if _prev_smoothed is None:
return True
dx = pos[0] - _prev_smoothed[0]
dy = pos[1] - _prev_smoothed[1]
return (dx * dx + dy * dy) <= gate_px * gate_px
def get_stable_laser_point(timeout_ms=15000, stable_count=STABLE_COUNT):
global _prev_smoothed
_prev_smoothed = None
try:
last_raw = None
stable = 0
start = time.ticks_ms()
cx, cy = WIDTH // 2, HEIGHT // 2
track_count = 0
skip_count = 0
while True:
if abs(time.ticks_diff(time.ticks_ms(), start)) > timeout_ms:
_prev_smoothed = None
return None
frame = camera_manager.read_frame()
if frame is None:
time.sleep_ms(10)
continue
if track_count > 0 and _prev_smoothed is not None:
search_cx = int(_prev_smoothed[0])
search_cy = int(_prev_smoothed[1])
search_r = TRACK_RADIUS
else:
search_cx = cx
search_cy = cy
search_r = SEARCH_RADIUS
pos_bright = find_brightest_bytes(frame, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
pos = pos_bright
if _USE_CV:
img_cv = image.image2cv(frame, False, False)
pos_ellipse = find_ellipse(img_cv, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
if pos_ellipse is not None:
pos = pos_ellipse
if pos is not None:
skip_count = 0
track_count += 1
filtered = _ema_filter(pos)
if last_raw is not None:
dx = abs(filtered[0] - last_raw[0])
dy = abs(filtered[1] - last_raw[1])
if dx <= 2 and dy <= 2:
stable += 1
else:
stable = 1
else:
stable = 1
last_raw = filtered
if logger_manager.logger:
logger_manager.logger.info(f"pos:{pos},filtered:{filtered},stable:{stable}")
if stable >= stable_count:
result = (int(filtered[0]), int(filtered[1]))
_prev_smoothed = None
return result
else:
skip_count += 1
if logger_manager.logger:
logger_manager.logger.info(f"find_brightest_bytes None, skip={skip_count}, track={track_count}, search_center=({search_cx},{search_cy}), search_r={search_r}")
if skip_count > MAX_SKIP_FRAMES:
_prev_smoothed = None
track_count = 0
stable = 0
last_raw = None
time.sleep_ms(_FRAME_INTERVAL_MS)
finally:
_prev_smoothed = None
+1291
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(maxsize=config.LOG_QUEUE_MAXSIZE)
# 确保日志文件所在的目录存在
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
+418 -797
View File
File diff suppressed because it is too large Load Diff
+2576
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# OTA 更新脚本 - 使用 curl 断点下载
# 用法: sh ota_curl.sh <下载URL>
# 示例: sh ota_curl.sh http://example.com/maix-t11-v2.15.1.zip
set -e
APP_DIR="/maixapp/apps/t11"
BACKUP_BASE="$APP_DIR/backups"
TMP_DIR="/tmp/ota_curl"
PENDING_FILE="$APP_DIR/ota_pending.json"
if [ $# -lt 1 ]; then
echo "用法: $0 <下载URL>"
exit 1
fi
OTA_URL="$1"
FILENAME=$(basename "$OTA_URL" | sed 's/?.*//')
[ -z "$FILENAME" ] && FILENAME="update.zip"
mkdir -p "$TMP_DIR" "$BACKUP_BASE"
# 1. 断点下载
echo "[OTA] 开始下载: $OTA_URL"
echo "[OTA] 保存到: $TMP_DIR/$FILENAME"
curl -C - -L --retry 3 --retry-delay 5 -o "$TMP_DIR/$FILENAME" "$OTA_URL"
echo "[OTA] 下载完成"
# 2. 备份当前目录
TIMESTAMP=$(date +%Y%m%d_%H%M%S 2>/dev/null || echo "00000000_000000")
BACKUP_DIR="$BACKUP_BASE/backup_$TIMESTAMP"
mkdir -p "$BACKUP_DIR"
echo "[OTA] 备份到: $BACKUP_DIR"
for f in "$APP_DIR"/*.py "$APP_DIR"/*.json "$APP_DIR"/*.xml "$APP_DIR"/*.yaml "$APP_DIR"/*.pem "$APP_DIR"/*.mud "$APP_DIR"/*.so "$APP_DIR"/S99archery; do
[ -f "$f" ] && cp "$f" "$BACKUP_DIR/"
done
echo "[OTA] 备份完成"
# 3. 解压并替换文件
echo "[OTA] 开始更新..."
if echo "$FILENAME" | grep -qi '\.zip$'; then
unzip -q -o "$TMP_DIR/$FILENAME" -d "$APP_DIR/"
else
cp "$TMP_DIR/$FILENAME" "$APP_DIR/"
fi
sync
# 4. 写入 pending 文件(用于崩溃恢复)
echo '{"ts":0,"url":"'"$OTA_URL"'","backup_dir":"'"$BACKUP_DIR"'","restart_count":0,"max_restarts":3}' > "$PENDING_FILE"
sync
echo "[OTA] 更新完成,准备重启..."
# 5. 重启
sleep 1
reboot
+1343
View File
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
电源管理模块INA226
提供电压电流监测和充电状态检测
"""
import config
import os
import subprocess
from logger_manager import logger_manager
from maix import time as maix_time
_INA226_PRESENT = None
def _ina226_ready() -> bool:
"""
是否允许访问 INA226
重要
- 这里刻意不做任何 I2C 探测/读写
- 经验上 INA226 未供电/未响应时I2C readfrom_mem 可能直接触发底层崩溃SIGSEGVtry/except 无法拦截
- 因此只在开机 init_ina226() 成功后才允许后续读电压/电流
"""
return bool(getattr(config, "INA226_ENABLE", True)) and (_INA226_PRESENT is True)
def write_register(reg, value):
"""写入INA226寄存器"""
from hardware import hardware_manager
logger = logger_manager.logger
data = [(value >> 8) & 0xFF, value & 0xFF]
# 某些底层驱动在失败时只打印 “write failed” 并返回 -1,而不是抛异常;
# 为避免误判“初始化成功”导致后续 readfrom_mem SIGSEGV,这里把失败显式转成异常。
ret = hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
if isinstance(ret, int) and ret < 0:
if logger:
logger.error(f"[INA226] writeto_mem 失败: addr=0x{config.INA226_ADDR:02X} reg=0x{reg:02X} ret={ret}")
raise OSError(ret)
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 芯片:配置模式 + 校准值"""
global _INA226_PRESENT
logger = logger_manager.logger
if not getattr(config, "INA226_ENABLE", True):
if logger:
logger.info("[INA226] INA226_ENABLE=False,跳过初始化与 I2C 探测")
# 显式标记不可用,避免后续误读
_INA226_PRESENT = False
return False
try:
# 仅通过“写寄存器成功”来判定可用,避免额外的读操作触发底层崩溃
write_register(config.REG_CONFIGURATION, 0x4527)
write_register(config.REG_CALIBRATION, config.CALIBRATION_VALUE)
_INA226_PRESENT = True
return True
except Exception as e:
_INA226_PRESENT = False
if logger:
logger.error(f"[INA226] 初始化失败:{e}")
return False
def get_bus_voltage():
"""读取总线电压(单位:V)。未探测到 INA226 或读失败时返回 0.0(上报用,避免 null)。"""
logger = logger_manager.logger
if not _ina226_ready():
return 0.0
try:
raw = read_register(config.REG_BUS_VOLTAGE)
return raw * 1.25 / 1000
except Exception as e:
if logger:
logger.error(f"[INA226] 读取电压失败:{e}")
return 0.0
def get_current():
"""
读取电流单位mA
当前电源板实测正数表示放电负数表示充电
INA226 电流计算公式
Current = (Current Register Value) × Current_LSB
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
"""
try:
if not _ina226_ready():
return 0.0
raw = read_register(config.REG_CURRENT)
# INA226 电流寄存器是16位有符号整数
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。
# 计算 Current_LSB(根据 CALIBRATION_VALUE
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
# 处理有符号数:如果最高位为1,转换为负数
if raw & 0x8000:
signed_raw = raw - 0x10000 # 转换为有符号整数
else:
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 < -abs(float(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):
"""
根据电压估算电池百分比高密度查表插值 + 滤波
- 电压先做 5 点移动平均抑制瞬时抖动
- SOC 再做一阶低通抑制跳电量
注意
- 该方法仍是开路电压SOC的近似负载较大/瞬时大电流时电压会下沉SOC 会偏低
- 滤波会带来滞后电量变化会更平滑但更新更慢
"""
if voltage is None:
return 0
try:
v = float(voltage)
except Exception:
return 0
if v <= 0:
return 0
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
class BatteryMonitor:
"""
电压SOC 估算器查表 + 线性插值 + 双重滤波
说明
- 表为单节锂电静态电压近似曲线不同电池/温度/老化会有偏差
- 这里不区分充电/放电曲线滞后主要用于显示电量/粗略判断
"""
def __init__(self, avg_window: int = 5, alpha: float = 0.2):
# 电压-SOC对照表(电压从高到低)
self.voltages = [
4.20, 4.15, 4.10, 4.05, 4.00,
3.95, 3.90, 3.88, 3.85, 3.82,
3.80, 3.78, 3.75, 3.72, 3.70,
3.65, 3.60, 3.55, 3.50, 3.45,
3.40, 3.35, 3.30, 3.20, 2.50,
]
self.socs = [
100, 98, 95, 90, 85,
80, 75, 72, 68, 64,
60, 56, 52, 48, 44,
38, 32, 26, 20, 14,
10, 6, 3, 1, 0,
]
self.avg_window = max(1, int(avg_window))
self.alpha = float(alpha) if alpha is not None else 0.2
if not (0.0 < self.alpha <= 1.0):
self.alpha = 0.2
self.voltage_history = []
self.last_soc = 50.0
def _voltage_to_soc_raw(self, voltage: float) -> float:
# 越界
if voltage >= self.voltages[0]:
return 100.0
if voltage <= self.voltages[-1]:
return 0.0
# 表是降序,二分查找
left, right = 0, len(self.voltages) - 1
while left <= right:
mid = (left + right) // 2
vm = self.voltages[mid]
if vm == voltage:
return float(self.socs[mid])
elif vm < voltage:
right = mid - 1
else:
left = mid + 1
# 线性插值:right 在高电压侧,left 在低电压侧(降序表)
# 例:voltages = [4.2,4.15,...],则 v_high=voltages[right] >= voltage >= voltages[left]=v_low
v_high, v_low = float(self.voltages[right]), float(self.voltages[left])
soc_high, soc_low = float(self.socs[right]), float(self.socs[left])
if abs(v_high - v_low) < 1e-9:
return soc_low
soc = soc_low + (voltage - v_low) * (soc_high - soc_low) / (v_high - v_low)
return soc
def get_soc(self, raw_voltage: float) -> float:
# 1) 电压滤波(移动平均)
self.voltage_history.append(float(raw_voltage))
if len(self.voltage_history) > self.avg_window:
self.voltage_history.pop(0)
voltage = sum(self.voltage_history) / float(len(self.voltage_history))
# 2) 查表插值
raw_soc = self._voltage_to_soc_raw(voltage)
# 3) SOC 低通滤波
a = self.alpha
self.last_soc = a * raw_soc + (1.0 - a) * float(self.last_soc)
# clip
if self.last_soc < 0.0:
self.last_soc = 0.0
if self.last_soc > 100.0:
self.last_soc = 100.0
return float(self.last_soc)
# 模块级单例:保留历史,实现平滑(进程重启会重置)
_BATTERY_MONITOR = BatteryMonitor(
avg_window=int(getattr(config, "BATTERY_SOC_AVG_WINDOW", 5)),
alpha=float(getattr(config, "BATTERY_SOC_LPF_ALPHA", 0.2)),
)
+33
View File
@@ -0,0 +1,33 @@
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgIUAZIGjFLTekYI+IIquQ/87qLDuNAwDQYJKoZIhvcNAQEL
BQAwXjELMAkGA1UEBhMCQ04xDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh
bDEOMAwGA1UECgwFTG9jYWwxHzAdBgNVBAMMFnd3dy5zaGVsaW5neGluZ3FpdS5j
b20wIBcNMjYwNDA3MDc0NDI2WhgPMjEyNjAzMTQwNzQ0MjZaMF4xCzAJBgNVBAYT
AkNOMQ4wDAYDVQQIDAVMb2NhbDEOMAwGA1UEBwwFTG9jYWwxDjAMBgNVBAoMBUxv
Y2FsMR8wHQYDVQQDDBZ3d3cuc2hlbGluZ3hpbmdxaXUuY29tMIICIjANBgkqhkiG
9w0BAQEFAAOCAg8AMIICCgKCAgEAvKRcWr8QeT1OzhMbWlHmqxmduE+e7r2Oet9I
mU4O888U1X1YKaIDnq+zqRCNteid3jrOWucDLReZzNnrZ4l3Jq9nbWuTwj9Y9vCq
ahW3K3BOhnuJ+qvqX2Izn1Z9iNCFhXnUaFy8+iP0nJNNIRXwg7ioKbY6+SaTbBzI
vfG33MjOmwnQlqZzdGyNpvieO9XzqVyRxeDen/LJf4Z1NocP2rOjqQC3dIDXOfBt
/ZOZymb4XwQ9b/t+6WJn9Zfycw0tp/7GqI+vqLDUMpipO4ahmybJPO02IhokZ09t
BnCXe0enLnMAshIipTxSaJEick9HnQVSUzF+9A1F0cCFAhS8cM/04aksfYsJD2xj
riiVHVoVo6tb0GJSCM+b0j9ObH9bDx3DKfy9EcqP25mJxWQTuT8G0oiyuxE5knjA
HL7yjwd5gVSuig+ACnxE3vITeVKtvyep7sD4tJqkN93t7OMeBRFMGsYpJ8w+8u6X
+9/RmMcOnuNcT/4HrOuAtlAnM1D44MSI1RLaOCJJ9evqhpWdktfn2Uv4gCnaTjUr
OiEU/G+lquST2kggjbcReLqkk+7yN3XkaR9dun4iV35WfEo1ENThVhLPGV61LaJq
PwbjltQlkcAFPJ1GJyE9FVO79bB51d0w/rlI/CcDUpTRMaXR35EmTjxvXOr/a/XI
56GUNaUCAwEAAaN2MHQwHQYDVR0OBBYEFH1HCDm4N7LMhIX2Fb2FXAfdyhwQMB8G
A1UdIwQYMBaAFH1HCDm4N7LMhIX2Fb2FXAfdyhwQMA8GA1UdEwEB/wQFMAMBAf8w
IQYDVR0RBBowGIIWd3d3LnNoZWxpbmd4aW5ncWl1LmNvbTANBgkqhkiG9w0BAQsF
AAOCAgEAG/PMwXCXJOaqCpU/LaY6w04ue6wk95RbPXf4JH4CrrLUfgyUmFlNNQPA
LuZSBRI6KUGkTvzuz/3ofZHVEin3CyE5NadB3UItpfA4Wl4r3jMPifIgnA/NT8xo
GE1gYaDbcfJNE8jy6GebjZekbVrPvCY9YgcUT2AmW5fcbnCTy+/iC7lf9MvvqHTJ
H5zvOp5nyWJYWYsvvif3Y7dp00ytg9I8/LSgUspKwB8qSWPWV8z4WsV6sc1mNqVS
nFBDkgzZxr4ZYlhVLzbSoab8D4A/z6riEMqv4S+oF5VkaJLhsN8vgHh9aPspCC3Q
zhcosH8XmNmJmT/X64FhhRqxAqX65WanVQABtBS/vsC+FAQDGMb3RkZSbLEnIlgj
bx/6bSkhHl+J2xIqA7tLvYhRSvM3H12X7VSVc+tkVzI5JoUSugZLxxRDGpYgkvRz
SPFCqb9eTn5ES5gnQX6+E+f/E/WQTmadolSbEppdxNZW7AaIUdQo0aFxFwctwhA2
YNUG9oW2TXAZjSECyTo28NFkFfwBhpHWigFCANNCd8Nrn0k0YMuJOkqW5e4w3/24
/IxM/C9K7aAx4S1XZ16Nvh5pZQduEGKTSUYMJ/uV26Mf4ZGroUfGB9tBguK5rYbL
UlRvtU9mkZPK04GbLsoo+8tZTDRtkuCiC19xk33XiitZrmavc24=
-----END CERTIFICATE-----
-56
View File
@@ -1,56 +0,0 @@
import configparser, os
def parse_apps_info():
info_path = "/maixapp/apps/app.info"
conf = configparser.ConfigParser()
conf.read(info_path)
version = conf["basic"]["version"]
apps = {}
for id in list(conf.keys()):
if id in ["basic", "DEFAULT"]:
continue
apps[id] = conf[id]
return apps
def list_apps():
apps = parse_apps_info()
print(f"APP num: {len(apps)}")
for i, (id, info) in enumerate(apps.items()):
name_zh = info.get("name[zh]", "")
print(f"{i + 1}. [{info['name']}] {name_zh}:")
print(f" id: {id}")
print(f" exec: {info['exec']}")
print(f" author: {info['author']}")
print(f" desc: {info['desc']}")
print(f" desc_zh: {info.get('desc', 'None')}")
print("")
def get_curr_autostart_app():
path = "/maixapp/auto_start.txt"
if os.path.exists(path):
with open(path, "r") as f:
app_id = f.readline().strip()
return app_id
return None
def set_autostart_app(app_id):
path = "/maixapp/auto_start.txt"
if not app_id:
if os.path.exists(path):
os.remove(path)
return
with open(path, "w") as f:
f.write(app_id)
os.sync()
if __name__ == "__main__":
new_autostart_app_id = "t11" # change to app_id you want to set
# new_autostart_app_id = None # remove autostart
list_apps()
print("Before set autostart appid:", get_curr_autostart_app())
set_autostart_app(new_autostart_app_id)
print("Current autostart appid:", get_curr_autostart_app())
+546
View File
@@ -0,0 +1,546 @@
import os
import threading
import time as time_std
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 triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
from maix import image, time
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
_tri_calib_cache = None
def _get_triangle_calib():
"""返回 (K, dist, marker_positions);首次调用时从磁盘加载并缓存。"""
global _tri_calib_cache
if _tri_calib_cache is not None:
return _tri_calib_cache
calib_path = getattr(config, "CAMERA_CALIB_XML", "")
tri_json = getattr(config, "TRIANGLE_POSITIONS_JSON", "")
if not (os.path.isfile(calib_path) and os.path.isfile(tri_json)):
_tri_calib_cache = (None, None, None)
return _tri_calib_cache
K, dist = load_camera_from_xml(calib_path)
pos = load_triangle_positions(tri_json)
_tri_calib_cache = (K, dist, pos)
return _tri_calib_cache
def preload_triangle_calib():
"""
启动阶段预加载三角形标定与坐标文件避免首次射箭触发时的读盘/解析开销
"""
try:
_get_triangle_calib()
except Exception:
# 预加载失败不影响主流程;射箭时会再次按需尝试
pass
def analyze_shot(frame, laser_point=None):
"""
分析射箭结果算法部分可迁移到C++
:param frame: 图像帧
:param laser_point: 激光点坐标 (x, y)
:return: 包含分析结果的字典
优先级
1. 三角形单应性USE_TRIANGLE_OFFSET=True 成功则直接返回跳过圆形检测
2. 圆形检测三角形不可用或识别失败时兜底
"""
logger = logger_manager.logger
from datetime import datetime
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
laser_point_method = None
distance_m_first = 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}")
else:
# 动态模式:先做一次无激光点检测以估算距离,再推算激光点
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None
if distance_m_first and distance_m_first > 0:
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
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
# ── Step 2: 提前转换一次图像,两个检测线程共享(只读)────────────────────────
img_cv = image.image2cv(frame, False, False)
# ── Step 3: 检查三角形是否可用 ────────────────────────────────────────────────
use_tri = getattr(config, "USE_TRIANGLE_OFFSET", False)
K = dist_coef = pos = None
if use_tri:
K, dist_coef, pos = _get_triangle_calib()
use_tri = K is not None and dist_coef is not None and pos
def _build_circle_result(cdata, yolo_roi_xyxy=None):
"""从圆形检测结果构建 analyze_shot 返回值。"""
r_img, center, radius, method, best_radius1, ellipse_params = cdata
dx, dy = None, None
d_m = distance_m_first
if center and radius:
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
d_m = estimate_distance(best_radius1) if best_radius1 else distance_m_first
out = {
"success": True,
"result_img": r_img,
"center": center, "radius": radius, "method": method,
"best_radius1": best_radius1, "ellipse_params": ellipse_params,
"dx": dx, "dy": dy, "distance_m": d_m,
"laser_point": laser_point, "laser_point_method": laser_point_method,
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
"distance_method": "yellow_radius",
}
if yolo_roi_xyxy is not None:
out["yolo_roi_xyxy"] = yolo_roi_xyxy
return out
if not use_tri:
# 三角形未配置,直接跑圆形检测
return _build_circle_result(
detect_circle_v3(frame, laser_point, img_cv=img_cv)
)
# ── Step 4: 先独占跑三角形,超时或失败后再跑圆形(不与圆心并行,避免抢 CPU)──
roi_xyxy = None
yolo_ring_ms = 0.0
yolo_black_ms = 0.0
if getattr(config, "TRIANGLE_YOLO_ROI_ENABLE", False):
_t_yolo_ring = time_std.perf_counter()
try:
from target_roi_yolo import try_get_triangle_roi_from_yolo
roi_xyxy = try_get_triangle_roi_from_yolo(
frame, img_cv.shape[1], img_cv.shape[0], logger
)
except Exception as e:
if logger:
logger.warning(f"[YOLO-ROI] {e}")
finally:
yolo_ring_ms = (time_std.perf_counter() - _t_yolo_ring) * 1000.0
_loc_mode = str(
getattr(config, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
).lower().strip()
if _loc_mode not in ("yolo", "traditional"):
_loc_mode = "yolo"
black_boxes_work = None
_run_stage2_black_yolo = (
_loc_mode == "yolo"
and getattr(config, "TRIANGLE_BLACK_YOLO_ENABLE", False)
and roi_xyxy is not None
)
if _run_stage2_black_yolo:
_t_yolo_black = time_std.perf_counter()
try:
from target_roi_yolo import try_black_triangle_boxes_work
black_boxes_work = try_black_triangle_boxes_work(
img_cv, roi_xyxy, logger
)
except Exception as e:
if logger:
logger.warning(f"[YOLO-BLACK] {e}")
finally:
yolo_black_ms = (time_std.perf_counter() - _t_yolo_black) * 1000.0
elif (
logger
and _loc_mode == "traditional"
and roi_xyxy is not None
and getattr(config, "TRIANGLE_BLACK_YOLO_ENABLE", False)
):
logger.info(
"[TRI] TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE=traditional:跳过 Stage2 黑三角 YOLO"
"仅在 Stage1 裁切内跑整幅传统三角检测"
)
tri_result = {}
def _run_triangle():
try:
logger.info(f"[TRI] begin {datetime.now()}")
logger.info(f"[TRI] K: {K}, dist: {dist_coef}, pos: {pos}, {datetime.now()}")
_t_wall_try = time_std.perf_counter()
tri = try_triangle_scoring(
img_cv, (x, y), pos, K, dist_coef,
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
roi_xyxy=roi_xyxy,
black_yolo_boxes_work=black_boxes_work,
yolo_ring_ms=yolo_ring_ms,
yolo_black_ms=yolo_black_ms,
)
_wall_try_ms = (time_std.perf_counter() - _t_wall_try) * 1000.0
if logger and bool(getattr(config, "TRIANGLE_LOG_E2E_TIMING", True)):
_e2e = float(yolo_ring_ms) + float(yolo_black_ms) + float(_wall_try_ms)
logger.info(
f"[TRI] timing_e2e_triangle_ms={_e2e:.1f} "
f"(yolo_ring={float(yolo_ring_ms):.1f} yolo_black={float(yolo_black_ms):.1f} "
f"try_triangle_wall={_wall_try_ms:.1f} locate_mode={_loc_mode})"
)
logger.info(f"[TRI] tri: {tri}, {datetime.now()}")
tri_result['data'] = tri
except Exception as e:
logger.error(f"[TRI] 三角形路径异常: {e}")
tri_result['data'] = {'ok': False}
t_tri = threading.Thread(target=_run_triangle, daemon=True)
t_tri.start()
tri_timeout_s = float(getattr(config, "TRIANGLE_TIMEOUT_MS", 2000)) / 1000.0
t_tri.join(timeout=tri_timeout_s)
def _tri_ok_validated(tri):
try:
import numpy as _np
ok = bool(tri.get('ok'))
if not ok:
return False
dxv = tri.get("dx_cm")
dyv = tri.get("dy_cm")
H = tri.get("homography")
if not _np.isfinite(dxv) or not _np.isfinite(dyv):
logger.warning("[TRI] dx/dy 非有限值,判定为误检")
return False
if H is not None and not _np.all(_np.isfinite(H)):
logger.warning("[TRI] 单应矩阵含非有限值,判定为误检")
return False
# ── 检查1:单应矩阵 x/y 缩放比(靶标是正方形,H[0,0]≈H[1,1])──
if H is not None:
sx = abs(float(H[0, 0]))
sy = abs(float(H[1, 1]))
if sy > 1e-6:
hxy_ratio = sx / sy
# 正常拍摄比值在 0.6~1.7 之间;超出则四点严重变形,说明有误检
if not (0.6 <= hxy_ratio <= 1.7):
logger.warning(
f"[TRI] 单应矩阵 sx/sy={hxy_ratio:.2f} 偏差过大,判定为误检,回退圆心"
)
return False
# ── 检查2:可选配置距离上下限(写 0 表示不启用)──────────────────
dist_m = tri.get("distance_m")
if dist_m is not None:
try:
import config as _vc
d_min = float(getattr(_vc, "TRIANGLE_DISTANCE_MIN_M", 0.0))
d_max = float(getattr(_vc, "TRIANGLE_DISTANCE_MAX_M", 0.0))
except Exception:
d_min, d_max = 0.0, 0.0
if d_min > 0 and d_max > d_min:
if not (d_min <= dist_m <= d_max):
logger.warning(
f"[TRI] 距离 {dist_m:.2f}m 超出配置范围 [{d_min},{d_max}],判定为误检,回退圆心"
)
return False
return True
except Exception:
return bool(tri.get('ok'))
def _build_tri_result(tri, yolo_roi_xyxy=None):
out = {
"success": True,
"result_img": frame,
"center": None, "radius": None,
"method": "triangle_homography",
"best_radius1": None, "ellipse_params": None,
"dx": tri["dx_cm"], "dy": tri["dy_cm"],
"distance_m": tri.get("distance_m") or distance_m_first,
"laser_point": laser_point, "laser_point_method": laser_point_method,
"offset_method": tri.get("offset_method") or "triangle_homography",
"distance_method": tri.get("distance_method") or "pnp_triangle",
"tri_markers": tri.get("markers", []),
"tri_markers_completed": tri.get("markers_completed", []),
"tri_homography": tri.get("homography"),
}
if yolo_roi_xyxy is not None:
out["yolo_roi_xyxy"] = yolo_roi_xyxy
return out
# 三角形在超时内完成
if not t_tri.is_alive():
tri = tri_result.get('data', {})
if _tri_ok_validated(tri):
logger.info(f"[TRI] end {datetime.now()} — 使用三角形结果(dx={tri['dx_cm']:.2f},dy={tri['dy_cm']:.2f}cm)")
return _build_tri_result(tri, roi_xyxy)
logger.info(f"[TRI] end(tri_failed, fallback circle) {datetime.now()}")
else:
logger.warning(f"[TRI] 超时 {tri_timeout_s:.2f}s 仍未结束,启动圆心算法(三角形仍在后台)")
# 三角形超时或失败 → 跑圆心;圆心跑完后再检查三角形是否已结束
try:
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
except Exception as e:
logger.error(f"[CIRCLE] 圆形检测异常: {e}")
cdata = (frame, None, None, None, None, None)
# 圆心跑完后,若三角形恰好已经结束且结果有效,优先用三角形
if not t_tri.is_alive():
tri = tri_result.get('data', {})
if _tri_ok_validated(tri):
logger.info(f"[TRI] 圆心跑完后三角形已就绪 — 优先使用三角形结果(dx={tri['dx_cm']:.2f},dy={tri['dy_cm']:.2f}cm)")
return _build_tri_result(tri, roi_xyxy)
return _build_circle_result(cdata, roi_xyxy)
def process_shot(adc_val):
"""
处理射箭事件逻辑控制部分
:param adc_val: ADC触发值
:return: None
"""
logger = logger_manager.logger
try:
frame = camera_manager.read_frame()
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
# 调用算法分析
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"]
offset_method = analysis_result.get("offset_method", "yellow_circle")
distance_method = analysis_result.get("distance_method", "yellow_radius")
tri_markers = analysis_result.get("tri_markers", [])
tri_markers_completed = analysis_result.get("tri_markers_completed", [])
tri_homography = analysis_result.get("tri_homography")
yolo_roi_xyxy = analysis_result.get("yolo_roi_xyxy")
draw_yolo_roi = (
yolo_roi_xyxy is not None
and getattr(config, "TRIANGLE_YOLO_DRAW_ROI_ON_SHOT", True)
)
x, y = laser_point
# 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
if (not method) and tri_markers:
method = "triangle_homography"
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
camera_manager.show(result_img)
if dx is None and dy is None and logger:
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
# 生成射箭ID
from shot_id_generator import shot_id_generator
shot_id = shot_id_generator.generate_id()
if logger:
logger.info(f"[MAIN] 射箭ID: {shot_id}")
laser_distance_m = None
laser_signal_quality = 0
# x,y 单位:物理厘米(compute_laser_position 与三角形单应性均输出物理 cm)
# 未检测到靶心时 x/y 用 200.0(脱靶标志)
srv_x = round(float(dx), 4) if dx is not None else 200.0
srv_y = round(float(dy), 4) if dy is not None else 200.0
# 构造上报数据
inner_data = {
"shot_id": shot_id,
"x": srv_x,
"y": srv_y,
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
"d": round((distance_m or 0.0) * 100),
"d_laser": round((laser_distance_m or 0.0) * 100),
"d_laser_quality": laser_signal_quality,
"m": method if method else "no_target",
"adc": adc_val,
"laser_method": laser_point_method,
"target_x": float(x),
"target_y": float(y),
"offset_method": offset_method,
"distance_method": distance_method,
}
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 result_img is not None:
# 1. 若有三角形标记,先用 cv2 画轮廓 / 顶点 / ID,再反推靶心位置
if tri_markers:
import cv2 as _cv2
import numpy as _np
_img_cv = image.image2cv(result_img, False, False)
# YOLO 靶环框在 vision.enqueue_save_shot 的 worker 里绘制,避免阻塞主流程
# 三角形轮廓 + 直角顶点 + ID
for _m in tri_markers:
_corners = _np.array(_m["corners"], dtype=_np.int32)
_cv2.polylines(_img_cv, [_corners], True, (0, 255, 0), 2)
_cx, _cy = int(_m["center"][0]), int(_m["center"][1])
_cv2.circle(_img_cv, (_cx, _cy), 4, (0, 0, 255), -1)
_cv2.putText(_img_cv, f"T{_m['id']}",
(_cx - 18, _cy - 12),
_cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1)
# 3点补全的虚拟角点:只画中心点 + 文本,避免误认为真实检测到的三角形
try:
if tri_markers_completed:
for _m in tri_markers_completed:
if not _m.get("is_virtual"):
continue
_cx, _cy = int(_m["center"][0]), int(_m["center"][1])
_cv2.circle(_img_cv, (_cx, _cy), 6, (255, 0, 255), 2) # 紫色空心圈
_cv2.putText(
_img_cv,
f"VT{_m['id']}",
(_cx - 22, _cy - 12),
_cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(255, 0, 255),
1,
)
except Exception:
pass
# 靶心(H_inv @ [0,0]):小红圆
_center_px = None
if tri_homography is not None:
try:
_H_inv = _np.linalg.inv(tri_homography)
_c_img = _cv2.perspectiveTransform(
_np.array([[[0.0, 0.0]]], dtype=_np.float32), _H_inv)[0][0]
_ocx, _ocy = int(_c_img[0]), int(_c_img[1])
_cv2.circle(_img_cv, (_ocx, _ocy), 5, (0, 0, 255), -1) # 实心
_cv2.circle(_img_cv, (_ocx, _ocy), 9, (0, 0, 255), 1) # 外框
_center_px = (_ocx, _ocy)
logger.info(f"[算法] 靶心: {_center_px}")
except Exception:
pass
# 叠加信息:落点-圆心距离 / 相机-靶距离等
try:
import math as _math
_lines = []
if dx is not None and dy is not None:
_r_cm = _math.hypot(float(dx), float(dy))
_lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm |r|={_r_cm:.2f}cm")
if distance_m is not None:
_lines.append(f"cam_dist={float(distance_m):.2f}m ({distance_method})")
if method:
_lines.append(f"method={method}")
if _lines:
_y0 = 22
for i, _t in enumerate(_lines):
_cv2.putText(
_img_cv,
_t,
(10, _y0 + i * 18),
_cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 255, 0),
1,
)
except Exception:
pass
result_img = image.cv2image(_img_cv, False, False)
elif draw_yolo_roi:
# 仅 YOLO 标注时也不在主线程画框,交给存图 worker
pass
# 2. 激光十字线
_lc = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
result_img.draw_line(int(x - config.LASER_LENGTH), int(y),
int(x + config.LASER_LENGTH), int(y),
_lc, config.LASER_THICKNESS)
result_img.draw_line(int(x), int(y - config.LASER_LENGTH),
int(x), int(y + config.LASER_LENGTH),
_lc, config.LASER_THICKNESS)
result_img.draw_circle(int(x), int(y), 1, _lc, config.LASER_THICKNESS)
# 闪一下激光(射箭反馈)
if config.FLASH_LASER_WHILE_SHOOTING:
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
# 保存图像(异步队列,与 main.py 一致)
enqueue_save_shot(
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,
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None,
)
if logger:
if dx is not None and dy is not None:
logger.info(f"射箭事件已加入发送队列(偏移=({dx:.2f},{dy:.2f})cm),ID: {shot_id}")
else:
logger.info(f"射箭事件已加入发送队列(未检测到偏移,已保存图像),ID: {shot_id}")
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()
+668
View File
@@ -0,0 +1,668 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MaixCAM NPU YOLOv5先检靶环/整靶区域并裁切 ROI黑三角 Stage2 在裁切图上推理与训练一致
再在各子框上跑传统直角点算法
- 相机全分辨率 640×480与模型输入 320×320不一致时需把检测框从
网络输入坐标系映回全图或直接使用 Maix 已映射到源图坐标的模式 config
依赖maix.nn.YOLOv5靶环模型 config.TRIANGLE_YOLO_MODEL_PATH黑三角模型
config.TRIANGLE_BLACK_YOLO_MODEL_PATH可多实例缓存按路径区分
224×224320×320 网络输入尺寸由导出的 .mud 决定运行时打印为 net_in=无需在业务 config 里写死
返回 (x0, y0, x1, y1) 为整幅 img_cv 上的轴对齐矩形半开区间按三角形裁剪习惯
实际裁剪为 img[y0:y1, x0:x1]
"""
from __future__ import annotations
import os
import threading
import numpy as np
def _stage2_roi_crop_save_worker(
slab_rgb,
out_local_boxes,
rx0,
ry0,
rw,
rh,
base_dir,
draw_boxes,
jpeg_quality,
roi_max_images,
logger_ref,
):
"""后台写 Stage2 裁切 JPEG,避免阻塞 NPU 后续流程。"""
try:
import time
import cv2
os.makedirs(base_dir, exist_ok=True)
fn = os.path.join(
base_dir,
f"stage2_roi_{rx0}_{ry0}_{rw}x{rh}_{int(time.time() * 1000)}.jpg",
)
bgr = cv2.cvtColor(slab_rgb, cv2.COLOR_RGB2BGR)
if draw_boxes and out_local_boxes:
for i, (bx0, by0, bx1, by1) in enumerate(out_local_boxes):
x0, y0 = int(bx0), int(by0)
x1, y1 = int(bx1) - 1, int(by1) - 1
x1 = max(x0, min(x1, rw - 1))
y1 = max(y0, min(y1, rh - 1))
cv2.rectangle(bgr, (x0, y0), (x1, y1), (0, 255, 0), 2)
cv2.putText(
bgr,
f"s2_{i}",
(x0, max(0, y0 - 4)),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 255, 0),
1,
cv2.LINE_AA,
)
cv2.imwrite(fn, bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)])
try:
from vision import prune_old_images_in_dir
prune_old_images_in_dir(
base_dir, roi_max_images, logger_ref, "[YOLO-BLACK]"
)
except Exception:
pass
if logger_ref:
extra = (
f",已绘 Stage2 框×{len(out_local_boxes)}"
if (draw_boxes and out_local_boxes)
else ""
)
logger_ref.info(f"[YOLO-BLACK] 已保存 Stage1 裁切图(异步): {fn}{extra}")
except Exception as e:
if logger_ref:
logger_ref.warning(f"[YOLO-BLACK] 异步保存裁切图失败: {e}")
_detector_by_path = {}
def reset_yolo_detector_cache():
"""切换模型路径时可调用(通常不必)。"""
global _detector_by_path
_detector_by_path.clear()
def _get_detector(model_path: str):
global _detector_by_path
if not model_path or not os.path.isfile(model_path):
return None
if model_path in _detector_by_path:
return _detector_by_path[model_path]
try:
from maix import nn
except ImportError:
return None
_detector_by_path[model_path] = nn.YOLOv5(model=model_path, dual_buff=False)
return _detector_by_path[model_path]
def preload_yolo_detector(logger=None):
"""
启动阶段预加载 YOLO detector避免第一次真实射箭承担模型加载开销
detect 使用 dual_buff=False不再需要用首帧 warmup 抵消双缓冲的一帧延迟
"""
try:
import config as cfg
except Exception as e:
if logger:
logger.warning(f"[YOLO-ROI] 预加载失败:无法读取 config: {e}")
return False
ok = False
if bool(getattr(cfg, "TRIANGLE_YOLO_ROI_ENABLE", False)):
model_path = getattr(cfg, "TRIANGLE_YOLO_MODEL_PATH", "") or ""
det = _get_detector(model_path)
if det is None:
if logger:
logger.warning(f"[YOLO-ROI] 预加载失败:无法加载模型 {model_path}")
else:
ok = True
try:
net_w = int(det.input_width())
net_h = int(det.input_height())
except Exception:
net_w = net_h = -1
if logger:
logger.info(
f"[YOLO-ROI] 靶环模型已预加载: {model_path}, net_in={net_w}×{net_h}"
)
_loc_black = str(
getattr(cfg, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
).lower().strip()
if _loc_black not in ("yolo", "traditional"):
_loc_black = "yolo"
_preload_black = (
bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False))
and _loc_black == "yolo"
and bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
)
if _preload_black:
bp = getattr(cfg, "TRIANGLE_BLACK_YOLO_MODEL_PATH", "") or ""
d2 = _get_detector(bp)
if d2 is None:
if logger:
logger.warning(f"[YOLO-BLACK] 预加载失败:无法加载模型 {bp}")
else:
ok = True
try:
nw2 = int(d2.input_width())
nh2 = int(d2.input_height())
except Exception:
nw2 = nh2 = -1
if logger:
logger.info(
f"[YOLO-BLACK] 黑三角模型已预加载: {bp}, net_in={nw2}×{nh2}"
)
elif logger and bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False)):
if _loc_black != "yolo":
logger.info(
"[YOLO-BLACK] TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE=%s:跳过黑三角模型预加载"
% (_loc_black,)
)
return ok
def _letterbox_net_to_src_xyxy(
x: float, y: float, w: float, h: float,
src_w: int, src_h: int, net_w: int, net_h: int,
):
"""
检测框在网络输入图上 letterbox 填充映回到 src_w×src_h 原图
x,y,w,h 为网络坐标系下的左上角与宽高
"""
scale = min(net_w / float(src_w), net_h / float(src_h))
nw = src_w * scale
nh = src_h * scale
pad_x = (net_w - nw) * 0.5
pad_y = (net_h - nh) * 0.5
x0 = (x - pad_x) / scale
y0 = (y - pad_y) / scale
x1 = (x + w - pad_x) / scale
y1 = (y + h - pad_y) / scale
return x0, y0, x1, y1
def _det_obj_class_id(o):
"""Maix / 不同版本可能用 class_id、cls、label 等字段。"""
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
if hasattr(o, key):
v = getattr(o, key)
if v is None:
continue
try:
return int(float(v))
except (TypeError, ValueError):
continue
return None
def _det_obj_from_seq(t):
"""若 detect 返回 list/tuple[x,y,w,h,score,cls]Maix 常用 xywh),包装成属性对象。"""
if not isinstance(t, (list, tuple)) or len(t) < 6:
return None
class _Box:
__slots__ = ("x", "y", "w", "h", "score", "class_id")
b = _Box()
b.x = float(t[0])
b.y = float(t[1])
b.w = float(t[2])
b.h = float(t[3])
b.score = float(t[4])
b.class_id = int(float(t[5]))
return b
def _normalize_objs(objs):
out = []
for o in objs or []:
if isinstance(o, (list, tuple)):
m = _det_obj_from_seq(o)
if m is not None:
out.append(m)
else:
out.append(o)
return out
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
if coord_mode in ("native", "source", "camera", "full"):
return x, y, x + w, y + h
return _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h)
def _merge_roi_xyxy(xy_list, merge_mode: str):
"""
merge_mode:
union 所有框的外接矩形适合整靶+多角标同属一类多框场景
largest 取面积最大的单个框适合只有一个大框代表整靶
"""
if not xy_list:
return None
if merge_mode in ("union", "merge", "all"):
x0 = min(a[0] for a in xy_list)
y0 = min(a[1] for a in xy_list)
x1 = max(a[2] for a in xy_list)
y1 = max(a[3] for a in xy_list)
return x0, y0, x1, y1
# largest
def _area(t):
return max(0.0, t[2] - t[0]) * max(0.0, t[3] - t[1])
best = max(xy_list, key=_area)
return best[0], best[1], best[2], best[3]
def _roi_aspect_sane(x0, y0, x1, y1, src_w: int, src_h: int) -> bool:
"""过滤 letterbox 重复映射等导致的扁条/细条 ROI。"""
bw = x1 - x0
bh = y1 - y0
if bw < 8 or bh < 8:
return False
area_frac = (bw * bh) / float(max(1, src_w * src_h))
if area_frac < 0.015: # 小于全图约 1.5% 认为不可信
return False
ar = bw / max(bh, 1e-6)
if ar > 5.5 or ar < 1.0 / 5.5:
return False
return True
def _expand_xyxy(x0, y0, x1, y1, src_w, src_h, margin_frac: float):
bw = max(x1 - x0, 1e-6)
bh = max(y1 - y0, 1e-6)
mx = bw * margin_frac
my = bh * margin_frac
x0 -= mx
y0 -= my
x1 += mx
y1 += my
x0 = max(0, min(int(round(x0)), src_w - 1))
y0 = max(0, min(int(round(y0)), src_h - 1))
x1 = max(x0 + 1, min(int(round(x1)), src_w))
y1 = max(y0 + 1, min(int(round(y1)), src_h))
return x0, y0, x1, y1
def try_get_triangle_roi_from_yolo(maix_frame, src_w: int, src_h: int, logger=None):
"""
YOLO maix_frame 上检测靶环类返回整图上的裁剪框 (x0,y0,x1,y1)失败返回 None
:param maix_frame: camera.read() 返回的 Maix 图像 nn.YOLOv5.detect 一致
:param src_w, src_h: img_cv / 标定一致的分辨率通常与 camera 一致
"""
try:
import config as cfg
except Exception:
return None
if not bool(getattr(cfg, "TRIANGLE_YOLO_ROI_ENABLE", False)):
return None
model_path = getattr(cfg, "TRIANGLE_YOLO_MODEL_PATH", "") or ""
if not os.path.isfile(model_path):
if logger:
logger.warning(f"[YOLO-ROI] 模型文件不存在: {model_path}")
return None
det = _get_detector(model_path)
if det is None:
if logger:
logger.warning("[YOLO-ROI] 无法加载 nn.YOLOv5(非 Maix 环境或导入失败)")
return None
conf_th = float(getattr(cfg, "TRIANGLE_YOLO_CONF_TH", 0.5))
iou_th = float(getattr(cfg, "TRIANGLE_YOLO_IOU_TH", 0.45))
class_ids = getattr(cfg, "TRIANGLE_YOLO_RING_CLASS_IDS", (0,))
if isinstance(class_ids, int):
class_ids = (class_ids,)
margin_frac = float(getattr(cfg, "TRIANGLE_YOLO_ROI_MARGIN_FRAC", 0.12))
coord_mode = str(getattr(cfg, "TRIANGLE_YOLO_COORD_MODE", "native")).lower()
merge_mode = str(getattr(cfg, "TRIANGLE_YOLO_ROI_MERGE_MODE", "union")).lower()
reject_bad = bool(getattr(cfg, "TRIANGLE_YOLO_REJECT_BAD_ROI", True))
try:
raw = det.detect(maix_frame, conf_th=conf_th, iou_th=iou_th)
except Exception as e:
if logger:
logger.warning(f"[YOLO-ROI] detect 异常: {e}")
return None
objs = _normalize_objs(raw if raw is not None else [])
candidates = []
for o in objs:
cid = _det_obj_class_id(o)
if cid is not None and cid in class_ids:
candidates.append(o)
if not candidates and bool(getattr(cfg, "TRIANGLE_YOLO_RETRY_ON_EMPTY", False)):
retry_conf = float(getattr(cfg, "TRIANGLE_YOLO_RETRY_CONF_TH", conf_th))
if retry_conf > 0 and retry_conf < conf_th:
try:
raw_retry = det.detect(maix_frame, conf_th=retry_conf, iou_th=iou_th)
objs_retry = _normalize_objs(raw_retry if raw_retry is not None else [])
candidates_retry = []
for o in objs_retry:
cid = _det_obj_class_id(o)
if cid is not None and cid in class_ids:
candidates_retry.append(o)
if candidates_retry:
if logger:
logger.info(
f"[YOLO-ROI] conf={conf_th} 下 0 候选,"
f"用 retry_conf={retry_conf} 重试得到 {len(candidates_retry)} 个候选"
)
objs = objs_retry
candidates = candidates_retry
conf_th = retry_conf
elif logger:
logger.info(
f"[YOLO-ROI] conf={conf_th} 下 0 候选;"
f"retry_conf={retry_conf} 仍为 0 候选"
)
except Exception as e:
if logger:
logger.warning(f"[YOLO-ROI] 低阈值重试异常: {e}")
if not candidates:
if logger:
n = len(objs)
if n == 0:
logger.info(
f"[YOLO-ROI] detect 返回 0 个框(conf≥{conf_th})。"
f"可尝试 config 里降低 TRIANGLE_YOLO_CONF_TH(如 0.25~0.35),"
f"或确认射箭帧与训练图光照/构图接近。"
)
else:
seen = []
for o in objs[:8]:
cid = _det_obj_class_id(o)
sc = getattr(o, "score", None)
try:
sc_f = float(sc) if sc is not None else None
except Exception:
sc_f = None
seen.append(f"cls={cid},score={sc_f}")
logger.info(
f"[YOLO-ROI] 有 {n} 个框但类别不在 {class_ids} 内;"
f"前几条: {seen}。请核对 TRIANGLE_YOLO_RING_CLASS_IDS"
f"或查看 Maix 文档中检测结果的类别字段名。"
)
return None
net_w = int(det.input_width())
net_h = int(det.input_height())
min_side = float(getattr(cfg, "TRIANGLE_YOLO_MIN_BOX_SIDE_PX", 8.0))
xy_list = []
for o in candidates:
x0n, y0n, x1n, y1n = _det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h)
bw, bh = x1n - x0n, y1n - y0n
if bw >= min_side and bh >= min_side:
xy_list.append((x0n, y0n, x1n, y1n))
if not xy_list:
if logger:
logger.info(
f"[YOLO-ROI] {len(candidates)} 个候选经 min_side={min_side} 过滤后为空,放弃 ROI"
)
return None
merged = _merge_roi_xyxy(xy_list, merge_mode)
if merged is None:
return None
x0, y0, x1, y1 = merged
# clip 到画布(合并前框可能略越界)
x0 = max(0, min(x0, src_w - 1))
y0 = max(0, min(y0, src_h - 1))
x1 = max(x0 + 1, min(x1, src_w))
y1 = max(y0 + 1, min(y1, src_h))
x0, y0, x1, y1 = _expand_xyxy(x0, y0, x1, y1, src_w, src_h, margin_frac)
if reject_bad and not _roi_aspect_sane(x0, y0, x1, y1, src_w, src_h):
if logger:
logger.warning(
f"[YOLO-ROI] 裁剪框异常(过小或过扁)mode={coord_mode} merge={merge_mode} "
f"→ [{x0},{y0},{x1},{y1}],放弃 ROI、三角形改用整图。"
f"若持续出现可尝试 coord_mode=letterbox/native 切换。"
)
return None
if logger:
nbox = len(candidates)
logger.info(
f"[YOLO-ROI] boxes={nbox} merge={merge_mode} coord={coord_mode} "
f"net_in={net_w}×{net_h}(来自模型) → crop=[{x0},{y0},{x1},{y1}] "
f"({x1-x0}×{y1-y0}px)"
)
return (x0, y0, x1, y1)
def _expand_xyxy_local(x0, y0, x1, y1, w_lim, h_lim, margin_frac: float):
"""在宽 w_lim、高 h_lim 的局部坐标系内扩展框。"""
bw = max(x1 - x0, 1e-6)
bh = max(y1 - y0, 1e-6)
mx = bw * margin_frac
my = bh * margin_frac
x0 -= mx
y0 -= my
x1 += mx
y1 += my
x0 = max(0, min(int(round(x0)), w_lim - 1))
y0 = max(0, min(int(round(y0)), h_lim - 1))
x1 = max(x0 + 1, min(int(round(x1)), w_lim))
y1 = max(y0 + 1, min(int(round(y1)), h_lim))
return x0, y0, x1, y1
def try_black_triangle_boxes_work(img_rgb, ring_roi_xyxy, logger=None):
"""
Stage2 **Stage1 靶环 ROI 裁切图** 上跑黑三角 YOLO与训练时 stage2 构图一致
检测框坐标已落在 **靶环裁切图** try_triangle_scoring img_work同一坐标系
返回 (x0,y0,x1,y1) 整数元组列表
img_rgb: try_triangle_scoring 相同的全图 RGBnumpyH×W×3
ring_roi_xyxy: 全图上的 (rx0, ry0, rx1, ry1) try_get_triangle_roi_from_yolo 一致
"""
if ring_roi_xyxy is None:
return []
if img_rgb is None or getattr(img_rgb, "size", 0) == 0:
return []
try:
import config as cfg
except Exception:
return []
if not bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False)):
return []
model_path = getattr(cfg, "TRIANGLE_BLACK_YOLO_MODEL_PATH", "") or ""
if not os.path.isfile(model_path):
if logger:
logger.warning(f"[YOLO-BLACK] 模型文件不存在: {model_path}")
return []
det = _get_detector(model_path)
if det is None:
if logger:
logger.warning("[YOLO-BLACK] 无法加载 nn.YOLOv5")
return []
conf_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_CONF_TH", 0.5))
iou_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_IOU_TH", 0.45))
class_ids = getattr(cfg, "TRIANGLE_BLACK_YOLO_CLASS_IDS", (0,))
if isinstance(class_ids, int):
class_ids = (class_ids,)
coord_mode = str(getattr(cfg, "TRIANGLE_BLACK_YOLO_COORD_MODE", "native")).lower()
margin_frac = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_BOX_MARGIN_FRAC", 0.08))
min_side = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_MIN_BOX_SIDE_PX", 6.0))
crop_min = int(getattr(cfg, "TRIANGLE_CROP_ROI_MIN_SIDE_PX", 64))
h_full, w_full = int(img_rgb.shape[0]), int(img_rgb.shape[1])
rx0, ry0, rx1, ry1 = [int(round(float(v))) for v in ring_roi_xyxy]
rx0 = max(0, min(rx0, w_full - 1))
ry0 = max(0, min(ry0, h_full - 1))
rx1 = max(rx0 + 1, min(rx1, w_full))
ry1 = max(ry0 + 1, min(ry1, h_full))
rw, rh = rx1 - rx0, ry1 - ry0
if rw < crop_min or rh < crop_min:
if logger:
logger.warning(
f"[YOLO-BLACK] Stage1 ROI 过小 {rw}×{rh} < {crop_min},跳过黑三角检测"
)
return []
# 必须与相机帧缓冲区脱钩:切片常为非连续视图,直接喂 cv2image/NPU 易 SIGSEGV
slab = np.ascontiguousarray(
img_rgb[ry0:ry1, rx0:rx1], dtype=np.uint8
).copy()
if slab.size == 0:
return []
_save_roi = bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP", False))
try:
from maix import image as maix_image
# copy=True:零拷贝时 detect 内 OpenCV 可能对底层 Mat release 触发 !fixedSize() 断言。
roi_maix = maix_image.cv2image(slab, False, True)
except Exception as e:
if logger:
logger.warning(f"[YOLO-BLACK] 裁切图转 Maix image 失败: {e}")
return []
try:
raw = det.detect(roi_maix, conf_th=conf_th, iou_th=iou_th)
except Exception as e:
if logger:
logger.warning(f"[YOLO-BLACK] detect 异常: {e}")
return []
objs = _normalize_objs(raw if raw is not None else [])
net_w = int(det.input_width())
net_h = int(det.input_height())
n_raw = len(objs)
n_cls_ok = 0
n_too_small = 0
out_local = []
for o in objs:
cid = _det_obj_class_id(o)
if cid is None or cid not in class_ids:
continue
n_cls_ok += 1
x0f, y0f, x1f, y1f = _det_to_src_xyxy(o, coord_mode, rw, rh, net_w, net_h)
lx0 = max(0, min(float(x0f), rw - 1))
ly0 = max(0, min(float(y0f), rh - 1))
lx1 = max(lx0 + 1, min(float(x1f), rw))
ly1 = max(ly0 + 1, min(float(y1f), rh))
lx0, ly0, lx1, ly1 = int(round(lx0)), int(round(ly0)), int(round(lx1)), int(round(ly1))
if (lx1 - lx0) < min_side or (ly1 - ly0) < min_side:
n_too_small += 1
continue
lx0, ly0, lx1, ly1 = _expand_xyxy_local(
lx0, ly0, lx1, ly1, rw, rh, margin_frac
)
out_local.append((lx0, ly0, lx1, ly1))
out_local.sort(key=lambda t: ((t[1] + t[3]) * 0.5, (t[0] + t[2]) * 0.5))
if logger and bool(
getattr(cfg, "TRIANGLE_BLACK_YOLO_LOG_EACH_SHOT", True)
):
msg = (
f"[YOLO-BLACK] Stage1裁切{rw}×{rh}上推理: raw={n_raw} 类∈{class_ids}{n_cls_ok} "
f"过小丢弃→{n_too_small} 最终子框={len(out_local)} "
f"(conf={conf_th}, coord={coord_mode}, net={net_w}×{net_h}, "
f"ring全图=[{rx0},{ry0},{rx1},{ry1}])"
)
logger.info(msg)
if n_raw > 0 and n_cls_ok == 0:
seen = []
for o in objs[:8]:
cid = _det_obj_class_id(o)
sc = getattr(o, "score", None)
try:
sc_f = float(sc) if sc is not None else None
except Exception:
sc_f = None
seen.append(f"cls={cid},score={sc_f}")
logger.info(
f"[YOLO-BLACK] 有框但类别不在 {class_ids} 内;前几条: {seen}"
f"请核对 TRIANGLE_BLACK_YOLO_CLASS_IDS。"
)
elif n_cls_ok > 0 and len(out_local) == 0:
logger.info(
f"[YOLO-BLACK] {n_cls_ok} 个目标类框但边长均 < min_side={min_side},已全部丢弃。"
)
if _save_roi:
try:
base = (getattr(cfg, "TRIANGLE_BLACK_YOLO_ROI_CROP_DIR", "") or "").strip()
if not base:
base = os.path.join(
getattr(cfg, "PHOTO_DIR", "/tmp") or "/tmp", "stage2_roi"
)
_draw = bool(
getattr(cfg, "TRIANGLE_BLACK_YOLO_SAVE_ROI_DRAW_BOXES", True)
)
_roi_max_raw = getattr(
cfg, "TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES", None
)
try:
_roi_max = (
int(_roi_max_raw)
if _roi_max_raw is not None
else int(getattr(cfg, "MAX_IMAGES", 1000))
)
except (TypeError, ValueError):
_roi_max = int(getattr(cfg, "MAX_IMAGES", 1000))
slab_copy = np.ascontiguousarray(slab, dtype=np.uint8).copy()
boxes_copy = [tuple(t) for t in out_local]
threading.Thread(
target=_stage2_roi_crop_save_worker,
args=(
slab_copy,
boxes_copy,
rx0,
ry0,
rw,
rh,
base,
_draw,
92,
_roi_max,
logger,
),
daemon=True,
).start()
except Exception as e:
if logger:
logger.warning(f"[YOLO-BLACK] 提交异步保存裁切图失败: {e}")
return out_local
+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
+6
View File
@@ -0,0 +1,6 @@
{
"0": [-20.0, -20.0, 0.0],
"1": [-20.0, 20.0, 0.0],
"2": [ 20.0, 20.0, 0.0],
"3": [ 20.0, -20.0, 0.0]
}
+1865
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
应用版本号
每次 OTA 更新时只需要更新这个文件中的版本号
"""
VERSION = '2.15.35'
+1027
View File
File diff suppressed because it is too large Load Diff
+704
View File
@@ -0,0 +1,704 @@
#!/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
from wpa_supplicant_conf import build_sta_conf_open, build_sta_conf_psk
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._wifi_quality_lock = threading.Lock()
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
# MaixPy 的 is_connected 可能不会同步填充 IP,这里用系统命令补齐一次
try:
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
if ip:
self._wifi_ip = ip
except Exception:
pass
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唯一实现 wpa_supplicant + /boot 凭证MaixPy Wifi.connect再等 IP 与可选校验
``NetworkManager.connect_wifi`` 仅封装本方法通过 ``verify_callback`` 传入 host/port 校验
重要``/boot/wpa_supplicant.conf`` 存在时 S30wifi 会优先 cp避免 shell 传中文 SSID
Args:
ssid: WiFi SSID
password: WiFi密码
verify_callback: 可选``(ip) -> (success: bool, error: str)``在拿到 IP 后调用
persist: 是否持久化保存凭证False 时成功后回滚 /boot /etc 中的本次写入
timeout_s: 等待 DHCP / 轮询 IP 的超时基数Maix 连接超时亦据此推导
Returns:
(ip, error): IP地址和错误信息成功时 error None
"""
# 配置文件路径定义
conf_path = "/etc/wpa_supplicant.conf"
boot_wpa_path = "/boot/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
def _restore_boot_wpa(old_wpa: str | None):
try:
if old_wpa is None:
if os.path.exists(boot_wpa_path):
os.remove(boot_wpa_path)
else:
_write_text(boot_wpa_path, old_wpa)
except Exception:
pass
old_conf = _read_text(conf_path)
old_boot_ssid = _read_text(ssid_file)
old_boot_pass = _read_text(pass_file)
try:
try:
full_conf = build_sta_conf_psk(ssid.strip(), password.strip())
except ValueError as ve:
raise RuntimeError(str(ve)) from ve
try:
_write_text(conf_path, full_conf)
except Exception:
pass
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
try:
if os.path.exists(boot_wpa_path):
os.remove(boot_wpa_path)
except Exception:
pass
_write_text(ssid_file, ssid.strip())
_write_text(pass_file, password.strip())
from maix import err as maix_err
from maix import network as maix_net
self.logger.info(f"[WIFI] Maix connect start ssid={ssid!r}")
w = maix_net.wifi.Wifi()
connect_timeout_s = int(timeout_s) if timeout_s and timeout_s > 0 else 60
connect_timeout_s = max(10, min(connect_timeout_s, 120))
e = w.connect(ssid, password, wait=True, timeout=connect_timeout_s)
maix_err.check_raise(e, "connect wifi failed")
try:
maix_ip = w.get_ip()
except Exception:
maix_ip = None
self.logger.info(f"[WIFI] Maix connect ok ip={maix_ip!r}")
# 等待获取 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/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"
boot_wpa_path = "/boot/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:
full_conf = build_sta_conf_psk(ssid, password)
else:
full_conf = build_sta_conf_open(ssid)
_write_text(conf_path, full_conf)
try:
if os.path.exists(boot_wpa_path):
os.remove(boot_wpa_path)
except Exception:
pass
except ValueError as e:
return False, str(e)
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 秒检查 STA 关联状态和 RSSI
只在 WiFi 连接时运行不影响业务发送性能
Args:
network_type_callback: 获取当前网络类型的回调函数
on_poor_quality_callback: WiFi质量差时的回调函数
"""
with self._wifi_quality_lock:
current_thread = self._wifi_quality_monitor_thread
current_stop_event = self._wifi_quality_stop_event
if (current_thread is not None and current_thread.is_alive()
and not current_stop_event.is_set()):
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
return
self._network_type_callback = network_type_callback
self._on_poor_quality_callback = on_poor_quality_callback
stop_event = threading.Event()
self._wifi_quality_stop_event = stop_event
self._wifi_quality_monitor_thread = threading.Thread(
target=self._quality_monitor_loop,
args=(stop_event,),
daemon=True,
name="wifi_quality_monitor"
)
self._wifi_quality_monitor_thread.start()
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
def stop_quality_monitor(self):
"""停止 WiFi 质量监测线程"""
with self._wifi_quality_lock:
t = self._wifi_quality_monitor_thread
stop_event = self._wifi_quality_stop_event
if t is None:
return
if not t.is_alive():
self._wifi_quality_monitor_thread = None
return
stop_event.set()
try:
t.join(timeout=2.0)
except Exception as e:
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
with self._wifi_quality_lock:
if t is self._wifi_quality_monitor_thread:
if t.is_alive():
self.logger.warning("[WiFi Monitor] 线程未在超时内退出,保留引用防止重复创建")
else:
self._wifi_quality_monitor_thread = None
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
def _quality_monitor_loop(self, stop_event):
"""
WiFi 质量监测循环后台线程
5 秒检查 STA 关联状态和 RSSI发现断链或质量差则触发切换
"""
while not stop_event.is_set():
try:
# 只在 WiFi 连接时才测量
network_type = self._network_type_callback()
if network_type == "wifi" and self._wifi_socket:
# RTT 测量当前禁用;STA 关联状态用于判断物理 WiFi 链路是否仍存在。
# 不能把禁用的 RTT 伪装成 0ms,否则关闭热点后会一直被判为正常。
reachable = self.is_sta_associated()
rtt_ms = None
# 获取 RSSI
rssi_dbm = self._get_wifi_rssi_dbm()
# 更新缓存
self._last_wifi_rtt_ms = rtt_ms
self._last_wifi_rssi_dbm = rssi_dbm
_rtt_s = f"{rtt_ms:.0f}ms" if rtt_ms is not None else "n/a"
_rssi_s = f"{rssi_dbm:.0f}" if rssi_dbm is not None else "n/a"
self.logger.debug(
f"[WiFi Monitor] - associated={reachable}, RTT={_rtt_s}, RSSI={_rssi_s}dBm"
)
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
def _is_bad_now(_reachable, _rtt, _rssi):
if not _reachable:
return True
# RTT 未启用时不参与质量判断;链路状态仍由 STA 关联保证。
if _rtt is None:
return False
if _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):
if stop_event.wait(1.0):
return
reachable2 = self.is_sta_associated()
rtt2 = None
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:
_rtt_disp = f"{rtt2:.0f}ms" if rtt2 is not None else "n/a"
self.logger.info(
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
f"rtt={_rtt_disp}, 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 秒
stop_event.wait(5.0)
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)
+521
View File
@@ -0,0 +1,521 @@
#!/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.stasyncreboot
"""
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 _cleanup_ap_flag_if_needed(logger):
"""若 /boot/wifi.ap 残留,删除它并恢复 /boot/wifi.sta,避免 main.py 误判为 AP 配网模式。"""
ap_flag = "/boot/wifi.ap"
sta_flag = "/boot/wifi.sta"
if not os.path.exists(ap_flag):
return
try:
os.remove(ap_flag)
logger.info(f"[WIFI-AP] 已清理残留标记 {ap_flag}")
except Exception as e:
logger.warning(f"[WIFI-AP] 清理 {ap_flag} 失败: {e}")
return
if not os.path.exists(sta_flag):
try:
with open(sta_flag, "w", encoding="utf-8") as f:
f.write("")
logger.info(f"[WIFI-AP] 已恢复 {sta_flag}")
except Exception as e:
logger.warning(f"[WIFI-AP] 恢复 {sta_flag} 失败: {e}")
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 可用,不启动热点配网")
# 清理上次开机可能残留的 /boot/wifi.ap 标记,避免 main.py 误判为 AP 配网模式
_cleanup_ap_flag_if_needed(logger)
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 可用,不启动热点配网")
_cleanup_ap_flag_if_needed(logger)
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()
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成 wpa_supplicant STA 配置不经过 shell / wpa_passphrase避免中文 SSID /bin/sh 传参时被破坏
wpa_passphrase 一致PMK = PBKDF2-SHA1(password_utf8, ssid_utf8, 4096, 32)
ssid 行使用 UTF-8 字节的十六进制无引号 wpa_supplicant 文档一致
"""
from __future__ import annotations
import hashlib
_CTRL_HEADER = (
"ctrl_interface=/var/run/wpa_supplicant\n"
"update_config=1\n\n"
)
def _ssid_utf8_bytes(ssid: str) -> bytes:
b = (ssid or "").encode("utf-8")
if not b:
raise ValueError("SSID 为空")
if len(b) > 32:
raise ValueError("SSID UTF-8 超过 32 字节")
return b
def build_sta_conf_psk(ssid: str, password: str) -> str:
"""WPA2-PSK STA:完整 wpa_supplicant.conf 文本。"""
ssid_b = _ssid_utf8_bytes(ssid)
pw = (password or "").encode("utf-8")
if len(pw) < 8 or len(pw) > 63:
raise ValueError("WPA2-PSK 密码长度应为 863 字节(UTF-8)")
pmk = hashlib.pbkdf2_hmac("sha1", pw, ssid_b, 4096, 32)
net = (
"network={\n"
f"\tssid={ssid_b.hex()}\n"
f"\tpsk={pmk.hex()}\n"
"}\n"
)
return _CTRL_HEADER + net
def build_sta_conf_open(ssid: str) -> str:
"""开放网络 STA:完整 wpa_supplicant.conf 文本。"""
ssid_b = _ssid_utf8_bytes(ssid)
net = (
"network={\n"
f"\tssid={ssid_b.hex()}\n"
"\tkey_mgmt=NONE\n"
"}\n"
)
return _CTRL_HEADER + net