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
+73 -6
View File
@@ -6,6 +6,7 @@
- **定时控制**:根据配置的时间段自动亮屏/息屏
- **超时保护**:屏幕亮起超过设定时长且不在亮屏时间段内 → 自动息屏
- **GPIO 触发**:(新增)检测 GPIO 电平变化 → 亮屏 + 60 秒倒计时 → 倒计时结束息屏
- **无线 ADB**:支持通过 TCP/IP 连接安卓设备(无线调试)
- **TUI 管理**:提供终端界面工具,方便查看状态和修改配置
- **IPC 通信**:通过 Unix Socket 与 TUI 工具通信
@@ -40,26 +41,43 @@ pip install -r requirements.txt
依赖列表:
- `adbutils>=2.12.0` — Python ADB 库
- `rpi-lgpio>=0.6` — 树莓派 GPIO 控制(仅在树莓派上需要)
### 3. 快速安装
```bash
sudo ./install.sh
```
安装脚本会将程序部署到 `/opt/apps/padsleep`,创建命令行入口和 systemd 服务。
## 使用方法
### 启动守护程序
```bash
# 直接启动
python padsleep.py
# 或使用安装后的命令行入口
padsleep
```
守护程序会自动:
1. 加载配置文件 `config.json`
2. 检测 ADB 设备
3. 启动 IPC 服务(`/tmp/padsleep.sock`
4. 进入主循环,定期检查屏幕状态
3. 启动 GPIO 电平检测(默认 BCM 17
4. 启动 IPC 服务(`/tmp/padsleep.sock`
5. 进入主循环,定期检查屏幕状态
### 启动 TUI 管理工具
```bash
# 新终端窗口
python padsleep_tui.py
# 或使用安装后的命令行入口
padsleep-config
```
TUI 快捷键:
@@ -94,7 +112,10 @@ TUI 快捷键:
"adb_device_serial": "",
"adb_wireless_host": "",
"adb_wireless_port": 5555,
"adb_wireless_auto_connect": false
"adb_wireless_auto_connect": false,
"gpio_enabled": true,
"gpio_pin": 17,
"gpio_countdown_seconds": 60
}
```
@@ -109,6 +130,48 @@ TUI 快捷键:
| `adb_wireless_host` | string | `""` | 无线设备 IP 地址,空表示不使用无线连接 |
| `adb_wireless_port` | int | `5555` | 无线设备端口 |
| `adb_wireless_auto_connect` | bool | `false` | 启动时是否自动连接无线设备 |
| `gpio_enabled` | bool | `true` | 是否启用 GPIO 电平检测 |
| `gpio_pin` | int | `17` | GPIO 引脚号(BCM 编号),如 GPIO17 = 物理 pin 11 |
| `gpio_countdown_seconds` | int | `60` | GPIO 触发后倒计时秒数,到期无新变化则息屏 |
## GPIO 电平检测
> 由于购买的模块有问题,无法稳定输出电平,但是人来人走时会发生短暂电平切换,
> 因此采用这个折中的法子:检测到 GPIO 电平变化就亮屏并重置倒计时,
> 倒计时结束且无新变化则息屏。
### 工作原理
1. **后台线程** 以 50ms 间隔高频轮询 GPIO 引脚
2. **检测到任意电平变化**(上升或下降沿)→ 立即亮屏,重置倒计时
3. **倒计时期间再次触发** → 倒计时重置,保持亮屏
4. **倒计时到期无新变化** → 息屏(除非当前在定时亮屏时段内)
### 决策优先级
```
GPIO 触发(60s 倒计时) > 定时亮屏时段 > 超时息屏
```
### 接线参考
GPIO 引脚号(BCM 编号)对照:
| BCM 编号 | 物理引脚 | 功能 |
|----------|----------|------|
| GPIO17 | Pin 11 | 默认 GPIO 检测引脚 |
| GND | Pin 6 / 9 / 14 / ... | 接地 |
将传感器信号线接 GPIO17,GND 接树莓派 GND 即可。
### 日志记录
所有 GPIO 事件记录在 `padsleep.log` 中:
```
[2026-07-19 15:59:25] GPIO触屏 — BCM 17 电平变化,60s 倒计时
[2026-07-19 15:59:28] GPIO息屏 — BCM 17 在 60s 内无电平变化
```
## 无线 ADB 连接
@@ -147,19 +210,23 @@ TUI 快捷键:
```
padsleep_adb/
├── padsleep.py # 守护程序主程序
├── padsleep.py # 守护程序主程序(含 GPIO 检测)
├── padsleep_tui.py # TUI 管理工具
├── config.json # 配置文件(首次运行自动生成)
├── config.json # 配置文件
├── requirements.txt # Python 依赖
── README.md # 本文档
── install.sh # 安装脚
├── padsleep.service # systemd 服务文件
└── README.md # 本文档
```
## 开发说明
- 在 Windows 上开发,在树莓派上运行
- 使用 `adbutils` 库与 ADB Server 通信
- GPIO 使用 `RPi.GPIO`(通过 `rpi-lgpio` 驱动 `lgpio` 内核模块)
- IPC 使用 Unix Socket + JSON 行协议
- TUI 使用 Python `curses`
- 非树莓派环境 GPIO 自动降级为模拟模式(不影响主功能)
## 许可证
+4 -1
View File
@@ -7,5 +7,8 @@
"check_interval_seconds": 30,
"auto_sleep_delay_minutes": 1,
"adb_device_serial": "",
"adb_path": "adb"
"adb_path": "adb",
"gpio_enabled": true,
"gpio_pin": 17,
"gpio_countdown_seconds": 60
}
+1 -1
View File
@@ -88,7 +88,7 @@ fi
# ── 安装 Python 依赖 ──
info "正在安装 Python 依赖..."
"$VENV_DIR/bin/pip" install --quiet adbutils 2>&1 || {
"$VENV_DIR/bin/pip" install --quiet adbutils rpi-lgpio 2>&1 || {
warn "pip 安装失败,尝试离线安装..."
if [ -f "$SRC_DIR/.venv" ]; then
cp -r "$SRC_DIR/.venv" "$VENV_DIR"
+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
+18
View File
@@ -434,6 +434,24 @@ class TuiApp:
self.stdscr.addstr(f"{s.get('config_path', '?')}", curses.A_DIM)
except curses.error:
pass
# ── GPIO 状态 ──
gpio_avail = s.get("gpio_available", False)
if gpio_avail:
y += 1
gpio_trig = s.get("gpio_triggered", False)
gpio_cnt = s.get("gpio_countdown", 0)
trig_text = f"触发中 ⏱ {gpio_cnt}s" if gpio_trig else "等待中"
trig_color = 3 if gpio_trig else 2
gpio_pin = s.get("gpio_pin", "?")
if y < h:
self._clear_line(y)
try:
self.stdscr.addstr(y, 4, "GPIO: ")
self.stdscr.addstr(f"BCM {gpio_pin} ", curses.A_DIM)
self.stdscr.addstr(f"{trig_text}", curses.color_pair(trig_color) | curses.A_BOLD)
except curses.error:
pass
else:
if y < h:
self._clear_line(y)
+1
View File
@@ -4,6 +4,7 @@
# .venv/bin/pip install -r requirements.txt
adbutils>=2.12.0
rpi-lgpio>=0.6 # GPIO 电平检测(树莓派硬件传感器触发息屏/亮屏)
# 系统依赖:adb (Android Debug Bridge)
# 安装: sudo apt-get install adb