#!/usr/bin/python3
"""ft-gazed: the gaze service. The headset's eye tracking, corrected, for the pointer.

Two settings in ~/.config/frametop.conf (the Gaze page of Frametop Input Settings), read
again when the file changes:
  GAZE_TRACKER=auto|own|steam
                           our own eye tracker (gaze/tracker/ft-eyes, ft-gaze's source "own";
                           this service runs it, see below) or SteamVR's. auto (the default)
                           is ours when it's installed (gaze/tracker/install.sh: the frame
                           grabber, and ft-eyes' Python in this checkout), else SteamVR's; it
                           switches when ours is installed or removed.
  GAZE_EYE=auto|left|right the eye bias (gazecal.EyeWeights): auto weights each eye by how far
                           off it was at your recent nudges (since our tracker's last
                           calibration: a new one starts them over); left or right counts that
                           eye twice as much as the other. Either eye alone carries the gaze
                           when the other isn't seen.
  GAZE_FUSION=off|on       with our tracker: SteamVR's gaze too (gazecal.Fusion, fusion.json).
                           SteamVR's two eyes' mean, corrected by an affine fitted at our
                           calibration's dots, is mixed half and half with ours where both our
                           eyes are found, and used alone where ours has one or none. off (the
                           default) until the bench says so (gaze/bench/compare.py's fusion).

Runs ft-gaze (in the dev container), and for every eye tracker sample (90 Hz):

  1. drops blinks: both eyes' openness under half its running median (each eye its own),
     or both lost (the tracker's variance for them, ft-gaze's "unc", over EYE_LOST);
     Looks down past the screens (pitch under KEYBOARD_PITCH, on no Frametop screen: at the
     keyboard, through the gap by the nose) aren't sent, so the pointer stays where it was
     instead of following you down; the tracker often loses an eye there (the lids come
     down), and that isn't counted as a lost eye either;
  2. combines the eyes, each corrected on its own. With SteamVR, that's each eye's own
     reading (set 2, ft-gaze's "left" and "right"), corrected by its calibration from
     ft-gazeprobe (calibration.json, reloaded when the probe changes it) plus what the
     pointer's corrections have taught that eye since (LiveCorrection, saved in
     pointer-lessons.json), then weighted by the eye bias. A lost or closed eye drops out.
     Our own tracker keeps its own calibration, so its eyes are used as they come, and with
     GAZE_FUSION=on (and a fusion.json) mixed with SteamVR's (above). When one of its eyes
     drops out or comes back, gazecal.EyeBridge eases the step between the two eyes' gaze
     and the one eye's over about 0.3 s, instead of a jump of about a degree. SteamVR's eyes count
     only when both are open and found, and not while SteamVR tracks one eye only;
  3. smooths it with a fixation lock (the running mean of the current fixation, 1 degree), on
     where the gaze points in the room, so a look at a screen stays put while the head turns
     (gazecal.GazeLock: ft-gaze gives each sample's head pose). With our tracker, an eye whose
     lids came down stays out for BLINK_SETTLE after they're up again;
  4. sends it to the pointer helper: "gz <yaw> <pitch> <raw yaw> <raw pitch>", head-relative
     degrees (yaw +left, pitch +up). The helper uses it only in gaze mode.

Without per-eye calibrations (a calibration from before the probe had the eyes as sources),
or with --source, it's the older path: one source, SteamVR's combined gaze (mmap set 1) by
default, corrected as a whole. There, with one eye lost or closed, the gaze comes from the
other (EyeFallback: that eye's own reading from set 2, plus what it usually reads against
the combined gaze, learned while both are seen). SteamVR's combined gaze (set 1) keeps going
on one eye too, but holds the lost eye's yaw, so it moves half as far sideways as the eyes
do. Before the fallback has learned an eye, set 1 is used as it is; set 2's combined
direction is the mean of the eyes' own (off by half of whatever the lost eye reads), so with
set 2 that sample is dropped, as is one where the angle between the eyes jumps more than 1.5
degrees from its median.

Lessons come back from the helper: when you nudge the gaze-placed pointer with the mouse and
click, it sends "lesson <raw yaw> <raw pitch> <true yaw> <true pitch>": where the raw gaze
was when the mouse took over, and where the pointer was when you clicked (you were looking
there). The gap is the tracker's error there. The raw gaze is the one sent, so it also says
when that look was (the history of what was sent), and so what each eye read then:
  - SteamVR: each eye learns its own error, unless the gaze was more than
    POINTER_GAZE_NUDGE_MAX degrees (frametop.conf, 55 by default: the helper's limit too)
    past the correction (then it wasn't a nudge onto what you looked at);
  - our tracker: unless it was more than POINTER_GAZE_NUDGE_MAX off, the look goes to it as
    a click ("click T YAW PITCH" on @ft_eyes), as the
    probe's clicks do, and it learns how far the headset has moved on your face. That's
    what it gets wrong, and after the headset was off, the first click resets it;
  - either way, how far off each eye was (before the lesson taught it anything) goes to the
    eye bias, for auto.

SteamVR's eye tracking log is followed for the headset going on (its eye model starts over,
and the error moves): lessons from before count less then, so the first few after it
relearn the offset.

Our own tracker (gaze/tracker/ft-eyes) runs here too, in the dev container, while
GAZE_TRACKER=own and the gaze is in use (below), or the gaze probe asks for it ("eyes SECONDS",
a lease the probe renews). It reads the eye-camera frames the root service frametop-eyegrab
copies (gaze/tracker/install.sh), which copies them only while ft-eyes runs. Which eye net it runs
(EYES_MODEL: models/catalog.py) is read at its start, so ft-eyes restarts when that changes; starting
it also runs `models/ft-models auto eyes` in the background (the catalog from GitHub, and the net
EYES_MODEL needs if it isn't downloaded), and ft-eyes moves to a net that brings.

Idle: ft-gaze and our own tracker run only while the gaze is in use: gaze mode on with someone
wearing the headset (the pointer helper says both: "gaze ? headset" -> "ok on|off worn|away"), a
check or calibration open or asked for, or a "wake" lease (the Gaze page of Frametop Input
Settings renews one while it's open). IDLE_AFTER after the last use they stop, and the frame
grabber goes idle with our tracker; with our tracker, that was over half a core with gaze mode
off. A check asked for while idle waits for the tracker to start (at most WAKE_SETTLE). Our
tracker's next click after it starts again re-seats it, as after the headset was off, so turning
gaze mode on after a while opens the quick check.

ft-gaze prints only the sources this uses ("sources LIST" on its stdin, see wanted_sources):
own and mmap1 with our tracker (and left, right with the fusion), left, right and mmap1 with
SteamVR's eyes, and every source
while a check or the calibration runs or is asked for, since those record them all. So
SteamVR's gaze action, the one source that calls into vrserver, is read only then (or with
--source action).

Checks and calibration (gaze/gazecheck.py): a one-dot quick check when the headset goes on or
our tracker asks for a click, and the full calibration when gaze mode comes on without one,
both in a panel fixed to the headset (gaze/panel/ft-gazepanel, which this service runs too).

Nothing here writes to SteamVR, its eye tracker, or its files: ft-gaze reads the eye
tracker's shared memory read-only.

Control socket: abstract unix datagram "@ft_gazed":
  lesson <rhy> <rhp> <thy> <thp>   from the pointer helper (see above)
  recheck <deg>                    from the pointer helper: a click's correction was past
                                   POINTER_GAZE_NUDGE_MAX, so the quick check (gazecheck.py)
  status                           reply: one JSON object
  forget                           drop what the lessons taught (the calibration stays)
  reload                           read calibration.json, fusion.json and the settings again
  eyes <seconds>                   keep our own tracker running that much longer (at most 120),
                                   whatever GAZE_TRACKER says: the probe's lease. Reply: "ok"
  wake <seconds>                   keep the gaze running (not idle) that much longer (at most 120),
                                   whatever gaze mode says: Input Settings' Gaze page. Reply: "ok"
  quickcal                         the one-dot check now (the calibration if there's none)
  calibrate                        the full calibration in the panel
  fitcheck                         the headset fit check in the panel
  fivecheck                        the five-dot check now (it otherwise follows a quick check
                                   that didn't fix the tracker)
  calaccept | calquit              from the pointer helper while the panel is up: take this dot
                                   now (a left click, Enter, a pinch, Meta+J, or a gaze
                                   precision or gaze drag press) | close it (a right click, Meta+K)

Options: --source action|mmap1|mmap2 (the older one-source path with that source, whatever
the settings say; set 2 was a little quieter in the probe, but loses the pointer whenever
the tracker loses an eye), -v (a status line every 5 s on stderr), --to NAME (send the gaze
to the abstract socket @NAME instead of the pointer helper; for testing: a helper without
gaze mode forwards what it doesn't know to its driver).
"""

import argparse
import json
import math
import os
import selectors
import signal
import socket
import statistics
import subprocess
import sys
import time
from collections import deque
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from gazecal import (DEFAULT_MODEL, EYE_FOUND, EYE_LOST, FUSION, MODELS, STATE, Correction, EyeBridge,  # noqa: E402
                     EyeFallback, EyeWeights, Fusion, GazeLock, LiveCorrection, SteamEyeLog, TrackedEye)
from gazecheck import Checks  # noqa: E402

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "models"))
import catalog  # noqa: E402  (which eye net EYES_MODEL means: models/catalog.py)

REPO = Path(__file__).resolve().parents[1]
HELPER = REPO / "gaze" / "build" / "ft-gaze"
IN_BOX = REPO / "scripts" / "in-box"  # runs a program in the container it was built in
ME = "\0ft_gazed"
POINTER = "\0ft_pointer_helper"
EYES_PROG = REPO / "gaze" / "tracker" / "ft-eyes"  # our own tracker
EYES_PYTHON = REPO / "gaze" / "tracker" / "build" / "venv" / "bin" / "python"  # numpy, OpenCV (build.sh)
EYES_SOCKET = "\0ft_eyes"                      # its control socket
EYES_CAMS = Path("/dev/shm/frametop-eyes-cams")  # the frames it reads (frametop-eyegrab.service)
EYEGRAB = (Path("/etc/frametop/ft-eyegrab"), Path("/etc/systemd/system/frametop-eyegrab.service"))  # tracker/install.sh
CONF = Path.home() / ".config" / "frametop.conf"
CALIBRATION = STATE / "calibration.json"
LESSONS = STATE / "pointer-lessons.json"
LESSON_LOG = STATE / "pointer-lessons.jsonl"
SOURCES = ("action", "mmap1", "mmap2", "left", "right")  # the ones with a calibration here
SIDES = ("left", "right")  # ft-gaze's order, and the sources for each eye alone
TRACKERS = ("steam", "own")
TRACKER_SETTINGS = ("auto",) + TRACKERS
BIASES = ("auto", "left", "right")
NUDGE_MAX = 55.0  # degrees: POINTER_GAZE_NUDGE_MAX's default, the largest lesson taken
HISTORY = 12.0    # seconds of the gaze sent, to find a lesson's look (the helper sends it up to 10 s later)
LOOK = 0.3        # seconds of samples before that moment make the look (the probe's fixation)
RETRY = 3.0       # seconds before starting ft-gaze again
EYES_RETRY = 10.0  # seconds before starting ft-eyes again after it stopped on its own
EYES_LEASE_MAX = 120.0
MODELS_CLI = REPO / "models" / "ft-models"  # "auto eyes": the eye catalog, and the net EYES_MODEL needs
SETTLE = 0.3      # seconds after an eye is found again before the fallback learns from it
IDLE_AFTER = 30.0  # seconds after the gaze was last in use before ft-gaze and our tracker stop
WAKE_SETTLE = 15.0  # seconds after waking that the tracker may take to start sending
WAKE_MAX = 120.0
KEYBOARD_PITCH = -20.0  # degrees: gaze under this, on no screen, is a look at the keyboard
BLINK_SETTLE = 0.1  # seconds our tracker's eye stays out after its lids came down: the pupil the
                    # lid cuts finds wrong for a few frames past SteamVR's openness (2026-10-17)


class PointerLessons(LiveCorrection):
    """LiveCorrection, with the offset held back: one lesson moves the whole correction by a
    third of what it measured, not all of it (two alike, half; three, three fifths). In the
    probe, clicks came thick and fast on a stale calibration, where the error was mostly one
    offset. Here lessons are few and the calibration is often fresh: in the first live test a
    6 degree lesson shifted everything 6 degrees, and the next target, 10 degrees away and
    1.4 off before, was 7.1 off (3.6 with this). Near the lesson, the kernel still takes up
    most of it (5.1 of the 6 degrees)."""

    RIDGE = [2.0] + LiveCorrection.RIDGE[1:]


def log(msg):
    print(f"ft-gazed: {msg}", file=sys.stderr, flush=True)


def venv_built(python):
    """Is a build/venv's python there? It's for the container: in a release its link points at the
    image's Python (/usr/bin/python3.14), which the host doesn't have, so the link itself counts."""
    return os.path.lexists(python)


def own_installed():
    """Is our own tracker installed: its frame grabber, and ft-eyes' Python here?"""
    return all(p.exists() for p in EYEGRAB) and venv_built(EYES_PYTHON)


def read_settings():
    """(tracker, GAZE_TRACKER, eye bias, nudge max) from frametop.conf, defaults for anything
    missing or unknown. The tracker is "steam" or "own": auto picks ours when it's installed."""
    conf = {}
    try:
        for line in CONF.read_text().splitlines():
            line = line.split("#", 1)[0].strip()
            if "=" in line:
                k, v = line.split("=", 1)
                conf[k.strip()] = v.strip().lower()
    except OSError:
        pass
    setting = conf.get("GAZE_TRACKER", "auto")
    setting = setting if setting in TRACKER_SETTINGS else "auto"
    tracker = setting if setting != "auto" else "own" if own_installed() else "steam"
    bias = conf.get("GAZE_EYE", "auto")
    try:
        nudge = min(max(float(conf.get("POINTER_GAZE_NUDGE_MAX", NUDGE_MAX)), 1.0), 110.0)  # the helper's range
    except ValueError:
        nudge = NUDGE_MAX
    return tracker, setting, bias if bias in BIASES else "auto", nudge


def read_fusion():
    """GAZE_FUSION from frametop.conf: True for on (off, the default, for anything else)."""
    try:
        for line in CONF.read_text().splitlines():
            k, _, v = line.split("#", 1)[0].partition("=")
            if k.strip() == "GAZE_FUSION":
                return v.strip().lower() == "on"
    except OSError:
        pass
    return False


def mtime(path):
    try:
        return path.stat().st_mtime
    except OSError:
        return None


class Service:
    fusion_on, fusion = False, None   # (set in __init__; here too for tests that build one by parts)

    def __init__(self, source, verbose, to=POINTER):
        self.override, self.verbose, self.to = source, verbose, to
        self.source = source or "mmap1"  # the older path's source
        STATE.mkdir(parents=True, exist_ok=True)
        self.tracker, self.tracker_setting, self.bias, self.nudge_max = read_settings()
        self.conf_mtime = mtime(CONF)
        self.models = {name: Correction() for name in SOURCES}
        self.mode = DEFAULT_MODEL
        self.cal_mtime = None
        self.lives = {name: PointerLessons() for name in SOURCES}
        self.weights = {t: EyeWeights(self.bias) for t in TRACKERS}
        self.own_made = None  # when our tracker's calibration was made, as its misses knew it
        self.dirty = False
        self.fusion_on = read_fusion()
        self.fusion, self.fusion_mtime = None, None
        self.load_fusion()
        self.load_calibration()
        self.load_lessons()
        self.steam = SteamEyeLog()
        self.steam.poll()
        self.refit()
        self.fix = GazeLock(radius=1.0)
        self.opens = (deque(maxlen=90), deque(maxlen=90))  # left, right
        self.low_until = [0.0, 0.0]  # sample time until which each eye counts as closed (BLINK_SETTLE)
        self.bridge = EyeBridge()    # our tracker: no jump when an eye drops out or comes back
        self.vergence = deque(maxlen=90)
        self.fallback = EyeFallback()
        # SteamVR's tracker following one eye only (gazecal.tracked_eye): 0 left, 1 right, or None.
        self.tracked = TrackedEye()
        self.one_eye = self.tracked()
        self.lost = [False, False]
        self.bad_at = [0.0, 0.0]    # sample time an eye was last lost or closed
        self.counts = {"samples": 0, "sent": 0, "blinks": 0, "one_eye": 0, "one_eye_used": 0, "lost_left": 0,
                       "lost_right": 0, "looking_down": 0, "dropped": 0, "lessons_taken": 0, "refused": 0,
                       "fused": 0, "steam_only": 0}
        self.last_sample = 0.0
        self.last = None
        self.last_kind = None
        # What was sent, for finding a lesson's look: (sample time, raw as sent, each eye's reading).
        self.history = deque()
        self.own = {}        # our tracker's last status reply
        self.own_at = 0.0
        self.eyes_proc = None  # ft-eyes, while it runs
        self.eyes_until = 0.0  # the probe's lease (monotonic time)
        self.eyes_restart_at = 0.0
        self.eyes_model = self.eyes_model_now()  # what EYES_MODEL means now: ft-eyes restarts when it changes
        self.models_proc = None  # ft-models auto eyes, while it runs
        self.awake = False      # ft-gaze (and our tracker) run: the gaze is in use (see the top)
        self.idle_at = 0.0      # idle from then, unless it's in use again first
        self.woke_at = 0.0
        self.wake_until = 0.0   # a "wake" lease

        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK)
        self.sock.bind(ME)
        self.out = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK)
        # To our tracker, with an address of its own, so its replies don't land on @ft_gazed.
        self.eyes_sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK)
        self.eyes_sock.bind("")
        self.sel = selectors.DefaultSelector()
        self.sel.register(self.sock, selectors.EVENT_READ, "control")
        self.sel.register(self.eyes_sock, selectors.EVENT_READ, "own")
        self.checks = Checks(self, self.sel)
        self.proc = None
        self.proc_sources = None  # what ft-gaze was last told to print
        self.mmap_unknown = False  # ft-gaze said SteamVR's eye-server.mmap has a layout it doesn't know
        self.buf = b""
        self.restart_at = 0.0
        self.running = True

    @property
    def kind(self):
        """"own" (our tracker), "eyes" (SteamVR's eyes, each corrected), or "source" (the
        older path: one SteamVR source, corrected as a whole)."""
        if self.override:
            return "source"
        if self.tracker == "own":
            return "own"
        # With one eye tracked, that eye's own calibration is enough.
        sides = SIDES if self.one_eye is None else (SIDES[self.one_eye],)
        return "eyes" if all(self.models[e].samples for e in sides) else "source"

    @property
    def fusing(self):
        """Our tracker's gaze mixed with SteamVR's (GAZE_FUSION=on, a fitted fusion.json)."""
        return self.kind == "own" and self.fusion_on and self.fusion is not None and self.fusion.ready()

    # --- Settings, calibration and lessons ---

    @staticmethod
    def eyes_model_now():
        """(EYES_MODEL, the folder it resolves to): ft-eyes reads it at start."""
        try:
            r = catalog.resolve("eyes")
            return r["setting"], r["folder"]
        except Exception as e:  # noqa: BLE001 (a broken catalog: ft-eyes falls back to the pupil finder)
            return f"error {e}", None

    def check_eyes_model(self):
        now = self.eyes_model_now()
        if now != self.eyes_model:
            log(f"eye model {now[0]}" + (f" ({now[1]})" if now[1] else "") + (": restarting ft-eyes" if self.eyes_proc else ""))
            self.eyes_model = now
            if self.eyes_proc:
                self.stop_eyes()
                self.eyes_restart_at = 0.0

    def models_auto(self):
        """The eye catalog from GitHub (at most every 15 minutes) and the net EYES_MODEL needs, if it's
        missing, in the background: ft-eyes starts with what's here, and moves to the new one after."""
        if self.models_proc and self.models_proc.poll() is None:
            return
        try:
            self.models_proc = subprocess.Popen([sys.executable, str(MODELS_CLI), "auto", "eyes"], stdin=subprocess.DEVNULL,
                                                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                                start_new_session=True)
        except OSError as e:
            log(f"couldn't check for eye models: {e}")

    def load_settings(self):
        self.conf_mtime = mtime(CONF)
        self.check_eyes_model()
        tracker, self.tracker_setting, bias, self.nudge_max = read_settings()
        fusion_on = read_fusion()
        if fusion_on != self.fusion_on:
            self.fusion_on = fusion_on
            log(f"fusion {'on' if fusion_on else 'off'}" + ("" if not fusion_on or (self.fusion and self.fusion.ready())
                                                            else f": no fitted {FUSION} yet (our tracker's calibration makes one)"))
            self.fix.reset()
        if (tracker, bias) != (self.tracker, self.bias):
            auto = ""
            if self.tracker_setting == "auto":
                auto = " (auto: ours is installed)" if tracker == "own" else " (auto: ours isn't installed)"
                if tracker == "steam" and EYEGRAB[1].exists() and not EYEGRAB[0].exists():
                    auto = (f" (auto: ours lost {EYEGRAB[0]}, which SteamOS updates delete unless it's kept;"
                            " reinstall it with gaze/tracker/install.sh)")
            log(f"tracker {tracker}{auto}, eye bias {bias}" + (f" (--source {self.override} wins)" if self.override else ""))
            self.tracker, self.bias = tracker, bias
            for w in self.weights.values():
                w.bias = bias
            self.fix.reset()

    def load_fusion(self):
        self.fusion_mtime = mtime(FUSION)
        self.fusion = Fusion.load(FUSION)
        if self.fusion:
            log(f"fusion: SteamVR's affine from {self.fusion.info.get('dots', 0)} dots "
                f"({self.fusion.info.get('how', '?')}, {self.fusion.info.get('made', '?')}), "
                + ("on" if self.fusion_on else "off (GAZE_FUSION)"))

    def load_calibration(self):
        try:
            mt = CALIBRATION.stat().st_mtime
            d = json.loads(CALIBRATION.read_text())
        except (OSError, ValueError):
            return
        self.cal_mtime = mt
        for name in SOURCES:
            if name in d:
                self.models[name].from_json(d[name])
        mode = d.get("_meta", {}).get("model")
        self.mode = mode if mode in MODELS else DEFAULT_MODEL
        log(f"calibration: {self.mode}, " + ", ".join(f"{n} {self.models[n].samples}" for n in (self.source,) + SIDES)
            + " samples")

    def load_lessons(self):
        try:
            d = json.loads(LESSONS.read_text())
        except (OSError, ValueError):
            d = {}
        # The first version kept one source's: {"source": NAME, "samples": [...]}.
        sources = d.get("sources") or ({d["source"]: d.get("samples", [])} if "source" in d else {})
        for name, samples in sources.items():
            if name in self.lives:
                self.lives[name].samples = samples[-PointerLessons.KEEP:]
        for t, misses in (d.get("misses") or {}).items():
            if t in self.weights:
                self.weights[t] = EyeWeights(self.bias, misses)
        self.own_made = d.get("own_made")
        log(", ".join(f"{n} {len(self.lives[n].samples)}" for n in (self.source,) + SIDES) + " lessons")

    def save_lessons(self):
        tmp = LESSONS.with_suffix(".tmp")
        tmp.write_text(json.dumps({"version": 2, "sources": {n: lv.samples for n, lv in self.lives.items() if lv.samples},
                                   "misses": {t: w.misses for t, w in self.weights.items()},
                                   "own_made": self.own_made}))
        tmp.replace(LESSONS)
        self.dirty = False

    def forget_lessons(self):
        """Drop what the lessons taught (the calibration stays)."""
        self.lives = {name: PointerLessons() for name in SOURCES}
        self.weights = {t: EyeWeights(self.bias) for t in TRACKERS}
        self.refit()
        self.save_lessons()

    def refit(self):
        for name, live in self.lives.items():
            live.wear_time = self.steam.worn()
            live.refit(self.models[name], self.mode)

    def correction(self, name, hy, hp):
        by, bp = self.models[name].get(hy, hp, self.mode)
        ly, lp = self.lives[name].get(hy, hp)
        return by + ly, bp + lp

    def look(self, ry, rp):
        """When the gaze sent as raw (ry, rp) was last sent, and each eye's median reading over
        the LOOK before it: (t, [(yaw, pitch) or None] * 2), or (None, None)."""
        key = f"{ry:.3f} {rp:.3f}"
        t = next((h[0] for h in reversed(self.history) if h[1] == key), None)
        if t is None:
            return None, None
        eyes = []
        for k in (0, 1):
            seen = [h[2][k] for h in self.history if t - LOOK <= h[0] <= t and h[2] and h[2][k]]
            eyes.append((statistics.median(e[0] for e in seen), statistics.median(e[1] for e in seen)) if seen else None)
        return t, eyes

    def lesson(self, rhy, rhp, thy, thp):
        kind = self.kind
        rec = {"time": time.time(), "kind": kind, "raw": [rhy, rhp], "true": [thy, thp], "wear": self.steam.worn()}
        if kind == "source":
            dy, dp = thy - rhy, thp - rhp  # the whole error there
            cy, cp = self.correction(self.source, rhy, rhp)
            left = math.hypot(dy - cy, dp - cp)
            rec.update(source=self.source, model=self.mode, correction=[cy, cp], lesson_deg=left)
            if left > self.nudge_max:
                rec["refused"] = f"more than {self.nudge_max:g} deg past the correction"
            else:
                self.lives[self.source].add({"time": rec["time"], "hy": rhy, "hp": rhp, "dy": dy, "dp": dp,
                                             "wy": 1.0, "wp": 1.0, "how": "pointer"}, self.models[self.source], self.mode)
            return self.taken(rec)
        # The raw gaze sent here is the corrected, combined one: its whole error is left.
        left = math.hypot(thy - rhy, thp - rhp)
        t, eyes = self.look(rhy, rhp)
        weights = self.weights[self.tracker if kind == "own" else "steam"]
        rec.update(tracker=self.tracker if kind == "own" else "steam", bias=self.bias, lesson_deg=left, look_t=t,
                   eyes=eyes, weights=[round(w, 3) for w in weights.weights()])
        if t is None:
            rec["refused"] = "that gaze isn't in the last few seconds sent"
        elif left > self.nudge_max:
            rec["refused"] = f"more than {self.nudge_max:g} deg off"
        if "refused" in rec:
            return self.taken(rec)
        if kind == "own":
            # Its eyes come calibrated: how far off each was is its miss. The click goes to it.
            miss = [math.hypot(thy - e[0], thp - e[1]) if e else None for e in eyes]
            try:
                self.eyes_sock.sendto(f"click {t:.6f} {thy:.4f} {thp:.4f}".encode(), EYES_SOCKET)
            except OSError as e:
                rec["refused"] = f"our tracker isn't running ({e})"
                return self.taken(rec)
        else:
            miss = []
            for name, e in zip(SIDES, eyes):
                if not e:
                    miss.append(None)
                    continue
                cy, cp = self.correction(name, *e)
                miss.append(math.hypot(thy - e[0] - cy, thp - e[1] - cp))
                self.lives[name].add({"time": rec["time"], "hy": e[0], "hp": e[1], "dy": thy - e[0], "dp": thp - e[1],
                                      "wy": 1.0, "wp": 1.0, "how": "pointer"}, self.models[name], self.mode)
        rec["miss"] = miss
        weights.add(miss)
        return self.taken(rec)

    def taken(self, rec):
        if "refused" in rec:
            self.counts["refused"] += 1
        else:
            self.counts["lessons_taken"] += 1
            self.dirty = True
        try:
            with open(LESSON_LOG, "a") as f:
                f.write(json.dumps(rec) + "\n")
        except OSError as e:
            log(f"lesson log: {e}")
        return rec

    # --- Idle (see the top) ---

    def use_reason(self):
        """Why the gaze is in use now, or None."""
        c = self.checks
        if c.gaze_on and c.headset is not False:
            return "gaze mode is on"
        if c.active or c.pending:
            return "a check"
        if time.monotonic() < self.wake_until:
            return "asked to stay awake"
        return None

    def idle_reason(self):
        c = self.checks
        if c.gaze_on is None:
            return "the pointer helper isn't answering (SteamVR not running?)"
        if c.gaze_on and c.headset is False:
            return "nobody is wearing the headset"
        return "gaze mode is off"

    def waking(self):
        """Idle, or awake too briefly for the tracker to be sending yet."""
        return not self.awake or time.monotonic() - self.woke_at < WAKE_SETTLE

    def update_awake(self):
        now = time.monotonic()
        why = self.use_reason()
        if why:
            self.idle_at = now + IDLE_AFTER
        if why and not self.awake:
            self.awake, self.woke_at, self.restart_at = True, now, 0.0
            log(f"awake: {why}")
        elif not why and self.awake and now >= self.idle_at:
            self.awake = False
            log(f"idle: {self.idle_reason()}; ft-gaze and our tracker stop until the gaze is used again")
            self.stop_helper()
            if self.eyes_proc and not self.eyes_wanted():
                self.stop_eyes()

    # --- ft-gaze ---

    def wanted_sources(self):
        """The sources ft-gaze should print (its --sources): what on_sample and the checks read.
        mmap1 always (blinks, lost eyes, and the headset going on, from its openness and
        variances); everything while a check runs or waits, since they record every source."""
        c = self.checks
        if c.active or c.pending:
            return "all"
        kind = self.kind
        if kind == "own":
            return "own,mmap1,left,right" if self.fusing else "own,mmap1"
        if kind == "eyes":
            return "left,right,mmap1"
        return ",".join(dict.fromkeys((self.source, "mmap1", "mmap2")))  # mmap2: each eye, for the fallback

    def sync_sources(self):
        want = self.wanted_sources()
        if not self.proc or want == self.proc_sources:
            return
        try:
            self.proc.stdin.write(f"sources {want}\n".encode())
            self.proc.stdin.flush()
        except (OSError, ValueError):
            return  # it's stopping: read_stdout notices
        self.proc_sources = want

    def start_helper(self):
        if not HELPER.exists():
            log(f"ft-gaze isn't built: run {REPO}/gaze/build.sh")
            self.restart_at = time.monotonic() + 30
            return
        env = dict(os.environ)
        env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}"  # podman needs the real one
        # ft-gaze quits when its stdin closes: the one thing distrobox passes on.
        sources = self.wanted_sources()
        self.proc = subprocess.Popen([str(IN_BOX), str(HELPER), "--watch-stdin", "--sources", sources], env=env,
                                     stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                     start_new_session=True)
        self.proc_sources = sources
        os.set_blocking(self.proc.stdout.fileno(), False)
        os.set_blocking(self.proc.stderr.fileno(), False)
        self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
        self.sel.register(self.proc.stderr, selectors.EVENT_READ, "stderr")
        self.buf = b""
        self.mmap_unknown = False
        log("ft-gaze started")

    def stop_helper(self):
        if not self.proc:
            return
        for f in (self.proc.stdout, self.proc.stderr):
            try:
                self.sel.unregister(f)
            except (KeyError, ValueError):
                pass
        if self.proc.stdin and not self.proc.stdin.closed:
            self.proc.stdin.close()
        try:
            self.proc.wait(timeout=2)
        except subprocess.TimeoutExpired:
            try:
                os.killpg(self.proc.pid, signal.SIGTERM)
            except ProcessLookupError:
                pass
        self.proc = None
        self.mmap_unknown = False

    def own_installed(self):
        return own_installed()  # (for the checks: the fit coach needs ft-eyes)

    def eyes_wanted(self):
        return (self.tracker == "own" and not self.override and self.awake) or time.monotonic() < self.eyes_until

    def start_eyes(self):
        """Our own tracker, in the container, with build/venv's numpy and OpenCV. Like
        ft-gaze, it quits when its stdin closes."""
        if not venv_built(EYES_PYTHON):
            log(f"ft-eyes isn't built: run {REPO}/gaze/tracker/build.sh")
            self.eyes_restart_at = time.monotonic() + 30
            return
        self.models_auto()
        self.eyes_model = self.eyes_model_now()
        env = dict(os.environ)
        env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}"
        self.eyes_proc = subprocess.Popen([str(IN_BOX), str(EYES_PYTHON), str(EYES_PROG), "-v", "--watch-stdin"],
                                          env=env, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
                                          stderr=subprocess.PIPE, start_new_session=True)
        os.set_blocking(self.eyes_proc.stderr.fileno(), False)
        self.sel.register(self.eyes_proc.stderr, selectors.EVENT_READ, "eyes")
        log("ft-eyes started" + ("" if EYES_CAMS.exists() else
                                 f": no {EYES_CAMS} yet (the frame grabber: gaze/tracker/install.sh)"))

    def stop_eyes(self):
        if not self.eyes_proc:
            return
        try:
            self.sel.unregister(self.eyes_proc.stderr)
        except (KeyError, ValueError):
            pass
        if self.eyes_proc.stdin and not self.eyes_proc.stdin.closed:
            self.eyes_proc.stdin.close()
        try:
            self.eyes_proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            try:
                os.killpg(self.eyes_proc.pid, signal.SIGTERM)
            except ProcessLookupError:
                pass
        self.eyes_proc = None
        self.own = {}

    def read_eyes(self):
        try:
            data = os.read(self.eyes_proc.stderr.fileno(), 65536)
        except BlockingIOError:
            return
        if not data:
            log(f"ft-eyes stopped (exit {self.eyes_proc.poll()}); again in {EYES_RETRY:.0f} s if still wanted")
            self.stop_eyes()
            self.eyes_restart_at = time.monotonic() + EYES_RETRY
            return
        for line in data.decode("utf-8", "replace").splitlines():
            if line.strip() and (self.verbose or "fps" not in line):
                log(line)

    def read_stdout(self):
        try:
            data = os.read(self.proc.stdout.fileno(), 65536)
        except BlockingIOError:
            return
        if not data:
            log(f"ft-gaze stopped (exit {self.proc.poll()}); again in {RETRY:.0f} s")
            self.stop_helper()
            self.restart_at = time.monotonic() + RETRY
            return
        self.buf += data
        *lines, self.buf = self.buf.split(b"\n")
        for line in lines:
            try:
                self.on_sample(json.loads(line))
            except (ValueError, KeyError, TypeError) as e:
                log(f"bad sample: {e}")

    def read_stderr(self):
        try:
            data = os.read(self.proc.stderr.fileno(), 65536)
        except BlockingIOError:
            return
        for line in data.decode("utf-8", "replace").splitlines():
            if line.strip():
                log(line)
            # For the Gaze page (gazecheck's problem): ft-gaze can't read SteamVR's tracker.
            if "layout is not recognized" in line:
                self.mmap_unknown = True
            elif "eye-server.mmap layout:" in line:
                self.mmap_unknown = False

    def judge_eyes(self, m1, down):
        """Which eyes (left, right) are closed, from SteamVR's openness (set 1); updates
        self.lost from its variances. Blinks and lost eyes are judged against the last second
        (see steady_samples: relative, because the lids come down looking down). An eye's
        floor comes from its good readings, so a lost eye doesn't drag it to 0."""
        low = [False, False]
        o = m1.get("open")
        if o and len(o) == 2:
            for k in (0, 1):
                hist = self.opens[k]
                good = [v for v in hist if v >= 0.12]
                floor = max(0.12, 0.5 * statistics.median(good)) if len(good) >= 30 else 0.12
                low[k] = o[k] < floor
                hist.append(o[k])
        unc = m1.get("unc")
        if unc and len(unc) == 2:
            for k in (0, 1):
                self.lost[k] = unc[k] > (EYE_FOUND if self.lost[k] else EYE_LOST)
            if not down:
                self.counts["lost_left"] += self.lost[0] and self.one_eye != 1
                self.counts["lost_right"] += self.lost[1] and self.one_eye != 0
        return low

    def on_sample(self, s):
        one = self.tracked()
        if one != self.one_eye:
            log("SteamVR now tracks both eyes" if one is None else
                f"SteamVR tracks the {SIDES[one]} eye only (Track Dominant Eye Only): going by that eye")
            self.one_eye = one
            self.fix.reset()
        self.checks.on_sample(s)
        kind = self.kind
        if kind != self.last_kind:
            log({"own": "our own tracker", "eyes": "SteamVR's eyes, each calibrated",
                 "source": f"SteamVR's {self.source}, calibrated as a whole"}[kind]
                + (f", eye bias {self.bias}" if kind != "source" else ""))
            self.last_kind = kind
            self.fix.reset()
        if kind == "source":
            self.on_source_sample(s)
        else:
            self.on_eyes_sample(s, kind == "own")

    def on_eyes_sample(self, s, own):
        m1 = s["src"].get("mmap1") or {}
        if own:
            src = s["src"].get("own") or {}
            if "hy" not in src:
                return
            eyes = [tuple(e) if e else None for e in (src.get("eyes") or [None, None])]
            hp, hit, then = src["hp"], src.get("hit"), src.get("hq")
        else:
            per = [s["src"].get(name) or {} for name in SIDES]
            eyes = [(p["hy"], p["hp"]) if "hy" in p else None for p in per]
            if not any(eyes):
                return
            one = self.one_eye
            hp, hit = (eyes[one] if one is not None and eyes[one] else next(e for e in eyes if e))[1], m1.get("hit")
            then = next((p["hq"] for p in per if "hq" in p), None)
        self.counts["samples"] += 1
        self.last_sample = time.monotonic()
        down = hp < KEYBOARD_PITCH and not hit
        low = self.judge_eyes(m1, down)
        if down:
            self.counts["looking_down"] += 1
            return
        if own and self.one_eye is not None:
            # SteamVR judges only that eye's openness, and a blink closes both: ours still
            # sees the other, so it stays in.
            low[1 - self.one_eye] = low[self.one_eye]
        if own:
            for k in (0, 1):
                if low[k]:
                    self.low_until[k] = s["t"] + BLINK_SETTLE
                elif s["t"] < self.low_until[k]:
                    low[k] = True
        # Our tracker finds the pupils itself; SteamVR's openness still marks the blinks.
        bad = [eyes[k] is None or low[k] or (not own and self.lost[k]) for k in (0, 1)]
        if not own and self.one_eye is not None:
            bad[1 - self.one_eye] = True  # SteamVR ignores that eye
        steam = None
        if own and self.fusing and self.one_eye is None:
            per = [s["src"].get(name) or {} for name in SIDES]
            steam = self.fusion.steam_gaze(*[(p["hy"], p["hp"]) if "hy" in p and not low[k] and not self.lost[k]
                                             else None for k, p in enumerate(per)])
        if all(bad) and steam is None:
            self.counts["blinks"] += 1
            return
        if any(bad):
            self.counts["one_eye"] += 1
            self.counts["one_eye_used"] += 1
        seen = [None if bad[k] else eyes[k] for k in (0, 1)]
        if own:
            corrected = seen
        else:
            corrected = []
            for name, e in zip(SIDES, seen):
                c = self.correction(name, *e) if e else None
                corrected.append((e[0] + c[0], e[1] + c[1]) if e else None)
        g = self.weights["own" if own else "steam"].combine(corrected)
        if own:
            g = self.bridge(s["t"], seen, g)
        if steam is not None:
            n = sum(e is not None for e in seen)
            self.counts["fused" if n == 2 else "steam_only"] += 1
            g = self.fusion.combine(g, n, steam)
        gy, gp = g
        fy, fp = self.fix(gy, gp, s["t"], then, (s.get("head") or {}).get("q"))
        self.send(s["t"], fy, fp, fy, fp, seen)

    def on_source_sample(self, s):
        src = s["src"].get(self.source) or {}
        if "hy" not in src:
            return
        self.counts["samples"] += 1
        self.last_sample = time.monotonic()
        m1 = s["src"].get("mmap1") or {}
        lr = src.get("lr", m1.get("lr"))
        down = src["hp"] < KEYBOARD_PITCH and not src.get("hit")
        low = self.judge_eyes(m1, down)
        if down:
            self.counts["looking_down"] += 1
            for k in (0, 1):
                self.bad_at[k] = s["t"]  # the fallback doesn't learn from these either
            return
        bad = [low[k] or self.lost[k] for k in (0, 1)]
        hy, hp = src["hy"], src["hp"]
        eyes = (s["src"].get("mmap2") or {}).get("eyes")
        one = self.one_eye
        if one is not None:
            # SteamVR's combined gaze already follows that eye alone; set 2's averages the
            # ignored one in, so mmap2 takes the eye's own reading. No fallback to learn.
            if bad[one]:
                self.counts["blinks"] += 1
                return
            if self.source == "mmap2":
                if not eyes:
                    self.counts["dropped"] += 1
                    return
                hy, hp = eyes[one]
            fy, fp = self.fix(hy, hp, s["t"], src.get("hq"), (s.get("head") or {}).get("q"))
            cy, cp = self.correction(self.source, fy, fp)
            self.send(s["t"], fy + cy, fp + cp, fy, fp, None)
            return
        if all(bad):
            self.counts["blinks"] += 1
            return
        for k in (0, 1):
            if bad[k]:
                self.bad_at[k] = s["t"]
        if any(bad):
            self.counts["one_eye"] += 1
            seen = 1 if bad[0] else 0
            est = self.fallback.get(seen, eyes[seen][0], eyes[seen][1]) if eyes else None
            if est:
                hy, hp = est
                self.counts["one_eye_used"] += 1
            elif self.source == "mmap2":
                self.counts["dropped"] += 1
                return
        else:
            if self.source == "mmap2":
                jump = (lr is not None and len(self.vergence) >= 30
                        and abs(lr - statistics.median(self.vergence)) > 1.5)
                if lr is not None:
                    self.vergence.append(lr)
                if jump:
                    self.counts["dropped"] += 1
                    return
            # Learn only once both have been seen for a moment: the tracker's filter starts
            # an eye over when it finds it again.
            if eyes and s["t"] - max(self.bad_at) > SETTLE:
                for k in (0, 1):
                    self.fallback.update(k, eyes[k][0], eyes[k][1], hy, hp)
        # The fixation lock works in degrees here, on the gaze's direction in the room.
        fy, fp = self.fix(hy, hp, s["t"], src.get("hq"), (s.get("head") or {}).get("q"))
        cy, cp = self.correction(self.source, fy, fp)
        self.send(s["t"], fy + cy, fp + cp, fy, fp, None)

    def send(self, t, hy, hp, rhy, rhp, eyes):
        self.last = (hy, hp, rhy, rhp)
        raw = f"{rhy:.3f} {rhp:.3f}"
        self.history.append((t, raw, eyes))
        while self.history and self.history[0][0] < t - HISTORY:
            self.history.popleft()
        try:
            self.out.sendto(f"gz {hy:.3f} {hp:.3f} {raw}".encode(), self.to)
            self.counts["sent"] += 1
        except OSError:
            pass  # the pointer helper isn't running

    # --- Control ---

    def on_control(self):
        while True:
            try:
                data, addr = self.sock.recvfrom(512)
            except BlockingIOError:
                return
            words = data.decode("utf-8", "replace").split()
            reply = None
            if words[:1] == ["lesson"] and len(words) == 5:
                try:
                    rec = self.lesson(*map(float, words[1:]))
                    self.checks.after_lesson(rec)
                    reply = "refused" if "refused" in rec else f"ok {rec['lesson_deg']:.2f}"
                    log(f"lesson {rec['lesson_deg']:.2f} deg at {rec['raw'][0]:+.1f},{rec['raw'][1]:+.1f}"
                        + (f", eyes off {', '.join('-' if m is None else f'{m:.2f}' for m in rec['miss'])}"
                           if rec.get("miss") else "")
                        + (f": {rec['refused']}" if "refused" in rec else ""))
                except ValueError:
                    reply = "error bad lesson"
            elif words[:1] == ["status"]:
                reply = json.dumps(self.status())
            elif words[:1] == ["forget"]:
                self.forget_lessons()
                reply = "ok"
            elif words[:1] and words[0] in ("quickcal", "calibrate", "fitcheck", "fivecheck", "calaccept", "calquit",
                                            "recheck"):
                reply = self.checks.command(words)
            elif words[:1] == ["eyes"] and len(words) == 2:
                try:
                    secs = min(max(float(words[1]), 0.0), EYES_LEASE_MAX)
                    self.eyes_until = max(self.eyes_until, time.monotonic() + secs)
                    reply = "ok"
                except ValueError:
                    reply = "error bad seconds"
            elif words[:1] == ["wake"] and len(words) == 2:
                try:
                    secs = min(max(float(words[1]), 0.0), WAKE_MAX)
                    self.wake_until = max(self.wake_until, time.monotonic() + secs)
                    self.update_awake()
                    reply = "ok"
                except ValueError:
                    reply = "error bad seconds"
            elif words[:1] == ["reload"]:
                self.load_settings()
                self.load_fusion()
                self.load_calibration()
                self.refit()
                reply = "ok"
            else:
                reply = "error unknown command"
            if reply and addr:
                try:
                    self.sock.sendto(reply.encode(), addr)
                except OSError:
                    pass

    def on_own(self):
        """Replies from our tracker: its status (JSON), or a click's "ok ..."/"fail ..."."""
        while True:
            try:
                data = self.eyes_sock.recv(4096).decode("utf-8", "replace")
            except (BlockingIOError, OSError):
                return
            if data.startswith("{"):
                try:
                    self.own, self.own_at = json.loads(data), time.monotonic()
                except ValueError:
                    pass
                else:
                    self.note_own_calibration()
            else:
                log(f"our tracker: {data}")

    def note_own_calibration(self):
        """Our tracker's status names a calibration other than the one its eye misses were
        measured on: they start over, and the eyes count alike until each has EyeWeights.MIN
        again. The misses say how far each eye's old mapping was off, and a new calibration
        replaces both mappings (it can drop an eye, or be someone else's). Live 2026-10-18 a
        calibration's own dots fed the right eye misses of the right-only one before it, and the
        balance after it was that one's. Whatever made the calibration: the panel, the probe."""
        made = (self.own.get("calibration") or {}).get("made")
        if not made or made == self.own_made:
            return
        w = self.weights["own"]
        if self.own_made is not None and any(w.misses):
            log(f"our tracker's calibration from {made}: the eye weights start over "
                f"(were {', '.join(f'{v:.2f}' for v in w.weights())} from {', '.join(str(len(m)) for m in w.misses)} misses)")
            self.weights["own"] = EyeWeights(self.bias)
        self.own_made = made
        self.dirty = True

    def status(self):
        kind = self.kind
        tracker = "own" if kind == "own" else "steam"
        w = self.weights[tracker]
        st = {"tracker": tracker, "kind": kind, "source": "own" if kind == "own" else self.source if kind == "source"
              else "left+right", "model": self.mode, "eye_bias": self.bias}
        if kind == "source":
            ly, lp = self.lives[self.source].offset()
            st.update(calibration_samples=self.models[self.source].samples, lessons=len(self.lives[self.source].samples),
                      lesson_offset=[round(ly, 3), round(lp, 3)])
        else:
            st.update(eye_weights=[round(v, 3) for v in w.weights()], eye_misses=[len(m) for m in w.misses],
                      eye_rms=[None if r is None else round(r, 2) for r in w.rms()])
        if kind == "eyes":
            st.update(calibration_samples=min(self.models[e].samples for e in SIDES),
                      lessons=max(len(self.lives[e].samples) for e in SIDES))
        if kind == "own":
            own = self.own if time.monotonic() - self.own_at < 5 else {}
            cal = own.get("calibration") or {}
            st.update(calibration_samples=cal.get("dots", 0), calibration_made=cal.get("made"),
                      lessons=max(len(m) for m in w.misses), own_running=bool(own),
                      own_reseat=any(e.get("reseat") for e in own.get("eyes", {}).values()),
                      size_guard=own.get("size_guard"), size_guard_counts=own.get("size_guard_counts"),
                      own_mapped=own.get("mapped"))
        f = self.fusion
        st["fusion"] = {"setting": "on" if self.fusion_on else "off", "in_use": self.fusing,
                        "fitted": bool(f and f.ready()), "dots": f.info.get("dots", 0) if f else 0,
                        "how": f.info.get("how") if f else None, "made": f.info.get("made") if f else None}
        st.update(awake=self.awake, idle=None if self.awake else self.idle_reason())
        st.update(eyes_process=self.eyes_proc is not None, eyegrab=EYES_CAMS.exists(),
                  tracker_setting=self.tracker_setting, own_installed=own_installed())
        st.update({"ft_gaze": self.proc is not None, "sample_age_s": round(time.monotonic() - self.last_sample, 2)
                   if self.last_sample else None, "headset_on": self.steam.wearing(),
                   "headset_on_since": self.steam.worn(), "last": [round(v, 2) for v in self.last] if self.last else None,
                   "eyes_lost": self.lost, "fallback_ready": [self.fallback.ready(0), self.fallback.ready(1)],
                   "checks": self.checks.status(), **self.counts})
        return st

    def periodic(self):
        if self.steam.poll() or any(lv.wear_time != self.steam.worn() for lv in self.lives.values()):
            if any(lv.wear_time != self.steam.worn() for lv in self.lives.values()):
                log("headset on again: older lessons count less until new ones come in")
            self.refit()
        if mtime(CALIBRATION) != self.cal_mtime:
            self.load_calibration()
            self.refit()
        if mtime(FUSION) != self.fusion_mtime:
            self.load_fusion()
        if mtime(CONF) != self.conf_mtime or (self.tracker_setting == "auto"
                                              and own_installed() != (self.tracker == "own")):
            self.load_settings()
        if self.models_proc and self.models_proc.poll() is not None:
            self.models_proc = None
            self.check_eyes_model()   # a net it fetched: ft-eyes moves to it
        self.update_awake()
        want = self.eyes_wanted()
        if want and not self.eyes_proc and time.monotonic() >= self.eyes_restart_at:
            self.start_eyes()
        elif not want and self.eyes_proc:
            log("ft-eyes no longer wanted: stopping it")
            self.stop_eyes()
        if self.eyes_proc:
            try:
                self.eyes_sock.sendto(b"status", EYES_SOCKET)
            except OSError:
                pass  # not up yet: status() says so once the last answer is old
        self.checks.periodic()
        if self.dirty:
            self.save_lessons()

    def run(self):
        next_periodic = time.monotonic()
        next_verbose = time.monotonic() + 5
        while self.running:
            now = time.monotonic()
            if self.awake and not self.proc and now >= self.restart_at:
                self.start_helper()
            for key, _ in self.sel.select(timeout=0.05 if self.checks.active else 0.5):
                if key.data == "control":
                    self.on_control()
                elif key.data == "checks":
                    self.checks.on_readable()
                elif key.data == "eyes_reply":
                    self.checks.on_eyes_reply(key.fileobj)
                elif key.data == "panel" and self.checks.panel_proc:
                    self.checks.read_panel()
                elif key.data == "own":
                    self.on_own()
                elif key.data == "eyes" and self.eyes_proc:
                    self.read_eyes()
                elif key.data == "stdout" and self.proc:
                    self.read_stdout()
                elif key.data == "stderr" and self.proc:
                    self.read_stderr()
            self.checks.tick()
            self.sync_sources()
            if now >= next_periodic:
                self.periodic()
                next_periodic = now + 1.0
            if self.verbose and now >= next_verbose:
                log(json.dumps(self.status()))
                next_verbose = now + 5
        self.checks.stop()
        self.stop_helper()
        self.stop_eyes()
        if self.dirty:
            self.save_lessons()


def main():
    ap = argparse.ArgumentParser(description="The gaze service: corrected eye tracking for the pointer")
    ap.add_argument("--source", choices=["action", "mmap1", "mmap2"],
                    help="the older one-source path with this SteamVR source, whatever the settings say")
    ap.add_argument("-v", "--verbose", action="store_true")
    ap.add_argument("--to", default="ft_pointer_helper", help="abstract socket to send the gaze to")
    args = ap.parse_args()
    try:
        service = Service(args.source, args.verbose, "\0" + args.to)
    except OSError as e:
        log(f"can't bind @ft_gazed (already running?): {e}")
        sys.exit(1)

    def stop(*_):
        service.running = False
    signal.signal(signal.SIGTERM, stop)
    signal.signal(signal.SIGINT, stop)
    service.run()


if __name__ == "__main__":
    main()
