Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
eb0a1ea
add ethernet connection
Garklein Jan 7, 2025
6e4535b
clean up transitions
Garklein Jan 7, 2025
60d7624
add instructions
Garklein Jan 7, 2025
6537cac
readme typo
Garklein Jan 7, 2025
8e70acd
add tests
Garklein Jan 7, 2025
5cb9a36
clean up state machine code
Garklein Jan 8, 2025
00037b8
clean up code, document, add command to query possible transitions
Garklein Jan 10, 2025
31b918c
add gui, make communications reliable
Garklein Jan 18, 2025
b2c1099
New changes
AnsonMoorey Jul 19, 2025
dc546e2
Added Fuel fill and LOX fill states, also updated python script
WillytheCHEESE Jul 21, 2025
ccec46e
added commments and adjusted logic
WillytheCHEESE Jul 21, 2025
0c29642
increase font size
Garklein Jul 22, 2025
54331c6
oops broke it
Garklein Jul 22, 2025
af175c8
test if gui blocks when arduino delays
Garklein Jul 22, 2025
3a9936a
3 /= 30
Garklein Jul 22, 2025
7d36671
I added the Valve_Testing state and I corrected some of the delays to…
AnsonMoorey Jul 26, 2025
1d96e98
good stuff
AnsonMoorey Jul 29, 2025
5f7eb20
hopefully fix blocking
Garklein Aug 14, 2025
2878308
Changed the GUI
WillytheCHEESE Aug 21, 2025
5d1c5ed
GUI changes
WillytheCHEESE Aug 22, 2025
c454553
changes
WillytheCHEESE Aug 23, 2025
e637cf9
for ethan
WillytheCHEESE Sep 1, 2025
da4b451
better readme
Garklein Sep 1, 2025
e80dfae
LabJack
WillytheCHEESE Sep 5, 2025
2a3deb9
Changes for ethernet
WillytheCHEESE Sep 7, 2025
526caaa
Changes
WillytheCHEESE Sep 24, 2025
0250d4b
update docs
Garklein Sep 28, 2025
550d93f
changes
WillytheCHEESE Oct 18, 2025
37482db
fix: delay the back to back send commands to allow arduino to fully c…
darrenrahnemoon Nov 21, 2025
6aa1c47
some tweaks
darrenrahnemoon Nov 21, 2025
97a953f
fix gitignore
darrenrahnemoon Nov 21, 2025
9288b35
improvement: base station and phil cart restructure
darrenrahnemoon Nov 26, 2025
f2d8e45
improvement: phil cart arduino code rewrite
darrenrahnemoon Nov 26, 2025
99a5a1a
fix: valves header files
darrenrahnemoon Nov 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
.vscode/
.vscode/c_cpp_properties.json
.DS_Store
*.pyc
*.pyo
*.pyd
.venv/
__pycache__/
base_station/logs/client/**/*
!/base_station/logs/client/.gitignore
base_station/logs/labjack/**/*
!/base_station/logs/labjack/.gitignore
3 changes: 3 additions & 0 deletions .vscode/arduino.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"board": "P1AM-100:samd:P1AM-100_native"
}
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"python-envs.pythonProjects": [],
"python.defaultInterpreterPath": "${workspaceFolder}/base_station/.venv/bin/python",
"pylint.interpreter": ["${workspaceFolder}/base_station/.venv/bin/python"]
}
Binary file added __pycache__/sm_eth.cpython-311.pyc
Binary file not shown.
Binary file added __pycache__/sm_eth.cpython-313.pyc
Binary file not shown.
1 change: 1 addition & 0 deletions base_station/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.10
27 changes: 27 additions & 0 deletions base_station/Base Station.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@echo off
setlocal

rem Ensure we run from the directory that contains this script so uv finds the project files.
set "SCRIPT_DIR=%~dp0"
cd /d "%SCRIPT_DIR%"

where uv >nul 2>&1
if errorlevel 1 (
echo uv is not installed or not on PATH. Install it from https://docs.astral.sh/uv/getting-started/installation/
pause
exit /b 1
)

rem Keep a local environment so dependencies (like pandas) persist between runs.
set "UV_PROJECT_ENVIRONMENT=%SCRIPT_DIR%\.venv"
echo Ensuring dependencies are installed (first run may take a minute)...

uv run main.py
set "STATUS=%ERRORLEVEL%"

if not "%STATUS%"=="0" (
echo Base Station exited with status %STATUS%
)

pause
exit /b %STATUS%
15 changes: 15 additions & 0 deletions base_station/BaseStation.command
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
if ! command -v uv >/dev/null 2>&1; then
echo "uv is not installed or not on PATH. Install it from https://docs.astral.sh/uv/getting-started/installation/"
read -p "Press Return to close."
exit 1
fi
uv run main.py
status=$?
if [ $status -ne 0 ]; then
echo "Base Station exited with status $status"
fi
read -p "Press Return to close."
exit $status
11 changes: 11 additions & 0 deletions base_station/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Base Station GUI

## Environment Setup
* Install UV: https://docs.astral.sh/uv/getting-started/installation/
* Install Labjack's library: https://support.labjack.com/docs/ljm-software-installer-downloads-t4-t7-t8-digit
* Run `uv sync` to install all the necessary python dependencies

## Running the App
* Option 1: Run `uv run main.py` in a terminal from the `base_station` folder.
* Option 2: `Base Station.command` (macOS) or `Base Station.bat` (Windows) to launch via `uv` using the local `.venv` and correct import paths.
If macOS blocks it the first time, run `chmod +x "Base Station.command"` and try again.
Binary file added base_station/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
308 changes: 308 additions & 0 deletions base_station/gui/control_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
import tkinter as tk
from tkinter import scrolledtext
import tkinter.font as font
import sys
import time
import datetime
import os
from phil_cart_client import State, STATE_NAMES, UI_STATES, send_async

# --- Configuration ---
# How long to wait for a response before declaring the connection lost (in seconds)
CONNECTION_TIMEOUT = 5.0
FIRE_COUNTDOWN_SECONDS = 10

# A helper class to redirect stdout (general print statements) to the GUI's text widget
class TextRedirector:
def __init__(self, widget, log_method):
self.widget = widget
self.log_method = log_method

def write(self, text):
# Use the GUI's log method to ensure thread-safe updates and proper formatting
self.widget.after(0, self.log_method, text)

def flush(self):
# This is needed for compatibility with the sys.stdout interface.
pass

class ControlPanel:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("State Machine Controller")

# --- Class Members ---
self.buttons = {} # Using a dictionary for easy access
self.last_known_state = -1
self.connection_established = False
self.last_update_time = 0
self.logo_image = None # This will hold the reference to the image object
self.log_file = None # Will hold the file handle for the log file

# --- Countdown specific members ---
self.countdown_job = None
self.countdown_value = 0

# --- Style Configuration ---
font.nametofont("TkDefaultFont").configure(size=14)
self.colors = {
'normal_bg': self.root.cget('bg'),
'highlight_bg': '#a6d8ff', # A light blue for highlighting
'fire_fg': "#e15e25"
}

# --- UI Widgets ---
self.create_widgets()
self.setup_half_screen_layout()

# --- Set up file logging ---
self.setup_file_logging()

# --- Configure Log Tags for Colors ---
bold_font = font.Font(family="Helvetica", size=10, weight="bold")
self.log_viewer.tag_configure("success", foreground="#009900", font=bold_font)
self.log_viewer.tag_configure("error", foreground="red", font=bold_font)

# --- Redirect stdout ---
self.redirector = TextRedirector(self.root, self.log)
sys.stdout = self.redirector
sys.stderr = self.redirector # Also redirect errors

# --- Catch window close event to safely close the log file ---
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)

# --- Start the update loops ---
self.update_status()
self.check_connection()

def create_widgets(self):
# --- LAYOUT: Controls on the right, centered log on the left ---

# --- Right Pane for Controls (packed first to reserve its space) ---
right_frame = tk.Frame(self.root)
right_frame.pack(side=tk.RIGHT, fill='y', padx=(10, 10), pady=10)

# 1. Logo (in the right pane)
try:
self.logo_image = tk.PhotoImage(file="assets/icon.png")
self.logo_image = self.logo_image.subsample(2, 2)
logo_label = tk.Label(right_frame, image=self.logo_image)
logo_label.pack(pady=5)
except tk.TclError:
pass

# --- Dynamic placeholder frame for swapping State and Countdown ---
dynamic_display_frame = tk.Frame(right_frame)
dynamic_display_frame.pack(pady=10, fill='x')

# 2. State Label (placed inside the dynamic frame)
self.state_label = tk.Label(dynamic_display_frame, text="Current state: CONNECTING...", font=('Helvetica', 16, 'bold'))
self.state_label.pack() # Show the state label by default

# 3. Countdown Timer Frame and its labels (created but not packed)
self.countdown_frame = tk.Frame(dynamic_display_frame)
countdown_font = ('Helvetica', 48, 'bold')
fire_in_label = tk.Label(self.countdown_frame, text="Fire in", font=countdown_font, fg=self.colors['fire_fg'])
fire_in_label.pack(side=tk.LEFT, padx=(0, 10))
self.countdown_label = tk.Label(self.countdown_frame, text="", font=countdown_font, fg=self.colors['fire_fg'])
self.countdown_label.pack(side=tk.LEFT)

# 4. Buttons Frame (in the right pane)
button_frame = tk.Frame(right_frame)
button_frame.pack(pady=10, padx=20)

for state in UI_STATES:
text = STATE_NAMES.get(state, "UNKNOWN")

# Special command for the FIRE and ABORT buttons
if state == State.FIRE:
command = self.start_fire_sequence
elif state == State.ABORT:
command = self.send_abort_command
else:
command = lambda s=state: send_async(s.value, lambda resp: None)

btn = tk.Button(button_frame, text=text, command=command, state='disabled')

if state == State.FIRE:
btn.configure(fg=self.colors['fire_fg'], font=('Helvetica', 14, 'bold'))

btn.pack(pady=5, fill='x')
self.buttons[state] = btn

# --- Left Pane for the Log Viewer ---
left_frame = tk.Frame(self.root)
left_frame.pack(side=tk.LEFT, expand=True, fill='both', padx=(10, 0), pady=10)

log_container = tk.Frame(left_frame)
log_container.place(relx=0, rely=0.25, relwidth=1.0, relheight=0.5)

log_label = tk.Label(log_container, text="Log Output:")
log_label.pack(anchor='w')
self.log_viewer = scrolledtext.ScrolledText(log_container, state='disabled', wrap=tk.WORD, width=50)
self.log_viewer.pack(expand=True, fill='both')

# Now that the log viewer definitely exists, log the logo error if it happened.
if not self.logo_image:
self.log("Error: icon.png not in folder.", tags=["error"])

def start_fire_sequence(self):
"""Disables buttons, swaps labels, and starts the countdown."""
self.log(f"FIRE sequence initiated. Countdown from {FIRE_COUNTDOWN_SECONDS}s.")

# Disable all buttons EXCEPT for the ABORT button.
for state, button in self.buttons.items():
if state != State.ABORT:
button.config(state='disabled')

# Swap State Label for Countdown Frame
self.state_label.pack_forget()
self.countdown_frame.pack()

self.countdown_value = FIRE_COUNTDOWN_SECONDS
self.update_countdown()

def update_countdown(self):
"""Recursively updates the countdown timer each second."""
if self.countdown_value > 0:
self.countdown_label.config(text=str(self.countdown_value))
self.countdown_value -= 1
self.countdown_job = self.root.after(1000, self.update_countdown)
else:
self.countdown_label.config(text="0")
self.log("Countdown complete. Sending FIRE command.")
send_async(State.FIRE.value, lambda resp: None)
self.root.after(2000, self.end_fire_sequence)

def end_fire_sequence(self):
"""Hides the countdown and swaps the state label back in."""
if self.countdown_job:
self.root.after_cancel(self.countdown_job)
self.countdown_job = None

# Swap Countdown Frame for State Label
self.countdown_frame.pack_forget()
self.state_label.pack()

def send_abort_command(self):
"""Sends the ABORT command and cancels any ongoing countdown."""
if self.countdown_job:
self.log("FIRE sequence ABORTED by user.", tags=["error"])
self.end_fire_sequence()

self.log("Sending ABORT command.")
send_async(State.ABORT.value, lambda resp: None)

def setup_file_logging(self):
"""--- UPDATED: Creates a timestamped log file inside a 'logs/client' directory. ---"""
try:
log_dir = "logs/client"

# Create the directory if it doesn't exist
if not os.path.exists(log_dir):
os.makedirs(log_dir)
print(f"Created directory: {log_dir}") # This will also go to the log

timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
# Safely join the directory and filename to create the full path
filename = os.path.join(log_dir, f"output_{timestamp}.txt")

self.log_file = open(filename, "w", encoding="utf-8")
# The log message will now show the full path
self.log(f"Logging session to {filename}")
except Exception as e:
self.log(f"Error: Could not create log file/directory. {e}", tags=["error"])

def setup_half_screen_layout(self):
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
width = int(screen_width * 0.7)
height = int(screen_height * 0.9)
x_position = screen_width - width
y_position = 0
self.root.geometry(f"{width}x{height}+{x_position}+{y_position}")

def log(self, message, tags=None):
"""Logs messages to both the GUI's text widget and the text file."""
timestamp = time.strftime("%H:%M:%S")
full_message = f"[{timestamp}] {message.strip()}"
self.log_viewer.configure(state='normal')
if tags: self.log_viewer.insert(tk.END, full_message + "\n", tags)
else: self.log_viewer.insert(tk.END, full_message + "\n")
self.log_viewer.see(tk.END)
self.log_viewer.configure(state='disabled')
if self.log_file:
try:
self.log_file.write(full_message + "\n")
self.log_file.flush()
except Exception as e:
error_msg = f"CRITICAL: Failed to write to log file: {e}"
self.log_viewer.insert(tk.END, error_msg + "\n", ["error"])

def on_closing(self):
"""Handles the window closing event to safely shut down."""
self.log("Application closing...")
if self.log_file: self.log_file.close()
self.root.destroy()

def update_current_state(self, packet):
if not packet: return
self.last_update_time = time.time()
if not self.connection_established:
self.connection_established = True
self.log("Connection established.", tags=["success"])
try:
new_state_val = int(packet[0])
if new_state_val != self.last_known_state:
self.last_known_state = new_state_val
state_name = STATE_NAMES.get(State(new_state_val), 'UNKNOWN')
self.state_label.config(text=f"Current state: {state_name}")
self.log(f"Current state: {state_name}")
self.highlight_current_state()
except (ValueError, IndexError):
self.state_label.config(text="Current state: INVALID")

def highlight_current_state(self):
for state, button in self.buttons.items():
if state.value == self.last_known_state:
button.config(bg=self.colors['highlight_bg'])
else:
button.config(bg=self.colors['normal_bg'])

def update_transitions(self, packet):
if not packet: return
# Do not update buttons if a countdown is in progress
if self.countdown_job: return
try:
valid_transitions = {int(p) for p in packet}
for state, button in self.buttons.items():
if state.value in valid_transitions:
button.config(state='normal')
else:
button.config(state='disabled')
except (ValueError, IndexError):
self.log("Error: Invalid transition data received.", tags=["error"])

def update_status(self):
"""The main loop that periodically fetches status from the device."""
if not self.root: return
send_async(State.GET_STATE.value, self.update_current_state)
self.root.after(500, lambda: send_async(State.GET_TRANSITIONS.value, self.update_transitions))
self.root.after(500, self.update_status)

def check_connection(self):
"""Periodically checks if the connection has timed out."""
if not self.root: return
time_since_last_update = time.time() - self.last_update_time
if self.connection_established and time_since_last_update > CONNECTION_TIMEOUT:
self.connection_established = False
self.last_known_state = -1
self.log("Connection lost.", tags=["error"])
self.state_label.config(text="Current state: DISCONNECTED")
# Cancel countdown on connection loss
if self.countdown_job:
self.log("FIRE sequence cancelled due to connection loss.", tags=["error"])
self.end_fire_sequence()
for button in self.buttons.values():
button.config(state='disabled', bg=self.colors['normal_bg'])
self.root.after(1000, self.check_connection)
Loading