Files
padsleep_adb/padsleep.py
T
kushidouandClaude Fable 5 e6fbe550bb 重构屏幕控制引擎 + 新增 Web 监控面板
- ScreenManager: 基于优先级(9/5/0)的统一屏幕控制引擎,替代 _tick 中的
  散乱 if-else 逻辑
- 四个亮屏事件: scheduled(p9), sensor(p9), manual(p5), external(p0)
- GPIO 回调: 电平变化立即触发亮屏(异步线程,不阻塞轮询)
- _fast_check: 每秒轻量评估,提高息屏超时精度(±1s 替代 ±30s)
- padsleep_web: 通过 IPC 读取守护进程状态的实时 Web 面板
  (GPIO 电平/屏幕状态/ADB 连接,端口 31400)
- padsleep-web.service: 关联 padsleep.service 的自启动服务
- install.sh: 修复 venv 初始化目录上下文,使用 requirements.txt
- IpcClient: 修复 socket 复用导致交替失败的问题
- padsleep_tui: 适配 ScreenManager 状态显示

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 00:15:07 +08:00

1334 lines
53 KiB
Python

#!/usr/bin/env python3
"""padsleep_adb 守护程序
基于树莓派,通过 ADB 控制安卓平板自动亮屏和息屏。
阶段一功能:
- 根据配置的亮屏时间段自动亮屏/息屏
- 屏幕亮起超过设定时长且不在亮屏时间段内 → 自动息屏
- 在亮屏时间段内检测到屏幕熄灭 → 自动恢复亮屏
- 通过 Unix Socket 提供 IPC 接口,供 TUI 工具管理
- 审计日志记录所有事件,自动维护日志大小不超过 50MB
"""
import json
import logging
import os
import signal
import socket
import threading
import time
from datetime import datetime
from pathlib import Path
from socketserver import ThreadingUnixStreamServer, StreamRequestHandler
from adbutils import AdbClient, AdbDevice
# ── 常量 ────────────────────────────────────────────────────────
# 注意: .resolve().parent 确保即使通过 symlink 调用也能正确找到真实目录
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_PATH = SCRIPT_DIR / "config.json"
LOG_PATH = SCRIPT_DIR / "padsleep.log"
SOCKET_PATH = "/tmp/padsleep.sock"
DEFAULT_CONFIG = {
"screen_on_periods": [
{"start": "08:00", "end": "22:00"}
],
"check_interval_seconds": 30,
"auto_sleep_delay_minutes": 5,
"adb_device_serial": "",
"adb_wireless_host": "",
"adb_wireless_port": 5555,
"adb_wireless_auto_connect": False,
# ── GPIO 电平触发(人来亮屏 / 人走息屏) ──
"gpio_enabled": True,
"gpio_pin": 17,
"gpio_countdown_seconds": 60,
}
# ── 日志 ────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("padsleep")
# ═══════════════════════════════════════════════════════════════════
# AuditLogger — 审计日志
# ═══════════════════════════════════════════════════════════════════
class AuditLogger:
"""审计日志,记录到 padsleep.log。
日志格式: [YYYY-MM-DD HH:MM:SS] 事件 — 详情
自动维护: 超过 50MB 时保留最后 10% 的内容。
"""
MAX_BYTES = 50 * 1024 * 1024 # 50 MB
_CHECK_INTERVAL = 100 # 每写入 100 次检查一次文件大小
def __init__(self, path: Path = LOG_PATH):
self.path = path
self._write_count = 0
# 启动时检查并裁剪
self._maybe_trim()
def log(self, event: str, detail: str = ""):
"""写入一条审计日志。"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
entry = f"[{now}] {event}"
if detail:
entry += f" — {detail}"
try:
with open(self.path, "a", encoding="utf-8") as f:
f.write(entry + "\n")
except OSError as e:
log.error("审计日志写入失败: %s", e)
self._write_count += 1
if self._write_count >= self._CHECK_INTERVAL:
self._write_count = 0
self._maybe_trim()
def _maybe_trim(self):
"""检查日志文件大小,超过上限则裁剪(保留最后 10% 内容)。"""
try:
if self.path.exists() and self.path.stat().st_size > self.MAX_BYTES:
self._trim()
except OSError:
pass
def _trim(self):
"""删除日志前 90% 行,只保留最后 10%。"""
try:
with open(self.path, "r", encoding="utf-8") as f:
lines = f.readlines()
if not lines:
return
keep = lines[-max(1, len(lines) // 10):]
old_size = self.path.stat().st_size
with open(self.path, "w", encoding="utf-8") as f:
f.writelines(keep)
new_size = self.path.stat().st_size
saved_mb = (old_size - new_size) / 1048576
log.info("审计日志已裁剪: 释放 %.1f MB (保留 %.1f MB)",
saved_mb, new_size / 1048576)
except OSError as e:
log.error("审计日志裁剪失败: %s", e)
def read_tail(self, n: int = 50) -> str:
"""读取日志末尾 N 行。"""
try:
if not self.path.exists():
return "(日志文件不存在)"
with open(self.path, "r", encoding="utf-8") as f:
lines = f.readlines()
return "".join(lines[-n:])
except OSError as e:
return f"(读取日志失败: {e})"
# ═══════════════════════════════════════════════════════════════════
# ConfigManager — 配置加载/保存/监控
# ═══════════════════════════════════════════════════════════════════
class ConfigManager:
"""管理 JSON 配置文件的加载、保存和变更检测。"""
def __init__(self, path: Path = CONFIG_PATH):
self.path = path
self.config = dict(DEFAULT_CONFIG)
self._mtime: float = 0
self._lock = threading.Lock()
def load(self) -> bool:
"""从 JSON 文件加载配置,失败时使用默认值并不中断运行。"""
try:
with open(self.path, "r") as f:
data = json.load(f)
if "screen_on_periods" not in data:
raise ValueError("缺少 screen_on_periods")
for p in data["screen_on_periods"]:
if "start" not in p or "end" not in p:
raise ValueError("时间段缺少 start 或 end")
with self._lock:
self.config.update(data)
self._mtime = os.path.getmtime(self.path)
log.info("配置已加载: %s", self.path)
return True
except FileNotFoundError:
log.warning("配置文件不存在,写入默认配置: %s", self.path)
self.save()
return True
except (json.JSONDecodeError, ValueError, OSError) as e:
log.error("配置加载失败: %s,使用默认配置", e)
return True # 不崩溃,用默认配置继续运行
def save(self) -> bool:
"""将当前配置写入 JSON 文件。"""
with self._lock:
data = dict(self.config)
try:
with open(self.path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
self._mtime = os.path.getmtime(self.path)
log.info("配置已保存")
return True
except OSError as e:
log.error("配置保存失败: %s", e)
return False
def get(self) -> dict:
"""线程安全地获取当前配置副本。"""
with self._lock:
return dict(self.config)
def update(self, new_config: dict) -> bool:
"""合并新配置并写入文件。"""
with self._lock:
self.config.update(new_config)
self.save()
return True
def has_changed(self) -> bool:
"""检查配置文件是否被外部修改。"""
try:
return os.path.getmtime(self.path) != self._mtime
except OSError:
return False
def reload_if_changed(self):
"""检测到外部修改则自动重载。"""
if self.has_changed():
log.info("配置文件已变更,重新加载")
self.load()
# ═══════════════════════════════════════════════════════════════════
# TimeChecker — 时间判断工具
# ═══════════════════════════════════════════════════════════════════
class TimeChecker:
"""亮屏时间段判断,支持跨天(如 22:00 ~ 08:00)。"""
@staticmethod
def _to_minutes(tstr: str) -> int:
h, m = tstr.strip().split(":")
return int(h) * 60 + int(m)
@staticmethod
def now_minutes() -> int:
now = datetime.now()
return now.hour * 60 + now.minute
@staticmethod
def is_within_period(period: dict, now_m: int | None = None) -> bool:
"""判断 now_m 是否在单个时间段内(支持跨天)。"""
if now_m is None:
now_m = TimeChecker.now_minutes()
start = TimeChecker._to_minutes(period["start"])
end = TimeChecker._to_minutes(period["end"])
if end > start:
return start <= now_m < end
elif end == start:
return True # 全天
else:
return now_m >= start or now_m < end # 跨天
@staticmethod
def is_within_any_period(periods: list, now_m: int | None = None) -> bool:
"""判断当前时间是否在任意一个亮屏时间段内。"""
if not periods:
return False
if now_m is None:
now_m = TimeChecker.now_minutes()
return any(TimeChecker.is_within_period(p, now_m) for p in periods)
@staticmethod
def nearest_period_description(periods: list) -> str:
"""返回时间段的文字描述,用于审计日志。"""
if not periods:
return "无配置"
descs = []
for p in periods:
descs.append(f"{p['start']}-{p['end']}")
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 = 1.0 # 轮询间隔 1s
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.current_value: int = 0 # 当前引脚电平(供外部读取)
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)
self.current_value = val # 对外暴露当前电平
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 库)
# ═══════════════════════════════════════════════════════════════════
class AdbManager:
"""封装所有 ADB 操作,使用 adbutils 库实现持久连接。"""
def __init__(self, config_getter, audit_logger: AuditLogger | None = None):
self._config_getter = config_getter
self._audit = audit_logger
self._client: AdbClient | None = None
self._device: AdbDevice | None = None
self._ready = False
self._last_error = ""
self._screen_on_since: float | None = None
self._last_known_screen_on = False
self._last_adb_check = 0 # 限频用
self._prev_ready = False # 前次 ADB 就绪状态,用于检测变化
self._current_serial: str | None = None # 自动检测到的设备串号
self._wireless_addr: str = "" # 当前无线连接地址 (IP:PORT)
self._wireless_retries: int = 0 # 无线重连尝试次数
# ── 连接管理 ──
def _ensure_client(self) -> AdbClient:
"""懒初始化 ADB 客户端连接。"""
if self._client is None:
self._client = AdbClient(host="127.0.0.1", port=5037)
return self._client
def _ensure_device(self) -> AdbDevice | None:
"""获取目标设备,支持串号过滤。"""
config = self._config_getter()
target_serial = config.get("adb_device_serial", "")
try:
client = self._ensure_client()
if target_serial:
self._device = client.device(target_serial)
else:
devices = client.device_list()
self._device = devices[0] if devices else None
return self._device
except Exception as e:
self._device = None
self._last_error = str(e)
return None
# ── 无线连接管理 ──
@staticmethod
def is_wireless_serial(serial: str) -> bool:
"""判断 serial 是否为无线格式(IP:PORT)。"""
parts = serial.split(":")
if len(parts) != 2:
return False
try:
port = int(parts[1])
ip_parts = parts[0].split(".")
if len(ip_parts) == 4 and all(0 <= int(p) <= 255 for p in ip_parts):
return True
except (ValueError, IndexError):
pass
return False
def connect_wireless(self, host: str, port: int = 5555, timeout: float = 5.0) -> tuple[bool, str]:
"""连接无线 ADB 设备。返回 (success, message)。"""
addr = f"{host}:{port}"
try:
client = self._ensure_client()
result = client.connect(addr, timeout=timeout)
if "connected" in result.lower():
self._wireless_addr = addr
self._wireless_retries = 0
log.info("无线 ADB 已连接: %s", addr)
if self._audit:
self._audit.log("无线连接", f"已连接: {addr}")
return True, result
return False, result
except Exception as e:
self._last_error = str(e)
log.error("无线连接失败: %s", e)
if self._audit:
self._audit.log("无线连接失败", f"{addr}: {e}")
return False, str(e)
def disconnect_wireless(self, addr: str = "") -> bool:
"""断开无线 ADB 连接。"""
target = addr or self._wireless_addr
if not target:
return False
try:
client = self._ensure_client()
client.disconnect(target)
log.info("无线 ADB 已断开: %s", target)
if self._audit:
self._audit.log("无线断开", f"已断开: {target}")
if target == self._wireless_addr:
self._wireless_addr = ""
return True
except Exception as e:
self._last_error = str(e)
log.error("断开无线连接失败: %s", e)
return False
def get_connection_type(self) -> str:
"""返回当前连接类型:'usb' / 'wireless' / 'none'。"""
if self._device is None:
return "none"
if self.is_wireless_serial(self._device.serial):
return "wireless"
return "usb"
def ensure_wireless_connection(self) -> bool:
"""确保无线连接存活,失败时尝试重连。返回是否成功。"""
config = self._config_getter()
wireless_host = config.get("adb_wireless_host", "")
wireless_port = config.get("adb_wireless_port", 5555)
if not wireless_host:
return False
addr = f"{wireless_host}:{wireless_port}"
# 检查是否已有连接
if self._device and self._device.serial == addr:
try:
self._device.getprop("ro.build.version.sdk")
return True
except Exception:
# 连接已断开,尝试重连
pass
# 尝试重连(最多 3 次)
max_retries = 3
for i in range(max_retries):
self._wireless_retries += 1
log.info("尝试重连无线设备 (%d/%d): %s", i + 1, max_retries, addr)
success, msg = self.connect_wireless(wireless_host, wireless_port, timeout=3.0)
if success:
# 重新获取设备
try:
client = self._ensure_client()
self._device = client.device(addr)
self._device.getprop("ro.build.version.sdk")
self._ready = True
self._last_error = ""
return True
except Exception:
pass
time.sleep(2)
self._last_error = f"无线重连失败: {addr}"
return False
# ── 设备管理 ──
def check_available(self) -> bool:
"""检查 ADB Server 是否可连接。"""
try:
client = self._ensure_client()
client.device_list()
return True
except Exception as e:
self._last_error = f"ADB Server 不可用: {e}"
return False
def list_devices(self) -> list[dict]:
"""返回 [{"serial": "...", "status": "device", "connection_type": "usb"/"wireless"}]。"""
try:
client = self._ensure_client()
devices = client.device_list()
result = []
for d in devices:
conn_type = "wireless" if self.is_wireless_serial(d.serial) else "usb"
result.append({
"serial": d.serial,
"status": "device",
"connection_type": conn_type,
})
return result
except Exception as e:
self._last_error = str(e)
return []
def get_ready_device(self) -> str | None:
"""返回当前已连接设备的串号,无则返回 None。"""
if self._device is not None:
return self._device.serial
return None
def is_ready(self) -> bool:
"""检查是否有已授权的 ADB 设备可用。"""
now = time.time()
if now - self._last_adb_check < 1 and self._ready:
return True
self._last_adb_check = now
config = self._config_getter()
target_serial = config.get("adb_device_serial", "")
wireless_host = config.get("adb_wireless_host", "")
try:
client = self._ensure_client()
devices = client.device_list()
# USB 连接优先
for d in devices:
if not self.is_wireless_serial(d.serial):
if not target_serial or d.serial == target_serial:
d.getprop("ro.build.version.sdk")
self._device = d
self._current_serial = d.serial
if not self._prev_ready and self._audit:
self._audit.log("ADB 设备已连接", f"串号: {d.serial}, 类型: USB")
self._prev_ready = True
self._ready = True
self._last_error = ""
return True
# 尝试无线连接
if wireless_host:
wireless_port = config.get("adb_wireless_port", 5555)
wireless_addr = f"{wireless_host}:{wireless_port}"
# 检查是否已在设备列表中
for d in devices:
if d.serial == wireless_addr:
d.getprop("ro.build.version.sdk")
self._device = d
if not self._prev_ready and self._audit:
self._audit.log("ADB 设备已连接", f"串号: {d.serial}, 类型: 无线")
self._prev_ready = True
self._ready = True
self._last_error = ""
self._wireless_addr = wireless_addr
return True
# 不在列表中,尝试连接
if self.ensure_wireless_connection():
if not self._prev_ready and self._audit:
self._audit.log("ADB 设备已连接", f"串号: {wireless_addr}, 类型: 无线")
self._prev_ready = True
return True
if not devices and not wireless_host:
self._last_error = "未检测到 ADB 设备,请连接平板"
elif not devices:
self._last_error = f"无线设备未连接: {wireless_host}:{config.get('adb_wireless_port', 5555)}"
else:
self._last_error = f"未找到指定设备: {target_serial}"
except Exception as e:
self._last_error = str(e)
# 如果之前是就绪状态但检测失败 → 设备断开
if self._prev_ready and self._audit:
last_serial = self._current_serial or (self._device.serial if self._device else "未知")
self._audit.log("ADB 设备已断开", f"上次串号: {last_serial}, 原因: {self._last_error}")
self._prev_ready = False
self._ready = False
self._device = None
return False
# ── 屏幕状态 ──
def get_screen_state(self) -> tuple[bool, str]:
"""返回 (is_on, wakefulness),失败时返回上次已知状态。"""
try:
device = self._ensure_device()
if device is None:
return self._last_known_screen_on, "unknown"
output = device.shell("dumpsys power")
for line in output.split("\n"):
line = line.strip()
if "mWakefulness" in line:
state = line.split("=")[-1].strip()
is_on = state == "Awake"
self._last_known_screen_on = is_on
if is_on and self._screen_on_since is None:
self._screen_on_since = time.time()
elif not is_on:
self._screen_on_since = None
return is_on, state
# fallback
for line in output.split("\n"):
if "Display Power" in line or "mScreenOn" in line:
is_on = "ON" in line.upper() or "=true" in line.lower()
return is_on, "unknown"
except Exception as e:
self._last_error = str(e)
return self._last_known_screen_on, "unknown"
def get_screen_on_duration(self) -> float:
"""返回屏幕已持续亮起的秒数,灭屏时返回 0。"""
if self._screen_on_since is not None:
return time.time() - self._screen_on_since
return 0.0
# ── 屏幕控制(含验证)──
def screen_off(self) -> bool:
"""发送息屏命令,等待 2 秒后验证,失败时重试一次。"""
log.info("执行息屏操作")
try:
device = self._ensure_device()
if device is None:
log.error("息屏失败: 无可用设备")
return False
if self._audit:
self._audit.log("息屏", "发出息屏指令")
device.shell("input keyevent 26")
# 等待并验证
time.sleep(2)
is_on, _ = self.get_screen_state()
if is_on:
log.warning("息屏命令已发出但屏幕未熄灭,重试一次")
device.shell("input keyevent 26")
time.sleep(2)
is_on, _ = self.get_screen_state()
if is_on:
log.error("息屏重试后仍未熄灭")
self._last_error = "息屏验证失败"
if self._audit:
self._audit.log("息屏异常", "屏幕状态未变化")
return False
self._screen_on_since = None
log.info("息屏成功")
if self._audit:
self._audit.log("息屏成功", "屏幕已熄灭")
return True
except Exception as e:
self._last_error = str(e)
log.error("息屏失败: %s", e)
if self._audit:
self._audit.log("息屏失败", str(e))
return False
def screen_on(self) -> bool:
"""发送亮屏命令,等待 2 秒后验证,失败时重试一次。"""
log.info("执行亮屏操作")
try:
device = self._ensure_device()
if device is None:
log.error("亮屏失败: 无可用设备")
return False
if self._audit:
self._audit.log("亮屏", "发出亮屏指令")
device.shell("input keyevent 26")
# 等待并验证
time.sleep(2)
is_on, _ = self.get_screen_state()
if not is_on:
log.warning("亮屏命令已发出但屏幕未点亮,重试一次")
device.shell("input keyevent 26")
time.sleep(2)
is_on, _ = self.get_screen_state()
if not is_on:
log.error("亮屏重试后仍未点亮")
self._last_error = "亮屏验证失败"
if self._audit:
self._audit.log("亮屏异常", "屏幕状态未变化")
return False
self._screen_on_since = time.time()
# 解锁屏幕
device.shell("input keyevent 82")
log.info("亮屏成功")
if self._audit:
self._audit.log("亮屏成功", "屏幕已点亮")
return True
except Exception as e:
self._last_error = str(e)
log.error("亮屏失败: %s", e)
if self._audit:
self._audit.log("亮屏失败", str(e))
return False
# ═══════════════════════════════════════════════════════════════════
# IPC 服务端 — Unix Socket JSON 行协议
# ═══════════════════════════════════════════════════════════════════
class IpcHandler(StreamRequestHandler):
"""每个客户端连接由独立线程处理。"""
def handle(self):
try:
raw = self.rfile.readline()
if not raw:
return
req = json.loads(raw.decode("utf-8").strip())
resp = self.server.app.handle_ipc(req)
self.wfile.write((json.dumps(resp) + "\n").encode("utf-8"))
except json.JSONDecodeError:
self.wfile.write('{"ok":false,"error":"JSON 解析失败"}\n'.encode("utf-8"))
except Exception as e:
self.wfile.write(json.dumps({"ok": False, "error": str(e)}).encode() + b"\n")
class IpcServer:
"""管理 Unix Socket 生命周期。"""
def __init__(self, app):
self.app = app
self.server: ThreadingUnixStreamServer | None = None
self._thread: threading.Thread | None = None
def start(self):
try:
os.unlink(SOCKET_PATH)
except OSError:
pass
self.server = ThreadingUnixStreamServer(SOCKET_PATH, IpcHandler)
self.server.app = self.app
self.server.socket.settimeout(1.0)
self._thread = threading.Thread(target=self._serve, daemon=True)
self._thread.start()
log.info("IPC 服务已启动: %s", SOCKET_PATH)
def _serve(self):
while self.app.running:
try:
self.server.handle_request()
except socket.timeout:
continue
except OSError:
break
def cleanup(self):
try:
os.unlink(SOCKET_PATH)
except OSError:
pass
def stop(self):
if self.server:
self.server.shutdown()
self.cleanup()
# ═══════════════════════════════════════════════════════════════════
# ScreenManager — 统一屏幕状态管理(flag 组 + 优先级决策)
# ═══════════════════════════════════════════════════════════════════
class ScreenManager:
"""统一管理四个亮屏事件,基于优先级决定息屏时机。
四个事件(优先级从高到低):
1. 定时点亮 (scheduled) — p9: 在亮屏时间段内 → 亮屏
2. 传感器点亮 (sensor) — p9: GPIO 检测到活动 → 亮屏 + 倒计时
3. IPC 控制 (manual) — p5: 收到 IPC screen_on → 亮屏
4. 检测到外部亮屏 (external) — p0: 检测到物理按键亮屏
决策规则:
- 每个事件的 flag 记录触发时间戳
- 每次 tick 检查最高优先级事件的息屏条件
- 同优先级需 ALL 满足息屏条件才息屏
- 息屏时清除所有 flag
"""
REASON_SCHEDULED = "scheduled" # 定时点亮, p9
REASON_SENSOR = "sensor" # 传感器点亮, p9
REASON_MANUAL = "manual" # IPC 控制, p5
REASON_EXTERNAL = "external" # 检测到外部亮屏, p0
PRIORITIES = {
REASON_SCHEDULED: 9,
REASON_SENSOR: 9,
REASON_MANUAL: 5,
REASON_EXTERNAL: 0,
}
def __init__(self):
self._flags: dict[str, float] = {} # reason → trigger timestamp
self._manual_released = False # manual 是否已要求释放
# ── 接口 ──
def trigger(self, reason: str):
"""设置亮屏事件 flag(记录当前时间戳)。多次调用刷新时间戳。"""
self._flags[reason] = time.time()
if reason == self.REASON_MANUAL:
self._manual_released = False
def release_manual(self):
"""标记手动息屏请求(收到 IPC screen_off)。"""
self._manual_released = True
def clear(self):
"""清除所有 flag(息屏时调用)。"""
self._flags.clear()
self._manual_released = False
# ── 核心决策 ──
def evaluate(self, *, actual_screen_on: bool, within_period: bool,
gpio_elapsed: float, gpio_countdown: int,
screen_on_duration: float, auto_sleep_delay: float) -> dict:
"""综合各事件 flag 判断应执行的操作。
返回:
{"action": "on", "reason": "..."} → 需要亮屏
{"action": "off", "reason": "..."} → 需要息屏(调用者负责 clear())
{"action": "none", "reason": "..."} → 保持当前状态
"""
active = list(self._flags.keys())
if not active:
return {"action": "none", "reason": "no_flags"}
# 按优先级分组
by_pri: dict[int, list[str]] = {}
for r in active:
p = self.PRIORITIES.get(r, 0)
by_pri.setdefault(p, []).append(r)
max_pri = max(by_pri)
top = by_pri[max_pri]
# 检查最高优先级组:是否 ALL 满足息屏条件
all_ok = all(
self._can_off(r, within_period, gpio_elapsed, gpio_countdown,
screen_on_duration, auto_sleep_delay)
for r in top
)
if all_ok:
if actual_screen_on:
return {"action": "off", "reason": f"p{max_pri}_all_ok"}
return {"action": "none", "reason": "already_off"}
# 有事件仍要求亮屏
if not actual_screen_on:
return {"action": "on", "reason": f"p{max_pri}_wants_on"}
return {"action": "none", "reason": f"p{max_pri}_active"}
def _can_off(self, reason: str, within_period: bool,
gpio_elapsed: float, gpio_countdown: int,
screen_on_duration: float, auto_sleep_delay: float) -> bool:
"""判断单个事件是否满足息屏条件。"""
if reason == self.REASON_SCHEDULED:
return not within_period
if reason == self.REASON_SENSOR:
return gpio_elapsed >= gpio_countdown
if reason == self.REASON_MANUAL:
return self._manual_released or screen_on_duration >= auto_sleep_delay
if reason == self.REASON_EXTERNAL:
return screen_on_duration >= auto_sleep_delay
return True # 未知事件默认可息屏
# ── 状态查询 ──
def get_active_reason(self) -> str | None:
"""返回当前最高优先级的 active 事件名(用于显示)。"""
if not self._flags:
return None
return max(self._flags, key=lambda r: self.PRIORITIES.get(r, 0))
def get_status(self) -> dict:
return {
"active_reason": self.get_active_reason(),
"flags": dict(self._flags),
}
# ═══════════════════════════════════════════════════════════════════
# PadSleepApp — 主应用
# ═══════════════════════════════════════════════════════════════════
class PadSleepApp:
"""守护程序核心:加载配置 → 主循环检查屏幕状态 → IPC 通信。"""
def __init__(self):
self.running = True
self.audit = AuditLogger()
self.config_mgr = ConfigManager()
self.adb = AdbManager(self._get_config, self.audit)
self.screen_mgr = ScreenManager()
self.gpio: GPIOEventDetector | None = None
self.ipc = IpcServer(self)
self._lock = threading.Lock()
self._start_time = time.time()
self._status = {
"screen_on": False,
"wakefulness": "unknown",
"within_period": False,
"adb_ready": False,
"device": None,
"device_serial": "",
"uptime": 0,
"start_time": self._start_time,
# ── GPIO 状态 ──
"gpio_available": False,
"gpio_triggered": False,
"gpio_countdown": 0,
"gpio_pin": 17,
# ── ScreenManager 状态 ──
"active_reason": None,
}
# ── 辅助 ──
def _get_config(self) -> dict:
return self.config_mgr.get()
def _update_status(self, **kwargs):
with self._lock:
self._status.update(kwargs)
def get_status(self) -> dict:
with self._lock:
s = dict(self._status)
s["uptime"] = int(time.time() - self._start_time)
return s
# ── GPIO 回调(在 50ms 轮询线程中触发)──
def _on_gpio_event(self, value):
"""GPIO 电平变化回调:设置传感器 flag,异步处理亮屏。
运行在 GPIOEventDetector 的 50ms 轮询线程中,
必须尽快返回以避免阻塞电平检测。
"""
self.screen_mgr.trigger(ScreenManager.REASON_SENSOR)
# 独立线程处理亮屏(screen_on 有 2s ADB 验证延迟,不阻塞 GPIO 轮询)
threading.Thread(target=self._gpio_screen_on, daemon=True).start()
def _gpio_screen_on(self):
"""在独立线程中检查屏幕状态,灭屏则立即亮屏。"""
try:
screen_on, _ = self.adb.get_screen_state()
if not screen_on:
config = self.config_mgr.get()
gpio_pin = config.get("gpio_pin", 17)
gpio_countdown = config.get("gpio_countdown_seconds", 60)
log.info("GPIO 回调 → 亮屏")
self.audit.log("GPIO触屏", f"BCM {gpio_pin} 电平变化,{gpio_countdown}s 倒计时")
self.adb.screen_on()
except Exception as e:
log.error("GPIO 亮屏异常: %s", e)
# ── IPC 命令处理 ──
def handle_ipc(self, request: dict) -> dict:
cmd = request.get("cmd", "")
log.debug("IPC 请求: %s", cmd)
if cmd == "status":
status = self.get_status()
cfg = self.config_mgr.get()
status["periods_count"] = len(cfg.get("screen_on_periods", []))
status["check_interval"] = cfg.get("check_interval_seconds", 30)
status["config_path"] = str(self.config_mgr.path)
status["log_path"] = str(LOG_PATH)
status["connection_type"] = self.adb.get_connection_type()
status["wireless_host"] = cfg.get("adb_wireless_host", "")
status["wireless_port"] = cfg.get("adb_wireless_port", 5555)
# 实时 GPIO 电平(由 GPIOEventDetector 50ms 轮询线程更新)
if self.gpio and self.gpio.available:
status["gpio_level"] = self.gpio.current_value
return {"ok": True, "data": status}
if cmd == "get_config":
return {"ok": True, "data": self.config_mgr.get()}
if cmd == "update_config":
new = request.get("config", {})
if "screen_on_periods" in new:
for p in new["screen_on_periods"]:
if "start" not in p or "end" not in p:
return {"ok": False, "error": "时间段格式错误"}
self.config_mgr.update(new)
return {"ok": True, "data": {"reloaded": True}}
if cmd == "reload":
self.config_mgr.load()
return {"ok": True, "data": {"reloaded": True}}
if cmd == "list_devices":
return {"ok": True, "data": {"devices": self.adb.list_devices()}}
if cmd == "select_device":
serial = request.get("serial", "")
self.config_mgr.update({"adb_device_serial": serial})
self.audit.log("设备选择", f"串号: {serial or '自动'}")
return {"ok": True, "data": {"selected": serial}}
if cmd == "shutdown":
log.info("收到 IPC 关闭命令")
self.audit.log("守护进程", "收到关闭命令")
self.running = False
return {"ok": True}
if cmd == "screen_off":
self.screen_mgr.release_manual()
ok = self.adb.screen_off()
return {"ok": ok}
if cmd == "screen_on":
self.screen_mgr.trigger(ScreenManager.REASON_MANUAL)
ok = self.adb.screen_on()
return {"ok": ok}
if cmd == "log_tail":
n = request.get("lines", 50)
return {"ok": True, "data": {"log": self.audit.read_tail(n)}}
if cmd == "connect_wireless":
host = request.get("host", "")
port = request.get("port", 5555)
if not host:
return {"ok": False, "error": "请提供 IP 地址"}
success, msg = self.adb.connect_wireless(host, int(port))
if success:
# 更新配置
self.config_mgr.update({
"adb_wireless_host": host,
"adb_wireless_port": int(port),
})
return {"ok": success, "data": {"message": msg}}
if cmd == "disconnect_wireless":
addr = request.get("addr", "")
ok = self.adb.disconnect_wireless(addr)
return {"ok": ok}
return {"ok": False, "error": f"未知命令: {cmd}"}
# ── 主循环 ──
def run(self):
log.info("=" * 50)
log.info("padsleep_adb 守护程序启动")
log.info("日志路径: %s", LOG_PATH)
log.info("=" * 50)
self.audit.log("守护进程启动", f"配置文件: {self.config_mgr.path}")
# 加载配置
self.config_mgr.load()
# 检测 ADB
if not self.adb.check_available():
log.warning("ADB 未就绪,请安装: sudo apt install adb")
log.warning("安装后自动检测到 ADB,无需重启本程序")
else:
log.info("ADB 可执行文件已就绪")
# 自动连接无线设备
config = self.config_mgr.get()
if config.get("adb_wireless_auto_connect") and config.get("adb_wireless_host"):
host = config["adb_wireless_host"]
port = config.get("adb_wireless_port", 5555)
log.info("尝试自动连接无线设备: %s:%d", host, port)
success, msg = self.adb.connect_wireless(host, port, timeout=5.0)
if success:
log.info("无线设备已连接: %s", msg)
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, callback=self._on_gpio_event)
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()
# 注册信号
def sighandler(sig, _frame):
log.info("收到信号 %s,正在退出...", sig)
self.audit.log("守护进程", "收到退出信号")
self.running = False
signal.signal(signal.SIGTERM, sighandler)
signal.signal(signal.SIGINT, sighandler)
# 记录上一次屏幕状态用于外部变化检测
prev_screen_on: bool | None = None
# 主循环
while self.running:
try:
prev_screen_on = self._tick(prev_screen_on)
except Exception as e:
log.error("主循环异常: %s", e, exc_info=True)
# 检测配置文件外部变更
self.config_mgr.reload_if_changed()
# 分段睡眠,每秒做一次轻量决策检查(evaluate 纯内存运算)
interval = self.config_mgr.get().get("check_interval_seconds", 30)
for _ in range(max(1, interval)):
if not self.running:
break
time.sleep(1)
self._fast_check()
# 清理
if self.gpio:
self.gpio.stop()
self.ipc.cleanup()
self.audit.log("守护进程", "已退出")
log.info("padsleep_adb 守护程序已退出")
def _tick(self, prev_screen_on: bool | None) -> bool:
"""单次检查逻辑。返回当前屏幕亮灭状态。"""
config = self.config_mgr.get()
# ── 检查 ADB 就绪 ──
if not self.adb.is_ready():
self._update_status(adb_ready=False, device=None)
now = time.time()
if not hasattr(self, "_last_adb_warn") or now - self._last_adb_warn > 60:
log.warning("ADB 未就绪: %s", self.adb._last_error)
self._last_adb_warn = now
return prev_screen_on if prev_screen_on is not None else False
# ── 获取屏幕实际状态 ──
screen_on, wakefulness = self.adb.get_screen_state()
periods = config.get("screen_on_periods", [])
within_period = TimeChecker.is_within_any_period(periods)
auto_sleep_delay = config.get("auto_sleep_delay_minutes", 5) * 60
# ── 1. 设置定时 flag ──
if within_period:
self.screen_mgr.trigger(ScreenManager.REASON_SCHEDULED)
# ── 2. 设置传感器 flag ──
gpio_triggered = False
gpio_countdown_remain = 0
gpio_elapsed = float("inf")
gpio_countdown = config.get("gpio_countdown_seconds", 60)
if self.gpio and self.gpio.available:
gpio_elapsed = self.gpio.seconds_since_last_event
gpio_triggered = gpio_elapsed < gpio_countdown
gpio_countdown_remain = max(0, gpio_countdown - int(gpio_elapsed))
if gpio_triggered:
self.screen_mgr.trigger(ScreenManager.REASON_SENSOR)
# ── 3. 检测外部状态变化(用户按电源键)──
if prev_screen_on is not None and prev_screen_on != screen_on:
if screen_on:
# 屏幕被外部点亮——设置 external flag 让 ScreenManager 追踪
self.screen_mgr.trigger(ScreenManager.REASON_EXTERNAL)
log.info("检测到屏幕被点亮 (外部操作)")
self.audit.log("检测到屏幕被点亮", "外部操作或用户手动")
else:
log.info("检测到屏幕被熄灭 (外部操作)")
self.audit.log("检测到屏幕被熄灭", "外部操作或用户手动")
# ── 4. ScreenManager 统一决策 ──
decision = self.screen_mgr.evaluate(
actual_screen_on=screen_on,
within_period=within_period,
gpio_elapsed=gpio_elapsed,
gpio_countdown=gpio_countdown,
screen_on_duration=self.adb.get_screen_on_duration(),
auto_sleep_delay=auto_sleep_delay,
)
# ── 5. 执行决策 ──
if decision["action"] == "on" and not screen_on:
log.info("ScreenManager 决策 → 亮屏 (%s)", decision["reason"])
# 区分亮屏原因
if ScreenManager.REASON_SENSOR in self.screen_mgr._flags:
gpio_pin = config.get("gpio_pin", 17)
countdown = config.get("gpio_countdown_seconds", 60)
self.audit.log("GPIO触屏", f"BCM {gpio_pin} 电平变化,{countdown}s 倒计时")
elif ScreenManager.REASON_SCHEDULED in self.screen_mgr._flags:
period_desc = TimeChecker.nearest_period_description(periods)
self.audit.log("亮屏", f"在亮屏时间段 ({period_desc}) 内检测到屏幕熄灭,自动恢复")
elif ScreenManager.REASON_MANUAL in self.screen_mgr._flags:
self.audit.log("亮屏", "手动 IPC 控制")
self.adb.screen_on()
screen_on = True
elif decision["action"] == "off" and screen_on:
reason = decision["reason"]
log.info("ScreenManager 决策 → 息屏 (%s)", reason)
if "sensor" in str(self.screen_mgr._flags):
self.audit.log("GPIO息屏",
f"BCM {config.get('gpio_pin', 17)}{gpio_countdown}s 内无电平变化")
elif reason == "p5_all_ok" or reason == "p0_all_ok":
period_desc = TimeChecker.nearest_period_description(periods)
self.audit.log("息屏",
f"不在亮屏时间段 ({period_desc}),超时 {config.get('auto_sleep_delay_minutes')} 分钟")
self.screen_mgr.clear()
self.adb.screen_off()
screen_on = False
# ── 6. 更新状态 ──
device = self.adb.get_ready_device()
gpio_level = self.gpio.current_value if self.gpio and self.gpio.available else -1
self._update_status(
adb_ready=True,
device=device or "无",
device_serial=config.get("adb_device_serial", ""),
screen_on=screen_on,
wakefulness=wakefulness,
within_period=within_period,
gpio_triggered=gpio_triggered,
gpio_countdown=gpio_countdown_remain,
gpio_level=gpio_level,
**self.screen_mgr.get_status(),
)
return screen_on
def _fast_check(self):
"""每秒轻量决策(不查 ADB,纯内存 evaluate)。
仅在缓存状态显示屏幕亮、且时间条件接近超时时,
才做一次 ADB 验证并执行息屏,避免 30 秒 tick 的超时误差。
"""
config = self.config_mgr.get()
delay_sec = config.get("auto_sleep_delay_minutes", 5) * 60
on_duration = self.adb.get_screen_on_duration() # 纯内存
if on_duration < delay_sec:
return # 远未超时,跳过
# 缓存状态显示屏幕灭 → 无需操作
if not self._status.get("screen_on", False):
return
within_period = TimeChecker.is_within_any_period(
config.get("screen_on_periods", []))
gpio_elapsed = self.gpio.seconds_since_last_event if self.gpio else 999
gpio_countdown = config.get("gpio_countdown_seconds", 60)
decision = self.screen_mgr.evaluate(
actual_screen_on=True,
within_period=within_period,
gpio_elapsed=gpio_elapsed,
gpio_countdown=gpio_countdown,
screen_on_duration=on_duration,
auto_sleep_delay=delay_sec,
)
if decision["action"] == "off":
# 验证屏幕是否真的亮着(一次 ADB 调用)
is_on, _ = self.adb.get_screen_state()
if is_on:
log.info("快速决策 → 息屏 (%s)", decision["reason"])
self.screen_mgr.clear()
self.adb.screen_off()
self._update_status(screen_on=False)
# ═══════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════
def main():
app = PadSleepApp()
try:
app.run()
except KeyboardInterrupt:
log.info("用户中断")
finally:
app.ipc.cleanup()
if __name__ == "__main__":
main()