#-----------------------------------------------------------------------------------------------------------------------
CURRENT_VERSION = "4.1.0.7"
# Boot-fireworks false trigger off (mpv D3D present ships; celebrate on real wins only).
TEMP_BOOT_FIREWORKS_TEST = False
# Half-res A/B abandoned (~9.5 fps).
TEMP_FIREWORKS_HALFRES_TEST = False
# d3d11va->BGR->pygame was ~11 fps (worse than ~17 sw). Off; Win11 OHD uses mpv D3D present.
TEMP_FFMPEG_HWACCEL_TEST = False
#-----------------------------------------------------------------------------------------------------------------------
import os
import sys
import copy
import glob
import time
import pygame
import qrcode
import random
import serial
import socket
import pathlib
import secrets
import shlex
import ipaddress
import threading
import subprocess
import json
import shutil
from typing import Any
from urllib.parse import urlparse
from pyvidplayer2 import (
    READER_FFMPEG,
    Video,
)

# Newer pyvidplayer2 (Win11 path pinning / hwaccel patch). Pi field often has
# 0.9.29 without these symbols - hard import crashed pos5 boot (2026-08-02).
try:
    from pyvidplayer2 import (  # type: ignore[attr-defined]
        get_ffmpeg_loglevel,
        get_ffmpeg_path,
        set_ffmpeg_path,
        set_ffprobe_path,
    )
except ImportError:

    def get_ffmpeg_loglevel() -> str:
        return "error"

    def get_ffmpeg_path() -> str:
        return "ffmpeg"

    def set_ffmpeg_path(_path: str) -> None:
        return None

    def set_ffprobe_path(_path: str) -> None:
        return None
from dataclasses import dataclass, replace
from natsort import natsorted, ns
from bottle import route, run, get, post, request, response, static_file, ServerAdapter
from wsgiref.simple_server import WSGIRequestHandler, make_server
from extended_configparser.parser import ExtendedConfigParser
from ethergold_reader import (
    build_progressives_sync_payload,
    default_local_progressives_path,
    format_dollars,
    is_remote_progressives_source,
    read_config_ini_file,
    resolve_progressives_source,
)
from enrollment import (
    ASSET_UNSET,
    read_config_content_settings,
    resolve_content_sync_targets,
    upsert_enrollment_from_identity,
)
from player_odometer import PlayerProgressiveDisplay
from progressive_fade import CROSSFADE_DURATION_SEC, CROSSFADE_STEP, crossfade_step_sleep
from ohd_progressive_modes import (
    OhdProgressiveScheduler,
    clamp_mode_1_display_time,
    coalesce_schedule,
    load_ohd_progressive_settings,
    summarize_schedule,
)
from floor_games_sync import (
    SOURCE_P3,
    build_floor_games_payload,
    fetch_remote_floor_games,
    floor_games_url_for_host,
    floor_games_url_from_progressives_source,
    load_local_floor_games,
    local_ethergold_floor_games_path,
)
from machine_identity import persist_machine_identity_from_gamestats, read_machine_identity
from ohd_remote_setup import register_ohd_setup_routes
from win32_kiosk import (
    apply_win32_kiosk_window,
    configure_win32_monitor,
    maybe_refresh_win32_kiosk_window,
    start_win32_kiosk_bootstrap,
)
from p3_gamestats import (
    GAMESTATS_CMD,
    READ_UNTIL,
    credit_from_parse,
    normalize_gamestats_bytes,
    parse_gamestats_csv,
)
from p3_targa_capture import (
    DEFAULT_OUT as P3_GRABS_DIR,
    fetch_targa_on_serial,
    run_p3_grab_sync,
    save_targa_capture,
    start_evdev_grab_listener,
    start_p3_grab_flag_listener,
)
from fireworks_ipc import (
    clear_fireworks_request,
    duration_from_request,
    format_progwin_display,
    fireworks_request_is_fresh,
    fireworks_url_from_progressives_source,
    is_local_jackpot_winner,
    read_fireworks_request,
    resolve_fireworks_request_path,
)
from fireworks_watcher import FireworksRequestWatcher, RemoteFireworksRequestWatcher
from fireworks_mpv_present import mpv_available, play_with_mpv
from helpreq_display import HelpreqSession
from current_game_ipc import (
    current_game_ipc_consume_enabled,
    clear_preboot_progressive_runtime,
    game_index_from_payload,
    resolve_current_game_path,
    this_boot_current_game_payload,
    write_current_game,
    write_df_displayed_game,
    write_pogcc_boot_arm,
    write_pogcc_telemetry,
)
from current_game_watcher import CurrentGameWatcher
from config_ini_persist import (
    call_never_raise,
    warn_if_config_unwritable,
    write_config_ini as persist_config_ini,
)
import heltec_cdc
#-----------------------------------------------------------------------------------------------------------------------
# TEST ZONE - defaults; load_config() sets SCREEN_* from display_mode (TBX/OHD/MINI).
SCREEN_WIDTH = 1920
SCREEN_HEIGHT = 1080
# Asset folder under Assets/**/{ASSET_RESOLUTION}/ - may differ from WxH when Mini
# uses an overscan pack (e.g. 1920x360-96) while HDMI/pygame stay 1920x360.
ASSET_RESOLUTION = "1920x1080"
DISPLAY_MODE_TBX = "TBX"
DISPLAY_MODE_OHD = "OHD"
DISPLAY_MODE_MINI = "MINI"  # Pi 1920x360 OHD Mini (OHD progressive behavior)
DISPLAY_MODE_STRIP = DISPLAY_MODE_MINI  # legacy alias for MINI
DISPLAY_MODE = DISPLAY_MODE_TBX
# Win11 only: which monitor gets fullscreen. Empty/auto = match SCREEN_WxH if present.
WIN32_MONITOR = ""


def is_ohd_mode() -> bool:
    """True for full OHD (3840x1080) or Mini OHD (1920x360)."""
    return DISPLAY_MODE in (DISPLAY_MODE_OHD, DISPLAY_MODE_MINI)
OHD_PROGRESSIVE_CROSSFADE_SEC = CROSSFADE_DURATION_SEC
MODE4_DUMP_FLAG = "./run/dump_mode4.request"
MODE4_DUMP_LOG = "./log/mode4_weights.txt"
P3_GRAB_FLAG = "./run/p3_grab.request"
P3_GRAB_STATUS = "./run/p3_grab.status.json"
_ohd_last_prog_snapshot = None
_ohd_last_prog_index = None
_ohd_staged_games_this_cycle: set[int] = set()
_tbx_staged_fade_game_id = None
#-----------------------------------------------------------------------------------------------------------------------
win = None
font = None
splash = None
_STAGING_PENDING_POLL_SEC = 10.0
_staging_pending_cached = {"df": False, "eg": False}
_staging_pending_checked_at = 0.0
_WLAN0_OCTET_POLL_SEC = 10.0
_wlan0_octet_cached = ""
_wlan0_octet_checked_at = 0.0
_ll_hud_font = None
_ll_hud_font_px = 0


def _ota_stamp_tuple(stamp: str):
    parts = []
    for tok in (stamp or "").strip().split("."):
        if not tok.isdigit():
            return None
        parts.append(int(tok))
    if not parts:
        return None
    while len(parts) < 4:
        parts.append(0)
    return tuple(parts[:4])


def _ota_stamp_is_older(left: str, right: str) -> bool:
    a, b = _ota_stamp_tuple(left), _ota_stamp_tuple(right)
    if a is None or b is None:
        return False
    return a < b


def _read_version_txt(path: str) -> str:
    try:
        with open(path, encoding="utf-8") as handle:
            return handle.readline().strip()
    except OSError:
        return ""


def _staging_pending_flag_path() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "run", "staging-pending")


def _eg_staging_pending_flag_path() -> str:
    return os.path.join(os.path.expanduser("~"), "EtherGold", "run", "staging-pending")


def _df_live_root() -> str:
    return os.path.dirname(os.path.abspath(__file__))


def _df_staging_root() -> str:
    """Pi: ~/DyzineFrame-Staging. Win11: sibling of Live (C:\\DyzineFrame-Staging)."""
    if sys.platform == "win32":
        parent = os.path.dirname(_df_live_root())
        return os.path.join(parent, "DyzineFrame-Staging")
    return os.path.join(os.path.expanduser("~"), "DyzineFrame-Staging")


def _df_rollback_root() -> str:
    if sys.platform == "win32":
        parent = os.path.dirname(_df_live_root())
        return os.path.join(parent, "DyzineFrame-Rollback")
    return os.path.join(os.path.expanduser("~"), "DyzineFrame-Rollback")


def _refresh_staging_pending_flag() -> None:
    global _staging_pending_cached, _staging_pending_checked_at
    now = time.monotonic()
    if (now - _staging_pending_checked_at) < _STAGING_PENDING_POLL_SEC:
        return
    _staging_pending_checked_at = now
    home = os.path.expanduser("~")
    df_root = _df_live_root()
    df_flag = os.path.isfile(_staging_pending_flag_path())
    if df_flag:
        live_v = _read_version_txt(os.path.join(df_root, "VERSION.txt")) or CURRENT_VERSION
        staged_v = _read_version_txt(os.path.join(_df_staging_root(), "VERSION.txt"))
        if _ota_stamp_is_older(staged_v, live_v):
            df_flag = False
    eg_flag = False
    if sys.platform != "win32":
        eg_flag = os.path.isfile(_eg_staging_pending_flag_path())
        if eg_flag:
            live_eg = _read_version_txt(os.path.join(home, "EtherGold", "VERSION.txt"))
            if not live_eg:
                live_eg = _read_version_txt(os.path.join(home, "EtherGold", "prod", "VERSION.txt"))
            staged_eg = _read_version_txt(os.path.join(home, "EtherGold-Staging", "VERSION.txt"))
            if not staged_eg:
                staged_eg = _read_version_txt(
                    os.path.join(home, "EtherGold-Staging", "prod", "VERSION.txt")
                )
            if _ota_stamp_is_older(staged_eg, live_eg):
                eg_flag = False
    _staging_pending_cached = {"df": df_flag, "eg": eg_flag}


def _ipv4_last_octet(addr: str) -> str:
    """Last IPv4 octet for HUD. Unresolved -> '--' (ASCII only; never mojibake)."""
    try:
        ip = ipaddress.IPv4Address((addr or "").strip())
    except (ipaddress.AddressValueError, ValueError):
        return "--"
    return str(int(ip) & 0xFF)


def _ioctl_iface_ipv4(ifname: str) -> str:
    """IPv4 of one Linux iface via SIOCGIFADDR, or ''."""
    if not sys.platform.startswith("linux") or not ifname:
        return ""
    try:
        import fcntl
        import struct

        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            packed = struct.pack("256s", ifname[:15].encode("ascii", "ignore"))
            info = fcntl.ioctl(sock.fileno(), 0x8915, packed)  # SIOCGIFADDR
            return socket.inet_ntoa(info[20:24])
        finally:
            sock.close()
    except OSError:
        return ""


def _route_primary_ipv4() -> str:
    """Source address toward office LAN / internet (may be POGCOM wlan1 — last resort)."""
    if not sys.platform.startswith("linux"):
        return ""
    for dest in ("10.1.10.1", "192.168.3.1", "8.8.8.8"):
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            try:
                sock.connect((dest, 80))
                ip = sock.getsockname()[0]
            finally:
                sock.close()
            if ip and not str(ip).startswith("127."):
                return str(ip)
        except OSError:
            continue
    return ""


def _wlan0_ipv4() -> str:
    """Admin Wi-Fi (SSH / splash identity). Dual-radio: wlan0=admin, wlan1=POGCOM."""
    return _ioctl_iface_ipv4("wlan0")


def _admin_ipv4() -> str:
    """Pi HUD / splash identity: wlan0 admin first — never prefer POGCOM wlan1."""
    if not sys.platform.startswith("linux"):
        return ""
    for ifname in ("wlan0", "eth0"):
        ip = _ioctl_iface_ipv4(ifname)
        if ip:
            return ip
    return _route_primary_ipv4()


def _refresh_wlan0_octet() -> str:
    global _wlan0_octet_cached, _wlan0_octet_checked_at
    now = time.monotonic()
    if (now - _wlan0_octet_checked_at) < _WLAN0_OCTET_POLL_SEC:
        return _wlan0_octet_cached
    _wlan0_octet_checked_at = now
    _wlan0_octet_cached = _ipv4_last_octet(_admin_ipv4())
    return _wlan0_octet_cached


def _refresh_hud_octet() -> str:
    """Own-machine last octet for lower-left HUD (all screens)."""
    if sys.platform == "win32":
        get_IP()
        return _ipv4_last_octet(local_ip if isinstance(local_ip, str) else "")
    return _refresh_wlan0_octet()


def _lower_left_hud_font(px: int):
    global _ll_hud_font, _ll_hud_font_px
    if _ll_hud_font is not None and _ll_hud_font_px == px:
        return _ll_hud_font
    try:
        _ll_hud_font = pygame.font.Font(pygame.font.get_default_font(), px)
        _ll_hud_font_px = px
    except Exception:
        _ll_hud_font = None
        _ll_hud_font_px = 0
    return _ll_hud_font


def overlay_staging_pending_dot() -> None:
    """Lower-left: own last octet, then green discs when Staging is newer than Live.

    Pi: wlan0 admin octet (wlan1 is POGCOM — never use for identity).
    Win11 OHD: get_IP() octet. Staging older than Live is not pending.
    Left disc = DyzineFrame, right = EtherGold (Pi only).
    """
    octet = _refresh_hud_octet()
    _refresh_staging_pending_flag()
    df_pending = bool(_staging_pending_cached.get("df"))
    eg_pending = bool(_staging_pending_cached.get("eg"))
    if not octet and not df_pending and not eg_pending:
        return
    try:
        surface = pygame.display.get_surface()
    except Exception:
        return
    if surface is None:
        return
    height = surface.get_height()
    diameter = max(10, int(round(height * 0.0132)))
    radius = max(5, diameter // 2)
    inset = max(12, int(round(height * 0.015)))
    gap = radius
    cy = height - inset - radius
    x = inset
    if octet:
        font_px = max(12, diameter)
        hud_font = _lower_left_hud_font(font_px)
        if hud_font is not None:
            try:
                label = hud_font.render(f" {octet} ", True, (255, 255, 255), (0, 0, 0))
                rect = label.get_rect()
                rect.midleft = (x, cy)
                surface.blit(label, rect)
                x = rect.right + gap
            except Exception:
                pass
    color = (48, 196, 88)
    cx = x + radius
    if df_pending:
        pygame.draw.circle(surface, color, (cx, cy), radius)
        cx += (2 * radius) + gap
    if eg_pending:
        pygame.draw.circle(surface, color, (cx, cy), radius)


def _lsusb_blob() -> str:
    if not IS_LINUX:
        return ""
    try:
        out = subprocess.run(
            ["lsusb"], capture_output=True, text=True, check=False, timeout=2
        )
    except (OSError, subprocess.TimeoutExpired):
        return ""
    return (out.stdout or "").lower()


def _heltec_cdc_on_bus() -> bool:
    """OTG CDC only (303a:0002). JTAG 303a:1001 also makes ttyACM0 - that is not Topbox."""
    return heltec_cdc.cdc_on_bus(_lsusb_blob())


def _heltec_jtag_on_bus() -> bool:
    return heltec_cdc.jtag_on_bus(_lsusb_blob())


def _heltec_usb_action() -> str:
    return heltec_cdc.usb_action(_lsusb_blob())


def _wait_heltec_cdc(timeout_s: float = 3.0) -> bool:
    """Poll lsusb until OTG CDC remounts (after usbreset 1001 or Heltec RST)."""
    deadline = time.perf_counter() + timeout_s
    while time.perf_counter() < deadline:
        if _heltec_cdc_on_bus():
            return True
        time.sleep(0.15)
    return False


def _listen_fault_text() -> str | None:
    """HDMI banner when copper listen is dead so $0.00 is not a live jackpot.

    OHD signs use remote HTTP + PLEASE WAIT. TopBox only. Grace after boot
    unlink of progressives.csv.
    """
    global _LISTEN_FAULT_CACHE
    if not IS_LINUX or TEST_MODE_NO_SERIAL or is_ohd_mode():
        return None
    if _DF_BOOT_MONO <= 0 or (time.monotonic() - _DF_BOOT_MONO) < 25.0:
        return None
    now = time.monotonic()
    if now - float(_LISTEN_FAULT_CACHE.get("t") or 0) < 2.0:
        return _LISTEN_FAULT_CACHE.get("text")
    text = None
    tty_ok = bool(glob.glob("/dev/ttyXRUSB*"))
    listen_ok = False
    try:
        proc = subprocess.run(
            ["pgrep", "-f", "pog422_listen.py"],
            capture_output=True,
            check=False,
            timeout=1,
        )
        listen_ok = proc.returncode == 0
    except (OSError, subprocess.TimeoutExpired):
        listen_ok = False
    csv_path = default_local_progressives_path()
    csv_missing = False
    try:
        csv_missing = os.path.getsize(csv_path) <= 0
    except OSError:
        csv_missing = True
    listen_down = (not tty_ok) or (not listen_ok)
    if listen_down:
        text = "Listen down - progressives not live (not a $0 jackpot)"
    elif csv_missing:
        text = "progressives.csv missing - listen will rewrite"
    _LISTEN_FAULT_CACHE = {"t": now, "text": text}
    return text


def _overlay_operator_banners(surface) -> None:
    """Red operator lines on every present (splash, video, meters)."""
    lines: list[str] = []
    if not TEST_MODE_NO_SERIAL and POGCC_COM_OWNER != "ethergold":
        if _heltec_usb_action() == heltec_cdc.ACTION_USBRESET_JTAG:
            lines.append(
                "Heltec USB remounting (was JTAG) - wait, do not reboot the Pi"
            )
        elif HELTEC_USB_FAIL:
            if _heltec_cdc_on_bus():
                lines.append(
                    "Heltec USB Fail - power-cycle Heltec 5V (RST is not enough if ttyACM0 did not bounce)"
                )
            else:
                lines.append("Heltec USB Fail - reboot the Heltec, not the Pi")
    listen_text = _listen_fault_text()
    if listen_text:
        lines.append(listen_text)
    if not lines:
        return
    try:
        px = max(18, min(32, SCREEN_HEIGHT // 36))
        banner_font = pygame.font.Font(pygame.font.get_default_font(), px)
    except Exception:
        return
    height = surface.get_height()
    width = surface.get_width()
    y = height - max(16, int(round(height * 0.04)))
    for line in reversed(lines):
        try:
            banner = banner_font.render(f" {line} ", True, (255, 240, 240), (160, 16, 16))
        except Exception:
            continue
        rect = banner.get_rect()
        rect.midbottom = (width // 2, y)
        surface.blit(banner, rect)
        y -= rect.height + 6


def _expire_helpreq_display() -> None:
    """Drop Service Requested HDMI after 30s even if Heltec never sends Cancel."""
    global DISPLAY_HELPREQ
    expired = False
    lock = mutex
    if lock is None:
        expired = _HELPREQ.poll()
        DISPLAY_HELPREQ = _HELPREQ.showing
    else:
        with lock:
            expired = _HELPREQ.poll()
            DISPLAY_HELPREQ = _HELPREQ.showing
    if expired:
        print(
            "POG-CC helpreq HDMI timeout (30s) - waiting for a new trigger",
            flush=True,
        )


def _df_present() -> None:
    _expire_helpreq_display()
    overlay_staging_pending_dot()
    try:
        surface = pygame.display.get_surface()
    except Exception:
        surface = None
    if surface is not None:
        if DRAW_EDGE_TEST:
            _draw_edge_bezel_test(surface)
        else:
            _overlay_operator_banners(surface)
    pygame.display.update()


_EDGE_TEST_CACHE = None  # (w, h, surface)


def _draw_edge_bezel_test(surface) -> None:
    """Black field + labeled horizontal lines every 10 rows (overscan measure).

    Full-width white line at y=0,10,20,... with large ``y=NNN`` text on that
    scanline. Read the first fully visible label - that is the first visible
    framebuffer row (approx). Avoids dense-bar moirÃ© / miscounts.
    """
    global _EDGE_TEST_CACHE
    w, h = surface.get_size()
    if _EDGE_TEST_CACHE is None or _EDGE_TEST_CACHE[0] != w or _EDGE_TEST_CACHE[1] != h:
        ruler = pygame.Surface((w, h))
        ruler.fill((0, 0, 0))
        white = (255, 255, 255)
        # 1px left/right markers
        for yy in range(h):
            ruler.set_at((0, yy), white)
            ruler.set_at((w - 1, yy), white)
        # Font sized to fit between 10px ticks on a 360 strip
        try:
            font_px = max(14, min(28, 9))
            f = pygame.font.Font(None, 22)
        except Exception:
            f = None
        for y in range(0, h, 10):
            pygame.draw.line(ruler, white, (0, y), (w - 1, y), 1)
            if f is not None:
                label = f"y={y}"
                text = f.render(label, True, white)
                # Place text just below the line so the line itself stays readable
                ty = min(h - text.get_height(), y + 1)
                # Left and center so one survives side crop
                ruler.blit(text, (4, ty))
                ruler.blit(text, ((w - text.get_width()) // 2, ty))
        # Bottom edge marker
        pygame.draw.line(ruler, white, (0, h - 1), (w - 1, h - 1), 1)
        if f is not None:
            text = f.render(f"y={h - 1} bottom", True, white)
            ruler.blit(text, (4, max(0, h - 1 - text.get_height())))
        _EDGE_TEST_CACHE = (w, h, ruler)
    surface.blit(_EDGE_TEST_CACHE[2], (0, 0))
surface1 = None
surface2 = None
fireworks = None  # Special video
attendant = None  # Special video
dooropen = None   # Special video
helpreq = None    # Special video
mutex = None
thread1 = None
thread2 = None
thread3 = None
thread4 = None
sds_event = None
local_ip = None
wifi_ssid = None
wifi_pwd = None
local_ip_last = 0
curr_index = 0
DOWNLOAD_URL_IP = None
DOWNLOAD_URL_PORT = None
ASSET_NUMBER = ASSET_UNSET
POG_ASSET_NUMBER = None  # legacy alias; kept in sync with ASSET_NUMBER
MACHINE_ROLE = ""
BANK_NUMBER = 0
TERM_NUMBER = 0
TOPBOX_HOST = ""
TARGET_WEBPAGE = None
TARGET_FOLDER = None
ENROLLMENT_LOCATION = None
CONTENT_SYNC_ERROR = None
CONFIG_FILE = "config.ini"
IS_WINDOWS = sys.platform == "win32"
IS_LINUX = sys.platform.startswith("linux")
QUITTING = False
DISPLAY_SPLASH = False
DISPLAY_FIREWORKS = False
DISPLAY_ATTENDANT = False
DISPLAY_DOOROPEN = False
DISPLAY_HELPREQ = False
_HELPREQ = HelpreqSession()
LAST_DISPLAY_STATE = 0
DISPLAY_FIREWORKS_LOOP = 0
DISPLAY_FIREWORKS_DURATION_SEC = 0.0
FIREWORKS_WINNER_DURATION_SEC = 60.0
FIREWORKS_BANK_DURATION_SEC = 30.0
FIREWORKS_IPC_WATCHER = None
CURRENT_GAME_IPC_WATCHER = None
FIREWORKS_LOCAL_WINNER = False
FIREWORKS_ERROR_DEFER_UNTIL = 0.0
FIREWORKS_WINNER_AMOUNT_TEXT = ""
FIREWORKS_OVERLAY_FONTS = None
_FPS_HUD_LAST_T = 0.0
_FPS_HUD_SMOOTH = 0.0
_FPS_HUD_FONT = None
# Win11 Intel: "d3d11va" / "qsv" after probe; None = software decode.
_FFMPEG_HWACCEL: str | None = None
_FFMPEG_READER_PATCHED = False
DISPLAY_ALTERNATE_VIDEO = 0
TOTAL_VIDEOS = 18
P3Cable = None
USBCDC = None
USBCDC_TIMESTAMP = time.perf_counter()
HELTEC_USB_FAIL = False
_HELTEC_JTAG_RESET_MONO = 0.0
_MOUSE_VISIBLE = False
_DF_BOOT_MONO = 0.0
_LISTEN_FAULT_CACHE = {"t": 0.0, "text": None}
P3_LAST_USAGE = 0
P3_SERIAL_LOCK = threading.Lock()
P3_GRAB_BUSY = False
P3_GRAB_LAST_REQUEST = 0.0
P3_POGCC_PORT = "/dev/pogcc"
P3_FALLBACK_PORT = "/dev/ttyUSB0"
P3_STATUS_PATH = "/run/dyzineframe/pogcc_link.status"
P3_SERIAL_PORT = P3_POGCC_PORT if os.path.exists(P3_POGCC_PORT) else P3_FALLBACK_PORT
P3_LINK_OK = True
P3_LINK_RETRY_AT = 0.0
P3_LINK_RETRY_SEC = 5.0
P3_LINK_WARNING_FONT = None
P3_STATUS_LAST_OK = None
P3_FLOOR_GAMES_LOADED = False
P3_SESSION_FLOOR_GAMES = None
P3_SESSION_BLACK_GOLD_21 = None
OHD_REMOTE_FLOOR_GAMES = None
OHD_REMOTE_BLACK_GOLD_21 = None
OHD_FLOOR_GAMES_ERROR = None
CREDIT_BALANCE = 0
# Who owns Heltec USB CDC + P3 GAMESTATS for /credits:
#   dyzineframe (default) | ethergold (ethergold-pogcc-com.service)
POGCC_COM_OWNER = "dyzineframe"
DRAW_EDGE_TEST = False
PROGRESSIVE_DISPLAY = None
OHD_PROGRESSIVE_SCHEDULER = None
PROGRESSIVE_DISPLAY_TIME = 120.0
PROGRESSIVE_CHASE_RATE_SCALE = 1.0
PROGRESSIVE_CATCHUP_LAG_THRESHOLD = 20
PROGRESSIVE_CATCHUP_LAG_BONUS_PER_CENT = 0.0
PROGRESSIVE_SNAP_BEHIND_THRESHOLD = 1000
PROGRESSIVE_SNAP_BEHIND_LAG = 200
PROGRESSIVE_REMOTE_POLL_SEC = 5.0
NON_PROGRESSIVE_LOGO_SHOW_PAUSE = 120.0
DEBUG_HUD_FONT = None
#-----------------------------------------------------------------------------------------------------------------------
VID_NAMES = [
    "content/0_menu_a.mp4",
    "content/1_JacksBetterPoker.mp4",
    "content/2_JokerPoker.mp4",
    "content/3_WildJokersPoker.mp4",
    "content/4_DeucesWildPoker.mp4",
    "content/5_Shamrock7s.mp4",
    "content/6_8BallPoker.mp4",
    "content/7_SuperGoldBingo.mp4",
    "content/8_Touch6Lotto.mp4",
    "content/9_SuperPickLotto.mp4",
    "content/10_ToucheasyKeno.mp4",
    "content/11_SuperBallKeno.mp4",
    "content/12_LightningKeno.mp4",
    "content/13_Triple7s.mp4",
    "content/14_SpinBallBonus.mp4",
    "content/15_BlackGold21.mp4",
    "content/16_SpinJack21.mp4",
    "content/17_menu_b.mp4"
]
VID_AB = [
    [87, 179],   #Game Menu A
    [90, 177],   #1
    [60, 148],   #2
    [126, 353],  #3
    [60, 119],   #4
    [89, 178],   #5
    [126, 353],  #6
    [126, 353],  #7
    [69, 188],   #8
    [86, 203],   #9
    [61, 118],   #10
    [59, 176],   #11
    [126, 353],  #12
    [87, 191],   #13
    [83, 145],   #14
    [141, 242],  #15
    [75, 119],   #16
    [135, 359],  #Game Menu B
]
vids = [None] * TOTAL_VIDEOS
#-----------------------------------------------------------------------------------------------------------------------
TOTAL_ADS = 0
TIME_PER_FRAME = 3      # How many seconds per image
ENABLE_BLUR = True
ads = []
ads_index = 0
ABORT_ADS = False
# Content sync targets come from enrollment.py (Asset -' Path + content_base_url).
#-----------------------------------------------------------------------------------------------------------------------
@dataclass
class Ad:
    media:None
    media_type:None
#-----------------------------------------------------------------------------------------------------------------------
def _validate_platform_config() -> None:
    """Win11 / display-only nodes have no local EtherGold - require remote progressives."""
    if IS_WINDOWS and not TEST_MODE_NO_SERIAL:
        print(
            "WARNING: Windows should use test_mode_no_serial = True (no P3 / POG-CC on this PC)",
            flush=True,
        )
    source = resolve_progressives_source()
    if is_remote_progressives_source(source):
        print(f"Progressives source (remote): {source}", flush=True)
        return
    if IS_WINDOWS and not os.path.isfile(source):
        # Hardening: hold + degraded LED phase 3 instead of hard exit when path is down.
        print(
            "WARNING: No local progressives on Windows yet. Set progressives_source in config.ini, e.g.\n"
            "  progressives_source = http://10.1.10.45:31234/EtherGold/progressives.csv\n"
            "Continuing with soft-fail (HAT LED2 code 3) - OHD will hold PLEASE WAIT.",
            flush=True,
        )
        try:
            import hat_status_led as _hsl

            _hsl.soft_fail(3)
        except Exception:
            pass
        return
    if os.path.isfile(source):
        print(f"Progressives source (local): {source}", flush=True)
    else:
        print(f"WARNING: progressives file not found yet: {source}", flush=True)
        try:
            import hat_status_led as _hsl

            _hsl.soft_fail(3)
        except Exception:
            pass


#-----------------------------------------------------------------------------------------------------------------------
def _ffmpeg_probe_ok(exe: pathlib.Path) -> bool:
    """True if CreateProcess can run this ffmpeg/ffprobe (rejects broken symlinks)."""
    try:
        if not exe.is_file() or exe.stat().st_size < 1_000_000:
            return False
        r = subprocess.run(
            [str(exe), "-version"],
            capture_output=True,
            text=True,
            timeout=15,
            check=False,
        )
        out = ((r.stdout or "") + (r.stderr or "")).lower()
        name = exe.name.lower()
        if r.returncode != 0:
            return False
        if name.startswith("ffprobe"):
            return "ffprobe version" in out
        return "ffmpeg version" in out
    except (OSError, subprocess.TimeoutExpired, PermissionError) as exc:
        print(f"FFmpeg probe fail: {exe} ({exc})", flush=True)
        return False


def _win_ffmpeg_search_roots() -> list[pathlib.Path]:
    roots: list[pathlib.Path] = []
    app = pathlib.Path(__file__).resolve().parent
    roots.append(app / "runtime" / "ffmpeg" / "bin")
    for env_key, parts in (
        ("ProgramFiles", ("WinGet", "Packages")),
        ("ProgramFiles(x86)", ("WinGet", "Packages")),
        ("LOCALAPPDATA", ("Microsoft", "WinGet", "Packages")),
    ):
        base = os.environ.get(env_key) or ""
        if base:
            roots.append(pathlib.Path(base).joinpath(*parts))
    # Hard fallbacks - OHD user PATH/env can omit ProgramFiles in odd launches.
    roots.extend(
        [
            pathlib.Path(r"C:\Program Files\WinGet\Packages"),
            pathlib.Path(r"C:\Program Files (x86)\WinGet\Packages"),
            pathlib.Path(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages")),
        ]
    )
    # Dedupe preserving order
    seen: set[str] = set()
    out: list[pathlib.Path] = []
    for r in roots:
        key = str(r).lower()
        if key in seen:
            continue
        seen.add(key)
        out.append(r)
    return out


def _win_find_ffmpeg_bins() -> list[pathlib.Path]:
    """Return candidate bin dirs that contain a large ffmpeg.exe (not a 0-byte link)."""
    found: list[pathlib.Path] = []
    for root in _win_ffmpeg_search_roots():
        try:
            if not root.exists():
                continue
        except OSError as exc:
            print(f"FFmpeg search skip {root}: {exc}", flush=True)
            continue
        # Bundled runtime/ffmpeg/bin
        direct = root / "ffmpeg.exe"
        if direct.is_file():
            found.append(root)
            continue
        # Gyan.FFmpeg_* package trees
        try:
            pkgs = sorted(root.glob("Gyan.FFmpeg_*"), reverse=True)
        except OSError as exc:
            print(f"FFmpeg glob fail {root}: {exc}", flush=True)
            continue
        for pkg in pkgs:
            for pattern in ("ffmpeg-*/bin/ffmpeg.exe", "**/bin/ffmpeg.exe"):
                try:
                    hits = list(pkg.glob(pattern))
                except OSError:
                    hits = []
                for hit in hits:
                    if hit.is_file() and hit.stat().st_size >= 1_000_000:
                        found.append(hit.parent)
    # Dedupe
    seen: set[str] = set()
    uniq: list[pathlib.Path] = []
    for d in found:
        key = str(d.resolve()).lower() if d.exists() else str(d).lower()
        if key in seen:
            continue
        seen.add(key)
        uniq.append(d)
    return uniq


def _win_ensure_runtime_ffmpeg(src_bin: pathlib.Path) -> pathlib.Path | None:
    """Copy a working ffmpeg into app runtime so OHD user always has a local exe."""
    app_bin = pathlib.Path(__file__).resolve().parent / "runtime" / "ffmpeg" / "bin"
    app_bin.mkdir(parents=True, exist_ok=True)
    dst_ff = app_bin / "ffmpeg.exe"
    if _ffmpeg_probe_ok(dst_ff):
        return app_bin
    src_ff = src_bin / "ffmpeg.exe"
    src_fp = src_bin / "ffprobe.exe"
    if not _ffmpeg_probe_ok(src_ff):
        return None
    try:
        print(f"FFmpeg: copying working binary into {app_bin}", flush=True)
        shutil.copy2(src_ff, dst_ff)
        if src_fp.is_file():
            shutil.copy2(src_fp, app_bin / "ffprobe.exe")
    except OSError as exc:
        print(f"FFmpeg: runtime copy failed ({exc})", flush=True)
        return None
    if _ffmpeg_probe_ok(dst_ff):
        return app_bin
    return None


def _configure_win_ffmpeg_paths() -> None:
    """Pin pyvidplayer2 to a CreateProcess-verified ffmpeg.exe (never WinGet Links)."""
    if not IS_WINDOWS:
        return
    candidates = _win_find_ffmpeg_bins()
    print(
        f"FFmpeg search: {len(candidates)} candidate bin dir(s)",
        flush=True,
    )
    chosen: pathlib.Path | None = None
    for bin_dir in candidates:
        ff = bin_dir / "ffmpeg.exe"
        print(f"FFmpeg try: {ff} size={ff.stat().st_size if ff.is_file() else 0}", flush=True)
        if _ffmpeg_probe_ok(ff):
            chosen = bin_dir
            break
    if chosen is None:
        print(
            "ERROR - no runnable ffmpeg.exe found. Install Gyan FFmpeg or place "
            "ffmpeg.exe under runtime\\ffmpeg\\bin\\",
            flush=True,
        )
        return
    # Prefer app-local runtime copy (survives OHD user ACL quirks on Program Files).
    app = pathlib.Path(__file__).resolve().parent / "runtime" / "ffmpeg" / "bin"
    if chosen.resolve() != app.resolve():
        ensured = _win_ensure_runtime_ffmpeg(chosen)
        if ensured is not None:
            chosen = ensured
    ffmpeg = chosen / "ffmpeg.exe"
    ffprobe = chosen / "ffprobe.exe"
    set_ffmpeg_path(str(ffmpeg))
    if ffprobe.is_file() and _ffmpeg_probe_ok(ffprobe):
        set_ffprobe_path(str(ffprobe))
    # Keep PATH ahead of WinGet\\Links / WindowsApps stubs.
    os.environ["PATH"] = str(chosen) + os.pathsep + os.environ.get("PATH", "")
    # Strip known-bad symlink dirs from PATH lookups if present later.
    print(f"FFmpeg path: {ffmpeg} (verified)", flush=True)


def _ffmpeg_hwaccel_probe_ok(method: str, sample: pathlib.Path) -> bool:
    """True if ffmpeg can HW-decode a couple of frames from *sample*."""
    ffmpeg = get_ffmpeg_path()
    cmd = [ffmpeg, "-hide_banner", "-loglevel", "error"]
    if method == "qsv":
        cmd += ["-hwaccel", "qsv", "-c:v", "h264_qsv"]
    else:
        cmd += ["-hwaccel", method]
    cmd += [
        "-i",
        str(sample),
        "-frames:v",
        "3",
        "-f",
        "null",
        "-",
    ]
    try:
        r = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )
        if r.returncode != 0:
            err = (r.stderr or r.stdout or "").strip().replace("\n", " ")[:180]
            print(f"FFmpeg hwaccel {method}: fail ({err})", flush=True)
            return False
        print(f"FFmpeg hwaccel {method}: probe ok", flush=True)
        return True
    except (OSError, subprocess.TimeoutExpired) as exc:
        print(f"FFmpeg hwaccel {method}: probe error ({exc})", flush=True)
        return False


def _patch_pyvidplayer_ffmpeg_hwaccel() -> None:
    """Inject Intel d3d11va/qsv into pyvidplayer2 (stock reader only knows CUDA)."""
    global _FFMPEG_READER_PATCHED
    if _FFMPEG_READER_PATCHED or not _FFMPEG_HWACCEL:
        return
    try:
        from pyvidplayer2.ffmpeg_reader import FFMPEGReader
    except ImportError as exc:
        print(f"FFmpeg hwaccel patch skip: {exc}", flush=True)
        return

    method = _FFMPEG_HWACCEL

    def _get_command(self, index=None):
        # Keep NVIDIA path if a caller ever sets cuda_device.
        if getattr(self, "cuda_device", -1) is not None and int(self.cuda_device) >= 0:
            return [
                get_ffmpeg_path(),
                "-hwaccel",
                "cuda",
                "-init_hw_device",
                f"cuda:{int(self.cuda_device)}",
                *(
                    ["-ss", self._convert_seconds(index / self.frame_rate)]
                    if index is not None
                    else []
                ),
                "-i",
                self._path,
                "-loglevel",
                get_ffmpeg_loglevel(),
                "-map",
                "0:v:0",
                "-f",
                "rawvideo",
                "-vf",
                "format=bgr24",
                "-sn",
                "-an",
                "-",
            ]
        pre: list[str] = ["-hwaccel", method]
        if method == "qsv":
            pre += ["-c:v", "h264_qsv"]
        return [
            get_ffmpeg_path(),
            *pre,
            *(
                ["-ss", self._convert_seconds(index / self.frame_rate)]
                if index is not None
                else []
            ),
            "-i",
            self._path,
            "-loglevel",
            get_ffmpeg_loglevel(),
            "-map",
            "0:v:0",
            "-f",
            "rawvideo",
            "-vf",
            "format=bgr24",
            "-sn",
            "-an",
            "-",
        ]

    FFMPEGReader._get_command = _get_command  # type: ignore[method-assign]
    _FFMPEG_READER_PATCHED = True
    print(f"FFmpeg hwaccel: pyvidplayer2 patched for {method}", flush=True)


def _configure_win_ffmpeg_hwaccel() -> None:
    """Pick d3d11va or qsv when probe succeeds (Intel OHD)."""
    global _FFMPEG_HWACCEL
    if not IS_WINDOWS:
        return
    if not TEMP_FFMPEG_HWACCEL_TEST:
        _FFMPEG_HWACCEL = None
        # ASCII only - OHD log redirect is cp1252 (Unicode arrow crashed 2026-08-01-i).
        print(
            "FFmpeg hwaccel: TEMP off (d3d11va->BGR->pygame lost vs software)",
            flush=True,
        )
        return
    sample = (
        pathlib.Path("Assets")
        / "Shared"
        / ASSET_RESOLUTION
        / "fireworks.mp4"
    )
    if not sample.is_file():
        sample = pathlib.Path("content") / "fireworks.mp4"
    if not sample.is_file():
        print("FFmpeg hwaccel: no sample clip to probe - software decode", flush=True)
        return
    for method in ("d3d11va", "qsv"):
        if _ffmpeg_hwaccel_probe_ok(method, sample):
            _FFMPEG_HWACCEL = method
            _patch_pyvidplayer_ffmpeg_hwaccel()
            return
    _FFMPEG_HWACCEL = None
    print("FFmpeg hwaccel: none - software decode", flush=True)


#-----------------------------------------------------------------------------------------------------------------------
def _init_pygame_display():
    """Create the fullscreen display surface (Pi + OHD). Win11 also pins HWND topmost."""
    global win, _MOUSE_VISIBLE, _DF_BOOT_MONO
    # Mini strip panels prefer 1920x360; stock kanshi often leaves 1920x1080 and
    # the 360-tall framebuffer only fills the top third. Fix before set_mode.
    if IS_LINUX and DISPLAY_MODE == DISPLAY_MODE_MINI:
        try:
            from pi_hdmi_mode import ensure_hdmi_for_config

            ensure_hdmi_for_config()
        except Exception as exc:
            print(f"WARN: pi_hdmi_mode failed: {exc}", flush=True)
    display_index = 0
    if IS_WINDOWS:
        try:
            display_index = configure_win32_monitor(
                WIN32_MONITOR,
                prefer_width=SCREEN_WIDTH,
                prefer_height=SCREEN_HEIGHT,
            )
        except Exception as exc:
            print(f"WARN: win32 monitor select failed: {exc}", flush=True)
            display_index = 0
    set_mode_kwargs = {
        "size": (SCREEN_WIDTH, SCREEN_HEIGHT),
        "flags": pygame.FULLSCREEN | pygame.NOFRAME,
    }
    if IS_WINDOWS:
        set_mode_kwargs["display"] = display_index
    win = pygame.display.set_mode(**set_mode_kwargs)
    try:
        surf = pygame.display.get_surface()
        sw, sh = surf.get_size() if surf is not None else (0, 0)
    except Exception:
        sw, sh = 0, 0
    print(
        f"pygame surface: requested={SCREEN_WIDTH}x{SCREEN_HEIGHT} got={sw}x{sh}"
        + (f" display={display_index}" if IS_WINDOWS else ""),
        flush=True,
    )
    pygame.mouse.set_visible(False)
    _MOUSE_VISIBLE = False
    _DF_BOOT_MONO = time.monotonic()
    if IS_WINDOWS:
        win.fill((0, 0, 0))
        _df_present()
        start_win32_kiosk_bootstrap()


#-----------------------------------------------------------------------------------------------------------------------
def main():

    global win, font, mutex, curr_index, splash, fireworks, attendant, dooropen, helpreq, surface1, surface2, ABORT_ADS, thread1, thread2, thread3, thread4, PROGRESSIVE_DISPLAY, OHD_PROGRESSIVE_SCHEDULER, LAST_DISPLAY_STATE, _tbx_staged_fade_game_id, _DF_BOOT_MONO

    # HAT LED2 POST (Pi only; no-op on Win11 / missing GPIO).
    _hsl = None
    try:
        import hat_status_led as _hsl_mod

        _hsl = _hsl_mod
        _hsl.phase(1)
    except Exception:
        _hsl = None

    # Load the config file - THIS MUST BE DONE FIRST AS IT CREATES MANY GLOBAL VARS

    if _hsl is not None:
        _hsl.phase(2)
    load_config()

    if _hsl is not None:
        _hsl.phase(3)
    _validate_platform_config()

    print(
        f"Display mode: {DISPLAY_MODE}  ({SCREEN_WIDTH}x{SCREEN_HEIGHT})"
        f"  assets={ASSET_RESOLUTION}",
        flush=True,
    )

    # Win11: pin pyvidplayer2 to real ffmpeg.exe (WinGet Links symlink type often disabled).
    _configure_win_ffmpeg_paths()
    _configure_win_ffmpeg_hwaccel()

    # Init PyGame

    if IS_WINDOWS:
        os.environ.setdefault("SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS", "0")

    pygame.init()

    # Disable the Pi 5's wlan0 Power Save "feature" (it's not a feature)

    if IS_LINUX:
        print("Attempting to disable WiFi Powersave...")
        ret = os.system("./dis_pwrsve.sh")
        if (ret != 0):
            print("ERROR - Unable to Disable WiFi Powersave")
        else:
            print("Disabled WiFi Powersave")

    # Check if this is the First-Run and if so launch the Configure Setup Webpage

    if (CONFIGSET == False):

        print("CONFIGSET: This is a First-Run")
        print(
            "CONFIGSET: Pi first-run is Wi-Fi only. "
            "OHD needs nothing else. Cabinet asset/theme/volume/content are Pi Identity /config.",
            flush=True,
        )

        _init_pygame_display()
        pygame.display.set_caption("TOP BOX")
        _cfg_lock()
        _start_first_run_usb()

        if (is_there_internet() == True):

            print("CONFIGSET: Site network already up - skip identity QR", flush=True)
            win.fill((0, 0, 0))
            note = (
                "Wi-Fi OK. Set asset/theme/volume on Pi Identity /config."
                if _cabinet_has_pogcc_usb()
                else "Wi-Fi OK. OHD first-run complete."
            )
            temp_font = pygame.font.Font(pygame.font.get_default_font(), 42)
            text_surface = temp_font.render(note, True, (255, 255, 255))
            win.blit(text_surface, dest=(40, SCREEN_HEIGHT // 2 - 24))
            _df_present()
            _persist_configset_true()
            time.sleep(2)
            print("CONFIGSET: first-run done - continue without machine reboot", flush=True)

        elif not IS_LINUX:

            print("CONFIGSET: Join site Wi-Fi, then restart DyzineFrame", flush=True)
            while (CONFIGSET == False):
                for event in pygame.event.get():
                    if (event.type == pygame.KEYDOWN):
                        _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))
                time.sleep(0.025)

        else:

            print("CONFIGSET: No internet - AP Wi-Fi setup", flush=True)

            SSID = secrets.token_urlsafe(15)
            PWD = secrets.token_urlsafe(20)

            bash_string = "sudo nmcli device wifi hotspot ssid %s password %s" % (
                shlex.quote(SSID),
                shlex.quote(PWD),
            )

            print("Executing: " + bash_string)

            ret = os.system(bash_string)

            if (ret != 0):
                print("ERROR - Unable to Enable AP Mode")
                exit_video()
            else:
                print("AP Mode Enabled")

            QR_Code_String = "WIFI:S:%s;T:WPA;P:%s;;" % (SSID, PWD)

            print("WiFi QR Code: " + QR_Code_String)

            img = qrcode.make(QR_Code_String)
            img.save("qr_code1.png")

            QR_Code_String = "http://10.42.0.1:" + str(WEB_SERVICE_PORT) + "/config2"

            print("Server QR Code: " + QR_Code_String)

            img = qrcode.make(QR_Code_String)
            img.save("qr_code2.png")

            splash = pygame.image.load("qr_code1.png")
            splash = pygame.transform.scale(splash, (950, 950))
            win.blit(splash, (0, 130))
            splash = pygame.image.load("qr_code2.png")
            splash = pygame.transform.scale(splash, (950, 950))
            win.blit(splash, (970, 130))

            temp_font = pygame.font.Font(pygame.font.get_default_font(), 100)
            text_surface = temp_font.render("WiFi:", True, (255, 255, 255))
            win.blit(text_surface, dest = (360, 25))
            text_surface = temp_font.render("Config Server:", True, (255, 255, 255))
            win.blit(text_surface, dest = (1070, 25))

            _df_present()

            pathlib.Path("./qr_code1.png").unlink(missing_ok=True)
            pathlib.Path("./qr_code2.png").unlink(missing_ok=True)

            thread1 = threading.Thread(target = thread_web_server, daemon = True)
            thread1.start()

            while (CONFIGSET == False):

                for event in pygame.event.get():
                    if (event.type == pygame.KEYDOWN):
                        _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

                time.sleep(0.025)

            print("DEBUG: CONFIGSET is TRUE")

            if wifi_ssid and wifi_pwd:
                if not _nmcli_join_sta(wifi_ssid, wifi_pwd):
                    print("ERROR - Unable to connect to the WiFi STA (Hotspot cleared; pick SSID on desktop)", flush=True)
                    exit_video()
                else:
                    print("Connected to the WiFi STA")

            print("CONFIGSET: first-run done - continue without machine reboot", flush=True)

    # Always continue into normal startup. Never `sudo reboot` after Wi-Fi join
    # (EtherGold HAT/XR does not recover from a software reboot).

    if True:

        # EtherGold ethergold-http.service owns port 31234 on office/collector Pis.
        # Mutex is still required for SDS/USB + display even when the public Bottle
        # server is skipped. Always bind localhost :31235 so ethergold-http can
        # proxy /ohd/* and (when pogcc_com_owner=ethergold) pogcc-com can hit /flag.
        if os.environ.get("ETHERGOLD_HTTP_EXTERNAL", "").strip().lower() in ("1", "true", "yes", "on"):
            print(
                "ETHERGOLD_HTTP_EXTERNAL=1 - skipping DyzineFrame public web server "
                "(EtherGold HTTP serves OHD; local :31235 for /ohd proxy + pogcc)",
                flush=True,
            )
            mutex = threading.Lock()
            thread_local_http = threading.Thread(
                target=thread_local_pogcc_http, daemon=True
            )
            thread_local_http.start()
        else:
            thread1 = threading.Thread(target = thread_web_server, daemon = True)  # A daemon thread is automatically killed when the process terminates
            mutex = threading.Lock()
            thread1.start()

        # Pi-only wlan0 watchdog (chknet.sh)
        if IS_LINUX:
            thread2 = threading.Thread(target = thread_network_check, daemon = True)  # A daemon thread is automatically killed when the process terminates
            thread2.start()

        # Start the Sync thread

        if (DISABLE_ADS == False):
            thread3 = threading.Thread(target = thread_ads_folder_sync, daemon = True)  # A daemon thread is automatically killed when the process terminates
            thread3.start()

        # POG-CC USB CDC + P3 serial (Pi field only). When pogcc_com_owner=ethergold,
        # Heltec CDC and GAMESTATS credits are owned by ethergold-pogcc-com.service.
        if (TEST_MODE_NO_SERIAL == False and POGCC_COM_OWNER != "ethergold"):
            if thread4 is None or not thread4.is_alive():
                thread4 = threading.Thread(target = thread_usb_serial, daemon = True)  # A daemon thread is automatically killed when the process terminates
                thread4.start()
        elif POGCC_COM_OWNER == "ethergold":
            print(
                "pogcc_com_owner=ethergold - skipping DF USBCDC thread "
                "(ethergold-pogcc-com.service)",
                flush=True,
            )

        _start_fireworks_ipc_watcher()

        # Init the window and stuff

        time.sleep(0.250)  # Let the webserver start and output to the console

        if _hsl is not None:
            _hsl.phase(4)
        _init_pygame_display()

        pygame.display.set_caption("TOP BOX")

        # Load up a font

        font = pygame.font.Font(pygame.font.get_default_font(), 16)

        # Create 2 off-screen Surfaces for use in Crossfades

        surface1 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)).convert()
        surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)).convert()
        if IS_LINUX:
            cleared = clear_preboot_progressive_runtime()
            if cleared:
                print(
                    "progressives: discarded pre-boot CSV/current-game "
                    f"({len(cleared)} file(s))",
                    flush=True,
                )
            try:
                write_df_displayed_game(0)
            except Exception as exc:
                print(f"WARN: df-displayed-game.json write failed: {exc}", flush=True)
        PROGRESSIVE_DISPLAY = PlayerProgressiveDisplay(
            resolution=ASSET_RESOLUTION,
            chase_rate_scale=PROGRESSIVE_CHASE_RATE_SCALE,
            catchup_lag_threshold=PROGRESSIVE_CATCHUP_LAG_THRESHOLD,
            catchup_lag_bonus_per_cent=PROGRESSIVE_CATCHUP_LAG_BONUS_PER_CENT,
            snap_behind_threshold_pennies=PROGRESSIVE_SNAP_BEHIND_THRESHOLD,
            snap_behind_to_lag_pennies=PROGRESSIVE_SNAP_BEHIND_LAG,
            remote_poll_interval_s=PROGRESSIVE_REMOTE_POLL_SEC,
        )

        ohd_config = ExtendedConfigParser()
        _read_config_ini(ohd_config)
        OHD_PROGRESSIVE_SCHEDULER = OhdProgressiveScheduler(
            _load_ohd_scheduler_settings(ohd_config),
            PROGRESSIVE_DISPLAY.reader,
        )

        # Load all of the videos into RAM

        for i in range(0, TOTAL_VIDEOS):
            if (VID_NAMES[i] != 0):
                vids[i] = load_video(VID_NAMES[i])

        fireworks_path = (
            resolve_shared_display_asset("fireworks")
            if is_ohd_mode()
            else pathlib.Path("content") / "fireworks.mp4"
        )
        if _hsl is not None:
            _hsl.phase(5)
        fireworks = load_video(str(fireworks_path))

        if (fireworks == None):
            print(f"ERROR - fireworks.mp4 not found: {fireworks_path} (soft-fail LED2 code 5)")
            if _hsl is not None:
                _hsl.soft_fail(5)
            else:
                exit_video()
        else:
            print(f"Fireworks asset: {fireworks_path}")
        # Half-res A/B must still fill the same on-screen area as full fireworks.mp4
        # (decode smaller, Lanczos upscale to 2Ã - - not a tiny centered window).
        if (
            TEMP_FIREWORKS_HALFRES_TEST
            and is_ohd_mode()
            and fireworks is not None
        ):
            try:
                ow, oh = fireworks.original_size
                tw, th = int(ow) * 2, int(oh) * 2
                if hasattr(fireworks, "set_interp"):
                    fireworks.set_interp("lanczos4")
                fireworks.resize((tw, th))
                print(
                    f"Fireworks half-res display upscale: {ow}x{oh} -> {tw}x{th}",
                    flush=True,
                )
            except Exception as exc:
                print(f"WARNING - half-res fireworks upscale failed: {exc}", flush=True)

        attendant = load_video("content/attendant.mp4")

        if (attendant == None):
            print("ERROR - attendant.mp4 not found (soft-fail LED2 code 5)")
            if _hsl is not None:
                _hsl.soft_fail(5)
            else:
                exit_video()

        dooropen = load_video("content/dooropen.mp4")

        if (dooropen == None):
            print("ERROR - dooropen.mp4 not found (soft-fail LED2 code 5)")
            if _hsl is not None:
                _hsl.soft_fail(5)
            else:
                exit_video()

        helpreq = load_video("content/helpreq.mp4")

        if (helpreq == None):
            print("ERROR - helpreq.mp4 not found (soft-fail LED2 code 5)")
            if _hsl is not None:
                _hsl.soft_fail(5)
            else:
                exit_video()

        # Load the resolution-specific shared splash into RAM.

        if _hsl is not None:
            _hsl.phase(6)
        splash_path = resolve_shared_display_asset("splash")
        splash = load_image(str(splash_path))
        if splash is not None and splash.get_size() != (SCREEN_WIDTH, SCREEN_HEIGHT):
            splash = pygame.transform.smoothscale(splash, (SCREEN_WIDTH, SCREEN_HEIGHT))
        print(f"Splash asset: {splash_path}")
        if splash is None and _hsl is not None:
            _hsl.soft_fail(6)

        # Load the Ads into RAM

        if (DISABLE_ADS == False):
            load_ads()

        # After vids[] is loaded - sds_handler no-ops while vids[code] is None.
        _start_current_game_ipc_watcher()

        # Get the Local IP Address
        get_IP()

        # Display the Splash screen

        if _hsl is not None:
            _hsl.phase(7)  # HTTP already started above
            if POGCC_COM_OWNER == "ethergold":
                _hsl.phase(8)  # EG owns Heltec path - treat as OK for POST
            elif TEST_MODE_NO_SERIAL:
                _hsl.phase(8)
            else:
                _hsl.phase(8)
            if is_ohd_mode():
                _hsl.phase(9)
            _hsl.healthy()

        display_splash()
        _apply_this_boot_current_game()

        if (is_ohd_mode()):
            _sync_ohd_floor_games_from_pi()
            if _hsl is not None and OHD_FLOOR_GAMES_ERROR:
                _hsl.soft_fail(9)
                _hsl.enter_degraded()

        # Forcefully grab the P3 credit balance (splash = safe idle window).
        # Same GAMESTATS response will supply progressive_floor_games once per boot (session cache).
        # Skip when EtherGold owns POG-CC COM (EG answers Heltec /credits).

        if POGCC_COM_OWNER != "ethergold":
            P3_update_credit_balance()

        if (TEST_MODE_NO_SERIAL == False and POGCC_COM_OWNER != "ethergold"):
            threading.Thread(target = _p3_link_retry_worker, daemon = True).start()

        if IS_LINUX:
            start_p3_grab_flag_listener(_request_p3_screen_grab, P3_GRAB_FLAG)
        if IS_LINUX and not TEST_MODE_NO_SERIAL:
            start_evdev_grab_listener(_request_p3_screen_grab)

        # TEMP boot fireworks - OHD only; short progressive-style celebration on every launch.
        if TEMP_BOOT_FIREWORKS_TEST and is_ohd_mode():
            print(
                "TEMP_BOOT_FIREWORKS_TEST: queue fireworks.mp4 + overlay at boot",
                flush=True,
            )
            _apply_fireworks_payload(
                {
                    "is_progressive": True,
                    "winner_tid": "TEST",
                    "progwin_cents": 312680,
                    "duration_sec": 20,
                }
            )

        while (True):

            _expire_helpreq_display()

            if IS_WINDOWS:
                maybe_refresh_win32_kiosk_window()

            if is_ohd_mode() and not DISPLAY_FIREWORKS:
                # Progressive-win fireworks must still play even if CSV health is stale.
                _ohd_reason, _ohd_detail = _check_ohd_health()
                if _ohd_reason is not None:
                    _render_please_wait(_ohd_reason, _ohd_detail)
                    continue
                if OHD_FLOOR_GAMES_ERROR:
                    _render_ohd_floor_games_hold(OHD_FLOOR_GAMES_ERROR)
                    continue

            if (QUITTING == True):
                exit_video()

            elif (DISPLAY_DOOROPEN == True):
                play_dooropen()

            elif (DISPLAY_HELPREQ == True):
                play_helpreq()

            elif (DISPLAY_SPLASH == True):
                display_splash()

            elif (DISPLAY_FIREWORKS == True and _fireworks_blocks_attendant()):
                play_fireworks(DISPLAY_FIREWORKS_LOOP, duration_sec=DISPLAY_FIREWORKS_DURATION_SEC)

            elif (DISPLAY_ATTENDANT == True):
                play_attendant()

            elif (DISPLAY_FIREWORKS == True):
                play_fireworks(DISPLAY_FIREWORKS_LOOP, duration_sec=DISPLAY_FIREWORKS_DURATION_SEC)

            else:
                if (is_ohd_mode()):
                    if (PROGRESSIVE_DISPLAY != None):
                        play_ohd_progressive_modes()
                else:
                    # LAST_DISPLAY_STATE: skip logo replay only when resuming progressive
                    # slide for the same game after door/fireworks (Brett 2026-07-02-e).
                    clip_index = curr_index
                    if (LAST_DISPLAY_STATE != 1 or _plays_progressive_slide_after_logo(clip_index) == False):
                        LAST_DISPLAY_STATE = 0
                        _tbx_staged_fade_game_id = None
                        clip_ab = VID_AB[clip_index]
                        if _plays_progressive_slide_after_logo(clip_index):
                            play_video(
                                LOOP_COUNT,
                                VIDEO_PAUSE,
                                clip_ab[0],
                                clip_ab[1],
                            )
                        else:
                            play_video(
                                LOOP_COUNT,
                                NON_PROGRESSIVE_LOGO_SHOW_PAUSE,
                                clip_ab[0],
                                clip_ab[1],
                                show_hold_then_exit=True,
                            )
                        # Enter-game from the menu aborts this clip (fade) while
                        # curr_index is already the new game. Falling through to
                        # the meter here skips the logo. Restart the loop so the
                        # new game gets play_video first.
                        if curr_index != clip_index:
                            print(
                                f"TBX: game changed during clip {clip_index} -> {curr_index}; "
                                "play new logo",
                                flush=True,
                            )
                            continue

                    if _plays_progressive_slide_after_logo(curr_index):
                        LAST_DISPLAY_STATE = 1
                        play_progressive_display(curr_index, PROGRESSIVE_DISPLAY_TIME)

                if (ABORT_ADS == True):
                    ABORT_ADS = False
                else:
                    if (DISABLE_ADS == False):
                        play_ads()

#-----------------------------------------------------------------------------------------------------------------------
# OHD health-gate: PLEASE WAIT overlay when connectivity is lost
#-----------------------------------------------------------------------------------------------------------------------

OHD_STALE_THRESHOLD_S = 15.0

def _pi_last_octet() -> str:
    """Last octet of progressives_source host. Unresolved -> '--' (ASCII)."""
    try:
        host = urlparse(PROGRESSIVE_DISPLAY.reader.source).hostname or ""
        if not host:
            return "--"
        octet = _ipv4_last_octet(host)
        if octet == "--":
            # hostname may be DNS label; keep last dotted token if numeric-ish
            tail = host.rsplit(".", 1)[-1].strip()
            return f".{tail}" if tail.isdigit() else "--"
        return f".{octet}"
    except Exception:
        return "--"


def _check_ohd_health() -> tuple[str | None, str]:
    """Return (reason, detail) if OHD is unhealthy, or (None, '') if OK."""
    get_IP()
    if local_ip is None or local_ip == "Disconnected":
        return ("No Network Connection", "Attempting to connect")

    if PROGRESSIVE_DISPLAY is not None and PROGRESSIVE_DISPLAY.reader._remote:
        stale = PROGRESSIVE_DISPLAY.reader.seconds_since_last_success
        if stale > OHD_STALE_THRESHOLD_S:
            octet = _pi_last_octet()
            return (f"Lost contact with server {octet}", "")

    return (None, "")


def _render_please_wait(reason: str, detail: str) -> None:
    """Full-screen PLEASE WAIT overlay; pumps events and sleeps briefly."""
    global win, surface1

    surface1.fill((20, 20, 20))

    title_font = pygame.font.SysFont("Segoe UI", 96, bold=True)
    reason_font = pygame.font.SysFont("Segoe UI", 48)
    detail_font = pygame.font.SysFont("Segoe UI", 36)

    cx = SCREEN_WIDTH // 2
    title_surf = title_font.render("PLEASE WAIT", True, (255, 255, 255))
    title_y = SCREEN_HEIGHT // 2 - 120
    surface1.blit(title_surf, title_surf.get_rect(center=(cx, title_y)))

    reason_surf = reason_font.render(reason, True, (255, 200, 60))
    reason_y = title_y + 100
    surface1.blit(reason_surf, reason_surf.get_rect(center=(cx, reason_y)))

    if detail:
        detail_surf = detail_font.render(detail, True, (200, 200, 200))
        surface1.blit(detail_surf, detail_surf.get_rect(center=(cx, reason_y + 70)))

    win.blit(surface1, (0, 0))
    _df_present()
    for event in pygame.event.get():
        if event.type == pygame.KEYDOWN:
            _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))
    if IS_WINDOWS:
        maybe_refresh_win32_kiosk_window()
    time.sleep(0.25)


def _render_ohd_floor_games_hold(reason: str) -> None:
    """Stay on splash with a small error - never invent progressive games."""
    splash_x = 0
    splash_y = 0
    if splash is not None:
        splash_w, splash_h = splash.get_size()
        if splash_w < SCREEN_WIDTH or splash_h < SCREEN_HEIGHT:
            splash_x = max(0, (SCREEN_WIDTH - splash_w) // 2)
            splash_y = max(0, (SCREEN_HEIGHT - splash_h) // 2)
        win.fill((0, 0, 0))
        win.blit(splash, (splash_x, splash_y))
    else:
        win.fill((20, 20, 20))

    get_IP()
    ip_surf = font.render(f" {local_ip} ", True, (255, 255, 255), (0, 0, 0))
    ver_surf = font.render(f" {CURRENT_VERSION} ", True, (255, 255, 255), (0, 0, 0))
    hud_y = splash_y + max(0, SCREEN_HEIGHT - 16) if splash is not None else SCREEN_HEIGHT - 40
    win.blit(ip_surf, dest=(splash_x, hud_y))
    win.blit(ver_surf, dest=(splash_x, splash_y))

    err_font = pygame.font.SysFont("Segoe UI", 36, bold=True)
    detail_font = pygame.font.SysFont("Segoe UI", 28)
    cx = SCREEN_WIDTH // 2
    title = err_font.render("Progressive floor list unavailable", True, (255, 210, 80))
    detail = detail_font.render(reason[:120], True, (255, 255, 255))
    hint = detail_font.render(
        "Holding splash - Black Gold 21 is never shown as a progressive",
        True,
        (200, 200, 200),
    )
    win.blit(title, title.get_rect(center=(cx, SCREEN_HEIGHT - 140)))
    win.blit(detail, detail.get_rect(center=(cx, SCREEN_HEIGHT - 95)))
    win.blit(hint, hint.get_rect(center=(cx, SCREEN_HEIGHT - 55)))

    _draw_pogcc_link_warning(win)
    _df_present()
    for event in pygame.event.get():
        if event.type == pygame.KEYDOWN:
            _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))
    if IS_WINDOWS:
        maybe_refresh_win32_kiosk_window()
    time.sleep(0.25)


#-----------------------------------------------------------------------------------------------------------------------
def _ohd_display_should_abort() -> bool:
    global sds_event, ABORT_ADS

    Handle_Event = None
    mutex.acquire() #########################
    if (sds_event != None):
        Handle_Event = sds_event
        sds_event = None
    mutex.release() #########################

    if (Handle_Event != None):
        ABORT_ADS = True
        return True

    for event in pygame.event.get():
        if (event.type == pygame.KEYDOWN):
            _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

    return False


def _render_progressive_snapshot(game_id: int):
    """Frozen progressive slide for crossfade transitions."""
    if (PROGRESSIVE_DISPLAY == None or PROGRESSIVE_DISPLAY.supports_game(game_id) == False):
        return None
    PROGRESSIVE_DISPLAY.prepare_for_slide(game_id)
    rendered, _ = PROGRESSIVE_DISPLAY.render_slide(
        game_id, 0.0, (SCREEN_WIDTH, SCREEN_HEIGHT)
    )
    if (rendered == None):
        return None
    return rendered.copy().convert()


def _crossfade_snapshots(from_surf, to_surf) -> bool:
    """Crossfade between two static snapshots. Returns False if aborted."""
    global win, surface1

    fade_from = from_surf.convert_alpha()
    fade_to = to_surf.convert_alpha()
    last_tick = time.perf_counter()
    fade_step = CROSSFADE_STEP
    fade_sleep = crossfade_step_sleep(fade_step)

    for i in range(0, 256, fade_step):
        if (_ohd_display_should_abort()):
            return False

        now_tick = time.perf_counter()
        if is_ohd_mode():
            _progressive_background_tick(now_tick - last_tick)
        last_tick = now_tick

        fade_from.set_alpha(255 - i)
        fade_to.set_alpha(i)

        win.fill((0, 0, 0))
        win.blit(fade_from, (0, 0))
        win.blit(fade_to, (0, 0))
        _df_present()
        if fade_sleep > 0:
            time.sleep(fade_sleep)

    surface1.blit(fade_to, (0, 0))
    win.blit(surface1, (0, 0))
    _df_present()
    return True


def _fade_in_progressive_snapshot(to_surf) -> bool:
    """Fade from black into a static progressive snapshot (legacy crossfade)."""
    black = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
    black.fill((0, 0, 0))
    return _crossfade_snapshots(black.convert(), to_surf)


def _staged_fade_in_progressive(game_id: int) -> bool:
    """Fade progressive slide in: BG, then frame+blocking, then digits."""
    global win, surface1, PROGRESSIVE_DISPLAY

    if (PROGRESSIVE_DISPLAY == None or PROGRESSIVE_DISPLAY.supports_game(game_id) == False):
        return False

    output_size = (SCREEN_WIDTH, SCREEN_HEIGHT)

    def _tick_fade(dt_seconds: float) -> None:
        if is_ohd_mode():
            _progressive_background_tick(dt_seconds, exclude_game_ids=(game_id,))

    ok = PROGRESSIVE_DISPLAY.play_staged_fade_in(
        game_id,
        win,
        output_size,
        tick_background=_tick_fade,
        should_abort=_ohd_display_should_abort if is_ohd_mode() else None,
        on_keydown=_handle_keyboard_dev_shortcuts,
    )
    if (ok == False):
        return False

    rendered, _ = PROGRESSIVE_DISPLAY.render_slide(game_id, 0.0, output_size)
    if (rendered != None):
        surface1.blit(rendered, (0, 0))
        win.blit(surface1, (0, 0))
        _df_present()
    return True


def _write_mode4_weights_dump() -> str | None:
    """Append one JSON line to log/mode4_weights.txt (modes 3/4 only)."""
    if not is_ohd_mode() or OHD_PROGRESSIVE_SCHEDULER is None:
        return None
    if PROG_DISPLAY_MODE not in (3, 4):
        return None
    line = OHD_PROGRESSIVE_SCHEDULER.format_mode4_diagnosis(PROG_DISPLAY_MODE)
    log_dir = os.path.dirname(MODE4_DUMP_LOG) or "."
    os.makedirs(log_dir, exist_ok=True)
    with open(MODE4_DUMP_LOG, "a", encoding="utf-8") as handle:
        handle.write(line + "\n")
    print(f"mode4 weights dump -> {MODE4_DUMP_LOG}", flush=True)
    return line


def _handle_keyboard_dev_shortcuts(key: str) -> None:
    """Dev shortcuts: q=quit, m=cursor, s=P3 screen grab (Pi), w=OHD mode-4 weight dump."""
    global _MOUSE_VISIBLE
    if key == "q":
        exit_video()
    elif key == "m":
        _MOUSE_VISIBLE = not _MOUSE_VISIBLE
        pygame.mouse.set_visible(_MOUSE_VISIBLE)
        if _MOUSE_VISIBLE:
            pygame.event.set_grab(False)
        print(
            f"mouse cursor {'shown' if _MOUSE_VISIBLE else 'hidden'} (M)",
            flush=True,
        )
    elif key == "s" and not TEST_MODE_NO_SERIAL:
        _request_p3_screen_grab()
    elif (
        key == "w"
        and is_ohd_mode()
        and PROG_DISPLAY_MODE in (3, 4)
    ):
        _write_mode4_weights_dump()


def _write_p3_grab_status(
    *,
    state: str,
    error: str = "",
    png: str = "",
    tga: str = "",
    filename: str = "",
) -> None:
    payload = {
        "state": state,
        "utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "error": error or None,
        "png": png or None,
        "tga": tga or None,
        "filename": filename or None,
    }
    log_dir = os.path.dirname(P3_GRAB_STATUS) or "."
    os.makedirs(log_dir, exist_ok=True)
    tmp = P3_GRAB_STATUS + ".tmp"
    try:
        with open(tmp, "w", encoding="utf-8") as handle:
            json.dump(payload, handle)
        os.replace(tmp, P3_GRAB_STATUS)
    except OSError as exc:
        print(f"P3 grab status write failed: {exc}", flush=True)


def _request_p3_screen_grab() -> None:
    global P3_GRAB_BUSY, P3_GRAB_LAST_REQUEST

    if TEST_MODE_NO_SERIAL:
        _write_p3_grab_status(
            state="error",
            error="TEST_MODE_NO_SERIAL - P3 port not used",
        )
        return

    now = time.monotonic()
    if (now - P3_GRAB_LAST_REQUEST) < 2.0:
        return

    if P3_GRAB_BUSY:
        print("P3 grab already in progress", flush=True)
        return

    P3_GRAB_LAST_REQUEST = now
    P3_GRAB_BUSY = True
    threading.Thread(target=_p3_screen_grab_worker, daemon=True).start()


def _p3_screen_grab_worker() -> None:
    global P3Cable, P3_GRAB_BUSY, P3_SERIAL_LOCK

    tga_path = None
    png_path = None
    err = ""
    _write_p3_grab_status(state="busy")
    try:
        with P3_SERIAL_LOCK:
            if P3Cable == None:
                P3Cable = _p3_open_serial()
                if P3Cable == None:
                    _p3_link_lost("grab: port not available")
                    print("P3 grab: ttyUSB0 not available", flush=True)
                    err = "P3 serial port not available"
                    return

            try:
                if P3Cable.is_open == False:
                    P3Cable.open()
            except Exception as exc:
                _p3_link_lost(f"grab: reopen failed: {exc}")
                err = f"P3 serial reopen failed: {exc}"
                return

            if P3Cable.is_open == False:
                _p3_link_lost("grab: connected but not open")
                print("P3 grab: P3Cable not open", flush=True)
                err = "P3 serial connected but not open"
                return

            print("P3 grab: TARGAFILE in progress (~30-90s, VRAM frozen on bank) ...", flush=True)
            raw = fetch_targa_on_serial(P3Cable, wait_s=150.0)
            tga_path, raw_path = save_targa_capture(raw, P3_GRABS_DIR)
            png_path = tga_path.with_suffix(".png")
            print(f"P3 grab saved: {tga_path} ({raw_path.stat().st_size} raw bytes)", flush=True)
            if png_path.is_file() == False:
                err = "TGA saved but PNG convert failed"
    except Exception as exc:
        err = str(exc)
        print(f"P3 grab failed: {exc}", flush=True)
    finally:
        P3_GRAB_BUSY = False
        if tga_path != None and err == "":
            _write_p3_grab_status(
                state="ok",
                png=str(png_path) if png_path != None else "",
                tga=str(tga_path),
                filename=png_path.name if png_path != None else tga_path.name,
            )
        else:
            _write_p3_grab_status(
                state="error",
                error=err or "P3 grab failed",
                tga=str(tga_path) if tga_path != None else "",
                png=str(png_path) if png_path != None and png_path.is_file() else "",
                filename=(
                    png_path.name
                    if png_path != None
                    else (tga_path.name if tga_path != None else "")
                ),
            )

    if tga_path != None:
        run_p3_grab_sync()


def _maybe_dump_mode4_weights_from_flag() -> None:
    """On-demand dump when run/dump_mode4.request exists (consumed once per trigger)."""
    if not os.path.isfile(MODE4_DUMP_FLAG):
        return
    try:
        os.remove(MODE4_DUMP_FLAG)
    except OSError:
        pass
    _write_mode4_weights_dump()


def play_ohd_progressive_modes():
    """Run one OHD progressive cycle for prog_display_mode 1..4."""

    global ABORT_ADS, _ohd_last_prog_snapshot, _ohd_last_prog_index, _ohd_staged_games_this_cycle

    if (PROGRESSIVE_DISPLAY == None or OHD_PROGRESSIVE_SCHEDULER == None):
        return

    if (PROG_DISPLAY_MODE < 1 or PROG_DISPLAY_MODE > 4):
        print("ERROR - PROG_DISPLAY_MODE is corrupted.")
        exit_video()

    scheduler = OHD_PROGRESSIVE_SCHEDULER
    scheduler.update_settings(
        replace(scheduler.settings, game_time_sec=float(MODE_1_DISPLAY_TIME))
    )
    schedule = coalesce_schedule(scheduler.build_schedule(PROG_DISPLAY_MODE))
    _ohd_staged_games_this_cycle = set()
    _maybe_dump_mode4_weights_from_flag()
    print(
        summarize_schedule(PROG_DISPLAY_MODE, schedule, scheduler.settings),
        flush=True,
    )

    crossfade_sec = OHD_PROGRESSIVE_CROSSFADE_SEC

    for prog_index, duration_sec in schedule:
        if (PROGRESSIVE_DISPLAY.supports_game(prog_index) == False):
            continue

        target_snapshot = _render_progressive_snapshot(prog_index)
        if (target_snapshot == None):
            continue

        needs_transition = False
        if (prog_index not in _ohd_staged_games_this_cycle):
            if (_ohd_last_prog_snapshot == None):
                needs_transition = True
            elif (_ohd_last_prog_index != prog_index):
                needs_transition = True
            _ohd_staged_games_this_cycle.add(prog_index)

        if (needs_transition):
            if (_ohd_last_prog_snapshot == None):
                if (_staged_fade_in_progressive(prog_index) == False):
                    break
            elif (_crossfade_snapshots(_ohd_last_prog_snapshot, target_snapshot) == False):
                break

        display_sec = max(0.0, float(duration_sec) - (crossfade_sec if needs_transition else 0.0))
        if (display_sec > 0.0):
            play_progressive_display(prog_index, display_sec)
        if (ABORT_ADS == True):
            break

        end_snapshot = _render_progressive_snapshot(prog_index)
        if (end_snapshot != None):
            _ohd_last_prog_snapshot = end_snapshot
            _ohd_last_prog_index = prog_index

    return

#-----------------------------------------------------------------------------------------------------------------------
MAX_PROGRESSIVE_TICK_DT = 0.25
PROGRESSIVE_BG_TICK_INTERVAL = 0.05  # 20 Hz while logos/ads play (avoids 11Ã -  work every 10 ms)
_progressive_bg_accum = 0.0


def _plays_progressive_slide_after_logo(game_index: int) -> bool:
    """TBX: short logo Show pause, then progressive slide - when floor has progressive enabled."""
    if PROGRESSIVE_DISPLAY is None:
        return False
    if not PROGRESSIVE_DISPLAY.supports_game(game_index):
        return False
    if P3_FLOOR_GAMES_LOADED and P3_SESSION_FLOOR_GAMES is not None:
        return game_index in P3_SESSION_FLOOR_GAMES
    return True


def _progressive_game_ids_for_background_tick() -> tuple[int, ...]:
    """Games whose odometer engines should advance while other content is on screen."""
    if PROGRESSIVE_DISPLAY is None:
        return ()
    if is_ohd_mode() and OHD_PROGRESSIVE_SCHEDULER is not None:
        return tuple(OHD_PROGRESSIVE_SCHEDULER.settings.floor_games)
    if _plays_progressive_slide_after_logo(curr_index):
        return (curr_index,)
    return ()


def _progressive_background_tick(
    dt_seconds: float,
    *,
    exclude_game_ids: tuple[int, ...] = (),
) -> None:
    """Advance progressive odometers without drawing (keeps sync with floor LEDs)."""
    global _progressive_bg_accum
    if PROGRESSIVE_DISPLAY is None or dt_seconds <= 0:
        return
    game_ids = _progressive_game_ids_for_background_tick()
    if exclude_game_ids:
        excluded = set(exclude_game_ids)
        game_ids = tuple(game_id for game_id in game_ids if game_id not in excluded)
    if not game_ids:
        return
    _progressive_bg_accum += dt_seconds
    if _progressive_bg_accum < PROGRESSIVE_BG_TICK_INTERVAL:
        return
    tick_dt = min(_progressive_bg_accum, MAX_PROGRESSIVE_TICK_DT)
    _progressive_bg_accum = 0.0
    PROGRESSIVE_DISPLAY.tick_games(game_ids, tick_dt)


def _blit_progressive_debug_hud(game_id: int) -> None:
    """On-screen chase stats - compare lag/rate to the floor LED while both are visible."""
    global DEBUG_HUD_FONT
    if (DRAW_PROGRESSIVE_DEBUG == False or PROGRESSIVE_DISPLAY is None):
        return
    timing = PROGRESSIVE_DISPLAY.debug_timing(game_id)
    if timing is None:
        return
    if DEBUG_HUD_FONT is None:
        DEBUG_HUD_FONT = pygame.font.Font(pygame.font.get_default_font(), 28)
    mode = "LIVE" if timing["contributions_active"] else "idle"
    boost = timing.get("catchup_bonus_per_sec", 0.0)
    boost_txt = f"  boost +{boost:.2f}c/s" if boost > 0.001 else ""
    catchup = timing.get("catchup_mode") or "penny"
    lines = (
        f"disp {format_dollars(timing['display_pennies'])}  "
        f"tgt {format_dollars(timing['target_pennies'])}",
        f"lag {timing['lag_pennies']}c  "
        f"chase {timing['chase_cents_per_sec']:.2f}c/s  "
        f"curve {timing['curve_cents_per_sec']:.2f}c/s  "
        f"drive {catchup}  "
        f"scale {PROGRESSIVE_CHASE_RATE_SCALE:.2f}{boost_txt}  {mode}",
    )
    y0 = SCREEN_HEIGHT - 8 - len(lines) * 32
    for row, line in enumerate(lines):
        text_surface = DEBUG_HUD_FONT.render(line, True, (255, 255, 0), (0, 0, 0))
        win.blit(text_surface, (8, y0 + row * 32))


#-----------------------------------------------------------------------------------------------------------------------
def play_ads():

    global ads_index, ABORT_ADS

    # If there are NO ads just return

    if (TOTAL_ADS == 0):
        return

    mutex.acquire() #########################
    local_ads = []
    for i in range(0, TOTAL_ADS):
        local_ads.append(Ad(copy.copy(ads[i].media), ads[i].media_type))
    mutex.release() #########################

    local_consec_run = 0

    while (True):

        mutex.acquire() #########################
        if (ads_index >= TOTAL_ADS):
            ads_index = 0
        local_ads_index = ads_index
        mutex.release() #########################

        # Check if we've played enough consecutive Ads, return to play the Game Logo again if so

        if (local_consec_run >= CONSEC_ADS):
            break

        # Check the Ad Type and call the appropriate display function

        if (local_ads[local_ads_index].media_type == "VIDEO"): # IndexError exception?
            play_ad_video(local_ads[local_ads_index].media)
        elif (local_ads[local_ads_index].media_type == "IMAGE"):
            play_ad_image(local_ads[local_ads_index].media)
        else:
            print("CRITCAL ERROR - play_ads() UNKNOWN AD TYPE - ABORTING")
            exit_video()

        mutex.acquire() #########################
        ads_index += 1
        mutex.release() #########################

        local_consec_run += 1

        if (ABORT_ADS == True):
            ABORT_ADS = False
            break

    del local_ads

    return

#-----------------------------------------------------------------------------------------------------------------------
def play_ad_video(video):

    global sds_event, surface1, surface2, ABORT_ADS

    if (video == None):
        print("ERROR - play_ad_video() invalid video")
        exit_video()

    # Recreate both Surfaces

    surface1 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
    surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))

    # Draw the video frame by frame

    video.restart()
    prog_last_tick = time.perf_counter()

    while (video.active):

        # Check if there is a new SDS Event

        Handle_Event = None

        mutex.acquire() #########################
        if (sds_event != None):
            Handle_Event = sds_event
            sds_event = None
        mutex.release() #########################

        # Handle the Event if one happened

        if (Handle_Event == 2): # Error/Door-Open/Attendant instantly need to exit
            ABORT_ADS = True
            return

        if (Handle_Event == 0 or Handle_Event == 3):  # Nicely fade to black and exit the currently playing Ad Video

            ABORT_ADS = True

            del surface2

            # Recreate Surface2 (it should default to black)

            surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))

            surface1.convert_alpha()
            surface2.convert_alpha()

            # Loop fade using the alpha

            for i in range(0, 256, 5):

                # Process all game events

                for event in pygame.event.get():
                    if (event.type == pygame.KEYDOWN):
                        _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

                surface1.set_alpha(255 - i)
                surface2.set_alpha(i)

                win.fill((0, 0, 0))

                win.blit(surface1, (0, 0))
                win.blit(surface2, (0, 0))

                _df_present()

                time.sleep(0.010)

            # We need to kill both Surfaces here because they have Alpha enabled and there is no way to disable it

            del surface1
            del surface2

            # Recreate both Surfaces

            surface1 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
            surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))

            return

        # Process all game events

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        # Get the Local IP Address
        get_IP()

        # only draw new frames, and only update the screen if something is drawn
        if video.draw(surface1, (0, 0), force_draw = False):

            if (DRAW_FRAME_COUNT == True):
                text_surface = font.render('Video Frame: ' + str(video.frame), True, (255, 255, 255))
                surface1.blit(text_surface, dest = (5, 2))

            if (DRAW_IP_ADDRESS == True):
                text_surface = font.render(" " + local_ip + " ", True, (255, 255, 255), (0, 0, 0))
                surface1.blit(text_surface, dest = (0, max(0, SCREEN_HEIGHT - 16)))

            win.blit(surface1, (0, 0))
            _draw_pogcc_link_warning(win)
            _df_present()

        now_tick = time.perf_counter()
        _progressive_background_tick(now_tick - prog_last_tick)
        prog_last_tick = now_tick
        time.sleep(0.010)  # This puts the script to sleep for 10ms just to save CPU cycles

    return

#-----------------------------------------------------------------------------------------------------------------------
def play_ad_image(image):

    global sds_event, surface1, surface2, ABORT_ADS

    if (image == None):
        print("ERROR - play_ad_image() invalid image")
        exit_video()

    # Copy the previous surface to surface1

    surface1 = surface2.copy()

    # Set the target width and height

    tw = SCREEN_WIDTH
    th = SCREEN_HEIGHT

    # Get the current image width and height

    iw = image.get_width()
    ih = image.get_height()

    # Check if the image is already scaled

    already_scaled = True

    if ((iw > tw) or (ih > th)):
        already_scaled = False

    if ((iw != tw) and (ih != th)):
        already_scaled = False

    # Check if we need to scale the image, and if so do it

    if (already_scaled == False):

        # Which metric is further away from target resolution?

        mx = iw / tw
        my = ih / th

        if (mx > my):
            sx = tw / iw
            fh = round(ih * sx)

            image = pygame.transform.smoothscale(image, (tw, fh))
        else:
            sy = th / ih
            fw = round(iw * sy)

            image = pygame.transform.smoothscale(image, (fw, th))

    # Adjust the x/y values to center the image

    x = round((tw / 2) - (image.get_width() / 2))
    y = round((th / 2) - (image.get_height() / 2))

    # surface1 has the old image so blit the new image to surface2

    surface2.fill((0, 0, 0))

    surface1 = surface1.convert_alpha()
    surface2 = surface2.convert_alpha()

    '''if (ENABLE_BLUR == True):

        # Blit the stretched image to surface2

        sw = image.get_width()
        sh = image.get_height()

        scale = 0

        if ((sw / sh) >= (tw / th)):
            scale = th / sh
        else:
            scale = tw / sw

        # Adjust the x/y values to center the image

        tx = round((tw / 2) - ((sw * scale) / 2))
        ty = round((th / 2) - ((sh * scale) / 2))

        surface2.blit(pygame.transform.smoothscale(image, (sw * scale, sh * scale)), (tx, ty))'''

    if (ENABLE_BLUR == True):

        # Blur the image

        blur = pygame.transform.smoothscale(image, (image.get_width() // 10, image.get_height() // 10))
        blur = pygame.transform.smoothscale(blur, (tw, th))

        # Darken the image a bit

        darken = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)).convert_alpha()
        darken.set_alpha(150)

        # Blit the image to surface2

        surface2.blit(blur, (0, 0))
        surface2.blit(darken, (0, 0))

    surface2.blit(image, (x, y))

    # Cross fade from surface1 to surface2
    prog_last_tick = time.perf_counter()

    for i in range(0, 256, 8):

        # Check if there is a new SDS Event

        Handle_Event = None

        mutex.acquire() #########################
        if (sds_event != None):
            Handle_Event = sds_event
            sds_event = None
        mutex.release() #########################

        # Handle the Event if one happened

        if (Handle_Event != None): # Instantly exit the currently playing Ad Image
            ABORT_ADS = True
            return

        # Process all game events

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        surface1.set_alpha(255 - i)
        surface2.set_alpha(i)

        win.fill((0, 0, 0))

        win.blit(surface1, (0, 0))
        win.blit(surface2, (0, 0))

        _df_present()

        now_tick = time.perf_counter()
        _progressive_background_tick(now_tick - prog_last_tick)
        prog_last_tick = now_tick
        time.sleep(0.010)

    # Draw the image for TIME_PER_FRAME

    win.fill((0, 0, 0))
    win.blit(surface2, (0, 0))

    _df_present()

    start_tick = time.perf_counter()
    prog_last_tick = start_tick

    while ((time.perf_counter() - start_tick) < TIME_PER_FRAME):

        # Check if there is a new SDS Event

        Handle_Event = None

        mutex.acquire() #########################
        if (sds_event != None):
            Handle_Event = sds_event
            sds_event = None
        mutex.release() #########################

        # Handle the Event if one happened

        if (Handle_Event != None): # Instantly exit the currently playing Ad Image
            ABORT_ADS = True
            return

        # Process all game events

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        now_tick = time.perf_counter()
        _progressive_background_tick(now_tick - prog_last_tick)
        prog_last_tick = now_tick
        time.sleep(0.010)  # This puts the script to sleep for 10ms just to save CPU cycles

    return

#-----------------------------------------------------------------------------------------------------------------------
def _stop_video_quiet(video) -> None:
    """Halt a pyvidplayer2 clip so the next restart() is not fighting ffmpeg."""
    if video is None:
        return
    try:
        video.stop()
    except Exception:
        pass


def play_video(loop_count = 1, delay = 0, A = 0, B = 0, show_hold_then_exit = False):

    global sds_event, surface1, surface2

    local_index = curr_index

    if (local_index < 0 or local_index >= TOTAL_VIDEOS or vids[local_index] == None):
        print("ERROR - play_video() invalid video")
        exit_video()

    # Recreate both Surfaces

    surface1 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
    surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))

    start_delay = None
    show_hold_done = False

    # Handle the Alternate Game Menu videos

    if (local_index == 0):
        if (DISPLAY_ALTERNATE_VIDEO == 1):
            local_index = 17

    # Draw the video frame by frame

    vids[local_index].restart()
    prog_last_tick = time.perf_counter()

    while (vids[local_index].active):

        # Check if there is a new SDS Event

        Handle_Event = None
        live_index = local_index

        mutex.acquire() #########################
        if (sds_event != None):
            Handle_Event = sds_event
            sds_event = None
        live_index = curr_index
        mutex.release() #########################

        # Abort 0/3 means "leave this clip for a different game." If we are
        # still on the same game, the event is leftover (boot/menu fade) and
        # must not kill the logo.
        playing_game = 0 if local_index == 17 else local_index
        if Handle_Event in (0, 3) and live_index == playing_game:
            print(
                f"play_video: ignore sds_event={Handle_Event} "
                f"(still game {live_index})",
                flush=True,
            )
            Handle_Event = None

        # Handle the Event if one happened

        FORCE_REDRAW = False

        if (Handle_Event == 0):  # Leave this clip - caller plays curr_index
            # Non-progressive logos (Black Gold 21) use a 120 s Show hold at
            # frame B. Attract SDS is ~13 s, so loop_count=0 + seek B restarted
            # that hold forever: HDMI stuck on game 15 while curr_index cycled.
            print(
                f"play_video: abort clip {playing_game} -> curr_index={live_index}",
                flush=True,
            )
            _stop_video_quiet(vids[local_index])
            return

        elif (Handle_Event == 1):  # Redraws the existing screen. Used when showing/hiding on-screen Fonts
            # Refresh the video screen
            FORCE_REDRAW = True
            loop_count = 0
            if (start_delay != None):
                start_delay = None
                vids[local_index].seek_frame(B)

        elif (Handle_Event == 2):  # Instant exit
            # surface1 (global) should have the last full screen rendered
            _stop_video_quiet(vids[local_index])
            return

        elif (Handle_Event == 3):  # Fade to Black exit
            # surface1 (global) should have the last full screen rendered, fade it to black then return

            del surface2

            # Recreate Surface2 (it should default to black)

            surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))

            surface1.convert_alpha()
            surface2.convert_alpha()

            # Loop fade using the alpha

            for i in range(0, 256, 5):

                # Process all game events

                for event in pygame.event.get():
                    if (event.type == pygame.KEYDOWN):
                        _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

                surface1.set_alpha(255 - i)
                surface2.set_alpha(i)

                win.fill((0, 0, 0))

                win.blit(surface1, (0, 0))
                win.blit(surface2, (0, 0))

                _df_present()

                time.sleep(0.010)

            # We need to kill both Surfaces here because they have Alpha enabled and there is no way to disable it

            del surface1
            del surface2

            # Recreate both Surfaces

            surface1 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
            surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))

            _stop_video_quiet(vids[local_index])
            return

        # Process all game events

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        # Get the Local IP Address
        get_IP()

        # Pause at frame B: short loop for progressive games, long Show hold for non-progressive.
        if (start_delay == None) and (vids[local_index].frame == B) and (delay > 0):
            if show_hold_then_exit and not show_hold_done:
                start_delay = time.perf_counter()
            elif loop_count > 1:
                start_delay = time.perf_counter()

        if (start_delay != None):
            if ((time.perf_counter() - start_delay) >= delay):
                start_delay = None
                if show_hold_then_exit:
                    show_hold_done = True
                elif loop_count > 1:
                    vids[local_index].seek_frame(A)
                    loop_count -= 1

        if (start_delay == None or FORCE_REDRAW == True):

            # only draw new frames, and only update the screen if something is drawn
            if vids[local_index].draw(surface1, (0, 0), force_draw = FORCE_REDRAW):

                if (DRAW_FRAME_COUNT == True):
                    text_surface = font.render('Video Frame: ' + str(vids[local_index].frame), True, (255, 255, 255))
                    surface1.blit(text_surface, dest = (5, 2))

                if (DRAW_IP_ADDRESS == True):
                    text_surface = font.render(" " + local_ip + " ", True, (255, 255, 255), (0, 0, 0))
                    surface1.blit(text_surface, dest = (0, max(0, SCREEN_HEIGHT - 16)))

                win.blit(surface1, (0, 0))
                _draw_pogcc_link_warning(win)
                _df_present()

        now_tick = time.perf_counter()
        _progressive_background_tick(now_tick - prog_last_tick)
        prog_last_tick = now_tick
        time.sleep(0.010)  # This puts the script to sleep for 10ms just to save CPU cycles

    _stop_video_quiet(vids[local_index])
    return

#-----------------------------------------------------------------------------------------------------------------------
def play_progressive_display(index, duration_sec = 120.0):

    global sds_event, surface1, surface2, PROGRESSIVE_DISPLAY, ABORT_ADS, _tbx_staged_fade_game_id

    if (PROGRESSIVE_DISPLAY == None or PROGRESSIVE_DISPLAY.supports_game(index) == False):
        return

    PROGRESSIVE_DISPLAY.prepare_for_slide(index)

    start_tick = time.perf_counter()
    last_tick = start_tick
    first_draw = True

    while ((time.perf_counter() - start_tick) < duration_sec):

        now_tick = time.perf_counter()
        dt_seconds = max(0.0, now_tick - last_tick)
        last_tick = now_tick

        Handle_Event = None

        mutex.acquire() #########################
        if (sds_event != None):
            Handle_Event = sds_event
            sds_event = None
        mutex.release() #########################

        if (Handle_Event != None):
            ABORT_ADS = True
            return

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        if is_ohd_mode():
            _progressive_background_tick(dt_seconds, exclude_game_ids=(index,))

        PROGRESSIVE_DISPLAY.reader.reload_if_changed()

        rendered, changed = PROGRESSIVE_DISPLAY.render_slide(index, dt_seconds, surface1.get_size())
        should_draw = rendered is not None and (
            first_draw
            or changed
            or DRAW_PROGRESSIVE_DEBUG
            or is_ohd_mode()
        )
        if should_draw:
            if (first_draw == True and not is_ohd_mode()):
                # TBX: staged fade from logo only on first show of this game (not timer reload).
                if (_tbx_staged_fade_game_id != index):
                    logo_under = surface1.copy().convert_alpha()

                    def _tbx_fade_tick(dt_seconds: float) -> None:
                        if PROGRESSIVE_DISPLAY is not None and dt_seconds > 0:
                            PROGRESSIVE_DISPLAY.tick_game(index, dt_seconds)

                    if (
                        PROGRESSIVE_DISPLAY.play_staged_fade_in(
                            index,
                            win,
                            surface1.get_size(),
                            under=logo_under,
                            tick_background=_tbx_fade_tick,
                            on_keydown=_handle_keyboard_dev_shortcuts,
                        )
                        == False
                    ):
                        return
                    _tbx_staged_fade_game_id = index

            surface1.blit(rendered, (0, 0))
            if (not is_ohd_mode()):
                surface2.blit(rendered, (0, 0))
            win.blit(surface1, (0, 0))
            _blit_progressive_debug_hud(index)
            _draw_pogcc_link_warning(win)
            _df_present()
            first_draw = False

        frame_budget = 0.010
        frame_elapsed = time.perf_counter() - now_tick
        sleep_remain = frame_budget - frame_elapsed
        if sleep_remain > 0.001:
            time.sleep(sleep_remain)

    return

#-----------------------------------------------------------------------------------------------------------------------
#-----------------------------------------------------------------------------------------------------------------------
def _fireworks_blocks_attendant() -> bool:
    """Local jackpot winner: play full fireworks before Call Attendant overlay."""
    if (not DISPLAY_FIREWORKS):
        return False
    if (not DISPLAY_ATTENDANT):
        return True
    if (FIREWORKS_LOCAL_WINNER and time.perf_counter() < FIREWORKS_ERROR_DEFER_UNTIL):
        return True
    return False


def _apply_fireworks_payload(payload: dict[str, Any]) -> None:

    global sds_event, DISPLAY_FIREWORKS, DISPLAY_FIREWORKS_LOOP, DISPLAY_FIREWORKS_DURATION_SEC
    global ABORT_ADS, FIREWORKS_LOCAL_WINNER, FIREWORKS_ERROR_DEFER_UNTIL, FIREWORKS_WINNER_AMOUNT_TEXT

    duration = duration_from_request(
        payload,
        winner_duration_sec=FIREWORKS_WINNER_DURATION_SEC,
        bank_duration_sec=FIREWORKS_BANK_DURATION_SEC,
    )
    winner_tid = str(payload.get("winner_tid", "")).upper()
    local_winner = is_local_jackpot_winner(payload)
    # A missing flag means an older bridge - treat as a bank progressive jackpot.
    is_progressive = bool(payload.get("is_progressive", True))
    # OHD has no local TID / Call Attendant. Bank progressive wins always show the
    # amount overlay; never defer attendant on the overhead sign.
    ohd_progressive = is_ohd_mode() and is_progressive
    show_amount = local_winner or ohd_progressive
    defer_attendant = local_winner and not ohd_progressive
    print(
        f"fireworks: winner_tid={winner_tid} duration={duration:.0f}s "
        f"local_jackpot_winner={local_winner} progressive={is_progressive} "
        f"show_amount={show_amount} defer_attendant={defer_attendant}",
        flush=True,
    )

    mutex.acquire() #########################
    sds_event = 2
    DISPLAY_FIREWORKS = True
    if (is_progressive):
        # Bank progressive jackpot: duration-based celebration.
        DISPLAY_FIREWORKS_LOOP = 0
        DISPLAY_FIREWORKS_DURATION_SEC = duration
        FIREWORKS_LOCAL_WINNER = show_amount
        if (show_amount):
            FIREWORKS_WINNER_AMOUNT_TEXT = format_progwin_display(
                payload.get("progwin_cents"),
                dollars=payload.get("progwin_dollars"),
            )
            if (defer_attendant):
                FIREWORKS_ERROR_DEFER_UNTIL = time.perf_counter() + float(duration)
                print(
                    f"fireworks: defer Call Attendant {duration:.0f}s - overlay "
                    f"{FIREWORKS_WINNER_AMOUNT_TEXT}",
                    flush=True,
                )
            else:
                FIREWORKS_ERROR_DEFER_UNTIL = 0.0
                print(
                    f"fireworks: amount overlay {FIREWORKS_WINNER_AMOUNT_TEXT}",
                    flush=True,
                )
        else:
            FIREWORKS_ERROR_DEFER_UNTIL = 0.0
            FIREWORKS_WINNER_AMOUNT_TEXT = ""
    else:
        # Local slot cash win: single loop, no amount overlay, no attendant defer.
        DISPLAY_FIREWORKS_LOOP = 1
        DISPLAY_FIREWORKS_DURATION_SEC = 0.0
        FIREWORKS_LOCAL_WINNER = False
        FIREWORKS_ERROR_DEFER_UNTIL = 0.0
        FIREWORKS_WINNER_AMOUNT_TEXT = ""
        print("fireworks: local cash win - single loop, no overlay text", flush=True)
    ABORT_ADS = True
    mutex.release() #########################


def _on_fireworks_ipc_request(payload):

    if (CONFIGSET == False):
        return

    if is_ohd_mode() and payload.get("is_progressive") is False:
        print("fireworks IPC: OHD ignores local non-progressive cash win", flush=True)
        return

    # Remote OHD polls tolerate modest clock skew vs the Pi's updated_utc.
    fresh_window = 45.0 if is_ohd_mode() else 15.0
    if not fireworks_request_is_fresh(payload, max_age_sec=fresh_window):
        print("fireworks IPC: ignore stale request", flush=True)
        return

    print("fireworks IPC: request received", flush=True)
    _apply_fireworks_payload(payload)


def _start_fireworks_ipc_watcher():

    global FIREWORKS_IPC_WATCHER

    if (FIREWORKS_IPC_WATCHER != None):
        return

    if IS_WINDOWS and is_ohd_mode():
        source = resolve_progressives_source()
        if not is_remote_progressives_source(source):
            print("fireworks watcher: OHD source is local; HTTP watcher disabled", flush=True)
            return
        request_url = fireworks_url_from_progressives_source(source)
        FIREWORKS_IPC_WATCHER = RemoteFireworksRequestWatcher(
            _on_fireworks_ipc_request,
            request_url=request_url,
            max_age_sec=45.0,
        )
        FIREWORKS_IPC_WATCHER.start()
        return

    if IS_LINUX:
        stale = read_fireworks_request()
        if (stale != None and not fireworks_request_is_fresh(stale)):
            clear_fireworks_request()
            print("fireworks IPC: cleared stale request on boot", flush=True)

        FIREWORKS_IPC_WATCHER = FireworksRequestWatcher(_on_fireworks_ipc_request)
        FIREWORKS_IPC_WATCHER.start()


def _on_current_game_ipc(payload):
    """EtherGold current-game.json -' same path as USBCDC /sds (last writer wins)."""
    if (CONFIGSET == False):
        return
    idx = game_index_from_payload(payload)
    if idx is None:
        return
    gid = payload.get("game_id_hex")
    print(f"current-game IPC: game_index={idx} game_id_hex={gid}", flush=True)
    sds_handler(idx, source="current-game")


def _apply_this_boot_current_game():
    """After splash: SDS to copper's this-boot game (Promote restart safe).

    Watcher start() records last_index without calling sds_handler so a
    leftover file cannot jump HDMI during splash. Pre-boot files are unlinked.
    A DF restart while listen already has SuperBall would otherwise stay on
    the POG logo until game_index changed.
    """
    if (not IS_LINUX):
        return
    if (CONFIGSET == False):
        return
    if (not current_game_ipc_consume_enabled()):
        return
    payload = this_boot_current_game_payload()
    if not payload:
        return
    _on_current_game_ipc(payload)


def _start_current_game_ipc_watcher():

    global CURRENT_GAME_IPC_WATCHER, sds_event

    if (not IS_LINUX):
        return
    if (not current_game_ipc_consume_enabled()):
        return
    if (CURRENT_GAME_IPC_WATCHER != None):
        return

    # Do not apply here - splash has not run. After splash, _apply_this_boot
    # SDS this-boot current-game.json. Pre-boot leftovers were unlinked.
    CURRENT_GAME_IPC_WATCHER = CurrentGameWatcher(_on_current_game_ipc)
    CURRENT_GAME_IPC_WATCHER.start()


def _fireworks_draw_pos():
    """Center a resolution-specific fireworks clip inside the display canvas."""
    size = getattr(fireworks, "current_size", None) or getattr(fireworks, "original_size", None)
    if not (isinstance(size, (tuple, list)) and len(size) >= 2):
        # The supplied OHD clip is encoded 3840x810 despite living in the
        # 3840x1080 asset folder.
        size = (3840, 810) if is_ohd_mode() and SCREEN_WIDTH == 3840 else (
            SCREEN_WIDTH,
            SCREEN_HEIGHT,
        )
    video_w, video_h = int(size[0]), int(size[1])
    return (max(0, (SCREEN_WIDTH - video_w) // 2), max(0, (SCREEN_HEIGHT - video_h) // 2))


def _draw_fireworks(surface, *, force_draw=False):
    ox, oy = _fireworks_draw_pos()
    if ox == 0 and oy == 0:
        return fireworks.draw(surface, (0, 0), force_draw=force_draw)
    surface.fill((0, 0, 0))
    return fireworks.draw(surface, (ox, oy), force_draw=force_draw)


def _fireworks_overlay_band() -> pygame.Rect:
    """Visible fireworks rectangle matching the loaded clip."""
    ox, oy = _fireworks_draw_pos()
    size = getattr(fireworks, "current_size", None) or getattr(fireworks, "original_size", None)
    if isinstance(size, (tuple, list)) and len(size) >= 2:
        return pygame.Rect(ox, oy, int(size[0]), int(size[1]))
    return pygame.Rect(ox, oy, SCREEN_WIDTH, SCREEN_HEIGHT)


def _render_fireworks_overlay_text(font, text, fg, outline, *, outline_px=2):

    core = font.render(text, True, fg)
    w, h = core.get_size()
    pad = outline_px + 1
    surf = pygame.Surface((w + pad * 2, h + pad * 2), pygame.SRCALPHA)
    shadow = font.render(text, True, outline)
    for dx in range(-outline_px, outline_px + 1):
        for dy in range(-outline_px, outline_px + 1):
            if (dx == 0 and dy == 0):
                continue
            surf.blit(shadow, (pad + dx, pad + dy))
    surf.blit(core, (pad, pad))
    return surf


def _fireworks_overlay_fonts():
    global FIREWORKS_OVERLAY_FONTS
    if (FIREWORKS_OVERLAY_FONTS != None):
        return FIREWORKS_OVERLAY_FONTS
    band_h = _fireworks_overlay_band().height
    base = pygame.font.get_default_font()
    title_px = max(40, min(68, band_h // 14))
    amount_px = max(112, min(200, band_h // 4))
    if (amount_px <= title_px):
        amount_px = min(200, title_px + 48)
    FIREWORKS_OVERLAY_FONTS = (
        pygame.font.Font(base, title_px),
        pygame.font.Font(base, amount_px),
    )
    return FIREWORKS_OVERLAY_FONTS


def _draw_fireworks_jackpot_overlay(surface) -> None:
    if (not FIREWORKS_LOCAL_WINNER or not FIREWORKS_WINNER_AMOUNT_TEXT):
        return
    band = _fireworks_overlay_band()
    title_font, amount_font = _fireworks_overlay_fonts()
    gold = (255, 215, 0)
    outline = (0, 0, 0)
    title_surf = _render_fireworks_overlay_text(title_font, "JACKPOT WINNER", gold, outline)
    amount_surf = _render_fireworks_overlay_text(
        amount_font, FIREWORKS_WINNER_AMOUNT_TEXT, (255, 255, 255), outline, outline_px=3
    )
    gap = max(8, amount_surf.get_height() // 12)
    stack_h = title_surf.get_height() + gap + amount_surf.get_height()
    title_x = band.centerx - title_surf.get_width() // 2
    title_y = band.centery - stack_h // 2
    amount_x = band.centerx - amount_surf.get_width() // 2
    amount_y = title_y + title_surf.get_height() + gap
    surface.blit(title_surf, (title_x, title_y))
    surface.blit(amount_surf, (amount_x, amount_y))


def _draw_fps_hud(surface) -> None:
    """TEMP small upper-left FPS while boot fireworks test is enabled."""
    global _FPS_HUD_LAST_T, _FPS_HUD_SMOOTH, _FPS_HUD_FONT
    if not TEMP_BOOT_FIREWORKS_TEST:
        return
    now = time.perf_counter()
    if _FPS_HUD_LAST_T > 0.0:
        inst = 1.0 / max(1e-6, now - _FPS_HUD_LAST_T)
        if _FPS_HUD_SMOOTH <= 0.0:
            _FPS_HUD_SMOOTH = inst
        else:
            _FPS_HUD_SMOOTH = (0.85 * _FPS_HUD_SMOOTH) + (0.15 * inst)
    _FPS_HUD_LAST_T = now
    if _FPS_HUD_FONT is None:
        _FPS_HUD_FONT = pygame.font.Font(pygame.font.get_default_font(), 22)
    accel = _FFMPEG_HWACCEL or "sw"
    label = _FPS_HUD_FONT.render(
        f"{_FPS_HUD_SMOOTH:4.1f} fps {accel}", True, (0, 255, 0), (0, 0, 0)
    )
    surface.blit(label, (8, 8))


def _present_fireworks_frame(surface, *, force_draw=False) -> bool:
    """Draw fireworks video frame and optional local-winner jackpot overlay."""
    if (not _draw_fireworks(surface, force_draw=force_draw)):
        return False
    _draw_fireworks_jackpot_overlay(surface)
    _draw_fps_hud(surface)
    return True


#-----------------------------------------------------------------------------------------------------------------------
def _fireworks_check_override() -> bool:
    """Door open may interrupt fireworks immediately.

    Machine error (attendant) interrupts bank fireworks immediately. On the local jackpot
    winner, error is deferred for the fireworks duration (60s default) so celebration
    plays first; if /flag/error is still set afterward, attendant runs normally.
    """

    global sds_event, DISPLAY_FIREWORKS, DISPLAY_FIREWORKS_LOOP, DISPLAY_FIREWORKS_DURATION_SEC
    global FIREWORKS_LOCAL_WINNER, FIREWORKS_ERROR_DEFER_UNTIL, FIREWORKS_WINNER_AMOUNT_TEXT

    handle = None
    mutex.acquire() #########################
    if (sds_event != None):
        handle = sds_event
        sds_event = None
    door_override = DISPLAY_DOOROPEN
    error_override = DISPLAY_ATTENDANT
    local_winner = FIREWORKS_LOCAL_WINNER
    error_defer_until = FIREWORKS_ERROR_DEFER_UNTIL
    mutex.release() #########################

    if (handle == 2 and door_override):
        DISPLAY_FIREWORKS = False
        DISPLAY_FIREWORKS_LOOP = 0
        DISPLAY_FIREWORKS_DURATION_SEC = 0.0
        FIREWORKS_LOCAL_WINNER = False
        FIREWORKS_ERROR_DEFER_UNTIL = 0.0
        FIREWORKS_WINNER_AMOUNT_TEXT = ""
        return True

    if (error_override):
        if (local_winner and time.perf_counter() < error_defer_until):
            return False
        if (handle == 2 or error_override):
            DISPLAY_FIREWORKS = False
            DISPLAY_FIREWORKS_LOOP = 0
            DISPLAY_FIREWORKS_DURATION_SEC = 0.0
            FIREWORKS_LOCAL_WINNER = False
            FIREWORKS_ERROR_DEFER_UNTIL = 0.0
            FIREWORKS_WINNER_AMOUNT_TEXT = ""
            return True

    # sds_event 0/1/3 (game switch, redraw, fade) and helpreq - swallowed; keep fireworks running
    return False


#-----------------------------------------------------------------------------------------------------------------------
def _fireworks_media_path() -> pathlib.Path | None:
    """Path to fireworks.mp4 for mpv (pyvidplayer2 Video.path when present)."""
    for attr in ("path", "filename", "name"):
        raw = getattr(fireworks, attr, None)
        if raw:
            p = pathlib.Path(str(raw))
            if p.is_file():
                return p
    if is_ohd_mode():
        p = pathlib.Path(resolve_shared_display_asset("fireworks"))
        if p.is_file():
            return p
    p = pathlib.Path("content") / "fireworks.mp4"
    return p if p.is_file() else None


def _fireworks_black_cover() -> None:
    """Keep a full-black pygame frame up so desktop never shows during mpv handoff."""
    if win is None:
        return
    try:
        win.fill((0, 0, 0))
        _df_present()
        apply_win32_kiosk_window()
    except Exception:
        pass


def _play_fireworks_mpv(loop_count: int, duration_sec: float) -> bool:
    """Win11 OHD GPU present via mpv. True if handled (including natural end)."""
    if not (IS_WINDOWS and is_ohd_mode()):
        return False
    if not mpv_available():
        print("mpv fireworks: mpv.exe not found - using software path", flush=True)
        return False
    media = _fireworks_media_path()
    if media is None:
        print("mpv fireworks: media path missing - using software path", flush=True)
        return False

    amount = ""
    if FIREWORKS_LOCAL_WINNER and FIREWORKS_WINNER_AMOUNT_TEXT:
        amount = FIREWORKS_WINNER_AMOUNT_TEXT

    def _pump() -> None:
        for event in pygame.event.get():
            if event.type == pygame.KEYDOWN:
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))
        # Do NOT re-assert pygame topmost while mpv owns the screen - that
        # fights mpv ontop and flashes the desktop/wallpaper between them.

    print(
        f"mpv fireworks: present {media} duration={duration_sec} loop={loop_count}",
        flush=True,
    )
    _fireworks_black_cover()
    ok = play_with_mpv(
        media,
        screen_w=SCREEN_WIDTH,
        screen_h=SCREEN_HEIGHT,
        duration_sec=float(duration_sec or 0.0),
        loop_count=int(loop_count or 1),
        amount_text=amount,
        show_hud=False,  # FPS/hwdec stay in log.txt; on-screen was cluttering jackpot
        pump_events=_pump,
        before_stop=_fireworks_black_cover,
        after_stop=_fireworks_black_cover,
    )
    return ok


def _recreate_fireworks_surfaces() -> None:
    """Drop alpha-enabled surfaces after fireworks fade helpers and rebuild solid RGB."""
    global surface1, surface2
    try:
        del surface1
    except Exception:
        pass
    try:
        del surface2
    except Exception:
        pass
    surface1 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
    surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))


def _fireworks_exit_crossfade(*, from_software_video: bool = True) -> bool:
    """Soft exit matching the entry fade - last celebration frame to normal content.

    Prefer crossfade into ``_ohd_last_prog_snapshot`` when available; otherwise fade
    to black so the next ad/progressive does not hard-cut off fireworks.

    Returns False if a higher-priority SDS interrupt (door / attendant) aborts.
    """
    global surface1, surface2

    if win is None:
        return True

    try:
        # From = last fireworks frame (software) or current black handoff (mpv).
        if from_software_video and fireworks is not None:
            surface1.fill((0, 0, 0))
            if not _present_fireworks_frame(surface1, force_draw=True):
                try:
                    surface1.blit(win, (0, 0))
                except Exception:
                    surface1.fill((0, 0, 0))
            try:
                fireworks.stop()
            except Exception:
                pass
        else:
            surface1.fill((0, 0, 0))
            try:
                surface1.blit(win, (0, 0))
            except Exception:
                pass

        # To = frozen progressive when we have one; else black.
        surface2.fill((0, 0, 0))
        try:
            snap = _ohd_last_prog_snapshot
            if snap is not None:
                if snap.get_size() != (SCREEN_WIDTH, SCREEN_HEIGHT):
                    snap = pygame.transform.smoothscale(
                        snap, (SCREEN_WIDTH, SCREEN_HEIGHT)
                    )
                surface2.blit(snap.convert(), (0, 0))
        except Exception:
            surface2.fill((0, 0, 0))

        surface1.convert_alpha()
        surface2.convert_alpha()

        # Same cadence as entry fade into fireworks (step 5 @ 10 ms).
        for i in range(0, 256, 5):
            if _fireworks_check_override():
                _recreate_fireworks_surfaces()
                return False

            for event in pygame.event.get():
                if event.type == pygame.KEYDOWN:
                    _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

            surface1.set_alpha(255 - i)
            surface2.set_alpha(i)
            win.fill((0, 0, 0))
            win.blit(surface1, (0, 0))
            win.blit(surface2, (0, 0))
            _df_present()
            time.sleep(0.010)

        # Hold the destination full-opacity for the next display cycle base.
        win.fill((0, 0, 0))
        win.blit(surface2, (0, 0))
        _df_present()
        _recreate_fireworks_surfaces()
        try:
            surface1.blit(win, (0, 0))
        except Exception:
            pass
    except Exception as exc:
        print(f"WARNING - fireworks exit crossfade failed: {exc}", flush=True)
        try:
            _recreate_fireworks_surfaces()
        except Exception:
            pass
    return True


#-----------------------------------------------------------------------------------------------------------------------
def play_fireworks(loop_count = 1, duration_sec = 0.0):

    global DISPLAY_FIREWORKS, DISPLAY_FIREWORKS_LOOP, DISPLAY_FIREWORKS_DURATION_SEC, surface1, surface2
    global FIREWORKS_LOCAL_WINNER, FIREWORKS_ERROR_DEFER_UNTIL, FIREWORKS_WINNER_AMOUNT_TEXT

    if fireworks is None:
        print("WARN: play_fireworks skipped - fireworks asset missing", flush=True)
        DISPLAY_FIREWORKS = False
        DISPLAY_FIREWORKS_LOOP = 0
        DISPLAY_FIREWORKS_DURATION_SEC = 0.0
        return

    # Win11 OHD: D3D11 GPU present (mpv ortho quad). Soft-fallback to pyvidplayer2.
    if _play_fireworks_mpv(loop_count, duration_sec):
        # mpv ends on black cover - crossfade black -' last progressive (or black) so
        # return to ads is not a hard cut into the next pygame frame.
        _fireworks_exit_crossfade(from_software_video=False)
        DISPLAY_FIREWORKS = False
        DISPLAY_FIREWORKS_LOOP = 0
        DISPLAY_FIREWORKS_DURATION_SEC = 0.0
        FIREWORKS_LOCAL_WINNER = False
        FIREWORKS_ERROR_DEFER_UNTIL = 0.0
        FIREWORKS_WINNER_AMOUNT_TEXT = ""
        if not (IS_WINDOWS and is_ohd_mode()):
            clear_fireworks_request()
        return

    deadline = (time.perf_counter() + float(duration_sec)) if duration_sec and duration_sec > 0 else None

    # Grab a frame of video 2

    fireworks.restart()

    while (_draw_fireworks(surface2, force_draw=False) == False):
        if (_fireworks_check_override()):
            return
        time.sleep(0.010)

    fireworks.stop()

    # Convert both surfaces to native hardware ARGB

    surface1.convert_alpha()
    surface2.convert_alpha()

    # Loop fade using the alpha (entry: current content -' fireworks)

    for i in range(0, 256, 5):

        if (_fireworks_check_override()):
            return

        # Process all game events

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        surface1.set_alpha(255 - i)
        surface2.set_alpha(i)

        win.fill((0, 0, 0))

        win.blit(surface1, (0, 0))
        fx_x, fx_y = _fireworks_draw_pos()
        win.blit(surface2, (fx_x, fx_y))
        _draw_fireworks_jackpot_overlay(win)
        _draw_fps_hud(win)

        _df_present()

        time.sleep(0.010)

    # We need to kill both Surfaces here because they have Alpha enabled and there is no way to disable it

    del surface1
    del surface2

    # Recreate both Surfaces

    surface1 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
    surface2 = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))

    # Play the fireworks video until duration_sec elapses or loop_count exhausts

    while (True):

        if (_fireworks_check_override()):
            return

        fireworks.restart()

        while (fireworks.active):

            if (_fireworks_check_override()):
                return

            # Process all game events

            for event in pygame.event.get():
                if (event.type == pygame.KEYDOWN):
                    _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

            if (deadline != None and time.perf_counter() >= deadline):
                fireworks.stop()
                break

            # only draw new frames, and only update the screen if something is drawn
            if _present_fireworks_frame(win, force_draw=False):

                _df_present()

            else:
                time.sleep(0.010)  # save CPU when no new frame

        if (deadline != None):
            if (time.perf_counter() >= deadline):
                break
            continue

        loop_count -= 1
        if (loop_count <= 0):
            break

    # Soft exit: last fireworks frame -' progressive snapshot (or black)
    _fireworks_exit_crossfade(from_software_video=True)

    DISPLAY_FIREWORKS = False
    DISPLAY_FIREWORKS_LOOP = 0
    DISPLAY_FIREWORKS_DURATION_SEC = 0.0
    FIREWORKS_LOCAL_WINNER = False
    FIREWORKS_ERROR_DEFER_UNTIL = 0.0
    FIREWORKS_WINNER_AMOUNT_TEXT = ""
    # Win11 OHD only polls the Pi; the Pi owns fireworks-request.json lifecycle.
    if not (IS_WINDOWS and is_ohd_mode()):
        clear_fireworks_request()

    return

#-----------------------------------------------------------------------------------------------------------------------
def play_attendant():

    global sds_event

    attendant.restart()

    while (attendant.active and DISPLAY_ATTENDANT == True):

        Handle_Event = None

        mutex.acquire() #########################
        if (sds_event != None):
            Handle_Event = sds_event
            sds_event = None
        mutex.release() #########################

        if (Handle_Event == 2):  # Instant exit
            return

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        if attendant.draw(win, (0, 0), force_draw = False):

            _df_present()

        if (attendant.active == False):
                attendant.restart()
        else:
            time.sleep(0.010)

    return

#-----------------------------------------------------------------------------------------------------------------------
def play_dooropen():

    global sds_event

    # Play the attendant video

    dooropen.restart()

    while (dooropen.active and DISPLAY_DOOROPEN == True):

        # Check if there is a new SDS Event

        Handle_Event = None

        mutex.acquire() #########################
        if (sds_event != None):
            Handle_Event = sds_event
            sds_event = None
        mutex.release() #########################

        # Handle the Event if one happened

        if (Handle_Event == 2):  # Instant exit
            return

        # Process all game events

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        # only draw new frames, and only update the screen if something is drawn
        if dooropen.draw(win, (0, 0), force_draw = False):

            _df_present()

        # Check if we need to loop again

        if (dooropen.active == False):
                dooropen.restart()
        else:
            time.sleep(0.010)  # This puts the script to sleep for 10ms just to save CPU cycles

    return

#-----------------------------------------------------------------------------------------------------------------------
def play_helpreq():

    global sds_event

    # Play Service Requested until Cancel or 30s, whichever first.

    helpreq.restart()

    while (helpreq.active and DISPLAY_HELPREQ == True):

        # Check if there is a new SDS Event

        Handle_Event = None

        mutex.acquire() #########################
        if (sds_event != None):
            Handle_Event = sds_event
            sds_event = None
        mutex.release() #########################

        # Handle the Event if one happened

        if (Handle_Event == 2):  # Instant exit
            return

        _expire_helpreq_display()
        if (DISPLAY_HELPREQ == False):
            return

        # Process all game events

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        # only draw new frames, and only update the screen if something is drawn
        if helpreq.draw(win, (0, 0), force_draw = False):

            _df_present()

        # Check if we need to loop again

        if (helpreq.active == False):
                helpreq.restart()
        else:
            time.sleep(0.010)  # This puts the script to sleep for 10ms just to save CPU cycles

    return

#-----------------------------------------------------------------------------------------------------------------------
def _normalize_display_mode(raw: str) -> str:
    mode = raw.strip().upper()
    if mode == "STRIP":
        mode = DISPLAY_MODE_MINI
    if mode in (DISPLAY_MODE_TBX, DISPLAY_MODE_OHD, DISPLAY_MODE_MINI):
        return mode
    print(f"WARNING - invalid display_mode {raw!r}; defaulting to {DISPLAY_MODE_TBX}")
    return DISPLAY_MODE_TBX


def _apply_display_mode_resolution(mode: str) -> None:
    global SCREEN_WIDTH, SCREEN_HEIGHT, ASSET_RESOLUTION
    if mode == DISPLAY_MODE_OHD:
        SCREEN_WIDTH = 3840
        SCREEN_HEIGHT = 1080
    elif mode == DISPLAY_MODE_MINI:
        SCREEN_WIDTH = 1920
        SCREEN_HEIGHT = 360
    else:
        SCREEN_WIDTH = 1920
        SCREEN_HEIGHT = 1080
    ASSET_RESOLUTION = f"{SCREEN_WIDTH}x{SCREEN_HEIGHT}"


def _apply_asset_resolution_override(raw: str) -> None:
    """Optional Assets/**/{name}/ pack (e.g. Mini overscan 1920x360-96)."""
    global ASSET_RESOLUTION
    name = (raw or "").strip()
    if not name:
        ASSET_RESOLUTION = f"{SCREEN_WIDTH}x{SCREEN_HEIGHT}"
        return
    ASSET_RESOLUTION = name


#-----------------------------------------------------------------------------------------------------------------------
def _read_config_ini(config):
    read_config_ini_file(config, CONFIG_FILE)


#-----------------------------------------------------------------------------------------------------------------------
def load_config():

    global CONFIGSET, TEST_MODE_NO_SERIAL, WEB_SERVICE_PORT, LOOP_COUNT, VIDEO_PAUSE, NON_PROGRESSIVE_LOGO_SHOW_PAUSE, DRAW_FRAME_COUNT, DRAW_IP_ADDRESS, DRAW_PROGRESSIVE_DEBUG, DRAW_EDGE_TEST, PROGRESSIVE_CHASE_RATE_SCALE, PROGRESSIVE_CATCHUP_LAG_THRESHOLD, PROGRESSIVE_CATCHUP_LAG_BONUS_PER_CENT, PROGRESSIVE_SNAP_BEHIND_THRESHOLD, PROGRESSIVE_SNAP_BEHIND_LAG, PROGRESSIVE_REMOTE_POLL_SEC, SPLASH_SCREEN_TIME, CHECK_IP_DELAY, CHECK_WLAN0_DELAY, MAX_FIREWORK_LOOPS, FIREWORKS_WINNER_DURATION_SEC, FIREWORKS_BANK_DURATION_SEC, DOWNLOAD_URL_IP, DOWNLOAD_URL_PORT, ASSET_NUMBER, POG_ASSET_NUMBER, MACHINE_ROLE, BANK_NUMBER, TERM_NUMBER, TOPBOX_HOST, CONSEC_ADS, DISABLE_ADS, PROG_DISPLAY_MODE, MODE_1_DISPLAY_TIME, DISPLAY_MODE, OHD_PROGRESSIVE_SCHEDULER, curr_index, POGCC_COM_OWNER, ASSET_RESOLUTION, WIN32_MONITOR

    config = ExtendedConfigParser()
    _read_config_ini(config)
    warn_if_config_unwritable(CONFIG_FILE)

    if config.has_option("TOPBOX", "DISPLAY_MODE"):
        DISPLAY_MODE = _normalize_display_mode(config.get("TOPBOX", "DISPLAY_MODE"))
    else:
        DISPLAY_MODE = DISPLAY_MODE_TBX
    _apply_display_mode_resolution(DISPLAY_MODE)
    if config.has_option("TOPBOX", "ASSET_RESOLUTION"):
        _apply_asset_resolution_override(config.get("TOPBOX", "ASSET_RESOLUTION"))
    else:
        ASSET_RESOLUTION = f"{SCREEN_WIDTH}x{SCREEN_HEIGHT}"

    if config.has_option("TOPBOX", "WIN32_MONITOR"):
        WIN32_MONITOR = config.get("TOPBOX", "WIN32_MONITOR").strip()
    else:
        WIN32_MONITOR = ""

    CONFIGSET = config.getboolean("TOPBOX", "CONFIGSET")
    TEST_MODE_NO_SERIAL = config.getboolean("TOPBOX", "TEST_MODE_NO_SERIAL")
    if config.has_option("TOPBOX", "POGCC_COM_OWNER"):
        POGCC_COM_OWNER = config.get("TOPBOX", "POGCC_COM_OWNER").strip().lower() or "dyzineframe"
    else:
        POGCC_COM_OWNER = "dyzineframe"
    if POGCC_COM_OWNER not in ("dyzineframe", "ethergold"):
        print(
            f"WARN: invalid pogcc_com_owner={POGCC_COM_OWNER!r}; using dyzineframe",
            flush=True,
        )
        POGCC_COM_OWNER = "dyzineframe"
    DISABLE_ADS = config.getboolean("TOPBOX", "DISABLE_ADS")
    PROG_DISPLAY_MODE = config.getint("TOPBOX", "PROG_DISPLAY_MODE")
    MODE_1_DISPLAY_TIME = clamp_mode_1_display_time(config.getint("TOPBOX", "MODE_1_DISPLAY_TIME"))
    WEB_SERVICE_PORT = config.getint("TOPBOX", "WEB_SERVICE_PORT")
    LOOP_COUNT = config.getint("TOPBOX", "LOOP_COUNT")
    VIDEO_PAUSE = config.getfloat("TOPBOX", "VIDEO_PAUSE")
    NON_PROGRESSIVE_LOGO_SHOW_PAUSE = (
        config.getfloat("TOPBOX", "NON_PROGRESSIVE_LOGO_SHOW_PAUSE")
        if config.has_option("TOPBOX", "NON_PROGRESSIVE_LOGO_SHOW_PAUSE")
        else 120.0
    )
    CONSEC_ADS = config.getint("TOPBOX", "CONSEC_ADS")
    DRAW_FRAME_COUNT = config.getboolean("TOPBOX", "DRAW_FRAME_COUNT")
    DRAW_IP_ADDRESS = config.getboolean("TOPBOX", "DRAW_IP_ADDRESS")
    DRAW_EDGE_TEST = (
        config.getboolean("TOPBOX", "DRAW_EDGE_TEST")
        if config.has_option("TOPBOX", "DRAW_EDGE_TEST")
        else False
    )
    if DRAW_EDGE_TEST:
        print(
            "EDGE TEST on: labeled lines every 10 rows (y=0,10,20,...). "
            "Read the first fully visible y= label. "
            f"Surface expect {SCREEN_WIDTH}x{SCREEN_HEIGHT}.",
            flush=True,
        )
    DRAW_PROGRESSIVE_DEBUG = (
        config.getboolean("TOPBOX", "DRAW_PROGRESSIVE_DEBUG")
        if config.has_option("TOPBOX", "DRAW_PROGRESSIVE_DEBUG")
        else False
    )
    if DRAW_PROGRESSIVE_DEBUG:
        print("Progressive debug HUD enabled (draw_progressive_debug)", flush=True)

    PROGRESSIVE_CHASE_RATE_SCALE = (
        config.getfloat("TOPBOX", "PROGRESSIVE_CHASE_RATE_SCALE")
        if config.has_option("TOPBOX", "PROGRESSIVE_CHASE_RATE_SCALE")
        else 1.0
    )
    if PROGRESSIVE_CHASE_RATE_SCALE != 1.0:
        print(
            f"Progressive chase rate scale: {PROGRESSIVE_CHASE_RATE_SCALE}",
            flush=True,
        )
    PROGRESSIVE_CATCHUP_LAG_THRESHOLD = (
        config.getint("TOPBOX", "PROGRESSIVE_CATCHUP_LAG_THRESHOLD")
        if config.has_option("TOPBOX", "PROGRESSIVE_CATCHUP_LAG_THRESHOLD")
        else 20
    )
    PROGRESSIVE_CATCHUP_LAG_BONUS_PER_CENT = (
        config.getfloat("TOPBOX", "PROGRESSIVE_CATCHUP_LAG_BONUS_PER_CENT")
        if config.has_option("TOPBOX", "PROGRESSIVE_CATCHUP_LAG_BONUS_PER_CENT")
        else 0.0
    )
    if PROGRESSIVE_CATCHUP_LAG_BONUS_PER_CENT > 0:
        print(
            f"Progressive catch-up: +{PROGRESSIVE_CATCHUP_LAG_BONUS_PER_CENT:.3f}c/s "
            f"per cent over {PROGRESSIVE_CATCHUP_LAG_THRESHOLD}c lag",
            flush=True,
        )
    PROGRESSIVE_SNAP_BEHIND_THRESHOLD = (
        config.getint("TOPBOX", "PROGRESSIVE_SNAP_BEHIND_THRESHOLD")
        if config.has_option("TOPBOX", "PROGRESSIVE_SNAP_BEHIND_THRESHOLD")
        else 1000
    )
    PROGRESSIVE_SNAP_BEHIND_LAG = (
        config.getint("TOPBOX", "PROGRESSIVE_SNAP_BEHIND_LAG")
        if config.has_option("TOPBOX", "PROGRESSIVE_SNAP_BEHIND_LAG")
        else 200
    )
    if PROGRESSIVE_SNAP_BEHIND_THRESHOLD > 0:
        print(
            f"Progressive lag snap: if >{PROGRESSIVE_SNAP_BEHIND_THRESHOLD}c behind, "
            f"snap to {PROGRESSIVE_SNAP_BEHIND_LAG}c behind live",
            flush=True,
        )
    PROGRESSIVE_REMOTE_POLL_SEC = (
        config.getfloat("TOPBOX", "PROGRESSIVE_REMOTE_POLL_SEC")
        if config.has_option("TOPBOX", "PROGRESSIVE_REMOTE_POLL_SEC")
        else 5.0
    )
    PROGRESSIVE_REMOTE_POLL_SEC = max(0.5, float(PROGRESSIVE_REMOTE_POLL_SEC))
    SPLASH_SCREEN_TIME = config.getfloat("TOPBOX", "SPLASH_SCREEN_TIME")
    CHECK_IP_DELAY = config.getfloat("TOPBOX", "CHECK_IP_DELAY")
    CHECK_WLAN0_DELAY = config.getfloat("TOPBOX", "CHECK_WLAN0_DELAY")
    MAX_FIREWORK_LOOPS = config.getint("TOPBOX", "MAX_FIREWORK_LOOPS")
    FIREWORKS_WINNER_DURATION_SEC = (
        config.getfloat("TOPBOX", "FIREWORKS_WINNER_DURATION_SEC")
        if config.has_option("TOPBOX", "FIREWORKS_WINNER_DURATION_SEC")
        else 60.0
    )
    FIREWORKS_BANK_DURATION_SEC = (
        config.getfloat("TOPBOX", "FIREWORKS_BANK_DURATION_SEC")
        if config.has_option("TOPBOX", "FIREWORKS_BANK_DURATION_SEC")
        else 30.0
    )
    FIREWORKS_WINNER_DURATION_SEC = max(1.0, float(FIREWORKS_WINNER_DURATION_SEC))
    FIREWORKS_BANK_DURATION_SEC = max(1.0, float(FIREWORKS_BANK_DURATION_SEC))
    DOWNLOAD_URL_IP = config.get("TOPBOX", "DOWNLOAD_URL_IP")
    DOWNLOAD_URL_PORT = config.getint("TOPBOX", "DOWNLOAD_URL_PORT")

    content_cfg = read_config_content_settings(config)
    ASSET_NUMBER = int(content_cfg["asset_number"])
    POG_ASSET_NUMBER = ASSET_NUMBER

    identity = read_machine_identity(config)
    MACHINE_ROLE = identity["machine_role"]
    BANK_NUMBER = identity["bank_number"]
    TERM_NUMBER = identity["term_number"]
    TOPBOX_HOST = identity["topbox_host"]
    if MACHINE_ROLE:
        print(
            f"Cabinet identity: role={MACHINE_ROLE} bank={BANK_NUMBER} term={TERM_NUMBER}",
            flush=True,
        )

    if (PROG_DISPLAY_MODE < 1 or PROG_DISPLAY_MODE > 4):
        PROG_DISPLAY_MODE = 1

    if (DISPLAY_MODE == DISPLAY_MODE_TBX and PROG_DISPLAY_MODE != 1):
        PROG_DISPLAY_MODE = 1

    if (OHD_PROGRESSIVE_SCHEDULER != None):
        OHD_PROGRESSIVE_SCHEDULER.update_settings(_load_ohd_scheduler_settings(config))

    if TEST_MODE_NO_SERIAL and config.has_option("TOPBOX", "DEFAULT_GAME_INDEX"):
        curr_index = config.getint("TOPBOX", "DEFAULT_GAME_INDEX")

    refresh_content_sync_targets()

    return


def refresh_content_sync_targets():
    """Resolve ad sync URL/folder from asset_number + Enrollment.csv."""
    global TARGET_WEBPAGE, TARGET_FOLDER, ENROLLMENT_LOCATION, CONTENT_SYNC_ERROR

    config = ExtendedConfigParser()
    _read_config_ini(config)
    content_cfg = read_config_content_settings(config)

    resolved = resolve_content_sync_targets(
        int(content_cfg["asset_number"]),
        enrollment_csv=str(content_cfg["enrollment_csv"]),
        content_base_url=str(content_cfg["content_base_url"]),
        content_local_folder=str(content_cfg["content_local_folder"]),
        download_ip=str(content_cfg["download_ip"]),
        download_port=int(content_cfg["download_port"]),
        enrollment_path=str(content_cfg.get("enrollment_path") or ""),
        enrollment_location=str(content_cfg.get("enrollment_location") or ""),
    )

    if resolved.get("error"):
        TARGET_WEBPAGE = None
        TARGET_FOLDER = None
        ENROLLMENT_LOCATION = None
        CONTENT_SYNC_ERROR = resolved["error"]
        print(f"content sync: {CONTENT_SYNC_ERROR}", flush=True)
        return

    TARGET_WEBPAGE = resolved["content_sync_url"]
    TARGET_FOLDER = resolved["content_local_folder"]
    ENROLLMENT_LOCATION = resolved.get("row")
    CONTENT_SYNC_ERROR = None
    print(
        f"content sync: asset={content_cfg['asset_number']} url={TARGET_WEBPAGE} local={TARGET_FOLDER}",
        flush=True,
    )
    if resolved.get("progressives_publish_url"):
        print(f"content sync: progressives target {resolved['progressives_publish_url']}", flush=True)


def _load_ohd_scheduler_settings(config):
    """OHD scheduler settings; Pi P3 boot cache or remote floor-games JSON override config."""
    kwargs = {}
    if P3_FLOOR_GAMES_LOADED and P3_SESSION_FLOOR_GAMES is not None:
        kwargs["floor_games_override"] = P3_SESSION_FLOOR_GAMES
    elif OHD_REMOTE_FLOOR_GAMES is not None:
        kwargs["floor_games_override"] = OHD_REMOTE_FLOOR_GAMES
    if P3_SESSION_BLACK_GOLD_21 is not None:
        kwargs["black_gold_21_override"] = P3_SESSION_BLACK_GOLD_21
    elif OHD_REMOTE_BLACK_GOLD_21 is not None:
        kwargs["black_gold_21_override"] = OHD_REMOTE_BLACK_GOLD_21
    return load_ohd_progressive_settings(config, **kwargs)


def _sync_ohd_floor_games_from_pi() -> None:
    """OHD / Mini: resolve progressive_floor_games after splash.

    Mini (`display_mode=MINI`):
 - floor_games_source=local_p3 -' sibling EtherGold/floor-games.json
 - floor_games_source=http -' http://{floor_games_http_host}:31234/...
      Progressives meters stay on local CSV (empty progressives_source).

    Win11 OHD: still derives floor-games URL from progressives_source when remote.
    """
    global OHD_REMOTE_FLOOR_GAMES, OHD_REMOTE_BLACK_GOLD_21, OHD_FLOOR_GAMES_ERROR

    if (not is_ohd_mode()):
        return

    OHD_FLOOR_GAMES_ERROR = None

    if DISPLAY_MODE == DISPLAY_MODE_MINI:
        _sync_mini_floor_games()
        return

    source = resolve_progressives_source()
    if (is_remote_progressives_source(source) == False):
        print("OHD floor games: local progressives - using config.ini floor list", flush=True)
        _refresh_ohd_scheduler_from_config()
        _ensure_ohd_floor_games_or_hold("config.ini progressive_floor_games is empty")
        return

    url = floor_games_url_from_progressives_source(source)
    _apply_remote_floor_games_result(url, *fetch_remote_floor_games(url))


def _mini_floor_games_source_from_config() -> tuple[str, str]:
    """Return (source, http_host) from live config.ini."""
    config = ExtendedConfigParser()
    _read_config_ini(config)
    src = "local_p3"
    if config.has_option("TOPBOX", "FLOOR_GAMES_SOURCE"):
        src = config.get("TOPBOX", "FLOOR_GAMES_SOURCE").strip().lower().replace("-", "_")
    if src in ("local", "p3"):
        src = "local_p3"
    if src not in ("local_p3", "http"):
        src = "local_p3"
    host = ""
    if config.has_option("TOPBOX", "FLOOR_GAMES_HTTP_HOST"):
        host = config.get("TOPBOX", "FLOOR_GAMES_HTTP_HOST").strip()
    return src, host


def _sync_mini_floor_games() -> None:
    """MINI: Local P3 file or HTTP host; never invent 1-12."""
    global OHD_REMOTE_FLOOR_GAMES, OHD_REMOTE_BLACK_GOLD_21

    src, host = _mini_floor_games_source_from_config()
    if src == "http":
        if not host:
            OHD_REMOTE_FLOOR_GAMES = None
            OHD_REMOTE_BLACK_GOLD_21 = None
            _refresh_ohd_scheduler_from_config()
            _ensure_ohd_floor_games_or_hold(
                "floor_games_http_host empty - set IP under Source Active Games from HTTP"
            )
            return
        url = floor_games_url_for_host(host)
        _apply_remote_floor_games_result(url, *fetch_remote_floor_games(url))
        return

    path = local_ethergold_floor_games_path()
    floor_games, bg21, remote_version, remote_source, err = load_local_floor_games(path)
    if err is not None:
        print(f"WARNING - Mini floor games: {err}; trying config.ini", flush=True)
        OHD_REMOTE_FLOOR_GAMES = None
        OHD_REMOTE_BLACK_GOLD_21 = None
        _refresh_ohd_scheduler_from_config()
        _ensure_ohd_floor_games_or_hold(
            f"Local P3 floor-games missing ({path}) - Grab GAMESTATS or attach P3"
        )
        return
    _apply_remote_floor_games_result(
        path, floor_games, bg21, remote_version, remote_source, None
    )


def _apply_remote_floor_games_result(
    url: str,
    floor_games,
    bg21,
    remote_version,
    remote_source,
    err,
) -> None:
    global OHD_REMOTE_FLOOR_GAMES, OHD_REMOTE_BLACK_GOLD_21

    if (err != None):
        print(f"WARNING - OHD floor games: {err}; trying config.ini", flush=True)
        OHD_REMOTE_FLOOR_GAMES = None
        OHD_REMOTE_BLACK_GOLD_21 = None
        _refresh_ohd_scheduler_from_config()
        _ensure_ohd_floor_games_or_hold(f"floor-games fetch failed ({err})")
        return

    if remote_source != SOURCE_P3:
        print(
            f"WARNING - OHD floor games: remote source={remote_source!r} "
            f"(no P3 GAMESTATS behind it)",
            flush=True,
        )

    if not floor_games:
        print(
            "WARNING - OHD floor games: remote list is empty after sanitizing "
            "(Black Gold 21 never qualifies)",
            flush=True,
        )
        OHD_REMOTE_FLOOR_GAMES = None
        OHD_REMOTE_BLACK_GOLD_21 = bg21
        _refresh_ohd_scheduler_from_config()
        _ensure_ohd_floor_games_or_hold(
            f"no progressive floor games published by {url}"
        )
        return

    OHD_REMOTE_FLOOR_GAMES = floor_games
    OHD_REMOTE_BLACK_GOLD_21 = bg21
    floor_list = ",".join(str(g) for g in floor_games)
    print(
        f"OHD floor games from Pi ({url}): [{floor_list}] black_gold_21={bg21} "
        f"source={remote_source}",
        flush=True,
    )
    if (remote_version != None and remote_version != CURRENT_VERSION):
        print(
            f"WARNING - Pi DyzineFrame {remote_version} != OHD {CURRENT_VERSION} "
            f"(deploy matching builds on both nodes)",
            flush=True,
        )
    _refresh_ohd_scheduler_from_config()
    _ensure_ohd_floor_games_or_hold("trusted floor list became empty after refresh")


def _ensure_ohd_floor_games_or_hold(reason_if_empty: str) -> None:
    """If the OHD scheduler has no progressive games, hold splash with an error."""
    global OHD_FLOOR_GAMES_ERROR

    floor = ()
    if OHD_PROGRESSIVE_SCHEDULER is not None:
        floor = tuple(OHD_PROGRESSIVE_SCHEDULER.settings.floor_games)
    if floor:
        OHD_FLOOR_GAMES_ERROR = None
        return
    OHD_FLOOR_GAMES_ERROR = reason_if_empty
    print(f"OHD floor games hold: {reason_if_empty}", flush=True)


def _refresh_ohd_scheduler_from_config():
    global OHD_PROGRESSIVE_SCHEDULER

    if OHD_PROGRESSIVE_SCHEDULER is None:
        return
    config = ExtendedConfigParser()
    _read_config_ini(config)
    OHD_PROGRESSIVE_SCHEDULER.update_settings(_load_ohd_scheduler_settings(config))

#-----------------------------------------------------------------------------------------------------------------------
def load_ads():

    global TOTAL_ADS, ads

    if (TARGET_FOLDER == None or TARGET_FOLDER == ""):
        print("load_ads: no local content folder configured", flush=True)
        TOTAL_ADS = 0
        ads = []
        return

    # Load all of the Ads into RAM

    start_tick = time.perf_counter()

    file_list  = glob.glob(TARGET_FOLDER + "/**/*.*", recursive=True)

    file_list = natsorted(file_list, alg=ns.IGNORECASE)

    LOCAL_ADS = len(file_list)
    local_ads = []
    invalid_ads = 0

    for i in range(0, LOCAL_ADS):

        f = file_list[i]
        ext = pathlib.Path(f).suffix

        if (ext == ".mp4"):

            v = load_video(f)

            if (v == None):
                invalid_ads += 1
            else:
                local_ads.append(Ad(v, "VIDEO"))

        elif (ext == ".jpg" or ext == ".jpeg"):

            v = load_image(f)

            if (v == None):
                invalid_ads += 1
            else:
                local_ads.append(Ad(v, "IMAGE"))

        else:
            print("load_ads() - Found an invalid file extension; ignoring it: " + f)
            invalid_ads += 1

    LOCAL_ADS -= invalid_ads

    if (LOCAL_ADS < 0):
        print("ERROR - load_ads() somehow LOCAL_ADS is less than 0: " + str(LOCAL_ADS))
        LOCAL_ADS = 0

    mutex.acquire() #########################
    TOTAL_ADS = LOCAL_ADS
    ads_index = LOCAL_ADS
    ads = []
    for i in range(0, TOTAL_ADS):
        ads.append(Ad(copy.copy(local_ads[i].media), local_ads[i].media_type))
    mutex.release() #########################

    end_tick = time.perf_counter()

    diff = (end_tick - start_tick) * 1000

    print(f"Ads loaded in {diff:.2f}ms")

    del local_ads

    return

#-----------------------------------------------------------------------------------------------------------------------
def load_video(filename):

    v = None

    try:
        # Force FFmpeg reader on Win11 when HW accel is armed (AUTO may pick OpenCV).
        if IS_WINDOWS and _FFMPEG_HWACCEL:
            v = Video(filename, no_audio=True, reader=READER_FFMPEG)
        else:
            v = Video(filename, no_audio=True)
    except Exception:
        print("ERROR - load_video() video file '" + filename + "' not found")
        return None

    return v

#-----------------------------------------------------------------------------------------------------------------------
def load_image(filename):

    v = None

    try:
        v = pygame.image.load(filename)
    except:
        print("ERROR - load_image() image file '" + filename + "' not found")
        return None

    return v

#-----------------------------------------------------------------------------------------------------------------------
def resolve_shared_display_asset(kind: str) -> pathlib.Path:
    shared = pathlib.Path("Assets") / "Shared" / ASSET_RESOLUTION
    names = {
        "splash": ("splash1.png", "splash.jpg", "splash.png"),
        "wallpaper": ("pot_dyzine_logo.png", "POG_Render_02.png", "wallpaper.png"),
        "fireworks": ("fireworks.mp4",),
    }
    if kind not in names:
        raise ValueError(f"Unknown shared display asset kind: {kind}")
    # TEMP half-res A/B - full fireworks.mp4 left untouched for HW-decode retry.
    if (
        kind == "fireworks"
        and TEMP_FIREWORKS_HALFRES_TEST
        and is_ohd_mode()
    ):
        half = shared / "fireworks_half_lanczos.mp4"
        if half.is_file():
            return half
        print(
            f"WARNING - TEMP_FIREWORKS_HALFRES_TEST set but missing {half}",
            flush=True,
        )
    for name in names[kind]:
        candidate = shared / name
        if candidate.is_file():
            return candidate
    # Return the canonical generic name so the loader reports the expected location.
    extension = ".mp4" if kind == "fireworks" else ".png"
    return shared / f"{kind}{extension}"

#-----------------------------------------------------------------------------------------------------------------------
def exit_video():

    for i in range(0, TOTAL_VIDEOS):
        if (vids[i] != None):
            vids[i].close()

    if (fireworks != None):
        fireworks.close()

    if (attendant != None):
        attendant.close()

    if (dooropen != None):
        dooropen.close()

    if (helpreq != None):
        helpreq.close()

    pygame.quit()
    # 75 = operator quit (keyboard Q). Supervisor cools off before fullscreen respawn.
    sys.exit(75)

#-----------------------------------------------------------------------------------------------------------------------
def get_IP():

    global local_ip, local_ip_last

    ct = time.perf_counter()

    if ((ct - local_ip_last) >= CHECK_IP_DELAY or local_ip_last == 0):

        local_ip_last = ct

        # Dual-radio TopBox: splash must show admin wlan0 (.148), not POGCOM wlan1 (.43).
        # The old UDP-to-10.254.254.254 trick followed whichever iface owned the route.
        if sys.platform.startswith("linux"):
            ip = _admin_ipv4()
            local_ip = ip if ip else "Disconnected"
            return

        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.settimeout(0)

        try:
            # doesn't even have to be reachable
            s.connect(('10.254.254.254', 1))
            IP = s.getsockname()[0]
        except Exception:
            IP = 'Disconnected'
        finally: # finally always executes regardless of the outcome above (it ignores break's and return's)
            s.close()

        local_ip = IP

    return

#-----------------------------------------------------------------------------------------------------------------------
def is_there_internet():

    # We will use Google's website to check if we have a real internet connection

    is_connected = False

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(2.500) # 2500ms

    try:
        s.connect((socket.gethostbyname("google.com"), 443))
        is_connected = True
    except:
        is_connected = False
    finally: # finally always executes regardless of the outcome above (it ignores break's and return's)
        s.close()

    return is_connected

#-----------------------------------------------------------------------------------------------------------------------
def _cfg_lock():
    global mutex
    if mutex is None:
        mutex = threading.Lock()
    return mutex


def _write_config_ini(config) -> bool:
    """Persist config.ini. Must not raise - USB CDC thread dies on PermissionError."""
    return persist_config_ini(config, CONFIG_FILE)


def _cabinet_has_pogcc_usb() -> bool:
    return (
        (not is_ohd_mode())
        and (not TEST_MODE_NO_SERIAL)
        and POGCC_COM_OWNER != "ethergold"
        and (not IS_WINDOWS)
    )


def _persist_configset_true() -> None:
    global CONFIGSET
    config = ExtendedConfigParser()
    _read_config_ini(config)
    config.set("TOPBOX", "CONFIGSET", str(True))
    if not _write_config_ini(config):
        print("CONFIGSET: could not persist True to config.ini", flush=True)
        return
    CONFIGSET = True
    print("CONFIGSET: persisted True (Wi-Fi first-run; Identity /config owns asset/theme/volume)", flush=True)


def _valid_content_host(host: str) -> bool:
    host = (host or "").strip()
    if not host or len(host) > 253:
        return False
    try:
        ipaddress.ip_address(host)
        return True
    except Exception:
        pass
    if host == "localhost":
        return True
    return all(c.isalnum() or c in ".-" for c in host)


def _delete_nm_hotspots() -> None:
    if not IS_LINUX:
        return
    os.system("sudo nmcli connection delete Hotspot >/dev/null 2>&1")
    for suffix in ("-1", "-2", "-3", "-4"):
        os.system("sudo nmcli connection delete Hotspot%s >/dev/null 2>&1" % suffix)


def _nmcli_join_sta(ssid: str, password: str) -> bool:
    if not IS_LINUX:
        return True
    _delete_nm_hotspots()
    os.system("sudo nmcli device disconnect wlan0 >/dev/null 2>&1")
    os.system("sudo nmcli device up wlan0 >/dev/null 2>&1")
    os.system("sudo nmcli device wifi rescan >/dev/null 2>&1")
    time.sleep(2)
    cmd = "sudo nmcli -w 30 device wifi connect %s password %s" % (
        shlex.quote(ssid),
        shlex.quote(password),
    )
    print("Executing: " + cmd, flush=True)
    return os.system(cmd) == 0


def _start_first_run_usb() -> None:
    global thread4
    if not _cabinet_has_pogcc_usb():
        return
    if thread4 is not None and thread4.is_alive():
        return
    thread4 = threading.Thread(target=thread_usb_serial, daemon=True)
    thread4.start()
    print("CONFIGSET: USB CDC up - will push Identity /asset /theme /volume to Heltec", flush=True)


def _first_run_reboot() -> None:
    """First-run used to `sudo reboot` here. EtherGold cannot recover from that."""
    print("CONFIGSET: skip machine reboot (EtherGold needs a power cycle, not sudo reboot)", flush=True)

#-----------------------------------------------------------------------------------------------------------------------
def display_splash():

    global DISPLAY_SPLASH, sds_event

    splash_x = 0
    splash_y = 0
    if (splash != None):
        splash_w, splash_h = splash.get_size()
        if (splash_w < SCREEN_WIDTH or splash_h < SCREEN_HEIGHT):
            splash_x = max(0, (SCREEN_WIDTH - splash_w) // 2)
            splash_y = max(0, (SCREEN_HEIGHT - splash_h) // 2)

    start_tick = time.perf_counter()
    prog_last_tick = start_tick

    while ((time.perf_counter() - start_tick) < SPLASH_SCREEN_TIME):

        # Process all game events

        for event in pygame.event.get():
            if (event.type == pygame.KEYDOWN):
                _handle_keyboard_dev_shortcuts(pygame.key.name(event.key))

        # Get the Local IP Address
        get_IP()

        win.fill((0, 0, 0))
        win.blit(splash, (splash_x, splash_y))
        text_surface = font.render(" " + local_ip + " ", True, (255, 255, 255), (0, 0, 0))
        text_surfac2 = font.render(" " + CURRENT_VERSION + " ", True, (255, 255, 255), (0, 0, 0))

        win.blit(text_surface, dest = (splash_x, splash_y + max(0, SCREEN_HEIGHT - 16)))
        win.blit(text_surfac2, dest = (splash_x, splash_y))

        _draw_pogcc_link_warning(win)

        _df_present()

        if IS_WINDOWS:
            maybe_refresh_win32_kiosk_window()

        now_tick = time.perf_counter()
        _progressive_background_tick(now_tick - prog_last_tick)
        prog_last_tick = now_tick
        time.sleep(0.010)  # This puts the script to sleep for 10ms just to save CPU cycles

    DISPLAY_SPLASH = False
    # Boot / splash current-game apply queues sds_event=3 (fade menu). Nothing
    # consumed it during splash - dropping it here so the first play_video is
    # the game logo, not an immediate fade-to-black.
    mutex.acquire() #########################
    sds_event = None
    mutex.release() #########################

    return

#-----------------------------------------------------------------------------------------------------------------------
def sds_handler(code, *, source="sds"):

    global sds_event, curr_index, DISPLAY_ALTERNATE_VIDEO, ABORT_ADS, LAST_DISPLAY_STATE, DISPLAY_FIREWORKS
    global _tbx_staged_fade_game_id

    if (CONFIGSET == False):
        return

    if (not isinstance(code, int)):
        return
    if (code < 0 or code >= TOTAL_VIDEOS or vids[code] == None):
        return

    # Heltec SDS Enter Game is the writer. Copper gameplay / 0x0A are not.
    # Watcher source=current-game must not echo-write (last writer wins).
    if (source == "sds" and 1 <= code <= 16):
        try:
            write_current_game(
                game_index=code,
                game_id_hex=f"SDS{code:02X}",
                source="sds_enter_game",
            )
        except Exception as exc:
            print(f"WARN: current-game.json SDS write failed: {exc}", flush=True)

    mutex.acquire() #########################

    if (DISPLAY_FIREWORKS == True):
        mutex.release() #########################
        return

    # Heltec SDS 0xA2 enter-game must play the logo even when DF already
    # shows that game's meter (current-game.json / missed /sds/0).
    # Heartbeat resends /sds only when Heltec and DF disagree, so this
    # does not loop. current-game IPC still only acts on index change.
    if (code == curr_index):
        if (
            source == "sds"
            and code > 0
            and LAST_DISPLAY_STATE == 1
        ):
            sds_event = 0
            LAST_DISPLAY_STATE = 0
            _tbx_staged_fade_game_id = None
            ABORT_ADS = True
            print(
                f"SDS enter-game: replay logo for game {code} "
                f"(already on meter, source={source})",
                flush=True,
            )
        mutex.release() #########################
        return

    prev_index = curr_index

    # If a Game Menu is currently displaying we need to Fade To Black and quickly display the Game Video
    if (curr_index == 0):
        sds_event = 3 # Fade to black
    else:
        sds_event = 0

    curr_index = code
    LAST_DISPLAY_STATE = 0
    _tbx_staged_fade_game_id = None

    print(f"SDS game switch: {prev_index} -> {code} source={source}", flush=True)

    # if code is 0 we need to toggle DISPLAY_ALTERNATE_VIDEO to use alternate Game Menu video
    if (code == 0):
        DISPLAY_ALTERNATE_VIDEO ^= 1

    # We need to stop displaying all Ads so the Game Logo video can play ASAP
    ABORT_ADS = True

    mutex.release() #########################

    try:
        write_df_displayed_game(code)
    except Exception as exc:
        print(f"WARN: df-displayed-game.json write failed: {exc}", flush=True)

    return

#-----------------------------------------------------------------------------------------------------------------------
def ip_port_handler(ip_addr, port):

    global DOWNLOAD_URL_IP, DOWNLOAD_URL_PORT

    if (isinstance(ip_addr, str) and _valid_content_host(ip_addr)):

        if (isinstance(port, int)):
            if (port >= 80 and port <= 65535):

                with _cfg_lock():
                    DOWNLOAD_URL_IP = ip_addr
                    DOWNLOAD_URL_PORT = port
                    config = ExtendedConfigParser()
                    _read_config_ini(config)
                    config.set("TOPBOX", "DOWNLOAD_URL_IP", DOWNLOAD_URL_IP)
                    config.set("TOPBOX", "DOWNLOAD_URL_PORT", str(DOWNLOAD_URL_PORT))
                    if not _write_config_ini(config):
                        print("WARN: download host not persisted to config.ini", flush=True)
                refresh_content_sync_targets()
                print("SUCCESS - The master download host:PORT has been set to: " + ip_addr + ":" + str(port))

    return

#-----------------------------------------------------------------------------------------------------------------------
def asset_handler(serial):

    global ASSET_NUMBER, POG_ASSET_NUMBER

    if (isinstance(serial, int)):
        if (serial >= 0 and serial <= 10000000):

            enroll_loc = ""
            enroll_path = ""
            with _cfg_lock():
                ASSET_NUMBER = serial
                POG_ASSET_NUMBER = serial
                config = ExtendedConfigParser()
                _read_config_ini(config)
                config.set("TOPBOX", "ASSET_NUMBER", str(ASSET_NUMBER))
                config.set("TOPBOX", "POG_ASSET_NUMBER", str(POG_ASSET_NUMBER))
                if config.has_option("TOPBOX", "ENROLLMENT_LOCATION"):
                    enroll_loc = config.get("TOPBOX", "ENROLLMENT_LOCATION")
                if config.has_option("TOPBOX", "ENROLLMENT_PATH"):
                    enroll_path = config.get("TOPBOX", "ENROLLMENT_PATH")
                if not _write_config_ini(config):
                    print("WARN: asset number not persisted to config.ini", flush=True)
            refresh_content_sync_targets()
            try:
                local = upsert_enrollment_from_identity(serial, enroll_loc, enroll_path)
                if local:
                    print(
                        f"enrollment: POG-CC /asset {serial} upserted {enroll_path} ({local})",
                        flush=True,
                    )
            except Exception as exc:
                print(f"WARN: enrollment overlay after /asset: {exc}", flush=True)

            print("SUCCESS - Asset number has been set to: " + str(serial))

    return

#-----------------------------------------------------------------------------------------------------------------------
def flag_handler(flag, state, pogcc_utc_ms=None, pogcc_local_ms=None):

    global sds_event, DRAW_FRAME_COUNT, DRAW_IP_ADDRESS, QUITTING, DISPLAY_SPLASH
    global DISPLAY_FIREWORKS, DISPLAY_FIREWORKS_LOOP, DISPLAY_FIREWORKS_DURATION_SEC
    global FIREWORKS_LOCAL_WINNER, FIREWORKS_ERROR_DEFER_UNTIL, FIREWORKS_WINNER_AMOUNT_TEXT
    global DISPLAY_ATTENDANT, DISPLAY_DOOROPEN, DISPLAY_HELPREQ, ABORT_ADS

    if (CONFIGSET == False):
        return

    if (isinstance(flag, str) and isinstance(state, int)):

        if (state == 0 or state == 1):

            if (flag == "frame_count"):

                mutex.acquire() #########################
                sds_event = 1
                DRAW_FRAME_COUNT = True if state == 1 else False
                config = ExtendedConfigParser()
                _read_config_ini(config)
                config.set("TOPBOX", "DRAW_FRAME_COUNT", str(DRAW_FRAME_COUNT))
                if not _write_config_ini(config):
                    print("WARN: DRAW_FRAME_COUNT not persisted to config.ini", flush=True)
                mutex.release() #########################

                return

            if (flag == "ip_addr"):

                mutex.acquire() #########################
                sds_event = 1
                DRAW_IP_ADDRESS = True if state == 1 else False
                config = ExtendedConfigParser()
                _read_config_ini(config)
                config.set("TOPBOX", "DRAW_IP_ADDRESS", str(DRAW_IP_ADDRESS))
                if not _write_config_ini(config):
                    print("WARN: DRAW_IP_ADDRESS not persisted to config.ini", flush=True)
                mutex.release() #########################

                return

            if (flag == "quit" and state == 1):

                mutex.acquire() #########################
                sds_event = 0
                QUITTING = True
                mutex.release() #########################

                return

            if (flag == "display_splash" and state == 1):

                mutex.acquire() #########################
                sds_event = 0
                DISPLAY_SPLASH = True
                ABORT_ADS = True
                mutex.release() #########################

                return

            if (flag == "error"):

                STATE_TRUE_FALSE = True if state == 1 else False
                SUCCESS = False

                mutex.acquire() #########################
                if (DISPLAY_ATTENDANT != STATE_TRUE_FALSE):
                    sds_event = 2
                    DISPLAY_ATTENDANT = STATE_TRUE_FALSE
                    SUCCESS = True
                    ABORT_ADS = True
                mutex.release() #########################

                if (SUCCESS == True):
                    pi_recv_ms = int(time.time() * 1000)
                    edge = "pending" if STATE_TRUE_FALSE else "cleared"
                    print(
                        f"POG-CC SDS error {edge}: "
                        f"pogcc_utc_ms={pogcc_utc_ms} "
                        f"pogcc_local_ms={pogcc_local_ms} "
                        f"pi_recv_utc_ms={pi_recv_ms} "
                        f"sync={'yes' if pogcc_utc_ms else 'no'}",
                        flush=True,
                    )
                    return

            if (flag == "dooropen"):

                STATE_TRUE_FALSE = True if state == 1 else False
                SUCCESS = False

                mutex.acquire() #########################
                if (DISPLAY_DOOROPEN != STATE_TRUE_FALSE):
                    sds_event = 2
                    DISPLAY_DOOROPEN = STATE_TRUE_FALSE
                    SUCCESS = True
                    ABORT_ADS = True
                mutex.release() #########################

                if (SUCCESS == True):
                    pi_recv_ms = int(time.time() * 1000)
                    edge = "open" if STATE_TRUE_FALSE else "close"
                    print(
                        f"POG-CC door {edge}: "
                        f"pogcc_utc_ms={pogcc_utc_ms} "
                        f"pogcc_local_ms={pogcc_local_ms} "
                        f"pi_recv_utc_ms={pi_recv_ms} "
                        f"sync={'yes' if pogcc_utc_ms else 'no'}",
                        flush=True,
                    )
                    return

            if (flag == "helpreq"):

                SUCCESS = False

                mutex.acquire() #########################
                was_held = _HELPREQ.held
                was_showing = _HELPREQ.showing
                started = _HELPREQ.on_flag(state)
                DISPLAY_HELPREQ = _HELPREQ.showing
                if (started or (state == 0 and was_showing)):
                    sds_event = 2
                    SUCCESS = True
                    ABORT_ADS = True
                mutex.release() #########################

                if (started):
                    print(
                        "POG-CC helpreq: Service Requested HDMI 30s "
                        "(early cancel dismisses)",
                        flush=True,
                    )
                    return
                if (state == 0 and was_showing):
                    print(
                        "POG-CC helpreq: cancel dismissed HDMI before 30s",
                        flush=True,
                    )
                    return
                if (state == 0 and was_held):
                    print(
                        "POG-CC helpreq: late cancel ignored (already timed out)",
                        flush=True,
                    )
                    return
                return

        if (flag == "fireworks" and state > 0):

            if (CONFIGSET == False):
                return

            ipc_payload = read_fireworks_request()
            if (ipc_payload != None and fireworks_request_is_fresh(ipc_payload)):
                _apply_fireworks_payload(ipc_payload)
                return

            if (state > MAX_FIREWORK_LOOPS):
                state = MAX_FIREWORK_LOOPS

            mutex.acquire() #########################
            sds_event = 2
            DISPLAY_FIREWORKS = True
            DISPLAY_FIREWORKS_LOOP = state
            DISPLAY_FIREWORKS_DURATION_SEC = 0.0
            FIREWORKS_LOCAL_WINNER = False
            FIREWORKS_ERROR_DEFER_UNTIL = 0.0
            FIREWORKS_WINNER_AMOUNT_TEXT = ""
            ABORT_ADS = True
            mutex.release() #########################
            print(
                f"fireworks HTTP legacy: loops={state} (no fresh IPC json - deploy bridge IPC)",
                flush=True,
            )

            return

    return

#-----------------------------------------------------------------------------------------------------------------------
@get('/EtherGold/progressives.csv')
def serve_progressives_csv():
    """Serve bridge-written progressives.csv for OHD and other remote readers."""
    path = default_local_progressives_path()
    directory = os.path.dirname(path) or "."
    filename = os.path.basename(path)
    return static_file(filename, root=directory, mimetype="text/csv")


@get('/EtherGold/progressives-sync.json')
def serve_progressives_sync_json():
    """Broadcast cadence hint so OHD polls CSV in phase with the Pi Topbox."""
    payload = build_progressives_sync_payload(
        csv_path=default_local_progressives_path(),
        broadcast_interval_s=PROGRESSIVE_REMOTE_POLL_SEC,
        version=CURRENT_VERSION,
    )
    response.content_type = "application/json"
    response.headers["Cache-Control"] = "no-cache"
    return json.dumps(payload)


@get('/EtherGold/fireworks-request.json')
def serve_fireworks_request_json():
    """Expose the same fresh event file to Win11 OHD nodes."""
    payload = read_fireworks_request(resolve_fireworks_request_path()) or {}
    response.content_type = "application/json"
    response.headers["Cache-Control"] = "no-store"
    return json.dumps(payload)


@get('/EtherGold/floor-games.json')
def serve_floor_games_json():
    """Floor progressive game list for OHD Win11 nodes (P3 cache or config fallback)."""
    config = ExtendedConfigParser()
    _read_config_ini(config)
    payload = build_floor_games_payload(
        version=CURRENT_VERSION,
        config=config,
        p3_floor_games=P3_SESSION_FLOOR_GAMES,
        p3_loaded=P3_FLOOR_GAMES_LOADED,
        p3_black_gold_21=P3_SESSION_BLACK_GOLD_21,
    )
    response.content_type = "application/json"
    return json.dumps(payload)


@get('/flag/<flag>/<state:int>')
def http_flag_handler(flag, state):
    """POG-CC and bridge Phase 1 early-win - same handler as USB CDC /flag/..."""
    flag_handler(flag, state)
    return '1'


@get('/pogcc/hb-state')
def http_pogcc_hb_state():
    """EtherGold pogcc-com heartbeat reply: DF display/game mirror (JSON)."""
    response.content_type = "application/json"
    return json.dumps(
        {
            "error": bool(DISPLAY_ATTENDANT),
            "door": bool(DISPLAY_DOOROPEN),
            "help": bool(_HELPREQ.held),
            "game_menu": int(curr_index) if isinstance(curr_index, int) else 0,
            "credit_pennies": int(CREDIT_BALANCE) if isinstance(CREDIT_BALANCE, int) else 0,
            "pogcc_com_owner": POGCC_COM_OWNER,
        }
    )


@get('/EtherGold/mode4-weights.txt')
def serve_mode4_weights_dump():
    """On-demand mode 4 activity snapshot (append to log/mode4_weights.txt and return line)."""
    line = _write_mode4_weights_dump()
    if line is None:
        response.status = 404
        response.content_type = "text/plain"
        return (
            "mode4 weights unavailable (need display_mode=OHD, prog_display_mode 3 or 4, "
            "and progressive scheduler active)\n"
        )
    response.content_type = "text/plain"
    return line + "\n"

#-----------------------------------------------------------------------------------------------------------------------
# First-run QR pages (/config, /config2) - same chrome as EtherGold Status.
_BOOT_UI_CSS = """
:root {
  --bg: #0f1419; --panel: #1a222c; --text: #e8eef4; --muted: #8b9aab;
  --accent: #c4a35a; --ok: #3d9a6a; --bad: #c45c5c; --border: #2c3948;
  --font: "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
  margin: 0; font-family: var(--font); background: var(--bg); color: var(--text);
  min-height: 100vh;
}
header {
  padding: 1rem 1.5rem; border-bottom: 1px solid var(--border);
  background: linear-gradient(180deg, #1c2632 0%, var(--bg) 100%);
}
header h1 { margin: 0; font-size: 1.3rem; letter-spacing: 0.04em; color: var(--accent); }
header p { margin: 0.3rem 0 0; color: var(--muted); font-size: 0.88rem; }
main { max-width: 520px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
.card {
  background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
  padding: 0.9rem 1rem;
}
.card h2 {
  margin: 0 0 0.75rem; font-size: 0.95rem; color: var(--accent);
  letter-spacing: 0.03em; font-weight: 600;
}
label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 0.25rem; }
input {
  width: 100%; padding: 0.55rem 0.65rem; margin-bottom: 0.75rem;
  background: #121820; border: 1px solid var(--border); border-radius: 4px;
  color: var(--text); font: inherit;
}
button, input[type="submit"] {
  background: var(--accent); color: #1a1408; border: none; border-radius: 4px;
  padding: 0.55rem 1rem; font-weight: 600; cursor: pointer; font: inherit;
}
.hint { font-size: 0.78rem; color: var(--muted); margin: 0 0 0.75rem; }
.err { color: var(--bad); font-size: 0.85rem; margin: 0; }
.ok { color: var(--ok); font-size: 0.95rem; margin: 0 0 0.75rem; }
"""


def _boot_ui_page(title, subtitle, body_html):
    response.content_type = "text/html; charset=utf-8"
    return (
        "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"/>"
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>"
        "<title>" + title + "</title><style>" + _BOOT_UI_CSS + "</style></head><body>"
        "<header><h1>" + title + "</h1><p>" + subtitle + "</p></header>"
        "<main><div class=\"card\">" + body_html + "</div></main>"
        "</body></html>"
    )


@get('/config') # optional manual identity (cabinet normally uses POG-CC USB)
def handler5():

    return _boot_ui_page(
        "DyzineFrame Setup",
        "Optional - content URL",
        "<h2>Content (optional)</h2>"
        "<p class=\"hint\">Cabinet identity is Pi Identity /config (EtherGold). "
        "Heltec Setup is Wi-Fi only. OHD has no content today. "
        "Future OHD spots (Responsible Gaming, hurricane) must be 3840Ã - 1080. "
        "Host may be a name or IP. Leave blank to skip.</p>"
        "<form action=\"/config\" method=\"POST\">"
        "<label for=\"url_ip\">Content host</label>"
        "<input id=\"url_ip\" name=\"url_ip\" type=\"text\" placeholder=\"pog-atlas.com or 104.6.5.168\"/>"
        "<label for=\"url_port\">Content port</label>"
        "<input id=\"url_port\" name=\"url_port\" type=\"number\" min=\"80\" max=\"65535\" placeholder=\"51709\"/>"
        "<label for=\"asset_number\">Asset Number (0 = unset)</label>"
        "<input id=\"asset_number\" name=\"asset_number\" type=\"number\" value=\"0\"/>"
        "<input value=\"Save\" type=\"submit\"/>"
        "</form>",
    )


@post('/config') # optional
def handler6():

    url_ip = str(request.forms.get("url_ip") or "").strip()
    url_port_raw = str(request.forms.get("url_port") or "").strip()
    asset_raw = str(request.forms.get("asset_number") or "0").strip() or "0"
    asset_number = int(asset_raw)

    url_port = 0
    if url_port_raw:
        url_port = int(url_port_raw)
        if (url_port < 80 or url_port > 65535):
            return _boot_ui_page(
                "DyzineFrame Setup",
                "Optional - content URL",
                "<p class=\"err\">Invalid URL port (80-65535).</p>"
                "<p class=\"hint\"><a href=\"/config\" style=\"color:var(--accent)\">Back</a></p>",
            )

    if url_ip and not _valid_content_host(url_ip):
        return _boot_ui_page(
            "DyzineFrame Setup",
            "Optional - content URL",
            "<p class=\"err\">Invalid content host (IP or hostname).</p>"
            "<p class=\"hint\"><a href=\"/config\" style=\"color:var(--accent)\">Back</a></p>",
        )

    if (asset_number != 0 and (asset_number < 1000 or asset_number > 10000000)):
        return _boot_ui_page(
            "DyzineFrame Setup",
            "Optional - content URL",
            "<p class=\"err\">Invalid asset number (0 = unset, else 1000-10000000).</p>"
            "<p class=\"hint\"><a href=\"/config\" style=\"color:var(--accent)\">Back</a></p>",
        )

    with _cfg_lock():
        config = ExtendedConfigParser()
        _read_config_ini(config)
        if url_ip:
            config.set("TOPBOX", "DOWNLOAD_URL_IP", url_ip)
        if url_port:
            config.set("TOPBOX", "DOWNLOAD_URL_PORT", str(url_port))
        config.set("TOPBOX", "ASSET_NUMBER", str(asset_number))
        config.set("TOPBOX", "POG_ASSET_NUMBER", str(asset_number))
        config.set("TOPBOX", "CONFIGSET", str(True))
        if not _write_config_ini(config):
            print("WARN: setup form not persisted to config.ini", flush=True)

    global CONFIGSET
    CONFIGSET = True
    refresh_content_sync_targets()

    return _boot_ui_page(
        "DyzineFrame Setup",
        "Configuration saved",
        "<p class=\"ok\">Saved. DyzineFrame will continue - do not reboot the Pi.</p>"
    )


@post('/reboot') # first-run
def handler7():

    global CONFIGSET

    CONFIGSET = True

    return _boot_ui_page(
        "DyzineFrame Setup",
        "Saved",
        "<p class=\"ok\">First-run saved. DyzineFrame will continue - do not reboot the Pi.</p>",
    )

#-----------------------------------------------------------------------------------------------------------------------
def heartbeat_handler(balance, errstate, doorstate, helpreq, gamemenu):

    # Respond to the Heartbeat request with our current states

    ERROR = False

    if (isinstance(balance, int) == False or balance < 0 or balance >= 100000000):
        print("DEBUG: heartbeat_handler() balance is corrupt: " + str(balance))
        ERROR = True

    if (isinstance(errstate, int) == False or (errstate != 0 and errstate != 1)):
        print("DEBUG: heartbeat_handler() errstate is corrupt: " + str(errstate))
        ERROR = True

    if (isinstance(doorstate, int) == False or (doorstate != 0 and doorstate != 1)):
        print("DEBUG: heartbeat_handler() doorstate is corrupt: " + str(doorstate))
        ERROR = True

    if (isinstance(helpreq, int) == False or (helpreq != 0 and helpreq != 1)):
        print("DEBUG: heartbeat_handler() helpreq is corrupt: " + str(helpreq))
        ERROR = True

    if (isinstance(gamemenu, int) == False or (gamemenu < 0 or gamemenu > 16)):
        print("DEBUG: heartbeat_handler() gamemenu is corrupt: " + str(gamemenu))
        ERROR = True

    if (ERROR == True):
        return "Error"

    ERR_S = "1" if DISPLAY_ATTENDANT == True else "0"
    DOOR_S = "1" if DISPLAY_DOOROPEN == True else "0"
    HLPRQ_S = "1" if _HELPREQ.held else "0"

    # 6th field: Pi UTC ms for POG-CC timesync (Heltec maps local millis -' UTC).
    # Prefix HB, so Heltec never treats a /credits line as a heartbeat (CDC race
    # left bare pennies in the RX buffer and skewed MappedUtc by ~8s).
    utc_ms = int(time.time() * 1000)
    return (
        "HB,"
        + str(CREDIT_BALANCE)
        + ","
        + ERR_S
        + ","
        + DOOR_S
        + ","
        + HLPRQ_S
        + ","
        + str(curr_index)
        + ","
        + str(utc_ms)
    )

#-----------------------------------------------------------------------------------------------------------------------
@get('/config2') # AP MODE - WiFi join only (OHD and cabinet)
def handler10() -> str:

    hint = (
        "Cabinet TopBox: Wi-Fi only here. Set asset and content URL on the POG-CC page; USB sends them to this Pi."
        if _cabinet_has_pogcc_usb()
        else "OHD: Wi-Fi only. There is no content URL or asset on this box today."
    )

    return _boot_ui_page(
        "DyzineFrame Setup",
        "First-run - join site Wi-Fi",
        "<h2>Wi-Fi</h2>"
        "<p class=\"hint\">" + hint + "</p>"
        "<form action=\"/config2\" method=\"POST\">"
        "<label for=\"ssid\">WiFi SSID</label>"
        "<input id=\"ssid\" name=\"ssid\" type=\"text\" required/>"
        "<label for=\"password\">WiFi Password</label>"
        "<input id=\"password\" name=\"password\" type=\"text\" required/>"
        "<input value=\"Save\" type=\"submit\"/>"
        "</form>",
    )


@post('/config2') # AP MODE
def handler11() -> str:

    global wifi_ssid, wifi_pwd, CONFIGSET

    wifi_ssid = str(request.forms.ssid)
    wifi_pwd = str(request.forms.password)

    with _cfg_lock():
        _persist_configset_true()

    CONFIGSET = True

    return _boot_ui_page(
        "DyzineFrame Setup",
        "Wi-Fi saved",
        "<p class=\"ok\">Saved. Closing AP mode and joining the site network. Do not reboot the Pi.</p>",
    )

#-----------------------------------------------------------------------------------------------------------------------
def _p3_resolve_serial_port():

    # Prefer PermDevMapper PTY; fall back to raw FTDI for pre-mapper benches.

    if (os.path.exists(P3_POGCC_PORT)):
        return P3_POGCC_PORT
    return P3_FALLBACK_PORT


def _p3_read_mapper_status():

    # Returns True/False from mapper JSON, or None if status file absent/unreadable.

    try:
        with open(P3_STATUS_PATH, encoding = "utf-8") as handle:
            payload = json.load(handle)
        if (isinstance(payload, dict) and "ok" in payload):
            return bool(payload.get("ok"))
    except (OSError, ValueError, json.JSONDecodeError):
        pass
    return None


def _p3_open_serial():

    global P3_SERIAL_PORT

    P3_SERIAL_PORT = _p3_resolve_serial_port()
    try:
        return serial.Serial(port=P3_SERIAL_PORT, baudrate=115200, bytesize=8, timeout=5, stopbits=serial.STOPBITS_ONE)
    except Exception as exc:
        print(f"P3Cable failed to open {P3_SERIAL_PORT}: {exc}", flush=True)
        return None


def _p3_link_lost(reason):

    global P3Cable, P3_LINK_OK, P3_LINK_RETRY_AT

    if (P3Cable != None):
        try:
            P3Cable.close()
        except Exception:
            pass

    P3Cable = None
    P3_LINK_RETRY_AT = time.perf_counter() + P3_LINK_RETRY_SEC

    if (P3_LINK_OK == True):
        P3_LINK_OK = False
        print(f"WARNING - POG-CC link lost ({reason}); retry every {P3_LINK_RETRY_SEC:.0f}s", flush=True)


def _p3_link_restored():

    global P3_LINK_OK

    if (P3_LINK_OK == False):
        P3_LINK_OK = True
        print("POG-CC link restored", flush=True)


def _p3_apply_mapper_status(status_ok):

    # Banner source of truth when PermDevMapper status file is present.

    global P3_LINK_OK, P3_STATUS_LAST_OK

    if (status_ok == None):
        return False

    if (P3_STATUS_LAST_OK == status_ok and P3_LINK_OK == status_ok):
        return True

    P3_STATUS_LAST_OK = status_ok
    if (status_ok == True):
        _p3_link_restored()
    elif (P3_LINK_OK == True):
        P3_LINK_OK = False
        print("WARNING - POG-CC link lost (mapper status); waiting for /dev/ser_DT1", flush=True)
    return True


def _p3_link_retry_worker():

    # Poll PermDevMapper status for the banner; reopen serial only when needed.
    # GAMESTATS blocks the T340+ ~600 ms and must stay on the normal schedule.

    global P3Cable, P3_LINK_RETRY_AT

    while (True):

        time.sleep(1.0)

        if (P3_GRAB_BUSY == True):
            continue

        status_ok = _p3_read_mapper_status()
        if (_p3_apply_mapper_status(status_ok)):
            # Mapper owns phys link state. Keep / reopen PTY handle when up.
            if (status_ok == True and P3Cable == None):
                with P3_SERIAL_LOCK:
                    if (P3Cable == None):
                        P3Cable = _p3_open_serial()
            continue

        # No status file (pre-mapper bench): fall back to open-retry.
        if (P3_LINK_OK == True):
            continue

        if (time.perf_counter() < P3_LINK_RETRY_AT):
            continue

        with P3_SERIAL_LOCK:
            if (P3Cable == None):
                P3Cable = _p3_open_serial()
            if (P3Cable == None):
                P3_LINK_RETRY_AT = time.perf_counter() + P3_LINK_RETRY_SEC
                continue

        _p3_link_restored()


def _p3_link_warning_font():

    global P3_LINK_WARNING_FONT

    if (P3_LINK_WARNING_FONT == None):
        px = max(20, min(40, SCREEN_HEIGHT // 30))
        P3_LINK_WARNING_FONT = pygame.font.Font(pygame.font.get_default_font(), px)

    return P3_LINK_WARNING_FONT


def _draw_pogcc_link_warning(surface) -> None:

    if (TEST_MODE_NO_SERIAL == True or P3_LINK_OK == True or surface == None):
        return

    try:
        banner = _p3_link_warning_font().render(
            " Warning: Connection to POG-CC Lost ", True, (255, 230, 120), (0, 0, 0)
        )
    except Exception:
        return

    surface.blit(banner, dest = (12, 12))


#-----------------------------------------------------------------------------------------------------------------------
def P3_fetch_gamestats_text():

    global P3Cable, P3_SERIAL_LOCK

    with P3_SERIAL_LOCK:
        if (P3Cable == None):
            P3Cable = _p3_open_serial()
            if (P3Cable == None):
                _p3_link_lost("open failed")
                return None

        try:
            if (P3Cable.is_open == False):
                P3Cable.open()
        except Exception as exc:
            _p3_link_lost(f"reopen failed: {exc}")
            return None

        if (P3Cable.is_open == False):
            _p3_link_lost("connected but not open")
            return None

        try:
            P3Cable.write(GAMESTATS_CMD)
        except Exception as exc:
            _p3_link_lost(f"write failed: {exc}")
            return None

        try:
            raw_data = P3Cable.read_until(expected=READ_UNTIL)
        except Exception as exc:
            _p3_link_lost(f"read failed: {exc}")
            return None

    if not raw_data:
        _p3_link_lost("no response to GAMESTATS")
        return None
    if not raw_data.endswith(READ_UNTIL):
        print(
            f"ERROR - incomplete P3 GAMESTATS response "
            f"({len(raw_data)} bytes; END terminator missing)",
            flush=True,
        )
        return None

    _p3_link_restored()

    return normalize_gamestats_bytes(raw_data)


def P3_apply_parsed_gamestats(parsed, *, apply_floor_games: bool):

    global CREDIT_BALANCE, P3_FLOOR_GAMES_LOADED, P3_SESSION_FLOOR_GAMES, P3_SESSION_BLACK_GOLD_21
    global MACHINE_ROLE, BANK_NUMBER, TERM_NUMBER

    TEMP = CREDIT_BALANCE
    credit = credit_from_parse(parsed)
    if credit is not None:
        CREDIT_BALANCE = credit
    else:
        print("ERROR - P3Cable failed to parse credit balance")
        CREDIT_BALANCE = TEMP

    if parsed.machine_role:
        persist_machine_identity_from_gamestats(
            CONFIG_FILE,
            machine_role=parsed.machine_role,
            bank_number=parsed.bank_number,
            term_number=(
                getattr(parsed, "machine_number", None) or parsed.term_number
            ),
            pog_tid=getattr(parsed, "tid_hex", None),
        )
        MACHINE_ROLE = parsed.machine_role
        if parsed.bank_number is not None:
            BANK_NUMBER = parsed.bank_number
        if parsed.term_number is not None:
            TERM_NUMBER = parsed.term_number
        print(
            f"P3 GAMESTATS identity: role={MACHINE_ROLE} bank={BANK_NUMBER} term={TERM_NUMBER}",
            flush=True,
        )

    if apply_floor_games and not P3_FLOOR_GAMES_LOADED:
        P3_FLOOR_GAMES_LOADED = True
        P3_SESSION_FLOOR_GAMES = parsed.progressive_floor_games
        P3_SESSION_BLACK_GOLD_21 = parsed.black_gold_21_enabled
        _refresh_ohd_scheduler_from_config()
        floor_list = ",".join(str(g) for g in parsed.progressive_floor_games)
        credit_str = f"{CREDIT_BALANCE} (${CREDIT_BALANCE / 100:.2f})" if CREDIT_BALANCE >= 0 else str(CREDIT_BALANCE)
        print(
            f"P3 GAMESTATS boot cache: progressive_floor_games=[{floor_list}] "
            f"black_gold_21={parsed.black_gold_21_enabled} credit={credit_str}",
            flush=True,
        )


def P3_update_credit_balance():

    global CREDIT_BALANCE, P3_LAST_USAGE, P3Cable

    if (TEST_MODE_NO_SERIAL):
        return

    if POGCC_COM_OWNER == "ethergold":
        return

    if (P3_GRAB_BUSY):
        return

    # If less than 2 seconds has passed ignore the request
    # GAMESTATS blocks T340+ ~600 ms (BBU + CSV format) - only call from safe idle
    # windows (splash boot, POG-CC /credits). Never during video/progressive animation.
    # Floor-game list: parse once at boot only; enabled games do not change at runtime.

    CT = time.perf_counter()

    if ((CT - P3_LAST_USAGE) < 2):
        return

    P3_LAST_USAGE = CT
    apply_floor_games = not P3_FLOOR_GAMES_LOADED

    text = P3_fetch_gamestats_text()
    if text is None:
        CREDIT_BALANCE = -99999999
        return

    try:
        parsed = parse_gamestats_csv(text)
    except Exception as exc:
        print(f"ERROR - P3 GAMESTATS parse failed: {exc}")
        return

    try:
        P3_apply_parsed_gamestats(parsed, apply_floor_games=apply_floor_games)
    except Exception as exc:
        # 4.0.0.5 field tree: DyzineFrame.py expected machine_number/tid_hex
        # while p3_gamestats.py was the older dataclass - splash then crash.
        print(f"ERROR - P3 GAMESTATS apply failed: {exc}", flush=True)
        return

    return

#-----------------------------------------------------------------------------------------------------------------------
def _close_heltec_cdc() -> None:
    """Close ACM without an extra DTR falling edge. Process death still drops DTR.

    A False-True DTR pulse on ESP32-S3 USB-Serial/JTAG resets the MCU with USB
    plugged and the shared PHY comes back as 303a:1001, not OTG CDC 0002.
    """
    global USBCDC
    ser = USBCDC
    USBCDC = None
    if ser is None:
        return
    try:
        ser.close()
    except Exception:
        pass


def _open_heltec_cdc():
    """Open OTG CDC only. Assert DTR (Heltec Topbox.bool) without a reset pulse."""
    if not _heltec_cdc_on_bus():
        raise RuntimeError("Heltec OTG CDC 303a:0002 not on bus")
    ser = serial.Serial(
        port="/dev/ttyACM0",
        baudrate=115200,
        bytesize=8,
        timeout=0.2,
        stopbits=serial.STOPBITS_ONE,
        dsrdtr=False,
        rtscts=False,
    )
    try:
        ser.dtr = True
    except Exception:
        pass
    try:
        ser.reset_input_buffer()
    except Exception:
        pass
    return ser


def _ignore_heltec_identity_uplink(kind: str) -> None:
    """Old 2701 boards still USB-push /asset and /ip. Pi Identity owns those."""
    now = time.perf_counter()
    last = getattr(_ignore_heltec_identity_uplink, "_t", 0.0)
    if now - last >= 60.0:
        _ignore_heltec_identity_uplink._t = now  # type: ignore[attr-defined]
        print(
            f"POG-CC USB ignored /{kind} (Pi Identity owns asset and content host)",
            flush=True,
        )


def pogcc_push_cabinet_cfg() -> None:
    """USB-downlink asset/theme/volume from config.ini. Re-read so Identity Apply takes effect."""
    global ASSET_NUMBER, POG_ASSET_NUMBER, USBCDC
    if USBCDC is None:
        return
    try:
        if not USBCDC.is_open:
            return
    except Exception:
        return
    config = ExtendedConfigParser()
    with _cfg_lock():
        _read_config_ini(config)
        content_cfg = read_config_content_settings(config)
        try:
            asset = int(content_cfg["asset_number"])
        except (TypeError, ValueError, KeyError):
            asset = 0
        theme = 1
        volume = 5
        if config.has_option("TOPBOX", "POG_CC_THEME"):
            try:
                theme = config.getint("TOPBOX", "POG_CC_THEME")
            except ValueError:
                theme = 1
        if config.has_option("TOPBOX", "POG_CC_VOLUME"):
            try:
                volume = config.getint("TOPBOX", "POG_CC_VOLUME")
            except ValueError:
                volume = 5
    if theme < 1 or theme > 5:
        theme = 1
    if volume < 0:
        volume = 0
    elif volume > 62:
        volume = 62
    line = "/asset/%s\n/theme/%s\n/volume/%s\n" % (asset, theme, volume)
    try:
        USBCDC.write(line.encode("ascii"))
    except Exception as exc:
        print(f"POG-CC USB push asset/theme/volume failed: {exc}", flush=True)
        return
    want = (asset, theme, volume)
    prev = getattr(pogcc_push_cabinet_cfg, "_last", None)
    if want != prev:
        pogcc_push_cabinet_cfg._last = want  # type: ignore[attr-defined]
        print(
            f"POG-CC USB pushed identity asset={asset} theme={theme} volume={volume}",
            flush=True,
        )
    if asset != ASSET_NUMBER:
        ASSET_NUMBER = asset
        POG_ASSET_NUMBER = asset
        refresh_content_sync_targets()


def USBCDC_Parse_Data():

    global USBCDC, USBCDC_TIMESTAMP, HELTEC_USB_FAIL

    # If there is no Serial Cable try to open it back up

    if (USBCDC == None):
        action = _heltec_usb_action()
        if action == heltec_cdc.ACTION_USBRESET_JTAG:
            _recover_heltec_jtag_if_needed()
            return
        if action != heltec_cdc.ACTION_OPEN_CDC:
            return
        try:
            USBCDC = _open_heltec_cdc()
        except Exception:
            _close_heltec_cdc()
            return

    # Check if the Serial Cable is closed

    if (USBCDC.is_open == False):
        if _heltec_usb_action() != heltec_cdc.ACTION_OPEN_CDC:
            _close_heltec_cdc()
            return
        USBCDC.open()
        if (USBCDC.is_open == False):
            return

    # If we've made it this far the USBCDC Cable should be working

    raw_data = None

    try:
        if (USBCDC.in_waiting > 0):
            raw_data = USBCDC.readline()
        else:
            return
    except:
        _close_heltec_cdc()
        return

    # Strip CR/LF - Heltec often ends lines with \r\n; trailing \r broke isdigit() on stamps

    raw_data = raw_data.replace(b'\x0A', b'').replace(b'\x0D', b'')

    # Convert data from bytes to ascii string

    ascii_data = None

    try:
        ascii_data = raw_data.decode("ascii").strip()
    except:
        print("USBCDC failed to decode ascii")
        return

    # Update the Watchdog Timer

    USBCDC_TIMESTAMP = time.perf_counter()
    HELTEC_USB_FAIL = False

    # Parse the Top Level commands

    split = [p.strip() for p in ascii_data.split('/')]

    if (len(split) <= 1):
        print("USBCDC recv'd an invalid message")
        return

    del split[0] # Remove the leading /

    def _opt_u64(idx):
        if len(split) <= idx:
            return None
        tok = split[idx]
        return int(tok) if tok.isdigit() else None

    if (split[0] == "sds"):
        sds_handler(int(split[1]), source="sds")

    elif (split[0] == "hello"):
        # POG-CC USB up / flash: version + clocks. Arms EG gap-fill only - does not
        # set curr_index (SDS enter-game /sds remains faster when both are live).
        fw = split[1] if len(split) > 1 else "unknown"
        local_ms = int(split[2]) if len(split) > 2 and split[2].isdigit() else None
        utc_ms = int(split[3]) if len(split) > 3 and split[3].isdigit() else None
        try:
            arm_path = write_pogcc_boot_arm(
                firmware_version=fw,
                heltec_local_ms=local_ms,
                heltec_utc_ms=utc_ms,
            )
            print(
                f"POG-CC hello fw={fw} local_ms={local_ms} utc_ms={utc_ms} "
                f"armed EG gap-fill -> {arm_path}",
                flush=True,
            )
        except OSError as exc:
            print(f"POG-CC hello: failed to write boot arm: {exc}", flush=True)
        pogcc_push_cabinet_cfg()

    elif (split[0] == "telemetry"):
        # POG-CC every ~5s: power rails + cab temps + credits -' EG status JSON.
        # Wire: /telemetry/<fw>/<local_ms>/<credits>/<menu>/
        #   <u_ok>/<u_f100>/<l_ok>/<l_f100>/
        #   <present>/<mv>/<ma>/<mw> Ã -  4 rails (5, 12A, 12B, 24)
        def _tok(i, default="0"):
            return split[i] if len(split) > i else default

        def _si(i, default=0):
            tok = _tok(i)
            try:
                return int(tok)
            except ValueError:
                return default

        def _rail(base_idx):
            present = _si(base_idx) != 0
            mv, ma, mw = _si(base_idx + 1), _si(base_idx + 2), _si(base_idx + 3)
            return {
                "present": present,
                "volts": round(mv / 1000.0, 3) if present else None,
                "amps": round(ma / 1000.0, 3) if present else None,
                "watts": round(mw / 1000.0, 3) if present else None,
                "millivolts": mv if present else None,
                "milliamps": ma if present else None,
                "milliwatts": mw if present else None,
            }

        u_ok = _si(5) != 0
        l_ok = _si(7) != 0
        u_f100 = _si(6)
        l_f100 = _si(8)
        payload = {
            "firmware_version": _tok(1, "unknown"),
            "heltec_local_ms": _si(2),
            "credits_pennies": _si(3),
            "credits_dollars": round(_si(3) / 100.0, 2),
            "game_menu": _si(4),
            "temps_f": {
                "upper": round(u_f100 / 100.0, 2) if u_ok else None,
                "lower": round(l_f100 / 100.0, 2) if l_ok else None,
                "upper_present": u_ok,
                "lower_present": l_ok,
            },
            "power": {
                "rail_5": _rail(9),
                "rail_12a": _rail(13),
                "rail_12b": _rail(17),
                "rail_24": _rail(21),
            },
        }
        try:
            tele_path = write_pogcc_telemetry(payload)
            # Rate-limited proof line - proves Heltec-DF hop when debugging null pogcc.
            now = time.perf_counter()
            last = getattr(USBCDC_Parse_Data, "_telemetry_log_t", 0.0)
            if now - last >= 60.0:
                USBCDC_Parse_Data._telemetry_log_t = now  # type: ignore[attr-defined]
                print(
                    f"POG-CC telemetry ok fw={payload.get('firmware_version')} "
                    f"credits={payload.get('credits_pennies')} path={tele_path}",
                    flush=True,
                )
        except OSError as exc:
            print(
                f"POG-CC telemetry write failed: {exc} "
                f"(path would be {getattr(write_pogcc_telemetry, '__name__', '')})",
                flush=True,
            )
            try:
                from current_game_ipc import resolve_pogcc_telemetry_path as _rtp
                print(f"POG-CC telemetry target={_rtp()}", flush=True)
            except Exception:
                pass

    elif (split[0] == "ip"):
        _ignore_heltec_identity_uplink("ip")

    elif (split[0] == "asset"):
        _ignore_heltec_identity_uplink("asset")

    elif (split[0] == "flag"):
        flag_handler(
            split[1],
            int(split[2]),
            pogcc_utc_ms=_opt_u64(3),
            pogcc_local_ms=_opt_u64(4),
        )

    elif (split[0] == "credits"):
        P3_update_credit_balance() # Updates credit balance (usually) which is an int : CREDIT_BALANCE
        # CR, prefix - Heltec skips this line on the heartbeat reader (and vice versa).
        USBCDC.write(("CR," + str(CREDIT_BALANCE) + "\n").encode("ascii"))

    elif (split[0] == "heartbeat"):
        ret = heartbeat_handler(int(split[1]), int(split[2]), int(split[3]), int(split[4]), int(split[5]))
        USBCDC.write((ret + "\n").encode("ascii"))
        pogcc_push_cabinet_cfg()

    else:
        print("ERROR - USBCDC_Parse_Data() invalid command: " + split[0])

    return

#-----------------------------------------------------------------------------------------------------------------------
def USB_CDC_Port_Reset():

    global USBCDC_TIMESTAMP

    # JTAG-only (303a:1001). Never usbreset Heltec CDC 303a:0002 - the cabinet
    # Heltec is locally 5 V with cable VBUS pin 1 open; host reset cannot reboot
    # that MCU. usbreset 1001 remounts OTG CDC without unplugging.
    if not IS_LINUX:
        USBCDC_TIMESTAMP = time.perf_counter()
        return

    if _heltec_usb_action() == heltec_cdc.ACTION_USBRESET_JTAG:
        os.system("sudo usbreset 303a:1001")
        _wait_heltec_cdc(3.0)

    USBCDC_TIMESTAMP = time.perf_counter()


def _recover_heltec_jtag_if_needed() -> None:
    """Rate-limited usbreset of 303a:1001 so DF Restart remounts OTG CDC."""
    global _HELTEC_JTAG_RESET_MONO
    if _heltec_usb_action() != heltec_cdc.ACTION_USBRESET_JTAG:
        return
    now = time.perf_counter()
    if now - _HELTEC_JTAG_RESET_MONO < 2.0:
        return
    _HELTEC_JTAG_RESET_MONO = now
    print(
        "USB CDC: Heltec is JTAG 303a:1001, not OTG CDC 303a:0002. "
        "usbreset 1001 so TinyUSB can remount. Do not reboot the Pi.",
        flush=True,
    )
    USB_CDC_Port_Reset()

#-----------------------------------------------------------------------------------------------------------------------
def thread_usb_serial():

    global USBCDC, USBCDC_TIMESTAMP, HELTEC_USB_FAIL

    USBCDC_TIMESTAMP = time.perf_counter()
    time.sleep(0.5)

    USB_CDC_Port_Reset()
    _wait_heltec_cdc(3.0)

    print("Starting the USB CDC Serial thread")

    while (True):

        if not call_never_raise(USBCDC_Parse_Data, label="USB CDC parse"):
            _close_heltec_cdc()

        if ((time.perf_counter() - USBCDC_TIMESTAMP) >= 15):
            HELTEC_USB_FAIL = True
            action = _heltec_usb_action()
            if action == heltec_cdc.ACTION_USBRESET_JTAG:
                _close_heltec_cdc()
                _recover_heltec_jtag_if_needed()
            elif action == heltec_cdc.ACTION_OPEN_CDC:
                print(
                    "USB CDC watchdog: Heltec silent with 303a:0002 on the bus. "
                    "Close dead ACM and reopen CDC (no DTR reset pulse).",
                    flush=True,
                )
                _close_heltec_cdc()
                try:
                    USBCDC = _open_heltec_cdc()
                except Exception:
                    pass
            else:
                print(
                    "USB CDC watchdog: Heltec not on the bus. Wait for 303a:0002. "
                    "Do not open ttyACM0. Do not reboot the Pi.",
                    flush=True,
                )
                _close_heltec_cdc()
            USBCDC_TIMESTAMP = time.perf_counter()

        time.sleep(0.025) # Check the Serial Port 40 times per second

#-----------------------------------------------------------------------------------------------------------------------
#-----------------------------------------------------------------------------------------------------------------------
class _QuietEtherGoldWSGIHandler(WSGIRequestHandler):
    """Suppress repetitive success access logs for high-frequency progressive polls."""

    _QUIET_PATHS = (
        "/EtherGold/progressives.csv",
        "/EtherGold/progressives-sync.json",
        "/EtherGold/fireworks-request.json",
    )

    def log_message(self, format, *args):
        try:
            request_line = args[2] if len(args) > 2 else ""
            status = str(args[3]) if len(args) > 3 else ""
            path = request_line.split(" ", 2)[1] if request_line else ""
            path = path.split("?", 1)[0]
            if path in self._QUIET_PATHS and status in ("200", "304"):
                return
        except Exception:
            pass
        super().log_message(format, *args)


class _QuietWSGIRefServer(ServerAdapter):
    """Bottle WSGIRefServer with selective access-log suppression."""

    def run(self, handler):
        server = make_server(
            self.host,
            self.port,
            handler,
            handler_class=_QuietEtherGoldWSGIHandler,
        )
        server.serve_forever()


def thread_web_server():

    # Start the web server, needed for AP Mode

    time.sleep(1)

    print("Starting the Web Server on Port: " + str(WEB_SERVICE_PORT))

    while (True):
        run(
            host="0.0.0.0",
            port=WEB_SERVICE_PORT,
            server=_QuietWSGIRefServer,
        )
        print("ERROR - The web server crashed or closed for some reason")


def thread_local_pogcc_http():
    """Localhost-only Bottle when ethergold-http owns :31234.

    Serves /ohd/* (proxied from public :31234) and POG-CC flag routes for
    ethergold-pogcc-com when pogcc_com_owner=ethergold.
    """
    time.sleep(0.5)
    port = int(os.environ.get("DYZINEFRAME_LOCAL_HTTP_PORT", "31235") or "31235")
    print(
        f"Starting local DyzineFrame HTTP on 127.0.0.1:{port} "
        "(ethergold-http /ohd proxy + DYZINEFRAME_HTTP)",
        flush=True,
    )
    while True:
        run(
            host="127.0.0.1",
            port=port,
            server=_QuietWSGIRefServer,
        )
        print("ERROR - local DyzineFrame HTTP crashed or closed", flush=True)
        time.sleep(1)

#-----------------------------------------------------------------------------------------------------------------------
def thread_network_check():

    # Start the chknet.sh script

    time.sleep(1.5) # Don't check for network errors in the first minute

    print("Starting the Network Check thread")

    while (True):
        ret = os.system("./chknet.sh")
        if (ret != 0):
            print("Network Check thread (chknet.sh) has failed, disabling the thread")
            return
        time.sleep(int(CHECK_WLAN0_DELAY))

#-----------------------------------------------------------------------------------------------------------------------
def thread_ads_folder_sync():

    # Start the Sync thread

    time.sleep(2)

    while (True):

        refresh_content_sync_targets()

        if (TARGET_WEBPAGE == None or TARGET_FOLDER == None):
            print("Sync thread idle: " + str(CONTENT_SYNC_ERROR), flush=True)
            time.sleep(900)
            continue

        print("Starting the Sync Thread with URL '" + TARGET_WEBPAGE + "' and local folder '" + TARGET_FOLDER + "'")

        # Check if there is a valid and real internet connection

        if (is_there_internet() == True):

            return_code = subprocess.call(["python", "sync.py", TARGET_WEBPAGE, TARGET_FOLDER])

            # Check if anything has changed and reload all Ads if needed

            if (return_code == 5 or return_code == 1280):  # 5 (1280 on Linux) is a special return code used inside sync.py
                load_ads()
            elif (return_code != 0):
                print(
                    "ERROR - thread_ads_folder_sync() sync.py failed "
                    f"(return_code={return_code}) - soft-fail LED2 code 10; retry later",
                    flush=True,
                )
                try:
                    import hat_status_led as _hsl

                    _hsl.soft_fail(10)
                    _hsl.enter_degraded()
                except Exception:
                    pass

        else:
            print("ERROR - thread_ads_folder_sync() no internet connection")

        time.sleep(900)

#-----------------------------------------------------------------------------------------------------------------------
register_ohd_setup_routes(
    get=get,
    post=post,
    request=request,
    response=response,
    config_path=CONFIG_FILE,
    version=CURRENT_VERSION,
    web_port=lambda: WEB_SERVICE_PORT,
    display_mode=lambda: DISPLAY_MODE,
    asset_number=lambda: ASSET_NUMBER,
    reload_config=load_config,
    modes_status=lambda: OHD_PROGRESSIVE_SCHEDULER.modes_status_snapshot(PROG_DISPLAY_MODE),
)

#-----------------------------------------------------------------------------------------------------------------------
if __name__ == '__main__':

    from singleton import acquire_singleton

    acquire_singleton()
    main()

#-----------------------------------------------------------------------------------------------------------------------
