Skip to content

Commit cf86997

Browse files
committed
Add idle_keepawake: idle detection + keep machine awake
Long unattended runs derail two ways: the screensaver/power policy sleeps the box mid-run, or the run should hold while a human is using the machine. The framework had neither signal. idle_seconds/is_idle read time since the last input (GetLastInputInfo on Windows) through an injectable probe; keep_awake (scoped CM) and keep_awake_on/allow_sleep (process-global on/off for JSON flows) stop the system and display sleeping through an injectable driver (SetThreadExecutionState/caffeinate/systemd-inhibit by default), restored on release. plan_keep_awake is the pure planner. All logic is unit-testable without touching the OS via the injected probe/driver.
1 parent cb27d10 commit cf86997

11 files changed

Lines changed: 587 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# What's New — AutoControl
22

3+
## What's new (2026-06-25) — Idle Detection + Keep the Machine Awake
4+
5+
Run only when the user has stepped away, and stop an overnight run from sleeping. Full reference: [`docs/source/Eng/doc/new_features/v204_features_doc.rst`](docs/source/Eng/doc/new_features/v204_features_doc.rst).
6+
7+
- **`idle_seconds` / `is_idle` / `keep_awake` / `keep_awake_on` / `allow_sleep` / `plan_keep_awake`** (`AC_idle_seconds`, `AC_is_idle`, `AC_plan_keep_awake`, `AC_keep_awake_on`, `AC_allow_sleep`): long unattended runs get derailed two ways — the screensaver / power policy sleeps the box mid-run, or the run should hold while a human is actively using the machine. The framework had neither signal. `idle_seconds` / `is_idle` report time since the last keyboard / mouse input (`GetLastInputInfo` on Windows) through an injectable `probe`; `keep_awake` (scoped context manager) and `keep_awake_on` / `allow_sleep` (process-global on/off for JSON flows) stop the system and display sleeping, applied through an injectable `driver` (`SetThreadExecutionState` / `caffeinate` / `systemd-inhibit` by default) and restored on release. `plan_keep_awake` is the pure planner. All logic is unit-testable without touching the OS via the injected probe/driver. Second feature of the ROUND-15 cross-app OS lane. No `PySide6`.
8+
39
## What's new (2026-06-25) — Open Files / URLs with the Default App
410

511
Hand a file to its default app, print it, or open a URL in the browser. Full reference: [`docs/source/Eng/doc/new_features/v203_features_doc.rst`](docs/source/Eng/doc/new_features/v203_features_doc.rst).
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
Idle Detection + Keep the Machine Awake
2+
=======================================
3+
4+
Long unattended automation runs get derailed two ways: the screensaver / power
5+
policy sleeps the box mid-run, or the run should hold while a human is actively
6+
using the machine. The framework had neither signal. ``idle_keepawake`` adds
7+
both, behind injectable seams so all logic is testable without touching the OS.
8+
9+
* :func:`idle_seconds` / :func:`is_idle` — seconds since the last user keyboard /
10+
mouse input (``GetLastInputInfo`` on Windows), through an injectable ``probe``.
11+
* :func:`plan_keep_awake` — pure planner describing which wake flags a request
12+
maps to.
13+
* :func:`keep_awake` — scoped context manager that keeps the machine awake for
14+
the duration of a ``with`` block, restoring the prior state on exit.
15+
* :func:`keep_awake_on` / :func:`allow_sleep` — a process-global on / off pair
16+
for JSON action flows.
17+
18+
All three keep-awake entry points apply the plan through an injectable ``driver``
19+
(``SetThreadExecutionState`` on Windows, ``caffeinate`` on macOS,
20+
``systemd-inhibit`` on Linux by default). Imports no ``PySide6``.
21+
22+
Headless API
23+
------------
24+
25+
.. code-block:: python
26+
27+
from je_auto_control import (
28+
idle_seconds, is_idle, keep_awake, keep_awake_on, allow_sleep,
29+
)
30+
31+
idle_seconds() # e.g. 3.4 — seconds since last input
32+
is_idle(300) # True once nobody has touched the machine for 5 min
33+
34+
# Scoped: keep awake only while a long step runs
35+
with keep_awake():
36+
run_long_batch()
37+
38+
# Flow-style: on at the start, off at the end
39+
keep_awake_on(display=True, system=True)
40+
try:
41+
run_long_batch()
42+
finally:
43+
allow_sleep()
44+
45+
:func:`is_idle` is the gate for "only run when the user has stepped away";
46+
:func:`keep_awake` / :func:`keep_awake_on` stop the display and system sleeping
47+
so an overnight run is not interrupted. ``display=False`` keeps the system awake
48+
but lets the screen blank (battery-friendly for headless boxes).
49+
50+
Executor commands
51+
-----------------
52+
53+
``AC_idle_seconds`` (→ ``{idle_seconds}``), ``AC_is_idle`` (``threshold`` →
54+
``{idle, idle_seconds}``), ``AC_plan_keep_awake`` (``display`` / ``system`` → the
55+
plan), ``AC_keep_awake_on`` (``display`` / ``system`` → the active plan) and
56+
``AC_allow_sleep`` (→ ``{released}``). They are exposed as the matching ``ac_*``
57+
MCP tools (reads read-only, keep-awake on/off side-effect-only) and as Script
58+
Builder commands under **Shell**. The :func:`keep_awake` context manager is the
59+
Python-API surface for scoped use.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
閒置偵測 + 保持機器清醒
2+
=======================
3+
4+
長時間無人值守的自動化執行常因兩種情況中斷:螢幕保護 / 電源原則在執行中途讓機器睡眠,或是當有人正在
5+
使用機器時執行應該暫停。框架原本兩種訊號都沒有。``idle_keepawake`` 補上這兩者,並以可注入接縫實作,
6+
所有邏輯都能在不碰作業系統的情況下測試。
7+
8+
* :func:`idle_seconds` / :func:`is_idle` ——距離使用者上次鍵盤 / 滑鼠輸入的秒數(Windows 上用
9+
``GetLastInputInfo``),透過可注入的 ``probe`` 取得。
10+
* :func:`plan_keep_awake` ——純 planner,描述請求對應到哪些清醒旗標。
11+
* :func:`keep_awake` ——具範圍的 context manager,在 ``with`` 區塊期間保持機器清醒,離開時還原先前狀態。
12+
* :func:`keep_awake_on` / :func:`allow_sleep` ——供 JSON 動作流程使用的行程全域開 / 關配對。
13+
14+
三個 keep-awake 入口皆透過可注入的 ``driver`` 套用計畫(預設 Windows 用
15+
``SetThreadExecutionState``、macOS 用 ``caffeinate``、Linux 用 ``systemd-inhibit``)。不匯入
16+
``PySide6``。
17+
18+
無頭 API
19+
--------
20+
21+
.. code-block:: python
22+
23+
from je_auto_control import (
24+
idle_seconds, is_idle, keep_awake, keep_awake_on, allow_sleep,
25+
)
26+
27+
idle_seconds() # 例如 3.4 ——距離上次輸入的秒數
28+
is_idle(300) # 沒人碰機器滿 5 分鐘後回傳 True
29+
30+
# 具範圍:只在長步驟執行時保持清醒
31+
with keep_awake():
32+
run_long_batch()
33+
34+
# 流程式:開始時開、結束時關
35+
keep_awake_on(display=True, system=True)
36+
try:
37+
run_long_batch()
38+
finally:
39+
allow_sleep()
40+
41+
:func:`is_idle` 是「只在使用者離開時才執行」的判斷閘;:func:`keep_awake` /
42+
:func:`keep_awake_on` 阻止螢幕與系統睡眠,讓整夜執行不被打斷。``display=False`` 會保持系統清醒但允許
43+
螢幕變黑(對無頭機器較省電)。
44+
45+
執行器指令
46+
----------
47+
48+
``AC_idle_seconds``(→ ``{idle_seconds}``)、``AC_is_idle``(``threshold`` →
49+
``{idle, idle_seconds}``)、``AC_plan_keep_awake``(``display`` / ``system`` → 計畫)、
50+
``AC_keep_awake_on``(``display`` / ``system`` → 生效中的計畫)與 ``AC_allow_sleep``
51+
(→ ``{released}``)。皆以對應的 ``ac_*`` MCP 工具(讀取為唯讀、keep-awake 開 / 關為僅副作用)
52+
及 Script Builder 指令(位於 **Shell** 分類下)形式提供。:func:`keep_awake` context manager
53+
則是具範圍使用的 Python API 介面。

je_auto_control/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@
9393
from je_auto_control.utils.ax_events import wait_for_focus_change
9494
# Open a file with its default app / a URL in the default browser
9595
from je_auto_control.utils.shell_open import open_path, plan_open
96+
# Detect user-idle time and keep the machine awake during unattended runs
97+
from je_auto_control.utils.idle_keepawake import (
98+
allow_sleep, idle_seconds, is_idle, keep_awake, keep_awake_on,
99+
plan_keep_awake,
100+
)
96101
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
97102
from je_auto_control.utils.clipboard_rich_formats import (
98103
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1703,6 +1708,8 @@ def start_autocontrol_gui(*args, **kwargs):
17031708
"get_selection", "list_views", "set_view",
17041709
"wait_for_focus_change",
17051710
"plan_open", "open_path",
1711+
"idle_seconds", "is_idle", "plan_keep_awake",
1712+
"keep_awake", "keep_awake_on", "allow_sleep",
17061713
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17071714
"set_clipboard_rtf", "get_clipboard_rtf",
17081715
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4268,6 +4268,40 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
42684268
),
42694269
description="Classify how a file/URL would be opened (pure, no launch).",
42704270
))
4271+
specs.append(CommandSpec(
4272+
"AC_idle_seconds", "Shell", "Idle Seconds",
4273+
fields=(),
4274+
description="Seconds since the last user keyboard / mouse input.",
4275+
))
4276+
specs.append(CommandSpec(
4277+
"AC_is_idle", "Shell", "Is User Idle",
4278+
fields=(
4279+
FieldSpec("threshold", FieldType.FLOAT, default=300.0,
4280+
placeholder="idle seconds threshold"),
4281+
),
4282+
description="True if the user has been idle for >= threshold seconds.",
4283+
))
4284+
specs.append(CommandSpec(
4285+
"AC_plan_keep_awake", "Shell", "Plan Keep Awake",
4286+
fields=(
4287+
FieldSpec("display", FieldType.BOOL, optional=True, default=True),
4288+
FieldSpec("system", FieldType.BOOL, optional=True, default=True),
4289+
),
4290+
description="Describe a keep-awake request (pure, no OS call).",
4291+
))
4292+
specs.append(CommandSpec(
4293+
"AC_keep_awake_on", "Shell", "Keep Machine Awake",
4294+
fields=(
4295+
FieldSpec("display", FieldType.BOOL, optional=True, default=True),
4296+
FieldSpec("system", FieldType.BOOL, optional=True, default=True),
4297+
),
4298+
description="Keep the machine awake until Allow Sleep is run.",
4299+
))
4300+
specs.append(CommandSpec(
4301+
"AC_allow_sleep", "Shell", "Allow Machine to Sleep",
4302+
fields=(),
4303+
description="Release a previously-started keep-awake.",
4304+
))
42714305
specs.append(CommandSpec(
42724306
"AC_take_golden", "Report", "Capture Golden Image",
42734307
fields=(FieldSpec("path", FieldType.FILE_PATH),),

je_auto_control/utils/executor/action_executor.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2621,6 +2621,37 @@ def _open_path(target: str, verb: str = "open") -> Dict[str, Any]:
26212621
return {"opened": bool(open_path(str(target), verb=str(verb)))}
26222622

26232623

2624+
def _idle_seconds() -> Dict[str, Any]:
2625+
"""Adapter: seconds since the last user input."""
2626+
from je_auto_control.utils.idle_keepawake import idle_seconds
2627+
return {"idle_seconds": float(idle_seconds())}
2628+
2629+
2630+
def _is_idle(threshold: Any) -> Dict[str, Any]:
2631+
"""Adapter: whether the user has been idle for >= ``threshold`` seconds."""
2632+
from je_auto_control.utils.idle_keepawake import idle_seconds, is_idle
2633+
seconds = float(threshold)
2634+
return {"idle": bool(is_idle(seconds)), "idle_seconds": idle_seconds()}
2635+
2636+
2637+
def _plan_keep_awake(display: Any = True, system: Any = True) -> Dict[str, Any]:
2638+
"""Adapter: describe a keep-awake request (pure, no OS call)."""
2639+
from je_auto_control.utils.idle_keepawake import plan_keep_awake
2640+
return plan_keep_awake(display=bool(display), system=bool(system))
2641+
2642+
2643+
def _keep_awake_on(display: Any = True, system: Any = True) -> Dict[str, Any]:
2644+
"""Adapter: keep the machine awake until ``AC_allow_sleep``."""
2645+
from je_auto_control.utils.idle_keepawake import keep_awake_on
2646+
return keep_awake_on(display=bool(display), system=bool(system))
2647+
2648+
2649+
def _allow_sleep() -> Dict[str, Any]:
2650+
"""Adapter: release a previously-started keep-awake."""
2651+
from je_auto_control.utils.idle_keepawake import allow_sleep
2652+
return {"released": bool(allow_sleep())}
2653+
2654+
26242655
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
26252656
app_name: Optional[str] = None,
26262657
automation_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6613,6 +6644,11 @@ def __init__(self):
66136644
"AC_wait_for_focus_change": _wait_for_focus_change,
66146645
"AC_plan_open": _plan_open,
66156646
"AC_open_path": _open_path,
6647+
"AC_idle_seconds": _idle_seconds,
6648+
"AC_is_idle": _is_idle,
6649+
"AC_plan_keep_awake": _plan_keep_awake,
6650+
"AC_keep_awake_on": _keep_awake_on,
6651+
"AC_allow_sleep": _allow_sleep,
66166652
"AC_get_control_text": _get_control_text,
66176653
"AC_find_control_text": _find_control_text,
66186654
"AC_select_control_text": _select_control_text,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Detect user-idle time and keep the machine awake during unattended runs."""
2+
from je_auto_control.utils.idle_keepawake.idle_keepawake import (
3+
allow_sleep, idle_seconds, is_idle, keep_awake, keep_awake_on,
4+
plan_keep_awake,
5+
)
6+
7+
__all__ = [
8+
"idle_seconds", "is_idle", "plan_keep_awake",
9+
"keep_awake", "keep_awake_on", "allow_sleep",
10+
]

0 commit comments

Comments
 (0)