243
background/exemaker.py
Normal file
243
background/exemaker.py
Normal 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
143
background/experiment.py
Normal 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
12
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; }}
|
||||
</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)
|
||||
|
||||
15
readme.md
15
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!
|
||||
Reference in New Issue
Block a user