# Project export: DriveSenseTrainer

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: Ever wonder why so many people suck at driving? I do too. Drive-Sense-Trainer is a compact driving instructor that judges driver inputs in real time, guides drills and delivers personalized lessons.
- Devpost: https://devpost.com/software/drivesensetrainer
- GitHub: https://github.com/lukehollingsworth219/TreeHacks2026
- Video: https://www.youtube.com/embed/B1T7yL5hyBU?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Luke Hollingsworth (27 commits)

## Devpost submission (written by the team)

### Inspiration

A lot of my friends I ski with don’t have a license yet. They’re excited to learn, but also nervous, and paying for professional lessons can feel expensive or intimidating. I wanted to build something that makes practice less scary and more structured, so people can learn to drive properly (smooth, controlled, predictable) instead of just learning how to “get around.”

### What it does

Drive-Sense-Trainer is a compact driving instructor that scores your vehicle inputs in real time and turns every session into practice. Tracks smoothness using an IMU (longitudinal acceleration, lateral acceleration, and jerk) Detects harsh events like hard braking, harsh acceleration, sharp turns, and jerk spikes Runs short training drills like Brake Modulation and Smooth Starts Ends each session with a score + a personalized coaching lesson (what you did well, what to fix, and drills for next time) Includes a DEMO vs DRIVE mode so I can reliably test on a table and also validate in a real car

### How we built it

ESP32 firmware reads accelerometer data, estimates driving inputs, detects events, and prints live telemetry plus a session_end JSON summary. A Node.js bridge connects to the ESP32 over serial and broadcasts structured live updates to a local WebSocket server. A web dashboard shows live stat cards, timing, drill status, and session history comparisons, with controls to change mode/duration/drills without needing the Arduino Serial Monitor. After session_end, the app uses AI coaching to generate a more natural, instructor-style lesson (and optionally a live avatar can read it out loud).

### Challenges we ran into

The 3D printer queue and hardware pickup lines made iteration slow, especially working solo. I had to make do with what I could grab: jumper wires were way too long, and the LCD wasn’t ideal, but I made it work. Tuning IMU thresholds so the system is responsive and stable was tricky, especially across tabletop testing vs real driving.

### Accomplishments we're proud of

This was my first hackathon, and getting a real end-to-end product across the finish line solo was incredibly rewarding. The best part is that it’s not just a sensor demo: it’s interactive, gives drills, and produces coaching that feels like a learning loop.

### What we learned

Don’t let the little things spiral. Stay calm, adapt, and keep moving. Don’t obsess over “winning.” Focus on learning, building something you’re proud of, and making it real. A good demo is about clarity: live feedback, simple controls, and an obvious improvement path.

### What's next

for Drive-Sense-Trainer More real-world testing and calibration in different cars/mounting positions Potentially integrate with an OBD2 reader for ECU data like speed (to improve context and accuracy) Replace borrowed components with my own hardware after the hackathon Keep building projects like this because they’re genuinely fun and rewarding

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 2 recognized source files, 89 KB.
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- CSS (language) — claimed on Devpost, not found in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- OpenAI (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (15 of 15)

```
.DS_Store
.gitattributes
DriveSense_V2/DriveSense_V2.ino
drivesense-bridge/.gitignore
drivesense-bridge/data/session_history.json
drivesense-bridge/index.js
drivesense-bridge/package.json
drivesense-bridge/web/index.html
DriveSense/DriveSense.ino
ESP32_Test/ESP32_Test.ino
images/.DS_Store
IMU_Connectivity/IMU_Connectivity.ino
LCD_Test/LCD_Test.ino
READ_Axis/READ_Axis.ino
Read_IMU/Read_IMU.ino
```

### Dependencies

- drivesense-bridge/package.json: dotenv@^17.3.1, serialport@^13.0.0, ws@^8.19.0

### Recent commits (newest first)

- Images
- Update session_history.json
- History
- Update session_history.json
- Accel calibration
- Demo vs Drive
- Code complete
- Eco Mode
- Instructors
- Coach Options
- Flowing Coach
- Large Summary
- Ai live avatar working
- HeyGen Addition
- Fixed Everything
- Update Bridge
- UI changes
- code
- bridge
- serial

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

### drivesense-bridge/package.json

```
{
  "name": "drivesense-bridge",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "dependencies": {
    "dotenv": "^17.3.1",
    "serialport": "^13.0.0",
    "ws": "^8.19.0"
  }
}

```

### drivesense-bridge/index.js

```javascript
require("dotenv").config();
const fs = require("node:fs/promises");
const path = require("node:path");
const { SerialPort } = require("serialport");
const { ReadlineParser } = require("@serialport/parser-readline");
const WebSocket = require("ws");

// ===== CONFIG =====
const SERIAL_PORT = process.env.SERIAL_PORT || "/dev/cu.usbserial-0001";
const BAUD_RATE = Number(process.env.BAUD_RATE || 115200);
const WS_PORT = Number(process.env.WS_PORT || 8787);

const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4o-mini";

const HEYGEN_API_KEY = process.env.HEYGEN_API_KEY || "";
const HEYGEN_API_BASE = process.env.HEYGEN_API_BASE || "https://api.heygen.com";
const HEYGEN_AVATAR_ID = process.env.HEYGEN_AVATAR_ID || "Wayne_20240711";
const HEYGEN_VOICE_ID = process.env.HEYGEN_VOICE_ID || "";
const HEYGEN_QUALITY = process.env.HEYGEN_QUALITY || "high";
const COACH_REFRESH_TTL_MS = Number(process.env.COACH_REFRESH_TTL_MS || 10 * 60 * 1000);
const STATIC_COACH_OPTIONS = [{ id: HEYGEN_AVATAR_ID, label: "Default Coach" }];

const MODE_SERIAL_MAP = {
  CITY: "1",
  HIGHWAY: "2",
  SPORT: "3",
};

const DRILL_SERIAL_MAP = {
  OFF: "drill:off\n",
  SMOOTH_START: "drill:smooth\n",
  BRAKE_MOD: "drill:brake\n",
};

const TEST_SERIAL_MAP = {
  DRIVE: "test:drive\n",
  DEMO: "test:demo\n",
};

const HISTORY_DIR = path.join(__dirname, "data");
const HISTORY_FILE = path.join(HISTORY_DIR, "session_history.json");
const MAX_HISTORY_ITEMS = 80;

let avatarSession = null;
let avatarStarting = false;
let latestSummaryText = "";
let accountCoachOptions = [];
let coachRefreshPromise = null;
let coachOptionsLastRefreshMs = 0;

// Per-session trace built from live lines (for event spacing analytics)
let liveTrace = createEmptyTrace();

function createEmptyTrace() {
  return {
    startedAtMs: null,
    mode: null,
    durationSec: null,
    lastCounts: { HB: 0, HA: 0, HT: 0, HJ: 0 },
    timeline: [],
  };
}

function resetLiveTrace() {
  liveTrace = createEmptyTrace();
}

function normalizeCount(v, fallback = 0) {
  const n = Number(v);
  if (!Number.isFinite(n) || n < 0) return fallback;
  return Math.floor(n);
}

function numberOrNull(v) {
  const n = Number(v);
  return Number.isFinite(n) ? n : null;
}

function updateTraceFromLive(live) {
  const nowMs = Date.now();
  const tSec = numberOrNull(live.t);

  if (liveTrace.startedAtMs === null) {
    liveTrace.startedAtMs = tSec !== null ? nowMs - Math.max(0, tSec * 1000) : nowMs;
    liveTrace.mode = live.mode || null;
    liveTrace.durationSec = numberOrNull(live.dur) ?? numberOrNull(live.durationSec);
    liveTrace.lastCounts.HB = normalizeCount(live.HB, 0);
    liveTrace.lastCounts.HA = normalizeCount(live.HA, 0);
    liveTrace.lastCounts.HT = normalizeCount(live.HT, 0);
    liveTrace.lastCounts.HJ = normalizeCount(live.HJ, 0);
    return;
  }

  if (live.mode) liveTrace.mode = live.mode;
  if (numberOrNull(live.dur) !== null) liveTrace.durationSec = numberOrNull(live.dur);

  const eventKeys = ["HB", "HA", "HT", "HJ"];
  for (const key of eventKeys) {
    const prev = normalizeCount(liveTrace.lastCounts[key], 0);
    const rawCur = live[key];

    if (rawCur === undefined || rawCur === null || rawCur === "") continue;
    const cur = normalizeCount(rawCur, prev);

    if (cur < prev) {
      // Counter reset during active trace; restart trace around this line.
      liveTrace.startedAtMs = tSec !== null ? nowMs - Math.max(0, tSec * 1000) : nowMs;
      liveTrace.lastCounts[key] = cur;
      continue;
    }

    if (cur > prev) {
      const stamp = tSec !== null ? tSec : (nowMs - liveTrace.startedAtMs) / 1000;
      for (let i = prev; i < cur; i += 1) {
        liveTrace.timeline.push({ type: key, tSec: Number(stamp.toFixed(2)) });
      }
    }

    liveTrace.lastCounts[key] = cur;
  }
}

function summarizeTimeline(trace, session) {
  const events = [...trace.timeline].sort((a, b) => a.tSec - b.tSec);
  const eventCount = events.length;
  const durationSec = numberOrNull(session?.durationSec) || trace.durationSec || 0;

  if (!eventCount) {
    return {
      eventCount: 0,
      perType: { HB: Number(session?.HB || 0), HA: Number(session?.HA || 0), HT: Number(session?.HT || 0), HJ: Number(session?.HJ || 0) },
      firstEventSec: null,
      lastEventSec: null,
      avgGapSec: null,
      minGapSec: null,
      tightGapCount: 0,
      clusterCount: 0,
      eventsPerMinute: durationSec > 0 ? 0 : null,
      peakBurstWindowSec: null,
      peakBurstCount: 0,
    };
  }

  const gaps = [];
  for (let i = 1; i < events.length; i += 1) {
    gaps.push(events[i].tSec - events[i - 1].tSec);
  }

  const tightGapCount = gaps.filter((g) => g <= 2.0).length;

  let clusterCount = 0;
  let inCluster = false;
  for (const g of gaps) {
    if (g <= 3.0) {
      if (!inCluster) {
        clusterCount += 1;
        inCluster = true;
      }
    } else {
      inCluster = false;
    }
  }

  // Sliding 10-second burst window.
  let peakBurstCount = 0;
  let peakBurstWindowSec = events[0].tSec;
  let left = 0;
  for (let right = 0; right < events.length; right += 1) {
    while (events[right].tSec - events[left].tSec > 10) left += 1;
    const c = right - left + 1;
    if (c > peakBurstCount) {
      peakBurstCount = c;
      peakBurstWindowSec = events[left].tSec;
    }
  }

  return {
    eventCount,
    perType: {
      HB: Number(session?.HB || 0),
      HA: Number(session?.HA || 0),
      HT: Number(session?.HT || 0),
      HJ: Number(session?.HJ || 0),
    },
    firstEventSec: events[0].tSec,
    lastEventSec: events[events.length - 1].tSec,
    avgGapSec: gaps.length ? Number((gaps.reduce((a, b) => a + b, 0) / gaps.length).toFixed(2)) : null,
    minGapSec: gaps.length ? Number(Math.min(...gaps).toFixed(2)) : null,
    tightGapCount,
    clusterCount,
    eventsPerMinute: durationSec > 0 ? Number(((eventCount * 60) / durationSec).toFixed(2)) : null,
    peakBurstWindowSec: Number(peakBurstWindowSec.toFixed(2)),
    p
[truncated — 30432 more characters]
```