Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9be7cffbb2 | ||
|
|
e1f12ae609 | ||
|
|
56fa8fc2a1 | ||
|
|
d3c8a26854 | ||
|
|
eae7da7291 | ||
|
|
dc5da0294f | ||
|
|
165eeff64e | ||
|
|
a184ff7d55 | ||
|
|
054e9e6d90 | ||
|
|
ae339889c2 | ||
|
|
b94b0f2e55 | ||
|
|
e82941a161 | ||
|
|
6556cfcf74 | ||
|
|
abbf30d7c0 | ||
|
|
5cf752bb3f | ||
|
|
6d8de56bfa | ||
|
|
aee1a92760 | ||
|
|
c34efed6f9 | ||
|
|
226394d3ed | ||
|
|
b169618b16 |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,3 +1,4 @@
|
||||
/cpp_ext/build/
|
||||
/.cursor/
|
||||
/dist/
|
||||
.idea
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/archery/cpp_ext"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
id: t11
|
||||
name: t11
|
||||
version: 2.15.14
|
||||
version: 2.15.35
|
||||
author: t11
|
||||
icon: ''
|
||||
desc: t11
|
||||
@@ -12,14 +12,15 @@ files:
|
||||
- at_client.py
|
||||
- camera_manager.py
|
||||
- cameraParameters.xml
|
||||
- charging_exit.sh
|
||||
- config.py
|
||||
- hardware.py
|
||||
- laser_detector.py
|
||||
- laser_manager.py
|
||||
- logger_manager.py
|
||||
- main.py
|
||||
- model_270139.cvimodel
|
||||
- model_270139.mud
|
||||
- model_285484.cvimodel
|
||||
- model_285484.mud
|
||||
- network.py
|
||||
- ota_curl.sh
|
||||
- ota_manager.py
|
||||
|
||||
+40
-3
@@ -69,13 +69,14 @@ class ATClient:
|
||||
# 同上:避免在 _reader_loop 持锁期间二次 acquire
|
||||
self._http_events.append(ev)
|
||||
|
||||
def send(self, cmd: str, expect: str = "OK", timeout_ms: int = 2000):
|
||||
def send(self, cmd: str, expect: str = "OK", timeout_ms: int = 2000, abort_event=None):
|
||||
"""
|
||||
发送 AT 命令并等待 expect(子串匹配)。
|
||||
注意:expect=">" 用于等待 prompt。
|
||||
"""
|
||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||
with self._cmd_lock:
|
||||
with self._q_lock:
|
||||
# 初始化等待
|
||||
self._waiting = True
|
||||
self._expect = expect_b
|
||||
@@ -89,6 +90,9 @@ class ATClient:
|
||||
|
||||
t0 = time.ticks_ms()
|
||||
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
||||
if abort_event is not None and abort_event.is_set():
|
||||
self._waiting = False
|
||||
break
|
||||
if (not self._waiting) or (self._expect in self._resp):
|
||||
self._waiting = False
|
||||
break
|
||||
@@ -101,6 +105,39 @@ class ATClient:
|
||||
except:
|
||||
return str(self._resp)
|
||||
|
||||
def send_raw_and_wait(self, data: bytes, expect: str = "OK", timeout_ms: int = 1000,
|
||||
suffix: bytes = b""):
|
||||
"""Register the response waiter before writing raw UART data."""
|
||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||
with self._cmd_lock:
|
||||
with self._q_lock:
|
||||
self._waiting = True
|
||||
self._expect = expect_b
|
||||
self._resp = b""
|
||||
|
||||
total = 0
|
||||
while total < len(data):
|
||||
n = self.uart.write(data[total:])
|
||||
if not n or n < 0:
|
||||
time.sleep_ms(1)
|
||||
continue
|
||||
total += n
|
||||
if suffix:
|
||||
self.uart.write(suffix)
|
||||
|
||||
t0 = time.ticks_ms()
|
||||
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
||||
if (not self._waiting) or (self._expect in self._resp):
|
||||
self._waiting = False
|
||||
break
|
||||
time.sleep_ms(5)
|
||||
|
||||
self._waiting = False
|
||||
try:
|
||||
return self._resp.decode(errors="ignore")
|
||||
except:
|
||||
return str(self._resp)
|
||||
|
||||
def _find_urc_tag(self, tag: bytes):
|
||||
"""
|
||||
只在"真正的 URC 边界"查找 tag,避免误命中 HTTP payload 内容。
|
||||
@@ -300,8 +337,8 @@ class ATClient:
|
||||
if len(self._rx) > 512 * 1024:
|
||||
self._rx = self._rx[-256 * 1024:]
|
||||
else:
|
||||
if len(self._rx) > 16384:
|
||||
self._rx = self._rx[-4096:]
|
||||
if len(self._rx) > 32768:
|
||||
self._rx = self._rx[-16384:]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
|
||||
# The application supplies its own PID. Refuse broad or malformed targets.
|
||||
TARGET_PID="$1"
|
||||
LASER_DEVICE="${2:-/dev/ttyS1}"
|
||||
LASER_BAUD="${3:-9600}"
|
||||
|
||||
turn_off_laser() {
|
||||
if [ ! -c "$LASER_DEVICE" ]; then
|
||||
echo "[CHARGE] laser serial device not found: $LASER_DEVICE" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
stty -F "$LASER_DEVICE" "$LASER_BAUD" raw -echo 2>/dev/null || return 1
|
||||
printf '\252\000\001\276\000\001\000\000\300' > "$LASER_DEVICE"
|
||||
}
|
||||
|
||||
case "$TARGET_PID" in
|
||||
''|*[!0-9]*)
|
||||
echo "[CHARGE] invalid application pid: $TARGET_PID" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$TARGET_PID" -le 1 ]; then
|
||||
echo "[CHARGE] refusing to terminate pid: $TARGET_PID" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# First request laser-off while the application still owns the initialized UART.
|
||||
turn_off_laser || true
|
||||
|
||||
kill -TERM "$TARGET_PID" 2>/dev/null || true
|
||||
|
||||
# Wait up to two seconds for a graceful exit, then force termination.
|
||||
WAIT_COUNT=0
|
||||
while kill -0 "$TARGET_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
|
||||
sleep 0.1
|
||||
WAIT_COUNT=$((WAIT_COUNT + 1))
|
||||
done
|
||||
if kill -0 "$TARGET_PID" 2>/dev/null; then
|
||||
kill -KILL "$TARGET_PID" 2>/dev/null || true
|
||||
sleep 0.1
|
||||
fi
|
||||
|
||||
# Send laser-off again after the application releases the UART.
|
||||
turn_off_laser || true
|
||||
@@ -24,7 +24,7 @@ TRIANGLE_DETECT_SCALE = 0.4
|
||||
# SERVER_IP = "stcp.shelingxingqiu.com"
|
||||
SERVER_IP = "www.shelingxingqiu.com"
|
||||
SERVER_PORT = 50005
|
||||
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
|
||||
HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
|
||||
|
||||
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
||||
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
||||
@@ -262,6 +262,16 @@ TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
||||
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||
|
||||
# YOLO 靶规格识别:class 0=20cm,class 1=40cm。
|
||||
TARGET_CLASS_YOLO_ENABLE = True
|
||||
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_285484.mud"
|
||||
TARGET_CLASS_YOLO_LABELS = (20, 40)
|
||||
TARGET_CLASS_YOLO_CONF_TH = 0.50
|
||||
TARGET_CLASS_YOLO_IOU_TH = 0.45
|
||||
TARGET_CLASS_YOLO_RETRY_ON_EMPTY = False
|
||||
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
|
||||
TARGET_CLASS_YOLO_PRELOAD_ON_BOOT = True
|
||||
|
||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||
# "yolo" — 调 Stage2 黑三角模型得子框,再子框内传统提取(需 TRIANGLE_BLACK_YOLO_ENABLE=True)。
|
||||
@@ -343,6 +353,18 @@ PIN_MAPPINGS = {
|
||||
# ==================== 电源配置 ====================
|
||||
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
||||
|
||||
# 实机数据:正常放电约为正电流,插入充电线后约为负电流。
|
||||
CHARGING_SHUTDOWN_ENABLED = True # True=充电时退出应用,False=关闭充电关机功能
|
||||
CHARGING_DIAGNOSTIC_LOG_ENABLED = False
|
||||
CHARGING_CHECK_INTERVAL_MS = 3000
|
||||
CHARGING_CURRENT_THRESHOLD_MA = 100.0
|
||||
CHARGING_CONFIRM_COUNT = 2
|
||||
CHARGING_NOTIFY_TIMEOUT_MS = 30000
|
||||
CHARGING_4G_UART_LOCK_TIMEOUT_SEC = 2.5
|
||||
CHARGING_4G_PROMPT_TIMEOUT_MS = 1500
|
||||
CHARGING_4G_CONFIRM_TIMEOUT_MS = 1000
|
||||
CHARGING_EXIT_SCRIPT = APP_DIR + "/charging_exit.sh"
|
||||
|
||||
BATTERY_SOC_LPF_ALPHA = 0.5
|
||||
BATTERY_SOC_AVG_WINDOW = 5
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Weusing · 思考、记录与实践">
|
||||
<title>Weusing · 思考与记录</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top"><a class="brand" href="/">WEUSING<span>.</span></a><nav><a href="#posts">文章</a><a href="#about">关于</a></nav></header>
|
||||
<main>
|
||||
<section class="intro"><p class="kicker">A SMALL NOTEBOOK ON THE WEB</p><h1>把想法写下来,<br><em>让时间看得见。</em></h1><p class="lede">这里记录技术、产品和生活里的小发现。保持好奇,持续构建。</p></section>
|
||||
<section id="posts" class="posts"><div class="section-head"><h2>最新文章</h2><span>2026 / 08</span></div>
|
||||
<article><div class="date">08.13<br><small>2026</small></div><div><p class="tag">TECHNOLOGY</p><h3>让静态网站快起来:从请求到首屏的几件小事</h3><p class="excerpt">更少的依赖、更短的路径,以及一些值得长期坚持的工程习惯。</p></div><a class="arrow" href="#">↗</a></article>
|
||||
<article><div class="date">08.06<br><small>2026</small></div><div><p class="tag">NOTES</p><h3>在复杂系统里,保留一条清晰的路</h3><p class="excerpt">记录一次调试过程,也记录那些最终留下来的判断。</p></div><a class="arrow" href="#">↗</a></article>
|
||||
<article><div class="date">07.28<br><small>2026</small></div><div><p class="tag">LIFE</p><h3>慢一点,观察风从哪里来</h3><p class="excerpt">日常、远方和一些不急着得到答案的问题。</p></div><a class="arrow" href="#">↗</a></article>
|
||||
</section>
|
||||
<section id="about" class="about"><p class="kicker">ABOUT THIS SPACE</p><p>Weusing 是一个个人写作空间。愿每一次发布,都比上一次更接近真实。</p></section>
|
||||
</main><footer><span>© 2026 WEUSING</span><span>BUILT WITH HTML & CSS</span></footer>
|
||||
</body></html>
|
||||
@@ -0,0 +1 @@
|
||||
:root{--ink:#202326;--muted:#747a80;--line:#d9d7d1;--accent:#d95d39;--paper:#f5f3ee}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:Arial,"Noto Sans SC",sans-serif}.top,main,footer{max-width:1080px;margin:auto}.top{height:88px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{font-size:18px;letter-spacing:2px;font-weight:700;color:var(--ink);text-decoration:none}.brand span{color:var(--accent)}nav{display:flex;gap:30px}nav a{font-size:13px;color:var(--muted);text-decoration:none}nav a:hover{color:var(--accent)}.intro{padding:105px 0 115px;border-bottom:1px solid var(--line)}.kicker,.tag{font-size:11px;letter-spacing:2px;color:var(--accent);font-weight:700}.intro h1{font-size:clamp(42px,7vw,78px);line-height:1.1;letter-spacing:-2px;margin:25px 0}.intro em{font-family:Georgia,serif;font-weight:400;color:#555;font-style:italic}.lede{max-width:390px;color:var(--muted);font-size:16px;line-height:1.8}.posts{padding:72px 0}.section-head{display:flex;align-items:baseline;justify-content:space-between;border-bottom:2px solid var(--ink);padding-bottom:16px}.section-head h2{font-size:25px;margin:0}.section-head span{font-size:12px;color:var(--muted)}article{display:grid;grid-template-columns:90px 1fr 32px;gap:28px;padding:32px 0;border-bottom:1px solid var(--line);align-items:start}.date{font-size:14px;color:var(--accent);line-height:1.45;font-weight:700}.date small{font-size:11px;color:var(--muted);font-weight:400}article h3{font-size:23px;line-height:1.35;margin:9px 0 8px;font-weight:500}article .tag{margin:0;color:var(--muted);font-size:10px}.excerpt{color:var(--muted);font-size:14px;line-height:1.7;margin:0}.arrow{color:var(--accent);font-size:22px;text-decoration:none}.about{padding:30px 0 110px;display:grid;grid-template-columns:1fr 2fr;gap:30px;border-top:1px solid var(--line)}.about p:last-child{font:italic 27px/1.5 Georgia,serif;max-width:620px;margin:0}footer{border-top:1px solid var(--line);padding:25px 0 35px;display:flex;justify-content:space-between;color:var(--muted);font-size:10px;letter-spacing:1px}@media(max-width:700px){.top,main,footer{margin:0 22px}.top{height:70px}.intro{padding:70px 0 75px}.intro h1{letter-spacing:-1px}article{grid-template-columns:58px 1fr 20px;gap:14px}article h3{font-size:18px}.about{grid-template-columns:1fr;padding-bottom:75px}.about p:last-child{font-size:23px}footer{margin:0;padding:22px}.section-head{margin-top:0}}
|
||||
@@ -122,7 +122,7 @@ def cmd_str():
|
||||
|
||||
# 1. 初始化日志系统
|
||||
import logging
|
||||
logger_manager.init_logging(log_level=logging.DEBUG)
|
||||
logger_manager.init_logging(log_level=logging.WARNING)
|
||||
logger = logger_manager.logger
|
||||
|
||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||
@@ -162,7 +162,11 @@ def cmd_str():
|
||||
and _loc_black == "yolo"
|
||||
and bool(getattr(config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
|
||||
)
|
||||
_preload_yolo = _preload_yolo or _need_black_preload
|
||||
_need_target_preload = (
|
||||
bool(getattr(config, "TARGET_CLASS_YOLO_ENABLE", False))
|
||||
and bool(getattr(config, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True))
|
||||
)
|
||||
_preload_yolo = _preload_yolo or _need_black_preload or _need_target_preload
|
||||
if _preload_yolo:
|
||||
preload_yolo_detector(logger)
|
||||
except Exception as e:
|
||||
@@ -283,37 +287,31 @@ def cmd_str():
|
||||
|
||||
pressure_buf = []
|
||||
pressure_sum = 0
|
||||
pressure_abs_sum = 0
|
||||
pressure_min = 4095
|
||||
pressure_max = 0
|
||||
pressure_t0_ms = None
|
||||
last_avg_abs = 0
|
||||
|
||||
def _flush_pressure_buf(reason: str):
|
||||
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
|
||||
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger
|
||||
if not pressure_buf:
|
||||
return
|
||||
if config.AIR_PRESSURE_lOG:
|
||||
t1_ms = time.ticks_ms()
|
||||
n = len(pressure_buf)
|
||||
avg = (pressure_sum / n) if n else 0
|
||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
||||
line = (
|
||||
f"[气压批量] reason={reason} "
|
||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
|
||||
f"values={','.join(map(str, pressure_buf))}"
|
||||
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
|
||||
)
|
||||
if logger:
|
||||
logger.debug(line)
|
||||
else:
|
||||
print(line)
|
||||
last_avg_abs = avg_abs
|
||||
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
||||
pressure_buf = []
|
||||
pressure_sum = 0
|
||||
pressure_abs_sum = 0
|
||||
pressure_min = 4095
|
||||
pressure_max = 0
|
||||
pressure_t0_ms = None
|
||||
@@ -335,6 +333,7 @@ def cmd_str():
|
||||
time.sleep_ms(250)
|
||||
continue
|
||||
|
||||
# todo 去除或者不在这里检测
|
||||
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
||||
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
||||
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
||||
@@ -351,12 +350,10 @@ def cmd_str():
|
||||
if network_manager.manual_trigger_flag:
|
||||
network_manager.clear_manual_trigger()
|
||||
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
||||
adc_abs_val = 10
|
||||
if logger:
|
||||
logger.info("[TEST] TCP命令触发射箭")
|
||||
else:
|
||||
adc_val = hardware_manager.adc_obj.read()
|
||||
adc_abs_val = hardware_manager.adc_obj.read_vol()
|
||||
except Exception as e:
|
||||
logger = logger_manager.logger
|
||||
if logger:
|
||||
@@ -367,9 +364,8 @@ def cmd_str():
|
||||
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
||||
if pressure_t0_ms is None:
|
||||
pressure_t0_ms = current_time
|
||||
pressure_buf.append((adc_val, adc_abs_val))
|
||||
pressure_buf.append(adc_val)
|
||||
pressure_sum += adc_val
|
||||
pressure_abs_sum += adc_abs_val
|
||||
if adc_val < pressure_min:
|
||||
pressure_min = adc_val
|
||||
if adc_val > pressure_max:
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
|
||||
[basic]
|
||||
type = cvimodel
|
||||
model = model_285484.cvimodel
|
||||
|
||||
[extra]
|
||||
model_type = yolov5
|
||||
input_type = rgb
|
||||
mean = 0, 0, 0
|
||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||
labels = 20, 40
|
||||
|
||||
+225
-20
@@ -18,7 +18,7 @@ import socket
|
||||
import config
|
||||
|
||||
from hardware import hardware_manager
|
||||
from power import get_bus_voltage, voltage_to_percent
|
||||
from power import get_bus_voltage, voltage_to_percent, is_charging
|
||||
from logger_manager import logger_manager
|
||||
from wifi import wifi_manager
|
||||
import subprocess
|
||||
@@ -67,6 +67,7 @@ class NetworkManager:
|
||||
self._queue_lock = threading.Lock()
|
||||
self._send_event = threading.Event()
|
||||
self._uart4g_lock = threading.Lock()
|
||||
self._terminal_send_event = threading.Event()
|
||||
self._device_id = None
|
||||
self._password = None
|
||||
self._raw_line_data = []
|
||||
@@ -624,6 +625,11 @@ class NetworkManager:
|
||||
password = inner_data.get("password")
|
||||
ota_res_url = inner_data.get("url")
|
||||
try:
|
||||
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
|
||||
try:
|
||||
os.remove(_f)
|
||||
except OSError:
|
||||
pass
|
||||
w = network.wifi.Wifi()
|
||||
e = w.connect(ssid, password, wait=True, timeout=15)
|
||||
err.check_raise(e, "connect wifi failed")
|
||||
@@ -664,9 +670,16 @@ class NetworkManager:
|
||||
self.logger.info(f"[conn wifi] cmd600 , data: {inner_data}")
|
||||
ssid = inner_data.get("ssid")
|
||||
password = inner_data.get("password")
|
||||
# 停止旧的WiFi质量监测(无论当前是WiFi还是4G连接)
|
||||
self._stop_wifi_quality_monitor()
|
||||
try:
|
||||
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
|
||||
try:
|
||||
os.remove(_f)
|
||||
except OSError:
|
||||
pass
|
||||
w = network.wifi.Wifi()
|
||||
e = w.connect(ssid, password, wait=True, timeout=15)
|
||||
e = w.connect(ssid, password, wait=True, timeout=10)
|
||||
err.check_raise(e, "connect wifi failed")
|
||||
if self.logger:
|
||||
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
|
||||
@@ -678,6 +691,11 @@ class NetworkManager:
|
||||
},
|
||||
2,
|
||||
)
|
||||
self._session_force_4g = False
|
||||
self.disconnect_server()
|
||||
self._tcp_connected = False
|
||||
self._network_type = None
|
||||
self.logger.info("[conn wifi] WiFi已连接,等待主循环重新登录")
|
||||
except Exception as e:
|
||||
self.logger.error(f"cmd600 失败: {e}")
|
||||
self.safe_enqueue(
|
||||
@@ -694,6 +712,35 @@ class NetworkManager:
|
||||
"""线程安全地将消息加入队列(公共方法)"""
|
||||
self._enqueue((msg_type, data_dict), high)
|
||||
|
||||
def safe_enqueue_and_wait(self, data_dict, msg_type=2, high=False, timeout_ms=30000):
|
||||
"""将消息加入队列,并等待网络线程确认已写入 TCP 连接。"""
|
||||
sent_event = threading.Event()
|
||||
self._enqueue((msg_type, data_dict, sent_event), high)
|
||||
return bool(sent_event.wait(max(0, int(timeout_ms)) / 1000.0))
|
||||
|
||||
def safe_replace_queue_and_wait(self, data_dict, msg_type=2, timeout_ms=30000):
|
||||
"""Drop queued messages, enqueue one terminal message, and wait for its TCP write."""
|
||||
sent_event = threading.Event()
|
||||
with self._queue_lock:
|
||||
self._high_send_queue.clear()
|
||||
self._normal_send_queue.clear()
|
||||
self._high_send_queue.append((msg_type, data_dict, sent_event))
|
||||
self._send_event.set()
|
||||
return bool(sent_event.wait(max(0, int(timeout_ms)) / 1000.0))
|
||||
|
||||
def safe_terminal_send_and_wait(self, data_dict, msg_type=2, timeout_ms=30000):
|
||||
"""Cancel ordinary 4G waits and replace queued work with one terminal message."""
|
||||
sent_event = threading.Event()
|
||||
result = {"sent": False}
|
||||
self._terminal_send_event.set()
|
||||
with self._queue_lock:
|
||||
self._high_send_queue.clear()
|
||||
self._normal_send_queue.clear()
|
||||
self._high_send_queue.append((msg_type, data_dict, sent_event, "terminal", result))
|
||||
self._send_event.set()
|
||||
completed = sent_event.wait(max(0, int(timeout_ms)) / 1000.0)
|
||||
return bool(completed and result["sent"])
|
||||
|
||||
def connect_server(self):
|
||||
"""
|
||||
连接到服务器(自动选择WiFi或4G)
|
||||
@@ -706,7 +753,7 @@ class NetworkManager:
|
||||
if self._network_type == "wifi":
|
||||
return self._check_wifi_connection()
|
||||
elif self._network_type == "4g":
|
||||
return True # 4G连接状态由AT命令维护
|
||||
return self._check_4g_connection()
|
||||
return False
|
||||
|
||||
# 自动选择网络
|
||||
@@ -723,6 +770,37 @@ class NetworkManager:
|
||||
return self._connect_tcp_via_4g()
|
||||
return False
|
||||
|
||||
def _check_4g_connection(self):
|
||||
"""检查4G TCP连接是否仍然有效(通过查询PDP地址验证网络附着状态)"""
|
||||
try:
|
||||
atc = hardware_manager.at_client
|
||||
if atc is None:
|
||||
return False
|
||||
if not self._uart4g_lock.acquire(timeout=3000):
|
||||
# 获取锁超时说明有其他操作在进行,视为连接仍有效
|
||||
return True
|
||||
try:
|
||||
r = atc.send("AT+CGPADDR=1", "OK", 3000)
|
||||
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
|
||||
ip = m.group(1) if m else ""
|
||||
if ip and ip != "0.0.0.0":
|
||||
return True
|
||||
# 无IP或IP无效,尝试重新激活PDP
|
||||
self.logger.warning("[4G-TCP] PDP地址无效,尝试重新激活")
|
||||
atc.send("AT+MIPCALL=1,1", "OK", 15000)
|
||||
r2 = atc.send("AT+CGPADDR=1", "OK", 3000)
|
||||
m2 = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r2)
|
||||
ip2 = m2.group(1) if m2 else ""
|
||||
if ip2 and ip2 != "0.0.0.0":
|
||||
return True
|
||||
self.logger.error("[4G-TCP] 重新激活PDP仍无有效IP,连接已断开")
|
||||
return False
|
||||
finally:
|
||||
self._uart4g_lock.release()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"[4G-TCP] 连接检查异常: {e}")
|
||||
return True # 异常时不误判断线
|
||||
|
||||
def _wrap_wifi_tls(self, plain_sock, hostname):
|
||||
"""
|
||||
在已建立的 TCP socket 上做 TLS(WiFi 走主机 ssl 库;4G 仍用模组 AT+SSL)。
|
||||
@@ -849,6 +927,12 @@ class NetworkManager:
|
||||
"""检查WiFi TCP连接是否仍然有效"""
|
||||
if not wifi_manager.wifi_socket:
|
||||
return False
|
||||
# TLS socket 无法可靠使用 MSG_PEEK,但物理 WiFi 链路仍可通过 STA 关联状态判断。
|
||||
if not wifi_manager.is_sta_associated():
|
||||
self.logger.warning("[WIFI-TCP] STA 已断开,关闭 WiFi TCP 并重新选网")
|
||||
wifi_manager.disconnect_wifi()
|
||||
self._tcp_connected = False
|
||||
return False
|
||||
# TLS(ssl.wrap_socket/SSLContext.wrap_socket) 后的 socket 往往不支持 MSG_PEEK/MSG_DONTWAIT。
|
||||
# 这种情况下“主动探测”反而容易误报断线;让真正的 send/recv 去判定更稳。
|
||||
try:
|
||||
@@ -1056,8 +1140,12 @@ class NetworkManager:
|
||||
return False
|
||||
try:
|
||||
for _ in range(max_retries):
|
||||
if self._terminal_send_event.is_set():
|
||||
return False
|
||||
cmd = f'AT+MIPSEND={link_id},{len(data)}'
|
||||
if ">" not in hardware_manager.at_client.send(cmd, ">", 2000):
|
||||
if self._terminal_send_event.is_set():
|
||||
return False
|
||||
time.sleep_ms(50)
|
||||
continue
|
||||
|
||||
@@ -1070,14 +1158,75 @@ class NetworkManager:
|
||||
total += n
|
||||
|
||||
hardware_manager.uart4g.write(b"\x1A")
|
||||
r = hardware_manager.at_client.send("", "OK", 8000)
|
||||
with hardware_manager.at_client._q_lock:
|
||||
hardware_manager.at_client._rx = b""
|
||||
r = hardware_manager.at_client.send(
|
||||
"", "OK", 8000, abort_event=self._terminal_send_event
|
||||
)
|
||||
if ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r):
|
||||
return True
|
||||
if self._terminal_send_event.is_set():
|
||||
return False
|
||||
time.sleep_ms(50)
|
||||
return False
|
||||
finally:
|
||||
self._uart4g_lock.release()
|
||||
|
||||
def _tcp_send_terminal_raw(self, data: bytes) -> bool:
|
||||
if not self._tcp_connected:
|
||||
return False
|
||||
if self._network_type == "wifi":
|
||||
return self._tcp_send_raw_via_wifi(data, max_retries=1)
|
||||
if self._network_type != "4g":
|
||||
return False
|
||||
|
||||
link_id = getattr(config, "TCP_LINK_ID", 0)
|
||||
lock_timeout_sec = float(
|
||||
getattr(config, "CHARGING_4G_UART_LOCK_TIMEOUT_SEC", 2.5)
|
||||
)
|
||||
prompt_timeout_ms = int(
|
||||
getattr(config, "CHARGING_4G_PROMPT_TIMEOUT_MS", 1500)
|
||||
)
|
||||
confirm_timeout_ms = int(
|
||||
getattr(config, "CHARGING_4G_CONFIRM_TIMEOUT_MS", 1000)
|
||||
)
|
||||
lock_start_ms = time.ticks_ms()
|
||||
if not self._uart4g_lock.acquire(timeout=max(0.0, lock_timeout_sec)):
|
||||
self.logger.warning(
|
||||
f"[CHARGE-4G] uart_lock timeout timeout_sec={lock_timeout_sec}"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
lock_elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), lock_start_ms))
|
||||
cmd = f'AT+MIPSEND={link_id},{len(data)}'
|
||||
prompt_start_ms = time.ticks_ms()
|
||||
if ">" not in hardware_manager.at_client.send(
|
||||
cmd, ">", max(0, prompt_timeout_ms)):
|
||||
prompt_elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), prompt_start_ms))
|
||||
self.logger.warning(
|
||||
f"[CHARGE-4G] prompt failed lock_ms={lock_elapsed_ms} "
|
||||
f"prompt_ms={prompt_elapsed_ms}"
|
||||
)
|
||||
return False
|
||||
prompt_elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), prompt_start_ms))
|
||||
confirm_start_ms = time.ticks_ms()
|
||||
r = hardware_manager.at_client.send_raw_and_wait(
|
||||
data,
|
||||
expect="OK",
|
||||
timeout_ms=max(0, confirm_timeout_ms),
|
||||
suffix=b"\x1A",
|
||||
)
|
||||
confirm_elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), confirm_start_ms))
|
||||
sent = ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r)
|
||||
self.logger.warning(
|
||||
f"[CHARGE-4G] send_done lock_ms={lock_elapsed_ms} "
|
||||
f"prompt_ms={prompt_elapsed_ms} confirm_ms={confirm_elapsed_ms} "
|
||||
f"sent={sent}"
|
||||
)
|
||||
return sent
|
||||
finally:
|
||||
self._uart4g_lock.release()
|
||||
|
||||
def _configure_ssl_before_connect(self, link_id: int) -> bool:
|
||||
"""按手册:MSSLCFG(auth) -> (可选) MSSLCERTWR -> MSSLCFG(cert) -> MIPCFG(ssl)"""
|
||||
ssl_id = getattr(config, "SSL_ID", 1)
|
||||
@@ -1162,6 +1311,14 @@ class NetworkManager:
|
||||
# 这里保持 socket 为非阻塞模式(连接时已 setblocking(False))。
|
||||
# 不要反复 settimeout(),否则会把 socket 切回"阻塞+超时",并导致 conncheck 误报 timed out。
|
||||
data = wifi_manager.wifi_socket.recv(4096) # 每次最多接收4KB(无数据会抛 BlockingIOError)
|
||||
if data == b"":
|
||||
self.logger.warning("[WIFI-TCP] 对端已关闭连接")
|
||||
try:
|
||||
wifi_manager.wifi_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
wifi_manager.wifi_socket = None
|
||||
self._tcp_connected = False
|
||||
return data
|
||||
|
||||
except BlockingIOError:
|
||||
@@ -1762,16 +1919,9 @@ class NetworkManager:
|
||||
self.logger.info("[NET] TCP主线程启动")
|
||||
|
||||
send_hartbeat_fail_count = 0
|
||||
last_charging_check = 0
|
||||
CHARGING_CHECK_INTERVAL = 5000 # 5秒检查一次充电状态
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 检查充电状态(每5秒检查一次)
|
||||
current_time = time.ticks_ms()
|
||||
if current_time - last_charging_check > CHARGING_CHECK_INTERVAL:
|
||||
last_charging_check = current_time
|
||||
|
||||
# OTA 期间不要 connect/登录/心跳/发送
|
||||
try:
|
||||
from ota_manager import ota_manager
|
||||
@@ -1784,7 +1934,7 @@ class NetworkManager:
|
||||
continue
|
||||
|
||||
if not self.connect_server():
|
||||
time.sleep_ms(5000)
|
||||
time.sleep_ms(1000)
|
||||
continue
|
||||
|
||||
# 发送登录包
|
||||
@@ -1805,7 +1955,7 @@ class NetworkManager:
|
||||
self.disconnect_server()
|
||||
except:
|
||||
pass
|
||||
time.sleep_ms(2000)
|
||||
time.sleep_ms(500)
|
||||
continue
|
||||
|
||||
self.logger.info("➡️ 登录包已发送,等待确认...")
|
||||
@@ -1813,12 +1963,25 @@ class NetworkManager:
|
||||
pending_cleared = False
|
||||
last_heartbeat_ack_time = time.ticks_ms()
|
||||
last_heartbeat_send_time = time.ticks_ms()
|
||||
last_wifi_sta_check_time = time.ticks_ms()
|
||||
|
||||
while True:
|
||||
# 如果底层连接已断开,尽快跳出内层循环触发重连/重选网络
|
||||
if not self._tcp_connected:
|
||||
break
|
||||
|
||||
if self._network_type == "wifi":
|
||||
now_ms = time.ticks_ms()
|
||||
if abs(time.ticks_diff(now_ms, last_wifi_sta_check_time)) >= 1000:
|
||||
last_wifi_sta_check_time = now_ms
|
||||
if not wifi_manager.is_sta_associated():
|
||||
self.logger.warning(
|
||||
"[WIFI-TCP] STA disconnected; leave WiFi session and reselect network"
|
||||
)
|
||||
wifi_manager.disconnect_wifi()
|
||||
self._tcp_connected = False
|
||||
break
|
||||
|
||||
# OTA 期间暂停 TCP 活动
|
||||
try:
|
||||
from ota_manager import ota_manager
|
||||
@@ -2092,9 +2255,20 @@ class NetworkManager:
|
||||
battery_data = {
|
||||
"battery": battery_percent,
|
||||
"voltage": round(float(voltage), 3),
|
||||
"netType": self.network_type,
|
||||
}
|
||||
self.safe_enqueue(battery_data, 2)
|
||||
self.logger.info(f"电量上报: {battery_percent}%")
|
||||
self.logger.info(f"电量上报: {battery_percent}% 充电: {is_charging()}")
|
||||
if is_charging():
|
||||
self.safe_enqueue(
|
||||
{
|
||||
"cmd": 700,
|
||||
},
|
||||
2,
|
||||
)
|
||||
elif inner_cmd == 700:
|
||||
self.logger.warning("服务器下发关机!!!")
|
||||
exit(-1)
|
||||
elif inner_cmd == 5: # OTA 升级
|
||||
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
||||
ssid = inner_data.get("ssid")
|
||||
@@ -2248,9 +2422,36 @@ class NetworkManager:
|
||||
item_is_high = False
|
||||
|
||||
if item:
|
||||
msg_type, data_dict = item
|
||||
msg_type, data_dict = item[:2]
|
||||
sent_event = item[2] if len(item) > 2 else None
|
||||
item_is_terminal = len(item) > 3 and item[3] == "terminal"
|
||||
terminal_result = item[4] if item_is_terminal and len(item) > 4 else None
|
||||
if (
|
||||
isinstance(data_dict, dict)
|
||||
and data_dict.get("cmd") == 1
|
||||
and isinstance(data_dict.get("data"), dict)
|
||||
):
|
||||
shot_data = data_dict["data"]
|
||||
self.logger.info(
|
||||
f"[REPORT-TX] shot_id={shot_data.get('shot_id')}, "
|
||||
f"target_class={shot_data.get('target_class')}, "
|
||||
f"confidence={shot_data.get('target_class_confidence')}"
|
||||
)
|
||||
pkt = self._netcore.make_packet(msg_type, data_dict)
|
||||
if not self.tcp_send_raw(pkt):
|
||||
send_ok = (
|
||||
self._tcp_send_terminal_raw(pkt)
|
||||
if item_is_terminal
|
||||
else self.tcp_send_raw(pkt)
|
||||
)
|
||||
if not send_ok:
|
||||
if item_is_terminal:
|
||||
if terminal_result is not None:
|
||||
terminal_result["sent"] = False
|
||||
if sent_event is not None:
|
||||
sent_event.set()
|
||||
break
|
||||
if self._terminal_send_event.is_set():
|
||||
continue
|
||||
# 发送失败:将消息放回队首(队列满则丢弃)
|
||||
with self.get_queue_lock():
|
||||
if item_is_high:
|
||||
@@ -2265,6 +2466,10 @@ class NetworkManager:
|
||||
except:
|
||||
pass
|
||||
break
|
||||
if sent_event is not None:
|
||||
if terminal_result is not None:
|
||||
terminal_result["sent"] = True
|
||||
sent_event.set()
|
||||
|
||||
# 发送激光校准结果
|
||||
if logged_in:
|
||||
@@ -2293,8 +2498,8 @@ class NetworkManager:
|
||||
pass
|
||||
break
|
||||
else:
|
||||
# 不立即断开,让下一轮心跳再试;同时缩短一点等待,提升恢复速度
|
||||
time.sleep_ms(200)
|
||||
# 不立即断开,让下一轮心跳再试
|
||||
time.sleep_ms(50)
|
||||
continue
|
||||
else:
|
||||
send_hartbeat_fail_count = 0
|
||||
@@ -2324,8 +2529,8 @@ class NetworkManager:
|
||||
self._send_event.clear()
|
||||
|
||||
self._tcp_connected = False
|
||||
self.logger.error("连接异常,2秒后重连...")
|
||||
time.sleep_ms(200)
|
||||
self.logger.error("连接异常,50ms后重连...")
|
||||
time.sleep_ms(50)
|
||||
|
||||
except Exception as e:
|
||||
# TCP主循环的顶层异常捕获,防止线程静默退出
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
提供电压、电流监测和充电状态检测
|
||||
"""
|
||||
import config
|
||||
import os
|
||||
import subprocess
|
||||
from logger_manager import logger_manager
|
||||
from maix import time as maix_time
|
||||
|
||||
@@ -85,7 +87,7 @@ def get_bus_voltage():
|
||||
def get_current():
|
||||
"""
|
||||
读取电流(单位:mA)
|
||||
正数表示充电,负数表示放电
|
||||
当前电源板实测:正数表示放电,负数表示充电。
|
||||
|
||||
INA226 电流计算公式:
|
||||
Current = (Current Register Value) × Current_LSB
|
||||
@@ -96,13 +98,13 @@ def get_current():
|
||||
return 0.0
|
||||
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,表示负数(放电)
|
||||
if raw & 0x8000:
|
||||
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||
else: # 最高位为0,表示正数(充电)
|
||||
else:
|
||||
signed_raw = raw
|
||||
# 转换为毫安
|
||||
current_ma = signed_raw * current_lsb * 1000
|
||||
@@ -129,7 +131,7 @@ def is_charging(threshold_ma=10.0):
|
||||
"""
|
||||
try:
|
||||
current = get_current()
|
||||
is_charge = current > threshold_ma
|
||||
is_charge = current < -abs(float(threshold_ma))
|
||||
return is_charge
|
||||
except Exception as e:
|
||||
logger = logger_manager.logger
|
||||
|
||||
+32
-1
@@ -320,8 +320,20 @@ def process_shot(adc_val):
|
||||
logger = logger_manager.logger
|
||||
|
||||
try:
|
||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||
frame = camera_manager.read_frame()
|
||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||
|
||||
# 每箭只识别当前帧,不复用上一箭的靶规格结果。
|
||||
target_class_result = None
|
||||
try:
|
||||
from target_roi_yolo import try_get_target_class_from_yolo
|
||||
|
||||
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
|
||||
if logger:
|
||||
logger.info(f"[YOLO-TARGET] 当前箭业务结果: {target_class_result}")
|
||||
except Exception as exc:
|
||||
if logger:
|
||||
logger.warning(f"[YOLO-TARGET] 当前箭分类失败,按未知处理: {exc}")
|
||||
|
||||
# 调用算法分析
|
||||
analysis_result = analyze_shot(frame)
|
||||
@@ -382,11 +394,25 @@ def process_shot(adc_val):
|
||||
srv_y = round(float(dy), 4) if dy is not None else 200.0
|
||||
|
||||
# 构造上报数据
|
||||
target_label = (
|
||||
target_class_result.get("label")
|
||||
if isinstance(target_class_result, dict)
|
||||
else None
|
||||
)
|
||||
target_confidence = (
|
||||
target_class_result.get("confidence")
|
||||
if isinstance(target_class_result, dict)
|
||||
else None
|
||||
)
|
||||
inner_data = {
|
||||
"shot_id": shot_id,
|
||||
"x": srv_x,
|
||||
"y": srv_y,
|
||||
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
||||
"target_class": target_label,
|
||||
"target_class_confidence": (
|
||||
float(target_confidence) if target_confidence is not None else None
|
||||
),
|
||||
"d": round((distance_m or 0.0) * 100),
|
||||
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||
"d_laser_quality": laser_signal_quality,
|
||||
@@ -414,6 +440,11 @@ def process_shot(adc_val):
|
||||
inner_data["ellipse_center_y"] = None
|
||||
|
||||
report_data = {"cmd": 1, "data": inner_data}
|
||||
if logger:
|
||||
logger.info(
|
||||
f"[REPORT-TARGET] enqueue shot_id={shot_id}, "
|
||||
f"target_class={target_label}, confidence={target_confidence}"
|
||||
)
|
||||
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
||||
|
||||
# 数据上报后再画标注,不干扰检测阶段的原始画面
|
||||
|
||||
+143
-1
@@ -89,6 +89,29 @@ def _stage2_roi_crop_save_worker(
|
||||
_detector_by_path = {}
|
||||
|
||||
|
||||
def _resolve_model_path(model_path: str):
|
||||
"""Resolve a model in either the installed app or MaixVision run directory."""
|
||||
model_path = (model_path or "").strip()
|
||||
if model_path and os.path.isfile(model_path):
|
||||
return model_path
|
||||
if not model_path:
|
||||
return ""
|
||||
name = os.path.basename(model_path)
|
||||
module_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates = (
|
||||
os.path.join(module_dir, name),
|
||||
os.path.join(module_dir, "test", name),
|
||||
os.path.join("/tmp/maixpy_run", name),
|
||||
os.path.join("/tmp/maixpy_run", "test", name),
|
||||
os.path.join(os.getcwd(), name),
|
||||
os.path.join(os.getcwd(), "test", name),
|
||||
)
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return model_path
|
||||
|
||||
|
||||
def reset_yolo_detector_cache():
|
||||
"""切换模型路径时可调用(通常不必)。"""
|
||||
global _detector_by_path
|
||||
@@ -175,6 +198,23 @@ def preload_yolo_detector(logger=None):
|
||||
% (_loc_black,)
|
||||
)
|
||||
|
||||
if bool(getattr(cfg, "TARGET_CLASS_YOLO_ENABLE", False)) and bool(
|
||||
getattr(cfg, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True)
|
||||
):
|
||||
class_model_path = _resolve_model_path(
|
||||
getattr(cfg, "TARGET_CLASS_YOLO_MODEL_PATH", "") or ""
|
||||
)
|
||||
class_detector = _get_detector(class_model_path)
|
||||
if class_detector is None:
|
||||
if logger:
|
||||
logger.warning(
|
||||
f"[YOLO-TARGET] 预加载失败:无法加载模型 {class_model_path}"
|
||||
)
|
||||
else:
|
||||
ok = True
|
||||
if logger:
|
||||
logger.info(f"[YOLO-TARGET] 靶规格模型已预加载: {class_model_path}")
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
@@ -206,8 +246,10 @@ def _det_obj_class_id(o):
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
if callable(v):
|
||||
v = v()
|
||||
return int(float(v))
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
return None
|
||||
|
||||
@@ -242,6 +284,106 @@ def _normalize_objs(objs):
|
||||
return out
|
||||
|
||||
|
||||
def _det_obj_score(o):
|
||||
"""兼容 Maix YOLO 不同版本的置信度字段。"""
|
||||
for key in ("score", "confidence", "conf", "prob"):
|
||||
if hasattr(o, key):
|
||||
try:
|
||||
value = getattr(o, key)
|
||||
if callable(value):
|
||||
value = value()
|
||||
value = float(value)
|
||||
if value == value:
|
||||
return value
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def try_get_target_class_from_yolo(maix_frame, logger=None):
|
||||
"""识别当前帧的 20/40 靶规格,失败返回 None。"""
|
||||
try:
|
||||
import config as cfg
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not bool(getattr(cfg, "TARGET_CLASS_YOLO_ENABLE", False)):
|
||||
return None
|
||||
model_path = _resolve_model_path(
|
||||
getattr(cfg, "TARGET_CLASS_YOLO_MODEL_PATH", "") or ""
|
||||
)
|
||||
if not os.path.isfile(model_path):
|
||||
if logger:
|
||||
logger.warning(f"[YOLO-TARGET] 模型文件不存在: {model_path}")
|
||||
return None
|
||||
detector = _get_detector(model_path)
|
||||
if detector is None:
|
||||
if logger:
|
||||
logger.warning("[YOLO-TARGET] 无法加载 nn.YOLOv5")
|
||||
return None
|
||||
|
||||
conf_th = float(getattr(cfg, "TARGET_CLASS_YOLO_CONF_TH", 0.5))
|
||||
iou_th = float(getattr(cfg, "TARGET_CLASS_YOLO_IOU_TH", 0.45))
|
||||
labels = getattr(cfg, "TARGET_CLASS_YOLO_LABELS", (20, 40))
|
||||
if isinstance(labels, str):
|
||||
labels = tuple(x.strip() for x in labels.split(",") if x.strip())
|
||||
labels = tuple(labels)
|
||||
|
||||
def _detect(threshold):
|
||||
try:
|
||||
raw = detector.detect(maix_frame, conf_th=threshold, iou_th=iou_th)
|
||||
except Exception as exc:
|
||||
if logger:
|
||||
logger.warning(f"[YOLO-TARGET] detect 异常: {exc}")
|
||||
return []
|
||||
return _normalize_objs(raw if raw is not None else [])
|
||||
|
||||
def _candidates(objs):
|
||||
found = []
|
||||
for obj in objs:
|
||||
class_id = _det_obj_class_id(obj)
|
||||
if class_id is None or class_id < 0 or class_id >= len(labels):
|
||||
continue
|
||||
try:
|
||||
label = int(float(labels[class_id]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if label in (20, 40):
|
||||
found.append((label, class_id, _det_obj_score(obj)))
|
||||
return found
|
||||
|
||||
objects = _detect(conf_th)
|
||||
candidates = _candidates(objects)
|
||||
if logger and objects:
|
||||
logger.info(
|
||||
"[YOLO-TARGET] 原始框=%d, 解析类别=%s"
|
||||
% (
|
||||
len(objects),
|
||||
[(_det_obj_class_id(o), _det_obj_score(o)) for o in objects[:8]],
|
||||
)
|
||||
)
|
||||
if not candidates and bool(
|
||||
getattr(cfg, "TARGET_CLASS_YOLO_RETRY_ON_EMPTY", False)
|
||||
):
|
||||
retry_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RETRY_CONF_TH", conf_th))
|
||||
if 0 < retry_th < conf_th:
|
||||
candidates = _candidates(_detect(retry_th))
|
||||
|
||||
if not candidates:
|
||||
if logger:
|
||||
logger.warning("[YOLO-TARGET] 当前帧未识别到 20/40,按未知处理")
|
||||
return None
|
||||
|
||||
label, class_id, confidence = max(candidates, key=lambda item: item[2])
|
||||
result = {"label": label, "class_id": class_id, "confidence": confidence}
|
||||
if logger:
|
||||
logger.info(
|
||||
f"[YOLO-TARGET] 当前帧分类={label}, class_id={class_id}, "
|
||||
f"conf={confidence:.3f}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
||||
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
|
||||
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,144 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
class _StopMonitor(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeTime:
|
||||
now_ms = 0
|
||||
stop_at_ms = None
|
||||
|
||||
@classmethod
|
||||
def reset(cls, stop_at_ms=None):
|
||||
cls.now_ms = 0
|
||||
cls.stop_at_ms = stop_at_ms
|
||||
|
||||
@classmethod
|
||||
def ticks_ms(cls):
|
||||
return cls.now_ms
|
||||
|
||||
@classmethod
|
||||
def sleep_ms(cls, milliseconds):
|
||||
cls.now_ms += milliseconds
|
||||
if cls.stop_at_ms is not None and cls.now_ms >= cls.stop_at_ms:
|
||||
raise _StopMonitor()
|
||||
|
||||
|
||||
def _load_power_module():
|
||||
module_path = Path(__file__).resolve().parents[1] / "power.py"
|
||||
module_name = "power_charging_shutdown_test"
|
||||
maix_module = types.ModuleType("maix")
|
||||
maix_module.time = _FakeTime
|
||||
|
||||
previous_maix = sys.modules.get("maix")
|
||||
sys.modules["maix"] = maix_module
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
finally:
|
||||
if previous_maix is None:
|
||||
sys.modules.pop("maix", None)
|
||||
else:
|
||||
sys.modules["maix"] = previous_maix
|
||||
|
||||
|
||||
power = _load_power_module()
|
||||
|
||||
|
||||
class ChargingShutdownTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config_patch = mock.patch.multiple(
|
||||
power.config,
|
||||
CHARGING_SHUTDOWN_ENABLED=True,
|
||||
CHARGING_DIAGNOSTIC_LOG_ENABLED=False,
|
||||
CHARGING_CHECK_INTERVAL_MS=5000,
|
||||
CHARGING_CURRENT_THRESHOLD_MA=100.0,
|
||||
CHARGING_CONFIRM_COUNT=2,
|
||||
CHARGING_NOTIFY_TIMEOUT_MS=30000,
|
||||
CHARGING_EXIT_SCRIPT="/tmp/charging_exit.sh",
|
||||
)
|
||||
self.config_patch.start()
|
||||
self.network_manager = mock.Mock()
|
||||
self.network_manager.safe_enqueue_and_wait.return_value = True
|
||||
network_module = types.ModuleType("network")
|
||||
network_module.network_manager = self.network_manager
|
||||
self.network_module_patch = mock.patch.dict(
|
||||
sys.modules,
|
||||
{"network": network_module},
|
||||
)
|
||||
self.network_module_patch.start()
|
||||
_FakeTime.reset()
|
||||
|
||||
def tearDown(self):
|
||||
self.network_module_patch.stop()
|
||||
self.config_patch.stop()
|
||||
|
||||
def test_two_charging_samples_notify_server_and_exit(self):
|
||||
popen_calls = []
|
||||
with (
|
||||
mock.patch.object(power, "get_current", return_value=-200.0),
|
||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||
mock.patch.object(
|
||||
power.subprocess,
|
||||
"Popen",
|
||||
side_effect=lambda args: popen_calls.append(args),
|
||||
),
|
||||
):
|
||||
power.charging_shutdown_monitor()
|
||||
|
||||
self.assertEqual(_FakeTime.now_ms, 5000)
|
||||
self.assertEqual(len(popen_calls), 1)
|
||||
self.network_manager.safe_enqueue_and_wait.assert_called_once_with(
|
||||
{"poweroff": "充电中"}, 2, high=True, timeout_ms=30000
|
||||
)
|
||||
|
||||
def test_discharging_does_not_notify_or_exit(self):
|
||||
_FakeTime.reset(stop_at_ms=10000)
|
||||
popen_calls = []
|
||||
|
||||
with (
|
||||
mock.patch.object(power, "get_current", return_value=200.0),
|
||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||
mock.patch.object(
|
||||
power.subprocess,
|
||||
"Popen",
|
||||
side_effect=lambda args: popen_calls.append(args),
|
||||
),
|
||||
self.assertRaises(_StopMonitor),
|
||||
):
|
||||
power.charging_shutdown_monitor()
|
||||
|
||||
self.assertEqual(popen_calls, [])
|
||||
self.network_manager.safe_enqueue_and_wait.assert_not_called()
|
||||
|
||||
def test_failed_sample_resets_confirmation_count(self):
|
||||
popen_calls = []
|
||||
currents = iter((-200.0, 0.0, -200.0, -200.0))
|
||||
with (
|
||||
mock.patch.object(power, "get_current", side_effect=lambda: next(currents)),
|
||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||
mock.patch.object(
|
||||
power.subprocess,
|
||||
"Popen",
|
||||
side_effect=lambda args: popen_calls.append(args),
|
||||
),
|
||||
):
|
||||
power.charging_shutdown_monitor()
|
||||
|
||||
self.assertEqual(_FakeTime.now_ms, 15000)
|
||||
self.assertEqual(len(popen_calls), 1)
|
||||
self.network_manager.safe_enqueue_and_wait.assert_called_once_with(
|
||||
{"poweroff": "充电中"}, 2, high=True, timeout_ms=30000
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -94,29 +94,33 @@ def detect_circle_v3(frame, laser_point=None):
|
||||
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
|
||||
if yellow_center and yellow_radius:
|
||||
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
|
||||
# 红色范围1: 0-10度(接近0度的红色)
|
||||
lower_red1 = np.array([0, 50, 40])
|
||||
upper_red1 = np.array([10, 255, 255])
|
||||
# 红色范围1: 0-12度(接近0度的红色)
|
||||
# 放宽S/V阈值:S>=30, V>=20 以捕获淡红/暗红
|
||||
lower_red1 = np.array([0, 30, 20])
|
||||
upper_red1 = np.array([12, 255, 255])
|
||||
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
||||
|
||||
# 红色范围2: 170-180度(接近180度的红色)
|
||||
lower_red2 = np.array([170, 50, 40])
|
||||
# 红色范围2: 168-180度(接近180度的红色)
|
||||
lower_red2 = np.array([168, 30, 20])
|
||||
upper_red2 = np.array([180, 255, 255])
|
||||
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
|
||||
|
||||
# 合并两个红色掩码
|
||||
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
|
||||
|
||||
# 形态学操作
|
||||
# 形态学操作:先CLOSE填充空洞,再DILATE加厚环状区域
|
||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
||||
|
||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
red_pixel_count = np.sum(mask_red > 0)
|
||||
print(f"Debug -> 红色掩码: {red_pixel_count} 像素, {len(contours_red)} 个轮廓")
|
||||
|
||||
found_valid_red = False
|
||||
|
||||
if contours_red:
|
||||
# 找到所有符合条件的红色圆圈
|
||||
for cnt_red in contours_red:
|
||||
area_red = cv2.contourArea(cnt_red)
|
||||
perimeter_red = cv2.arcLength(cnt_red, True)
|
||||
@@ -126,9 +130,10 @@ def detect_circle_v3(frame, laser_point=None):
|
||||
else:
|
||||
circularity_red = 0
|
||||
|
||||
# 红色圆圈也应该有一定的圆度
|
||||
if area_red > 30 and circularity_red > 0.4:
|
||||
# 计算红色圆圈的中心和半径
|
||||
# 环状轮廓圆度可能偏低,放宽到0.2
|
||||
print(f"Debug -> 红轮廓: 面积={area_red:.1f}, 圆度={circularity_red:.2f}" +
|
||||
f" (面积>15={area_red > 15}, 圆度>0.2={circularity_red > 0.2})")
|
||||
if area_red > 15 and circularity_red > 0.2:
|
||||
if len(cnt_red) >= 5:
|
||||
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
|
||||
radius_red = min(w_red, h_red) / 2
|
||||
@@ -139,22 +144,25 @@ def detect_circle_v3(frame, laser_point=None):
|
||||
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
|
||||
max_distance = yellow_radius * 2.0
|
||||
min_r = min(red_radius, yellow_radius)
|
||||
max_r = max(red_radius, yellow_radius)
|
||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
||||
f"大小比={size_ratio:.2f}(阈值=0.5), "
|
||||
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}")
|
||||
|
||||
# 红色圆圈应该比黄色圆圈大(外圈)
|
||||
if distance < max_distance and red_radius > yellow_radius * 0.7:
|
||||
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
||||
if distance < max_distance and size_ratio > 0.5:
|
||||
found_valid_red = True
|
||||
print(
|
||||
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
||||
|
||||
# 记录这个有效目标
|
||||
valid_targets.append({
|
||||
'center': yellow_center,
|
||||
'radius': yellow_radius,
|
||||
@@ -164,6 +172,16 @@ def detect_circle_v3(frame, laser_point=None):
|
||||
break
|
||||
|
||||
if not found_valid_red:
|
||||
# 如果黄圈非常可靠(大且圆),在没有红圈验证时仍接受
|
||||
if area > 30 and circularity > 0.85:
|
||||
print(f"[target] -> 黄圈高置信度(面积:{area:.0f}, 圆度:{circularity:.2f}),跳过红圈验证直接接受")
|
||||
valid_targets.append({
|
||||
'center': yellow_center,
|
||||
'radius': yellow_radius,
|
||||
'ellipse': yellow_ellipse,
|
||||
'area': area
|
||||
})
|
||||
else:
|
||||
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||
|
||||
# 从所有有效目标中选择最佳目标
|
||||
@@ -596,7 +614,7 @@ if __name__ == "__main__":
|
||||
# 检查文件扩展名
|
||||
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
||||
# 过滤掉 _result.jpg 后缀的文件
|
||||
if not filename.endswith('_result.jpg'):
|
||||
if filename.endswith('no_target.jpg'):
|
||||
filepath = os.path.join(TARGET_DIR, filename)
|
||||
if os.path.isfile(filepath):
|
||||
image_files.append(filepath)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read the digital voltage level on the MaixCAM P21 pin.
|
||||
|
||||
P21 is a digital GPIO pin, not the MaixCAM analog ADC input. Therefore this
|
||||
script can only distinguish LOW and HIGH. For a continuous voltage value,
|
||||
connect the signal to the board's B3/ADC pin and use ADC channel 0 instead.
|
||||
|
||||
Do not apply more than 3.3 V to P21. Always connect the signal ground to the
|
||||
MaixCAM ground.
|
||||
"""
|
||||
|
||||
from maix import app, gpio, pinmap, time
|
||||
|
||||
|
||||
PIN = "P21"
|
||||
IO_HIGH_VOLTAGE = 3.3
|
||||
SAMPLE_INTERVAL_MS = 200
|
||||
|
||||
|
||||
def find_gpio_function(pin):
|
||||
"""Return the GPIO function supported by the requested physical pin."""
|
||||
functions = pinmap.get_pin_functions(pin)
|
||||
gpio_functions = [name for name in functions if name.startswith("GPIO")]
|
||||
|
||||
print(f"{pin} supported functions: {', '.join(functions)}")
|
||||
if not gpio_functions:
|
||||
raise RuntimeError(f"{pin} does not provide a GPIO input function")
|
||||
|
||||
return gpio_functions[0]
|
||||
|
||||
|
||||
def main():
|
||||
gpio_function = find_gpio_function(PIN)
|
||||
pinmap.set_pin_function(PIN, gpio_function)
|
||||
voltage_input = gpio.GPIO(gpio_function, gpio.Mode.IN)
|
||||
|
||||
print(f"Reading {PIN} through {gpio_function}")
|
||||
print("P21 only reports LOW/HIGH; displayed voltage is an estimate.")
|
||||
print("Press the MaixCAM exit key to stop.")
|
||||
|
||||
while not app.need_exit():
|
||||
level = voltage_input.value()
|
||||
estimated_voltage = IO_HIGH_VOLTAGE if level else 0.0
|
||||
state = "HIGH" if level else "LOW"
|
||||
print(
|
||||
f"{PIN}: level={level}, state={state}, "
|
||||
f"estimated_voltage={estimated_voltage:.1f} V"
|
||||
)
|
||||
time.sleep_ms(SAMPLE_INTERVAL_MS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"P21 voltage detection failed: {error}")
|
||||
print("Check that this MaixCAM model exposes P21 as a GPIO pin.")
|
||||
raise
|
||||
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
class _FakeTime:
|
||||
@staticmethod
|
||||
def sleep(_seconds):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def sleep_ms(_milliseconds):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def ticks_ms():
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def ticks_diff(left, right):
|
||||
return left - right
|
||||
|
||||
|
||||
class _FakeLogger:
|
||||
def debug(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def info(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def warning(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeSocket:
|
||||
def __init__(self, recv_data=b""):
|
||||
self.recv_data = recv_data
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def recv(self, _size, *_flags):
|
||||
return self.recv_data
|
||||
|
||||
|
||||
class _StopAfterCallback:
|
||||
def __init__(self):
|
||||
self.stopped = False
|
||||
|
||||
def is_set(self):
|
||||
return self.stopped
|
||||
|
||||
|
||||
maix_module = types.ModuleType("maix")
|
||||
maix_module.time = _FakeTime
|
||||
maix_module.network = types.SimpleNamespace()
|
||||
maix_module.err = types.SimpleNamespace()
|
||||
sys.modules.setdefault("maix", maix_module)
|
||||
sys.modules.setdefault("ujson", json)
|
||||
|
||||
netcore_module = types.ModuleType("archery_netcore")
|
||||
netcore_module.get_config = lambda: {"SERVER_IP": "127.0.0.1", "SERVER_PORT": 1234}
|
||||
netcore_module.parse_packet = lambda _packet: (0, {})
|
||||
netcore_module.make_packet = lambda *_args, **_kwargs: b""
|
||||
netcore_module.actions_for_inner_cmd = lambda *_args, **_kwargs: []
|
||||
sys.modules["archery_netcore"] = netcore_module
|
||||
|
||||
hardware_module = types.ModuleType("hardware")
|
||||
hardware_module.hardware_manager = types.SimpleNamespace()
|
||||
sys.modules["hardware"] = hardware_module
|
||||
|
||||
power_module = types.ModuleType("power")
|
||||
power_module.get_bus_voltage = lambda: 0
|
||||
power_module.voltage_to_percent = lambda _voltage: 0
|
||||
sys.modules["power"] = power_module
|
||||
|
||||
import logger_manager
|
||||
import wifi
|
||||
import network
|
||||
|
||||
|
||||
class WiFiFailoverTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
logger_manager.logger_manager._logger = _FakeLogger()
|
||||
|
||||
def test_monitor_switches_when_sta_association_is_lost(self):
|
||||
manager = wifi.wifi_manager
|
||||
stop_event = _StopAfterCallback()
|
||||
callbacks = []
|
||||
|
||||
manager._wifi_socket = _FakeSocket()
|
||||
manager._wifi_quality_stop_event = stop_event
|
||||
manager._network_type_callback = lambda: "wifi"
|
||||
manager.is_sta_associated = lambda: False
|
||||
manager._get_wifi_rssi_dbm = lambda: None
|
||||
|
||||
def on_poor_quality():
|
||||
callbacks.append(True)
|
||||
stop_event.stopped = True
|
||||
|
||||
manager._on_poor_quality_callback = on_poor_quality
|
||||
manager._quality_monitor_loop()
|
||||
|
||||
self.assertEqual(callbacks, [True])
|
||||
self.assertIsNone(manager.last_wifi_rtt_ms)
|
||||
|
||||
def test_tls_connection_check_rejects_lost_sta_association(self):
|
||||
manager = network.network_manager
|
||||
sock = _FakeSocket()
|
||||
wifi.wifi_manager._wifi_socket = sock
|
||||
wifi.wifi_manager._wifi_connected = True
|
||||
wifi.wifi_manager._wifi_ip = "192.168.1.2"
|
||||
wifi.wifi_manager.is_sta_associated = lambda: False
|
||||
manager._tcp_connected = True
|
||||
|
||||
self.assertFalse(manager._check_wifi_connection())
|
||||
self.assertTrue(sock.closed)
|
||||
self.assertIsNone(wifi.wifi_manager.wifi_socket)
|
||||
self.assertFalse(manager.tcp_connected)
|
||||
|
||||
def test_receive_eof_marks_wifi_tcp_disconnected(self):
|
||||
manager = network.network_manager
|
||||
sock = _FakeSocket(recv_data=b"")
|
||||
wifi.wifi_manager._wifi_socket = sock
|
||||
manager._tcp_connected = True
|
||||
|
||||
self.assertEqual(manager.receive_tcp_data_via_wifi(), b"")
|
||||
self.assertTrue(sock.closed)
|
||||
self.assertIsNone(wifi.wifi_manager.wifi_socket)
|
||||
self.assertFalse(manager.tcp_connected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+12
@@ -25,3 +25,15 @@
|
||||
# 2.15.12 优化算法
|
||||
# 2.15.13 优化算法
|
||||
# 2.15.14 优化算法
|
||||
# 2.15.15 优化wifi连接
|
||||
# 2.15.16 修复wifi连接问题
|
||||
# 2.15.17 修复wifi连接问题
|
||||
# 2.15.18 wifi连接成功重新登录
|
||||
# 2.15.20 加了充电关机,激光也同时关闭
|
||||
# 2.15.21 测试4g 扩大了缓存池和改了心跳时间
|
||||
# 2.15.22 修复了4g网络和wifi切换问题
|
||||
# 2.15.23 合并充电关机与稳定版网络修复
|
||||
# 2.15.24 空改测试
|
||||
# 2.15.25 修复整合后关机失败和ota格式更新问题
|
||||
# 2.15.26
|
||||
# 2.15.33 26-8-12 14:03 修改充4g电关机时间 修复切换网络卡住bug
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
应用版本号
|
||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||
"""
|
||||
VERSION = '2.15.14'
|
||||
VERSION = '2.15.36'
|
||||
|
||||
|
||||
|
||||
@@ -631,7 +631,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
||||
min_r = min(rc["radius"], yellow_radius)
|
||||
max_r = max(rc["radius"], yellow_radius)
|
||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||
if dist_centers < max_dist and size_ratio > 0.5:
|
||||
if dist_centers < max_dist and size_ratio >= 0.3:
|
||||
if logger:
|
||||
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
||||
|
||||
@@ -239,7 +239,6 @@ class WiFiManager:
|
||||
old_conf = _read_text(conf_path)
|
||||
old_boot_ssid = _read_text(ssid_file)
|
||||
old_boot_pass = _read_text(pass_file)
|
||||
old_boot_wpa = _read_text(boot_wpa_path) if os.path.exists(boot_wpa_path) else None
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -251,9 +250,13 @@ class WiFiManager:
|
||||
_write_text(conf_path, full_conf)
|
||||
except Exception:
|
||||
pass
|
||||
_write_text(boot_wpa_path, full_conf)
|
||||
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
|
||||
try:
|
||||
if os.path.exists(boot_wpa_path):
|
||||
os.remove(boot_wpa_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 仍写入 ssid/pass,便于其它脚本/人工查看;S30wifi 优先使用 wpa_supplicant.conf
|
||||
_write_text(ssid_file, ssid.strip())
|
||||
_write_text(pass_file, password.strip())
|
||||
|
||||
@@ -293,7 +296,6 @@ class WiFiManager:
|
||||
if not persist:
|
||||
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||
_restore_boot_wpa(old_boot_wpa)
|
||||
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
||||
else:
|
||||
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
||||
@@ -307,7 +309,6 @@ class WiFiManager:
|
||||
except Exception as e:
|
||||
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||
_restore_boot_wpa(old_boot_wpa)
|
||||
try:
|
||||
if old_conf is not None:
|
||||
_write_text(conf_path, old_conf)
|
||||
@@ -352,7 +353,11 @@ class WiFiManager:
|
||||
else:
|
||||
full_conf = build_sta_conf_open(ssid)
|
||||
_write_text(conf_path, full_conf)
|
||||
_write_text(boot_wpa_path, full_conf)
|
||||
try:
|
||||
if os.path.exists(boot_wpa_path):
|
||||
os.remove(boot_wpa_path)
|
||||
except Exception:
|
||||
pass
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
@@ -536,7 +541,7 @@ class WiFiManager:
|
||||
|
||||
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
||||
"""
|
||||
启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI)
|
||||
启动 WiFi 质量后台监测线程(每 5 秒检查 STA 关联状态和 RSSI)
|
||||
只在 WiFi 连接时运行,不影响业务发送性能
|
||||
|
||||
Args:
|
||||
@@ -544,15 +549,20 @@ class WiFiManager:
|
||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||
"""
|
||||
with self._wifi_quality_lock:
|
||||
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
||||
current_thread = self._wifi_quality_monitor_thread
|
||||
current_stop_event = self._wifi_quality_stop_event
|
||||
if (current_thread is not None and current_thread.is_alive()
|
||||
and not current_stop_event.is_set()):
|
||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||
return
|
||||
|
||||
self._network_type_callback = network_type_callback
|
||||
self._on_poor_quality_callback = on_poor_quality_callback
|
||||
self._wifi_quality_stop_event.clear()
|
||||
stop_event = threading.Event()
|
||||
self._wifi_quality_stop_event = stop_event
|
||||
self._wifi_quality_monitor_thread = threading.Thread(
|
||||
target=self._quality_monitor_loop,
|
||||
args=(stop_event,),
|
||||
daemon=True,
|
||||
name="wifi_quality_monitor"
|
||||
)
|
||||
@@ -563,13 +573,14 @@ class WiFiManager:
|
||||
"""停止 WiFi 质量监测线程"""
|
||||
with self._wifi_quality_lock:
|
||||
t = self._wifi_quality_monitor_thread
|
||||
stop_event = self._wifi_quality_stop_event
|
||||
if t is None:
|
||||
return
|
||||
if not t.is_alive():
|
||||
self._wifi_quality_monitor_thread = None
|
||||
return
|
||||
|
||||
self._wifi_quality_stop_event.set()
|
||||
stop_event.set()
|
||||
try:
|
||||
t.join(timeout=2.0)
|
||||
except Exception as e:
|
||||
@@ -583,37 +594,41 @@ class WiFiManager:
|
||||
self._wifi_quality_monitor_thread = None
|
||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||
|
||||
def _quality_monitor_loop(self):
|
||||
def _quality_monitor_loop(self, stop_event):
|
||||
"""
|
||||
WiFi 质量监测循环(后台线程)
|
||||
每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
|
||||
每 5 秒检查 STA 关联状态和 RSSI,发现断链或质量差则触发切换
|
||||
"""
|
||||
while not self._wifi_quality_stop_event.is_set():
|
||||
while not 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
|
||||
# )
|
||||
# RTT 测量当前禁用;STA 关联状态用于判断物理 WiFi 链路是否仍存在。
|
||||
# 不能把禁用的 RTT 伪装成 0ms,否则关闭热点后会一直被判为正常。
|
||||
reachable = self.is_sta_associated()
|
||||
rtt_ms = None
|
||||
|
||||
# 获取 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_rtt_ms = rtt_ms
|
||||
self._last_wifi_rssi_dbm = rssi_dbm
|
||||
_rtt_s = f"{rtt_ms:.0f}ms" if rtt_ms is not None else "n/a"
|
||||
_rssi_s = f"{rssi_dbm:.0f}" if rssi_dbm is not None else "n/a"
|
||||
self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={_rssi_s}dBm")
|
||||
self.logger.debug(
|
||||
f"[WiFi Monitor] - associated={reachable}, RTT={_rtt_s}, RSSI={_rssi_s}dBm"
|
||||
)
|
||||
|
||||
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
||||
def _is_bad_now(_reachable, _rtt, _rssi):
|
||||
if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
|
||||
if not _reachable:
|
||||
return True
|
||||
# RTT 未启用时不参与质量判断;链路状态仍由 STA 关联保证。
|
||||
if _rtt is None:
|
||||
return False
|
||||
if _rtt == float("inf"):
|
||||
return True
|
||||
return self._is_wifi_quality_bad(_rtt, _rssi)
|
||||
|
||||
@@ -622,14 +637,10 @@ class WiFiManager:
|
||||
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
|
||||
# )
|
||||
if stop_event.wait(1.0):
|
||||
return
|
||||
reachable2 = self.is_sta_associated()
|
||||
rtt2 = None
|
||||
rssi2 = self._get_wifi_rssi_dbm()
|
||||
|
||||
# 更新缓存,便于外部查看最新状态
|
||||
@@ -638,14 +649,10 @@ class WiFiManager:
|
||||
|
||||
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
||||
try:
|
||||
_rtt_disp = (
|
||||
rtt2
|
||||
if rtt2 is not None and rtt2 != float("inf")
|
||||
else -1
|
||||
)
|
||||
_rtt_disp = f"{rtt2:.0f}ms" if rtt2 is not None else "n/a"
|
||||
self.logger.info(
|
||||
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
||||
f"rtt={_rtt_disp:.0f}ms, rssi={rssi2}, bad={bad2}"
|
||||
f"rtt={_rtt_disp}, rssi={rssi2}, bad={bad2}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -660,7 +667,7 @@ class WiFiManager:
|
||||
self._on_poor_quality_callback()
|
||||
|
||||
# 休眠 5 秒
|
||||
time.sleep(5)
|
||||
stop_event.wait(5.0)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[WiFi Monitor] 监测异常:{e}")
|
||||
|
||||
Reference in New Issue
Block a user