Skip to content

Commit b283dc2

Browse files
committed
Implement scheduled power-up with cron-based auto power-on and time-bound keep-alive
Add scheduled_power_up_times to machine config with cron expressions for automatic power-up and keep_alive_time for post-wake keep-alive duration. Introduce ScheduledPowerUpManager with its own event loop; overlapping keep-alive windows are automatically merged. Extract _send() helper from TargetKeepAliveSender and add stop() for clean thread/loop teardown.
1 parent 811bd7f commit b283dc2

7 files changed

Lines changed: 230 additions & 20 deletions

File tree

.dockerignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ __pycache__/
88
wolsocketproxy.conf
99
wolsocketproxy-keepalive.conf
1010
*.egg-info/
11-
.vscode/
11+
.vscode/
12+
.kilo/

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ __pycache__/
88
wolsocketproxy.conf
99
wolsocketproxy-keepalive.conf
1010
*.egg-info/
11-
.vscode/
11+
.vscode/
12+
.kilo/

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,14 @@ The `wolsocketproxy.conf` has the following structure:
7171
"ipmi_force_reset_if_power_up_failed": true, // Use IPMI power reset if this machine is not online after timeout, optional, default is false
7272
"ipmi_max_reset_try_count": 3, // Max IPMI power reset retry count before giving-up, optional, default is 3
7373
"keep_alive_mode": true, // Indicates whether this machine has a keep-alive daemon, optional, default is false
74-
// If you enable this, wolsocketproxy will send a request when there is any traffic
75-
"keep_alive_mode_base_url": "http://192.168.1.124:8080" // Keep-alive daemon provided URL, optional
74+
// If you enable this, wolsocketproxy will send a request when there is any traffic
75+
"keep_alive_mode_base_url": "http://192.168.1.124:8080", // Keep-alive daemon provided URL, optional
76+
"scheduled_power_up_times": [ // Scheduled power-up times, optional
77+
{
78+
"cron": "0 7 * * 1-5", // Cron expression for auto power-up
79+
"keep_alive_time": 7200 // Send keep-alive requests for N seconds after power-up
80+
}
81+
]
7682
},
7783
// ... more machines ...
7884
},
@@ -136,3 +142,16 @@ or the special process will be killed.
136142
You can combine this mode with `circadian`'s `process_block` config to keep it from auto-suspending.
137143

138144
If you enable `keep_alive_mode` in proxy mode's config, the proxy will send requests to `/watchdog/feed` of this machine whenever there is any traffic towards this machine through it.
145+
146+
### Scheduled power-up
147+
148+
You can configure `scheduled_power_up_times` in each machine entry to automatically power up the machine at specified times using
149+
a cron expression. After the machine comes online, the proxy sends keep-alive requests to the machine's keep-alive daemon
150+
(for the duration specified by `keep_alive_time`) to prevent it from auto-suspending during the expected usage window.
151+
152+
Each entry in `scheduled_power_up_times` contains:
153+
- `cron`: A standard 5-field cron expression (e.g. `"0 7 * * 1-5"` for weekdays at 7:00)
154+
- `keep_alive_time`: Duration in seconds to send keep-alive requests after power-up (e.g. `7200` for 2 hours)
155+
156+
If multiple entries overlap in time, the keep-alive period is automatically extended to cover the longest window, and no
157+
duplicate keep-alive requests are sent.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ classifiers = [
2020
requires-python = ">=3.12"
2121
dependencies = [
2222
"aiohttp==3.13.5",
23+
"croniter==6.0.0",
2324
"dataclass-wizard==0.39.1",
2425
"icmplib==3.0.4",
2526
"redfish==3.3.5",

src/wolsocketproxy/proxy.py

Lines changed: 152 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import asyncio
22
import contextlib
33
import logging
4+
import time
5+
from collections.abc import Callable, Coroutine
46
from dataclasses import dataclass
7+
from datetime import datetime
58
from logging import Logger
6-
from threading import Thread
9+
from threading import Event, Thread
710
from typing import Any, Literal, override
811

912
import aiohttp
1013
import wakeonlan
14+
from croniter import croniter
1115
from redfish.rest.v1 import HttpClient, redfish_client
1216

1317
from wolsocketproxy.common import URL_WATCHDOG_FEED
1418
from wolsocketproxy.monitor import Monitor, MonitorConfig
1519
from wolsocketproxy.utils import perform_ipmi_action
1620

1721

22+
@dataclass
23+
class ScheduledPowerUpTime:
24+
cron: str
25+
keep_alive_time: int = 0
26+
27+
1828
@dataclass
1929
class MachineConfig:
2030
wake_up_method: Literal["ipmi", "wol"] = "wol"
@@ -36,6 +46,8 @@ class MachineConfig:
3646
keep_alive_mode_base_url: str | None = None
3747
keep_alive_min_interval: int = 1
3848

49+
scheduled_power_up_times: list[ScheduledPowerUpTime] | None = None
50+
3951

4052
@dataclass
4153
class ProxyRoute:
@@ -70,43 +82,160 @@ class TargetKeepAliveSender:
7082
_keep_alive_min_interval: int
7183
_loop: asyncio.AbstractEventLoop
7284
_queue: asyncio.Queue
85+
_stop_event: Event
7386

7487
def __init__(self, target_base_url: str, keep_alive_min_interval: int) -> None:
7588
self._target_url = target_base_url.removesuffix("/") + URL_WATCHDOG_FEED
7689
self._keep_alive_min_interval = keep_alive_min_interval
7790

7891
self._loop = asyncio.new_event_loop()
7992
self._queue = asyncio.Queue(1)
93+
self._stop_event = Event()
8094

81-
def _loop() -> None:
95+
def _run_loop() -> None:
8296
asyncio.set_event_loop(self._loop)
8397
self._loop.run_until_complete(self._send_worker())
8498

85-
Thread(target=_loop, daemon=True).start()
99+
Thread(target=_run_loop, daemon=True).start()
86100

87101
async def _send_worker(self) -> None:
88-
while True:
102+
while not self._stop_event.is_set():
89103
await self._queue.get()
90-
91-
try:
92-
async with aiohttp.request(
93-
"GET",
94-
self._target_url,
95-
timeout=aiohttp.ClientTimeout(total=5),
96-
) as resp:
97-
await resp.json()
98-
except (aiohttp.ClientError, aiohttp.ClientResponseError):
99-
self._log.warning("Failed to send target keep alive request to %s", self._target_url, exc_info=True)
100-
104+
if self._stop_event.is_set():
105+
break
106+
await self._send()
101107
await asyncio.sleep(self._keep_alive_min_interval)
102108

109+
async def _send(self) -> None:
110+
try:
111+
async with aiohttp.request(
112+
"GET",
113+
self._target_url,
114+
timeout=aiohttp.ClientTimeout(total=5),
115+
) as resp:
116+
await resp.json()
117+
except (aiohttp.ClientError, aiohttp.ClientResponseError):
118+
self._log.warning("Failed to send target keep alive request to %s", self._target_url, exc_info=True)
119+
103120
def schedule_send(self) -> None:
104121
def _no_exception_put() -> None:
105122
with contextlib.suppress(asyncio.QueueFull):
106123
self._queue.put_nowait(1)
107124

108125
self._loop.call_soon_threadsafe(_no_exception_put)
109126

127+
def stop(self) -> None:
128+
self._stop_event.set()
129+
with contextlib.suppress(asyncio.QueueFull):
130+
self._loop.call_soon_threadsafe(lambda: self._queue.put_nowait(None))
131+
132+
133+
class ScheduledPowerUpManager:
134+
_log: Logger = logging.getLogger(__name__)
135+
136+
_machines: dict[str, MachineConfig]
137+
_wake_up_callback: Callable[[str], Coroutine[Any, Any, None]]
138+
_loop: asyncio.AbstractEventLoop
139+
_keep_alive_end_times: dict[str, float]
140+
141+
def __init__(
142+
self,
143+
machines: dict[str, MachineConfig],
144+
wake_up_callback: Callable[[str], Coroutine[Any, Any, None]],
145+
) -> None:
146+
self._machines = machines
147+
self._wake_up_callback = wake_up_callback
148+
self._keep_alive_end_times = {}
149+
150+
def start(self) -> None:
151+
self._loop = asyncio.new_event_loop()
152+
153+
def _run_loop() -> None:
154+
asyncio.set_event_loop(self._loop)
155+
self._loop.run_forever()
156+
157+
Thread(target=_run_loop, daemon=True).start()
158+
159+
for machine_name, machine in self._machines.items():
160+
schedules = machine.scheduled_power_up_times
161+
if not schedules:
162+
continue
163+
164+
if machine.keep_alive_mode_base_url is None:
165+
self._log.warning(
166+
"Machine %s has scheduled_power_up_times but no keep_alive_mode_base_url configured, "
167+
"keep-alive after power-up will be skipped",
168+
machine_name,
169+
)
170+
171+
for schedule in schedules:
172+
asyncio.run_coroutine_threadsafe(
173+
self._process_schedule(machine_name, machine, schedule),
174+
self._loop,
175+
)
176+
177+
async def _process_schedule(
178+
self,
179+
machine_name: str,
180+
machine: MachineConfig,
181+
schedule: ScheduledPowerUpTime,
182+
) -> None:
183+
while True:
184+
now = datetime.now()
185+
cron = croniter(schedule.cron, now)
186+
next_time = cron.get_next(datetime)
187+
188+
delay = (next_time - datetime.now()).total_seconds()
189+
if delay > 0:
190+
await asyncio.sleep(delay)
191+
192+
self._log.info(
193+
"Scheduled power-up triggered for %s (cron: %s)",
194+
machine_name,
195+
schedule.cron,
196+
)
197+
198+
try:
199+
await self._wake_up_callback(machine_name)
200+
except ConnectionAbortedError:
201+
self._log.error(
202+
"Scheduled power-up failed for %s, will retry at next schedule time",
203+
machine_name,
204+
)
205+
continue
206+
207+
if schedule.keep_alive_time > 0 and machine.keep_alive_mode_base_url is not None:
208+
await self._run_keep_alive(machine_name, machine, schedule.keep_alive_time)
209+
210+
211+
async def _run_keep_alive(
212+
self,
213+
machine_name: str,
214+
machine: MachineConfig,
215+
keep_alive_time: int,
216+
) -> None:
217+
assert machine.keep_alive_mode_base_url is not None
218+
new_end = time.monotonic() + keep_alive_time
219+
current_end = self._keep_alive_end_times.get(machine_name, 0.0)
220+
221+
if new_end > current_end:
222+
self._keep_alive_end_times[machine_name] = new_end
223+
224+
if current_end > time.monotonic():
225+
return
226+
227+
sender = TargetKeepAliveSender(
228+
machine.keep_alive_mode_base_url,
229+
machine.keep_alive_min_interval,
230+
)
231+
232+
try:
233+
while time.monotonic() < self._keep_alive_end_times.get(machine_name, 0.0):
234+
sender.schedule_send()
235+
await asyncio.sleep(machine.keep_alive_min_interval)
236+
finally:
237+
sender.stop()
238+
110239

111240
class ProxyUdpProtocol(asyncio.DatagramProtocol):
112241
_proxy: "Proxy"
@@ -163,6 +292,7 @@ class Proxy:
163292
_machines: dict[str, MachineConfig]
164293
_routes: list[ProxyRoute]
165294
_ipmi_configs: dict[str, IPMIConfig]
295+
_scheduled_power_up_manager: ScheduledPowerUpManager
166296

167297
def __init__(self, config: ProxyConfig) -> None:
168298
self._config = config
@@ -202,6 +332,11 @@ def __init__(self, config: ProxyConfig) -> None:
202332
}
203333
)
204334

335+
self._scheduled_power_up_manager = ScheduledPowerUpManager(
336+
machines=self._machines,
337+
wake_up_callback=self._wake_up_target,
338+
)
339+
205340
def start(self) -> None:
206341
self._monitor.start()
207342

@@ -210,6 +345,8 @@ def start(self) -> None:
210345

211346
loop = asyncio.get_event_loop()
212347

348+
self._scheduled_power_up_manager.start()
349+
213350
self._log.info("Proxy server started.")
214351

215352
with contextlib.suppress(KeyboardInterrupt):

uv.lock

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)