Compare commits
54
Commits
541418fd60
...
3.00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56125a8de4 | ||
|
|
35fa4ad58c | ||
|
|
a00baa1770 | ||
|
|
179b30a944 | ||
|
|
42026d43e5 | ||
|
|
8a83deddd3 | ||
|
|
6a1d3fe2bd | ||
|
|
1fee464924 | ||
|
|
06994c5905 | ||
|
|
23755f48ae | ||
|
|
5f509488c5 | ||
|
|
c0bb245c8c | ||
|
|
9cfc871645 | ||
|
|
27f96d8bce | ||
|
|
80e780b931 | ||
|
|
3683033abf | ||
|
|
f0df9ad915 | ||
|
|
3fcd38f417 | ||
|
|
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 |
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/new/new/nw/archery - 副本/cpp_ext"
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
from maix import app, uart, pinmap, time
|
from maix import app, uart, pinmap, time
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import re
|
||||||
import ujson
|
import ujson
|
||||||
|
|
||||||
# ========== 配置 ==========
|
# ========== 配置 ==========
|
||||||
@@ -130,53 +131,109 @@ def generate_token(device_id):
|
|||||||
return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
|
return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
def send_cmd(cmd_str, timeout_ms=3000):
|
def send_cmd(cmd_str, timeout_ms=3000):
|
||||||
"""发送 AT 指令并等待 OK / ERROR"""
|
"""发送 AT 指令并返回完整响应;超时返回已收到的内容。"""
|
||||||
print("[AT] =>", cmd_str)
|
print("[AT] =>", cmd_str)
|
||||||
http_serial.write((cmd_str + "\r\n").encode())
|
http_serial.write((cmd_str + "\r\n").encode())
|
||||||
buffer = b""
|
buffer = b""
|
||||||
start = time.ticks_ms()
|
start = time.ticks_ms()
|
||||||
while time.ticks_ms() - start < timeout_ms:
|
while time.ticks_diff(time.ticks_ms(), start) < timeout_ms:
|
||||||
data = http_serial.read(128)
|
data = http_serial.read(128)
|
||||||
if data:
|
if data:
|
||||||
buffer += data
|
buffer += data
|
||||||
try:
|
try:
|
||||||
decoded = buffer.decode()
|
decoded = buffer.decode("utf-8", "ignore")
|
||||||
print("<= ", decoded.strip())
|
if "OK" in decoded or "+CME ERROR" in decoded or "ERROR" in decoded:
|
||||||
if "OK" in decoded:
|
print("[AT] <=", decoded.strip())
|
||||||
return True
|
return decoded
|
||||||
if "+CME ERROR" in decoded or "ERROR" in decoded:
|
|
||||||
return False
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep_ms(10)
|
time.sleep_ms(10)
|
||||||
|
decoded = buffer.decode("utf-8", "ignore")
|
||||||
|
print("[AT] !! timeout", timeout_ms, "ms, response:", decoded.strip() or "<empty>")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def response_ok(response):
|
||||||
|
return "OK" in response and "ERROR" not in response
|
||||||
|
|
||||||
|
|
||||||
|
def wait_modem_ready():
|
||||||
|
"""等待模组响应,并确认 PDP 上下文已经获得 IP。"""
|
||||||
|
for attempt in range(15):
|
||||||
|
if response_ok(send_cmd("AT", 1000)):
|
||||||
|
break
|
||||||
|
print("[4G] 等待模组启动", attempt + 1, "/15")
|
||||||
|
time.sleep_ms(1000)
|
||||||
|
else:
|
||||||
|
print("[4G] UART2 无 AT 响应,请检查模组供电、A28/A29 接线和串口占用")
|
||||||
|
return False
|
||||||
|
|
||||||
|
send_cmd("ATE0", 1000)
|
||||||
|
cpin = send_cmd("AT+CPIN?", 3000)
|
||||||
|
if "READY" not in cpin:
|
||||||
|
print("[4G] SIM 卡未就绪:", cpin.strip())
|
||||||
|
return False
|
||||||
|
|
||||||
|
addr = send_cmd("AT+CGPADDR=1", 3000)
|
||||||
|
match = re.search(r'\+CGPADDR:\s*1,"([^\"]+)"', addr)
|
||||||
|
if match and match.group(1) != "0.0.0.0":
|
||||||
|
print("[4G] PDP ready, IP:", match.group(1))
|
||||||
|
return True
|
||||||
|
|
||||||
|
send_cmd("AT+MIPCALL=1,1", 15000)
|
||||||
|
for _ in range(20):
|
||||||
|
addr = send_cmd("AT+CGPADDR=1", 3000)
|
||||||
|
match = re.search(r'\+CGPADDR:\s*1,"([^\"]+)"', addr)
|
||||||
|
if match and match.group(1) != "0.0.0.0":
|
||||||
|
print("[4G] PDP ready, IP:", match.group(1))
|
||||||
|
return True
|
||||||
|
time.sleep_ms(1000)
|
||||||
|
|
||||||
|
print("[4G] PDP 未获得 IP,请检查 SIM 流量、信号和 APN")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_http_instances():
|
||||||
|
for instance_id in range(6):
|
||||||
|
send_cmd(f"AT+MHTTPDEL={instance_id}", 1200)
|
||||||
|
|
||||||
def create_http_instance(url):
|
def create_http_instance(url):
|
||||||
cmd = f'AT+MHTTPCREATE="{url}"'
|
cmd = f'AT+MHTTPCREATE="{url}"'
|
||||||
if send_cmd(cmd):
|
response = send_cmd(cmd, 8000)
|
||||||
# 尝试提取 instance ID(如果模块返回)
|
match = re.search(r"\+MHTTPCREATE:\s*(\d+)", response)
|
||||||
# 注意:部分模块不会返回 ID,可忽略,直接用 0 或 1
|
if not response_ok(response) or not match:
|
||||||
return True
|
print("❌ 创建 HTTP 实例失败,模组响应:", response.strip() or "<empty>")
|
||||||
return False
|
return None
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
def send_http_request(url, api_path, token, device_id, json_data):
|
def send_http_request(url, api_path, token, device_id, json_data):
|
||||||
# 1. 创建 HTTP 实例
|
# 1. 创建 HTTP 实例
|
||||||
if not create_http_instance(url):
|
instance_id = create_http_instance(url)
|
||||||
print("❌ 创建 HTTP 实例失败")
|
if instance_id is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 2. 设置 Headers(假设实例 ID 为 0,或根据模块默认)
|
# 2. 设置 Headers
|
||||||
instance_id = 0 # 大多数模块默认实例为 0;若支持多实例,需解析返回值
|
commands = (
|
||||||
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"Content-Type: application/json"')
|
f'AT+MHTTPCFG="header",{instance_id},"Content-Type: application/json"',
|
||||||
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"Authorization: {token}"')
|
f'AT+MHTTPCFG="header",{instance_id},"Authorization: {token}"',
|
||||||
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"DeviceId: {device_id}"')
|
f'AT+MHTTPCFG="header",{instance_id},"DeviceId: {device_id}"',
|
||||||
|
)
|
||||||
|
for command in commands:
|
||||||
|
if not response_ok(send_cmd(command)):
|
||||||
|
print("❌ HTTP Header 配置失败")
|
||||||
|
send_cmd(f"AT+MHTTPDEL={instance_id}", 2000)
|
||||||
|
return False
|
||||||
|
|
||||||
# 3. 发送 Body
|
# 3. 发送 Body
|
||||||
json_str = ujson.dumps(json_data)
|
json_str = ujson.dumps(json_data)
|
||||||
send_cmd(f'AT+MHTTPCONTENT={instance_id},0,0,"{json_str}"')
|
at_json = json_str.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
if not response_ok(send_cmd(f'AT+MHTTPCONTENT={instance_id},0,0,"{at_json}"', 8000)):
|
||||||
|
print("❌ HTTP Body 配置失败")
|
||||||
|
send_cmd(f"AT+MHTTPDEL={instance_id}", 2000)
|
||||||
|
return False
|
||||||
|
|
||||||
# 4. 发起 POST 请求
|
# 4. 发起 POST 请求
|
||||||
if send_cmd(f'AT+MHTTPREQUEST={instance_id},2,0,"{api_path}"'):
|
if response_ok(send_cmd(f'AT+MHTTPREQUEST={instance_id},2,0,"{api_path}"', 15000)):
|
||||||
print("✅ HTTP 请求已发送")
|
print("✅ HTTP 请求已发送")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@@ -199,7 +256,7 @@ def read_response(timeout_ms=5000):
|
|||||||
print("🚀 启动直接上传流程...")
|
print("🚀 启动直接上传流程...")
|
||||||
|
|
||||||
token = generate_token(device_id)
|
token = generate_token(device_id)
|
||||||
print("🔑 Token:", token)
|
print("🔑 Token 已生成:", token[:12] + "...")
|
||||||
|
|
||||||
# 构造模拟数据
|
# 构造模拟数据
|
||||||
timestamp = int(time.time() * 1000)
|
timestamp = int(time.time() * 1000)
|
||||||
@@ -216,9 +273,16 @@ json_data = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 执行上传
|
# 执行上传
|
||||||
if send_http_request(url, api_path, token, device_id, json_data):
|
upload_ok = False
|
||||||
|
if not wait_modem_ready():
|
||||||
|
print("💥 4G 模组未就绪")
|
||||||
|
else:
|
||||||
|
clear_http_instances()
|
||||||
|
upload_ok = send_http_request(url, api_path, token, device_id, json_data)
|
||||||
|
|
||||||
|
if upload_ok:
|
||||||
read_response()
|
read_response()
|
||||||
else:
|
else:
|
||||||
print("💥 上传流程失败")
|
print("💥 上传流程失败")
|
||||||
|
|
||||||
print("🔚 程序结束")
|
print("🔚 程序结束")
|
||||||
|
|||||||
@@ -1,403 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
#!/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)")
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,12 +1,10 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 1.2.12
|
version: 3.0.3
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
files:
|
files:
|
||||||
- 4g_download_manager.py
|
|
||||||
- 4g_upload_manager.py
|
|
||||||
- app.yaml
|
- app.yaml
|
||||||
- archery_netcore.cpython-311-riscv64-linux-gnu.so
|
- archery_netcore.cpython-311-riscv64-linux-gnu.so
|
||||||
- at_client.py
|
- at_client.py
|
||||||
@@ -14,17 +12,18 @@ files:
|
|||||||
- cameraParameters.xml
|
- cameraParameters.xml
|
||||||
- config.py
|
- config.py
|
||||||
- hardware.py
|
- hardware.py
|
||||||
|
- laser_detector.py
|
||||||
- laser_manager.py
|
- laser_manager.py
|
||||||
- logger_manager.py
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
- model_270139.cvimodel
|
- model_317828.cvimodel
|
||||||
- model_270139.mud
|
- model_317828.mud
|
||||||
- model_270820.cvimodel
|
|
||||||
- model_270820.mud
|
|
||||||
- network.py
|
- network.py
|
||||||
|
- ota_curl.sh
|
||||||
- ota_manager.py
|
- ota_manager.py
|
||||||
- power.py
|
- power.py
|
||||||
- server.pem
|
- server.pem
|
||||||
|
- set_autostart.py
|
||||||
- shoot_manager.py
|
- shoot_manager.py
|
||||||
- shot_id_generator.py
|
- shot_id_generator.py
|
||||||
- target_roi_yolo.py
|
- target_roi_yolo.py
|
||||||
|
|||||||
Binary file not shown.
+7
-6
@@ -76,10 +76,11 @@ class ATClient:
|
|||||||
"""
|
"""
|
||||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||||
with self._cmd_lock:
|
with self._cmd_lock:
|
||||||
# 初始化等待
|
with self._q_lock:
|
||||||
self._waiting = True
|
# 初始化等待
|
||||||
self._expect = expect_b
|
self._waiting = True
|
||||||
self._resp = b""
|
self._expect = expect_b
|
||||||
|
self._resp = b""
|
||||||
|
|
||||||
# 发送
|
# 发送
|
||||||
if cmd:
|
if cmd:
|
||||||
@@ -300,8 +301,8 @@ class ATClient:
|
|||||||
if len(self._rx) > 512 * 1024:
|
if len(self._rx) > 512 * 1024:
|
||||||
self._rx = self._rx[-256 * 1024:]
|
self._rx = self._rx[-256 * 1024:]
|
||||||
else:
|
else:
|
||||||
if len(self._rx) > 16384:
|
if len(self._rx) > 32768:
|
||||||
self._rx = self._rx[-4096:]
|
self._rx = self._rx[-16384:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ TRIANGLE_DETECT_SCALE = 0.4
|
|||||||
# SERVER_IP = "stcp.shelingxingqiu.com"
|
# SERVER_IP = "stcp.shelingxingqiu.com"
|
||||||
SERVER_IP = "www.shelingxingqiu.com"
|
SERVER_IP = "www.shelingxingqiu.com"
|
||||||
SERVER_PORT = 50005
|
SERVER_PORT = 50005
|
||||||
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
|
HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
|
||||||
|
|
||||||
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
||||||
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
||||||
@@ -34,10 +34,10 @@ WIFI_QUALITY_RSSI_BAD_DBM = -80.0 # 低于此 dBm(更负更差)视为信号
|
|||||||
WIFI_QUALITY_USE_RSSI = True # 是否把 RSSI 纳入综合判定
|
WIFI_QUALITY_USE_RSSI = True # 是否把 RSSI 纳入综合判定
|
||||||
|
|
||||||
# WiFi 热点配网(手机连设备 AP,浏览器提交路由器 SSID/密码;仅 GET/POST,标准库 socket)
|
# WiFi 热点配网(手机连设备 AP,浏览器提交路由器 SSID/密码;仅 GET/POST,标准库 socket)
|
||||||
WIFI_CONFIG_AP_FALLBACK = True # # WiFi 配网失败时,是否退回热点模式,并等待重新配网
|
WIFI_CONFIG_AP_FALLBACK = False # # WiFi 配网失败时,是否退回热点模式,并等待重新配网
|
||||||
WIFI_AP_FALLBACK_WAIT_SEC = 5 # 等待5秒后再检测STA/4G
|
WIFI_AP_FALLBACK_WAIT_SEC = 5 # 等待5秒后再检测STA/4G
|
||||||
WIFI_CONFIG_AP_TIMEOUT = 5 # 热点模式超时时间(秒)
|
WIFI_CONFIG_AP_TIMEOUT = 5 # 热点模式超时时间(秒)
|
||||||
WIFI_CONFIG_AP_ENABLED = True # True=启动时开热点并起迷你 HTTP 配网服务
|
WIFI_CONFIG_AP_ENABLED = False # True=启动时开热点并起迷你 HTTP 配网服务
|
||||||
WIFI_CONFIG_AP_SSID = "ArcherySetup" # 设备发出的热点名称
|
WIFI_CONFIG_AP_SSID = "ArcherySetup" # 设备发出的热点名称
|
||||||
WIFI_CONFIG_AP_PASSWORD = "12345678" # 热点密码(WPA2 通常至少 8 位)
|
WIFI_CONFIG_AP_PASSWORD = "12345678" # 热点密码(WPA2 通常至少 8 位)
|
||||||
WIFI_CONFIG_HTTP_HOST = "0.0.0.0" # HTTP 监听地址
|
WIFI_CONFIG_HTTP_HOST = "0.0.0.0" # HTTP 监听地址
|
||||||
@@ -96,6 +96,11 @@ ADC_LASER_THRESHOLD = 3000
|
|||||||
|
|
||||||
# ==================== 激光配置 ====================
|
# ==================== 激光配置 ====================
|
||||||
MODULE_ADDR = 0x00
|
MODULE_ADDR = 0x00
|
||||||
|
# 激光开关改由 A14 GPIO 控制:低电平开启,高电平关闭。
|
||||||
|
LASER_CONTROL_PIN = "A14"
|
||||||
|
LASER_CONTROL_GPIO = "GPIOA14"
|
||||||
|
LASER_CONTROL_ON_LEVEL = 0
|
||||||
|
LASER_CONTROL_OFF_LEVEL = 1
|
||||||
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
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])
|
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_QUERY_CMD = bytes([0xAA, MODULE_ADDR, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x21]) # 激光测距查询命令
|
||||||
@@ -134,7 +139,7 @@ IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
|
|||||||
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
|
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
|
||||||
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
|
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
|
||||||
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
|
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
|
||||||
USE_TRIANGLE_OFFSET = True # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
|
USE_TRIANGLE_OFFSET = False # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
|
||||||
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
|
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
|
||||||
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
|
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
|
||||||
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
||||||
@@ -255,8 +260,22 @@ TRIANGLE_YOLO_REJECT_BAD_ROI = True
|
|||||||
TRIANGLE_CROP_ROI_MIN_SIDE_PX = 64
|
TRIANGLE_CROP_ROI_MIN_SIDE_PX = 64
|
||||||
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
|
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
|
||||||
TRIANGLE_YOLO_DRAW_ROI_ON_SHOT = True
|
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,避免返回上一帧结果。
|
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = True
|
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||||
|
|
||||||
|
# YOLO target size classification: class 0=20cm, class 1=40cm.
|
||||||
|
TARGET_CLASS_YOLO_ENABLE = True
|
||||||
|
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_317828.mud"
|
||||||
|
TARGET_CLASS_YOLO_LABELS = (20, 40)
|
||||||
|
TARGET_CLASS_YOLO_CONF_TH = 0.50
|
||||||
|
TARGET_CLASS_YOLO_IOU_TH = 0.45
|
||||||
|
TARGET_CLASS_YOLO_RETRY_ON_EMPTY = False
|
||||||
|
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
|
||||||
|
TARGET_CLASS_YOLO_PRELOAD_ON_BOOT = True
|
||||||
|
|
||||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||||
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||||
@@ -304,8 +323,16 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
|
|||||||
LASER_THICKNESS = 1
|
LASER_THICKNESS = 1
|
||||||
LASER_LENGTH = 2
|
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 = True # 是否保存图像(True=保存,False=不保存)
|
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
||||||
|
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
|
||||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
MAX_IMAGES = 1000
|
MAX_IMAGES = 1000
|
||||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||||
@@ -326,11 +353,29 @@ PIN_MAPPINGS = {
|
|||||||
"A28": "UART2_TX",
|
"A28": "UART2_TX",
|
||||||
"A15": "I2C5_SCL",
|
"A15": "I2C5_SCL",
|
||||||
"A27": "I2C5_SDA",
|
"A27": "I2C5_SDA",
|
||||||
|
"A14": "GPIOA14", # 激光开关:低开、高关
|
||||||
"A24": "GPIOA24", # 电源板关机控制
|
"A24": "GPIOA24", # 电源板关机控制
|
||||||
|
"A25": "GPIOA25", # 电源状态绿灯
|
||||||
|
"A23": "GPIOA23", # 电源状态红灯
|
||||||
}
|
}
|
||||||
|
|
||||||
# ==================== 电源配置 ====================
|
# ==================== 电源配置 ====================
|
||||||
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
||||||
|
# 充电时自动关机暂时禁用;需要恢复时改为 True。
|
||||||
|
CHARGING_AUTO_POWER_OFF_ENABLED = False
|
||||||
|
|
||||||
|
# 一代电源控制:A24 由电源板负责按键/关机信号,软件关机时输出高电平。
|
||||||
|
|
||||||
|
# 电源状态指示灯
|
||||||
|
STATUS_LED_GREEN_GPIO = "GPIOA25"
|
||||||
|
STATUS_LED_RED_GPIO = "GPIOA23"
|
||||||
|
STATUS_LED_GREEN_ENABLED = True
|
||||||
|
STATUS_LED_RED_ENABLED = True
|
||||||
|
STATUS_LED_ACTIVE_LEVEL = 1
|
||||||
|
STATUS_LED_LOW_BATTERY_PERCENT = 10
|
||||||
|
STATUS_LED_FULL_BATTERY_PERCENT = 90
|
||||||
|
STATUS_LED_CHARGING_BLINK_MS = 500
|
||||||
|
STATUS_LED_POLL_MS = 1000
|
||||||
|
|
||||||
BATTERY_SOC_LPF_ALPHA = 0.5
|
BATTERY_SOC_LPF_ALPHA = 0.5
|
||||||
BATTERY_SOC_AVG_WINDOW = 5
|
BATTERY_SOC_AVG_WINDOW = 5
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
#include <pybind11/pybind11.h>
|
|
||||||
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <openssl/evp.h>
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <openssl/evp.h>
|
||||||
#include "native_logger.hpp"
|
#include "native_logger.hpp"
|
||||||
|
|
||||||
namespace netcore{
|
namespace netcore{
|
||||||
@@ -18,11 +15,11 @@ namespace netcore{
|
|||||||
constexpr size_t kOtaMagicLen = 7;
|
constexpr size_t kOtaMagicLen = 7;
|
||||||
constexpr size_t kGcmNonceLen = 12;
|
constexpr size_t kGcmNonceLen = 12;
|
||||||
constexpr size_t kGcmTagLen = 16;
|
constexpr size_t kGcmTagLen = 16;
|
||||||
|
constexpr size_t kHeaderLen = kOtaMagicLen + kGcmNonceLen;
|
||||||
|
// 分块解密,避免整包读入导致 RAM 峰值约为「文件大小×2」(小内存设备易 OOM)
|
||||||
|
constexpr size_t kDecryptChunk = 65536;
|
||||||
|
|
||||||
// 固定 32-byte AES-256-GCM key(提高被直接查看的成本;不是绝对安全)
|
|
||||||
// 注意:需要与打包端传入的 --aead-key-hex 保持一致。
|
|
||||||
static std::array<uint8_t, 32> ota_key_bytes() {
|
static std::array<uint8_t, 32> ota_key_bytes() {
|
||||||
// 简单拆分混淆:key = a XOR b
|
|
||||||
static const std::array<uint8_t, 32> a = {
|
static const std::array<uint8_t, 32> a = {
|
||||||
0x92,0x99,0x4d,0x06,0x6f,0xb6,0xa6,0x3d,0x85,0x08,0xbe,0x73,0x5e,0x73,0x4d,0x8a,
|
0x92,0x99,0x4d,0x06,0x6f,0xb6,0xa6,0x3d,0x85,0x08,0xbe,0x73,0x5e,0x73,0x4d,0x8a,
|
||||||
0x53,0x88,0xe6,0x99,0xfc,0x10,0x29,0xb9,0x16,0x9b,0xe7,0x0c,0x65,0x21,0x1c,0xce
|
0x53,0x88,0xe6,0x99,0xfc,0x10,0x29,0xb9,0x16,0x9b,0xe7,0x0c,0x65,0x21,0x1c,0xce
|
||||||
@@ -36,56 +33,45 @@ namespace netcore{
|
|||||||
return k;
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool read_file_all(const std::string& path, std::vector<uint8_t>& out) {
|
|
||||||
std::ifstream ifs(path, std::ios::binary);
|
|
||||||
if (!ifs) return false;
|
|
||||||
ifs.seekg(0, std::ios::end);
|
|
||||||
std::streampos size = ifs.tellg();
|
|
||||||
if (size <= 0) return false;
|
|
||||||
ifs.seekg(0, std::ios::beg);
|
|
||||||
out.resize(static_cast<size_t>(size));
|
|
||||||
if (!ifs.read(reinterpret_cast<char*>(out.data()), size)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool write_file_all(const std::string& path, const uint8_t* data, size_t len) {
|
|
||||||
std::ofstream ofs(path, std::ios::binary | std::ios::trunc);
|
|
||||||
if (!ofs) return false;
|
|
||||||
ofs.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(len));
|
|
||||||
return static_cast<bool>(ofs);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path) {
|
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path) {
|
||||||
std::vector<uint8_t> in;
|
std::ifstream ifs(input_path, std::ios::binary);
|
||||||
if (!netcore::read_file_all(input_path, in)) {
|
if (!ifs) {
|
||||||
netcore::log_error(std::string("decrypt_ota_file: read failed: ") + input_path);
|
netcore::log_error(std::string("decrypt_ota_file: open in failed: ") + input_path);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
ifs.seekg(0, std::ios::end);
|
||||||
const size_t min_len = kOtaMagicLen + kGcmNonceLen + kGcmTagLen + 1;
|
const std::streampos szp = ifs.tellg();
|
||||||
if (in.size() < min_len) {
|
if (szp <= 0) {
|
||||||
|
netcore::log_error("decrypt_ota_file: empty input");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const uint64_t file_size = static_cast<uint64_t>(szp);
|
||||||
|
const size_t min_len = kHeaderLen + kGcmTagLen + 1;
|
||||||
|
if (file_size < min_len) {
|
||||||
netcore::log_error("decrypt_ota_file: too short");
|
netcore::log_error("decrypt_ota_file: too short");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!std::equal(in.begin(), in.begin() + kOtaMagicLen, reinterpret_cast<const uint8_t*>(kOtaMagic))) {
|
const uint64_t ciphertext_len = file_size - kHeaderLen - kGcmTagLen;
|
||||||
|
|
||||||
|
ifs.seekg(0, std::ios::beg);
|
||||||
|
std::array<uint8_t, kHeaderLen> header{};
|
||||||
|
ifs.read(reinterpret_cast<char*>(header.data()), static_cast<std::streamsize>(kHeaderLen));
|
||||||
|
if (ifs.gcount() != static_cast<std::streamsize>(kHeaderLen)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: read header failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!std::equal(header.begin(), header.begin() + kOtaMagicLen,
|
||||||
|
reinterpret_cast<const uint8_t*>(kOtaMagic))) {
|
||||||
netcore::log_error("decrypt_ota_file: bad magic");
|
netcore::log_error("decrypt_ota_file: bad magic");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const uint8_t* nonce = header.data() + kOtaMagicLen;
|
||||||
|
|
||||||
const uint8_t* nonce = in.data() + kOtaMagicLen;
|
std::ofstream ofs(output_zip_path, std::ios::binary | std::ios::trunc);
|
||||||
const uint8_t* ct_and_tag = in.data() + kOtaMagicLen + kGcmNonceLen;
|
if (!ofs) {
|
||||||
const size_t ct_and_tag_len = in.size() - (kOtaMagicLen + kGcmNonceLen);
|
netcore::log_error(std::string("decrypt_ota_file: open out failed: ") + output_zip_path);
|
||||||
if (ct_and_tag_len <= kGcmTagLen) {
|
|
||||||
netcore::log_error("decrypt_ota_file: no ciphertext");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const size_t ciphertext_len = ct_and_tag_len - kGcmTagLen;
|
|
||||||
const uint8_t* ciphertext = ct_and_tag;
|
|
||||||
const uint8_t* tag = ct_and_tag + ciphertext_len;
|
|
||||||
|
|
||||||
std::vector<uint8_t> plain(ciphertext_len);
|
|
||||||
int out_len1 = 0;
|
|
||||||
int out_len2 = 0;
|
|
||||||
|
|
||||||
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
|
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
@@ -95,6 +81,8 @@ namespace netcore{
|
|||||||
|
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
auto key = ota_key_bytes();
|
auto key = ota_key_bytes();
|
||||||
|
std::vector<uint8_t> chunk_in(kDecryptChunk);
|
||||||
|
std::vector<uint8_t> chunk_out(kDecryptChunk + EVP_MAX_BLOCK_LENGTH);
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr)) {
|
if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr)) {
|
||||||
@@ -109,27 +97,59 @@ namespace netcore{
|
|||||||
netcore::log_error("decrypt_ota_file: set key/iv failed");
|
netcore::log_error("decrypt_ota_file: set key/iv failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (1 != EVP_DecryptUpdate(ctx, plain.data(), &out_len1, ciphertext, static_cast<int>(ciphertext_len))) {
|
|
||||||
netcore::log_error("decrypt_ota_file: update failed");
|
uint64_t remaining = ciphertext_len;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const size_t n = static_cast<size_t>(std::min<uint64_t>(remaining, kDecryptChunk));
|
||||||
|
ifs.read(reinterpret_cast<char*>(chunk_in.data()), static_cast<std::streamsize>(n));
|
||||||
|
if (ifs.gcount() != static_cast<std::streamsize>(n)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: read ciphertext chunk failed");
|
||||||
|
goto cleanup_ctx;
|
||||||
|
}
|
||||||
|
int outl = 0;
|
||||||
|
if (1 != EVP_DecryptUpdate(ctx, chunk_out.data(), &outl,
|
||||||
|
chunk_in.data(), static_cast<int>(n))) {
|
||||||
|
netcore::log_error("decrypt_ota_file: update failed");
|
||||||
|
goto cleanup_ctx;
|
||||||
|
}
|
||||||
|
if (outl > 0) {
|
||||||
|
ofs.write(reinterpret_cast<const char*>(chunk_out.data()), outl);
|
||||||
|
if (!ofs) {
|
||||||
|
netcore::log_error("decrypt_ota_file: write plaintext failed");
|
||||||
|
goto cleanup_ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining -= n;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::array<uint8_t, kGcmTagLen> tag{};
|
||||||
|
ifs.read(reinterpret_cast<char*>(tag.data()), static_cast<std::streamsize>(kGcmTagLen));
|
||||||
|
if (ifs.gcount() != static_cast<std::streamsize>(kGcmTagLen)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: read tag failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(kGcmTagLen), const_cast<uint8_t*>(tag))) {
|
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(kGcmTagLen), tag.data())) {
|
||||||
netcore::log_error("decrypt_ota_file: set tag failed");
|
netcore::log_error("decrypt_ota_file: set tag failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (1 != EVP_DecryptFinal_ex(ctx, plain.data() + out_len1, &out_len2)) {
|
|
||||||
|
int outl2 = 0;
|
||||||
|
if (1 != EVP_DecryptFinal_ex(ctx, chunk_out.data(), &outl2)) {
|
||||||
netcore::log_error("decrypt_ota_file: final failed (auth tag mismatch?)");
|
netcore::log_error("decrypt_ota_file: final failed (auth tag mismatch?)");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const size_t plain_len = static_cast<size_t>(out_len1 + out_len2);
|
if (outl2 > 0) {
|
||||||
if (!netcore::write_file_all(output_zip_path, plain.data(), plain_len)) {
|
ofs.write(reinterpret_cast<const char*>(chunk_out.data()), outl2);
|
||||||
netcore::log_error(std::string("decrypt_ota_file: write failed: ") + output_zip_path);
|
if (!ofs) {
|
||||||
break;
|
netcore::log_error("decrypt_ota_file: write final failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
} while (false);
|
} while (false);
|
||||||
|
|
||||||
|
cleanup_ctx:
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
}
|
} // namespace netcore
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "tcp_ssl_password.hpp"
|
||||||
|
|
||||||
|
#include <openssl/md5.h>
|
||||||
|
#include <sstream>
|
||||||
|
#include <iomanip>
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
|
||||||
|
static std::string md5_hex(const std::string& input) {
|
||||||
|
MD5_CTX ctx;
|
||||||
|
MD5_Init(&ctx);
|
||||||
|
MD5_Update(&ctx, input.data(), input.size());
|
||||||
|
|
||||||
|
unsigned char digest[MD5_DIGEST_LENGTH];
|
||||||
|
MD5_Final(digest, &ctx);
|
||||||
|
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::hex << std::setfill('0');
|
||||||
|
for (int i = 0; i < MD5_DIGEST_LENGTH; ++i) {
|
||||||
|
oss << std::setw(2) << static_cast<unsigned int>(digest[i]);
|
||||||
|
}
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string calculate_tcp_ssl_password(const std::string& device_id, const std::string& iccid) {
|
||||||
|
std::string md5_device_hex = md5_hex(device_id);
|
||||||
|
if (!iccid.empty()) {
|
||||||
|
md5_device_hex += iccid;
|
||||||
|
}
|
||||||
|
return md5_hex(md5_device_hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace netcore
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
std::string calculate_tcp_ssl_password(const std::string& device_id, const std::string& iccid);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
|
|
||||||
1. CPP构建命令:
|
1. CPP构建命令:在docker环境下执行以下命令
|
||||||
|
|
||||||
cd /mnt/d/code/archery/cpp_ext
|
cd /data/cpp_ext
|
||||||
rm -rf build && mkdir build && cd build
|
rm -rf build && mkdir build && cd build
|
||||||
|
|
||||||
TOOLCHAIN_BIN=/mnt/d/code/MaixCDK/dl/extracted/toolchains/maixcam/host-tools/gcc/riscv64-linux-musl-x86_64/bin
|
TOOLCHAIN_BIN=/data/MaixCDK-main/dl/extracted/toolchains/maixcam/host-tools/gcc/riscv64-linux-musl-x86_64/bin
|
||||||
PYDEV=/mnt/d/code/shooting/python3_lib_maixcam_musl_3.11.6
|
PYDEV=/data/python3_lib_maixcam_musl_3.11.6
|
||||||
MAIXCDK=/mnt/d/code/MaixCDK
|
MAIXCDK=/data/MaixCDK-main
|
||||||
|
|
||||||
cmake .. -G Ninja \
|
cmake .. -G Ninja \
|
||||||
-DCMAKE_C_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-gcc" \
|
-DCMAKE_C_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-gcc" \
|
||||||
|
|||||||
+79
-1
@@ -5,6 +5,7 @@
|
|||||||
提供硬件对象的统一管理和访问
|
提供硬件对象的统一管理和访问
|
||||||
"""
|
"""
|
||||||
from maix import time
|
from maix import time
|
||||||
|
import _thread
|
||||||
import config
|
import config
|
||||||
from at_client import ATClient
|
from at_client import ATClient
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class HardwareManager:
|
|||||||
self._bus = None # I2C总线
|
self._bus = None # I2C总线
|
||||||
self._adc_obj = None # ADC对象
|
self._adc_obj = None # ADC对象
|
||||||
self._at_client = None # AT客户端
|
self._at_client = None # AT客户端
|
||||||
|
self._status_led_monitor_started = False
|
||||||
|
|
||||||
self._last_active_time = 0 # 用于记录用户的最后一次活跃的时间
|
self._last_active_time = 0 # 用于记录用户的最后一次活跃的时间
|
||||||
self._stop_timer = False # 用于停止定时器的标志
|
self._stop_timer = False # 用于停止定时器的标志
|
||||||
@@ -104,11 +106,87 @@ class HardwareManager:
|
|||||||
# 物理引脚是 A24,对应 GPIO 功能是 GPIOA24
|
# 物理引脚是 A24,对应 GPIO 功能是 GPIOA24
|
||||||
# 注意:这里需要先在 config.PIN_MAPPINGS 中配置好 "A24": "GPIOA24"
|
# 注意:这里需要先在 config.PIN_MAPPINGS 中配置好 "A24": "GPIOA24"
|
||||||
from maix import gpio
|
from maix import gpio
|
||||||
# 输出高电平关闭
|
# 一代电源板关机信号为高电平
|
||||||
gpio.GPIO("GPIOA24", gpio.Mode.OUT).value(1)
|
gpio.GPIO("GPIOA24", gpio.Mode.OUT).value(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"关机失败: {e}")
|
print(f"关机失败: {e}")
|
||||||
|
|
||||||
|
def start_status_led_monitor(self):
|
||||||
|
"""后台更新状态灯:正常/充满绿常亮、充电绿闪烁、低电量红常亮。"""
|
||||||
|
if self._status_led_monitor_started:
|
||||||
|
return
|
||||||
|
self._status_led_monitor_started = True
|
||||||
|
_thread.start_new_thread(self._status_led_loop, ())
|
||||||
|
|
||||||
|
def _status_led_loop(self):
|
||||||
|
from maix import gpio
|
||||||
|
from power import get_bus_voltage, is_charging, voltage_to_percent
|
||||||
|
|
||||||
|
try:
|
||||||
|
green = None
|
||||||
|
if getattr(config, "STATUS_LED_GREEN_ENABLED", True):
|
||||||
|
green = gpio.GPIO(config.STATUS_LED_GREEN_GPIO, gpio.Mode.OUT)
|
||||||
|
red = None
|
||||||
|
if getattr(config, "STATUS_LED_RED_ENABLED", True):
|
||||||
|
red = gpio.GPIO(config.STATUS_LED_RED_GPIO, gpio.Mode.OUT)
|
||||||
|
active = int(config.STATUS_LED_ACTIVE_LEVEL)
|
||||||
|
inactive = 0 if active else 1
|
||||||
|
if green is not None:
|
||||||
|
green.value(inactive)
|
||||||
|
if red is not None:
|
||||||
|
red.value(inactive)
|
||||||
|
last_state = None
|
||||||
|
blink_on = False
|
||||||
|
blink_period = max(100, int(config.STATUS_LED_CHARGING_BLINK_MS))
|
||||||
|
poll_ms = max(100, int(config.STATUS_LED_POLL_MS))
|
||||||
|
tick_ms = min(blink_period, poll_ms)
|
||||||
|
sensor_elapsed = poll_ms
|
||||||
|
blink_elapsed = blink_period
|
||||||
|
state = "normal"
|
||||||
|
percent = None
|
||||||
|
charging = False
|
||||||
|
|
||||||
|
while self._status_led_monitor_started:
|
||||||
|
if sensor_elapsed >= poll_ms:
|
||||||
|
voltage = get_bus_voltage()
|
||||||
|
percent = voltage_to_percent(voltage) if voltage > 0 else None
|
||||||
|
charging = is_charging()
|
||||||
|
low = percent is not None and percent <= int(config.STATUS_LED_LOW_BATTERY_PERCENT)
|
||||||
|
full = percent is not None and percent >= int(config.STATUS_LED_FULL_BATTERY_PERCENT)
|
||||||
|
if charging:
|
||||||
|
state = "full" if full else "charging"
|
||||||
|
else:
|
||||||
|
state = "low" if low else "normal"
|
||||||
|
sensor_elapsed = 0
|
||||||
|
|
||||||
|
if state == "low":
|
||||||
|
if green is not None:
|
||||||
|
green.value(inactive)
|
||||||
|
if red is not None:
|
||||||
|
red.value(active)
|
||||||
|
elif state == "charging":
|
||||||
|
if blink_elapsed >= blink_period:
|
||||||
|
blink_on = not blink_on
|
||||||
|
blink_elapsed = 0
|
||||||
|
if green is not None:
|
||||||
|
green.value(active if blink_on else inactive)
|
||||||
|
if red is not None:
|
||||||
|
red.value(inactive)
|
||||||
|
else: # normal or full
|
||||||
|
if green is not None:
|
||||||
|
green.value(active)
|
||||||
|
if red is not None:
|
||||||
|
red.value(inactive)
|
||||||
|
if state != last_state:
|
||||||
|
print(f"[STATUS_LED] state={state} percent={percent} charging={charging}")
|
||||||
|
last_state = state
|
||||||
|
time.sleep_ms(tick_ms)
|
||||||
|
sensor_elapsed += tick_ms
|
||||||
|
blink_elapsed += tick_ms
|
||||||
|
except Exception as e:
|
||||||
|
self._status_led_monitor_started = False
|
||||||
|
print(f"[STATUS_LED] monitor failed: {e}")
|
||||||
|
|
||||||
def start_idle_timer(self):
|
def start_idle_timer(self):
|
||||||
self._stop_timer = False
|
self._stop_timer = False
|
||||||
self._last_active_time = time.time()
|
self._last_active_time = time.time()
|
||||||
|
|||||||
@@ -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
|
||||||
+83
-92
@@ -31,6 +31,7 @@ class LaserManager:
|
|||||||
|
|
||||||
# 私有状态
|
# 私有状态
|
||||||
self._serial = None # 激光串口,由 laser_manager 自己持有
|
self._serial = None # 激光串口,由 laser_manager 自己持有
|
||||||
|
self._laser_gpio = None # A14 激光开关,低电平开启、高电平关闭
|
||||||
self._calibration_active = False
|
self._calibration_active = False
|
||||||
self._calibration_result = None
|
self._calibration_result = None
|
||||||
self._calibration_lock = threading.Lock()
|
self._calibration_lock = threading.Lock()
|
||||||
@@ -54,8 +55,8 @@ class LaserManager:
|
|||||||
@property
|
@property
|
||||||
def laser_point(self):
|
def laser_point(self):
|
||||||
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
|
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
|
||||||
if config.HARDCODE_LASER_POINT:
|
# if config.HARDCODE_LASER_POINT:
|
||||||
return config.HARDCODE_LASER_POINT_VALUE
|
# return config.HARDCODE_LASER_POINT_VALUE
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
def get_last_frame_with_ellipse(self):
|
def get_last_frame_with_ellipse(self):
|
||||||
@@ -69,10 +70,21 @@ class LaserManager:
|
|||||||
|
|
||||||
# ==================== 初始化方法 ====================
|
# ==================== 初始化方法 ====================
|
||||||
|
|
||||||
|
def init_control_gpio(self):
|
||||||
|
"""尽早初始化 A14,并拉高确保激光关闭。"""
|
||||||
|
from maix import gpio, pinmap
|
||||||
|
|
||||||
|
pinmap.set_pin_function(config.LASER_CONTROL_PIN, config.LASER_CONTROL_GPIO)
|
||||||
|
if self._laser_gpio is None:
|
||||||
|
self._laser_gpio = gpio.GPIO(config.LASER_CONTROL_GPIO, gpio.Mode.OUT)
|
||||||
|
self._laser_gpio.value(config.LASER_CONTROL_OFF_LEVEL)
|
||||||
|
self._laser_turned_on = False
|
||||||
|
print(f"[LASER] {config.LASER_CONTROL_PIN}=HIGH,激光已关闭")
|
||||||
|
|
||||||
def init(self, serial_device=None, baudrate=None):
|
def init(self, serial_device=None, baudrate=None):
|
||||||
"""
|
"""
|
||||||
初始化激光模块(包括串口)
|
初始化激光模块(A14 开关 + 测距串口)
|
||||||
初始化完成后主动发送关闭命令,防止 UART 初始化噪声误触发激光
|
初始化时先将 A14 拉高关闭激光,防止开机误触发
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serial_device: 串口设备路径,默认使用 config.DISTANCE_SERIAL_DEVICE
|
serial_device: 串口设备路径,默认使用 config.DISTANCE_SERIAL_DEVICE
|
||||||
@@ -82,51 +94,36 @@ class LaserManager:
|
|||||||
device = serial_device or config.DISTANCE_SERIAL_DEVICE
|
device = serial_device or config.DISTANCE_SERIAL_DEVICE
|
||||||
baud = baudrate or config.DISTANCE_SERIAL_BAUDRATE
|
baud = baudrate or config.DISTANCE_SERIAL_BAUDRATE
|
||||||
|
|
||||||
|
self.init_control_gpio()
|
||||||
|
|
||||||
self._serial = uart.UART(device, baud)
|
self._serial = uart.UART(device, baud)
|
||||||
print(f"[LASER] 激光串口初始化完成: device={device}, baudrate={baud}")
|
print(f"[LASER] 激光串口初始化完成: device={device}, baudrate={baud}")
|
||||||
|
|
||||||
# 等待串口稳定后主动关闭激光,防止初始化噪声误触发
|
|
||||||
time.sleep_ms(100)
|
|
||||||
try:
|
|
||||||
self._serial.read(-1) # 清空接收缓冲区
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._serial.write(config.LASER_OFF_CMD)
|
|
||||||
time.sleep_ms(60)
|
|
||||||
try:
|
|
||||||
self._serial.read(-1) # 清空回包
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
print("[LASER] 已发送关闭命令(防止开机误触发)")
|
|
||||||
|
|
||||||
# ==================== 业务方法 ====================
|
# ==================== 业务方法 ====================
|
||||||
|
|
||||||
def load_laser_point(self):
|
def load_laser_point(self):
|
||||||
"""从配置文件加载激光中心点,失败则使用默认值
|
"""加载激光中心点:优先使用本地保存的坐标,其次硬编码值,最后默认值"""
|
||||||
如果启用硬编码模式,则直接使用硬编码值
|
# 优先:从本地持久化文件加载(由 cmd 201 保存)
|
||||||
"""
|
|
||||||
if config.HARDCODE_LASER_POINT:
|
|
||||||
# 硬编码模式:直接使用硬编码值
|
|
||||||
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
|
|
||||||
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
|
|
||||||
return self._laser_point
|
|
||||||
|
|
||||||
# 正常模式:从配置文件加载
|
|
||||||
try:
|
try:
|
||||||
if "laser_config.json" in os.listdir("/root"):
|
if "laser_config.json" in os.listdir("/root"):
|
||||||
with open(config.CONFIG_FILE, "r") as f:
|
with open(config.CONFIG_FILE, "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if isinstance(data, list) and len(data) == 2:
|
if isinstance(data, list) and len(data) == 2:
|
||||||
self._laser_point = (int(data[0]), int(data[1]))
|
self._laser_point = (int(data[0]), int(data[1]))
|
||||||
self.logger.debug(f"[INFO] 加载激光点: {self._laser_point}")
|
self.logger.info(f"[LASER] 从本地加载激光点: {self._laser_point}")
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
else:
|
except Exception:
|
||||||
raise ValueError
|
pass
|
||||||
else:
|
|
||||||
self._laser_point = config.DEFAULT_LASER_POINT
|
# 其次:硬编码值
|
||||||
except:
|
if config.HARDCODE_LASER_POINT:
|
||||||
self._laser_point = config.DEFAULT_LASER_POINT
|
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
|
||||||
|
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
|
||||||
|
return self._laser_point
|
||||||
|
|
||||||
|
# 最后:默认值
|
||||||
|
self._laser_point = config.DEFAULT_LASER_POINT
|
||||||
|
self.logger.info(f"[LASER] 使用默认激光点: {self._laser_point}")
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
def save_laser_point(self, point):
|
def save_laser_point(self, point):
|
||||||
@@ -150,66 +147,38 @@ class LaserManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def turn_on_laser(self):
|
def turn_on_laser(self):
|
||||||
"""发送指令开启激光,并读取回包(部分模块支持)"""
|
"""A14 输出低电平,开启激光。"""
|
||||||
if self._serial is None:
|
if self._laser_gpio is None:
|
||||||
self.logger.error("[LASER] 激光串口未初始化,请先调用 init()")
|
if self.logger:
|
||||||
return None
|
self.logger.error("[LASER] A14 GPIO 未初始化,请先调用 init()")
|
||||||
|
return False
|
||||||
# 打印调试信息
|
|
||||||
self.logger.info(f"[LASER] 发送开启命令: {config.LASER_ON_CMD.hex()}")
|
|
||||||
|
|
||||||
# 清空接收缓冲区
|
|
||||||
try:
|
try:
|
||||||
self._serial.read(-1) # 清空缓冲区
|
self._laser_gpio.value(config.LASER_CONTROL_ON_LEVEL)
|
||||||
except:
|
self._laser_turned_on = True
|
||||||
pass
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] A14=LOW,激光开启")
|
||||||
# 发送命令
|
return True
|
||||||
written = self._serial.write(config.LASER_ON_CMD)
|
except Exception as e:
|
||||||
self.logger.info(f"[LASER] 写入字节数: {written}")
|
if self.logger:
|
||||||
|
self.logger.error(f"[LASER] A14 开启激光失败: {e}")
|
||||||
time.sleep_ms(60)
|
return False
|
||||||
|
|
||||||
# 读取回包
|
|
||||||
resp = self._serial.read(len=20, timeout=10)
|
|
||||||
if resp:
|
|
||||||
self.logger.info(f"[LASER] 收到回包 ({len(resp)}字节): {resp.hex()}")
|
|
||||||
if resp == config.LASER_ON_CMD:
|
|
||||||
self.logger.info("✅ 激光开启指令已确认")
|
|
||||||
else:
|
|
||||||
self.logger.warning("🔇 无回包(可能正常或模块不支持回包)")
|
|
||||||
self._laser_turned_on = True
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def turn_off_laser(self):
|
def turn_off_laser(self):
|
||||||
"""发送指令关闭激光"""
|
"""A14 输出高电平,关闭激光。"""
|
||||||
if self._serial is None:
|
if self._laser_gpio is None:
|
||||||
self.logger.error("[LASER] 激光串口未初始化,请先调用 init()")
|
if self.logger:
|
||||||
return None
|
self.logger.error("[LASER] A14 GPIO 未初始化,请先调用 init()")
|
||||||
|
return False
|
||||||
# 打印调试信息
|
|
||||||
self.logger.info(f"[LASER] 发送关闭命令: {config.LASER_OFF_CMD.hex()}")
|
|
||||||
|
|
||||||
# 清空接收缓冲区
|
|
||||||
try:
|
try:
|
||||||
self._serial.read(-1)
|
self._laser_gpio.value(config.LASER_CONTROL_OFF_LEVEL)
|
||||||
except:
|
self._laser_turned_on = False
|
||||||
pass
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] A14=HIGH,激光关闭")
|
||||||
# 发送命令
|
return True
|
||||||
written = self._serial.write(config.LASER_OFF_CMD)
|
except Exception as e:
|
||||||
self.logger.info(f"[LASER] 写入字节数: {written}")
|
if self.logger:
|
||||||
|
self.logger.error(f"[LASER] A14 关闭激光失败: {e}")
|
||||||
time.sleep_ms(60)
|
return False
|
||||||
|
|
||||||
# 读取回包
|
|
||||||
resp = self._serial.read(20)
|
|
||||||
if resp:
|
|
||||||
self.logger.info(f"[LASER] 收到回包 ({len(resp)}字节): {resp.hex()}")
|
|
||||||
else:
|
|
||||||
self.logger.warning("🔇 无回包")
|
|
||||||
self._laser_turned_on = False
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def flash_laser(self, duration_ms=1000):
|
def flash_laser(self, duration_ms=1000):
|
||||||
"""闪一下激光(非阻塞版本)"""
|
"""闪一下激光(非阻塞版本)"""
|
||||||
@@ -1264,6 +1233,28 @@ class LaserManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[LASER] 关闭激光失败: {e}")
|
self.logger.error(f"[LASER] 关闭激光失败: {e}")
|
||||||
|
|
||||||
|
def set_hardcoded_laser_point(self, raw_x, raw_y):
|
||||||
|
"""
|
||||||
|
设置服务下发的硬编码激光点坐标,并保存到本地持久化文件。
|
||||||
|
下次启动时 load_laser_point() 会优先使用此保存的值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_x: 服务下发的 x 坐标
|
||||||
|
raw_y: 服务下发的 y 坐标
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(int_x, int_y) 元组
|
||||||
|
"""
|
||||||
|
ix = int(raw_x)
|
||||||
|
iy = int(raw_y)
|
||||||
|
self._laser_point = (ix, iy)
|
||||||
|
try:
|
||||||
|
with open(config.CONFIG_FILE, "w") as f:
|
||||||
|
json.dump([ix, iy], f)
|
||||||
|
self.logger.info(f"[LASER] 设置并持久化激光点: ({ix}, {iy})")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[LASER] 持久化激光点失败: {e}")
|
||||||
|
return ix, iy
|
||||||
|
|
||||||
# 创建全局单例实例
|
# 创建全局单例实例
|
||||||
laser_manager = LaserManager()
|
laser_manager = LaserManager()
|
||||||
|
|||||||
+2
-2
@@ -65,8 +65,8 @@ class LoggerManager:
|
|||||||
backup_count = config.LOG_BACKUP_COUNT
|
backup_count = config.LOG_BACKUP_COUNT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建日志队列(无界队列)
|
# 创建日志队列(有界队列,防止内存泄漏;满时自动丢弃旧日志)
|
||||||
self._log_queue = queue.Queue(-1)
|
self._log_queue = queue.Queue(maxsize=config.LOG_QUEUE_MAXSIZE)
|
||||||
|
|
||||||
# 确保日志文件所在的目录存在
|
# 确保日志文件所在的目录存在
|
||||||
log_dir = os.path.dirname(log_file)
|
log_dir = os.path.dirname(log_file)
|
||||||
|
|||||||
@@ -76,12 +76,14 @@ def laser_calibration_worker():
|
|||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
time.sleep_ms(1000) # 等待1秒后继续
|
time.sleep_ms(1000) # 等待1秒后继续
|
||||||
|
|
||||||
def cmd_str():
|
def cmd_str():
|
||||||
"""主程序入口"""
|
"""主程序入口"""
|
||||||
# ==================== 第一阶段:硬件初始化 ====================
|
# ==================== 第一阶段:硬件初始化 ====================
|
||||||
# 按照 main104.py 的顺序,先完成所有硬件初始化
|
# 按照 main104.py 的顺序,先完成所有硬件初始化
|
||||||
|
|
||||||
|
# 开机第一步先拉高 A14 关闭激光,避免其他硬件初始化期间误亮。
|
||||||
|
laser_manager.init_control_gpio()
|
||||||
|
|
||||||
# 1. 引脚功能映射
|
# 1. 引脚功能映射
|
||||||
for pin, func in config.PIN_MAPPINGS.items():
|
for pin, func in config.PIN_MAPPINGS.items():
|
||||||
try:
|
try:
|
||||||
@@ -103,6 +105,8 @@ def cmd_str():
|
|||||||
print(f"[BOOT] init_ina226 开始 wall_s={_w_boot:.3f}")
|
print(f"[BOOT] init_ina226 开始 wall_s={_w_boot:.3f}")
|
||||||
init_ina226()
|
init_ina226()
|
||||||
print(f"[BOOT] init_ina226 结束 wall +{int(round((wall_time.time() - _w_boot) * 1000))} ms")
|
print(f"[BOOT] init_ina226 结束 wall +{int(round((wall_time.time() - _w_boot) * 1000))} ms")
|
||||||
|
# 启动 A25 绿灯和 A23 红灯状态指示。
|
||||||
|
hardware_manager.start_status_led_monitor()
|
||||||
|
|
||||||
# 4. 初始化显示和相机
|
# 4. 初始化显示和相机
|
||||||
_w_boot = wall_time.time()
|
_w_boot = wall_time.time()
|
||||||
@@ -120,9 +124,9 @@ def cmd_str():
|
|||||||
|
|
||||||
# ==================== 第二阶段:软件初始化 ====================
|
# ==================== 第二阶段:软件初始化 ====================
|
||||||
|
|
||||||
# 1. 初始化日志系统
|
# 1. 初始化日志系统(WARNING级别,不打印/写入INFO和DEBUG日志,提高执行流畅度)
|
||||||
import logging
|
import logging
|
||||||
logger_manager.init_logging(log_level=logging.DEBUG)
|
logger_manager.init_logging(log_level=logging.WARNING)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||||
@@ -162,7 +166,11 @@ def cmd_str():
|
|||||||
and _loc_black == "yolo"
|
and _loc_black == "yolo"
|
||||||
and bool(getattr(config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
|
and bool(getattr(config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
|
||||||
)
|
)
|
||||||
_preload_yolo = _preload_yolo or _need_black_preload
|
_need_target_preload = (
|
||||||
|
bool(getattr(config, "TARGET_CLASS_YOLO_ENABLE", False))
|
||||||
|
and bool(getattr(config, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True))
|
||||||
|
)
|
||||||
|
_preload_yolo = _preload_yolo or _need_black_preload or _need_target_preload
|
||||||
if _preload_yolo:
|
if _preload_yolo:
|
||||||
preload_yolo_detector(logger)
|
preload_yolo_detector(logger)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -245,8 +253,8 @@ def cmd_str():
|
|||||||
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
||||||
network_manager.read_device_id()
|
network_manager.read_device_id()
|
||||||
|
|
||||||
# 5. 创建照片存储目录(如果启用图像保存)
|
# 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存)
|
||||||
if config.SAVE_IMAGE_ENABLED:
|
if config.SAVE_IMAGE_ENABLED or getattr(config, "SAVE_IMAGE_ON_FAILURE", False):
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
if photo_dir not in os.listdir("/root"):
|
if photo_dir not in os.listdir("/root"):
|
||||||
try:
|
try:
|
||||||
@@ -278,46 +286,45 @@ def cmd_str():
|
|||||||
logger.info("系统准备完成...")
|
logger.info("系统准备完成...")
|
||||||
|
|
||||||
last_adc_trigger = 0
|
last_adc_trigger = 0
|
||||||
|
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
|
||||||
|
try:
|
||||||
|
last_adc_val = hardware_manager.adc_obj.read()
|
||||||
|
except Exception:
|
||||||
|
last_adc_val = 0
|
||||||
|
peak_adc_val = 0 # 当前周期内的压力峰值
|
||||||
# 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样
|
# 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样
|
||||||
PRESSURE_BATCH_SIZE = 100
|
PRESSURE_BATCH_SIZE = 100
|
||||||
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_sum = 0
|
||||||
pressure_abs_sum = 0
|
|
||||||
pressure_min = 4095
|
pressure_min = 4095
|
||||||
pressure_max = 0
|
pressure_max = 0
|
||||||
pressure_t0_ms = None
|
pressure_t0_ms = None
|
||||||
last_avg_abs = 0
|
|
||||||
|
|
||||||
def _flush_pressure_buf(reason: str):
|
def _flush_pressure_buf(reason: str):
|
||||||
if not config.AIR_PRESSURE_lOG:
|
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger
|
||||||
return
|
|
||||||
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
|
|
||||||
if not pressure_buf:
|
if not pressure_buf:
|
||||||
return
|
return
|
||||||
t1_ms = time.ticks_ms()
|
if config.AIR_PRESSURE_lOG:
|
||||||
n = len(pressure_buf)
|
t1_ms = time.ticks_ms()
|
||||||
avg = (pressure_sum / n) if n else 0
|
n = len(pressure_buf)
|
||||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
avg = (pressure_sum / n) if n else 0
|
||||||
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
|
line = (
|
||||||
line = (
|
f"[气压批量] reason={reason} "
|
||||||
f"[气压批量] reason={reason} "
|
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
|
||||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
f"values={','.join(map(str, pressure_buf))}"
|
||||||
f"values={','.join(map(str, pressure_buf))}"
|
)
|
||||||
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
|
if logger:
|
||||||
)
|
logger.debug(line)
|
||||||
if logger:
|
else:
|
||||||
logger.debug(line)
|
print(line)
|
||||||
else:
|
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
||||||
print(line)
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_sum = 0
|
||||||
pressure_abs_sum = 0
|
|
||||||
pressure_min = 4095
|
pressure_min = 4095
|
||||||
pressure_max = 0
|
pressure_max = 0
|
||||||
pressure_t0_ms = None
|
pressure_t0_ms = None
|
||||||
last_avg_abs = avg_abs
|
|
||||||
|
|
||||||
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
||||||
while not app.need_exit():
|
while not app.need_exit():
|
||||||
@@ -352,12 +359,10 @@ def cmd_str():
|
|||||||
if network_manager.manual_trigger_flag:
|
if network_manager.manual_trigger_flag:
|
||||||
network_manager.clear_manual_trigger()
|
network_manager.clear_manual_trigger()
|
||||||
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
||||||
adc_abs_val = 10
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info("[TEST] TCP命令触发射箭")
|
logger.info("[TEST] TCP命令触发射箭")
|
||||||
else:
|
else:
|
||||||
adc_val = hardware_manager.adc_obj.read()
|
adc_val = hardware_manager.adc_obj.read()
|
||||||
adc_abs_val = hardware_manager.adc_obj.read_vol()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -368,24 +373,29 @@ def cmd_str():
|
|||||||
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
||||||
if pressure_t0_ms is None:
|
if pressure_t0_ms is None:
|
||||||
pressure_t0_ms = current_time
|
pressure_t0_ms = current_time
|
||||||
pressure_buf.append((adc_val, adc_abs_val))
|
pressure_buf.append(adc_val)
|
||||||
pressure_sum += adc_val
|
pressure_sum += adc_val
|
||||||
pressure_abs_sum += adc_abs_val
|
|
||||||
if adc_val < pressure_min:
|
if adc_val < pressure_min:
|
||||||
pressure_min = adc_val
|
pressure_min = adc_val
|
||||||
if adc_val > pressure_max:
|
if adc_val > pressure_max:
|
||||||
pressure_max = adc_val
|
pressure_max = adc_val
|
||||||
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
||||||
_flush_pressure_buf("batch")
|
_flush_pressure_buf("batch")
|
||||||
# if adc_val >= 2000:
|
# 峰值检测:压力从峰值下降时触发,确保捕获到最大冲击时刻
|
||||||
# print(f"adc :{adc_val}")
|
if adc_val > peak_adc_val:
|
||||||
if adc_val >= config.ADC_TRIGGER_THRESHOLD:
|
peak_adc_val = adc_val # 更新峰值
|
||||||
|
if (peak_adc_val >= config.ADC_TRIGGER_THRESHOLD
|
||||||
|
and adc_val < peak_adc_val
|
||||||
|
and last_adc_val >= peak_adc_val):
|
||||||
|
# 封顶后下降沿触发:peak是最大值,当前值开始下降,且上次值还在peak位置
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
diff_ms = current_time - last_adc_trigger
|
diff_ms = current_time - last_adc_trigger
|
||||||
if diff_ms < 3000:
|
if diff_ms < 3000:
|
||||||
logger.info(f"[MAIN] 扳机触发过于频繁, {diff_ms}ms")
|
peak_adc_val = 0 # 去抖期间重置峰值
|
||||||
|
time.sleep_ms(5)
|
||||||
continue
|
continue
|
||||||
last_adc_trigger = current_time
|
last_adc_trigger = current_time
|
||||||
|
peak_adc_val = 0 # 触发后重置峰值
|
||||||
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
|
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
|
||||||
_flush_pressure_buf("before_trigger")
|
_flush_pressure_buf("before_trigger")
|
||||||
|
|
||||||
@@ -404,10 +414,9 @@ def cmd_str():
|
|||||||
try:
|
try:
|
||||||
camera_manager.show(camera_manager.read_frame())
|
camera_manager.show(camera_manager.read_frame())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
pass
|
||||||
if logger:
|
|
||||||
logger.error(f"[MAIN] 显示异常: {e}")
|
|
||||||
time.sleep_ms(5)
|
time.sleep_ms(5)
|
||||||
|
last_adc_val = adc_val
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 主循环的顶层异常捕获,防止程序静默退出
|
# 主循环的顶层异常捕获,防止程序静默退出
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
[basic]
|
|
||||||
type = cvimodel
|
|
||||||
model = model_270139.cvimodel
|
|
||||||
|
|
||||||
[extra]
|
|
||||||
model_type = yolov5
|
|
||||||
input_type = rgb
|
|
||||||
mean = 0, 0, 0
|
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
|
||||||
labels = 黑三角和圆环
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[basic]
|
[basic]
|
||||||
type = cvimodel
|
type = cvimodel
|
||||||
model = model_270820.cvimodel
|
model = model_317828.cvimodel
|
||||||
|
|
||||||
[extra]
|
[extra]
|
||||||
model_type = yolov5
|
model_type = yolov5
|
||||||
@@ -9,5 +9,5 @@ input_type = rgb
|
|||||||
mean = 0, 0, 0
|
mean = 0, 0, 0
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
labels = triangle
|
labels = 20, 40
|
||||||
|
|
||||||
+501
-209
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,603 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# PYTHON_ARGCOMPLETE_OK
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import os.path
|
||||||
|
import collections
|
||||||
|
import uuid
|
||||||
|
import argparse
|
||||||
|
import tarfile
|
||||||
|
import io
|
||||||
|
from struct import pack, unpack
|
||||||
|
|
||||||
|
|
||||||
|
PYTHON_MIN_VERSION = (3, 5, 2) # Ubuntu 16.04 LTS contains Python v3.5.2 by default
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info < PYTHON_MIN_VERSION:
|
||||||
|
print("Python >= %r is required" % (PYTHON_MIN_VERSION,))
|
||||||
|
sys.exit(-1)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import coloredlogs
|
||||||
|
except ImportError:
|
||||||
|
coloredlogs = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import argcomplete
|
||||||
|
except ImportError:
|
||||||
|
argcomplete = None
|
||||||
|
|
||||||
|
TOC_HEADER_NAME = 0xAA640001
|
||||||
|
FIP_MAX_SIZE = 0xA0000
|
||||||
|
FIP_ALIGN_SIZE = 2 * 1024
|
||||||
|
ENTRY_SIZE = 0x28
|
||||||
|
|
||||||
|
IV_ZERO = b"\0" * 16
|
||||||
|
|
||||||
|
|
||||||
|
class FIP_HEADER_FLAG:
|
||||||
|
BitRange = collections.namedtuple("BitRange", "shift, bits")
|
||||||
|
|
||||||
|
REE_SCS = BitRange(0, 2)
|
||||||
|
REE_ENCRYPTION = BitRange(2, 2)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def test(cls, value, flag):
|
||||||
|
v = value >> flag.shift
|
||||||
|
v &= (1 << flag.bits) - 1
|
||||||
|
return v
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def value(cls, flag):
|
||||||
|
v = (1 << flag.bits) - 1
|
||||||
|
v <<= flag.shift
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class FIP_UUID:
|
||||||
|
# from arm-trusted-firmware/include/tools_share/firmware_image_package.h
|
||||||
|
uuid_c_define = """
|
||||||
|
/* ToC Entry UUIDs */
|
||||||
|
#define UUID_LICENSE_FILE \
|
||||||
|
{0x25360c62, 0x5151, 0x48ad, 0xb5, 0x91, {0x2d, 0x35, 0x67, 0x26, 0x85, 0xa5} }
|
||||||
|
#define UUID_TRUSTED_UPDATE_FIRMWARE_SCP_BL2U \
|
||||||
|
{0x03279265, 0x742f, 0x44e6, 0x8d, 0xff, {0x57, 0x9a, 0xc1, 0xff, 0x06, 0x10} }
|
||||||
|
#define UUID_TRUSTED_UPDATE_FIRMWARE_BL2U \
|
||||||
|
{0x37ebb360, 0xe5c1, 0x41ea, 0x9d, 0xf3, {0x19, 0xed, 0xa1, 0x1f, 0x68, 0x01} }
|
||||||
|
#define UUID_TRUSTED_UPDATE_FIRMWARE_NS_BL2U \
|
||||||
|
{0x111d514f, 0xe52b, 0x494e, 0xb4, 0xc5, {0x83, 0xc2, 0xf7, 0x15, 0x84, 0x0a} }
|
||||||
|
#define UUID_TRUSTED_FWU_CERT \
|
||||||
|
{0xb28a4071, 0xd618, 0x4c87, 0x8b, 0x2e, {0xc6, 0xdc, 0xcd, 0x50, 0xf0, 0x96} }
|
||||||
|
#define UUID_TRUSTED_BOOT_FIRMWARE_BL2 \
|
||||||
|
{0x0becf95f, 0x224d, 0x4d3e, 0xa5, 0x44, {0xc3, 0x9d, 0x81, 0xc7, 0x3f, 0x0a} }
|
||||||
|
#define UUID_BLD \
|
||||||
|
{0x3dfd6697, 0xbe89, 0x49e8, 0xae, 0x5d, {0x78, 0xa1, 0x40, 0x60, 0x82, 0x13} }
|
||||||
|
#define UUID_EL3_RUNTIME_FIRMWARE_BL31 \
|
||||||
|
{0x6d08d447, 0xfe4c, 0x4698, 0x9b, 0x95, {0x29, 0x50, 0xcb, 0xbd, 0x5a, 0x00} }
|
||||||
|
#define UUID_SECURE_PAYLOAD_BL32 \
|
||||||
|
{0x89e1d005, 0xdc53, 0x4713, 0x8d, 0x2b, {0x50, 0x0a, 0x4b, 0x7a, 0x3e, 0x38} }
|
||||||
|
#define UUID_NON_TRUSTED_FIRMWARE_BL33 \
|
||||||
|
{0xa7eed0d6, 0xeafc, 0x4bd5, 0x97, 0x82, {0x99, 0x34, 0xf2, 0x34, 0xb6, 0xe4} }
|
||||||
|
/* Key certificates */
|
||||||
|
#define UUID_ROT_KEY_CERT \
|
||||||
|
{0x721d2d86, 0x60f8, 0x11e4, 0x92, 0x0b, {0x8b, 0xe7, 0x62, 0x16, 0x0f, 0x24} }
|
||||||
|
#define UUID_BLD1_KEY_CERT \
|
||||||
|
{0x90e87e82, 0x60f8, 0x11e4, 0xa1, 0xb4, {0x77, 0x7a, 0x21, 0xb4, 0xf9, 0x4c} }
|
||||||
|
#define UUID_BLD2_KEY_CERT \
|
||||||
|
{0xa1214202, 0x60f8, 0x11e4, 0x8d, 0x9b, {0xf3, 0x3c, 0x0e, 0x15, 0xa0, 0x14} }
|
||||||
|
#define UUID_SOC_FW_KEY_CERT \
|
||||||
|
{0xccbeb88a, 0x60f9, 0x11e4, 0x9a, 0xd0, {0xeb, 0x48, 0x22, 0xd8, 0xdc, 0xf8} }
|
||||||
|
#define UUID_TRUSTED_OS_FW_KEY_CERT \
|
||||||
|
{0x03d67794, 0x60fb, 0x11e4, 0x85, 0xdd, {0xb7, 0x10, 0x5b, 0x8c, 0xee, 0x04} }
|
||||||
|
#define UUID_BL33_KEY_CERT \
|
||||||
|
{0x2a83d58a, 0x60fb, 0x11e4, 0x8a, 0xaf, {0xdf, 0x30, 0xbb, 0xc4, 0x98, 0x59} }
|
||||||
|
/* Content certificates */
|
||||||
|
#define UUID_TRUSTED_BOOT_FW_CERT \
|
||||||
|
{0xea69e2d6, 0x635d, 0x11e4, 0x8d, 0x8c, {0x9f, 0xba, 0xbe, 0x99, 0x56, 0xa5} }
|
||||||
|
#define UUID_BLD_CONTENT_CERT \
|
||||||
|
{0x046fbe44, 0x635e, 0x11e4, 0xb2, 0x8b, {0x73, 0xd8, 0xea, 0xae, 0x96, 0x56} }
|
||||||
|
#define UUID_SOC_FW_CONTENT_CERT \
|
||||||
|
{0x200cb2e2, 0x635e, 0x11e4, 0x9c, 0xe8, {0xab, 0xcc, 0xf9, 0x2b, 0xb6, 0x66} }
|
||||||
|
#define UUID_TRUSTED_OS_FW_CONTENT_CERT \
|
||||||
|
{0x11449fa4, 0x635e, 0x11e4, 0x87, 0x28, {0x3f, 0x05, 0x72, 0x2a, 0xf3, 0x3d} }
|
||||||
|
#define UUID_BL33_CONTENT_CERT \
|
||||||
|
{0xf3c1c48e, 0x635d, 0x11e4, 0xa7, 0xa9, {0x87, 0xee, 0x40, 0xb2, 0x3f, 0xa7} }
|
||||||
|
/* CV keys */
|
||||||
|
#define UUID_CV_TRUSTED_KEY_CERT \
|
||||||
|
{0x64fbfc49, 0x4b8c, 0x4ad3, 0xb9, 0x92, {0x93, 0x55, 0x89, 0xee, 0xf0, 0x12} }
|
||||||
|
#define UUID_CV_NON_TRUSTED_KEY_CERT \
|
||||||
|
{0xcb48bf0d, 0x7012, 0x4201, 0xbc, 0x35, {0x8a, 0x51, 0xc4, 0x90, 0x90, 0x94} }
|
||||||
|
|
||||||
|
/* DDR init*/
|
||||||
|
#define UUID_CV_DDRINIT_KEY_CERT \
|
||||||
|
{0xa61c53c9, 0x886c, 0x484f, 0x96, 0x5d, {0xd2, 0xda, 0xd7, 0xc3, 0xeb, 0x13} }
|
||||||
|
#define UUID_CV_DDRINIT_CONTENT_CERT \
|
||||||
|
{0x9dfaabd2, 0x7f1b, 0x47e6, 0xa8, 0xa6, {0x6a, 0xc3, 0x10, 0xcc, 0xac, 0x91} }
|
||||||
|
#define UUID_CV_DDRINIT \
|
||||||
|
{0x5888a5cd, 0x38fc, 0x4f66, 0xae, 0x3d, {0x2e, 0x18, 0x6d, 0x69, 0x41, 0xfb} }
|
||||||
|
|
||||||
|
/* Fast boot */
|
||||||
|
#define UUID_CV_FASTBOOT_KEY_CERT \
|
||||||
|
{0x285df54e, 0x7b50, 0x4309, 0x9b, 0x52, {0x4b, 0xc4, 0x92, 0x82, 0x60, 0xdd} }
|
||||||
|
#define UUID_CV_FASTBOOT_CONTENT_CERT \
|
||||||
|
{0x61f7595b, 0x8d77, 0x4e13, 0x91, 0x2a, {0x63, 0x6e, 0x58, 0xda, 0x5b, 0x69} }
|
||||||
|
#define UUID_CV_FASTBOOT \
|
||||||
|
{0x43766198, 0xc363, 0x48db, 0xa9, 0x97, {0xf1, 0x0e, 0x93, 0x80, 0x4f, 0xea} }
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cls_init(cls):
|
||||||
|
txt = cls.uuid_c_define
|
||||||
|
txt = txt.replace("\r\n", "\n")
|
||||||
|
txt = txt.replace("\\\n", "\n")
|
||||||
|
rx = r"""
|
||||||
|
\#define\s+
|
||||||
|
(?P<name>\S+)\s+
|
||||||
|
{
|
||||||
|
\s*(?P<u0>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u1>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u2>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u3>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u4>0x\S+)\s*,\s*
|
||||||
|
{
|
||||||
|
\s*(?P<u5>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u6>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u7>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u8>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u9>0x\S+)\s*,\s*
|
||||||
|
\s*(?P<u10>0x\S+)\s*
|
||||||
|
}\s*,?\s*
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
for m in re.finditer(rx, txt, flags=re.X):
|
||||||
|
name = m.group("name")
|
||||||
|
u = m.group(*["u%d" % i for i in range(11)])
|
||||||
|
u = [int(i, 0) for i in u]
|
||||||
|
u = pack("<IHHBBBBBBBB", *u)
|
||||||
|
u = uuid.UUID(bytes=u)
|
||||||
|
setattr(cls, name, u)
|
||||||
|
|
||||||
|
|
||||||
|
class Entry:
|
||||||
|
__slots__ = ["name", "loc", "uuid", "address", "flag", "content"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.loc = 0
|
||||||
|
self.uuid = uuid.UUID(int=0)
|
||||||
|
self.address = 0
|
||||||
|
self.flag = 0
|
||||||
|
self.content = b""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def make(cls, uuid, content):
|
||||||
|
entry = cls()
|
||||||
|
entry.uuid = uuid
|
||||||
|
entry.content = content
|
||||||
|
return entry
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_fip(cls, name, loc, fip_bin):
|
||||||
|
data = fip_bin[loc : loc + ENTRY_SIZE]
|
||||||
|
uuid_bytes, address, size, flag = unpack("<16sQQQ", data)
|
||||||
|
content = fip_bin[address : address + size]
|
||||||
|
|
||||||
|
entry = cls()
|
||||||
|
entry.name = name
|
||||||
|
entry.loc = loc
|
||||||
|
entry.uuid = uuid.UUID(bytes=uuid_bytes)
|
||||||
|
entry.address = address
|
||||||
|
entry.flag = flag
|
||||||
|
entry.content = content
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def to_bytes(self):
|
||||||
|
return pack("<16sQQQ", self.uuid.bytes, self.address, self.size, self.flag)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self):
|
||||||
|
return len(self.content)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def end(self):
|
||||||
|
return self.address + self.size
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "<%-31s loc=0x%03x U=%s a=0x%05x,0x%05x,0x%05x f=0x%x>" % (
|
||||||
|
self.name,
|
||||||
|
self.loc,
|
||||||
|
self.uuid.hex[:8],
|
||||||
|
self.address,
|
||||||
|
self.end,
|
||||||
|
self.size,
|
||||||
|
self.flag,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FIP:
|
||||||
|
ENTRY_NAMES = collections.OrderedDict(
|
||||||
|
[
|
||||||
|
("LICENSE_FILE", "UUID_LICENSE_FILE"),
|
||||||
|
("BL2", "UUID_TRUSTED_BOOT_FIRMWARE_BL2"),
|
||||||
|
("BLD", "UUID_BLD"),
|
||||||
|
("BL31", "UUID_EL3_RUNTIME_FIRMWARE_BL31"),
|
||||||
|
("BL32", "UUID_SECURE_PAYLOAD_BL32"),
|
||||||
|
("BL33", "UUID_NON_TRUSTED_FIRMWARE_BL33"),
|
||||||
|
("BLD1_KEY_CERT", "UUID_BLD1_KEY_CERT"),
|
||||||
|
("BLD2_KEY_CERT", "UUID_BLD2_KEY_CERT"),
|
||||||
|
("CV_TRUSTED_KEY_CERT", "UUID_CV_TRUSTED_KEY_CERT"),
|
||||||
|
("SOC_FW_KEY_CERT", "UUID_SOC_FW_KEY_CERT"),
|
||||||
|
("TRUSTED_OS_FW_KEY_CERT", "UUID_TRUSTED_OS_FW_KEY_CERT"),
|
||||||
|
("CV_NON_TRUSTED_KEY_CERT", "UUID_CV_NON_TRUSTED_KEY_CERT"),
|
||||||
|
("BL33_KEY_CERT", "UUID_BL33_KEY_CERT"),
|
||||||
|
("TRUSTED_BOOT_FW_CERT", "UUID_TRUSTED_BOOT_FW_CERT"),
|
||||||
|
("BLD_CONTENT_CERT", "UUID_BLD_CONTENT_CERT"),
|
||||||
|
("SOC_FW_CONTENT_CERT", "UUID_SOC_FW_CONTENT_CERT"),
|
||||||
|
("TRUSTED_OS_FW_CONTENT_CERT", "UUID_TRUSTED_OS_FW_CONTENT_CERT"),
|
||||||
|
("BL33_CONTENT_CERT", "UUID_BL33_CONTENT_CERT"),
|
||||||
|
("CV_DDRINIT", "UUID_CV_DDRINIT"),
|
||||||
|
("CV_FASTBOOT", "UUID_CV_FASTBOOT"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
TOC_Header = collections.namedtuple(
|
||||||
|
"TOC_Header", "name, serial, flag_res, flag_plat, flag_res2"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, path):
|
||||||
|
logging.info("FIP_BIN: %s", path)
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
with open(self.path, "rb") as fp:
|
||||||
|
self.binary = fp.read(FIP_MAX_SIZE)
|
||||||
|
logging.info("%s is %d bytes", self.path, len(self.binary))
|
||||||
|
|
||||||
|
self.header = self.TOC_Header(*unpack("<IIIHH", self.binary[0x00:0x10]))
|
||||||
|
if self.header.name != TOC_HEADER_NAME:
|
||||||
|
raise ValueError(
|
||||||
|
"FIP header is 0x%08x but should be 0x%08x"
|
||||||
|
% (self.header[0], TOC_HEADER_NAME)
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.info("TOC header: flag_plat=0x%04x", self.header.flag_plat)
|
||||||
|
logging.info(
|
||||||
|
" REE_SCS: %r",
|
||||||
|
FIP_HEADER_FLAG.test(self.header.flag_plat, FIP_HEADER_FLAG.REE_SCS),
|
||||||
|
)
|
||||||
|
logging.info(
|
||||||
|
" REE_ENCRYPTION: %r",
|
||||||
|
FIP_HEADER_FLAG.test(self.header.flag_plat, FIP_HEADER_FLAG.REE_ENCRYPTION),
|
||||||
|
)
|
||||||
|
|
||||||
|
ents = []
|
||||||
|
for k, v in self.ENTRY_NAMES.items():
|
||||||
|
try:
|
||||||
|
ents.append((k, self.find_entry(v)))
|
||||||
|
except ValueError as err:
|
||||||
|
logging.warning("%s", err)
|
||||||
|
|
||||||
|
ents.sort(key=lambda x: x[1].address)
|
||||||
|
for n, (k, v) in enumerate(ents):
|
||||||
|
logging.debug("%s", v)
|
||||||
|
if n > 0:
|
||||||
|
pk, pv = ents[n - 1]
|
||||||
|
if v.loc != pv.loc + ENTRY_SIZE or v.address != pv.address + pv.size:
|
||||||
|
raise Exception("Invalid FIP")
|
||||||
|
|
||||||
|
rest = self.binary[ents[-1][1].end :]
|
||||||
|
loc = rest.find(b"APLB")
|
||||||
|
if loc < 0:
|
||||||
|
raise Exception("No BLD/DDRC")
|
||||||
|
self.blp_ddrc_binary = rest[loc:]
|
||||||
|
logging.debug("blp_ddrc: 0x%04x at 0x%08x", len(self.blp_ddrc_binary), loc)
|
||||||
|
|
||||||
|
self.ents = collections.OrderedDict(ents)
|
||||||
|
|
||||||
|
def make_fip(self, output_path=None):
|
||||||
|
logging.info("New TOC header: flag_plat=0x%04x", self.header.flag_plat)
|
||||||
|
header_bin = pack("<IIIHH", *self.header)
|
||||||
|
fip_bin = header_bin
|
||||||
|
|
||||||
|
# Sort self.ents by the order of FIP.ENTRY_NAMES
|
||||||
|
sorted_ents = collections.OrderedDict()
|
||||||
|
for name in self.ENTRY_NAMES:
|
||||||
|
try:
|
||||||
|
sorted_ents[name] = self.ents[name]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
self.ents = sorted_ents
|
||||||
|
|
||||||
|
offset = (len(self.ents) + 1) * ENTRY_SIZE + 0x10
|
||||||
|
|
||||||
|
for k, v in self.ents.items():
|
||||||
|
v.address = offset
|
||||||
|
fip_bin += v.to_bytes()
|
||||||
|
offset += v.size
|
||||||
|
|
||||||
|
null_entry = Entry()
|
||||||
|
null_entry.address = offset
|
||||||
|
fip_bin += null_entry.to_bytes()
|
||||||
|
|
||||||
|
for k, v in self.ents.items():
|
||||||
|
fip_bin += v.content
|
||||||
|
|
||||||
|
if (len(fip_bin) % FIP_ALIGN_SIZE) > 0:
|
||||||
|
fip_bin += b"\x00" * (FIP_ALIGN_SIZE - len(fip_bin) % FIP_ALIGN_SIZE)
|
||||||
|
fip_bin += self.blp_ddrc_binary
|
||||||
|
|
||||||
|
if output_path:
|
||||||
|
path = output_path
|
||||||
|
else:
|
||||||
|
path = os.path.splitext(self.path)
|
||||||
|
path = path[0] + "_signed_encrypted" + path[1]
|
||||||
|
logging.info("Save new FIP image to %s", path)
|
||||||
|
with open(path, "wb") as fp:
|
||||||
|
fp.write(fip_bin)
|
||||||
|
|
||||||
|
def dump_uuids(self):
|
||||||
|
for k, v in vars(FIP_UUID).items():
|
||||||
|
if k.startswith("UUID_"):
|
||||||
|
print("%-38s" % k, v.hex)
|
||||||
|
|
||||||
|
def find_entry(self, name):
|
||||||
|
# UUID=0, offset=any, size=0, flags=0
|
||||||
|
nullm = re.search(rb"\0{16}.{8}\0{16}", self.binary, flags=re.DOTALL)
|
||||||
|
if nullm is None:
|
||||||
|
raise Exception("NULL TOC entry is not found")
|
||||||
|
|
||||||
|
max_toc_size = nullm.start(0)
|
||||||
|
uuid = getattr(FIP_UUID, name)
|
||||||
|
loc = self.binary.find(uuid.bytes, 0, max_toc_size)
|
||||||
|
if loc < 0:
|
||||||
|
raise ValueError("%s is not found" % name)
|
||||||
|
return Entry.from_fip(name, loc, self.binary)
|
||||||
|
|
||||||
|
|
||||||
|
def entry(args):
|
||||||
|
logging.debug("cmd_fip")
|
||||||
|
|
||||||
|
|
||||||
|
def init_logging(log_file=None, file_level="DEBUG", stdout_level="WARNING"):
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(logging.NOTSET)
|
||||||
|
|
||||||
|
fmt = "%(asctime)s %(levelname)8s:%(name)s:%(message)s"
|
||||||
|
|
||||||
|
if log_file is not None:
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setFormatter(logging.Formatter(fmt))
|
||||||
|
file_handler.setLevel(file_level)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
if coloredlogs:
|
||||||
|
os.environ["COLOREDLOGS_DATE_FORMAT"] = "%H:%M:%S"
|
||||||
|
|
||||||
|
field_styles = {
|
||||||
|
"asctime": {"color": "green"},
|
||||||
|
"hostname": {"color": "magenta"},
|
||||||
|
"levelname": {"color": "black", "bold": True},
|
||||||
|
"name": {"color": "blue"},
|
||||||
|
"programname": {"color": "cyan"},
|
||||||
|
}
|
||||||
|
|
||||||
|
level_styles = coloredlogs.DEFAULT_LEVEL_STYLES
|
||||||
|
level_styles["debug"]["color"] = "cyan"
|
||||||
|
|
||||||
|
coloredlogs.install(
|
||||||
|
level=stdout_level,
|
||||||
|
fmt=fmt,
|
||||||
|
field_styles=field_styles,
|
||||||
|
level_styles=level_styles,
|
||||||
|
milliseconds=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fip(fip_path):
|
||||||
|
logging.debug("parse_fip: %s", fip_path)
|
||||||
|
fip = FIP(fip_path)
|
||||||
|
fip.load()
|
||||||
|
|
||||||
|
|
||||||
|
def unpack_fip(fip_path):
|
||||||
|
logging.debug("unpack_fip: %s", fip_path)
|
||||||
|
fip = FIP(fip_path)
|
||||||
|
fip.load()
|
||||||
|
|
||||||
|
def save(name, content):
|
||||||
|
fn = os.path.splitext(fip_path)
|
||||||
|
fn = "%s_%s%s" % (fn[0], name, fn[1])
|
||||||
|
logging.info("Save %s", fn)
|
||||||
|
with open(fn, "wb") as fp:
|
||||||
|
fp.write(content)
|
||||||
|
|
||||||
|
for k, v in fip.ents.items():
|
||||||
|
save(k, v.content)
|
||||||
|
|
||||||
|
save("BLP_DDRC", fip.blp_ddrc_binary)
|
||||||
|
|
||||||
|
|
||||||
|
def tar_bld(fip_path, output_path, multibin):
|
||||||
|
logging.debug("tar_bld: %s multibin=%r", fip_path, multibin)
|
||||||
|
fip = FIP(fip_path)
|
||||||
|
fip.load()
|
||||||
|
|
||||||
|
members = [
|
||||||
|
"BLD_CONTENT_CERT",
|
||||||
|
"BLD2_KEY_CERT",
|
||||||
|
"BLD1_KEY_CERT",
|
||||||
|
"CV_DDRINIT" if multibin else "BLD",
|
||||||
|
]
|
||||||
|
|
||||||
|
if not output_path:
|
||||||
|
output_path = os.path.join(os.path.dirname(fip_path), "bld.tar")
|
||||||
|
|
||||||
|
logging.info("bld_tar_path=%s", output_path)
|
||||||
|
|
||||||
|
with tarfile.open(output_path, "w") as tf:
|
||||||
|
for m in members:
|
||||||
|
logging.debug("Tar %s", m)
|
||||||
|
try:
|
||||||
|
fp = io.BytesIO(fip.ents[m].content)
|
||||||
|
except KeyError:
|
||||||
|
logging.warning("%s doesn't exist", m)
|
||||||
|
continue
|
||||||
|
info = tarfile.TarInfo(name=m + ".bin")
|
||||||
|
info.size = len(fp.getbuffer())
|
||||||
|
tf.addfile(tarinfo=info, fileobj=fp)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_fip(fip_path, inputs, output_path):
|
||||||
|
logging.debug("merge_fip: %s", fip_path)
|
||||||
|
fip = FIP(fip_path)
|
||||||
|
fip.load()
|
||||||
|
|
||||||
|
for name in FIP.ENTRY_NAMES:
|
||||||
|
binary = inputs.get(name)
|
||||||
|
if not binary:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logging.debug("merge %s", name)
|
||||||
|
ent = fip.ents.get(name)
|
||||||
|
if ent:
|
||||||
|
ent.content = binary
|
||||||
|
else:
|
||||||
|
ent = Entry.make(getattr(FIP_UUID, "UUID_" + name), binary)
|
||||||
|
fip.ents[name] = ent
|
||||||
|
|
||||||
|
binary = inputs.get("BLP_DDRC")
|
||||||
|
if binary:
|
||||||
|
fip.blp_ddrc_binary = binary
|
||||||
|
|
||||||
|
if not output_path:
|
||||||
|
fn = os.path.splitext(fip_path)
|
||||||
|
fn = "%s_%s%s" % (fn[0], "merged", fn[1])
|
||||||
|
output_path = fn
|
||||||
|
|
||||||
|
fip.make_fip(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def round_up(n, k):
|
||||||
|
return (n + k - 1) // k * k
|
||||||
|
|
||||||
|
|
||||||
|
def read_blp_and_ddrc(inputs, blp_path, ddrc_path):
|
||||||
|
logging.info("Open %s and %s", blp_path, ddrc_path)
|
||||||
|
with open(blp_path, "rb") as fp:
|
||||||
|
blp_bin = fp.read()
|
||||||
|
|
||||||
|
logging.info("Open %s", ddrc_path)
|
||||||
|
with open(ddrc_path, "rb") as fp:
|
||||||
|
ddrc_bin = fp.read()
|
||||||
|
|
||||||
|
blp_bin += b"\0" * (round_up(len(blp_bin), FIP_ALIGN_SIZE) - len(blp_bin))
|
||||||
|
ddrc_bin += b"\0" * (round_up(len(ddrc_bin), FIP_ALIGN_SIZE) - len(ddrc_bin))
|
||||||
|
|
||||||
|
inputs["BLP_DDRC"] = blp_bin + ddrc_bin
|
||||||
|
|
||||||
|
|
||||||
|
def read_bld_tar(inputs, bld_tar_path, multibin):
|
||||||
|
logging.info("Open %s multibin=%r", bld_tar_path, multibin)
|
||||||
|
members = [
|
||||||
|
"BLD_CONTENT_CERT.bin",
|
||||||
|
"BLD2_KEY_CERT.bin",
|
||||||
|
"BLD1_KEY_CERT.bin",
|
||||||
|
"CV_DDRINIT.bin" if multibin else "BLD.bin",
|
||||||
|
]
|
||||||
|
|
||||||
|
with tarfile.open(bld_tar_path, "r") as tf:
|
||||||
|
for member in members:
|
||||||
|
try:
|
||||||
|
fp = tf.extractfile(member)
|
||||||
|
inputs[os.path.splitext(member)[0]] = fp.read()
|
||||||
|
except KeyError:
|
||||||
|
logging.warning("%s does not exist", member)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="FIP packer")
|
||||||
|
|
||||||
|
for name in FIP.ENTRY_NAMES:
|
||||||
|
parser.add_argument(
|
||||||
|
"--add-%s" % name.lower(),
|
||||||
|
dest=name,
|
||||||
|
type=str,
|
||||||
|
help="Merge %s into FIP" % name,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--add-blp-ddrc", dest="BLP_DDRC", type=str, help="Merge BLP+DDRC into FIP"
|
||||||
|
)
|
||||||
|
parser.add_argument("--add-blp", dest="BLP", type=str, help="Merge BLP into FIP")
|
||||||
|
parser.add_argument("--add-ddrc", dest="DDRC", type=str, help="Merge DDRC into FIP")
|
||||||
|
parser.add_argument(
|
||||||
|
"--add-bld-tar", dest="BLD_TAR", type=str, help="Merge BLD.tar into FIP"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("--multibin", action="store_true", help="Use multibin")
|
||||||
|
|
||||||
|
parser.add_argument("FIP_BIN", type=str, nargs=1, help="Input FIP binary")
|
||||||
|
parser.add_argument("--output", type=str, help="Output filename")
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--version", action="store_true", help="Output version information and exit"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
help="Increase output verbosity",
|
||||||
|
action="store_const",
|
||||||
|
const=logging.DEBUG,
|
||||||
|
default=logging.DEBUG,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("--unpack", action="store_true", help="Unpack FIP.bin")
|
||||||
|
parser.add_argument("--parse", action="store_true", help="Parse FIP.bin")
|
||||||
|
parser.add_argument(
|
||||||
|
"--tar-bld", action="store_true", help="Extrace BLD.bin and tar"
|
||||||
|
)
|
||||||
|
|
||||||
|
if argcomplete:
|
||||||
|
argcomplete.autocomplete(parser)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
init_logging(stdout_level=args.verbose)
|
||||||
|
|
||||||
|
logging.debug("args=%r", args)
|
||||||
|
|
||||||
|
FIP_UUID.cls_init()
|
||||||
|
|
||||||
|
if args.parse:
|
||||||
|
parse_fip(args.FIP_BIN[0])
|
||||||
|
|
||||||
|
if args.unpack:
|
||||||
|
unpack_fip(args.FIP_BIN[0])
|
||||||
|
|
||||||
|
if args.tar_bld:
|
||||||
|
tar_bld(args.FIP_BIN[0], args.output, args.multibin)
|
||||||
|
|
||||||
|
inputs = collections.OrderedDict()
|
||||||
|
for name in list(FIP.ENTRY_NAMES) + ["BLP_DDRC"]:
|
||||||
|
fn = getattr(args, name)
|
||||||
|
if not fn:
|
||||||
|
continue
|
||||||
|
logging.info("Open %s", fn)
|
||||||
|
with open(fn, "rb") as fp:
|
||||||
|
inputs[name] = fp.read()
|
||||||
|
|
||||||
|
if args.BLP or args.DDRC:
|
||||||
|
read_blp_and_ddrc(inputs, args.BLP, args.DDRC)
|
||||||
|
|
||||||
|
if args.BLD_TAR:
|
||||||
|
read_bld_tar(inputs, args.BLD_TAR, args.multibin)
|
||||||
|
|
||||||
|
if len(inputs):
|
||||||
|
merge_fip(args.FIP_BIN[0], inputs, args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+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
|
||||||
+5
-5
@@ -770,12 +770,12 @@ class OTAManager:
|
|||||||
# 很多 ML307R 的 MHTTP 对 https 不稳定;对已知域名做降级
|
# 很多 ML307R 的 MHTTP 对 https 不稳定;对已知域名做降级
|
||||||
|
|
||||||
if isinstance(url, str) and url.startswith("https://static.shelingxingqiu.com/"):
|
if isinstance(url, str) and url.startswith("https://static.shelingxingqiu.com/"):
|
||||||
base_url = "https://static.shelingxingqiu.com"
|
base_url = "http://static.shelingxingqiu.com"
|
||||||
# TODO:使用https,看看是否能成功
|
self._is_https = False
|
||||||
self._is_https = True
|
|
||||||
else:
|
else:
|
||||||
base_url = f"http://{host}"
|
base_url = f"http://{host}"
|
||||||
self._is_https = False
|
self._is_https = False
|
||||||
|
self.logger.info(f"base_url: {base_url}, self._is_https: {self._is_https}")
|
||||||
# logger removed - use self.logger instead
|
# logger removed - use self.logger instead
|
||||||
|
|
||||||
def _log(*a):
|
def _log(*a):
|
||||||
@@ -1160,8 +1160,8 @@ class OTAManager:
|
|||||||
self.logger.error(f"[OTA-4G][PWR] before_urc read_failed: {e}")
|
self.logger.error(f"[OTA-4G][PWR] before_urc read_failed: {e}")
|
||||||
|
|
||||||
t_dl0 = time.ticks_ms()
|
t_dl0 = time.ticks_ms()
|
||||||
success, msg = self.download_file_via_4g(ota_url, downloaded_filename, debug=False)
|
success, msg = self.download_file_via_4g(ota_url, downloaded_filename, debug=True)
|
||||||
t_dl_cost = time.ticks_diff(t_dl0, time.ticks_ms())
|
t_dl_cost = time.ticks_diff(time.ticks_ms(), t_dl0)
|
||||||
self.logger.info(f"[OTA-4G] {msg}")
|
self.logger.info(f"[OTA-4G] {msg}")
|
||||||
self.logger.info(f"[OTA-4G] download_cost_ms={t_dl_cost}")
|
self.logger.info(f"[OTA-4G] download_cost_ms={t_dl_cost}")
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
提供电压、电流监测和充电状态检测
|
提供电压、电流监测和充电状态检测
|
||||||
"""
|
"""
|
||||||
import config
|
import config
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import _thread
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from maix import time as maix_time
|
from maix import time as maix_time
|
||||||
|
|
||||||
|
|
||||||
_INA226_PRESENT = None
|
_INA226_PRESENT = None
|
||||||
|
_INA226_LOCK = _thread.allocate_lock()
|
||||||
|
|
||||||
|
|
||||||
def _ina226_ready() -> bool:
|
def _ina226_ready() -> bool:
|
||||||
@@ -31,7 +34,11 @@ def write_register(reg, value):
|
|||||||
data = [(value >> 8) & 0xFF, value & 0xFF]
|
data = [(value >> 8) & 0xFF, value & 0xFF]
|
||||||
# 某些底层驱动在失败时只打印 “write failed” 并返回 -1,而不是抛异常;
|
# 某些底层驱动在失败时只打印 “write failed” 并返回 -1,而不是抛异常;
|
||||||
# 为避免误判“初始化成功”导致后续 readfrom_mem SIGSEGV,这里把失败显式转成异常。
|
# 为避免误判“初始化成功”导致后续 readfrom_mem SIGSEGV,这里把失败显式转成异常。
|
||||||
ret = hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
|
_INA226_LOCK.acquire()
|
||||||
|
try:
|
||||||
|
ret = hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
|
||||||
|
finally:
|
||||||
|
_INA226_LOCK.release()
|
||||||
if isinstance(ret, int) and ret < 0:
|
if isinstance(ret, int) and ret < 0:
|
||||||
if logger:
|
if logger:
|
||||||
logger.error(f"[INA226] writeto_mem 失败: addr=0x{config.INA226_ADDR:02X} reg=0x{reg:02X} ret={ret}")
|
logger.error(f"[INA226] writeto_mem 失败: addr=0x{config.INA226_ADDR:02X} reg=0x{reg:02X} ret={ret}")
|
||||||
@@ -41,7 +48,11 @@ def write_register(reg, value):
|
|||||||
def read_register(reg):
|
def read_register(reg):
|
||||||
"""读取INA226寄存器"""
|
"""读取INA226寄存器"""
|
||||||
from hardware import hardware_manager
|
from hardware import hardware_manager
|
||||||
data = hardware_manager.bus.readfrom_mem(config.INA226_ADDR, reg, 2)
|
_INA226_LOCK.acquire()
|
||||||
|
try:
|
||||||
|
data = hardware_manager.bus.readfrom_mem(config.INA226_ADDR, reg, 2)
|
||||||
|
finally:
|
||||||
|
_INA226_LOCK.release()
|
||||||
return (data[0] << 8) | data[1]
|
return (data[0] << 8) | data[1]
|
||||||
|
|
||||||
|
|
||||||
@@ -85,8 +96,8 @@ def get_bus_voltage():
|
|||||||
def get_current():
|
def get_current():
|
||||||
"""
|
"""
|
||||||
读取电流(单位:mA)
|
读取电流(单位:mA)
|
||||||
正数表示充电,负数表示放电
|
当前电源板实测:正数表示放电,负数表示充电。
|
||||||
|
|
||||||
INA226 电流计算公式:
|
INA226 电流计算公式:
|
||||||
Current = (Current Register Value) × Current_LSB
|
Current = (Current Register Value) × Current_LSB
|
||||||
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
|
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
|
||||||
@@ -96,13 +107,13 @@ def get_current():
|
|||||||
return 0.0
|
return 0.0
|
||||||
raw = read_register(config.REG_CURRENT)
|
raw = read_register(config.REG_CURRENT)
|
||||||
# INA226 电流寄存器是16位有符号整数
|
# INA226 电流寄存器是16位有符号整数
|
||||||
# 最高位是符号位:0=正(充电),1=负(放电)
|
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。
|
||||||
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
||||||
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
||||||
# 处理有符号数:如果最高位为1,转换为负数
|
# 处理有符号数:如果最高位为1,转换为负数
|
||||||
if raw & 0x8000: # 最高位为1,表示负数(放电)
|
if raw & 0x8000:
|
||||||
signed_raw = raw - 0x10000 # 转换为有符号整数
|
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||||
else: # 最高位为0,表示正数(充电)
|
else:
|
||||||
signed_raw = raw
|
signed_raw = raw
|
||||||
# 转换为毫安
|
# 转换为毫安
|
||||||
current_ma = signed_raw * current_lsb * 1000
|
current_ma = signed_raw * current_lsb * 1000
|
||||||
@@ -119,17 +130,17 @@ def get_current():
|
|||||||
def is_charging(threshold_ma=10.0):
|
def is_charging(threshold_ma=10.0):
|
||||||
"""
|
"""
|
||||||
检测是否在充电(通过电流方向判断)
|
检测是否在充电(通过电流方向判断)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
threshold_ma: 电流阈值(毫安),超过此值认为在充电,默认10mA
|
threshold_ma: 电流阈值(毫安),超过此值认为在充电,默认10mA
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True: 正在充电
|
True: 正在充电
|
||||||
False: 未充电或读取失败
|
False: 未充电或读取失败
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
current = get_current()
|
current = get_current()
|
||||||
is_charge = current > threshold_ma
|
is_charge = current < -abs(float(threshold_ma))
|
||||||
return is_charge
|
return is_charge
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
@@ -159,7 +170,7 @@ def voltage_to_percent(voltage):
|
|||||||
return 0
|
return 0
|
||||||
if v <= 0:
|
if v <= 0:
|
||||||
return 0
|
return 0
|
||||||
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
|
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
|
||||||
|
|
||||||
|
|
||||||
class BatteryMonitor:
|
class BatteryMonitor:
|
||||||
|
|||||||
+37
-2
@@ -320,9 +320,23 @@ def process_shot(adc_val):
|
|||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
try:
|
try:
|
||||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
|
||||||
frame = camera_manager.read_frame()
|
frame = camera_manager.read_frame()
|
||||||
|
|
||||||
|
# 网络事件移到拍照之后,避免阻塞拍照
|
||||||
|
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||||
|
|
||||||
|
# Classify only the current shot frame; never reuse a previous result.
|
||||||
|
target_class_result = None
|
||||||
|
try:
|
||||||
|
from target_roi_yolo import try_get_target_class_from_yolo
|
||||||
|
|
||||||
|
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[YOLO-TARGET] 当前箭业务结果: {target_class_result}")
|
||||||
|
except Exception as exc:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-TARGET] 当前箭分类失败,按未知处理: {exc}")
|
||||||
|
|
||||||
# 调用算法分析
|
# 调用算法分析
|
||||||
analysis_result = analyze_shot(frame)
|
analysis_result = analyze_shot(frame)
|
||||||
|
|
||||||
@@ -382,11 +396,25 @@ def process_shot(adc_val):
|
|||||||
srv_y = round(float(dy), 4) if dy is not None else 200.0
|
srv_y = round(float(dy), 4) if dy is not None else 200.0
|
||||||
|
|
||||||
# 构造上报数据
|
# 构造上报数据
|
||||||
|
target_label = (
|
||||||
|
target_class_result.get("label")
|
||||||
|
if isinstance(target_class_result, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
target_confidence = (
|
||||||
|
target_class_result.get("confidence")
|
||||||
|
if isinstance(target_class_result, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
inner_data = {
|
inner_data = {
|
||||||
"shot_id": shot_id,
|
"shot_id": shot_id,
|
||||||
"x": srv_x,
|
"x": srv_x,
|
||||||
"y": srv_y,
|
"y": srv_y,
|
||||||
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
||||||
|
"target_class": target_label,
|
||||||
|
"target_class_confidence": (
|
||||||
|
float(target_confidence) if target_confidence is not None else None
|
||||||
|
),
|
||||||
"d": round((distance_m or 0.0) * 100),
|
"d": round((distance_m or 0.0) * 100),
|
||||||
"d_laser": round((laser_distance_m or 0.0) * 100),
|
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||||
"d_laser_quality": laser_signal_quality,
|
"d_laser_quality": laser_signal_quality,
|
||||||
@@ -414,6 +442,11 @@ def process_shot(adc_val):
|
|||||||
inner_data["ellipse_center_y"] = None
|
inner_data["ellipse_center_y"] = None
|
||||||
|
|
||||||
report_data = {"cmd": 1, "data": inner_data}
|
report_data = {"cmd": 1, "data": inner_data}
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[REPORT-TARGET] enqueue shot_id={shot_id}, "
|
||||||
|
f"target_class={target_label}, confidence={target_confidence}"
|
||||||
|
)
|
||||||
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
||||||
|
|
||||||
# 数据上报后再画标注,不干扰检测阶段的原始画面
|
# 数据上报后再画标注,不干扰检测阶段的原始画面
|
||||||
@@ -518,6 +551,7 @@ def process_shot(adc_val):
|
|||||||
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
|
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
|
||||||
|
|
||||||
# 保存图像(异步队列,与 main.py 一致)
|
# 保存图像(异步队列,与 main.py 一致)
|
||||||
|
_force_save = (dx is None and dy is None) and getattr(config, "SAVE_IMAGE_ON_FAILURE", False)
|
||||||
enqueue_save_shot(
|
enqueue_save_shot(
|
||||||
result_img,
|
result_img,
|
||||||
center,
|
center,
|
||||||
@@ -527,8 +561,9 @@ def process_shot(adc_val):
|
|||||||
(x, y),
|
(x, y),
|
||||||
distance_m,
|
distance_m,
|
||||||
shot_id=shot_id,
|
shot_id=shot_id,
|
||||||
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
|
photo_dir=config.PHOTO_DIR if (config.SAVE_IMAGE_ENABLED or _force_save) else None,
|
||||||
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None,
|
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None,
|
||||||
|
force_save=_force_save,
|
||||||
)
|
)
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
|
|||||||
+143
-1
@@ -89,6 +89,29 @@ def _stage2_roi_crop_save_worker(
|
|||||||
_detector_by_path = {}
|
_detector_by_path = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model_path(model_path: str):
|
||||||
|
"""Resolve a model in either the installed app or MaixVision run directory."""
|
||||||
|
model_path = (model_path or "").strip()
|
||||||
|
if model_path and os.path.isfile(model_path):
|
||||||
|
return model_path
|
||||||
|
if not model_path:
|
||||||
|
return ""
|
||||||
|
name = os.path.basename(model_path)
|
||||||
|
module_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
candidates = (
|
||||||
|
os.path.join(module_dir, name),
|
||||||
|
os.path.join(module_dir, "test", name),
|
||||||
|
os.path.join("/tmp/maixpy_run", name),
|
||||||
|
os.path.join("/tmp/maixpy_run", "test", name),
|
||||||
|
os.path.join(os.getcwd(), name),
|
||||||
|
os.path.join(os.getcwd(), "test", name),
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return model_path
|
||||||
|
|
||||||
|
|
||||||
def reset_yolo_detector_cache():
|
def reset_yolo_detector_cache():
|
||||||
"""切换模型路径时可调用(通常不必)。"""
|
"""切换模型路径时可调用(通常不必)。"""
|
||||||
global _detector_by_path
|
global _detector_by_path
|
||||||
@@ -175,6 +198,23 @@ def preload_yolo_detector(logger=None):
|
|||||||
% (_loc_black,)
|
% (_loc_black,)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if bool(getattr(cfg, "TARGET_CLASS_YOLO_ENABLE", False)) and bool(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True)
|
||||||
|
):
|
||||||
|
class_model_path = _resolve_model_path(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_MODEL_PATH", "") or ""
|
||||||
|
)
|
||||||
|
class_detector = _get_detector(class_model_path)
|
||||||
|
if class_detector is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning(
|
||||||
|
f"[YOLO-TARGET] 预加载失败:无法加载模型 {class_model_path}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ok = True
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[YOLO-TARGET] 靶规格模型已预加载: {class_model_path}")
|
||||||
|
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
|
||||||
@@ -206,8 +246,10 @@ def _det_obj_class_id(o):
|
|||||||
if v is None:
|
if v is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
if callable(v):
|
||||||
|
v = v()
|
||||||
return int(float(v))
|
return int(float(v))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError, AttributeError):
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -242,6 +284,106 @@ def _normalize_objs(objs):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _det_obj_score(o):
|
||||||
|
"""Return confidence across supported Maix YOLO result formats."""
|
||||||
|
for key in ("score", "confidence", "conf", "prob"):
|
||||||
|
if hasattr(o, key):
|
||||||
|
try:
|
||||||
|
value = getattr(o, key)
|
||||||
|
if callable(value):
|
||||||
|
value = value()
|
||||||
|
value = float(value)
|
||||||
|
if value == value:
|
||||||
|
return value
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def try_get_target_class_from_yolo(maix_frame, logger=None):
|
||||||
|
"""Classify the current target as 20cm or 40cm; return None if unknown."""
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not bool(getattr(cfg, "TARGET_CLASS_YOLO_ENABLE", False)):
|
||||||
|
return None
|
||||||
|
model_path = _resolve_model_path(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_MODEL_PATH", "") or ""
|
||||||
|
)
|
||||||
|
if not os.path.isfile(model_path):
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-TARGET] 模型文件不存在: {model_path}")
|
||||||
|
return None
|
||||||
|
detector = _get_detector(model_path)
|
||||||
|
if detector is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning("[YOLO-TARGET] 无法加载 nn.YOLOv5")
|
||||||
|
return None
|
||||||
|
|
||||||
|
conf_th = float(getattr(cfg, "TARGET_CLASS_YOLO_CONF_TH", 0.5))
|
||||||
|
iou_th = float(getattr(cfg, "TARGET_CLASS_YOLO_IOU_TH", 0.45))
|
||||||
|
labels = getattr(cfg, "TARGET_CLASS_YOLO_LABELS", (20, 40))
|
||||||
|
if isinstance(labels, str):
|
||||||
|
labels = tuple(x.strip() for x in labels.split(",") if x.strip())
|
||||||
|
labels = tuple(labels)
|
||||||
|
|
||||||
|
def _detect(threshold):
|
||||||
|
try:
|
||||||
|
raw = detector.detect(maix_frame, conf_th=threshold, iou_th=iou_th)
|
||||||
|
except Exception as exc:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-TARGET] detect 异常: {exc}")
|
||||||
|
return []
|
||||||
|
return _normalize_objs(raw if raw is not None else [])
|
||||||
|
|
||||||
|
def _candidates(objs):
|
||||||
|
found = []
|
||||||
|
for obj in objs:
|
||||||
|
class_id = _det_obj_class_id(obj)
|
||||||
|
if class_id is None or class_id < 0 or class_id >= len(labels):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
label = int(float(labels[class_id]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if label in (20, 40):
|
||||||
|
found.append((label, class_id, _det_obj_score(obj)))
|
||||||
|
return found
|
||||||
|
|
||||||
|
objects = _detect(conf_th)
|
||||||
|
candidates = _candidates(objects)
|
||||||
|
if logger and objects:
|
||||||
|
logger.info(
|
||||||
|
"[YOLO-TARGET] 原始框=%d, 解析类别=%s"
|
||||||
|
% (
|
||||||
|
len(objects),
|
||||||
|
[(_det_obj_class_id(o), _det_obj_score(o)) for o in objects[:8]],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not candidates and bool(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_RETRY_ON_EMPTY", False)
|
||||||
|
):
|
||||||
|
retry_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RETRY_CONF_TH", conf_th))
|
||||||
|
if 0 < retry_th < conf_th:
|
||||||
|
candidates = _candidates(_detect(retry_th))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
if logger:
|
||||||
|
logger.warning("[YOLO-TARGET] 当前帧未识别到 20/40,按未知处理")
|
||||||
|
return None
|
||||||
|
|
||||||
|
label, class_id, confidence = max(candidates, key=lambda item: item[2])
|
||||||
|
result = {"label": label, "class_id": class_id, "confidence": confidence}
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-TARGET] 当前帧分类={label}, class_id={class_id}, "
|
||||||
|
f"conf={confidence:.3f}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
||||||
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
|
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
|
||||||
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
|
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
|
|||||||
|
"""Run independently and keep A24 at a high logic level."""
|
||||||
|
|
||||||
|
from maix import app, gpio, pinmap, time
|
||||||
|
|
||||||
|
|
||||||
|
PIN = "P19"
|
||||||
|
GPIO_NAME = "GPIOP19"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pinmap.set_pin_function(PIN, GPIO_NAME)
|
||||||
|
output = gpio.GPIO(GPIO_NAME, gpio.Mode.OUT)
|
||||||
|
output.value(1)
|
||||||
|
print(f"{PIN} is HIGH. Stop the script to set it LOW.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not app.need_exit():
|
||||||
|
# Refresh the output in case another component changes its state.
|
||||||
|
output.value(1)
|
||||||
|
time.sleep_ms(100)
|
||||||
|
finally:
|
||||||
|
output.value(0)
|
||||||
|
print(f"{PIN} is LOW.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
@@ -0,0 +1,330 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
离线测试脚本:直接复用 detect_circle 逻辑进行测试
|
||||||
|
运行环境:MaixPy (Sipeed MAIX)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
# import time
|
||||||
|
from maix import image, time
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import math
|
||||||
|
|
||||||
|
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
||||||
|
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
||||||
|
|
||||||
|
def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
|
||||||
|
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
|
||||||
|
如果提供 laser_point,会选择最接近激光点的目标
|
||||||
|
优化:
|
||||||
|
1. 缩图到 MAX_DET_DIM 后再做 HSV/形态学,最长边 640->320 可获得 ~4x 加速
|
||||||
|
2. 红色掩码在黄色轮廓循环外只计算一次,避免 N 次重复计算
|
||||||
|
3. img_cv 可由外部传入(与其他线程共享转换结果),为 None 时自动转换
|
||||||
|
Args:
|
||||||
|
frame: 图像帧(img_cv 为 None 时使用)
|
||||||
|
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
||||||
|
img_cv: 已转换的 numpy BGR/RGB 图像;不为 None 时跳过 image2cv 转换
|
||||||
|
Returns:
|
||||||
|
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
||||||
|
"""
|
||||||
|
if img_cv is None:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
from datetime import datetime
|
||||||
|
print(f"[detect_circle_v3] begin {datetime.now()}")
|
||||||
|
# -- 1. 缩图加速(与三角形路径保持一致)
|
||||||
|
h_orig, w_orig = img_cv.shape[:2]
|
||||||
|
MAX_DET_DIM = 480
|
||||||
|
long_side = max(h_orig, w_orig)
|
||||||
|
if long_side > MAX_DET_DIM:
|
||||||
|
det_scale = MAX_DET_DIM / long_side
|
||||||
|
img_det = cv2.resize(img_cv, (int(w_orig * det_scale), int(h_orig * det_scale)),
|
||||||
|
interpolation=cv2.INTER_LINEAR)
|
||||||
|
inv_scale = 1.0 / det_scale # 检测坐标 -> 原始坐标的倍率
|
||||||
|
else:
|
||||||
|
img_det = img_cv
|
||||||
|
inv_scale = 1.0
|
||||||
|
|
||||||
|
# 激光点映射到检测分辨率
|
||||||
|
lp_det = None
|
||||||
|
if laser_point is not None:
|
||||||
|
lp_det = (laser_point[0] / inv_scale, laser_point[1] / inv_scale)
|
||||||
|
best_center = best_radius = best_radius1 = method = None
|
||||||
|
ellipse_params = None
|
||||||
|
|
||||||
|
print(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
||||||
|
|
||||||
|
# -- 2. HSV + 黄色掩码
|
||||||
|
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
lower_yellow = np.array([7, 80, 0])
|
||||||
|
upper_yellow = np.array([32, 255, 255])
|
||||||
|
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
print(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
||||||
|
|
||||||
|
# -- 3. 红色掩码:在循环外只算一次
|
||||||
|
mask_red = cv2.bitwise_or(
|
||||||
|
cv2.inRange(hsv, np.array([0, 50, 40]), np.array([10, 255, 255])),
|
||||||
|
cv2.inRange(hsv, np.array([170, 50, 40]), np.array([180, 255, 255])),
|
||||||
|
)
|
||||||
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
||||||
|
red_candidates = []
|
||||||
|
for cnt_r in contours_red:
|
||||||
|
ar = cv2.contourArea(cnt_r)
|
||||||
|
if ar <= 10:
|
||||||
|
continue
|
||||||
|
pr = cv2.arcLength(cnt_r, True)
|
||||||
|
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.3:
|
||||||
|
continue
|
||||||
|
if len(cnt_r) >= 5:
|
||||||
|
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
||||||
|
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(min(wr, hr) / 2)})
|
||||||
|
else:
|
||||||
|
(xr, yr), rr = cv2.minEnclosingCircle(cnt_r)
|
||||||
|
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
||||||
|
|
||||||
|
print(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
||||||
|
|
||||||
|
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
|
||||||
|
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
valid_targets = []
|
||||||
|
for cnt_yellow in contours_yellow:
|
||||||
|
area = cv2.contourArea(cnt_yellow)
|
||||||
|
if area <= 15:
|
||||||
|
continue
|
||||||
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
|
if perimeter <= 0:
|
||||||
|
continue
|
||||||
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
|
if circularity <= 0.5:
|
||||||
|
continue
|
||||||
|
print(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
||||||
|
if len(cnt_yellow) >= 5:
|
||||||
|
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
||||||
|
yellow_ellipse = ((x, y), (width, height), angle)
|
||||||
|
yellow_center = (int(x), int(y))
|
||||||
|
yellow_radius = int(min(width, height) / 2)
|
||||||
|
else:
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
||||||
|
yellow_center = (int(x), int(y))
|
||||||
|
yellow_radius = int(radius)
|
||||||
|
yellow_ellipse = None
|
||||||
|
# 在预筛好的红色候选中匹配
|
||||||
|
matched = False
|
||||||
|
for rc in red_candidates:
|
||||||
|
ddx = yellow_center[0] - rc["center"][0]
|
||||||
|
ddy = yellow_center[1] - rc["center"][1]
|
||||||
|
dist_centers = math.hypot(ddx, ddy)
|
||||||
|
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.7:
|
||||||
|
print(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
||||||
|
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
||||||
|
f"黄半径:{yellow_radius}, 红半径:{rc['radius']}")
|
||||||
|
valid_targets.append({
|
||||||
|
"center": yellow_center,
|
||||||
|
"radius": yellow_radius,
|
||||||
|
"ellipse": yellow_ellipse,
|
||||||
|
"area": area,
|
||||||
|
})
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched :
|
||||||
|
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
|
|
||||||
|
print(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
||||||
|
|
||||||
|
# -- 5. 选最佳目标,坐标还原到原始分辨率
|
||||||
|
if valid_targets:
|
||||||
|
if lp_det:
|
||||||
|
best_target = min(valid_targets,
|
||||||
|
key=lambda t: (t["center"][0] - lp_det[0]) ** 2
|
||||||
|
+ (t["center"][1] - lp_det[1]) ** 2)
|
||||||
|
method = "v3_ellipse_red_validated_laser_selected"
|
||||||
|
else:
|
||||||
|
best_target = max(valid_targets, key=lambda t: t["area"])
|
||||||
|
method = "v3_ellipse_red_validated"
|
||||||
|
bc = best_target["center"]
|
||||||
|
br = best_target["radius"]
|
||||||
|
be = best_target["ellipse"]
|
||||||
|
if inv_scale != 1.0:
|
||||||
|
best_center = (int(bc[0] * inv_scale), int(bc[1] * inv_scale))
|
||||||
|
best_radius = int(br * inv_scale)
|
||||||
|
if be is not None:
|
||||||
|
(ex, ey), (ew, eh), ea = be
|
||||||
|
be = ((ex * inv_scale, ey * inv_scale),
|
||||||
|
(ew * inv_scale, eh * inv_scale), ea)
|
||||||
|
else:
|
||||||
|
best_center = bc
|
||||||
|
best_radius = br
|
||||||
|
ellipse_params = be
|
||||||
|
best_radius1 = best_radius * 5
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
print(f"[detect_circle_v3] step 5 fin {datetime.now()}")
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
|
||||||
|
def run_offline_test(image_path):
|
||||||
|
"""读取图片,检测圆,绘制结果,保存图片"""
|
||||||
|
|
||||||
|
# 1. 检查文件是否存在
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
print(f"[ERROR] 找不到图片文件: {image_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
||||||
|
try:
|
||||||
|
# 使用 image.load 读取文件,返回 Image 对象
|
||||||
|
img = image.load(image_path)
|
||||||
|
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 读取图片失败: {e}")
|
||||||
|
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 调用 detect_circle_v3 函数
|
||||||
|
print("[INFO] 正在调用 detect_circle_v3 进行检测...")
|
||||||
|
start_time = time.ticks_ms()
|
||||||
|
|
||||||
|
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
||||||
|
|
||||||
|
cost_time = time.ticks_ms() - start_time
|
||||||
|
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
||||||
|
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
print(
|
||||||
|
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
||||||
|
|
||||||
|
# 4. 绘制辅助线(可选,用于调试)
|
||||||
|
if center and radius:
|
||||||
|
# 为了绘制椭圆,需要转换回 cv2 图像
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
|
||||||
|
cx, cy = center
|
||||||
|
|
||||||
|
# 如果有椭圆参数,绘制椭圆
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
||||||
|
|
||||||
|
# 确定长轴和短轴
|
||||||
|
if width >= height:
|
||||||
|
# width 是长轴,height 是短轴
|
||||||
|
axes_major = width
|
||||||
|
axes_minor = height
|
||||||
|
major_angle = angle # 长轴角度就是 angle
|
||||||
|
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
||||||
|
else:
|
||||||
|
# height 是长轴,width 是短轴
|
||||||
|
axes_major = height
|
||||||
|
axes_minor = width
|
||||||
|
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
||||||
|
minor_angle = angle # 短轴角度就是 angle
|
||||||
|
|
||||||
|
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
||||||
|
cv2.ellipse(img_cv,
|
||||||
|
(cx_ell, cy_ell), # 中心点
|
||||||
|
(int(width / 2), int(height / 2)), # 半宽、半高
|
||||||
|
angle, # 旋转角度(OpenCV需要原始angle)
|
||||||
|
0, 360, # 起始和结束角度
|
||||||
|
(0, 255, 0), # 绿色 (RGB格式)
|
||||||
|
2) # 线宽
|
||||||
|
|
||||||
|
# 绘制椭圆中心点(红色)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
|
||||||
|
import math
|
||||||
|
# 绘制短轴(蓝色线条)
|
||||||
|
minor_length = axes_minor / 2
|
||||||
|
minor_angle_rad = math.radians(minor_angle)
|
||||||
|
dx_minor = minor_length * math.cos(minor_angle_rad)
|
||||||
|
dy_minor = minor_length * math.sin(minor_angle_rad)
|
||||||
|
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
||||||
|
else:
|
||||||
|
# 如果没有椭圆参数,绘制圆形(红色)
|
||||||
|
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
||||||
|
|
||||||
|
# 转换回 maix image
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
|
||||||
|
# 定义颜色对象用于文字
|
||||||
|
try:
|
||||||
|
color_black = image.Color.from_rgb(0, 0, 0)
|
||||||
|
except AttributeError:
|
||||||
|
color_black = image.Color(0, 0, 0)
|
||||||
|
|
||||||
|
# D. 添加文字信息
|
||||||
|
FOCAL_LENGTH_PIX = 1900
|
||||||
|
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
||||||
|
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
||||||
|
print(info_str)
|
||||||
|
|
||||||
|
# 计算文字位置,防止超出图片边界
|
||||||
|
r_outer = int(radius * 11.0) if radius else 100
|
||||||
|
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
||||||
|
|
||||||
|
# 调用 draw_string
|
||||||
|
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
||||||
|
|
||||||
|
# 5. 保存结果图片
|
||||||
|
base, ext = os.path.splitext(image_path)
|
||||||
|
output_path = f"{base}_result{ext}"
|
||||||
|
try:
|
||||||
|
result_img.save(output_path, quality=100)
|
||||||
|
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 保存图片失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# ================= 配置区域 =================
|
||||||
|
|
||||||
|
# 1. 设置要测试的图片路径
|
||||||
|
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
||||||
|
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
||||||
|
|
||||||
|
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
||||||
|
|
||||||
|
# 支持的图片格式
|
||||||
|
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
|
||||||
|
# ================= 执行区域 =================
|
||||||
|
if 'TARGET_DIR' in locals():
|
||||||
|
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
||||||
|
image_files = []
|
||||||
|
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
||||||
|
for filename in os.listdir(TARGET_DIR):
|
||||||
|
# 检查文件扩展名
|
||||||
|
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
||||||
|
# 过滤掉 _result.jpg 后缀的文件
|
||||||
|
if not filename.endswith('_result.jpg'):
|
||||||
|
filepath = os.path.join(TARGET_DIR, filename)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
image_files.append(filepath)
|
||||||
|
|
||||||
|
# 按文件名排序(可选)
|
||||||
|
image_files.sort()
|
||||||
|
|
||||||
|
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
||||||
|
|
||||||
|
# 处理每张图片
|
||||||
|
for img_path in image_files:
|
||||||
|
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
||||||
|
run_offline_test(img_path)
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
run_offline_test(TARGET_IMAGE)
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
离线测试脚本:直接复用 detect_circle 逻辑进行测试
|
||||||
|
运行环境:MaixPy (Sipeed MAIX)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
# import time
|
||||||
|
from maix import image, time
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
||||||
|
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 复制的核心算法 ====================
|
||||||
|
# 注意:这里直接复制了 detect_circle 的逻辑,避免 import main 导致的冲突
|
||||||
|
|
||||||
|
|
||||||
|
def detect_circle_v3(frame, laser_point=None):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
|
||||||
|
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
|
||||||
|
如果提供 laser_point,会选择最接近激光点的目标
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 图像帧
|
||||||
|
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
||||||
|
"""
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
best_center = best_radius = best_radius1 = method = None
|
||||||
|
ellipse_params = None
|
||||||
|
|
||||||
|
# HSV 黄色掩码检测(模糊靶心)
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# 调整饱和度策略:稍微增强,不要过度
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||||
|
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||||
|
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||||
|
|
||||||
|
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 调整形态学操作
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
# 存储所有有效的黄色-红色组合
|
||||||
|
valid_targets = []
|
||||||
|
|
||||||
|
if contours_yellow:
|
||||||
|
for cnt_yellow in contours_yellow:
|
||||||
|
area = cv2.contourArea(cnt_yellow)
|
||||||
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
|
|
||||||
|
# 计算圆度
|
||||||
|
if perimeter > 0:
|
||||||
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
|
else:
|
||||||
|
circularity = 0
|
||||||
|
|
||||||
|
if area > 50 and circularity > 0.7:
|
||||||
|
print(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
|
||||||
|
# 尝试拟合椭圆
|
||||||
|
yellow_center = None
|
||||||
|
yellow_radius = None
|
||||||
|
yellow_ellipse = None
|
||||||
|
|
||||||
|
if len(cnt_yellow) >= 5:
|
||||||
|
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
||||||
|
yellow_ellipse = ((x, y), (width, height), angle)
|
||||||
|
axes_minor = min(width, height)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
yellow_center = (int(x), int(y))
|
||||||
|
yellow_radius = int(radius)
|
||||||
|
else:
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
||||||
|
yellow_center = (int(x), int(y))
|
||||||
|
yellow_radius = int(radius)
|
||||||
|
yellow_ellipse = None
|
||||||
|
|
||||||
|
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
|
||||||
|
if yellow_center and yellow_radius:
|
||||||
|
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
|
||||||
|
# 红色范围1: 0-12度(接近0度的红色)
|
||||||
|
# 放宽S/V阈值:S>=30, V>=20 以捕获淡红/暗红
|
||||||
|
lower_red1 = np.array([0, 30, 20])
|
||||||
|
upper_red1 = np.array([12, 255, 255])
|
||||||
|
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
||||||
|
|
||||||
|
# 红色范围2: 168-180度(接近180度的红色)
|
||||||
|
lower_red2 = np.array([168, 30, 20])
|
||||||
|
upper_red2 = np.array([180, 255, 255])
|
||||||
|
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
|
||||||
|
|
||||||
|
# 合并两个红色掩码
|
||||||
|
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
|
||||||
|
|
||||||
|
# 形态学操作:先CLOSE填充空洞,再DILATE加厚环状区域
|
||||||
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
||||||
|
|
||||||
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
red_pixel_count = np.sum(mask_red > 0)
|
||||||
|
print(f"Debug -> 红色掩码: {red_pixel_count} 像素, {len(contours_red)} 个轮廓")
|
||||||
|
|
||||||
|
found_valid_red = False
|
||||||
|
|
||||||
|
if contours_red:
|
||||||
|
for cnt_red in contours_red:
|
||||||
|
area_red = cv2.contourArea(cnt_red)
|
||||||
|
perimeter_red = cv2.arcLength(cnt_red, True)
|
||||||
|
|
||||||
|
if perimeter_red > 0:
|
||||||
|
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
|
||||||
|
else:
|
||||||
|
circularity_red = 0
|
||||||
|
|
||||||
|
# 环状轮廓圆度可能偏低,放宽到0.2
|
||||||
|
print(f"Debug -> 红轮廓: 面积={area_red:.1f}, 圆度={circularity_red:.2f}" +
|
||||||
|
f" (面积>15={area_red > 15}, 圆度>0.2={circularity_red > 0.2})")
|
||||||
|
if area_red > 15 and circularity_red > 0.2:
|
||||||
|
if len(cnt_red) >= 5:
|
||||||
|
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
|
||||||
|
radius_red = min(w_red, h_red) / 2
|
||||||
|
red_center = (int(x_red), int(y_red))
|
||||||
|
red_radius = int(radius_red)
|
||||||
|
else:
|
||||||
|
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
|
||||||
|
red_center = (int(x_red), int(y_red))
|
||||||
|
red_radius = int(radius_red)
|
||||||
|
|
||||||
|
if red_center:
|
||||||
|
dx = yellow_center[0] - red_center[0]
|
||||||
|
dy = yellow_center[1] - red_center[1]
|
||||||
|
distance = np.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
max_distance = yellow_radius * 2.0
|
||||||
|
min_r = min(red_radius, yellow_radius)
|
||||||
|
max_r = max(red_radius, yellow_radius)
|
||||||
|
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||||
|
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
||||||
|
f"大小比={size_ratio:.2f}(阈值=0.4), "
|
||||||
|
f"距离OK={distance < max_distance}, 大小OK={size_ratio >= 0.4}")
|
||||||
|
|
||||||
|
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
||||||
|
if distance < max_distance and size_ratio >= 0.4:
|
||||||
|
found_valid_red = True
|
||||||
|
print(
|
||||||
|
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
||||||
|
|
||||||
|
valid_targets.append({
|
||||||
|
'center': yellow_center,
|
||||||
|
'radius': yellow_radius,
|
||||||
|
'ellipse': yellow_ellipse,
|
||||||
|
'area': area
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found_valid_red:
|
||||||
|
# 如果黄圈非常可靠(大且圆),在没有红圈验证时仍接受
|
||||||
|
if area > 30 and circularity > 0.85:
|
||||||
|
print(f"[target] -> 黄圈高置信度(面积:{area:.0f}, 圆度:{circularity:.2f}),跳过红圈验证直接接受")
|
||||||
|
valid_targets.append({
|
||||||
|
'center': yellow_center,
|
||||||
|
'radius': yellow_radius,
|
||||||
|
'ellipse': yellow_ellipse,
|
||||||
|
'area': area
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
|
|
||||||
|
# 从所有有效目标中选择最佳目标
|
||||||
|
if valid_targets:
|
||||||
|
if laser_point:
|
||||||
|
# 如果有激光点,选择最接近激光点的目标
|
||||||
|
best_target = None
|
||||||
|
min_distance = float('inf')
|
||||||
|
for target in valid_targets:
|
||||||
|
dx = target['center'][0] - laser_point[0]
|
||||||
|
dy = target['center'][1] - laser_point[1]
|
||||||
|
distance = np.sqrt(dx * dx + dy * dy)
|
||||||
|
if distance < min_distance:
|
||||||
|
min_distance = distance
|
||||||
|
best_target = target
|
||||||
|
if best_target:
|
||||||
|
best_center = best_target['center']
|
||||||
|
best_radius = best_target['radius']
|
||||||
|
ellipse_params = best_target['ellipse']
|
||||||
|
method = "v3_ellipse_red_validated_laser_selected"
|
||||||
|
best_radius1 = best_radius * 5
|
||||||
|
else:
|
||||||
|
# 如果没有激光点,选择面积最大的目标
|
||||||
|
best_target = max(valid_targets, key=lambda t: t['area'])
|
||||||
|
best_center = best_target['center']
|
||||||
|
best_radius = best_target['radius']
|
||||||
|
ellipse_params = best_target['ellipse']
|
||||||
|
method = "v3_ellipse_red_validated"
|
||||||
|
best_radius1 = best_radius * 5
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
|
||||||
|
def detect_circle(frame):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
||||||
|
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||||
|
# edged = cv2.Canny(blurred, 50, 150)
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# best_center = best_radius = best_radius1 = method = None
|
||||||
|
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
# lower_yellow = np.array([7, 80, 0])
|
||||||
|
# upper_yellow = np.array([32, 255, 182])
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# auto
|
||||||
|
# R:31 M:v2 D:2.410110127692767
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# # 1. 增强饱和度(模糊照片需要更强的增强)
|
||||||
|
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
|
||||||
|
|
||||||
|
# # 2. 增强亮度(模糊照片可能偏暗)
|
||||||
|
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
|
||||||
|
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# # 3. 放宽HSV颜色范围(特别是模糊照片)
|
||||||
|
# # 降低饱和度下限,提高亮度上限
|
||||||
|
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
|
||||||
|
# upper_yellow = np.array([35, 255, 255])
|
||||||
|
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# # 4. 增强形态学操作(连接被分割的区域)
|
||||||
|
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
|
||||||
|
|
||||||
|
# # 先开运算去除噪声
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
|
||||||
|
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
|
||||||
|
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# area = cv2.contourArea(largest)
|
||||||
|
# if area > 50:
|
||||||
|
# # 5. 使用面积计算等效半径(更准确)
|
||||||
|
# equivalent_radius = np.sqrt(area / np.pi)
|
||||||
|
|
||||||
|
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
|
||||||
|
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
|
||||||
|
|
||||||
|
# # 取两者中的较大值,确保不遗漏
|
||||||
|
# radius = max(equivalent_radius, enclosing_radius)
|
||||||
|
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# codegee
|
||||||
|
# R:24 M:v2 D:3.061493895819174
|
||||||
|
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# 2. 调整饱和度策略:
|
||||||
|
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
|
||||||
|
# 这里我们稍微增加一点饱和度,并确保不溢出
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
|
||||||
|
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
||||||
|
# v = clahe.apply(v)
|
||||||
|
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||||
|
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
|
||||||
|
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||||
|
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||||
|
|
||||||
|
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 4. 调整形态学操作
|
||||||
|
# 去掉 MORPH_OPEN,因为它会减小面积。
|
||||||
|
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
# 再进行一次膨胀,确保边缘被包含进来
|
||||||
|
# mask = cv2.dilate(mask, kernel, iterations=1)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
if contours:
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
|
||||||
|
# 这里可以适当降低面积阈值,或者保持不变
|
||||||
|
if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
|
||||||
|
# --- 核心修改开始 ---
|
||||||
|
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
|
||||||
|
if len(largest) >= 5:
|
||||||
|
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
|
||||||
|
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
|
||||||
|
|
||||||
|
# 2. 计算半径
|
||||||
|
# 选项A:取长短轴的平均值 (比较稳健)
|
||||||
|
# radius = (axes_major + axes_minor) / 4
|
||||||
|
|
||||||
|
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2_ellipse"
|
||||||
|
else:
|
||||||
|
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2"
|
||||||
|
# --- 核心修改结束 ---
|
||||||
|
|
||||||
|
# 你的后续逻辑
|
||||||
|
best_radius1 = radius * 5
|
||||||
|
|
||||||
|
# operas 4.5
|
||||||
|
# R:25 M:v2 D:2.9554872521538527
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
|
||||||
|
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# # 2. 放宽 HSV 阈值范围(关键改动)
|
||||||
|
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
|
||||||
|
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
|
||||||
|
# lower_yellow = np.array([7, 40, 30])
|
||||||
|
# upper_yellow = np.array([35, 255, 255])
|
||||||
|
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
|
||||||
|
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
|
||||||
|
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
|
||||||
|
# # 创建一个彩色掩码(红色通道为255,其他为0)
|
||||||
|
# mask_overlay = np.zeros_like(img_cv)
|
||||||
|
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
|
||||||
|
#
|
||||||
|
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1
|
||||||
|
|
||||||
|
|
||||||
|
def detect_circle_v2(frame):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
|
||||||
|
global REAL_RADIUS_CM
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
best_center = best_radius = best_radius1 = method = None
|
||||||
|
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
|
||||||
|
|
||||||
|
# HSV 黄色掩码检测(模糊靶心)
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# 调整饱和度策略:稍微增强,不要过度
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||||
|
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||||
|
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||||
|
|
||||||
|
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 调整形态学操作
|
||||||
|
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
if contours:
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
|
||||||
|
if cv2.contourArea(largest) > 50:
|
||||||
|
# 尝试拟合椭圆 (需要轮廓点至少为5个)
|
||||||
|
if len(largest) >= 5:
|
||||||
|
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
|
||||||
|
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
|
||||||
|
(x, y), (width, height), angle = cv2.fitEllipse(largest)
|
||||||
|
|
||||||
|
# 保存椭圆参数(保持原始顺序,用于绘制)
|
||||||
|
ellipse_params = ((x, y), (width, height), angle)
|
||||||
|
|
||||||
|
# 计算半径:使用较小的尺寸作为短轴
|
||||||
|
axes_minor = min(width, height)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2_ellipse"
|
||||||
|
else:
|
||||||
|
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2"
|
||||||
|
ellipse_params = None # 圆形,没有椭圆参数
|
||||||
|
|
||||||
|
best_radius1 = radius * 5
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 测试逻辑 ====================
|
||||||
|
|
||||||
|
def run_offline_test(image_path):
|
||||||
|
"""读取图片,检测圆,绘制结果,保存图片"""
|
||||||
|
|
||||||
|
# 1. 检查文件是否存在
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
print(f"[ERROR] 找不到图片文件: {image_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
||||||
|
try:
|
||||||
|
# 使用 image.load 读取文件,返回 Image 对象
|
||||||
|
img = image.load(image_path)
|
||||||
|
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 读取图片失败: {e}")
|
||||||
|
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 调用 detect_circle_v2 函数
|
||||||
|
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
|
||||||
|
start_time = time.ticks_ms()
|
||||||
|
|
||||||
|
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
||||||
|
|
||||||
|
cost_time = time.ticks_ms() - start_time
|
||||||
|
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
||||||
|
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
print(
|
||||||
|
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
||||||
|
|
||||||
|
# 4. 绘制辅助线(可选,用于调试)
|
||||||
|
if center and radius:
|
||||||
|
# 为了绘制椭圆,需要转换回 cv2 图像
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
|
||||||
|
cx, cy = center
|
||||||
|
|
||||||
|
# 如果有椭圆参数,绘制椭圆
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
||||||
|
|
||||||
|
# 确定长轴和短轴
|
||||||
|
if width >= height:
|
||||||
|
# width 是长轴,height 是短轴
|
||||||
|
axes_major = width
|
||||||
|
axes_minor = height
|
||||||
|
major_angle = angle # 长轴角度就是 angle
|
||||||
|
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
||||||
|
else:
|
||||||
|
# height 是长轴,width 是短轴
|
||||||
|
axes_major = height
|
||||||
|
axes_minor = width
|
||||||
|
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
||||||
|
minor_angle = angle # 短轴角度就是 angle
|
||||||
|
|
||||||
|
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
||||||
|
cv2.ellipse(img_cv,
|
||||||
|
(cx_ell, cy_ell), # 中心点
|
||||||
|
(int(width / 2), int(height / 2)), # 半宽、半高
|
||||||
|
angle, # 旋转角度(OpenCV需要原始angle)
|
||||||
|
0, 360, # 起始和结束角度
|
||||||
|
(0, 255, 0), # 绿色 (RGB格式)
|
||||||
|
2) # 线宽
|
||||||
|
|
||||||
|
# 绘制椭圆中心点(红色)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
|
||||||
|
import math
|
||||||
|
# 绘制短轴(蓝色线条)
|
||||||
|
minor_length = axes_minor / 2
|
||||||
|
minor_angle_rad = math.radians(minor_angle)
|
||||||
|
dx_minor = minor_length * math.cos(minor_angle_rad)
|
||||||
|
dy_minor = minor_length * math.sin(minor_angle_rad)
|
||||||
|
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
||||||
|
else:
|
||||||
|
# 如果没有椭圆参数,绘制圆形(红色)
|
||||||
|
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
||||||
|
|
||||||
|
# 转换回 maix image
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
|
||||||
|
# 定义颜色对象用于文字
|
||||||
|
try:
|
||||||
|
color_black = image.Color.from_rgb(0, 0, 0)
|
||||||
|
except AttributeError:
|
||||||
|
color_black = image.Color(0, 0, 0)
|
||||||
|
|
||||||
|
# D. 添加文字信息
|
||||||
|
FOCAL_LENGTH_PIX = 1900
|
||||||
|
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
||||||
|
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
||||||
|
print(info_str)
|
||||||
|
|
||||||
|
# 计算文字位置,防止超出图片边界
|
||||||
|
r_outer = int(radius * 11.0) if radius else 100
|
||||||
|
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
||||||
|
|
||||||
|
# 调用 draw_string
|
||||||
|
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
||||||
|
|
||||||
|
# 5. 保存结果图片
|
||||||
|
output_path = image_path.replace(".bmp", "_result.bmp")
|
||||||
|
output_path = image_path.replace(".jpg", "_result.jpg")
|
||||||
|
try:
|
||||||
|
result_img.save(output_path, quality=100)
|
||||||
|
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 保存图片失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# ================= 配置区域 =================
|
||||||
|
|
||||||
|
# 1. 设置要测试的图片路径
|
||||||
|
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
||||||
|
TARGET_IMAGE = "/root/phot/shot_1830921_0_no_target.jpg"
|
||||||
|
|
||||||
|
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
||||||
|
|
||||||
|
# 支持的图片格式
|
||||||
|
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
|
||||||
|
# ================= 执行区域 =================
|
||||||
|
if 'TARGET_DIR' in locals():
|
||||||
|
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
||||||
|
image_files = []
|
||||||
|
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
||||||
|
for filename in os.listdir(TARGET_DIR):
|
||||||
|
# 检查文件扩展名
|
||||||
|
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
||||||
|
# 过滤掉 _result.jpg 后缀的文件
|
||||||
|
if filename.endswith('no_target.jpg'):
|
||||||
|
filepath = os.path.join(TARGET_DIR, filename)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
image_files.append(filepath)
|
||||||
|
|
||||||
|
# 按文件名排序(可选)
|
||||||
|
image_files.sort()
|
||||||
|
|
||||||
|
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
||||||
|
|
||||||
|
# 处理每张图片
|
||||||
|
for img_path in image_files:
|
||||||
|
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
||||||
|
run_offline_test(img_path)
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
run_offline_test(TARGET_IMAGE)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Interactive GPIO test for physical pin A14."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from maix import gpio, pinmap
|
||||||
|
|
||||||
|
|
||||||
|
PIN = "A14"
|
||||||
|
GPIO_NAME = "GPIOA14"
|
||||||
|
|
||||||
|
|
||||||
|
def set_level(output, command):
|
||||||
|
if command == "1":
|
||||||
|
output.value(1)
|
||||||
|
print("A14 = HIGH, laser OFF")
|
||||||
|
return True
|
||||||
|
if command == "0":
|
||||||
|
output.value(0)
|
||||||
|
print("A14 = LOW, laser ON")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pinmap.set_pin_function(PIN, GPIO_NAME)
|
||||||
|
output = gpio.GPIO(GPIO_NAME, gpio.Mode.OUT)
|
||||||
|
|
||||||
|
# One-shot mode for SSH/serial shells: python3 test_gpio_a14.py 1|0
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
command = sys.argv[1].strip()
|
||||||
|
if not set_level(output, command):
|
||||||
|
print("Invalid argument. Use 1 or 0.")
|
||||||
|
return
|
||||||
|
return
|
||||||
|
|
||||||
|
output.value(1)
|
||||||
|
print("A14 laser test: input 0 for ON, 1 for OFF, q to quit.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
command = input("A14> ").strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
print("This runner has no stdin. Run from an SSH/serial shell with argument 1 or 0.")
|
||||||
|
return
|
||||||
|
if set_level(output, command):
|
||||||
|
continue
|
||||||
|
elif command in ("q", "quit", "exit"):
|
||||||
|
break
|
||||||
|
elif command:
|
||||||
|
print("Invalid input. Use 1, 0, or q.")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
finally:
|
||||||
|
output.value(1)
|
||||||
|
print("A14 = HIGH, laser OFF, test stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Standalone WiFi/GPIO/INA226 isolation test for the official MaixPy tool.
|
||||||
|
|
||||||
|
This file intentionally does not import project modules or start project
|
||||||
|
threads. Select TEST_MODE below, then run the file directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from maix import gpio, i2c, network, pinmap
|
||||||
|
|
||||||
|
|
||||||
|
# Change only this value before each run.
|
||||||
|
# wifi WiFi only
|
||||||
|
# a25 A25 only
|
||||||
|
# a23 A23 only
|
||||||
|
# gpio A23/A26 only
|
||||||
|
# ina INA226 only
|
||||||
|
# gpio_ina GPIOs, then INA226
|
||||||
|
# a25_wifi A25, then WiFi
|
||||||
|
# a23_wifi A23, then WiFi
|
||||||
|
# all GPIOs, INA226, then WiFi
|
||||||
|
TEST_MODE = "a25_wifi"
|
||||||
|
|
||||||
|
WIFI_SSID = "sheling4b02-5G"
|
||||||
|
WIFI_PASSWORD = "Aa12345678"
|
||||||
|
WIFI_TIMEOUT_S = 20
|
||||||
|
I2C_BUS_NUM = 5
|
||||||
|
INA226_ADDR = 0x40
|
||||||
|
|
||||||
|
|
||||||
|
def init_leds():
|
||||||
|
return init_selected_leds(True, True)
|
||||||
|
|
||||||
|
|
||||||
|
def init_selected_leds(use_a26, use_a23):
|
||||||
|
outputs = []
|
||||||
|
if use_a26:
|
||||||
|
print("Initializing A25 -> GPIOA25")
|
||||||
|
pinmap.set_pin_function("A25", "GPIOA25")
|
||||||
|
green = gpio.GPIO("GPIOA25", gpio.Mode.OUT)
|
||||||
|
green.value(0)
|
||||||
|
outputs.append(("GPIOA25", green))
|
||||||
|
print("GPIOA25 initialized LOW")
|
||||||
|
if use_a23:
|
||||||
|
print("Initializing A23 -> GPIOA23")
|
||||||
|
pinmap.set_pin_function("A23", "GPIOA23")
|
||||||
|
red = gpio.GPIO("GPIOA23", gpio.Mode.OUT)
|
||||||
|
red.value(0)
|
||||||
|
outputs.append(("GPIOA23", red))
|
||||||
|
print("GPIOA23 initialized LOW")
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def test_ina226():
|
||||||
|
# Match the board mapping used by the application before opening I2C5.
|
||||||
|
pinmap.set_pin_function("A15", "I2C5_SCL")
|
||||||
|
pinmap.set_pin_function("A27", "I2C5_SDA")
|
||||||
|
print("A15/A27 configured for I2C5")
|
||||||
|
print("Initializing I2C bus", I2C_BUS_NUM)
|
||||||
|
bus = i2c.I2C(I2C_BUS_NUM, i2c.Mode.MASTER)
|
||||||
|
print("Reading INA226 at 0x%02X" % INA226_ADDR)
|
||||||
|
config = bus.readfrom_mem(INA226_ADDR, 0x00, 2)
|
||||||
|
voltage_raw = bus.readfrom_mem(INA226_ADDR, 0x02, 2)
|
||||||
|
voltage = ((voltage_raw[0] << 8) | voltage_raw[1]) * 1.25 / 1000
|
||||||
|
print("INA226 config=0x%02X%02X voltage=%.3fV" % (config[0], config[1], voltage))
|
||||||
|
return bus
|
||||||
|
|
||||||
|
|
||||||
|
def test_wifi():
|
||||||
|
print("Starting MaixPy WiFi connection...")
|
||||||
|
wifi = network.wifi.Wifi()
|
||||||
|
result = wifi.connect(WIFI_SSID, WIFI_PASSWORD, wait=True, timeout=WIFI_TIMEOUT_S)
|
||||||
|
print("WiFi connect result:", result)
|
||||||
|
print("WiFi connected:", wifi.is_connected())
|
||||||
|
try:
|
||||||
|
print("WiFi IP:", wifi.get_ip())
|
||||||
|
except Exception as exc:
|
||||||
|
print("WiFi status query failed:", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
valid = ("wifi", "a25", "a23", "gpio", "ina", "gpio_ina", "a25_wifi", "a23_wifi", "all")
|
||||||
|
mode = TEST_MODE.lower()
|
||||||
|
if mode not in valid:
|
||||||
|
print("TEST_MODE must be one of:", ", ".join(valid))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
leds = []
|
||||||
|
try:
|
||||||
|
print("=== Standalone WiFi/GPIO/INA226 isolation ===")
|
||||||
|
print("mode:", mode)
|
||||||
|
if mode in ("a25", "a25_wifi"):
|
||||||
|
leds = init_selected_leds(True, False)
|
||||||
|
time.sleep(1)
|
||||||
|
elif mode in ("a23", "a23_wifi"):
|
||||||
|
leds = init_selected_leds(False, True)
|
||||||
|
time.sleep(1)
|
||||||
|
elif mode in ("gpio", "gpio_ina", "all"):
|
||||||
|
leds = init_leds()
|
||||||
|
time.sleep(1)
|
||||||
|
if mode in ("ina", "gpio_ina", "all"):
|
||||||
|
test_ina226()
|
||||||
|
time.sleep(1)
|
||||||
|
if mode in ("wifi", "a25_wifi", "a23_wifi", "all"):
|
||||||
|
test_wifi()
|
||||||
|
print("TEST COMPLETE")
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
print("TEST FAILED:", repr(exc))
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
for name, led in leds:
|
||||||
|
try:
|
||||||
|
led.value(0)
|
||||||
|
print(name, "LOW")
|
||||||
|
except Exception as exc:
|
||||||
|
print(name, "cleanup failed:", exc)
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Standalone WiFi/GPIO isolation test.
|
||||||
|
|
||||||
|
This script intentionally does not import any project module. It only tests
|
||||||
|
MaixPy WiFi startup with optional A23/A26 GPIO initialization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from maix import gpio, network, pinmap
|
||||||
|
|
||||||
|
|
||||||
|
GREEN_PIN = "A26"
|
||||||
|
GREEN_GPIO = "GPIOA26"
|
||||||
|
RED_PIN = "A23"
|
||||||
|
RED_GPIO = "GPIOA23"
|
||||||
|
|
||||||
|
# Run this file directly from the official MaixPy tool.
|
||||||
|
# Change only TEST_MODE between runs: none -> a26 -> a23 -> both.
|
||||||
|
TEST_MODE = "none"
|
||||||
|
WIFI_SSID = "sheling4b02-5G"
|
||||||
|
WIFI_PASSWORD = "Aa12345678"
|
||||||
|
WIFI_TIMEOUT_S = 20
|
||||||
|
|
||||||
|
|
||||||
|
def init_gpio(mode):
|
||||||
|
outputs = []
|
||||||
|
if mode in ("a26", "both"):
|
||||||
|
pinmap.set_pin_function(GREEN_PIN, GREEN_GPIO)
|
||||||
|
green = gpio.GPIO(GREEN_GPIO, gpio.Mode.OUT)
|
||||||
|
green.value(1)
|
||||||
|
outputs.append((GREEN_GPIO, green))
|
||||||
|
print("GPIOA26 initialized HIGH")
|
||||||
|
if mode in ("a23", "both"):
|
||||||
|
pinmap.set_pin_function(RED_PIN, RED_GPIO)
|
||||||
|
red = gpio.GPIO(RED_GPIO, gpio.Mode.OUT)
|
||||||
|
red.value(1)
|
||||||
|
outputs.append((RED_GPIO, red))
|
||||||
|
print("GPIOA23 initialized HIGH")
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def connect_wifi(ssid, password, timeout_s):
|
||||||
|
print("Starting MaixPy WiFi connection...")
|
||||||
|
wifi = network.wifi.Wifi()
|
||||||
|
result = wifi.connect(ssid, password, wait=True, timeout=timeout_s)
|
||||||
|
print("WiFi connect result:", result)
|
||||||
|
try:
|
||||||
|
print("WiFi connected:", wifi.is_connected())
|
||||||
|
print("WiFi IP:", wifi.get_ip())
|
||||||
|
except Exception as exc:
|
||||||
|
print("WiFi status query failed:", exc)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mode = TEST_MODE.lower()
|
||||||
|
if mode not in ("none", "a26", "a23", "both"):
|
||||||
|
print("TEST_MODE must be none, a26, a23, or both")
|
||||||
|
return 1
|
||||||
|
ssid = WIFI_SSID
|
||||||
|
password = WIFI_PASSWORD
|
||||||
|
timeout_s = WIFI_TIMEOUT_S
|
||||||
|
|
||||||
|
print("=== Standalone WiFi/GPIO isolation ===")
|
||||||
|
print("mode:", mode)
|
||||||
|
print("ssid:", ssid)
|
||||||
|
outputs = []
|
||||||
|
try:
|
||||||
|
outputs = init_gpio(mode)
|
||||||
|
time.sleep(1)
|
||||||
|
connect_wifi(ssid, password, timeout_s)
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
print("TEST FAILED:", repr(exc))
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
for gpio_name, output in outputs:
|
||||||
|
try:
|
||||||
|
output.value(0)
|
||||||
|
print(gpio_name, "LOW")
|
||||||
|
except Exception as exc:
|
||||||
|
print(gpio_name, "cleanup failed:", exc)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
||||||
|
# 1.2.1 ota使用加密包
|
||||||
|
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
||||||
|
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
||||||
|
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
||||||
|
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
||||||
|
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
||||||
|
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
||||||
|
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
||||||
|
# 1.2.9 增加电源板的控制和自动关机的功能
|
||||||
|
# 1.2.10 config formal
|
||||||
|
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
|
||||||
|
# 1.2.110 关掉了黑色三角形算法,只用于测试
|
||||||
|
# 1.2.13 修改wifi连接
|
||||||
|
# 1.2.14 修改了icc登录部分
|
||||||
|
# 2.15.3 新版本ota,去除ai算环数方法
|
||||||
|
# 2.15.4 更新版本号
|
||||||
|
# 2.15.5 打印ota进度
|
||||||
|
# 2.15.6 更新版本号
|
||||||
|
# 2.15.7 更新版本号
|
||||||
|
# 2.15.8 启动不加载预加载yolo
|
||||||
|
# 2.15.9 20cm
|
||||||
|
# 2.15.10 不保存图片
|
||||||
|
# 2.15.11 优化内存
|
||||||
|
# 2.15.12 优化算法
|
||||||
|
# 2.15.13 优化算法
|
||||||
|
# 2.15.14 优化算法
|
||||||
|
# 2.15.15 优化wifi连接
|
||||||
|
# 2.15.16 修复wifi连接问题
|
||||||
|
# 2.15.17 修复wifi连接问题
|
||||||
|
# 2.15.18 wifi连接成功重新登录
|
||||||
|
# 2.16.4 优化射箭延迟
|
||||||
|
# 2.17.0 yolo标靶类别识别
|
||||||
|
# 3.0.4 26-09-03 9:36 引脚修改:A23 -> P19 red light
|
||||||
+1
-22
@@ -4,27 +4,6 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '1.2.12'
|
VERSION = '2.18.4'
|
||||||
|
|
||||||
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
|
||||||
# 1.2.1 ota使用加密包
|
|
||||||
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
|
||||||
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
|
||||||
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
|
||||||
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
|
||||||
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
|
||||||
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
|
||||||
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
|
||||||
# 1.2.9 增加电源板的控制和自动关机的功能
|
|
||||||
# 1.2.10 config formal
|
|
||||||
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
|
|
||||||
# 1.2.110 关掉了黑色三角形算法,只用于测试
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
|
||||||
# -- 1. 缩图加速(与三角形路径保持一致)
|
# -- 1. 缩图加速(与三角形路径保持一致)
|
||||||
h_orig, w_orig = img_cv.shape[:2]
|
h_orig, w_orig = img_cv.shape[:2]
|
||||||
MAX_DET_DIM = 320
|
MAX_DET_DIM = 480
|
||||||
long_side = max(h_orig, w_orig)
|
long_side = max(h_orig, w_orig)
|
||||||
if long_side > MAX_DET_DIM:
|
if long_side > MAX_DET_DIM:
|
||||||
det_scale = MAX_DET_DIM / long_side
|
det_scale = MAX_DET_DIM / long_side
|
||||||
@@ -570,20 +570,22 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
|
|
||||||
# -- 3. 红色掩码:在循环外只算一次
|
# -- 3. 红色掩码:在循环外只算一次
|
||||||
mask_red = cv2.bitwise_or(
|
mask_red = cv2.bitwise_or(
|
||||||
cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])),
|
cv2.inRange(hsv, np.array([0, 30, 20]), np.array([12, 255, 255])),
|
||||||
cv2.inRange(hsv, np.array([170, 80, 0]), np.array([180, 255, 255])),
|
cv2.inRange(hsv, np.array([168, 30, 20]), np.array([180, 255, 255])),
|
||||||
)
|
)
|
||||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
# 再加一次膨胀,加厚环状区域避免碎片化
|
||||||
|
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
||||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
||||||
red_candidates = []
|
red_candidates = []
|
||||||
for cnt_r in contours_red:
|
for cnt_r in contours_red:
|
||||||
ar = cv2.contourArea(cnt_r)
|
ar = cv2.contourArea(cnt_r)
|
||||||
if ar <= 50:
|
if ar <= 10:
|
||||||
continue
|
continue
|
||||||
pr = cv2.arcLength(cnt_r, True)
|
pr = cv2.arcLength(cnt_r, True)
|
||||||
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6:
|
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.2:
|
||||||
continue
|
continue
|
||||||
if len(cnt_r) >= 5:
|
if len(cnt_r) >= 5:
|
||||||
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
||||||
@@ -599,13 +601,13 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
valid_targets = []
|
valid_targets = []
|
||||||
for cnt_yellow in contours_yellow:
|
for cnt_yellow in contours_yellow:
|
||||||
area = cv2.contourArea(cnt_yellow)
|
area = cv2.contourArea(cnt_yellow)
|
||||||
if area <= 50:
|
if area <= 15:
|
||||||
continue
|
continue
|
||||||
perimeter = cv2.arcLength(cnt_yellow, True)
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
if perimeter <= 0:
|
if perimeter <= 0:
|
||||||
continue
|
continue
|
||||||
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
if circularity <= 0.7:
|
if circularity <= 0.5:
|
||||||
continue
|
continue
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
||||||
@@ -625,7 +627,11 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
ddx = yellow_center[0] - rc["center"][0]
|
ddx = yellow_center[0] - rc["center"][0]
|
||||||
ddy = yellow_center[1] - rc["center"][1]
|
ddy = yellow_center[1] - rc["center"][1]
|
||||||
dist_centers = math.hypot(ddx, ddy)
|
dist_centers = math.hypot(ddx, ddy)
|
||||||
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
|
max_dist = yellow_radius * 2.0
|
||||||
|
min_r = min(rc["radius"], yellow_radius)
|
||||||
|
max_r = max(rc["radius"], yellow_radius)
|
||||||
|
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||||
|
if dist_centers < max_dist and size_ratio >= 0.4:
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
||||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
||||||
@@ -638,8 +644,17 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
})
|
})
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
if not matched and logger:
|
if not matched:
|
||||||
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
# 黄圈高置信度兜底:大且圆时跳过红圈验证
|
||||||
|
if area > 30 and circularity > 0.8:
|
||||||
|
valid_targets.append({
|
||||||
|
"center": yellow_center,
|
||||||
|
"radius": yellow_radius,
|
||||||
|
"ellipse": yellow_ellipse,
|
||||||
|
"area": area,
|
||||||
|
})
|
||||||
|
elif logger:
|
||||||
|
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
||||||
|
|
||||||
@@ -782,12 +797,12 @@ def estimate_pixel(physical_distance_cm, target_distance_m):
|
|||||||
|
|
||||||
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
||||||
由 save_shot_image(同步)和存图 worker(异步)调用。
|
由 save_shot_image(同步)和存图 worker(异步)调用。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return None
|
return None
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -923,11 +938,12 @@ def start_save_shot_worker():
|
|||||||
|
|
||||||
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
||||||
|
force_save=True 时,忽略 SAVE_IMAGE_ENABLED 配置强制保存(用于检测失败时的调试图像)。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return
|
return
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -950,6 +966,7 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
|||||||
shot_id,
|
shot_id,
|
||||||
photo_dir,
|
photo_dir,
|
||||||
yolo_roi_xyxy,
|
yolo_roi_xyxy,
|
||||||
|
force_save,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_save_queue.put_nowait(task)
|
_save_queue.put_nowait(task)
|
||||||
@@ -961,12 +978,12 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
|||||||
|
|
||||||
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
保存射击图像(带标注)。同步调用,会阻塞。
|
保存射击图像(带标注)。同步调用,会阻塞。
|
||||||
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return None
|
return None
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -983,6 +1000,7 @@ def save_shot_image(result_img, center, radius, method, ellipse_params,
|
|||||||
shot_id,
|
shot_id,
|
||||||
photo_dir,
|
photo_dir,
|
||||||
yolo_roi_xyxy,
|
yolo_roi_xyxy,
|
||||||
|
force_save,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class WiFiManager:
|
|||||||
# WiFi 质量监测(后台线程)
|
# WiFi 质量监测(后台线程)
|
||||||
self._wifi_quality_monitor_thread = None
|
self._wifi_quality_monitor_thread = None
|
||||||
self._wifi_quality_stop_event = threading.Event()
|
self._wifi_quality_stop_event = threading.Event()
|
||||||
|
self._wifi_quality_lock = threading.Lock()
|
||||||
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
||||||
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
||||||
|
|
||||||
@@ -238,7 +239,6 @@ class WiFiManager:
|
|||||||
old_conf = _read_text(conf_path)
|
old_conf = _read_text(conf_path)
|
||||||
old_boot_ssid = _read_text(ssid_file)
|
old_boot_ssid = _read_text(ssid_file)
|
||||||
old_boot_pass = _read_text(pass_file)
|
old_boot_pass = _read_text(pass_file)
|
||||||
old_boot_wpa = _read_text(boot_wpa_path) if os.path.exists(boot_wpa_path) else None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -250,9 +250,13 @@ class WiFiManager:
|
|||||||
_write_text(conf_path, full_conf)
|
_write_text(conf_path, full_conf)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_write_text(boot_wpa_path, full_conf)
|
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
|
||||||
|
try:
|
||||||
|
if os.path.exists(boot_wpa_path):
|
||||||
|
os.remove(boot_wpa_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 仍写入 ssid/pass,便于其它脚本/人工查看;S30wifi 优先使用 wpa_supplicant.conf
|
|
||||||
_write_text(ssid_file, ssid.strip())
|
_write_text(ssid_file, ssid.strip())
|
||||||
_write_text(pass_file, password.strip())
|
_write_text(pass_file, password.strip())
|
||||||
|
|
||||||
@@ -292,7 +296,6 @@ class WiFiManager:
|
|||||||
if not persist:
|
if not persist:
|
||||||
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
||||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
_restore_boot_wpa(old_boot_wpa)
|
|
||||||
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
||||||
else:
|
else:
|
||||||
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
||||||
@@ -306,7 +309,6 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
||||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
_restore_boot_wpa(old_boot_wpa)
|
|
||||||
try:
|
try:
|
||||||
if old_conf is not None:
|
if old_conf is not None:
|
||||||
_write_text(conf_path, old_conf)
|
_write_text(conf_path, old_conf)
|
||||||
@@ -351,7 +353,11 @@ class WiFiManager:
|
|||||||
else:
|
else:
|
||||||
full_conf = build_sta_conf_open(ssid)
|
full_conf = build_sta_conf_open(ssid)
|
||||||
_write_text(conf_path, full_conf)
|
_write_text(conf_path, full_conf)
|
||||||
_write_text(boot_wpa_path, full_conf)
|
try:
|
||||||
|
if os.path.exists(boot_wpa_path):
|
||||||
|
os.remove(boot_wpa_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -542,34 +548,45 @@ class WiFiManager:
|
|||||||
network_type_callback: 获取当前网络类型的回调函数
|
network_type_callback: 获取当前网络类型的回调函数
|
||||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||||
"""
|
"""
|
||||||
if self._wifi_quality_monitor_thread is not None:
|
with self._wifi_quality_lock:
|
||||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
||||||
return
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
|
return
|
||||||
self._network_type_callback = network_type_callback
|
|
||||||
self._on_poor_quality_callback = on_poor_quality_callback
|
self._network_type_callback = network_type_callback
|
||||||
self._wifi_quality_stop_event.clear()
|
self._on_poor_quality_callback = on_poor_quality_callback
|
||||||
self._wifi_quality_monitor_thread = threading.Thread(
|
self._wifi_quality_stop_event.clear()
|
||||||
target=self._quality_monitor_loop,
|
self._wifi_quality_monitor_thread = threading.Thread(
|
||||||
daemon=True,
|
target=self._quality_monitor_loop,
|
||||||
name="wifi_quality_monitor"
|
daemon=True,
|
||||||
)
|
name="wifi_quality_monitor"
|
||||||
self._wifi_quality_monitor_thread.start()
|
)
|
||||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
self._wifi_quality_monitor_thread.start()
|
||||||
|
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||||
|
|
||||||
def stop_quality_monitor(self):
|
def stop_quality_monitor(self):
|
||||||
"""停止 WiFi 质量监测线程"""
|
"""停止 WiFi 质量监测线程"""
|
||||||
if self._wifi_quality_monitor_thread is None:
|
with self._wifi_quality_lock:
|
||||||
return
|
t = self._wifi_quality_monitor_thread
|
||||||
|
if t is None:
|
||||||
|
return
|
||||||
|
if not t.is_alive():
|
||||||
|
self._wifi_quality_monitor_thread = None
|
||||||
|
return
|
||||||
|
|
||||||
self._wifi_quality_stop_event.set()
|
self._wifi_quality_stop_event.set()
|
||||||
try:
|
try:
|
||||||
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
t.join(timeout=2.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||||
finally:
|
|
||||||
self._wifi_quality_monitor_thread = None
|
with self._wifi_quality_lock:
|
||||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
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):
|
def _quality_monitor_loop(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user