new alarm mechanism

This commit is contained in:
Mikkeli Matlock
2026-02-15 22:44:36 +09:00
parent e5cc124dd3
commit 89c975bf17
10 changed files with 243 additions and 22 deletions

114
pi/alarm_scheduler.py Normal file
View File

@@ -0,0 +1,114 @@
"""Alarm scheduler — load config and check firing schedule."""
import json
import logging
import re
from datetime import datetime
from pathlib import Path
log = logging.getLogger(__name__)
DEFAULT_CONFIG_PATH = Path(__file__).parent / "config" / "alarms.json"
VALID_DAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
TIME_RE = re.compile(r"^([01]\d|2[0-3])[0-5]\d$")
def load_config(path: Path) -> dict | None:
"""Read and validate alarm config JSON.
Returns the config dict, or None if the file is missing, empty, or invalid.
Never raises — logs warnings and returns None on any problem.
"""
try:
text = path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
log.warning("Config file not found: %s", path)
return None
except OSError as e:
log.warning("Cannot read config %s: %s", path, e)
return None
if not text:
log.warning("Config file is empty: %s", path)
return None
try:
data = json.loads(text)
except json.JSONDecodeError as e:
log.warning("Invalid JSON in %s: %s", path, e)
return None
# Empty object or empty list means "no alarms"
if not data or (isinstance(data, list) and len(data) == 0):
log.info("Config is empty — no alarms configured")
return None
if not isinstance(data, dict):
log.warning("Config must be a JSON object, got %s", type(data).__name__)
return None
# Validate alarm_time (required)
alarm_time = data.get("alarm_time")
if alarm_time is None:
log.warning("Missing required field 'alarm_time'")
return None
if not isinstance(alarm_time, str) or not TIME_RE.match(alarm_time):
log.warning("Invalid alarm_time '%s' — must be 4-digit HHMM (e.g. '0730')", alarm_time)
return None
# Validate alarm_days (optional)
alarm_days = data.get("alarm_days")
if alarm_days is not None:
if not isinstance(alarm_days, list) or not all(isinstance(d, str) for d in alarm_days):
log.warning("alarm_days must be a list of strings")
return None
bad = [d for d in alarm_days if d not in VALID_DAYS]
if bad:
log.warning("Invalid day abbreviations in alarm_days: %s", bad)
return None
# Validate alarm_dates (optional)
alarm_dates = data.get("alarm_dates")
if alarm_dates is not None:
if not isinstance(alarm_dates, list) or not all(isinstance(d, str) for d in alarm_dates):
log.warning("alarm_dates must be a list of strings")
return None
date_re = re.compile(r"^(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])$")
bad = [d for d in alarm_dates if not date_re.match(d)]
if bad:
log.warning("Invalid date formats in alarm_dates (expected MM/DD): %s", bad)
return None
log.info("Alarm config loaded: time=%s days=%s", alarm_time, alarm_days or "(every day)")
return data
def should_fire(config: dict) -> bool:
"""Check if the alarm should fire right now based on config schedule.
Rules:
- alarm_time must match current HHMM
- If alarm_days is present, today's 3-letter abbreviation must be in the list
- If alarm_days is absent but alarm_dates is present, today's MM/DD must match
- If neither alarm_days nor alarm_dates is present, fires every day
- If both are present, alarm_days wins (alarm_dates ignored)
"""
now = datetime.now()
current_hhmm = now.strftime("%H%M")
if config["alarm_time"] != current_hhmm:
return False
alarm_days = config.get("alarm_days")
alarm_dates = config.get("alarm_dates")
# alarm_days takes priority over alarm_dates
if alarm_days is not None:
return now.strftime("%a") in alarm_days
if alarm_dates is not None:
return now.strftime("%m/%d") in alarm_dates
# Neither specified — fire every day
return True