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)