diff --git a/README.md b/README.md index be87423..8d076a5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assets/icon_bluetooth.png b/assets/icon_bluetooth.png new file mode 100644 index 0000000..5159a4d Binary files /dev/null and b/assets/icon_bluetooth.png differ diff --git a/assets/icon_bluetooth.svg b/assets/icon_bluetooth.svg new file mode 100644 index 0000000..e759115 --- /dev/null +++ b/assets/icon_bluetooth.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icon_trash2.png b/assets/icon_trash2.png new file mode 100644 index 0000000..b8d048c Binary files /dev/null and b/assets/icon_trash2.png differ diff --git a/assets/icon_trash2.svg b/assets/icon_trash2.svg new file mode 100644 index 0000000..e8cb0ed --- /dev/null +++ b/assets/icon_trash2.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/daemon/claude-usage-daemon.service b/daemon/claude-usage-daemon.service index 759e93a..04857cf 100644 --- a/daemon/claude-usage-daemon.service +++ b/daemon/claude-usage-daemon.service @@ -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 diff --git a/daemon/claude-usage-daemon.sh b/daemon/claude-usage-daemon.sh index ea0ab43..ce68008 100755 --- a/daemon/claude-usage-daemon.sh +++ b/daemon/claude-usage-daemon.sh @@ -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 - return 0 +# 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" } -close_serial() { - eval "exec ${SERIAL_FD}>&-" 2>/dev/null +# 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 } -# 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 +# 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 +} + +# 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 +} + +# 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 diff --git a/firmware/platformio.ini b/firmware/platformio.ini index e007926..ec26601 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -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 diff --git a/firmware/src/ble.cpp b/firmware/src/ble.cpp new file mode 100644 index 0000000..efa91ca --- /dev/null +++ b/firmware/src/ble.cpp @@ -0,0 +1,207 @@ +#include "ble.h" +#include +#include +#include + +#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; +} + diff --git a/firmware/src/ble.h b/firmware/src/ble.h new file mode 100644 index 0000000..de539c8 --- /dev/null +++ b/firmware/src/ble.h @@ -0,0 +1,26 @@ +#pragma once +#include + +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); + diff --git a/firmware/src/hid.cpp b/firmware/src/hid.cpp index fbcf0d3..33b3281 100644 --- a/firmware/src/hid.cpp +++ b/firmware/src/hid.cpp @@ -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; } } diff --git a/firmware/src/icons.h b/firmware/src/icons.h index d436b3b..2a85f8f 100644 --- a/firmware/src/icons.h +++ b/firmware/src/icons.h @@ -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, +}; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 04a97ec..9a152f0 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -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 (serial_pos < SERIAL_BUF_SIZE - 1) { - serial_buf[serial_pos++] = c; + if (c == '\n' || c == '\r') { + cmd_buf[cmd_pos] = '\0'; + if (strcmp(cmd_buf, "screenshot") == 0) { + send_screenshot(); + } + 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(); } } diff --git a/firmware/src/touch.cpp b/firmware/src/touch.cpp index 76e29d4..6351811 100644 --- a/firmware/src/touch.cpp +++ b/firmware/src/touch.cpp @@ -1,5 +1,6 @@ #include "touch.h" #include "ui.h" +#include "ble.h" #include #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) { diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 81214f1..63c1b0a 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -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); + } } diff --git a/firmware/src/ui.h b/firmware/src/ui.h index f978378..ffcb11b 100644 --- a/firmware/src/ui.h +++ b/firmware/src/ui.h @@ -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); diff --git a/flash.sh b/flash.sh index cc3517b..2bbada8 100755 --- a/flash.sh +++ b/flash.sh @@ -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! ===" diff --git a/install.sh b/install.sh index 4442d8e..2357258 100755 --- a/install.sh +++ b/install.sh @@ -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 " +echo " 5. Run: bluetoothctl trust " +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" diff --git a/screenshot.sh b/screenshot.sh new file mode 100755 index 0000000..9cb285b --- /dev/null +++ b/screenshot.sh @@ -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 diff --git a/screenshots/bluetooth.png b/screenshots/bluetooth.png new file mode 100644 index 0000000..445c6b3 Binary files /dev/null and b/screenshots/bluetooth.png differ diff --git a/screenshots/controller.png b/screenshots/controller.png new file mode 100644 index 0000000..832082b Binary files /dev/null and b/screenshots/controller.png differ diff --git a/screenshots/usage.png b/screenshots/usage.png new file mode 100644 index 0000000..ff6fa57 Binary files /dev/null and b/screenshots/usage.png differ