Files
padsleep_adb/padsleep.py
T
kushidouandClaude Fable 5 458b67213f Phase 1: 守护程序、TUI配置工具、安装脚本
核心变更:
- padsleep.py: 完整重写,实现守护进程主循环
  - ConfigManager: JSON配置加载/保存/变更检测
  - TimeChecker: 亮屏时间段判断(支持跨天)
  - AdbManager: ADB操作封装,自动检测设备,命令后验证
  - AuditLogger: 审计日志,超50MB自动裁剪保留后10%
  - IpcServer: Unix Socket JSON协议IPC服务端
  - 自动恢复: 亮屏时段内检测到屏幕熄灭立即点亮
  - 外部状态检测: 记录用户手动开关屏事件
- padsleep_tui.py: curses TUI配置工具
  - 亮屏时间段增删改查
  - ADB设备列表查看/选择
  - 守护进程状态监控
  - 审计日志查看
- padsleep.service: systemd服务单元(路径自适应)
- install.sh: 安装到 /opt/apps/padsleep,自动设置权限

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

770 lines
29 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 subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from socketserver import ThreadingUnixStreamServer, StreamRequestHandler
# ── 常量 ────────────────────────────────────────────────────────
# 注意: .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_path": "adb",
}
# ── 日志 ────────────────────────────────────────────────────────
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)
# ═══════════════════════════════════════════════════════════════════
# AdbManager — ADB 操作封装
# ═══════════════════════════════════════════════════════════════════
class AdbManager:
"""封装所有 ADB CLI 操作,所有异常都被捕获,不会导致进程退出。"""
def __init__(self, config_getter, audit_logger: AuditLogger | None = None):
self._config_getter = config_getter
self._audit = audit_logger
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._current_serial: str | None = None # 自动检测到的设备串号
# ── 底层命令执行 ──
def _exec(self, args: list, timeout: int = 10) -> tuple:
"""执行 ADB 命令,返回 (returncode, stdout, stderr)。永不抛出。"""
config = self._config_getter()
adb_path = config.get("adb_path", "adb")
serial = config.get("adb_device_serial", "") or self._current_serial or ""
cmd = [adb_path]
# "devices" / "--version" 命令不加 -s
if serial and args and args[0] not in ("devices", "--version"):
cmd.extend(["-s", serial])
cmd.extend(args)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout.strip(), r.stderr.strip()
except FileNotFoundError:
self._ready = False
self._last_error = f"ADB 未找到({adb_path}),请安装: sudo apt install adb"
except subprocess.TimeoutExpired:
self._ready = False
self._last_error = "ADB 命令超时"
except Exception as e:
self._ready = False
self._last_error = str(e)
return -1, "", self._last_error
# ── 设备管理 ──
def check_available(self) -> bool:
"""检查 ADB 是否可执行。"""
rc, _, _ = self._exec(["--version"], timeout=5)
return rc == 0
def list_devices(self) -> list[dict]:
"""返回 [{"serial": "...", "status": "..."}]。"""
rc, out, _ = self._exec(["devices"])
if rc != 0:
return []
devices = []
for line in out.split("\n"):
parts = line.strip().split("\t")
if len(parts) >= 2 and not line.startswith("List"):
devices.append({"serial": parts[0], "status": parts[1]})
return devices
def get_ready_device(self) -> str | None:
"""返回第一个状态为 device 的串号,无则返回 None。"""
for d in self.list_devices():
if d["status"] == "device":
return d["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 = config.get("adb_device_serial", "")
devices = self.list_devices()
for d in devices:
if d["status"] == "device":
if not target or d["serial"] == target:
self._ready = True
self._current_serial = d["serial"]
self._last_error = f"已就绪: {d['serial']}"
return True
# 诊断
unauthorized = [d for d in devices if d["status"] == "unauthorized"]
if unauthorized:
self._last_error = f"设备未授权: {[d['serial'] for d in unauthorized]},请在平板上确认授权"
elif not devices:
self._last_error = "未检测到 ADB 设备,请连接平板"
else:
self._last_error = "没有已就绪的 ADB 设备"
self._ready = False
return False
# ── 屏幕状态 ──
def get_screen_state(self) -> tuple[bool, str]:
"""返回 (is_on, wakefulness),失败时返回上次已知状态。"""
rc, out, _ = self._exec(["shell", "dumpsys", "power"])
if rc != 0:
return self._last_known_screen_on, "unknown"
for line in out.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 out.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"
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("执行息屏操作")
if self._audit:
self._audit.log("息屏", "发出息屏指令")
rc, _, err = self._exec(["shell", "input", "keyevent", "26"])
if rc != 0:
log.error("息屏命令失败: %s", err)
if self._audit:
self._audit.log("息屏失败", err)
return False
# 等待并验证
time.sleep(2)
is_on, _ = self.get_screen_state()
if is_on:
log.warning("息屏命令已发出但屏幕未熄灭,重试一次")
rc, _, err = self._exec(["shell", "input", "keyevent", "26"])
time.sleep(2)
is_on, _ = self.get_screen_state()
if is_on:
log.error("息屏重试后仍未熄灭")
if self._audit:
self._audit.log("息屏异常", "屏幕状态未变化")
# 不更新 _screen_on_since
return False
self._screen_on_since = None
log.info("息屏成功")
if self._audit:
self._audit.log("息屏成功", "屏幕已熄灭")
return True
def screen_on(self) -> bool:
"""发送亮屏命令,等待 2 秒后验证,失败时重试一次。"""
log.info("执行亮屏操作")
if self._audit:
self._audit.log("亮屏", "发出亮屏指令")
rc, _, err = self._exec(["shell", "input", "keyevent", "26"])
if rc != 0:
log.error("亮屏命令失败: %s", err)
if self._audit:
self._audit.log("亮屏失败", err)
return False
# 等待并验证
time.sleep(2)
is_on, _ = self.get_screen_state()
if not is_on:
log.warning("亮屏命令已发出但屏幕未点亮,重试一次")
rc, _, err = self._exec(["shell", "input", "keyevent", "26"])
time.sleep(2)
is_on, _ = self.get_screen_state()
if not is_on:
log.error("亮屏重试后仍未点亮")
if self._audit:
self._audit.log("亮屏异常", "屏幕状态未变化")
return False
self._screen_on_since = time.time()
# 解锁屏幕
self._exec(["shell", "input", "keyevent", "82"])
log.info("亮屏成功")
if self._audit:
self._audit.log("亮屏成功", "屏幕已点亮")
return True
# ═══════════════════════════════════════════════════════════════════
# 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()
# ═══════════════════════════════════════════════════════════════════
# 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.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,
}
# ── 辅助 ──
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
# ── 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)
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":
ok = self.adb.screen_off()
return {"ok": ok}
if cmd == "screen_on":
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)}}
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 可执行文件已就绪")
# 启动 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()
# 分段睡眠以快速响应退出信号
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.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)
device = self.adb.get_ready_device()
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,
)
# ── 检测外部屏幕状态变化 ──
if prev_screen_on is not None and prev_screen_on != screen_on:
if screen_on:
log.info("检测到屏幕被点亮 (外部操作)")
self.audit.log("检测到屏幕被点亮", "外部操作或用户手动")
else:
log.info("检测到屏幕被熄灭 (外部操作)")
self.audit.log("检测到屏幕被熄灭", "外部操作或用户手动")
# ── 决策逻辑 ──
delay_sec = config.get("auto_sleep_delay_minutes", 5) * 60
period_desc = TimeChecker.nearest_period_description(periods)
if within_period and not screen_on:
# 在亮屏时段但屏幕灭着 → 立即亮屏(自动恢复)
log.info("在亮屏时间段内但屏幕已熄灭 → 立即亮屏")
self.audit.log("亮屏", f"在亮屏时间段 ({period_desc}) 内检测到屏幕熄灭,自动恢复")
self.adb.screen_on()
return True
if not within_period and screen_on:
# 非亮屏时段但屏幕亮着 → 超时后息屏
on_duration = self.adb.get_screen_on_duration()
if on_duration >= delay_sec:
log.info("不在亮屏时段(已亮 %.0fs)→ 自动息屏", on_duration)
self.audit.log("息屏", f"不在亮屏时间段 ({period_desc}),超时 {config.get('auto_sleep_delay_minutes')} 分钟")
self.adb.screen_off()
return False
else:
remaining = delay_sec - on_duration
if remaining <= 10:
log.info("不在亮屏时段,%.0f 秒后将息屏", remaining)
elif within_period and screen_on:
# 在亮屏时段且屏幕已亮 — 一切正常,但记录一个调试信息
if prev_screen_on is False:
# 这是上一步我们刚刚点亮了屏幕
pass
return screen_on
# ═══════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════
def main():
app = PadSleepApp()
try:
app.run()
except KeyboardInterrupt:
log.info("用户中断")
finally:
app.ipc.cleanup()
if __name__ == "__main__":
main()