Compare commits
37
Commits
main
..
685dce2519
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685dce2519 | ||
|
|
ec80107128 | ||
|
|
fffca13941 | ||
|
|
760b43cc68 | ||
|
|
3bc48598cd | ||
|
|
704b20cde1 | ||
|
|
d1ae364dbd | ||
|
|
75def0ff38 | ||
|
|
ff629e596d | ||
|
|
592dc6ceb1 | ||
|
|
573c0a3385 | ||
|
|
8aea76d99b | ||
|
|
61096ba190 | ||
|
|
f476545172 | ||
|
|
aae97f6ce9 | ||
|
|
8ce8831315 | ||
|
|
28fb62e5d6 | ||
|
|
42bfdd033c | ||
|
|
945077a453 | ||
|
|
0ce140a210 | ||
|
|
83fe0776eb | ||
|
|
a0019b8b0e | ||
|
|
2a0534ac62 | ||
|
|
3c45fba0f5 | ||
|
|
708925ab41 | ||
|
|
92ad32bb8e | ||
|
|
669d032f96 | ||
|
|
b37c492930 | ||
|
|
46757e848f | ||
|
|
201de84ad0 | ||
|
|
85a5ff9ff0 | ||
|
|
e712e11ea0 | ||
|
|
b552d20a46 | ||
|
|
21cec260b8 | ||
|
|
5a98bf2e85 | ||
|
|
f11b31c09c | ||
|
|
0b18ec353c |
@@ -0,0 +1,3 @@
|
|||||||
|
/cpp_ext/build/
|
||||||
|
/.cursor/
|
||||||
|
/dist/
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import re
|
||||||
|
import hashlib
|
||||||
|
import binascii
|
||||||
|
from maix import time
|
||||||
|
from power import get_bus_voltage, voltage_to_percent
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from hardware import hardware_manager
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadManager4G:
|
||||||
|
"""4g下载管理器(单例)"""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(DownloadManager4G, cls).__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 私有状态
|
||||||
|
self.FRAG_SIZE = 1024
|
||||||
|
self.FRAG_DELAY = 10
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def _log(self, *a):
|
||||||
|
if debug:
|
||||||
|
self.logger.debug(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
def _pwr_log(self, prefix=""):
|
||||||
|
"""debug 用:输出电压/电量"""
|
||||||
|
if not debug:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
v = get_bus_voltage()
|
||||||
|
p = voltage_to_percent(v)
|
||||||
|
self.logger.debug(f"[PWR]{prefix} v={v:.3f}V p={p}%")
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
self.logger.debug(f"[PWR]{prefix} read_failed: {e}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _clear_http_events(self):
|
||||||
|
if hardware_manager.at_client:
|
||||||
|
while hardware_manager.at_client.pop_http_event() is not None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _parse_httpid(self, resp: str):
|
||||||
|
m = re.search(r"\+MHTTPCREATE:\s*(\d+)", resp)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
def _get_ip(self, ):
|
||||||
|
r = hardware_manager.at_client.send("AT+CGPADDR=1", "OK", 3000)
|
||||||
|
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
def _ensure_pdp(self, ):
|
||||||
|
ip = self._get_ip()
|
||||||
|
if ip and ip != "0.0.0.0":
|
||||||
|
return True, ip
|
||||||
|
hardware_manager.at_client.send("AT+MIPCALL=1,1", "OK", 15000)
|
||||||
|
for _ in range(10):
|
||||||
|
ip = self._get_ip()
|
||||||
|
if ip and ip != "0.0.0.0":
|
||||||
|
return True, ip
|
||||||
|
time.sleep(1)
|
||||||
|
return False, ip
|
||||||
|
|
||||||
|
def _extract_hdr_fields(self, hdr_text: str):
|
||||||
|
mlen = re.search(r"Content-Length:\s*(\d+)", hdr_text, re.IGNORECASE)
|
||||||
|
clen = int(mlen.group(1)) if mlen else None
|
||||||
|
mmd5 = re.search(r"Content-Md5:\s*([A-Za-z0-9+/=]+)", hdr_text, re.IGNORECASE)
|
||||||
|
md5_b64 = mmd5.group(1).strip() if mmd5 else None
|
||||||
|
return clen, md5_b64
|
||||||
|
|
||||||
|
def _extract_content_range(self, hdr_text: str):
|
||||||
|
m = re.search(r"Content-Range:\s*bytes\s*(\d+)\s*-\s*(\d+)\s*/\s*(\d+)", hdr_text, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return None, None, None
|
||||||
|
try:
|
||||||
|
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||||
|
except:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
def _hard_reset_http(self, ):
|
||||||
|
"""模块进入"坏状态"时的保守清场"""
|
||||||
|
self._clear_http_events()
|
||||||
|
for i in range(0, 6):
|
||||||
|
try:
|
||||||
|
hardware_manager.at_client.send(f"AT+MHTTPDEL={i}", "OK", 1200)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self._clear_http_events()
|
||||||
|
|
||||||
|
def _create_httpid(self, full_reset=False):
|
||||||
|
self._clear_http_events()
|
||||||
|
if hardware_manager.at_client:
|
||||||
|
hardware_manager.at_client.flush()
|
||||||
|
if full_reset:
|
||||||
|
self._hard_reset_http()
|
||||||
|
resp = hardware_manager.at_client.send(f'AT+MHTTPCREATE="{base_url}"', "OK", 8000)
|
||||||
|
hid = self._parse_httpid(resp)
|
||||||
|
if self._is_https:
|
||||||
|
resp = hardware_manager.at_client.send(f'AT+MHTTPCFG="ssl",{hid},1,1', "OK", 2000)
|
||||||
|
if "ERROR" in resp or "CME ERROR" in resp:
|
||||||
|
self.logger.error(f"MHTTPCFG SSL failed: {resp}")
|
||||||
|
# 尝试https 降级到http
|
||||||
|
downgraded_base_url = base_url.replace("https://", "http://")
|
||||||
|
resp = hardware_manager.at_client.send(f'AT+MHTTPCREATE="{downgraded_base_url}"', "OK", 8000)
|
||||||
|
hid = self._parse_httpid(resp)
|
||||||
|
|
||||||
|
return hid, resp
|
||||||
|
|
||||||
|
def _fetch_range_into_buf(self, start, want_len, out_buf, path, full_reset=False):
|
||||||
|
"""
|
||||||
|
请求 Range [start, start+want_len),写入 out_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 not host:
|
||||||
|
return False, "bad_url (no host)"
|
||||||
|
|
||||||
|
if isinstance(url, str) and url.startswith("https://static.shelingxingqiu.com/"):
|
||||||
|
base_url = "https://static.shelingxingqiu.com"
|
||||||
|
# TODO:使用https,看看是否能成功
|
||||||
|
self._is_https = True
|
||||||
|
else:
|
||||||
|
base_url = f"http://{host}"
|
||||||
|
self._is_https = False
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._begin_ota()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from network import network_manager
|
||||||
|
with network_manager.get_uart_lock():
|
||||||
|
try:
|
||||||
|
ok_pdp, ip = self._ensure_pdp()
|
||||||
|
if not ok_pdp:
|
||||||
|
return False, f"PDP not ready (ip={ip})"
|
||||||
|
|
||||||
|
# 先清空旧事件,避免串台
|
||||||
|
self._clear_http_events()
|
||||||
|
|
||||||
|
# 为了支持随机写入,先创建空文件
|
||||||
|
try:
|
||||||
|
with open(filename, "wb") as f:
|
||||||
|
f.write(b"")
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"open_file_failed: {e}"
|
||||||
|
|
||||||
|
total_len = None
|
||||||
|
expect_md5_b64 = None
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
chunk = CHUNK_MAX
|
||||||
|
t_start = time.ticks_ms()
|
||||||
|
last_progress_ms = t_start
|
||||||
|
STALL_TIMEOUT_MS = 60000
|
||||||
|
last_pwr_ms = t_start
|
||||||
|
self._pwr_log(prefix=" ota_start")
|
||||||
|
bad_http_state = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = time.ticks_ms()
|
||||||
|
if debug and time.ticks_diff(now, last_pwr_ms) >= 5000:
|
||||||
|
last_pwr_ms = now
|
||||||
|
self._pwr_log(prefix=f" off={offset}/{total_len or '?'}")
|
||||||
|
if time.ticks_diff(now, t_start) > total_timeout_ms:
|
||||||
|
return False, f"timeout overall after {total_timeout_ms}ms offset={offset} total={total_len}"
|
||||||
|
|
||||||
|
if time.ticks_diff(now, last_progress_ms) > STALL_TIMEOUT_MS:
|
||||||
|
return False, f"timeout stalled {STALL_TIMEOUT_MS}ms offset={offset} total={total_len}"
|
||||||
|
|
||||||
|
if total_len is not None and offset >= total_len:
|
||||||
|
break
|
||||||
|
|
||||||
|
want = chunk
|
||||||
|
if total_len is not None:
|
||||||
|
remain = total_len - offset
|
||||||
|
if remain <= 0:
|
||||||
|
break
|
||||||
|
if want > remain:
|
||||||
|
want = remain
|
||||||
|
|
||||||
|
# 本 chunk 的 buffer(长度=want)
|
||||||
|
buf = bytearray(want)
|
||||||
|
|
||||||
|
success = False
|
||||||
|
last_err = "unknown"
|
||||||
|
md5_seen = None
|
||||||
|
got_len = 0
|
||||||
|
for k in range(1, CHUNK_RETRIES + 1):
|
||||||
|
do_full_reset = (bad_http_state >= 2)
|
||||||
|
ok, msg, tlen, md5_b64, got = self._fetch_range_into_buf(offset, want, buf, base_url, path, full_reset=do_full_reset)
|
||||||
|
last_err = msg
|
||||||
|
if tlen is not None and total_len is None:
|
||||||
|
total_len = tlen
|
||||||
|
if md5_b64 and not expect_md5_b64:
|
||||||
|
expect_md5_b64 = md5_b64
|
||||||
|
if ok:
|
||||||
|
success = True
|
||||||
|
got_len = got
|
||||||
|
bad_http_state = 0
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
if ("no_header_or_total" in msg) or ("MHTTPREQUEST failed" in msg) or (
|
||||||
|
"MHTTPCREATE failed" in msg):
|
||||||
|
bad_http_state += 1
|
||||||
|
else:
|
||||||
|
bad_http_state = max(0, bad_http_state - 1)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if chunk > CHUNK_MIN:
|
||||||
|
chunk = max(CHUNK_MIN, chunk // 2)
|
||||||
|
want = min(chunk, want)
|
||||||
|
buf = bytearray(want)
|
||||||
|
self._log(f"[RETRY] off={offset} want={want} try={k} err={msg}")
|
||||||
|
self._pwr_log(prefix=f" retry{k} off={offset}")
|
||||||
|
time.sleep_ms(120)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return False, f"chunk_failed off={offset} want={want} err={last_err} total={total_len}"
|
||||||
|
|
||||||
|
# 写入文件(二进制模式)
|
||||||
|
try:
|
||||||
|
with open(filename, "r+b") as f:
|
||||||
|
f.seek(offset)
|
||||||
|
f.write(bytes(buf))
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"write_failed off={offset}: {e}"
|
||||||
|
|
||||||
|
offset += len(buf)
|
||||||
|
last_progress_ms = time.ticks_ms()
|
||||||
|
chunk = CHUNK_MAX
|
||||||
|
if debug:
|
||||||
|
self._log(f"[OK] offset={offset}/{total_len or '?'}")
|
||||||
|
|
||||||
|
# MD5 校验
|
||||||
|
if expect_md5_b64 and hashlib is not None:
|
||||||
|
try:
|
||||||
|
with open(filename, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
digest = hashlib.md5(data).digest()
|
||||||
|
got_b64 = binascii.b2a_base64(digest).decode().strip()
|
||||||
|
if got_b64 != expect_md5_b64:
|
||||||
|
return False, f"md5_mismatch got={got_b64} expected={expect_md5_b64}"
|
||||||
|
self.logger.debug(f"[4G-DL] MD5 verified: {got_b64}")
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"md5_check_failed: {e}"
|
||||||
|
|
||||||
|
t_cost = time.ticks_diff(time.ticks_ms(), t_func0)
|
||||||
|
self.logger.info(f"[4G-DL] download complete: size={offset} ip={ip} cost_ms={t_cost}")
|
||||||
|
return True, f"OK size={offset} ip={ip} cost_ms={t_cost}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self._end_ota()
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# /etc/init.d/S99archery
|
||||||
|
# 系统启动时处理致命错误恢复(仅处理无法启动的情况)
|
||||||
|
# 注意:应用的启动由系统自动启动机制处理(通过 auto_start.txt)
|
||||||
|
# 功能:
|
||||||
|
# 1. 处理致命错误(无法启动)- 恢复 main.py
|
||||||
|
# 2. 如果重启次数超过阈值,恢复 main.py 并重启系统
|
||||||
|
|
||||||
|
APP_DIR="/maixapp/apps/t11"
|
||||||
|
MAIN_PY="$APP_DIR/main.py"
|
||||||
|
PENDING_FILE="$APP_DIR/ota_pending.json"
|
||||||
|
BACKUP_BASE="$APP_DIR/backups"
|
||||||
|
|
||||||
|
# 进入应用目录
|
||||||
|
cd "$APP_DIR" || exit 0
|
||||||
|
|
||||||
|
# 检查 pending 文件,如果存在且超过重启次数,恢复 main.py(处理致命错误)
|
||||||
|
if [ -f "$PENDING_FILE" ]; then
|
||||||
|
echo "[S99] 检测到 ota_pending.json,检查重启计数..."
|
||||||
|
|
||||||
|
# 尝试从JSON中提取重启计数(使用grep简单提取)
|
||||||
|
RESTART_COUNT=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"restart_count":[0-9]*' | grep -o '[0-9]*' || echo "0")
|
||||||
|
MAX_RESTARTS=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"max_restarts":[0-9]*' | grep -o '[0-9]*' || echo "3")
|
||||||
|
|
||||||
|
if [ -n "$RESTART_COUNT" ] && [ "$RESTART_COUNT" -ge "$MAX_RESTARTS" ]; then
|
||||||
|
echo "[S99] 检测到重启次数 ($RESTART_COUNT) 超过阈值 ($MAX_RESTARTS),恢复 main.py..."
|
||||||
|
|
||||||
|
# 尝试从JSON中提取备份目录
|
||||||
|
BACKUP_DIR=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"backup_dir":"[^"]*"' | grep -o '/[^"]*' || echo "")
|
||||||
|
|
||||||
|
if [ -n "$BACKUP_DIR" ] && [ -f "$BACKUP_DIR/main.py" ]; then
|
||||||
|
# 使用指定的备份目录
|
||||||
|
echo "[S99] 从备份目录恢复: $BACKUP_DIR/main.py"
|
||||||
|
cp "$BACKUP_DIR/main.py" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
|
||||||
|
else
|
||||||
|
# 查找最新的备份目录
|
||||||
|
LATEST_BACKUP=$(ls -dt "$BACKUP_BASE"/backup_* 2>/dev/null | head -1)
|
||||||
|
if [ -n "$LATEST_BACKUP" ] && [ -f "$LATEST_BACKUP/main.py" ]; then
|
||||||
|
echo "[S99] 从最新备份恢复: $LATEST_BACKUP/main.py"
|
||||||
|
cp "$LATEST_BACKUP/main.py" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
|
||||||
|
else
|
||||||
|
# 如果没有备份目录,尝试使用 main.py.bak
|
||||||
|
if [ -f "$APP_DIR/main.py.bak" ]; then
|
||||||
|
echo "[S99] 从 main.py.bak 恢复"
|
||||||
|
cp "$APP_DIR/main.py.bak" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 恢复后重置重启计数,避免循环恢复
|
||||||
|
# 注意:不在这里删除 pending 文件,让 main.py 在心跳成功后删除
|
||||||
|
# 但是重置重启计数,以便恢复后的版本可以重新开始计数
|
||||||
|
python3 -c "
|
||||||
|
import json, os
|
||||||
|
try:
|
||||||
|
pending_path = '$PENDING_FILE'
|
||||||
|
if os.path.exists(pending_path):
|
||||||
|
with open(pending_path, 'r', encoding='utf-8') as f:
|
||||||
|
d = json.load(f)
|
||||||
|
d['restart_count'] = 0 # 重置重启计数
|
||||||
|
with open(pending_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(d, f)
|
||||||
|
print('[S99] 已重置重启计数为 0')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[S99] 重置重启计数失败: {e}')
|
||||||
|
" 2>/dev/null || echo "[S99] 无法重置重启计数(可能需要Python支持)"
|
||||||
|
|
||||||
|
echo "[S99] 已恢复 main.py,重启系统..."
|
||||||
|
echo "[S99] 注意:pending 文件将在心跳成功后由 main.py 删除"
|
||||||
|
sleep 2
|
||||||
|
reboot
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 不启动应用,让系统自动启动机制处理
|
||||||
|
# 这个脚本只负责处理致命错误恢复
|
||||||
|
exit 0
|
||||||
|
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 1.0.2
|
version: 1.2.10
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
files:
|
files:
|
||||||
|
- app.yaml
|
||||||
|
- archery_netcore.cpython-311-riscv64-linux-gnu.so
|
||||||
|
- at_client.py
|
||||||
|
- camera_manager.py
|
||||||
|
- config.py
|
||||||
|
- hardware.py
|
||||||
|
- laser_manager.py
|
||||||
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
|
- network.py
|
||||||
|
- ota_manager.py
|
||||||
|
- power.py
|
||||||
|
- shoot_manager.py
|
||||||
|
- shot_id_generator.py
|
||||||
|
- time_sync.py
|
||||||
|
- version.py
|
||||||
|
- vision.cpython-311-riscv64-linux-gnu.so
|
||||||
|
- wifi.py
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
ArUco标记检测模块
|
||||||
|
提供基于ArUco标记的靶心标定和激光点定位功能
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import math
|
||||||
|
import config
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
|
|
||||||
|
class ArUcoDetector:
|
||||||
|
"""ArUco标记检测器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logger_manager.logger
|
||||||
|
# 创建ArUco字典和检测器参数
|
||||||
|
self.aruco_dict = cv2.aruco.getPredefinedDictionary(config.ARUCO_DICT_TYPE)
|
||||||
|
self.detector_params = cv2.aruco.DetectorParameters()
|
||||||
|
|
||||||
|
# 设置检测参数
|
||||||
|
self.detector_params.minMarkerPerimeterRate = config.ARUCO_MIN_MARKER_PERIMETER_RATE
|
||||||
|
self.detector_params.cornerRefinementMethod = config.ARUCO_CORNER_REFINEMENT_METHOD
|
||||||
|
|
||||||
|
# 创建检测器
|
||||||
|
self.detector = cv2.aruco.ArucoDetector(self.aruco_dict, self.detector_params)
|
||||||
|
|
||||||
|
# 预定义靶纸上的标记位置(物理坐标,毫米)
|
||||||
|
self.marker_positions_mm = config.ARUCO_MARKER_POSITIONS_MM
|
||||||
|
self.marker_ids = config.ARUCO_MARKER_IDS
|
||||||
|
self.marker_size_mm = config.ARUCO_MARKER_SIZE_MM
|
||||||
|
self.target_paper_size_mm = config.TARGET_PAPER_SIZE_MM
|
||||||
|
|
||||||
|
# 靶心偏移(相对于靶纸中心)
|
||||||
|
self.target_center_offset_mm = config.TARGET_CENTER_OFFSET_MM
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(f"[ARUCO] ArUco检测器初始化完成,字典类型: {config.ARUCO_DICT_TYPE}")
|
||||||
|
|
||||||
|
def detect_markers(self, frame):
|
||||||
|
"""
|
||||||
|
检测图像中的ArUco标记
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: MaixPy图像帧对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(corners, ids, rejected) - 检测到的标记角点、ID列表、被拒绝的候选
|
||||||
|
如果检测失败返回 (None, None, None)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 转换为OpenCV格式
|
||||||
|
from maix import image
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
# 转换为灰度图(ArUco检测需要)
|
||||||
|
if len(img_cv.shape) == 3:
|
||||||
|
gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
||||||
|
else:
|
||||||
|
gray = img_cv
|
||||||
|
|
||||||
|
# 检测标记
|
||||||
|
corners, ids, rejected = self.detector.detectMarkers(gray)
|
||||||
|
|
||||||
|
if self.logger and ids is not None:
|
||||||
|
self.logger.debug(f"[ARUCO] 检测到 {len(ids)} 个标记: {ids.flatten().tolist()}")
|
||||||
|
|
||||||
|
return corners, ids, rejected
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.error(f"[ARUCO] 标记检测失败: {e}")
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
def get_target_center_from_markers(self, corners, ids):
|
||||||
|
"""
|
||||||
|
从检测到的ArUco标记计算靶心位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
corners: 标记角点列表
|
||||||
|
ids: 标记ID列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(center_x, center_y, radius, ellipse_params) 或 (None, None, None, None)
|
||||||
|
center_x, center_y: 靶心像素坐标
|
||||||
|
radius: 估计的靶心半径(像素)
|
||||||
|
ellipse_params: 椭圆参数用于透视校正
|
||||||
|
"""
|
||||||
|
if ids is None or len(ids) < 3:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.debug(f"[ARUCO] 检测到的标记数量不足: {len(ids) if ids is not None else 0} < 3")
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 将ID转换为列表便于查找
|
||||||
|
detected_ids = ids.flatten().tolist()
|
||||||
|
|
||||||
|
# 收集检测到的标记中心点和对应的物理坐标
|
||||||
|
image_points = [] # 图像坐标 (像素)
|
||||||
|
object_points = [] # 物理坐标 (毫米)
|
||||||
|
marker_centers = {} # 存储每个标记的中心
|
||||||
|
|
||||||
|
for i, marker_id in enumerate(detected_ids):
|
||||||
|
if marker_id not in self.marker_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算标记中心(四个角的平均值)
|
||||||
|
corner = corners[i][0] # shape: (4, 2)
|
||||||
|
center_x = np.mean(corner[:, 0])
|
||||||
|
center_y = np.mean(corner[:, 1])
|
||||||
|
marker_centers[marker_id] = (center_x, center_y)
|
||||||
|
|
||||||
|
# 添加到点列表
|
||||||
|
image_points.append([center_x, center_y])
|
||||||
|
object_points.append(self.marker_positions_mm[marker_id])
|
||||||
|
|
||||||
|
if len(image_points) < 3:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.debug(f"[ARUCO] 有效标记数量不足: {len(image_points)} < 3")
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
# 转换为numpy数组
|
||||||
|
image_points = np.array(image_points, dtype=np.float32)
|
||||||
|
object_points = np.array(object_points, dtype=np.float32)
|
||||||
|
|
||||||
|
# 计算单应性矩阵(Homography)
|
||||||
|
# 这建立了物理坐标到图像坐标的映射
|
||||||
|
H, status = cv2.findHomography(object_points, image_points, cv2.RANSAC, 5.0)
|
||||||
|
|
||||||
|
if H is None:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning("[ARUCO] 无法计算单应性矩阵")
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
# 计算靶心在图像中的位置
|
||||||
|
# 靶心物理坐标 = 靶纸中心 + 偏移
|
||||||
|
target_center_mm = np.array([[self.target_center_offset_mm[0],
|
||||||
|
self.target_center_offset_mm[1]]], dtype=np.float32)
|
||||||
|
target_center_mm = target_center_mm.reshape(-1, 1, 2)
|
||||||
|
|
||||||
|
# 使用单应性矩阵投影到图像坐标
|
||||||
|
target_center_img = cv2.perspectiveTransform(target_center_mm, H)
|
||||||
|
center_x = target_center_img[0][0][0]
|
||||||
|
center_y = target_center_img[0][0][1]
|
||||||
|
|
||||||
|
# 计算靶心半径(像素)
|
||||||
|
# 使用已知物理距离和像素距离的比例
|
||||||
|
# 选择两个标记计算比例尺
|
||||||
|
if len(marker_centers) >= 2:
|
||||||
|
# 使用对角线上的标记计算比例尺
|
||||||
|
if 0 in marker_centers and 2 in marker_centers:
|
||||||
|
p1_img = np.array(marker_centers[0])
|
||||||
|
p2_img = np.array(marker_centers[2])
|
||||||
|
p1_mm = np.array(self.marker_positions_mm[0])
|
||||||
|
p2_mm = np.array(self.marker_positions_mm[2])
|
||||||
|
elif 1 in marker_centers and 3 in marker_centers:
|
||||||
|
p1_img = np.array(marker_centers[1])
|
||||||
|
p2_img = np.array(marker_centers[3])
|
||||||
|
p1_mm = np.array(self.marker_positions_mm[1])
|
||||||
|
p2_mm = np.array(self.marker_positions_mm[3])
|
||||||
|
else:
|
||||||
|
# 使用任意两个标记
|
||||||
|
keys = list(marker_centers.keys())
|
||||||
|
p1_img = np.array(marker_centers[keys[0]])
|
||||||
|
p2_img = np.array(marker_centers[keys[1]])
|
||||||
|
p1_mm = np.array(self.marker_positions_mm[keys[0]])
|
||||||
|
p2_mm = np.array(self.marker_positions_mm[keys[1]])
|
||||||
|
|
||||||
|
pixel_distance = np.linalg.norm(p1_img - p2_img)
|
||||||
|
mm_distance = np.linalg.norm(p1_mm - p2_mm)
|
||||||
|
|
||||||
|
if mm_distance > 0:
|
||||||
|
pixels_per_mm = pixel_distance / mm_distance
|
||||||
|
# 标准靶心半径:10环半径约1.22cm = 12.2mm
|
||||||
|
# 但这里我们返回一个估计值,实际环数计算在laser_manager中
|
||||||
|
radius_mm = 122.0 # 整个靶纸的半径约200mm,但靶心区域较小
|
||||||
|
radius = int(radius_mm * pixels_per_mm)
|
||||||
|
else:
|
||||||
|
radius = 100 # 默认值
|
||||||
|
else:
|
||||||
|
radius = 100 # 默认值
|
||||||
|
|
||||||
|
# 计算椭圆参数(用于透视校正)
|
||||||
|
# 从单应性矩阵可以推导出透视变形
|
||||||
|
ellipse_params = self._compute_ellipse_params(H, center_x, center_y)
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(f"[ARUCO] 靶心计算成功: 中心=({center_x:.1f}, {center_y:.1f}), "
|
||||||
|
f"半径={radius}px, 检测到{len(marker_centers)}个标记")
|
||||||
|
|
||||||
|
return (int(center_x), int(center_y)), radius, "aruco", ellipse_params
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.error(f"[ARUCO] 计算靶心失败: {e}")
|
||||||
|
import traceback
|
||||||
|
self.logger.error(traceback.format_exc())
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
def _compute_ellipse_params(self, H, center_x, center_y):
|
||||||
|
"""
|
||||||
|
从单应性矩阵计算椭圆参数,用于透视校正
|
||||||
|
|
||||||
|
Args:
|
||||||
|
H: 单应性矩阵 (3x3)
|
||||||
|
center_x, center_y: 靶心图像坐标
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ellipse_params: ((center_x, center_y), (width, height), angle)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 在物理坐标系中画一个圆,投影到图像中看变成什么形状
|
||||||
|
# 物理圆:半径10mm
|
||||||
|
r_mm = 10.0
|
||||||
|
angles = np.linspace(0, 2*np.pi, 16)
|
||||||
|
circle_mm = np.array([[self.target_center_offset_mm[0] + r_mm * np.cos(a),
|
||||||
|
self.target_center_offset_mm[1] + r_mm * np.sin(a)]
|
||||||
|
for a in angles], dtype=np.float32)
|
||||||
|
circle_mm = circle_mm.reshape(-1, 1, 2)
|
||||||
|
|
||||||
|
# 投影到图像
|
||||||
|
circle_img = cv2.perspectiveTransform(circle_mm, H)
|
||||||
|
circle_img = circle_img.reshape(-1, 2)
|
||||||
|
|
||||||
|
# 拟合椭圆
|
||||||
|
if len(circle_img) >= 5:
|
||||||
|
ellipse = cv2.fitEllipse(circle_img.astype(np.float32))
|
||||||
|
return ellipse
|
||||||
|
else:
|
||||||
|
# 从单应性矩阵近似估计
|
||||||
|
# 提取缩放和旋转
|
||||||
|
# H = K * [R|t] 的近似
|
||||||
|
# 这里简化处理:假设没有严重变形
|
||||||
|
scale_x = np.linalg.norm(H[0, :2])
|
||||||
|
scale_y = np.linalg.norm(H[1, :2])
|
||||||
|
avg_scale = (scale_x + scale_y) / 2
|
||||||
|
|
||||||
|
width = r_mm * 2 * scale_x
|
||||||
|
height = r_mm * 2 * scale_y
|
||||||
|
angle = np.degrees(np.arctan2(H[1, 0], H[0, 0]))
|
||||||
|
|
||||||
|
return ((center_x, center_y), (width, height), angle)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.debug(f"[ARUCO] 计算椭圆参数失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def transform_laser_point(self, laser_point, corners, ids):
|
||||||
|
"""
|
||||||
|
将激光点从图像坐标转换到物理坐标(毫米),再计算相对于靶心的偏移
|
||||||
|
|
||||||
|
Args:
|
||||||
|
laser_point: (x, y) 激光点在图像中的坐标
|
||||||
|
corners: 检测到的标记角点
|
||||||
|
ids: 检测到的标记ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(dx_mm, dy_mm) 激光点相对于靶心的偏移(毫米),或 (None, None)
|
||||||
|
"""
|
||||||
|
if laser_point is None or ids is None or len(ids) < 3:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 重新计算单应性矩阵(可以优化为缓存)
|
||||||
|
detected_ids = ids.flatten().tolist()
|
||||||
|
image_points = []
|
||||||
|
object_points = []
|
||||||
|
|
||||||
|
for i, marker_id in enumerate(detected_ids):
|
||||||
|
if marker_id not in self.marker_ids:
|
||||||
|
continue
|
||||||
|
corner = corners[i][0]
|
||||||
|
center_x = np.mean(corner[:, 0])
|
||||||
|
center_y = np.mean(corner[:, 1])
|
||||||
|
image_points.append([center_x, center_y])
|
||||||
|
object_points.append(self.marker_positions_mm[marker_id])
|
||||||
|
|
||||||
|
if len(image_points) < 3:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
image_points = np.array(image_points, dtype=np.float32)
|
||||||
|
object_points = np.array(object_points, dtype=np.float32)
|
||||||
|
|
||||||
|
H, _ = cv2.findHomography(object_points, image_points, cv2.RANSAC, 5.0)
|
||||||
|
if H is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 求逆矩阵,将图像坐标转换到物理坐标
|
||||||
|
H_inv = np.linalg.inv(H)
|
||||||
|
|
||||||
|
laser_img = np.array([[laser_point[0], laser_point[1]]], dtype=np.float32)
|
||||||
|
laser_img = laser_img.reshape(-1, 1, 2)
|
||||||
|
|
||||||
|
laser_mm = cv2.perspectiveTransform(laser_img, H_inv)
|
||||||
|
laser_x_mm = laser_mm[0][0][0]
|
||||||
|
laser_y_mm = laser_mm[0][0][1]
|
||||||
|
|
||||||
|
# 计算相对于靶心的偏移
|
||||||
|
# 注意:Y轴方向可能需要翻转(图像Y向下,物理Y通常向上)
|
||||||
|
dx_mm = laser_x_mm - self.target_center_offset_mm[0]
|
||||||
|
dy_mm = -(laser_y_mm - self.target_center_offset_mm[1]) # 翻转Y轴
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.debug(f"[ARUCO] 激光点转换: 图像({laser_point[0]:.1f}, {laser_point[1]:.1f}) -> "
|
||||||
|
f"物理({laser_x_mm:.1f}, {laser_y_mm:.1f}) -> "
|
||||||
|
f"偏移({dx_mm:.1f}, {dy_mm:.1f})mm")
|
||||||
|
|
||||||
|
return dx_mm, dy_mm
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.error(f"[ARUCO] 激光点转换失败: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def draw_debug_info(self, frame, corners, ids, target_center=None, laser_point=None):
|
||||||
|
"""
|
||||||
|
在图像上绘制调试信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: MaixPy图像帧
|
||||||
|
corners: 标记角点
|
||||||
|
ids: 标记ID
|
||||||
|
target_center: 计算的靶心位置
|
||||||
|
laser_point: 激光点位置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
绘制后的图像
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from maix import image
|
||||||
|
img_cv = image.image2cv(frame, False, False).copy()
|
||||||
|
|
||||||
|
# 绘制检测到的标记
|
||||||
|
if ids is not None:
|
||||||
|
cv2.aruco.drawDetectedMarkers(img_cv, corners, ids)
|
||||||
|
|
||||||
|
# 绘制标记ID和中心
|
||||||
|
for i, marker_id in enumerate(ids.flatten()):
|
||||||
|
corner = corners[i][0]
|
||||||
|
center_x = int(np.mean(corner[:, 0]))
|
||||||
|
center_y = int(np.mean(corner[:, 1]))
|
||||||
|
|
||||||
|
# 绘制中心点
|
||||||
|
cv2.circle(img_cv, (center_x, center_y), 5, (0, 255, 0), -1)
|
||||||
|
|
||||||
|
# 绘制ID
|
||||||
|
cv2.putText(img_cv, f"ID:{marker_id}",
|
||||||
|
(center_x + 10, center_y - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
||||||
|
|
||||||
|
# 绘制靶心
|
||||||
|
if target_center:
|
||||||
|
cv2.circle(img_cv, target_center, 8, (255, 0, 0), -1)
|
||||||
|
cv2.circle(img_cv, target_center, 50, (255, 0, 0), 2)
|
||||||
|
cv2.putText(img_cv, "TARGET", (target_center[0] + 15, target_center[1] - 15),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 0, 0), 2)
|
||||||
|
|
||||||
|
# 绘制激光点
|
||||||
|
if laser_point:
|
||||||
|
cv2.circle(img_cv, (int(laser_point[0]), int(laser_point[1])), 6, (0, 0, 255), -1)
|
||||||
|
cv2.putText(img_cv, "LASER", (int(laser_point[0]) + 10, int(laser_point[1]) - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
||||||
|
|
||||||
|
# 转换回MaixPy图像
|
||||||
|
return image.cv2image(img_cv, False, False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.error(f"[ARUCO] 绘制调试信息失败: {e}")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局单例实例
|
||||||
|
aruco_detector = ArUcoDetector()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_target_with_aruco(frame, laser_point=None):
|
||||||
|
"""
|
||||||
|
使用ArUco标记检测靶心的便捷函数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: MaixPy图像帧
|
||||||
|
laser_point: 激光点坐标(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(result_img, center, radius, method, best_radius1, ellipse_params)
|
||||||
|
与detect_circle_v3保持相同的返回格式
|
||||||
|
"""
|
||||||
|
detector = aruco_detector
|
||||||
|
|
||||||
|
# 检测ArUco标记
|
||||||
|
corners, ids, rejected = detector.detect_markers(frame)
|
||||||
|
|
||||||
|
# 计算靶心
|
||||||
|
center, radius, method, ellipse_params = detector.get_target_center_from_markers(corners, ids)
|
||||||
|
|
||||||
|
# 绘制调试信息
|
||||||
|
result_img = detector.draw_debug_info(frame, corners, ids, center, laser_point)
|
||||||
|
|
||||||
|
# 返回与detect_circle_v3相同的格式
|
||||||
|
# best_radius1用于距离估算,这里用radius代替
|
||||||
|
return result_img, center, radius, method, radius, ellipse_params
|
||||||
|
|
||||||
|
|
||||||
|
def compute_laser_offset_aruco(laser_point, corners, ids):
|
||||||
|
"""
|
||||||
|
使用ArUco计算激光点相对于靶心的偏移(毫米)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
laser_point: (x, y) 激光点图像坐标
|
||||||
|
corners: ArUco标记角点
|
||||||
|
ids: ArUco标记ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(dx_mm, dy_mm) 偏移量(毫米),或 (None, None)
|
||||||
|
"""
|
||||||
|
return aruco_detector.transform_laser_point(laser_point, corners, ids)
|
||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
AT客户端模块
|
||||||
|
负责4G模块的AT命令通信和URC解析
|
||||||
|
"""
|
||||||
|
import _thread
|
||||||
|
from maix import time
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
|
||||||
|
class ATClient:
|
||||||
|
"""
|
||||||
|
单读者 AT/URC 客户端:唯一读取 uart4g,避免 tcp_main/at()/OTA 抢读导致 EOF / 丢包。
|
||||||
|
- send(cmd, expect, timeout_ms) : 发送 AT 并等待 expect
|
||||||
|
- pop_tcp_payload() : 获取 +MIPURC:"rtcp" 的 payload(已按长度裁剪)
|
||||||
|
- pop_http_event() : 获取 +MHTTPURC 事件(header/content)
|
||||||
|
"""
|
||||||
|
def __init__(self, uart_obj):
|
||||||
|
self.uart = uart_obj
|
||||||
|
self._cmd_lock = threading.Lock()
|
||||||
|
self._q_lock = threading.Lock()
|
||||||
|
self._rx = b""
|
||||||
|
self._tcp_payloads = []
|
||||||
|
self._http_events = []
|
||||||
|
|
||||||
|
# 当前命令等待状态(仅允许单命令 in-flight)
|
||||||
|
self._waiting = False
|
||||||
|
self._expect = b"OK"
|
||||||
|
self._resp = b""
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
_thread.start_new_thread(self._reader_loop, ())
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
"""清空内部缓存与队列(用于 OTA/异常恢复)"""
|
||||||
|
with self._q_lock:
|
||||||
|
self._rx = b""
|
||||||
|
self._tcp_payloads.clear()
|
||||||
|
self._http_events.clear()
|
||||||
|
self._resp = b""
|
||||||
|
|
||||||
|
def pop_tcp_payload(self):
|
||||||
|
with self._q_lock:
|
||||||
|
if self._tcp_payloads:
|
||||||
|
return self._tcp_payloads.pop(0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def pop_http_event(self):
|
||||||
|
with self._q_lock:
|
||||||
|
if self._http_events:
|
||||||
|
return self._http_events.pop(0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _push_tcp_payload(self, payload: bytes):
|
||||||
|
# 注意:在 _reader_loop 内部解析 URC 时已经持有 _q_lock,
|
||||||
|
# 这里不要再次 acquire(锁不可重入,会死锁)。
|
||||||
|
self._tcp_payloads.append(payload)
|
||||||
|
|
||||||
|
def _push_http_event(self, ev):
|
||||||
|
# 同上:避免在 _reader_loop 持锁期间二次 acquire
|
||||||
|
self._http_events.append(ev)
|
||||||
|
|
||||||
|
def send(self, cmd: str, expect: str = "OK", timeout_ms: int = 2000):
|
||||||
|
"""
|
||||||
|
发送 AT 命令并等待 expect(子串匹配)。
|
||||||
|
注意:expect=">" 用于等待 prompt。
|
||||||
|
"""
|
||||||
|
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||||
|
with self._cmd_lock:
|
||||||
|
# 初始化等待
|
||||||
|
self._waiting = True
|
||||||
|
self._expect = expect_b
|
||||||
|
self._resp = b""
|
||||||
|
|
||||||
|
# 发送
|
||||||
|
if cmd:
|
||||||
|
# 注意:这里不要再用 uart4g_lock(否则外层已经持锁时会死锁)。
|
||||||
|
# 写入由 _cmd_lock 串行化即可。
|
||||||
|
self.uart.write((cmd + "\r\n").encode())
|
||||||
|
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
||||||
|
if (not self._waiting) or (self._expect in self._resp):
|
||||||
|
self._waiting = False
|
||||||
|
break
|
||||||
|
time.sleep_ms(5)
|
||||||
|
|
||||||
|
# 超时也返回已收集内容(便于诊断)
|
||||||
|
self._waiting = False
|
||||||
|
try:
|
||||||
|
return self._resp.decode(errors="ignore")
|
||||||
|
except:
|
||||||
|
return str(self._resp)
|
||||||
|
|
||||||
|
def _find_urc_tag(self, tag: bytes):
|
||||||
|
"""
|
||||||
|
只在"真正的 URC 边界"查找 tag,避免误命中 HTTP payload 内容。
|
||||||
|
规则:tag 必须出现在 buffer 开头,或紧跟在 b"\\r\\n" 后面。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
i = 0
|
||||||
|
rx = self._rx
|
||||||
|
while True:
|
||||||
|
j = rx.find(tag, i)
|
||||||
|
if j < 0:
|
||||||
|
return -1
|
||||||
|
if j == 0:
|
||||||
|
return 0
|
||||||
|
if j >= 2 and rx[j - 2:j] == b"\r\n":
|
||||||
|
return j
|
||||||
|
i = j + 1
|
||||||
|
except:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def _parse_mipurc_rtcp(self):
|
||||||
|
"""
|
||||||
|
解析:+MIPURC: "rtcp",<link_id>,<len>,<payload...>
|
||||||
|
之前硬编码 link_id=0 会导致在多连接/重连场景下收不到数据。
|
||||||
|
"""
|
||||||
|
prefix = b'+MIPURC: "rtcp",'
|
||||||
|
i = self._find_urc_tag(prefix)
|
||||||
|
if i < 0:
|
||||||
|
return False
|
||||||
|
# 丢掉前置噪声
|
||||||
|
if i > 0:
|
||||||
|
self._rx = self._rx[i:]
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
j = len(prefix)
|
||||||
|
# 解析 link_id
|
||||||
|
k = j
|
||||||
|
while k < len(self._rx) and 48 <= self._rx[k] <= 57:
|
||||||
|
k += 1
|
||||||
|
if k == j or k >= len(self._rx):
|
||||||
|
return False
|
||||||
|
if self._rx[k:k+1] != b",":
|
||||||
|
self._rx = self._rx[1:]
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
link_id = int(self._rx[j:k].decode())
|
||||||
|
except:
|
||||||
|
self._rx = self._rx[1:]
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 解析 len
|
||||||
|
j2 = k + 1
|
||||||
|
k2 = j2
|
||||||
|
while k2 < len(self._rx) and 48 <= self._rx[k2] <= 57:
|
||||||
|
k2 += 1
|
||||||
|
if k2 == j2 or k2 >= len(self._rx):
|
||||||
|
return False
|
||||||
|
if self._rx[k2:k2+1] != b",":
|
||||||
|
self._rx = self._rx[1:]
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
n = int(self._rx[j2:k2].decode())
|
||||||
|
except:
|
||||||
|
self._rx = self._rx[1:]
|
||||||
|
return True
|
||||||
|
|
||||||
|
payload_start = k2 + 1
|
||||||
|
payload_end = payload_start + n
|
||||||
|
if len(self._rx) < payload_end:
|
||||||
|
return False # payload 未收齐
|
||||||
|
|
||||||
|
payload = self._rx[payload_start:payload_end]
|
||||||
|
# 把 link_id 一起带上,便于上层过滤(如果需要)
|
||||||
|
self._push_tcp_payload((link_id, payload))
|
||||||
|
self._rx = self._rx[payload_end:]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _parse_mhttpurc_header(self):
|
||||||
|
tag = b'+MHTTPURC: "header",'
|
||||||
|
i = self._find_urc_tag(tag)
|
||||||
|
if i < 0:
|
||||||
|
return False
|
||||||
|
if i > 0:
|
||||||
|
self._rx = self._rx[i:]
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
# header: +MHTTPURC: "header",<id>,<code>,<hdr_len>,<hdr_text...>
|
||||||
|
j = len(tag)
|
||||||
|
comma_count = 0
|
||||||
|
k = j
|
||||||
|
while k < len(self._rx) and comma_count < 3:
|
||||||
|
if self._rx[k:k+1] == b",":
|
||||||
|
comma_count += 1
|
||||||
|
k += 1
|
||||||
|
if comma_count < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
prefix = self._rx[:k]
|
||||||
|
m = re.search(rb'\+MHTTPURC: "header",\s*(\d+),\s*(\d+),\s*(\d+),', prefix)
|
||||||
|
if not m:
|
||||||
|
self._rx = self._rx[1:]
|
||||||
|
return True
|
||||||
|
urc_id = int(m.group(1))
|
||||||
|
code = int(m.group(2))
|
||||||
|
hdr_len = int(m.group(3))
|
||||||
|
|
||||||
|
text_start = k
|
||||||
|
text_end = text_start + hdr_len
|
||||||
|
if len(self._rx) < text_end:
|
||||||
|
return False
|
||||||
|
|
||||||
|
hdr_text = self._rx[text_start:text_end].decode("utf-8", "ignore")
|
||||||
|
self._push_http_event(("header", urc_id, code, hdr_text))
|
||||||
|
self._rx = self._rx[text_end:]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _parse_mhttpurc_content(self):
|
||||||
|
tag = b'+MHTTPURC: "content",'
|
||||||
|
i = self._find_urc_tag(tag)
|
||||||
|
if i < 0:
|
||||||
|
return False
|
||||||
|
if i > 0:
|
||||||
|
self._rx = self._rx[i:]
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
# content: +MHTTPURC: "content",<id>,<total>,<sum>,<cur>,<payload...>
|
||||||
|
j = len(tag)
|
||||||
|
comma_count = 0
|
||||||
|
k = j
|
||||||
|
while k < len(self._rx) and comma_count < 4:
|
||||||
|
if self._rx[k:k+1] == b",":
|
||||||
|
comma_count += 1
|
||||||
|
k += 1
|
||||||
|
if comma_count < 4:
|
||||||
|
return False
|
||||||
|
|
||||||
|
prefix = self._rx[:k]
|
||||||
|
m = re.search(rb'\+MHTTPURC: "content",\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+),', prefix)
|
||||||
|
if not m:
|
||||||
|
self._rx = self._rx[1:]
|
||||||
|
return True
|
||||||
|
urc_id = int(m.group(1))
|
||||||
|
total_len = int(m.group(2))
|
||||||
|
sum_len = int(m.group(3))
|
||||||
|
cur_len = int(m.group(4))
|
||||||
|
|
||||||
|
payload_start = k
|
||||||
|
payload_end = payload_start + cur_len
|
||||||
|
if len(self._rx) < payload_end:
|
||||||
|
return False
|
||||||
|
|
||||||
|
payload = self._rx[payload_start:payload_end]
|
||||||
|
self._push_http_event(("content", urc_id, total_len, sum_len, cur_len, payload))
|
||||||
|
self._rx = self._rx[payload_end:]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _reader_loop(self):
|
||||||
|
while self._running:
|
||||||
|
# 关键:UART 驱动偶发 read failed,必须兜住,否则线程挂了 OTA/TCP 都会卡死
|
||||||
|
try:
|
||||||
|
d = self.uart.read(4096) # 8192 在一些驱动上更容易触发 read failed
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
print("[ATClient] uart read failed:", e)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep_ms(50)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not d:
|
||||||
|
time.sleep_ms(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with self._q_lock:
|
||||||
|
self._rx += d
|
||||||
|
if self._waiting:
|
||||||
|
self._resp += d
|
||||||
|
|
||||||
|
while True:
|
||||||
|
progressed = (
|
||||||
|
self._parse_mipurc_rtcp()
|
||||||
|
or self._parse_mhttpurc_header()
|
||||||
|
or self._parse_mhttpurc_content()
|
||||||
|
)
|
||||||
|
if not progressed:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 使用 ota_manager 访问 ota_in_progress
|
||||||
|
try:
|
||||||
|
from ota_manager import ota_manager
|
||||||
|
ota_flag = ota_manager.ota_in_progress
|
||||||
|
except:
|
||||||
|
ota_flag = False
|
||||||
|
|
||||||
|
has_http_hint = (b"+MHTTP" in self._rx) or (b"+MHTTPURC" in self._rx)
|
||||||
|
if ota_flag or has_http_hint:
|
||||||
|
if len(self._rx) > 512 * 1024:
|
||||||
|
self._rx = self._rx[-256 * 1024:]
|
||||||
|
else:
|
||||||
|
if len(self._rx) > 16384:
|
||||||
|
self._rx = self._rx[-4096:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
相机管理器模块
|
||||||
|
提供相机和显示的统一管理和线程安全访问
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
import config
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
|
|
||||||
|
class CameraManager:
|
||||||
|
"""相机管理器(单例)"""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(CameraManager, cls).__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 私有对象
|
||||||
|
self._camera = None
|
||||||
|
self._display = None
|
||||||
|
|
||||||
|
# 线程安全锁
|
||||||
|
self._camera_lock = threading.Lock()
|
||||||
|
self._display_lock = threading.Lock()
|
||||||
|
|
||||||
|
# 相机配置
|
||||||
|
self._camera_width = 640
|
||||||
|
self._camera_height = 480
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
# ==================== 初始化方法 ====================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logger(self):
|
||||||
|
"""获取 logger 对象"""
|
||||||
|
return logger_manager.logger
|
||||||
|
|
||||||
|
def init_camera(self, width=640, height=480):
|
||||||
|
"""初始化相机"""
|
||||||
|
if self._camera is not None:
|
||||||
|
return self._camera
|
||||||
|
|
||||||
|
from maix import camera
|
||||||
|
|
||||||
|
self._camera_width = width
|
||||||
|
self._camera_height = height
|
||||||
|
|
||||||
|
with self._camera_lock:
|
||||||
|
if self._camera is None:
|
||||||
|
self._camera = camera.Camera(width, height)
|
||||||
|
|
||||||
|
return self._camera
|
||||||
|
|
||||||
|
def init_display(self):
|
||||||
|
"""初始化显示"""
|
||||||
|
if self._display is not None:
|
||||||
|
return self._display
|
||||||
|
|
||||||
|
from maix import display
|
||||||
|
|
||||||
|
with self._display_lock:
|
||||||
|
if self._display is None:
|
||||||
|
self._display = display.Display()
|
||||||
|
|
||||||
|
return self._display
|
||||||
|
|
||||||
|
# ==================== 访问方法 ====================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def camera(self):
|
||||||
|
"""获取相机实例(懒加载)"""
|
||||||
|
if self._camera is None:
|
||||||
|
self.init_camera()
|
||||||
|
return self._camera
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display(self):
|
||||||
|
"""获取显示实例(懒加载)"""
|
||||||
|
if self._display is None:
|
||||||
|
self.init_display()
|
||||||
|
return self._display
|
||||||
|
|
||||||
|
# ==================== 业务方法 ====================
|
||||||
|
|
||||||
|
def read_frame(self):
|
||||||
|
"""
|
||||||
|
线程安全地读取一帧图像
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
frame: 图像帧对象
|
||||||
|
"""
|
||||||
|
with self._camera_lock:
|
||||||
|
if self._camera is None:
|
||||||
|
self.init_camera()
|
||||||
|
return self._camera.read()
|
||||||
|
|
||||||
|
def show(self, image):
|
||||||
|
"""
|
||||||
|
线程安全地显示图像
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: 要显示的图像对象
|
||||||
|
"""
|
||||||
|
with self._display_lock:
|
||||||
|
if self._display is None:
|
||||||
|
self.init_display()
|
||||||
|
self._display.show(image)
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
"""释放相机和显示资源(如果需要)"""
|
||||||
|
with self._camera_lock:
|
||||||
|
if self._camera is not None:
|
||||||
|
# MaixPy 的 Camera 可能不需要显式释放,但可以在这里清理
|
||||||
|
self._camera = None
|
||||||
|
|
||||||
|
with self._display_lock:
|
||||||
|
if self._display is not None:
|
||||||
|
# MaixPy 的 Display 可能不需要显式释放
|
||||||
|
self._display = None
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局单例实例
|
||||||
|
camera_manager = CameraManager()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
系统配置常量
|
||||||
|
这些值在程序运行期间基本不变,或只在配置时改变
|
||||||
|
"""
|
||||||
|
from version import VERSION
|
||||||
|
|
||||||
|
# ==================== 应用配置 ====================
|
||||||
|
APP_VERSION = VERSION
|
||||||
|
APP_DIR = "/maixapp/apps/t11"
|
||||||
|
LOCAL_FILENAME = "/maixapp/apps/t11/main_tmp.py"
|
||||||
|
|
||||||
|
# ==================== 服务器配置 ====================
|
||||||
|
# SERVER_IP = "stcp.shelingxingqiu.com"
|
||||||
|
SERVER_IP = "www.shelingxingqiu.com"
|
||||||
|
SERVER_PORT = 50005
|
||||||
|
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
|
||||||
|
|
||||||
|
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
||||||
|
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
||||||
|
WIFI_QUALITY_RTT_BAD_MS = 600.0 # 中位数超过此值认为延迟过高
|
||||||
|
WIFI_QUALITY_RTT_WARN_MS = 350.0 # 与 RSSI 联合:超过此值且信号弱也判为差
|
||||||
|
WIFI_QUALITY_RSSI_BAD_DBM = -80.0 # 低于此 dBm(更负更差)视为信号弱
|
||||||
|
WIFI_QUALITY_USE_RSSI = True # 是否把 RSSI 纳入综合判定(False 则仅看 RTT)
|
||||||
|
|
||||||
|
# ===== TCP over SSL(TLS) 配置 =====
|
||||||
|
USE_TCP_SSL = False # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL
|
||||||
|
TCP_LINK_ID = 2 #
|
||||||
|
TCP_SSL_PORT = 443 # TLS 端口(不一定必须 443,以服务器为准)
|
||||||
|
|
||||||
|
# SSL profile
|
||||||
|
SSL_ID = 1 # ssl_id=1
|
||||||
|
SSL_AUTH_MODE = 0 # 1=单向认证(验证服务器),2=双向
|
||||||
|
SSL_VERIFY_MODE = 1 # 0=不验(仅测试用);1=写入并使用 CA 证书
|
||||||
|
|
||||||
|
SSL_CERT_FILENAME = "www.shelingxingqiu.com.crt" # 模组里证书名(MSSLCERTWR / MSSLCFG="cert" 用)
|
||||||
|
SSL_CERT_PATH = "/root/www.shelingxingqiu.com.crt" # 设备文件系统里 CA 证书路径(你自己放进去)
|
||||||
|
# MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留
|
||||||
|
MIPOPEN_TAIL = ",,0"
|
||||||
|
|
||||||
|
# ==================== 文件路径配置 ====================
|
||||||
|
CONFIG_FILE = "/root/laser_config.json"
|
||||||
|
LOG_FILE = "/maixapp/apps/t11/app.log"
|
||||||
|
BACKUP_BASE = "/maixapp/apps/t11/backups"
|
||||||
|
|
||||||
|
# ==================== 硬件配置 ====================
|
||||||
|
# WiFi模块开关(True=有WiFi模块,False=无WiFi模块)
|
||||||
|
HAS_WIFI_MODULE = True # 根据实际硬件情况设置
|
||||||
|
|
||||||
|
# UART配置
|
||||||
|
UART4G_DEVICE = "/dev/ttyS2"
|
||||||
|
UART4G_BAUDRATE = 115200
|
||||||
|
DISTANCE_SERIAL_DEVICE = "/dev/ttyS1"
|
||||||
|
DISTANCE_SERIAL_BAUDRATE = 9600
|
||||||
|
|
||||||
|
# I2C配置(根据WiFi模块开关自动选择)
|
||||||
|
# 无WiFi模块:I2C_BUS_NUM = 1,引脚:P18(I2C1_SCL), P21(I2C1_SDA)
|
||||||
|
# 有WiFi模块:I2C_BUS_NUM = 5,引脚:A15(I2C5_SCL), A27(I2C5_SDA)
|
||||||
|
I2C_BUS_NUM = 5 if HAS_WIFI_MODULE else 1
|
||||||
|
|
||||||
|
INA226_ADDR = 0x40
|
||||||
|
REG_CONFIGURATION = 0x00
|
||||||
|
REG_BUS_VOLTAGE = 0x02
|
||||||
|
REG_CURRENT = 0x04 # 电流寄存器
|
||||||
|
REG_CALIBRATION = 0x05
|
||||||
|
CALIBRATION_VALUE = 0x1400
|
||||||
|
|
||||||
|
# ==================== 空气传感器配置 ====================
|
||||||
|
ADC_TRIGGER_THRESHOLD = 2700 # TODO:4096只是用于测试,因为最大值是4095,这个值是永远不会触发的,最终需要改为正常值
|
||||||
|
AIR_PRESSURE_lOG = False # TODO: 在正式环境中关闭
|
||||||
|
AIR_PRESSURE_HARDWARE_MAX = 10
|
||||||
|
# ADC配置
|
||||||
|
ADC_CHANNEL = 0
|
||||||
|
ADC_LASER_THRESHOLD = 3000
|
||||||
|
|
||||||
|
# ==================== 激光配置 ====================
|
||||||
|
MODULE_ADDR = 0x00
|
||||||
|
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
||||||
|
LASER_OFF_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
||||||
|
DISTANCE_QUERY_CMD = bytes([0xAA, MODULE_ADDR, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x21]) # 激光测距查询命令
|
||||||
|
DISTANCE_RESPONSE_LEN = 13 # 激光测距响应数据长度(字节)
|
||||||
|
DEFAULT_LASER_POINT = (320, 245) # 默认激光中心点
|
||||||
|
|
||||||
|
# 硬编码激光点配置
|
||||||
|
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值)
|
||||||
|
HARDCODE_LASER_POINT_VALUE = (320, 245) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y)
|
||||||
|
|
||||||
|
# 激光点检测配置
|
||||||
|
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150)
|
||||||
|
LASER_RED_RATIO = 1.5 # 红色相对于绿色/蓝色的倍数要求(默认1.5,可调整,范围建议:1.3-2.0)
|
||||||
|
LASER_SEARCH_RADIUS = 50 # 搜索半径(像素),从图像中心开始搜索(默认20,限制激光点不能偏离中心太远)
|
||||||
|
LASER_MAX_DISTANCE_FROM_CENTER = 50 # 激光点距离中心的最大允许距离(像素),超过此距离则拒绝(默认20)
|
||||||
|
LASER_OVEREXPOSED_THRESHOLD = 200 # 过曝红色判断阈值(默认200,接近白色时的阈值)
|
||||||
|
LASER_OVEREXPOSED_DIFF = 10 # 过曝红色时,r 与 g/b 的最小差值(默认10)
|
||||||
|
LASER_REQUIRE_IN_ELLIPSE = False # 是否要求激光点必须在黄心椭圆内(True=必须,False=不要求)
|
||||||
|
LASER_USE_ELLIPSE_FITTING = True # 是否使用椭圆拟合方法查找激光点(True=椭圆拟合更准确,False=最亮点方法)
|
||||||
|
LASER_MIN_AREA = 5 # 激光点区域的最小面积(像素),小于此值认为是噪声(默认5)
|
||||||
|
LASER_DRAW_ELLIPSE = True # 是否在图像上绘制激光点的拟合椭圆(True=绘制,False=不绘制)
|
||||||
|
|
||||||
|
# ==================== 视觉检测配置 ====================
|
||||||
|
FOCAL_LENGTH_PIX = 2250.0 # 焦距(像素)
|
||||||
|
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
||||||
|
|
||||||
|
# 图像清晰度检测配置
|
||||||
|
IMAGE_SHARPNESS_THRESHOLD = 100.0 # 清晰度阈值,低于此值认为图像模糊
|
||||||
|
# 清晰图像通常 > 200,模糊图像通常 < 100
|
||||||
|
|
||||||
|
# 激光与摄像头物理位置配置
|
||||||
|
LASER_CAMERA_OFFSET_CM = 1.4 # 激光在摄像头下方的物理距离(厘米),正值表示激光在摄像头下方
|
||||||
|
IMAGE_CENTER_X = 320 # 图像中心 X 坐标
|
||||||
|
IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
|
||||||
|
|
||||||
|
FLASH_LASER_WHILE_SHOOTING = True # 是否在拍摄时闪一下激光(True=闪,False=不闪)
|
||||||
|
FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒)
|
||||||
|
|
||||||
|
# ==================== 显示配置 ====================
|
||||||
|
LASER_COLOR = (0, 255, 0) # RGB颜色
|
||||||
|
LASER_THICKNESS = 1
|
||||||
|
LASER_LENGTH = 2
|
||||||
|
|
||||||
|
# ==================== 图像保存配置 ====================
|
||||||
|
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
||||||
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
|
MAX_IMAGES = 1000
|
||||||
|
|
||||||
|
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
||||||
|
|
||||||
|
# ==================== OTA配置 ====================
|
||||||
|
MAX_BACKUPS = 5
|
||||||
|
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
||||||
|
LOG_BACKUP_COUNT = 5
|
||||||
|
|
||||||
|
# ==================== 引脚映射配置 ====================
|
||||||
|
# 无WiFi模块的引脚映射(I2C1)
|
||||||
|
PIN_MAPPINGS_NO_WIFI = {
|
||||||
|
"A18": "UART1_RX",
|
||||||
|
"A19": "UART1_TX",
|
||||||
|
"A29": "UART2_RX",
|
||||||
|
"A28": "UART2_TX",
|
||||||
|
"P18": "I2C1_SCL",
|
||||||
|
"P21": "I2C1_SDA",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 有WiFi模块的引脚映射(I2C5)
|
||||||
|
PIN_MAPPINGS_WITH_WIFI = {
|
||||||
|
"A18": "UART1_RX",
|
||||||
|
"A19": "UART1_TX",
|
||||||
|
"A29": "UART2_RX",
|
||||||
|
"A28": "UART2_TX",
|
||||||
|
"A15": "I2C5_SCL",
|
||||||
|
"A27": "I2C5_SDA",
|
||||||
|
"A24": "GPIOA24", # 电源板的引脚
|
||||||
|
}
|
||||||
|
|
||||||
|
# 根据WiFi模块开关选择引脚映射
|
||||||
|
PIN_MAPPINGS = PIN_MAPPINGS_WITH_WIFI if HAS_WIFI_MODULE else PIN_MAPPINGS_NO_WIFI
|
||||||
|
|
||||||
|
# ==================== ArUco标定配置 ====================
|
||||||
|
USE_ARUCO = False # 是否使用ArUco标定(True=使用ArUco,False=使用传统黄色靶心检测)
|
||||||
|
|
||||||
|
# ArUco标记配置
|
||||||
|
if USE_ARUCO:
|
||||||
|
import cv2
|
||||||
|
ARUCO_DICT_TYPE = cv2.aruco.DICT_4X4_50 # ArUco字典类型
|
||||||
|
ARUCO_MARKER_SIZE_MM = 40 # ArUco标记边长(毫米)
|
||||||
|
ARUCO_MARKER_IDS = [0, 1, 2, 3] # 四个角的ArUco标记ID
|
||||||
|
|
||||||
|
# 靶纸物理尺寸(毫米)
|
||||||
|
TARGET_PAPER_SIZE_MM = 400 # 靶纸边长 400mm x 400mm
|
||||||
|
|
||||||
|
# ArUco标记在靶纸上的中心坐标(毫米,以靶纸中心为原点)
|
||||||
|
# 靶纸坐标系:中心(0,0),X向右,Y向下(图像坐标系)
|
||||||
|
# 四个角位置:(20,20), (20,380), (380,380), (380,20)
|
||||||
|
# 转换为以中心为原点的坐标:
|
||||||
|
# 左上角(0): (-180, -180) -> 实际(20,20)相对于中心(200,200) = (-180,-180)
|
||||||
|
# 右上角(1): (180, -180) -> 实际(380,20)相对于中心 = (180,-180)
|
||||||
|
# 右下角(2): (180, 180) -> 实际(380,380)相对于中心 = (180,180)
|
||||||
|
# 左下角(3): (-180, 180) -> 实际(20,380)相对于中心 = (-180,180)
|
||||||
|
ARUCO_MARKER_POSITIONS_MM = {
|
||||||
|
0: (-180, -180), # 左上角
|
||||||
|
1: (180, -180), # 右上角
|
||||||
|
2: (180, 180), # 右下角
|
||||||
|
3: (-180, 180), # 左下角
|
||||||
|
}
|
||||||
|
|
||||||
|
# 靶心(黄心)在靶纸上的位置(毫米,相对于靶纸中心)
|
||||||
|
# 标准靶纸靶心就在正中心
|
||||||
|
TARGET_CENTER_OFFSET_MM = (0, 0)
|
||||||
|
|
||||||
|
# ArUco检测参数
|
||||||
|
ARUCO_MIN_MARKER_PERIMETER_RATE = 0.03 # 最小标记周长比例(相对于图像)
|
||||||
|
ARUCO_CORNER_REFINEMENT_METHOD = cv2.aruco.CORNER_REFINE_SUBPIX # 角点精修方法
|
||||||
|
|
||||||
|
# ==================== 电源配置 ====================
|
||||||
|
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(archery_netcore CXX)
|
||||||
|
|
||||||
|
set(CMAKE_SYSTEM_NAME Linux)
|
||||||
|
set(CMAKE_SYSTEM_PROCESSOR riscv64)
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||||
|
|
||||||
|
if(NOT DEFINED PY_INCLUDE_DIR)
|
||||||
|
message(FATAL_ERROR "PY_INCLUDE_DIR not set")
|
||||||
|
endif()
|
||||||
|
if(NOT DEFINED PY_LIB)
|
||||||
|
message(FATAL_ERROR "PY_LIB not set")
|
||||||
|
endif()
|
||||||
|
if(NOT DEFINED PY_EXT_SUFFIX)
|
||||||
|
message(FATAL_ERROR "PY_EXT_SUFFIX not set")
|
||||||
|
endif()
|
||||||
|
if(NOT DEFINED MAIXCDK_PATH)
|
||||||
|
message(FATAL_ERROR "MAIXCDK_PATH not set (need components/3rd_party/pybind11)")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_library(archery_netcore MODULE
|
||||||
|
archery_netcore.cpp
|
||||||
|
native_logger.cpp
|
||||||
|
utils.cpp
|
||||||
|
decrypt_ota_file.cpp
|
||||||
|
msg_handler.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(archery_netcore PRIVATE
|
||||||
|
"${PY_INCLUDE_DIR}"
|
||||||
|
"${MAIXCDK_PATH}/components/3rd_party/pybind11/pybind11/include"
|
||||||
|
"${MAIXCDK_PATH}/components/3rd_party/openssl/include"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/third_party" # 添加 nlohmann/json 路径
|
||||||
|
)
|
||||||
|
|
||||||
|
# 尽量减少 .so 体积并增加逆向成本
|
||||||
|
target_compile_options(archery_netcore PRIVATE
|
||||||
|
-Os
|
||||||
|
-ffunction-sections
|
||||||
|
-fdata-sections
|
||||||
|
-fvisibility=hidden
|
||||||
|
-fvisibility-inlines-hidden
|
||||||
|
)
|
||||||
|
target_link_options(archery_netcore PRIVATE
|
||||||
|
-Wl,--gc-sections
|
||||||
|
-Wl,-s
|
||||||
|
)
|
||||||
|
|
||||||
|
set_target_properties(archery_netcore PROPERTIES
|
||||||
|
PREFIX ""
|
||||||
|
SUFFIX "${PY_EXT_SUFFIX}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OpenSSL (for AES-256-GCM decrypt)
|
||||||
|
# 使用 MaixCDK 提供的 OpenSSL 库(在 so/maixcam 目录下)
|
||||||
|
set(OPENSSL_LIB_DIR "${MAIXCDK_PATH}/components/3rd_party/openssl/so/maixcam")
|
||||||
|
if(EXISTS "${OPENSSL_LIB_DIR}/libcrypto.so")
|
||||||
|
target_link_directories(archery_netcore PRIVATE "${OPENSSL_LIB_DIR}")
|
||||||
|
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" crypto ssl)
|
||||||
|
message(STATUS "Using OpenSSL from MaixCDK: ${OPENSSL_LIB_DIR}")
|
||||||
|
else()
|
||||||
|
# Fallback: 尝试 find_package 或系统库
|
||||||
|
find_package(OpenSSL QUIET)
|
||||||
|
if(OpenSSL_FOUND)
|
||||||
|
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" OpenSSL::Crypto OpenSSL::SSL)
|
||||||
|
else()
|
||||||
|
message(WARNING "OpenSSL not found in MaixCDK, trying system libraries (may fail)")
|
||||||
|
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" crypto ssl)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#include <pybind11/pybind11.h>
|
||||||
|
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
#include <fstream>
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
#include "msg_handler.hpp"
|
||||||
|
#include "native_logger.hpp"
|
||||||
|
#include "decrypt_ota_file.hpp"
|
||||||
|
#include "utils.hpp"
|
||||||
|
|
||||||
|
namespace py = pybind11;
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 配置项
|
||||||
|
const std::string _cfg_server_ip = "www.shelingxingqiu.com";
|
||||||
|
const int _cfg_server_port = 50005;
|
||||||
|
const std::string _cfg_device_id_file = "/device_key";
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义获取配置的函数
|
||||||
|
py::dict get_config() {
|
||||||
|
py::dict config;
|
||||||
|
config["SERVER_IP"] = _cfg_server_ip;
|
||||||
|
config["SERVER_PORT"] = _cfg_server_port;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
PYBIND11_MODULE(archery_netcore, m) {
|
||||||
|
m.doc() = "Archery net core (native, pybind11).";
|
||||||
|
|
||||||
|
// Optional: configure native logger from Python.
|
||||||
|
// Default log file: /maixapp/apps/t11/netcore.log
|
||||||
|
m.def("set_log_file", [](const std::string& path) { netcore::set_log_file(path); }, py::arg("path"));
|
||||||
|
m.def("set_log_level", [](int level) {
|
||||||
|
if (level < 0) level = 0;
|
||||||
|
if (level > 3) level = 3;
|
||||||
|
netcore::set_log_level(static_cast<netcore::LogLevel>(level));
|
||||||
|
}, py::arg("level"));
|
||||||
|
m.def("log_test", [](const std::string& msg) {
|
||||||
|
netcore::log_info(std::string("log_test: ") + msg);
|
||||||
|
}, py::arg("msg"));
|
||||||
|
|
||||||
|
m.def("make_packet", &netcore::make_packet,
|
||||||
|
"Pack TCP packet: header (len+type+checksum) + JSON body",
|
||||||
|
py::arg("msg_type"), py::arg("body_dict"));
|
||||||
|
|
||||||
|
m.def("parse_packet", &netcore::parse_packet,
|
||||||
|
"Parse TCP packet, return (msg_type, body_dict)");
|
||||||
|
|
||||||
|
m.def("get_config", &get_config, "Get system configuration");
|
||||||
|
|
||||||
|
m.def(
|
||||||
|
"decrypt_ota_file",
|
||||||
|
[](const std::string& input_path, const std::string& output_zip_path) {
|
||||||
|
netcore::log_info(std::string("decrypt_ota_file in=") + input_path + " out=" + output_zip_path);
|
||||||
|
return netcore::decrypt_ota_file_impl(input_path, output_zip_path);
|
||||||
|
},
|
||||||
|
py::arg("input_path"),
|
||||||
|
py::arg("output_zip_path"),
|
||||||
|
"Decrypt OTA encrypted file (MAGIC|nonce|ciphertext|tag) to plaintext zip."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Minimal demo: return actions for inner_cmd=41 (manual trigger + ack)
|
||||||
|
m.def("actions_for_inner_cmd", [](int inner_cmd) {
|
||||||
|
py::list actions;
|
||||||
|
|
||||||
|
if (inner_cmd == 41) {
|
||||||
|
// 1) set manual trigger flag
|
||||||
|
{
|
||||||
|
py::dict a;
|
||||||
|
a["type"] = "SET_FLAG";
|
||||||
|
py::dict args;
|
||||||
|
args["name"] = "manual_trigger_flag";
|
||||||
|
args["value"] = true;
|
||||||
|
a["args"] = args;
|
||||||
|
actions.append(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) enqueue trigger_ack
|
||||||
|
{
|
||||||
|
py::dict a;
|
||||||
|
a["type"] = "ENQUEUE";
|
||||||
|
py::dict args;
|
||||||
|
args["msg_type"] = 2;
|
||||||
|
args["high"] = false;
|
||||||
|
py::dict body;
|
||||||
|
body["result"] = "trigger_ack";
|
||||||
|
args["body"] = body;
|
||||||
|
a["args"] = args;
|
||||||
|
actions.append(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#include <pybind11/pybind11.h>
|
||||||
|
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
#include <fstream>
|
||||||
|
#include <array>
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include "native_logger.hpp"
|
||||||
|
|
||||||
|
namespace netcore{
|
||||||
|
|
||||||
|
// OTA AEAD format: MAGIC(7) | nonce(12) | ciphertext(N) | tag(16)
|
||||||
|
constexpr const char* kOtaMagic = "AROTAE1";
|
||||||
|
constexpr size_t kOtaMagicLen = 7;
|
||||||
|
constexpr size_t kGcmNonceLen = 12;
|
||||||
|
constexpr size_t kGcmTagLen = 16;
|
||||||
|
|
||||||
|
// 固定 32-byte AES-256-GCM key(提高被直接查看的成本;不是绝对安全)
|
||||||
|
// 注意:需要与打包端传入的 --aead-key-hex 保持一致。
|
||||||
|
static std::array<uint8_t, 32> ota_key_bytes() {
|
||||||
|
// 简单拆分混淆:key = a XOR b
|
||||||
|
static const std::array<uint8_t, 32> a = {
|
||||||
|
0x92,0x99,0x4d,0x06,0x6f,0xb6,0xa6,0x3d,0x85,0x08,0xbe,0x73,0x5e,0x73,0x4d,0x8a,
|
||||||
|
0x53,0x88,0xe6,0x99,0xfc,0x10,0x29,0xb9,0x16,0x9b,0xe7,0x0c,0x65,0x21,0x1c,0xce
|
||||||
|
};
|
||||||
|
static const std::array<uint8_t, 32> b = {
|
||||||
|
0xcf,0x60,0xa2,0xc2,0x32,0x7a,0x61,0xb0,0x4c,0x8e,0x8a,0x62,0x31,0xc7,0x82,0xff,
|
||||||
|
0xec,0xac,0xa1,0x04,0x2a,0x4d,0xaa,0xf2,0xb0,0x5b,0x39,0x2b,0xf4,0xb3,0xad,0xad
|
||||||
|
};
|
||||||
|
std::array<uint8_t, 32> k{};
|
||||||
|
for (size_t i = 0; i < k.size(); i++) k[i] = static_cast<uint8_t>(a[i] ^ b[i]);
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool read_file_all(const std::string& path, std::vector<uint8_t>& out) {
|
||||||
|
std::ifstream ifs(path, std::ios::binary);
|
||||||
|
if (!ifs) return false;
|
||||||
|
ifs.seekg(0, std::ios::end);
|
||||||
|
std::streampos size = ifs.tellg();
|
||||||
|
if (size <= 0) return false;
|
||||||
|
ifs.seekg(0, std::ios::beg);
|
||||||
|
out.resize(static_cast<size_t>(size));
|
||||||
|
if (!ifs.read(reinterpret_cast<char*>(out.data()), size)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool write_file_all(const std::string& path, const uint8_t* data, size_t len) {
|
||||||
|
std::ofstream ofs(path, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!ofs) return false;
|
||||||
|
ofs.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(len));
|
||||||
|
return static_cast<bool>(ofs);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path) {
|
||||||
|
std::vector<uint8_t> in;
|
||||||
|
if (!netcore::read_file_all(input_path, in)) {
|
||||||
|
netcore::log_error(std::string("decrypt_ota_file: read failed: ") + input_path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t min_len = kOtaMagicLen + kGcmNonceLen + kGcmTagLen + 1;
|
||||||
|
if (in.size() < min_len) {
|
||||||
|
netcore::log_error("decrypt_ota_file: too short");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!std::equal(in.begin(), in.begin() + kOtaMagicLen, reinterpret_cast<const uint8_t*>(kOtaMagic))) {
|
||||||
|
netcore::log_error("decrypt_ota_file: bad magic");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t* nonce = in.data() + kOtaMagicLen;
|
||||||
|
const uint8_t* ct_and_tag = in.data() + kOtaMagicLen + kGcmNonceLen;
|
||||||
|
const size_t ct_and_tag_len = in.size() - (kOtaMagicLen + kGcmNonceLen);
|
||||||
|
if (ct_and_tag_len <= kGcmTagLen) {
|
||||||
|
netcore::log_error("decrypt_ota_file: no ciphertext");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const size_t ciphertext_len = ct_and_tag_len - kGcmTagLen;
|
||||||
|
const uint8_t* ciphertext = ct_and_tag;
|
||||||
|
const uint8_t* tag = ct_and_tag + ciphertext_len;
|
||||||
|
|
||||||
|
std::vector<uint8_t> plain(ciphertext_len);
|
||||||
|
int out_len1 = 0;
|
||||||
|
int out_len2 = 0;
|
||||||
|
|
||||||
|
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
|
||||||
|
if (!ctx) {
|
||||||
|
netcore::log_error("decrypt_ota_file: EVP_CIPHER_CTX_new failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
auto key = ota_key_bytes();
|
||||||
|
|
||||||
|
do {
|
||||||
|
if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: DecryptInit failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast<int>(kGcmNonceLen), nullptr)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: set ivlen failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (1 != EVP_DecryptInit_ex(ctx, nullptr, nullptr, key.data(), nonce)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: set key/iv failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (1 != EVP_DecryptUpdate(ctx, plain.data(), &out_len1, ciphertext, static_cast<int>(ciphertext_len))) {
|
||||||
|
netcore::log_error("decrypt_ota_file: update failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(kGcmTagLen), const_cast<uint8_t*>(tag))) {
|
||||||
|
netcore::log_error("decrypt_ota_file: set tag failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (1 != EVP_DecryptFinal_ex(ctx, plain.data() + out_len1, &out_len2)) {
|
||||||
|
netcore::log_error("decrypt_ota_file: final failed (auth tag mismatch?)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const size_t plain_len = static_cast<size_t>(out_len1 + out_len2);
|
||||||
|
if (!netcore::write_file_all(output_zip_path, plain.data(), plain_len)) {
|
||||||
|
netcore::log_error(std::string("decrypt_ota_file: write failed: ") + output_zip_path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ok = true;
|
||||||
|
} while (false);
|
||||||
|
|
||||||
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace netcore{
|
||||||
|
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
#include "native_logger.hpp"
|
||||||
|
#include "msg_handler.hpp"
|
||||||
|
#include "utils.hpp"
|
||||||
|
|
||||||
|
namespace py = pybind11;
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
// 打包 TCP 数据包
|
||||||
|
py::bytes make_packet(int msg_type, py::dict body_dict) {
|
||||||
|
netcore::log_debug(std::string("make_packet msg_type=") + std::to_string(msg_type));
|
||||||
|
// 1) 将 py::dict 转为 JSON 字符串
|
||||||
|
json j = netcore::py_dict_to_json(body_dict);
|
||||||
|
std::string body_str = j.dump();
|
||||||
|
|
||||||
|
// 2) 计算 body_len 和 checksum
|
||||||
|
uint32_t body_len = body_str.size();
|
||||||
|
uint32_t checksum = body_len + msg_type;
|
||||||
|
|
||||||
|
// 3) 打包头部(大端序)
|
||||||
|
std::vector<uint8_t> packet;
|
||||||
|
packet.reserve(12 + body_len);
|
||||||
|
|
||||||
|
// body_len (big-endian, 4 bytes)
|
||||||
|
packet.push_back((body_len >> 24) & 0xFF);
|
||||||
|
packet.push_back((body_len >> 16) & 0xFF);
|
||||||
|
packet.push_back((body_len >> 8) & 0xFF);
|
||||||
|
packet.push_back(body_len & 0xFF);
|
||||||
|
|
||||||
|
// msg_type (big-endian, 4 bytes)
|
||||||
|
packet.push_back((msg_type >> 24) & 0xFF);
|
||||||
|
packet.push_back((msg_type >> 16) & 0xFF);
|
||||||
|
packet.push_back((msg_type >> 8) & 0xFF);
|
||||||
|
packet.push_back(msg_type & 0xFF);
|
||||||
|
|
||||||
|
// checksum (big-endian, 4 bytes)
|
||||||
|
packet.push_back((checksum >> 24) & 0xFF);
|
||||||
|
packet.push_back((checksum >> 16) & 0xFF);
|
||||||
|
packet.push_back((checksum >> 8) & 0xFF);
|
||||||
|
packet.push_back(checksum & 0xFF);
|
||||||
|
|
||||||
|
// 4) 追加 body
|
||||||
|
packet.insert(packet.end(), body_str.begin(), body_str.end());
|
||||||
|
|
||||||
|
netcore::log_debug(std::string("make_packet done bytes=") + std::to_string(packet.size()));
|
||||||
|
return py::bytes(reinterpret_cast<const char*>(packet.data()), packet.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 TCP 数据包
|
||||||
|
py::tuple parse_packet(py::bytes data) {
|
||||||
|
// 1) 转换为 bytes view
|
||||||
|
py::buffer_info buf = py::buffer(data).request();
|
||||||
|
if (buf.size < 12) {
|
||||||
|
netcore::log_error(std::string("parse_packet too_short len=") + std::to_string(buf.size));
|
||||||
|
return py::make_tuple(py::none(), py::none());
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t* ptr = static_cast<const uint8_t*>(buf.ptr);
|
||||||
|
|
||||||
|
// 2) 解析头部(大端序)
|
||||||
|
uint32_t body_len = (ptr[0] << 24) | (ptr[1] << 16) | (ptr[2] << 8) | ptr[3];
|
||||||
|
uint32_t msg_type = (ptr[4] << 24) | (ptr[5] << 16) | (ptr[6] << 8) | ptr[7];
|
||||||
|
uint32_t checksum = (ptr[8] << 24) | (ptr[9] << 16) | (ptr[10] << 8) | ptr[11];
|
||||||
|
|
||||||
|
// 3) 校验 checksum(可选,你现有代码不强制校验)
|
||||||
|
// if (checksum != (body_len + msg_type)) {
|
||||||
|
// return py::make_tuple(py::none(), py::none());
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 4) 检查长度
|
||||||
|
uint32_t expected_len = 12 + body_len;
|
||||||
|
if (buf.size < expected_len) {
|
||||||
|
// 半包
|
||||||
|
netcore::log_warn(std::string("parse_packet incomplete got=") + std::to_string(buf.size) +
|
||||||
|
" expected=" + std::to_string(expected_len));
|
||||||
|
return py::make_tuple(py::none(), py::none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 防御性检查:如果 data 比预期长,说明可能有粘包
|
||||||
|
// (只解析第一个包,忽略多余数据)
|
||||||
|
if (buf.size > expected_len) {
|
||||||
|
netcore::log_warn(std::string("parse_packet concat got=") + std::to_string(buf.size) +
|
||||||
|
" expected=" + std::to_string(expected_len) +
|
||||||
|
" body_len=" + std::to_string(body_len) +
|
||||||
|
" msg_type=" + std::to_string(msg_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 提取 body 并解析 JSON
|
||||||
|
std::string body_str(reinterpret_cast<const char*>(ptr + 12), body_len);
|
||||||
|
|
||||||
|
try {
|
||||||
|
json j = json::parse(body_str);
|
||||||
|
py::dict body_dict = netcore::json_to_py_dict(j);
|
||||||
|
return py::make_tuple(py::int_(msg_type), body_dict);
|
||||||
|
} catch (const json::parse_error& e) {
|
||||||
|
// JSON 解析失败,返回 raw(兼容你现有的逻辑)
|
||||||
|
netcore::log_error(std::string("parse_packet json_parse_error: ") + e.what());
|
||||||
|
py::dict raw_dict;
|
||||||
|
raw_dict["raw"] = body_str;
|
||||||
|
return py::make_tuple(py::int_(msg_type), raw_dict);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
netcore::log_error(std::string("parse_packet json_parse_error: ") + e.what());
|
||||||
|
py::dict raw_dict;
|
||||||
|
raw_dict["raw"] = body_str;
|
||||||
|
return py::make_tuple(py::int_(msg_type), raw_dict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <pybind11/pybind11.h>
|
||||||
|
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
||||||
|
|
||||||
|
namespace py = pybind11;
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
|
||||||
|
// 打包 TCP 数据包
|
||||||
|
py::bytes make_packet(int msg_type, py::dict body_dict);
|
||||||
|
// 解包 TCP 数据包
|
||||||
|
py::tuple parse_packet(py::bytes data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#include "native_logger.hpp"
|
||||||
|
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
|
||||||
|
static std::mutex g_mu;
|
||||||
|
static int g_fd = -1;
|
||||||
|
static std::string g_path = "netcore.log";
|
||||||
|
static LogLevel g_level = LogLevel::kDebug; //LogLevel::kInfo;
|
||||||
|
|
||||||
|
static const char* level_name(LogLevel lvl) {
|
||||||
|
switch (lvl) {
|
||||||
|
case LogLevel::kError: return "E";
|
||||||
|
case LogLevel::kWarn: return "W";
|
||||||
|
case LogLevel::kInfo: return "I";
|
||||||
|
case LogLevel::kDebug: return "D";
|
||||||
|
default: return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensure_open_locked() {
|
||||||
|
if (g_path.empty()) return;
|
||||||
|
if (g_fd >= 0) return;
|
||||||
|
g_fd = ::open(g_path.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0644);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_log_file(const std::string& path) {
|
||||||
|
std::lock_guard<std::mutex> lk(g_mu);
|
||||||
|
g_path = path;
|
||||||
|
if (g_fd >= 0) {
|
||||||
|
::close(g_fd);
|
||||||
|
g_fd = -1;
|
||||||
|
}
|
||||||
|
ensure_open_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_log_level(LogLevel level) {
|
||||||
|
std::lock_guard<std::mutex> lk(g_mu);
|
||||||
|
g_level = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
void log(LogLevel level, const std::string& msg) {
|
||||||
|
std::lock_guard<std::mutex> lk(g_mu);
|
||||||
|
if (static_cast<int>(level) > static_cast<int>(g_level)) return;
|
||||||
|
if (g_path.empty()) return;
|
||||||
|
|
||||||
|
ensure_open_locked();
|
||||||
|
if (g_fd < 0) {
|
||||||
|
// Last resort: stderr (avoid any Python APIs)
|
||||||
|
::write(STDERR_FILENO, msg.c_str(), msg.size());
|
||||||
|
::write(STDERR_FILENO, "\n", 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp: epoch milliseconds (simple and cheap)
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_REALTIME, &ts);
|
||||||
|
// long long ms = (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
|
||||||
|
// 1. 将秒数转换为本地时间结构体 struct tm
|
||||||
|
struct tm *tm_info = localtime(&ts.tv_sec);
|
||||||
|
|
||||||
|
// 2. 准备一个缓冲区来存储时间字符串
|
||||||
|
char buffer[30];
|
||||||
|
|
||||||
|
// 3. 格式化秒的部分
|
||||||
|
// 格式: 年-月-日 时:分:秒
|
||||||
|
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", tm_info);
|
||||||
|
|
||||||
|
// 4. 计算毫秒部分并追加到字符串中
|
||||||
|
// ts.tv_nsec 是纳秒,除以 1,000,000 得到毫秒
|
||||||
|
char ms_buffer[8];
|
||||||
|
snprintf(ms_buffer, sizeof(ms_buffer), ".%03ld", ts.tv_nsec / 1000000);
|
||||||
|
|
||||||
|
// Build one line to keep writes atomic-ish
|
||||||
|
char head[256];
|
||||||
|
int n = ::snprintf(head, sizeof(head), "[%s%s] [%s] ", buffer, ms_buffer, level_name(level));
|
||||||
|
if (n < 0) n = 0;
|
||||||
|
|
||||||
|
::write(g_fd, head, (size_t)n);
|
||||||
|
::write(g_fd, msg.c_str(), msg.size());
|
||||||
|
::write(g_fd, "\n", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void log_debug(const std::string& msg) { log(LogLevel::kDebug, msg); }
|
||||||
|
void log_info (const std::string& msg) { log(LogLevel::kInfo, msg); }
|
||||||
|
void log_warn (const std::string& msg) { log(LogLevel::kWarn, msg); }
|
||||||
|
void log_error(const std::string& msg) { log(LogLevel::kError, msg); }
|
||||||
|
|
||||||
|
} // namespace netcore
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
|
||||||
|
enum class LogLevel : int {
|
||||||
|
kError = 0,
|
||||||
|
kWarn = 1,
|
||||||
|
kInfo = 2,
|
||||||
|
kDebug = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set log file path. If empty, logging is disabled.
|
||||||
|
void set_log_file(const std::string& path);
|
||||||
|
|
||||||
|
// Set minimum log level to write (default: kInfo).
|
||||||
|
void set_log_level(LogLevel level);
|
||||||
|
|
||||||
|
// Log helpers (thread-safe, never calls into Python).
|
||||||
|
void log(LogLevel level, const std::string& msg);
|
||||||
|
void log_debug(const std::string& msg);
|
||||||
|
void log_info(const std::string& msg);
|
||||||
|
void log_warn(const std::string& msg);
|
||||||
|
void log_error(const std::string& msg);
|
||||||
|
|
||||||
|
} // namespace netcore
|
||||||
|
|
||||||
+24765
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
|||||||
|
#include <fstream>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <fstream>
|
||||||
|
#include "utils.hpp"
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
// 辅助函数:将 py::dict 转为 nlohmann::json
|
||||||
|
json py_dict_to_json(py::dict d) {
|
||||||
|
json j;
|
||||||
|
for (auto item : d) {
|
||||||
|
std::string key = py::str(item.first);
|
||||||
|
py::object val = py::reinterpret_borrow<py::object>(item.second);
|
||||||
|
|
||||||
|
if (py::isinstance<py::dict>(val)) {
|
||||||
|
j[key] = py_dict_to_json(py::cast<py::dict>(val));
|
||||||
|
} else if (py::isinstance<py::list>(val)) {
|
||||||
|
py::list py_list = py::cast<py::list>(val);
|
||||||
|
json arr = json::array();
|
||||||
|
for (auto elem : py_list) {
|
||||||
|
py::object elem_obj = py::reinterpret_borrow<py::object>(elem);
|
||||||
|
if (py::isinstance<py::dict>(elem_obj)) {
|
||||||
|
arr.push_back(py_dict_to_json(py::cast<py::dict>(elem_obj)));
|
||||||
|
} else if (py::isinstance<py::int_>(elem_obj)) {
|
||||||
|
arr.push_back(py::cast<int64_t>(elem_obj));
|
||||||
|
} else if (py::isinstance<py::float_>(elem_obj)) {
|
||||||
|
arr.push_back(py::cast<double>(elem_obj));
|
||||||
|
} else {
|
||||||
|
arr.push_back(py::str(elem_obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j[key] = arr;
|
||||||
|
} else if (py::isinstance<py::int_>(val)) {
|
||||||
|
j[key] = py::cast<int64_t>(val);
|
||||||
|
} else if (py::isinstance<py::float_>(val)) {
|
||||||
|
j[key] = py::cast<double>(val);
|
||||||
|
} else if (py::isinstance<py::bool_>(val)) {
|
||||||
|
j[key] = py::cast<bool>(val);
|
||||||
|
} else if (val.is_none()) {
|
||||||
|
j[key] = nullptr;
|
||||||
|
} else {
|
||||||
|
j[key] = py::str(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助函数:将 nlohmann::json 转为 py::dict
|
||||||
|
py::dict json_to_py_dict(const json& j) {
|
||||||
|
py::dict d;
|
||||||
|
if (j.is_object()) {
|
||||||
|
for (auto& item : j.items()) {
|
||||||
|
std::string key = item.key();
|
||||||
|
json val = item.value();
|
||||||
|
|
||||||
|
if (val.is_object()) {
|
||||||
|
d[py::str(key)] = json_to_py_dict(val);
|
||||||
|
} else if (val.is_array()) {
|
||||||
|
py::list py_list;
|
||||||
|
for (auto& elem : val) {
|
||||||
|
if (elem.is_object()) {
|
||||||
|
py_list.append(json_to_py_dict(elem));
|
||||||
|
} else if (elem.is_number_integer()) {
|
||||||
|
py_list.append(py::int_(elem.get<int64_t>()));
|
||||||
|
} else if (elem.is_number_float()) {
|
||||||
|
py_list.append(py::float_(elem.get<double>()));
|
||||||
|
} else if (elem.is_boolean()) {
|
||||||
|
py_list.append(py::bool_(elem.get<bool>()));
|
||||||
|
} else if (elem.is_null()) {
|
||||||
|
py_list.append(py::none());
|
||||||
|
} else {
|
||||||
|
py_list.append(py::str(elem.get<std::string>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d[py::str(key)] = py_list;
|
||||||
|
} else if (val.is_number_integer()) {
|
||||||
|
d[py::str(key)] = py::int_(val.get<int64_t>());
|
||||||
|
} else if (val.is_number_float()) {
|
||||||
|
d[py::str(key)] = py::float_(val.get<double>());
|
||||||
|
} else if (val.is_boolean()) {
|
||||||
|
d[py::str(key)] = py::bool_(val.get<bool>());
|
||||||
|
} else if (val.is_null()) {
|
||||||
|
d[py::str(key)] = py::none();
|
||||||
|
} else {
|
||||||
|
d[py::str(key)] = py::str(val.get<std::string>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <pybind11/pybind11.h>
|
||||||
|
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace py = pybind11;
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
namespace netcore {
|
||||||
|
|
||||||
|
json py_dict_to_json(py::dict d);
|
||||||
|
py::dict json_to_py_dict(const json& j);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
1. CPP构建命令:
|
||||||
|
|
||||||
|
cd /mnt/d/code/archery/cpp_ext
|
||||||
|
rm -rf build && mkdir build && cd build
|
||||||
|
|
||||||
|
TOOLCHAIN_BIN=/mnt/d/code/MaixCDK/dl/extracted/toolchains/maixcam/host-tools/gcc/riscv64-linux-musl-x86_64/bin
|
||||||
|
PYDEV=/mnt/d/code/shooting/python3_lib_maixcam_musl_3.11.6
|
||||||
|
MAIXCDK=/mnt/d/code/MaixCDK
|
||||||
|
|
||||||
|
cmake .. -G Ninja \
|
||||||
|
-DCMAKE_C_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-gcc" \
|
||||||
|
-DCMAKE_CXX_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-g++" \
|
||||||
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_C_FLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" \
|
||||||
|
-DCMAKE_CXX_FLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" \
|
||||||
|
-DPY_INCLUDE_DIR="${PYDEV}/include/python3.11" \
|
||||||
|
-DPY_LIB="${PYDEV}/lib/libpython3.11.so" \
|
||||||
|
-DPY_EXT_SUFFIX=".cpython-311-riscv64-linux-gnu.so" \
|
||||||
|
-DMAIXCDK_PATH="${MAIXCDK}"
|
||||||
|
|
||||||
|
ninja
|
||||||
|
|
||||||
|
|
||||||
|
2. Maixvision 直接跑项目的时候,是复制到板子上的这个目录:/tmp/maixpy_run
|
||||||
|
|
||||||
|
3. 4g 模块的终端测试方法:
|
||||||
|
3.1 一个窗口 ssh 到maixcam的板子上之后,通过 printf 输入命令到 /dev/ttyS2, 然后另外一个窗口通过 cat /dev/ttyS2 输出
|
||||||
|
# 1. 确保 PDP 激活
|
||||||
|
printf 'AT+CGPADDR=1\r\n' > /dev/ttyS2
|
||||||
|
# 2. 开启日志监听(另一个 SSH 窗口)
|
||||||
|
cat /dev/ttyS2
|
||||||
|
# 3. 发送下载命令(原窗口)
|
||||||
|
printf 'AT+MHTTPDLFILE="http://static.shelingxingqiu.com/shoot/v1/main.py","downloaded.py",5120\r\n' > /dev/ttyS2
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
1. 问题描述:开机失败,一直遇到Traceback (most recent call last):
|
||||||
|
File "/tmp/maixpy_run/main.py", line 525, in <module>
|
||||||
|
cmd_str()
|
||||||
|
File "/tmp/maixpy_run/main.py", line 102, in cmd_str
|
||||||
|
camera_manager.init_camera(640, 480)
|
||||||
|
File "/tmp/maixpy_run/camera_manager.py", line 59, in init_camera
|
||||||
|
self._camera = camera.Camera(width, height)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
RuntimeError: : Runtime error: mmf vi init failed
|
||||||
|
解决方案:
|
||||||
|
根据过往经验,极有可能是摄像头的接线有问题。因为在测试环境,摄像头是通过一个24针转22针的线出来的,然后再通过一个接线中继,连接到一个22针
|
||||||
|
的fpc线到Maixcam。接线中继如果是24针的,多了两针,需要选好一边然后对连。但这里很容易出错或者松动。可以先用摄像头本身的金色接线直接接到
|
||||||
|
Maixcam,然后跑test目录下的test_cammera.py,看看能不能正常启动,如果正常,就确定是中继接线的问题。
|
||||||
|
|
||||||
|
2. 问题描述:202609 批次的拓展版,在连接 202601 批次的电源板,或者不链接电源板的时候,开机后不久,出错,程序退出,日志是:
|
||||||
|
[v1.2.10] [INFO] network.py:1078 - [NET] TCP主线程启动
|
||||||
|
[v1.2.10] [INFO] network.py:406 - [NET] WiFi不可用或无法连接服务器,使用4G网络
|
||||||
|
[v1.2.10] [INFO] network.py:475 - 连接到服务器,使用4G...
|
||||||
|
[v1.2.10] [INFO] network.py:527 - [4G-TCP] AT+MIPCLOSE=2 response:
|
||||||
|
OK
|
||||||
|
+MIPCLOSE: 2
|
||||||
|
-- [E] read failed
|
||||||
|
Trigger signal, code:SIGSEGV(11)!
|
||||||
|
maix multi-media driver released.
|
||||||
|
ISP Vipipe(0) Free pa(0x8a52c000) va(0x0x3fbeb5e000)
|
||||||
|
program exit failed. exit code: 1.
|
||||||
|
|
||||||
|
解决方案:
|
||||||
|
从日志看,就是开始发送登录信息之后就崩溃了。出发了底层的read failed。经过排查,是一定要插上电源板的数据连线,以及电源板要插上电池。这个应该是
|
||||||
|
登录时需要读电源电压数据,
|
||||||
|
|
||||||
|
3. 问题描述:202609 批次的拓展版,有一块maixcam的蓝灯常亮,询问maixcam的人,他们觉得应该是卡没有插好。但是拓展版上的激光口挡住了数据卡的出口,
|
||||||
|
没法拔出检查,
|
||||||
|
解决方案:需要做拓展版的公司(深链鑫创)在做好板子之后,确定系统能正常启动
|
||||||
|
4.
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
1. 4G OTA 下载的时候,为什么使用十六进制下载,读取 URC 事件?
|
||||||
|
因为使用二进制下载的时候,经常会出现错误,并且会失败?然后最稳定传输的办法,是每次传输的时候,是分块,而且每次分块都要“删/建”http实例。推测原因是因为我们现在是直接传输文件的源代码,代码中含有了一些字符串可能和 AT指令重复,导致了 AT 模块在解释的时候出错。而使用 16 进制的方式,可以避免这个问题。因为十六进制直接把数据先转成了字符串,然后在设备端再把字符串转成数据,这样就不可能出现 AT的指令,从而减少了麻烦。
|
||||||
|
2. 4G OTA 下载的时候,为什么不用 AT 模块里 HTTPDLFILE 的指令?
|
||||||
|
因为在测试中发现,使用 HTTPDLFILE,其实是下载到了 4G 模块内部,需要重新从模块内部转到存储卡,而且 4G 模块的存储较小,大概只有 40k,所以还需要分块来下载和转存,比较麻烦,于是最终使用了使用读取串口事件的模式。
|
||||||
|
3. 4G OTA 下载的时候,为什么不用 AT 模块里 HTTPREAD 的指令?
|
||||||
|
因为之前测试发现,READ模式其实是需要多步:
|
||||||
|
3.1. AT+MHTTPCREATE
|
||||||
|
3.2. AT+MHTTPCFG
|
||||||
|
3.3. AT+MHTTPREQUEST
|
||||||
|
3.4. AT+MHTTPREAD
|
||||||
|
它其实也是把数据下载到 4g 模块的缓存里,然后再从缓存里读取出来。所以也是比较繁琐的,还不如 HTTPDLFILE 简单。
|
||||||
|
4. WiFi OTA 流程(ota_manager.handle_wifi_and_update())
|
||||||
|
* 解析 ota_url 得到 host:port
|
||||||
|
* 调用 network_manager.connect_wifi(ssid, password, verify_host=host, verify_port=port, persist=True)
|
||||||
|
* 只有“能连上 WiFi 且能访问 OTA host:port”才会把新凭证保留在 /boot
|
||||||
|
* 连接成功后开始下载 OTA 文件(download_file())
|
||||||
|
* 下载成功则 apply_ota_and_reboot()
|
||||||
|
5. TCP 通信
|
||||||
|
1) 平时 TCP 通信主流程(network_manager.tcp_main())
|
||||||
|
外层无限循环:一直尝试保持与服务器的 TCP 会话。
|
||||||
|
每轮开始:
|
||||||
|
如果 OTA 正在进行:暂停(避免抢占资源/串口)。
|
||||||
|
connect_server():建立 TCP 连接(自动选 WiFi 或 4G)。
|
||||||
|
发送“登录包”(msg_type=1),等待服务器返回“登录成功”。
|
||||||
|
登录成功后进入内层循环:
|
||||||
|
接收数据:
|
||||||
|
WiFi:非阻塞 recv();没数据返回 b"";有数据进入缓冲区拼包解析。
|
||||||
|
4G:从 ATClient 的队列 pop_tcp_payload() 取数据。
|
||||||
|
处理命令/ACK:
|
||||||
|
登录响应、心跳 ACK、OTA 命令、关机命令、日志上传命令等。
|
||||||
|
发送业务队列:
|
||||||
|
从高优/普通队列取 1 条,发送失败会放回队首,并断线重连(不再丢消息)。
|
||||||
|
发送心跳:
|
||||||
|
按 HEARTBEAT_INTERVAL 发心跳包。
|
||||||
|
心跳失败会计数(当前为连续失败到阈值才重连)。
|
||||||
|
任何发送/接收致命失败:
|
||||||
|
关闭 socket/断开连接 → 跳出内层循环 → 外层等待一会儿后重新 connect_server() → 重新登录。
|
||||||
|
6. “WiFi 连接/验证”
|
||||||
|
TCP 连接建立与网络选择(connect_server() / select_network())
|
||||||
|
* select_network():WiFi 优先,但要求:
|
||||||
|
is_wifi_connected() 为 True(系统层面有 WiFi IP 或 Maix WLAN connected)
|
||||||
|
且能连到 TCP 服务器 SERVER_IP:SERVER_PORT
|
||||||
|
否则回退到 4G
|
||||||
|
* connect_server():
|
||||||
|
若已有连接:WiFi 会做 _check_wifi_connection() 轻量检查;4G 直接认为 OK(由 AT 层维护)。
|
||||||
|
否则按网络类型走:
|
||||||
|
WiFi:创建 socket → connect → setblocking(False)(接收用非阻塞)
|
||||||
|
4G:AT+MIPOPEN 建链
|
||||||
|
WiFi 链接(connect_wifi())
|
||||||
|
当前 connect_wifi() 的关键特点是:必须让 /etc/init.d/S30wifi restart 真正用新 SSID 去连,所以会临时写 /boot/wifi.ssid 和 /boot/wifi.pass,失败自动回滚。
|
||||||
|
流程是:
|
||||||
|
(1) 备份旧配置
|
||||||
|
* /boot/wifi.ssid、/boot/wifi.pass
|
||||||
|
* /etc/wpa_supplicant.conf(尽量备份)
|
||||||
|
(2) 写入新凭证
|
||||||
|
* 把新 ssid/pass 写到 /boot/*
|
||||||
|
-(同时尽量写 /etc/wpa_supplicant.conf,但不强依赖)
|
||||||
|
(3) 重启 WiFi 服务:/etc/init.d/S30wifi restart
|
||||||
|
(4) 等待获取 IP(默认 20 秒,可调)
|
||||||
|
(5) 验证可用性,连到 verify_host:verify_port
|
||||||
|
(6) 成功
|
||||||
|
* persist=True:保留 /boot/*(持久化)
|
||||||
|
* persist=False:回滚 /boot/* 到旧值(不重启,当前连接仍可继续)
|
||||||
|
(7) 失败
|
||||||
|
* 回滚 /boot/* + 回滚 /etc/wpa_supplicant.conf(如果有备份)
|
||||||
|
* 再 S30wifi restart 恢复旧网络
|
||||||
|
* 返回错误
|
||||||
|
|
||||||
|
7. 日志上传(inner_cmd == 43),当前只支持 wifi 上传日志
|
||||||
|
命令带 ssid/password/url 时:
|
||||||
|
* 若 WiFi 未连接:先 connect_wifi(..., verify_host=upload_host, verify_port=upload_port, persist=True)
|
||||||
|
上传内容:
|
||||||
|
* sync # 把日志从内存同步到文件
|
||||||
|
* 快照 app.log* 到 /tmp staging
|
||||||
|
* 打包成 tar.gz(默认)或 zip
|
||||||
|
* 以 multipart/form-data 的 file 字段 POST 到 url
|
||||||
|
|
||||||
|
8. 自动关机:
|
||||||
|
hardware中设定了开停表,然后再增加了获取idle的时间。
|
||||||
|
自动关机的时机: 超过配置的idle时长,
|
||||||
|
禁止自动关机的情况:1.校准中,2.OTA中
|
||||||
|
重启计时的时机:1.校准完成,2.命令触发射箭,3.真实触发射箭,4.初始化完成
|
||||||
|
9. Wifi网络监控:
|
||||||
|
有两次发现wifi网络下,有些消息发送很慢,但具体是什么缘故还不清楚,现在增加了wifi网络下的检测,并一旦发现wifi的网络质量差,就会切换到4G。
|
||||||
|
WiFi 连接成功
|
||||||
|
↓
|
||||||
|
启动后台监测线程
|
||||||
|
↓
|
||||||
|
每 5 秒循环:
|
||||||
|
测量 RTT (1 样本,600ms timeout)
|
||||||
|
获取 RSSI
|
||||||
|
更新缓存
|
||||||
|
判断是否差:
|
||||||
|
- RTT >= 600ms → 差
|
||||||
|
- RTT >= 350ms 且 RSSI <= -80dBm → 差
|
||||||
|
↓
|
||||||
|
如果质量差:
|
||||||
|
快速重试2次,如果其中任意一次网络恢复了,继续使用wifi。否则,
|
||||||
|
调用 _switch_to_4g_due_to_poor_wifi()
|
||||||
|
关闭 WiFi socket
|
||||||
|
重置连接状态
|
||||||
|
尝试切换到 4G
|
||||||
|
↓
|
||||||
|
上层检测到连接断开:
|
||||||
|
重新 connect_server() → 自动选择 4G
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
你现在要防的是“别人拿到设备/拿到代码包后,能伪造请求、刷接口、下发恶意 OTA、甚至劫持通信”。单靠隐藏 Python 源码只能提高门槛,真正的安全要靠协议和密钥设计。结合你仓库里实际内容,建议你重点隐藏/整改这些点(按风险排序)。
|
||||||
|
1. 必须隐藏/必须整改(高风险)
|
||||||
|
1.1 登录口令规则太弱(几乎等于明文)
|
||||||
|
你现在的登录是 password = device_id + "."(见 network.py 读取设备 ID 后直接拼出来),这意味着只要攻击者知道/猜到 device_id,就能直接登录伪装设备。
|
||||||
|
相关位置:
|
||||||
|
with open("/device_key", "r") as f: device_id = f.read().strip() ... self._device_id = device_id self._password = device_id + "."
|
||||||
|
1.2 HTTP 鉴权 token 的盐值是硬编码常量(泄露后可离线伪造)
|
||||||
|
你 token 是 HMAC-SHA256((SALT+device_id), SALT2),而 SALT/SALT2 是固定字符串:"shootMessageFire" / "shoot"。这类“硬编码盐值 + 可猜/可读的 device_id”意味着:攻击者只要拿到代码包/逆向 .so,就能在自己电脑上批量算 token,伪造 HTTP 请求。
|
||||||
|
相关位置:
|
||||||
|
SALT = "shootMessageFire"SALT2 = "shoot"return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
|
||||||
|
1.3 TLS 配置目前看起来没有做证书校验(容易被中间人攻击)
|
||||||
|
config.py 虽然 USE_TCP_SSL=True,但你在 network.py 里实际把 MSSLCFG="auth" 固定成 0(不验),且写证书分支被 if False 禁用了。这样“看起来是 TLS”,但仍可能被抓包/篡改/假服务器接入。
|
||||||
|
相关位置:
|
||||||
|
r = hardware_manager.at_client.send(f'AT+MSSLCFG="auth",{ssl_id},0', "OK", 3000)...if False: # 写证书/校验被禁用 ...r = hardware_manager.at_client.send(f'AT+MIPCFG="ssl",{link_id},{ssl_id},1', "OK", 3000)
|
||||||
|
1.4 OTA 下发“url”如果缺少强校验,就是远程代码执行入口
|
||||||
|
你 OTA 逻辑里从服务器指令拿到 url 就去下载并替换文件/重启(这是正常 OTA),但安全性取决于:
|
||||||
|
是否只允许白名单域名/路径
|
||||||
|
是否强制 https 并校验服务器证书
|
||||||
|
是否对 OTA 包做签名校验(最关键)
|
||||||
|
你这里能看到固定域名 static.shelingxingqiu.com 的特殊处理(ota_manager.py 里还在纠结 http/https),这块一定要“服务端签名 + 设备端验签”,否则隐藏源码也没用。
|
||||||
|
|
||||||
|
2. 建议隐藏(中风险,但很容易被人利用)
|
||||||
|
2.1 所有服务器地址/端口/API 路径(可被用于扫描、压测、撞库、协议逆向)
|
||||||
|
这些在 config.py 是明文:
|
||||||
|
SERVER_IP = "stcp.shelingxingqiu.com"SERVER_PORT = 50005HTTP_URL = "http://ws.shelingxingqiu.com"HTTP_API_PATH = "/home/shoot/device_fire/arrow/fire"
|
||||||
|
注意:即使你把它们藏进 .so,攻击者仍可能通过抓包/观察 DNS/SNI/流量拿到域名或 IP,所以“隐藏”只能降低静态分析风险,不能替代鉴权/签名。
|
||||||
|
2.2 WiFi 凭证落盘位置
|
||||||
|
你会把 SSID/密码写到 /boot/wifi.ssid 和 /boot/wifi.pass(network.py/wifi.py 都有)。拿到设备存储就能读到明文密码,这属于设备侧安全问题。
|
||||||
|
2.3 日志/调试信息泄露
|
||||||
|
你仓库里 .cursor/debug.log* 已经记录了完整的 AT+MIPOPEN ... host:port 等信息;如果这些被打进包或被用户导出,也会泄露通信细节。
|
||||||
|
你发布包里“真正会被带走的敏感内容”
|
||||||
|
你现在 app.yaml 的打包清单只包含核心运行文件(不包含 test/、backup_code/、.cursor/ 等),这一点很好:
|
||||||
|
files: - app.yaml - at_client.py - camera_manager.py - config.py - hardware.py - laser_manager.py - logger_manager.py - main.py - network.py - ota_manager.py - power.py - shot_id_generator.py - time_sync.py - version.py - vision.py
|
||||||
|
但要注意:你当前最敏感的“盐值/口令规则/HTTP header 逻辑/OTA 策略”都在这些会打包的文件里(尤其是 network.py、ota_manager.py、config.py)。
|
||||||
|
|
||||||
|
3. 最有效的“防黑”措施(比隐藏更关键)
|
||||||
|
把“password=device_id+.” 改为真正的 per-device secret(设备出厂烧录,服务端保存;或服务端下发公钥,设备用私钥签名)
|
||||||
|
所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用)
|
||||||
|
OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用)
|
||||||
|
TLS 必须做证书校验/最好做 pinning(至少别用 auth=0)
|
||||||
|
如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
硬件管理器模块
|
||||||
|
提供硬件对象的统一管理和访问
|
||||||
|
"""
|
||||||
|
from maix import time
|
||||||
|
import config
|
||||||
|
from at_client import ATClient
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareManager:
|
||||||
|
"""硬件管理器(单例)"""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(HardwareManager, cls).__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 私有硬件对象
|
||||||
|
self._uart4g = None # 4G模块UART
|
||||||
|
self._bus = None # I2C总线
|
||||||
|
self._adc_obj = None # ADC对象
|
||||||
|
self._at_client = None # AT客户端
|
||||||
|
|
||||||
|
self._last_active_time = 0 # 用于记录用户的最后一次活跃的时间
|
||||||
|
self._stop_timer = False # 用于停止定时器的标志
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 硬件访问(只读属性)====================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uart4g(self):
|
||||||
|
"""4G模块UART(只读)"""
|
||||||
|
return self._uart4g
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bus(self):
|
||||||
|
"""I2C总线(只读)"""
|
||||||
|
return self._bus
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adc_obj(self):
|
||||||
|
"""ADC对象(只读)"""
|
||||||
|
return self._adc_obj
|
||||||
|
|
||||||
|
@property
|
||||||
|
def at_client(self):
|
||||||
|
"""AT客户端(只读)"""
|
||||||
|
return self._at_client
|
||||||
|
|
||||||
|
# ==================== 初始化方法 ====================
|
||||||
|
|
||||||
|
def init_uart4g(self, device=None, baudrate=None):
|
||||||
|
"""初始化4G模块UART"""
|
||||||
|
from maix import uart
|
||||||
|
if device is None:
|
||||||
|
device = config.UART4G_DEVICE
|
||||||
|
if baudrate is None:
|
||||||
|
baudrate = config.UART4G_BAUDRATE
|
||||||
|
self._uart4g = uart.UART(device, baudrate)
|
||||||
|
return self._uart4g
|
||||||
|
|
||||||
|
def init_bus(self, bus_num=None):
|
||||||
|
"""初始化I2C总线"""
|
||||||
|
from maix import i2c
|
||||||
|
if bus_num is None:
|
||||||
|
bus_num = config.I2C_BUS_NUM
|
||||||
|
self._bus = i2c.I2C(bus_num, i2c.Mode.MASTER)
|
||||||
|
return self._bus
|
||||||
|
|
||||||
|
def init_adc(self, channel=None, res_bit=None):
|
||||||
|
"""初始化ADC"""
|
||||||
|
from maix.peripheral import adc
|
||||||
|
if channel is None:
|
||||||
|
channel = config.ADC_CHANNEL
|
||||||
|
if res_bit is None:
|
||||||
|
res_bit = adc.RES_BIT_12
|
||||||
|
self._adc_obj = adc.ADC(channel, res_bit)
|
||||||
|
return self._adc_obj
|
||||||
|
|
||||||
|
def init_at_client(self, uart_obj=None):
|
||||||
|
"""初始化AT客户端"""
|
||||||
|
if uart_obj is None:
|
||||||
|
if self._uart4g is None:
|
||||||
|
raise ValueError("uart4g must be initialized before at_client")
|
||||||
|
uart_obj = self._uart4g
|
||||||
|
self._at_client = ATClient(uart_obj)
|
||||||
|
self._at_client.start()
|
||||||
|
return self._at_client
|
||||||
|
|
||||||
|
def power_off(self):
|
||||||
|
"""关闭电源板"""
|
||||||
|
try:
|
||||||
|
# 物理引脚是 A24,对应 GPIO 功能是 GPIOA24
|
||||||
|
# 注意:这里需要先在 config.PIN_MAPPINGS 中配置好 "A24": "GPIOA24"
|
||||||
|
from maix import gpio
|
||||||
|
# 输出高电平关闭
|
||||||
|
gpio.GPIO("GPIOA24", gpio.Mode.OUT).value(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"关机失败: {e}")
|
||||||
|
|
||||||
|
def start_idle_timer(self):
|
||||||
|
self._stop_timer = False
|
||||||
|
self._last_active_time = time.time()
|
||||||
|
|
||||||
|
def stop_idle_timer(self):
|
||||||
|
self._stop_timer = True
|
||||||
|
|
||||||
|
def get_idle_time_in_sec(self):
|
||||||
|
if self._stop_timer:
|
||||||
|
return 0
|
||||||
|
diff = time.time() - self._last_active_time
|
||||||
|
if diff < 0:
|
||||||
|
# 时间可能被重置了,重新计时
|
||||||
|
self._last_active_time = time.time()
|
||||||
|
return 0
|
||||||
|
return diff
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局单例实例
|
||||||
|
hardware_manager = HardwareManager()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def generate_key_pair():
|
||||||
|
"""
|
||||||
|
生成一对新的密钥a和b,使得a XOR b等于原始key
|
||||||
|
:return: (a, b, key) 元组,每个元素都是32字节的字节数组
|
||||||
|
"""
|
||||||
|
# 原始key值
|
||||||
|
key = bytes([
|
||||||
|
0x5d, 0xf9, 0xef, 0xc4, 0x5d, 0xcc, 0xc7, 0x8d, 0xc9, 0x86, 0x34, 0x11, 0x6f, 0xb4, 0xcf, 0x75,
|
||||||
|
0xbf, 0x24, 0x47, 0x9d, 0xd6, 0x5d, 0x83, 0x4b, 0xa6, 0xc0, 0xde, 0x27, 0x91, 0x92, 0xb1, 0x63
|
||||||
|
])
|
||||||
|
|
||||||
|
# 随机生成a
|
||||||
|
a = os.urandom(32)
|
||||||
|
|
||||||
|
# 计算b = key XOR a
|
||||||
|
b = bytes([key[i] ^ a[i] for i in range(32)])
|
||||||
|
|
||||||
|
return a, b, key
|
||||||
|
|
||||||
|
|
||||||
|
def format_hex_array(data):
|
||||||
|
"""
|
||||||
|
将字节数组格式化为C++风格的十六进制数组
|
||||||
|
:param data: 字节数组
|
||||||
|
:return: 格式化后的字符串
|
||||||
|
"""
|
||||||
|
return "{" + ",".join([f"0x{b:02x}" for b in data]) + "}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_new_key_pair():
|
||||||
|
"""
|
||||||
|
生成新的密钥对并打印出来
|
||||||
|
"""
|
||||||
|
a, b, key = generate_key_pair()
|
||||||
|
|
||||||
|
print("原始key:")
|
||||||
|
print(format_hex_array(key))
|
||||||
|
print("\n新的密钥对:")
|
||||||
|
print("a =", format_hex_array(a))
|
||||||
|
print("b =", format_hex_array(b))
|
||||||
|
|
||||||
|
# 验证a XOR b是否等于key
|
||||||
|
verify_key = bytes([a[i] ^ b[i] for i in range(32)])
|
||||||
|
assert verify_key == key, "验证失败:a XOR b 不等于 key"
|
||||||
|
print("\n验证成功:a XOR b 等于 key")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generate_new_key_pair()
|
||||||
@@ -1,826 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
激光射击系统主程序(激光测距版)
|
|
||||||
功能:目标检测、激光校准、4G TCP 通信、OTA 升级、M01 激光测距、INA226 电量监测
|
|
||||||
平台:MaixPy (Sipeed MAIX)
|
|
||||||
作者:ZZH
|
|
||||||
最后更新:2025-11-21
|
|
||||||
"""
|
|
||||||
|
|
||||||
from maix import camera, display, image, app, time, key, uart, pinmap, i2c, network, err
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import json
|
|
||||||
import struct
|
|
||||||
import re
|
|
||||||
from maix.peripheral import adc
|
|
||||||
import _thread
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
import socket
|
|
||||||
import binascii
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# 全局配置
|
|
||||||
# ==============================
|
|
||||||
# OTA 升级地址(建议后续改为动态下发)
|
|
||||||
url = "https://static.shelingxingqiu.com/shoot/202511031031/main.py"
|
|
||||||
local_filename = "/maixapp/apps/t11/main.py"
|
|
||||||
|
|
||||||
DEVICE_ID = None
|
|
||||||
PASSWORD = None
|
|
||||||
SERVER_IP = "www.shelingxingqiu.com"
|
|
||||||
SERVER_PORT = 50005
|
|
||||||
HEARTBEAT_INTERVAL = 2 # 心跳间隔(秒)
|
|
||||||
|
|
||||||
CONFIG_FILE = "/root/laser_config.json"
|
|
||||||
DEFAULT_POINT = (640, 480) # 图像中心点
|
|
||||||
laser_point = DEFAULT_POINT
|
|
||||||
|
|
||||||
# HTTP API(当前未使用,保留备用)
|
|
||||||
URL = "http://ws.shelingxingqiu.com"
|
|
||||||
API_PATH = "/home/shoot/device_fire/arrow/fire"
|
|
||||||
|
|
||||||
# UART 设备初始化
|
|
||||||
uart4g = uart.UART("/dev/ttyS2", 115200) # 4G 模块(TCP 透传)
|
|
||||||
distance_serial = uart.UART("/dev/ttyS1", 9600) # M01 激光测距模块
|
|
||||||
|
|
||||||
# 消息类型常量
|
|
||||||
MSG_TYPE_LOGIN_REQ = 1 # 登录请求
|
|
||||||
MSG_TYPE_STATUS = 2 # 状态上报
|
|
||||||
MSG_TYPE_HEARTBEAT = 4 # 心跳包
|
|
||||||
# 引脚功能映射
|
|
||||||
pinmap.set_pin_function("A18", "UART1_RX")
|
|
||||||
pinmap.set_pin_function("A19", "UART1_TX")
|
|
||||||
pinmap.set_pin_function("A29", "UART2_RX")
|
|
||||||
pinmap.set_pin_function("A28", "UART2_TX")
|
|
||||||
pinmap.set_pin_function("P18", "I2C1_SCL")
|
|
||||||
pinmap.set_pin_function("P21", "I2C1_SDA")
|
|
||||||
# pinmap.set_pin_function("A15", "I2C5_SCL")
|
|
||||||
# pinmap.set_pin_function("A27", "I2C5_SDA")#ota升级要修改的
|
|
||||||
# ADC 触发阈值(用于检测扳机/激光触发)
|
|
||||||
ADC_TRIGGER_THRESHOLD = 3000
|
|
||||||
ADC_LASER_THRESHOLD = 3000
|
|
||||||
# 显示参数
|
|
||||||
color = image.Color(255, 100, 0) # 橙色十字线
|
|
||||||
thickness = 1
|
|
||||||
length = 2
|
|
||||||
|
|
||||||
# ADC 扳机触发阈值(0~4095)
|
|
||||||
ADC_TRIGGER_THRESHOLD = 3000
|
|
||||||
|
|
||||||
# I2C 电源监测(INA226)
|
|
||||||
adc_obj = adc.ADC(0, adc.RES_BIT_12)
|
|
||||||
bus = i2c.I2C(1, i2c.Mode.MASTER)
|
|
||||||
# bus = i2c.I2C(5, i2c.Mode.MASTER)#ota升级总线
|
|
||||||
INA226_ADDR = 0x40
|
|
||||||
REG_CONFIGURATION = 0x00
|
|
||||||
REG_BUS_VOLTAGE = 0x02
|
|
||||||
REG_CALIBRATION = 0x05
|
|
||||||
CALIBRATION_VALUE = 0x1400
|
|
||||||
|
|
||||||
# M01 激光模块指令
|
|
||||||
MODULE_ADDR = 0x00
|
|
||||||
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
|
||||||
LASER_OFF_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
|
||||||
DISTANCE_QUERY_CMD = bytes([0xAA, MODULE_ADDR, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x21])
|
|
||||||
DISTANCE_RESPONSE_LEN = 13
|
|
||||||
|
|
||||||
# TCP / 线程状态
|
|
||||||
tcp_connected = False
|
|
||||||
send_queue = []
|
|
||||||
update_thread_started = False # 防止重复 OTA
|
|
||||||
send_queue_lock = _thread.allocate_lock()
|
|
||||||
laser_calibration_data_lock = _thread.allocate_lock()
|
|
||||||
laser_calibration_active = False
|
|
||||||
laser_calibration_result = None
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# 网络工具函数
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
def is_server_reachable(host, port=80, timeout=5):
|
|
||||||
"""检查能否连接到指定主机和端口(用于 OTA 前网络检测)"""
|
|
||||||
try:
|
|
||||||
addr_info = socket.getaddrinfo(host, port)[0]
|
|
||||||
s = socket.socket(addr_info[0], addr_info[1], addr_info[2])
|
|
||||||
s.settimeout(timeout)
|
|
||||||
s.connect(addr_info[-1])
|
|
||||||
s.close()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[NET] 无法连接 {host}:{port} - {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def download_file(url, filename):
|
|
||||||
"""
|
|
||||||
从指定 URL 下载文件并保存为 UTF-8 文本。
|
|
||||||
注意:此操作会覆盖本地 main.py!
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
print(f"[OTA] 正在从 {url} 下载文件...")
|
|
||||||
response = requests.get(url, timeout=10) # ⏱️ 防止卡死
|
|
||||||
response.raise_for_status()
|
|
||||||
response.encoding = 'utf-8'
|
|
||||||
with open(filename, 'w', encoding='utf-8') as file:
|
|
||||||
file.write(response.text)
|
|
||||||
return f"下载成功!文件已保存为: {filename}"
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
return f"下载失败!网络请求错误: {e}"
|
|
||||||
except OSError as e:
|
|
||||||
return f"下载失败!文件写入错误: {e}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"下载失败!发生未知错误: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def connect_wifi(ssid, password):
|
|
||||||
"""
|
|
||||||
连接 Wi-Fi 并持久化凭证到 /boot/ 目录,使设备重启后自动连接。
|
|
||||||
返回 (ip, error) 元组。
|
|
||||||
"""
|
|
||||||
conf_path = "/etc/wpa_supplicant.conf"
|
|
||||||
ssid_file = "/boot/wifi.ssid"
|
|
||||||
pass_file = "/boot/wifi.pass"
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 生成 wpa_supplicant 配置
|
|
||||||
net_conf = os.popen(f'wpa_passphrase "{ssid}" "{password}"').read()
|
|
||||||
if "network={" not in net_conf:
|
|
||||||
return None, "Failed to generate wpa config"
|
|
||||||
|
|
||||||
# 写入运行时配置
|
|
||||||
with open(conf_path, "w") as f:
|
|
||||||
f.write("ctrl_interface=/var/run/wpa_supplicant\n")
|
|
||||||
f.write("update_config=1\n\n")
|
|
||||||
f.write(net_conf)
|
|
||||||
|
|
||||||
# 持久化保存(供开机脚本读取)
|
|
||||||
with open(ssid_file, "w") as f:
|
|
||||||
f.write(ssid.strip())
|
|
||||||
with open(pass_file, "w") as f:
|
|
||||||
f.write(password.strip())
|
|
||||||
|
|
||||||
# 重启 Wi-Fi 服务
|
|
||||||
os.system("/etc/init.d/S30wifi restart")
|
|
||||||
|
|
||||||
# 等待获取 IP(最多 20 秒)
|
|
||||||
for _ in range(20):
|
|
||||||
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
|
||||||
if ip:
|
|
||||||
return ip, None
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
return None, "Timeout: No IP obtained"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return None, f"Exception: {str(e)}"
|
|
||||||
|
|
||||||
def direct_ota_download():
|
|
||||||
"""
|
|
||||||
直接执行 OTA 下载(假设已有网络)
|
|
||||||
用于 cmd=7 触发
|
|
||||||
"""
|
|
||||||
global update_thread_started
|
|
||||||
try:
|
|
||||||
# 再次确认网络可达(可选但推荐)
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
host = parsed_url.hostname
|
|
||||||
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
|
|
||||||
|
|
||||||
if not is_server_reachable(host, port, timeout=8):
|
|
||||||
safe_enqueue({"result": "ota_failed", "reason": f"无法连接 {host}:{port}"}, MSG_TYPE_STATUS)
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"[OTA] 开始直接下载固件...")
|
|
||||||
result_msg = download_file(url, local_filename)
|
|
||||||
print(f"[OTA] {result_msg}")
|
|
||||||
safe_enqueue({"result": result_msg}, MSG_TYPE_STATUS)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"OTA 异常: {str(e)}"
|
|
||||||
print(error_msg)
|
|
||||||
safe_enqueue({"result": "ota_failed", "reason": error_msg}, MSG_TYPE_STATUS)
|
|
||||||
finally:
|
|
||||||
update_thread_started = False # 允许下次 OTA
|
|
||||||
|
|
||||||
|
|
||||||
def handle_wifi_and_update(ssid, password):
|
|
||||||
"""
|
|
||||||
OTA 更新线程入口。
|
|
||||||
注意:必须在 finally 中重置 update_thread_started!
|
|
||||||
"""
|
|
||||||
global update_thread_started
|
|
||||||
try:
|
|
||||||
ip, error = connect_wifi(ssid, password)
|
|
||||||
if error:
|
|
||||||
safe_enqueue({"result": "wifi_failed", "error": error}, MSG_TYPE_STATUS)
|
|
||||||
return
|
|
||||||
|
|
||||||
safe_enqueue({"result": "wifi_connected", "ip": ip}, MSG_TYPE_STATUS)
|
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
host = parsed_url.hostname
|
|
||||||
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
|
|
||||||
|
|
||||||
if not is_server_reachable(host, port, timeout=8):
|
|
||||||
err_msg = f"网络不通:无法连接 {host}:{port}"
|
|
||||||
safe_enqueue({"result": err_msg}, MSG_TYPE_STATUS)
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"[OTA] 已确认可访问 {host}:{port},开始下载...")
|
|
||||||
try:
|
|
||||||
cs = download_file(url, local_filename)
|
|
||||||
except Exception as e:
|
|
||||||
cs = f"下载失败: {str(e)}"
|
|
||||||
print(cs)
|
|
||||||
safe_enqueue({"result": cs}, MSG_TYPE_STATUS)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# ✅ 关键修复:允许下次 OTA
|
|
||||||
update_thread_started = False
|
|
||||||
print("[UPDATE] OTA 线程执行完毕,标志已重置。")
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# 工具函数
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
def read_device_id():
|
|
||||||
"""从 /device_key 读取设备唯一 ID"""
|
|
||||||
try:
|
|
||||||
with open("/device_key", "r") as f:
|
|
||||||
device_id = f.read().strip()
|
|
||||||
if device_id:
|
|
||||||
print(f"[INFO] 从 /device_key 读取到 DEVICE_ID: {device_id}")
|
|
||||||
return device_id
|
|
||||||
else:
|
|
||||||
raise ValueError("文件为空")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 无法读取 /device_key: {e}")
|
|
||||||
return "DEFAULT_DEVICE_ID"
|
|
||||||
|
|
||||||
|
|
||||||
def safe_enqueue(data_dict, msg_type=MSG_TYPE_STATUS):
|
|
||||||
"""线程安全地将消息加入发送队列"""
|
|
||||||
global send_queue, send_queue_lock
|
|
||||||
with send_queue_lock:
|
|
||||||
send_queue.append((msg_type, data_dict))
|
|
||||||
|
|
||||||
|
|
||||||
def at(cmd, wait="OK", timeout=2000):
|
|
||||||
"""向 4G 模块发送 AT 指令并等待响应"""
|
|
||||||
if cmd:
|
|
||||||
uart4g.write((cmd + "\r\n").encode())
|
|
||||||
t0 = time.ticks_ms()
|
|
||||||
buf = b""
|
|
||||||
while time.ticks_ms() - t0 < timeout:
|
|
||||||
data = uart4g.read()
|
|
||||||
if data:
|
|
||||||
buf += data
|
|
||||||
if wait.encode() in buf:
|
|
||||||
return buf.decode(errors="ignore")
|
|
||||||
return buf.decode(errors="ignore")
|
|
||||||
|
|
||||||
|
|
||||||
def make_packet(msg_type: int, body_dict: dict) -> bytes:
|
|
||||||
"""构造二进制数据包:[body_len][msg_type][checksum][body]"""
|
|
||||||
body = json.dumps(body_dict, ensure_ascii=False).encode('utf-8')
|
|
||||||
body_len = len(body)
|
|
||||||
checksum = body_len + msg_type
|
|
||||||
header = struct.pack(">III", body_len, msg_type, checksum)
|
|
||||||
return header + body
|
|
||||||
|
|
||||||
|
|
||||||
def parse_packet(data: bytes):
|
|
||||||
"""解析二进制数据包"""
|
|
||||||
if len(data) < 12:
|
|
||||||
return None, None
|
|
||||||
body_len, msg_type, checksum = struct.unpack(">III", data[:12])
|
|
||||||
body = data[12:12 + body_len]
|
|
||||||
try:
|
|
||||||
# ✅ 显式指定 UTF-8 编码
|
|
||||||
return msg_type, json.loads(body.decode('utf-8'))
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 解析包体失败: {e}")
|
|
||||||
return msg_type, {"raw": body.decode('utf-8', errors='ignore')}
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_send_raw(data: bytes, max_retries=2) -> bool:
|
|
||||||
"""通过 4G 模块发送原始 TCP 数据(仅在 tcp_main 线程调用)"""
|
|
||||||
global tcp_connected
|
|
||||||
if not tcp_connected:
|
|
||||||
return False
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
cmd = f'AT+MIPSEND=0,{len(data)}'
|
|
||||||
if ">" not in at(cmd, ">", 1500):
|
|
||||||
time.sleep_ms(100)
|
|
||||||
continue
|
|
||||||
|
|
||||||
time.sleep_ms(10)
|
|
||||||
full = data + b"\x1A"
|
|
||||||
try:
|
|
||||||
sent = uart4g.write(full)
|
|
||||||
if sent != len(full):
|
|
||||||
time.sleep_ms(100)
|
|
||||||
continue
|
|
||||||
except:
|
|
||||||
time.sleep_ms(100)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if "OK" in at("", "OK", 1000):
|
|
||||||
return True
|
|
||||||
time.sleep_ms(100)
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def load_laser_point():
|
|
||||||
"""从配置文件加载激光点坐标"""
|
|
||||||
global laser_point
|
|
||||||
try:
|
|
||||||
if "laser_config.json" in os.listdir("/root"):
|
|
||||||
with open(CONFIG_FILE, "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if isinstance(data, list) and len(data) == 2:
|
|
||||||
laser_point = (int(data[0]), int(data[1]))
|
|
||||||
print(f"[INFO] 加载激光点: {laser_point}")
|
|
||||||
else:
|
|
||||||
raise ValueError
|
|
||||||
else:
|
|
||||||
laser_point = DEFAULT_POINT
|
|
||||||
except:
|
|
||||||
laser_point = DEFAULT_POINT
|
|
||||||
|
|
||||||
|
|
||||||
def save_laser_point(point):
|
|
||||||
"""保存激光点坐标到文件"""
|
|
||||||
global laser_point
|
|
||||||
try:
|
|
||||||
with open(CONFIG_FILE, "w") as f:
|
|
||||||
json.dump([point[0], point[1]], f)
|
|
||||||
laser_point = point
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def turn_on_laser():
|
|
||||||
"""发送激光开启指令"""
|
|
||||||
distance_serial.write(LASER_ON_CMD)
|
|
||||||
time.sleep_ms(10)
|
|
||||||
resp = distance_serial.read(20)
|
|
||||||
if resp:
|
|
||||||
if resp == LASER_ON_CMD:
|
|
||||||
print("✅ 激光指令已确认")
|
|
||||||
else:
|
|
||||||
print("🔇 无回包(正常或模块不支持)")
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# M01 激光测距模块
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
def parse_bcd_distance(bcd_bytes: bytes) -> float:
|
|
||||||
"""将 4 字节 BCD 码转换为距离(米)"""
|
|
||||||
if len(bcd_bytes) != 4:
|
|
||||||
return 0.0
|
|
||||||
try:
|
|
||||||
hex_string = binascii.hexlify(bcd_bytes).decode()
|
|
||||||
distance_int = int(hex_string)
|
|
||||||
return distance_int / 1000.0
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] BCD 解析失败: {e}")
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def read_distance_from_laser_sensor():
|
|
||||||
"""发送测距指令并返回距离(米)"""
|
|
||||||
global distance_serial
|
|
||||||
try:
|
|
||||||
distance_serial.read() # 清空缓冲区
|
|
||||||
distance_serial.write(DISTANCE_QUERY_CMD)
|
|
||||||
time.sleep_ms(500)
|
|
||||||
response = distance_serial.read(DISTANCE_RESPONSE_LEN)
|
|
||||||
|
|
||||||
if response and len(response) == DISTANCE_RESPONSE_LEN:
|
|
||||||
if response[3] != 0x20:
|
|
||||||
if response[0] == 0xEE:
|
|
||||||
err_code = (response[7] << 8) | response[8]
|
|
||||||
print(f"[LASER] 模块错误代码: {hex(err_code)}")
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
bcd_bytes = response[6:10]
|
|
||||||
distance_value_m = parse_bcd_distance(bcd_bytes)
|
|
||||||
signal_quality = (response[10] << 8) | response[11]
|
|
||||||
print(f"[LASER] 测距成功: {distance_value_m:.3f} m, 信号质量: {signal_quality}")
|
|
||||||
return distance_value_m
|
|
||||||
|
|
||||||
print(f"[LASER] 无效响应: {response.hex() if response else 'None'}")
|
|
||||||
return 0.0
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 读取激光测距失败: {e}")
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# 激光点校准
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
def find_red_laser(frame, threshold=150):
|
|
||||||
"""在图像中查找最亮的红色点(简单 RGB 判定)"""
|
|
||||||
w, h = frame.width(), frame.height()
|
|
||||||
img_bytes = frame.to_bytes()
|
|
||||||
max_sum = 0
|
|
||||||
best_pos = None
|
|
||||||
for y in range(0, h, 2):
|
|
||||||
for x in range(0, w, 2):
|
|
||||||
idx = (y * w + x) * 3
|
|
||||||
r, g, b = img_bytes[idx], img_bytes[idx+1], img_bytes[idx+2]
|
|
||||||
if r > threshold and r > g * 2 and r > b * 2:
|
|
||||||
rgb_sum = r + g + b
|
|
||||||
if rgb_sum > max_sum:
|
|
||||||
max_sum = rgb_sum
|
|
||||||
best_pos = (x, y)
|
|
||||||
return best_pos
|
|
||||||
|
|
||||||
|
|
||||||
def calibrate_laser_position():
|
|
||||||
"""拍摄一帧并识别激光点位置"""
|
|
||||||
time.sleep_ms(80)
|
|
||||||
cam = camera.Camera(640, 480)
|
|
||||||
frame = cam.read()
|
|
||||||
pos = find_red_laser(frame)
|
|
||||||
if pos:
|
|
||||||
save_laser_point(pos)
|
|
||||||
return pos
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# 电量监测(INA226)
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
def write_register(reg, value):
|
|
||||||
data = [(value >> 8) & 0xFF, value & 0xFF]
|
|
||||||
bus.writeto_mem(INA226_ADDR, reg, bytes(data))
|
|
||||||
|
|
||||||
|
|
||||||
def read_register(reg):
|
|
||||||
data = bus.readfrom_mem(INA226_ADDR, reg, 2)
|
|
||||||
return (data[0] << 8) | data[1]
|
|
||||||
|
|
||||||
|
|
||||||
def init_ina226():
|
|
||||||
write_register(REG_CONFIGURATION, 0x4527)
|
|
||||||
write_register(REG_CALIBRATION, CALIBRATION_VALUE)
|
|
||||||
|
|
||||||
|
|
||||||
def get_bus_voltage():
|
|
||||||
raw = read_register(REG_BUS_VOLTAGE)
|
|
||||||
return raw * 1.25 / 1000
|
|
||||||
|
|
||||||
|
|
||||||
def voltage_to_percent(voltage):
|
|
||||||
points = [
|
|
||||||
(4.20, 100), (4.10, 95), (4.05, 85), (4.00, 75), (3.95, 65),
|
|
||||||
(3.90, 55), (3.85, 45), (3.80, 35), (3.75, 25), (3.70, 15),
|
|
||||||
(3.65, 5), (3.60, 0)
|
|
||||||
]
|
|
||||||
if voltage >= points[0][0]: return 100
|
|
||||||
if voltage <= points[-1][0]: return 0
|
|
||||||
for i in range(len(points) - 1):
|
|
||||||
v1, p1 = points[i]; v2, p2 = points[i + 1]
|
|
||||||
if v2 <= voltage <= v1:
|
|
||||||
ratio = (voltage - v1) / (v2 - v1)
|
|
||||||
percent = p1 + (p2 - p1) * ratio
|
|
||||||
return max(0, min(100, int(round(percent))))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# 目标检测
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
def detect_circle(frame):
|
|
||||||
"""检测靶心圆(清晰/模糊两种模式)"""
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
|
|
||||||
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
||||||
edged = cv2.Canny(blurred, 50, 150)
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
best_center = best_radius = method = None
|
|
||||||
|
|
||||||
for cnt in contours:
|
|
||||||
area = cv2.contourArea(cnt)
|
|
||||||
perimeter = cv2.arcLength(cnt, True)
|
|
||||||
if perimeter < 100 or area < 100: continue
|
|
||||||
circularity = 4 * np.pi * area / (perimeter ** 2)
|
|
||||||
if circularity > 0.75 and len(cnt) >= 5:
|
|
||||||
center, axes, angle = cv2.fitEllipse(cnt)
|
|
||||||
radius = (axes[0] + axes[1]) / 4
|
|
||||||
best_center = (int(center[0]), int(center[1]))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "清晰"
|
|
||||||
break
|
|
||||||
|
|
||||||
if not best_center:
|
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
s = np.clip(s * 2, 0, 255).astype(np.uint8)
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
lower_yellow = np.array([7, 80, 0])
|
|
||||||
upper_yellow = np.array([32, 255, 182])
|
|
||||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
|
||||||
mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
|
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
if contours:
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
if cv2.contourArea(largest) > 50:
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "模糊"
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius
|
|
||||||
|
|
||||||
|
|
||||||
def compute_laser_position(circle_center, laser_point, radius, method):
|
|
||||||
"""计算激光相对于靶心的偏差(单位:厘米)"""
|
|
||||||
if not all([circle_center, radius, method]):
|
|
||||||
return None, None
|
|
||||||
cx, cy = circle_center
|
|
||||||
lx, ly = laser_point
|
|
||||||
# 根据检测模式估算实际半径(单位:像素 → 厘米)
|
|
||||||
circle_r_cm = (radius / 4.0) * 20.0 if method == "模糊" else (68 / 16.0) * 20.0
|
|
||||||
dx = lx - cx
|
|
||||||
dy = ly - cy
|
|
||||||
scale = circle_r_cm / radius if radius != 0 else 1.0
|
|
||||||
return dx * scale, -dy * scale
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# TCP 通信主线程
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
def connect_server():
|
|
||||||
"""连接服务器(通过 4G 模块 AT 指令)"""
|
|
||||||
global tcp_connected
|
|
||||||
if tcp_connected:
|
|
||||||
return True
|
|
||||||
print("正在连接服务器...")
|
|
||||||
at("AT+MIPCLOSE=0", "OK", 1000)
|
|
||||||
res = at(f'AT+MIPOPEN=0,"TCP","{SERVER_IP}",{SERVER_PORT}', "+MIPOPEN", 8000)
|
|
||||||
if "+MIPOPEN: 0,0" in res:
|
|
||||||
tcp_connected = True
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_main():
|
|
||||||
"""TCP 通信主循环(独立线程)"""
|
|
||||||
global tcp_connected, send_queue, laser_calibration_active, laser_calibration_result,update_thread_started
|
|
||||||
|
|
||||||
while not app.need_exit():
|
|
||||||
if not connect_server():
|
|
||||||
time.sleep_ms(5000)
|
|
||||||
continue
|
|
||||||
|
|
||||||
login_data = {"deviceId": DEVICE_ID, "password": PASSWORD}
|
|
||||||
if not tcp_send_raw(make_packet(MSG_TYPE_LOGIN_REQ, login_data)):
|
|
||||||
tcp_connected = False
|
|
||||||
time.sleep_ms(2000)
|
|
||||||
continue
|
|
||||||
|
|
||||||
print("➡️ 登录包已发送,等待确认...")
|
|
||||||
logged_in = False
|
|
||||||
last_heartbeat_ack_time = time.ticks_ms()
|
|
||||||
last_heartbeat_send_time = time.ticks_ms()
|
|
||||||
rx_buf = b""
|
|
||||||
|
|
||||||
while True:
|
|
||||||
data = uart4g.read()
|
|
||||||
if data:
|
|
||||||
rx_buf += data
|
|
||||||
while b'+MIPURC: "rtcp"' in rx_buf:
|
|
||||||
try:
|
|
||||||
match = re.search(b'\+MIPURC: "rtcp",0,(\d+),(.+)', rx_buf, re.DOTALL)
|
|
||||||
if match:
|
|
||||||
payload_len = int(match.group(1))
|
|
||||||
payload = match.group(2)[:payload_len]
|
|
||||||
msg_type, body = parse_packet(payload)
|
|
||||||
|
|
||||||
if not logged_in and msg_type == MSG_TYPE_LOGIN_REQ:
|
|
||||||
if body and body.get("cmd") == 1 and body.get("data") == "登录成功":
|
|
||||||
logged_in = True
|
|
||||||
last_heartbeat_ack_time = time.ticks_ms()
|
|
||||||
print("✅ 登录成功")
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
elif logged_in and msg_type == MSG_TYPE_HEARTBEAT:
|
|
||||||
last_heartbeat_ack_time = time.ticks_ms()
|
|
||||||
print("✅ 收到心跳确认")
|
|
||||||
|
|
||||||
elif logged_in and isinstance(body, dict):
|
|
||||||
inner_data = body.get("data", {})
|
|
||||||
if isinstance(inner_data, dict) and "cmd" in inner_data:
|
|
||||||
inner_cmd = inner_data["cmd"]
|
|
||||||
if inner_cmd == 2:
|
|
||||||
turn_on_laser()
|
|
||||||
time.sleep_ms(100)
|
|
||||||
laser_calibration_active = True
|
|
||||||
safe_enqueue({"result": "calibrating"}, MSG_TYPE_STATUS)
|
|
||||||
elif inner_cmd == 3:
|
|
||||||
distance_serial.write(LASER_OFF_CMD)
|
|
||||||
laser_calibration_active = False
|
|
||||||
safe_enqueue({"result": "laser_off"}, MSG_TYPE_STATUS)
|
|
||||||
elif inner_cmd == 4:
|
|
||||||
voltage = get_bus_voltage()
|
|
||||||
battery_percent = voltage_to_percent(voltage)
|
|
||||||
battery_data = {"battery": battery_percent, "voltage": round(voltage, 3)}
|
|
||||||
safe_enqueue(battery_data, MSG_TYPE_STATUS)
|
|
||||||
elif inner_cmd == 5:
|
|
||||||
ssid = inner_data.get("ssid")
|
|
||||||
password = inner_data.get("password")
|
|
||||||
if not ssid or not password:
|
|
||||||
safe_enqueue({"result": "missing_ssid_or_password"}, MSG_TYPE_STATUS)
|
|
||||||
else:
|
|
||||||
# global update_thread_started
|
|
||||||
if not update_thread_started:
|
|
||||||
update_thread_started = True
|
|
||||||
_thread.start_new_thread(handle_wifi_and_update, (ssid, password))
|
|
||||||
else:
|
|
||||||
safe_enqueue({"result": "update_already_started"}, MSG_TYPE_STATUS)
|
|
||||||
elif inner_cmd == 6:
|
|
||||||
try:
|
|
||||||
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
|
||||||
ip = ip if ip else "no_ip"
|
|
||||||
except:
|
|
||||||
ip = "error_getting_ip"
|
|
||||||
safe_enqueue({"result": "current_ip", "ip": ip}, MSG_TYPE_STATUS)
|
|
||||||
|
|
||||||
elif inner_cmd == 7:
|
|
||||||
# global update_thread_started
|
|
||||||
if update_thread_started:
|
|
||||||
safe_enqueue({"result": "update_already_started"}, MSG_TYPE_STATUS)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 实时检查是否有 IP
|
|
||||||
try:
|
|
||||||
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
|
||||||
except:
|
|
||||||
ip = None
|
|
||||||
|
|
||||||
if not ip:
|
|
||||||
safe_enqueue({"result": "ota_rejected", "reason": "no_wifi_ip"}, MSG_TYPE_STATUS)
|
|
||||||
else:
|
|
||||||
# 启动纯下载线程
|
|
||||||
update_thread_started = True
|
|
||||||
_thread.start_new_thread(direct_ota_download, ())
|
|
||||||
rx_buf = rx_buf[match.end():]
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 解析/处理数据包失败: {e}")
|
|
||||||
rx_buf = b""
|
|
||||||
break
|
|
||||||
|
|
||||||
# 发送队列处理
|
|
||||||
msg_type = None
|
|
||||||
if logged_in:
|
|
||||||
with send_queue_lock:
|
|
||||||
if send_queue:
|
|
||||||
msg_type, data_dict = send_queue.pop(0)
|
|
||||||
if msg_type is not None:
|
|
||||||
pkt = make_packet(msg_type, data_dict)
|
|
||||||
if not tcp_send_raw(pkt):
|
|
||||||
print("💔 发送失败,断开重连")
|
|
||||||
break
|
|
||||||
|
|
||||||
# 校准结果上报
|
|
||||||
if logged_in:
|
|
||||||
x = y = None
|
|
||||||
with laser_calibration_data_lock:
|
|
||||||
if laser_calibration_result is not None:
|
|
||||||
x, y = laser_calibration_result
|
|
||||||
laser_calibration_result = None
|
|
||||||
if x is not None:
|
|
||||||
safe_enqueue({"result": "ok", "x": x, "y": y}, MSG_TYPE_STATUS)
|
|
||||||
|
|
||||||
# 心跳机制
|
|
||||||
current_time = time.ticks_ms()
|
|
||||||
if logged_in and current_time - last_heartbeat_send_time > HEARTBEAT_INTERVAL * 1000:
|
|
||||||
if not tcp_send_raw(make_packet(MSG_TYPE_HEARTBEAT, {"t": int(time.time())})):
|
|
||||||
print("💔 心跳发送失败")
|
|
||||||
break
|
|
||||||
last_heartbeat_send_time = current_time
|
|
||||||
|
|
||||||
if logged_in and current_time - last_heartbeat_ack_time > 6000:
|
|
||||||
print("⏰ 6秒无心跳ACK,重连")
|
|
||||||
break
|
|
||||||
|
|
||||||
time.sleep_ms(50)
|
|
||||||
|
|
||||||
tcp_connected = False
|
|
||||||
time.sleep_ms(2000)
|
|
||||||
|
|
||||||
|
|
||||||
def laser_calibration_worker():
|
|
||||||
"""后台激光校准线程"""
|
|
||||||
global laser_calibration_active, laser_calibration_result
|
|
||||||
while True:
|
|
||||||
if laser_calibration_active:
|
|
||||||
result = calibrate_laser_position()
|
|
||||||
if result and len(result) == 2:
|
|
||||||
with laser_calibration_data_lock:
|
|
||||||
laser_calibration_result = result
|
|
||||||
laser_calibration_active = False
|
|
||||||
print(f"✅ 后台校准成功: {result}")
|
|
||||||
else:
|
|
||||||
time.sleep_ms(80)
|
|
||||||
else:
|
|
||||||
time.sleep_ms(50)
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# 主程序入口
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
def cmd_str():
|
|
||||||
global DEVICE_ID, PASSWORD
|
|
||||||
DEVICE_ID = read_device_id()
|
|
||||||
PASSWORD = DEVICE_ID + "."
|
|
||||||
|
|
||||||
photo_dir = "/root/phot"
|
|
||||||
if photo_dir not in os.listdir("/root"):
|
|
||||||
try:
|
|
||||||
os.mkdir(photo_dir)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
init_ina226()
|
|
||||||
load_laser_point()
|
|
||||||
|
|
||||||
disp = display.Display()
|
|
||||||
cam = camera.Camera(640, 480)
|
|
||||||
|
|
||||||
_thread.start_new_thread(tcp_main, ())
|
|
||||||
_thread.start_new_thread(laser_calibration_worker, ())
|
|
||||||
|
|
||||||
print("系统准备完成...")
|
|
||||||
|
|
||||||
while not app.need_exit():
|
|
||||||
if adc_obj.read() > ADC_TRIGGER_THRESHOLD:
|
|
||||||
time.sleep_ms(60)
|
|
||||||
frame = cam.read()
|
|
||||||
|
|
||||||
x, y = laser_point
|
|
||||||
frame.draw_line(int(x - length), int(y), int(x + length), int(y), color, thickness)
|
|
||||||
frame.draw_line(int(x), int(y - length), int(x), int(y + length), color, thickness)
|
|
||||||
frame.draw_circle(int(x), int(y), 1, color, thickness)
|
|
||||||
|
|
||||||
result_img, center, radius, method, _ = detect_circle(frame)
|
|
||||||
disp.show(result_img)
|
|
||||||
|
|
||||||
dx, dy = compute_laser_position(center, (x, y), radius, method)
|
|
||||||
distance_m = read_distance_from_laser_sensor()
|
|
||||||
voltage = get_bus_voltage()
|
|
||||||
battery_percent = voltage_to_percent(voltage)
|
|
||||||
|
|
||||||
try:
|
|
||||||
jpg_count = len([f for f in os.listdir(photo_dir) if f.endswith('.jpg')])
|
|
||||||
filename = f"{photo_dir}/{int(x)}_{int(y)}_{round((distance_m or 0.0) * 100)}_{method}_{jpg_count:04d}.jpg"
|
|
||||||
result_img.save(filename, quality=70)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 保存照片失败: {e}")
|
|
||||||
|
|
||||||
inner_data = {
|
|
||||||
"x": float(dx) if dx is not None else 200.0,
|
|
||||||
"y": float(dy) if dy is not None else 200.0,
|
|
||||||
"r": 90.0,
|
|
||||||
"d": round((distance_m or 0.0) * 100),
|
|
||||||
"m": method
|
|
||||||
}
|
|
||||||
report_data = {"cmd": 1, "data": inner_data}
|
|
||||||
safe_enqueue(report_data, MSG_TYPE_STATUS)
|
|
||||||
|
|
||||||
time.sleep_ms(100)
|
|
||||||
else:
|
|
||||||
disp.show(cam.read())
|
|
||||||
time.sleep_ms(50)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
cmd_str()
|
|
||||||
+1272
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
日志管理器模块
|
||||||
|
提供异步日志功能(使用 QueueHandler + QueueListener)
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
|
||||||
|
import queue
|
||||||
|
import os
|
||||||
|
import config
|
||||||
|
from version import VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class LoggerManager:
|
||||||
|
"""日志管理器(单例)"""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(LoggerManager, cls).__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 私有状态
|
||||||
|
self._log_queue = None
|
||||||
|
self._queue_listener = None
|
||||||
|
self._logger = None
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
# ==================== 状态访问(只读属性)====================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logger(self):
|
||||||
|
"""获取logger对象(只读)"""
|
||||||
|
return self._logger
|
||||||
|
|
||||||
|
@property
|
||||||
|
def log_queue(self):
|
||||||
|
"""获取日志队列(只读)"""
|
||||||
|
return self._log_queue
|
||||||
|
|
||||||
|
# ==================== 业务方法 ====================
|
||||||
|
|
||||||
|
def init_logging(self, log_level=logging.INFO, log_file=None, max_bytes=None, backup_count=None):
|
||||||
|
"""
|
||||||
|
初始化异步日志系统(使用 QueueHandler + QueueListener)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_level: 日志级别,默认 INFO
|
||||||
|
log_file: 日志文件路径,默认使用 config.LOG_FILE
|
||||||
|
max_bytes: 单个日志文件最大大小(字节),默认使用 config.LOG_MAX_BYTES
|
||||||
|
backup_count: 保留的备份文件数量,默认使用 config.LOG_BACKUP_COUNT
|
||||||
|
"""
|
||||||
|
if log_file is None:
|
||||||
|
log_file = config.LOG_FILE
|
||||||
|
if max_bytes is None:
|
||||||
|
max_bytes = config.LOG_MAX_BYTES
|
||||||
|
if backup_count is None:
|
||||||
|
backup_count = config.LOG_BACKUP_COUNT
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 创建日志队列(无界队列)
|
||||||
|
self._log_queue = queue.Queue(-1)
|
||||||
|
|
||||||
|
# 确保日志文件所在的目录存在
|
||||||
|
log_dir = os.path.dirname(log_file)
|
||||||
|
if log_dir: # 如果日志路径包含目录
|
||||||
|
try:
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 无法创建日志目录 {log_dir}: {e}")
|
||||||
|
|
||||||
|
# 尝试创建文件Handler(带日志轮转)
|
||||||
|
try:
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
log_file,
|
||||||
|
maxBytes=max_bytes,
|
||||||
|
backupCount=backup_count,
|
||||||
|
encoding='utf-8',
|
||||||
|
mode='a' # 追加模式,确保不覆盖
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 如果RotatingFileHandler不可用,降级为普通FileHandler
|
||||||
|
print(f"[WARN] RotatingFileHandler不可用,使用普通FileHandler: {e}")
|
||||||
|
try:
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding='utf-8', mode='a')
|
||||||
|
except Exception as e2:
|
||||||
|
# 如果文件Handler创建失败,只使用控制台Handler
|
||||||
|
print(f"[WARN] 无法创建文件Handler,仅使用控制台输出: {e2}")
|
||||||
|
file_handler = None
|
||||||
|
|
||||||
|
# 自定义Formatter,包含版本信息
|
||||||
|
class CustomFormatter(logging.Formatter):
|
||||||
|
"""自定义日志格式,包含版本信息和行号"""
|
||||||
|
def format(self, record):
|
||||||
|
record.version = VERSION
|
||||||
|
return super().format(record)
|
||||||
|
|
||||||
|
# 如果file_handler存在,设置格式和级别
|
||||||
|
if file_handler is not None:
|
||||||
|
file_handler.setFormatter(CustomFormatter(
|
||||||
|
'%(asctime)s [v%(version)s] [%(levelname)s] %(filename)s:%(lineno)d - %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
|
))
|
||||||
|
file_handler.setLevel(log_level)
|
||||||
|
|
||||||
|
# 创建控制台Handler(保留原有的print输出)
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setFormatter(CustomFormatter(
|
||||||
|
'[v%(version)s] [%(levelname)s] %(filename)s:%(lineno)d - %(message)s'
|
||||||
|
))
|
||||||
|
console_handler.setLevel(log_level)
|
||||||
|
|
||||||
|
# 创建QueueListener(后台线程处理日志写入)
|
||||||
|
# 如果file_handler为None,只使用console_handler
|
||||||
|
handlers = [console_handler]
|
||||||
|
if file_handler is not None:
|
||||||
|
handlers.append(file_handler)
|
||||||
|
|
||||||
|
self._queue_listener = QueueListener(
|
||||||
|
self._log_queue,
|
||||||
|
*handlers,
|
||||||
|
respect_handler_level=True
|
||||||
|
)
|
||||||
|
self._queue_listener.start()
|
||||||
|
|
||||||
|
# 创建QueueHandler(用于记录日志)
|
||||||
|
queue_handler = QueueHandler(self._log_queue)
|
||||||
|
|
||||||
|
# 配置根logger
|
||||||
|
self._logger = logging.getLogger()
|
||||||
|
self._logger.addHandler(queue_handler)
|
||||||
|
self._logger.setLevel(log_level)
|
||||||
|
|
||||||
|
# 避免日志向上传播到其他logger
|
||||||
|
self._logger.propagate = False
|
||||||
|
|
||||||
|
# 添加启动标记
|
||||||
|
self._logger.info("=" * 60)
|
||||||
|
self._logger.info("程序启动 - 日志系统初始化")
|
||||||
|
self._logger.info(f"版本: {VERSION}")
|
||||||
|
self._logger.info(f"日志文件: {log_file}")
|
||||||
|
self._logger.info("=" * 60)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
# 如果日志初始化失败,至少保证程序能运行
|
||||||
|
print(f"[ERROR] 日志系统初始化失败: {e}")
|
||||||
|
import traceback
|
||||||
|
try:
|
||||||
|
traceback.print_exc()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_logging(self):
|
||||||
|
"""停止日志系统(程序退出时调用)"""
|
||||||
|
try:
|
||||||
|
if self._logger:
|
||||||
|
# 确保所有日志都写入
|
||||||
|
self._logger.info("程序退出,正在保存日志...")
|
||||||
|
import time as std_time
|
||||||
|
std_time.sleep(0.5) # 给一点时间让日志写入
|
||||||
|
|
||||||
|
if self._queue_listener:
|
||||||
|
self._queue_listener.stop()
|
||||||
|
|
||||||
|
if self._logger:
|
||||||
|
# 等待队列中的日志处理完成
|
||||||
|
if self._log_queue:
|
||||||
|
import time as std_time
|
||||||
|
timeout = 5
|
||||||
|
start = std_time.time()
|
||||||
|
while not self._log_queue.empty() and (std_time.time() - start) < timeout:
|
||||||
|
std_time.sleep(0.1)
|
||||||
|
print("[LOG] 日志系统已停止")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 停止日志系统失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局单例实例
|
||||||
|
logger_manager = LoggerManager()
|
||||||
|
|
||||||
|
# ==================== 向后兼容的函数接口 ====================
|
||||||
|
|
||||||
|
def init_logging(log_level=logging.INFO, log_file=None, max_bytes=None, backup_count=None):
|
||||||
|
"""初始化日志系统(向后兼容接口)"""
|
||||||
|
return logger_manager.init_logging(log_level, log_file, max_bytes, backup_count)
|
||||||
|
|
||||||
|
def stop_logging():
|
||||||
|
"""停止日志系统(向后兼容接口)"""
|
||||||
|
return logger_manager.stop_logging()
|
||||||
|
|
||||||
|
def get_logger():
|
||||||
|
"""
|
||||||
|
获取全局logger对象(向后兼容接口)
|
||||||
|
如果日志系统未初始化,返回None(此时可以使用print作为fallback)
|
||||||
|
"""
|
||||||
|
return logger_manager.logger
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+1692
File diff suppressed because it is too large
Load Diff
+1338
File diff suppressed because it is too large
Load Diff
+230
@@ -0,0 +1,230 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
应用打包脚本
|
||||||
|
根据 app.yaml 中列出的文件,打包成 zip 文件
|
||||||
|
版本号从 version.py 中读取
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
import sys
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
MAGIC = b"AROTAE1" # 7 bytes: Archery OTA Encrypted v1
|
||||||
|
GCM_NONCE_LEN = 12
|
||||||
|
GCM_TAG_LEN = 16
|
||||||
|
|
||||||
|
# 添加当前目录到路径,以便导入 version 模块
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def load_app_yaml(yaml_path='app.yaml'):
|
||||||
|
"""加载 app.yaml 文件"""
|
||||||
|
try:
|
||||||
|
with open(yaml_path, 'r', encoding='utf-8') as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 读取 {yaml_path} 失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_files_exist(files, base_dir='.'):
|
||||||
|
"""检查文件是否存在"""
|
||||||
|
missing_files = []
|
||||||
|
existing_files = []
|
||||||
|
|
||||||
|
for file_path in files:
|
||||||
|
full_path = os.path.join(base_dir, file_path)
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
existing_files.append(file_path)
|
||||||
|
else:
|
||||||
|
missing_files.append(file_path)
|
||||||
|
|
||||||
|
return existing_files, missing_files
|
||||||
|
|
||||||
|
|
||||||
|
def get_version_from_version_py():
|
||||||
|
"""从 version.py 读取版本号"""
|
||||||
|
try:
|
||||||
|
from version import VERSION
|
||||||
|
return VERSION
|
||||||
|
except ImportError:
|
||||||
|
print("[WARNING] 无法导入 version.py,使用默认版本号 1.0.0")
|
||||||
|
return '1.0.0'
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] 读取 version.py 失败: {e},使用默认版本号 1.0.0")
|
||||||
|
return '1.0.0'
|
||||||
|
|
||||||
|
|
||||||
|
def create_zip_package(app_info, files, output_dir='.', base_dir='.'):
|
||||||
|
"""创建 zip 打包文件"""
|
||||||
|
# 生成输出文件名:{name}_v{version}_{timestamp}.zip
|
||||||
|
# 版本号从 version.py 读取,而不是从 app.yaml
|
||||||
|
app_name = app_info.get('name', 'app')
|
||||||
|
version = get_version_from_version_py() # 从 version.py 读取版本号
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
zip_filename = f"{app_name}_v{version}_{timestamp}.zip"
|
||||||
|
zip_path = os.path.join(output_dir, zip_filename)
|
||||||
|
|
||||||
|
print(f"[INFO] 开始打包: {zip_filename}")
|
||||||
|
print(f"[INFO] 包含文件数: {len(files)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for file_path in files:
|
||||||
|
full_path = os.path.join(base_dir, file_path)
|
||||||
|
# 使用相对路径作为 zip 内的路径
|
||||||
|
zipf.write(full_path, file_path)
|
||||||
|
print(f" ✓ {file_path}")
|
||||||
|
|
||||||
|
# 获取文件大小
|
||||||
|
file_size = os.path.getsize(zip_path)
|
||||||
|
file_size_mb = file_size / (1024 * 1024)
|
||||||
|
|
||||||
|
print(f"\n[SUCCESS] 打包完成!")
|
||||||
|
print(f" 文件名: {zip_filename}")
|
||||||
|
print(f" 文件大小: {file_size_mb:.2f} MB ({file_size:,} 字节)")
|
||||||
|
print(f" 文件路径: {os.path.abspath(zip_path)}")
|
||||||
|
|
||||||
|
return zip_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 打包失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_key_hex(key_hex: str) -> bytes:
|
||||||
|
if not isinstance(key_hex, str):
|
||||||
|
raise ValueError("aead key must be hex string")
|
||||||
|
key_hex = key_hex.strip().lower()
|
||||||
|
if key_hex.startswith("0x"):
|
||||||
|
key_hex = key_hex[2:]
|
||||||
|
if len(key_hex) != 64:
|
||||||
|
raise ValueError("aead key must be 64 hex chars (32 bytes)")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(key_hex)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"invalid hex key: {e}")
|
||||||
|
if len(key) != 32:
|
||||||
|
raise ValueError("aead key must be 32 bytes")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_zip_aead(zip_path: str, key_hex: str, out_ext: str = ".enc") -> str:
|
||||||
|
"""
|
||||||
|
Encrypt the whole zip file as one blob:
|
||||||
|
output format: MAGIC(7) | nonce(12) | ciphertext(N) | tag(16)
|
||||||
|
using AES-256-GCM (AEAD).
|
||||||
|
"""
|
||||||
|
# Lazy import: packaging-only dependency
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Missing dependency: cryptography. Install with: pip install cryptography. "
|
||||||
|
f"Import error: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
key = _validate_key_hex(key_hex)
|
||||||
|
with open(zip_path, "rb") as f:
|
||||||
|
plain = f.read()
|
||||||
|
|
||||||
|
nonce = secrets.token_bytes(GCM_NONCE_LEN)
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
ct_and_tag = aesgcm.encrypt(nonce, plain, None) # ciphertext || tag (16 bytes)
|
||||||
|
|
||||||
|
enc_path = zip_path + out_ext if out_ext else (zip_path + ".enc")
|
||||||
|
with open(enc_path, "wb") as f:
|
||||||
|
f.write(MAGIC)
|
||||||
|
f.write(nonce)
|
||||||
|
f.write(ct_and_tag)
|
||||||
|
|
||||||
|
return enc_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
parser = argparse.ArgumentParser(description="打包 app.yaml 文件列表到 zip,并可选进行 AES-256-GCM 加密输出 .enc")
|
||||||
|
parser.add_argument("--aead-key-hex", default=None, help="AES-256-GCM key (64 hex chars = 32 bytes). If set, output encrypted file.")
|
||||||
|
parser.add_argument("--keep-zip", action="store_true", help="Keep the plaintext zip when encryption is enabled.")
|
||||||
|
parser.add_argument("--out-ext", default=".enc", help="Encrypted output extension appended to zip path. Default: .enc (produces *.zip.enc)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("应用打包脚本")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. 加载 app.yaml
|
||||||
|
app_info = load_app_yaml('app.yaml')
|
||||||
|
if app_info is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 从 version.py 读取版本号
|
||||||
|
version = get_version_from_version_py()
|
||||||
|
|
||||||
|
print(f"\n[INFO] 应用信息:")
|
||||||
|
print(f" ID: {app_info.get('id', 'N/A')}")
|
||||||
|
print(f" 名称: {app_info.get('name', 'N/A')}")
|
||||||
|
print(f" 版本: {version} (来自 version.py)")
|
||||||
|
print(f" 作者: {app_info.get('author', 'N/A')}")
|
||||||
|
if app_info.get('version') != version:
|
||||||
|
print(f" [注意] app.yaml 中的版本 ({app_info.get('version', 'N/A')}) 与 version.py 不一致")
|
||||||
|
|
||||||
|
# 2. 获取文件列表
|
||||||
|
files = app_info.get('files', [])
|
||||||
|
if not files:
|
||||||
|
print("[ERROR] app.yaml 中没有找到 files 列表")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n[INFO] 文件列表 ({len(files)} 个文件):")
|
||||||
|
|
||||||
|
# 3. 检查文件是否存在
|
||||||
|
existing_files, missing_files = check_files_exist(files)
|
||||||
|
|
||||||
|
if missing_files:
|
||||||
|
print(f"\n[WARNING] 以下文件不存在,将被跳过:")
|
||||||
|
for f in missing_files:
|
||||||
|
print(f" ✗ {f}")
|
||||||
|
|
||||||
|
if not existing_files:
|
||||||
|
print("\n[ERROR] 没有找到任何有效文件,无法打包")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n[INFO] 找到 {len(existing_files)} 个有效文件")
|
||||||
|
|
||||||
|
# 4. 创建 zip 包
|
||||||
|
zip_path = create_zip_package(app_info, existing_files)
|
||||||
|
|
||||||
|
if zip_path:
|
||||||
|
enc_path = None
|
||||||
|
if args.aead_key_hex:
|
||||||
|
try:
|
||||||
|
enc_path = encrypt_zip_aead(zip_path, args.aead_key_hex, out_ext=args.out_ext)
|
||||||
|
enc_size = os.path.getsize(enc_path)
|
||||||
|
print(f"\n[SUCCESS] AEAD加密完成: {os.path.basename(enc_path)} ({enc_size:,} bytes)")
|
||||||
|
print(f" 文件路径: {os.path.abspath(enc_path)}")
|
||||||
|
if not args.keep_zip:
|
||||||
|
try:
|
||||||
|
os.remove(zip_path)
|
||||||
|
print(f"[INFO] 已删除明文zip: {os.path.basename(zip_path)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] 删除明文zip失败(可忽略): {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] AEAD加密失败: {e}")
|
||||||
|
print("[ERROR] 保留明文zip用于排查。")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("打包成功完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
else:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("打包失败!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
电源管理模块(INA226)
|
||||||
|
提供电压、电流监测和充电状态检测
|
||||||
|
"""
|
||||||
|
import config
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
|
|
||||||
|
def write_register(reg, value):
|
||||||
|
"""写入INA226寄存器"""
|
||||||
|
from hardware import hardware_manager
|
||||||
|
data = [(value >> 8) & 0xFF, value & 0xFF]
|
||||||
|
hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
|
||||||
|
|
||||||
|
|
||||||
|
def read_register(reg):
|
||||||
|
"""读取INA226寄存器"""
|
||||||
|
from hardware import hardware_manager
|
||||||
|
data = hardware_manager.bus.readfrom_mem(config.INA226_ADDR, reg, 2)
|
||||||
|
return (data[0] << 8) | data[1]
|
||||||
|
|
||||||
|
|
||||||
|
def init_ina226():
|
||||||
|
"""初始化 INA226 芯片:配置模式 + 校准值"""
|
||||||
|
write_register(config.REG_CONFIGURATION, 0x4527)
|
||||||
|
write_register(config.REG_CALIBRATION, config.CALIBRATION_VALUE)
|
||||||
|
|
||||||
|
|
||||||
|
def get_bus_voltage():
|
||||||
|
"""读取总线电压(单位:V)"""
|
||||||
|
raw = read_register(config.REG_BUS_VOLTAGE)
|
||||||
|
return raw * 1.25 / 1000
|
||||||
|
|
||||||
|
|
||||||
|
def get_current():
|
||||||
|
"""
|
||||||
|
读取电流(单位:mA)
|
||||||
|
正数表示充电,负数表示放电
|
||||||
|
|
||||||
|
INA226 电流计算公式:
|
||||||
|
Current = (Current Register Value) × Current_LSB
|
||||||
|
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = read_register(config.REG_CURRENT)
|
||||||
|
# INA226 电流寄存器是16位有符号整数
|
||||||
|
# 最高位是符号位:0=正(充电),1=负(放电)
|
||||||
|
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
||||||
|
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
||||||
|
# 处理有符号数:如果最高位为1,转换为负数
|
||||||
|
if raw & 0x8000: # 最高位为1,表示负数(放电)
|
||||||
|
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||||
|
else: # 最高位为0,表示正数(充电)
|
||||||
|
signed_raw = raw
|
||||||
|
# 转换为毫安
|
||||||
|
current_ma = signed_raw * current_lsb * 1000
|
||||||
|
return current_ma
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[INA226] 读取电流失败: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[INA226] 读取电流失败: {e}")
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def is_charging(threshold_ma=10.0):
|
||||||
|
"""
|
||||||
|
检测是否在充电(通过电流方向判断)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
threshold_ma: 电流阈值(毫安),超过此值认为在充电,默认10mA
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True: 正在充电
|
||||||
|
False: 未充电或读取失败
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
current = get_current()
|
||||||
|
is_charge = current > threshold_ma
|
||||||
|
return is_charge
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[CHARGE] 检测充电状态失败: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[CHARGE] 检测充电状态失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def voltage_to_percent(voltage):
|
||||||
|
"""根据电压估算电池百分比(查表插值)"""
|
||||||
|
points = [
|
||||||
|
(4.20, 100), (4.10, 95), (4.05, 85), (4.00, 75), (3.95, 65),
|
||||||
|
(3.90, 55), (3.85, 45), (3.80, 35), (3.75, 25), (3.70, 15),
|
||||||
|
(3.65, 5), (3.60, 0)
|
||||||
|
]
|
||||||
|
if voltage >= points[0][0]:
|
||||||
|
return 100
|
||||||
|
if voltage <= points[-1][0]:
|
||||||
|
return 0
|
||||||
|
for i in range(len(points) - 1):
|
||||||
|
v1, p1 = points[i]
|
||||||
|
v2, p2 = points[i + 1]
|
||||||
|
if voltage >= v2:
|
||||||
|
ratio = (voltage - v1) / (v2 - v1)
|
||||||
|
percent = p1 + (p2 - p1) * ratio
|
||||||
|
return max(0, min(100, int(round(percent))))
|
||||||
|
return 0
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ def set_autostart_app(app_id):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
new_autostart_app_id = "t11" # change to app_id you want to set
|
new_autostart_app_id = "t11" # change to app_id you want to set
|
||||||
# new_autostart_app_id = None # remove autostart
|
# new_autostart_app_id = None # remove autostart
|
||||||
|
# new_autostart_app_id = "z1222" # change to app_id you want to set
|
||||||
|
|
||||||
list_apps()
|
list_apps()
|
||||||
print("Before set autostart appid:", get_curr_autostart_app())
|
print("Before set autostart appid:", get_curr_autostart_app())
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import config
|
||||||
|
from camera_manager import camera_manager
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
from network import network_manager
|
||||||
|
from power import get_bus_voltage, voltage_to_percent
|
||||||
|
from vision import estimate_distance, detect_circle_v3, save_shot_image
|
||||||
|
from maix import camera, display, image, app, time, uart, pinmap, i2c
|
||||||
|
|
||||||
|
def analyze_shot(frame, laser_point=None):
|
||||||
|
"""
|
||||||
|
分析射箭结果(算法部分,可迁移到C++)
|
||||||
|
:param frame: 图像帧
|
||||||
|
:param laser_point: 激光点坐标 (x, y)
|
||||||
|
:return: 包含分析结果的字典
|
||||||
|
"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
|
||||||
|
# 先检测靶心以获取距离(用于计算激光点)
|
||||||
|
result_img_temp, center_temp, radius_temp, method_temp, best_radius1_temp, ellipse_params_temp = detect_circle_v3(
|
||||||
|
frame, None)
|
||||||
|
|
||||||
|
# 计算距离
|
||||||
|
distance_m = estimate_distance(best_radius1_temp) if best_radius1_temp else None
|
||||||
|
|
||||||
|
# 根据距离动态计算激光点坐标
|
||||||
|
laser_point_method = None
|
||||||
|
if config.HARDCODE_LASER_POINT:
|
||||||
|
laser_point = laser_manager.laser_point
|
||||||
|
laser_point_method = "hardcode"
|
||||||
|
elif laser_manager.has_calibrated_point():
|
||||||
|
laser_point = laser_manager.laser_point
|
||||||
|
laser_point_method = "calibrated"
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
|
||||||
|
elif distance_m and distance_m > 0:
|
||||||
|
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m)
|
||||||
|
laser_point_method = "dynamic"
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[算法] 使用比例尺: {laser_point}")
|
||||||
|
else:
|
||||||
|
laser_point = laser_manager.laser_point
|
||||||
|
laser_point_method = "default"
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[算法] 使用默认值: {laser_point}")
|
||||||
|
|
||||||
|
if laser_point is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reason": "laser_point_not_initialized"
|
||||||
|
}
|
||||||
|
|
||||||
|
x, y = laser_point
|
||||||
|
|
||||||
|
# 绘制激光十字线
|
||||||
|
color = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
|
||||||
|
frame.draw_line(
|
||||||
|
int(x - config.LASER_LENGTH), int(y),
|
||||||
|
int(x + config.LASER_LENGTH), int(y),
|
||||||
|
color, config.LASER_THICKNESS
|
||||||
|
)
|
||||||
|
frame.draw_line(
|
||||||
|
int(x), int(y - config.LASER_LENGTH),
|
||||||
|
int(x), int(y + config.LASER_LENGTH),
|
||||||
|
color, config.LASER_THICKNESS
|
||||||
|
)
|
||||||
|
frame.draw_circle(int(x), int(y), 1, color, config.LASER_THICKNESS)
|
||||||
|
|
||||||
|
# 重新检测靶心(使用计算出的激光点)
|
||||||
|
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
|
||||||
|
|
||||||
|
# 计算偏移与距离
|
||||||
|
if center and radius:
|
||||||
|
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
|
||||||
|
distance_m = estimate_distance(best_radius1)
|
||||||
|
else:
|
||||||
|
dx, dy = None, None
|
||||||
|
distance_m = None
|
||||||
|
|
||||||
|
# 返回分析结果
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"result_img": result_img,
|
||||||
|
"center": center,
|
||||||
|
"radius": radius,
|
||||||
|
"method": method,
|
||||||
|
"best_radius1": best_radius1,
|
||||||
|
"ellipse_params": ellipse_params,
|
||||||
|
"dx": dx,
|
||||||
|
"dy": dy,
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"laser_point": laser_point,
|
||||||
|
"laser_point_method": laser_point_method
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def process_shot(adc_val):
|
||||||
|
"""
|
||||||
|
处理射箭事件(逻辑控制部分)
|
||||||
|
:param adc_val: ADC触发值
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = camera_manager.read_frame()
|
||||||
|
|
||||||
|
# 调用算法分析
|
||||||
|
analysis_result = analyze_shot(frame)
|
||||||
|
|
||||||
|
if not analysis_result.get("success"):
|
||||||
|
reason = analysis_result.get("reason", "unknown")
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[MAIN] 射箭分析失败: {reason}")
|
||||||
|
time.sleep_ms(100)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 提取分析结果
|
||||||
|
result_img = analysis_result["result_img"]
|
||||||
|
center = analysis_result["center"]
|
||||||
|
radius = analysis_result["radius"]
|
||||||
|
method = analysis_result["method"]
|
||||||
|
ellipse_params = analysis_result["ellipse_params"]
|
||||||
|
dx = analysis_result["dx"]
|
||||||
|
dy = analysis_result["dy"]
|
||||||
|
distance_m = analysis_result["distance_m"]
|
||||||
|
laser_point = analysis_result["laser_point"]
|
||||||
|
laser_point_method = analysis_result["laser_point_method"]
|
||||||
|
x, y = laser_point
|
||||||
|
|
||||||
|
camera_manager.show(result_img)
|
||||||
|
|
||||||
|
if not (center and radius) and logger:
|
||||||
|
logger.warning("[MAIN] 未检测到靶心,但会保存图像")
|
||||||
|
|
||||||
|
# 读取电量
|
||||||
|
voltage = get_bus_voltage()
|
||||||
|
battery_percent = voltage_to_percent(voltage)
|
||||||
|
|
||||||
|
# 生成射箭ID
|
||||||
|
from shot_id_generator import shot_id_generator
|
||||||
|
shot_id = shot_id_generator.generate_id()
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
||||||
|
|
||||||
|
# 保存图像
|
||||||
|
save_shot_image(
|
||||||
|
result_img,
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
method,
|
||||||
|
ellipse_params,
|
||||||
|
(x, y),
|
||||||
|
distance_m,
|
||||||
|
shot_id=shot_id,
|
||||||
|
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构造上报数据
|
||||||
|
inner_data = {
|
||||||
|
"shot_id": shot_id,
|
||||||
|
"x": float(dx) if dx is not None else 200.0,
|
||||||
|
"y": float(dy) if dy is not None else 200.0,
|
||||||
|
"r": 90.0,
|
||||||
|
"d": round((distance_m or 0.0) * 100),
|
||||||
|
"d_laser": 0.0,
|
||||||
|
"d_laser_quality": 0,
|
||||||
|
"m": method if method else "no_target",
|
||||||
|
"adc": adc_val,
|
||||||
|
"laser_method": laser_point_method,
|
||||||
|
"target_x": float(x),
|
||||||
|
"target_y": float(y),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
inner_data["ellipse_major_axis"] = float(max(width, height))
|
||||||
|
inner_data["ellipse_minor_axis"] = float(min(width, height))
|
||||||
|
inner_data["ellipse_angle"] = float(angle)
|
||||||
|
inner_data["ellipse_center_x"] = float(ell_center[0])
|
||||||
|
inner_data["ellipse_center_y"] = float(ell_center[1])
|
||||||
|
else:
|
||||||
|
inner_data["ellipse_major_axis"] = None
|
||||||
|
inner_data["ellipse_minor_axis"] = None
|
||||||
|
inner_data["ellipse_angle"] = None
|
||||||
|
inner_data["ellipse_center_x"] = None
|
||||||
|
inner_data["ellipse_center_y"] = None
|
||||||
|
|
||||||
|
report_data = {"cmd": 1, "data": inner_data}
|
||||||
|
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
if center and radius:
|
||||||
|
logger.info(f"射箭事件已加入发送队列(已检测到靶心),ID: {shot_id}")
|
||||||
|
else:
|
||||||
|
logger.info(f"射箭事件已加入发送队列(未检测到靶心,已保存图像),ID: {shot_id}")
|
||||||
|
|
||||||
|
# 闪一下激光(射箭反馈)
|
||||||
|
laser_manager.flash_laser(1000)
|
||||||
|
|
||||||
|
time.sleep_ms(100)
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[MAIN] 图像处理异常: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
time.sleep_ms(100)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
射箭ID生成器
|
||||||
|
为每次射箭生成唯一ID,格式:{timestamp_ms}_{counter}
|
||||||
|
"""
|
||||||
|
from maix import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
class ShotIDGenerator:
|
||||||
|
"""射箭ID生成器(单例)"""
|
||||||
|
_instance = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(ShotIDGenerator, cls).__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._counter = 0
|
||||||
|
self._last_timestamp_ms = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def generate_id(self, device_id=None):
|
||||||
|
"""
|
||||||
|
生成唯一的射箭ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: 可选的设备ID,如果提供则包含在ID中(格式:{device_id}_{timestamp_ms}_{counter})
|
||||||
|
如果不提供,则使用简单格式(格式:{timestamp_ms}_{counter})
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 唯一的射箭ID
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
current_timestamp_ms = time.ticks_ms()
|
||||||
|
|
||||||
|
# 如果时间戳相同,增加计数器;否则重置计数器
|
||||||
|
if current_timestamp_ms == self._last_timestamp_ms:
|
||||||
|
self._counter += 1
|
||||||
|
else:
|
||||||
|
self._counter = 0
|
||||||
|
self._last_timestamp_ms = current_timestamp_ms
|
||||||
|
|
||||||
|
# 生成ID
|
||||||
|
if device_id:
|
||||||
|
shot_id = f"{device_id}_{current_timestamp_ms}_{self._counter}"
|
||||||
|
else:
|
||||||
|
shot_id = f"{current_timestamp_ms}_{self._counter}"
|
||||||
|
|
||||||
|
return shot_id
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""重置计数器(通常不需要调用)"""
|
||||||
|
with self._lock:
|
||||||
|
self._counter = 0
|
||||||
|
self._last_timestamp_ms = 0
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局单例实例
|
||||||
|
shot_id_generator = ShotIDGenerator()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# test_camera.py
|
||||||
|
from maix import camera, display, time
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Initializing camera...")
|
||||||
|
cam = camera.Camera(640, 480)
|
||||||
|
print("Camera initialized successfully!")
|
||||||
|
|
||||||
|
disp = display.Display()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
frame = cam.read()
|
||||||
|
disp.show(frame)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
离线测试脚本:直接复用 detect_circle 逻辑进行测试
|
||||||
|
运行环境:MaixPy (Sipeed MAIX)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
# import time
|
||||||
|
from maix import image,time
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
||||||
|
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
||||||
|
|
||||||
|
# ==================== 复制的核心算法 ====================
|
||||||
|
# 注意:这里直接复制了 detect_circle 的逻辑,避免 import main 导致的冲突
|
||||||
|
|
||||||
|
|
||||||
|
def detect_circle_v3(frame, laser_point=None):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
|
||||||
|
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
|
||||||
|
如果提供 laser_point,会选择最接近激光点的目标
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 图像帧
|
||||||
|
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
||||||
|
"""
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
best_center = best_radius = best_radius1 = method = None
|
||||||
|
ellipse_params = None
|
||||||
|
|
||||||
|
# HSV 黄色掩码检测(模糊靶心)
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# 调整饱和度策略:稍微增强,不要过度
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||||
|
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||||
|
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||||
|
|
||||||
|
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 调整形态学操作
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
# 存储所有有效的黄色-红色组合
|
||||||
|
valid_targets = []
|
||||||
|
|
||||||
|
if contours_yellow:
|
||||||
|
for cnt_yellow in contours_yellow:
|
||||||
|
area = cv2.contourArea(cnt_yellow)
|
||||||
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
|
|
||||||
|
# 计算圆度
|
||||||
|
if perimeter > 0:
|
||||||
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
|
else:
|
||||||
|
circularity = 0
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
if area > 50 and circularity > 0.7:
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
|
||||||
|
# 尝试拟合椭圆
|
||||||
|
yellow_center = None
|
||||||
|
yellow_radius = None
|
||||||
|
yellow_ellipse = None
|
||||||
|
|
||||||
|
if len(cnt_yellow) >= 5:
|
||||||
|
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
||||||
|
yellow_ellipse = ((x, y), (width, height), angle)
|
||||||
|
axes_minor = min(width, height)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
yellow_center = (int(x), int(y))
|
||||||
|
yellow_radius = int(radius)
|
||||||
|
else:
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
||||||
|
yellow_center = (int(x), int(y))
|
||||||
|
yellow_radius = int(radius)
|
||||||
|
yellow_ellipse = None
|
||||||
|
|
||||||
|
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
|
||||||
|
if yellow_center and yellow_radius:
|
||||||
|
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
|
||||||
|
# 红色范围1: 0-10度(接近0度的红色)
|
||||||
|
lower_red1 = np.array([0, 80, 0])
|
||||||
|
upper_red1 = np.array([10, 255, 255])
|
||||||
|
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
||||||
|
|
||||||
|
# 红色范围2: 170-180度(接近180度的红色)
|
||||||
|
lower_red2 = np.array([170, 80, 0])
|
||||||
|
upper_red2 = np.array([180, 255, 255])
|
||||||
|
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
|
||||||
|
|
||||||
|
# 合并两个红色掩码
|
||||||
|
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
|
||||||
|
|
||||||
|
# 形态学操作
|
||||||
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
|
||||||
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
found_valid_red = False
|
||||||
|
|
||||||
|
if contours_red:
|
||||||
|
# 找到所有符合条件的红色圆圈
|
||||||
|
for cnt_red in contours_red:
|
||||||
|
area_red = cv2.contourArea(cnt_red)
|
||||||
|
perimeter_red = cv2.arcLength(cnt_red, True)
|
||||||
|
|
||||||
|
if perimeter_red > 0:
|
||||||
|
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
|
||||||
|
else:
|
||||||
|
circularity_red = 0
|
||||||
|
|
||||||
|
# 红色圆圈也应该有一定的圆度
|
||||||
|
if area_red > 50 and circularity_red > 0.6:
|
||||||
|
# 计算红色圆圈的中心和半径
|
||||||
|
if len(cnt_red) >= 5:
|
||||||
|
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
|
||||||
|
radius_red = min(w_red, h_red) / 2
|
||||||
|
red_center = (int(x_red), int(y_red))
|
||||||
|
red_radius = int(radius_red)
|
||||||
|
else:
|
||||||
|
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
|
||||||
|
red_center = (int(x_red), int(y_red))
|
||||||
|
red_radius = int(radius_red)
|
||||||
|
|
||||||
|
# 计算黄色和红色圆心的距离
|
||||||
|
if red_center:
|
||||||
|
dx = yellow_center[0] - red_center[0]
|
||||||
|
dy = yellow_center[1] - red_center[1]
|
||||||
|
distance = np.sqrt(dx*dx + dy*dy)
|
||||||
|
|
||||||
|
# 圆心距离阈值:应该小于黄色半径的某个倍数(比如1.5倍)
|
||||||
|
max_distance = yellow_radius * 1.5
|
||||||
|
|
||||||
|
# 红色圆圈应该比黄色圆圈大(外圈)
|
||||||
|
if distance < max_distance and red_radius > yellow_radius * 0.8:
|
||||||
|
found_valid_red = True
|
||||||
|
logger = get_logger()
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
||||||
|
|
||||||
|
# 记录这个有效目标
|
||||||
|
valid_targets.append({
|
||||||
|
'center': yellow_center,
|
||||||
|
'radius': yellow_radius,
|
||||||
|
'ellipse': yellow_ellipse,
|
||||||
|
'area': area
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found_valid_red:
|
||||||
|
logger = get_logger()
|
||||||
|
if logger:
|
||||||
|
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
|
|
||||||
|
# 从所有有效目标中选择最佳目标
|
||||||
|
if valid_targets:
|
||||||
|
if laser_point:
|
||||||
|
# 如果有激光点,选择最接近激光点的目标
|
||||||
|
best_target = None
|
||||||
|
min_distance = float('inf')
|
||||||
|
for target in valid_targets:
|
||||||
|
dx = target['center'][0] - laser_point[0]
|
||||||
|
dy = target['center'][1] - laser_point[1]
|
||||||
|
distance = np.sqrt(dx*dx + dy*dy)
|
||||||
|
if distance < min_distance:
|
||||||
|
min_distance = distance
|
||||||
|
best_target = target
|
||||||
|
if best_target:
|
||||||
|
best_center = best_target['center']
|
||||||
|
best_radius = best_target['radius']
|
||||||
|
ellipse_params = best_target['ellipse']
|
||||||
|
method = "v3_ellipse_red_validated_laser_selected"
|
||||||
|
best_radius1 = best_radius * 5
|
||||||
|
else:
|
||||||
|
# 如果没有激光点,选择面积最大的目标
|
||||||
|
best_target = max(valid_targets, key=lambda t: t['area'])
|
||||||
|
best_center = best_target['center']
|
||||||
|
best_radius = best_target['radius']
|
||||||
|
ellipse_params = best_target['ellipse']
|
||||||
|
method = "v3_ellipse_red_validated"
|
||||||
|
best_radius1 = best_radius * 5
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
def detect_circle(frame):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
||||||
|
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||||
|
# edged = cv2.Canny(blurred, 50, 150)
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# best_center = best_radius = best_radius1 = method = None
|
||||||
|
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
# lower_yellow = np.array([7, 80, 0])
|
||||||
|
# upper_yellow = np.array([32, 255, 182])
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# auto
|
||||||
|
# R:31 M:v2 D:2.410110127692767
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# # 1. 增强饱和度(模糊照片需要更强的增强)
|
||||||
|
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
|
||||||
|
|
||||||
|
# # 2. 增强亮度(模糊照片可能偏暗)
|
||||||
|
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
|
||||||
|
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# # 3. 放宽HSV颜色范围(特别是模糊照片)
|
||||||
|
# # 降低饱和度下限,提高亮度上限
|
||||||
|
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
|
||||||
|
# upper_yellow = np.array([35, 255, 255])
|
||||||
|
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# # 4. 增强形态学操作(连接被分割的区域)
|
||||||
|
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
|
||||||
|
|
||||||
|
# # 先开运算去除噪声
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
|
||||||
|
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
|
||||||
|
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# area = cv2.contourArea(largest)
|
||||||
|
# if area > 50:
|
||||||
|
# # 5. 使用面积计算等效半径(更准确)
|
||||||
|
# equivalent_radius = np.sqrt(area / np.pi)
|
||||||
|
|
||||||
|
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
|
||||||
|
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
|
||||||
|
|
||||||
|
# # 取两者中的较大值,确保不遗漏
|
||||||
|
# radius = max(equivalent_radius, enclosing_radius)
|
||||||
|
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# codegee
|
||||||
|
# R:24 M:v2 D:3.061493895819174
|
||||||
|
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# 2. 调整饱和度策略:
|
||||||
|
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
|
||||||
|
# 这里我们稍微增加一点饱和度,并确保不溢出
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
|
||||||
|
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
||||||
|
# v = clahe.apply(v)
|
||||||
|
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||||
|
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
|
||||||
|
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||||
|
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||||
|
|
||||||
|
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 4. 调整形态学操作
|
||||||
|
# 去掉 MORPH_OPEN,因为它会减小面积。
|
||||||
|
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
# 再进行一次膨胀,确保边缘被包含进来
|
||||||
|
# mask = cv2.dilate(mask, kernel, iterations=1)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
if contours:
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
|
||||||
|
# 这里可以适当降低面积阈值,或者保持不变
|
||||||
|
if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
|
||||||
|
# --- 核心修改开始 ---
|
||||||
|
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
|
||||||
|
if len(largest) >= 5:
|
||||||
|
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
|
||||||
|
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
|
||||||
|
|
||||||
|
# 2. 计算半径
|
||||||
|
# 选项A:取长短轴的平均值 (比较稳健)
|
||||||
|
# radius = (axes_major + axes_minor) / 4
|
||||||
|
|
||||||
|
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2_ellipse"
|
||||||
|
else:
|
||||||
|
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2"
|
||||||
|
# --- 核心修改结束 ---
|
||||||
|
|
||||||
|
# 你的后续逻辑
|
||||||
|
best_radius1 = radius * 5
|
||||||
|
|
||||||
|
|
||||||
|
# operas 4.5
|
||||||
|
# R:25 M:v2 D:2.9554872521538527
|
||||||
|
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
# h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
|
||||||
|
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
|
||||||
|
# hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# # 2. 放宽 HSV 阈值范围(关键改动)
|
||||||
|
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
|
||||||
|
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
|
||||||
|
# lower_yellow = np.array([7, 40, 30])
|
||||||
|
# upper_yellow = np.array([35, 255, 255])
|
||||||
|
|
||||||
|
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
|
||||||
|
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
|
||||||
|
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
|
||||||
|
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
|
||||||
|
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
|
||||||
|
|
||||||
|
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# if contours:
|
||||||
|
# largest = max(contours, key=cv2.contourArea)
|
||||||
|
# if cv2.contourArea(largest) > 50:
|
||||||
|
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
# best_center = (int(x), int(y))
|
||||||
|
# best_radius = int(radius)
|
||||||
|
# best_radius1 = radius * 5
|
||||||
|
# method = "v2"
|
||||||
|
|
||||||
|
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
|
||||||
|
# # 创建一个彩色掩码(红色通道为255,其他为0)
|
||||||
|
# mask_overlay = np.zeros_like(img_cv)
|
||||||
|
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
|
||||||
|
#
|
||||||
|
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1
|
||||||
|
|
||||||
|
|
||||||
|
def detect_circle_v2(frame):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
|
||||||
|
global REAL_RADIUS_CM
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
best_center = best_radius = best_radius1 = method = None
|
||||||
|
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
|
||||||
|
|
||||||
|
# HSV 黄色掩码检测(模糊靶心)
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# 调整饱和度策略:稍微增强,不要过度
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||||
|
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||||
|
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||||
|
|
||||||
|
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 调整形态学操作
|
||||||
|
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
if contours:
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
|
||||||
|
if cv2.contourArea(largest) > 50:
|
||||||
|
# 尝试拟合椭圆 (需要轮廓点至少为5个)
|
||||||
|
if len(largest) >= 5:
|
||||||
|
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
|
||||||
|
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
|
||||||
|
(x, y), (width, height), angle = cv2.fitEllipse(largest)
|
||||||
|
|
||||||
|
# 保存椭圆参数(保持原始顺序,用于绘制)
|
||||||
|
ellipse_params = ((x, y), (width, height), angle)
|
||||||
|
|
||||||
|
# 计算半径:使用较小的尺寸作为短轴
|
||||||
|
axes_minor = min(width, height)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2_ellipse"
|
||||||
|
else:
|
||||||
|
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(largest)
|
||||||
|
best_center = (int(x), int(y))
|
||||||
|
best_radius = int(radius)
|
||||||
|
method = "v2"
|
||||||
|
ellipse_params = None # 圆形,没有椭圆参数
|
||||||
|
|
||||||
|
best_radius1 = radius * 5
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
# ==================== 测试逻辑 ====================
|
||||||
|
|
||||||
|
def run_offline_test(image_path):
|
||||||
|
"""读取图片,检测圆,绘制结果,保存图片"""
|
||||||
|
|
||||||
|
# 1. 检查文件是否存在
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
print(f"[ERROR] 找不到图片文件: {image_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
||||||
|
try:
|
||||||
|
# 使用 image.load 读取文件,返回 Image 对象
|
||||||
|
img = image.load(image_path)
|
||||||
|
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 读取图片失败: {e}")
|
||||||
|
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# 3. 调用 detect_circle_v2 函数
|
||||||
|
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
|
||||||
|
start_time = time.ticks_ms()
|
||||||
|
|
||||||
|
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
||||||
|
|
||||||
|
cost_time = time.ticks_ms() - start_time
|
||||||
|
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
||||||
|
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
print(f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
||||||
|
|
||||||
|
# 4. 绘制辅助线(可选,用于调试)
|
||||||
|
if center and radius:
|
||||||
|
# 为了绘制椭圆,需要转换回 cv2 图像
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
|
||||||
|
cx, cy = center
|
||||||
|
|
||||||
|
# 如果有椭圆参数,绘制椭圆
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
||||||
|
|
||||||
|
# 确定长轴和短轴
|
||||||
|
if width >= height:
|
||||||
|
# width 是长轴,height 是短轴
|
||||||
|
axes_major = width
|
||||||
|
axes_minor = height
|
||||||
|
major_angle = angle # 长轴角度就是 angle
|
||||||
|
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
||||||
|
else:
|
||||||
|
# height 是长轴,width 是短轴
|
||||||
|
axes_major = height
|
||||||
|
axes_minor = width
|
||||||
|
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
||||||
|
minor_angle = angle # 短轴角度就是 angle
|
||||||
|
|
||||||
|
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
||||||
|
cv2.ellipse(img_cv,
|
||||||
|
(cx_ell, cy_ell), # 中心点
|
||||||
|
(int(width/2), int(height/2)), # 半宽、半高
|
||||||
|
angle, # 旋转角度(OpenCV需要原始angle)
|
||||||
|
0, 360, # 起始和结束角度
|
||||||
|
(0, 255, 0), # 绿色 (RGB格式)
|
||||||
|
2) # 线宽
|
||||||
|
|
||||||
|
# 绘制椭圆中心点(红色)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
|
||||||
|
import math
|
||||||
|
# 绘制短轴(蓝色线条)
|
||||||
|
minor_length = axes_minor / 2
|
||||||
|
minor_angle_rad = math.radians(minor_angle)
|
||||||
|
dx_minor = minor_length * math.cos(minor_angle_rad)
|
||||||
|
dy_minor = minor_length * math.sin(minor_angle_rad)
|
||||||
|
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
||||||
|
else:
|
||||||
|
# 如果没有椭圆参数,绘制圆形(红色)
|
||||||
|
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
||||||
|
|
||||||
|
# 转换回 maix image
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
|
||||||
|
# 定义颜色对象用于文字
|
||||||
|
try:
|
||||||
|
color_black = image.Color.from_rgb(0,0,0)
|
||||||
|
except AttributeError:
|
||||||
|
color_black = image.Color(0,0,0)
|
||||||
|
|
||||||
|
# D. 添加文字信息
|
||||||
|
FOCAL_LENGTH_PIX = 1900
|
||||||
|
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
||||||
|
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
||||||
|
print(info_str)
|
||||||
|
|
||||||
|
# 计算文字位置,防止超出图片边界
|
||||||
|
r_outer = int(radius * 11.0) if radius else 100
|
||||||
|
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
||||||
|
|
||||||
|
# 调用 draw_string
|
||||||
|
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# 5. 保存结果图片
|
||||||
|
output_path = image_path.replace(".bmp", "_result.bmp")
|
||||||
|
output_path = image_path.replace(".jpg", "_result.jpg")
|
||||||
|
try:
|
||||||
|
result_img.save(output_path, quality=100)
|
||||||
|
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 保存图片失败: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# ================= 配置区域 =================
|
||||||
|
|
||||||
|
# 1. 设置要测试的图片路径
|
||||||
|
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
||||||
|
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
||||||
|
|
||||||
|
# TARGET_DIR = "/root/phot_test2" # 修改为你想要读取的目录路径
|
||||||
|
|
||||||
|
# 支持的图片格式
|
||||||
|
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
|
||||||
|
# ================= 执行区域 =================
|
||||||
|
if 'TARGET_DIR' in locals():
|
||||||
|
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
||||||
|
image_files = []
|
||||||
|
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
||||||
|
for filename in os.listdir(TARGET_DIR):
|
||||||
|
# 检查文件扩展名
|
||||||
|
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
||||||
|
# 过滤掉 _result.jpg 后缀的文件
|
||||||
|
if not filename.endswith('_result.jpg'):
|
||||||
|
filepath = os.path.join(TARGET_DIR, filename)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
image_files.append(filepath)
|
||||||
|
|
||||||
|
# 按文件名排序(可选)
|
||||||
|
image_files.sort()
|
||||||
|
|
||||||
|
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
||||||
|
|
||||||
|
# 处理每张图片
|
||||||
|
for img_path in image_files:
|
||||||
|
print(f"\n{'='*10} 开始处理: {img_path} {'='*10}")
|
||||||
|
run_offline_test(img_path)
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
run_offline_test(TARGET_IMAGE)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# test_i2c_devices.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
from maix import i2c
|
||||||
|
|
||||||
|
def list_i2c_devices():
|
||||||
|
"""List available I2C device nodes"""
|
||||||
|
print("Available I2C devices:")
|
||||||
|
|
||||||
|
# Check /dev directory
|
||||||
|
try:
|
||||||
|
dev_files = os.listdir("/dev")
|
||||||
|
i2c_devices = [f for f in dev_files if "i2c" in f]
|
||||||
|
if i2c_devices:
|
||||||
|
for dev in sorted(i2c_devices):
|
||||||
|
print(f" /dev/{dev}")
|
||||||
|
else:
|
||||||
|
print(" No /dev/i2c-* devices found!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error listing /dev: {e}")
|
||||||
|
|
||||||
|
def try_i2c_bus(bus_num):
|
||||||
|
"""Try to initialize an I2C bus"""
|
||||||
|
try:
|
||||||
|
bus = i2c.I2C(bus_num, i2c.Mode.MASTER)
|
||||||
|
print(f" I2C bus {bus_num}: OK")
|
||||||
|
return True
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f" I2C bus {bus_num}: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" I2C bus {bus_num}: Unexpected error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("I2C Device Diagnostic")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# List kernel devices
|
||||||
|
list_i2c_devices()
|
||||||
|
|
||||||
|
# Try common bus numbers
|
||||||
|
print("\nTesting I2C buses:")
|
||||||
|
working_buses = []
|
||||||
|
for bus_num in range(10):
|
||||||
|
if try_i2c_bus(bus_num):
|
||||||
|
working_buses.append(bus_num)
|
||||||
|
|
||||||
|
print(f"\nWorking buses: {working_buses}")
|
||||||
|
|
||||||
|
if not working_buses:
|
||||||
|
print("\nERROR: No I2C buses available!")
|
||||||
|
print("Possible causes:")
|
||||||
|
print(" 1. I2C kernel driver not loaded")
|
||||||
|
print(" 2. Device tree doesn't enable I2C")
|
||||||
|
print(" 3. Different kernel version with different device naming")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
激光模块测试脚本
|
||||||
|
用于诊断激光开关问题
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
python test_laser.py
|
||||||
|
|
||||||
|
功能:
|
||||||
|
1. 初始化串口
|
||||||
|
2. 循环测试激光开/关
|
||||||
|
3. 打印详细调试信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
from maix import uart, pinmap, time
|
||||||
|
|
||||||
|
# ==================== 配置 ====================
|
||||||
|
UART_PORT = "/dev/ttyS1" # 激光模块连接的串口(UART1)
|
||||||
|
BAUDRATE = 9600 # 波特率
|
||||||
|
|
||||||
|
# 引脚映射(确保与硬件连接一致)
|
||||||
|
print("=" * 50)
|
||||||
|
print("🔧 步骤1: 配置引脚映射")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pinmap.set_pin_function("A18", "UART1_RX")
|
||||||
|
print("✅ A18 -> UART1_RX")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ A18 配置失败: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pinmap.set_pin_function("A19", "UART1_TX")
|
||||||
|
print("✅ A19 -> UART1_TX")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ A19 配置失败: {e}")
|
||||||
|
|
||||||
|
# ==================== 激光控制指令 ====================
|
||||||
|
MODULE_ADDR = 0x00
|
||||||
|
|
||||||
|
# 原始命令
|
||||||
|
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
||||||
|
LASER_OFF_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
||||||
|
|
||||||
|
# 备用命令格式(如果原始命令不工作,可以尝试这些)
|
||||||
|
# 格式1: 简化命令
|
||||||
|
LASER_ON_CMD_ALT1 = bytes([0xAA, 0x01, 0x01])
|
||||||
|
LASER_OFF_CMD_ALT1 = bytes([0xAA, 0x01, 0x00])
|
||||||
|
|
||||||
|
# 格式2: 不同的协议头
|
||||||
|
LASER_ON_CMD_ALT2 = bytes([0x55, 0xAA, 0x01])
|
||||||
|
LASER_OFF_CMD_ALT2 = bytes([0x55, 0xAA, 0x00])
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("🔧 步骤2: 初始化串口")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"设备: {UART_PORT}")
|
||||||
|
print(f"波特率: {BAUDRATE}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
laser_uart = uart.UART(UART_PORT, BAUDRATE)
|
||||||
|
print(f"✅ 串口初始化成功: {laser_uart}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 串口初始化失败: {e}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# ==================== 测试函数 ====================
|
||||||
|
def send_and_check(cmd, name):
|
||||||
|
"""发送命令并检查回包"""
|
||||||
|
print(f"\n📤 发送: {name}")
|
||||||
|
print(f" 命令字节: {cmd.hex()}")
|
||||||
|
print(f" 命令长度: {len(cmd)} 字节")
|
||||||
|
|
||||||
|
# 清空接收缓冲区
|
||||||
|
try:
|
||||||
|
old_data = laser_uart.read(-1)
|
||||||
|
if old_data:
|
||||||
|
print(f" 清空缓冲区: {len(old_data)} 字节")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 发送命令
|
||||||
|
try:
|
||||||
|
written = laser_uart.write(cmd)
|
||||||
|
print(f" 写入字节数: {written}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 写入失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 等待响应
|
||||||
|
time.sleep_ms(100)
|
||||||
|
|
||||||
|
# 读取回包
|
||||||
|
try:
|
||||||
|
resp = laser_uart.read(50)
|
||||||
|
if resp:
|
||||||
|
print(f" 📥 收到回包: {resp.hex()} ({len(resp)} 字节)")
|
||||||
|
return resp
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 无回包")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 读取失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_laser_cycle(on_cmd, off_cmd, cmd_name="标准命令"):
|
||||||
|
"""测试一个开关周期"""
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"🧪 测试 {cmd_name}")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
|
||||||
|
print("\n>>> 测试开启激光")
|
||||||
|
send_and_check(on_cmd, f"{cmd_name} - 开启")
|
||||||
|
print(" ⏱️ 等待 2 秒观察激光是否亮起...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
print("\n>>> 测试关闭激光")
|
||||||
|
send_and_check(off_cmd, f"{cmd_name} - 关闭")
|
||||||
|
print(" ⏱️ 等待 2 秒观察激光是否熄灭...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# ==================== 主测试 ====================
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("🚀 开始激光测试")
|
||||||
|
print("=" * 50)
|
||||||
|
print("\n请观察激光模块的状态变化...")
|
||||||
|
print("测试将依次尝试不同的命令格式\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 测试1: 标准命令
|
||||||
|
test_laser_cycle(LASER_ON_CMD, LASER_OFF_CMD, "标准命令")
|
||||||
|
|
||||||
|
input("\n按回车继续测试备用命令1...")
|
||||||
|
|
||||||
|
# 测试2: 备用命令格式1
|
||||||
|
test_laser_cycle(LASER_ON_CMD_ALT1, LASER_OFF_CMD_ALT1, "备用命令1 (简化)")
|
||||||
|
|
||||||
|
input("\n按回车继续测试备用命令2...")
|
||||||
|
|
||||||
|
# 测试3: 备用命令格式2
|
||||||
|
test_laser_cycle(LASER_ON_CMD_ALT2, LASER_OFF_CMD_ALT2, "备用命令2 (0x55AA头)")
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("🏁 测试完成")
|
||||||
|
print("=" * 50)
|
||||||
|
print("\n诊断建议:")
|
||||||
|
print("1. 如果激光始终不亮/始终亮:")
|
||||||
|
print(" - 检查激光模块的电源连接")
|
||||||
|
print(" - 检查串口TX/RX是否接反")
|
||||||
|
print(" - 尝试不同的波特率 (4800/19200)")
|
||||||
|
print("")
|
||||||
|
print("2. 如果有回包但激光无反应:")
|
||||||
|
print(" - 命令格式可能正确但激光硬件问题")
|
||||||
|
print("")
|
||||||
|
print("3. 如果某个备用命令有效:")
|
||||||
|
print(" - 需要更新 config.py 中的命令格式")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n🛑 测试被中断")
|
||||||
|
# 确保激光关闭
|
||||||
|
laser_uart.write(LASER_OFF_CMD)
|
||||||
|
print("✅ 已发送关闭指令")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 测试出错: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# test_power_with_init.py
|
||||||
|
|
||||||
|
from maix import i2c, time
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# INA226 register addresses
|
||||||
|
INA226_ADDR = 0x40
|
||||||
|
REG_CONFIGURATION = 0x00
|
||||||
|
REG_BUS_VOLTAGE = 0x02
|
||||||
|
REG_CURRENT = 0x04
|
||||||
|
REG_CALIBRATION = 0x05
|
||||||
|
|
||||||
|
# Configuration values
|
||||||
|
CONFIG_VALUE = 0x4527 # Configuration: 16 averages, 1.1ms conversion time, continuous mode
|
||||||
|
CALIBRATION_VALUE = 0x1400 # Calibration value
|
||||||
|
|
||||||
|
def write_register(bus, reg, value):
|
||||||
|
"""Write to INA226 register"""
|
||||||
|
data = [(value >> 8) & 0xFF, value & 0xFF]
|
||||||
|
bus.writeto_mem(INA226_ADDR, reg, bytes(data))
|
||||||
|
|
||||||
|
def read_register(bus, reg):
|
||||||
|
"""Read from INA226 register"""
|
||||||
|
data = bus.readfrom_mem(INA226_ADDR, reg, 2)
|
||||||
|
return (data[0] << 8) | data[1]
|
||||||
|
|
||||||
|
def init_ina226(bus):
|
||||||
|
"""Initialize INA226 chip"""
|
||||||
|
try:
|
||||||
|
# Write configuration register
|
||||||
|
write_register(bus, REG_CONFIGURATION, CONFIG_VALUE)
|
||||||
|
time.sleep_ms(10)
|
||||||
|
|
||||||
|
# Write calibration register
|
||||||
|
write_register(bus, REG_CALIBRATION, CALIBRATION_VALUE)
|
||||||
|
time.sleep_ms(10)
|
||||||
|
|
||||||
|
# Verify configuration by reading it back
|
||||||
|
config_read = read_register(bus, REG_CONFIGURATION)
|
||||||
|
if config_read != CONFIG_VALUE:
|
||||||
|
print(f" Warning: Config readback mismatch: 0x{config_read:04X} != 0x{CONFIG_VALUE:04X}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Init failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read_voltage(bus):
|
||||||
|
"""Read bus voltage"""
|
||||||
|
raw = read_register(bus, REG_BUS_VOLTAGE)
|
||||||
|
voltage = raw * 1.25 / 1000
|
||||||
|
return voltage
|
||||||
|
|
||||||
|
def read_current(bus):
|
||||||
|
"""Read current"""
|
||||||
|
raw = read_register(bus, REG_CURRENT)
|
||||||
|
# Handle signed value
|
||||||
|
if raw & 0x8000:
|
||||||
|
raw = raw - 0x10000
|
||||||
|
current_lsb = 0.001 * CALIBRATION_VALUE / 4096
|
||||||
|
current = raw * current_lsb * 1000 # mA
|
||||||
|
return current
|
||||||
|
|
||||||
|
def test_i2c_bus(bus_num):
|
||||||
|
"""Test a single I2C bus with full initialization"""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Testing I2C Bus {bus_num}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Initialize I2C bus
|
||||||
|
print(f" 1. Initializing I2C bus...")
|
||||||
|
bus = i2c.I2C(bus_num, i2c.Mode.MASTER)
|
||||||
|
print(f" OK")
|
||||||
|
|
||||||
|
# Step 2: Initialize INA226
|
||||||
|
print(f" 2. Initializing INA226...")
|
||||||
|
if not init_ina226(bus):
|
||||||
|
print(f" FAILED")
|
||||||
|
return False
|
||||||
|
print(f" OK")
|
||||||
|
|
||||||
|
# Step 3: Read voltage multiple times
|
||||||
|
print(f" 3. Reading voltage...")
|
||||||
|
for i in range(5):
|
||||||
|
try:
|
||||||
|
voltage = read_voltage(bus)
|
||||||
|
current = read_current(bus)
|
||||||
|
print(f" Read {i+1}: {voltage:.3f}V, {current:.1f}mA")
|
||||||
|
time.sleep_ms(100)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Read {i+1} failed: {e}")
|
||||||
|
|
||||||
|
print(f" SUCCESS")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAILED: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test all I2C buses"""
|
||||||
|
print("INA226 Test with Proper Initialization")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Test buses in order of likelihood
|
||||||
|
test_order = [5, 1, 3, 4, 0, 2]
|
||||||
|
|
||||||
|
success_buses = []
|
||||||
|
|
||||||
|
for bus_num in test_order:
|
||||||
|
if test_i2c_bus(bus_num):
|
||||||
|
success_buses.append(bus_num)
|
||||||
|
# If we found a working bus, stop testing others
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Summary:")
|
||||||
|
print(f" Working buses: {success_buses}")
|
||||||
|
if not success_buses:
|
||||||
|
print(f" ERROR: No working I2C bus found!")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
时间同步模块
|
||||||
|
从4G模块获取时间并同步到系统
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import config
|
||||||
|
# from logger_bak import get_logger
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
|
|
||||||
|
def parse_4g_time(cclk_response, timezone_offset=8):
|
||||||
|
"""
|
||||||
|
解析 AT+CCLK? 返回的时间字符串,并转换为本地时间
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cclk_response: AT+CCLK? 的响应字符串
|
||||||
|
timezone_offset: 时区偏移(小时),默认8(中国时区 UTC+8)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime 对象(已转换为本地时间),如果解析失败返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 匹配格式: +CCLK: "YY/MM/DD,HH:MM:SS+TZ"
|
||||||
|
# 时区单位是四分之一小时(quarters of an hour)
|
||||||
|
match = re.search(r'\+CCLK:\s*"(\d{2})/(\d{2})/(\d{2}),(\d{2}):(\d{2}):(\d{2})([+-]\d{1,3})?"', cclk_response)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
yy, mm, dd, hh, MM, ss, tz_str = match.groups()
|
||||||
|
|
||||||
|
# 年份处理:26 -> 2026
|
||||||
|
year = 2000 + int(yy)
|
||||||
|
month = int(mm)
|
||||||
|
day = int(dd)
|
||||||
|
hour = int(hh)
|
||||||
|
minute = int(MM)
|
||||||
|
second = int(ss)
|
||||||
|
|
||||||
|
# 创建 UTC 时间的 datetime 对象
|
||||||
|
dt_utc = datetime(year, month, day, hour, minute, second)
|
||||||
|
|
||||||
|
# 解析时区偏移(单位:四分之一小时)
|
||||||
|
if tz_str:
|
||||||
|
try:
|
||||||
|
# 时区偏移值(四分之一小时)
|
||||||
|
tz_quarters = int(tz_str)
|
||||||
|
|
||||||
|
# 转换为小时(除以4)
|
||||||
|
tz_hours = tz_quarters / 4.0
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[TIME] 时区偏移: {tz_str} (四分之一小时) = {tz_hours} 小时")
|
||||||
|
|
||||||
|
# 转换为本地时间
|
||||||
|
dt_local = dt_utc + timedelta(hours=tz_hours)
|
||||||
|
except ValueError:
|
||||||
|
# 如果时区解析失败,使用默认值
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[TIME] 时区解析失败: {tz_str},使用默认 UTC+{timezone_offset}")
|
||||||
|
dt_local = dt_utc + timedelta(hours=timezone_offset)
|
||||||
|
else:
|
||||||
|
# 没有时区信息,使用默认值
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[TIME] 未找到时区信息,使用默认 UTC+{timezone_offset}")
|
||||||
|
dt_local = dt_utc + timedelta(hours=timezone_offset)
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[TIME] UTC时间: {dt_utc.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
logger.info(f"[TIME] 本地时间: {dt_local.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
|
return dt_local
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[TIME] 解析时间失败: {e}, 响应: {cclk_response}")
|
||||||
|
else:
|
||||||
|
print(f"[TIME] 解析时间失败: {e}, 响应: {cclk_response}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_time_from_4g(timezone_offset=8):
|
||||||
|
"""
|
||||||
|
通过4G模块获取当前时间(已转换为本地时间)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timezone_offset: 时区偏移(小时),默认8(中国时区)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime 对象(本地时间),如果获取失败返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 发送 AT+CCLK? 命令(延迟导入避免循环依赖)
|
||||||
|
from hardware import hardware_manager
|
||||||
|
# 检查 at_client 是否已初始化
|
||||||
|
if hardware_manager.at_client is None:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning("[TIME] ATClient 尚未初始化,无法获取4G时间")
|
||||||
|
else:
|
||||||
|
print("[TIME] ATClient 尚未初始化,无法获取4G时间")
|
||||||
|
return None
|
||||||
|
resp = hardware_manager.at_client.send("AT+CCLK?", "OK", 3000)
|
||||||
|
|
||||||
|
if not resp or "+CCLK:" not in resp:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[TIME] 未获取到时间响应: {resp}")
|
||||||
|
else:
|
||||||
|
print(f"[TIME] 未获取到时间响应: {resp}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 解析并转换时区
|
||||||
|
dt = parse_4g_time(resp, timezone_offset)
|
||||||
|
return dt
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[TIME] 获取4G时间异常: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[TIME] 获取4G时间异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sync_system_time_from_4g(timezone_offset=8):
|
||||||
|
"""
|
||||||
|
从4G模块同步时间到系统
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timezone_offset: 时区偏移(小时),默认8(中国时区)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功
|
||||||
|
"""
|
||||||
|
dt = get_time_from_4g(timezone_offset)
|
||||||
|
if not dt:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 转换为系统 date 命令需要的格式
|
||||||
|
time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# 设置系统时间
|
||||||
|
cmd = f'date -s "{time_str}" 2>&1'
|
||||||
|
result = os.system(cmd)
|
||||||
|
|
||||||
|
if result == 0:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[TIME] 系统时间已设置为: {time_str}")
|
||||||
|
else:
|
||||||
|
print(f"[TIME] 系统时间已设置为: {time_str}")
|
||||||
|
|
||||||
|
# 可选:同步到硬件时钟
|
||||||
|
try:
|
||||||
|
os.system('hwclock -w 2>/dev/null')
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info("[TIME] 已同步到硬件时钟")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[TIME] 设置系统时间失败,退出码: {result}")
|
||||||
|
else:
|
||||||
|
print(f"[TIME] 设置系统时间失败,退出码: {result}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[TIME] 同步系统时间异常: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[TIME] 同步系统时间异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
应用版本号
|
||||||
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
|
"""
|
||||||
|
VERSION = '1.2.10'
|
||||||
|
|
||||||
|
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
||||||
|
# 1.2.1 ota使用加密包
|
||||||
|
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
||||||
|
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
||||||
|
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
||||||
|
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
||||||
|
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
||||||
|
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
||||||
|
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
||||||
|
# 1.2.9 增加电源板的控制和自动关机的功能
|
||||||
|
# 1.2.10 config formal
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+784
@@ -0,0 +1,784 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
视觉检测模块
|
||||||
|
提供靶心检测、距离估算、图像保存等功能
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
from maix import image
|
||||||
|
import config
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
|
# 导入ArUco检测器(如果启用)
|
||||||
|
if config.USE_ARUCO:
|
||||||
|
from aruco_detector import detect_target_with_aruco, aruco_detector
|
||||||
|
|
||||||
|
# 存图队列 + worker
|
||||||
|
_save_queue = queue.Queue(maxsize=16)
|
||||||
|
_save_worker_started = False
|
||||||
|
_save_worker_lock = threading.Lock()
|
||||||
|
|
||||||
|
def check_laser_point_sharpness(frame, laser_point=None, roi_size=30, threshold=100.0, ellipse_params=None):
|
||||||
|
"""
|
||||||
|
检测激光点本身的清晰度(不是整个靶子)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 图像帧对象
|
||||||
|
laser_point: 激光点坐标 (x, y),如果为None则自动查找
|
||||||
|
roi_size: ROI区域大小(像素),默认30x30
|
||||||
|
threshold: 清晰度阈值
|
||||||
|
ellipse_params: 椭圆参数 ((center_x, center_y), (width, height), angle),用于限制激光点必须在椭圆内
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_sharp, sharpness_score, laser_pos): (是否清晰, 清晰度分数, 激光点坐标)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 如果没有提供激光点,先查找
|
||||||
|
if laser_point is None:
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
laser_point = laser_manager.find_red_laser(frame, ellipse_params=ellipse_params)
|
||||||
|
if laser_point is None:
|
||||||
|
logger_manager.logger.debug(f"未找到激光点")
|
||||||
|
return False, 0.0, None
|
||||||
|
|
||||||
|
x, y = laser_point
|
||||||
|
|
||||||
|
# 2. 转换为 OpenCV 格式
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
|
||||||
|
# 3. 提取 ROI 区域(激光点周围)
|
||||||
|
roi_half = roi_size // 2
|
||||||
|
x_min = max(0, int(x) - roi_half)
|
||||||
|
x_max = min(w, int(x) + roi_half)
|
||||||
|
y_min = max(0, int(y) - roi_half)
|
||||||
|
y_max = min(h, int(y) + roi_half)
|
||||||
|
|
||||||
|
roi = img_cv[y_min:y_max, x_min:x_max]
|
||||||
|
|
||||||
|
if roi.size == 0:
|
||||||
|
return False, 0.0, laser_point
|
||||||
|
|
||||||
|
# 4. 转换为灰度图(用于清晰度检测)
|
||||||
|
gray_roi = cv2.cvtColor(roi, cv2.COLOR_RGB2GRAY)
|
||||||
|
|
||||||
|
# 5. 方法1:检测点的扩散程度(能量集中度)
|
||||||
|
# 计算中心区域的能量集中度
|
||||||
|
center_x, center_y = roi.shape[1] // 2, roi.shape[0] // 2
|
||||||
|
center_radius = min(5, roi.shape[0] // 4) # 中心区域半径
|
||||||
|
|
||||||
|
# 创建中心区域的掩码
|
||||||
|
y_coords, x_coords = np.ogrid[:roi.shape[0], :roi.shape[1]]
|
||||||
|
center_mask = (x_coords - center_x)**2 + (y_coords - center_y)**2 <= center_radius**2
|
||||||
|
|
||||||
|
# 计算中心区域和周围区域的亮度
|
||||||
|
center_brightness = gray_roi[center_mask].mean()
|
||||||
|
outer_mask = ~center_mask
|
||||||
|
outer_brightness = gray_roi[outer_mask].mean() if np.any(outer_mask) else 0
|
||||||
|
|
||||||
|
# 对比度(清晰的点对比度高)
|
||||||
|
contrast = abs(center_brightness - outer_brightness)
|
||||||
|
|
||||||
|
# 6. 方法2:检测点的边缘锐度(使用拉普拉斯)
|
||||||
|
laplacian = cv2.Laplacian(gray_roi, cv2.CV_64F)
|
||||||
|
edge_sharpness = abs(laplacian).var()
|
||||||
|
|
||||||
|
# 7. 方法3:检测点的能量集中度(方差)
|
||||||
|
# 清晰的点:能量集中在中心,方差小
|
||||||
|
# 模糊的点:能量分散,方差大
|
||||||
|
# 但我们需要的是:清晰的点中心亮度高,周围低,所以梯度大
|
||||||
|
sobel_x = cv2.Sobel(gray_roi, cv2.CV_64F, 1, 0, ksize=3)
|
||||||
|
sobel_y = cv2.Sobel(gray_roi, cv2.CV_64F, 0, 1, ksize=3)
|
||||||
|
gradient = np.sqrt(sobel_x**2 + sobel_y**2)
|
||||||
|
gradient_sharpness = gradient.var()
|
||||||
|
|
||||||
|
# 8. 组合多个指标
|
||||||
|
# 对比度权重0.3,边缘锐度权重0.4,梯度权重0.3
|
||||||
|
sharpness_score = (contrast * 0.3 + edge_sharpness * 0.4 + gradient_sharpness * 0.3)
|
||||||
|
|
||||||
|
is_sharp = sharpness_score >= threshold
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.debug(f"[VISION] 激光点清晰度: 位置=({x}, {y}), 对比度={contrast:.2f}, 边缘={edge_sharpness:.2f}, 梯度={gradient_sharpness:.2f}, 综合={sharpness_score:.2f}, 是否清晰={is_sharp}")
|
||||||
|
|
||||||
|
return is_sharp, sharpness_score, laser_point
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] 激光点清晰度检测失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False, 0.0, laser_point
|
||||||
|
|
||||||
|
def check_image_sharpness(frame, threshold=100.0, save_debug_images=False):
|
||||||
|
"""
|
||||||
|
检查图像清晰度(针对圆形靶子优化,基于圆形边缘检测)
|
||||||
|
检测靶心的圆形边缘,计算边缘区域的梯度清晰度
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 图像帧对象
|
||||||
|
threshold: 清晰度阈值,低于此值认为图像模糊(默认100.0)
|
||||||
|
可以根据实际情况调整:
|
||||||
|
- 清晰图像通常 > 200
|
||||||
|
- 模糊图像通常 < 100
|
||||||
|
- 中等清晰度 100-200
|
||||||
|
save_debug_images: 是否保存调试图像(原始图和边缘图),默认False
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_sharp, sharpness_score): (是否清晰, 清晰度分数)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger_manager.logger.debug(f"begin")
|
||||||
|
# 转换为 OpenCV 格式
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
logger_manager.logger.debug(f"after image2cv")
|
||||||
|
|
||||||
|
# 转换为 HSV 颜色空间
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
logger_manager.logger.debug(f"after HSV conversion")
|
||||||
|
|
||||||
|
# 检测黄色区域(靶心)
|
||||||
|
# 调整饱和度策略:稍微增强,不要过度
|
||||||
|
s_enhanced = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
hsv_enhanced = cv2.merge((h, s_enhanced, v))
|
||||||
|
|
||||||
|
# HSV 阈值范围(与 detect_circle_v3 保持一致)
|
||||||
|
lower_yellow = np.array([7, 80, 0])
|
||||||
|
upper_yellow = np.array([32, 255, 255])
|
||||||
|
mask_yellow = cv2.inRange(hsv_enhanced, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 形态学操作,填充小孔洞
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
logger_manager.logger.debug(f"after yellow mask detection")
|
||||||
|
|
||||||
|
# 计算边缘区域:扩展黄色区域,然后减去原始区域,得到边缘区域
|
||||||
|
mask_dilated = cv2.dilate(mask_yellow, kernel, iterations=2)
|
||||||
|
mask_edge = cv2.subtract(mask_dilated, mask_yellow) # 边缘区域
|
||||||
|
|
||||||
|
# 计算边缘区域的像素数量
|
||||||
|
edge_pixel_count = np.sum(mask_edge > 0)
|
||||||
|
logger_manager.logger.debug(f"edge pixel count: {edge_pixel_count}")
|
||||||
|
|
||||||
|
# 如果检测不到边缘区域,使用全局梯度作为后备方案
|
||||||
|
if edge_pixel_count < 100:
|
||||||
|
logger_manager.logger.debug(f"edge region too small, using global gradient")
|
||||||
|
# 使用 V 通道计算全局梯度
|
||||||
|
sobel_v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3)
|
||||||
|
sobel_v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3)
|
||||||
|
gradient = np.sqrt(sobel_v_x**2 + sobel_v_y**2)
|
||||||
|
sharpness_score = gradient.var()
|
||||||
|
logger_manager.logger.debug(f"global gradient variance: {sharpness_score:.2f}")
|
||||||
|
else:
|
||||||
|
# 在边缘区域计算梯度清晰度
|
||||||
|
# 使用 V(亮度)通道计算梯度,因为边缘在亮度上通常很明显
|
||||||
|
sobel_v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3)
|
||||||
|
sobel_v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3)
|
||||||
|
gradient = np.sqrt(sobel_v_x**2 + sobel_v_y**2)
|
||||||
|
|
||||||
|
# 只在边缘区域计算清晰度
|
||||||
|
edge_gradient = gradient[mask_edge > 0]
|
||||||
|
|
||||||
|
if len(edge_gradient) > 0:
|
||||||
|
# 计算边缘梯度的方差(清晰图像的边缘梯度变化大)
|
||||||
|
sharpness_score = edge_gradient.var()
|
||||||
|
# 也可以使用均值作为补充指标(清晰图像的边缘梯度均值也较大)
|
||||||
|
gradient_mean = edge_gradient.mean()
|
||||||
|
logger_manager.logger.debug(f"edge gradient: mean={gradient_mean:.2f}, var={sharpness_score:.2f}, pixels={len(edge_gradient)}")
|
||||||
|
else:
|
||||||
|
# 如果边缘区域没有有效梯度,使用全局梯度
|
||||||
|
sharpness_score = gradient.var()
|
||||||
|
logger_manager.logger.debug(f"no edge gradient, using global: {sharpness_score:.2f}")
|
||||||
|
|
||||||
|
# 保存调试图像(如果启用)
|
||||||
|
if save_debug_images:
|
||||||
|
try:
|
||||||
|
debug_dir = config.PHOTO_DIR
|
||||||
|
if debug_dir not in os.listdir("/root"):
|
||||||
|
try:
|
||||||
|
os.mkdir(debug_dir)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 生成文件名
|
||||||
|
try:
|
||||||
|
all_images = [f for f in os.listdir(debug_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
|
||||||
|
img_count = len(all_images)
|
||||||
|
except:
|
||||||
|
img_count = 0
|
||||||
|
|
||||||
|
# 保存原始图像
|
||||||
|
img_orig = image.cv2image(img_cv, False, False)
|
||||||
|
orig_filename = f"{debug_dir}/sharpness_debug_orig_{img_count:04d}.bmp"
|
||||||
|
img_orig.save(orig_filename)
|
||||||
|
|
||||||
|
# # 保存边缘检测结果(可视化)
|
||||||
|
# # 创建可视化图像:原始图像 + 黄色区域 + 边缘区域
|
||||||
|
# debug_img = img_cv.copy()
|
||||||
|
# # 在黄色区域绘制绿色
|
||||||
|
# debug_img[mask_yellow > 0] = [0, 255, 0] # RGB格式,绿色
|
||||||
|
# # 在边缘区域绘制红色
|
||||||
|
# debug_img[mask_edge > 0] = [255, 0, 0] # RGB格式,红色
|
||||||
|
|
||||||
|
# debug_img_maix = image.cv2image(debug_img, False, False)
|
||||||
|
# debug_filename = f"{debug_dir}/sharpness_debug_edge_{img_count:04d}.bmp"
|
||||||
|
# debug_img_maix.save(debug_filename)
|
||||||
|
|
||||||
|
# logger = logger_manager.logger
|
||||||
|
# if logger:
|
||||||
|
# logger.info(f"[VISION] 保存调试图像: {orig_filename}, {debug_filename}")
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[VISION] 保存调试图像失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
is_sharp = sharpness_score >= threshold
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.debug(f"[VISION] 清晰度检测: 分数={sharpness_score:.2f}, 边缘像素数={edge_pixel_count}, 是否清晰={is_sharp}, 阈值={threshold}")
|
||||||
|
|
||||||
|
return is_sharp, sharpness_score
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] 清晰度检测失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
# 出错时返回 False,避免使用模糊图像
|
||||||
|
return False, 0.0
|
||||||
|
|
||||||
|
def save_calibration_image(frame, laser_pos, photo_dir=None):
|
||||||
|
"""
|
||||||
|
保存激光校准图像(带标注)
|
||||||
|
在找到的激光点位置绘制圆圈,便于检查算法是否正确
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 原始图像帧
|
||||||
|
laser_pos: 找到的激光点坐标 (x, y)
|
||||||
|
photo_dir: 照片存储目录,如果为None则使用 config.PHOTO_DIR
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 保存的文件路径,如果保存失败则返回 None
|
||||||
|
"""
|
||||||
|
# 检查是否启用图像保存
|
||||||
|
if not config.SAVE_IMAGE_ENABLED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 确保照片目录存在
|
||||||
|
try:
|
||||||
|
if photo_dir not in os.listdir("/root"):
|
||||||
|
os.mkdir(photo_dir)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 生成文件名
|
||||||
|
try:
|
||||||
|
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
|
||||||
|
img_count = len(all_images)
|
||||||
|
except:
|
||||||
|
img_count = 0
|
||||||
|
|
||||||
|
x, y = laser_pos
|
||||||
|
filename = f"{photo_dir}/calibration_{int(x)}_{int(y)}_{img_count:04d}.bmp"
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"保存校准图像: {filename}, 激光点: ({x}, {y})")
|
||||||
|
|
||||||
|
# 转换图像为 OpenCV 格式以便绘制
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
# 绘制激光点圆圈(用绿色圆圈标出找到的激光点)
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 10, (0, 255, 0), 2) # 外圈:绿色,半径10
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 5, (0, 255, 0), 2) # 中圈:绿色,半径5
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 2, (0, 255, 0), -1) # 中心点:绿色实心
|
||||||
|
|
||||||
|
# 可选:绘制十字线帮助定位
|
||||||
|
cv2.line(img_cv,
|
||||||
|
(int(x - 20), int(y)),
|
||||||
|
(int(x + 20), int(y)),
|
||||||
|
(0, 255, 0), 1) # 水平线
|
||||||
|
cv2.line(img_cv,
|
||||||
|
(int(x), int(y - 20)),
|
||||||
|
(int(x), int(y + 20)),
|
||||||
|
(0, 255, 0), 1) # 垂直线
|
||||||
|
|
||||||
|
# 转换回 MaixPy 图像格式并保存
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
result_img.save(filename)
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
logger.debug(f"校准图像已保存: {filename}")
|
||||||
|
|
||||||
|
return filename
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"保存校准图像失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return None
|
||||||
|
|
||||||
|
def detect_circle_v3(frame, laser_point=None):
|
||||||
|
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
|
||||||
|
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
|
||||||
|
如果提供 laser_point,会选择最接近激光点的目标
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 图像帧
|
||||||
|
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
||||||
|
"""
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
best_center = best_radius = best_radius1 = method = None
|
||||||
|
ellipse_params = None
|
||||||
|
|
||||||
|
# HSV 黄色掩码检测(模糊靶心)
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
|
||||||
|
# 调整饱和度策略:稍微增强,不要过度
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
|
||||||
|
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||||
|
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||||
|
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||||
|
|
||||||
|
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 调整形态学操作
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
# 存储所有有效的黄色-红色组合
|
||||||
|
valid_targets = []
|
||||||
|
|
||||||
|
if contours_yellow:
|
||||||
|
for cnt_yellow in contours_yellow:
|
||||||
|
area = cv2.contourArea(cnt_yellow)
|
||||||
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
|
|
||||||
|
# 计算圆度
|
||||||
|
if perimeter > 0:
|
||||||
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
|
else:
|
||||||
|
circularity = 0
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if area > 50 and circularity > 0.7:
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
|
||||||
|
# 尝试拟合椭圆
|
||||||
|
yellow_center = None
|
||||||
|
yellow_radius = None
|
||||||
|
yellow_ellipse = None
|
||||||
|
|
||||||
|
if len(cnt_yellow) >= 5:
|
||||||
|
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
||||||
|
yellow_ellipse = ((x, y), (width, height), angle)
|
||||||
|
axes_minor = min(width, height)
|
||||||
|
radius = axes_minor / 2
|
||||||
|
yellow_center = (int(x), int(y))
|
||||||
|
yellow_radius = int(radius)
|
||||||
|
else:
|
||||||
|
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
||||||
|
yellow_center = (int(x), int(y))
|
||||||
|
yellow_radius = int(radius)
|
||||||
|
yellow_ellipse = None
|
||||||
|
|
||||||
|
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
|
||||||
|
if yellow_center and yellow_radius:
|
||||||
|
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
|
||||||
|
# 红色范围1: 0-10度(接近0度的红色)
|
||||||
|
lower_red1 = np.array([0, 80, 0])
|
||||||
|
upper_red1 = np.array([10, 255, 255])
|
||||||
|
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
||||||
|
|
||||||
|
# 红色范围2: 170-180度(接近180度的红色)
|
||||||
|
lower_red2 = np.array([170, 80, 0])
|
||||||
|
upper_red2 = np.array([180, 255, 255])
|
||||||
|
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
|
||||||
|
|
||||||
|
# 合并两个红色掩码
|
||||||
|
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
|
||||||
|
|
||||||
|
# 形态学操作
|
||||||
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
|
||||||
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
found_valid_red = False
|
||||||
|
|
||||||
|
if contours_red:
|
||||||
|
# 找到所有符合条件的红色圆圈
|
||||||
|
for cnt_red in contours_red:
|
||||||
|
area_red = cv2.contourArea(cnt_red)
|
||||||
|
perimeter_red = cv2.arcLength(cnt_red, True)
|
||||||
|
|
||||||
|
if perimeter_red > 0:
|
||||||
|
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
|
||||||
|
else:
|
||||||
|
circularity_red = 0
|
||||||
|
|
||||||
|
# 红色圆圈也应该有一定的圆度
|
||||||
|
if area_red > 50 and circularity_red > 0.6:
|
||||||
|
# 计算红色圆圈的中心和半径
|
||||||
|
if len(cnt_red) >= 5:
|
||||||
|
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
|
||||||
|
radius_red = min(w_red, h_red) / 2
|
||||||
|
red_center = (int(x_red), int(y_red))
|
||||||
|
red_radius = int(radius_red)
|
||||||
|
else:
|
||||||
|
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
|
||||||
|
red_center = (int(x_red), int(y_red))
|
||||||
|
red_radius = int(radius_red)
|
||||||
|
|
||||||
|
# 计算黄色和红色圆心的距离
|
||||||
|
if red_center:
|
||||||
|
dx = yellow_center[0] - red_center[0]
|
||||||
|
dy = yellow_center[1] - red_center[1]
|
||||||
|
distance = np.sqrt(dx*dx + dy*dy)
|
||||||
|
|
||||||
|
# 圆心距离阈值:应该小于黄色半径的某个倍数(比如1.5倍)
|
||||||
|
max_distance = yellow_radius * 1.5
|
||||||
|
|
||||||
|
# 红色圆圈应该比黄色圆圈大(外圈)
|
||||||
|
if distance < max_distance and red_radius > yellow_radius * 0.8:
|
||||||
|
found_valid_red = True
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
||||||
|
|
||||||
|
# 记录这个有效目标
|
||||||
|
valid_targets.append({
|
||||||
|
'center': yellow_center,
|
||||||
|
'radius': yellow_radius,
|
||||||
|
'ellipse': yellow_ellipse,
|
||||||
|
'area': area
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found_valid_red:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
|
|
||||||
|
# 从所有有效目标中选择最佳目标
|
||||||
|
if valid_targets:
|
||||||
|
if laser_point:
|
||||||
|
# 如果有激光点,选择最接近激光点的目标
|
||||||
|
best_target = None
|
||||||
|
min_distance = float('inf')
|
||||||
|
for target in valid_targets:
|
||||||
|
dx = target['center'][0] - laser_point[0]
|
||||||
|
dy = target['center'][1] - laser_point[1]
|
||||||
|
distance = np.sqrt(dx*dx + dy*dy)
|
||||||
|
if distance < min_distance:
|
||||||
|
min_distance = distance
|
||||||
|
best_target = target
|
||||||
|
if best_target:
|
||||||
|
best_center = best_target['center']
|
||||||
|
best_radius = best_target['radius']
|
||||||
|
ellipse_params = best_target['ellipse']
|
||||||
|
method = "v3_ellipse_red_validated_laser_selected"
|
||||||
|
best_radius1 = best_radius * 5
|
||||||
|
else:
|
||||||
|
# 如果没有激光点,选择面积最大的目标
|
||||||
|
best_target = max(valid_targets, key=lambda t: t['area'])
|
||||||
|
best_center = best_target['center']
|
||||||
|
best_radius = best_target['radius']
|
||||||
|
ellipse_params = best_target['ellipse']
|
||||||
|
method = "v3_ellipse_red_validated"
|
||||||
|
best_radius1 = best_radius * 5
|
||||||
|
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_distance(pixel_radius):
|
||||||
|
"""根据像素半径估算实际距离(单位:米)"""
|
||||||
|
if not pixel_radius:
|
||||||
|
return 0.0
|
||||||
|
return (config.REAL_RADIUS_CM * config.FOCAL_LENGTH_PIX) / pixel_radius / 100.0
|
||||||
|
|
||||||
|
def estimate_pixel(physical_distance_cm, target_distance_m):
|
||||||
|
"""
|
||||||
|
根据物理距离和目标距离计算对应的像素偏移
|
||||||
|
|
||||||
|
Args:
|
||||||
|
physical_distance_cm: 物理世界中的距离(厘米),例如激光与摄像头的距离
|
||||||
|
target_distance_m: 目标距离(米),例如到靶心的距离
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: 对应的像素偏移
|
||||||
|
"""
|
||||||
|
if not target_distance_m or target_distance_m <= 0:
|
||||||
|
return 0.0
|
||||||
|
# 公式:像素偏移 = (物理距离_米) * 焦距_像素 / 目标距离_米
|
||||||
|
return (physical_distance_cm / 100.0) * config.FOCAL_LENGTH_PIX / target_distance_m
|
||||||
|
|
||||||
|
|
||||||
|
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
||||||
|
laser_point, distance_m, shot_id=None, photo_dir=None):
|
||||||
|
"""
|
||||||
|
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
||||||
|
由 save_shot_image(同步)和存图 worker(异步)调用。
|
||||||
|
"""
|
||||||
|
if not config.SAVE_IMAGE_ENABLED:
|
||||||
|
return None
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if photo_dir not in os.listdir("/root"):
|
||||||
|
os.mkdir(photo_dir)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
x, y = laser_point
|
||||||
|
if shot_id:
|
||||||
|
if center is None or radius is None:
|
||||||
|
filename = f"{photo_dir}/shot_{shot_id}_no_target.bmp"
|
||||||
|
else:
|
||||||
|
method_str = method or "unknown"
|
||||||
|
filename = f"{photo_dir}/shot_{shot_id}_{method_str}.bmp"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
|
||||||
|
img_count = len(all_images)
|
||||||
|
except Exception:
|
||||||
|
img_count = 0
|
||||||
|
if center is None or radius is None:
|
||||||
|
method_str = "no_target"
|
||||||
|
distance_str = "000"
|
||||||
|
else:
|
||||||
|
method_str = method or "unknown"
|
||||||
|
distance_str = str(round((distance_m or 0.0) * 100))
|
||||||
|
filename = f"{photo_dir}/{method_str}_{int(x)}_{int(y)}_{distance_str}_{img_count:04d}.bmp"
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
if shot_id:
|
||||||
|
logger.info(f"[VISION] 保存射箭图像,ID: {shot_id}, 文件名: {filename}")
|
||||||
|
if center and radius:
|
||||||
|
logger.info(f"结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
||||||
|
if ellipse_params:
|
||||||
|
(ec, (ew, eh), ea) = ellipse_params
|
||||||
|
logger.info(f"椭圆 -> 中心: ({ec[0]:.1f}, {ec[1]:.1f}), 长轴: {max(ew, eh):.1f}, 短轴: {min(ew, eh):.1f}, 角度: {ea:.1f}°")
|
||||||
|
else:
|
||||||
|
logger.info(f"结果 -> 未检测到靶心,保存原始图像(激光点: ({x}, {y}))")
|
||||||
|
|
||||||
|
laser_color = (config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
|
||||||
|
cross_thickness = int(max(getattr(config, "LASER_THICKNESS", 1), 1))
|
||||||
|
cross_length = int(max(getattr(config, "LASER_LENGTH", 10), 10))
|
||||||
|
cv2.line(img_cv, (int(x - cross_length), int(y)), (int(x + cross_length), int(y)), laser_color, cross_thickness)
|
||||||
|
cv2.line(img_cv, (int(x), int(y - cross_length)), (int(x), int(y + cross_length)), laser_color, cross_thickness)
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 1, laser_color, cross_thickness)
|
||||||
|
ring_thickness = 1
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 10, laser_color, ring_thickness)
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 5, laser_color, ring_thickness)
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 2, laser_color, -1)
|
||||||
|
|
||||||
|
if center and radius:
|
||||||
|
cx, cy = center
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
||||||
|
cv2.ellipse(img_cv, (cx_ell, cy_ell), (int(width / 2), int(height / 2)), angle, 0, 360, (0, 255, 0), 2)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
minor_length = min(width, height) / 2
|
||||||
|
minor_angle = angle + 90 if width >= height else angle
|
||||||
|
minor_angle_rad = math.radians(minor_angle)
|
||||||
|
dx_minor = minor_length * math.cos(minor_angle_rad)
|
||||||
|
dy_minor = minor_length * math.sin(minor_angle_rad)
|
||||||
|
pt1 = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2 = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1, pt2, (0, 0, 255), 2)
|
||||||
|
else:
|
||||||
|
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
||||||
|
cv2.line(img_cv, (int(x), int(y)), (cx, cy), (255, 255, 0), 1)
|
||||||
|
|
||||||
|
out = image.cv2image(img_cv, False, False)
|
||||||
|
out.save(filename)
|
||||||
|
if logger:
|
||||||
|
if center and radius:
|
||||||
|
logger.debug(f"图像已保存(含靶心标注): {filename}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"图像已保存(无靶心,含激光十字线): {filename}")
|
||||||
|
|
||||||
|
# 清理旧图片:如果目录下图片超过100张,删除最老的
|
||||||
|
try:
|
||||||
|
image_files = []
|
||||||
|
for f in os.listdir(photo_dir):
|
||||||
|
if f.endswith(('.bmp', '.jpg', '.jpeg')):
|
||||||
|
filepath = os.path.join(photo_dir, f)
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(filepath)
|
||||||
|
image_files.append((mtime, filepath, f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from config import MAX_IMAGES
|
||||||
|
if len(image_files) > MAX_IMAGES:
|
||||||
|
image_files.sort(key=lambda x: x[0])
|
||||||
|
to_delete = len(image_files) - MAX_IMAGES
|
||||||
|
deleted_count = 0
|
||||||
|
for _, filepath, fname in image_files[:to_delete]:
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
deleted_count += 1
|
||||||
|
if logger:
|
||||||
|
logger.debug(f"[VISION] 删除旧图片: {fname}")
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[VISION] 删除旧图片失败 {fname}: {e}")
|
||||||
|
if logger and deleted_count > 0:
|
||||||
|
logger.info(f"[VISION] 已清理 {deleted_count} 张旧图片,当前剩余 {MAX_IMAGES} 张")
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[VISION] 清理旧图片时出错(可忽略): {e}")
|
||||||
|
|
||||||
|
return filename
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"保存图像失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_worker_loop():
|
||||||
|
"""存图 worker:从队列取任务并调用 _save_shot_image_impl。"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
item = _save_queue.get()
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
|
_save_shot_image_impl(*item)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] 存图 worker 异常: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_save_queue.task_done()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_save_shot_worker():
|
||||||
|
"""启动存图 worker 线程(应在程序初始化时调用一次)。"""
|
||||||
|
global _save_worker_started
|
||||||
|
with _save_worker_lock:
|
||||||
|
if _save_worker_started:
|
||||||
|
return
|
||||||
|
_save_worker_started = True
|
||||||
|
t = threading.Thread(target=_save_worker_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info("[VISION] 存图 worker 线程已启动")
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
||||||
|
laser_point, distance_m, shot_id=None, photo_dir=None):
|
||||||
|
"""
|
||||||
|
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
||||||
|
"""
|
||||||
|
if not config.SAVE_IMAGE_ENABLED:
|
||||||
|
return
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
img_copy = np.copy(img_cv)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] enqueue_save_shot 复制图像失败: {e}")
|
||||||
|
return
|
||||||
|
task = (img_copy, center, radius, method, ellipse_params, laser_point, distance_m, shot_id, photo_dir)
|
||||||
|
try:
|
||||||
|
_save_queue.put_nowait(task)
|
||||||
|
except queue.Full:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning("[VISION] 存图队列已满,跳过本次保存")
|
||||||
|
|
||||||
|
|
||||||
|
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
||||||
|
laser_point, distance_m, shot_id=None, photo_dir=None):
|
||||||
|
"""
|
||||||
|
保存射击图像(带标注)。同步调用,会阻塞。
|
||||||
|
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
||||||
|
"""
|
||||||
|
if not config.SAVE_IMAGE_ENABLED:
|
||||||
|
return None
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
return _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
||||||
|
laser_point, distance_m, shot_id, photo_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] save_shot_image 转换图像失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_target(frame, laser_point=None):
|
||||||
|
"""
|
||||||
|
统一的靶心检测接口,根据配置自动选择检测方法
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: MaixPy图像帧
|
||||||
|
laser_point: 激光点坐标(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(result_img, center, radius, method, best_radius1, ellipse_params)
|
||||||
|
与detect_circle_v3保持相同的返回格式
|
||||||
|
"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
|
||||||
|
if config.USE_ARUCO:
|
||||||
|
# 使用ArUco检测
|
||||||
|
if logger:
|
||||||
|
logger.debug("[VISION] 使用ArUco标记检测靶心")
|
||||||
|
|
||||||
|
# 延迟导入以避免循环依赖
|
||||||
|
from aruco_detector import detect_target_with_aruco
|
||||||
|
return detect_target_with_aruco(frame, laser_point)
|
||||||
|
else:
|
||||||
|
# 使用传统黄色靶心检测
|
||||||
|
if logger:
|
||||||
|
logger.debug("[VISION] 使用传统黄色靶心检测")
|
||||||
|
return detect_circle_v3(frame, laser_point)
|
||||||
|
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
WiFi管理模块
|
||||||
|
提供WiFi连接、网络检测、质量监测等功能
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time as std_time
|
||||||
|
from maix import time
|
||||||
|
|
||||||
|
import config
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
|
|
||||||
|
class WiFiManager:
|
||||||
|
"""WiFi管理器(单例)"""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(WiFiManager, cls).__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# WiFi 相关状态
|
||||||
|
self._wifi_connected = False
|
||||||
|
self._wifi_ip = None
|
||||||
|
self._wifi_socket = None
|
||||||
|
self._wifi_socket_lock = threading.Lock()
|
||||||
|
self._prefer_wifi = True # 是否优先使用 WiFi
|
||||||
|
self._recv_buffer = b"" # TCP 接收缓冲区
|
||||||
|
|
||||||
|
# WiFi 质量监测(后台线程)
|
||||||
|
self._wifi_quality_monitor_thread = None
|
||||||
|
self._wifi_quality_stop_event = threading.Event()
|
||||||
|
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
||||||
|
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
||||||
|
|
||||||
|
# 服务器相关(用于网络检测)
|
||||||
|
try:
|
||||||
|
import archery_netcore as _netcore
|
||||||
|
self._server_ip = _netcore.get_config().get("SERVER_IP")
|
||||||
|
self._server_port = _netcore.get_config().get("SERVER_PORT")
|
||||||
|
except Exception:
|
||||||
|
self._server_ip = getattr(config, "SERVER_IP", None)
|
||||||
|
self._server_port = getattr(config, "SERVER_PORT", None)
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logger(self):
|
||||||
|
"""获取 logger 对象"""
|
||||||
|
return logger_manager.logger
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wifi_connected(self):
|
||||||
|
"""WiFi是否已连接"""
|
||||||
|
return self._wifi_connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wifi_ip(self):
|
||||||
|
"""WiFi IP地址"""
|
||||||
|
return self._wifi_ip
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wifi_socket(self):
|
||||||
|
"""WiFi socket对象"""
|
||||||
|
return self._wifi_socket
|
||||||
|
|
||||||
|
@wifi_socket.setter
|
||||||
|
def wifi_socket(self, value):
|
||||||
|
"""设置WiFi socket对象"""
|
||||||
|
self._wifi_socket = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wifi_socket_lock(self):
|
||||||
|
"""获取WiFi socket锁"""
|
||||||
|
return self._wifi_socket_lock
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prefer_wifi(self):
|
||||||
|
"""是否优先使用WiFi"""
|
||||||
|
return self._prefer_wifi
|
||||||
|
|
||||||
|
@prefer_wifi.setter
|
||||||
|
def prefer_wifi(self, value):
|
||||||
|
"""设置是否优先使用WiFi"""
|
||||||
|
self._prefer_wifi = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_wifi_rtt_ms(self):
|
||||||
|
"""最近一次测量的RTT"""
|
||||||
|
return self._last_wifi_rtt_ms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_wifi_rssi_dbm(self):
|
||||||
|
"""最近一次测量的RSSI"""
|
||||||
|
return self._last_wifi_rssi_dbm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recv_buffer(self):
|
||||||
|
"""TCP接收缓冲区"""
|
||||||
|
return self._recv_buffer
|
||||||
|
|
||||||
|
@recv_buffer.setter
|
||||||
|
def recv_buffer(self, value):
|
||||||
|
"""设置TCP接收缓冲区"""
|
||||||
|
self._recv_buffer = value
|
||||||
|
|
||||||
|
# ==================== WiFi 连接方法 ====================
|
||||||
|
|
||||||
|
def is_wifi_connected(self):
|
||||||
|
"""检查WiFi是否已连接"""
|
||||||
|
# 优先用 MaixPy network(如果可用)
|
||||||
|
try:
|
||||||
|
from maix import network
|
||||||
|
wifi = network.wifi.Wifi()
|
||||||
|
if wifi.is_connected():
|
||||||
|
self._wifi_connected = True
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
self.logger.warning("Failed to check WiFi connection using MaixPy network", exc_info=True)
|
||||||
|
|
||||||
|
# 兜底:看系统 wlan0 有没有 IP
|
||||||
|
try:
|
||||||
|
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
||||||
|
if ip:
|
||||||
|
self._wifi_connected = True
|
||||||
|
self._wifi_ip = ip
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
self.logger.warning("Failed to check WiFi connection using system command", exc_info=True)
|
||||||
|
|
||||||
|
self._wifi_connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def connect_wifi(self, ssid, password, verify_callback=None, persist=True, timeout_s=20):
|
||||||
|
"""
|
||||||
|
连接 Wi-Fi(先用新凭证尝试连接并验证可用性;失败自动回滚;成功后再决定是否落盘)
|
||||||
|
|
||||||
|
重要:系统的 /etc/init.d/S30wifi 通常会读取 /boot/wifi.ssid 与 /boot/wifi.pass 来连接 WiFi。
|
||||||
|
因此要"真正尝试连接新 WiFi",必须临时写入 /boot/ 触发重启;若失败则把旧值写回去(回滚)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ssid: WiFi SSID
|
||||||
|
password: WiFi密码
|
||||||
|
verify_callback: 验证回调函数,接收 (ip) 参数,返回 (success: bool, error: str)
|
||||||
|
persist: 是否持久化保存凭证
|
||||||
|
timeout_s: 连接超时时间(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ip, error): IP地址和错误信息(成功时error为None)
|
||||||
|
"""
|
||||||
|
# 配置文件路径定义
|
||||||
|
conf_path = "/etc/wpa_supplicant.conf"
|
||||||
|
ssid_file = "/boot/wifi.ssid"
|
||||||
|
pass_file = "/boot/wifi.pass"
|
||||||
|
|
||||||
|
def _read_text(path: str):
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_text(path: str, content: str):
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
def _restore_boot(old_ssid: str | None, old_pass: str | None):
|
||||||
|
# 还原 /boot 凭证:原来没有就删除,原来有就写回
|
||||||
|
try:
|
||||||
|
if old_ssid is None:
|
||||||
|
if os.path.exists(ssid_file):
|
||||||
|
os.remove(ssid_file)
|
||||||
|
else:
|
||||||
|
_write_text(ssid_file, old_ssid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if old_pass is None:
|
||||||
|
if os.path.exists(pass_file):
|
||||||
|
os.remove(pass_file)
|
||||||
|
else:
|
||||||
|
_write_text(pass_file, old_pass)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
old_conf = _read_text(conf_path)
|
||||||
|
old_boot_ssid = _read_text(ssid_file)
|
||||||
|
old_boot_pass = _read_text(pass_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 生成 wpa_supplicant 配置(写 /etc 作为辅助,具体是否生效取决于 S30wifi 脚本)
|
||||||
|
net_conf = os.popen(f'wpa_passphrase "{ssid}" "{password}"').read()
|
||||||
|
if "network={" not in net_conf:
|
||||||
|
raise RuntimeError("Failed to generate wpa config")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_write_text(
|
||||||
|
conf_path,
|
||||||
|
"ctrl_interface=/var/run/wpa_supplicant\n"
|
||||||
|
"update_config=1\n\n"
|
||||||
|
+ net_conf,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# 不强制要求写 /etc 成功(某些系统只用 /boot)
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ====== 临时写入 /boot 凭证,触发 WiFi 服务真正尝试连接新 SSID ======
|
||||||
|
_write_text(ssid_file, ssid.strip())
|
||||||
|
_write_text(pass_file, password.strip())
|
||||||
|
|
||||||
|
# 重启 Wi-Fi 服务
|
||||||
|
os.system("/etc/init.d/S30wifi restart")
|
||||||
|
|
||||||
|
# 等待获取 IP
|
||||||
|
wait_s = int(timeout_s) if timeout_s and timeout_s > 0 else 20
|
||||||
|
wait_s = min(max(wait_s, 5), 60)
|
||||||
|
for _ in range(wait_s):
|
||||||
|
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
||||||
|
if ip:
|
||||||
|
# 拿到 IP 不代表可上网/可访问目标;继续做可达性验证
|
||||||
|
self._wifi_connected = True
|
||||||
|
self._wifi_ip = ip
|
||||||
|
self.logger.info(f"[WIFI] 已连接,IP: {ip},开始验证网络可用性...")
|
||||||
|
|
||||||
|
# 验证能访问指定目标(通过回调函数)
|
||||||
|
if verify_callback:
|
||||||
|
success, error = verify_callback(ip)
|
||||||
|
if not success:
|
||||||
|
raise RuntimeError(error or "Verification failed")
|
||||||
|
|
||||||
|
# ====== 验证通过 ======
|
||||||
|
if not persist:
|
||||||
|
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
||||||
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
|
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
||||||
|
else:
|
||||||
|
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
||||||
|
|
||||||
|
return ip, None
|
||||||
|
|
||||||
|
std_time.sleep(1)
|
||||||
|
|
||||||
|
raise RuntimeError("Timeout: No IP obtained")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
||||||
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
|
try:
|
||||||
|
if old_conf is not None:
|
||||||
|
_write_text(conf_path, old_conf)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.system("/etc/init.d/S30wifi restart")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._wifi_connected = False
|
||||||
|
self._wifi_ip = None
|
||||||
|
self.logger.error(f"[WIFI] 连接/验证失败,已回滚: {e}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
def disconnect_wifi(self):
|
||||||
|
"""断开WiFi连接并清理资源"""
|
||||||
|
if self._wifi_socket:
|
||||||
|
try:
|
||||||
|
self._wifi_socket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._wifi_socket = None
|
||||||
|
self._wifi_connected = False
|
||||||
|
self._wifi_ip = None
|
||||||
|
|
||||||
|
# ==================== WiFi 质量监测 ====================
|
||||||
|
|
||||||
|
def _get_wifi_rssi_dbm(self):
|
||||||
|
"""
|
||||||
|
获取 WiFi 信号强度(dBm,越大越好;比如 -40 比 -80 好)
|
||||||
|
由于不同固件实现差异,这里做多策略兜底,失败返回 None
|
||||||
|
"""
|
||||||
|
# 1) 优先使用:iw dev wlan0 link
|
||||||
|
# 你提供的输出示例包含:signal: -58 dBm
|
||||||
|
try:
|
||||||
|
out = os.popen("iw dev wlan0 link 2>/dev/null").read()
|
||||||
|
if out:
|
||||||
|
m = re.search(r"signal:\s*(-?\d+(?:\.\d+)?)\s*dBm", out, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
v = float(m.group(1))
|
||||||
|
# 合理范围兜底
|
||||||
|
if -120.0 <= v <= 0.0:
|
||||||
|
return v
|
||||||
|
m2 = re.search(r"signal:\s*(-?\d+(?:\.\d+)?)", out, re.IGNORECASE)
|
||||||
|
if m2:
|
||||||
|
v = float(m2.group(1))
|
||||||
|
if -120.0 <= v <= 0.0:
|
||||||
|
return v
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2) 兜底:iwconfig
|
||||||
|
try:
|
||||||
|
out = os.popen("iwconfig wlan0 2>/dev/null").read()
|
||||||
|
m = re.search(r"Signal level[=:]\s*(-?\d+(?:\.\d+)?)\s*dBm", out, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
v = float(m.group(1))
|
||||||
|
if -120.0 <= v <= 0.0:
|
||||||
|
return v
|
||||||
|
m2 = re.search(r"Signal level[=:]\s*(-?\d+(?:\.\d+)?)", out, re.IGNORECASE)
|
||||||
|
if m2:
|
||||||
|
v = float(m2.group(1))
|
||||||
|
if -120.0 <= v <= 0.0:
|
||||||
|
return v
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _measure_wifi_tcp_rtt_ms(self, host, port, samples=3, per_sample_timeout_ms=900):
|
||||||
|
"""
|
||||||
|
测量:在当前 WiFi 下,TCP 建连耗时(RTT 的近似)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: 目标主机
|
||||||
|
port: 目标端口
|
||||||
|
samples: 采样次数
|
||||||
|
per_sample_timeout_ms: 每次采样超时时间(毫秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(median_rtt_ms, reachable_bool)
|
||||||
|
"""
|
||||||
|
rtts = []
|
||||||
|
reachable = False
|
||||||
|
addr = None
|
||||||
|
|
||||||
|
# 先解析一次地址,避免每次样本都做 DNS
|
||||||
|
try:
|
||||||
|
addr_info = socket.getaddrinfo(host, port)[0]
|
||||||
|
addr = (addr_info[0], addr_info[1], addr_info[2], addr_info[-1])
|
||||||
|
except Exception:
|
||||||
|
return float("inf"), False
|
||||||
|
|
||||||
|
for _ in range(max(1, int(samples or 1))):
|
||||||
|
s = None
|
||||||
|
try:
|
||||||
|
s = socket.socket(addr[0], addr[1], addr[2])
|
||||||
|
s.settimeout(max(0.1, float(per_sample_timeout_ms) / 1000.0))
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
s.connect(addr[-1])
|
||||||
|
elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), t0))
|
||||||
|
rtts.append(float(elapsed_ms))
|
||||||
|
reachable = True
|
||||||
|
except Exception:
|
||||||
|
# 单个样本失败不影响整体,只要有成功样本就继续
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if s:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 小间隔,避免过度占用
|
||||||
|
try:
|
||||||
|
time.sleep_ms(100)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not rtts:
|
||||||
|
return float("inf"), False
|
||||||
|
|
||||||
|
rtts_sorted = sorted(rtts)
|
||||||
|
mid = len(rtts_sorted) // 2
|
||||||
|
if len(rtts_sorted) % 2 == 1:
|
||||||
|
median = rtts_sorted[mid]
|
||||||
|
else:
|
||||||
|
median = (rtts_sorted[mid - 1] + rtts_sorted[mid]) / 2.0
|
||||||
|
return median, reachable
|
||||||
|
|
||||||
|
def _is_wifi_quality_bad(self, wifi_rtt_ms, wifi_rssi_dbm):
|
||||||
|
"""
|
||||||
|
综合判断 WiFi 质量是否差:
|
||||||
|
- RTT中位数超过阈值 -> bad
|
||||||
|
- 若启用 RSSI:信号弱(RSSI更差于阈值) 且 RTT 也偏高 -> bad
|
||||||
|
"""
|
||||||
|
if wifi_rtt_ms >= config.WIFI_QUALITY_RTT_BAD_MS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not getattr(config, "WIFI_QUALITY_USE_RSSI", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if wifi_rssi_dbm is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# "rtt_warn + rssi_bad" 联合条件
|
||||||
|
if wifi_rtt_ms >= config.WIFI_QUALITY_RTT_WARN_MS and wifi_rssi_dbm <= config.WIFI_QUALITY_RSSI_BAD_DBM:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_wifi_quality_status(self):
|
||||||
|
"""
|
||||||
|
获取当前 WiFi 质量状态(用于调试或显示)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"rtt_ms": float, "rssi_dbm": float, "is_bad": bool}
|
||||||
|
"""
|
||||||
|
rtt = self._last_wifi_rtt_ms
|
||||||
|
rssi = self._last_wifi_rssi_dbm
|
||||||
|
is_bad = False
|
||||||
|
|
||||||
|
if rtt is not None and rtt != float("inf"):
|
||||||
|
is_bad = self._is_wifi_quality_bad(rtt, rssi)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rtt_ms": rtt if rtt is not None and rtt != float("inf") else None,
|
||||||
|
"rssi_dbm": rssi,
|
||||||
|
"is_bad": is_bad
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==================== 后台质量监测线程 ====================
|
||||||
|
|
||||||
|
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
||||||
|
"""
|
||||||
|
启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI)
|
||||||
|
只在 WiFi 连接时运行,不影响业务发送性能
|
||||||
|
|
||||||
|
Args:
|
||||||
|
network_type_callback: 获取当前网络类型的回调函数
|
||||||
|
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||||
|
"""
|
||||||
|
if self._wifi_quality_monitor_thread is not None:
|
||||||
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._network_type_callback = network_type_callback
|
||||||
|
self._on_poor_quality_callback = on_poor_quality_callback
|
||||||
|
self._wifi_quality_stop_event.clear()
|
||||||
|
self._wifi_quality_monitor_thread = threading.Thread(
|
||||||
|
target=self._quality_monitor_loop,
|
||||||
|
daemon=True,
|
||||||
|
name="wifi_quality_monitor"
|
||||||
|
)
|
||||||
|
self._wifi_quality_monitor_thread.start()
|
||||||
|
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||||
|
|
||||||
|
def stop_quality_monitor(self):
|
||||||
|
"""停止 WiFi 质量监测线程"""
|
||||||
|
if self._wifi_quality_monitor_thread is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._wifi_quality_stop_event.set()
|
||||||
|
try:
|
||||||
|
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||||
|
finally:
|
||||||
|
self._wifi_quality_monitor_thread = None
|
||||||
|
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||||
|
|
||||||
|
def _quality_monitor_loop(self):
|
||||||
|
"""
|
||||||
|
WiFi 质量监测循环(后台线程)
|
||||||
|
每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
|
||||||
|
"""
|
||||||
|
while not self._wifi_quality_stop_event.is_set():
|
||||||
|
try:
|
||||||
|
# 只在 WiFi 连接时才测量
|
||||||
|
network_type = self._network_type_callback()
|
||||||
|
if network_type == "wifi" and self._wifi_socket:
|
||||||
|
# # 测量 RTT(1 个样本,快速测量)
|
||||||
|
# rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
|
||||||
|
# self._server_ip, self._server_port,
|
||||||
|
# samples=1, per_sample_timeout_ms=600
|
||||||
|
# )
|
||||||
|
|
||||||
|
# 获取 RSSI
|
||||||
|
rssi_dbm = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
|
# 更新缓存
|
||||||
|
# 不使用 RTT 测量
|
||||||
|
rtt_ms = 0
|
||||||
|
reachable = True
|
||||||
|
self._last_wifi_rtt_ms = rtt_ms if reachable else None
|
||||||
|
self._last_wifi_rssi_dbm = rssi_dbm
|
||||||
|
self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={rssi_dbm:.0f}dBm")
|
||||||
|
|
||||||
|
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
||||||
|
def _is_bad_now(_reachable, _rtt, _rssi):
|
||||||
|
if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
|
||||||
|
return True
|
||||||
|
return self._is_wifi_quality_bad(_rtt, _rssi)
|
||||||
|
|
||||||
|
bad = _is_bad_now(reachable, rtt_ms, rssi_dbm)
|
||||||
|
if bad:
|
||||||
|
self.logger.warning("[WiFi Monitor] 质量差,切换前快速重试 2 次(每次间隔1秒)")
|
||||||
|
|
||||||
|
for retry_idx in range(2):
|
||||||
|
time.sleep_ms(1000)
|
||||||
|
# 不使用 RTT 测量
|
||||||
|
rtt2 = 0
|
||||||
|
reachable2 = True
|
||||||
|
# rtt2, reachable2 = self._measure_wifi_tcp_rtt_ms(
|
||||||
|
# self._server_ip, self._server_port,
|
||||||
|
# samples=1, per_sample_timeout_ms=600
|
||||||
|
# )
|
||||||
|
rssi2 = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
|
# 更新缓存,便于外部查看最新状态
|
||||||
|
self._last_wifi_rtt_ms = rtt2 if reachable2 else None
|
||||||
|
self._last_wifi_rssi_dbm = rssi2
|
||||||
|
|
||||||
|
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
||||||
|
try:
|
||||||
|
self.logger.info(
|
||||||
|
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
||||||
|
f"rtt={rtt2 if rtt2 != float('inf') else -1:.0f}ms, rssi={rssi2}, bad={bad2}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not bad2:
|
||||||
|
self.logger.info("[WiFi Monitor] 复测恢复正常,继续保留 WiFi(不切换)")
|
||||||
|
bad = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if bad:
|
||||||
|
self.logger.warning("[WiFi Monitor] 复测仍差/不通,尝试切换到 4G")
|
||||||
|
self._on_poor_quality_callback()
|
||||||
|
|
||||||
|
# 休眠 5 秒
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[WiFi Monitor] 监测异常:{e}")
|
||||||
|
# 异常后继续循环,避免线程退出
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
# 全局 WiFi 管理器实例
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 兼容旧接口的函数 ====================
|
||||||
|
|
||||||
|
def is_wifi_connected():
|
||||||
|
"""尽量判断当前是否有 Wi-Fi(有则走 Wi-Fi OTA,否则走 4G OTA)"""
|
||||||
|
return wifi_manager.is_wifi_connected()
|
||||||
|
|
||||||
|
|
||||||
|
def connect_wifi(ssid, password, verify_callback=None, persist=True, timeout_s=20):
|
||||||
|
"""
|
||||||
|
连接 Wi-Fi 并将凭证持久化保存到 /boot/ 目录,
|
||||||
|
以便设备重启后自动连接。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ssid: WiFi SSID
|
||||||
|
password: WiFi密码
|
||||||
|
verify_callback: 验证回调函数,接收 (ip) 参数,返回 (success: bool, error: str)
|
||||||
|
persist: 是否持久化保存
|
||||||
|
timeout_s: 超时时间(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ip, error): IP地址和错误信息(成功时error为None)
|
||||||
|
"""
|
||||||
|
return wifi_manager.connect_wifi(ssid, password, verify_callback, persist, timeout_s)
|
||||||
Reference in New Issue
Block a user