From 10b8052bcc1421fc040f24e29ef30c91caa6ce6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hermann=20Bj=C3=B6rgvin=20Haraldsson?= Date: Sat, 4 Apr 2026 19:30:19 +0000 Subject: [PATCH] Replace USB serial/HID with Bluetooth Low Energy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 99 +++++++++++--- assets/icon_bluetooth.png | Bin 0 -> 494 bytes assets/icon_bluetooth.svg | 13 ++ assets/icon_trash2.png | Bin 0 -> 519 bytes assets/icon_trash2.svg | 17 +++ daemon/claude-usage-daemon.service | 4 +- daemon/claude-usage-daemon.sh | 190 +++++++++++++++++++------- firmware/platformio.ini | 10 +- firmware/src/ble.cpp | 207 +++++++++++++++++++++++++++++ firmware/src/ble.h | 26 ++++ firmware/src/hid.cpp | 44 +++--- firmware/src/icons.h | 73 ++++++++++ firmware/src/main.cpp | 92 ++++++++++--- firmware/src/touch.cpp | 22 ++- firmware/src/ui.cpp | 150 ++++++++++++++++++--- firmware/src/ui.h | 16 ++- flash.sh | 34 +---- install.sh | 21 +-- screenshot.sh | 66 +++++++++ screenshots/bluetooth.png | Bin 0 -> 7640 bytes screenshots/controller.png | Bin 0 -> 10618 bytes screenshots/usage.png | Bin 0 -> 8479 bytes 22 files changed, 917 insertions(+), 167 deletions(-) create mode 100644 assets/icon_bluetooth.png create mode 100644 assets/icon_bluetooth.svg create mode 100644 assets/icon_trash2.png create mode 100644 assets/icon_trash2.svg create mode 100644 firmware/src/ble.cpp create mode 100644 firmware/src/ble.h create mode 100755 screenshot.sh create mode 100644 screenshots/bluetooth.png create mode 100644 screenshots/controller.png create mode 100644 screenshots/usage.png 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 0000000000000000000000000000000000000000..5159a4d3995db39ceadd4334c67d405df975c1f4 GIT binary patch literal 494 zcmVa0BXa59rcFq6l>$1>z7Okl+>|dQt!>LdikW0yT&P8;Gs7$4-03}M4wcwQ}0hB1=x2TmT11x|@N^Z8?Wt7MM z)fQ0aM@XGzNSy}}bsxq2@?{iKhC^WLlAr%HP|`ZE=efp}(PPVIkpe&)xc7Wxpp`>` z5NiW>z?yr%1-8uW^{*80&xWXfgj@>D + + diff --git a/assets/icon_trash2.png b/assets/icon_trash2.png new file mode 100644 index 0000000000000000000000000000000000000000..b8d048c9e2f4ee15dd2284b43901f98951f13525 GIT binary patch literal 519 zcmV+i0{H!jP)I0qVdmP;f5CKo7W8)yH6`sv5L_ zGvEWz2`d6x!F~gSR|13EBFZ7YR{|4N2Ug8_11PGh*o&l`O=GiW_T*Cn;M%&oLW*6% zvbzfLJ=BCEatQ1gM+d+w@CIz!*$|PCd)pF`V_*Z=1|CQn^+i?hM=}6ZjX`Lgr(H2n z&DsMqgV--3s|4r3k)cl*ElC>Ol;0Ts4!FQq0;id}n3RP+&nb;LNPYJ=kKxR)cmhPE zCL)(2QnI*`;cA%`WbFZK+!++OOMBD)+7|z}}i-4YWemac^v!~(l`2$bg$|S=@UO%D{us=3d zO7paG5)lDSU!0+z(lKc)lT0{!j1Hrd5ChL7!|IbaMw)q*Af5# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..445c6b3723eedeacafa14b79dde4ab699fe04018 GIT binary patch literal 7640 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 literal 0 HcmV?d00001 diff --git a/screenshots/controller.png b/screenshots/controller.png new file mode 100644 index 0000000000000000000000000000000000000000..832082b0332da9a4c3c456d90c4693e298a49696 GIT binary patch literal 10618 zcmeHtXH-*Nw=TAz7(h^|0q}??0)fy&?;SNDO(66R zf|O7KNKa@d`hEADJMNEj&-m`WKkgVOV`S{T=ALWMXFsdVx#n6Cn(7MVWDH~^BqZcY zin7`yBv*bA|1WM{B}z7npK}v0x85omxe%|ve^*F?y=aMrJ0wc7U|r8oxQt4B9);wv zV-3xUFXY0WS4nA}SA70(L!JAr`Iqaz@4x)@0QmI-6-D%00SRHcVA$eQVgWe#VNvZ! z*l^MnJ?8)qnR*K&fBeb(+D`m{PuF2_bMdD8@p>7r-|oXQg1dpiIbpOMh1%3VM}LuK zCLwtda)#azB_Rnkq6M1CSKn12T{xwEyeEBVla2zh4_23U!U7f|bsd zQelUA&evyu;*tH67WzRq=3fbe17oLU` z2XEJ9b3V*#IK7x^0<@M7yw$w(kBT_yO8TgOnZ{c_3Ti%||jcix$$u>4}K77pH zXgZH5z_#$hU3w-rMB=`f&sYk6eO2=YvzC>aDx~PEkfdpsUvnrhZxQB2a4^Fnej@bfcu`+-f+z)L_o|jIv>mSBPTPi!9vsC^dmH+ zo)1Bj6}G-K71Z8?nW?Pl{XHA%JkJj;OdSVK6D(FDeBRoUpAP|eqzcPBM+sHKc7#<_W}d;CU!MRHyyIhi%)`K~p#yNF zoLk{}bZe=s6R3Zy2(LewCu|(DRk}Pr32e4UGqItDC^%rrlDMf?Qr%AOb{yS5R9^N( z;2wpUSdXKd-B(LpbXonr&@mJymYD=YRkLmWV(7Az9Vomu@s+0L!oE9iFss|!!MpsY znKcKnjv6+<*&EBwP$EaPe4lUODia$7#FkbXPd%NWd+}W-VA6dv#=l5Tzsq~y`KKUT zM%m7C`Ulc4eynyq(|=u2a<|C9Nzo=L9#EcxIORO~bZ?KJg|90P9Vmeg7Up#*=gpg* zz$KABj-E^kiFs_3%I=fLz1t>&hVkw|F^8~NPaDb}gIg}>?PtwG2=AZbmpCv6xFsZ2 zo2{EwKK3mUY@e|ud1$ut%VuuMY}i_OR}Y0&oeKV}43Z#Hs3|1FK2L-YuhojVwg4IMn=_k^NL2aJpEhp24L2 zW&WFlr%*LfJ+#aq53Oy^K)znFtXD$+X}NjkWO~m_7{&2Da=FiDYiZia5dg`R-sF4a z<9SRl3UT?qW*r|Qct5(t@+N?z7uN4%h9%}mHLcljT?*=kQoS~{<$$SJ9d2BXP7Pf{ zppB(fou35+gKg#Ss&n+o!ZP1mt3(1ehhxEEgC}>9jt8F~KY@)ewB*5sw2{wCe6+e! z58%$u17+3L9OV_ahUXmlDaW$~Q@{n=@^||LIuTFIrWzRpe<-8_aMMBCo$}mXbTxQ? zJGMZ$09G><4CpfGB%+gE~153#5fa5Yh1c&$X0d9X2PU z1bYANZHZ~bZO9Ul8;UIUzG$(TP0Sh3ldYXt)m&y+reQJ%K=N0wCFXD>7Ob%UQk5#N z1Ci&1MI+ti?M;T~Z$yZ@QqntO@Iy@Uy$IZH1dNy1XjP+JiBz%Yk!{5oL|2k^Bv_-R ziq7?1P{;^=tIHJe1p z)~TI{E>k05lYg23c9)hcWDYr97Lj>p-|ZVf#Z_a`3Nw!S`;w=}oaNwEf~e}B$|k#I znTK=-9aZ0lNhP=cAWsC(K*J!RV%KA!yjSyfTfIbtp1>%wVxRH-0w}x#O5KhM1i862 z4Ooz+Qm0zZIZV995UK8Ck9}uA11rnQ2NHhti_!x~*$lA-bsi|$=Up};Us}NFm-(tZ zBNGn&N2=v7ZK~K@Q1e+$9Mf((h6V+j4o1pJ-TqcXr?dPiU`nYgi4Bv9RjLn2iJj>; znQ35_>rzR_6!s^sKc#d)U$cVc+FRh)aigtn9Q$`E@giFySTRq}`IggTW9`Y_%_KPk z15O8u!5o)K!Y1xztiTQWWIo0N%S9FUOvkBe2r8spRy3htf>t zKIaSEbUa8Ic;>8|k`tr>|y zuB2B#Z!s`Zay!B)vQr5kGC%)1Few>7(91@eA3R;p4f~=`9rLJcD?I0mv7kLBa=?*g z;5&*U2c_n5tkJ*9T{HqmqXM?^yX(sqi$9+EG4!iG$uE^)Tzj%s zm0TqjB;5LJF8)1DU!b~!^f@W_>BfWXxW5)v_vh|YBtcn!Vrkqksa`_4>1&##Jpt^- z8?x=d)>tmSKJ$wE@;}=Q++Byvy%wl}HQ+wzHjqJ0hjWJofwE;ZXO+ExD^t)5_NtH2g0dyU z&pH#oGZ0JT#FA3@nRViK`hyLlh3g=TChx~rY{I%BFM4XgE}LBUJSsRoz%j)7SCuU> zu}e8@g24FpK)4djOmc)Vs6fK=(;fW$KOfbQ$M2M~0dVZR$}eaBTl|Nv|BRDm0>F>3 z*fV!L=+y4xy^m81+pLZobgf4}2{UMKZo#h-Hu_LegM8{z&*OPeeQC5Q{2_G@_ga%) zEg(8GAQyPANLuN+b*BQB40(v;<*#i*AL{3EEb9LMq?d>LM3u%+9y>@RaR@TF>L2 zQ=K-JQoGc3C9MzShZk|&s(D`_>}fm&tEQk_YJCC z4%Z~PyY+e^fyPpC`_M%~AJ^ulVViW*1^S>8EyraRDN$bnF2)FlcOK{{@1vMC_4O_zHS>Sh*aRP=KX58hU}<$L zJRf>58M`29%za7!&VW*f#9$-Z*DJK`9F3~97(5OT0x_3%MGBRcS@8hQS^2SZhH+8> zew!u@RHn8cuX^fOEVH!w9c;zbm(SwEJm03A&75B4pN<7z_D*%#^7%Ja{~GvqGcMUA zOE@R%($nBX_9rJVfZS2XWVsJneQvz8tx?~#)AEarhvsbQi$;gZSDc+tke499wBu%6 zoA-I$?lNp8)Mb<=pQKMyZ#k-U+WAo~6_48bHpS|b)uHSjbBs@Qxk8rT*#_;RN-VsH zQ}qfBGP7Iyd{>vbqjr1YVt-^*v#eV~`zVNrX)ys^9jD%0-<+6_A6=%m#XDiWzuq%f z;*2;W_8G^D{eF=N{F~92(n|EV0q0x>kXIWgD=*ddrT5~+*SOw^XH54+MS*2zbgA*e z^F~sMExrQMN}f~Y-fAgg+go3HyS%i2 zJL%`v&yn?_shT0$tbI5pqhhS297-GxqUXEIE9e&Xn*8*F%39xcn)7d43dE@3Mj?Y;q~NxUEkk>blp$IC%|OF7Z!nb%x{7 zDLa7!;WI5@4>i%lsRrH#!L_W6sIUuMz@>r&VSUGWx%`5wOj2@??jTy14u3Z4<6jbY zxgGW^A*A=iG$KL#VFHLprV*@SPgp*`Tlx&v6G^Lr60P|;5~sdqr|=r{Oxt$(nbGjs zq~f=soY|I4(~8`fy66Tv*Hss?Hv&-~A|Fv^nlel;O;_#zhHcqgXZp$1X?SVR?qIDP zaslmq_#WY(H^*VoyH{GOz%2q$QWmyLQ8^iF^MXd(Jqj3hMP@yngBqmNQ+;PDe#~(u2V(3y(9v%q1YEgaTkxkK7;{E z{kZqQujbVSSt(<*T$VMmDlR|XI+fXO-i1&m#=9SAEzabgg38SnI0KqY-ctpG-`U^P zF?fki3ZyyM$9ZUkY)gRvJUBJ5w2HWx-Tu*bozKX4ryY9A9+|m0&l^h(+t!~L@`8pE z9%X^bSCcmJVKmUTt!gGeGD*; zbI(AlN(?qHd0_l1z)kPOd~eA@b8hc^0JKzAmP;xYx;~ZVgaf3eM)2ogFrDO`qdEGj zRv4}0pc<3X1OutyE@e9lXBdO)=o%toMoY_H(i-z0FP4Yt=jL$>m$qB1%e7J4n|aF4 znJww(EJugaw)-B6BV_PrOKYd7{St?Rbala#rGn-!lAUuMLmjN0qa9+MEQgXNlee?y zMlO%a`SPqePocI!{&{>@D`abP!r;&cKIZwM^sLC#4L-|`w!=HWpyF)*wEkpoLc7Wg zrcaSIdr6$#s$HJi7fFaLyo%z0`1WHh{XW0%9XrM8(qRV&0ZFoHIe_#V z_Xyy0*$;zIZ`>4SDx#sV)Ny@V!{C7}XeLz|i@m|*O~q7bGwtT9!XrjBrn83u#Qibm z4x7Io$EnH};FDe6c$_i2v1hTgQ#Sb3-AgyVJrYh=gKQ|}EJ6+(?_rVkX8jGr8=j$E zJ+GtAR+2gpOr4H~J*^Ui-5$U4psj$@u$+NAlVoxHd$ju0F!y`)j-Y-dvhfRRF0N#o zn+N;T!+??O7PG`aH$+8QrN2~lX@Rh=I_zy#7y=D(fX&A z4mGul%TD(21-6z}w(X*RM_mP$>?_QsFtExhh_Mw?>V#=}5WW1iQ^Fh*cL(TwO_@_=hIO}h|d+#RpjGB6_6ETXb?Ds~)k z19YyOM*W79QhHtSG4EM%WlwE&3lahjxp4P6xRLHU-A?h3qTb{KjaBR#;gUEmHO;+8 zryr?~orp;8w3fV2%>B#I)^A5{r~@>$EjP3kaWZ}1=F~xOZ^p(MCN-JuoLbfVDq1Y0 zXjHpsQu}lQF408WLUWwU*!_flX$lM|yyC8S*#6h^a z(v(6%oUcT#?zfsSb7JVsI}&@7d}yO|+}ANGWN;`RtT0{_i_E7PI*gA7A3sJZ9(@;R z+I|Dbz}uAzZ1)mseWD!nXHIQuo8KvCA`_8L31@OcGZEo@Z$dM9XG%#QkMRgo0lk^& zyXXWp*h7|9U%Tql=e=}iiR^rw13)?qMHV}^E}Mwww%m|Bk$oWiu(lRTKB-$}A@cHA zR%^|!rqug}t-EvAyc5P~p<1lM0$`U+mQF20V*2ekb_ql4F*j_q z;Pl5Jr?p?-G!yG^D?34%f0k7o>eZZ@8U;V;l6ilX!mImlFmlJxdNZKcg~2&pS-)A? zfX^)%o{As9?a6dWkT=E-{dBJ3bTwUye3OzjB|Zxx-qi+jpJmJ< z>f*xgF$zXo9|kH`R)AqFMH8_360^oMQkc8#;q>hv9UDm~FMg$x5=+?K?u}aQBsWoJCQ6!RY$+n!|xqu8{i63!me8g+ep{!^IoZ928)+0!v|Bi4hrR7TAZD-qm>2#Ovn zfm-v|JyWzK9+{j!a@Xax+^dK$tIq9hN2J}hZR6yQqEarf zt(W=g_EsSEJNM*s&!ZckGi)($4}#B8wtja<9WW2=c}$`C$qNf;gih3T`5AP;Ge}Yj zXmr*p8b16~cqMh#jmF`D#OYNB&3ks-uWW3A%Iqt&H7j9vW(qj88}SZ zwDQw;sYn^+TxaEG1I3x_9xtMWS`0ftIWKbE+)Wa@DF&gDR?s0>DV)d#6JOtOwVI-0yg6jj(`1# z)2b~fyzWsu;Qxs;ujd4D8RfiHCZtkLtvuse=LS)UNkcNJ5=3IyyXz)pmz}Tv_B@+f zRhb~Nf?VkoaMZmzCky;W2%T#R;ZXpWIfg6TBo2Pat@S*Fp4_`*>M54@P~k%V^Y71R z25lPU@Jr0vctjavia=M~MAGfl>vx(1E#?tO9q77tfnrj9ee{r#Ljbb(C#^PSmfVBf7(ks~~<$8^1i5s=mVUZs#e>4GsWQ@-E z`q;kz6RY0*ynwuIyfersBf2O5RIzl5PJK}NwT!>mzatU`*&F6)#^T!IZ;uRe-zpru zLJ&W+4C8s^{WJbB6am-c(V9H<=e7eE zBwvq?brZnu>E!l`pHOIeVAMZCjV2QVi>u3`SD5%$beZvtS7B!0S?|OW+la4V*Z}vf zkM9QeZUBA4reeh9id%@gNgT3$5A&~&9_JmlFzar_os|ZH%l~rHQ`WDNT||I)hE=ZG z%vSqeLC>=k2me&+)rjYGZUpa`QBqY3*jyi_jYE-YcRywgxceL3OfFm{cF|htg$#@R zhL>caL~%Dn?oaUgIO#wbntXjrPOw}vQsF)x#U23`k0k2P1Nz~Ct)oZYn*R|)v)^)* zS@z-jtq}#s2+OwB|LN=hve={=-s%DfdB&dCfL|38(lhb!UI~ZpJwmUFt1Z^7>VI7P zsb=;y#`BS)GRcq9 zZ$@`({Ble>e?*4kfomF18w9e|n@X$2t@V|+BvtAwhML0Ns!F?QH}7048bQ|EVVoQG z<2>-Bi_;yY%01zuCFwSp4JNUG8i$%G1sNE{n$vjEbM#b#P6BP8fjfcs#{tLm(R*=4gRa=6$mrQY6=RoTr{PI@G{^^j zRsLFNwpJCZr4$-gpy_S!xJEYTR>?DfsrICwo@pkJ{6(2^-26wdb9ctGrE!E+NtvnVcJCo^$}-2?CUh>R#j)mCXG*FrDgFHd!w)y zDe@YV?y?r$AQZN~g?wJ~>-qCYv}VB8K$o=jy{e9 zs%k|XMA^U9rV$>Dzpy++HCJNqacd%~O_O_f1)r1D61%MJSARakJu<}0y8C!;ua#~Z zTHg{KrNBnwh;bg05q^yksxIZf@$ok6g30e`@d}K%lkj`BpAz8D8!#v>db=GSfa99#uVZO*J2^O>s5+nW&{%P)xeVu4 zW`!rR6_)TCk@YOE(9+sxm}>Jxc^U(VL)M;!1WNT>l0E#v&k-yaM}gG=K5kHzZ_MdfLmbbwu_^!5Xewa# zMjM`>+{@ji%$ogk0stWI|4Yw=vsPD3jmIoPctUQu{H2}7zVy)|-b$n|!B>+pX>aBm$7*faui6xiK=3F6&0{%&oH3-Q zK?zwJEoBmnN_)PSVi3>#q2l4tjh`;o%{7$9P7Lq$@u*8XsT}9SSgXj*pMk!A2b6MT zfZY?UZVYK949_G|$*H>Pvg><}9a>@~Wt>~wQ%0bhbL`#nYN9QkP1XLOJRNY3?~xhv zRCT=qyIaoaMG;ObJ%`JNbDzYN6_&j5$l|rznfV|BHz%!UFAzazQ9~G^Y=+Q&(XidL9Y z`PyV&hXyDaT*j?#^aF>q#7(jL-sHxNTky73P4Rt^Exms_&t&ny> z<-BXFqW87+?feXJg3*q8oyqD$c3<_O%{fUn0j%Qq=r{fchdrI{bp=<{rX(aUD<1nq zUY$#K*^o^=_o)wim`?pH!7K3Z3QA)ccEMgM@J#b17*eVPqNb)lFw-jSyaoA%OL|$( z68XW9tQFuGlZ)M2jA}AFqO+O6B@b_HxPW&a!kzn5S7pfZW16A$W$cSTWqMBpv{##- z3($&7u)W-=9~uDRbq`h@_&U%|g!#LN+gVdi^nKx}JrXYzb*F0G^0JP}r@vp2`qe(Z zzxZ&Dm*SL@Wo#pEh`wv=c7o!_R-QeB)?tnRk9(r9*iiU^H%fVmD`!?|ORtFSzErNq zaaR2eCgDi*#jpALE3J%>qt+bU_A4h$sMNU8k@gd1T-LPNWRSWw^It?p3P;=*5dB+O zMM6d0(pWlI3*F-;7cVjnY2CG_HJ5B#HMd6MfZlI+-w_8^ZJ_%$G|pdF*zx-qB$0-B1T*RUiY|x)g61uDUfyxVD{oEKQa5;Ul2&oRPZiYf^N*CWr$_ z$R3{QT(rb^>uEdds`buaLlyvV)dTG|I3~}x{G$RmrcR3)h!jwHkkV6?ys9!0?Z5xM z78sy8qNWzmK4S9uw>b?*z6YHjz%sW?VdDg+#}x*c{3B^BN+?w}nhG#CbEDFLjMPY4 zkVc5O`EE$uEdNiB)zJ%M6DZ^O+Qpjj+gF)+s7VUiMMb?&Z<5$7?)EG?O+4%HVS&vv zz4C_%p}ET1M8&;r&$|K8&@XSKTmUiRG99f7*SIi38NAk9WA@cRU%lAOA1*-P`Fe*-{;@L&J{ literal 0 HcmV?d00001 diff --git a/screenshots/usage.png b/screenshots/usage.png new file mode 100644 index 0000000000000000000000000000000000000000..ff6fa5786a2406be6bfd6c14dcdd1ca9296092bc GIT binary patch literal 8479 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 literal 0 HcmV?d00001