Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1f12ae609 | ||
|
|
56fa8fc2a1 | ||
|
|
d3c8a26854 | ||
|
|
eae7da7291 | ||
|
|
dc5da0294f | ||
|
|
165eeff64e | ||
|
|
a184ff7d55 | ||
|
|
054e9e6d90 | ||
|
|
ae339889c2 | ||
|
|
b94b0f2e55 | ||
|
|
e82941a161 | ||
|
|
6556cfcf74 | ||
|
|
abbf30d7c0 | ||
|
|
5cf752bb3f | ||
|
|
6d8de56bfa | ||
|
|
aee1a92760 | ||
|
|
c34efed6f9 | ||
|
|
226394d3ed | ||
|
|
b169618b16 | ||
|
|
5ab4ef2944 | ||
|
|
577ff02c04 | ||
|
|
82d0008257 | ||
|
|
373eeb786a | ||
|
|
4500e62647 | ||
|
|
49a84e80e1 | ||
|
|
9654b79cec | ||
|
|
1ea8c64a40 | ||
|
|
9dd6fef6f8 | ||
|
|
860f9c84c3 | ||
|
|
1a0bfd54f7 | ||
|
|
c46cf5c567 | ||
|
|
0d69a01a1f | ||
|
|
583748fda3 | ||
|
|
d508478c73 | ||
|
|
30c7200a7a | ||
|
|
959635f461 | ||
|
|
86cd8cd46e | ||
|
|
26ed3c1523 | ||
|
|
aa16676c74 | ||
|
|
99614fe321 | ||
|
|
2ad2836d77 | ||
|
|
801453fbdb | ||
|
|
c754dff4ad | ||
|
|
47018fcd69 | ||
|
|
afa99f598b | ||
|
|
e90ea5154c | ||
|
|
b895ea819c | ||
|
|
1a1dac6b8f | ||
|
|
541418fd60 | ||
|
|
dff5096164 | ||
|
|
8b580fc732 | ||
|
|
f9123889f2 | ||
|
|
9fd1c961e4 | ||
|
|
4ea15567c2 | ||
|
|
ef16c7e037 | ||
|
|
4b94e03413 | ||
|
|
0a1c7cff5c | ||
|
|
bd5ebdaa43 | ||
|
|
a090579db9 | ||
|
|
5e7db5e271 | ||
|
|
4a3b111ce4 | ||
|
|
fe3e26e21d | ||
|
|
8efe1ae5c5 | ||
|
|
12fac4ea1c | ||
|
|
1bace88f37 | ||
|
|
ba5ca7e0b3 | ||
|
|
e030f3a194 | ||
|
|
43e7e0ba17 | ||
|
|
0ee970d8bd | ||
|
|
ead2060ab3 | ||
|
|
bdc3254ed2 | ||
|
|
685dce2519 | ||
|
|
ec80107128 | ||
|
|
fffca13941 | ||
|
|
760b43cc68 | ||
|
|
3bc48598cd | ||
|
|
704b20cde1 | ||
|
|
d1ae364dbd | ||
|
|
75def0ff38 | ||
|
|
ff629e596d | ||
|
|
592dc6ceb1 | ||
|
|
573c0a3385 | ||
|
|
8aea76d99b | ||
|
|
61096ba190 | ||
|
|
f476545172 | ||
|
|
aae97f6ce9 | ||
|
|
8ce8831315 | ||
|
|
28fb62e5d6 | ||
|
|
42bfdd033c | ||
|
|
945077a453 | ||
|
|
0ce140a210 | ||
|
|
83fe0776eb | ||
|
|
a0019b8b0e | ||
|
|
2a0534ac62 | ||
|
|
3c45fba0f5 | ||
|
|
708925ab41 | ||
|
|
92ad32bb8e | ||
|
|
669d032f96 | ||
|
|
b37c492930 | ||
|
|
46757e848f | ||
|
|
201de84ad0 | ||
|
|
85a5ff9ff0 | ||
|
|
e712e11ea0 | ||
|
|
b552d20a46 | ||
|
|
21cec260b8 | ||
|
|
5a98bf2e85 | ||
|
|
f11b31c09c | ||
|
|
0b18ec353c |
@@ -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("🔚 程序结束")
|
|
||||||
@@ -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_buf(bytearray,长度=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 header(inclusive)
|
||||||
|
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()
|
||||||
@@ -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)")
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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
@@ -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:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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 detector;detect 使用 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
|
||||||
|
|
||||||
|
# ==================== 引脚映射配置(板载 WiFi,I2C5)====================
|
||||||
|
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
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+2576
File diff suppressed because it is too large
Load Diff
+57
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 可能直接触发底层崩溃(SIGSEGV),try/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
@@ -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-----
|
||||||
@@ -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())
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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×224、320×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 相同的全图 RGB(numpy,H×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
@@ -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
|
||||||
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
应用版本号
|
||||||
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
|
"""
|
||||||
|
VERSION = '2.15.35'
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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.sta、sync、reboot。
|
||||||
|
"""
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time as std_time
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
import config
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
from wifi import wifi_manager
|
||||||
|
|
||||||
|
|
||||||
|
_http_thread = None
|
||||||
|
_http_stop = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def _http_response(status, body_bytes, content_type="text/html; charset=utf-8"):
|
||||||
|
head = (
|
||||||
|
f"HTTP/1.1 {status}\r\n"
|
||||||
|
f"Content-Type: {content_type}\r\n"
|
||||||
|
f"Content-Length: {len(body_bytes)}\r\n"
|
||||||
|
f"Connection: close\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
return head + body_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _read_http_request(conn, max_total=65536):
|
||||||
|
"""返回 (method, path, headers_str, body_bytes) 或 None。"""
|
||||||
|
buf = b""
|
||||||
|
while b"\r\n\r\n" not in buf and len(buf) < max_total:
|
||||||
|
chunk = conn.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
if b"\r\n\r\n" not in buf:
|
||||||
|
return None
|
||||||
|
idx = buf.index(b"\r\n\r\n")
|
||||||
|
header_bytes = buf[:idx]
|
||||||
|
rest = buf[idx + 4 :]
|
||||||
|
try:
|
||||||
|
headers_str = header_bytes.decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
headers_str = ""
|
||||||
|
lines = headers_str.split("\r\n")
|
||||||
|
if not lines:
|
||||||
|
return None
|
||||||
|
parts = lines[0].split()
|
||||||
|
method = parts[0] if parts else "GET"
|
||||||
|
path = parts[1] if len(parts) > 1 else "/"
|
||||||
|
|
||||||
|
content_length = 0
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line.lower().startswith("content-length:"):
|
||||||
|
try:
|
||||||
|
content_length = int(line.split(":", 1)[1].strip())
|
||||||
|
except Exception:
|
||||||
|
content_length = 0
|
||||||
|
break
|
||||||
|
|
||||||
|
body = rest
|
||||||
|
while content_length > 0 and len(body) < content_length and len(body) < max_total:
|
||||||
|
chunk = conn.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
body += chunk
|
||||||
|
body = body[:content_length]
|
||||||
|
return method, path, headers_str, body
|
||||||
|
|
||||||
|
|
||||||
|
def _page_form(msg_html=""):
|
||||||
|
# 页面展示的热点名:以 /boot/wifi.ssid 为准(与实际 AP 保持一致)
|
||||||
|
try:
|
||||||
|
if os.path.exists("/boot/wifi.ssid"):
|
||||||
|
with open("/boot/wifi.ssid", "r", encoding="utf-8") as f:
|
||||||
|
_ssid = f.read().strip()
|
||||||
|
else:
|
||||||
|
_ssid = ""
|
||||||
|
except Exception:
|
||||||
|
_ssid = ""
|
||||||
|
ap_ssid = html.escape(_ssid or getattr(config, "WIFI_CONFIG_AP_SSID", "ArcherySetup"))
|
||||||
|
port = int(getattr(config, "WIFI_CONFIG_HTTP_PORT", 8080))
|
||||||
|
ap_ip = html.escape(getattr(config, "WIFI_CONFIG_AP_IP", "192.168.66.1"))
|
||||||
|
body = f"""<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<title>WiFi 配网</title></head><body>
|
||||||
|
<h1>WiFi 配网</h1>
|
||||||
|
<p>热点:<b>{ap_ssid}</b> · 端口 <b>{port}</b></p>
|
||||||
|
<p>请填写要连接的<b>路由器</b> SSID 与密码(用于 STA 上网,不是热点密码)。提交后将关闭热点、保存并<b>重启设备</b>。</p>
|
||||||
|
{msg_html}
|
||||||
|
<form method="POST" action="/" accept-charset="utf-8">
|
||||||
|
<p>SSID<br/><input name="ssid" type="text" style="width:100%;max-width:320px" required/></p>
|
||||||
|
<p>密码(开放网络可留空)<br/><input name="password" type="password" style="width:100%;max-width:320px"/></p>
|
||||||
|
<p><button type="submit">保存并重启</button></p>
|
||||||
|
</form>
|
||||||
|
<p style="color:#666;font-size:12px">提示:提交后设备会重启;请手机改连路由器 WiFi。</p>
|
||||||
|
</body></html>"""
|
||||||
|
return body.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sta_and_reboot(router_ssid: str, router_password: str):
|
||||||
|
"""
|
||||||
|
写路由器 STA 凭证 -> 停 WiFi 服务 -> 删 /boot/wifi.ap -> 建 /boot/wifi.sta -> sync -> reboot
|
||||||
|
"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
ok, err = wifi_manager.persist_sta_credentials(router_ssid, router_password, restart_service=False)
|
||||||
|
if not ok:
|
||||||
|
return False, err
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.system("/etc/init.d/S30wifi stop")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WIFI-AP] S30wifi stop: {e}")
|
||||||
|
|
||||||
|
ap_flag = "/boot/wifi.ap"
|
||||||
|
sta_flag = "/boot/wifi.sta"
|
||||||
|
try:
|
||||||
|
if os.path.exists(ap_flag):
|
||||||
|
os.remove(ap_flag)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"删除 {ap_flag} 失败: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sta_flag, "w", encoding="utf-8") as f:
|
||||||
|
f.write("")
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"创建 {sta_flag} 失败: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.system("sync")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("[WIFI-AP] 已切换为 STA 标志并准备 reboot")
|
||||||
|
try:
|
||||||
|
os.system("reboot")
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"reboot 调用失败: {e}"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_client(conn, addr):
|
||||||
|
logger = logger_manager.logger
|
||||||
|
try:
|
||||||
|
conn.settimeout(30.0)
|
||||||
|
req = _read_http_request(conn)
|
||||||
|
if not req:
|
||||||
|
conn.sendall(_http_response("400 Bad Request", b"Bad Request"))
|
||||||
|
return
|
||||||
|
method, path, _headers, body = req
|
||||||
|
path = path.split("?", 1)[0]
|
||||||
|
|
||||||
|
if method == "GET" and path in ("/", "/index.html"):
|
||||||
|
conn.sendall(_http_response("200 OK", _page_form()))
|
||||||
|
return
|
||||||
|
|
||||||
|
if method == "POST" and path in ("/", "/index.html"):
|
||||||
|
try:
|
||||||
|
qs = body.decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
qs = ""
|
||||||
|
fields = parse_qs(qs, keep_blank_values=True)
|
||||||
|
ssid = (fields.get("ssid") or [""])[0].strip()
|
||||||
|
password = (fields.get("password") or [""])[0]
|
||||||
|
ok, err = _apply_sta_and_reboot(ssid, password)
|
||||||
|
if ok:
|
||||||
|
msg = '<p style="color:green"><b>已保存,设备正在重启…</b></p>'
|
||||||
|
else:
|
||||||
|
msg = f'<p style="color:red"><b>失败:</b>{html.escape(err)}</p>'
|
||||||
|
conn.sendall(_http_response("200 OK", _page_form(msg)))
|
||||||
|
return
|
||||||
|
|
||||||
|
if method == "GET" and path == "/favicon.ico":
|
||||||
|
conn.sendall(_http_response("204 No Content", b""))
|
||||||
|
return
|
||||||
|
|
||||||
|
conn.sendall(_http_response("404 Not Found", b"Not Found"))
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
logger.error(f"[WIFI-HTTP] 处理请求异常 {addr}: {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_loop(host, port):
|
||||||
|
logger = logger_manager.logger
|
||||||
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
srv.bind((host, port))
|
||||||
|
srv.listen(5)
|
||||||
|
srv.settimeout(1.0)
|
||||||
|
logger.info(f"[WIFI-HTTP] 监听 {host}:{port}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WIFI-HTTP] bind 失败: {e}")
|
||||||
|
try:
|
||||||
|
srv.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
while not _http_stop.is_set():
|
||||||
|
try:
|
||||||
|
conn, addr = srv.accept()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
if _http_stop.is_set():
|
||||||
|
break
|
||||||
|
logger.warning(f"[WIFI-HTTP] accept: {e}")
|
||||||
|
continue
|
||||||
|
t = threading.Thread(target=_handle_client, args=(conn, addr), daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
srv.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("[WIFI-HTTP] 服务已停止")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_hostapd_ssid(ssid: str, logger=None) -> bool:
|
||||||
|
"""
|
||||||
|
某些固件会把 SSID 写到 /etc/hostapd.conf 或 /boot/hostapd.conf。
|
||||||
|
为避免只改 /boot/wifi.ssid 不生效,这里同步更新已存在的 hostapd.conf。
|
||||||
|
Returns:
|
||||||
|
bool: 任一文件被修改则 True
|
||||||
|
"""
|
||||||
|
if logger is None:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if not ssid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed_any = False
|
||||||
|
for conf_path in ("/etc/hostapd.conf", "/boot/hostapd.conf"):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(conf_path):
|
||||||
|
continue
|
||||||
|
with open(conf_path, "r", encoding="utf-8") as f:
|
||||||
|
lines = f.read().splitlines()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
out = []
|
||||||
|
seen = False
|
||||||
|
for ln in lines:
|
||||||
|
s = ln.strip()
|
||||||
|
if s.lower().startswith("ssid="):
|
||||||
|
seen = True
|
||||||
|
cur = s.split("=", 1)[1].strip()
|
||||||
|
if cur != ssid:
|
||||||
|
out.append(f"ssid={ssid}")
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
out.append(ln)
|
||||||
|
else:
|
||||||
|
out.append(ln)
|
||||||
|
if not seen:
|
||||||
|
out.append(f"ssid={ssid}")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
try:
|
||||||
|
with open(conf_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(out).rstrip() + "\n")
|
||||||
|
changed_any = True
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[WIFI-AP] 写入 {conf_path} 失败: {e}")
|
||||||
|
|
||||||
|
if changed_any and logger:
|
||||||
|
logger.info(f"[WIFI-AP] 已同步热点 SSID 到 hostapd.conf: {ssid}")
|
||||||
|
return changed_any
|
||||||
|
|
||||||
|
|
||||||
|
def _write_boot_ap_credentials_for_s30wifi():
|
||||||
|
"""供 S30wifi AP 分支 gen_hostapd 使用的热点 SSID/密码。"""
|
||||||
|
base = (getattr(config, "WIFI_CONFIG_AP_SSID", "ArcherySetup") or "ArcherySetup").strip()
|
||||||
|
# 追加设备码,便于区分多台设备(读取 /device_key,失败则不加后缀)
|
||||||
|
suffix = ""
|
||||||
|
try:
|
||||||
|
with open("/device_key", "r", encoding="utf-8") as f:
|
||||||
|
dev = (f.read() or "").strip()
|
||||||
|
if dev:
|
||||||
|
s = dev
|
||||||
|
# 只保留字母数字,避免 SSID 出现不可见字符
|
||||||
|
s = "".join([c for c in s if c.isalnum()])
|
||||||
|
if s:
|
||||||
|
suffix = s
|
||||||
|
except Exception:
|
||||||
|
suffix = ""
|
||||||
|
ssid = f"{base}_{suffix}" if suffix else base
|
||||||
|
pwd = getattr(config, "WIFI_CONFIG_AP_PASSWORD", "12345678")
|
||||||
|
with open("/boot/wifi.ssid", "w", encoding="utf-8") as f:
|
||||||
|
f.write(ssid.strip())
|
||||||
|
with open("/boot/wifi.pass", "w", encoding="utf-8") as f:
|
||||||
|
f.write(pwd.strip())
|
||||||
|
try:
|
||||||
|
_ensure_hostapd_ssid(ssid.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _ensure_hostapd_modern_security(logger=None) -> bool:
|
||||||
|
"""
|
||||||
|
确保 AP 使用较新的安全标准(至少 WPA2-PSK + CCMP)。
|
||||||
|
你现场验证需要的两行:
|
||||||
|
- wpa_key_mgmt=WPA-PSK
|
||||||
|
- rsn_pairwise=CCMP
|
||||||
|
Returns:
|
||||||
|
bool: 若文件被修改返回 True,否则 False
|
||||||
|
"""
|
||||||
|
if logger is None:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
|
||||||
|
conf_path = "/etc/hostapd.conf"
|
||||||
|
try:
|
||||||
|
if not os.path.exists(conf_path):
|
||||||
|
return False
|
||||||
|
with open(conf_path, "r", encoding="utf-8") as f:
|
||||||
|
lines = f.read().splitlines()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WIFI-AP] 读取 hostapd.conf 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
wanted = {
|
||||||
|
"wpa_key_mgmt": "WPA-PSK",
|
||||||
|
"rsn_pairwise": "CCMP",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
seen = set()
|
||||||
|
new_lines = []
|
||||||
|
for ln in lines:
|
||||||
|
s = ln.strip()
|
||||||
|
if not s or s.startswith("#") or "=" not in s:
|
||||||
|
new_lines.append(ln)
|
||||||
|
continue
|
||||||
|
k, v = s.split("=", 1)
|
||||||
|
k = k.strip()
|
||||||
|
if k in wanted:
|
||||||
|
seen.add(k)
|
||||||
|
new_v = wanted[k]
|
||||||
|
if v.strip() != new_v:
|
||||||
|
new_lines.append(f"{k}={new_v}")
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
new_lines.append(ln)
|
||||||
|
continue
|
||||||
|
new_lines.append(ln)
|
||||||
|
|
||||||
|
# 缺的补到末尾
|
||||||
|
for k, v in wanted.items():
|
||||||
|
if k not in seen:
|
||||||
|
new_lines.append(f"{k}={v}")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(conf_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(new_lines).rstrip() + "\n")
|
||||||
|
logger.info("[WIFI-AP] 已更新 /etc/hostapd.conf 安全参数(WPA-PSK + CCMP)")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WIFI-AP] 写入 hostapd.conf 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _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()
|
||||||
@@ -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 密码长度应为 8–63 字节(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
|
||||||
Reference in New Issue
Block a user