Project Info
Inspiration
We’ve all been there: you desperately need to find a quiet place to study, but every room is taken. The Vision Our project, School of Fish, is designed to solve this. It works by placing inexpensive Raspberry Pi-based devices in each classroom and study room. These devices analyze the nearby Bluetooth, or more specifically Bluetooth Low Energy, signals emitted from occupants’ devices (phones, laptops, headphones, smart watches) to estimate the number of occupants in the room. Furthermore, by feeding this data through forecasting algorithms and machine learning models, we can accurately predict when a room is most likely to be vacant days in advance. One benefit of using Bluetooth signals over a computer vision-based solution is that it’s inherently anonymous. The only data we can get out of a Bluetooth signal is a 36 character long randomized identifier, and these are often rotated as frequently as every 15 minutes. Each device communicates with a central ingest API, periodically reporting the number of signals detected in a given room. These signals are stored in a database and plotted on an interactive map. This allows students to find available study spaces all across campus. Because it’s 2026, School of Fish also implements an MCP server. This allows interacting with School of Fish data through your favorite AI agents such as Poke, Claude, and Openclawd. Along with accessing the data, it works really well with calendar integration for scheduling study sessions at optimal times. For campuses, the data our systems provide can help them to determine which buildings on campus they need to expand or bring more attention to. It can also be used to analyze trends in time spent studying and correlations with student mental health during particularly stressful periods.
How we built it
School of Fish consists of four main components: the site monitoring devices, ingest API, interactive map, and MCP server. The ingest API, written in Go, is responsible for collecting data from each device across a college campus. This data is stored in a PostgreSQL database using the TimescaleDB and PostGIS extensions for aggregating temporal and efficient spatial querying. The interactive map and admin panel are a Next.js app written in TypeScript. The map uses OpenStreetMap imagery and GeoJSON data extracted from Stanford’s campus map to overlay accurate and interactive heatmaps of each building and its tracked study areas. When a study room is selected, a time-series graph is displayed showing historic and forecasted (future) occupancy based on data from days and weeks prior. These predictions are calculated using Holt-Winters seasonal forecasting which works best with our limited dataset. The on-site monitors are completely passive devices with software written in Python that periodically scan for nearby Bluetooth Low Energy Devices with a signal strength above a certain threshold. The data is then sent securely over a Tailscale VPN to the ingest API. The MCP server provides agents with advanced querying capabilities of current and forecasted traffic around campus. We opted to create a general AI integration over a custom one which allows our project to integrate with agents that people already use. This also enables working in tangent with their other integrations such as calendar management.
Challenges we ran into
The main challenge for this project is that forecasting requires a large amount of historical data. We opted to use weekly seasonality for our forecasting, which means it will recognize weekly trends such as a class that only meets on Monday afternoons, and it won’t influence the forecast of Tuesday afternoons. However, in order to perform forecasts this way, we would need at least two weeks of prior data, which obviously isn’t possible in a 36 hour hackathon. For the sake of demonstration we synthesized historic data based on the 24-ish hours of data collection we were able to perform during the hackathon. Originally, we planned to track the volume of WiFi packets as a means of measuring the number of people in a room, however, we found that this method was very dependent on the environment (e.g. access point setup, firewalling). Bluetooth Low Energy, which is emitted by the vast majority of modern consumer electronics, was a far more accurate way to measure human presence since it can be readily picked up without dependence on network configuration.
Accomplishments we're proud of
Ben - One of the coolest moments of the hackathon was getting the BLE sensors working. We took many walks with our laptops outside away from the buildings just to watch the number of nearby devices drop to zero, and immediately jump back up when we walked back near the buildings. Akhil - My favorite moment was waking up Saturday morning after my laptop had been collecting real data all night and seeing the gradual drop off at night and massive influx of devices at 9AM.
What we learned
Bluetooth may not be the best data transfer protocol, but its widespread use makes it a simple and anonymous metric for room occupancy. Accurate data forecasting is incredibly complex and requires a lot of raw data. AI can be, but is not a guaranteed speed boost in writing code. Writing code with AI is a path that has many traps you can fall down. For example, we completely rewrote the UI after AI skipped over major UX red flags.
What's next
There are so many ways this could be expanded and integrated into students’ lives: Automatically booking a room using the college’s booking system Automatically finding study rooms that fit into the schedules of students working on a group project Determining the average turnaround time for a study room Siri/voice assistant integration, “Hey Siri, find me an open study room” Additionally, it could also be used in settings beyond college campuses: Tracking how busy restaurants are Tracking attendance at event venues
School of Fish
Bluetooth crowd density monitoring system.
Architecture
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Bluetooth │────▶│ API Server (Go) │────▶│ TimescaleDB │
│ Sensors │POST │ chi router │ │ + PostGIS │
└──────────────────┘ └──────┬───────────┘ └──────────────────┘
│
┌────────▼─────────┐
│ Frontend │
│ Next.js + MapLibre│
└──────────────────┘
External Bluetooth sensors detect nearby devices and push detections to the API server via HTTP POST. The server hashes MAC addresses for privacy and bulk-inserts events into TimescaleDB. The frontend visualizes real-time and historical crowd density on a map.
Tech Stack
| Layer | Technology |
|---|---|
| Database | TimescaleDB (PostgreSQL) + PostGIS |
| Backend | Go, chi, pgx |
| Frontend | Next.js 15, React, MapLibre GL, Tailwind CSS |
| Infra | Docker Compose |
Quick Start
export INGEST_API_KEY="your-secret-key"
docker compose up
This starts:
- TimescaleDB on port
5432(with PostGIS, hypertables, and seed data) - Go API server on port
8080 - Next.js frontend on port
3000
Environment Variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string |
PORT | No | API server port (default: 8080) |
INGEST_API_KEY | Yes | Shared secret for sensor authentication |
Ingest Endpoint
Bluetooth sensors push device detections to the API server.
POST /api/ingest
Headers
| Header | Required | Description |
|---|---|---|
Content-Type | Yes | application/json |
X-API-Key | Yes | Shared secret matching INGEST_API_KEY |
Request Body
{
"sensor_id": "550e8400-e29b-41d4-a716-446655440000",
"devices": [
{"mac": "AA:BB:CC:DD:EE:FF", "rssi": -65},
{"mac": "11:22:33:44:55:66", "rssi": -72}
]
}
sensor_id— UUID of the reporting sensor (must exist insensorstable)devices— array of detected devices (1–1000 entries)mac— device MAC address (hashed server-side with SHA-256 before storage)rssi— received signal strength indicator
Responses
200 OK
{"ingested": 2}
400 Bad Request
{"error": "invalid request body"}
{"error": "invalid sensor_id"}
{"error": "devices array is required"}
{"error": "too many devices, max 1000"}
{"error": "each device must have mac and rssi"}
401 Unauthorized
{"error": "invalid api key"}
500 Internal Server Error
{"error": "failed to write events"}
Example
curl -X POST http://localhost:8080/api/ingest \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"sensor_id": "550e8400-e29b-41d4-a716-446655440000",
"devices": [
{"mac": "AA:BB:CC:DD:EE:FF", "rssi": -65},
{"mac": "11:22:33:44:55:66", "rssi": -72}
]
}'
Privacy
MAC addresses are never stored in plaintext. The server computes a SHA-256 hash of each MAC before inserting into the database.
Development
Backend
cd backend
export DATABASE_URL="postgres://fish:fish@localhost:5432/schooloffish?sslmode=disable"
export INGEST_API_KEY="dev-secret"
go run ./cmd/api
Frontend
cd frontend
npm install
npm run dev
Database
The database is automatically initialized with the schema from db/init.sql on first start. To reset:
docker compose down -v
docker compose up
Project Structure
school-of-fish/
├── docker-compose.yml # TimescaleDB + Go backend + Next.js frontend
├── README.md
├── backend/
│ ├── cmd/
│ │ └── api/main.go # API server entry point
│ ├── internal/
│ │ ├── db/ # Database connection + migrations
│ │ ├── models/ # Go structs matching DB tables
│ │ ├── ingest/ # Bluetooth ingest types + batch writer
│ │ ├── api/ # HTTP router + handlers
│ │ └── forecast/ # Holt-Winters forecasting
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── app/ # Next.js App Router pages
│ │ ├── components/ # React components
│ │ └── lib/ # API client helpers
│ ├── public/
│ │ └── campus.geojson # Example building polygons
│ └── Dockerfile
└── db/
└── init.sql # Database initialization script
Analysis
View
Metric
- 38
- 2
- 1
Figures cover GitHub contributors during the hackathon window. A co-authored commit counts in full for each author, so per-member totals add up to more than the whole-team figures.
Technology
- CSSIn code
- GoIn code
- JavaScriptIn code
- Next.jsIn code
- PostgreSQLIn code
- PythonIn code
- ReactIn code
- SQLIn code
- Tailwind CSSIn code
- TypeScriptIn code
10 of 10 appear in the indexed code.
AI coding agents
No AI coding agent signals were found in this repository.
Detected from committed agent config files and commit authorship. Absence of a signal is not proof an agent was unused.
Codebase size
Source size
134 KB
Source files
39
Counts recognized source files only; vendored directories, binaries and lockfiles are excluded, so this is smaller than the repository on disk.
Repository
Commandtechno/school-of-fish
64 files · 32.0 MB · @ 8d5b33b
Structure
Interface
9 files · 14%Screens, components and styles rendered to the user.
API & routing
4 files · 6%Request entry points: routes, handlers and controllers.
Application logic
20 files · 31%Domain rules, services and shared utilities.
+1 moreData & schema
4 files · 6%Schema definitions, migrations and data access.
Supporting
Layers are inferred from where files sit in the tree, not from reading the code. A project that names its directories unconventionally will read oddly here — open the file browser to check anything the diagram implies.
Languages
- TypeScript50%
- Go34%
- Python5%
- SQL4%
- Markdown4%
- YAML1%
- Other (2)1%
Share of indexed source by file size. Binary and vendored files are excluded.
Dependencies
frontend/package.json
npm · 14- maplibre-gl
- next
- react
- react-dom
- react-map-gl
- recharts
- +8 more
backend/go.mod
go · 10- github.com/go-chi/chi/v5
- github.com/go-chi/cors
- github.com/google/uuid
- github.com/jackc/pgx/v5
- nhooyr.io/websocket
- +5 more
mcp/package.json
npm · 10- @hono/mcp
- @hono/node-server
- @modelcontextprotocol/sdk
- hono
- pg
- zod
- +4 more
scanner/pyproject.toml
pypi · 3- bleak
- python-dotenv
- requests
frontend/public/package.json
npm · 1- fast-xml-parser
Declared in the repository’s manifests at the indexed commit. A declared package is not proof it is used, and runtime dependencies are listed first.
This project’s features have not been analysed yet.
Export this project's context (description, README, evidence, key source files) to chat with an AI agent elsewhere.