Files
padsleep_adb/padsleep.py
T
kushidou 1cde7a5d81 添加无线 ADB 连接支持
- 扩展配置:添加 adb_wireless_host/port/auto_connect 字段
- AdbManager: 新增无线连接方法 (connect_wireless, disconnect_wireless)
- AdbManager: 实现自动重连机制 (ensure_wireless_connection)
- AdbManager: USB 优先策略,无线作为备选
- IPC: 新增 connect_wireless/disconnect_wireless 命令
- TUI: 新增无线连接对话框 (W 键)
- TUI: 设备列表显示连接类型 (USB/WiFi)
2026-07-10 09:39:45 +08:00

785 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""padsleep_adb 守护程序
基于树莓派,通过 ADB 控制安卓平板自动亮屏和息屏。
阶段一功能:
- 根据配置的亮屏时间段自动亮屏/息屏
- 屏幕亮起超过设定时长且不在亮屏时间段内 → 自动息屏
- 通过 Unix Socket 提供 IPC 接口,供 TUI 工具管理
"""
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
# ── 常量 ────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).parent.resolve()
CONFIG_PATH = SCRIPT_DIR / "config.json"
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,
}
# ── 日志 ────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("padsleep")
# ═══════════════════════════════════════════════════════════════════
# 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)
# ═══════════════════════════════════════════════════════════════════
# AdbManager — ADB 操作封装(使用 adbutils 库)
# ═══════════════════════════════════════════════════════════════════
class AdbManager:
"""封装所有 ADB 操作,使用 adbutils 库实现持久连接。"""
def __init__(self, config_getter):
self._config_getter = config_getter
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._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)
return True, result
return False, result
except Exception as e:
self._last_error = str(e)
log.error("无线连接失败: %s", 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 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._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
self._ready = True
self._last_error = ""
self._wireless_addr = wireless_addr
return True
# 不在列表中,尝试连接
if self.ensure_wireless_connection():
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)
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:
"""发送息屏命令。"""
log.info("执行息屏操作")
try:
device = self._ensure_device()
if device is None:
log.error("息屏失败: 无可用设备")
return False
device.shell("input keyevent 26")
self._screen_on_since = None
return True
except Exception as e:
self._last_error = str(e)
log.error("息屏失败: %s", e)
return False
def screen_on(self) -> bool:
"""发送亮屏并解锁命令。"""
log.info("执行亮屏操作")
try:
device = self._ensure_device()
if device is None:
log.error("亮屏失败: 无可用设备")
return False
device.shell("input keyevent 26")
self._screen_on_since = time.time()
device.shell("input keyevent 82") # 解锁(MENU
return True
except Exception as e:
self._last_error = str(e)
log.error("亮屏失败: %s", 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()
# ═══════════════════════════════════════════════════════════════════
# PadSleepApp — 主应用
# ═══════════════════════════════════════════════════════════════════
class PadSleepApp:
"""守护程序核心:加载配置 → 主循环检查屏幕状态 → IPC 通信。"""
def __init__(self):
self.running = True
self.config_mgr = ConfigManager()
self.adb = AdbManager(self._get_config)
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,
"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["connection_type"] = self.adb.get_connection_type()
status["wireless_host"] = cfg.get("adb_wireless_host", "")
status["wireless_port"] = cfg.get("adb_wireless_port", 5555)
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)
cfg = self.config_mgr.get()
# 重设串号以使 is_ready 立即用新串号检查
self.config_mgr.config["adb_device_serial"] = cfg.get("adb_device_serial", "")
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})
return {"ok": True, "data": {"selected": serial}}
if cmd == "shutdown":
log.info("收到 IPC 关闭命令")
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 == "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("=" * 50)
# 加载配置
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)
# 启动 IPC
self.ipc.start()
# 注册信号
def sighandler(sig, _frame):
log.info("收到信号 %s,正在退出...", sig)
self.running = False
signal.signal(signal.SIGTERM, sighandler)
signal.signal(signal.SIGINT, sighandler)
# 主循环
while self.running:
try:
self._tick()
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()
log.info("padsleep_adb 守护程序已退出")
def _tick(self):
"""单次检查逻辑。"""
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
# ── 获取屏幕状态 ──
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,
screen_on=screen_on,
wakefulness=wakefulness,
within_period=within_period,
)
# ── 决策逻辑 ──
delay_sec = config.get("auto_sleep_delay_minutes", 5) * 60
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.adb.screen_off()
else:
remaining = delay_sec - on_duration
if remaining <= 10:
log.info("不在亮屏时段,%.0f 秒后将息屏", remaining)
elif within_period and not screen_on:
# 在亮屏时段但屏幕灭着 → 亮屏
log.info("在亮屏时段内 → 自动亮屏")
self.adb.screen_on()
# ═══════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════
def main():
app = PadSleepApp()
try:
app.run()
except KeyboardInterrupt:
log.info("用户中断")
finally:
app.ipc.cleanup()
if __name__ == "__main__":
main()