From eb1a632ef2fe04308c93cbee68a96d0c3e87887f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 11 May 2026 21:38:28 -0600 Subject: [PATCH] programs: decode TIMED sunrise/sunset-relative time encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hour byte of a TIMED program is overloaded as a 1-of-3 discriminator: 0..23 means absolute wall-clock time, 25 means sunrise-relative, 26 means sunset-relative. For the relative forms, the minute byte is signed (sbyte) -- positive = after, negative = before, zero = at. Source: frmPopUpEditTime.cs:186-217 (decode) + :241-263 (encode). This was the canary that tripped our earlier sanity test: slots 182/183 in the live fixture have hour=26 minute=246 and hour=25 minute=10 -- nominally invalid as clock times, but they're "10 min before sunset" and "10 min after sunrise" respectively. With this commit those decode cleanly via TimeKind.SUNSET / SUNRISE. omni_pca.programs: * New TimeKind IntEnum: ABSOLUTE / SUNRISE / SUNSET. * New Program.time_kind property (classifies via hour-byte discriminator). * New Program.time_offset_minutes property (signed minutes-offset for sunrise/sunset; 0 for absolute). * New Program.format_time() -> str: "07:15" | "at sunrise" | "30 min before sunset" etc. * Module-level _classify_time helper + sentinel constants _HR_SUNRISE_SENTINEL=25 / _HR_SUNSET_SENTINEL=26. omni_pca top-level: re-exports TimeKind. tests/test_programs.py (+13 cases): * Parametrised TimeKind classification across absolute / sunrise / sunset including boundary cases (sbyte ±128, ±1, 0). * Wire-bytes round-trip preserves TimeKind + offset. tests/test_pca_file.py: tightened the previously-loosened sanity invariant. ABSOLUTE-time TIMED programs must hit valid wall-clock ranges (0-23 / 0-59); relative-time programs must have a valid sbyte offset (-128..127). Both pass cleanly on the 209 TIMED programs in the live fixture (207 absolute, 1 sunrise, 1 sunset). Full suite: 439 passed, 1 skipped (was 426 / 1). --- src/omni_pca/__init__.py | 2 + src/omni_pca/programs.py | 82 ++++++++++++++++++++++++++++++++++++++++ tests/test_pca_file.py | 44 ++++++++++++++------- tests/test_programs.py | 52 +++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 13 deletions(-) diff --git a/src/omni_pca/__init__.py b/src/omni_pca/__init__.py index bd2bca1..3bb4857 100644 --- a/src/omni_pca/__init__.py +++ b/src/omni_pca/__init__.py @@ -7,6 +7,7 @@ from .programs import ( Program, ProgramCond, ProgramType, + TimeKind, decode_program_table, iter_defined, ) @@ -21,6 +22,7 @@ __all__ = [ "Program", "ProgramCond", "ProgramType", + "TimeKind", "__version__", "decode_program_table", "iter_defined", diff --git a/src/omni_pca/programs.py b/src/omni_pca/programs.py index de192c3..3fbe100 100644 --- a/src/omni_pca/programs.py +++ b/src/omni_pca/programs.py @@ -146,6 +146,44 @@ class Days(IntFlag): SUNDAY = 0x80 +class TimeKind(IntEnum): + """How the ``hour`` / ``minute`` bytes of a TIMED program are interpreted. + + PC Access overloads the ``Hr`` byte as a one-of-three discriminator: + a value in 0..23 means an absolute wall-clock time; ``Hr == 25`` + means sunrise-relative; ``Hr == 26`` means sunset-relative. For + the two relative kinds, ``Min`` is read as a **signed** byte + (-128..127): a positive value is minutes *after* sunrise/sunset, + a negative value is minutes *before*, and zero is "at". + + Reference: frmPopUpEditTime.cs:186-217 (decode), :241-263 (encode). + """ + + ABSOLUTE = 0 + SUNRISE = 1 + SUNSET = 2 + + +_HR_SUNRISE_SENTINEL = 25 +_HR_SUNSET_SENTINEL = 26 + + +def _classify_time(hour: int, minute: int) -> tuple[TimeKind, int]: + """Decode ``(hour, minute)`` bytes into a ``(kind, value)`` pair. + + For ``TimeKind.ABSOLUTE`` the ``value`` is the minute byte 0..59 + (caller should also use the ``hour`` field for the full time). For + sunrise / sunset, ``value`` is the signed minutes offset. + """ + if hour == _HR_SUNRISE_SENTINEL: + offset = minute if minute < 0x80 else minute - 0x100 + return TimeKind.SUNRISE, offset + if hour == _HR_SUNSET_SENTINEL: + offset = minute if minute < 0x80 else minute - 0x100 + return TimeKind.SUNSET, offset + return TimeKind.ABSOLUTE, minute & 0xFF + + # Once-per-process warnings — see _warn_unknown. _warned_unknown: set[tuple[str, int]] = set() @@ -342,6 +380,50 @@ class Program: # ---- convenience ------------------------------------------------- + @property + def time_kind(self) -> TimeKind: + """Classify the ``hour`` byte as absolute / sunrise / sunset. + + Only meaningful for TIMED programs; for other ``prog_type`` + values the return is still computed mechanically but has no + semantic interpretation. + """ + return _classify_time(self.hour, self.minute)[0] + + @property + def time_offset_minutes(self) -> int: + """Signed minutes-offset for sunrise/sunset-relative TIMED programs. + + Returns 0 for absolute-time programs (and for non-TIMED types, + whose ``hour`` / ``minute`` bytes aren't time-of-day at all). + Positive = after sunrise/sunset, negative = before, zero = at. + """ + kind, value = _classify_time(self.hour, self.minute) + return value if kind in (TimeKind.SUNRISE, TimeKind.SUNSET) else 0 + + def format_time(self) -> str: + """Human-readable rendering of the TIMED time-of-day. + + Examples: + ``"07:15"`` for an absolute-time program. + ``"at sunrise"`` for ``hour==25, minute==0``. + ``"30 min before sunset"`` for ``hour==26, minute==226`` (sbyte -30). + + Returns the raw ``"hh:mm"`` form for non-TIMED programs even + though it's semantically meaningless there; callers should + check ``prog_type`` first. + """ + kind, value = _classify_time(self.hour, self.minute) + if kind == TimeKind.SUNRISE: + if value == 0: + return "at sunrise" + return f"{abs(value)} min {'after' if value > 0 else 'before'} sunrise" + if kind == TimeKind.SUNSET: + if value == 0: + return "at sunset" + return f"{abs(value)} min {'after' if value > 0 else 'before'} sunset" + return f"{self.hour:02d}:{self.minute:02d}" + @property def event_id(self) -> int: """The 16-bit event identifier (only meaningful for EVENT type). diff --git a/tests/test_pca_file.py b/tests/test_pca_file.py index 89335b6..c854a8d 100644 --- a/tests/test_pca_file.py +++ b/tests/test_pca_file.py @@ -159,21 +159,24 @@ def test_programs_sanity_invariants() -> None: """Coarse invariants on the 330 defined programs. The byte-for-byte round-trip test above is the load-bearing - correctness signal. This adds light coverage to catch a Mon/Day - swap regression specifically on YEARLY-typed programs (which use - bytes 9/10 as a true calendar date). + correctness signal. The asserts here are belt-and-suspenders: - What we DON'T assert and why: - - * **EVENT** programs encode a u16 event identifier in bytes 9/10 - (see ``clsProgram.Evt`` at lines 152-163), not a calendar date. - * **TIMED** programs use bytes 12/13 either as absolute hour:minute - (0-23 : 0-59) *or* as a sunrise/sunset-relative offset - (Owner's Manual: ±0-120 minutes), with a flag we haven't - reverse-engineered yet. So hour=26 / minute=246 are valid wire - values in the absence of that flag decoder. + * **YEARLY** uses bytes 9/10 as a real calendar date. + * **TIMED** programs come in two flavors: + ABSOLUTE (``hour`` 0..23, ``minute`` 0..59) and + sunrise/sunset-relative (``hour`` == 25 or 26 — see + :class:`omni_pca.programs.TimeKind`). The decoder classifies via + ``Program.time_kind``; ABSOLUTE-time programs must hit real + wall-clock ranges. + * **EVENT** encodes a u16 event ID in bytes 9/10 rather than + a calendar date (see ``clsProgram.Evt``); no calendar assertion. """ - from omni_pca.programs import ProgramType, decode_program_table, iter_defined + from omni_pca.programs import ( + ProgramType, + TimeKind, + decode_program_table, + iter_defined, + ) blob = _load_programs_blob_or_skip() programs = decode_program_table(blob) @@ -189,6 +192,21 @@ def test_programs_sanity_invariants() -> None: f"slot {p.slot} YEARLY: day={p.day}" ) + timed = [p for p in defined if p.prog_type == int(ProgramType.TIMED)] + assert timed, "fixture should have TIMED programs" + for p in timed: + assert p.days != 0, f"slot {p.slot}: TIMED with no days mask" + if p.time_kind == TimeKind.ABSOLUTE: + assert 0 <= p.hour <= 23, ( + f"slot {p.slot} TIMED-ABSOLUTE: hour={p.hour}" + ) + assert 0 <= p.minute <= 59, ( + f"slot {p.slot} TIMED-ABSOLUTE: minute={p.minute}" + ) + else: + # Sunrise/sunset offsets fit in a signed byte. + assert -128 <= p.time_offset_minutes <= 127 + def test_remarks_walker_on_empty_table() -> None: """Hand-built minimal tail with zero description entries + zero remarks.""" diff --git a/tests/test_programs.py b/tests/test_programs.py index 0dc2f1d..79d7c04 100644 --- a/tests/test_programs.py +++ b/tests/test_programs.py @@ -245,3 +245,55 @@ def test_days_bitmask_values() -> None: assert Days.MONDAY == 0x02 assert Days.SUNDAY == 0x80 assert Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY == 0x2A + + +# ---- TimeKind classification + sunrise/sunset offsets ---------------------- + + +from omni_pca.programs import TimeKind # noqa: E402 + + +@pytest.mark.parametrize( + "hour, minute, expected_kind, expected_offset, expected_label", + [ + # Absolute times — hour 0..23, minute 0..59. + (0, 0, TimeKind.ABSOLUTE, 0, "00:00"), + (7, 15, TimeKind.ABSOLUTE, 0, "07:15"), + (23, 59, TimeKind.ABSOLUTE, 0, "23:59"), + # Sunrise-relative. + (25, 0, TimeKind.SUNRISE, 0, "at sunrise"), + (25, 30, TimeKind.SUNRISE, 30, "30 min after sunrise"), + (25, 226, TimeKind.SUNRISE, -30, "30 min before sunrise"), + (25, 255, TimeKind.SUNRISE, -1, "1 min before sunrise"), + (25, 127, TimeKind.SUNRISE, 127, "127 min after sunrise"), + # Sunset-relative. + (26, 0, TimeKind.SUNSET, 0, "at sunset"), + (26, 10, TimeKind.SUNSET, 10, "10 min after sunset"), + (26, 246, TimeKind.SUNSET, -10, "10 min before sunset"), + (26, 128, TimeKind.SUNSET, -128, "128 min before sunset"), + ], +) +def test_time_kind_classification( + hour, minute, expected_kind, expected_offset, expected_label +) -> None: + p = Program( + prog_type=int(ProgramType.TIMED), + hour=hour, minute=minute, days=int(Days.MONDAY), + ) + assert p.time_kind == expected_kind + assert p.time_offset_minutes == expected_offset + assert p.format_time() == expected_label + + +def test_time_kind_round_trip_through_wire() -> None: + """Build a sunset-relative program, encode → decode → assert preserved.""" + p = Program( + prog_type=int(ProgramType.TIMED), + hour=26, minute=246, # 10 min before sunset + days=int(Days.FRIDAY | Days.SATURDAY), + ) + body = p.encode_wire_bytes() + p2 = Program.from_wire_bytes(body) + assert p2.time_kind == TimeKind.SUNSET + assert p2.time_offset_minutes == -10 + assert p2.format_time() == "10 min before sunset"