From e25e7a99ec08ce039b619e29225d5b48d6f71a6f Mon Sep 17 00:00:00 2001 From: Juan Li Puma Date: Mon, 7 Sep 2026 12:57:51 -0700 Subject: [PATCH 1/2] feat: add reset_state_on_resume option When wolnut starts and finds `ups_on_battery` set in the state file, it logs "resuming from a UPS battery event" and immediately calls `ClientStateTracker.reset()`, which clears `was_online_before_battery` for every client. The restoration branch then skips every client with "was not online before power loss", so no WOL packet is ever sent. That only matters when wolnut's own host was shut down by the outage -- exactly the case where the state file is the only surviving record of who was online. When wolnut stays up through the outage the in-memory path is used and the wipe is harmless, which is why this has gone unnoticed. Make the wipe configurable via a new top-level `reset_state_on_resume` boolean. It defaults to `true`, which is byte-for-byte the current behaviour, so upgrading without touching the config changes nothing. When set to `false`, the loaded state is kept and `on_battery` is set instead: the main loop then takes the normal power restoration branch on the first "OL" reading with `was_online_before_battery` intact, and does not re-snapshot an offline fleet if the UPS is still reporting "OB". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KTXBZgVFTjfbQd1Npk8Sgf --- tests/test_cli.py | 151 +++++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 38 +++++++++++ wolnut/cli.py | 15 ++++- wolnut/config.py | 12 ++++ 4 files changed, 214 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 12e7ff5..666bfc9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,8 @@ from wolnut import main from wolnut.cli import wolnut, get_battery_percent +from wolnut.config import ClientConfig, NutConfig, WakeOnConfig, WolnutConfig +from wolnut.state import ClientStateTracker @pytest.fixture @@ -10,6 +12,155 @@ def runner(): return CliRunner() +class LoopBreaker(Exception): + """Raised from the patched time.sleep to break out of main()'s infinite loop.""" + + +CLIENT = ClientConfig(name="client-1", host="192.168.1.10", mac="DE:AD:BE:EF:00:01") + + +def _make_config(status_file: str, reset_state_on_resume: bool) -> WolnutConfig: + """Builds a config whose wake_on thresholds never delay a WOL attempt.""" + return WolnutConfig( + nut=NutConfig(ups="ups@localhost"), + status_file=status_file, + poll_interval=1, + wake_on=WakeOnConfig( + restore_delay_sec=0, + min_battery_percent=0, + client_timeout_sec=600, + reattempt_delay=0, + ), + clients=[CLIENT], + reset_state_on_resume=reset_state_on_resume, + ) + + +def _write_post_outage_state(status_file: str): + """ + Writes the state file exactly as wolnut would have left it if it was killed by + a power outage: the client was online, got snapshotted when the UPS went to + battery, and then went offline. + """ + tracker = ClientStateTracker([CLIENT], status_file=status_file) + tracker.update(CLIENT.name, True) + tracker.mark_all_online_clients() + tracker.set_ups_on_battery(True, 55) + tracker.update(CLIENT.name, False) + tracker.save_state() + + # Sanity check: the state we just persisted is what a fresh process would load. + reloaded = ClientStateTracker([CLIENT], status_file=status_file) + assert reloaded.was_ups_on_battery() + assert reloaded.was_online_before_shutdown(CLIENT.name) + + +def _run_main_once(mocker, tmp_path, reset_state_on_resume: bool): + """ + Simulates wolnut restarting on a freshly-booted host after an outage, with the + UPS already back on line power, and returns the send_wol_packet mock. + """ + status_file = str(tmp_path / "wolnut_state.json") + _write_post_outage_state(status_file) + + mocker.patch( + "wolnut.cli.load_config", + return_value=_make_config(status_file, reset_state_on_resume), + ) + mocker.patch( + "wolnut.cli.get_ups_status", + return_value={"ups.status": "OL", "battery.charge": "100"}, + ) + mocker.patch("wolnut.cli.is_client_online", return_value=False) + mock_send_wol = mocker.patch("wolnut.cli.send_wol_packet", return_value=True) + # Break out of the main loop at the end of the first iteration. + mocker.patch("wolnut.cli.time.sleep", side_effect=LoopBreaker) + + with pytest.raises(LoopBreaker): + main("dummy.yaml", status_file, False) + + return mock_send_wol + + +def test_resume_with_reset_state_on_resume_true_sends_no_wol(mocker, tmp_path): + """ + Default behaviour (unchanged): the saved state is discarded on resume, so no + client is considered to have been online before the outage and nothing is woken. + """ + mock_send_wol = _run_main_once(mocker, tmp_path, reset_state_on_resume=True) + + mock_send_wol.assert_not_called() + + +def test_resume_with_reset_state_on_resume_false_sends_wol(mocker, tmp_path): + """ + Regression test: with reset_state_on_resume disabled, a client that was online + before the outage is still eligible for WOL after wolnut restarts. + """ + mock_send_wol = _run_main_once(mocker, tmp_path, reset_state_on_resume=False) + + mock_send_wol.assert_called_once_with(CLIENT.mac) + + +def test_resume_with_reset_state_on_resume_false_does_not_reset_tracker( + mocker, tmp_path +): + """The loaded state must survive startup untouched when the option is disabled.""" + status_file = str(tmp_path / "wolnut_state.json") + _write_post_outage_state(status_file) + + mocker.patch( + "wolnut.cli.load_config", + return_value=_make_config(status_file, reset_state_on_resume=False), + ) + mocker.patch( + "wolnut.cli.get_ups_status", + return_value={"ups.status": "OL", "battery.charge": "100"}, + ) + mocker.patch("wolnut.cli.is_client_online", return_value=False) + mocker.patch("wolnut.cli.send_wol_packet", return_value=True) + mock_reset = mocker.patch.object(ClientStateTracker, "reset") + mocker.patch("wolnut.cli.time.sleep", side_effect=LoopBreaker) + + with pytest.raises(LoopBreaker): + main("dummy.yaml", status_file, False) + + mock_reset.assert_not_called() + + +def test_resume_while_still_on_battery_does_not_resnapshot(mocker, tmp_path): + """ + If the host boots back up while the UPS is still on battery, the preserved + 'was online before the outage' flags must not be overwritten with the current + (all offline) state. + """ + status_file = str(tmp_path / "wolnut_state.json") + _write_post_outage_state(status_file) + + mocker.patch( + "wolnut.cli.load_config", + return_value=_make_config(status_file, reset_state_on_resume=False), + ) + mocker.patch( + "wolnut.cli.get_ups_status", + return_value={"ups.status": "OB DISCHRG", "battery.charge": "40"}, + ) + mocker.patch("wolnut.cli.is_client_online", return_value=False) + mock_send_wol = mocker.patch("wolnut.cli.send_wol_packet", return_value=True) + mock_snapshot = mocker.patch.object(ClientStateTracker, "mark_all_online_clients") + mocker.patch("wolnut.cli.time.sleep", side_effect=LoopBreaker) + + with pytest.raises(LoopBreaker): + main("dummy.yaml", status_file, False) + + mock_snapshot.assert_not_called() + mock_send_wol.assert_not_called() + + # The persisted state still remembers the client was online before the outage. + reloaded = ClientStateTracker([CLIENT], status_file=status_file) + assert reloaded.was_online_before_shutdown(CLIENT.name) + + def test_get_battery_percent(): """Tests the battery percentage parsing function.""" assert get_battery_percent({"battery.charge": "95.5"}) == 96 diff --git a/tests/test_config.py b/tests/test_config.py index 67d1446..9edcdb8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -91,6 +91,44 @@ def test_load_config_full(mocker, full_config_dict): mock_resolve_mac.assert_called_once_with("server.local") +def test_reset_state_on_resume_defaults_to_true(mocker, minimal_config_dict): + """An absent 'reset_state_on_resume' key must preserve the historical behaviour.""" + assert "reset_state_on_resume" not in minimal_config_dict + mocker.patch( + "builtins.open", mocker.mock_open(read_data=yaml.dump(minimal_config_dict)) + ) + mocker.patch("wolnut.config.validate_config") + mocker.patch("wolnut.config.resolve_mac_from_host") + + cfg = config.load_config("dummy.yaml", None, False) + + assert cfg.reset_state_on_resume is True + + +@pytest.mark.parametrize("value", [True, False]) +def test_reset_state_on_resume_explicit(mocker, minimal_config_dict, value): + """An explicit 'reset_state_on_resume' value is carried through to the config.""" + minimal_config_dict["reset_state_on_resume"] = value + mocker.patch( + "builtins.open", mocker.mock_open(read_data=yaml.dump(minimal_config_dict)) + ) + mocker.patch("wolnut.config.validate_config") + mocker.patch("wolnut.config.resolve_mac_from_host") + + cfg = config.load_config("dummy.yaml", None, False) + + assert cfg.reset_state_on_resume is value + + +def test_validate_config_rejects_non_boolean_reset_state_on_resume( + minimal_config_dict, +): + """'reset_state_on_resume' must be a boolean when present.""" + minimal_config_dict["reset_state_on_resume"] = "yes please" + with pytest.raises(ValueError, match="'reset_state_on_resume' must be a boolean"): + config.validate_config(minimal_config_dict) + + def test_load_config_file_not_found(mocker): """Tests that None is returned when the config file is not found.""" mocker.patch("builtins.open", side_effect=FileNotFoundError) diff --git a/wolnut/cli.py b/wolnut/cli.py index 53a0196..d7eb6af 100644 --- a/wolnut/cli.py +++ b/wolnut/cli.py @@ -43,8 +43,19 @@ def main(config_file: str, status_file: str, verbose: bool = False) -> int: state_tracker = ClientStateTracker(config.clients, status_file=config.status_file) if state_tracker.was_ups_on_battery(): logger.info("WOLNUT is resuming from a UPS battery event") - restoration_event = True - state_tracker.reset() + if config.reset_state_on_resume: + restoration_event = True + state_tracker.reset() + else: + # Keep the persisted state so we still know which clients were online + # before the outage. Pretending we are still on battery makes the main + # loop take the normal power restoration branch as soon as the UPS + # reports "OL", without re-snapshotting an offline fleet while it is + # still reporting "OB". + logger.info( + "Preserving saved client state (reset_state_on_resume is disabled)" + ) + on_battery = True ups_status = get_ups_status(config.nut.ups) battery_percent = get_battery_percent(ups_status) diff --git a/wolnut/config.py b/wolnut/config.py index bb5f604..6cdc603 100644 --- a/wolnut/config.py +++ b/wolnut/config.py @@ -12,6 +12,9 @@ DEFAULT_CONFIG_FILEPATHS = ["/config/config.yaml", "./config.yaml"] DEFAULT_LOG_LEVEL = "INFO" +# Historical behaviour: discard the persisted client state when resuming from a +# UPS battery event. See docs/configuration.md for why you may want to disable it. +DEFAULT_RESET_STATE_ON_RESUME = True @dataclass @@ -46,6 +49,7 @@ class WolnutConfig: wake_on: WakeOnConfig = field(default_factory=WakeOnConfig) clients: list[ClientConfig] = field(default_factory=list) log_level: str = "INFO" + reset_state_on_resume: bool = DEFAULT_RESET_STATE_ON_RESUME def find_state_file(state_file: Optional[str] = None) -> str: @@ -118,6 +122,9 @@ def load_config( clients=clients, log_level=raw.get("log_level", DEFAULT_LOG_LEVEL).upper(), status_file=final_status_path, + reset_state_on_resume=raw.get( + "reset_state_on_resume", DEFAULT_RESET_STATE_ON_RESUME + ), ) logger.info("Config Imported Successfully") for client in wolnut_config.clients: @@ -136,6 +143,11 @@ def validate_config(raw: dict): if "status_file" not in raw: logger.warning("No 'status_file' specified in config, using default.") + if "reset_state_on_resume" in raw and not isinstance( + raw["reset_state_on_resume"], bool + ): + raise ValueError("'reset_state_on_resume' must be a boolean") + for i, client in enumerate(raw["clients"]): if "name" not in client: raise ValueError(f"Client #{i} is missing required field: 'name'") From aa0cbdef07362dedf1c14a089e9e10e14f4f3cb7 Mon Sep 17 00:00:00 2001 From: Juan Li Puma Date: Mon, 7 Sep 2026 12:57:57 -0700 Subject: [PATCH 2/2] docs: document reset_state_on_resume Add the new top-level option to the configuration guide and the example config, including when you would want to turn it off. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KTXBZgVFTjfbQd1Npk8Sgf --- config.example.yaml | 3 +++ docs/configuration.md | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index be3ae2b..2d1da6c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,6 +8,9 @@ nut: poll_interval: 15 # Poll interval in seconds — should be shorter than the NUT shutdown delay on any client status_file: "/config/wolnut_state.json" # Path to status file, recommended you change this to be outside container if using Docker +reset_state_on_resume: true # true (default) discards the saved client state when wolnut restarts during a UPS battery event. + # Set to false if wolnut's own host is shut down by the outage, so it can still wake the clients + # that were online before power was lost. See docs/configuration.md wake_on: restore_delay_sec: 30 # Delay (in seconds) after power is restored before sending WOL diff --git a/docs/configuration.md b/docs/configuration.md index 0351e22..43168a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,6 +28,17 @@ The file path where `wolnut` will store its state. This allows the service to re - **Type**: `string` - **Default**: `"/config/wolnut_state.json"` +### `reset_state_on_resume` + +Controls what `wolnut` does with the state persisted in `status_file` when it starts up and finds that the UPS was on battery the last time it ran — in other words, when `wolnut` itself was restarted during an outage. + +- **Type**: `boolean` +- **Default**: `true` + +When `true` (the default, and the historical behaviour), the saved per-client state is cleared on startup. `wolnut` will then only wake clients whose "was online before the outage" status it observed itself, so it will *not* send WOL packets for an outage it did not stay running through. + +Set it to `false` if `wolnut` runs on a machine that is itself shut down by the outage. In that case the saved state is the only record of which clients were online before power was lost, so keeping it is what allows `wolnut` to wake them once it boots back up. `wolnut` resumes as if the UPS were still on battery and takes the normal power restoration path as soon as the UPS reports `OL` again. + --- ## `nut`