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