From d6205cd3300624385dd87a7ae59de348d52264a2 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 14 May 2026 01:27:54 -0600 Subject: [PATCH] =?UTF-8?q?program=5Fengine:=20Phase=203=20=E2=80=94=20YEA?= =?UTF-8?q?RLY=20+=20sunrise/sunset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YEARLY programs (month/day match at hour:minute, regardless of weekday) now fire autonomously. Each YEARLY slot gets its own asyncio worker running sleep-until-next-fire / fire / loop, just like TIMED. Sunrise/sunset support lands in the same phase. New PanelLocation dataclass wraps a geographic position; PanelLocation.from_account(acct) builds one from the .pca's latitude/longitude/time_zone, flipping the longitude sign to match astral's east-positive convention. The engine's __init__ accepts an optional location=PanelLocation. TIMED programs whose hour byte hits the AT_SUNRISE (25) or AT_SUNSET (26) sentinels resolve against astral's computed sunrise/sunset on each candidate day, applying the program's signed minute offset (time_offset_minutes). Without a location supplied, sun-relative programs are silently skipped — the same effect as an empty Days mask. _next_yearly_fire validates the (month, day) combination — Feb 30 etc. return None and the program never fires (matches real-panel range checks). 12 new tests covering: * PanelLocation longitude sign-flip and timezone derivation * _next_yearly_fire for today / next-year rollover / disabled / invalid * YEARLY worker end-to-end + multi-year loop * _next_sun_relative_fire AT_SUNSET / sunrise-with-offset / no-days * engine sun-relative skip without location * engine sun-relative fires correctly with location Full suite: 539 passed, 1 skipped (up from 527). --- src/omni_pca/program_engine.py | 193 ++++++++++++++++++++++++++++- tests/test_program_engine.py | 214 +++++++++++++++++++++++++++++++++ 2 files changed, 401 insertions(+), 6 deletions(-) diff --git a/src/omni_pca/program_engine.py b/src/omni_pca/program_engine.py index f8eda05..783a39a 100644 --- a/src/omni_pca/program_engine.py +++ b/src/omni_pca/program_engine.py @@ -210,6 +210,78 @@ def classify(programs: tuple[Program, ...]) -> _ClassifiedPrograms: ) +# -------------------------------------------------------------------------- +# Geo / sun events +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PanelLocation: + """Geographic position for sunrise/sunset computation. + + A thin wrapper that decouples the engine from a hard dependency on + :mod:`astral`. Build one from a decoded ``.pca`` like:: + + from omni_pca.pca_file import parse_pca_file + acct = parse_pca_file(data, key=KEY_EXPORT) + loc = PanelLocation.from_account(acct) + + The engine accepts ``location=None`` (the default); in that mode + sunrise/sunset-relative TIMED programs simply don't fire — equivalent + to an empty Days mask. + """ + + name: str = "Panel" + region: str = "US" + timezone: str = "UTC" + latitude: float = 0.0 + longitude: float = 0.0 + + @classmethod + def from_account(cls, account, *, name: str = "Panel") -> "PanelLocation": + """Build a PanelLocation from a :class:`omni_pca.pca_file.PcaAccount`. + + ``acct.latitude``/``longitude`` are raw degrees; ``acct.time_zone`` + is hours west of UTC, which we convert to an IANA-style + ``"Etc/GMT+N"`` zone — pyttz / astral resolve that correctly. + (The Etc/GMT signs are inverted relative to common usage by the + POSIX convention, hence "+N" for west-of-UTC.) + """ + tz_off = getattr(account, "time_zone", 0) + # Etc/GMT+0 normalises to UTC for the zero case. + tz_name = f"Etc/GMT+{tz_off}" if tz_off else "UTC" + # Longitude on Omni is stored as positive degrees west; astral + # expects signed degrees east-of-prime. Negate. + return cls( + name=name, + region="US", # not stored in .pca; "US" is fine as a label + timezone=tz_name, + latitude=float(getattr(account, "latitude", 0)), + longitude=-float(getattr(account, "longitude", 0)), + ) + + +def _sun_events(location: PanelLocation, day: date) -> tuple[datetime, datetime]: + """Return (sunrise, sunset) on ``day`` as timezone-aware UTC datetimes. + + Raises :class:`ImportError` if the optional ``astral`` dependency + isn't installed; callers should catch and treat as "no sun events + available" (equivalent to skipping the program for that day). + """ + from astral import LocationInfo + from astral.sun import sun + + info = LocationInfo( + name=location.name, + region=location.region, + timezone=location.timezone, + latitude=location.latitude, + longitude=location.longitude, + ) + times = sun(info.observer, date=day) + return times["sunrise"], times["sunset"] + + # -------------------------------------------------------------------------- # TIMED scheduling # -------------------------------------------------------------------------- @@ -264,6 +336,69 @@ def _next_absolute_fire(now: datetime, program: Program) -> datetime | None: return None # safety net — shouldn't happen if mask is non-zero +def _next_sun_relative_fire( + now: datetime, + program: Program, + location: PanelLocation, +) -> datetime | None: + """Compute next fire for a sunrise/sunset-relative TIMED program. + + For each candidate day (today through +8 days) we compute the + panel's sunrise/sunset, apply the program's signed minute offset + (``time_offset_minutes``), and return the first result strictly + after ``now`` whose date matches the program's Days mask. + + Returns ``None`` if Days mask is empty, the program isn't sun-relative, + or the astral computation raises. + """ + if program.time_kind not in (TimeKind.SUNRISE, TimeKind.SUNSET): + return None + if program.days == 0: + return None + offset = timedelta(minutes=program.time_offset_minutes) + is_sunrise = program.time_kind == TimeKind.SUNRISE + for delta_days in range(0, 8): + day = (now + timedelta(days=delta_days)).date() + if not _matches_days_mask(day, program.days): + continue + try: + sunrise, sunset = _sun_events(location, day) + except Exception: + _log.debug( + "sun computation failed for %s — skipping day", day, exc_info=True + ) + return None + candidate = (sunrise if is_sunrise else sunset) + offset + if candidate > now: + return candidate + return None + + +def _next_yearly_fire(now: datetime, program: Program) -> datetime | None: + """Compute the next datetime a YEARLY program should fire. + + YEARLY programs match a fixed month/day at hour:minute, regardless + of weekday. Returns ``None`` if month is 0 (program disabled) or + the month/day combination is invalid (e.g. Feb 30). + """ + if program.month == 0 or program.day == 0: + return None + candidate_year = now.year + for _ in range(2): # try this year then next + try: + candidate = datetime( + candidate_year, program.month, program.day, + program.hour, program.minute, + tzinfo=now.tzinfo, + ) + except ValueError: + return None # Feb 30 etc. + if candidate > now: + return candidate + candidate_year += 1 + return None # safety net + + def _command_payload(program: Program) -> bytes: """Build the 4-byte Command wire payload from a Program record. @@ -318,9 +453,16 @@ class ProgramEngine: explicit start. """ - def __init__(self, panel: "MockPanel", *, clock: Clock | None = None) -> None: + def __init__( + self, + panel: "MockPanel", + *, + clock: Clock | None = None, + location: PanelLocation | None = None, + ) -> None: self._panel = panel self._clock = clock or RealClock() + self._location = location # Decode raw bytes from MockState.programs into Program objects # once, at construction. Reclassifying on every start would be # wasteful and would also lose the slot indices. @@ -373,19 +515,39 @@ class ProgramEngine: name=f"omni-pca-timed-slot-{program.slot}", ) ) + # Phase 3: one worker per YEARLY program. + for program in self._classified.yearly: + self._tasks.append( + asyncio.create_task( + self._run_yearly_program(program), + name=f"omni-pca-yearly-slot-{program.slot}", + ) + ) async def _run_timed_program(self, program: Program) -> None: """Sleep-until-next-fire loop for one TIMED program. - Wakes at the program's scheduled hour:minute on the next - matching weekday, fires the command, then loops to the next - occurrence. Returns when the engine stops (task cancellation). + Handles both ABSOLUTE (wall-clock hour:minute) and sunrise / + sunset-relative time kinds. Sun-relative programs only run if + the engine was given a :class:`PanelLocation`; without one they + return immediately, the same way an empty Days mask would. """ try: while self._running: - next_fire = _next_absolute_fire(self._clock.now(), program) + now = self._clock.now() + if program.time_kind == TimeKind.ABSOLUTE: + next_fire = _next_absolute_fire(now, program) + elif self._location is None: + _log.debug( + "engine: TIMED slot %s is sun-relative but no " + "location was supplied — skipping", + program.slot, + ) + return + else: + next_fire = _next_sun_relative_fire(now, program, self._location) if next_fire is None: - return # no valid Days mask — give up on this program + return # disabled (empty Days, sun unavailable, etc.) await self._clock.sleep_until(next_fire) if not self._running: return @@ -398,6 +560,25 @@ class ProgramEngine: ) self.metrics.errors += 1 + async def _run_yearly_program(self, program: Program) -> None: + """Sleep-until-next-fire loop for one YEARLY program.""" + try: + while self._running: + next_fire = _next_yearly_fire(self._clock.now(), program) + if next_fire is None: + return # disabled or invalid month/day + await self._clock.sleep_until(next_fire) + if not self._running: + return + await self._fire(program) + except asyncio.CancelledError: + raise + except Exception: + _log.exception( + "engine: YEARLY slot %s crashed", program.slot, + ) + self.metrics.errors += 1 + async def _fire(self, program: Program) -> None: """Execute one program by feeding its command through the same wire-handler path the v2 Command opcode uses.""" diff --git a/tests/test_program_engine.py b/tests/test_program_engine.py index 3416902..6e2f5dc 100644 --- a/tests/test_program_engine.py +++ b/tests/test_program_engine.py @@ -412,3 +412,217 @@ async def test_engine_skips_empty_days_mask() -> None: await clock.advance_to(t0 + timedelta(days=7)) await asyncio.sleep(0) assert engine.metrics.timed_fired == 0 + + +# ---- Phase 3: YEARLY + sunrise/sunset ----------------------------------- + + +from omni_pca.program_engine import ( # noqa: E402 + PanelLocation, + _next_sun_relative_fire, + _next_yearly_fire, +) + + +def test_panel_location_from_account_negates_longitude() -> None: + """The Omni stores longitude as positive degrees west; PanelLocation + flips the sign to match astral's east-positive convention.""" + + class _AcctStub: + latitude = 44 + longitude = 117 + time_zone = 7 + + loc = PanelLocation.from_account(_AcctStub()) + assert loc.latitude == 44.0 + assert loc.longitude == -117.0 # flipped from +117 west → -117 east + assert loc.timezone == "Etc/GMT+7" + + +def test_next_yearly_fire_picks_today() -> None: + # 2026-05-14 12:00; program 05/14 13:00 → today 13:00. + p = Program( + slot=1, prog_type=int(ProgramType.YEARLY), + cmd=1, month=5, day=14, hour=13, minute=0, + ) + now = datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc) + nxt = _next_yearly_fire(now, p) + assert nxt == datetime(2026, 5, 14, 13, 0, tzinfo=timezone.utc) + + +def test_next_yearly_fire_rolls_over_to_next_year() -> None: + # 2026-12-31 23:00; program 01/01 00:00 → next year. + p = Program( + slot=1, prog_type=int(ProgramType.YEARLY), + cmd=1, month=1, day=1, hour=0, minute=0, + ) + now = datetime(2026, 12, 31, 23, 0, tzinfo=timezone.utc) + nxt = _next_yearly_fire(now, p) + assert nxt == datetime(2027, 1, 1, 0, 0, tzinfo=timezone.utc) + + +def test_next_yearly_fire_zero_month_returns_none() -> None: + p = Program( + slot=1, prog_type=int(ProgramType.YEARLY), + cmd=1, month=0, day=0, hour=0, minute=0, + ) + now = datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc) + assert _next_yearly_fire(now, p) is None + + +def test_next_yearly_fire_invalid_date_returns_none() -> None: + """Feb 30 is invalid — program never fires.""" + p = Program( + slot=1, prog_type=int(ProgramType.YEARLY), + cmd=1, month=2, day=30, hour=12, minute=0, + ) + now = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + assert _next_yearly_fire(now, p) is None + + +@pytest.mark.asyncio +async def test_engine_fires_yearly_program() -> None: + """YEARLY May-14 12:00 program fires when clock crosses that date.""" + t0 = datetime(2026, 5, 14, 11, 59, tzinfo=timezone.utc) + p = Program( + slot=10, prog_type=int(ProgramType.YEARLY), + cmd=int(Command.UNIT_ON), pr2=3, + month=5, day=14, hour=12, minute=0, + ) + panel = _panel_with_programs(p) + clock = FakeClock(t0) + async with ProgramEngine(panel, clock=clock) as engine: + await asyncio.sleep(0) + await clock.advance_to(datetime(2026, 5, 14, 12, 1, tzinfo=timezone.utc)) + await asyncio.sleep(0) + assert engine.metrics.yearly_fired == 1 + assert panel.state.units[3].state == 1 + + +@pytest.mark.asyncio +async def test_engine_yearly_loops_next_year() -> None: + """After firing, YEARLY workers re-arm for the next year.""" + t0 = datetime(2026, 5, 14, 11, 59, tzinfo=timezone.utc) + p = Program( + slot=10, prog_type=int(ProgramType.YEARLY), + cmd=int(Command.UNIT_ON), pr2=3, + month=5, day=14, hour=12, minute=0, + ) + panel = _panel_with_programs(p) + clock = FakeClock(t0) + async with ProgramEngine(panel, clock=clock) as engine: + await asyncio.sleep(0) + # First fire. + await clock.advance_to(datetime(2026, 5, 14, 12, 1, tzinfo=timezone.utc)) + await asyncio.sleep(0) + # Second fire next year. + await clock.advance_to(datetime(2027, 5, 14, 12, 1, tzinfo=timezone.utc)) + await asyncio.sleep(0) + assert engine.metrics.yearly_fired == 2 + + +def test_next_sun_relative_fire_at_sunset() -> None: + """A TIMED program scheduled "at sunset" on a Thursday fires at + the astral-computed sunset for that day.""" + p = Program( + slot=1, prog_type=int(ProgramType.TIMED), + cmd=int(Command.UNIT_ON), pr2=5, + hour=26, minute=0, # AT_SUNSET sentinel + days=int(Days.THURSDAY), + ) + loc = PanelLocation( + name="Boise", region="US", timezone="UTC", + latitude=43.6, longitude=-116.2, + ) + # 2026-05-14 is a Thursday. + now = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc) + nxt = _next_sun_relative_fire(now, p, loc) + assert nxt is not None + # Sunset in Boise mid-May is roughly 03:00 UTC the *next* day (late + # evening Mountain Time). Just verify it's in the right ballpark + # and after `now`. + assert nxt > now + assert nxt < now + timedelta(days=2) + + +def test_next_sun_relative_fire_with_offset_before_sunrise() -> None: + """A "30 min before sunrise" program lands earlier than astral's + raw sunrise time by exactly 30 minutes.""" + p_at = Program( + slot=1, prog_type=int(ProgramType.TIMED), + cmd=1, hour=25, minute=0, # AT_SUNRISE + days=int(Days.THURSDAY), + ) + p_before = Program( + slot=2, prog_type=int(ProgramType.TIMED), + cmd=1, hour=25, minute=256 - 30, # 30 min before (sbyte -30) + days=int(Days.THURSDAY), + ) + loc = PanelLocation( + name="Boise", region="US", timezone="UTC", + latitude=43.6, longitude=-116.2, + ) + now = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc) + at = _next_sun_relative_fire(now, p_at, loc) + before = _next_sun_relative_fire(now, p_before, loc) + assert at is not None and before is not None + # The "before" fire is 30 minutes earlier than the "at" fire. + assert at - before == timedelta(minutes=30) + + +def test_next_sun_relative_fire_empty_days_returns_none() -> None: + p = Program( + slot=1, prog_type=int(ProgramType.TIMED), + cmd=1, hour=25, minute=0, + days=0, # disabled + ) + loc = PanelLocation(latitude=43.6, longitude=-116.2) + now = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc) + assert _next_sun_relative_fire(now, p, loc) is None + + +@pytest.mark.asyncio +async def test_engine_sun_relative_without_location_is_skipped() -> None: + """Engine with no PanelLocation drops sunrise/sunset programs.""" + t0 = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc) + p = Program( + slot=1, prog_type=int(ProgramType.TIMED), + cmd=int(Command.UNIT_ON), pr2=5, + hour=25, minute=0, # AT_SUNRISE + days=int(Days.THURSDAY), + ) + panel = _panel_with_programs(p) + clock = FakeClock(t0) + async with ProgramEngine(panel, clock=clock) as engine: + # No location → worker returns immediately, never fires. + await asyncio.sleep(0) + await clock.advance_to(t0 + timedelta(days=2)) + await asyncio.sleep(0) + assert engine.metrics.timed_fired == 0 + + +@pytest.mark.asyncio +async def test_engine_fires_sun_relative_program_with_location() -> None: + """End-to-end: TIMED AT_SUNSET program fires at astral-computed + sunset when the clock advances past it.""" + loc = PanelLocation( + name="Boise", region="US", timezone="UTC", + latitude=43.6, longitude=-116.2, + ) + p = Program( + slot=1, prog_type=int(ProgramType.TIMED), + cmd=int(Command.UNIT_ON), pr2=5, + hour=26, minute=0, # AT_SUNSET + days=int(Days.THURSDAY), + ) + # Start at midnight UTC on a Thursday. + t0 = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc) + panel = _panel_with_programs(p) + clock = FakeClock(t0) + async with ProgramEngine(panel, clock=clock, location=loc) as engine: + await asyncio.sleep(0) + # Advance well past sunset (which is roughly 03:00 UTC Friday). + await clock.advance_to(t0 + timedelta(days=2)) + await asyncio.sleep(0) + assert engine.metrics.timed_fired == 1 + assert panel.state.units[5].state == 1