# Project export: Sprout

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: Your mini robot microfarm.
- Devpost: https://devpost.com/software/greenguardian-microfarm
- GitHub: https://github.com/lethan3/cactushacks
- Video: https://www.youtube.com/embed/PbnM8TVgxBQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner ([NVIDIA] Edge AI Track)
- Team: 3 GitHub contributor(s) — lethan3 (15 commits), Eric Sun (4 commits), Markus (1 commits)

## Devpost submission (written by the team)

### Inspiration

We wanted to bridge the gap between rigid, industrial robotics and the organic beauty of nature. The idea was to create a "gardener" that doesn't just automate a task, but feels like a companion to the plants it cares for. We were inspired by the precision of 3D printers and CNC machines but wanted to apply that technology to something living, creating a system that is both high-tech and deeply aesthetic. It's also an excellent application of applying systems like these towards tangible social good.

### What it does

Sprout is an autonomous, AI-controlled robotic garden powered by edge computing. Intelligent Vision: A camera mounted directly to the gantry moves over the garden bed, feeding live video to an onboard NVIDIA Jetson Nano. Edge AI Analysis: Instead of just seeing "green," our custom Edge AI model analyzes the footage in real-time to identify the specific plant species and assess its health status. Smart Watering: Based on the plant type and its current health, Sprout calculates exactly how much water is needed and pumps that precise amount, ensuring optimal growth without waste.

### How we built it

We approached this as a full-stack robotics challenge, combining mechanical engineering with edge computing: Hardware & Mechanics: We designed a 3-axis gantry frame (similar to a 3D printer) to provide full coverage of the microfarm. We used machining tools (mill and lathe) to fabricate custom structural components and prototyped parts to ensure smooth motion. Compute & Vision: The system's "brain" is an NVIDIA Jetson Nano. We mounted a camera to the gantry head to give the AI a close-up, top-down view of every leaf. Software: We developed an Edge AI pipeline that processes visual data locally on the Jetson. The model classifies plants and determines health metrics, which then triggers the pump system via Python scripts. Aesthetics: To give Sprout personality, and because vinyl wraps rock, we wrapped the chassis in vinyl. ##

### Challenges we ran into

Edge Optimization: Running complex computer vision models on the Jetson Nano required optimizing our code to ensure real-time performance without lag. Dynamic Watering Logic: Training the model to not just recognize a plant, but judge its health and decide on a water volume, was significantly harder than simple object detection. Hardware Integration: Calibrating the gantry system so the physical nozzle aligned perfectly with what the camera was seeing required precise coordinate mapping.

### Accomplishments we're proud of

Edge AI Implementation: Successfully deploying a health-assessment model on the Jetson Nano that runs entirely offline/on-device. Functional Gantry: Building a reliable CNC-style motion system from scratch over the weekend.

### What we learned

Systems Integration: We learned the complexities of marrying high-level AI (Jetson) with low-level hardware control (motors and pumps). Computer Vision on the Edge: We gained deep experience in optimizing neural networks for embedded devices. Rapid Prototyping: We honed our skills in machining and fabrication under pressure, making quick design decisions to keep the build moving. At many points - especially in wiring - realizations about the underlying logic of certain components forced us to quickly pivot.

## README (from the GitHub repository)

<img width="725" height="336" alt="sproutPosterV2" src="https://github.com/user-attachments/assets/cd7dbbec-0757-4349-8176-7c570f65025d" />

# Sprout
### Autonomous AI-Driven Autonomous Robotic Microfarm
#### TreeHacks 2026 NVIDIA Edge AI Track Winner

Check out our [Devpost](https://devpost.com/software/greenguardian-microfarm) and view our [Demo Video](https://www.youtube.com/watch?v=PbnM8TVgxBQ).

### Inspiration
We wanted to bridge the gap between rigid, industrial robotics and the organic beauty of nature. The idea was to create a "gardener" that doesn't just automate a task, but feels like a companion to the plants it cares for. We were inspired by the precision of 3D printers and CNC machines but wanted to apply that technology to something living, creating a system that is both high-tech and deeply aesthetic.

It's also an excellent application of applying systems like these towards tangible social good.

### What it does
Sprout is an autonomous, AI-controlled robotic garden powered by edge computing.

- Intelligent Vision: A camera mounted directly to the gantry moves over the garden bed, feeding live video to an onboard NVIDIA Jetson Nano.
Edge AI Analysis: Instead of just seeing "green," our custom Edge AI model analyzes the footage in real-time to identify the specific plant species and assess its health status.
- Smart Watering: Based on the plant type and its current health, Sprout calculates exactly how much water is needed and pumps that precise amount, ensuring optimal growth without waste.

### How we built it
We approached this as a full-stack robotics challenge, combining mechanical engineering with edge computing:
Hardware & Mechanics: We designed a 3-axis gantry frame (similar to a 3D printer) to provide full coverage of the microfarm. We used machining tools (mill and lathe) to fabricate custom structural components and prototyped parts to ensure smooth motion.
- Compute & Vision: The system's "brain" is an NVIDIA Jetson Nano. We mounted a camera to the gantry head to give the AI a close-up, top-down view of every leaf.
- Software: We developed an Edge AI pipeline that processes visual data locally on the Jetson. The model classifies plants and determines health metrics, which then triggers the pump system via Python scripts.
- Aesthetics: To give Sprout personality, and because vinyl wraps rock, we wrapped the chassis in vinyl. ## Challenges we ran into
- Edge Optimization: Running complex computer vision models on the Jetson Nano required optimizing our code to ensure real-time performance without lag.
- Dynamic Watering Logic: Training the model to not just recognize a plant, but judge its health and decide on a water volume, was significantly harder than simple object detection.
- Hardware Integration: Calibrating the gantry system so the physical nozzle aligned perfectly with what the camera was seeing required precise coordinate mapping.

### Accomplishments that we're proud of
- Edge AI Implementation: Successfully deploying a health-assessment model on the Jetson Nano that runs entirely offline/on-device.
- Functional Gantry: Building a reliable CNC-style motion system from scratch over the weekend.

### What we learned
- Systems Integration: We learned the complexities of marrying high-level AI (Jetson) with low-level hardware control (motors and pumps).
- Computer Vision on the Edge: We gained deep experience in optimizing neural networks for embedded devices.
- Rapid Prototyping: We honed our skills in machining and fabrication under pressure, making quick design decisions to keep the build moving. At many points - especially in wiring - realizations about the underlying logic of certain components forced us to quickly pivot.


## Detected evidence (automated analysis)

Indexed codebase: 32 recognized source files, 209 KB.
- Ollama (technology) — detected in the code
- Python (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (37 of 37)

```
.gitignore
agent.py
camera_test_capture.py
capture.py
CLAUDE.md
harness.py
knowledge/camera-audit-2026-02-15.md
knowledge/working-notes.md
live_feed.py
motion/camera_calibrator_debug.py
motion/camera_calibrator.py
motion/camera.py
plant_vision.py
playgrounds/e2b/test.py
playgrounds/smolagents/analysis.md
playgrounds/smolagents/test.py
poke-mcp/README.md
poke-mcp/render.yaml
poke-mcp/requirements.txt
poke-mcp/server.py
prompts/agent_prompt.txt
README.md
requirements.txt
scripts/ollama_agent.py
scripts/ollama_step3_calculator_tool.py
scripts/test_ollama_move_y.py
test_overshoot_prompt.py
test_overshoot.py
Untitled
utils/__init__.py
utils/clock.py
utils/logger.py
utils/overshoot_ai.py
utils/plant.py
utils/poke_ai.py
utils/priority_queue.py
utils/sensors.py
```

### Dependencies

- poke-mcp/requirements.txt: fastmcp@>=2.0.0
- requirements.txt: charset-normalizer@>=3.4.4, matplotlib@>=3.7.0, numpy@>=1.24.0, ollama@>=0.4.0, opencv-python@>=4.13.0, Pillow@>=10.0.0, python-dotenv@>=1.0.0, requests@>=2.32.5, urllib3@>=2.6.3

### Recent commits (newest first)

- fix markdown formatting in devpost and demo video links
- Added links to Devpost and Demo Video
- Add detailed project overview to readme
- add TreeHacks 2026 award
- Add image and project title to README
- Add changes
- added sensor mock functions
- commit?
- Merge branch 'main' of https://github.com/lethan3/cactushacks
- ajsdfasdkjasfdkasfdk
- add new sprout poster
- preparation for integration with physical
- this is all vibe coded idk if it works
- picturee??????
- Rename project from GreenGuardian to Sprout
- capture webcam photo from jetson testing
- asdfasda
- ugh motion
- ;li.objkhgmj;/lkjhgnvfcloiu8im6utj
- Merge branch 'main' of https://github.com/lethan3/cactushacks

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
Keep the code style simple, hackable, not overly-engineered, functional, emphasis on the operations rather than the abstractions, concise vars. max clarity and simplicity.
```

### knowledge/camera-audit-2026-02-15.md

```markdown
# Camera Audit — 2026-02-15

## Goal

Capture a single live frame from the camera feed as a PNG.

## System

- **Board**: Jetson Orin Nano Super (Tegra234), R36 rev 4.4, JetPack 6.x
- **Kernel**: 5.15.148-tegra aarch64
- **OpenCV**: 4.13.0 (FFMPEG: YES, V4L2: YES, **GStreamer: NO**)
- **GStreamer**: 1.20.3 installed system-wide but NOT compiled into OpenCV

## Finding: no camera currently connected

### Evidence

| Check | Result |
|-------|--------|
| `ls /dev/video*` | No such file or directory — zero V4L2 video device nodes |
| `lsusb` | Hub, Bluetooth, Touchscreen — no USB webcam |
| `dmesg \| grep -i video/uvc/camera` | Empty — no camera driver messages in kernel log |
| `cv2.VideoCapture(i)` for i in 0–9 | All fail: "device_list->nb_devices is 0" |
| GStreamer nvarguscamerasrc pipeline via OpenCV | Fails (OpenCV compiled without GStreamer) |
| `/sys/class/video4linux/` | Directory missing or empty |
| `/proc/device-tree/cam*` | Debug nodes only (dbg, echo, diag) — no CSI sensor |

### USB devices present

```
Bus 002: 4-Port USB 3.0 Hub (Realtek 0bda:0489)
Bus 001: Bluetooth Radio (IMC Networks 13d3:3549)
Bus 001: ILITEK-TOUCH (ILI Technology 222a:0001)
Bus 001: 4-Port USB 2.0 Hub (Realtek 0bda:5489)
```

No webcam.

## Previous successful captures

A camera WAS working earlier today and has since been disconnected.

| File | Timestamp | Resolution | Size |
|------|-----------|------------|------|
| `webcam_photo.jpg` | 04:09 | 640×480 | 62 KB |
| `webcam_photo_jetson.jpg` | 04:14 | 640×480 | 63 KB |
| `20260215_051023.jpg` | 06:35 | 4080×2296 | 2.9 MB |

- 640×480 images match `capture.py` and `ActualCamera` settings (index 1, MJPG codec)
- 4080×2296 image suggests either a higher-res USB camera or different capture settings
- Pixel inspection confirms real photos (varied colors, not blank)

## Existing capture code

| File | Camera index | Resolution | Notes |
|------|-------------|------------|-------|
| `cactushacks/capture.py` | 1 | default (no explicit set) | Standalone script, saves `webcam_photo_jetson.jpg` |
| `cactushacks/motion/camera.py` `ActualCamera` | 1 | 640×480 forced | MJPG codec, 5-frame warmup, returns PIL Image |

Both use `cv2.VideoCapture(1)`.

## Conclusion (initial audit ~07:26)

Camera was disconnected at time of first audit. No `/dev/video*` nodes, no USB webcam in `lsusb`.

---

## Live capture — SUCCESS (07:28)

Camera reconnected by user. Full audit and capture completed.

### USB discovery

```
Bus 001 Device 005: ID 046d:08e5 Logitech, Inc. HD Pro Webcam C920
```

Connected via USB 2.0 hub at port 1-2.3.

### V4L2 device nodes

| Node | Name | Backend | Status |
|------|------|---------|--------|
| `/dev/video0` | HD Pro Webcam C920 | V4L2 | **Primary capture** — works |
| `/dev/video1` | HD Pro Webcam C920 | (metadata) | Not a capture source |

### Permissions

- Device: `crw-rw----` root:video
- User `cactushacks` is in `video` group — access OK

### Capture details

- **Index 0** (not 1 — existing code use
[truncated — 1256 more characters]
```

### requirements.txt

```
ollama>=0.4.0
requests>=2.32.5
charset-normalizer>=3.4.4
urllib3>=2.6.3
numpy>=1.24.0
Pillow>=10.0.0
matplotlib>=3.7.0
opencv-python>=4.13.0
python-dotenv>=1.0.0
```

### poke-mcp/requirements.txt

```
fastmcp>=2.0.0

```

### poke-mcp/server.py

```python
#!/usr/bin/env python3
"""
FastMCP server wrapping the Plant Care AI Agent functionality.
Based on https://github.com/InteractionCo/mcp-server-template
"""
import subprocess
import sys
import json
import os
from pathlib import Path
from fastmcp import FastMCP

# Project directory - use relative path or environment variable
PROJECT_DIR = os.getenv("PROJECT_DIR", str(Path(__file__).parent.parent))

mcp = FastMCP("Plant Care AI Agent")


@mcp.tool()
def run_simulation(steps: int = 10, time_step: int = 30, model: str = "llama3.2") -> str:
    """
    Run the plant care simulation for a specified number of steps.

    Args:
        steps: Number of time steps to simulate (default: 10)
        time_step: Time step in minutes (default: 30)
        model: Ollama model to use (default: llama3.2)

    Returns:
        Simulation output including plant status updates and agent actions
    """
    try:
        harness_path = Path(PROJECT_DIR) / "harness.py"
        if not harness_path.exists():
            return f"Error: harness.py not found at {harness_path}"
        
        result = subprocess.run(
            [sys.executable, str(harness_path), "--steps", str(steps), "--time-step", str(time_step), "--model", model],
            capture_output=True,
            text=True,
            cwd=PROJECT_DIR,
            timeout=600
        )

        output = result.stdout
        if result.stderr:
            output += f"\n\nSTDERR:\n{result.stderr}"

        if result.returncode != 0:
            output += f"\n\nCommand failed with return code: {result.returncode}"

        return output
    except subprocess.TimeoutExpired:
        return "Error: Simulation timed out after 10 minutes"
    except Exception as e:
        return f"Error running simulation: {str(e)}"


@mcp.tool()
def check_plant_status() -> str:
    """
    Check the current status of all plants in the simulation.
    This reads the most recent log file to see agent activities.

    Returns:
        Latest plant status information from agent logs
    """
    try:
        logs_dir = Path(PROJECT_DIR) / "logs"
        if not logs_dir.exists():
            return "No logs directory found. Run a simulation first."

        # Get most recent log file
        log_files = sorted(logs_dir.glob("agent_*.log"), key=lambda p: p.stat().st_mtime, reverse=True)

        if not log_files:
            return "No log files found. Run a simulation first."

        latest_log = log_files[0]

        # Read last 100 lines of the log
        try:
            with open(latest_log, 'r', encoding='utf-8') as f:
                lines = f.readlines()
                last_lines = lines[-100:] if len(lines) > 100 else lines
                return f"Latest log file: {latest_log.name}\n\n{''.join(last_lines)}"
        except Exception as e:
            return f"Error reading log file: {str(e)}"

        return f"Latest log file: {latest_log.name}\n\n{result.stdout}"
    except Exception as e:
        return f"Error reading plant status: {str(e)}"


@mcp.tool()
def list_available_species() -> str:
    """
    List all available plant species and their care requirements.

    Returns:
        JSON formatted list of plant species with watering instructions
    """
    try:
        # Run Python to import and access the Plant class constants
        code = f"""
import sys
sys.path.insert(0, r'{PROJECT_DIR}')
from utils.plant import Plant
import json
print(json.dumps(Plant.SPECIES_CARE, indent=2))
"""
        result = subprocess.run(
            [sys.executable, "-c", code],
            capture_output=True,
            text=True,
            cwd=PROJECT_DIR
        )

        if result.returncode == 0:
            return f"Available plant species:\n\n{result.stdout}"
        else:
            return f"Error: {result.stderr}"
    except Exception as e:
        return f"Error listing species: {str(e)}"


@mcp.tool()
def view_agent_prompt() -> str:
    """
    View the system prompt used by the AI agent.
    This shows how the agent is instructed to care for plants.

    Returns:
        The agent's system prompt
    """
    try:
        prompt_file = Path(PROJECT_DIR) / "prompts" / "agent_prompt.txt"

        if not prompt_file.exists():
            return "Agent prompt file not found"

        with open(prompt_file, 'r') as f:
            content = f.read()

        return content
    except Exception as e:
        return f"Error reading agent prompt: {str(e)}"


@mcp.tool()
def view_tool_schema() -> str:
    """
    View the tool schema available to the AI agent.
    This shows what actions the agent can take.

    Returns:
        The tool schema specification
    """
    try:
        schema_file = Path(PROJECT_DIR) / "prompts" / "tool_schema.txt"

        if not schema_file.exists():
            return "Tool schema file not found"

        with open(schema_file, 'r') as f:
            content = f.read()

        return content
    except Exception as e:
        return f"Error reading tool schema: {str(e)}"


@mcp.tool()
def list_logs() -> str:
    """
    List all available agent log files with timestamps.

    Returns:
        List of log files sorted by date
    """
    try:
        logs_dir = Path(PROJECT_DIR) / "logs"

        if not logs_dir.exists():
            return "No logs directory found"

        log_files = sorted(logs_dir.glob("agent_*.log"), key=lambda p: p.stat().st_mtime, reverse=True)

        if not log_files:
            return "No log files found"

        output = "Available log files (most recent first):\n\n"
        for log_file in log_files:
            stat = log_file.stat()
            from datetime import datetime
            mtime = datetime.fromtimestamp(stat.st_mtime)
            size = stat.st_size
            output += f"- {log_file.name} ({size} bytes, modified: {mtime})\n"

        return output
    except Exception as e:
        return f"Error listing logs: {str(e)}"


@mcp.tool()
def read_log(log_name: str) -> str:
    """
    Read 
[truncated — 4406 more characters]
```

### capture.py

```python
import cv2

def take_picture():
    # Initialize the camera (0 is the default USB camera index)
    cap = cv2.VideoCapture(1)
    
    # # Optional: Set resolution to 1080p (1920x1080)
    # # Without this, it often defaults to 640x480
    # cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
    # cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
    
    # Optional: Set codec to MJPG for better framerate at high res
    cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))

    if not cap.isOpened():
        print("Error: Could not open webcam.")
        return

    # Warm up the camera (skip first few frames for auto-focus/exposure adjustment)
    for _ in range(5):
        cap.read()

    # Capture a single frame
    ret, frame = cap.read()

    if ret:
        # Save the image
        filename = "webcam_photo_jetson.jpg"
        cv2.imwrite(filename, frame)
        print(f"Success: Image saved as {filename}")
    else:
        print("Error: Failed to capture image.")

    # Release the camera
    cap.release()

if __name__ == "__main__":
    take_picture()

```

### test_overshoot.py

```python
#!/usr/bin/env python3
"""Test script to test overshoot.ai prompt on an image."""

from PIL import Image
from utils.overshoot_ai import OvershootAIClient
import json
from pathlib import Path
from dotenv import load_dotenv

# Load .env file from project root
env_path = Path(__file__).parent / '.env'
load_dotenv(env_path)

def test_overshoot_prompt(image_path: str, plant_name: str = "TestPlant", plant_species: str = "cactus"):
    """Test the overshoot.ai prompt on an image."""
    print(f"Loading image: {image_path}")
    image = Image.open(image_path)
    print(f"Image size: {image.size}")
    print(f"Image mode: {image.mode}")
    
    print("\nInitializing OpenAI Vision API client...")
    try:
        client = OvershootAIClient()
        print("Client initialized successfully")
    except Exception as e:
        print(f"Error initializing client: {e}")
        return
    
    print(f"\nAnalyzing image for plant: {plant_name} ({plant_species})")
    print("=" * 60)
    
    try:
        result = client.analyze_plant_image(image, plant_name, plant_species)
        print("\nResult:")
        print(json.dumps(result, indent=2))
    except Exception as e:
        print(f"Error analyzing image: {e}")
        import traceback
        traceback.print_exc()

if __name__ == "__main__":
    import sys
    image_path = "20260215_051023.jpg"
    if len(sys.argv) > 1:
        image_path = sys.argv[1]
    
    plant_name = "TestCactus"
    plant_species = "cactus"
    if len(sys.argv) > 2:
        plant_name = sys.argv[2]
    if len(sys.argv) > 3:
        plant_species = sys.argv[3]
    
    test_overshoot_prompt(image_path, plant_name, plant_species)

```

### live_feed.py

```python
#!/usr/bin/env python3
"""Live camera feed on the left half of the touchscreen. Press 'q' or ESC to quit."""
import cv2
import os
import glob
import subprocess

os.environ.setdefault('DISPLAY', ':0')
os.environ.setdefault('DBUS_SESSION_BUS_ADDRESS', 'unix:path=/run/user/1000/bus')

DOCK_KEYS = {
    'autohide': None,
    'dock-fixed': None,
    'intellihide': None,
}
SCHEMA = 'org.gnome.shell.extensions.dash-to-dock'


def gsettings(action, key, value=None):
    cmd = ['gsettings', action, SCHEMA, key]
    if value is not None:
        cmd.append(value)
    return subprocess.run(cmd, capture_output=True, text=True).stdout.strip()


def hide_dock():
    """Save current dock state and hide it."""
    for key in DOCK_KEYS:
        DOCK_KEYS[key] = gsettings('get', key)
    gsettings('set', 'autohide', 'false')
    gsettings('set', 'dock-fixed', 'false')
    gsettings('set', 'intellihide', 'false')
    print("Dock hidden")


def restore_dock():
    """Restore dock to its original state."""
    for key, val in DOCK_KEYS.items():
        if val is not None:
            gsettings('set', key, val)
    print("Dock restored")


def find_camera():
    # Extract index from /dev/videoN and open by index (V4L2 needs int)
    for dev in sorted(glob.glob('/dev/video*')):
        idx = int(dev.replace('/dev/video', ''))
        cap = cv2.VideoCapture(idx)
        if cap.isOpened():
            ret, _ = cap.read()
            if ret:
                print(f"Found camera at {dev} (index {idx})")
                return cap
            cap.release()
    raise RuntimeError("No camera found")


# 1. Hide dock first
hide_dock()

# 2. Open camera
cap = find_camera()
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)

# 3. Create window on left half (1024x600 screen → 512x600 left half)
WIN = "Live Feed"
cv2.namedWindow(WIN, cv2.WINDOW_NORMAL)
cv2.resizeWindow(WIN, 512, 600)
cv2.moveWindow(WIN, 0, 0)

print("Streaming to left half — press q or ESC to quit")

try:
    while True:
        ret, frame = cap.read()
        if not ret:
            continue
        cv2.imshow(WIN, frame)
        key = cv2.waitKey(1) & 0xFF
        if key in (ord('q'), 27):
            break
finally:
    # 4. Always restore dock on exit
    cap.release()
    cv2.destroyAllWindows()
    restore_dock()

```

### test_overshoot_prompt.py

```python
#!/usr/bin/env python3
"""Test script to show the overshoot.ai prompt that would be used."""

from PIL import Image
import base64
from io import BytesIO

def show_prompt(image_path: str, plant_name: str = "TestPlant", plant_species: str = "cactus"):
    """Show the prompt that would be sent to overshoot.ai."""
    print(f"Loading image: {image_path}")
    image = Image.open(image_path)
    print(f"Image size: {image.size}")
    print(f"Image mode: {image.mode}")
    
    # Convert PIL Image to base64 (same as the actual code)
    buffered = BytesIO()
    image.save(buffered, format="JPEG")
    image_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
    print(f"Base64 length: {len(image_base64)} characters")
    
    # Create prompt (same as in overshoot_ai.py)
    prompt = f"""Analyze this image of a plant named {plant_name} (species: {plant_species}).

Please provide the following information in JSON format:
- hydration: One of "well_hydrated", "slightly_dry", "dry", "very_dry", "overwatered"
- hydration_deviation: A number between -2.0 and 2.0 where:
  * 0.0 means perfectly hydrated
  * Positive values mean overwatered (max 2.0)
  * Negative values mean underwatered (min -2.0)
- recommended_action: One of "water_now", "water_soon", "no_action_needed", "reduce_watering"
- water_frequency_status: A number indicating how well the watering frequency is maintained (0.0 = perfect, positive = too frequent, negative = too infrequent)
- visible_health_issues: List of any visible health issues (e.g., "yellowing_leaves", "wilting", "brown_spots")
- overall_health: A number from 0-100 indicating overall plant health

Return ONLY valid JSON, no other text."""
    
    print("\n" + "=" * 60)
    print("PROMPT THAT WOULD BE SENT TO OVERSHOOT.AI:")
    print("=" * 60)
    print(prompt)
    print("=" * 60)
    
    print("\nAPI PAYLOAD STRUCTURE:")
    print("-" * 60)
    print(f"Model: Qwen/Qwen3-VL-30B-A3B-Instruct")
    print(f"Endpoint: https://api.overshoot.ai/v1/vision/analyze")
    print(f"Max tokens: 500")
    print(f"Image: [base64 encoded, {len(image_base64)} chars]")
    print(f"Prompt length: {len(prompt)} characters")
    print("-" * 60)

if __name__ == "__main__":
    import sys
    image_path = "20260215_051023.jpg"
    if len(sys.argv) > 1:
        image_path = sys.argv[1]
    
    plant_name = "TestCactus"
    plant_species = "cactus"
    if len(sys.argv) > 2:
        plant_name = sys.argv[2]
    if len(sys.argv) > 3:
        plant_species = sys.argv[3]
    
    show_prompt(image_path, plant_name, plant_species)

```

### camera_test_capture.py

```python
#!/usr/bin/env python3
"""
Minimal camera capture script — tries all available methods to grab one frame.
Saves to workspace as camera_test_frame.png.

Usage:
    python3 camera_test_capture.py          # auto-detect
    python3 camera_test_capture.py 0        # force index 0
    python3 camera_test_capture.py 1        # force index 1
"""
import sys
import os
import cv2
from pathlib import Path
from datetime import datetime

OUT_DIR = Path(__file__).parent
OUT_FILE = OUT_DIR / "camera_test_frame.png"


def try_index(idx: int) -> bool:
    """Try to capture from a specific V4L2 camera index."""
    print(f"  Trying cv2.VideoCapture({idx}) ...")
    cap = cv2.VideoCapture(idx)
    if not cap.isOpened():
        print(f"    Could not open index {idx}")
        cap.release()
        return False

    # Set MJPG for better throughput
    cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)

    # Warm up (auto-exposure / auto-focus)
    for _ in range(5):
        cap.read()

    ret, frame = cap.read()
    cap.release()

    if not ret or frame is None:
        print(f"    Opened index {idx} but read() failed")
        return False

    cv2.imwrite(str(OUT_FILE), frame)
    h, w = frame.shape[:2]
    print(f"    SUCCESS: {w}x{h} frame saved to {OUT_FILE}")
    return True


def probe_all() -> bool:
    """Scan /dev/video* and try each, then fall back to brute-force 0-9."""
    # Check device nodes first
    dev_videos = sorted(Path("/dev").glob("video*"))
    if dev_videos:
        print(f"Found device nodes: {[str(d) for d in dev_videos]}")
        for dev in dev_videos:
            idx = int(dev.name.replace("video", ""))
            if try_index(idx):
                return True
    else:
        print("No /dev/video* nodes found")

    # Brute-force indices 0-4
    print("Brute-force scanning indices 0-4 ...")
    for idx in range(5):
        if try_index(idx):
            return True

    return False


def main():
    print(f"Camera test capture — {datetime.now().isoformat()}")
    print(f"OpenCV {cv2.__version__}")
    print()

    if len(sys.argv) > 1:
        idx = int(sys.argv[1])
        print(f"Forced camera index: {idx}")
        if not try_index(idx):
            print("\nFAILED — camera not available at that index")
            sys.exit(1)
    else:
        print("Auto-detecting camera ...")
        if not probe_all():
            print("\nFAILED — no camera detected")
            print("  Check: ls /dev/video*")
            print("  Check: lsusb (look for webcam)")
            print("  Is the USB camera plugged in?")
            sys.exit(1)

    print(f"\nDone. Output: {OUT_FILE} ({os.path.getsize(OUT_FILE)} bytes)")


if __name__ == "__main__":
    main()

```

[22 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]