569 lines
24 KiB
Python
569 lines
24 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
from PIL import Image, ImageTk
|
|
import json
|
|
import os
|
|
from datetime import datetime, date
|
|
from plant_model import PlantGrowthModel
|
|
from data_handler import DataHandler
|
|
from tkcalendar import DateEntry, Calendar
|
|
from plant_meteo import HappyMeteo
|
|
|
|
class PlantGrowthDashboard:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("WeGrow")
|
|
self.root.geometry("1000x800") # More square dimensions
|
|
self.root.configure(bg='#f0f0f0')
|
|
|
|
image = Image.open("public/logoTransparent.png")
|
|
|
|
desired_size = (128, 128)
|
|
image = image.resize(desired_size, Image.Resampling.LANCZOS)
|
|
|
|
# Convert to PhotoImage
|
|
icon = ImageTk.PhotoImage(image)
|
|
# Set as window icon
|
|
self.root.iconphoto(False, icon)
|
|
|
|
# Initialize components
|
|
self.plant_model = PlantGrowthModel()
|
|
self.data_handler = DataHandler()
|
|
self.happyMeteo = HappyMeteo()
|
|
|
|
# Variables - fixed plant type
|
|
self.current_plant = "tomato" # Fixed plant type
|
|
self.counter = 0
|
|
self.filenames = ["basilico.jpg", "pomodoro.png"]
|
|
self.ambient_mode = tk.StringVar(value="controlled")
|
|
self.baseline_image_path = None
|
|
|
|
# Environmental parameters with defaults
|
|
self.default_params = {
|
|
'temperature': 22.0,
|
|
'humidity': 65.0,
|
|
'soil_acidity': 6.5,
|
|
'pressure': 1013.25,
|
|
'brightness': 30,
|
|
'nutrients': 75.0,
|
|
'water': 80.0,
|
|
'co2': 850
|
|
}
|
|
|
|
self.env_params = {
|
|
'temperature': tk.DoubleVar(value=self.default_params['temperature']),
|
|
'humidity': tk.DoubleVar(value=self.default_params['humidity']),
|
|
'soil_acidity': tk.DoubleVar(value=self.default_params['soil_acidity']),
|
|
'pressure': tk.DoubleVar(value=self.default_params['pressure']),
|
|
'brightness': tk.DoubleVar(value=self.default_params['brightness']),
|
|
'nutrients': tk.DoubleVar(value=self.default_params['nutrients']),
|
|
'water': tk.DoubleVar(value=self.default_params['water']),
|
|
'co2': tk.DoubleVar(value=self.default_params['co2'])
|
|
}
|
|
|
|
self.setup_ui()
|
|
|
|
def setup_ui(self):
|
|
# Main container with square layout
|
|
main_frame = ttk.Frame(self.root, padding="8")
|
|
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
# Configure grid weights for square layout
|
|
self.root.columnconfigure(0, weight=1)
|
|
self.root.rowconfigure(0, weight=1)
|
|
main_frame.columnconfigure(0, weight=3)
|
|
main_frame.columnconfigure(1, weight=1) # Center panel wider
|
|
main_frame.columnconfigure(2, weight=1)
|
|
main_frame.rowconfigure(1, weight=1)
|
|
|
|
# Title
|
|
title_label = ttk.Label(main_frame, text="🌱 Plant Growth Dashboard",
|
|
font=('Arial', 14, 'bold'))
|
|
title_label.grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
|
|
|
# Left panel - Controls
|
|
self.setup_control_panel(main_frame)
|
|
|
|
# Center panel - Plant Visualization
|
|
self.setup_visualization_panel(main_frame)
|
|
|
|
# Right panel - Results only (no system messages)
|
|
self.setup_results_panel(main_frame)
|
|
|
|
def setup_control_panel(self, parent):
|
|
control_frame = ttk.LabelFrame(parent, text="Environmental Controls", padding="6")
|
|
control_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 6))
|
|
|
|
# Ambient mode
|
|
ttk.Label(control_frame, text="Environment Mode:", font=('Arial', 9, 'bold')).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 6))
|
|
|
|
mode_frame = ttk.Frame(control_frame)
|
|
mode_frame.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 8))
|
|
|
|
ttk.Radiobutton(mode_frame, text="Controlled", variable=self.ambient_mode,
|
|
value="controlled", command=self.on_mode_change).pack(anchor=tk.W)
|
|
ttk.Radiobutton(mode_frame, text="Open", variable=self.ambient_mode,
|
|
value="open", command=self.on_mode_change).pack(anchor=tk.W)
|
|
|
|
# Baseline image
|
|
ttk.Button(control_frame, text="📷 Load Plant Image",
|
|
command=self.load_baseline_image).grid(row=2, column=0, columnspan=2, pady=(0, 10), sticky=(tk.W, tk.E))
|
|
|
|
ttk.Label(control_frame, text="Parameters:",
|
|
font=('Arial', 9, 'bold')).grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=(0, 4))
|
|
|
|
param_labels = {
|
|
'temperature': '🌡️ Temp (°C)',
|
|
'humidity': '💧 Humidity (%)',
|
|
'soil_acidity': '🧪 (pH)',
|
|
'pressure': '🌬️ Pressure (Pa)',
|
|
'brightness': '☀️ Light (DLI)',
|
|
'nutrients': '🌿 Nutrients (%)',
|
|
'water': '💦 Water (%)',
|
|
'co2': '🫧 CO2 (ppm)'
|
|
}
|
|
|
|
# Define bounds for each parameter (min, max)
|
|
param_bounds = {
|
|
'temperature': (10, 40),
|
|
'humidity': (20, 90),
|
|
'soil_acidity': (4.0, 9.0),
|
|
'pressure': (950, 1100),
|
|
'brightness': (5, 50),
|
|
'nutrients': (0, 100),
|
|
'water': (10, 100),
|
|
'co2': (400, 1200)
|
|
}
|
|
|
|
row = 4
|
|
for param, label in param_labels.items():
|
|
# Compact parameter layout
|
|
param_frame = ttk.Frame(control_frame)
|
|
param_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=1)
|
|
|
|
ttk.Label(param_frame, text=label, width=11, font=('Arial', 8)).pack(side=tk.LEFT)
|
|
|
|
# Get bounds for this parameter
|
|
scale_min, scale_max = param_bounds.get(param, (0, 100))
|
|
|
|
scale = ttk.Scale(param_frame, from_=scale_min, to=scale_max,
|
|
variable=self.env_params[param], orient=tk.HORIZONTAL,
|
|
command=lambda x, p=param: self.on_param_change(p))
|
|
scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 4))
|
|
|
|
setattr(self, f"{param}_scale", scale)
|
|
|
|
# Value label
|
|
value_label = ttk.Label(param_frame, text=f"{self.env_params[param].get():.1f}", width=5, font=('Arial', 8))
|
|
value_label.pack(side=tk.RIGHT)
|
|
|
|
# Store reference for updates
|
|
setattr(self, f"{param}_value_label", value_label)
|
|
|
|
row += 1
|
|
|
|
# Set to Default button
|
|
ttk.Button(control_frame, text="🔄 Set to Default",
|
|
command=self.set_to_default).grid(row=row, column=0, columnspan=2, pady=(10, 0), sticky=(tk.W, tk.E))
|
|
|
|
row += 1
|
|
|
|
# ADD EMPTY SPACER
|
|
spacer = ttk.Label(control_frame, text="")
|
|
spacer.grid(row=row, column=0, columnspan=2, pady=10)
|
|
|
|
# Add this after the parameters section
|
|
ttk.Label(control_frame, text="Final date of growth (choose a date):",
|
|
font=('Arial', 9, 'bold')).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=(10, 4))
|
|
|
|
row += 1
|
|
# Compact date entry with calendar popup
|
|
# To get the selected dat simply call self.calendar.get_date()
|
|
# self.date_entry
|
|
self.calendar = Calendar(control_frame, selectmode='day',
|
|
year=2025, month=8, day=1,
|
|
font=('Arial', 8))
|
|
self.calendar.grid(row=row, column=0, columnspan=2, pady=2)
|
|
|
|
row += 1
|
|
|
|
control_frame.columnconfigure(0, weight=1)
|
|
control_frame.columnconfigure(1, weight=1)
|
|
|
|
def disable_parameter(self, param):
|
|
scale = getattr(self, f"{param}_scale", None)
|
|
if scale:
|
|
scale.configure(state='disabled')
|
|
|
|
def enable_parameter(self, param):
|
|
scale = getattr(self, f"{param}_scale", None)
|
|
if scale:
|
|
scale.configure(state='normal')
|
|
|
|
def setup_visualization_panel(self, parent):
|
|
viz_frame = ttk.LabelFrame(parent, text="Plant Visualization", padding="6")
|
|
viz_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=3)
|
|
|
|
# Notebook for different views
|
|
notebook = ttk.Notebook(viz_frame)
|
|
notebook.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
# Plant Before Growth tab
|
|
self.setup_before_growth_tab(notebook)
|
|
|
|
# Plant Evolution tab
|
|
self.setup_plant_evolution_tab(notebook)
|
|
|
|
viz_frame.columnconfigure(0, weight=1)
|
|
viz_frame.rowconfigure(0, weight=1)
|
|
|
|
def setup_before_growth_tab(self, notebook):
|
|
"""Tab showing the plant before growth starts"""
|
|
before_frame = ttk.Frame(notebook)
|
|
notebook.add(before_frame, text="🌱 Initial Plant")
|
|
|
|
# Create a frame for the initial plant display
|
|
display_frame = ttk.Frame(before_frame)
|
|
display_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
|
|
|
|
# Title for initial state
|
|
title_label = ttk.Label(display_frame, text="Plant Initial State",
|
|
font=('Arial', 11, 'bold'))
|
|
title_label.pack(pady=(0, 8))
|
|
|
|
# Frame for the plant image
|
|
self.initial_plant_frame = ttk.Frame(display_frame)
|
|
self.initial_plant_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Initial plant image label
|
|
self.initial_plant_label = ttk.Label(self.initial_plant_frame,
|
|
text="Initial plant state will appear here",
|
|
font=('Arial', 9))
|
|
self.initial_plant_label.pack(expand=True)
|
|
|
|
# Plant info display
|
|
info_frame = ttk.LabelFrame(display_frame, text="Plant Information", padding="4")
|
|
info_frame.pack(fill=tk.X, pady=(8, 0))
|
|
|
|
self.plant_info_text = tk.Text(info_frame, height=8, width=35, wrap=tk.WORD,
|
|
font=('Arial', 8), bg="#000000", fg="white")
|
|
self.plant_info_text.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Submit button
|
|
submit_frame = ttk.Frame(display_frame)
|
|
submit_frame.pack(fill=tk.X, pady=(8, 0))
|
|
|
|
ttk.Button(submit_frame, text="📤 Submit Plant Information & Photo",
|
|
command=self.submit_plant_data).pack(fill=tk.X)
|
|
|
|
def setup_plant_evolution_tab(self, notebook):
|
|
"""Evolution tab with single image display"""
|
|
evolution_frame = ttk.Frame(notebook)
|
|
notebook.add(evolution_frame, text="🌿 Growth Evolution")
|
|
|
|
# Create main container for image display
|
|
self.image_display_frame = ttk.Frame(evolution_frame)
|
|
self.image_display_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
# Create label for image or fallback text
|
|
self.evolution_image_label = ttk.Label(
|
|
self.image_display_frame,
|
|
text="Plant state after the prediction will appear here",
|
|
font=('Arial', 12),
|
|
foreground='gray',
|
|
anchor='center'
|
|
)
|
|
self.evolution_image_label.pack(expand=True)
|
|
|
|
def update_evolution_image(self, filename=None):
|
|
"""Update the evolution tab with an image from file or show fallback text"""
|
|
if filename and os.path.exists(filename):
|
|
try:
|
|
print(filename)
|
|
# Open and resize image if needed
|
|
pil_image = Image.open(filename)
|
|
# Optional: resize to fit the display area
|
|
pil_image = pil_image.resize((400, 300), Image.Resampling.LANCZOS)
|
|
|
|
# Convert to PhotoImage for tkinter
|
|
photo_image = ImageTk.PhotoImage(pil_image)
|
|
|
|
# Display the image
|
|
self.evolution_image_label.config(image=photo_image, text="")
|
|
self.evolution_image_label.image = photo_image # Keep reference to prevent garbage collection
|
|
|
|
except Exception as e:
|
|
print(f"Error loading image {filename}: {e}")
|
|
# Show fallback text on error
|
|
self.evolution_image_label.config(
|
|
image="",
|
|
text="Plant state after the prediction will appear here"
|
|
)
|
|
else:
|
|
# Show fallback text when no filename or file doesn't exist
|
|
self.evolution_image_label.config(
|
|
image="",
|
|
text="Plant state after the prediction will appear here"
|
|
)
|
|
|
|
def setup_results_panel(self, parent):
|
|
results_frame = ttk.LabelFrame(parent, text="Growth Prediction", padding="6")
|
|
results_frame.grid(row=1, column=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(6, 0))
|
|
|
|
# Prediction results only (no system messages)
|
|
ttk.Label(results_frame, text="Forecast Results:",
|
|
font=('Arial', 9, 'bold')).grid(row=0, column=0, sticky=tk.W, pady=(0, 4))
|
|
|
|
self.results_text = tk.Text(results_frame, height=10, width=26, wrap=tk.WORD, font=('Arial', 8), state='disabled')
|
|
self.results_text.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
img = Image.open("public/TransparentFlower.png")
|
|
|
|
# Resize if needed
|
|
img = img.resize((180, 290), Image.Resampling.LANCZOS)
|
|
photo = ImageTk.PhotoImage(img)
|
|
|
|
static_image_label = ttk.Label(results_frame, image=photo)
|
|
static_image_label.image = photo # Keep reference
|
|
static_image_label.grid(row=2, column=0, pady=(6, 0))
|
|
|
|
results_frame.columnconfigure(0, weight=1)
|
|
results_frame.rowconfigure(1, weight=1)
|
|
|
|
def on_mode_change(self):
|
|
#KEY FOCUS IS TO IMPLEMENT THIS PART
|
|
"""Handle mode changes"""
|
|
current_mode = self.ambient_mode.get()
|
|
|
|
if current_mode == "controlled":
|
|
print("Switched to Controlled mode")
|
|
# Enable all parameter controls
|
|
self.enable_parameter("humidity")
|
|
self.enable_parameter("brightness")
|
|
self.enable_parameter("temperature")
|
|
|
|
# No need to call the meteo api
|
|
|
|
elif current_mode == "open":
|
|
print("Switched to Open mode")
|
|
# Disable most parameter controls (temp, humidity, light)
|
|
self.disable_parameter("humidity")
|
|
self.disable_parameter("brightness")
|
|
self.disable_parameter("temperature")
|
|
|
|
def on_param_change(self, param):
|
|
value = self.env_params[param].get()
|
|
|
|
# Update value label
|
|
value_label = getattr(self, f"{param}_value_label")
|
|
if param == 'soil_acidity':
|
|
value_label.config(text=f"{value:.1f}")
|
|
elif param in ['brightness', 'pressure', 'co2']:
|
|
value_label.config(text=f"{value:.0f}")
|
|
else:
|
|
value_label.config(text=f"{value:.1f}")
|
|
|
|
def set_to_default(self):
|
|
"""Reset all parameters to default values"""
|
|
for param_name, default_value in self.default_params.items():
|
|
self.env_params[param_name].set(default_value)
|
|
self.update_parameter_label(param_name, default_value)
|
|
|
|
self.ambient_mode.set("controlled")
|
|
|
|
self.calendar.selection_set(date.today())
|
|
|
|
def update_parameter_label(self, param, value):
|
|
"""Update the value label for a specific parameter"""
|
|
try:
|
|
# Get the value label for this parameter
|
|
value_label = getattr(self, f"{param}_value_label")
|
|
|
|
# Format the value based on parameter type
|
|
if param == 'soil_acidity':
|
|
value_label.config(text=f"{value:.1f}")
|
|
elif param in ['brightness', 'pressure', 'co2']:
|
|
value_label.config(text=f"{value:.0f}")
|
|
else:
|
|
value_label.config(text=f"{value:.1f}")
|
|
except AttributeError:
|
|
# Handle case where label doesn't exist
|
|
print(f"Warning: No label found for parameter {param}")
|
|
|
|
def load_baseline_image(self):
|
|
self.results_text.delete(1.0, tk.END)
|
|
self.set_to_default()
|
|
|
|
file_path = filedialog.askopenfilename(
|
|
title="Select baseline plant image",
|
|
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
|
|
)
|
|
if file_path:
|
|
self.baseline_image_path = file_path
|
|
self.update_initial_plant_display()
|
|
|
|
def submit_plant_data(self):
|
|
"""Submit plant information and photo"""
|
|
try:
|
|
start_date = datetime.now().date()
|
|
|
|
# Fix: Convert calendar date string to date object
|
|
calendar_date = self.calendar.get_date()
|
|
if isinstance(calendar_date, str):
|
|
# Parse the string date (assuming format like "2025-08-02" or "02/08/2025")
|
|
try:
|
|
if '/' in calendar_date:
|
|
# Handle DD/MM/YYYY format
|
|
end_date = datetime.strptime(calendar_date, '%d/%m/%Y').date()
|
|
else:
|
|
# Handle YYYY-MM-DD format
|
|
end_date = datetime.strptime(calendar_date, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
# Fallback: try different formats
|
|
for fmt in ['%d/%m/%Y', '%Y-%m-%d', '%m/%d/%Y']:
|
|
try:
|
|
end_date = datetime.strptime(calendar_date, fmt).date()
|
|
break
|
|
except ValueError:
|
|
continue
|
|
else:
|
|
# If all formats fail, use today
|
|
end_date = datetime.now().date()
|
|
else:
|
|
# It's already a date object
|
|
end_date = calendar_date
|
|
|
|
time_lapse = end_date - start_date
|
|
days_difference = time_lapse.days
|
|
|
|
params = {param: var.get() for param, var in self.env_params.items()}
|
|
params['plant_type'] = self.current_plant
|
|
params['ambient_mode'] = self.ambient_mode.get()
|
|
|
|
current_mode = self.ambient_mode.get()
|
|
happy_data = None # Initialize to None instead of 0
|
|
|
|
if current_mode == "open":
|
|
happy_data = self.happyMeteo.openMeteoCall(days_difference)
|
|
|
|
# Filter out excluded parameters for open mode
|
|
excluded_params = {"humidity", "temperature", "brightness"}
|
|
params = {param: var.get() for param, var in self.env_params.items()
|
|
if param not in excluded_params}
|
|
# Re-add the metadata
|
|
params['plant_type'] = self.current_plant
|
|
params['ambient_mode'] = self.ambient_mode.get()
|
|
|
|
# Create submission data
|
|
submission_data = {
|
|
'timestamp': datetime.now().isoformat(),
|
|
'parameters': params,
|
|
'baseline_image_path': self.baseline_image_path,
|
|
'plant_info': self.plant_info_text.get(1.0, tk.END),
|
|
'start_date': start_date.isoformat(), # Fixed: was 'start date' (space)
|
|
'end_date': end_date.isoformat(),
|
|
'time_lapse_days': days_difference # Added time lapse info
|
|
}
|
|
|
|
if current_mode == "open" and happy_data is not None:
|
|
submission_data['meteoForecast'] = happy_data
|
|
|
|
# Clear plant_info_text
|
|
self.plant_info_text.delete(1.0, tk.END)
|
|
|
|
# Save submission data
|
|
data_dir = "../data"
|
|
os.makedirs(data_dir, exist_ok=True)
|
|
current_date = datetime.now().strftime('%Y%m%d')
|
|
filename = f"{current_date}-{current_date}.txt"
|
|
filepath = os.path.join(data_dir, filename)
|
|
|
|
with open(filepath, 'w') as f:
|
|
json.dump(submission_data, f, indent=4)
|
|
|
|
# Here call the bot pipeline to store results on files in plant_data
|
|
# results are in the form of (text, image)
|
|
results = None
|
|
|
|
if results is not None: # Fixed: changed != None to is not None
|
|
text = getattr(results, 'text', None)
|
|
image_filename = getattr(results, 'image', None)
|
|
else:
|
|
text = "<<<----Here at your left you can see the results of the growth of the plant!"
|
|
image_filename = self.filenames[self.counter] # Fixed: removed leading slash
|
|
self.counter += 1
|
|
|
|
# Create plant_data directory
|
|
images_dir = "./plant_data"
|
|
os.makedirs(images_dir, exist_ok=True) # Fixed: was data_dir instead of images_dir
|
|
|
|
image_path = f"public/{image_filename.split('/')[-1]}"
|
|
|
|
# Update UI with results
|
|
self.updating_evolution_and_forecasts(text, image_path)
|
|
|
|
# Here update the informations in the last box from plant_data/texts
|
|
# TODO: Implement reading from plant_data/texts
|
|
|
|
# Here update the informations in growth evolution from plant_data/images
|
|
# TODO: Implement reading from plant_data/images
|
|
|
|
# Show success message with better formatting
|
|
messagebox.showinfo("Submission Successful",
|
|
"Submission successful!\n\n"
|
|
"Go to Growth Evolution tab to see the results.")
|
|
|
|
print(f"Submission data saved to: {filepath}")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Submission Error", f"Error submitting data: {str(e)}")
|
|
print(f"Error details: {e}") # For debugging
|
|
|
|
def updating_evolution_and_forecasts(self, text, image_path):
|
|
self.results_text.config(state='normal') # Enable editing
|
|
self.results_text.delete(1.0, tk.END) # Clear existing content
|
|
if text != None:
|
|
self.results_text.insert(1.0, text) # Insert new text
|
|
self.results_text.config(state='disabled') # Disable editing again
|
|
|
|
self.update_evolution_image(image_path)
|
|
|
|
def update_initial_plant_display(self):
|
|
"""Update the initial plant state display"""
|
|
try:
|
|
initial_image = None
|
|
# Generate initial plant image (stage 0)
|
|
try:
|
|
if self.baseline_image_path != None and os.path.exists(self.baseline_image_path):
|
|
initial_image = Image.open(self.baseline_image_path)
|
|
except Exception as e:
|
|
print(f"Error loading image from {self.baseline_image_path}: {e}")
|
|
|
|
# Resize image to fit better in square layout
|
|
|
|
if initial_image != None:
|
|
initial_image = initial_image.resize((280, 210), Image.Resampling.LANCZOS)
|
|
|
|
# Convert to PhotoImage and display
|
|
photo = ImageTk.PhotoImage(initial_image)
|
|
self.initial_plant_label.configure(image=photo, text="")
|
|
self.initial_plant_label.image = photo # Keep reference
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Image Error", f"Could not generate initial plant image: {str(e)}")
|
|
|
|
def update_results_display(self, prediction):
|
|
self.results_text.delete(1.0, tk.END)
|
|
|
|
#Here update the results display after submitting the photo and the message with the parameters and receveing the output
|
|
results = ""
|
|
|
|
self.results_text.insert(1.0, results)
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
app = PlantGrowthDashboard(root)
|
|
root.mainloop()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|