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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 21:00:11 +02:00
parent c43af4edb7
commit f5eb7fc2bd
4 changed files with 375 additions and 1 deletions

85
DOCS-curtains-hardware.md Normal file
View File

@ -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.

166
wake_alarm/_curtains.py Normal file
View File

@ -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

View File

@ -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,

View File

@ -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