From be3e27c49b6f2feb8d728f8f271d84d449802e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Mon, 2 Nov 2026 01:36:29 -0600 Subject: [PATCH] feat(homelab): enable Wake-on-LAN on the mini PCs (#509) --- Justfile | 7 +- README.md | 4 +- docs/README.md | 5 + docs/bios.md | 58 +++++ docs/wake-on-lan.md | 61 +++++ hosts/README.md | 3 + hosts/idols-aquamarine/README.md | 28 +- lib/genVmHostModule.nix | 8 + scripts/tests/test_wake.py | 157 ++++++++++++ scripts/wake-homelab.py | 46 ---- scripts/wake.py | 427 +++++++++++++++++++++++++++++++ 11 files changed, 731 insertions(+), 73 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/bios.md create mode 100644 docs/wake-on-lan.md create mode 100644 scripts/tests/test_wake.py delete mode 100755 scripts/wake-homelab.py create mode 100755 scripts/wake.py diff --git a/Justfile b/Justfile index b26a741c..4149e3a4 100644 --- a/Justfile +++ b/Justfile @@ -246,11 +246,12 @@ youko mode="switch": aquamarine mode="switch": colmena apply {{mode}} --build-on-target --on '@aquamarine' --verbose --show-trace -# Wake every homelab host that has Wake-on-LAN armed. +# Wake homelab hosts and wait until they answer. Defaults to all; accepts host +# names, with `aqua` as an alias for aquamarine. Usage: just wake [host ...] [linux] [group('homelab')] -wake-homelab: - python3 {{absolute_path("scripts/wake-homelab.py")}} +wake *hosts: + python3 {{absolute_path("scripts/wake.py")}} {{hosts}} ############################################################################ # diff --git a/README.md b/README.md index 5dfcda0e..219b7b99 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ This repository is home to the Nix code that builds all of my systems: cluster and the self-hosted services. See [./hosts](./hosts) for the host inventory, [./outputs](./outputs) for how the flake outputs are -composed, [./BACKUP.md](./BACKUP.md) for the backup design, and [./AGENTS.md](./AGENTS.md) for the -repository conventions. +composed, [./docs](./docs) for the BIOS and Wake-on-LAN runbooks, [./BACKUP.md](./BACKUP.md) for the +backup design, and [./AGENTS.md](./AGENTS.md) for the repository conventions. ## Why NixOS & Flakes? diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..ff56d29a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,5 @@ +# Documentation + +- [BIOS settings](./bios.md) +- [Wake-on-LAN](./wake-on-lan.md) +- [NixOS modernization options](./nixos-modernization-options.md) diff --git a/docs/bios.md b/docs/bios.md new file mode 100644 index 00000000..4b2c7ea4 --- /dev/null +++ b/docs/bios.md @@ -0,0 +1,58 @@ +# BIOS Settings + +Manual firmware settings are not managed by Nix. Record values that affect power use, remote wake, +and boot behavior here so they survive BIOS updates and hardware swaps. The related Wake-on-LAN +workflow and verification commands are in [Wake-on-LAN](./wake-on-lan.md). + +Enter firmware setup with `Del` unless a host section says otherwise. + +Last reviewed: 2026-11-02. + +## `12kingdoms` mini PCs + +Hosts: `shoryu` (MoreFine S500+), `shushou` (MinisForum UM560), `youko` (Beelink GTR5). + +### Common settings + +- `Advanced > CPU Configuration > PPC Adjustment (P-State)` = `PState 2` (full-range adjustment + mode). +- `Advanced > AMD CBS > FCH Common Options > AC Power Loss Options`: + - `AC Power Loss` = `Always On` + - `SATA Controller` = `Disabled` +- Disable unused onboard devices where the firmware exposes them (for example `HDD` and WLAN + options); none are in use on these hosts. `youko`'s two 4TB HDDs are behind a USB JMicron JMS567 + enclosure, so disabling the motherboard SATA controller does not affect them. + +### Wake-on-LAN + +The firmware path differs by model: + +| Host | Model | Setting | +| --------- | ---------------- | -------------------------------------- | +| `shoryu` | MoreFine S500+ | `Advanced > Network Stack` = `Enabled` | +| `shushou` | MinisForum UM560 | `Advanced > Resume on LAN` = `Enabled` | +| `youko` | Beelink GTR5 | `Advanced > Network Stack` = `Enabled` | + +The firmware side only exposes the wake path. NixOS arming and verification are documented in +[Wake-on-LAN](./wake-on-lan.md#nixos-configuration). + +## `aquamarine` + +Board: Colorful CVN Z890 ARK FROZEN V20 (BIOS 1013). Settings changed on 2026-11-01. + +| Page | Setting | Value | +| ---------------- | ------------------------ | ---------------------------------------------- | +| Home | Performance mode | Power-saving mode | +| Home | Cooler | Intel default settings (lowers PL1/PL2 limits) | +| Home | HD audio | Disabled | +| Advanced > Power | RGB lighting | Disabled | +| Advanced > Power | Restore on AC power loss | Power on | +| OC > Frequency | Intel 200S Boost | Disabled | +| OC > Frequency | Intel Default Settings | Enabled | + +Wake-on-LAN is also required for the idle-suspend path: + +- `Advanced > Power Setting > Wake By Lan` = `Enabled`. +- EUP must not cut standby power to the NIC. +- NixOS arms the RTL8125BG through `systemd.network.links`; see + [Wake-on-LAN](./wake-on-lan.md#verify). diff --git a/docs/wake-on-lan.md b/docs/wake-on-lan.md new file mode 100644 index 00000000..f651acea --- /dev/null +++ b/docs/wake-on-lan.md @@ -0,0 +1,61 @@ +# Wake-on-LAN + +Wake-on-LAN is available on `aquamarine` and the three `12kingdoms` mini PCs (`shoryu`, `shushou`, +and `youko`). Firmware prerequisites are recorded in [BIOS settings](./bios.md). + +## NixOS configuration + +The three mini PCs share [`lib/genVmHostModule.nix`](../lib/genVmHostModule.nix), which adds a +`systemd.network.links` entry that arms `WakeOnLan = "magic"` on their physical `eno1` NIC after +every boot. `aquamarine` has the equivalent entry for its `en*` NIC in +[`hosts/idols-aquamarine/default.nix`](../hosts/idols-aquamarine/default.nix). + +The MAC addresses used by the wake command live in [`scripts/wake.py`](../scripts/wake.py). + +## Verify + +After a boot, confirm the physical NIC reports `Wake-on: g`: + +| Host | Command | +| ------------ | --------------------------------------------------------- | +| `12kingdoms` | `ethtool eno1 \| grep -E 'Supports Wake-on\|Wake-on'` | +| `aquamarine` | `ethtool enp133s0 \| grep -E 'Supports Wake-on\|Wake-on'` | + +## Wake hosts + +Wake every Wake-on-LAN-capable homelab host from another L2 host: + +```sh +just wake +``` + +Pass one or more host names to wake only those machines; `aqua` is an alias for `aquamarine`: + +```sh +just wake youko +just wake aqua shoryu +``` + +The command retries the magic packet every few seconds and exits when every selected host answers +ICMP **and** accepts an SSH connection. The SSH check prevents a host that is still shutting down or +partway through boot from being mistaken for online. It prints a progress line every five seconds +while waiting. Run `just wake --help` for all options: + +```text +--timeout SECONDS Stop waiting after SECONDS; 0 waits forever (default: 180) +--retry-interval SECONDS Time between magic-packet bursts (default: 5) +--probe-interval SECONDS Time between reachability checks (default: 1) +--progress-interval SECONDS How often to print progress (default: 5) +--packets N Magic packets per broadcast address and burst (default: 3) +``` + +Examples: + +```sh +just wake aqua --timeout 0 +just wake youko --timeout 60 +just wake --retry-interval 2 --progress-interval 2 +``` + +Add a host to the MAC list only after its firmware setting and NixOS link config are in place. Test +wake-up from a powered-off state, not from a reboot. diff --git a/hosts/README.md b/hosts/README.md index 96ae6b45..e3a950db 100644 --- a/hosts/README.md +++ b/hosts/README.md @@ -2,6 +2,9 @@ This directory contains all host-specific configurations for my NixOS and macOS systems. +Manual firmware settings are recorded in [`docs/bios.md`](../docs/bios.md), and the shared +Wake-on-LAN workflow is in [`docs/wake-on-lan.md`](../docs/wake-on-lan.md). + ## Current Host Inventory ### Physical Machines diff --git a/hosts/idols-aquamarine/README.md b/hosts/idols-aquamarine/README.md index 36aa3a28..e29183a7 100644 --- a/hosts/idols-aquamarine/README.md +++ b/hosts/idols-aquamarine/README.md @@ -6,6 +6,9 @@ that name was decommissioned; this is a different, physical machine that reuses ## Hardware +Manual BIOS and power-saving settings are recorded in +[`docs/bios.md`](../../docs/bios.md#aquamarine). + | Part | Model | Notes | | ------ | ----------------------------------------------------- | ------------------------------------------------------- | | Board | Colorful CVN Z890 ARK FROZEN V20 (BIOS 1013) | moved from `ai` on 2026-10-13, so its EFI vars come too | @@ -288,33 +291,14 @@ The install only touches the target disk; the staging host keeps booting from it the disk back, restore the `hosts/idols-ai/disko-fs-data.nix` module this change deleted, and re-enroll that machine's TPM2 slot — see `../idols-ai/README.md`. -## Wake-on-LAN - -BIOS: `Advanced > Power Setting` must have `Wake By Lan` enabled, and EUP must not cut standby power -to the NIC. NixOS arms the RTL8125 through `systemd.network.links` using `OriginalName=en*`. - -After a boot, verify the link reports `Wake-on: g`: - -```bash -ethtool enp133s0 | grep -E 'Supports Wake-on|Wake-on' -``` - -Wake every homelab host with Wake-on-LAN armed from another L2 host: - -```bash -just wake-homelab -``` - -The command currently wakes only aquamarine; add a host to `scripts/wake-homelab.py` after its WoL -link config and BIOS setting are in place. Test from a powered-off state, not from a reboot. - ## Idle suspend `autosuspend` checks every minute and suspends into S3 after 60 minutes without an active user session, meaningful CPU load, or an established connection to one of the request-serving ports: `18080` (ASR), `63081` (llama-swap/Strata), `63083`/`63084` (MagPie), and `65002` (direct Strata). -It calls `systemctl suspend`, so systemd sleep inhibitors are also honored. WoL is armed by the -`systemd.network.links` configuration above; no separate WoL service is needed. +It calls `systemctl suspend`, so systemd sleep inhibitors are also honored. Wake-on-LAN is armed by +the host's `systemd.network.links` configuration; see +[`docs/wake-on-lan.md`](../../docs/wake-on-lan.md). No separate WoL service is needed. The daemon resets its idle timer after resume, so a wake does not immediately go back to sleep. To keep the host awake during maintenance: diff --git a/lib/genVmHostModule.nix b/lib/genVmHostModule.nix index 197439e5..b650d955 100644 --- a/lib/genVmHostModule.nix +++ b/lib/genVmHostModule.nix @@ -80,6 +80,14 @@ in networking.useNetworkd = true; systemd.network.enable = true; + # Arm the physical NIC for Wake-on-LAN after every boot. systemd.link matches + # the kernel-assigned name; `Name=` is not a valid [Match] key. The firmware + # side is recorded in ../docs/bios.md. + systemd.network.links."40-wake-on-lan" = { + matchConfig.OriginalName = iface; + linkConfig.WakeOnLan = "magic"; + }; + # Linux bridge for the VMs: the microVM taps and the libvirt domains attach to # br0, and the host keeps its own address on it (the physical NIC is just a # bridge port). diff --git a/scripts/tests/test_wake.py b/scripts/tests/test_wake.py new file mode 100644 index 00000000..5dd8027d --- /dev/null +++ b/scripts/tests/test_wake.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import io +import unittest + +from scripts.wake import ( + Host, + HostSelectionError, + Reporter, + WakeConfig, + WakeSession, + magic_packet, + resolve_hosts, +) + + +class FakeClock: + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +class RecordingSender: + def __init__(self) -> None: + self.calls: list[tuple[str, int]] = [] + + def __call__(self, host: Host, packets_per_broadcast: int) -> None: + self.calls.append((host.name, packets_per_broadcast)) + + +class SequenceProbe: + def __init__(self, online_on_call: int) -> None: + self._online_on_call = online_on_call + self._calls = 0 + + def __call__(self, _host: Host) -> bool: + self._calls += 1 + return self._calls >= self._online_on_call + + +class MagicPacketTests(unittest.TestCase): + def test_builds_standard_packet(self) -> None: + mac = "8c:32:23:36:81:84" + packet = magic_packet(mac) + + self.assertEqual(len(packet), 102) + self.assertEqual(packet[:6], b"\xff" * 6) + self.assertEqual(packet[6:], bytes.fromhex(mac.replace(":", "")) * 16) + + def test_rejects_invalid_mac(self) -> None: + with self.assertRaises(ValueError): + magic_packet("not-a-mac") + + +class ResolveHostsTests(unittest.TestCase): + def setUp(self) -> None: + self.hosts = { + "alpha": Host("alpha", "00:00:00:00:00:01", "192.0.2.1"), + "beta": Host("beta", "00:00:00:00:00:02", "192.0.2.2"), + } + + def test_defaults_to_all_hosts(self) -> None: + self.assertEqual(resolve_hosts([], hosts=self.hosts), list(self.hosts.values())) + + def test_resolves_alias_and_removes_duplicates(self) -> None: + selected = resolve_hosts( + ["a", "alpha", "beta"], + hosts=self.hosts, + aliases={"a": "alpha"}, + ) + self.assertEqual(selected, [self.hosts["alpha"], self.hosts["beta"]]) + + def test_rejects_unknown_host(self) -> None: + with self.assertRaises(HostSelectionError): + resolve_hosts(["missing"], hosts=self.hosts, aliases={}) + + +class WakeSessionTests(unittest.TestCase): + def test_retries_until_host_is_online(self) -> None: + clock = FakeClock() + sender = RecordingSender() + stdout = io.StringIO() + stderr = io.StringIO() + reporter = Reporter(stream=stdout, error_stream=stderr, clock=clock) + host = Host("test", "00:00:00:00:00:01", "192.0.2.1") + config = WakeConfig( + timeout=30, + retry_interval=5, + probe_interval=1, + progress_interval=5, + packets_per_broadcast=2, + ) + session = WakeSession( + [host], + config, + reporter, + sender=sender, + probe=SequenceProbe(online_on_call=10), + clock=clock, + sleep=clock.sleep, + ) + + self.assertEqual(session.run(), 0) + self.assertEqual(sender.calls, [("test", 2), ("test", 2)]) + output = stdout.getvalue() + self.assertIn("attempt 2", output) + self.assertIn("still waiting for: test (attempt 2, 192.0.2.1)", output) + self.assertIn("test is online", output) + + def test_exits_immediately_when_host_is_already_online(self) -> None: + clock = FakeClock() + sender = RecordingSender() + reporter = Reporter( + stream=io.StringIO(), error_stream=io.StringIO(), clock=clock + ) + host = Host("test", "00:00:00:00:00:01", "192.0.2.1") + session = WakeSession( + [host], + WakeConfig(), + reporter, + sender=sender, + probe=lambda _host: True, + clock=clock, + sleep=clock.sleep, + ) + + self.assertEqual(session.run(), 0) + self.assertEqual(sender.calls, []) + + def test_times_out_when_host_never_comes_online(self) -> None: + clock = FakeClock() + sender = RecordingSender() + stdout = io.StringIO() + stderr = io.StringIO() + reporter = Reporter(stream=stdout, error_stream=stderr, clock=clock) + host = Host("test", "00:00:00:00:00:01", "192.0.2.1") + session = WakeSession( + [host], + WakeConfig(timeout=3, retry_interval=5), + reporter, + sender=sender, + probe=lambda _host: False, + clock=clock, + sleep=clock.sleep, + ) + + self.assertEqual(session.run(), 1) + self.assertIn("timed out waiting for: test", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/wake-homelab.py b/scripts/wake-homelab.py deleted file mode 100755 index 981eb243..00000000 --- a/scripts/wake-homelab.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -"""Wake every homelab host whose NixOS config arms Wake-on-LAN.""" - -from __future__ import annotations - -import socket -import sys - -# Keep this list limited to hosts that actually set Wake-on: g in NixOS. -# Add a host here only after its WoL service and BIOS setting are in place. -HOSTS = { - "aquamarine": "8c:32:23:36:81:84", -} -BROADCASTS = ("192.168.5.255", "255.255.255.255") -PORT = 9 -PACKETS = 3 - - -def magic_packet(mac: str) -> bytes: - octets = bytes.fromhex(mac.replace(":", "")) - if len(octets) != 6: - raise ValueError(f"invalid MAC: {mac}") - return b"\xff" * 6 + octets * 16 - - -def main() -> int: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - failed = False - - for host, mac in HOSTS.items(): - packet = magic_packet(mac) - print(f"waking {host} ({mac})") - for _ in range(PACKETS): - for broadcast in BROADCASTS: - try: - sock.sendto(packet, (broadcast, PORT)) - except OSError as exc: - print(f" {broadcast}:{PORT}: {exc}", file=sys.stderr) - failed = True - - return 1 if failed else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/wake.py b/scripts/wake.py new file mode 100755 index 00000000..768ce31e --- /dev/null +++ b/scripts/wake.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Wake homelab hosts over Wake-on-LAN and wait until they are reachable.""" + +from __future__ import annotations + +import argparse +import socket +import subprocess +import sys +import time +from collections.abc import Callable, Mapping, Sequence +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from typing import Self, TextIO + +BROADCAST_ADDRESSES = ("192.168.5.255", "255.255.255.255") +WOL_PORT = 9 +SSH_PORT = 22 +PING_WAIT_SECONDS = 1 +PING_TIMEOUT_SECONDS = PING_WAIT_SECONDS + 1 +SSH_TIMEOUT_SECONDS = 1.0 + +DEFAULT_TIMEOUT_SECONDS = 180.0 +DEFAULT_RETRY_INTERVAL_SECONDS = 5.0 +DEFAULT_PROBE_INTERVAL_SECONDS = 1.0 +DEFAULT_PROGRESS_INTERVAL_SECONDS = 5.0 +DEFAULT_PACKETS_PER_BROADCAST = 3 + + +@dataclass(frozen=True, slots=True) +class Host: + """A Wake-on-LAN-capable host.""" + + name: str + mac: str + ip: str + + +# Keep this list limited to hosts that actually set Wake-on: g in NixOS. +# Add a host here only after its firmware setting and NixOS link config exist. +HOSTS: Mapping[str, Host] = { + "aquamarine": Host("aquamarine", "8c:32:23:36:81:84", "192.168.5.101"), + "shoryu": Host("shoryu", "70:70:fc:01:1b:56", "192.168.5.181"), + "shushou": Host("shushou", "58:47:ca:70:0d:72", "192.168.5.182"), + "youko": Host("youko", "70:70:fc:01:00:44", "192.168.5.183"), +} + +ALIASES: Mapping[str, str] = { + "aqua": "aquamarine", +} + + +@dataclass(frozen=True, slots=True) +class WakeConfig: + """Timing and packet settings for one wake run.""" + + timeout: float = DEFAULT_TIMEOUT_SECONDS + retry_interval: float = DEFAULT_RETRY_INTERVAL_SECONDS + probe_interval: float = DEFAULT_PROBE_INTERVAL_SECONDS + progress_interval: float = DEFAULT_PROGRESS_INTERVAL_SECONDS + packets_per_broadcast: int = DEFAULT_PACKETS_PER_BROADCAST + + +class HostSelectionError(ValueError): + """Raised when a requested host name is unknown.""" + + +class Reporter: + """Print timestamped progress messages.""" + + def __init__( + self, + *, + stream: TextIO | None = None, + error_stream: TextIO | None = None, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._stream = stream if stream is not None else sys.stdout + self._error_stream = error_stream if error_stream is not None else sys.stderr + self._clock = clock + self._started_at = clock() + + def elapsed(self) -> float: + return self._clock() - self._started_at + + def info(self, message: str) -> None: + print(f"[{self.elapsed():5.1f}s] {message}", file=self._stream, flush=True) + + def error(self, message: str) -> None: + print( + f"[{self.elapsed():5.1f}s] {message}", file=self._error_stream, flush=True + ) + + +class MagicPacketSender: + """Send Wake-on-LAN magic packets over a UDP broadcast socket.""" + + def __init__( + self, + *, + broadcasts: Sequence[str] = BROADCAST_ADDRESSES, + port: int = WOL_PORT, + ) -> None: + self._broadcasts = tuple(broadcasts) + self._port = port + self._socket: socket.socket | None = None + + def __enter__(self) -> Self: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + return self + + def __exit__(self, *_exc_info: object) -> None: + if self._socket is not None: + self._socket.close() + self._socket = None + + def send(self, host: Host, packets_per_broadcast: int) -> None: + """Send one magic-packet burst.""" + if self._socket is None: + raise RuntimeError("sender must be used as a context manager") + + packet = magic_packet(host.mac) + for _ in range(packets_per_broadcast): + for broadcast in self._broadcasts: + self._socket.sendto(packet, (broadcast, self._port)) + + +@dataclass(slots=True) +class _HostState: + host: Host + attempts: int = 0 + last_sent_at: float = 0.0 + + +class WakeSession: + """Retry magic packets until all hosts are online or the timeout expires.""" + + def __init__( + self, + hosts: Sequence[Host], + config: WakeConfig, + reporter: Reporter, + *, + sender: Callable[[Host, int], None], + probe: Callable[[Host], bool] | None = None, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self._states = {host.name: _HostState(host) for host in hosts} + self._config = config + self._reporter = reporter + self._sender = sender + self._probe = probe or host_online + self._clock = clock + self._sleep = sleep + self._send_failed = False + self._last_progress_at = 0.0 + + def run(self) -> int: + self._probe_pending(initial=True) + if not self._states: + return 0 + + for name in sorted(self._states): + self._send(self._states[name]) + self._reporter.info( + f"waking {self._describe_pending()}; retrying every " + f"{self._config.retry_interval:g}s until online" + ) + self._last_progress_at = self._clock() + + while self._states: + if self._timed_out(): + names = ", ".join(sorted(self._states)) + self._reporter.error(f"timed out waiting for: {names}") + return 1 + + self._probe_pending(initial=False) + if not self._states: + break + + self._retry_pending() + self._report_progress() + self._sleep(self._config.probe_interval) + + return 1 if self._send_failed else 0 + + def _timed_out(self) -> bool: + return ( + self._config.timeout > 0 + and self._reporter.elapsed() >= self._config.timeout + ) + + def _probe_pending(self, *, initial: bool) -> None: + states = list(self._states.values()) + if not states: + return + + with ThreadPoolExecutor(max_workers=len(states)) as executor: + futures = { + executor.submit(self._probe, state.host): state for state in states + } + for future in as_completed(futures): + state = futures[future] + if not future.result(): + continue + + self._states.pop(state.host.name, None) + if initial: + self._reporter.info(f"{state.host.name} is already online") + else: + self._reporter.info( + f"{state.host.name} is online after {state.attempts} magic-packet bursts" + ) + + def _send(self, state: _HostState) -> None: + state.attempts += 1 + try: + self._sender(state.host, self._config.packets_per_broadcast) + except OSError as exc: + self._send_failed = True + self._reporter.error( + f"failed to send magic packets to {state.host.name}: {exc}" + ) + return + + state.last_sent_at = self._clock() + + def _retry_pending(self) -> None: + now = self._clock() + for name in sorted(self._states): + state = self._states[name] + if now - state.last_sent_at >= self._config.retry_interval: + self._send(state) + + def _report_progress(self) -> None: + now = self._clock() + if now - self._last_progress_at < self._config.progress_interval: + return + + self._reporter.info(f"still waiting for: {self._describe_pending()}") + self._last_progress_at = now + + def _describe_pending(self) -> str: + return ", ".join( + f"{name} (attempt {state.attempts}, {state.host.ip})" + for name, state in sorted(self._states.items()) + ) + + +def magic_packet(mac: str) -> bytes: + """Build the standard 102-byte Wake-on-LAN magic packet.""" + try: + mac_bytes = bytes.fromhex(mac.replace(":", "")) + except ValueError as exc: + raise ValueError(f"invalid MAC address: {mac!r}") from exc + + if len(mac_bytes) != 6: + raise ValueError(f"invalid MAC address: {mac!r}") + + return b"\xff" * 6 + mac_bytes * 16 + + +def resolve_hosts( + names: Sequence[str], + *, + hosts: Mapping[str, Host] = HOSTS, + aliases: Mapping[str, str] = ALIASES, +) -> list[Host]: + """Resolve CLI host names and aliases, preserving order and removing duplicates.""" + if not names: + return list(hosts.values()) + + selected: dict[str, Host] = {} + for requested_name in names: + canonical_name = aliases.get(requested_name, requested_name) + try: + host = hosts[canonical_name] + except KeyError as exc: + known = ", ".join(sorted(hosts)) + alias_help = ", ".join( + f"{alias} -> {target}" for alias, target in aliases.items() + ) + raise HostSelectionError( + f"unknown host {requested_name!r}; known hosts: {known}; aliases: {alias_help}" + ) from exc + selected[host.name] = host + + return list(selected.values()) + + +def host_online(host: Host) -> bool: + """Return whether a host answers ICMP and has reached a usable SSH state. + + Requiring SSH prevents a host that is still shutting down, or partway through boot, from being + mistaken for online just because it still answers ping. + """ + return _ping(host.ip) and _ssh_port_open(host.ip) + + +def _ping(ip: str) -> bool: + try: + result = subprocess.run( + ["ping", "-c", "1", "-W", str(PING_WAIT_SECONDS), ip], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=PING_TIMEOUT_SECONDS + 1, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def _ssh_port_open(ip: str) -> bool: + try: + with socket.create_connection((ip, SSH_PORT), timeout=SSH_TIMEOUT_SECONDS): + return True + except OSError: + return False + + +def _positive_float(value: str) -> float: + number = float(value) + if number <= 0: + raise argparse.ArgumentTypeError("must be greater than zero") + return number + + +def _non_negative_float(value: str) -> float: + number = float(value) + if number < 0: + raise argparse.ArgumentTypeError("must be zero or greater") + return number + + +def _positive_int(value: str) -> int: + number = int(value) + if number <= 0: + raise argparse.ArgumentTypeError("must be greater than zero") + return number + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="just wake", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=( + "Wake selected homelab hosts, keep retrying the magic packet, and exit when every " + "selected host answers. With no host arguments, wake every WoL-capable host." + ), + epilog=( + "Examples:\n" + " just wake\n" + " just wake aqua youko\n" + " just wake shoryu --timeout 0 --progress-interval 2" + ), + ) + parser.add_argument( + "hosts", + nargs="*", + metavar="HOST", + help="hosts to wake (default: all); use 'aqua' as an alias for aquamarine", + ) + parser.add_argument( + "--timeout", + type=_non_negative_float, + default=DEFAULT_TIMEOUT_SECONDS, + metavar="SECONDS", + help="stop waiting after this many seconds (default: 180; 0 waits forever)", + ) + parser.add_argument( + "--retry-interval", + type=_positive_float, + default=DEFAULT_RETRY_INTERVAL_SECONDS, + metavar="SECONDS", + help="time between magic-packet bursts for hosts still offline (default: 5)", + ) + parser.add_argument( + "--probe-interval", + type=_positive_float, + default=DEFAULT_PROBE_INTERVAL_SECONDS, + metavar="SECONDS", + help="time between reachability checks (default: 1)", + ) + parser.add_argument( + "--progress-interval", + type=_positive_float, + default=DEFAULT_PROGRESS_INTERVAL_SECONDS, + metavar="SECONDS", + help="how often to print a waiting-for progress line (default: 5)", + ) + parser.add_argument( + "--packets", + type=_positive_int, + default=DEFAULT_PACKETS_PER_BROADCAST, + metavar="N", + help="magic packets per broadcast address and burst (default: 3)", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + hosts = resolve_hosts(args.hosts) + except HostSelectionError as exc: + parser.error(str(exc)) + + config = WakeConfig( + timeout=args.timeout, + retry_interval=args.retry_interval, + probe_interval=args.probe_interval, + progress_interval=args.progress_interval, + packets_per_broadcast=args.packets, + ) + + reporter = Reporter() + with MagicPacketSender() as sender: + return WakeSession(hosts, config, reporter, sender=sender.send).run() + + +if __name__ == "__main__": + sys.exit(main())