feat: 添加 GPIO 电平检测触发亮屏/息屏

新增 GPIOEventDetector 后台线程,以 50ms 间隔轮询 GPIO 引脚。
检测到电平变化时唤醒屏幕并启动 60 秒倒计时,到期无新变化则息屏。

- 添加 GPIOEventDetector 类,支持 RPi.GPIO 硬读取和 Mock 回退
- 集成到 PadSleepApp 主循环,GPIO 触发优先级高于定时亮屏时段
- TUI 状态面板新增 GPIO 行,显示引脚号和倒计时
- 支持通过配置文件调整引脚号(gpio_pin)和倒计时长度
- install.sh 修复:补装 rpi-lgpio 依赖

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kushidou
2026-07-19 16:10:42 +08:00
co-authored by Claude Fable 5
parent da8ad4d17d
commit 9940db451f
6 changed files with 254 additions and 13 deletions
+157 -5
View File
@@ -42,6 +42,10 @@ DEFAULT_CONFIG = {
"adb_wireless_host": "",
"adb_wireless_port": 5555,
"adb_wireless_auto_connect": False,
# ── GPIO 电平触发(人来亮屏 / 人走息屏) ──
"gpio_enabled": True,
"gpio_pin": 17,
"gpio_countdown_seconds": 60,
}
# ── 日志 ────────────────────────────────────────────────────────
@@ -257,6 +261,94 @@ class TimeChecker:
return "; ".join(descs)
# ═══════════════════════════════════════════════════════════════════
# GPIOEventDetector — GPIO 电平变化检测(人来亮屏 / 人走息屏)
# ═══════════════════════════════════════════════════════════════════
#
# 由于购买的模块有问题,无法稳定输出电平,但是人来人走时会发生短暂电平切换,
# 因此采用这个折中的法子:检测到 GPIO 电平变化就亮屏并重置 60 秒倒计时,
# 倒计时结束且无新变化则息屏。
class GPIOEventDetector:
"""在后台线程高频轮询 GPIO 引脚,检测电平变化并记录时间戳。
用法:
detector = GPIOEventDetector(pin=17)
detector.start()
...
elapsed = detector.seconds_since_last_event # 距上次变化秒数
if elapsed < 60:
... # 有人/有活动
detector.stop()
"""
POLL_INTERVAL = 0.05 # 轮询间隔 50ms
def __init__(self, pin: int, callback=None):
self.pin = pin
self.callback = callback # 可选:每条变化时触发
self.last_event_time: float = 0.0 # 0 表示从未触发
self.last_value: int | None = None
self.available = False # 硬件是否可用
self._running = False
self._thread: threading.Thread | None = None
self._reader = None
@property
def seconds_since_last_event(self) -> float:
"""距上次 GPIO 电平变化的秒数。从未触发时返回 inf。"""
if self.last_event_time == 0:
return float("inf")
return time.time() - self.last_event_time
def start(self):
"""启动后台轮询线程。"""
# ── 初始化 GPIO ──
try:
import RPi.GPIO as GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setup(self.pin, GPIO.IN)
self._gpio = GPIO
self.available = True
log.info("[GPIO] BCM %d — 电平检测已启动", self.pin)
except (ImportError, RuntimeError, OSError) as e:
log.warning("[GPIO] 无法初始化 GPIO%d: %s — 电平检测已禁用", self.pin, e)
self.available = False
return
self.last_value = self._gpio.input(self.pin)
self.last_event_time = time.time() # 启动瞬间作为一次"事件",防止开机立刻息屏
self._running = True
self._thread = threading.Thread(target=self._poll, daemon=True, name="gpio-poll")
self._thread.start()
def stop(self):
"""停止轮询并清理。"""
self._running = False
if self._thread:
self._thread.join(timeout=1)
if hasattr(self, "_gpio") and self._gpio:
self._gpio.cleanup(self.pin)
self.available = False
log.info("[GPIO] BCM %d — 电平检测已停止", self.pin)
def _poll(self):
"""后台轮询:50ms 间隔检测电平变化。"""
while self._running:
try:
val = self._gpio.input(self.pin)
if val != self.last_value:
self.last_value = val
self.last_event_time = time.time()
edge = "上升" if val else "下降"
log.debug("[GPIO] 电平变化: %s%d", edge, val)
if self.callback:
self.callback(val)
except Exception as e:
log.error("[GPIO] 读取异常: %s", e)
time.sleep(self.POLL_INTERVAL)
# ═══════════════════════════════════════════════════════════════════
# AdbManager — ADB 操作封装(使用 adbutils 库)
# ═══════════════════════════════════════════════════════════════════
@@ -716,6 +808,7 @@ class PadSleepApp:
self.audit = AuditLogger()
self.config_mgr = ConfigManager()
self.adb = AdbManager(self._get_config, self.audit)
self.gpio: GPIOEventDetector | None = None
self.ipc = IpcServer(self)
self._lock = threading.Lock()
self._start_time = time.time()
@@ -728,6 +821,11 @@ class PadSleepApp:
"device_serial": "",
"uptime": 0,
"start_time": self._start_time,
# ── GPIO 状态 ──
"gpio_available": False,
"gpio_triggered": False,
"gpio_countdown": 0,
"gpio_pin": 17,
}
# ── 辅助 ──
@@ -859,6 +957,20 @@ class PadSleepApp:
else:
log.warning("无线设备连接失败: %s", msg)
# ── 启动 GPIO 电平检测 ──
if config.get("gpio_enabled", True):
gpio_pin = config.get("gpio_pin", 17)
self.gpio = GPIOEventDetector(pin=gpio_pin)
self.gpio.start()
if self.gpio.available:
log.info("GPIO 电平检测已启用 (BCM %d, 倒计时 %ds)",
gpio_pin, config.get("gpio_countdown_seconds", 60))
self.audit.log("GPIO检测", f"已启用 (BCM {gpio_pin})")
self._update_status(gpio_available=self.gpio.available, gpio_pin=gpio_pin)
else:
log.info("GPIO 电平检测已禁用 (配置 gpio_enabled=false)")
self._update_status(gpio_available=False)
# 启动 IPC
self.ipc.start()
@@ -892,6 +1004,8 @@ class PadSleepApp:
time.sleep(1)
# 清理
if self.gpio:
self.gpio.stop()
self.ipc.cleanup()
self.audit.log("守护进程", "已退出")
log.info("padsleep_adb 守护程序已退出")
@@ -914,6 +1028,43 @@ class PadSleepApp:
periods = config.get("screen_on_periods", [])
within_period = TimeChecker.is_within_any_period(periods)
# ═══════════════════════════════════════════════════════════════
# GPIO 触发检测(人来亮屏 / 人走息屏)
#
# 由于购买的模块有问题,无法稳定输出电平,但是人来人走时会发生
# 短暂电平切换,因此采用这个折中的法子:检测到 GPIO 电平变化
# 就亮屏并重置 gpio_countdown_seconds 秒倒计时,倒计时结束
# 且无新变化则息屏(除非当前在亮屏时段)。
# ═══════════════════════════════════════════════════════════════
gpio_triggered = False
gpio_countdown_remain = 0
if self.gpio and self.gpio.available:
gpio_pin = config.get("gpio_pin", 17)
countdown = config.get("gpio_countdown_seconds", 60)
elapsed = self.gpio.seconds_since_last_event
gpio_triggered = elapsed < countdown
gpio_countdown_remain = max(0, countdown - int(elapsed))
if gpio_triggered:
# GPIO 刚刚触发过 → 确保屏幕亮起
if not screen_on:
log.info("GPIO 检测到电平变化 → 亮屏 (%ds 倒计时)", gpio_countdown_remain)
self.audit.log("GPIO触屏", f"BCM {gpio_pin} 电平变化,{countdown}s 倒计时")
self.adb.screen_on()
screen_on = True
else:
# 屏幕已亮,仅做调试日志
if gpio_countdown_remain >= countdown - 1:
log.info("GPIO 电平变化 → 重置倒计时 %ds", countdown)
else:
# GPIO 倒计时已结束且屏幕不在亮屏时段 → 息屏
if screen_on and not within_period and elapsed > countdown:
log.info("GPIO 倒计时结束(%.0fs 无变化)→ 息屏", elapsed)
self.audit.log("GPIO息屏", f"BCM {gpio_pin}{countdown}s 内无电平变化")
self.adb.screen_off()
screen_on = False
# ── 更新状态(含 GPIO 信息)──
device = self.adb.get_ready_device()
self._update_status(
adb_ready=True,
@@ -922,6 +1073,8 @@ class PadSleepApp:
screen_on=screen_on,
wakefulness=wakefulness,
within_period=within_period,
gpio_triggered=gpio_triggered,
gpio_countdown=gpio_countdown_remain,
)
# ── 检测外部屏幕状态变化 ──
@@ -933,7 +1086,7 @@ class PadSleepApp:
log.info("检测到屏幕被熄灭 (外部操作)")
self.audit.log("检测到屏幕被熄灭", "外部操作或用户手动")
# ── 决策逻辑 ──
# ── 原有决策逻辑(仅在非 GPIO 触发时有效) ──
delay_sec = config.get("auto_sleep_delay_minutes", 5) * 60
period_desc = TimeChecker.nearest_period_description(periods)
@@ -945,8 +1098,8 @@ class PadSleepApp:
self.adb.screen_on()
return True
if not within_period and screen_on:
# 非亮屏时段屏幕亮着 → 超时后息屏
if not within_period and screen_on and not gpio_triggered:
# 非亮屏时段、非 GPIO 触发、屏幕亮着 → 超时后息屏
on_duration = self.adb.get_screen_on_duration()
if on_duration >= delay_sec:
log.info("不在亮屏时段(已亮 %.0fs)→ 自动息屏", on_duration)
@@ -959,9 +1112,8 @@ class PadSleepApp:
log.info("不在亮屏时段,%.0f 秒后将息屏", remaining)
elif within_period and screen_on:
# 在亮屏时段且屏幕已亮 — 一切正常,但记录一个调试信息
# 在亮屏时段且屏幕已亮 — 一切正常
if prev_screen_on is False:
# 这是上一步我们刚刚点亮了屏幕
pass
return screen_on