1138 lines
48 KiB
Python
1138 lines
48 KiB
Python
import tkinter as tk
|
||
from tkinter import messagebox, ttk
|
||
import time
|
||
import threading
|
||
from datetime import datetime
|
||
import requests
|
||
import random
|
||
import csv
|
||
import io
|
||
import os
|
||
import json
|
||
|
||
# -------------------------
|
||
# Paths & defaults
|
||
# -------------------------
|
||
documents_folder = os.path.join(os.path.expanduser("~"), "Documents")
|
||
app_folder = os.path.join(documents_folder, "RocketLaunchCountdown")
|
||
os.makedirs(app_folder, exist_ok=True)
|
||
|
||
COUNTDOWN_HTML = os.path.join(app_folder, "countdown.html")
|
||
GONOGO_HTML = os.path.join(app_folder, "gonogo.html")
|
||
GONOGO_JS = os.path.join(app_folder, "gonogo_data.js")
|
||
SETTINGS_FILE = os.path.join(app_folder, "settings.json")
|
||
|
||
# Default CSV link you provided (kept for CSV fetch fallback)
|
||
DEFAULT_CSV_LINK = "https://docs.google.com/spreadsheets/d/1UPJTW8vH2mgEzispjg_Y_zSqYTFaLoxuoZnqleVlSZ0/export?format=csv&gid=855477916"
|
||
|
||
session = requests.Session()
|
||
appVersion = "0.3.0"
|
||
|
||
# -------------------------
|
||
# Settings helpers
|
||
# -------------------------
|
||
def load_settings():
|
||
if not os.path.exists(SETTINGS_FILE):
|
||
# default settings
|
||
s = {"mode": "sheet", "sheet_url": ""}
|
||
save_settings(s)
|
||
return s
|
||
try:
|
||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return {"mode": "sheet", "sheet_url": ""}
|
||
|
||
def save_settings(settings):
|
||
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(settings, f, indent=2)
|
||
|
||
# -------------------------
|
||
# Sheet fetching / manager
|
||
# -------------------------
|
||
def col_letters_to_index(col_letters: str) -> int:
|
||
"""Convert column letters like 'A' or 'AA' to 0-based index."""
|
||
col_letters = col_letters.upper()
|
||
idx = 0
|
||
for ch in col_letters:
|
||
if 'A' <= ch <= 'Z':
|
||
idx = idx * 26 + (ord(ch) - ord('A') + 1)
|
||
return idx - 1
|
||
|
||
def fetch_cell(sheet_url, cell_ref, timeout=2):
|
||
"""Fetch a specific cell value from a public Google Sheet CSV link.
|
||
|
||
cell_ref like 'L2' or 'AA10'. Returns string value or 'Error: ...'.
|
||
"""
|
||
try:
|
||
# add cache-buster to avoid stale cached CSV from Google
|
||
if sheet_url:
|
||
cb = int(time.time() * 1000)
|
||
fetch_url = sheet_url + ("&" if "?" in sheet_url else "?") + f"cb={cb}"
|
||
else:
|
||
fetch_url = sheet_url
|
||
resp = session.get(fetch_url, timeout=timeout)
|
||
resp.raise_for_status()
|
||
reader = csv.reader(io.StringIO(resp.text))
|
||
data = list(reader)
|
||
# parse cell_ref
|
||
# split letters then digits
|
||
letters = ''.join([c for c in cell_ref if c.isalpha()])
|
||
digits = ''.join([c for c in cell_ref if c.isdigit()])
|
||
# invalid cell ref -> treat as failure
|
||
if not letters or not digits:
|
||
return None
|
||
col = col_letters_to_index(letters)
|
||
row = int(digits) - 1
|
||
if row < 0 or col < 0:
|
||
return None
|
||
if row >= len(data) or col >= len(data[row]):
|
||
return None
|
||
return data[row][col].strip()
|
||
except Exception as e:
|
||
# network or parsing error -> return None so caller can treat as failure
|
||
print(f"[WARN] fetch_cell error for {cell_ref} @ {sheet_url}: {e}")
|
||
return None
|
||
|
||
|
||
# Note: single-sheet behavior only. Multi-sheet manager removed; use top-level
|
||
# settings keys `sheet_url` and `sheet_cells` in the Settings dialog.
|
||
|
||
# -------------------------
|
||
# Fetch (CSV) Go/No-Go (fallback/prefill)
|
||
# -------------------------
|
||
def fetch_gonogo_csv(csv_link=DEFAULT_CSV_LINK, timeout=3):
|
||
"""
|
||
Fetch Go/No-Go parameters from CSV export (rows 2-4, col 12).
|
||
Returns [Range, Weather, Vehicle] (strings).
|
||
"""
|
||
try:
|
||
# add cache-buster to try to get fresh CSV content from Google
|
||
cb = int(time.time() * 1000)
|
||
fetch_url = csv_link + ("&" if "?" in csv_link else "?") + f"cb={cb}"
|
||
resp = session.get(fetch_url, timeout=timeout)
|
||
resp.raise_for_status()
|
||
reader = csv.reader(io.StringIO(resp.text))
|
||
data = list(reader)
|
||
gonogo = []
|
||
# rows index 1,2,3 correspond to spreadsheet rows 2,3,4
|
||
for i in [1, 2, 3]:
|
||
value = data[i][11] if len(data) > i and len(data[i]) > 11 else "N/A"
|
||
gonogo.append(value.strip().upper())
|
||
return gonogo
|
||
except Exception as e:
|
||
print(f"[WARN] Failed to fetch Go/No-Go CSV: {e}")
|
||
return None
|
||
|
||
|
||
def parse_csv_and_get_cell(csv_text, cell_ref):
|
||
"""Parse CSV text and return the value at cell_ref (A1-style) or None if missing."""
|
||
try:
|
||
reader = csv.reader(io.StringIO(csv_text))
|
||
data = list(reader)
|
||
letters = ''.join([c for c in cell_ref if c.isalpha()])
|
||
digits = ''.join([c for c in cell_ref if c.isdigit()])
|
||
# invalid cell ref -> signal failure
|
||
if not letters or not digits:
|
||
return None
|
||
col = col_letters_to_index(letters)
|
||
row = int(digits) - 1
|
||
if row < 0 or col < 0:
|
||
return None
|
||
if row >= len(data) or col >= len(data[row]):
|
||
return None
|
||
return data[row][col].strip()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def try_fetch_csv_text(url, timeout=3):
|
||
"""Fetch CSV text with cache-buster; return text or None."""
|
||
try:
|
||
if not url:
|
||
return None
|
||
cb = int(time.time() * 1000)
|
||
fetch_url = url + ("&" if "?" in url else "?") + f"cb={cb}"
|
||
resp = session.get(fetch_url, timeout=timeout)
|
||
resp.raise_for_status()
|
||
return resp.text
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def build_gviz_csv_url(sheet_url):
|
||
"""Try to construct a gviz CSV URL from common sheet URLs.
|
||
Example: https://docs.google.com/spreadsheets/d/<id>/gviz/tq?tqx=out:csv&gid=<gid>
|
||
Returns None if it can't parse an id.
|
||
"""
|
||
try:
|
||
if not sheet_url:
|
||
return None
|
||
parts = sheet_url.split('/d/')
|
||
if len(parts) < 2:
|
||
return None
|
||
spid = parts[1].split('/')[0]
|
||
gid = None
|
||
# try to extract gid param if present
|
||
if 'gid=' in sheet_url:
|
||
for seg in sheet_url.replace('?', '&').split('&'):
|
||
if seg.startswith('gid='):
|
||
gid = seg.split('=', 1)[1]
|
||
break
|
||
if not gid:
|
||
gid = '0'
|
||
return f"https://docs.google.com/spreadsheets/d/{spid}/gviz/tq?tqx=out:csv&gid={gid}"
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def fetch_gonogo_values(sheet_url=None, csv_fallback=None, cells=None, timeout=1):
|
||
"""Attempt to fetch the three gonogo values (Range, Weather, Vehicle).
|
||
Returns a list [R, W, V] or None on failure.
|
||
Tries gviz CSV first (usually fresher), then the export CSV, then fetch_gonogo_csv.
|
||
"""
|
||
try:
|
||
url = sheet_url or csv_fallback or DEFAULT_CSV_LINK
|
||
# try gviz CSV first
|
||
gviz = build_gviz_csv_url(url)
|
||
csv_text = None
|
||
if gviz:
|
||
csv_text = try_fetch_csv_text(gviz, timeout=min(2, timeout))
|
||
if not csv_text:
|
||
csv_text = try_fetch_csv_text(url, timeout=timeout)
|
||
if csv_text:
|
||
# If specific cell mappings provided, use them and treat missing mapping as failure
|
||
if cells:
|
||
try:
|
||
r = parse_csv_and_get_cell(csv_text, cells.get('Range', ''))
|
||
w = parse_csv_and_get_cell(csv_text, cells.get('Weather', ''))
|
||
v = parse_csv_and_get_cell(csv_text, cells.get('Vehicle', ''))
|
||
if r is not None and w is not None and v is not None:
|
||
return [r.strip().upper(), w.strip().upper(), v.strip().upper()]
|
||
return None
|
||
except Exception:
|
||
return None
|
||
# No mappings provided: fall back to default L2/L3/L4 positions (column 12 -> index 11)
|
||
reader = csv.reader(io.StringIO(csv_text))
|
||
data = list(reader)
|
||
vals = []
|
||
for i in [1,2,3]:
|
||
v = data[i][11] if len(data) > i and len(data[i]) > 11 else "N/A"
|
||
vals.append(v.strip().upper())
|
||
return vals
|
||
# last-resort: use existing CSV fetch helper which does similar work
|
||
return fetch_gonogo_csv(url)
|
||
except Exception:
|
||
return None
|
||
|
||
# -------------------------
|
||
# Utility
|
||
# -------------------------
|
||
def get_status_color(status):
|
||
try:
|
||
return "green" if str(status).strip().upper() == "GO" else "red"
|
||
except Exception:
|
||
return "white"
|
||
|
||
def ensure_iframe_url(url):
|
||
"""
|
||
Convert many common Google sheet URLs to an embeddable pubhtml URL.
|
||
If user pasted a 'publish to web' link already, return as-is.
|
||
If they pasted an /edit? URL, attempt to convert to preview/pub versions.
|
||
"""
|
||
if not url:
|
||
return ""
|
||
if "pubhtml" in url:
|
||
return url
|
||
# If it's the spreadsheet "edit" URL, try to convert to /preview (works in many cases)
|
||
if "/edit" in url:
|
||
return url.split("/edit")[0] + "/preview"
|
||
# If it's the direct docs/d/<id>/ URL without pubhtml, attempt the embed pattern
|
||
# This won't always preserve sheet/tab selection; best is to instruct users to Publish to web.
|
||
return url
|
||
|
||
# -------------------------
|
||
# HTML writers
|
||
# -------------------------
|
||
def write_countdown_html(mission_name, timer_text):
|
||
html = f"""<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<style>
|
||
body {{
|
||
margin: 0;
|
||
background-color: black;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
color: white;
|
||
font-family: Consolas, monospace;
|
||
}}
|
||
#mission {{ font-size: 4vw; margin-bottom: 0; }}
|
||
#timer {{ font-size: 8vw; margin-bottom: 40px; }}
|
||
</style>
|
||
<script>setTimeout(()=>location.reload(),1000);</script>
|
||
</head>
|
||
<body>
|
||
<div id="mission">{mission_name}</div>
|
||
<div id="timer">{timer_text}</div>
|
||
</body>
|
||
</html>"""
|
||
with open(COUNTDOWN_HTML, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
|
||
def write_gonogo_html_from_values(values):
|
||
"""
|
||
values: [Range, Weather, Vehicle] strings like "GO" or "NO-GO"
|
||
"""
|
||
# ensure three items:
|
||
vals = (values + ["N/A","N/A","N/A"])[:3]
|
||
ts = int(time.time() * 1000)
|
||
html = f"""<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<meta http-equiv="Pragma" content="no-cache">
|
||
<meta http-equiv="Expires" content="0">
|
||
<style>
|
||
body {{
|
||
margin: 0;
|
||
background-color: black;
|
||
color: white;
|
||
font-family: Consolas, monospace;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 100vh;
|
||
}}
|
||
#gonogo {{
|
||
display: flex;
|
||
gap: 40px;
|
||
}}
|
||
.status-box {{
|
||
border: 2px solid white;
|
||
padding: 20px 40px;
|
||
font-size: 2.5vw;
|
||
text-align: center;
|
||
background-color: #111;
|
||
}}
|
||
.go {{ color: #0f0; }}
|
||
.nogo {{ color: #f00; }}
|
||
</style>
|
||
</script>
|
||
<script>
|
||
// Reload frequently but keep it small to be responsive in OBS browser-source
|
||
setTimeout(()=>location.reload(),1000);
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<!-- updated: {ts} -->
|
||
<div id="gonogo">
|
||
<div class="status-box {'go' if vals[0].strip().upper()=='GO' else 'nogo'}">Range: {vals[0]}</div>
|
||
<div class="status-box {'go' if vals[2].strip().upper()=='GO' else 'nogo'}">Vehicle: {vals[2]}</div>
|
||
<div class="status-box {'go' if vals[1].strip().upper()=='GO' else 'nogo'}">Weather: {vals[1]}</div>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
# atomic write: write to temp file then rename
|
||
tmp = GONOGO_HTML + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
try:
|
||
os.replace(tmp, GONOGO_HTML)
|
||
except Exception:
|
||
# best-effort fallback
|
||
with open(GONOGO_HTML, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
|
||
def write_gonogo_html_iframe(sheet_embed_url):
|
||
"""
|
||
Writes a gonogo.html that embeds the published Google Sheet via iframe.
|
||
Prefer users to publish-to-web and paste the pubhtml URL.
|
||
"""
|
||
# safe empty handling
|
||
ts = int(time.time() * 1000)
|
||
if not sheet_embed_url:
|
||
content = "<div style='color:orange'>No sheet URL set in settings</div>"
|
||
else:
|
||
# add cache-busting query param so iframe reloads when we update the file
|
||
glue = '&' if '?' in sheet_embed_url else '?'
|
||
content = f'<iframe src="{sheet_embed_url}{glue}cb={ts}" width="100%" height="600" style="border:none;"></iframe>'
|
||
html = f"""<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<meta http-equiv="Pragma" content="no-cache">
|
||
<meta http-equiv="Expires" content="0">
|
||
<style>
|
||
body {{
|
||
margin: 0;
|
||
background-color: black;
|
||
color: white;
|
||
font-family: Consolas, monospace;
|
||
}}
|
||
.container {{
|
||
width: 95%;
|
||
margin: 10px auto;
|
||
}}
|
||
</style>
|
||
<script>
|
||
setTimeout(()=>location.reload(),1000);
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<!-- updated: {ts} -->
|
||
<div class="container">
|
||
{content}
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
tmp = GONOGO_HTML + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
try:
|
||
os.replace(tmp, GONOGO_HTML)
|
||
except Exception:
|
||
with open(GONOGO_HTML, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
|
||
# -------------------------
|
||
# Countdown App
|
||
# -------------------------
|
||
class CountdownApp:
|
||
def __init__(self, root):
|
||
self.root = root
|
||
self.root.title(f"RocketLaunchCountdown {appVersion}")
|
||
self.root.config(bg="black")
|
||
self.root.attributes("-topmost", True)
|
||
self.root.geometry("900x700")
|
||
|
||
# state
|
||
self.running = False
|
||
self.on_hold = False
|
||
self.scrubbed = False
|
||
self.counting_up = False
|
||
self.target_time = None
|
||
self.hold_start_time = None
|
||
self.remaining_time = 0
|
||
self.mission_name = "Placeholder Mission"
|
||
|
||
# manual gonogo statuses (default N/A)
|
||
self.manual_range = "N/A"
|
||
self.manual_weather = "N/A"
|
||
self.manual_vehicle = "N/A"
|
||
|
||
# load settings
|
||
self.settings = load_settings()
|
||
# if sheet mode and sheet_url empty, try to fallback to CSV link to prefill GUI
|
||
self.csv_link = DEFAULT_CSV_LINK
|
||
|
||
# initial gonogo_html: if settings.mode == sheet -> iframe embed, else fill from CSV/fallback or manual
|
||
if self.settings.get("mode", "sheet") == "sheet":
|
||
embed_url = ensure_iframe_url(self.settings.get("sheet_url", ""))
|
||
write_gonogo_html_iframe(embed_url)
|
||
else:
|
||
# manual: populate from csv fetch as initial values or keep N/A
|
||
vals = fetch_gonogo_csv(self.csv_link)
|
||
# csv returns [Range, Weather, Vehicle] index 0,1,2 -> but our layout expects [Range, Weather, Vehicle]
|
||
write_gonogo_html_from_values(vals)
|
||
self.manual_range, self.manual_weather, self.manual_vehicle = vals[0], vals[1], vals[2]
|
||
|
||
write_countdown_html("Placeholder Mission", "T-00:00:00")
|
||
|
||
# GUI layout
|
||
self.titletext = tk.Label(root, text="RocketLaunchCountdown", font=("Consolas", 24), fg="white", bg="black")
|
||
self.titletext.pack(pady=(10, 0))
|
||
|
||
self.text = tk.Label(root, text="T-00:00:00", font=("Consolas", 80, "bold"), fg="white", bg="black")
|
||
self.text.pack(pady=(0, 5))
|
||
|
||
# mission input
|
||
frame_top = tk.Frame(root, bg="black")
|
||
frame_top.pack(pady=5)
|
||
tk.Label(frame_top, text="Mission Name:", fg="white", bg="black").pack(side="left")
|
||
self.mission_entry = tk.Entry(frame_top, width=30, font=("Arial", 16))
|
||
self.mission_entry.insert(0, self.mission_name)
|
||
self.mission_entry.pack(side="left", padx=5)
|
||
|
||
# Mode toggle (duration/clock)
|
||
frame_mode = tk.Frame(root, bg="black")
|
||
frame_mode.pack(pady=5)
|
||
self.mode_var = tk.StringVar(value="duration")
|
||
self.radio_duration = tk.Radiobutton(frame_mode, text="Duration", variable=self.mode_var, value="duration",
|
||
fg="white", bg="black", selectcolor="black", command=self.update_inputs)
|
||
self.radio_duration.pack(side="left", padx=5)
|
||
self.radio_clock = tk.Radiobutton(frame_mode, text="Clock Time", variable=self.mode_var, value="clock",
|
||
fg="white", bg="black", selectcolor="black", command=self.update_inputs)
|
||
self.radio_clock.pack(side="left", padx=5)
|
||
|
||
# Duration inputs
|
||
frame_duration = tk.Frame(root, bg="black")
|
||
frame_duration.pack(pady=5)
|
||
tk.Label(frame_duration, text="H", fg="white", bg="black").pack(side="left")
|
||
self.hours_entry = tk.Entry(frame_duration, width=3, font=("Arial", 16))
|
||
self.hours_entry.insert(0, "0")
|
||
self.hours_entry.pack(side="left", padx=2)
|
||
tk.Label(frame_duration, text="M", fg="white", bg="black").pack(side="left")
|
||
self.minutes_entry = tk.Entry(frame_duration, width=3, font=("Arial", 16))
|
||
self.minutes_entry.insert(0, "5")
|
||
self.minutes_entry.pack(side="left", padx=2)
|
||
tk.Label(frame_duration, text="S", fg="white", bg="black").pack(side="left")
|
||
self.seconds_entry = tk.Entry(frame_duration, width=3, font=("Arial", 16))
|
||
self.seconds_entry.insert(0, "0")
|
||
self.seconds_entry.pack(side="left", padx=2)
|
||
|
||
# Clock input
|
||
frame_clock = tk.Frame(root, bg="black")
|
||
frame_clock.pack(pady=5)
|
||
tk.Label(frame_clock, text="HH:MM", fg="white", bg="black").pack(side="left")
|
||
self.clock_entry = tk.Entry(frame_clock, width=7, font=("Arial", 16))
|
||
self.clock_entry.insert(0, "14:00")
|
||
self.clock_entry.pack(side="left", padx=5)
|
||
|
||
# control buttons
|
||
frame_buttons = tk.Frame(root, bg="black")
|
||
frame_buttons.pack(pady=10)
|
||
self.start_btn = tk.Button(frame_buttons, text="▶ Start", command=self.start, font=("Arial", 14))
|
||
self.start_btn.grid(row=0, column=0, padx=5)
|
||
self.hold_btn = tk.Button(frame_buttons, text="⏸ Hold", command=self.hold, font=("Arial", 14))
|
||
self.hold_btn.grid(row=0, column=1, padx=5)
|
||
self.resume_btn = tk.Button(frame_buttons, text="⏵ Resume", command=self.resume, font=("Arial", 14))
|
||
self.resume_btn.grid(row=0, column=1, padx=5)
|
||
self.resume_btn.grid_remove()
|
||
self.scrub_btn = tk.Button(frame_buttons, text="🚫 Scrub", command=self.scrub, font=("Arial", 14), fg="red")
|
||
self.scrub_btn.grid(row=0, column=2, padx=5)
|
||
self.reset_btn = tk.Button(frame_buttons, text="⟳ Reset", command=self.reset, font=("Arial", 14))
|
||
self.reset_btn.grid(row=0, column=3, padx=5)
|
||
|
||
# Settings + manual GO/NO-GO controls
|
||
ctrl_frame = tk.Frame(root, bg="black")
|
||
ctrl_frame.pack(pady=10)
|
||
|
||
self.settings_btn = tk.Button(ctrl_frame, text="⚙ Settings", command=self.open_settings, font=("Arial", 12))
|
||
self.settings_btn.pack(side="left", padx=6)
|
||
|
||
# Quick view for sheet data (single-sheet mode)
|
||
self.sheet_show_btn = tk.Button(ctrl_frame, text="Show Sheet Data", command=self.show_sheet_data, font=("Arial", 12))
|
||
self.sheet_show_btn.pack(side="left", padx=6)
|
||
self.refresh_btn = tk.Button(ctrl_frame, text="Refresh Now", command=self.refresh_gonogo_now, font=("Arial", 12))
|
||
self.refresh_btn.pack(side="left", padx=6)
|
||
# Rapid poll button: temporarily speed up polling for quick spreadsheet edits
|
||
self.rapid_btn = tk.Button(ctrl_frame, text="Rapid Poll", command=lambda: self.start_rapid_poll(15), font=("Arial", 12))
|
||
self.rapid_btn.pack(side="left", padx=6)
|
||
|
||
# Manual GO/NO-GO toggles (visible only in manual mode)
|
||
self.manual_frame = tk.Frame(ctrl_frame, bg="black")
|
||
self.manual_frame.pack(side="left", padx=10)
|
||
tk.Label(self.manual_frame, text="Manual:", fg="white", bg="black").pack(side="left", padx=(0,6))
|
||
self.range_btn = tk.Button(self.manual_frame, text="Range: N/A", command=self.toggle_range, font=("Arial", 12))
|
||
self.range_btn.pack(side="left", padx=4)
|
||
self.weather_btn = tk.Button(self.manual_frame, text="Weather: N/A", command=self.toggle_weather, font=("Arial", 12))
|
||
self.weather_btn.pack(side="left", padx=4)
|
||
self.vehicle_btn = tk.Button(self.manual_frame, text="Vehicle: N/A", command=self.toggle_vehicle, font=("Arial", 12))
|
||
self.vehicle_btn.pack(side="left", padx=4)
|
||
|
||
# Go/No-Go display labels in main GUI (mirror)
|
||
frame_gn = tk.Frame(root, bg="black")
|
||
frame_gn.pack(pady=10)
|
||
self.range_label = tk.Label(frame_gn, text="RANGE: N/A", font=("Consolas", 18), fg="white", bg="black")
|
||
self.range_label.pack()
|
||
self.weather_label = tk.Label(frame_gn, text="WEATHER: N/A", font=("Consolas", 18), fg="white", bg="black")
|
||
self.weather_label.pack()
|
||
self.vehicle_label = tk.Label(frame_gn, text="VEHICLE: N/A", font=("Consolas", 18), fg="white", bg="black")
|
||
self.vehicle_label.pack()
|
||
|
||
# Initialize gonogo cache and apply initial values (non-blocking preferred)
|
||
self.gonogo_values = ["N/A", "N/A", "N/A"]
|
||
# rapid polling window timestamp (seconds since epoch)
|
||
self._rapid_until = 0
|
||
# last rapid-update timestamp (seconds since epoch)
|
||
self.last_gonogo_update = 0
|
||
# inflight flag so we only have one background fetch at a time
|
||
self._gonogo_fetch_inflight = False
|
||
|
||
# Try a quick synchronous prefill if sheet_cells are configured (small/fast requests only)
|
||
try:
|
||
if self.settings.get("mode", "sheet") == "sheet":
|
||
cells = self.settings.get("sheet_cells", {})
|
||
url = self.settings.get("sheet_url") or self.csv_link
|
||
if cells and url:
|
||
# fetch CSV once and parse mapped cells locally to avoid multiple HTTP requests
|
||
try:
|
||
cb = int(time.time() * 1000)
|
||
fetch_url = url + ("&" if "?" in url else "?") + f"cb={cb}"
|
||
resp = session.get(fetch_url, timeout=2)
|
||
resp.raise_for_status()
|
||
csv_text = resp.text
|
||
r = parse_csv_and_get_cell(csv_text, cells.get("Range", "")) or "N/A"
|
||
w = parse_csv_and_get_cell(csv_text, cells.get("Weather", "")) or "N/A"
|
||
v = parse_csv_and_get_cell(csv_text, cells.get("Vehicle", "")) or "N/A"
|
||
self.gonogo_values = [r.upper(), w.upper(), v.upper()]
|
||
except Exception:
|
||
# fallback to CSV fetch helper
|
||
self.gonogo_values = fetch_gonogo_csv(self.csv_link) or ["N/A", "N/A", "N/A"]
|
||
else:
|
||
# quick CSV fallback
|
||
self.gonogo_values = fetch_gonogo_csv(self.csv_link) or ["N/A", "N/A", "N/A"]
|
||
else:
|
||
self.gonogo_values = [self.manual_range, self.manual_weather, self.manual_vehicle]
|
||
except Exception:
|
||
# keep defaults on failure
|
||
pass
|
||
|
||
# apply initial gonogo UI and HTML
|
||
write_gonogo_html_from_values(self.gonogo_values)
|
||
self.range_label.config(text=f"RANGE: {self.gonogo_values[0]}", fg=get_status_color(self.gonogo_values[0]))
|
||
self.weather_label.config(text=f"WEATHER: {self.gonogo_values[1]}", fg=get_status_color(self.gonogo_values[1]))
|
||
self.vehicle_label.config(text=f"VEHICLE: {self.gonogo_values[2]}", fg=get_status_color(self.gonogo_values[2]))
|
||
|
||
# start background poller for Go/No-Go updates
|
||
self.gonogo_poll_interval = int(self.settings.get("gonogo_interval", 10))
|
||
# failure/backoff state
|
||
self._gonogo_failures = 0
|
||
self._gonogo_backoff_until = 0
|
||
self._gonogo_max_failures = int(self.settings.get("gonogo_max_failures", 5))
|
||
self.start_gonogo_poller()
|
||
|
||
# footer
|
||
footer_frame = tk.Frame(root, bg="black")
|
||
footer_frame.pack(side="bottom", pady=0, fill="x")
|
||
self.footer_label = tk.Label(footer_frame, text=f"Made by HamsterSpaceNerd3000 — v{appVersion}",
|
||
font=("Consolas", 12), fg="black", bg="white")
|
||
self.footer_label.pack(fill="x")
|
||
|
||
# initialize visibility + values
|
||
self.update_inputs()
|
||
self.apply_settings_to_ui()
|
||
self.update_clock()
|
||
|
||
# ----------------------------
|
||
# Settings UI and behavior
|
||
# ----------------------------
|
||
def apply_settings_to_ui(self):
|
||
# show/hide manual controls based on settings.mode
|
||
mode = self.settings.get("mode", "sheet")
|
||
if mode == "manual":
|
||
self.manual_frame.pack(side="left", padx=10)
|
||
else:
|
||
# sheet mode -> hide manual controls
|
||
self.manual_frame.pack_forget()
|
||
|
||
def show_sheet_data(self):
|
||
# Single-sheet mode: use top-level sheet_url and sheet_cells
|
||
cells = self.settings.get("sheet_cells", {})
|
||
url = self.settings.get("sheet_url", "")
|
||
if not cells or not url:
|
||
messagebox.showinfo("Info", "No sheet URL or cell mappings configured in Settings.")
|
||
return
|
||
output = ["--- Sheet Data ---"]
|
||
for name, cell in cells.items():
|
||
val = fetch_cell(url, cell)
|
||
output.append(f"{name}: {val}")
|
||
messagebox.showinfo("Sheet Data", "\n".join(output))
|
||
|
||
def refresh_gonogo_now(self):
|
||
# Reset backoff and attempt an aggressive, short polling loop (up to 1s)
|
||
def worker():
|
||
prev = list(self.gonogo_values)
|
||
deadline = time.time() + 1.0 # 1 second budget to try to get fresh data
|
||
# clear temporary backoff to allow immediate requests
|
||
self._gonogo_failures = 0
|
||
self._gonogo_backoff_until = 0
|
||
while time.time() < deadline:
|
||
try:
|
||
self.poll_gonogo_once()
|
||
except Exception:
|
||
pass
|
||
# if values changed, stop early
|
||
if self.gonogo_values != prev:
|
||
break
|
||
time.sleep(0.18)
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def open_settings(self):
|
||
win = tk.Toplevel(self.root)
|
||
win.title("Settings")
|
||
win.config(bg="black")
|
||
win.geometry("700x180")
|
||
tk.Label(win, text="Data Source Mode:", bg="black", fg="white").pack(anchor="w", padx=10, pady=(10,0))
|
||
mode_var = tk.StringVar(value=self.settings.get("mode", "sheet"))
|
||
|
||
rb1 = tk.Radiobutton(win, text="Google Sheet (embed published sheet)", variable=mode_var, value="sheet",
|
||
bg="black", fg="white", selectcolor="black")
|
||
rb1.pack(anchor="w", padx=20)
|
||
rb2 = tk.Radiobutton(win, text="Manual GO/NOGO (use GUI buttons)", variable=mode_var, value="manual",
|
||
bg="black", fg="white", selectcolor="black")
|
||
rb2.pack(anchor="w", padx=20)
|
||
|
||
tk.Label(win, text="Google Sheet embed URL (Publish to web → copy link):", bg="black", fg="white").pack(anchor="w", padx=10, pady=(10,0))
|
||
sheet_entry = tk.Entry(win, width=100)
|
||
sheet_entry.insert(0, self.settings.get("sheet_url", ""))
|
||
sheet_entry.pack(padx=10, pady=(0,10))
|
||
|
||
# Cell mappings: allow user to specify exact cells for Range/Weather/Vehicle
|
||
tk.Label(win, text="Cell mappings (A1-style). Use selected sheet or these values:", bg="black", fg="white").pack(anchor="w", padx=10)
|
||
cells = self.settings.get("sheet_cells", {})
|
||
mapping_frame = tk.Frame(win, bg="black")
|
||
mapping_frame.pack(anchor="w", padx=10, pady=(4,10))
|
||
tk.Label(mapping_frame, text="Range:", bg="black", fg="white").grid(row=0, column=0, sticky="w")
|
||
range_entry = tk.Entry(mapping_frame, width=8)
|
||
range_entry.insert(0, cells.get("Range", "L2"))
|
||
range_entry.grid(row=0, column=1, padx=6)
|
||
tk.Label(mapping_frame, text="Weather:", bg="black", fg="white").grid(row=0, column=2, sticky="w")
|
||
weather_entry = tk.Entry(mapping_frame, width=8)
|
||
weather_entry.insert(0, cells.get("Weather", "L3"))
|
||
weather_entry.grid(row=0, column=3, padx=6)
|
||
tk.Label(mapping_frame, text="Vehicle:", bg="black", fg="white").grid(row=0, column=4, sticky="w")
|
||
vehicle_entry = tk.Entry(mapping_frame, width=8)
|
||
vehicle_entry.insert(0, cells.get("Vehicle", "L4"))
|
||
vehicle_entry.grid(row=0, column=5, padx=6)
|
||
|
||
def save_settings_cmd():
|
||
new_mode = mode_var.get()
|
||
new_url = sheet_entry.get().strip()
|
||
self.settings["mode"] = new_mode
|
||
self.settings["sheet_url"] = new_url
|
||
# save cell mappings to top-level settings (used when no selected_sheet is set)
|
||
new_cells = {
|
||
"Range": range_entry.get().strip(),
|
||
"Weather": weather_entry.get().strip(),
|
||
"Vehicle": vehicle_entry.get().strip()
|
||
}
|
||
# store mappings (only keys with non-empty values)
|
||
self.settings["sheet_cells"] = {k: v for k, v in new_cells.items() if v}
|
||
# single-sheet mode: mappings are stored in top-level sheet_cells only
|
||
save_settings(self.settings)
|
||
# regenerate gonogo HTML according to mode
|
||
if new_mode == "sheet":
|
||
embed = ensure_iframe_url(new_url)
|
||
write_gonogo_html_iframe(embed)
|
||
# prefill labels from top-level sheet_url & sheet_cells if provided
|
||
cells = self.settings.get("sheet_cells", {})
|
||
url = self.settings.get("sheet_url")
|
||
if cells and url:
|
||
r = fetch_cell(url, cells.get("Range", "")) or "N/A"
|
||
w = fetch_cell(url, cells.get("Weather", "")) or "N/A"
|
||
v = fetch_cell(url, cells.get("Vehicle", "")) or "N/A"
|
||
vals = [r, w, v]
|
||
else:
|
||
vals = fetch_gonogo_csv(self.csv_link)
|
||
self.range_label.config(text=f"RANGE: {vals[0]}", fg=get_status_color(vals[0]))
|
||
self.weather_label.config(text=f"WEATHER: {vals[1]}", fg=get_status_color(vals[1]))
|
||
self.vehicle_label.config(text=f"VEHICLE: {vals[2]}", fg=get_status_color(vals[2]))
|
||
else:
|
||
# manual: write current manual values
|
||
write_gonogo_html_from_values([self.manual_range, self.manual_weather, self.manual_vehicle])
|
||
self.apply_settings_to_ui()
|
||
win.destroy()
|
||
|
||
btn_frame = tk.Frame(win, bg="black")
|
||
btn_frame.pack(fill="x", pady=(0,10))
|
||
tk.Button(btn_frame, text="Save", command=save_settings_cmd, width=12).pack(side="right", padx=10)
|
||
|
||
# ----------------------------
|
||
# Inputs
|
||
# ----------------------------
|
||
def update_inputs(self):
|
||
if self.mode_var.get() == "duration":
|
||
self.hours_entry.config(state="normal")
|
||
self.minutes_entry.config(state="normal")
|
||
self.seconds_entry.config(state="normal")
|
||
self.clock_entry.config(state="disabled")
|
||
else:
|
||
self.hours_entry.config(state="disabled")
|
||
self.minutes_entry.config(state="disabled")
|
||
self.seconds_entry.config(state="disabled")
|
||
self.clock_entry.config(state="normal")
|
||
|
||
# ----------------------------
|
||
# Manual toggle callbacks
|
||
# ----------------------------
|
||
def toggle_range(self):
|
||
if self.settings.get("mode", "sheet") != "manual":
|
||
return
|
||
self.manual_range = "GO" if self.manual_range.strip().upper() != "GO" else "NOGO"
|
||
self.range_btn.config(text=f"Range: {self.manual_range}")
|
||
self.range_label.config(text=f"RANGE: {self.manual_range}", fg=get_status_color(self.manual_range))
|
||
write_gonogo_html_from_values([self.manual_range, self.manual_weather, self.manual_vehicle])
|
||
|
||
def toggle_weather(self):
|
||
if self.settings.get("mode", "sheet") != "manual":
|
||
return
|
||
self.manual_weather = "GO" if self.manual_weather.strip().upper() != "GO" else "NOGO"
|
||
self.weather_btn.config(text=f"Weather: {self.manual_weather}")
|
||
self.weather_label.config(text=f"WEATHER: {self.manual_weather}", fg=get_status_color(self.manual_weather))
|
||
write_gonogo_html_from_values([self.manual_range, self.manual_weather, self.manual_vehicle])
|
||
|
||
def toggle_vehicle(self):
|
||
if self.settings.get("mode", "sheet") != "manual":
|
||
return
|
||
self.manual_vehicle = "GO" if self.manual_vehicle.strip().upper() != "GO" else "NOGO"
|
||
self.vehicle_btn.config(text=f"Vehicle: {self.manual_vehicle}")
|
||
self.vehicle_label.config(text=f"VEHICLE: {self.manual_vehicle}", fg=get_status_color(self.manual_vehicle))
|
||
write_gonogo_html_from_values([self.manual_range, self.manual_weather, self.manual_vehicle])
|
||
|
||
# ----------------------------
|
||
# Control logic
|
||
# ----------------------------
|
||
def start(self):
|
||
self.mission_name = self.mission_entry.get().strip() or "Placeholder Mission"
|
||
self.running = True
|
||
self.on_hold = False
|
||
self.scrubbed = False
|
||
self.counting_up = False
|
||
self.show_hold_button()
|
||
try:
|
||
if self.mode_var.get() == "duration":
|
||
h = int(self.hours_entry.get())
|
||
m = int(self.minutes_entry.get())
|
||
s = int(self.seconds_entry.get())
|
||
total_seconds = h * 3600 + m * 60 + s
|
||
else:
|
||
now = datetime.now()
|
||
parts = [int(p) for p in self.clock_entry.get().split(":")]
|
||
h, m = parts[0], parts[1]
|
||
s = parts[2] if len(parts) == 3 else 0
|
||
target_today = now.replace(hour=h, minute=m, second=s, microsecond=0)
|
||
if target_today < now:
|
||
target_today = target_today.replace(day=now.day + 1)
|
||
total_seconds = (target_today - now).total_seconds()
|
||
except Exception:
|
||
self.text.config(text="Invalid time")
|
||
write_countdown_html(self.mission_name, "Invalid time")
|
||
return
|
||
self.target_time = time.time() + total_seconds
|
||
self.remaining_time = total_seconds
|
||
|
||
def hold(self):
|
||
if self.running and not self.on_hold and not self.scrubbed:
|
||
self.on_hold = True
|
||
self.hold_start_time = time.time()
|
||
self.remaining_time = max(0, self.target_time - self.hold_start_time)
|
||
self.show_resume_button()
|
||
|
||
def resume(self):
|
||
if self.running and self.on_hold and not self.scrubbed:
|
||
self.on_hold = False
|
||
self.target_time = time.time() + self.remaining_time
|
||
self.show_hold_button()
|
||
|
||
def show_hold_button(self):
|
||
self.resume_btn.grid_remove()
|
||
self.hold_btn.grid()
|
||
|
||
def show_resume_button(self):
|
||
self.hold_btn.grid_remove()
|
||
self.resume_btn.grid()
|
||
|
||
def scrub(self):
|
||
self.scrubbed = True
|
||
self.running = False
|
||
write_countdown_html(self.mission_name, "SCRUB")
|
||
self.text.config(text="SCRUB")
|
||
|
||
def reset(self):
|
||
self.running = False
|
||
self.on_hold = False
|
||
self.scrubbed = False
|
||
self.counting_up = False
|
||
self.text.config(text="T-00:00:00")
|
||
write_countdown_html(self.mission_name, "T-00:00:00")
|
||
self.show_hold_button()
|
||
|
||
# ----------------------------
|
||
# Format/clock loop
|
||
# ----------------------------
|
||
def format_time(self, seconds, prefix="T-"):
|
||
h = int(seconds // 3600)
|
||
m = int((seconds % 3600) // 60)
|
||
s = int(seconds % 60)
|
||
return f"{prefix}{h:02}:{m:02}:{s:02}"
|
||
|
||
def update_clock(self):
|
||
now_time = time.time()
|
||
|
||
# Timer logic
|
||
if self.running and not self.scrubbed:
|
||
if getattr(self, 'paused', False):
|
||
try:
|
||
secs = int(self.remaining_time)
|
||
except Exception:
|
||
secs = 0
|
||
timer_text = self.format_time(secs, "T-")
|
||
else:
|
||
if self.on_hold:
|
||
elapsed = int(now_time - self.hold_start_time)
|
||
timer_text = self.format_time(elapsed, "H+")
|
||
elif self.target_time:
|
||
diff = int(self.target_time - now_time)
|
||
if diff <= 0 and not self.counting_up:
|
||
self.counting_up = True
|
||
self.target_time = now_time
|
||
diff = 0
|
||
if self.counting_up:
|
||
elapsed = int(now_time - self.target_time)
|
||
timer_text = self.format_time(elapsed, "T+")
|
||
else:
|
||
timer_text = self.format_time(diff, "T-")
|
||
else:
|
||
timer_text = "T-00:00:00"
|
||
else:
|
||
timer_text = self.text.cget("text")
|
||
|
||
self.text.config(text=timer_text)
|
||
write_countdown_html(self.mission_name, timer_text)
|
||
|
||
# Gonogo updates are handled by background poller which updates `self.gonogo_values`
|
||
# Here we just ensure the display mirrors the latest cache (non-blocking)
|
||
vals = self.gonogo_values
|
||
# Only update UI here; file writes are done by the poller when values change
|
||
self.range_label.config(text=f"RANGE: {vals[0]}", fg=get_status_color(vals[0]))
|
||
self.weather_label.config(text=f"WEATHER: {vals[1]}", fg=get_status_color(vals[1]))
|
||
self.vehicle_label.config(text=f"VEHICLE: {vals[2]}", fg=get_status_color(vals[2]))
|
||
|
||
# Rapid near-instant fetch: if it's been >0.1s since last rapid update, start a quick background fetch
|
||
try:
|
||
if now_time - getattr(self, 'last_gonogo_update', 0) > 0.1 and not getattr(self, '_gonogo_fetch_inflight', False):
|
||
# mark inflight and perform quick fetch in background
|
||
self._gonogo_fetch_inflight = True
|
||
def rapid_fetch():
|
||
try:
|
||
# Try fetching mapped cells if configured
|
||
url = self.settings.get('sheet_url') or self.csv_link
|
||
cells = self.settings.get('sheet_cells', {})
|
||
new_vals = None
|
||
if cells and url:
|
||
# fetch CSV once via helper (gviz preferred inside helper)
|
||
# try to use gviz first, then export
|
||
gviz = build_gviz_csv_url(url)
|
||
csv_text = None
|
||
if gviz:
|
||
csv_text = try_fetch_csv_text(gviz, timeout=1)
|
||
if not csv_text:
|
||
csv_text = try_fetch_csv_text(url, timeout=1)
|
||
if csv_text:
|
||
r = parse_csv_and_get_cell(csv_text, cells.get('Range', ''))
|
||
w = parse_csv_and_get_cell(csv_text, cells.get('Weather', ''))
|
||
v = parse_csv_and_get_cell(csv_text, cells.get('Vehicle', ''))
|
||
if r is not None and w is not None and v is not None:
|
||
new_vals = [str(r).upper(), str(w).upper(), str(v).upper()]
|
||
if new_vals is None:
|
||
# fallback to generic fetch helper with short timeout, pass mappings so it won't mix sources
|
||
new_vals = fetch_gonogo_values(url, self.csv_link, cells=cells, timeout=1)
|
||
if new_vals and new_vals != self.gonogo_values:
|
||
self.gonogo_values = new_vals
|
||
# schedule UI update on main thread
|
||
try:
|
||
self.root.after(0, lambda: (
|
||
write_gonogo_html_from_values(self.gonogo_values),
|
||
self.range_label.config(text=f"RANGE: {self.gonogo_values[0]}", fg=get_status_color(self.gonogo_values[0])),
|
||
self.weather_label.config(text=f"WEATHER: {self.gonogo_values[1]}", fg=get_status_color(self.gonogo_values[1])),
|
||
self.vehicle_label.config(text=f"VEHICLE: {self.gonogo_values[2]}", fg=get_status_color(self.gonogo_values[2]))
|
||
))
|
||
except Exception:
|
||
pass
|
||
self.last_gonogo_update = time.time()
|
||
finally:
|
||
self._gonogo_fetch_inflight = False
|
||
threading.Thread(target=rapid_fetch, daemon=True).start()
|
||
except Exception:
|
||
pass
|
||
|
||
# schedule next tick
|
||
self.root.after(500, self.update_clock) # 2× per second
|
||
|
||
# ----------------------------
|
||
# Go/No-Go poller (background)
|
||
# ----------------------------
|
||
def start_gonogo_poller(self):
|
||
# run the poller in a background thread to avoid blocking the UI
|
||
def poll_loop():
|
||
while True:
|
||
try:
|
||
self.poll_gonogo_once()
|
||
except Exception:
|
||
pass
|
||
# support rapid poll mode: if _rapid_until is set and not expired, use 1s
|
||
now = time.time()
|
||
if getattr(self, '_rapid_until', 0) > now:
|
||
poll_interval = 1.0
|
||
else:
|
||
# use configured interval (ensure float and minimum 0.5s)
|
||
try:
|
||
poll_interval = max(0.5, float(self.gonogo_poll_interval))
|
||
except Exception:
|
||
poll_interval = 1.0
|
||
time.sleep(poll_interval)
|
||
t = threading.Thread(target=poll_loop, daemon=True)
|
||
t.start()
|
||
|
||
def start_rapid_poll(self, seconds=15):
|
||
"""Enable rapid polling for a short duration (seconds)."""
|
||
try:
|
||
self._rapid_until = time.time() + float(seconds)
|
||
# kick off a UI refresher to show rapid status on button
|
||
try:
|
||
self.root.after(200, self._update_rapid_button_ui)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
def _update_rapid_button_ui(self):
|
||
# update the rapid button label to indicate remaining time
|
||
now = time.time()
|
||
if getattr(self, '_rapid_until', 0) > now:
|
||
remaining = int(getattr(self, '_rapid_until', 0) - now)
|
||
try:
|
||
self.rapid_btn.config(text=f"Rapid ({remaining}s)")
|
||
except Exception:
|
||
pass
|
||
# continue updating until expired
|
||
try:
|
||
self.root.after(500, self._update_rapid_button_ui)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
try:
|
||
self.rapid_btn.config(text="Rapid Poll")
|
||
except Exception:
|
||
pass
|
||
|
||
def poll_gonogo_once(self):
|
||
# Determine what to fetch: top-level mapping or fallback
|
||
mode = self.settings.get("mode", "sheet")
|
||
if mode != "sheet":
|
||
# manual mode: nothing to poll
|
||
return
|
||
# check backoff window
|
||
now = time.time()
|
||
if getattr(self, '_gonogo_backoff_until', 0) > now:
|
||
return
|
||
|
||
cells = self.settings.get("sheet_cells", {})
|
||
url = self.settings.get("sheet_url") or self.csv_link
|
||
|
||
new_vals = None
|
||
# Try mapped fetch first but use a single CSV HTTP request to reduce latency
|
||
if cells and url:
|
||
try:
|
||
cb = int(time.time() * 1000)
|
||
fetch_url = url + ("&" if "?" in url else "?") + f"cb={cb}"
|
||
resp = session.get(fetch_url, timeout=3)
|
||
resp.raise_for_status()
|
||
csv_text = resp.text
|
||
r = parse_csv_and_get_cell(csv_text, cells.get("Range", ""))
|
||
w = parse_csv_and_get_cell(csv_text, cells.get("Weather", ""))
|
||
v = parse_csv_and_get_cell(csv_text, cells.get("Vehicle", ""))
|
||
if r is not None and w is not None and v is not None:
|
||
new_vals = [str(r).upper(), str(w).upper(), str(v).upper()]
|
||
except Exception as e:
|
||
print(f"[WARN] mapped CSV fetch failed: {e}")
|
||
|
||
# If mapped failed or not provided, try CSV fallback (also uses cache-busted URL)
|
||
if new_vals is None:
|
||
csv_vals = fetch_gonogo_csv(url)
|
||
if csv_vals is not None:
|
||
new_vals = csv_vals
|
||
|
||
if new_vals is None:
|
||
# treat as failure: increment failure count and compute backoff
|
||
self._gonogo_failures = getattr(self, '_gonogo_failures', 0) + 1
|
||
# exponential backoff with jitter (seconds)
|
||
backoff = min(60, (2 ** min(self._gonogo_failures, 6)))
|
||
jitter = random.uniform(0, backoff * 0.3)
|
||
wait = backoff + jitter
|
||
self._gonogo_backoff_until = now + wait
|
||
print(f"[WARN] gonogo fetch failed #{self._gonogo_failures}; backing off for {wait:.1f}s")
|
||
# If too many failures, switch to iframe-only mode (less aggressive polling)
|
||
if self._gonogo_failures >= getattr(self, '_gonogo_max_failures', 5):
|
||
print("[WARN] Switching to iframe fallback due to repeated failures")
|
||
embed = ensure_iframe_url(self.settings.get('sheet_url', ''))
|
||
write_gonogo_html_iframe(embed)
|
||
# set longer backoff
|
||
self._gonogo_backoff_until = now + 300
|
||
return
|
||
|
||
# Success: reset failures and apply new values
|
||
self._gonogo_failures = 0
|
||
|
||
# if changed, update cache and write files via main thread
|
||
if new_vals != self.gonogo_values:
|
||
self.gonogo_values = new_vals
|
||
def apply_update():
|
||
write_gonogo_html_from_values(self.gonogo_values)
|
||
self.range_label.config(text=f"RANGE: {self.gonogo_values[0]}", fg=get_status_color(self.gonogo_values[0]))
|
||
self.weather_label.config(text=f"WEATHER: {self.gonogo_values[1]}", fg=get_status_color(self.gonogo_values[1]))
|
||
self.vehicle_label.config(text=f"VEHICLE: {self.gonogo_values[2]}", fg=get_status_color(self.gonogo_values[2]))
|
||
try:
|
||
self.root.after(0, apply_update)
|
||
except Exception:
|
||
# if root is gone, just ignore
|
||
pass
|
||
|
||
# -------------------------
|
||
# Main: splash then launch
|
||
# -------------------------
|
||
if __name__ == "__main__":
|
||
def show_splash_and_start():
|
||
splash = tk.Tk()
|
||
splash.title(f"RocketLaunchCountdown — Initialization {appVersion}")
|
||
splash.config(bg="black")
|
||
splash.geometry("500x200")
|
||
splash.attributes("-topmost", True)
|
||
|
||
tk.Label(splash, text="RocketLaunchCountdown", fg="white", bg="black", font=("Arial", 20, "bold")).pack(pady=(12,4))
|
||
info = tk.Label(splash, text="Loading resources...", fg="#ccc", bg="black", font=("Arial", 12))
|
||
info.pack(pady=6)
|
||
cont_btn = tk.Button(splash, text="Continue", state="disabled", width=16)
|
||
cont_btn.pack(pady=8)
|
||
|
||
init_state = {'done': False, 'error': None}
|
||
|
||
def init_worker():
|
||
try:
|
||
# pre-create files
|
||
s = load_settings()
|
||
# create initial countdown & gonogo files
|
||
write_countdown_html("Placeholder Mission", "T-00:00:00")
|
||
if s.get("mode", "sheet") == "sheet":
|
||
write_gonogo_html_iframe(ensure_iframe_url(s.get("sheet_url", "")))
|
||
else:
|
||
vals = fetch_gonogo_csv(DEFAULT_CSV_LINK)
|
||
write_gonogo_html_from_values(vals)
|
||
init_state['done'] = True
|
||
except Exception as e:
|
||
init_state['error'] = str(e)
|
||
init_state['done'] = True
|
||
|
||
threading.Thread(target=init_worker, daemon=True).start()
|
||
|
||
def check_init():
|
||
if init_state['done']:
|
||
if init_state['error']:
|
||
info.config(text=f"Initialization error: {init_state['error']}")
|
||
cont_btn.config(state="normal")
|
||
else:
|
||
info.config(text="Ready. You may open browser sources now, then click Continue.")
|
||
cont_btn.config(state="normal")
|
||
splash.after(5000, on_continue) # auto-continue after 5s
|
||
return
|
||
splash.after(200, check_init)
|
||
|
||
def on_continue():
|
||
splash.destroy()
|
||
root = tk.Tk()
|
||
app = CountdownApp(root)
|
||
root.mainloop()
|
||
|
||
cont_btn.config(command=on_continue)
|
||
splash.after(200, check_init)
|
||
splash.mainloop()
|
||
|
||
show_splash_and_start()
|