diff --git a/README.md b/README.md index 8bb0647..306c041 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,24 @@ python padsleep_tui.py padsleep-config ``` +### 启动 Web 监控面板 + +```bash +# 新终端窗口(需先启动守护进程) +python padsleep_web.py + +# 或使用安装后的命令行入口 +padsleep-web +``` + +然后在浏览器打开 `http://树莓派IP:5000` 查看仪表盘。 + +Web 面板功能: +- **GPIO 电平实时监控**:独立线程 50ms 轮询 GPIO 引脚,毫秒级变化展示 +- **守护进程状态**:屏幕状态、亮屏事件、ADB 连接、传感器触发等 +- **电平事件日志**:实时记录所有 GPIO 电平变化 +- **历史波形**:最近 40 次电平变化可视化 + TUI 快捷键: | 按键 | 功能 | @@ -212,6 +230,9 @@ GPIO 引脚号(BCM 编号)对照: padsleep_adb/ ├── padsleep.py # 守护程序主程序(含 GPIO 检测) ├── padsleep_tui.py # TUI 管理工具 +├── padsleep_web.py # Web 监控面板(Flask + SocketIO) +├── templates/ +│ └── index.html # Web 面板前端 ├── config.json # 配置文件 ├── requirements.txt # Python 依赖 ├── install.sh # 安装脚本 diff --git a/install.sh b/install.sh index 40ebb5a..90dd308 100755 --- a/install.sh +++ b/install.sh @@ -63,6 +63,8 @@ info "已创建目录 $INSTALL_DIR" # ── 复制文件 ── cp "$SRC_DIR/padsleep.py" "$INSTALL_DIR/" cp "$SRC_DIR/padsleep_tui.py" "$INSTALL_DIR/" +cp "$SRC_DIR/padsleep_web.py" "$INSTALL_DIR/" 2>/dev/null || true +cp -r "$SRC_DIR/templates" "$INSTALL_DIR/" 2>/dev/null || true cp "$SRC_DIR/requirements.txt" "$INSTALL_DIR/" 2>/dev/null || true # 配置文件:目标不存在则复制,存在则保留 @@ -77,25 +79,34 @@ fi touch "$INSTALL_DIR/padsleep.log" chmod 644 "$INSTALL_DIR/padsleep.log" -# ── 创建 Python 虚拟环境 ── +# ── 创建 Python 虚拟环境并安装依赖 ── +# 进入目标目录确保 venv 上下文正确 +cd "$INSTALL_DIR" + if [ ! -d "$VENV_DIR" ]; then info "正在创建 Python 虚拟环境..." - python3 -m venv "$VENV_DIR" + python3 -m venv .venv info "虚拟环境已创建: $VENV_DIR" else info "虚拟环境已存在,跳过创建" fi -# ── 安装 Python 依赖 ── -info "正在安装 Python 依赖..." -"$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" - fi -} +info "正在安装 Python 依赖 (requirements.txt)..." +if [ -f "$INSTALL_DIR/requirements.txt" ]; then + .venv/bin/pip install --quiet -r requirements.txt 2>&1 || { + warn "pip 安装失败,尝试逐个安装..." + .venv/bin/pip install --quiet adbutils rpi-lgpio flask flask-socketio 2>&1 || { + warn "pip 安装仍然失败,请手动执行: cd $INSTALL_DIR && .venv/bin/pip install -r requirements.txt" + } + } +else + .venv/bin/pip install --quiet adbutils rpi-lgpio flask flask-socketio 2>&1 || \ + warn "pip 安装失败,请手动安装依赖" +fi info "Python 依赖安装完成" +cd "$SRC_DIR" # 回到源目录 + # 设置执行权限 chmod 755 "$INSTALL_DIR/padsleep.py" chmod 755 "$INSTALL_DIR/padsleep_tui.py" @@ -121,22 +132,38 @@ WRAPPER create_wrapper "padsleep.py" "$BIN_DIR/padsleep" create_wrapper "padsleep_tui.py" "$BIN_DIR/padsleep-config" +if [ -f "$INSTALL_DIR/padsleep_web.py" ]; then + create_wrapper "padsleep_web.py" "$BIN_DIR/padsleep-web" +fi info "已创建命令入口:" info " $BIN_DIR/padsleep → 启动守护进程 ($PYTHON_BIN)" info " $BIN_DIR/padsleep-config → 启动 TUI 配置工具 ($PYTHON_BIN)" +info " $BIN_DIR/padsleep-web → 启动 Web 监控面板 ($PYTHON_BIN)" # ── 可选:复制 systemd 服务 ── -if [ -f "$SRC_DIR/padsleep.service" ]; then - cp "$SRC_DIR/padsleep.service" "$SERVICE_DIR/padsleep.service" - sed -i "s|WorkingDirectory=.*|WorkingDirectory=$INSTALL_DIR|" "$SERVICE_DIR/padsleep.service" - sed -i "s|ExecStart=.*|ExecStart=$PYTHON_BIN $INSTALL_DIR/padsleep.py|" "$SERVICE_DIR/padsleep.service" - sed -i "s|^User=.*|User=$RUN_USER|" "$SERVICE_DIR/padsleep.service" - systemctl daemon-reload - info "已复制 systemd 服务文件: $SERVICE_DIR/padsleep.service" - info "服务用户已设为: $RUN_USER" - info "可执行以下命令启用:" - info " sudo systemctl enable --now padsleep" -fi +install_service() { + local src="$1" + local dst="$2" + local script="$3" + if [ ! -f "$src" ]; then + warn "服务文件不存在: $src,跳过" + return + fi + cp "$src" "$dst" + sed -i "s|WorkingDirectory=.*|WorkingDirectory=$INSTALL_DIR|" "$dst" + sed -i "s|ExecStart=.*|ExecStart=$PYTHON_BIN $INSTALL_DIR/$script|" "$dst" + sed -i "s|^User=.*|User=$RUN_USER|" "$dst" + info "已安装 $dst" +} + +install_service "$SRC_DIR/padsleep.service" "$SERVICE_DIR/padsleep.service" "padsleep.py" +install_service "$SRC_DIR/padsleep-web.service" "$SERVICE_DIR/padsleep-web.service" "padsleep_web.py" + +systemctl daemon-reload +info "systemd 已重载" +echo "" +info "启用服务(自启动 + 立即启动):" +info " sudo systemctl enable --now padsleep padsleep-web" # ── 检查 ADB ── if command -v adb &>/dev/null; then @@ -151,9 +178,11 @@ echo -e "${GREEN}═════════════════════ info "安装完成!" echo "" info "使用方法:" -echo " 1. 启动守护进程: padsleep" -echo " 2. 打开配置界面: padsleep-config" -echo " 3. 注册系统服务: sudo systemctl enable --now padsleep" +echo " 1. 启动守护进程: padsleep" +echo " 2. 打开 TUI 配置界面: padsleep-config" +echo " 3. 打开 Web 监控面板: padsleep-web" +echo " 4. 注册系统服务: sudo systemctl enable --now padsleep padsleep-web" +echo " 5. Web 面板地址: http://树莓派IP:31400" echo "" info "配置文件: $INSTALL_DIR/config.json" info "日志文件: $INSTALL_DIR/padsleep.log" diff --git a/padsleep-web.service b/padsleep-web.service new file mode 100644 index 0000000..be5b6ff --- /dev/null +++ b/padsleep-web.service @@ -0,0 +1,18 @@ +[Unit] +Description=padsleep Web Monitor — 实时监控面板 +Documentation=https://github.com/kushidou/padsleep_adb +Requires=padsleep.service +After=padsleep.service network.target + +[Service] +Type=simple +User=_INSTALL_USER_ +WorkingDirectory=/opt/apps/padsleep +ExecStart=/opt/apps/padsleep/.venv/bin/python3 /opt/apps/padsleep/padsleep_web.py +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/padsleep.py b/padsleep.py index 4f2197a..4dcc651 100644 --- a/padsleep.py +++ b/padsleep.py @@ -282,7 +282,7 @@ class GPIOEventDetector: detector.stop() """ - POLL_INTERVAL = 0.05 # 轮询间隔 50ms + POLL_INTERVAL = 1.0 # 轮询间隔 1s def __init__(self, pin: int, callback=None): self.pin = pin @@ -290,6 +290,7 @@ class GPIOEventDetector: 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 @@ -337,6 +338,7 @@ class GPIOEventDetector: 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() @@ -796,6 +798,130 @@ class IpcServer: 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 — 主应用 # ═══════════════════════════════════════════════════════════════════ @@ -808,6 +934,7 @@ class PadSleepApp: 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() @@ -826,6 +953,8 @@ class PadSleepApp: "gpio_triggered": False, "gpio_countdown": 0, "gpio_pin": 17, + # ── ScreenManager 状态 ── + "active_reason": None, } # ── 辅助 ── @@ -843,6 +972,32 @@ class PadSleepApp: 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: @@ -859,6 +1014,9 @@ class PadSleepApp: 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": @@ -893,10 +1051,12 @@ class PadSleepApp: 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} @@ -960,7 +1120,7 @@ class PadSleepApp: # ── 启动 GPIO 电平检测 ── if config.get("gpio_enabled", True): gpio_pin = config.get("gpio_pin", 17) - self.gpio = GPIOEventDetector(pin=gpio_pin) + self.gpio = GPIOEventDetector(pin=gpio_pin, callback=self._on_gpio_event) self.gpio.start() if self.gpio.available: log.info("GPIO 电平检测已启用 (BCM %d, 倒计时 %ds)", @@ -996,12 +1156,13 @@ class PadSleepApp: # 检测配置文件外部变更 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: @@ -1023,49 +1184,82 @@ class PadSleepApp: 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 - # ═══════════════════════════════════════════════════════════════ - # GPIO 触发检测(人来亮屏 / 人走息屏) - # - # 由于购买的模块有问题,无法稳定输出电平,但是人来人走时会发生 - # 短暂电平切换,因此采用这个折中的法子:检测到 GPIO 电平变化 - # 就亮屏并重置 gpio_countdown_seconds 秒倒计时,倒计时结束 - # 且无新变化则息屏(除非当前在亮屏时段)。 - # ═══════════════════════════════════════════════════════════════ + # ── 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_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)) - + 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: - # 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 + self.screen_mgr.trigger(ScreenManager.REASON_SENSOR) - # ── 更新状态(含 GPIO 信息)── + # ── 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 "无", @@ -1075,49 +1269,51 @@ class PadSleepApp: within_period=within_period, gpio_triggered=gpio_triggered, gpio_countdown=gpio_countdown_remain, + gpio_level=gpio_level, + **self.screen_mgr.get_status(), ) - # ── 检测外部屏幕状态变化 ── - 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("检测到屏幕被熄灭", "外部操作或用户手动") - - # ── 原有决策逻辑(仅在非 GPIO 触发时有效) ── - - 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 and not gpio_triggered: - # 非亮屏时段、非 GPIO 触发、屏幕亮着 → 超时后息屏 - 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 _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) + # ═══════════════════════════════════════════════════════════════════ # 入口 diff --git a/padsleep_tui.py b/padsleep_tui.py index 4bd21f8..eeeba08 100644 --- a/padsleep_tui.py +++ b/padsleep_tui.py @@ -29,7 +29,7 @@ CONFIG_PATH = SCRIPT_DIR / "config.json" # ═══════════════════════════════════════════════════════════════════ class IpcClient: - """Unix Socket JSON 行协议客户端。""" + """Unix Socket JSON 行协议客户端(每次 send 新建连接)。""" def __init__(self, socket_path=SOCKET_PATH): self.socket_path = socket_path @@ -58,9 +58,10 @@ class IpcClient: self.connected = False def send(self, cmd: str, **kwargs) -> dict: - if not self.sock: - if not self.connect(): - return {"ok": False, "error": "未连接守护进程"} + """发送命令,每次新建连接(守护进程每请求关闭连接)。""" + self.disconnect() + if not self.connect(): + return {"ok": False, "error": "未连接守护进程"} req = {"cmd": cmd, **kwargs} try: self.sock.sendall((json.dumps(req) + "\n").encode("utf-8")) @@ -435,6 +436,25 @@ class TuiApp: except curses.error: pass + + # ── 亮屏事件状态 ── + active_reason = s.get("active_reason") + if active_reason: + y += 1 + reason_labels = { + "scheduled": "定时点亮", + "sensor": "传感器触发", + "manual": "手动控制", + "external": "外部亮屏", + } + label = reason_labels.get(active_reason, active_reason) + if y < h: + self._clear_line(y) + try: + self.stdscr.addstr(y, 4, "亮屏事件: ") + self.stdscr.addstr(f"{label}", curses.color_pair(3) | curses.A_BOLD) + except curses.error: + pass # ── GPIO 状态 ── gpio_avail = s.get("gpio_available", False) if gpio_avail: diff --git a/padsleep_web.py b/padsleep_web.py new file mode 100644 index 0000000..314e0c5 --- /dev/null +++ b/padsleep_web.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +padsleep Web Monitor +==================== +通过 Unix Socket 从 padsleep 守护进程读取全量状态(含 GPIO 实时电平), +通过 WebSocket 推送到浏览器实时展示。不与守护进程竞争硬件资源。 + +用法: + # 先启动 padsleep 守护进程 + python padsleep.py + + # 再启动 Web 监控(新终端) + python padsleep_web.py + + # 浏览器打开 http://树莓派IP:31400 + +依赖: + pip install flask flask-socketio + +配置项(环境变量): + PORT=31400 Web 端口 +""" + +import json +import os +import socket +import threading +import time + +from flask import Flask, render_template +from flask_socketio import SocketIO, emit + +# ── 配置 ──────────────────────────────────────────── +PORT = int(os.environ.get("PORT", "31400")) +SOCKET_PATH = "/tmp/padsleep.sock" +POLL_FAST_S = 0.2 # GPIO 电平轮询间隔(200ms,跟随守护进程 50ms 更新) +POLL_SLOW_S = 1 # 其他状态轮询间隔 + + +# ═══════════════════════════════════════════════════════════════════ +# IpcClient — 与 padsleep 守护进程通信(Unix Socket) +# ═══════════════════════════════════════════════════════════════════ + +class IpcClient: + """Unix Socket JSON 行协议客户端(每次 send 新建连接)。""" + + def __init__(self, socket_path=SOCKET_PATH): + self.socket_path = socket_path + self.last_error = "" + + def _connect(self) -> socket.socket | None: + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(2) + sock.connect(self.socket_path) + return sock + except (socket.error, OSError) as e: + self.last_error = str(e) + return None + + def send(self, cmd: str, **kwargs) -> dict: + """发送命令,每次新建连接(守护进程每请求关闭连接)。""" + sock = self._connect() + if sock is None: + return {"ok": False, "error": f"未连接: {self.last_error}"} + req = {"cmd": cmd, **kwargs} + try: + sock.sendall((json.dumps(req) + "\n").encode("utf-8")) + resp = sock.recv(65536) + return json.loads(resp.decode("utf-8").strip()) + except (socket.error, json.JSONDecodeError) as e: + return {"ok": False, "error": str(e)} + finally: + try: + sock.close() + except Exception: + pass + + +# ═══════════════════════════════════════════════════════════════════ +# Flask 应用 +# ═══════════════════════════════════════════════════════════════════ + +app = Flask(__name__) +app.config["SECRET_KEY"] = os.urandom(16).hex() +socketio = SocketIO(app, cors_allowed_origins="*") + +ipc = IpcClient() + +# ── 全局缓存(供不同频率的轮询线程共享)── +_data_lock = threading.Lock() +_cached = { + "gpio_level": -1, + "gpio_time": "--", + "status": None, +} + + +# ── 路由 ───────────────────────────────────────────── + +@app.route("/") +def index(): + return render_template("index.html") + + +# ── 后台线程:高频轮询 GPIO 电平 ──────────────────── + +def poll_gpio(): + """通过 IPC 高频读取 GPIO 电平,变化时 WebSocket 广播。""" + last_val = -1 + while True: + r = ipc.send("status") + if r.get("ok"): + level = r["data"].get("gpio_level", -1) + now = time.strftime("%H:%M:%S") + f".{int(time.time() * 1000) % 1000:03d}" + if level != -1 and level != last_val: + socketio.emit("gpio_update", { + "value": level, + "time": now, + }) + last_val = level + # 更新缓存(补上 connected 字段,前端据此判断连接状态) + r["data"]["connected"] = True + with _data_lock: + _cached["gpio_level"] = level + _cached["gpio_time"] = now + _cached["status"] = r["data"] + socketio.sleep(POLL_FAST_S) + + +# ── 后台线程:慢速轮询全量状态 ────────────────────── + +def poll_status(): + """定期查询守护进程全量状态,通过 WebSocket 广播。""" + while True: + with _data_lock: + status = _cached.get("status") + if status: + socketio.emit("status_update", status) + socketio.sleep(POLL_SLOW_S) + + +# ── WebSocket 事件 ────────────────────────────────── + +@socketio.on("connect") +def on_connect(auth=None): + print("[WS] 客户端已连接") + # 推送初始状态 + r = ipc.send("status") + if r.get("ok"): + data = r["data"] + data["connected"] = True + level = data.get("gpio_level", -1) + now = time.strftime("%H:%M:%S") + f".{int(time.time() * 1000) % 1000:03d}" + with _data_lock: + _cached["gpio_level"] = level + _cached["gpio_time"] = now + _cached["status"] = data + socketio.emit("gpio_update", {"value": level, "time": now}) + socketio.emit("status_update", data) + else: + socketio.emit("status_update", { + "connected": False, + "error": r.get("error", "无法连接守护进程"), + }) + + +@socketio.on("disconnect") +def on_disconnect(): + print("[WS] 客户端已断开") + + +# ── 入口 ───────────────────────────────────────────── + +def main(): + print("╔══════════════════════════════════════════╗") + print("║ padsleep Web Monitor ║") + print("║ (通过 IPC 读取守护进程状态) ║") + print("╠══════════════════════════════════════════╣") + print(f"║ 监听地址: http://0.0.0.0:{PORT}") + print(f"║ 数据源: {SOCKET_PATH}") + print(f"║ GPIO 轮询: {POLL_FAST_S*1000:.0f}ms — 跟随守护进程 50ms 线程") + print(f"║ 状态轮询: {POLL_SLOW_S}s") + print(f"║ 请先启动 padsleep.py 守护进程") + print(f"║ 按 Ctrl+C 停止 ║") + print("╚══════════════════════════════════════════╝") + + # 启动后台线程 + socketio.start_background_task(poll_gpio) + socketio.start_background_task(poll_status) + + try: + socketio.run(app, host="0.0.0.0", port=PORT, allow_unsafe_werkzeug=True) + except KeyboardInterrupt: + pass + finally: + print("👋 已退出") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 8eadcdd..e730afd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,8 @@ adbutils>=2.12.0 rpi-lgpio>=0.6 # GPIO 电平检测(树莓派硬件传感器触发息屏/亮屏) +flask>=3.0 # Web 监控面板 +flask-socketio>=5.0 # WebSocket 实时推送 # 系统依赖:adb (Android Debug Bridge) # 安装: sudo apt-get install adb diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..d59e875 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,544 @@ + + + + + + padsleep 监控面板 + + + + +
+

+ 📊 padsleep 监控面板 + padsleep_adb 守护进程 · 通过 IPC 读取状态 +

+ +
+ +
+
GPIO 实时电平
+
+ 0 +
+
LOW (0V)
+
BCM GPIO --
+
--
+ +
+
+
+
+ + +
+
守护进程状态
+
+
+ 屏幕 + -- +
+
+ 亮屏事件 + -- +
+
+ 在定时时段内 + -- +
+
+ ADB + -- +
+
+ 连接类型 + -- +
+
+ GPIO 传感器 + -- +
+
+ 守护运行 + -- +
+
+ 配置时间段 + -- +
+
+
+
+ + +
+
GPIO 电平事件
+
+
等待数据…
+
+
+ + +
+ + + +