From f5eb7fc2bd412c95b65c55263b1cfe17214d1b65 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 27 Aug 2026 21:00:11 +0200 Subject: [PATCH] Phase 7: curtains open on the alarm, no-op until hardware exists (item 5) _curtains.py: a two-method driver protocol, a registry, and a logging no-op default. prepare_hardware() calls open_curtains() next to turn_on_plug(). The no-op is not a placeholder awaiting hardware -- it is the correct behaviour on a machine with no motor. Shipping it now means the call site and the JSONL event are real, so buying a motor later is a config file rather than a code change. Shaped like _smart_plug.py, which solves the same problem: absent or malformed config means "no curtains" rather than an error, an unknown driver name degrades with a WARNING, and a driver that throws is caught -- a jammed motor must never be the reason nobody is woken. The call sits on the startup path, ahead of the first beep, so an exception escaping it would take the alarm down entirely. Why bother at all with no hardware: the audio failsafe silences the alarm at FAILSAFE_SILENCE_SECONDS, and daylight is the one wake cue that outlives it -- and the only one independent of a sink, a speaker or an HDMI handshake, which is the exact chain that made this alarm silent in the first place. DOCS-curtains-hardware.md has the buy list, the three rules a new driver must follow, and what is deliberately excluded (no cloud, no scheduling of its own, no close-at-night -- closing belongs to the night lockdown, item 13). CURTAINS_CONFIG_FILE and CURTAINS_DEFAULT_DRIVER live in _curtains.py rather than _constants.py, since this is their only consumer -- the same call _rounds.py and _alarm_session.py already make. That also keeps _constants.py under the 250-line cap without dragging _sync.py's pre-existing overage into this commit; splitting _sync.py belongs to the phase that legitimately touches it (item 8). 465 tests pass, 100% branch coverage. vmbox harness 8/8, exit 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh --- DOCS-curtains-hardware.md | 85 +++++++++++++++ wake_alarm/_curtains.py | 166 ++++++++++++++++++++++++++++++ wake_alarm/_startup.py | 7 +- wake_alarm/tests/test_curtains.py | 118 +++++++++++++++++++++ 4 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 DOCS-curtains-hardware.md create mode 100644 wake_alarm/_curtains.py create mode 100644 wake_alarm/tests/test_curtains.py diff --git a/DOCS-curtains-hardware.md b/DOCS-curtains-hardware.md new file mode 100644 index 0000000..10e29ad --- /dev/null +++ b/DOCS-curtains-hardware.md @@ -0,0 +1,85 @@ +# Curtains: what to buy, and what the code already does + +Item 5 of the hardening plan — "sun curtains open automatically on alarm +trigger". The **software is shipped and wired**; there is no hardware yet. The +alarm already calls `open_curtains()` on its startup path, next to +`turn_on_plug()`, and the default driver logs what it would have done. + +So buying a motor is a config file, not a code change: + +```bash +cat > ~/.config/wake_alarm/curtains.json <<'JSON' +{"driver": "noop"} +JSON +``` + +Absent file, malformed file, or an unknown driver name all fall back to the +no-op with a WARNING. A driver that throws is caught and the alarm rings on — +a jammed motor must never be a reason not to be woken. + +## Why curtains are worth having at all + +The audio failsafe silences the alarm after `FAILSAFE_SILENCE_SECONDS` +(300 s). Daylight is the one wake cue that keeps working after that, and the +only one that does not depend on a speaker, a sink, or a working HDMI +handshake — the exact chain that made this alarm silent in the first place. +It is also the cue that keeps working while you are in the other room solving +the phone's challenge. + +## Adding a driver + +`_curtains.py` has a two-method `CurtainDriver` protocol (`open`, `close`) and +a `_DRIVERS` registry. A new motor is one class plus one registry entry. + +Three rules, all of which the existing no-op and `_smart_plug.py` follow: + +1. **Import the dependency inside the factory, guarded.** The service runs + `/usr/bin/python`, not a venv, so a library that failed to install must + degrade to the no-op rather than kill the alarm process. +2. **Return, never raise.** `open_curtains()` catches, but a driver that + blocks forever cannot be caught — give every network call a timeout, the + way `TAPO_TIMEOUT_SECONDS` does. +3. **Never block the alarm.** The call is on the startup path, ahead of the + first beep. + +## Hardware options + +Nothing here is bought or tested — this is a shortlist, not a recommendation +from use. Ordered by how well each fits the constraints above. + +### 1. Retrofit chain/rod motor (best fit) + +A motor that clamps onto the existing rail, so no new curtains and no +landlord conversation. Look for: + +- **Local control** — an HTTP or MQTT endpoint on the LAN. A cloud-only + device makes a 07:00 wake dependent on someone else's uptime, which is + precisely the dependency `_sync.py` already works hard to bound. +- **A physical manual override**, so a dead motor is not a dark room. +- **Mains power, not battery.** A battery that dies quietly gives you a + failure with no symptom until the morning it matters. + +Zigbee/Z-Wave models need a hub; a Wi-Fi model with a local API needs none +and is the simpler driver to write. + +### 2. Tapo/Kasa-driven mains switch (cheapest path) + +If a curtain motor with a hard switch is already in place, a second smart plug +would work through `python-kasa`, which this repo already depends on. Cheapest +by far, but it only works for a motor that opens on power-up — most do not. + +### 3. Nothing (the current state) + +Genuinely fine. The no-op driver is not a stub awaiting replacement; it is the +correct behaviour for a machine with no motor, and it keeps the log event and +the call site real so nothing has to be rediscovered later. + +## What is deliberately not here + +- **No cloud integrations.** See the local-control point above. +- **No scheduling of its own.** The alarm decides when morning is; a curtain + motor with its own timer would be a second source of truth, and the whole + design of this repo is that the phone is the only one. +- **No "close at night" automation.** `CurtainDriver.close()` exists so a + driver has both ends implemented, but nothing calls it yet. Closing belongs + to the night lockdown (item 13), not to the alarm. diff --git a/wake_alarm/_curtains.py b/wake_alarm/_curtains.py new file mode 100644 index 0000000..c3b8661 --- /dev/null +++ b/wake_alarm/_curtains.py @@ -0,0 +1,166 @@ +"""Open the curtains when the alarm fires, if any hardware is configured. + +Item 5. There is **no curtain hardware yet**, so the default driver is a +logging no-op: it records what it would have done and returns. That is the +whole point of shipping this now rather than waiting for a motor — the alarm +sequence gains its call site, the JSONL log gains its event, and buying +hardware later is a config file, not a code change. + +Deliberately shaped like :mod:`wake_alarm._smart_plug`, which solves the same +problem (optional hardware that must never delay or break the alarm): + +* absent or malformed config means "no curtains", never an error; +* every failure is caught and logged, because a stuck motor must not stop the + alarm from ringing; +* a driver's dependency is import-guarded, so an uninstalled library degrades + to the no-op instead of killing the alarm process. + +Config lives at ``~/.config/wake_alarm/curtains.json``:: + + {"driver": "noop"} + +``DOCS-curtains-hardware.md`` has the buy list and what each driver needs. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Callable + +_logger = logging.getLogger(__name__) + +# Kept here rather than in _constants.py because this module is their only +# consumer -- the same call the repo already makes for _rounds.py's timing +# constants and _alarm_session.py's state names. +CURTAINS_CONFIG_FILE = Path.home() / ".config" / "wake_alarm" / "curtains.json" +"""Which motor to drive, if any. Absent => the no-op driver.""" + +CURTAINS_DEFAULT_DRIVER = "noop" +"""Used when nothing is configured, and when a config names an unknown one.""" + + +class CurtainDriver(Protocol): + """What any curtain implementation has to provide. + + Two methods, both of which must return rather than raise: the alarm calls + them on its startup path, and a driver that throws would take the whole + alarm down with it. + """ + + def open(self) -> bool: + """Open the curtains. Returns True when the hardware acknowledged.""" + + def close(self) -> bool: + """Close the curtains. Returns True when the hardware acknowledged.""" + + +class NoOpCurtains: + """The default: log the intent, touch nothing. + + Not a placeholder to be replaced later so much as the correct behaviour on + a machine with no curtain motor. It keeps the call site and the log event + real, so the day hardware arrives the only change is a config file. + """ + + name = "noop" + + def open(self) -> bool: + """Record that the curtains would have opened.""" + _logger.info( + "Curtains: would open now (no hardware configured)", + extra={"event": "curtains_open", "driver": self.name, "real": False}, + ) + return True + + def close(self) -> bool: + """Record that the curtains would have closed.""" + _logger.info( + "Curtains: would close now (no hardware configured)", + extra={"event": "curtains_close", "driver": self.name, "real": False}, + ) + return True + + +# Driver registry. A new motor means adding one entry and one class, with its +# dependency import-guarded inside the factory so an uninstalled library +# degrades to the no-op rather than killing the alarm. +_DRIVERS: dict[str, Callable[[], CurtainDriver]] = { + NoOpCurtains.name: NoOpCurtains, +} + + +def _load_config() -> dict[str, object] | None: + """Return the curtain config, or None when there is none to read. + + Returns: + The parsed config dict, or None when the file is absent, unreadable, + or not a JSON object. Every one of those means "no curtains", which is + the correct state on a machine that has none. + """ + if not CURTAINS_CONFIG_FILE.exists(): + return None + try: + with CURTAINS_CONFIG_FILE.open() as handle: + config = json.load(handle) + except (OSError, json.JSONDecodeError): + _logger.warning( + "Cannot read %s; falling back to the no-op curtain driver", + CURTAINS_CONFIG_FILE, + ) + return None + if not isinstance(config, dict): + _logger.warning("Curtain config is not a JSON object; using the no-op driver") + return None + return config + + +def get_driver() -> CurtainDriver: + """Build the configured curtain driver, or the no-op when none applies. + + Returns: + A driver honouring :class:`CurtainDriver`. Never None and never + raising: the alarm calls this on its startup path. + """ + config = _load_config() + name = CURTAINS_DEFAULT_DRIVER + if config is not None: + requested = config.get("driver") + if isinstance(requested, str): + name = requested + factory = _DRIVERS.get(name) + if factory is None: + _logger.warning( + "Unknown curtain driver %r; using %s. Known drivers: %s", + name, + CURTAINS_DEFAULT_DRIVER, + ", ".join(sorted(_DRIVERS)), + ) + factory = _DRIVERS[CURTAINS_DEFAULT_DRIVER] + return factory() + + +def open_curtains() -> bool: + """Open the curtains as part of the alarm's wake sequence. + + Sunlight is the one wake cue that keeps working after the sound stops at + ``FAILSAFE_SILENCE_SECONDS``, which is why this is worth a call site even + with no motor attached yet. + + Returns: + True when a driver reported success. A failure is logged and + swallowed — a jammed curtain must never stop the alarm ringing. + """ + try: + return get_driver().open() + except (OSError, RuntimeError, ValueError): + _logger.warning( + "Curtain driver failed to open; the alarm continues", + exc_info=True, + extra={"event": "curtains_failed"}, + ) + return False diff --git a/wake_alarm/_startup.py b/wake_alarm/_startup.py index bbc582e..dfb0bd5 100644 --- a/wake_alarm/_startup.py +++ b/wake_alarm/_startup.py @@ -5,7 +5,7 @@ Split out of ``_alarm.py`` for the 250-line cap. Nothing here blocks waiting for the monitor. It powers things on and returns; the beep loop's per-cycle sink check and ``_reactivate``'s retry schedule pick up outputs as they enumerate, which is a real presence check rather than a -timed guess. +timed guess. The curtains are the same shape: fire and forget, never block. """ from __future__ import annotations @@ -17,6 +17,7 @@ import time from wake_alarm._alarm_display import _wake_display from wake_alarm._brightness import _set_max_brightness from wake_alarm._constants import PLUG_PREROLL_SECONDS +from wake_alarm._curtains import open_curtains from wake_alarm._sinks import _warn_if_no_real_sink from wake_alarm._smart_plug import turn_on_plug @@ -39,6 +40,10 @@ def prepare_hardware(*, sleep: _Sleep = time.sleep) -> None: # so firing the plug and the first tone together loses the opening of the # alarm entirely. Costs nothing when no plug is configured. turn_on_plug() + # Daylight is the one wake cue that outlives the audio failsafe: the sound + # stops at FAILSAFE_SILENCE_SECONDS, an open curtain does not. No-op until + # hardware exists, but the call site and the log event are real now. + open_curtains() _logger.info( "Waiting %.1fs for speaker soft-start before the first beep", PLUG_PREROLL_SECONDS, diff --git a/wake_alarm/tests/test_curtains.py b/wake_alarm/tests/test_curtains.py new file mode 100644 index 0000000..f778a12 --- /dev/null +++ b/wake_alarm/tests/test_curtains.py @@ -0,0 +1,118 @@ +"""Tests for the curtain driver the alarm calls on its wake sequence.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from wake_alarm import _curtains +from wake_alarm._curtains import NoOpCurtains, get_driver, open_curtains + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture(autouse=True) +def config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point the config at a temporary path, never the real one.""" + path = tmp_path / "curtains.json" + monkeypatch.setattr(_curtains, "CURTAINS_CONFIG_FILE", path) + return path + + +def _write(path: Path, payload: object) -> None: + """Write *payload* as the curtain config.""" + path.write_text(json.dumps(payload)) + + +class TestNoOpDriver: + """The default driver on a machine with no curtain motor.""" + + def test_open_reports_success(self, caplog: pytest.LogCaptureFixture) -> None: + """Logging the intent counts as success — there is nothing to fail.""" + with caplog.at_level("INFO"): + assert NoOpCurtains().open() is True + assert "would open" in caplog.text + + def test_close_reports_success(self, caplog: pytest.LogCaptureFixture) -> None: + """The close half exists so a driver swap has both ends to implement.""" + with caplog.at_level("INFO"): + assert NoOpCurtains().close() is True + assert "would close" in caplog.text + + +class TestDriverSelection: + """Choosing a driver from config, and every way that can go wrong.""" + + def test_no_config_means_the_noop(self) -> None: + """The ordinary case today: no file, no hardware, no error.""" + assert isinstance(get_driver(), NoOpCurtains) + + def test_explicit_noop(self, config_file: Path) -> None: + """Naming the default explicitly works too.""" + _write(config_file, {"driver": "noop"}) + assert isinstance(get_driver(), NoOpCurtains) + + def test_unknown_driver_falls_back( + self, config_file: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A typo must not stop the alarm; it degrades and says so loudly.""" + _write(config_file, {"driver": "definitely-not-a-motor"}) + with caplog.at_level("WARNING"): + assert isinstance(get_driver(), NoOpCurtains) + assert "Unknown curtain driver" in caplog.text + + def test_non_string_driver_is_ignored(self, config_file: Path) -> None: + """A JSON number where a name belongs is not a driver.""" + _write(config_file, {"driver": 42}) + assert isinstance(get_driver(), NoOpCurtains) + + def test_malformed_json_falls_back( + self, config_file: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A half-written config means no curtains, not a crash.""" + config_file.write_text("{not json") + with caplog.at_level("WARNING"): + assert isinstance(get_driver(), NoOpCurtains) + assert "Cannot read" in caplog.text + + def test_non_dict_config_falls_back( + self, config_file: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A JSON list is not a config object.""" + _write(config_file, ["noop"]) + with caplog.at_level("WARNING"): + assert isinstance(get_driver(), NoOpCurtains) + assert "not a JSON object" in caplog.text + + +class TestOpenCurtains: + """The entry point the alarm's startup path calls.""" + + def test_opens_via_the_configured_driver(self) -> None: + """The ordinary path.""" + assert open_curtains() is True + + def test_a_failing_driver_never_stops_the_alarm( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """A jammed motor is a curtain problem, not an alarm problem. + + The call sits on the startup path, so an exception escaping here would + take the whole alarm down and the user would not be woken at all. + """ + + class Jammed: + def open(self) -> bool: + msg = "the motor is stuck" + raise RuntimeError(msg) + + def close(self) -> bool: + return False + + monkeypatch.setattr(_curtains, "get_driver", Jammed) + with caplog.at_level("WARNING"): + assert open_curtains() is False + assert "the alarm continues" in caplog.text