diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dfdb8b7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.sh text eol=lf
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..35410ca
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/archery.iml b/.idea/archery.iml
new file mode 100644
index 0000000..5550dcc
--- /dev/null
+++ b/.idea/archery.iml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..261be2b
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..33066cd
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..c00e8fd
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "cmake.sourceDirectory": "E:/code/code/code/new/new/new/archery/cpp_ext"
+}
\ No newline at end of file
diff --git a/__pycache__/at_client.cpython-312.pyc b/__pycache__/at_client.cpython-312.pyc
new file mode 100644
index 0000000..16417d1
Binary files /dev/null and b/__pycache__/at_client.cpython-312.pyc differ
diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc
new file mode 100644
index 0000000..87ca0ac
Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ
diff --git a/__pycache__/hardware.cpython-312.pyc b/__pycache__/hardware.cpython-312.pyc
new file mode 100644
index 0000000..bf54453
Binary files /dev/null and b/__pycache__/hardware.cpython-312.pyc differ
diff --git a/__pycache__/laser_manager.cpython-312.pyc b/__pycache__/laser_manager.cpython-312.pyc
new file mode 100644
index 0000000..8b340a5
Binary files /dev/null and b/__pycache__/laser_manager.cpython-312.pyc differ
diff --git a/__pycache__/logger_manager.cpython-312.pyc b/__pycache__/logger_manager.cpython-312.pyc
new file mode 100644
index 0000000..7b74d03
Binary files /dev/null and b/__pycache__/logger_manager.cpython-312.pyc differ
diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc
new file mode 100644
index 0000000..151a0f4
Binary files /dev/null and b/__pycache__/main.cpython-312.pyc differ
diff --git a/__pycache__/network.cpython-312.pyc b/__pycache__/network.cpython-312.pyc
new file mode 100644
index 0000000..b5ee661
Binary files /dev/null and b/__pycache__/network.cpython-312.pyc differ
diff --git a/__pycache__/power.cpython-312.pyc b/__pycache__/power.cpython-312.pyc
new file mode 100644
index 0000000..74bbc83
Binary files /dev/null and b/__pycache__/power.cpython-312.pyc differ
diff --git a/__pycache__/version.cpython-310.pyc b/__pycache__/version.cpython-310.pyc
new file mode 100644
index 0000000..1bfb602
Binary files /dev/null and b/__pycache__/version.cpython-310.pyc differ
diff --git a/__pycache__/version.cpython-312.pyc b/__pycache__/version.cpython-312.pyc
new file mode 100644
index 0000000..51dc910
Binary files /dev/null and b/__pycache__/version.cpython-312.pyc differ
diff --git a/__pycache__/vision.cpython-312.pyc b/__pycache__/vision.cpython-312.pyc
new file mode 100644
index 0000000..55f4dd1
Binary files /dev/null and b/__pycache__/vision.cpython-312.pyc differ
diff --git a/__pycache__/wifi.cpython-312.pyc b/__pycache__/wifi.cpython-312.pyc
new file mode 100644
index 0000000..3607e2c
Binary files /dev/null and b/__pycache__/wifi.cpython-312.pyc differ
diff --git a/__pycache__/wpa_supplicant_conf.cpython-312.pyc b/__pycache__/wpa_supplicant_conf.cpython-312.pyc
new file mode 100644
index 0000000..2b02460
Binary files /dev/null and b/__pycache__/wpa_supplicant_conf.cpython-312.pyc differ
diff --git a/app.yaml b/app.yaml
index 88b8739..ae749ae 100644
--- a/app.yaml
+++ b/app.yaml
@@ -12,6 +12,7 @@ files:
- at_client.py
- camera_manager.py
- cameraParameters.xml
+ - charging_exit.sh
- config.py
- hardware.py
- laser_detector.py
diff --git a/at_client.py b/at_client.py
index 40f967a..5bb003d 100644
--- a/at_client.py
+++ b/at_client.py
@@ -76,10 +76,11 @@ class ATClient:
"""
expect_b = expect.encode() if isinstance(expect, str) else expect
with self._cmd_lock:
- # 初始化等待
- self._waiting = True
- self._expect = expect_b
- self._resp = b""
+ with self._q_lock:
+ # 初始化等待
+ self._waiting = True
+ self._expect = expect_b
+ self._resp = b""
# 发送
if cmd:
@@ -300,8 +301,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:]
diff --git a/charging_exit.sh b/charging_exit.sh
new file mode 100644
index 0000000..619fa2c
--- /dev/null
+++ b/charging_exit.sh
@@ -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
diff --git a/config.py b/config.py
index b39d257..ca67358 100644
--- a/config.py
+++ b/config.py
@@ -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 建连耗时采样次数,取中位数
@@ -343,6 +343,15 @@ 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 = 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_AVG_WINDOW = 5
diff --git a/main.py b/main.py
index 8bc549f..fe768fe 100644
--- a/main.py
+++ b/main.py
@@ -22,7 +22,7 @@ from version import VERSION
# from logger import init_logging, get_logger, stop_logging
from logger_manager import logger_manager
from time_sync import sync_system_time_from_4g
-from power import init_ina226
+from power import charging_shutdown_monitor, init_ina226
from laser_manager import laser_manager
from vision import start_save_shot_worker
from network import network_manager
@@ -125,6 +125,13 @@ def cmd_str():
logger_manager.init_logging(log_level=logging.DEBUG)
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()
diff --git a/network.py b/network.py
index f18a763..ec110b6 100644
--- a/network.py
+++ b/network.py
@@ -709,6 +709,12 @@ 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 connect_server(self):
"""
连接到服务器(自动选择WiFi或4G)
@@ -721,7 +727,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
# 自动选择网络
@@ -738,6 +744,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)。
@@ -864,6 +901,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:
@@ -1085,6 +1128,8 @@ class NetworkManager:
total += n
hardware_manager.uart4g.write(b"\x1A")
+ with hardware_manager.at_client._q_lock:
+ hardware_manager.at_client._rx = b""
r = hardware_manager.at_client.send("", "OK", 8000)
if ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r):
return True
@@ -1177,6 +1222,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:
@@ -1777,16 +1830,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
@@ -1799,7 +1845,7 @@ class NetworkManager:
continue
if not self.connect_server():
- time.sleep_ms(5000)
+ time.sleep_ms(1000)
continue
# 发送登录包
@@ -1820,7 +1866,7 @@ class NetworkManager:
self.disconnect_server()
except:
pass
- time.sleep_ms(2000)
+ time.sleep_ms(500)
continue
self.logger.info("➡️ 登录包已发送,等待确认...")
@@ -2264,7 +2310,8 @@ 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
pkt = self._netcore.make_packet(msg_type, data_dict)
if not self.tcp_send_raw(pkt):
# 发送失败:将消息放回队首(队列满则丢弃)
@@ -2281,6 +2328,8 @@ class NetworkManager:
except:
pass
break
+ if sent_event is not None:
+ sent_event.set()
# 发送激光校准结果
if logged_in:
@@ -2309,8 +2358,8 @@ class NetworkManager:
pass
break
else:
- # 不立即断开,让下一轮心跳再试;同时缩短一点等待,提升恢复速度
- time.sleep_ms(200)
+ # 不立即断开,让下一轮心跳再试
+ time.sleep_ms(50)
continue
else:
send_hartbeat_fail_count = 0
@@ -2340,8 +2389,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主循环的顶层异常捕获,防止线程静默退出
diff --git a/power.py b/power.py
index 102b713..c25e9c2 100644
--- a/power.py
+++ b/power.py
@@ -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
@@ -140,6 +142,115 @@ def is_charging(threshold_ma=10.0):
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):
"""
根据电压估算电池百分比(高密度查表插值 + 滤波)。
diff --git a/t11_v2.15.25_20260807_174012.zip b/t11_v2.15.25_20260807_174012.zip
new file mode 100644
index 0000000..809cbe1
Binary files /dev/null and b/t11_v2.15.25_20260807_174012.zip differ
diff --git a/test/__pycache__/test_charging_shutdown.cpython-312-pytest-7.4.4.pyc b/test/__pycache__/test_charging_shutdown.cpython-312-pytest-7.4.4.pyc
new file mode 100644
index 0000000..b3232be
Binary files /dev/null and b/test/__pycache__/test_charging_shutdown.cpython-312-pytest-7.4.4.pyc differ
diff --git a/test/__pycache__/test_charging_shutdown.cpython-312.pyc b/test/__pycache__/test_charging_shutdown.cpython-312.pyc
new file mode 100644
index 0000000..aa92777
Binary files /dev/null and b/test/__pycache__/test_charging_shutdown.cpython-312.pyc differ
diff --git a/test/__pycache__/test_p21_voltage.cpython-312.pyc b/test/__pycache__/test_p21_voltage.cpython-312.pyc
new file mode 100644
index 0000000..bc5bc2b
Binary files /dev/null and b/test/__pycache__/test_p21_voltage.cpython-312.pyc differ
diff --git a/test/__pycache__/test_wifi_failover.cpython-312-pytest-7.4.4.pyc b/test/__pycache__/test_wifi_failover.cpython-312-pytest-7.4.4.pyc
new file mode 100644
index 0000000..d782ebe
Binary files /dev/null and b/test/__pycache__/test_wifi_failover.cpython-312-pytest-7.4.4.pyc differ
diff --git a/test/test_charging_shutdown.py b/test/test_charging_shutdown.py
new file mode 100644
index 0000000..6f2ab10
--- /dev/null
+++ b/test/test_charging_shutdown.py
@@ -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()
diff --git a/test/test_p21_voltage.py b/test/test_p21_voltage.py
new file mode 100644
index 0000000..b73ffdf
--- /dev/null
+++ b/test/test_p21_voltage.py
@@ -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
diff --git a/test/test_wifi_failover.py b/test/test_wifi_failover.py
new file mode 100644
index 0000000..20683bf
--- /dev/null
+++ b/test/test_wifi_failover.py
@@ -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()
diff --git a/version.md b/version.md
index 5b19cd3..8116534 100644
--- a/version.md
+++ b/version.md
@@ -28,4 +28,11 @@
# 2.15.15 优化wifi连接
# 2.15.16 修复wifi连接问题
# 2.15.17 修复wifi连接问题
-# 2.15.18 wifi连接成功重新登录
\ No newline at end of file
+# 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
diff --git a/version.py b/version.py
index 4ee9054..cb899c4 100644
--- a/version.py
+++ b/version.py
@@ -4,6 +4,6 @@
应用版本号
每次 OTA 更新时,只需要更新这个文件中的版本号
"""
-VERSION = '2.15.18'
+VERSION = '2.15.25'
diff --git a/wifi.py b/wifi.py
index ba53cd2..4dc63f4 100644
--- a/wifi.py
+++ b/wifi.py
@@ -541,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:
@@ -591,34 +591,38 @@ class WiFiManager:
def _quality_monitor_loop(self):
"""
WiFi 质量监测循环(后台线程)
- 每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
+ 每 5 秒检查 STA 关联状态和 RSSI,发现断链或质量差则触发切换
"""
while not self._wifi_quality_stop_event.is_set():
try:
# 只在 WiFi 连接时才测量
network_type = self._network_type_callback()
if network_type == "wifi" and self._wifi_socket:
- # # 测量 RTT(1 个样本,快速测量)
- # rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
- # self._server_ip, self._server_port,
- # samples=1, per_sample_timeout_ms=600
- # )
-
+ # 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)
@@ -628,13 +632,8 @@ class WiFiManager:
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
- # )
+ reachable2 = self.is_sta_associated()
+ rtt2 = None
rssi2 = self._get_wifi_rssi_dbm()
# 更新缓存,便于外部查看最新状态
@@ -643,14 +642,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