diff --git a/background/experiment.py b/background/experiment.py index 5f88795..60929ae 100644 --- a/background/experiment.py +++ b/background/experiment.py @@ -1,375 +1,143 @@ import tkinter as tk -import time -from datetime import datetime import requests -import csv -import io +import threading +import time +import json -COUNTDOWN_HTML = "countdown.html" -GONOGO_HTML = "gonogo.html" -SHEET_LINK = "https://docs.google.com/spreadsheets/d/1UPJTW8vH2mgEzispjg_Y_zSqYTFaLoxuoZnqleVlSZ0/export?format=csv&gid=855477916" -session = requests.Session() +SETTINGS_FILE = "settings.json" -# ------------------------- -# Fetch Go/No-Go Data -# ------------------------- -def fetch_gonogo(): - """Fetch Go/No-Go parameters from L2, L3, L4 (rows 2,3,4; col 12)""" - try: - resp = session.get(SHEET_LINK, timeout=2) # timeout for faster failure if network is slow - resp.raise_for_status() - reader = csv.reader(io.StringIO(resp.text)) - data = list(reader) - gonogo = [] - for i in [1, 2, 3]: - value = data[i][11] if len(data[i]) > 11 else "N/A" - gonogo.append(value.strip().upper()) # <-- always uppercase - return gonogo - except Exception as e: - print(f"[ERROR] Failed to fetch Go/No-Go: {e}") - return ["ERROR"] * 3 - -# ------------------------- -# Write Countdown HTML -# ------------------------- -def write_countdown_html(mission_name, timer_text): - html = f""" - - - - - - - -
{mission_name}
-
{timer_text}
- -""" - with open(COUNTDOWN_HTML, "w", encoding="utf-8") as f: - f.write(html) - -# ------------------------- -# Write Go/No-Go HTML -# ------------------------- -def write_gonogo_html(gonogo_values=None): - if gonogo_values is None: - gonogo_values = ["N/A", "N/A", "N/A"] - html = f""" - - - - - - - -
-
Range: {gonogo_values[0]}
-
Vehicle: {gonogo_values[2]}
-
Weather: {gonogo_values[1]}
-
- -""" - 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("RocketLaunchCountdown") - self.root.config(bg="black") - self.root.attributes("-topmost", True) + self.root.title("Launch Control - GO/NOGO") - # 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" - # fetch_gonogo() returns [Range, Weather, Vehicle] to match gonogo.html writer - self.gonogo_values = fetch_gonogo() - self.last_gonogo_update = time.time() - - # Display - self.text = tk.Label(root, text="T-00:00:00", font=("Consolas", 80, "bold"), fg="white", bg="black") - self.text.pack(padx=50, pady=20) - - # Mission name 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=20, font=("Arial", 18)) - self.mission_entry.insert(0, self.mission_name) - self.mission_entry.pack(side="left") - - # Mode toggle - frame_mode = tk.Frame(root, bg="black") - frame_mode.pack(pady=5) - self.mode_var = tk.StringVar(value="duration") - self.rb_duration = tk.Radiobutton(frame_mode, text="Duration", variable=self.mode_var, value="duration", - fg="white", bg="black", indicatoron=0, width=12, - command=self.update_inputs) - self.rb_duration.pack(side="left", padx=5) - self.rb_clock = tk.Radiobutton(frame_mode, text="Clock Time", variable=self.mode_var, value="clock", - fg="black", bg="white", indicatoron=0, width=12, - command=self.update_inputs) - self.rb_clock.pack(side="left", padx=5) - - def update_mode_buttons(*args): - val = self.mode_var.get() - if val == 'duration': - self.rb_duration.config(bg='black', fg='white', relief='sunken') - self.rb_clock.config(bg='white', fg='black', relief='raised') - else: - self.rb_duration.config(bg='white', fg='black', relief='raised') - self.rb_clock.config(bg='black', fg='white', relief='sunken') - - self.mode_var.trace_add('write', update_mode_buttons) - update_mode_buttons() - - # 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", 18)) - 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", 18)) - 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", 18)) - self.seconds_entry.insert(0, "0") - self.seconds_entry.pack(side="left", padx=2) - - # Clock time 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", 18)) - 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) - - # Hold and resume share the same position - 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() # hidden at start - - 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) - - frame_gn = tk.Frame(root, bg="black") - frame_gn.pack(pady=10) - # Labels displayed: Range, Weather, Vehicle — match write_gonogo_html ordering - self.range_label = tk.Label(frame_gn, text="RANGE: N/A", font=("Consolas", 20), fg="white", bg="black") - self.range_label.pack() - self.weather_label = tk.Label(frame_gn, text="WEATHER: N/A", font=("Consolas", 20), fg="white", bg="black") - self.weather_label.pack() - self.vehicle_label = tk.Label(frame_gn, text="VEHICLE: N/A", font=("Consolas", 20), fg="white", bg="black") - self.vehicle_label.pack() - - - self.update_inputs() - self.update_clock() - - # ---------------------------- - # Update input visibility based on mode - # ---------------------------- - 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") - - # ---------------------------- - # Control logic - # ---------------------------- - def start(self): - self.mission_name = self.mission_entry.get().strip() or "Placeholder Mission" + self.go_nogo_labels = {} + self.sheet_data = {} + self.last_data = {} self.running = True - self.on_hold = False - self.scrubbed = False - self.counting_up = False - self.show_hold_button() + # Load settings + self.settings = self.load_settings() + + tk.Label(root, text="GO/NOGO STATUS", font=("Arial", 16, "bold")).pack(pady=10) + + # Create display area + self.frame = tk.Frame(root) + self.frame.pack(pady=10) + + # Buttons + tk.Button(root, text="Add Spreadsheet", command=self.add_spreadsheet_window).pack(pady=5) + tk.Button(root, text="Stop", command=self.stop).pack(pady=5) + + self.start_update_thread() + + def load_settings(self): 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() + with open(SETTINGS_FILE, "r") as f: + return json.load(f) + except FileNotFoundError: + return {"spreadsheets": []} + + def save_settings(self): + with open(SETTINGS_FILE, "w") as f: + json.dump(self.settings, f, indent=4) + + def add_spreadsheet_window(self): + win = tk.Toplevel(self.root) + win.title("Add Spreadsheet") + + tk.Label(win, text="Name:").grid(row=0, column=0) + name_entry = tk.Entry(win) + name_entry.grid(row=0, column=1) + + tk.Label(win, text="Link (CSV export or share link):").grid(row=1, column=0) + link_entry = tk.Entry(win, width=60) + link_entry.grid(row=1, column=1) + + tk.Label(win, text="Range cell (e.g., L2):").grid(row=2, column=0) + range_entry = tk.Entry(win) + range_entry.grid(row=2, column=1) + + def save_sheet(): + name = name_entry.get().strip() + link = link_entry.get().strip() + cell = range_entry.get().strip().upper() + if name and link and cell: + self.settings["spreadsheets"].append({ + "name": name, + "link": link, + "cell": cell + }) + self.save_settings() + self.add_go_nogo_label(name) + win.destroy() + + tk.Button(win, text="Save", command=save_sheet).grid(row=3, column=0, columnspan=2, pady=10) + + def add_go_nogo_label(self, name): + if name not in self.go_nogo_labels: + label = tk.Label(self.frame, text=f"{name}: ---", font=("Arial", 14), width=25) + label.pack(pady=2) + self.go_nogo_labels[name] = label + + def update_labels(self): + for sheet in self.settings["spreadsheets"]: + name = sheet["name"] + link = sheet["link"] + cell = sheet["cell"] + + # Convert normal sheet link to CSV export link if needed + if "/edit" in link and "export" not in link: + link = link.split("/edit")[0] + "/gviz/tq?tqx=out:csv" + + try: + r = requests.get(link, timeout=5) + if r.status_code == 200: + content = r.text + if name not in self.last_data or self.last_data[name] != content: + self.last_data[name] = content + # Just read raw content and extract cell text if possible + value = self.extract_cell_value(content, cell) + self.update_label_color(name, value) + except Exception as e: + print(f"Error updating {name}: {e}") + + def extract_cell_value(self, csv_data, cell): + # Simple CSV parser to get cell data like L2 + try: + rows = [r.split(",") for r in csv_data.splitlines() if r.strip()] + col = ord(cell[0]) - 65 + row = int(cell[1:]) - 1 + return rows[row][col].strip().upper() except Exception: - self.text.config(text="Invalid time") - write_countdown_html(self.mission_name, "Invalid time") + return "ERR" + + def update_label_color(self, name, value): + label = self.go_nogo_labels.get(name) + if not label: 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() - - # ---------------------------- - # Clock updating - # ---------------------------- - 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() - - # Update timer - if self.running and not self.scrubbed: - 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" + if "GO" in value: + label.config(text=f"{name}: GO", bg="green", fg="white") + elif "NO" in value: + label.config(text=f"{name}: NO GO", bg="red", fg="white") else: - timer_text = self.text.cget("text") + label.config(text=f"{name}: ---", bg="gray", fg="black") - self.text.config(text=timer_text) - write_countdown_html(self.mission_name, timer_text) + def start_update_thread(self): + threading.Thread(target=self.update_loop, daemon=True).start() - # Update Go/No-Go every 10 seconds - if now_time - self.last_gonogo_update > 0.1: - # fetch_gonogo returns [Range, Weather, Vehicle] - self.range_status, self.weather, self.vehicle = fetch_gonogo() - self.range_label.config(text=f"RANGE: {self.range_status}") - self.weather_label.config(text=f"WEATHER: {self.weather}") - self.vehicle_label.config(text=f"VEHICLE: {self.vehicle}") - self.gonogo_values = [self.range_status, self.weather, self.vehicle] - write_gonogo_html(self.gonogo_values) - self.last_gonogo_update = now_time + def update_loop(self): + while self.running: + self.update_labels() + time.sleep(0.1) - self.root.after(200, self.update_clock) + def stop(self): + self.running = False + self.root.destroy() if __name__ == "__main__": root = tk.Tk() app = CountdownApp(root) - write_countdown_html("Placeholder Mission", "T-00:00:00") - write_gonogo_html(fetch_gonogo()) root.mainloop() diff --git a/main.py b/main.py index 186a319..70122a3 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,10 @@ 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 @@ -17,6 +19,7 @@ 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) @@ -44,6 +47,57 @@ 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) # ------------------------- @@ -53,7 +107,10 @@ def fetch_gonogo_csv(csv_link=DEFAULT_CSV_LINK, timeout=3): Returns [Range, Weather, Vehicle] (strings). """ try: - resp = session.get(csv_link, timeout=timeout) + # 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) @@ -64,17 +121,118 @@ def fetch_gonogo_csv(csv_link=DEFAULT_CSV_LINK, timeout=3): gonogo.append(value.strip().upper()) return gonogo except Exception as e: - print(f"[ERROR] Failed to fetch Go/No-Go CSV: {e}") - return ["N/A", "N/A", "N/A"] + 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//gviz/tq?tqx=out:csv&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): - status = (status or "").strip().upper() - if status == "GO": return "green" - if status == "NOGO" or status == "NOGO" or status == "NOGO": return "red" - return "white" + try: + return "green" if str(status).strip().upper() == "GO" else "red" + except Exception: + return "white" def ensure_iframe_url(url): """ @@ -131,10 +289,14 @@ def write_gonogo_html_from_values(values): """ # ensure three items: vals = (values + ["N/A","N/A","N/A"])[:3] + ts = int(time.time() * 1000) html = f""" + + + + +
Range: {vals[0]}
Vehicle: {vals[2]}
@@ -172,8 +337,16 @@ setTimeout(()=>location.reload(),5000);
""" - with open(GONOGO_HTML, "w", encoding="utf-8") as f: + # 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): """ @@ -181,14 +354,20 @@ def write_gonogo_html_iframe(sheet_embed_url): 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 = "
No sheet URL set in settings
" else: - content = f'' + # add cache-busting query param so iframe reloads when we update the file + glue = '&' if '?' in sheet_embed_url else '?' + content = f'' html = f""" + + + +
{content}
""" - with open(GONOGO_HTML, "w", encoding="utf-8") as f: + 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 @@ -330,6 +516,15 @@ class CountdownApp: 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) @@ -351,11 +546,63 @@ class CountdownApp: 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") + font=("Consolas", 12), fg="black", bg="white") self.footer_label.pack(fill="x") # initialize visibility + values @@ -375,6 +622,38 @@ class CountdownApp: # 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") @@ -395,18 +674,53 @@ class CountdownApp: 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 CSV fetch if possible - vals = fetch_gonogo_csv(self.csv_link) + # 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])) @@ -573,28 +887,194 @@ class CountdownApp: self.text.config(text=timer_text) write_countdown_html(self.mission_name, timer_text) - # Update gonogo block depending on settings.mode - mode = self.settings.get("mode", "sheet") - if mode == "sheet": - # ensure embed is current - embed = ensure_iframe_url(self.settings.get("sheet_url", "")) - write_gonogo_html_iframe(embed) - # optionally update mirrored labels by fetching CSV (best-effort) - 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 mode: write gonogo based on manual toggles and update labels - vals = [self.manual_range, self.manual_weather, self.manual_vehicle] - write_gonogo_html_from_values(vals) - 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])) + # 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 # ------------------------- @@ -640,6 +1120,7 @@ if __name__ == "__main__": 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)