An open-source ESP32-C3 desktop companion robot and personal IoT avatar.
License: CC BY-NC-SA 4.0 Platform Web Platform
Click the image above to watch the assembly video on YouTube.
| Component | Specification | Notes |
|---|---|---|
| MCU | ESP32-C3 Super Mini (e.g. Seeed XIAO ESP32-C3) | Valid GPIOs: 0-10, 20, 21 |
| OLED Display | SSD1306 128x64, I2C, address 0x3C | SH1106-compatible clones also supported |
| Touch Sensor | TTP223 capacitive touch module | Digital output (HIGH when touched) |
| Buzzer | Passive buzzer | Driven via PWM (LEDC) |
Download the STL files for the QBIT enclosure from MakerWorld:
Default pin assignments for the ESP32-C3 Super Mini. All pins can be reassigned through the web dashboard at http://qbit.local and are stored in NVS (persistent across reboots; changes require a reboot to take effect).
| Function | Default GPIO | Direction | Connection |
|---|---|---|---|
| Touch Sensor (TTP223) | GPIO 1 | Input | TTP223 OUT -> GPIO 1 |
| Buzzer | GPIO 2 | Output | GPIO 2 -> Buzzer +, Buzzer - -> GND |
| OLED SDA | GPIO 20 | I2C Data | SSD1306 SDA -> GPIO 20 |
| OLED SCL | GPIO 21 | I2C Clock | SSD1306 SCL -> GPIO 21 |
Power connections:
| Component | VCC | GND |
|---|---|---|
| SSD1306 OLED | 3.3V | GND |
| TTP223 Touch Sensor | 3.3V | GND |
| Passive Buzzer | -- | GND |
The I2C bus runs at 400 kHz. No external pull-up resistors are needed if the OLED module has built-in pull-ups (most breakout boards do).
The easiest way to flash QBIT firmware is through the browser-based flasher. No toolchain installation is required.
- Open the QBIT Firmware Flasher in Chrome or Edge (version 89+).
- Connect your QBIT board via USB.
- Click Connect & Flash and select the serial port.
- The flasher will write the bootloader, partition table, firmware, and filesystem image automatically.
After flashing, if Wi-Fi is not yet configured, QBIT enters Wi-Fi setup mode:
- The OLED shows a QR code (SSID and password) by default. Tap the touch sensor to switch to text: SSID
QBITand password (device MAC last 8 hex digits, e.g.1A2B3C4D). - Connect your phone or computer to the QBIT Wi-Fi access point using the password shown on screen.
- Once connected to the AP, a captive portal opens automatically (or open a browser manually to the setup page). Select your home Wi-Fi and enter its password.
- Credentials are saved to NVS and the device reconnects to your home Wi-Fi; you can then access the dashboard at
http://qbit.local.
If Wi-Fi is lost for about 30 seconds, QBIT automatically opens the AP again so you can reconfigure.
Once connected to Wi-Fi, the QBIT hosts a local web dashboard accessible at:
http://qbit.local
From the dashboard you can:
- View the unique device ID and set a custom display name
- Adjust display brightness, buzzer volume, and animation playback speed
- Upload and manage .qgif animation files
- Configure a local MQTT broker connection for home automation (see MQTT & Home Assistant)
The QBIT web platform can be self-hosted and provides a central interface for monitoring and interacting with all your online QBIT devices.
If you use the official firmware, your device will automatically connect to the official server: https://qbit.labxcloud.com
If you prefer, you can deploy your own web platform and backend on your own server, and configure the firmware to connect to your custom domain.
The Network page shows all currently connected QBIT devices as an interactive graph powered by vis-js/vis-network. Each node displays the device name. Devices that have been online longer are drawn closer to the central hub. The current number of online devices is displayed at the bottom.
Clicking a device node opens a poke dialog where logged-in users can:
- Poke -- send a text message (up to 25 characters) to the device. Both the sender name and message text are rendered as 1-bit bitmaps on the web to support multi-language display (including CJK and emoji) on the monochrome OLED. If the text exceeds the screen width, it scrolls horizontally.
- Claim -- bind a device to your account (see Device Claiming). Claimed devices show the owner's name and avatar on the graph node.
- Unclaim -- remove your ownership of a claimed device.
The Flash page embeds the browser-based firmware flasher, allowing users to flash their QBIT directly from the web platform without visiting a separate site.
The Library page is a community-driven repository of .qgif animation files.
QBIT uses a custom binary animation format (.qgif) optimized for the 128x64 monochrome OLED. The format stores 1-bit monochrome frames with per-frame delay values.
Binary layout
| Offset | Type | Description |
|---|---|---|
| 0 | uint8 | Frame count |
| 1-2 | uint16 LE | Width (pixels) |
| 3-4 | uint16 LE | Height (pixels) |
| 5+ | uint16 LE[] | Per-frame delay (ms), one per frame |
| ... | uint8[] | Frame data: 1024 bytes per frame (128x64 / 8), row-major |
Use the included conversion tool to create .qgif files from standard GIF animations:
pip install Pillow
python tools/gif2qbit.py input.gif
python tools/gif2qbit.py input.gif --threshold 100 --invert --scale stretch
python tools/gif2qbit.py *.gifOptions:
| Flag | Description |
|---|---|
-o / --output |
Output file path |
--threshold |
Binarization threshold (0-255, default 128) |
--invert |
Invert black/white |
--scale |
Scaling mode: fit (default), stretch, fit_width, fit_height |
To embed a .qgif animation into firmware as a PROGMEM constant (e.g. for idle or boot animations):
python tools/qgif2header.py firmware/include/sys_idle.qgif
This generates a C header file with the animation data as an AnimatedGIF struct, ready for #include in firmware source.
QBIT supports local MQTT integration with automatic Home Assistant discovery. Configure the MQTT broker connection from the device dashboard at http://qbit.local.
Once connected, the device publishes HA discovery payloads that automatically create the following entities in Home Assistant:
| Entity | Type | Description |
|---|---|---|
| Status | Binary Sensor | Online/offline connectivity status |
| IP | Sensor | Device local IP address |
| Poke | Button | Send a poke message to the device |
| Last Poke | Sensor | Last received poke (sender, message, time as attributes) |
| Mute | Switch | Toggle buzzer mute (ON = muted) |
| Touch | Sensor | Touch gesture: single_tap, double_tap, long_press |
| Next Animation | Button | Switch to the next .qgif animation |
MQTT topics (default prefix qbit)
| Topic | Dir | Description |
|---|---|---|
qbit/<id>/status |
Pub | online / offline (retained, with LWT) |
qbit/<id>/info |
Pub | Device info JSON (id, name, ip) |
qbit/<id>/command |
Sub | Commands. JSON: {"command":"poke","sender":"...","text":"..."} |
qbit/<id>/poke |
Pub | Poke event JSON (sender, text, time) |
qbit/<id>/mute/state |
Pub | Mute state ON / OFF (retained) |
qbit/<id>/mute/set |
Sub | Set mute: ON or OFF |
qbit/<id>/touch |
Pub | Touch event JSON (type: gesture type) |
qbit/<id>/animation/state |
Pub | Currently playing animation filename (retained) |
qbit/<id>/animation/next |
Sub | Trigger switch to next animation (no payload) |
Logged-in users can claim a QBIT device to bind it to their account. Claimed devices display the owner's name and avatar on the Network graph.
Claiming flow:
- Click a device on the Network page and select "Claim this device".
- Enter the full 12-character device ID (printed on the device dashboard).
- The web sends a claim request to the device via WebSocket.
- The QBIT OLED displays the requester's name and prompts for a long-press confirmation.
- Long-press the touch button on the device to confirm, or wait 30 seconds to reject.
- On confirmation, the claim is stored on the server and the device shows the owner's avatar on the graph.
Unclaiming:
Click a claimed device on the Network page and select "Unclaim this device". Only the owner can unclaim.
- A Linux server (VPS) with Docker and Docker Compose installed
- A domain name with DNS managed by Cloudflare (or any reverse proxy that provides TLS)
- A Google Cloud project with OAuth 2.0 credentials
Architecture
Internet
|
+-- Cloudflare Tunnel / Reverse Proxy (TLS termination)
|
+-- frontend (Nginx, port 80)
| |-- Serves React SPA
| |-- Proxies /api/, /auth/, /socket.io/, /device to backend
|
+-- backend (Node.js, port 3001 + 3002)
|-- REST API (devices, poke, library, claims)
|-- Google OAuth (Passport.js)
|-- Socket.io (real-time frontend updates)
|-- WebSocket /device (ESP32 device connections)
|-- Admin UI on port 3002 (sessions, users, devices, bans)
|-- SQLite DB + files in /data (volume)
Only the frontend container exposes port 80 (mapped to host 3000) to the internet. The backend listens on 3001 (API) and 3002 (admin); restrict port 3002 to internal/VPN. Backend storage: SQLite database qbit.db, library files under /data/files/, and optional /data/secrets.json for auto-generated session and health secrets.
Copy the example and fill in your values:
cd web
cp .env.example .env| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID |
OAuth 2.0 client ID from Google Cloud Console |
GOOGLE_CLIENT_SECRET |
OAuth 2.0 client secret |
GOOGLE_CALLBACK_URL |
OAuth callback URL, e.g. https://yourdomain.com/auth/google/callback |
COOKIE_DOMAIN |
Parent domain for cookies, e.g. .yourdomain.com |
FRONTEND_URL |
Full frontend URL for CORS, e.g. https://yourdomain.com |
DEVICE_API_KEY |
Shared secret with ESP32 firmware. Must match WS_API_KEY in firmware. |
MAX_DEVICE_CONNECTIONS |
Max device WebSocket connections (default: 100) |
ADMIN_USERNAME |
Admin UI login (1–64 chars). Empty = no admin login. |
ADMIN_PASSWORD |
Admin UI password (8–128 chars). |
Optional (auto-generated and stored in /data/secrets.json if not set): SESSION_SECRET, ADMIN_SESSION_SECRET, HEALTH_SECRET.
Generate secure random values (e.g. for DEVICE_API_KEY):
openssl rand -hex 32
Passwords with special characters: Use the .env file and wrap values in double quotes. Escape \ and " inside (e.g. ADMIN_PASSWORD="my\"pass").
For CI builds, set the QBIT_WS_API_KEY repository secret to the same value as DEVICE_API_KEY; it is injected into the firmware at build time.
cd web
docker compose -f docker-compose.dev.yml up --build| Endpoint | URL |
|---|---|
| Frontend | http://localhost:3000 |
| Backend API | http://localhost:3001 |
| Health | http://localhost:3001/health |
For Google OAuth locally, add http://localhost:3000/auth/google/callback to Authorized Redirect URIs in Google Cloud Console.
cd web
docker compose up --build -dThis starts the frontend (exposed on port 3000) and backend (internal only) containers. Point your reverse proxy or Cloudflare Tunnel to the frontend service on port 3000. The frontend Nginx configuration handles proxying all API, auth, WebSocket, and Socket.io traffic to the backend internally. Only port 3000 is intended for external access; the backend is not exposed to the internet.
Verify the deployment:
docker compose ps # both containers should be running docker compose logs backend # should show "QBIT backend listening on port 3001"
All persistent data is in the qbit-data volume (path /data inside the backend container): SQLite database qbit.db (sessions, users, claims, bans, library metadata), uploaded library files in files/, and secrets.json for auto-generated secrets.
The backend logs to stdout (pino, JSON in production). View with docker compose logs -f backend. No log files are written.
GET /health returns server status (uptime, devices, claims, online users, library count). Use ?format=json for JSON. From the server: docker exec qbit-frontend curl -s http://backend:3001/health.
Two workflows are included:
Build and Release (build-and-release.yml) -- triggered on version tags (v*):
- Compiles firmware with PlatformIO
- Builds the LittleFS filesystem image from
firmware/data/ - Injects
WS_HOSTandWS_API_KEYfrom repository secrets (QBIT_WS_HOST,QBIT_WS_API_KEY) - Creates a GitHub Release with
firmware.bin,littlefs.bin,bootloader.bin, andpartitions.bin
Deploy Flasher (deploy-gh-pages.yml) -- triggered after a successful build or on push to main:
- Downloads the latest release artifacts
- Generates
manifest.jsonfor esp-web-tools with partition offsets matchingfirmware/partitions.csv - Deploys the flasher tool to GitHub Pages
To set up CI/CD, add these repository secrets in GitHub (Settings > Secrets and variables > Actions > Repository secrets):
| Secret | Value |
|---|---|
QBIT_WS_HOST |
Your backend domain (e.g. qbit.labxcloud.com) |
QBIT_WS_API_KEY |
Same value as DEVICE_API_KEY in your .env |
Requirements: PlatformIO CLI
cd firmware pio run --target upload # compile and flash firmware pio run --target uploadfs # upload LittleFS filesystem (animations, web dashboard) pio device monitor # open serial monitor (115200 baud)
The firmware connects to the backend WebSocket server using the WS_HOST, WS_PORT, and WS_API_KEY defines in firmware/src/main.cpp. For local development, the defaults point to localhost:3001. For production builds via GitHub Actions, these values are injected from repository secrets at compile time.
Custom partition table (firmware/partitions.csv)
| Tool | Description |
|---|---|
tools/gif2qbit.py |
Convert standard GIF animations to the .qgif format |
tools/qgif2header.py |
Convert .qgif files to C header files for PROGMEM embedding |
tools/simulate-devices.py |
Simulate multiple QBIT devices connecting to the backend for testing |
tools/flasher/ |
Browser-based firmware flasher (deployed to GitHub Pages) |
This project is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0).
Commercial use of this project or any derivative works is not permitted without explicit permission from the author. For commercial licensing, contact: scx@gapp.nthu.edu.tw