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