mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
5.7 KiB
Python
166 lines
5.7 KiB
Python
"""Merge the v2 enforcement fields across every device's ``alarm.json``.
|
|
|
|
Pure: ``(device_id, raw_json)`` pairs in -- the same list
|
|
``_sync_transport.fetch_firebase_logs`` returns and ``_session_payload``
|
|
reads -- one :class:`EnforcementView` out. Every field is a crdt_sync
|
|
``(value, HLC)`` pair:
|
|
|
|
* ``ring_started`` and ``stick_state`` describe *now*, so the newest HLC wins
|
|
(``device_id`` breaks an exact tie, so two installs never alternate);
|
|
* ``escapes`` and ``phone_events`` are history, so they are the **union**:
|
|
newest-wins on a list would let a reinstalled phone, publishing an empty
|
|
history, erase every escape and event the old install recorded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from wake_alarm._alarm_payload import extract_hlc
|
|
from wake_alarm._constants import (
|
|
ESCAPE_HATCH_LAST_DAY,
|
|
ESCAPE_REASON_MIN_CHARS,
|
|
ESCAPES_FIELD,
|
|
PHONE_EVENTS_FIELD,
|
|
RING_STARTED_FIELD,
|
|
STICK_STATE_FIELD,
|
|
)
|
|
from wake_alarm._enforcement_records import (
|
|
Escape,
|
|
PhoneEvent,
|
|
RingStarted,
|
|
StickState,
|
|
escape_from,
|
|
phone_event_from,
|
|
ring_started_from,
|
|
stick_state_from,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EnforcementView:
|
|
"""What every device together says about the enforcement fields."""
|
|
|
|
ring_started: RingStarted | None = None
|
|
escapes: tuple[Escape, ...] = ()
|
|
stick_state: StickState | None = None
|
|
phone_events: tuple[PhoneEvent, ...] = ()
|
|
|
|
def ring_started_on(self, day: str) -> bool:
|
|
"""Whether the phone's ring screen opened on ``day``."""
|
|
return self.ring_started is not None and self.ring_started.date == day
|
|
|
|
def honoured_escapes(self) -> tuple[Escape, ...]:
|
|
"""Escapes the PC acts on; see :func:`is_honoured`."""
|
|
return tuple(e for e in self.escapes if is_honoured(e))
|
|
|
|
def escape_for(self, day: str) -> Escape | None:
|
|
"""The first honoured escape excusing ``day``, or None."""
|
|
return next((e for e in self.honoured_escapes() if e.date == day), None)
|
|
|
|
def escaped_dates(self) -> frozenset[str]:
|
|
"""Every morning an honoured escape excuses."""
|
|
return frozenset(e.date for e in self.honoured_escapes())
|
|
|
|
|
|
def is_honoured(escape: Escape) -> bool:
|
|
"""An escape counts only inside the hatch's life and with a real reason.
|
|
|
|
The phone hides the button after ``ESCAPE_HATCH_LAST_DAY`` and refuses a
|
|
short reason; the PC checks both again so a stale or hand-edited record
|
|
cannot cancel a penalty the phone itself would not have excused.
|
|
"""
|
|
try:
|
|
day = date.fromisoformat(escape.date)
|
|
except ValueError:
|
|
return False
|
|
return (
|
|
day <= ESCAPE_HATCH_LAST_DAY
|
|
and len(escape.reason.strip()) >= ESCAPE_REASON_MIN_CHARS
|
|
)
|
|
|
|
|
|
def _pair_value(log: dict[str, Any], field: str) -> object:
|
|
"""The value half of one ``(value, HLC)`` pair, or None."""
|
|
record = log.get("alarm")
|
|
fields = record.get("fields") if isinstance(record, dict) else None
|
|
pair = fields.get(field) if isinstance(fields, dict) else None
|
|
return pair[0] if isinstance(pair, list) and pair else None
|
|
|
|
|
|
def _newest[T](
|
|
logs: list[tuple[str, dict[str, Any]]],
|
|
field: str,
|
|
build: Callable[[object], T | None],
|
|
) -> T | None:
|
|
"""The parseable value with the newest ``(HLC, device_id)``, or None."""
|
|
ranked: list[tuple[tuple[str, str], T]] = []
|
|
for device_id, log in logs:
|
|
built = build(_pair_value(log, field))
|
|
if built is not None:
|
|
ranked.append(((extract_hlc(log, field), device_id), built))
|
|
return max(ranked, key=lambda entry: entry[0])[1] if ranked else None
|
|
|
|
|
|
def _union[T](
|
|
logs: list[tuple[str, dict[str, Any]]],
|
|
field: str,
|
|
build: Callable[[object], T | None],
|
|
identity: Callable[[T], str],
|
|
) -> tuple[T, ...]:
|
|
"""Every parseable list entry across devices, first occurrence wins."""
|
|
seen: set[str] = set()
|
|
merged: list[T] = []
|
|
for _, log in sorted(
|
|
logs, key=lambda entry: extract_hlc(entry[1], field), reverse=True
|
|
):
|
|
value = _pair_value(log, field)
|
|
for raw in value if isinstance(value, list) else []:
|
|
built = build(raw)
|
|
if built is None or identity(built) in seen:
|
|
continue
|
|
seen.add(identity(built))
|
|
merged.append(built)
|
|
return tuple(merged)
|
|
|
|
|
|
def _event_identity(event: PhoneEvent) -> str:
|
|
"""Two devices reporting the same event at the same instant are one."""
|
|
return json.dumps([event.at, event.kind, event.data], sort_keys=True, default=str)
|
|
|
|
|
|
def parse_logs(published: list[tuple[str, str]]) -> list[tuple[str, dict[str, Any]]]:
|
|
"""``(device_id, parsed_log)`` for every log that is a JSON object."""
|
|
logs: list[tuple[str, dict[str, Any]]] = []
|
|
for device_id, raw in published:
|
|
try:
|
|
log = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
_logger.info("Ignoring unparsable enforcement log from %r", device_id)
|
|
continue
|
|
if isinstance(log, dict):
|
|
logs.append((device_id, log))
|
|
return logs
|
|
|
|
|
|
def enforcement_view(published: list[tuple[str, str]]) -> EnforcementView:
|
|
"""Merge every device's v2 fields into one view."""
|
|
logs = parse_logs(published)
|
|
return EnforcementView(
|
|
ring_started=_newest(logs, RING_STARTED_FIELD, ring_started_from),
|
|
escapes=_union(logs, ESCAPES_FIELD, escape_from, lambda e: e.id),
|
|
stick_state=_newest(logs, STICK_STATE_FIELD, stick_state_from),
|
|
phone_events=_union(
|
|
logs, PHONE_EVENTS_FIELD, phone_event_from, _event_identity
|
|
),
|
|
)
|