diff --git a/background/exemaker.py b/background/exemaker.py new file mode 100644 index 0000000..4c1d334 --- /dev/null +++ b/background/exemaker.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Simple Tkinter GUI to run PyInstaller and make an exe from a .py file. + +Features: +- choose script +- choose onefile / dir +- choose windowed (noconsole) +- add icon +- add additional data (file or folder; multiple, separated by semicolons) +- set output folder +- show live PyInstaller output +- cancel build +""" + +import tkinter as tk +from tkinter import filedialog, messagebox, scrolledtext +import subprocess +import threading +import sys +import shlex +import os +from pathlib import Path + +class BuilderGUI(tk.Tk): + def __init__(self): + super().__init__() + self.title("Python → EXE (PyInstaller GUI)") + self.geometry("800x600") + self.create_widgets() + self.proc = None # subprocess handle + self.stop_requested = False + + def create_widgets(self): + frame_top = tk.Frame(self) + frame_top.pack(fill="x", padx=10, pady=8) + + tk.Label(frame_top, text="Script:").grid(row=0, column=0, sticky="e") + self.script_entry = tk.Entry(frame_top, width=70) + self.script_entry.grid(row=0, column=1, padx=6) + tk.Button(frame_top, text="Browse...", command=self.browse_script).grid(row=0, column=2) + + tk.Label(frame_top, text="Output folder:").grid(row=1, column=0, sticky="e") + self.out_entry = tk.Entry(frame_top, width=70) + self.out_entry.grid(row=1, column=1, padx=6) + tk.Button(frame_top, text="Choose...", command=self.choose_output).grid(row=1, column=2) + + opts_frame = tk.LabelFrame(self, text="Options", padx=8, pady=8) + opts_frame.pack(fill="x", padx=10) + + self.onefile_var = tk.BooleanVar(value=True) + tk.Checkbutton(opts_frame, text="Onefile (single exe)", variable=self.onefile_var).grid(row=0, column=0, sticky="w", padx=6, pady=2) + + self.windowed_var = tk.BooleanVar(value=False) + tk.Checkbutton(opts_frame, text="Windowed (no console) / --noconsole", variable=self.windowed_var).grid(row=0, column=1, sticky="w", padx=6, pady=2) + + tk.Label(opts_frame, text="Icon (.ico):").grid(row=1, column=0, sticky="e") + self.icon_entry = tk.Entry(opts_frame, width=50) + self.icon_entry.grid(row=1, column=1, sticky="w", padx=6) + tk.Button(opts_frame, text="Browse", command=self.browse_icon).grid(row=1, column=2) + + tk.Label(opts_frame, text="Additional data (src;dest pairs separated by ';', e.g. resources;resources):").grid(row=2, column=0, columnspan=3, sticky="w", pady=(6,0)) + self.data_entry = tk.Entry(opts_frame, width=110) + self.data_entry.grid(row=3, column=0, columnspan=3, padx=6, pady=4) + + tk.Label(opts_frame, text="Extra PyInstaller args:").grid(row=4, column=0, sticky="w") + self.extra_entry = tk.Entry(opts_frame, width=80) + self.extra_entry.grid(row=4, column=1, columnspan=2, padx=6, pady=4, sticky="w") + + run_frame = tk.Frame(self) + run_frame.pack(fill="x", padx=10, pady=8) + + self.build_btn = tk.Button(run_frame, text="Build EXE", command=self.start_build, bg="#2b7a78", fg="white") + self.build_btn.pack(side="left", padx=(0,6)) + + self.cancel_btn = tk.Button(run_frame, text="Cancel", command=self.request_cancel, state="disabled", bg="#b00020", fg="white") + self.cancel_btn.pack(side="left") + + clear_btn = tk.Button(run_frame, text="Clear Log", command=self.clear_log) + clear_btn.pack(side="left", padx=6) + + open_out_btn = tk.Button(run_frame, text="Open Output Folder", command=self.open_output) + open_out_btn.pack(side="right") + + self.log = scrolledtext.ScrolledText(self, height=18, font=("Consolas", 10)) + self.log.pack(fill="both", expand=True, padx=10, pady=(0,10)) + + def browse_script(self): + path = filedialog.askopenfilename(filetypes=[("Python files", "*.py")]) + if path: + self.script_entry.delete(0, tk.END) + self.script_entry.insert(0, path) + # default output to script parent /dist + parent = os.path.dirname(path) + default_out = os.path.join(parent, "dist") + self.out_entry.delete(0, tk.END) + self.out_entry.insert(0, default_out) + + def choose_output(self): + path = filedialog.askdirectory() + if path: + self.out_entry.delete(0, tk.END) + self.out_entry.insert(0, path) + + def browse_icon(self): + path = filedialog.askopenfilename(filetypes=[("Icon files", "*.ico")]) + if path: + self.icon_entry.delete(0, tk.END) + self.icon_entry.insert(0, path) + + def clear_log(self): + self.log.delete("1.0", tk.END) + + def open_output(self): + out = self.out_entry.get().strip() + if not out: + messagebox.showinfo("Output folder", "No output folder set.") + return + os.startfile(out) if os.name == "nt" else subprocess.run(["xdg-open", out]) + + def request_cancel(self): + if self.proc and self.proc.poll() is None: + self.stop_requested = True + # try terminate politely + try: + self.proc.terminate() + except Exception: + pass + self.log_insert("\nCancellation requested...\n") + self.cancel_btn.config(state="disabled") + + def log_insert(self, text): + self.log.insert(tk.END, text) + self.log.see(tk.END) + + def start_build(self): + script = self.script_entry.get().strip() + if not script or not os.path.isfile(script): + messagebox.showerror("Error", "Please choose a valid Python script to build.") + return + + out_dir = self.out_entry.get().strip() or os.path.dirname(script) + os.makedirs(out_dir, exist_ok=True) + + self.build_btn.config(state="disabled") + self.cancel_btn.config(state="normal") + self.stop_requested = False + self.clear_log() + + # Start build on a thread to keep UI responsive + thread = threading.Thread(target=self.run_pyinstaller, args=(script, out_dir), daemon=True) + thread.start() + + def run_pyinstaller(self, script, out_dir): + # Build the PyInstaller command + cmd = [sys.executable, "-m", "PyInstaller"] + + if self.onefile_var.get(): + cmd.append("--onefile") + else: + cmd.append("--onedir") + + if self.windowed_var.get(): + cmd.append("--noconsole") + + icon = self.icon_entry.get().strip() + if icon: + cmd.extend(["--icon", icon]) + + # add additional data: user can provide pairs like "data;data" or "assets;assets" + data_spec = self.data_entry.get().strip() + if data_spec: + # support multiple separated by semicolons or vertical bars + pairs = [p for p in (data_spec.split(";") + data_spec.split("|")) if p.strip()] + # normalize pairs to PyInstaller format: src;dest (on Windows use ';' in CLI but PyInstaller expects src;dest as single argument) + for p in pairs: + # if user typed "src:dest" or "src->dest", replace with semicolon + p_fixed = p.replace(":", ";").replace("->", ";") + cmd.extend(["--add-data", p_fixed]) + + # user extra args (raw) + extra = self.extra_entry.get().strip() + if extra: + # split carefully + cmd.extend(shlex.split(extra)) + + # ensure output path goes to chosen dir: use --distpath + cmd.extend(["--distpath", out_dir]) + + # entry script + cmd.append(script) + + self.log_insert("Running PyInstaller with command:\n" + " ".join(shlex.quote(c) for c in cmd) + "\n\n") + + # spawn the process + try: + self.proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + universal_newlines=True, + ) + except Exception as e: + self.log_insert(f"Failed to start PyInstaller: {e}\n") + self.build_btn.config(state="normal") + self.cancel_btn.config(state="disabled") + return + + # Stream output line by line + try: + for line in self.proc.stdout: + if line: + self.log_insert(line) + if self.stop_requested: + try: + self.proc.terminate() + except Exception: + pass + break + self.proc.wait(timeout=30) + except subprocess.TimeoutExpired: + self.log_insert("Process did not exit in time after termination.\n") + except Exception as e: + self.log_insert(f"Error while running PyInstaller: {e}\n") + + retcode = self.proc.returncode if self.proc else None + if self.stop_requested: + self.log_insert("\nBuild cancelled by user.\n") + elif retcode == 0: + self.log_insert("\nBuild finished successfully.\n") + else: + self.log_insert(f"\nBuild finished with return code {retcode}.\n") + + # Re-enable buttons + self.build_btn.config(state="normal") + self.cancel_btn.config(state="disabled") + self.proc = None + self.stop_requested = False + +if __name__ == "__main__": + app = BuilderGUI() + app.mainloop() diff --git a/background/experiment.py b/background/experiment.py new file mode 100644 index 0000000..60929ae --- /dev/null +++ b/background/experiment.py @@ -0,0 +1,143 @@ +import tkinter as tk +import requests +import threading +import time +import json + +SETTINGS_FILE = "settings.json" + +class CountdownApp: + def __init__(self, root): + self.root = root + self.root.title("Launch Control - GO/NOGO") + + self.go_nogo_labels = {} + self.sheet_data = {} + self.last_data = {} + self.running = True + + # 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: + 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: + return "ERR" + + def update_label_color(self, name, value): + label = self.go_nogo_labels.get(name) + if not label: + return + + 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: + label.config(text=f"{name}: ---", bg="gray", fg="black") + + def start_update_thread(self): + threading.Thread(target=self.update_loop, daemon=True).start() + + def update_loop(self): + while self.running: + self.update_labels() + time.sleep(0.1) + + def stop(self): + self.running = False + self.root.destroy() + + +if __name__ == "__main__": + root = tk.Tk() + app = CountdownApp(root) + root.mainloop() diff --git a/main.py b/main.py index f7247f3..1d3de91 100644 --- a/main.py +++ b/main.py @@ -27,7 +27,7 @@ COUNTDOWN_HTML = os.path.join(app_folder, "countdown.html") GONOGO_HTML = os.path.join(app_folder, "gonogo.html") SHEET_LINK = "" session = requests.Session() -appVersion = "0.6.0" +appVersion = "0.6.1" SETTINGS_FILE = os.path.join(app_folder, "settings.json") # Default settings @@ -237,7 +237,9 @@ body {{ #timer {{ font-size: {timer_px}px; margin-bottom: 40px; }}
@@ -303,7 +305,9 @@ body {{ .nogo {{ color: {gn_nogo}; }} @@ -642,8 +646,6 @@ class CountdownApp: ) self.settings_btn.grid(row=0, column=4, padx=6) - # Note: gonogo mode switching remains in Settings; manual buttons appear when mode == 'buttons' - # Manual Go/No-Go buttons will go next to control buttons self.manual_frame = tk.Frame(root, bg="black") self.manual_frame.pack(pady=6) diff --git a/readme.md b/readme.md index 589dc1c..847e075 100644 --- a/readme.md +++ b/readme.md @@ -1,13 +1,18 @@ RocketLaunchCountdown is a python and HTML system to operate a launch countdown and go/nogo indicator. The go/nogo indicator is operated either by a spreadsheet, using a sharelink inserted in the settings windows. Or there are buttons in the app that can manage it as well. Which mode is used is dictated by a simple radio button in the settings window. -Latest release: [RocketLaunchCountdown 0.6.0](https://github.com/HamsterSpaceNerd3000/RocketLaunchCountdown/releases/tag/v0.6.0) +Latest release: [RocketLaunchCountdown 0.6.1](https://github.com/HamsterSpaceNerd3000/RocketLaunchCountdown/releases/tag/v0.6.1) Features Added: - T-/L- Switching has been added. - Light/Dark mode no longer effects the HTML files. - Added ability to remove mission name from the HTML output. - Created/added app icon (will be added to the window in future version, currently on desktop shortcut) + Fixed Browser Source Cache Issue (More Info Below as OBS changes need to be made) + Optimizations +OBS INSTRUCTIONS +For setting up the HTMLs for use in OBS. Do the following: +1. Create a browser source +2. Select "Local file" +3. Select html file +4. Check both "Shutdown source when not visible" and "Refresh browser when scene becomes active" + INSTALL INSTRUCTIONS Install is simple, download the installer, run it, and wham bam dans the man, you got a countdown manager! \ No newline at end of file