Merge pull request #11 from HamsterSpaceNerd3000/dev

Dev
This commit is contained in:
HamsterSpaceNerd3000
2025-11-14 21:15:14 -05:00
committed by GitHub
4 changed files with 403 additions and 10 deletions

243
background/exemaker.py Normal file
View File

@@ -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()

143
background/experiment.py Normal file
View File

@@ -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()

12
main.py
View File

@@ -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; }}
</style>
<script>
setTimeout(() => location.reload(), 1000);
setTimeout(() => {{
location.href = location.href.split('?')[0] + '?t=' + Date.now();
}}, 1000);
</script>
</head>
<body>
@@ -303,7 +305,9 @@ body {{
.nogo {{ color: {gn_nogo}; }}
</style>
<script>
setTimeout(() => location.reload(), 5000);
setTimeout(() => {{
location.href = location.href.split('?')[0] + '?t=' + Date.now();
}}, 5000);
</script>
</head>
<body>
@@ -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)

View File

@@ -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!