screen-locker/screen_locker/_constants.py
Krzysztof kuhy Rudnicki 9ab7c09c79
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The early-bird window is the wake-alarm carrot, not a clock
05:00-08:30 (09:00 with the extended reward) locked nobody but did hold the
workout lock off on a clock that knew nothing about whether the user was
up, next to the morning-session rung that did. EarlyBirdMixin._is_early_bird_time
is now that rung's verdict: open before the alarm, during the session, and
until 11:00 once it completed in time -- exactly what wake-alarm signs. The
wake_alarm_skip rung banks the pending marker so the carrot's end still tries
a phone/RunnerUp workout before locking; early-bird-workout-check.timer moves
to 09:30:30 and 11:00:30, the two possible ends. The consumer's boot-retry
window starts at 05:00 with the refresher.

Also: the sick screen lists four past justifications, one line each -- six
full-length ones pushed it 7 px past 1366x768, which the screens-fit gate
only sees with a real history on disk. Takes @types/node 26.6.2, eslint 10.11.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTdfXjbGYYrY7Quxcfz6mg
2026-09-20 08:39:44 +02:00

227 lines
12 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
# The sick screen lists past justifications one per line, at most this many
# and each cut to this many characters: six full-length ones pushed the
# screen 7 px past 1366x768, which the screens-fit gate only sees with a real
# history on disk. The review *count* above is for the budget logic, not the
# screen.
SICK_SCREEN_RECENT_LINES = 4
SICK_JUSTIFICATION_PREVIEW_CHARS = 36
# 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"
# The PC's copy of the phone's morning session, written HMAC-signed by
# wake-alarm (`python -m wake_alarm._session --refresh`, every 2 min through
# the morning). Read here, never fetched: wake-alarm is the only Firebase
# reader. XDG state rather than a sibling checkout, because the previous
# reader pointed at "../wake_alarm/wake_state.json" -- a path that stopped
# existing when the repos split -- and returned "no skip" for a year without
# a single error. Keep the path in step with wake_alarm/_constants.py.
MORNING_SESSION_FILE = (
Path.home() / ".local" / "state" / "wake_alarm" / "morning_session.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",
)
# Where RunnerUp's WebDAV synchroniser drops the same exports on this machine
# (dufs, see ~/src/dufs-cloud), and where endurain-import moves them once
# Endurain has them. Read straight off the disk, so a run is credited without
# the phone being adb-reachable -- on 2026-09-16 the TCX landed here at 20:08
# and the phone was only plugged in at 20:25. Same filenames as the phone
# export, optionally prefixed with the device name (RunnerUp_Pixel_6a_...).
RUNNERUP_WEBDAV_DIRS: tuple[Path, ...] = (
Path.home() / "data" / "cloud" / "RunnerUp",
Path.home() / "data" / "cloud" / "RunnerUp" / "processed",
)
# ---------------------------------------------------------------------------
# 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