# Project export: LocalGuard

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: A private, distributed, & intelligent Security System. Imagine Ring, but Big Tech never sees your data.
- Devpost: https://devpost.com/software/localguard
- GitHub: https://github.com/RussellBustamante/LocalGuard
- Video: https://www.youtube.com/embed/VsIA-jDH0F8?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Anthony Bustamante (10 commits)

## Devpost submission (written by the team)

### Overview

Fully-local home security system: NO cloud, NO subscriptions. Your data never even leaves your house.

### Inspiration

Home security shouldn't require a cloud subscription or handing your private video feeds to Big Tech. At the Superbowl, Ring just announced that they'll utilize users' cameras to track lost pets. That's great, until it starts tracking people. Society continues to head down a "You will own nothing and be happy" direction, all amidst constant surveillance.

### What it does

LocalGuard is a network of small computers on a wired ethernet switch, each running a specialized AI task: Why leave your security in the hands of a corporation that doesn't care about you? And why overspend? How it was built Orange Pi 5 Max (Reasoning): Qwen3-4B via llama.cpp — lightweight distributed reasoning on ARM Spark (optional): 3 models running concurrently on a single Blackwell GPU via partitioned vLLM containers — YOLOv11 object detection (CPU auto-fallback, ~17 FPS), Cosmos-Reason2-2B (20% GPU, fast scene captions every ~1.5s), and Cosmos-Reason2-8B (40% GPU, deep temporal analysis every ~20s fusing frames from both cameras for cross-scene reasoning) Orange Pi 5 Plus (Voice): Parakeet TDT 0.6B (STT), Qwen3-1.7B via llama.cpp (LLM), Piper (TTS) — all CPU Jetson: YOLOv11 + TensorRT FP16, RealSense depth alignment, Moondream2 (1.86B VLM via Ollama) for scene-level descriptions running alongside YOLO with zero FPS impact Dashboard: Next.js, server-side sensor fusion merging dual-camera YOLO detections + VLM analysis, REST polling across nodes Dashboard: Next.js, server-side sensor fusion merging dual-camera YOLO detections + VLM analysis, REST polling across nodes Voice fast-path: Deterministic intent router bypasses LLM for common queries (<1s vs 3-5s) Voice fast-path: Deterministic intent router bypasses LLM for common queries (<1s vs 3-5s) Voice context grounding: Before every LLM query, the voice assistant fetches a live sensor snapshot from the dashboard's fusion API, so answers are grounded in real-time detections — not hallucinated Voice context grounding: Before every LLM query, the voice assistant fetches a live sensor snapshot from the dashboard's fusion API, so answers are grounded in real-time detections — not hallucinated Sensor fusion: Dashboard merges dual-camera YOLO detections, VLM scene descriptions, and proximity data into a unified risk score (0-100), alert level, and event timeline Sensor fusion: Dashboard merges dual-camera YOLO detections, VLM scene descriptions, and proximity data into a unified risk score (0-100), alert level, and event timeline Compute offload/fallback (planned): UI toggle to override Orange Pi STT/TTS processing to DGX Spark for lower latency when available. Also serves as a back-up in case the Orange Pi node fails or goes offline. Compute offload/fallback (planned): UI toggle to override Orange Pi STT/TTS processing to DGX Spark for lower latency when available. Also serves as a back-up in case the Orange Pi node fails or goes offline. Each node runs a Flask server. The dashboard fuses data server-side and serves a single UI. All communication stays on a local ethernet switch. There are zero cloud dependencies, zero subscriptions, zero external API calls.

## README (from the GitHub repository)

# LocalGuard

**Fully-local home security system: NO cloud, NO subscriptions. Your data never leaves your house.**

Built at TreeHacks 2026.

---

## Inspiration

Home security shouldn't require a cloud subscription or handing your private video feeds to Big Tech. At the Superbowl, Ring announced they'll use customers' cameras to track lost pets. That's great, until it starts tracking people. Society keeps heading toward "you will own nothing and be happy," all amidst constant surveillance.

We built LocalGuard to prove that a smart, multi-camera, voice-interactive security system can run **entirely on local hardware** — no internet required, no data ever leaving your home network.

## What It Does

LocalGuard is a network of small computers on a wired ethernet switch, each running a specialized AI task:

| Node | Hardware | Role |
|------|----------|------|
| **Camera** | NVIDIA Jetson Orin Nano ($250) | Real-time YOLO object detection with depth sensing at 30 FPS, plus Moondream2 VLM scene descriptions every ~5s |
| **Scene Intelligence** | NVIDIA DGX Spark ($2,999) | Runs **3 AI models concurrently** on 128 GB unified memory: YOLOv11 detection on scene camera (~17 FPS), Cosmos-Reason2-**2B** VLM for fast scene analysis every ~1.5s, and Cosmos-Reason2-**8B** VLM for deep multi-camera temporal reasoning every ~20s — all served simultaneously via vLLM with partitioned GPU memory |
| **Voice Assistant** | Orange Pi 5 Plus (~$280) | Wake-word activated. "Security, how many people?" — answered in <5s, fully on-device STT/LLM/TTS |
| **Reasoning** | Orange Pi 5 Max 16 GB (~$180) | Lightweight LLM node — distributed intelligence and low-cost compute |
| **Dashboard** | Any local computer | Web UI fusing all node data into risk scores, alert levels, event timeline, and live feeds |

LocalGuard is a modular framework. The core system runs on affordable ARM boards and a Jetson, combining detection, reasoning, and voice queries into a unified security posture with real-time alerting. The DGX Spark adds vision-language scene intelligence — its 128 GB unified memory lets us run multiple large models concurrently (YOLO + two VLMs) without swapping, something impossible on consumer GPUs.

## How It Works

### Sensor Fusion

The dashboard continuously polls all nodes and fuses their data into a unified security picture:

```
Jetson YOLO detections ──────────┐
Jetson VLM scene descriptions ───┤
Spark YOLO detections ───────────┤
Spark Fast VLM (2B, ~1.5s) ─────┼──→ Fusion Engine ──→ Risk Score (0-100)
Spark Deep VLM (8B, ~20s) ──────┤                      Alert Level
Orange Pi voice status ──────────┘                      Event Timeline
                                                        Scene Summary
```

- **Risk scoring** combines person count (both cameras), proximity (depth sensor), restricted object detection, and VLM keyword analysis across all three vision-language models
- **Alert levels** (low / guarded / elevated / critical) derived from risk score with thresholds
- **Timeline events** generated for person count changes, proximity alerts, restricted objects, and voice queries

### Voice Assistant

Fully on-device voice pipeline — wake word "Security" triggers:

1. **STT** — Parakeet TDT 0.6B (12.7x real-time) transcribes speech
2. **Intent Router** — deterministic fast-path for common queries (<1s response): "how many people?", "what's the status?", "any restricted objects?"
3. **LLM Fallback** — for open-ended questions, fetches live sensor context from the fusion API before querying the LLM, so answers are grounded in real detections — not hallucinated
4. **TTS** — Piper synthesizes the response and plays it back

### DGX Spark: Concurrent Multi-Model Inference

The Spark runs three models simultaneously on a single Blackwell GPU using partitioned vLLM containers:

| Model | GPU Allocation | Cadence | Role |
|-------|---------------|---------|------|
| YOLOv11 Nano | CPU (auto-fallback) | Real-time (~17 FPS) | Object detection on AKASO scene camera |
| Cosmos-Reason2-**2B** | 20% GPU | Every ~1.5s | Fast single-frame scene captions (~24 tok/s generation) |
| Cosmos-Reason2-**8B** | 40% GPU | Every ~20s | Deep temporal analysis fusing frames from **both** cameras (~6 tok/s generation) |

The 8B deep model ingests frames from both the local AKASO camera and the remote Jetson MJPEG stream, producing cross-scene temporal security assessments that capture patterns no single camera could see alone.

### Jetson: Detection + Depth + VLM

The Jetson runs YOLOv11 with TensorRT FP16 acceleration alongside an Intel RealSense D435 depth camera:

- **30 FPS** object detection with per-object depth measurements
- Proximity alerting when a person is within 1.5m of the camera
- **Moondream2** (1.86B VLM via Ollama) generates scene descriptions every ~5s with zero impact on YOLO FPS

## How It Was Built

- **Jetson**: YOLOv11 + TensorRT FP16, RealSense depth alignment, Moondream2 (1.86B VLM via Ollama)
- **Spark**: 3 models via partitioned vLLM Docker containers on Blackwell GPU — Cosmos-Reason2-2B (20% GPU), Cosmos-Reason2-8B (40% GPU), YOLOv11 (CPU)
- **Orange Pi 5 Plus (Voice)**: Parakeet TDT 0.6B (STT), Qwen3-1.7B via llama.cpp (LLM), Piper (TTS) — all CPU, all on-device
- **Orange Pi 5 Max (Reasoning)**: Qwen3-4B via llama.cpp — distributed reasoning on ARM
- **Dashboard**: Next.js 16, React 19, Tailwind 4, TypeScript — server-side sensor fusion across all nodes

Each node runs a Flask server. The dashboard fuses data server-side and serves a single UI. All communication stays on a local ethernet switch — **zero cloud dependencies, zero subscriptions, zero external API calls.**

---

## Technical Deep Dive

### Architecture

```
192.168.50.0/24 wired ethernet switch
   │
   ├── 192.168.50.1  MacBook         Dashboard (Next.js :3000)
   ├── 192.168.50.2  DGX Spark       YOLO + VLM inference (Flask :8090, vLLM :8001/:8002)
   ├── 192.168.50.3  Orange Pi 5+    Voice assistant (Flask :8070)
   └── 192.168.50.4  Jetson Nano     YOLO + depth + VLM (Flask :8080)
```

### Repo Layout

```
dashboard/                  Next.js app (React 19, Tailwind 4, TypeScript)
  app/page.tsx              Main page — node controls + posture banner + panels + feeds
  app/api/jetson/           Health check + SSH start/stop
  app/api/jetson/vlm/       Proxy for Jetson /vlm_results
  app/api/spark/            Health check + SSH start/stop
  app/api/spark/detections/ Proxy for Spark /detections (YOLO)
  app/api/spark/results/    Proxy for Spark /results, /results/fast, /results/deep
  app/api/detections/       Proxy for Jetson /detections
  app/api/orangepi/         Health check + SSH start/stop
  app/api/insights/         Fused snapshot endpoint
  app/api/insights/brief/   Compact context endpoint for voice assistant
  app/api/events/           Timeline events endpoint
  components/               UI components (CameraFeed, DetectionPanel, SparkInference,
                            VoiceAssistant, SecurityPosture, EventTimeline, NodeCard)
  lib/config.ts             Device URLs (env-overridable)
  lib/insights.ts           Sensor fusion engine + risk scoring + event generation
  lib/types.ts              Shared TypeScript types
jetson/stream.py            Flask server on Jetson (YOLO + D435 + Moondream VLM)
spark/spark_server.py       Flask server on Spark (YOLO + camera + dual VLM inference)
spark/start_vllm.sh         Multi-model vLLM launcher (partitioned GPU containers)
orangepi/voice_server.py    Flask server on Orange Pi (wake word + STT/LLM/TTS)
deploy.sh                   SCP files to devices and restart servers
```

### Sensor Fusion Engine (`insights.ts`)

The fusion engine runs server-side in Next.js, polling all nodes in parallel every ~700ms:

1. **Parallel fetch** — `Promise.all` across 6 endpoints (Jetson detections, Jetson VLM, Spark detections, Spark fast VLM, Spark deep VLM, Orange Pi status) with per-source timeouts (900-1500ms)
2. **Detection merge** — YOLO counts su

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 40 recognized source files, 144 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Flask (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (46 of 46)

```
.gitignore
dashboard/.gitignore
dashboard/app/api/detections/route.ts
dashboard/app/api/events/route.ts
dashboard/app/api/insights/brief/route.ts
dashboard/app/api/insights/route.ts
dashboard/app/api/jetson/route.ts
dashboard/app/api/jetson/vlm/route.ts
dashboard/app/api/orangepi/route.ts
dashboard/app/api/orangepi/status/route.ts
dashboard/app/api/spark/detections/route.ts
dashboard/app/api/spark/results/deep/route.ts
dashboard/app/api/spark/results/fast/route.ts
dashboard/app/api/spark/results/route.ts
dashboard/app/api/spark/route.ts
dashboard/app/globals.css
dashboard/app/layout.tsx
dashboard/app/page.tsx
dashboard/components/CameraFeed.tsx
dashboard/components/Clock.tsx
dashboard/components/DemoPrompts.tsx
dashboard/components/DetectionPanel.tsx
dashboard/components/EventTimeline.tsx
dashboard/components/NodeCard.tsx
dashboard/components/OccupancyTrend.tsx
dashboard/components/SecurityPosture.tsx
dashboard/components/SparkInference.tsx
dashboard/components/VoiceAssistant.tsx
dashboard/eslint.config.mjs
dashboard/lib/config.ts
dashboard/lib/hooks.ts
dashboard/lib/insights.ts
dashboard/lib/nodes.ts
dashboard/lib/types.ts
dashboard/next.config.ts
dashboard/package.json
dashboard/postcss.config.mjs
dashboard/README.md
dashboard/tsconfig.json
deploy.sh
jetson/stream.py
orangepi/voice_server.py
README.md
scripts/demo_preflight.sh
spark/spark_server.py
spark/start_vllm.sh
```

### Dependencies

- dashboard/package.json: @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, eslint@^9, eslint-config-next@16.1.6, next@16.1.6, react@19.2.3, react-dom@19.2.3, tailwindcss@^4, typescript@^5

### Recent commits (newest first)

- Create README.md
- Add DGX Spark fallback behavior future step
- Add YOLO to DGX Spark
- Deploy multiple VLMs to DGX Spark
- Improve layout
- Enhance project demo
- Begin data synthesis, improve deployment
- Update gitignore
- Add support for Voice Assistant Node prototype
- UI improvements
- Initial prototype commit

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

### dashboard/package.json

```
{
  "name": "dashboard",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "next": "16.1.6",
    "react": "19.2.3",
    "react-dom": "19.2.3"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### dashboard/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "LocalGuard",
  description: "Monitoring dashboard for LocalGuard",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" className="dark">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased bg-zinc-950 text-zinc-100`}
      >
        {children}
      </body>
    </html>
  );
}

```

### dashboard/app/page.tsx

```typescript
import CameraFeed from "@/components/CameraFeed";
import Clock from "@/components/Clock";
import DemoPrompts from "@/components/DemoPrompts";
import DetectionPanel from "@/components/DetectionPanel";
import EventTimeline from "@/components/EventTimeline";
import NodeCard from "@/components/NodeCard";
import OccupancyTrend from "@/components/OccupancyTrend";
import SecurityPosture from "@/components/SecurityPosture";
import VoiceAssistant from "@/components/VoiceAssistant";
import { NODES } from "@/lib/nodes";

export default function Home() {
  const streamNodes = NODES.filter((n) => n.streamUrl && n.apiRoute);

  return (
    <div className="min-h-screen p-6 md:p-8 max-w-7xl mx-auto">
      {/* Header */}
      <header className="mb-8 flex items-baseline justify-between">
        <div>
          <h1 className="font-mono text-xl font-bold uppercase tracking-widest text-zinc-100">
            LocalGuard
          </h1>
          <p className="font-mono text-xs text-zinc-600 mt-1">
            Distributed edge AI monitoring
          </p>
        </div>
        <Clock />
      </header>

      {/* Node Status Grid */}
      <section className="mb-8">
        <h2 className="font-mono text-xs font-semibold uppercase tracking-wider text-zinc-400 mb-3">
          Nodes
        </h2>
        <div className="bg-zinc-800 grid gap-px sm:grid-cols-2 lg:grid-cols-4 border border-zinc-800 overflow-hidden">
          {NODES.map((node) => (
            <NodeCard key={node.id} node={node} />
          ))}
        </div>
      </section>

      {/* Row 1: Video Feeds side by side */}
      <section className="mb-8">
        <h2 className="font-mono text-xs font-semibold uppercase tracking-wider text-zinc-400 mb-3">
          Feeds
        </h2>
        <div className="grid gap-4 sm:grid-cols-2">
          {streamNodes.map((node) => (
            <CameraFeed
              key={node.id}
              label={node.name}
              apiRoute={node.apiRoute!}
              streamUrl={node.streamUrl!}
              vlmRoute={
                node.id === "jetson"
                  ? "/api/jetson/vlm"
                  : node.id === "spark"
                    ? "/api/spark/results/fast"
                    : undefined
              }
            />
          ))}
        </div>
      </section>

      {/* Row 2: Security Posture + Occupancy/Detections */}
      <div className="grid gap-6 lg:grid-cols-2 mb-8">
        <SecurityPosture />
        <div className="flex flex-col gap-4">
          <OccupancyTrend />
          <DetectionPanel />
        </div>
      </div>

      {/* Row 3: Voice + Demo Prompts left, Timeline right */}
      <div className="grid gap-6 lg:grid-cols-2 mb-8">
        <div className="flex flex-col gap-4">
          <VoiceAssistant />
          <DemoPrompts />
        </div>
        <EventTimeline />
      </div>
    </div>
  );
}

```

### dashboard/app/api/insights/route.ts

```typescript
import { NextResponse } from "next/server";
import { getInsightsSnapshot } from "@/lib/insights";

export async function GET() {
  try {
    const data = await getInsightsSnapshot();
    return NextResponse.json(data);
  } catch {
    return NextResponse.json(
      {
        error: "insights_unavailable",
      },
      { status: 503 }
    );
  }
}

```

### dashboard/app/api/detections/route.ts

```typescript
import { NextResponse } from "next/server";
import { JETSON_DETECTIONS_URL } from "@/lib/config";

export async function GET() {
  try {
    const res = await fetch(JETSON_DETECTIONS_URL, { cache: "no-store" });
    const data = await res.json();
    return NextResponse.json(data);
  } catch {
    return NextResponse.json({ fps: 0, detections: [] });
  }
}

```

### dashboard/app/api/events/route.ts

```typescript
import { NextResponse } from "next/server";
import { getTimelineEvents } from "@/lib/insights";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const rawLimit = searchParams.get("limit");
  const limit = rawLimit ? Number.parseInt(rawLimit, 10) : 50;

  try {
    const events = await getTimelineEvents(Number.isFinite(limit) ? limit : 50);
    return NextResponse.json({ events });
  } catch {
    return NextResponse.json({ events: [] });
  }
}

```

### dashboard/app/api/jetson/route.ts

```typescript
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { JETSON_URL } from "@/lib/config";

const run = promisify(exec);
const JETSON_HOST = (() => {
  try {
    return new URL(JETSON_URL).hostname;
  } catch {
    return "192.168.50.4";
  }
})();
const SSH_TARGET = process.env.JETSON_SSH_TARGET ?? `antwon@${JETSON_HOST}`;
const SSH = `ssh -o ConnectTimeout=3 -o StrictHostKeyChecking=no ${SSH_TARGET}`;
const START_CMD =
  process.env.JETSON_START_CMD ??
  "cd ~/yolo && source .venv/bin/activate && nohup python3 stream.py > /tmp/stream.log 2>&1 < /dev/null &";
const STOP_CMD = process.env.JETSON_STOP_CMD ?? "pkill -f '[p]ython3 stream.py'";

async function isReachable(): Promise<boolean> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 2000);
  try {
    await fetch(JETSON_URL, { signal: controller.signal, cache: "no-store" });
    return true;
  } catch {
    return false;
  } finally {
    clearTimeout(timeout);
  }
}

export async function GET() {
  return NextResponse.json({ running: await isReachable() });
}

export async function POST(req: Request) {
  const { action } = await req.json();

  if (action === "start") {
    try {
      await run(`${SSH} -f "${START_CMD}"`);
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : "unknown error";
      return NextResponse.json(
        { ok: false, running: false, error: msg },
        { status: 500 }
      );
    }
    // Poll until server is actually serving (max 15s)
    for (let i = 0; i < 15; i++) {
      await new Promise((r) => setTimeout(r, 1000));
      if (await isReachable())
        return NextResponse.json({ ok: true, running: true });
    }
    return NextResponse.json({ ok: true, running: false });
  }

  if (action === "stop") {
    try {
      await run(`${SSH} "${STOP_CMD}"`);
    } catch {
      // pkill returns non-zero if no matching process
    }
    // Verify it's actually down (max 3s)
    for (let i = 0; i < 6; i++) {
      await new Promise((r) => setTimeout(r, 500));
      if (!(await isReachable()))
        return NextResponse.json({ ok: true, running: false });
    }
    return NextResponse.json({ ok: true, running: false });
  }

  return NextResponse.json({ error: "invalid action" }, { status: 400 });
}

```

### dashboard/app/api/orangepi/route.ts

```typescript
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { ORANGEPI_HEALTH_URL, ORANGEPI_URL } from "@/lib/config";

const run = promisify(exec);
const ORANGEPI_HOST = (() => {
  try {
    return new URL(ORANGEPI_URL).hostname;
  } catch {
    return "192.168.50.3";
  }
})();
const SSH_TARGET = process.env.ORANGEPI_SSH_TARGET ?? `ubuntu@${ORANGEPI_HOST}`;
const SSH = `ssh -o ConnectTimeout=3 -o StrictHostKeyChecking=no ${SSH_TARGET}`;
const START_CMD =
  process.env.ORANGEPI_START_CMD ??
  "cd ~/voice-assistant && source ~/voice-assistant-venv/bin/activate && nohup python3 voice_server.py > /tmp/voice_server.log 2>&1 < /dev/null &";
const STOP_CMD = process.env.ORANGEPI_STOP_CMD ?? "pkill -f '[p]ython3 voice_server.py'";

async function isReachable(): Promise<boolean> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 2000);
  try {
    await fetch(ORANGEPI_HEALTH_URL, {
      signal: controller.signal,
      cache: "no-store",
    });
    return true;
  } catch {
    return false;
  } finally {
    clearTimeout(timeout);
  }
}

export async function GET() {
  return NextResponse.json({ running: await isReachable() });
}

export async function POST(req: Request) {
  const { action } = await req.json();

  if (action === "start") {
    try {
      await run(`${SSH} -f "${START_CMD}"`);
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : "unknown error";
      return NextResponse.json(
        { ok: false, running: false, error: msg },
        { status: 500 }
      );
    }
    for (let i = 0; i < 20; i++) {
      await new Promise((r) => setTimeout(r, 1000));
      if (await isReachable())
        return NextResponse.json({ ok: true, running: true });
    }
    return NextResponse.json({ ok: true, running: false });
  }

  if (action === "stop") {
    try {
      await run(`${SSH} "${STOP_CMD}"`);
    } catch {
      // pkill returns non-zero if no matching process
    }
    for (let i = 0; i < 6; i++) {
      await new Promise((r) => setTimeout(r, 500));
      if (!(await isReachable()))
        return NextResponse.json({ ok: true, running: false });
    }
    return NextResponse.json({ ok: true, running: false });
  }

  return NextResponse.json({ error: "invalid action" }, { status: 400 });
}

```

### dashboard/app/api/spark/route.ts

```typescript
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { SPARK_HEALTH_URL, SPARK_URL } from "@/lib/config";

const run = promisify(exec);
const SPARK_HOST = (() => {
  try {
    return new URL(SPARK_URL).hostname;
  } catch {
    return "192.168.50.2";
  }
})();
const SSH_TARGET = process.env.SPARK_SSH_TARGET ?? `asus@${SPARK_HOST}`;
const SSH = `ssh -o ConnectTimeout=3 -o StrictHostKeyChecking=no ${SSH_TARGET}`;
const START_CMD =
  process.env.SPARK_START_CMD ??
  "cd ~/cam-inference && source .venv/bin/activate && nohup python3 spark_server.py > /tmp/spark_server.log 2>&1 < /dev/null &";
const STOP_CMD = process.env.SPARK_STOP_CMD ?? "pkill -f '[p]ython3 spark_server.py'";

async function isReachable(): Promise<boolean> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 2000);
  try {
    await fetch(SPARK_HEALTH_URL, {
      signal: controller.signal,
      cache: "no-store",
    });
    return true;
  } catch {
    return false;
  } finally {
    clearTimeout(timeout);
  }
}

export async function GET() {
  return NextResponse.json({ running: await isReachable() });
}

export async function POST(req: Request) {
  const { action } = await req.json();

  if (action === "start") {
    try {
      await run(`${SSH} -f "${START_CMD}"`);
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : "unknown error";
      return NextResponse.json(
        { ok: false, running: false, error: msg },
        { status: 500 }
      );
    }
    // Poll until server is actually serving (max 15s)
    for (let i = 0; i < 15; i++) {
      await new Promise((r) => setTimeout(r, 1000));
      if (await isReachable())
        return NextResponse.json({ ok: true, running: true });
    }
    return NextResponse.json({ ok: true, running: false });
  }

  if (action === "stop") {
    try {
      await run(`${SSH} "${STOP_CMD}"`);
    } catch {
      // pkill returns non-zero if no matching process
    }
    // Verify it's actually down (max 3s)
    for (let i = 0; i < 6; i++) {
      await new Promise((r) => setTimeout(r, 500));
      if (!(await isReachable()))
        return NextResponse.json({ ok: true, running: false });
    }
    return NextResponse.json({ ok: true, running: false });
  }

  return NextResponse.json({ error: "invalid action" }, { status: 400 });
}

```

### dashboard/app/api/spark/results/route.ts

```typescript
import { NextResponse } from "next/server";
import { SPARK_RESULTS_URL } from "@/lib/config";

export async function GET() {
  try {
    const res = await fetch(SPARK_RESULTS_URL, { cache: "no-store" });
    const data = await res.json();
    return NextResponse.json(data);
  } catch {
    return NextResponse.json([]);
  }
}

```

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