10 Commits
Author SHA1 Message Date
linyimin 06994c5905 fix: 网络连接 2026-08-12 18:34:25 +08:00
linyimin 23755f48ae fix: 检测充电关机 2026-08-12 18:33:31 +08:00
linyimin 5f509488c5 fix: 触发 2026-08-11 13:20:59 +08:00
linyimin c0bb245c8c pref: 20cm靶子检测 2026-08-11 13:14:57 +08:00
linyimin 9cfc871645 pref: 删除无引用方法调用 2026-08-11 09:26:30 +08:00
linyimin 27f96d8bce fix: 优化射箭拍照慢问题 2026-08-10 12:06:01 +08:00
linyimin 80e780b931 fix: 关闭拍照图片的打印 2026-08-10 11:40:31 +08:00
linyimin 3683033abf pref: 拍照更快 2026-08-10 11:38:12 +08:00
linyimin f0df9ad915 fix: 重连时间设置更小 2026-07-31 14:09:11 +08:00
linyimin 3fcd38f417 fix: 4g通讯 2026-07-31 13:59:48 +08:00
41 changed files with 105 additions and 669 deletions
-1
View File
@@ -1 +0,0 @@
*.sh text eol=lf
-8
View File
@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="yolov8" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="yolov8" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="yolov8" project-jdk-type="Python SDK" />
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/archery.iml" filepath="$PROJECT_DIR$/.idea/archery.iml" />
</modules>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-3
View File
@@ -1,3 +0,0 @@
{
"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.
+1 -2
View File
@@ -1,6 +1,6 @@
id: t11 id: t11
name: t11 name: t11
version: 2.15.18 version: 2.16.3
author: t11 author: t11
icon: '' icon: ''
desc: t11 desc: t11
@@ -12,7 +12,6 @@ files:
- at_client.py - at_client.py
- camera_manager.py - camera_manager.py
- cameraParameters.xml - cameraParameters.xml
- charging_exit.sh
- config.py - config.py
- hardware.py - hardware.py
- laser_detector.py - laser_detector.py
-47
View File
@@ -1,47 +0,0 @@
#!/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
+1 -9
View File
@@ -317,6 +317,7 @@ MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下
# ==================== 图像保存配置 ==================== # ==================== 图像保存配置 ====================
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存) SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
PHOTO_DIR = "/root/phot" # 照片存储目录 PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000 MAX_IMAGES = 1000
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同 # Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
@@ -343,15 +344,6 @@ PIN_MAPPINGS = {
# ==================== 电源配置 ==================== # ==================== 电源配置 ====================
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机 AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
# 实机数据:正常放电约为正电流,插入充电线后约为负电流。
CHARGING_SHUTDOWN_ENABLED = True # True=充电时退出应用,False=关闭充电关机功能
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 = APP_DIR + "/charging_exit.sh"
BATTERY_SOC_LPF_ALPHA = 0.5 BATTERY_SOC_LPF_ALPHA = 0.5
BATTERY_SOC_AVG_WINDOW = 5 BATTERY_SOC_AVG_WINDOW = 5
+26 -31
View File
@@ -22,7 +22,7 @@ from version import VERSION
# from logger import init_logging, get_logger, stop_logging # from logger import init_logging, get_logger, stop_logging
from logger_manager import logger_manager from logger_manager import logger_manager
from time_sync import sync_system_time_from_4g from time_sync import sync_system_time_from_4g
from power import charging_shutdown_monitor, init_ina226 from power import init_ina226
from laser_manager import laser_manager from laser_manager import laser_manager
from vision import start_save_shot_worker from vision import start_save_shot_worker
from network import network_manager from network import network_manager
@@ -120,18 +120,11 @@ def cmd_str():
# ==================== 第二阶段:软件初始化 ==================== # ==================== 第二阶段:软件初始化 ====================
# 1. 初始化日志系统 # 1. 初始化日志系统WARNING级别,不打印/写入INFO和DEBUG日志,提高执行流畅度)
import logging import logging
logger_manager.init_logging(log_level=logging.DEBUG) logger_manager.init_logging(log_level=logging.WARNING)
logger = logger_manager.logger logger = logger_manager.logger
# 充电关机独立读取 INA226,不依赖 TCP 连接或心跳流程。
try:
_thread.start_new_thread(charging_shutdown_monitor, ())
except Exception as e:
if logger:
logger.error(f"[CHARGE] 启动独立监测线程失败: {e}")
# 补充:因为初始化的时候,激光会亮,先关了它 # 补充:因为初始化的时候,激光会亮,先关了它
# laser_manager.turn_off_laser() # laser_manager.turn_off_laser()
@@ -252,8 +245,8 @@ def cmd_str():
# 4. 初始化设备IDnetwork_manager 内部会自动设置 device_id 和 password # 4. 初始化设备IDnetwork_manager 内部会自动设置 device_id 和 password
network_manager.read_device_id() network_manager.read_device_id()
# 5. 创建照片存储目录(如果启用图像保存) # 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存
if config.SAVE_IMAGE_ENABLED: if config.SAVE_IMAGE_ENABLED or getattr(config, "SAVE_IMAGE_ON_FAILURE", False):
photo_dir = config.PHOTO_DIR photo_dir = config.PHOTO_DIR
if photo_dir not in os.listdir("/root"): if photo_dir not in os.listdir("/root"):
try: try:
@@ -285,42 +278,42 @@ def cmd_str():
logger.info("系统准备完成...") logger.info("系统准备完成...")
last_adc_trigger = 0 last_adc_trigger = 0
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
try:
last_adc_val = hardware_manager.adc_obj.read()
except Exception:
last_adc_val = 0
peak_adc_val = 0 # 当前周期内的压力峰值
# 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样 # 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样
PRESSURE_BATCH_SIZE = 100 PRESSURE_BATCH_SIZE = 100
pressure_buf = [] pressure_buf = []
pressure_sum = 0 pressure_sum = 0
pressure_abs_sum = 0
pressure_min = 4095 pressure_min = 4095
pressure_max = 0 pressure_max = 0
pressure_t0_ms = None pressure_t0_ms = None
last_avg_abs = 0
def _flush_pressure_buf(reason: str): def _flush_pressure_buf(reason: str):
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: if not pressure_buf:
return return
if config.AIR_PRESSURE_lOG: if config.AIR_PRESSURE_lOG:
t1_ms = time.ticks_ms() t1_ms = time.ticks_ms()
n = len(pressure_buf) n = len(pressure_buf)
avg = (pressure_sum / n) if n else 0 avg = (pressure_sum / n) if n else 0
avg_abs = (pressure_abs_sum / n) if n else 0
line = ( line = (
f"[气压批量] reason={reason} " f"[气压批量] reason={reason} "
f"t0={pressure_t0_ms} t1={t1_ms} n={n} " f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} " f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
f"values={','.join(map(str, pressure_buf))}" f"values={','.join(map(str, pressure_buf))}"
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
) )
if logger: if logger:
logger.debug(line) logger.debug(line)
else: else:
print(line) print(line)
last_avg_abs = avg_abs
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏 # 无论是否记录日志,都必须清空 buffer,否则内存泄漏
pressure_buf = [] pressure_buf = []
pressure_sum = 0 pressure_sum = 0
pressure_abs_sum = 0
pressure_min = 4095 pressure_min = 4095
pressure_max = 0 pressure_max = 0
pressure_t0_ms = None pressure_t0_ms = None
@@ -358,12 +351,10 @@ def cmd_str():
if network_manager.manual_trigger_flag: if network_manager.manual_trigger_flag:
network_manager.clear_manual_trigger() network_manager.clear_manual_trigger()
adc_val = config.ADC_TRIGGER_THRESHOLD + 1 adc_val = config.ADC_TRIGGER_THRESHOLD + 1
adc_abs_val = 10
if logger: if logger:
logger.info("[TEST] TCP命令触发射箭") logger.info("[TEST] TCP命令触发射箭")
else: else:
adc_val = hardware_manager.adc_obj.read() adc_val = hardware_manager.adc_obj.read()
adc_abs_val = hardware_manager.adc_obj.read_vol()
except Exception as e: except Exception as e:
logger = logger_manager.logger logger = logger_manager.logger
if logger: if logger:
@@ -374,24 +365,29 @@ def cmd_str():
# ====== 气压采样缓存(每次循环都记录,批量输出日志)====== # ====== 气压采样缓存(每次循环都记录,批量输出日志)======
if pressure_t0_ms is None: if pressure_t0_ms is None:
pressure_t0_ms = current_time pressure_t0_ms = current_time
pressure_buf.append((adc_val, adc_abs_val)) pressure_buf.append(adc_val)
pressure_sum += adc_val pressure_sum += adc_val
pressure_abs_sum += adc_abs_val
if adc_val < pressure_min: if adc_val < pressure_min:
pressure_min = adc_val pressure_min = adc_val
if adc_val > pressure_max: if adc_val > pressure_max:
pressure_max = adc_val pressure_max = adc_val
if len(pressure_buf) >= PRESSURE_BATCH_SIZE: if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
_flush_pressure_buf("batch") _flush_pressure_buf("batch")
# if adc_val >= 2000: # 峰值检测:压力从峰值下降时触发,确保捕获到最大冲击时刻
# print(f"adc :{adc_val}") if adc_val > peak_adc_val:
if adc_val >= config.ADC_TRIGGER_THRESHOLD: peak_adc_val = adc_val # 更新峰值
if (peak_adc_val >= config.ADC_TRIGGER_THRESHOLD
and adc_val < peak_adc_val
and last_adc_val >= peak_adc_val):
# 封顶后下降沿触发:peak是最大值,当前值开始下降,且上次值还在peak位置
hardware_manager.start_idle_timer() # 重新计时 hardware_manager.start_idle_timer() # 重新计时
diff_ms = current_time - last_adc_trigger diff_ms = current_time - last_adc_trigger
if diff_ms < 3000: if diff_ms < 3000:
logger.info(f"[MAIN] 扳机触发过于频繁, {diff_ms}ms") peak_adc_val = 0 # 去抖期间重置峰值
time.sleep_ms(5)
continue continue
last_adc_trigger = current_time last_adc_trigger = current_time
peak_adc_val = 0 # 触发后重置峰值
# 触发前先把缓存刷出来,避免波形被长耗时处理截断 # 触发前先把缓存刷出来,避免波形被长耗时处理截断
_flush_pressure_buf("before_trigger") _flush_pressure_buf("before_trigger")
@@ -410,10 +406,9 @@ def cmd_str():
try: try:
camera_manager.show(camera_manager.read_frame()) camera_manager.show(camera_manager.read_frame())
except Exception as e: except Exception as e:
logger = logger_manager.logger pass
if logger:
logger.error(f"[MAIN] 显示异常: {e}")
time.sleep_ms(5) time.sleep_ms(5)
last_adc_val = adc_val
except Exception as e: except Exception as e:
# 主循环的顶层异常捕获,防止程序静默退出 # 主循环的顶层异常捕获,防止程序静默退出
+22 -26
View File
@@ -18,7 +18,7 @@ import socket
import config import config
from hardware import hardware_manager 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 logger_manager import logger_manager
from wifi import wifi_manager from wifi import wifi_manager
import subprocess import subprocess
@@ -669,6 +669,8 @@ class NetworkManager:
self.logger.info(f"[conn wifi] cmd600 , data: {inner_data}") self.logger.info(f"[conn wifi] cmd600 , data: {inner_data}")
ssid = inner_data.get("ssid") ssid = inner_data.get("ssid")
password = inner_data.get("password") password = inner_data.get("password")
# 停止旧的WiFi质量监测(无论当前是WiFi还是4G连接)
self._stop_wifi_quality_monitor()
try: try:
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"): for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
try: try:
@@ -709,12 +711,6 @@ class NetworkManager:
"""线程安全地将消息加入队列(公共方法)""" """线程安全地将消息加入队列(公共方法)"""
self._enqueue((msg_type, data_dict), high) 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 connect_server(self): def connect_server(self):
""" """
连接到服务器(自动选择WiFi或4G) 连接到服务器(自动选择WiFi或4G)
@@ -901,12 +897,6 @@ class NetworkManager:
"""检查WiFi TCP连接是否仍然有效""" """检查WiFi TCP连接是否仍然有效"""
if not wifi_manager.wifi_socket: if not wifi_manager.wifi_socket:
return False 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。 # TLS(ssl.wrap_socket/SSLContext.wrap_socket) 后的 socket 往往不支持 MSG_PEEK/MSG_DONTWAIT。
# 这种情况下“主动探测”反而容易误报断线;让真正的 send/recv 去判定更稳。 # 这种情况下“主动探测”反而容易误报断线;让真正的 send/recv 去判定更稳。
try: try:
@@ -1222,14 +1212,6 @@ class NetworkManager:
# 这里保持 socket 为非阻塞模式(连接时已 setblocking(False))。 # 这里保持 socket 为非阻塞模式(连接时已 setblocking(False))。
# 不要反复 settimeout(),否则会把 socket 切回"阻塞+超时",并导致 conncheck 误报 timed out。 # 不要反复 settimeout(),否则会把 socket 切回"阻塞+超时",并导致 conncheck 误报 timed out。
data = wifi_manager.wifi_socket.recv(4096) # 每次最多接收4KB(无数据会抛 BlockingIOError 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 return data
except BlockingIOError: except BlockingIOError:
@@ -1830,9 +1812,16 @@ class NetworkManager:
self.logger.info("[NET] TCP主线程启动") self.logger.info("[NET] TCP主线程启动")
send_hartbeat_fail_count = 0 send_hartbeat_fail_count = 0
last_charging_check = 0
CHARGING_CHECK_INTERVAL = 5000 # 5秒检查一次充电状态
while True: while True:
try: try:
# 检查充电状态(每5秒检查一次)
current_time = time.ticks_ms()
if current_time - last_charging_check > CHARGING_CHECK_INTERVAL:
last_charging_check = current_time
# OTA 期间不要 connect/登录/心跳/发送 # OTA 期间不要 connect/登录/心跳/发送
try: try:
from ota_manager import ota_manager from ota_manager import ota_manager
@@ -2156,7 +2145,17 @@ class NetworkManager:
"netType": self.network_type, "netType": self.network_type,
} }
self.safe_enqueue(battery_data, 2) 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 升级 elif inner_cmd == 5: # OTA 升级
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {} inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
ssid = inner_data.get("ssid") ssid = inner_data.get("ssid")
@@ -2310,8 +2309,7 @@ class NetworkManager:
item_is_high = False item_is_high = False
if item: if item:
msg_type, data_dict = item[:2] msg_type, data_dict = item
sent_event = item[2] if len(item) > 2 else None
pkt = self._netcore.make_packet(msg_type, data_dict) pkt = self._netcore.make_packet(msg_type, data_dict)
if not self.tcp_send_raw(pkt): if not self.tcp_send_raw(pkt):
# 发送失败:将消息放回队首(队列满则丢弃) # 发送失败:将消息放回队首(队列满则丢弃)
@@ -2328,8 +2326,6 @@ class NetworkManager:
except: except:
pass pass
break break
if sent_event is not None:
sent_event.set()
# 发送激光校准结果 # 发送激光校准结果
if logged_in: if logged_in:
+5 -116
View File
@@ -5,8 +5,6 @@
提供电压、电流监测和充电状态检测 提供电压、电流监测和充电状态检测
""" """
import config import config
import os
import subprocess
from logger_manager import logger_manager from logger_manager import logger_manager
from maix import time as maix_time from maix import time as maix_time
@@ -87,7 +85,7 @@ def get_bus_voltage():
def get_current(): def get_current():
""" """
读取电流(单位:mA 读取电流(单位:mA
当前电源板实测:正数表示电,负数表示充电。 正数表示电,负数表示放电
INA226 电流计算公式: INA226 电流计算公式:
Current = (Current Register Value) × Current_LSB Current = (Current Register Value) × Current_LSB
@@ -98,13 +96,13 @@ def get_current():
return 0.0 return 0.0
raw = read_register(config.REG_CURRENT) raw = read_register(config.REG_CURRENT)
# INA226 电流寄存器是16位有符号整数 # INA226 电流寄存器是16位有符号整数
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。 # 最高位是符号位0=正(充电),1=负(放电)
# 计算 Current_LSB(根据 CALIBRATION_VALUE # 计算 Current_LSB(根据 CALIBRATION_VALUE
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
# 处理有符号数:如果最高位为1,转换为负数 # 处理有符号数:如果最高位为1,转换为负数
if raw & 0x8000: if raw & 0x8000: # 最高位为1,表示负数(放电)
signed_raw = raw - 0x10000 # 转换为有符号整数 signed_raw = raw - 0x10000 # 转换为有符号整数
else: else: # 最高位为0,表示正数(充电)
signed_raw = raw signed_raw = raw
# 转换为毫安 # 转换为毫安
current_ma = signed_raw * current_lsb * 1000 current_ma = signed_raw * current_lsb * 1000
@@ -131,7 +129,7 @@ def is_charging(threshold_ma=10.0):
""" """
try: try:
current = get_current() current = get_current()
is_charge = current < -abs(float(threshold_ma)) is_charge = current > threshold_ma
return is_charge return is_charge
except Exception as e: except Exception as e:
logger = logger_manager.logger logger = logger_manager.logger
@@ -142,115 +140,6 @@ def is_charging(threshold_ma=10.0):
return False return False
def charging_shutdown_monitor():
"""独立监测 INA226;连续确认充电后通知服务器并退出应用。"""
logger = logger_manager.logger
shutdown_enabled = bool(getattr(config, "CHARGING_SHUTDOWN_ENABLED", False))
diagnostic_enabled = bool(getattr(config, "CHARGING_DIAGNOSTIC_LOG_ENABLED", False))
if not shutdown_enabled and not diagnostic_enabled:
if logger:
logger.info("[CHARGE] 充电退出监测已禁用")
return
interval_ms = max(100, int(getattr(config, "CHARGING_CHECK_INTERVAL_MS", 5000)))
threshold_ma = float(getattr(config, "CHARGING_CURRENT_THRESHOLD_MA", 10.0))
confirm_required = max(1, int(getattr(config, "CHARGING_CONFIRM_COUNT", 2)))
confirm_count = 0
if logger:
logger.info(
f"[CHARGE] 独立监测线程启动: interval={interval_ms}ms, "
f"threshold={threshold_ma:.1f}mA, confirm={confirm_required}, "
f"shutdown={'on' if shutdown_enabled else 'off'}"
)
while True:
current_ma = get_current()
if diagnostic_enabled and logger:
voltage = get_bus_voltage()
logger.info(
f"[CHARGE-DIAG] INA226 voltage={voltage:.3f}V, "
f"current={current_ma:.1f}mA"
)
if not shutdown_enabled:
maix_time.sleep_ms(interval_ms)
continue
if current_ma < -abs(threshold_ma):
confirm_count += 1
if logger:
logger.info(
f"[CHARGE] INA226 充电电流 {current_ma:.1f}mA "
f"({confirm_count}/{confirm_required})"
)
else:
confirm_count = 0
if confirm_count >= confirm_required:
script_path = getattr(
config,
"CHARGING_EXIT_SCRIPT",
config.APP_DIR + "/charging_exit.sh",
)
if not os.path.isfile(script_path):
if logger:
logger.error(f"[CHARGE] 退出脚本不存在: {script_path}")
confirm_count = 0
else:
if logger:
logger.warning(
f"[CHARGE] 已连续确认充电,通知服务器后退出应用: current={current_ma:.1f}mA"
)
try:
from network import network_manager
notify_timeout_ms = max(
0,
int(getattr(config, "CHARGING_NOTIFY_TIMEOUT_MS", 30000)),
)
notification_sent = network_manager.safe_enqueue_and_wait(
{"poweroff": "充电中"},
2,
high=True,
timeout_ms=notify_timeout_ms,
)
if notification_sent:
if logger:
logger.info("[CHARGE] 充电状态已发送到服务器")
elif logger:
logger.warning(
f"[CHARGE] 等待服务器发送超时({notify_timeout_ms}ms),继续执行退出"
)
except Exception as e:
if logger:
logger.error(f"[CHARGE] 充电状态上报失败,继续执行退出: {e}")
try:
from laser_manager import laser_manager
laser_manager.turn_off_laser()
if logger:
logger.info("[CHARGE] 激光关闭命令已发送")
except Exception as e:
if logger:
logger.error(f"[CHARGE] Python 关闭激光失败,交由退出脚本兜底: {e}")
try:
subprocess.Popen([
"/bin/sh",
script_path,
str(os.getpid()),
str(getattr(config, "DISTANCE_SERIAL_DEVICE", "/dev/ttyS1")),
str(getattr(config, "DISTANCE_SERIAL_BAUDRATE", 9600)),
])
return
except Exception as e:
if logger:
logger.error(f"[CHARGE] 调用退出脚本失败: {e}")
confirm_count = 0
maix_time.sleep_ms(interval_ms)
def voltage_to_percent(voltage): def voltage_to_percent(voltage):
""" """
根据电压估算电池百分比(高密度查表插值 + 滤波)。 根据电压估算电池百分比(高密度查表插值 + 滤波)。
+6 -2
View File
@@ -320,9 +320,11 @@ def process_shot(adc_val):
logger = logger_manager.logger logger = logger_manager.logger
try: try:
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
frame = camera_manager.read_frame() frame = camera_manager.read_frame()
# 网络事件移到拍照之后,避免阻塞拍照
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
# 调用算法分析 # 调用算法分析
analysis_result = analyze_shot(frame) analysis_result = analyze_shot(frame)
@@ -518,6 +520,7 @@ def process_shot(adc_val):
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS) laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
# 保存图像(异步队列,与 main.py 一致) # 保存图像(异步队列,与 main.py 一致)
_force_save = (dx is None and dy is None) and getattr(config, "SAVE_IMAGE_ON_FAILURE", False)
enqueue_save_shot( enqueue_save_shot(
result_img, result_img,
center, center,
@@ -527,8 +530,9 @@ def process_shot(adc_val):
(x, y), (x, y),
distance_m, distance_m,
shot_id=shot_id, shot_id=shot_id,
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None, photo_dir=config.PHOTO_DIR if (config.SAVE_IMAGE_ENABLED or _force_save) else None,
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None, yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None,
force_save=_force_save,
) )
if logger: if logger:
Binary file not shown.
Binary file not shown.
-144
View File
@@ -1,144 +0,0 @@
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()
+4 -4
View File
@@ -154,11 +154,11 @@ def detect_circle_v3(frame, laser_point=None):
max_r = max(red_radius, yellow_radius) max_r = max(red_radius, yellow_radius)
size_ratio = min_r / max_r if max_r > 0 else 0 size_ratio = min_r / max_r if max_r > 0 else 0
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), " print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
f"大小比={size_ratio:.2f}(阈值=0.5), " f"大小比={size_ratio:.2f}(阈值=0.4), "
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}") f"距离OK={distance < max_distance}, 大小OK={size_ratio >= 0.4}")
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5) # 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
if distance < max_distance and size_ratio > 0.5: if distance < max_distance and size_ratio >= 0.4:
found_valid_red = True found_valid_red = True
print( print(
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}") f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
@@ -598,7 +598,7 @@ if __name__ == "__main__":
# 1. 设置要测试的图片路径 # 1. 设置要测试的图片路径
# 建议将图片放在与脚本同级目录,或者使用绝对路径 # 建议将图片放在与脚本同级目录,或者使用绝对路径
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp" TARGET_IMAGE = "/root/phot/shot_1830921_0_no_target.jpg"
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径 TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
-59
View File
@@ -1,59 +0,0 @@
#!/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
-139
View File
@@ -1,139 +0,0 @@
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()
-7
View File
@@ -29,10 +29,3 @@
# 2.15.16 修复wifi连接问题 # 2.15.16 修复wifi连接问题
# 2.15.17 修复wifi连接问题 # 2.15.17 修复wifi连接问题
# 2.15.18 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
+1 -1
View File
@@ -4,6 +4,6 @@
应用版本号 应用版本号
每次 OTA 更新时,只需要更新这个文件中的版本号 每次 OTA 更新时,只需要更新这个文件中的版本号
""" """
VERSION = '2.15.25' VERSION = '2.16.3'
+10 -7
View File
@@ -631,7 +631,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
min_r = min(rc["radius"], yellow_radius) min_r = min(rc["radius"], yellow_radius)
max_r = max(rc["radius"], yellow_radius) max_r = max(rc["radius"], yellow_radius)
size_ratio = min_r / max_r if max_r > 0 else 0 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.4:
if logger: if logger:
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), " logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
f"红心({rc['center']}), 距离:{dist_centers:.1f}, " f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
@@ -797,12 +797,12 @@ def estimate_pixel(physical_distance_cm, target_distance_m):
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params, def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None, laser_point, distance_m, shot_id=None, photo_dir=None,
yolo_roi_xyxy=None): yolo_roi_xyxy=None, force_save=False):
""" """
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。 内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
由 save_shot_image(同步)和存图 worker(异步)调用。 由 save_shot_image(同步)和存图 worker(异步)调用。
""" """
if not config.SAVE_IMAGE_ENABLED: if not config.SAVE_IMAGE_ENABLED and not force_save:
return None return None
if photo_dir is None: if photo_dir is None:
photo_dir = config.PHOTO_DIR photo_dir = config.PHOTO_DIR
@@ -938,11 +938,12 @@ def start_save_shot_worker():
def enqueue_save_shot(result_img, center, radius, method, ellipse_params, def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None, laser_point, distance_m, shot_id=None, photo_dir=None,
yolo_roi_xyxy=None): yolo_roi_xyxy=None, force_save=False):
""" """
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。 将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
force_save=True 时,忽略 SAVE_IMAGE_ENABLED 配置强制保存(用于检测失败时的调试图像)。
""" """
if not config.SAVE_IMAGE_ENABLED: if not config.SAVE_IMAGE_ENABLED and not force_save:
return return
if photo_dir is None: if photo_dir is None:
photo_dir = config.PHOTO_DIR photo_dir = config.PHOTO_DIR
@@ -965,6 +966,7 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
shot_id, shot_id,
photo_dir, photo_dir,
yolo_roi_xyxy, yolo_roi_xyxy,
force_save,
) )
try: try:
_save_queue.put_nowait(task) _save_queue.put_nowait(task)
@@ -976,12 +978,12 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
def save_shot_image(result_img, center, radius, method, ellipse_params, def save_shot_image(result_img, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None, laser_point, distance_m, shot_id=None, photo_dir=None,
yolo_roi_xyxy=None): yolo_roi_xyxy=None, force_save=False):
""" """
保存射击图像(带标注)。同步调用,会阻塞。 保存射击图像(带标注)。同步调用,会阻塞。
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。 主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
""" """
if not config.SAVE_IMAGE_ENABLED: if not config.SAVE_IMAGE_ENABLED and not force_save:
return None return None
if photo_dir is None: if photo_dir is None:
photo_dir = config.PHOTO_DIR photo_dir = config.PHOTO_DIR
@@ -998,6 +1000,7 @@ def save_shot_image(result_img, center, radius, method, ellipse_params,
shot_id, shot_id,
photo_dir, photo_dir,
yolo_roi_xyxy, yolo_roi_xyxy,
force_save,
) )
except Exception as e: except Exception as e:
logger = logger_manager.logger logger = logger_manager.logger
+26 -21
View File
@@ -541,7 +541,7 @@ class WiFiManager:
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback): def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
""" """
启动 WiFi 质量后台监测线程(每 5 秒检查 STA 关联状态和 RSSI 启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI
只在 WiFi 连接时运行,不影响业务发送性能 只在 WiFi 连接时运行,不影响业务发送性能
Args: Args:
@@ -591,38 +591,34 @@ class WiFiManager:
def _quality_monitor_loop(self): def _quality_monitor_loop(self):
""" """
WiFi 质量监测循环(后台线程) WiFi 质量监测循环(后台线程)
每 5 秒检查 STA 关联状态和 RSSI,发现断链或质量差则触发切换 每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
""" """
while not self._wifi_quality_stop_event.is_set(): while not self._wifi_quality_stop_event.is_set():
try: try:
# 只在 WiFi 连接时才测量 # 只在 WiFi 连接时才测量
network_type = self._network_type_callback() network_type = self._network_type_callback()
if network_type == "wifi" and self._wifi_socket: if network_type == "wifi" and self._wifi_socket:
# RTT 测量当前禁用;STA 关联状态用于判断物理 WiFi 链路是否仍存在。 # # 测量 RTT(1 个样本,快速测量)
# 不能把禁用的 RTT 伪装成 0ms,否则关闭热点后会一直被判为正常。 # rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
reachable = self.is_sta_associated() # self._server_ip, self._server_port,
rtt_ms = None # samples=1, per_sample_timeout_ms=600
# )
# 获取 RSSI # 获取 RSSI
rssi_dbm = self._get_wifi_rssi_dbm() rssi_dbm = self._get_wifi_rssi_dbm()
# 更新缓存 # 更新缓存
self._last_wifi_rtt_ms = rtt_ms # 不使用 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._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" _rssi_s = f"{rssi_dbm:.0f}" if rssi_dbm is not None else "n/a"
self.logger.debug( self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={_rssi_s}dBm")
f"[WiFi Monitor] - associated={reachable}, RTT={_rtt_s}, RSSI={_rssi_s}dBm"
)
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动) # 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
def _is_bad_now(_reachable, _rtt, _rssi): def _is_bad_now(_reachable, _rtt, _rssi):
if not _reachable: if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
return True
# RTT 未启用时不参与质量判断;链路状态仍由 STA 关联保证。
if _rtt is None:
return False
if _rtt == float("inf"):
return True return True
return self._is_wifi_quality_bad(_rtt, _rssi) return self._is_wifi_quality_bad(_rtt, _rssi)
@@ -632,8 +628,13 @@ class WiFiManager:
for retry_idx in range(2): for retry_idx in range(2):
time.sleep_ms(1000) time.sleep_ms(1000)
reachable2 = self.is_sta_associated() # 不使用 RTT 测量
rtt2 = None 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() rssi2 = self._get_wifi_rssi_dbm()
# 更新缓存,便于外部查看最新状态 # 更新缓存,便于外部查看最新状态
@@ -642,10 +643,14 @@ class WiFiManager:
bad2 = _is_bad_now(reachable2, rtt2, rssi2) bad2 = _is_bad_now(reachable2, rtt2, rssi2)
try: try:
_rtt_disp = f"{rtt2:.0f}ms" if rtt2 is not None else "n/a" _rtt_disp = (
rtt2
if rtt2 is not None and rtt2 != float("inf")
else -1
)
self.logger.info( self.logger.info(
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, " f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
f"rtt={_rtt_disp}, rssi={rssi2}, bad={bad2}" f"rtt={_rtt_disp:.0f}ms, rssi={rssi2}, bad={bad2}"
) )
except Exception: except Exception:
pass pass