diff --git a/README.md b/README.md index 1283f03..8bb0647 100644 --- a/README.md +++ b/README.md @@ -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 自动降级为模拟模式(不影响主功能) ## 许可证 diff --git a/config.json b/config.json index 766112a..84812da 100644 --- a/config.json +++ b/config.json @@ -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 } diff --git a/install.sh b/install.sh index c9f4dee..40ebb5a 100755 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/padsleep.py b/padsleep.py index 005fb3f..4f2197a 100644 --- a/padsleep.py +++ b/padsleep.py @@ -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 diff --git a/padsleep_tui.py b/padsleep_tui.py index 042fd7c..4bd21f8 100644 --- a/padsleep_tui.py +++ b/padsleep_tui.py @@ -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) diff --git a/requirements.txt b/requirements.txt index fe66e90..8eadcdd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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