programs: decode TIMED sunrise/sunset-relative time encoding
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).
This commit is contained in:
parent
00f0028053
commit
eb1a632ef2
@ -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",
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user