mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
manual:2026-09-13T14:30 -- the synced copy of the football that this
morning's day-key rekey let back in under a second id -- made every
workout-sync tick log "Manual-workout budget exhausted -- not ingested".
Tombstoning it once did not stick: sync_log pulls only the OTHER devices'
logs and _manual_push re-derives the PC's log from log.json each tick, so
the next push overwrote the tombstone ("tombstoned", then "LIVE" five
seconds later). Deletion is monotonic only for records still in the merge.
- New _sync_tombstones: {id: {deleted_hlc, reason, tombstoned_at}} in
screen_locker/sync_tombstones.json (gitignored with its siblings). The
HLC is minted once and stored so re-pushing does not churn the store.
The ledger path is resolved at call time, not as a default argument, so
the test suite's redirect actually applies -- verified by hashing the
real ledger before and after the run.
- _manual_push.records_from_workout_log merges the ledger over the live
derivation, so every push carries the tombstones.
- scripts/tombstone_sync_record.py <id> [--apply --reason ...]: dry run
reports live/tombstoned; --apply writes the ledger, runs one tick and
reports what the store holds afterwards. Rerunnable.
- tests/_isolated_state.py redirects SYNC_TOMBSTONES_FILE (both bindings).
Verified: --apply -> "tombstoned"; a second ordinary tick -> "already
tombstoned" with the same HLC; workout-sync.service now logs "Sync record
manual:2026-09-13T14:30 is tombstoned -- skipping it" and the budget line
for that id is gone. log.json untouched (the local entry stays; it shares
the original's credit per duplicate_credits.json).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
203 lines
11 KiB
Python
203 lines
11 KiB
Python
"""Constants for the screen locker module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from gatelock import default_runtime_dir
|
|
from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE
|
|
|
|
# Ephemeral runtime state (self-clears on reboot, same directory gatelock's
|
|
# own Arbiter uses) -- not repo-tracked data, so it does not belong next to
|
|
# log.json/sick_history.json below.
|
|
INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock"
|
|
|
|
# Single-sourced from gatelock so the literal key path can't drift.
|
|
HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE
|
|
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
|
|
PHONE_PENALTY_DELAY_DEMO = 10
|
|
PHONE_PENALTY_DELAY_PRODUCTION = 100
|
|
# Penalty added to phone-penalty timer when ADB / phone unavailable
|
|
# (so unplugging phone does not become an easy escape into sick mode).
|
|
NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base
|
|
# Sick day rate-limiting (rolling windows). Once any window is exhausted
|
|
# the "I'm sick" button disappears entirely.
|
|
SICK_BUDGET_PER_7_DAYS = 1
|
|
SICK_BUDGET_PER_30_DAYS = 3
|
|
SICK_BUDGET_PER_90_DAYS = 10
|
|
# Each sick day in the trailing 30 days doubles the wait countdown.
|
|
SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
|
|
# Minimum chars in the freeform sick justification.
|
|
SICK_JUSTIFICATION_MIN_CHARS = 120
|
|
# How many past sick justifications to show on the dialog (read-only).
|
|
SICK_HISTORY_REVIEW_COUNT = 10
|
|
# Forced read-only delay before SUBMIT enables when a commitment was made.
|
|
SICK_COMMITMENT_FORCED_READ_SECONDS = 5
|
|
# Breaking a commitment counts as this many sick budget days.
|
|
SICK_COMMITMENT_PENALTY_DAYS = 2
|
|
# How long the commitment prompt stays visible after a workout unlock.
|
|
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
|
|
# Manual (unverified) workout rate-limiting — looser than sick days since
|
|
# activities like table tennis recur more often than illness. Once either
|
|
# window is exhausted the "Log Manual Workout" option disappears entirely.
|
|
MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2
|
|
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10
|
|
# Manual entries use the shared MIN_WORKOUT_DURATION_MINUTES bar below —
|
|
# every workout type answers to the same minimum, whatever the sport.
|
|
# Minimum chars for the "what was done" activity-details field.
|
|
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS = 40
|
|
# Minimum chars for each reflection field (what went well / to improve / feeling).
|
|
MANUAL_WORKOUT_REFLECTION_MIN_CHARS = 20
|
|
MANUAL_WORKOUT_RPE_MIN = 1
|
|
MANUAL_WORKOUT_RPE_MAX = 10
|
|
ADB_TIMEOUT = 15
|
|
# Workout app JSON candidate paths on the phone, in the order the app prefers
|
|
# when writing (see sync_service.dart). The app writes to the primary /sdcard/
|
|
# path first and only falls back to its app-external files dir if /sdcard/ is
|
|
# not writable, so the locker must check both — primary first.
|
|
WORKOUT_APP_JSON_REMOTES = (
|
|
"/sdcard/workout_result.json",
|
|
"/storage/emulated/0/Android/data/com.kuhy.workout_app/files/workout_result.json",
|
|
)
|
|
# Port the workout app's HTTP server listens on (no ADB/developer-options needed).
|
|
WORKOUT_HTTP_PORT = 8765
|
|
# The ONE duration bar every workout type answers to — weightlifting, manual
|
|
# entries and runs alike. This is the ADVERTISED number: it is the only one
|
|
# that may ever appear in a message the user reads.
|
|
MIN_WORKOUT_DURATION_MINUTES = 40
|
|
# ...and this is the leeway that is deliberately INVISIBLE. A session is
|
|
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 35 minutes
|
|
# passes while every string still says 40. That gap is intentional (the user
|
|
# asked for it explicitly): do NOT "fix" the inconsistency by advertising the
|
|
# real cutoff, and never interpolate WORKOUT_DURATION_ACCEPT_MINUTES into an
|
|
# f-string.
|
|
WORKOUT_DURATION_LEEWAY_MINUTES = 5
|
|
# Derived so the advertised bar and the accept bar cannot drift apart.
|
|
# Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every
|
|
# caller compares with `>=` — one operator across all workout types, so a
|
|
# 35.0-minute session cannot pass one gate and fail another.
|
|
WORKOUT_DURATION_ACCEPT_MINUTES = (
|
|
MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES
|
|
)
|
|
RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free")
|
|
RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db"
|
|
# A run qualifies on EITHER criterion — they are an OR, not an AND. A short
|
|
# but long-lasting run counts, and so does a fast 5 km that takes under the
|
|
# duration bar. Do not reinstate an AND here: it would reject exactly the
|
|
# slow-run case this pair exists to allow. The duration arm is the shared
|
|
# MIN_WORKOUT_DURATION_MINUTES above; only the distance arm is run-specific.
|
|
MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km"
|
|
# GPS distance tracking undershoots on tight turns/tree cover/urban canyons;
|
|
# a run within 5% of the minimum is accepted rather than rejected on
|
|
# measurement noise. This is SEPARATE from WORKOUT_DURATION_LEEWAY_MINUTES:
|
|
# that one is a deliberate concession to the user, this one corrects a
|
|
# measurement error. Duration now gets a leeway too, but for the other reason.
|
|
RUNNERUP_DISTANCE_TOLERANCE = 0.05
|
|
MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP
|
|
EARLY_BIRD_START_HOUR = 5
|
|
EARLY_BIRD_END_HOUR = 8
|
|
EARLY_BIRD_END_MINUTE = 30
|
|
HEAT_SKIP_TEMP_THRESHOLD: int = 33 # °C — above this the heat-skip dialog is offered
|
|
HEAT_SKIP_CITY: str = "Warsaw"
|
|
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
|
|
# Helper script path (relative to this file)
|
|
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
|
# State file to track sick day usage and original config values
|
|
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
|
|
# Persistent sick-day history (rate-limit, debt, commitments, justifications).
|
|
# Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot.
|
|
SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json"
|
|
# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped.
|
|
SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
|
|
# State file tracking streak, skip credits, and early-bird extension weeks.
|
|
EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json"
|
|
# State file storing the base (pre-bonus) shutdown hours and last reset date.
|
|
SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
|
|
# Workout records this PC must keep deleted in the sync store, pushed as
|
|
# tombstones on every tick (see _sync_tombstones for why once is not enough).
|
|
SYNC_TOMBSTONES_FILE = Path(__file__).resolve().parent / "sync_tombstones.json"
|
|
# Self-expiring marker: "logged in during today's early-bird window, still
|
|
# waiting to see if a real workout shows up." Not a log.json entry —
|
|
# it's a same-day pending flag, checked against its own "date" field.
|
|
EARLY_BIRD_PENDING_FILE = Path(__file__).resolve().parent / "early_bird_pending.json"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wake-alarm integration (originally from wake_alarm._constants / _state).
|
|
# These must match the values used by the companion wake_alarm service.
|
|
# ---------------------------------------------------------------------------
|
|
# Days on which the wake alarm fires (0=Mon … 6=Sun).
|
|
ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6})
|
|
# How many hours after midnight the alarm triggers (configurable in wake_alarm).
|
|
WAKE_AFTER_HOURS: int = 8
|
|
# Path to the rtcwake binary.
|
|
RTCWAKE_BIN: str = "/usr/sbin/rtcwake"
|
|
# State file written by wake_alarm; read here to check for workout skip.
|
|
WAKE_STATE_FILE = (
|
|
Path(__file__).resolve().parent.parent / "wake_alarm" / "wake_state.json"
|
|
)
|
|
|
|
# Directories where RunnerUp writes per-activity TCX exports (File Synchronizer).
|
|
# Listed in preference order; both resolve to the same path on most devices.
|
|
RUNNERUP_EXPORT_DIRS: tuple[str, ...] = (
|
|
"/sdcard/Documents/RunnerUp",
|
|
"/storage/emulated/0/Documents/RunnerUp",
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync (phone workout data) — Workstream C, built on the shared crdt-sync
|
|
# library. GitHub is used purely as dumb file storage via the REST Contents
|
|
# API (not a git clone), same pattern as diet_guard's cross-device sync. The
|
|
# phone app pushes to devices/<SYNC_PHONE_DEVICE_ID>/log.json; the PC reads
|
|
# every device's log and pushes its OWN manual workouts to devices/pc/log.json
|
|
# (see _manual_push) — which needs a token with contents:write, not read-only.
|
|
# ---------------------------------------------------------------------------
|
|
SYNC_REPO_OWNER: str = "kuhyx"
|
|
SYNC_REPO_NAME: str = "syncs"
|
|
# The phone's pre-uuid device id. Still read (the phone may not have migrated
|
|
# yet), but no longer the only path the reader knows about -- it discovers
|
|
# every device directory.
|
|
SYNC_PHONE_DEVICE_ID: str = "phone"
|
|
# The id this machine pushed manual workouts under before per-install uuids.
|
|
# Kept so the sync layer still recognises devices/pc/ as its own and does not
|
|
# re-merge its own history as a peer's; drop to None once that path is gone.
|
|
SYNC_LEGACY_DEVICE_ID: str | None = "pc"
|
|
# This machine's sync id, minted once and persisted. A fixed "pc" collides as
|
|
# soon as a second machine takes the same role, and a reinstall would inherit
|
|
# the previous install's CRDT identity.
|
|
SYNC_DEVICE_ID_FILE: Path = (
|
|
Path.home() / ".local" / "share" / "screen_locker" / ".device_id"
|
|
)
|
|
# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The
|
|
# user creates this once via github.com and saves it here, mode 600. Never
|
|
# committed — this path is outside the repo entirely. Unlike diet_guard,
|
|
# this file being absent is a normal, expected state (sync is optional here).
|
|
SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token"
|
|
|
|
# Revision cache for cross-device sync. Without it every push re-uploads and
|
|
# every pull re-downloads regardless of change, which is the traffic the
|
|
# Firebase free tier's monthly budget depends on not happening.
|
|
SYNC_STATE_FILE: Path = (
|
|
Path.home() / ".local" / "share" / "screen_locker" / "sync_state.json"
|
|
)
|
|
SYNC_TIMEOUT_SECONDS: float = 10.0
|
|
|
|
# Published while the locker has DECIDED to lock but is waiting for a
|
|
# higher-ranked holder (wake_alarm) to release the screen. From outside the
|
|
# process that state is indistinguishable from a hang: on 2026-08-30 the
|
|
# locker sat in gatelock's queue for 2h58m and the only evidence anywhere was
|
|
# a single INFO line at the moment it started waiting.
|
|
QUEUE_STATE_FILE: Path = (
|
|
Path.home() / ".local" / "share" / "screen_locker" / "queue_state.json"
|
|
)
|
|
|
|
# Sync runs from the morning routine, which is started right after boot and
|
|
# right after resume — often seconds before networking is actually up. Such a
|
|
# run used to fail outright, and reported it as a permissions problem
|
|
# (2026-07-20: six workouts "FAILED" at 08:04 with the network still down, then
|
|
# pushed fine by hand minutes later on the same token). So retry before giving
|
|
# up. Exponential backoff from SYNC_RETRY_DELAY_SECONDS gives 2s + 4s + 8s =
|
|
# ~14s of patience — enough for DHCP/DNS to come up, without making a genuinely
|
|
# offline run drag the whole morning routine.
|
|
SYNC_RETRY_ATTEMPTS: int = 4
|
|
SYNC_RETRY_DELAY_SECONDS: float = 2.0
|