wake-alarm/wake_alarm/_startup.py
Krzysztof kuhy Rudnicki f5eb7fc2bd 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
2026-08-27 21:00:11 +02:00

53 lines
2.0 KiB
Python

"""Bring the machine's hardware up before the alarm window appears.
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. The curtains are the same shape: fire and forget, never block.
"""
from __future__ import annotations
from collections.abc import Callable
import logging
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
_logger = logging.getLogger(__name__)
_Sleep = Callable[[float], object]
def prepare_hardware(*, sleep: _Sleep = time.sleep) -> None:
"""Wake the display, raise brightness and power the speakers.
Args:
sleep: Delay function, injected so tests do not really wait.
"""
_warn_if_no_real_sink()
_wake_display()
_set_max_brightness()
# Power the speakers BEFORE the first beep. An amplifier's soft-start /
# mute relay swallows the first seconds of audio after mains is applied,
# 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,
extra={"event": "plug_preroll", "seconds": PLUG_PREROLL_SECONDS},
)
sleep(PLUG_PREROLL_SECONDS)