# Project export: Plantcasso

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: UC Berkeley AI Hackathon 2026
- Tagline: What's a plant saying? We've given a plant a robot arm with a pencil, mapped the servo controls to the plant's biosignals, and let it draw on a canvas, visualizing its life and environmental stimuli.
- Devpost: https://devpost.com/software/plantcasso-qhu2so
- GitHub: https://github.com/Maltomatic/Plantcasso
- Demo: https://shangen.org/blog/posts/berkeley-ai-hackathon-2026-plantcasso/
- Video: https://player.vimeo.com/video/1203254537?byline=0&portrait=0&title=0#t=
- Team: 4 GitHub contributor(s) — Maltomatic (13 commits), Shang En Sim (6 commits), Devin (3 commits), Angel Li (1 commits)

## Devpost submission (written by the team)

### Inspiration

The starting point for our project was artist David Bowen's Plant Machete, where plant signals were mapped into control signals for a robot arm holding a machete. We decided to take it one step further--what if the temporal changes of the artistic expression were retained? And better yet if this piece of technoart also had some practical uses.

### What it does

This landed us with Plantcasso. By mapping a plant's electric biosignals into servo control signals, we are able to visualize through both the arm's movements and drawn brushstrokes a plant's hidden signs of life and signs of agitation from the environment. This in turn converts our piece into a multi-functional installation: it is as much a work of performance art as visual art; the plant's paintings can serve as true random signals for cryptographic hashes akin to CloudFlare's lava lamps; anomalies such as wildfires, air quality degredation, or plant sickness can be detected AND visually presented as chaos in the plant's paintings.

### How we built it

The project can be broken down into four main segments. The first and most critical is reading the plant signals. By using conductive gel pads and an INA333 instrument amplifier we are able to detect and boost the voltage differences across a plant's leaves and stem, which are then read into an ESP32-S3 Supermini. The second part of the project is an unsupervised clustering model. With a dataset of collected plant readings, we sample sequences, extract a number of meaningful features, and project them into 3D space, after which we run a clustering algorithm. This is trained on a laptop; the resulting weights are deployed onto the ESP32-S3 for real-time inference. The third segment is the arm control: we 3D printed a robot arm motivated by 5 MG-90 servos, and map the plant signals to points in the legal range of motion such that as the plant lives, so does the arm--and so does the painting. Finally, we implement a deshboard where we can see rolling graphs of information such as the plant's agitation, detected voltage, and spikes in activity.

### Challenges we ran into

The first challenge was just getting the signals at all. Plant biosignals are infamously weak and noisy. A lot of work went into filtering the signal to keep it as clear and clean as possible; not just through software filters but also in hardware with changing pad adhesion points periodically, braiding cables, and building physical isolation. The second challenge was finding features that made sense for anomaly detection. A little literature research yielded the hjorth complexity as a good indicator of externally-induced spikes, and thus was weighed more heavily in our final clustering implementation. Finally, the largest nightmare was also teh simplest: getting the servos to play nice. Using a 16-channel servo control board over I2C was fickle, to say the least. For this we relied on redundancies and checks, but at the end of the day we have little choice but to cross our fingers.

### Accomplishments we're proud of

The project works--it reads signals, it reacts to agitation, and the arm does what it's supposed to. The fact that such an abstract idea was able to be realized in such a short time is amazing enough in and of itself. The clustering was clean, the arm looked alive, and aside from lacking a more reliable servo motor control interface it's just about everything we envisioned it to be.

### What we learned

The devil's in the details, and it's the things you least expect that might trip you up. We thought processing the plant signals would be the hard part, but it was fighting with the servo motors that kept us up all night. ALWAYS verify your hardware works! We also learned that mint plant stems are more fragile than expected.

### What's next

We're going to build a better hardware rig and try a broader range of plants than just our proof-of-concept mint. The idea is to gather a generalized dataset and map corresponding clusters for a variety of environmental stimuli such as fire, carbon dioxide concentrations, and other signals that we can't test at the venue but would make this infinitely more practical and useful in disaster-prone areas.

## README (from the GitHub repository)

# PlantCasso

Turning a plant's bioelectric signals into expressive robot-arm motion.
Built at the **Berkeley AI Hackathon 2026**.

An electrode on a plant is sampled by an ESP32-S3 ADC. The signal is filtered,
reduced to 9 cheap time-domain features per window, projected to 3D with PCA and
clustered with K-means. The resulting embedding (plus two raw features) drives a
5-DOF servo arm so the plant's electrical "mood" becomes visible movement.

```
plant electrode → ESP32 ADC → lowpass filter → 9 features → StandardScaler
              → PCA (3D) → K-means cluster → servo angles (smoothed) → arm
```

## Repository layout

| Path | What it is |
|------|------------|
| `pipeline/` | Offline Python ML pipeline (filter → features → PCA/K-means → C header) |
| `pipeline/out/` | Generated artifacts (figures, model, CSVs). **Git-ignored** — regenerate by running the pipeline |
| `plant_inference/` | ESP32-S3 firmware that runs real-time inference + servo control |
| `plant_inference/model_params.h` | Auto-generated C header (scaler/PCA/K-means/filter) — produced by `pipeline/02_train.py` |
| `POC_electrode_reader/` | Minimal Arduino sketch that streams raw ADC voltage over serial |
| `data/` | Recorded voltage datasets (`data_5hz.csv`, `data_100hz.csv`, `data_unhealthy.csv`) |
| `data.csv` | Raw capture from a collection session |
| `data_collection.py` | Logs serial voltage samples from the ESP32 to a CSV |
| `Base.3mf` | 3D-printable arm base model |
| `ref_plantsignal.md`, `ref_plantsignal_converter.py` | Design notes / reference end-to-end script |
| `AGENTS.md` | Embedded C++ coding guidelines for the firmware |

## Quick start

### 1. Python environment

This project uses [`uv`](https://docs.astral.sh/uv/) (see `pyproject.toml` /
`.python-version`):

```bash
uv sync
```

Or with plain pip:

```bash
pip install numpy pandas scipy scikit-learn matplotlib pyserial
```

### 2. Run the pipeline (in order)

```bash
python pipeline/01_filter_extract.py --csv data/data_100hz.csv  # → pipeline/out/features.csv, filter_sos.json
python pipeline/02_train.py                                     # → model.pkl, plant_inference/model_params.h
python pipeline/03_visualize.py                                 # → pipeline/out/fig1..3.png
```

`python main.py` prints this sequence as a reminder.

### 3. Flash the firmware

1. Open `plant_inference/plant_inference.ino` in the Arduino IDE (Arduino-ESP32
   core ≥ 2.0) with `model_params.h` alongside it.
2. Install the **Adafruit PWM Servo Driver Library** (pulls in **Adafruit BusIO**).
   The 5 servos are driven through a **PCA9685** over I2C.
3. Wire the PCA9685: `SDA/SCL` → `PIN_I2C_SDA`/`PIN_I2C_SCL`, `V+` → a dedicated
   5–6 V servo supply (not the 3.3 V rail), `GND` common with the ESP32.
4. Set `PIN_PLANT`, the I2C pins, `SERVO_CH`, and the `SERVO_US_MIN/MAX` pulse
   range to match your wiring/servos, then upload.

To just capture data, flash `POC_electrode_reader/` instead and run
`python data_collection.py --port <your-port>`.

## How it works

**9 features per 1 s window** (`pipeline/01_filter_extract.py`): `mean`, `std`,
`ptp`, `slope`, `zcr`, `spike_count`, `hjorth_mobility`, `hjorth_complexity`,
`rms_first_diff`. They are intentionally FFT-free so the exact same math runs in
Python (training) and C++ on the ESP32 (inference) — keep
`extract_window()` and `extract_features()` in sync.

**Servo mapping** (`pipeline/02_train.py` → firmware): joints 0–2 follow the 3
PCA axes (slow, smooth "posture"), while joints 3–4 follow `spike_count` and
`hjorth_complexity` directly (snappy, expressive transients).

> **Note on windowing:** the training window hop is set in
> `pipeline/01_filter_extract.py` (`HOP = 50`), while the firmware's
> `HOP_SIZE` is the on-device inference cadence and is configured independently
> in the generated header.

See `ref_plantsignal.md` for the design rationale behind the feature choices,
window size, and PCA-vs-autoencoder decision.

## Firmware conventions

C++ for the ESP32 follows the guidelines in [`AGENTS.md`](AGENTS.md) — C++23,
`float`-only math (no hardware `double`), no heap allocation on hot paths, and
fixed compile-time buffer sizes.


## Detected evidence (automated analysis)

Indexed codebase: 29 recognized source files, 268 KB.
- C (language) — detected in the code
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- HTML (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
- C++ (language) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository
- AI coding agent: Codex — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (49 of 49)

```
.gitignore
.python-version
AGENTS.md
Base.3mf
data_collection.py
data.csv
data/data_100hz.csv
data/data_5hz.csv
data/data_unhealthy.csv
main.py
pipeline/01_filter_extract.py
pipeline/02_train.py
pipeline/03_visualize.py
plant_inference/model_params.h
plant_inference/model_params.h.bak
plant_inference/plant_inference.ino
plant-dashboard/.gitignore
plant-dashboard/AGENTS.md
plant-dashboard/app/api/stream/route.ts
plant-dashboard/app/components/Dashboard.tsx
plant-dashboard/app/globals.css
plant-dashboard/app/layout.tsx
plant-dashboard/app/lib/serial.ts
plant-dashboard/app/page.tsx
plant-dashboard/CLAUDE.md
plant-dashboard/eslint.config.mjs
plant-dashboard/next.config.ts
plant-dashboard/package.json
plant-dashboard/pnpm-workspace.yaml
plant-dashboard/postcss.config.mjs
plant-dashboard/README.md
plant-dashboard/tsconfig.json
POC_electrode_reader/POC_electrode_reader.ino
pyproject.toml
README.md
ref_plantsignal_converter.py
ref_plantsignal.md
servo_sample/servo_sample.ino
uv.lock
web_browser/dashboard-2/app.py
web_browser/dashboard-2/requirements.txt
web_browser/dashboard-2/templates/index.html
web_browser/dashboard-2/templates/index2.html
web_browser/dashboard-2/templates/Rainly.otf
web_browser/dashboard/app.py
web_browser/dashboard/backend.py
web_browser/dashboard/plant-81856-firebase-adminsdk-fbsvc-3ef0631cfd.json
web_browser/dashboard/templates/index.html
web_browser/dashboard/test_firebase.py
```

### Dependencies

- plant-dashboard/package.json: @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, eslint@^9, eslint-config-next@16.2.9, next@16.2.9, react@19.2.4, react-dom@19.2.4, recharts@^3.8.1, serialport@^13.0.0, tailwindcss@^4, typescript@^5
- pyproject.toml: matplotlib@>=3.9, numpy@>=1.26, pandas@>=2.2, pyserial@>=3.5, scikit-learn@>=1.5, scipy@>=1.13
- web_browser/dashboard-2/requirements.txt: flask, flask-socketio, pyserial

### Recent commits (newest first)

- Fix truncated telemetry JSON dropping chaos/volts fields
- Fix Recharts ResponsiveContainer zero-dimension warning
- Remove simulated-data fallback from dashboard serial reader
- Redesign plant-dashboard with daylight botanical telemetry theme
- Migrate dashboard-2 to Next.js with server-side serial reading
- Emit inference output as JSON via ArduinoJson
- dashboard
- servo bounce reduce
- Merge branch 'main' of https://github.com/Maltomatic/Plantcasso
- PCA communication spotty - need I2C fix?
- Drive servos via PCA9685 over I2C
- Organise repo: docs, deps, gitignore, untrack generated artifacts
- Serial output dict for graph
- electro side + K-means done
- Add agent guidelines for C++ development on ESP32, focusing on language standards, memory management, modern C++ practices, and coding style recommendations.
- Add new datasets: 5Hz, 100Hz, and unhealthy data for analysis and processing
- Add initial project structure for plantcasso with Python version 3.14, main function, and project metadata in pyproject.toml
- references
- references
- expand 100Hz dataset

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

### AGENTS.md

```markdown
# Plantcasso — Agent Guidelines

## C++ Guidelines (ESP32 / Embedded)

### Language & Toolchain
- **Use C++23** (`-std=gnu++23`). Target is the **ESP32** (Xtensa LX6/LX7 or RISC-V on
  -C3/-S3 variants). Use the ESP-IDF toolchain (GCC) or Arduino-ESP32 core built on it.
- The ESP32 has a **single-precision FPU only** (no hardware `double`). Always prefer
  `float` and `float`-suffixed literals (`1.0f`). Avoid `double` in hot paths — it forces
  slow software emulation.
- Enable warnings: `-Wall -Wextra -Wshadow -Wconversion`.

### Memory: no surprise heap allocation
- **No dynamic allocation in steady-state / real-time paths.** Avoid `new`, `malloc`,
  `std::vector`, `std::string`, `std::map`, etc. on hot paths. Heap fragmentation on a
  long-running MCU leads to failures.
- Size buffers at compile time. Prefer `std::array<T, N>` over C arrays and over `std::vector`.
- Use **templates with compile-time dimensions** (e.g. `KMeans<N, DIM, K>`) so sizes are
  known to the compiler and storage can live in `.bss`/`.data` or on the stack.
- Place large objects in `static`/global storage (`.bss`) rather than on the stack — the
  ESP32 default task stack is small (~8 KB). Watch stack depth in recursive/deep calls.
- For caller-provided memory, accept a `std::span<T>` or a fixed pool; never allocate
  internally.

### Modern C++ for embedded (prefer these)
- `constexpr` / `consteval` — push computation to compile time; build lookup tables at
  compile time instead of runtime.
- `std::array`, `std::span` (C++20), `std::string_view` — zero-overhead, no allocation.
- `enum class` for type-safe states; `[[nodiscard]]`, `[[likely]]`/`[[unlikely]]`.
- Strong typing over raw ints (units, pin numbers). Use `std::int32_t`, `std::uint16_t`
  etc. from `<cstdint>` — never assume `int` width.
- `if constexpr` for compile-time branching across board variants.
- RAII for hardware resources (GPIO, I2C/SPI handles, mutexes) — acquire in ctor, release
  in dtor.
- `std::optional` / `std::expected` (C++23) for error handling instead of magic return
  values; avoid heavyweight machinery on hot paths.

### Avoid / use with care
- **Exceptions and RTTI**: typically disabled on ESP-IDF (`-fno-exceptions -fno-rtti`) to
  save flash/RAM. Don't write code that depends on them. Use `std::expected`/error codes.
- **`<iostream>`**: pulls in large code; use `printf`/ESP-IDF `ESP_LOGx` for logging.
- Recursion and unbounded loops in ISRs. Keep ISRs tiny; defer work to tasks/queues.
- `double`, `std::function` (heap-allocating), and virtual dispatch in hot paths.

### Style
- Squared Euclidean distance instead of `sqrt` when only comparing magnitudes.
- Deterministic, seedable RNG (small LCG) instead of `<random>` global state.
- Keep numerical work in `float`; use fixed-point integer math on FPU-less targets.

```

### ref_plantsignal.md

```markdown
Works end-to-end. Here's the reasoning and the script.

## The 9 features I picked

Chosen to map cleanly onto your three demo goals (spikes / chaos / environmental change) and to stay cheap enough for live computation:

| Feature | Captures | Demo role |
|---|---|---|
| `mean` | DC level relative to baseline | environmental state |
| `slope` | linear trend within window | environmental drift direction |
| `std` | overall variability amplitude | general activity level |
| `ptp` | peak-to-peak range | spike amplitude |
| `zero_crossings` | oscillation rate | chaos (frequency-ish) |
| `spike_count` | threshold-crossing peak count | spikes, directly |
| `hjorth_complexity` | waveform shape complexity | chaos |
| `band_power_ratio` | high-freq vs low-freq energy | chaos vs calm balance |
| `spectral_entropy` | disorder of the frequency spectrum | chaos, directly |

## Key decisions baked into the script

**Window size: 1s (100 samples), 50% overlap, not 5s.** Your dataset is only ~5 minutes (30k samples @ 100Hz = 300s). At 5s windows you'd get ~120 observations — thin for clustering and laggy for a live demo. At 1s windows with overlap you get ~600, which is workable for both. The trade-off: shorter windows mean noisier individual feature estimates, which the filtering step partly compensates for.

**PCA, not an autoencoder, for the 3D embedding.** With ~600 windows, an autoencoder would more likely memorize noise than learn real structure. PCA is also linear and deterministic, which matters for the servo side: smooth input changes produce smooth, predictable output changes — no risk of the arm jerking unpredictably because a nonlinear embedding had a discontinuity nearby.

**Filtering**: a 30 Hz Butterworth lowpass (your signal's meaningful content lives well under that; everything above is most likely just noise/EMI), plus an optional notch filter for mains hum — disabled by default since I don't know your environment. If you see hum, set `NOTCH_HZ = 60` (US) or `50` (most of the rest of the world).

**The 3D→5-servo mapping is split deliberately.** Joints 0-2 follow the 3 PCA dimensions (smooth, slow "posture" reflecting overall state). Joints 3-4 (wrist, gripper) follow `spike_count` and `band_power_ratio` directly rather than through PCA — so a spike or a burst of chaotic activity shows up as a sharp, distinct flick rather than getting smoothed into an averaged blob. The `ServoSmoother` class applies different exponential smoothing per joint (slow for posture, fast for the expressive joints) so the arm reads as alive rather than either frozen or twitchy.

**`send_to_servos()` is a stub** — I don't know your servo driver (ServoKit/PCA9685, Arduino over serial, GPIO PWM, etc.), so it just prints. Swap in your actual hardware call there.

One honest caveat: with only ~600 windows, K-means found k=2 as the best silhouette split (0.72) — treat anything beyond 2-3 clusters with real skepticism on a dataset this size; you'd want a longer recording to t
[truncated — 276 more characters]
```

### pyproject.toml

```
[project]
name = "plantcasso"
version = "0.1.0"
description = "Plant bioelectric signals → feature extraction → PCA/K-means → 5-DOF servo arm (Berkeley AI Hackathon 2026)."
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
    "numpy>=1.26",
    "pandas>=2.2",
    "scipy>=1.13",
    "scikit-learn>=1.5",
    "matplotlib>=3.9",
    "pyserial>=3.5",
]

[project.scripts]
plantcasso = "main:main"

```

### plant-dashboard/package.json

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

```

### web_browser/dashboard-2/requirements.txt

```
flask
flask-socketio
pyserial
```

### main.py

```python
"""
PlantCasso entry point.

This is a thin convenience launcher. The real work lives in the numbered
pipeline scripts, which are designed to run in order:

    python pipeline/01_filter_extract.py   # raw CSV → filtered → features.csv
    python pipeline/02_train.py            # features → PCA + K-means → model_params.h
    python pipeline/03_visualize.py        # clustering figures in pipeline/out/

Firmware that consumes the generated model lives in plant_inference/.
"""

PIPELINE_STEPS = [
    ("pipeline/01_filter_extract.py", "Filter signal and extract 9 windowed features"),
    ("pipeline/02_train.py", "Scale → PCA(3D) → K-means, export model_params.h"),
    ("pipeline/03_visualize.py", "Render clustering / time-series / correlation figures"),
]


def main() -> None:
    print("PlantCasso — plant bioelectric signals → 5-DOF servo arm\n")
    print("Run the pipeline in order:")
    for script, desc in PIPELINE_STEPS:
        print(f"  python {script:<32s} {desc}")


if __name__ == "__main__":
    main()

```

### plant-dashboard/app/page.tsx

```typescript
import Dashboard from "./components/Dashboard";

export default function Home() {
  return <Dashboard />;
}

```

### plant-dashboard/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono, Fraunces } 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"],
});

// Fraunces — the organic, optical serif. Carries the "life" voice of the
// instrument: used with restraint for the wordmark and the hero state.
const fraunces = Fraunces({
  variable: "--font-fraunces",
  subsets: ["latin"],
  axes: ["opsz", "SOFT"],
});

export const metadata: Metadata = {
  title: "Plantcasso — Biosignal Agitation Dashboard",
  description: "Real-time plant biosignal anomaly detection using K-means clustering",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="en"
      className={`${geistSans.variable} ${geistMono.variable} ${fraunces.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col">{children}</body>
    </html>
  );
}

```

### web_browser/dashboard/app.py

```python
from flask import Flask, render_template, jsonify
from firebase_admin import credentials, firestore
import firebase_admin
import plotly.graph_objects as go
import plotly.utils
import json
import time

# ----------------------------
# 1. Firebase setup
# ----------------------------

cred = credentials.Certificate("plant-81856-firebase-adminsdk-fbsvc-3ef0631cfd.json")
firebase_admin.initialize_app(cred)
db = firestore.client()

# ----------------------------
# 2. Flask app
# ----------------------------

app = Flask(__name__)

def get_recent_anomalies(limit=50):
    docs = db.collection("plant_anomalies") \
        .order_by("timestamp") \
        .limit(limit) \
        .get()

    timestamps = []
    voltage_means = []
    scores = []
    anomalies = []

    for doc in docs:
        data = doc.to_dict()
        ts = data.get("timestamp")
        if ts is None:
            continue
        # Convert Firestore timestamp to something usable
        if hasattr(ts, "nano"):
           timestamps.append(ts.timestamp())
        else:
            timestamps.append(time.time())

        voltage_means.append(data["voltage_mean"])
        scores.append(data["anomaly_score"])
        anomalies.append(data["is_anomaly"])

    return timestamps, voltage_means, scores, anomalies

@app.route("/")
def index():
    timestamps, voltage_means, scores, anomalies = get_recent_anomalies()
    return render_template("index.html")

@app.route("/data")
def data():
    timestamps, voltage_means, scores, anomalies = get_recent_anomalies()
    return jsonify({
        "timestamps": timestamps,
        "voltage_means": voltage_means,
        "scores": scores,
        "anomalies": anomalies,
    })

if __name__ == "__main__":
    app.run(debug=True, port=5000)
```

### web_browser/dashboard-2/app.py

```python
from flask import Flask, render_template
from flask_socketio import SocketIO
import serial
import json
import time
import random
import threading

app = Flask(__name__, static_folder='static')
socketio = SocketIO(app)

# ESP32 serial port - adjust to your system
# On macOS: /dev/cu.usbserial-...
# On Windows: COM3, COM4, etc.
SERIAL_PORT = "/dev/cu.usbmodem101"
SERIAL_BAUDRATE = 115200

ser = None

def connect_serial():
    try:
        ser = serial.Serial(SERIAL_PORT, SERIAL_BAUDRATE, timeout=1)
        print(f"Connected to serial on {SERIAL_PORT}")
    except Exception as e:
        print(f"Serial connection failed: {e}")
        ser = None

def parse_esp32_message(line):
    """
    Parse ESP32 string:
    C:0  S0:166  S1:98  S2:10  S3:33  S4:75    mean:-0.000  spk:0.0200  chaos:1.382
      volts:0.1725
    """
    try:
        # Extract cluster from "C:0"
        cluster = 0
        if "C:" in line:
            cluster_str = line.split("C:")[1].split()[0]
            cluster = int(cluster_str)
        
        # Extract mean from "mean:-0.000"
        mean = 0.0
        if "mean:" in line:
            mean_str = line.split("mean:")[1].split()[0]
            mean = float(mean_str)
        
        # Extract std from "spk:0.0200"
        std = 0.0
        if "spk:" in line:
            std_str = line.split("spk:")[1].split()[0]
            std = float(std_str)
        
        # Extract hjorth from "chaos:1.382"
        hjorth = 0.0
        if "chaos:" in line:
            hjorth_str = line.split("chaos:")[1].split()[0]
            hjorth = float(hjorth_str)
        
        # Spike count: set to 0 or extract from S0-S4
        # For now, set to 0 since you don't have a direct spike count
        spike_count = 0
        
        return {
            "mean": mean,
            "std": std,
            "spike_count": spike_count,
            "hjorth": hjorth,
            "cluster": cluster
        }
    except Exception as e:
        print(f"Parse error: {e}, line: {line}")
        return None
    
# def serial_read_loop():
#     """
#     Background thread that reads serial and emits data to the browser.
#     """
#     global ser
#     connect_serial()
#     if ser is None:
#         return

#     while True:
#         try:
#             if ser.in_waiting > 0:
#                 line = ser.readline().decode("utf-8", errors="ignore").strip()
#                 if not line:
#                     continue
#                 data = parse_esp32_message(line)
#                 if data:
#                     data["timestamp"] = time.time()
#                     socketio.emit("new_data", data)
#         except Exception as e:
#             print(f"Serial read error: {e}")
#             time.sleep(1)


#test func while usb is being used / no live data

def serial_read_loop():
    """
    Simulate ESP32 data for testing.
    Replace this with real serial reading when ESP32 is connected.
    """
    while True:
        data = {
            "mean": 1.5 + random.uniform(-0.1, 0.1),
            "std": random.uniform(0.01, 0.03),
            "spike_count": random.randint(0, 3),
            "hjorth": random.uniform(0.7, 1.1),
            "cluster": random.randint(0, 2),
            "timestamp": time.time(),
        }
        socketio.emit("new_data", data)
        time.sleep(0.5)  # send every 0.5 seconds

@app.route("/")
def index():
    return render_template("index.html")

if __name__ == "__main__":
    # Start serial reading in a background thread
    t = threading.Thread(target=serial_read_loop, daemon=True)
    t.start()

    socketio.run(app, host="0.0.0.0", port=5001, debug=True)
    
```

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