# Project export: The Magic Table

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: Any sufficiently advanced technology looks like magic. With VLMs, LLMs, and a custom magnetic gantry, we built a table that aims to change how physically disadvantaged people interact with the world.
- Devpost: https://devpost.com/software/the-magic-table
- GitHub: https://github.com/Jaybear411/treehacks2026
- Video: https://www.youtube.com/embed/zQGj1xWwJHQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Jay Khemchandani (1 commits)

## Devpost submission (written by the team)

No Devpost description available.

## README (from the GitHub repository)

# Magic Table - Voice-Controlled Object Retrieval

A voice-controlled robotic system for accessibility. Uses computer vision to identify objects on a tabletop and an XY magnet gantry to drag them to a pickup zone.

**Core use case:** Accessibility for blind/mobility-impaired users — "voice-controlled physical retrieval" in a clean, magical demo.

## How It Works

1. **Camera + Vision Model** watches the tabletop, identifies objects (keys, phone, glasses, etc.), and tracks their (x, y) position
2. **XY Gantry** with powerful magnets moves under the table surface
3. **Metal pucks** attached to objects couple with magnets underneath
4. **Voice commands** like "bring me the keys" route the magnet to pull that object to the pickup zone

## Hardware Requirements

### Electronics Stack

| Component | Purpose |
|-----------|---------|
| Arduino Uno | Controller (runs GRBL firmware) |
| CNC Shield V3 | Motor driver interface (plugs onto Uno) |
| DRV8825 Drivers (x3) | Stepper motor drivers (X, Y, A slots) |
| 24V Power Supply | Motor power (NOT to Arduino!) |
| NEMA 17 Steppers (x3) | Two for Y-axis (ganged), one for X-axis |
| Webcam | Overhead view of table surface |

### Mechanical Stack

| Component | Specification |
|-----------|---------------|
| 2020 Aluminum Extrusion | 400mm length (x3) |
| Linear Rails/V-wheels | For smooth gantry movement |
| GT2 Belts + Pulleys | Motion transmission |
| Strong Neodymium Magnets | Under-table magnet carriage |
| Metal Pucks | Attached to objects for magnetic coupling |

### Wiring Diagram

```
                    ┌─────────────────┐
                    │   24V Power     │
                    │   Supply        │
                    └────────┬────────┘
                             │
    ┌────────────────────────┴────────────────────────┐
    │                 CNC Shield V3                    │
    │  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐               │
    │  │  X  │ │  Y  │ │  Z  │ │  A  │  ← Drivers    │
    │  │DRV  │ │DRV  │ │empty│ │DRV  │               │
    │  └──┬──┘ └──┬──┘ └─────┘ └──┬──┘               │
    │     │      │               │                   │
    │  Motor   Motor           Motor                 │
    │   #3      #1              #2                   │
    │  (X)     (Y)            (Y clone)              │
    │                                                │
    │  Set A→Y jumper to clone A axis to Y!          │
    └─────────────────────────────────┬──────────────┘
                                      │ (sits on top)
                    ┌─────────────────┴──────────────┐
                    │        Arduino Uno              │
                    │         (GRBL)                  │
                    └─────────────────┬──────────────┘
                                      │ USB
                    ┌─────────────────┴──────────────┐
                    │         Computer                │
                    │   (runs this Python code)       │
                    └────────────────────────────────┘
```

## Software Setup

### 1. Flash GRBL to Arduino

1. Download [GRBL](https://github.com/grbl/grbl)
2. Flash to Arduino Uno via Arduino IDE
3. Configure GRBL settings (see below)

### 2. Install Python Dependencies

```bash
# Create virtual environment
python3 -m venv .venv
source .venv/bin/activate  # Linux/Mac
# .venv\Scripts\activate   # Windows

# Install dependencies

```

**Note for macOS:** PyAudio may require portaudio:
```bash
brew install portaudio
pip install pyaudio
```

### 3. Configure Serial Port

Edit `config.py` and set the correct serial port:

```python
# macOS
port: str = "/dev/tty.usbmodem1101"

# Linux
port: str = "/dev/ttyUSB0"

# Windows
port: str = "COM3"
```

Find your port:
```bash
# macOS/Linux
ls /dev/tty.*

# Or use Python
python -c "from grbl_controller import GRBLController; print(GRBLController.list_ports())"
```

### 4. GRBL Configuration

Connect via serial terminal (115200 baud) and configure:

```
$100=160    # X steps/mm (adjust for your setup)
$101=160    # Y steps/mm
$110=5000   # X max rate mm/min
$111=5000   # Y max rate mm/min
$120=500    # X acceleration mm/sec^2
$121=500    # Y acceleration
$130=400    # X max travel mm
$131=400    # Y max travel mm
```

## Usage

### Run Full System

```bash
python main.py
```

### Test Individual Components

```bash
# Test vision only (no motor/voice)
python main.py --test-vision

# Test voice recognition
python main.py --test-voice

# Test motor control
python main.py --test-motor

# Run without voice
python main.py --no-voice

# Run without motor (vision demo)
python main.py --no-motor
```

### Calibrate Camera-to-Gantry Mapping

```bash
python main.py --calibrate
```

This walks you through clicking four corners to map camera pixels to physical coordinates.

### Motor Sweep Calibration (recommended for motion accuracy)

```bash
# Default 4x3 sweep grid (12 points)
python main.py --calibrate-motor

# Denser grid for higher accuracy
python main.py --calibrate-motor --cal-grid-x 5 --cal-grid-y 4
```

The gantry automatically moves across the board. At each stop, click the magnet in the camera view.
This records many pixel->physical pairs and saves an improved homography in `calibration_data.json`.

## Voice Commands

| Command | Action |
|---------|--------|
| "bring me the keys" | Fetch keys to pickup zone |
| "get my phone" | Fetch phone to pickup zone |
| "where is the wallet" | Highlight wallet location |
| "stop" | Emergency stop |
| "go home" | Return magnet to home position |

You can also just say the object name: "keys", "phone", "glasses"

## Detectable Objects

Default objects (configurable in `config.py`):
- keys
- airpods
- phone
- wallet
- pill bottle
- glasses
- remote
- pen
- cup

## File Structure

```
treehacks2026/
├── main.py              # Main orchestration
├── config.py            # Configuration settings
├── grbl_controller.py   # Arduino/GRBL serial control
├── object_tracker.py    # Vision-based object detection
├── voice_control.py     # Speech recognition
├── calibration.py       # Camera-to-gantry calibration
├── requirements.txt     # Python dependencies
└── README.md            # This file
```

## Troubleshooting

### Camera not found
- Check camera index in `config.py` (try 0, 1, 2)
- Ensure no other app is using the camera

### Motor not responding
- Check USB connection
- Verify serial port in `config.py`
- Ensure GRBL is flashed correctly
- Check 24V power to CNC shield

### Voice not recognized
- Ensure microphone is working
- Calibrate ambient noise (automatic on startup)
- Speak clearly and at normal volume
- Check internet connection (uses Google Speech API)

### Object detection poor
- Improve lighting (even, diffused)
- Adjust `score_threshold` in config (lower = more sensitive)
- Try different objects/backgrounds
- Camera should be overhead with clear view

## Development

### Architecture

```
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│    Voice     │     │   Vision     │     │    Motor     │
│  Controller  │     │   Tracker    │     │  Controller  │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                    │                    │
       │    ┌───────────────┴───────────────┐    │
       └────►       MagicTable (main.py)    ◄────┘
             │  - Orchestrates components   │
             │  - Coordinate transforms     │
             │  - Command processing        │
             └──────────────────────────────┘
```

### Adding New Objects

Edit `config.py`:
```python
@dataclass
class DetectionConfig:
    prompts: list = None
    
    def __post_init__(self):
        if self.prompts is None:
            self.prompts = [
                "keys",
                "phone",
                "your_new_object",  # Add here
            ]
```

## License

MIT License - TreeHacks 2026

## Credits

- Zero-shot object detection: [GroundingDINO](https://github.com/IDEA-Research/GroundingDINO)
- Motor control: [GRBL](https://github.com/grbl/grbl)
- 

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 22 recognized source files, 269 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- Hugging Face (technology) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- PyTorch (technology) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Vercel (technology) — claimed on Devpost, not found in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (29 of 29)

```
.gitignore
axis_lengths.json
calibration_data.json
calibration.py
config.py
goto.py
grbl_controller.py
home.py
magic-table-web/.gitignore
magic-table-web/app/chat/page.tsx
magic-table-web/app/globals.css
magic-table-web/app/layout.tsx
magic-table-web/app/page.tsx
magic-table-web/next-env.d.ts
magic-table-web/next.config.js
magic-table-web/package.json
magic-table-web/postcss.config.js
magic-table-web/tailwind.config.ts
magic-table-web/tsconfig.json
main.py
measure_axis.py
motortest.py
nlp_voice.py
object_tracker.py
README.md
requirements.txt
switch_diag.py
voice_control.py
web_server.py
```

### Dependencies

- magic-table-web/package.json: @types/node@^20, @types/react@^18, @types/react-dom@^18, autoprefixer@^10, next@^14.2.0, postcss@^8, react@^18.3.0, react-dom@^18.3.0, tailwindcss@^3.4.0, typescript@^5
- requirements.txt: anthropic@>=0.39.0, elevenlabs@>=1.0.0, flask@>=3.0.0, flask-cors@>=4.0.0, numpy@>=1.24.0, openai@>=1.0.0, opencv-python@>=4.8.0, pillow@>=10.0.0, pyaudio@>=0.2.14, pynput@>=1.7.7, pyserial@>=3.5, python-dotenv@>=1.0.0, SpeechRecognition@>=3.10.0, torch@>=2.2, torchvision, transformers@>=4.36.0

### Recent commits (newest first)

- final edits
- buttons
- Add venv/ to .gitignore to prevent large files from being tracked
- many changes to software and calibration
- Initial commit: Magic Table project

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

### requirements.txt

```
# Magic Table - Voice-Controlled Object Retrieval System
# Python 3.10+ recommended

# Core ML/Vision
torch>=2.2
torchvision
transformers>=4.36.0

# Computer Vision
opencv-python>=4.8.0
pillow>=10.0.0
numpy>=1.24.0

# Speech Recognition
SpeechRecognition>=3.10.0
pyaudio>=0.2.14  # Required for microphone input
pynput>=1.7.7    # Key hold detection for push-to-talk

# OpenAI (vision tasks — scan_table_objects)
openai>=1.0.0
python-dotenv>=1.0.0

# Anthropic Claude (text NLP — object extraction, conversation)
anthropic>=0.39.0

# ElevenLabs TTS (spoken responses in conversation mode)
elevenlabs>=1.0.0

# Web API server (remote command interface)
flask>=3.0.0
flask-cors>=4.0.0

# Serial Communication (Arduino/GRBL)
pyserial>=3.5

# Optional: Text-to-Speech feedback
# pyttsx3>=2.90  # Uncomment for TTS

# Development/Testing
# pytest>=7.0.0
# black>=23.0.0

```

### magic-table-web/package.json

```
{
  "name": "magic-table-web",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^14.2.0",
    "react": "^18.3.0",
    "react-dom": "^18.3.0"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10",
    "postcss": "^8",
    "tailwindcss": "^3.4.0",
    "typescript": "^5"
  }
}

```

### main.py

```python
"""
Magic Table - Voice-Controlled Object Retrieval System

A voice-controlled robotic system that uses computer vision to identify
objects on a tabletop and moves them to a pickup zone using an XY gantry
with magnets.

Core Use Case: Accessibility for blind/mobility-impaired users.

Hardware Stack:
- Arduino Uno + CNC Shield V3 running GRBL
- DRV8825 stepper drivers (X, Y, Z slots — Z mirrors Y in software for dual-Y gantry)
- 24V power supply
- 3x NEMA 17 stepper motors (1x X-axis, 2x Y-axis)
- 400mm x 400mm gantry (2020 aluminum extrusion)
- Camera (overhead view of table)
- Magnets under table surface + metal pucks on objects

Usage:
    python main.py              # Run full system
    python main.py --no-voice   # Run without voice control
    python main.py --no-motor   # Run without motor control (vision only)
    python main.py --calibrate  # Run calibration wizard
"""

import argparse
import os
import subprocess
import sys
import time
from typing import Optional, Tuple

import cv2
try:
    from pynput import keyboard as pynput_keyboard
except Exception:
    pynput_keyboard = None

# Audio file paths (relative to this script)
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
AUDIO_YES_RIGHT_AWAY = os.path.join(_SCRIPT_DIR, "yesrightaway.mp3")
AUDIO_SHUTTING_DOWN = os.path.join(_SCRIPT_DIR, "shuttingdown.mp3")
AUDIO_HAPPY_TO_HELP = os.path.join(_SCRIPT_DIR, "happytohelp.mp3")


def play_audio(filepath: str, block: bool = False):
    """
    Play an mp3 file using macOS afplay.

    Args:
        filepath: Path to the audio file.
        block: If True, wait for playback to finish before returning.
    """
    if not os.path.exists(filepath):
        print(f"Audio file not found: {filepath}")
        return
    try:
        proc = subprocess.Popen(
            ["afplay", filepath],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        if block:
            proc.wait()
    except Exception as e:
        print(f"Audio playback error: {e}")

from config import GANTRY, GRBL, CAMERA, DETECTION, print_config
from grbl_controller import GRBLController
from object_tracker import ObjectTracker, TrackedObject
from voice_control import VoiceController, VoiceCommand, CommandType
from nlp_voice import NLPVoiceController
# Calibration is no longer used — pixel→mm conversion is simple flip+scale.
# from calibration import CoordinateTransformer, CalibrationWizard, AutoCalibrator


class MagicTable:
    """
    Main controller that orchestrates vision, voice, and motor control.
    
    This is the central coordinator that:
    1. Tracks objects via computer vision
    2. Listens for voice commands
    3. Translates pixel coordinates to physical coordinates
    4. Controls the gantry to retrieve objects
    """
    
    def __init__(self, 
                 enable_voice: bool = True,
                 enable_motor: bool = True,
                 target_label: str = None):
        """
        Initialize the Magic Table system.
        
        Args:
            enable_voice: Enable voice recognition (NLP push-to-talk)
            enable_motor: Enable motor control
            target_label: Fixed label for 'V' key detection (optional)
        """
        self.enable_voice = enable_voice
        self.enable_motor = enable_motor
        self.target_label = target_label
        
        # Components
        self.tracker: Optional[ObjectTracker] = None
        self.nlp_voice: Optional[NLPVoiceController] = None
        self.voice: Optional[VoiceController] = None
        self.motor: Optional[GRBLController] = None
        self.transformer = None  # No longer used (flip+scale instead)
        
        # State
        self.running = False
        self._busy = False  # Currently executing a command
        self._last_command: Optional[VoiceCommand] = None
        
        # Last detected object coordinates (pixel)
        self._last_result: Optional[Tuple[str, float, float]] = None
        
        # Status message for display
        self._status_message = "Initializing..."
        
        # Hold-to-talk keyboard state (global listener)
        self._keyboard_listener = None
        self._keys_down: set[str] = set()
        self._space_hold_latched = False
        self._c_hold_latched = False
    
    def _start_key_listener(self):
        """Start global key listener for hold-to-talk."""
        if pynput_keyboard is None or self._keyboard_listener is not None:
            return
        
        def on_press(key):
            if key == pynput_keyboard.Key.space:
                self._keys_down.add("space")
                return
            try:
                ch = key.char.lower() if key.char else None
                if ch == "c":
                    self._keys_down.add("c")
            except Exception:
                pass
        
        def on_release(key):
            if key == pynput_keyboard.Key.space:
                self._keys_down.discard("space")
                return
            try:
                ch = key.char.lower() if key.char else None
                if ch == "c":
                    self._keys_down.discard("c")
            except Exception:
                pass
        
        self._keyboard_listener = pynput_keyboard.Listener(
            on_press=on_press,
            on_release=on_release,
        )
        self._keyboard_listener.daemon = True
        self._keyboard_listener.start()
    
    def _stop_key_listener(self):
        """Stop global key listener."""
        if self._keyboard_listener:
            self._keyboard_listener.stop()
            self._keyboard_listener = None
        self._keys_down.clear()
    
    def _is_key_held(self, key_name: str) -> bool:
        return key_name in self._keys_down
        
    def initialize(self) -> bool:
        """
        Initialize all components.
        
        Returns True if successful.
        """
        print("\n" + "="*60)
        print("MAGIC TABLE - Voice-Controlled 
[truncated — 33383 more characters]
```

### magic-table-web/app/layout.tsx

```typescript
import type { Metadata, Viewport } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Magic Table",
  description: "Voice-controlled object retrieval — web interface",
};

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  maximumScale: 5,
  userScalable: true,
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-white text-black min-h-screen">{children}</body>
    </html>
  );
}

```

### magic-table-web/app/page.tsx

```typescript
"use client";

import { useState, FormEvent } from "react";
import { useRouter } from "next/navigation";

export default function LoginPage() {
  const [username, setUsername] = useState("");
  const [serverUrl, setServerUrl] = useState(
    process.env.NEXT_PUBLIC_API_URL || "http://localhost:5050"
  );
  const [error, setError] = useState("");
  const router = useRouter();

  function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (username.trim().toLowerCase() !== "treehacks") {
      setError("Invalid username.");
      return;
    }
    // Store auth + server URL in sessionStorage
    sessionStorage.setItem("authed", "true");
    sessionStorage.setItem("serverUrl", serverUrl.replace(/\/+$/, ""));
    router.push("/chat");
  }

  return (
    <div className="flex items-center justify-center min-h-screen">
      <form
        onSubmit={handleSubmit}
        className="w-full max-w-sm flex flex-col gap-4 px-6"
      >
        <h1 className="text-2xl font-bold text-center mb-2">Magic Table</h1>

        <input
          type="text"
          placeholder="Username"
          value={username}
          onChange={(e) => {
            setUsername(e.target.value);
            setError("");
          }}
          className="border border-gray-300 rounded px-3 py-2 text-base focus:outline-none focus:ring-2 focus:ring-black min-h-[44px]"
          autoFocus
        />

        <input
          type="text"
          placeholder="Server URL"
          value={serverUrl}
          onChange={(e) => setServerUrl(e.target.value)}
          className="border border-gray-300 rounded px-3 py-2 text-base focus:outline-none focus:ring-2 focus:ring-black text-gray-500 min-h-[44px]"
        />

        {error && <p className="text-red-600 text-sm text-center">{error}</p>}

        <button
          type="submit"
          className="bg-black text-white rounded px-4 py-2 text-base font-medium hover:bg-gray-800 transition-colors min-h-[44px]"
        >
          Log in
        </button>
      </form>
    </div>
  );
}

```

### magic-table-web/app/chat/page.tsx

```typescript
"use client";

import { useState, useEffect, useRef, FormEvent } from "react";
import { useRouter } from "next/navigation";

interface Message {
  role: "user" | "assistant";
  text: string;
}

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [serverUrl, setServerUrl] = useState("");
  const bottomRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const router = useRouter();

  // Auth gate
  useEffect(() => {
    const authed = sessionStorage.getItem("authed");
    if (authed !== "true") {
      router.replace("/");
      return;
    }
    setServerUrl(
      sessionStorage.getItem("serverUrl") || "http://localhost:5050"
    );
  }, [router]);

  // Auto-scroll to bottom
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  async function handleSend(e: FormEvent) {
    e.preventDefault();
    const text = input.trim();
    if (!text || loading) return;

    // Add user message
    const userMsg: Message = { role: "user", text };
    setMessages((prev) => [...prev, userMsg]);
    setInput("");
    setLoading(true);

    try {
      const res = await fetch(`${serverUrl}/api/command`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });

      const data = await res.json();

      const reply =
        data.reply ||
        data.error ||
        "No response from server.";

      setMessages((prev) => [...prev, { role: "assistant", text: reply }]);
    } catch (err) {
      setMessages((prev) => [
        ...prev,
        {
          role: "assistant",
          text: `Could not reach server at ${serverUrl}. Is it running?`,
        },
      ]);
    } finally {
      setLoading(false);
      inputRef.current?.focus();
    }
  }

  async function handleAction(endpoint: string, label: string) {
    if (loading) return;

    setMessages((prev) => [...prev, { role: "user", text: label }]);
    setLoading(true);

    try {
      const res = await fetch(`${serverUrl}${endpoint}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({}),
      });

      const data = await res.json();

      const reply =
        data.reply ||
        data.error ||
        "No response from server.";

      setMessages((prev) => [...prev, { role: "assistant", text: reply }]);
    } catch (err) {
      setMessages((prev) => [
        ...prev,
        {
          role: "assistant",
          text: `Could not reach server at ${serverUrl}. Is it running?`,
        },
      ]);
    } finally {
      setLoading(false);
      inputRef.current?.focus();
    }
  }

  function handleLogout() {
    sessionStorage.clear();
    router.replace("/");
  }

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto">
      {/* Header */}
      <header className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
        <h1 className="text-lg font-semibold">Magic Table</h1>
        <button
          onClick={handleLogout}
          className="text-sm text-gray-400 hover:text-black transition-colors min-h-[44px] min-w-[44px] px-2 -mr-2"
        >
          Log out
        </button>
      </header>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
        {messages.length === 0 && (
          <p className="text-gray-400 text-sm text-center mt-12">
            Type a command like &quot;fetch the red bottle&quot; or just chat
            with Jarvis.
          </p>
        )}

        {messages.map((msg, i) => (
          <div
            key={i}
            className={`flex ${
              msg.role === "user" ? "justify-end" : "justify-start"
            }`}
          >
            <div
              className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap ${
                msg.role === "user"
                  ? "bg-black text-white"
                  : "bg-gray-100 text-black"
              }`}
            >
              {msg.text}
            </div>
          </div>
        ))}

        {loading && (
          <div className="flex justify-start">
            <div className="bg-gray-100 rounded-lg px-3 py-2 text-sm text-gray-400">
              Thinking...
            </div>
          </div>
        )}

        <div ref={bottomRef} />
      </div>

      {/* Quick-action buttons */}
      <div className="px-4 pt-2 flex gap-2">
        <button
          onClick={() =>
            handleAction("/api/describe", "What's on the table?")
          }
          disabled={loading}
          className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-base font-medium text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors disabled:opacity-30 min-h-[44px]"
        >
          What&apos;s on the table?
        </button>
        <button
          onClick={() => handleAction("/api/cleanup", "Clean up the table")}
          disabled={loading}
          className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-base font-medium text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors disabled:opacity-30 min-h-[44px]"
        >
          Cleanup
        </button>
      </div>

      {/* Input */}
      <form
        onSubmit={handleSend}
        className="border-t border-gray-200 px-4 py-3 flex gap-2"
      >
        <input
          ref={inputRef}
          type="text"
          placeholder="Type a command..."
          value={input}
          onChange={(e) => setInput(e.target.value)}
          disabled={loading}
          className="flex-1 border border-gray-300 rounded px-3 py-2 text-base focus:outline-none focus:ring-2 focus:ring-black disabled:opacity-50 min-h-[44px]"
          autoFocus
        />
        <button
          type="submit"
        
[truncated — 264 more characters]
```

### switch_diag.py

```python
"""
Limit-switch diagnostic for GRBL 1.1 + CNC Shield V3.

Shows the RAW pin state GRBL reports so you can verify switches work.
No baseline filtering — every pin that GRBL sees is printed so you can
tell exactly what changes (appears OR disappears) when you press a switch.

Hardware reality (CNC Shield V3 / Arduino Uno):
  - X-/X+ share one Arduino pin  (pin 9)
  - Y-/Y+ share one Arduino pin  (pin 10)
  - Z-/Z+ share one Arduino pin  (pin 11)
  Each pair is the SAME electrical input; GRBL can't tell + from -.

Bring-up sequence (per GRBL docs):
  1. $21=0  (hard limits OFF while testing)
  2. $22=1  (homing enabled for later)
  3. Observe raw pin states, toggle $5 to find correct polarity
  4. Press each switch → verify GRBL sees the change
  5. After confirmed: $21=1 (hard limits ON), then $H to home

Usage:
    python3 switch_diag.py --port /dev/cu.usbmodem14101
"""

import argparse
import re
import select
import sys
import time

import serial

# GRBL Pn: letters → human-readable names for your wiring
PIN_NAMES = {"X": "X", "Y": "Y+", "Z": "Y-"}


# ── Serial helpers ─────────────────────────────────────────────────────

def send(ser, cmd):
    """Send a command, wait briefly, return response text."""
    ser.reset_input_buffer()
    ser.write((cmd.strip() + "\n").encode())
    time.sleep(0.3)
    lines = []
    while ser.in_waiting:
        lines.append(ser.readline().decode("utf-8", errors="replace").strip())
    return " ".join(lines).strip()


def query_status(ser, timeout=0.5):
    """Send '?' and return the raw <…> status line."""
    ser.reset_input_buffer()
    ser.write(b"?")
    deadline = time.time() + timeout
    while time.time() < deadline:
        if ser.in_waiting:
            line = ser.readline().decode("utf-8", errors="replace").strip()
            if line.startswith("<") and ">" in line:
                return line
        else:
            time.sleep(0.01)
    return ""


def parse_state(status_line):
    """Extract GRBL state word (Idle, Alarm, Run …) from status line."""
    inner = status_line.strip("<>")
    return inner.split("|")[0] if inner else "?"


def parse_pins(status_line):
    """Extract active-pin letters from Pn: field (e.g. 'XYZ', 'Z', '')."""
    m = re.search(r"Pn:([A-Za-z]+)", status_line)
    return m.group(1) if m else ""


def pins_to_names(pins):
    """Pin letters → human names string, or '(none)'."""
    return ", ".join(PIN_NAMES.get(p, p) for p in pins) if pins else "(none)"


def sample_pins(ser, count=10):
    """Sample pins several times, return the most-common reading."""
    from collections import Counter
    readings = []
    for _ in range(count):
        raw = query_status(ser)
        readings.append(parse_pins(raw))
        time.sleep(0.1)
    if not readings:
        return ""
    return Counter(readings).most_common(1)[0][0]


# ── Main ───────────────────────────────────────────────────────────────

def main():
    ap = argparse.ArgumentParser(
        description="Limit-switch diagnostic (GRBL 1.1 / CNC Shield V3)")
    ap.add_argument("--port", required=True)
    ap.add_argument("--baud", type=int, default=115200)
    args = ap.parse_args()

    ser = serial.Serial(args.port, args.baud, timeout=1)
    time.sleep(2)
    while ser.in_waiting:
        ser.readline()

    # ── Step 1: safe starting state ────────────────────────────────
    send(ser, "$X")       # clear any alarm
    send(ser, "$21=0")    # hard limits OFF (prevent alarm on switch press)
    send(ser, "$22=1")    # homing cycle enabled (for later)
    send(ser, "$X")       # clear alarm again in case $22 triggered one

    # ── Step 2: probe both $5 polarities ───────────────────────────
    print("\n" + "=" * 60)
    print("  PROBING LIMIT PIN POLARITY")
    print("  Do NOT press any switches right now.")
    print("=" * 60)
    time.sleep(1)

    send(ser, "$5=0"); send(ser, "$X"); time.sleep(0.3)
    pins_0 = sample_pins(ser, 10)

    send(ser, "$5=1"); send(ser, "$X"); time.sleep(0.3)
    pins_1 = sample_pins(ser, 10)

    print(f"\n  $5=0 → idle Pn: {pins_to_names(pins_0):20s}  (raw: '{pins_0}')")
    print(f"  $5=1 → idle Pn: {pins_to_names(pins_1):20s}  (raw: '{pins_1}')")
    print()
    print("  Correct polarity = the $5 where idle shows FEWER pins.")
    print("  (Pins visible at idle with no switches pressed are phantoms")
    print("   from floating/pulled-up inputs with no switch attached.)")

    # Pick best $5 — fewer idle phantoms wins; tie-break $5=1 (NO + pull-up)
    if len(pins_1) < len(pins_0):
        best_5 = "1"
    elif len(pins_0) < len(pins_1):
        best_5 = "0"
    else:
        best_5 = "1"
    idle_pins = pins_0 if best_5 == "0" else pins_1
    print(f"\n  → Using $5={best_5}  (idle pins: {pins_to_names(idle_pins)})")
    send(ser, f"$5={best_5}")
    send(ser, "$X")

    # ── Step 3: live polling — show RAW state ──────────────────────
    print("\n" + "=" * 60)
    print("  LIVE PIN MONITOR  ($21=0, hard limits OFF)")
    print("=" * 60)
    print(f"  $5={best_5}")
    print()
    print("  Shows RAW Pn: field from GRBL — no filtering.")
    print("  When you press a switch, pins will APPEAR or DISAPPEAR:")
    print("    NO switch + $5=0 → pin DISAPPEARS on press")
    print("    NO switch + $5=1 → pin APPEARS on press")
    print()
    print("  If NOTHING changes when you press → wiring/contact issue.")
    print()
    print("  Keys: 'i'+Enter = toggle $5  |  'r'+Enter = dump raw status")
    print("         Ctrl+C = quit")
    print("=" * 60)
    print()

    current_5 = best_5
    prev_pins = None
    n = 0

    try:
        while True:
            raw = query_status(ser)
            state = parse_state(raw)
            pins = parse_pins(raw)
            n += 1

            # Auto-unlock alarms (shouldn't happen with $21=0, but just in case)
            if "alarm" in state.lower():
                send(ser, "$X")

            # ── Change detection (raw — no baseline filtering) ─────
            change_str = ""
         
[truncated — 1710 more characters]
```

### motortest.py

```python
"""
Motor and limit switch test for GRBL 1.1.

Modes:
  1. Single-axis back-and-forth (default)
  2. --seek-limits: move X/Y diagonally until limit alarm OR max distance

Does NOT override $5 (limit pin invert) — configure that with switch_diag.py first.

Usage:
    python3 motortest.py --port /dev/cu.usbmodem14101 --axis x
    python3 motortest.py --port /dev/cu.usbmodem14101 --seek-limits --feed 1200
"""

import argparse
import time

from grbl_controller import GRBLController, GRBLState

# GRBL settings applied before each test (does NOT include $5 — that's a
# hardware config set once via switch_diag.py and saved to EEPROM).
GRBL_SETTINGS = [
    ("$0", "10",    "step pulse (us)"),
    ("$1", "25",    "step idle delay (ms)"),
    # $5 intentionally omitted — set once via switch_diag.py and saved to EEPROM.
    ("$21", "1",    "hard limits ON"),
    ("$100", "160", "X steps/mm"),
    ("$101", "160", "Y steps/mm"),
    ("$102", "160", "Z steps/mm"),
    ("$110", "2000", "X max rate (mm/min)"),
    ("$111", "2000", "Y max rate (mm/min)"),
    ("$112", "2000", "Z max rate (mm/min)"),
    ("$120", "80",  "X accel (mm/s^2)"),
    ("$121", "80",  "Y accel (mm/s^2)"),
    ("$122", "80",  "Z accel (mm/s^2)"),
]

# GRBL pin → human name
PIN_NAMES = {"X": "X", "Y": "Y+", "Z": "Y-"}


def apply_settings(ctrl):
    print("Applying GRBL settings...")
    for key, val, desc in GRBL_SETTINGS:
        cmd = f"{key}={val}"
        ctrl.serial.reset_input_buffer()
        ctrl.serial.write((cmd + "\n").encode())
        time.sleep(0.3)
        resp = ""
        while ctrl.serial.in_waiting:
            resp += ctrl.serial.readline().decode("utf-8", errors="ignore").strip() + " "
        ok = "ok" if "ok" in resp.lower() else resp.strip()
        print(f"  {cmd:12s} ({desc}) -> {ok}")


def wait_idle(ctrl, timeout=60.0):
    start = time.time()
    while (time.time() - start) < timeout:
        s = ctrl.get_status()
        if s:
            if s.state == GRBLState.IDLE:
                return True
            if s.state == GRBLState.ALARM:
                return False
        time.sleep(0.1)
    return False


def pins_to_names(pins):
    return ", ".join(PIN_NAMES.get(p, p) for p in pins) if pins else ""


def unlock(ctrl):
    print("  Unlocking ($X)...")
    ctrl.serial.reset_input_buffer()
    ctrl.serial.write(b"$X\n")
    time.sleep(0.5)
    while ctrl.serial.in_waiting:
        ctrl.serial.readline()
    ctrl.get_status()


# ── Diagonal limit-seek test ──────────────────────────────────────────

def check_limit(ctrl):
    """Return description if alarm or Y-limit pin active, else None."""
    s = ctrl.get_status()
    if not s:
        return None
    pins = s.active_pins
    if s.state == GRBLState.ALARM:
        name = pins_to_names(pins) or "ALARM"
        return f"{name} (ALARM, pos=({s.position_x:.1f}, {s.position_y:.1f}))"
    # Check for Y-related pins
    if "Y" in pins or "Z" in pins:
        name = pins_to_names(pins)
        return f"{name} (pos=({s.position_x:.1f}, {s.position_y:.1f}))"
    return None


def seek_limit(ctrl, sign, step, feed, max_dist):
    """
    Jog X+Y together until:
    - A Y limit alarm/pin fires, OR
    - We've traveled max_dist mm (software safety stop)
    """
    label = "Y+" if sign > 0 else "Y-"
    max_steps = int(max_dist / step) + 1
    print(f"Seeking {label}: step={sign * step:.1f}mm, feed=F{feed:.0f}, max={max_dist:.0f}mm")

    traveled = 0.0
    for idx in range(1, max_steps + 1):
        # Check before moving
        hit = check_limit(ctrl)
        if hit:
            print(f"  {label} limit at {traveled:.1f}mm: {hit}")
            return True

        ok = ctrl.jog(sign * step, sign * step, feed_rate=feed)
        traveled += step

        if not ok:
            hit = check_limit(ctrl)
            if hit:
                print(f"  {label} limit at {traveled:.1f}mm: {hit}")
                return True
            print(f"  Jog failed at {traveled:.1f}mm, unlocking...")
            unlock(ctrl)
            continue

        if idx % 20 == 0:
            s = ctrl.get_status()
            if s:
                active = pins_to_names(s.active_pins)
                pin_str = f"  pins={active}" if active else ""
                print(f"  {traveled:.0f}mm  pos=({s.position_x:.1f}, {s.position_y:.1f})"
                      f"  state={s.state.value}{pin_str}")

    print(f"  Reached max distance ({max_dist:.0f}mm) without hitting {label} limit.")
    print(f"  (This is OK if you don't have switches wired yet.)")
    return False


def run_seek_limits(ctrl, step, feed, max_dist):
    print("\n" + "=" * 55)
    print("  DIAGONAL XY LIMIT TEST")
    print("  Moves X+Y together, stops on limit alarm or max distance")
    print("=" * 55)

    ctrl.soft_home()
    ctrl._send_command("G90", wait_for_ok=True)

    # Seek Y+
    found_plus = seek_limit(ctrl, +1, step, feed, max_dist)
    if found_plus:
        print("  Y+ limit confirmed.\n")
        unlock(ctrl)
        # Back off
        print("Backing off from limit...")
        ctrl._send_command("G91", wait_for_ok=True)
        ctrl._send_command(f"G1 X{-step * 5:.3f} Y{-step * 5:.3f} F{feed:.0f}", wait_for_ok=True)
        ctrl._send_command("G90", wait_for_ok=True)
        wait_idle(ctrl, timeout=10)
    else:
        print("  Y+ limit not found (stopped at max distance).\n")

    ctrl.soft_home()

    # Seek Y-
    found_minus = seek_limit(ctrl, -1, step, feed, max_dist)
    if found_minus:
        print("  Y- limit confirmed.\n")
        unlock(ctrl)
    else:
        print("  Y- limit not found (stopped at max distance).\n")

    return found_plus or found_minus


# ── Single-axis back-and-forth ────────────────────────────────────────

def run_axis_test(ctrl, axes, distance, feed, loops):
    print(f"\nMotor test: axes={axes}  distance={distance}mm  feed={feed}mm/min  loops={loops}")
    print("Ctrl+C to stop.\n")

    try:
        for axis in axes:
            label = axis.upper()
            print(f
[truncated — 3568 more characters]
```

### goto.py

```python
"""
goto.py — Move gantry to a point specified in a 400×400 virtual coordinate space.

Maps (0-400, 0-400) inputs to real mm using measured axis lengths from
axis_lengths.json. Tracks cumulative position from origin (0,0) and
clamps so the gantry never exceeds its physical travel in any direction.

The origin (0,0) in the 400-space maps to (0,0) mm — the homed front-left corner.
(400,400) maps to (x_mm, y_mm) — the full extent of each axis.

Usage:
    python3 goto.py --port /dev/cu.usbmodem14101 --x 200 --y 200
    python3 goto.py --x 0 --y 0                  # return to origin
    python3 goto.py --interactive                 # keep entering points

Can also be imported:
    from goto import GantryMover
    mover = GantryMover(port="/dev/cu.usbmodem14101")
    mover.connect()
    mover.goto(200, 200)   # center of 400x400 space
    mover.goto(0, 0)       # back to origin
    mover.disconnect()
"""

import argparse
import json
import os
import time

import serial
from serial.tools import list_ports

# ── Config ─────────────────────────────────────────────────────────────
VIRTUAL_SIZE = 400.0      # virtual coordinate space is 400 x 400
FEED_RATE = 3500          # mm/min for moves
MIRROR_Y_TO_Z = True
Y_Z_SCALE = 0.90          # Z gets 90% of Y so Y motor leads

AXIS_LENGTHS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "axis_lengths.json")


def load_axis_lengths():
    if not os.path.isfile(AXIS_LENGTHS_FILE):
        raise FileNotFoundError(
            f"No axis_lengths.json found. Run measure_axis.py first.")
    with open(AXIS_LENGTHS_FILE) as f:
        data = json.load(f)
    x_mm = data.get("x_mm")
    y_mm = data.get("y_mm")
    if x_mm is None or y_mm is None:
        raise ValueError(
            f"axis_lengths.json missing x_mm or y_mm. Run measure_axis.py for both axes.")
    return float(x_mm), float(y_mm)


# ── Serial helpers ─────────────────────────────────────────────────────

def _find_port():
    for p in list_ports.comports():
        if any(k in p.description.lower()
               for k in ["arduino", "usbmodem", "usbserial", "ch340", "ftdi"]):
            return p.device
    return None


class GantryMover:
    """Move gantry in a 400×400 virtual space, tracking real mm position."""

    def __init__(self, port=None, feed_rate=FEED_RATE):
        self.port = port or _find_port()
        self.feed_rate = feed_rate
        self.ser = None

        # Measured physical limits (mm)
        self.x_max_mm, self.y_max_mm = load_axis_lengths()

        # Scale factors: virtual units → mm
        self.x_scale = self.x_max_mm / VIRTUAL_SIZE
        self.y_scale = self.y_max_mm / VIRTUAL_SIZE

        # Current position in real mm (starts at origin after homing)
        self.pos_x_mm = 0.0
        self.pos_y_mm = 0.0

        print(f"  Axis lengths: X={self.x_max_mm:.1f}mm, Y={self.y_max_mm:.1f}mm")
        print(f"  Scale: 1 virtual unit = {self.x_scale:.3f}mm (X), {self.y_scale:.3f}mm (Y)")

    # ── Serial ─────────────────────────────────────────────────────

    def connect(self):
        if not self.port:
            raise RuntimeError("No Arduino found. Specify port.")
        self.ser = serial.Serial(self.port, 115200, timeout=1)
        time.sleep(2)
        while self.ser.in_waiting:
            self.ser.readline()
        self._send("$X")
        self._send("$5=1")
        self._send("$21=0")       # hard limits off during moves
        self._send("$110=6000")
        self._send("$111=6000")
        self._send("$112=6000")
        self._send("$120=200")
        self._send("$121=200")
        self._send("$122=200")
        self._send("$X")
        self._send("G90")
        self._send("G92 X0 Y0 Z0")  # current pos = origin
        self.pos_x_mm = 0.0
        self.pos_y_mm = 0.0
        print(f"  Connected to {self.port} — origin set at current position")

    def disconnect(self):
        if self.ser and self.ser.is_open:
            self.ser.close()
        print("  Disconnected.")

    def _send(self, cmd, timeout=5.0):
        self.ser.reset_input_buffer()
        self.ser.write((cmd.strip() + "\n").encode())
        deadline = time.time() + timeout
        while time.time() < deadline:
            if self.ser.in_waiting:
                line = self.ser.readline().decode("utf-8", errors="ignore").strip()
                if line:
                    low = line.lower()
                    if low == "ok" or low.startswith("error") or low.startswith("alarm"):
                        return line
                    if line.startswith("<"):
                        return line
            else:
                time.sleep(0.01)
        return ""

    def _wait_idle(self, timeout=30.0):
        deadline = time.time() + timeout
        while time.time() < deadline:
            self.ser.write(b"?")
            time.sleep(0.05)
            while self.ser.in_waiting:
                line = self.ser.readline().decode("utf-8", errors="ignore").strip()
                if line.startswith("<") and "Idle" in line:
                    return True
                if "Alarm" in line:
                    self._send("$X")
            time.sleep(0.05)
        return False

    # ── Core movement ──────────────────────────────────────────────

    def goto(self, vx: float, vy: float):
        """
        Move to (vx, vy) in the 400×400 virtual space.

        Positive values are absolute positions (0-400).
        Negative values are relative: move back toward origin by that amount.
          e.g. goto(-200, 0) from pos (300, 100) → go to (100, 100).
        Movement toward origin is clamped so you can't go below 0 (origin).
        Movement away from origin is clamped at axis max.
        """
        # Handle negative as relative (subtract from current virtual position)
        cur_vx = self.pos_x_mm / self.x_scale
        cur_vy = self.pos_y_mm / self.y_scale

        if vx < 0:
            vx = cur_vx + vx  # e.g. 300 + (-200) = 100
        if vy < 0:
            v
[truncated — 4144 more characters]
```

### magic-table-web/postcss.config.js

```javascript
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

```

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