Replace USB serial/HID with Bluetooth Low Energy

Move all communication from USB to BLE. The device now advertises as
"Claude Controller" and acts as both a BLE HID keyboard (for touch
gestures) and a GATT data server (for usage updates from the daemon).

- Add NimBLE-Arduino BLE module with custom GATT service + HID keyboard
- Add third screen (Bluetooth) with connection status, MAC, reset button
- Rewrite daemon in bash using bluetoothctl/busctl for BLE GATT writes
- Add screenshot capture via LVGL snapshot over serial
- Remove TinyUSB mode — normal pio upload works again
- Update README with BLE architecture, screenshots, gesture docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann Björgvin Haraldsson
2026-04-04 19:30:19 +00:00
co-authored by Claude Opus 4.6
parent 930055170f
commit 10b8052bcc
22 changed files with 917 additions and 167 deletions
+82 -17
View File
@@ -1,58 +1,121 @@
# Claude Usage Tracker - Panlee SC01 Plus
Displays your Claude Code usage limits on a Panlee SC01 Plus (ESP32-S3) 3.5" touchscreen.
Bluetooth-connected Claude Code usage monitor and touch controller on a Panlee SC01 Plus (ESP32-S3) 3.5" touchscreen.
![Demo](assets/demo.gif)
Shows your 5-hour session and 7-day weekly utilization.
## Features
- **Usage dashboard** — Live 5-hour session and 7-day weekly utilization bars with color-coded thresholds
- **Touch controller** — Gesture-based Claude Code navigation (swipe, tap, hold) sent as BLE HID keyboard input
- **Bluetooth screen** — Connection status, device name, MAC address, bond management
- **Wireless** — All communication over Bluetooth Low Energy (USB only for charging and flashing)
- **Auto-reconnect** — Daemon discovers and reconnects automatically with exponential backoff
## Hardware
- [Panlee SC01 Plus](http://en.smartpanle.com/product-item-15.html) (WT32-SC01 Plus) — ESP32-S3, 3.5" 480x320 IPS, capacitive touch
- USB-C cable for power and data)
- USB-C cable for flashing firmware and charging
## Prerequisites
- Linux (tested on my Ubuntu laptop)
- Linux (tested on Ubuntu)
- [PlatformIO CLI](https://docs.platformio.org/en/latest/core/installation/index.html)
- `curl`, `awk`, `inotify-tools` (pre-installed on most Linux distros, install `inotify-tools` if missing)
- Claude Code
- User in `dialout` group: `sudo adduser $USER dialout` (log out/in after)
- `curl`, `bluetoothctl`, `busctl` (BlueZ Bluetooth stack)
- Claude Code with an active subscription
## Flash the firmware
```bash
cd firmware
pio run -t upload
pio run -t upload --upload-port /dev/ttyACM0
```
The SC01 Plus appears at `/dev/ttyACM0`.
## Bluetooth pairing
## Run the daemon
The daemon polls your Claude usage every 30 seconds and sends it to the display over USB serial.
After flashing, the device advertises as **"Claude Controller"**. Pair it once:
```bash
bash daemon/claude-usage-daemon.sh
# Scan for the device
bluetoothctl scan le
# When "Claude Controller" appears, pair and trust it
bluetoothctl pair F4:12:FA:C0:8F:E5 # use your device's MAC
bluetoothctl trust F4:12:FA:C0:8F:E5
```
## Install the systemd service
The MAC address is shown on the Bluetooth screen (third screen, tap the logo to cycle).
To auto-start the daemon on login:
## Install the daemon
The daemon polls your Claude usage every 30 seconds and sends it to the display over BLE.
```bash
./install.sh
systemctl --user start claude-usage-daemon
```
Check status: `systemctl --user status claude-usage-daemon`
View logs: `journalctl --user -u claude-usage-daemon -f`
## How it works
1. The daemon reads your Claude Code OAuth token from `~/.claude/.credentials.json`
2. Makes a minimal API call to `api.anthropic.com/v1/messages` (1 token of Haiku, essentially free)
3. Extracts usage data from the response headers (`anthropic-ratelimit-unified-5h-utilization`, etc.)
4. Sends a JSON line over USB serial to the ESP32
4. Connects to the ESP32 over BLE and writes a JSON payload to the GATT RX characteristic
5. The ESP32 parses it and updates the LVGL dashboard
6. Touch gestures on the Controller screen are sent as BLE HID keyboard input to the host
## Screens
Tap the Claude logo (top-left) to cycle between screens:
| Usage | Controller | Bluetooth |
| :-----------------------------: | :---------------------------------------: | :-------------------------------------: |
| ![Usage](screenshots/usage.png) | ![Controller](screenshots/controller.png) | ![Bluetooth](screenshots/bluetooth.png) |
| Session and weekly utilization | Touch zones and swipe gestures | Connection status and reset |
## Gesture controls
On the **Controller** screen:
| Gesture | Action |
| ------------- | ----------------------- |
| Swipe up | Arrow Up |
| Swipe down | Arrow Down |
| Swipe left | Shift+Tab (toggle mode) |
| Swipe right | Enter |
| Hold (center) | Space (voice dictation) |
Touch zones (tap):
| Zone | Action |
| ------------ | ----------- |
| Top-left | Escape |
| Bottom-left | Arrow Left |
| Top-right | Delete |
| Bottom-right | Arrow Right |
## BLE protocol
The device advertises a custom GATT service alongside the standard HID keyboard service:
| | UUID |
| -------------------------- | -------------------------------------- |
| **Data Service** | `4c41555a-4465-7669-6365-000000000001` |
| RX Characteristic (write) | `4c41555a-4465-7669-6365-000000000002` |
| TX Characteristic (notify) | `4c41555a-4465-7669-6365-000000000003` |
| **HID Service** | `00001812-0000-1000-8000-00805f9b34fb` |
JSON payload format (written to RX):
```json
{ "s": 45, "sr": 120, "w": 28, "wr": 7200, "st": "allowed", "ok": true }
```
Fields: `s` = session %, `sr` = session reset (minutes), `w` = weekly %, `wr` = weekly reset (minutes), `st` = status, `ok` = success flag.
## Recompiling fonts
@@ -98,12 +161,14 @@ The controller screen uses [Lucide](https://lucide.dev) icons (the same icon set
1. Get Lucide SVGs from [github.com/lucide-icons/lucide](https://github.com/lucide-icons/lucide) (`icons/` directory)
2. Convert SVG to PNG at desired size:
```bash
inkscape icons/delete.svg --export-width=32 --export-height=32 \
--export-filename=assets/icon_delete.png --export-background-opacity=0
```
3. Convert PNG to RGB565 C array:
```python
from PIL import Image
@@ -125,7 +190,7 @@ for y in range(img.height):
# Write as C array to firmware/src/icons.h
```
Current icons: `delete`, `arrow-left`, `arrow-right`, `circle-arrow-out-up-left` (escape), `hand` (gestures).
Current icons: `delete`, `arrow-left`, `arrow-right`, `circle-arrow-out-up-left` (escape), `hand` (gestures), `bluetooth`.
## Licensing gray area warning
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m7 7 10 10-5 5V2l5 5L7 17" />
</svg>

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

+3 -1
View File
@@ -1,5 +1,7 @@
[Unit]
Description=Claude Usage Tracker Daemon
Description=Claude Usage Tracker Daemon (BLE)
Requires=bluetooth.target
After=bluetooth.target
[Service]
Type=simple
+143 -45
View File
@@ -1,14 +1,16 @@
#!/bin/bash
# Claude Usage Tracker Daemon
# Reads Claude Code OAuth token, polls usage via API, sends to ESP32 over serial.
# Survives USB plug/unplug cycles using inotifywait for instant device detection.
# Dependencies: curl, awk, inotify-tools
# Claude Usage Tracker Daemon (BLE)
# Reads Claude Code OAuth token, polls usage via API, sends to ESP32 over BLE GATT.
# Auto-connects and reconnects to the Claude Controller BLE device.
# Dependencies: curl, awk, bluetoothctl
SERIAL_PORT="${SERIAL_PORT:-/dev/ttyACM0}"
SERIAL_DEV=$(basename "$SERIAL_PORT")
DEVICE_NAME="Claude Controller"
DEVICE_MAC="${DEVICE_MAC:-}" # auto-discovered if empty
SERVICE_UUID="4c41555a-4465-7669-6365-000000000001"
RX_CHAR_UUID="4c41555a-4465-7669-6365-000000000002"
POLL_INTERVAL=30
BAUD=115200
SERIAL_FD=3
SAVED_MAC_FILE="$HOME/.config/claude-usage-monitor/ble-address"
DBUS_DEST="org.bluez"
log() {
echo "[$(date '+%H:%M:%S')] $1"
@@ -18,27 +20,112 @@ read_token() {
grep -o '"accessToken":"[^"]*"' "$HOME/.claude/.credentials.json" | cut -d'"' -f4
}
device_connected() {
[ -e "$SERIAL_PORT" ]
# Convert MAC to D-Bus path: AA:BB:CC:DD:EE:FF -> dev_AA_BB_CC_DD_EE_FF
mac_to_dbus_path() {
local adapter
adapter=$(busctl call org.bluez / org.freedesktop.DBus.ObjectManager GetManagedObjects 2>/dev/null | grep -o '/org/bluez/hci[0-9]' | head -1)
adapter=${adapter:-/org/bluez/hci0}
echo "${adapter}/dev_$(echo "$1" | tr ':' '_')"
}
open_serial() {
stty -F "$SERIAL_PORT" "$BAUD" raw -echo -echoe -echok 2>/dev/null || return 1
eval "exec ${SERIAL_FD}<>\"$SERIAL_PORT\"" 2>/dev/null || return 1
# Check if device is connected via D-Bus
is_connected() {
local path
path=$(mac_to_dbus_path "$DEVICE_MAC")
busctl get-property "$DBUS_DEST" "$path" org.bluez.Device1 Connected 2>/dev/null | grep -q "true"
}
# Load saved MAC address
load_mac() {
if [ -n "$DEVICE_MAC" ]; then return 0; fi
if [ -f "$SAVED_MAC_FILE" ]; then
DEVICE_MAC=$(cat "$SAVED_MAC_FILE")
[ -n "$DEVICE_MAC" ] && return 0
fi
return 1
}
# Save MAC for fast reconnect
save_mac() {
mkdir -p "$(dirname "$SAVED_MAC_FILE")"
echo "$DEVICE_MAC" > "$SAVED_MAC_FILE"
}
# Scan for Claude Controller
scan_for_device() {
log "Scanning for '$DEVICE_NAME'..."
# Start LE scan
bluetoothctl scan le &>/dev/null &
local scan_pid=$!
sleep 8
kill "$scan_pid" 2>/dev/null
wait "$scan_pid" 2>/dev/null
# Find the device
local found
found=$(bluetoothctl devices 2>/dev/null | grep "$DEVICE_NAME" | awk '{print $2}')
if [ -n "$found" ]; then
DEVICE_MAC="$found"
save_mac
log "Found: $DEVICE_MAC"
return 0
fi
return 1
}
close_serial() {
eval "exec ${SERIAL_FD}>&-" 2>/dev/null
# Connect to the device
connect_device() {
log "Connecting to $DEVICE_MAC..."
# Trust first (allows auto-reconnect)
bluetoothctl trust "$DEVICE_MAC" &>/dev/null
# Connect
bluetoothctl connect "$DEVICE_MAC" &>/dev/null
sleep 2
if is_connected; then
log "Connected"
return 0
fi
log "Connection failed"
return 1
}
# Wait for device to appear using inotifywait (blocks, zero CPU)
wait_for_device() {
while ! device_connected; do
inotifywait -qq -e create /dev --include "$SERIAL_DEV" 2>/dev/null || sleep 1
# Find the GATT characteristic handle via D-Bus
find_rx_char_path() {
local dev_path
dev_path=$(mac_to_dbus_path "$DEVICE_MAC")
# Search for our RX characteristic UUID in the device's GATT tree
busctl tree "$DBUS_DEST" 2>/dev/null | grep -o "${dev_path}/service[0-9a-f]*/char[0-9a-f]*" | while read -r char_path; do
local uuid
uuid=$(busctl get-property "$DBUS_DEST" "$char_path" org.bluez.GattCharacteristic1 UUID 2>/dev/null | tr -d '"' | awk '{print $2}')
if [ "$uuid" = "$RX_CHAR_UUID" ]; then
echo "$char_path"
return 0
fi
done
}
# Write data to the RX characteristic via D-Bus
write_gatt() {
local char_path="$1"
local data="$2"
# Convert string to byte array for D-Bus: "hi" -> 0x68 0x69
local bytes=""
for ((i = 0; i < ${#data}; i++)); do
local byte
byte=$(printf "0x%02x" "'${data:$i:1}")
bytes="$bytes $byte"
done
local count=${#data}
busctl call "$DBUS_DEST" "$char_path" org.bluez.GattCharacteristic1 \
WriteValue "aya{sv}" "$count" $bytes 0 2>/dev/null
}
poll() {
local token
token=$(read_token) || { log "Error: could not read token"; return 1; }
@@ -69,7 +156,6 @@ poll() {
s7d_reset=${s7d_reset:-0}
status=${status:-unknown}
# Build JSON payload in a single awk process (utilization * 100, timestamps to minutes)
local payload
payload=$(awk -v u5="$s5h_util" -v r5="$s5h_reset" -v u7="$s7d_util" -v r7="$s7d_reset" -v st="$status" -v now="$now" \
'BEGIN {
@@ -80,49 +166,61 @@ poll() {
printf "{\"s\":%s,\"sr\":%s,\"w\":%s,\"wr\":%s,\"st\":\"%s\",\"ok\":true}", sp, sr, wp, wr, st;
}')
if ! echo "$payload" >&${SERIAL_FD} 2>/dev/null; then
log "Write failed - device disconnected"
return 1
fi
log "Sending: $payload"
write_gatt "$RX_CHAR_PATH" "$payload" || { log "Write failed"; return 1; }
return 0
}
cleanup() {
close_serial
log "Daemon stopped"
exit 0
}
trap cleanup INT TERM
log "=== Claude Usage Tracker Daemon ==="
log "Serial port: $SERIAL_PORT"
log "=== Claude Usage Tracker Daemon (BLE) ==="
log "Poll interval: ${POLL_INTERVAL}s"
BACKOFF=1
while true; do
# Wait for device (instant detection via inotify, or already plugged in)
if ! device_connected; then
log "Waiting for device..."
wait_for_device
# Find the device
if ! load_mac; then
scan_for_device || {
log "Device not found, retrying in ${BACKOFF}s..."
sleep "$BACKOFF"
BACKOFF=$((BACKOFF < 60 ? BACKOFF * 2 : 60))
continue
}
fi
log "Device connected"
sleep 2 # wait for ESP32 boot
# Connect if not connected
if ! is_connected; then
connect_device || {
log "Retrying in ${BACKOFF}s..."
sleep "$BACKOFF"
BACKOFF=$((BACKOFF < 60 ? BACKOFF * 2 : 60))
continue
}
fi
if ! open_serial; then
log "Failed to open serial port"
sleep 2
# Find the GATT characteristic
RX_CHAR_PATH=$(find_rx_char_path)
if [ -z "$RX_CHAR_PATH" ]; then
log "Error: RX characteristic not found, retrying..."
sleep 5
continue
fi
log "Serial port opened"
log "GATT RX path: $RX_CHAR_PATH"
# Poll loop - polls API every POLL_INTERVAL, exits on disconnect or write failure
while device_connected; do
poll || break
# Sleep POLL_INTERVAL but wake instantly if device disappears
inotifywait -qq -e delete /dev --include "$SERIAL_DEV" -t "$POLL_INTERVAL" 2>/dev/null
BACKOFF=1 # reset backoff on successful connection
# Poll loop
while is_connected; do
poll || log "Poll failed, will retry"
sleep "$POLL_INTERVAL"
done
# Disconnected - clean up and loop back to wait
log "Device disconnected"
close_serial
log "Device disconnected, reconnecting..."
sleep 2
done
+9 -1
View File
@@ -7,8 +7,14 @@ monitor_speed = 115200
build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1
-UARDUINO_USB_MODE -DARDUINO_USB_MODE=0
; HID is via BLE now, no TinyUSB needed — normal pio upload works
-DBOARD_HAS_PSRAM
; NimBLE config — peripheral-only, single connection
-DCONFIG_BT_NIMBLE_ROLE_BROADCASTER=1
-DCONFIG_BT_NIMBLE_ROLE_PERIPHERAL=1
-DCONFIG_BT_NIMBLE_ROLE_CENTRAL=0
-DCONFIG_BT_NIMBLE_ROLE_OBSERVER=0
-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
; LVGL config via build flags
-DLV_CONF_SKIP
-DLV_COLOR_DEPTH=16
@@ -29,8 +35,10 @@ build_flags =
-DLV_USE_IMAGE=1
-DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=1
-DLV_USE_SNAPSHOT=1
lib_deps =
lovyan03/LovyanGFX@^1.1.16
lvgl/lvgl@^9.2.0
bblanchon/ArduinoJson@^7.0.0
h2zero/NimBLE-Arduino@^2.1.1
+207
View File
@@ -0,0 +1,207 @@
#include "ble.h"
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <NimBLEHIDDevice.h>
#define DEVICE_NAME "Claude Controller"
// Custom GATT UUIDs for data channel
#define SERVICE_UUID "4c41555a-4465-7669-6365-000000000001"
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
#define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device notifies here
#define BLE_BUF_SIZE 512
// HID keyboard report descriptor
static const uint8_t HID_REPORT_MAP[] = {
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224) - Left Control
0x29, 0xE7, // Usage Maximum (231) - Right GUI
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte
0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Constant) - Reserved byte
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
0xC0, // End Collection
};
static NimBLEServer* server = nullptr;
static NimBLEHIDDevice* hid_dev = nullptr;
static NimBLECharacteristic* input_kbd = nullptr;
static NimBLECharacteristic* tx_char = nullptr;
static NimBLECharacteristic* rx_char = nullptr;
static ble_state_t state = BLE_STATE_INIT;
static bool need_advertise = false;
static char rx_buf[BLE_BUF_SIZE];
static volatile bool data_ready = false;
static char mac_str[18];
static void start_advertising() {
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
adv->reset();
adv->addServiceUUID(SERVICE_UUID);
adv->setAppearance(HID_KEYBOARD);
adv->enableScanResponse(true);
adv->setName(DEVICE_NAME);
bool ok = adv->start();
state = BLE_STATE_ADVERTISING;
Serial.printf("BLE: advertising start=%s\n", ok ? "OK" : "FAILED");
}
class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* s, NimBLEConnInfo& info) override {
state = BLE_STATE_CONNECTED;
Serial.printf("BLE: connected from %s\n", info.getAddress().toString().c_str());
}
void onDisconnect(NimBLEServer* s, NimBLEConnInfo& info, int reason) override {
state = BLE_STATE_DISCONNECTED;
need_advertise = true;
Serial.printf("BLE: disconnected (reason=%d)\n", reason);
}
};
class RxCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic* chr, NimBLEConnInfo& info) override {
std::string val = chr->getValue();
size_t len = val.length();
if (len >= BLE_BUF_SIZE) len = BLE_BUF_SIZE - 1;
memcpy(rx_buf, val.c_str(), len);
rx_buf[len] = '\0';
data_ready = true;
}
};
void ble_init(void) {
NimBLEDevice::init(DEVICE_NAME);
NimBLEDevice::setSecurityAuth(true, false, true); // bonding, no MITM, SC
// Format MAC address
NimBLEAddress addr = NimBLEDevice::getAddress();
snprintf(mac_str, sizeof(mac_str), "%s", addr.toString().c_str());
for (int i = 0; mac_str[i]; i++) {
if (mac_str[i] >= 'a' && mac_str[i] <= 'f') mac_str[i] -= 32;
}
server = NimBLEDevice::createServer();
static ServerCallbacks serverCb;
server->setCallbacks(&serverCb);
// --- HID keyboard service ---
hid_dev = new NimBLEHIDDevice(server);
hid_dev->setReportMap((uint8_t*)HID_REPORT_MAP, sizeof(HID_REPORT_MAP));
hid_dev->setManufacturer("Anthropic");
hid_dev->setPnp(0x02, 0x05AC, 0x820A, 0x0210); // BT SIG, generic keyboard
hid_dev->setHidInfo(0x00, 0x02); // country=0, flags=normally connectable
hid_dev->setBatteryLevel(100);
input_kbd = hid_dev->getInputReport(1); // report ID 1
// --- Custom data service ---
NimBLEService* svc = server->createService(SERVICE_UUID);
rx_char = svc->createCharacteristic(
RX_CHAR_UUID,
NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR
);
static RxCallbacks rxCb;
rx_char->setCallbacks(&rxCb);
tx_char = svc->createCharacteristic(
TX_CHAR_UUID,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
);
svc->start();
server->start();
start_advertising();
Serial.printf("BLE: init complete, MAC=%s\n", mac_str);
}
void ble_tick(void) {
if (need_advertise) {
need_advertise = false;
start_advertising();
}
}
ble_state_t ble_get_state(void) {
return state;
}
const char* ble_get_device_name(void) {
return DEVICE_NAME;
}
const char* ble_get_mac_address(void) {
return mac_str;
}
void ble_clear_bonds(void) {
NimBLEDevice::deleteAllBonds();
Serial.println("BLE: bonds cleared");
if (state == BLE_STATE_CONNECTED) {
server->disconnect(server->getPeerInfo(0).getConnHandle());
}
need_advertise = true;
}
bool ble_has_data(void) {
return data_ready;
}
const char* ble_get_data(void) {
data_ready = false;
return rx_buf;
}
void ble_send_ack(void) {
if (state == BLE_STATE_CONNECTED && tx_char) {
tx_char->setValue("{\"ack\":true}");
tx_char->notify();
}
}
void ble_send_nack(void) {
if (state == BLE_STATE_CONNECTED && tx_char) {
tx_char->setValue("{\"err\":true}");
tx_char->notify();
}
}
void ble_keyboard_press(uint8_t key, uint8_t modifier) {
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
uint8_t report[8] = {modifier, 0, key, 0, 0, 0, 0, 0};
input_kbd->setValue(report, sizeof(report));
input_kbd->notify();
}
void ble_keyboard_release(void) {
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
uint8_t report[8] = {0};
input_kbd->setValue(report, sizeof(report));
input_kbd->notify();
}
bool ble_is_connected(void) {
return state == BLE_STATE_CONNECTED;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <stdint.h>
enum ble_state_t {
BLE_STATE_INIT,
BLE_STATE_ADVERTISING,
BLE_STATE_CONNECTED,
BLE_STATE_DISCONNECTED,
};
void ble_init(void);
void ble_tick(void);
ble_state_t ble_get_state(void);
const char* ble_get_device_name(void);
const char* ble_get_mac_address(void);
void ble_clear_bonds(void);
bool ble_has_data(void);
const char* ble_get_data(void);
void ble_send_ack(void);
void ble_send_nack(void);
// BLE HID keyboard
void ble_keyboard_press(uint8_t key, uint8_t modifier);
void ble_keyboard_release(void);
bool ble_is_connected(void);
+26 -18
View File
@@ -1,10 +1,21 @@
#include "hid.h"
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "ble.h"
static USBHIDKeyboard keyboard;
// HID usage codes (same for USB and BLE HID)
#define HID_KEY_ARROW_UP 0x52
#define HID_KEY_ARROW_DOWN 0x51
#define HID_KEY_ARROW_LEFT 0x50
#define HID_KEY_ARROW_RIGHT 0x4F
#define HID_KEY_ENTER 0x28
#define HID_KEY_ESCAPE 0x29
#define HID_KEY_BACKSPACE 0x2A
#define HID_KEY_TAB 0x2B
#define HID_KEY_SPACE 0x2C
// Modifier bits
#define MOD_LEFT_CTRL 0x01
#define MOD_LEFT_SHIFT 0x02
// Gesture-to-key mapping table. Edit this to change controls.
struct gesture_mapping_t {
gesture_t gesture;
uint8_t key;
@@ -14,43 +25,40 @@ struct gesture_mapping_t {
static const gesture_mapping_t mappings[] = {
{ GESTURE_SWIPE_UP, HID_KEY_ARROW_UP, 0 },
{ GESTURE_SWIPE_DOWN, HID_KEY_ARROW_DOWN, 0 },
{ GESTURE_SWIPE_LEFT, HID_KEY_TAB, KEY_LEFT_SHIFT },
{ GESTURE_SWIPE_LEFT, HID_KEY_TAB, MOD_LEFT_SHIFT },
{ GESTURE_SWIPE_RIGHT, HID_KEY_ENTER, 0 },
{ GESTURE_TAP_ESCAPE, HID_KEY_ESCAPE, 0 },
{ GESTURE_TAP_BACKSPACE, HID_KEY_BACKSPACE, 0 },
{ GESTURE_TAP_CTRL_SPACE, HID_KEY_SPACE, KEY_LEFT_CTRL },
{ GESTURE_TAP_CTRL_SPACE, HID_KEY_SPACE, MOD_LEFT_CTRL },
{ GESTURE_TAP_ARROW_LEFT, HID_KEY_ARROW_LEFT, 0 },
{ GESTURE_TAP_ARROW_RIGHT, HID_KEY_ARROW_RIGHT, 0 },
};
#define MAPPING_COUNT (sizeof(mappings) / sizeof(mappings[0]))
#define HOLD_KEY HID_KEY_SPACE
void hid_init(void) {
USB.begin();
keyboard.begin();
keyboard.releaseAll();
// BLE HID is initialized in ble_init()
}
void hid_on_gesture(gesture_t gesture) {
if (!ble_is_connected()) return;
if (gesture == GESTURE_HOLD_START) {
keyboard.pressRaw(HOLD_KEY);
ble_keyboard_press(HID_KEY_SPACE, 0);
return;
}
if (gesture == GESTURE_HOLD_END) {
keyboard.releaseRaw(HOLD_KEY);
ble_keyboard_release();
return;
}
if (gesture == GESTURE_TOGGLE_SCREEN) {
return; // handled by touch.cpp, not a keystroke
return;
}
for (int i = 0; i < MAPPING_COUNT; i++) {
for (int i = 0; i < (int)MAPPING_COUNT; i++) {
if (mappings[i].gesture == gesture) {
if (mappings[i].modifier) keyboard.press(mappings[i].modifier);
keyboard.pressRaw(mappings[i].key);
ble_keyboard_press(mappings[i].key, mappings[i].modifier);
delay(20);
keyboard.releaseAll();
ble_keyboard_release();
return;
}
}
+73
View File
@@ -317,3 +317,76 @@ static const uint16_t icon_hand_data[576] = {
0x73AD, 0x4228, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
};
#define ICON_BLUETOOTH_W 32
#define ICON_BLUETOOTH_H 32
static const uint16_t icon_bluetooth_data[1024] = {
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x2104, 0x6B4C, 0x6B4C, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A28, 0xB574, 0xB574, 0x840F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0xB574, 0x840F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0xB574, 0xB574, 0x842F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0xAD74, 0xB574, 0xB574, 0x840F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x630B, 0xA4F2, 0xB574, 0xB574, 0x842F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x3186, 0xA4F2, 0xB574, 0xB574, 0x842F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x73AE, 0xAD33, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x18E3, 0x31A6, 0xA4F2, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xAD33, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x18E3, 0x18E3, 0x632C, 0xB574, 0xB574, 0xA533, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x5ACA, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x5ACA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x840F, 0xB574, 0xB574, 0x840F, 0xAD74, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD74, 0x5ACA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD74, 0x5ACA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x840F, 0xB574, 0xB574, 0x840F, 0xAD74, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x5ACA, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x5ACA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x18E3, 0x52AA, 0xAD74, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xAD33, 0xB574, 0xAD74, 0x52AA, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x18E3, 0x18E3, 0x632C, 0xB574, 0xB574, 0xA533, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x73AE, 0xAD33, 0x52AA, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x18E3, 0x31A6, 0xA4F2, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x4A69, 0x3186, 0xA4F2, 0xB574, 0xB574, 0x842F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0x630B, 0xA4F2, 0xB574, 0xB574, 0x842F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0xAD74, 0xB574, 0xB574, 0x840F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0xB574, 0xB574, 0x842F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A69, 0xB574, 0xB574, 0xB574, 0x840F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A28, 0xB574, 0xB574, 0x840F, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x2104, 0x6B4C, 0x6B4C, 0x2104, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
};
#define ICON_TRASH2_W 32
#define ICON_TRASH2_H 32
static const uint16_t icon_trash2_data[1024] = {
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x39C6, 0x632C, 0x7BEE, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEE, 0x632C, 0x39C6, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x5ACA, 0xAD54, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD54, 0x5ACA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x39C6, 0xAD54, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD54, 0x39A6, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x632C, 0xB574, 0xB574, 0x52AA, 0x2103, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x2103, 0x5AAA, 0xB574, 0xB574, 0x632C, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x2103, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x2103, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x39C6, 0x4A69, 0x5289, 0x6B4C, 0x52AA, 0x4A69, 0x8C70, 0xB574, 0xB574, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0xB574, 0xB574, 0x8C70, 0x4A69, 0x52AA, 0x6B4C, 0x5289, 0x4A69, 0x39C6, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x39C6, 0xAD74, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD74, 0x39C6, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x39C6, 0xAD74, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD74, 0x39C6, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x39C6, 0x4A69, 0x8C70, 0xB574, 0xB574, 0x4A69, 0x5289, 0x6B4C, 0x52AA, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x4A69, 0x52AA, 0x6B4C, 0x5289, 0x4A69, 0xB574, 0xB574, 0x8C70, 0x4A69, 0x39C6, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x4A49, 0x7BAE, 0x3185, 0x18E3, 0x18E3, 0x3185, 0x7BAE, 0x4A49, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xAD33, 0xB574, 0x7BAE, 0x18E3, 0x18E3, 0x7BAE, 0xB574, 0xAD33, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xAD74, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xAD74, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x73AE, 0xAD33, 0x4A49, 0x18E3, 0x18E3, 0x4A49, 0xAD33, 0x73AE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x7BEE, 0xB574, 0xB574, 0x2103, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x2103, 0xB574, 0xB574, 0x7BEE, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x632C, 0xB574, 0xB574, 0x5AAA, 0x2103, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x2103, 0x5ACA, 0xB574, 0xB574, 0x632C, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x39A6, 0xAD54, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD54, 0x31A6, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x5ACA, 0xAD54, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xB574, 0xAD54, 0x5ACA, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x39A6, 0x632C, 0x7BEE, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEF, 0x7BEE, 0x632C, 0x31A6, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3, 0x18E3,
};
+72 -18
View File
@@ -6,6 +6,7 @@
#include "ui.h"
#include "hid.h"
#include "touch.h"
#include "ble.h"
LGFX lcd; // global - used by touch.cpp
static UsageData usage = {};
@@ -15,11 +16,6 @@ static UsageData usage = {};
static uint16_t buf1[480 * BUF_LINES];
static uint16_t buf2[480 * BUF_LINES];
// Serial line buffer
#define SERIAL_BUF_SIZE 512
static char serial_buf[SERIAL_BUF_SIZE];
static int serial_pos = 0;
// LVGL tick callback
static uint32_t my_tick(void) {
return millis();
@@ -67,20 +63,56 @@ static bool parse_json(const char* json, UsageData* out) {
return true;
}
// Read serial data, returns true when a complete line is ready
static bool read_serial_line() {
// Serial command buffer
#define CMD_BUF_SIZE 64
static char cmd_buf[CMD_BUF_SIZE];
static int cmd_pos = 0;
static void send_screenshot() {
// Allocate buffer in PSRAM (internal RAM is too small for 307KB)
const uint32_t w = 480, h = 320;
const uint32_t row_bytes = w * 2;
const uint32_t buf_size = row_bytes * h;
uint8_t* buf = (uint8_t*)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
if (!buf) {
Serial.println("SCREENSHOT_ERR");
return;
}
// Use LVGL snapshot with caller-provided buffer
lv_draw_buf_t draw_buf;
lv_draw_buf_init(&draw_buf, w, h, LV_COLOR_FORMAT_RGB565, row_bytes, buf, buf_size);
lv_result_t res = lv_snapshot_take_to_draw_buf(lv_screen_active(), LV_COLOR_FORMAT_RGB565, &draw_buf);
if (res != LV_RESULT_OK) {
heap_caps_free(buf);
Serial.println("SCREENSHOT_ERR");
return;
}
Serial.printf("SCREENSHOT_START %lu %lu %lu\n", (unsigned long)w, (unsigned long)h, (unsigned long)buf_size);
Serial.flush();
Serial.write(buf, buf_size);
Serial.flush();
Serial.println();
Serial.println("SCREENSHOT_END");
heap_caps_free(buf);
}
static void check_serial_cmd() {
while (Serial.available()) {
char c = Serial.read();
if (c == '\n') {
serial_buf[serial_pos] = '\0';
serial_pos = 0;
return true;
if (c == '\n' || c == '\r') {
cmd_buf[cmd_pos] = '\0';
if (strcmp(cmd_buf, "screenshot") == 0) {
send_screenshot();
}
if (serial_pos < SERIAL_BUF_SIZE - 1) {
serial_buf[serial_pos++] = c;
cmd_pos = 0;
} else if (cmd_pos < CMD_BUF_SIZE - 1) {
cmd_buf[cmd_pos++] = c;
}
}
return false;
}
void setup() {
@@ -110,24 +142,46 @@ void setup() {
// Init USB HID keyboard
hid_init();
// Init BLE data channel
ble_init();
// Init touch gesture detection
touch_init();
// Build dashboard
ui_init();
Serial.println("Dashboard ready, waiting for data on serial...");
// Show initial BLE status on Bluetooth screen
ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address());
Serial.println("Dashboard ready, waiting for data on BLE...");
}
static ble_state_t last_ble_state = BLE_STATE_INIT;
void loop() {
lv_timer_handler();
ui_tick_anim();
touch_tick();
ble_tick();
if (read_serial_line()) {
if (parse_json(serial_buf, &usage)) {
// Update BLE status on screen when state changes
ble_state_t bs = ble_get_state();
if (bs != last_ble_state) {
last_ble_state = bs;
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
}
// Check for serial commands (screenshot, etc.)
check_serial_cmd();
// Process incoming BLE data
if (ble_has_data()) {
if (parse_json(ble_get_data(), &usage)) {
ui_update(&usage);
Serial.println("{\"ack\":true}");
ble_send_ack();
} else {
ble_send_nack();
}
}
+16 -6
View File
@@ -1,5 +1,6 @@
#include "touch.h"
#include "ui.h"
#include "ble.h"
#include <Arduino.h>
#include "display_cfg.h"
@@ -62,6 +63,11 @@ static bool is_in_tap_zone(uint16_t x, uint16_t y) {
return classify_zone_tap(x, y) != GESTURE_NONE;
}
// Check if tap is in the "Clear Bonds" zone on the Bluetooth screen
static bool in_ble_clear_zone(uint16_t x, uint16_t y) {
return x >= 8 && x <= 472 && y >= 190 && y < 250;
}
void touch_init(void) {
state = TS_IDLE;
}
@@ -89,15 +95,19 @@ void touch_tick(void) {
abs((int)last_y - (int)start_y) < TAP_MAX_MOVE_PX;
if (is_tap && in_logo(start_x, start_y)) {
// Logo tap toggles screen (works on both screens)
if (ui_is_controller_shown()) ui_show_usage();
else ui_show_controller();
} else if (is_tap && ui_is_controller_shown()) {
// Logo tap cycles screens: Usage -> Controller -> Bluetooth -> ...
ui_cycle_screen();
} else if (is_tap && ui_get_current_screen() == SCREEN_CONTROLLER) {
// Zone taps only on controller screen
gesture_t zone = classify_zone_tap(start_x, start_y);
if (zone != GESTURE_NONE) {
hid_on_gesture(zone);
}
} else if (is_tap && ui_get_current_screen() == SCREEN_BLUETOOTH) {
// "Clear Bonds" tap zone on Bluetooth screen
if (in_ble_clear_zone(start_x, start_y)) {
ble_clear_bonds();
}
}
state = TS_IDLE;
} else {
@@ -107,12 +117,12 @@ void touch_tick(void) {
int dy = (int)y - (int)start_y;
// Logo hold = Ctrl+Space (controller screen only)
if (in_logo(start_x, start_y) && ui_is_controller_shown() && (now - down_time) >= LOGO_HOLD_TIME_MS) {
if (in_logo(start_x, start_y) && ui_get_current_screen() == SCREEN_CONTROLLER && (now - down_time) >= LOGO_HOLD_TIME_MS) {
hid_on_gesture(GESTURE_TAP_CTRL_SPACE);
state = TS_LOGO_HOLD;
}
// Everything below only on controller screen
else if (ui_is_controller_shown()) {
else if (ui_get_current_screen() == SCREEN_CONTROLLER) {
if (abs(dx) >= SWIPE_MIN_PX || abs(dy) >= SWIPE_MIN_PX) {
state = TS_SWIPING;
} else if (!in_logo(start_x, start_y) && !is_in_tap_zone(start_x, start_y) && (now - down_time) >= HOLD_TIME_MS) {
+135 -15
View File
@@ -43,9 +43,15 @@ static lv_obj_t* ctrl_session_bar;
static lv_obj_t* ctrl_session_lbl;
static lv_obj_t* ctrl_reset_lbl;
// ---- Bluetooth screen widgets ----
static lv_obj_t* ble_container;
static lv_obj_t* lbl_ble_status;
static lv_obj_t* lbl_ble_device;
static lv_obj_t* lbl_ble_mac;
// ---- Shared ----
static lv_image_dsc_t logo_dsc;
static bool controller_shown = false;
static screen_t current_screen = SCREEN_USAGE;
// Animation state
static uint32_t anim_last_ms = 0;
@@ -388,6 +394,85 @@ static void init_controller_screen(lv_obj_t* scr) {
lv_obj_add_flag(ctrl_container, LV_OBJ_FLAG_HIDDEN);
}
static void init_bluetooth_screen(lv_obj_t* scr) {
ble_container = lv_obj_create(scr);
lv_obj_set_size(ble_container, 480, 320);
lv_obj_set_pos(ble_container, 0, 0);
lv_obj_set_style_bg_opa(ble_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(ble_container, 0, 0);
lv_obj_set_style_pad_all(ble_container, 0, 0);
lv_obj_clear_flag(ble_container, LV_OBJ_FLAG_SCROLLABLE);
// Title
lv_obj_t* lbl_ble_title = lv_label_create(ble_container);
lv_label_set_text(lbl_ble_title, "Bluetooth");
lv_obj_set_style_text_font(lbl_ble_title, &font_tiempos_34, 0);
lv_obj_set_style_text_color(lbl_ble_title, COL_TEXT, 0);
lv_obj_set_pos(lbl_ble_title, 66, 12);
// Info panel
lv_obj_t* p_info = make_panel(ble_container, 8, 56, 464, 120);
// Bluetooth icon + status row
static lv_image_dsc_t icon_bt_dsc;
init_icon_dsc(&icon_bt_dsc, ICON_BLUETOOTH_W, ICON_BLUETOOTH_H, icon_bluetooth_data);
lv_obj_t* bt_img = lv_image_create(p_info);
lv_image_set_src(bt_img, &icon_bt_dsc);
lv_obj_set_pos(bt_img, 0, 0);
lbl_ble_status = lv_label_create(p_info);
lv_label_set_text(lbl_ble_status, "Initializing...");
lv_obj_set_style_text_font(lbl_ble_status, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl_ble_status, COL_DIM, 0);
lv_obj_set_pos(lbl_ble_status, 40, 2);
lbl_ble_device = lv_label_create(p_info);
lv_label_set_text(lbl_ble_device, "Device: ---");
lv_obj_set_style_text_font(lbl_ble_device, &font_styrene_16, 0);
lv_obj_set_style_text_color(lbl_ble_device, COL_DIM, 0);
lv_obj_set_pos(lbl_ble_device, 0, 50);
lbl_ble_mac = lv_label_create(p_info);
lv_label_set_text(lbl_ble_mac, "Address: ---");
lv_obj_set_style_text_font(lbl_ble_mac, &font_styrene_16, 0);
lv_obj_set_style_text_color(lbl_ble_mac, COL_DIM, 0);
lv_obj_set_pos(lbl_ble_mac, 0, 74);
// Reset Bluetooth tap zone with trash icon
lv_obj_t* reset_zone = lv_obj_create(ble_container);
lv_obj_set_pos(reset_zone, 8, 190);
lv_obj_set_size(reset_zone, 464, 60);
lv_obj_set_style_bg_color(reset_zone, COL_PANEL, 0);
lv_obj_set_style_bg_opa(reset_zone, LV_OPA_COVER, 0);
lv_obj_set_style_radius(reset_zone, 8, 0);
lv_obj_set_style_border_width(reset_zone, 0, 0);
lv_obj_set_style_pad_column(reset_zone, 10, 0);
lv_obj_set_flex_flow(reset_zone, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(reset_zone, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(reset_zone, LV_OBJ_FLAG_SCROLLABLE);
static lv_image_dsc_t icon_trash_dsc;
init_icon_dsc(&icon_trash_dsc, ICON_TRASH2_W, ICON_TRASH2_H, icon_trash2_data);
lv_obj_t* trash_img = lv_image_create(reset_zone);
lv_image_set_src(trash_img, &icon_trash_dsc);
lv_obj_t* reset_lbl = lv_label_create(reset_zone);
lv_label_set_text(reset_lbl, "Reset Bluetooth");
lv_obj_set_style_text_font(reset_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(reset_lbl, COL_DIM, 0);
// Footer
lv_obj_t* lbl_footer = lv_label_create(ble_container);
lv_label_set_text(lbl_footer, "Bluetooth Low Energy");
lv_obj_set_style_text_font(lbl_footer, &font_styrene_14, 0);
lv_obj_set_style_text_color(lbl_footer, COL_DIM, 0);
lv_obj_align(lbl_footer, LV_ALIGN_BOTTOM_MID, 0, -16);
// Start hidden
lv_obj_add_flag(ble_container, LV_OBJ_FLAG_HIDDEN);
}
void ui_init(void) {
lv_obj_t* scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, COL_BG, 0);
@@ -403,8 +488,9 @@ void ui_init(void) {
init_usage_screen(scr);
init_controller_screen(scr);
init_bluetooth_screen(scr);
// Logo on top of both containers
// Logo on top of all containers
lv_obj_t* img = lv_image_create(scr);
lv_image_set_src(img, &logo_dsc);
lv_obj_set_pos(img, 10, 4);
@@ -443,7 +529,7 @@ void ui_update(const UsageData* data) {
}
void ui_tick_anim(void) {
if (controller_shown) return; // no animation on controller screen
if (current_screen != SCREEN_USAGE) return; // animation only on usage screen
uint32_t now = lv_tick_get();
@@ -464,22 +550,56 @@ void ui_tick_anim(void) {
}
}
void ui_show_controller(void) {
void ui_show_screen(screen_t screen) {
lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(ctrl_container, LV_OBJ_FLAG_HIDDEN);
controller_shown = true;
}
void ui_show_usage(void) {
lv_obj_add_flag(ctrl_container, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
controller_shown = false;
lv_obj_add_flag(ble_container, LV_OBJ_FLAG_HIDDEN);
switch (screen) {
case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_CONTROLLER: lv_obj_clear_flag(ctrl_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_BLUETOOTH: lv_obj_clear_flag(ble_container, LV_OBJ_FLAG_HIDDEN); break;
default: break;
}
current_screen = screen;
}
bool ui_is_controller_shown(void) {
return controller_shown;
void ui_cycle_screen(void) {
ui_show_screen((screen_t)((current_screen + 1) % SCREEN_COUNT));
}
void ui_set_status(const char* text) {
(void)text;
screen_t ui_get_current_screen(void) {
return current_screen;
}
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac) {
switch (state) {
case BLE_STATE_CONNECTED:
lv_label_set_text(lbl_ble_status, "Connected");
lv_obj_set_style_text_color(lbl_ble_status, COL_GREEN, 0);
break;
case BLE_STATE_ADVERTISING:
lv_label_set_text(lbl_ble_status, "Advertising...");
lv_obj_set_style_text_color(lbl_ble_status, COL_AMBER, 0);
break;
case BLE_STATE_DISCONNECTED:
lv_label_set_text(lbl_ble_status, "Disconnected");
lv_obj_set_style_text_color(lbl_ble_status, COL_RED, 0);
break;
default:
lv_label_set_text(lbl_ble_status, "Initializing...");
lv_obj_set_style_text_color(lbl_ble_status, COL_DIM, 0);
break;
}
if (name) {
static char nbuf[48];
snprintf(nbuf, sizeof(nbuf), "Device: %s", name);
lv_label_set_text(lbl_ble_device, nbuf);
}
if (mac) {
static char mbuf[48];
snprintf(mbuf, sizeof(mbuf), "Address: %s", mac);
lv_label_set_text(lbl_ble_mac, mbuf);
}
}
+12 -4
View File
@@ -1,10 +1,18 @@
#pragma once
#include "data.h"
#include "ble.h"
enum screen_t {
SCREEN_USAGE,
SCREEN_CONTROLLER,
SCREEN_BLUETOOTH,
SCREEN_COUNT,
};
void ui_init(void);
void ui_update(const UsageData* data);
void ui_tick_anim(void);
void ui_set_status(const char* text);
void ui_show_controller(void);
void ui_show_usage(void);
bool ui_is_controller_shown(void);
void ui_show_screen(screen_t screen);
void ui_cycle_screen(void);
screen_t ui_get_current_screen(void);
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac);
+3 -31
View File
@@ -1,7 +1,5 @@
#!/bin/bash
# Flash helper for TinyUSB mode firmware.
# The built-in JTAG bootloader doesn't auto-reset with TinyUSB, so we
# trigger a reset via DTR/RTS on the CDC serial port before flashing.
# Build and flash firmware to the SC01 Plus.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -11,34 +9,8 @@ echo "=== Flashing Claude Usage Tracker ==="
echo "Port: $PORT"
echo ""
# Build first
echo "[1/3] Building firmware..."
cd "$SCRIPT_DIR/firmware"
~/.platformio/penv/bin/pio run
# Reset the device into bootloader via DTR/RTS
echo "[2/3] Resetting device into bootloader..."
python3 -c "
import serial, time
port = serial.Serial('$PORT', 115200)
port.dtr = False; port.rts = True; time.sleep(0.1)
port.dtr = True; port.rts = False; time.sleep(0.05)
port.dtr = False; time.sleep(0.1); port.close()
"
sleep 0.5
# Flash with no_reset (device is already in bootloader)
echo "[3/3] Flashing..."
python3 ~/.platformio/packages/tool-esptoolpy/esptool.py \
--chip esp32s3 \
--port "$PORT" \
--before no_reset \
--baud 921600 \
write_flash -z \
0x0 .pio/build/sc01plus/bootloader.bin \
0x8000 .pio/build/sc01plus/partitions.bin \
0xe000 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \
0x10000 .pio/build/sc01plus/firmware.bin
~/.platformio/penv/bin/pio run -t upload --upload-port "$PORT"
echo ""
echo "=== Done! Device will reboot automatically. ==="
echo "=== Done! ==="
+12 -9
View File
@@ -11,10 +11,10 @@ echo ""
# Check dependencies
echo "[1/3] Checking dependencies..."
for cmd in curl stty inotifywait; do
for cmd in curl awk bluetoothctl busctl; do
command -v "$cmd" >/dev/null || { echo "Error: $cmd is required but not installed"; exit 1; }
done
echo " All dependencies found (curl, python3, stty)"
echo " All dependencies found"
echo ""
# Install systemd user service with resolved path
@@ -24,7 +24,7 @@ DAEMON_BIN="$SCRIPT_DIR/daemon/$SERVICE_NAME.sh"
sed "s|DAEMON_PATH|${DAEMON_BIN}|g" "$SERVICE_FILE" > "$USER_SERVICE_DIR/$SERVICE_NAME.service"
systemctl --user daemon-reload
# Enable (service starts automatically when device is plugged in)
# Enable service
echo "[3/3] Enabling service..."
systemctl --user enable "$SERVICE_NAME"
@@ -32,7 +32,15 @@ echo ""
echo "=== Done! ==="
echo ""
echo "The daemon will now start automatically when you log in"
echo "and the SC01 Plus is connected via USB."
echo "and connect to the SC01 Plus over Bluetooth Low Energy."
echo ""
echo "First-time Bluetooth pairing:"
echo " 1. Power on the SC01 Plus"
echo " 2. Run: bluetoothctl scan le"
echo " 3. Find 'Claude Controller' and note the MAC address"
echo " 4. Run: bluetoothctl pair <MAC>"
echo " 5. Run: bluetoothctl trust <MAC>"
echo " 6. Start the daemon: systemctl --user start $SERVICE_NAME"
echo ""
echo "Useful commands:"
echo " systemctl --user status $SERVICE_NAME # check status"
@@ -40,8 +48,3 @@ echo " journalctl --user -u $SERVICE_NAME -f # view logs"
echo " systemctl --user restart $SERVICE_NAME # restart"
echo " systemctl --user stop $SERVICE_NAME # stop"
echo ""
echo "To uninstall:"
echo " systemctl --user stop $SERVICE_NAME"
echo " systemctl --user disable $SERVICE_NAME"
echo " rm $USER_SERVICE_DIR/$SERVICE_NAME.service"
echo " systemctl --user daemon-reload"
Executable
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# Take a screenshot from the SC01 Plus display via LVGL snapshot.
# Usage: ./screenshot.sh [output.png] [port]
OUTPUT="${1:-screenshot.png}"
PORT="${2:-/dev/ttyACM0}"
TMPRAW=$(mktemp /tmp/screenshot_XXXXXX.raw)
trap "rm -f '$TMPRAW'" EXIT
echo "Taking screenshot from $PORT..."
python3 - "$PORT" "$TMPRAW" << 'PYEOF'
import serial, sys
port_path, raw_path = sys.argv[1], sys.argv[2]
port = serial.Serial(port_path, 115200, timeout=10)
port.reset_input_buffer()
port.write(b"screenshot\n")
port.flush()
while True:
line = port.readline().decode("utf-8", errors="replace").strip()
if line.startswith("SCREENSHOT_START"):
parts = line.split()
w, h, raw_size = int(parts[1]), int(parts[2]), int(parts[3])
break
if line == "SCREENSHOT_ERR":
print("Device reported screenshot error", file=sys.stderr)
sys.exit(1)
data = b""
while len(data) < raw_size:
chunk = port.read(min(4096, raw_size - len(data)))
if not chunk:
print(f"Timeout: got {len(data)} of {raw_size} bytes", file=sys.stderr)
sys.exit(1)
data += chunk
with open(raw_path, "wb") as f:
f.write(data)
for _ in range(10):
line = port.readline().decode("utf-8", errors="replace").strip()
if line == "SCREENSHOT_END":
break
port.close()
print(f"Captured {w}x{h} ({len(data)} bytes)")
PYEOF
if [ $? -ne 0 ]; then
echo "Screenshot capture failed"
exit 1
fi
ffmpeg -y -f rawvideo -pixel_format rgb565le -video_size 480x320 \
-i "$TMPRAW" -update 1 -frames:v 1 "$OUTPUT" 2>/dev/null || true
if [ -f "$OUTPUT" ]; then
echo "Saved: $OUTPUT"
else
echo "Error: conversion failed"
exit 1
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB