diff --git a/.github/workflows/arduino.yml b/.github/workflows/arduino.yml index 370e14f..174cbc9 100644 --- a/.github/workflows/arduino.yml +++ b/.github/workflows/arduino.yml @@ -1,139 +1,147 @@ name: Build and Release + on: + pull_request: push: + branches: + - master + - dev tags: - - v* + - "v*" + +permissions: + contents: read + +env: + ARDUINO_ESP32_VERSION: 3.3.11 + FQBN: esp32:esp32:m5stack_atom jobs: + native-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Run native tests + run: make -C tests runtests + build: - strategy: - matrix: - board: [tinypico, pico32] - include: - - board: tinypico - arduino-platform: esp32:esp32 - arduino-platform-version: 2.0.15 - arduino-board: esp32:esp32:tinypico - - board: pico32 - arduino-platform: esp32:esp32 - arduino-platform-version: 2.0.15 - arduino-board: esp32:esp32:pico32 + needs: native-tests runs-on: ubuntu-latest steps: - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y python3-pip - pip3 install pyserial - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + - name: Set up Arduino CLI - uses: arduino/setup-arduino-cli@v1 - - name: Install core + uses: arduino/setup-arduino-cli@v2 + + - name: Install ESP32 core run: | - arduino-cli core update-index - arduino-cli core install --additional-urls "https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json" ${{matrix.arduino-platform}}@${{matrix.arduino-platform-version}} + arduino-cli core update-index --additional-urls "https://espressif.github.io/arduino-esp32/package_esp32_index.json" + arduino-cli core install --additional-urls "https://espressif.github.io/arduino-esp32/package_esp32_index.json" "esp32:esp32@${ARDUINO_ESP32_VERSION}" + - name: Install libraries run: | - arduino-cli lib install "TinyPICO Helper Library"@1.4.0 - arduino-cli lib install "FreeRTOS"@11.0.1-5 + arduino-cli lib install "M5Unified"@0.2.19 + arduino-cli lib install "FastLED"@3.10.5 arduino-cli lib install "ArduinoQueue"@1.2.5 arduino-cli lib install "ArduinoLog"@1.1.1 - arduino-cli lib install "AUnit"@1.7.1 - - name: Compile - run: | - arduino-cli compile --fqbn ${{matrix.arduino-board}} --export-binaries src/bb-link/bb-link.ino - - name: Define version - run: | - VERSION=${GITHUB_REF#refs/tags/} - VERSION=${VERSION#v} - echo "VERSION=${VERSION}" >> $GITHUB_ENV - - name: Rename binary - run: | - fqbnpath=$(echo ${{matrix.arduino-board}} | tr ':' '.') - cp src/bb-link/build/$fqbnpath/bb-link.ino.bin src/bb-link/build/bb-link-${{matrix.board}}-${{env.VERSION}}.bin - - name: Calculate checksum + + - name: Compile ATOM Lite firmware run: | - sha256sum src/bb-link/build/bb-link-${{matrix.board}}-${{env.VERSION}}.bin | cut -d ' ' -f 1 > src/bb-link/build/bb-link-${{matrix.board}}-${{env.VERSION}}.bin.sha256.txt - - name: Create OTA manifest + arduino-cli compile \ + --fqbn "${FQBN}" \ + --build-path build/atomlite \ + --build-property "compiler.cpp.extra_flags=-DBB_LINK_ALLOW_UNSIGNED_OTA_WITH_PHYSICAL_ACCESS=1" \ + --warnings all \ + src/bb-link + + - name: Prepare release artifacts + if: startsWith(github.ref, 'refs/tags/v') run: | - mkdir -p src/bb-link/build/ota/${{matrix.board}} - echo "{ - \"version\": \"${{env.VERSION}}\", - \"url\": \"https://github.com/islandmagic/bb-link/releases/download/v${{env.VERSION}}/bb-link-${{matrix.board}}-${{env.VERSION}}.bin\", - \"sha256\": \"$(cat src/bb-link/build/bb-link-${{matrix.board}}-${{env.VERSION}}.bin.sha256.txt)\" - }" > src/bb-link/build/ota/${{matrix.board}}/beta.json - - name: Upload binaries artifacts + VERSION="${GITHUB_REF_NAME#v}" + BINARY="bb-link-atomlite-${VERSION}.bin" + cp build/atomlite/bb-link.ino.bin "build/atomlite/${BINARY}" + sha256sum "build/atomlite/${BINARY}" | cut -d ' ' -f 1 > "build/atomlite/${BINARY}.sha256.txt" + mkdir -p build/ota/atomlite + SHA256="$(cat "build/atomlite/${BINARY}.sha256.txt")" + printf '{\n "version": "%s",\n "url": "https://github.com/%s/releases/download/v%s/%s",\n "sha256": "%s"\n}\n' \ + "${VERSION}" "${GITHUB_REPOSITORY}" "${VERSION}" "${BINARY}" "${SHA256}" \ + > build/ota/atomlite/beta.json + + - name: Upload firmware + if: startsWith(github.ref, 'refs/tags/v') uses: actions/upload-artifact@v4 with: - name: bin-${{matrix.board}} - path: src/bb-link/build/bb-link-${{matrix.board}}* + name: firmware-atomlite + path: | + build/atomlite/bb-link-atomlite-*.bin + build/atomlite/bb-link-atomlite-*.sha256.txt if-no-files-found: error - - name: Upload ota artifacts + + - name: Upload OTA manifest + if: startsWith(github.ref, 'refs/tags/v') uses: actions/upload-artifact@v4 with: - name: ota-${{matrix.board}} - path: src/bb-link/build/ota/*/beta.json + name: ota-atomlite + path: build/ota/atomlite/beta.json if-no-files-found: error + release: + if: startsWith(github.ref, 'refs/tags/v') needs: build runs-on: ubuntu-latest + permissions: + contents: write steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: true - prerelease: true - - - name: Download artifacts + - name: Download firmware uses: actions/download-artifact@v4 with: + name: firmware-atomlite path: artifacts - pattern: bin-* - merge-multiple: true - - name: Upload Release Assets - id: upload_release_assets - uses: dwenegar/upload-release-assets@v1 + + - name: Create prerelease env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - release_id: ${{ steps.create_release.outputs.id }} - assets_path: "./artifacts/*" + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" artifacts/* \ + --prerelease \ + --title "Release ${GITHUB_REF_NAME}" + publish-ota: + if: startsWith(github.ref, 'refs/tags/v') needs: release runs-on: ubuntu-latest + permissions: + contents: write steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - pattern: ota-* - merge-multiple: true - - name: Setup git user/email - run: | - git config --global user.name github-actions - git config --global user.email github-actions@github.com - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - ref: ota-firmware - path: ota-firmware - - name: Publish OTA + fetch-depth: 0 + + - name: Download OTA manifest + uses: actions/download-artifact@v4 + with: + name: ota-atomlite + path: ${{ runner.temp }}/ota-artifacts/atomlite + + - name: Publish beta manifest run: | - cp -r artifacts/* ota-firmware/ - cd ota-firmware - [ -z "$(git status -s -- .)" ] && echo "No changes to be committed. Exiting." && exit 0 - git add -- . - git commit -m "Add new beta firmware" - git push origin ota-firmware - echo "Done." \ No newline at end of file + git config user.name github-actions + git config user.email github-actions@github.com + if git ls-remote --exit-code --heads origin ota-firmware; then + git fetch origin ota-firmware + git switch --force-create ota-firmware origin/ota-firmware + else + git switch --orphan ota-firmware + git rm -r --force . + fi + mkdir -p atomlite + cp "${RUNNER_TEMP}/ota-artifacts/atomlite/beta.json" atomlite/beta.json + git add atomlite/beta.json + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "Publish ${GITHUB_REF_NAME} beta firmware" + git push origin HEAD:ota-firmware diff --git a/.github/workflows/ota-publisher.yml b/.github/workflows/ota-publisher.yml index 91d98f0..4b0d75b 100644 --- a/.github/workflows/ota-publisher.yml +++ b/.github/workflows/ota-publisher.yml @@ -1,34 +1,32 @@ -name: OTA publish +name: Promote OTA beta on: workflow_dispatch: jobs: promote-beta-to-release: runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + ref: ota-firmware - name: Setup git user/email run: | - git config --global user.name github-actions - git config --global user.email github-actions@github.com - - - name: Checkout - uses: actions/checkout@v3 - with: - ref: ota-firmware - path: ota-firmware + git config user.name github-actions + git config user.email github-actions@github.com - name: Promote beta to release run: | - cd ota-firmware for dir in */; do if [[ -f "${dir}beta.json" ]]; then - cp "${dir}beta.json" "${dir}latest.json" + cp "${dir}beta.json" "${dir}latest.json" fi done - [ -z "$(git status -s -- .)" ] && echo "No changes to be committed. Exiting." && exit 0 - git add -- . + git add -- */latest.json + if git diff --cached --quiet; then + exit 0 + fi git commit -m "Promoted beta to release." git push origin ota-firmware - echo "Done." \ No newline at end of file diff --git a/.gitignore b/.gitignore index f24491c..c3d4a7c 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,8 @@ Thumbs.db .vscode/ src/bb-link/build/ +tests/build/ *.o *.out -sketch.yaml \ No newline at end of file +sketch.yaml diff --git a/README.md b/README.md index 349ab72..d015d37 100644 --- a/README.md +++ b/README.md @@ -36,18 +36,16 @@ To power the adapter from an iPhone or iPad: The firmware keeps the CPU clock, Bluetooth transmit power, and status LED brightness low by default so the adapter draws less current from mobile devices. This assumes the board is powered through the ATOM-Lite USB-C port; USB-C source detection itself is handled by the board hardware, not firmware. ## Build -1. Install Arduino IDE [https://www.arduino.cc](https://www.arduino.cc) -2. Add additional board manager URLs in Arduino IDE Settings: `https://espressif.github.io/arduino-esp32/package_esp32_index.json` -3. Install the ESP32 board library by Espressif Systems -4. Install the M5Unified library -5. Install the FastLED library -6. Install the FreeRTOS library -7. Install the ArduinoQueue library -8. Install the ArduinoLog library -9. Clone this repository -10. Flash the code to the M5Stack ATOM-Lite board -11. Download the [B.B. Link Configurator](https://apps.apple.com/us/app/b-b-link-configurator/id6476163710) app on your phone -12. Download RadioMail on your phone [https://radiomail.app](https://radiomail.app) + +1. Install [Arduino IDE](https://www.arduino.cc/en/software). +2. Add `https://espressif.github.io/arduino-esp32/package_esp32_index.json` to the additional Board Manager URLs. +3. Install **esp32 by Espressif Systems 3.3.11**. +4. Install M5Unified 0.2.19, FastLED 3.10.5, ArduinoQueue 1.2.5, and ArduinoLog 1.1.1. +5. Select **M5Atom** as the board (`esp32:esp32:m5stack_atom` in Arduino CLI). +6. Open `src/bb-link/bb-link.ino`, compile, and flash the ATOM Lite. +7. Install [B.B. Link Configurator](https://apps.apple.com/us/app/b-b-link-configurator/id6476163710) and [RadioMail](https://radiomail.app) on the iPhone or iPad. + +The firmware advertises the compatibility name `B.B. Link` by default. A custom name can be supplied at build time with `ADAPTER_NAME`, but application-side rig-control detection must be verified if the name is changed. ### Rig Control @@ -83,10 +81,9 @@ By default, the adapter sets the radio to KISS mode and automatically responds t ### Buttons - **Main Button**: - - Long press: Power on/off - - Short press: Reconnect to radio and/or iOS device -- **Side Button**: - - Long press: Reboot the adapter + - Short press: Disconnect and retry the saved radio connection. + - Hold for 2 seconds while running: enter deep sleep. Press once to wake. + - Hold for 3 seconds during startup: request the five-minute BLE firmware-update window. The service is exposed only when the build permits signed OTA, or when unsigned physical-access OTA was explicitly enabled at build time. ### LED Indicators @@ -120,6 +117,31 @@ Alternatively, you can reset the adapter by connecting it to a computer: 5. Monitor the response in the Serial Monitor, which will confirm the clearing of previously paired devices. 6. After the process is complete, disconnect the adapter from your PC. +## Firmware Update Security + +The BLE OTA service is not advertised during normal operation. It requires a three-second physical button hold during startup, uses an encrypted BLE characteristic, closes after five minutes, and checks every ESP-IDF OTA operation for failure. New firmware is marked valid only after the bridge, Bluetooth Classic, BLE, preferences, button, and status indicator have initialized successfully. + +Unsigned OTA is disabled in source builds by default. Production OTA builds should use ESP-IDF signed-app verification (`CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT`) and bootloader rollback (`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`). Developers who accept the reduced security of a physically authorized unsigned update may explicitly build with `BB_LINK_ALLOW_UNSIGNED_OTA_WITH_PHYSICAL_ACCESS=1`. + +ATOM Lite uses hardware board ID `3`. Its update manifests are published under `atomlite/` on the `ota-firmware` branch. The corresponding Configurator mapping is: + +```swift +case 0x03: + return URL(string: "https://raw.githubusercontent.com/halka/bb-link/ota-firmware/atomlite/\(channel).json") +``` + +The release workflow builds only the ATOM Lite target and explicitly enables the physical-access unsigned OTA profile. It creates `bb-link-atomlite-.bin`, its SHA-256 checksum, and the beta manifest. The separate **Promote OTA beta** workflow promotes `beta.json` to `latest.json` after validation. For higher-assurance deployments, replace that profile with a custom ESP-IDF core configured for signed-app verification and rollback. + +## Tests + +The KISS stream parser tests run without Arduino or EpoxyDuino: + +```sh +make -C tests runtests +``` + +They cover offset frames, malformed and truncated commands, escaped data, multiple frames in one BLE write, and commands split across writes. The legacy hardware-abstraction tests remain available as `epoxy-tests` and accept `EPOXYDUINO_DIR` instead of relying on a fixed home-directory path. + ## Troubleshooting If the adapter connects to the radio but the radio does not transmit, check the TNC settings. Go to Menu > Configuration > Interface > KISS (983) and set it to Bluetooth. diff --git a/src/bb-link/Adapter.cpp b/src/bb-link/Adapter.cpp index e23c4ba..f178e07 100644 --- a/src/bb-link/Adapter.cpp +++ b/src/bb-link/Adapter.cpp @@ -1,9 +1,12 @@ #include #include +#include #include "Adapter.h" #define ACTION_FEEDBACK_DURATION 2000 // Duration for action feedback (long-press registered) #define LINGER_TIME_BEFORE_SHUTDOWN 2000 // Grace time so user can release button before sleep +#define OTA_BUTTON_HOLD_MS 3000 +#define OTA_SESSION_TIMEOUT_MS 300000 #define SERVICE_UUID_OTA "1A68D2B0-C2E4-453F-A2BB-B659D66CF442" #define CHARACTERISTIC_UUID_OTA_FLASH "1A68D2B1-C2E4-453F-A2BB-B659D66CF442" @@ -60,7 +63,9 @@ void Adapter::init() { button.setOnLongPressed([this]() { onLongPressed(); }); button.setOnShortPressed([this]() { onShortPressed(); }); - if (!bridge.init()) { + const bool bridgeReady = bridge.init(); + if (!bridgeReady) { + verifyFirmware(false); Log.fatalln("FATAL: Bridge init failed !!!!!"); statusIndicator.set(error); while (true) { @@ -69,11 +74,24 @@ void Adapter::init() { } } - initBLEOtaService(); - verifyFirmware(); + if (otaModeRequested()) { + if (otaSecurityConfigured()) { + otaModeEnabled = initBLEOtaService(); + if (otaModeEnabled) + otaModeStartedAt = millis(); + } else { + Log.errorln("OTA: disabled because signed-app verification is not configured"); + } + } + + // Core services, state machines, preferences and BLE/BTC initialization have + // all succeeded by this point. Run the bridge once so its initial states and + // BLE advertising also enter successfully before accepting an updated image. + bridge.perform(); + verifyFirmware(true); } -void Adapter::verifyFirmware() { +void Adapter::verifyFirmware(bool selfTestPassed) { Log.traceln("Checking firmware..."); const esp_partition_t *running = esp_ota_get_running_partition(); esp_ota_img_states_t ota_state; @@ -87,6 +105,13 @@ void Adapter::verifyFirmware() { Log.infoln("OTA state: %s", otaState); if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) { + if (!selfTestPassed) { + Log.fatalln("Firmware self-test failed; rolling back"); + esp_err_t error = esp_ota_mark_app_invalid_rollback_and_reboot(); + Log.fatalln("Rollback failed (0x%x)", static_cast(error)); + return; + } + if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) { Log.infoln("App is valid, rollback cancelled successfully"); } else { @@ -98,10 +123,55 @@ void Adapter::verifyFirmware() { } } -void Adapter::initBLEOtaService() { +bool Adapter::otaModeRequested() { +#if !BB_LINK_ENABLE_BLE_OTA + return false; +#else + if (digitalRead(ATOM_LITE_BTN_GPIO) != LOW) + return false; + + Log.infoln("OTA: hold the button for %d ms to enter update mode", OTA_BUTTON_HOLD_MS); + statusIndicator.set(actionRegistered); + const unsigned long started = millis(); + while (digitalRead(ATOM_LITE_BTN_GPIO) == LOW) + { + statusIndicator.render(); + if (millis() - started >= OTA_BUTTON_HOLD_MS) + { + Log.infoln("OTA: physical-presence update mode requested"); + return true; + } + delay(10); + } + return false; +#endif +} + +bool Adapter::otaSecurityConfigured() { +#if (defined(CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT) && CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT) || \ + (defined(CONFIG_SECURE_BOOT) && CONFIG_SECURE_BOOT) || \ + BB_LINK_ALLOW_UNSIGNED_OTA_WITH_PHYSICAL_ACCESS + return true; +#else + return false; +#endif +} + +bool Adapter::initBLEOtaService() { Log.traceln("Adapter: init BLE OTA service"); + // Encrypt the OTA transport. In signed-app builds, firmware authenticity is + // independently enforced by ESP-IDF when esp_ota_end() validates the image. + BLESecurity::setAuthenticationMode(true, false, true); + BLESecurity::setCapability(ESP_IO_CAP_NONE); + BLESecurity::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT_NO_MITM); + BLEService *pOtaService = bridge.getBLEServer()->createService(SERVICE_UUID_OTA); + if (pOtaService == nullptr) + { + Log.errorln("OTA: failed to create service"); + return false; + } pOtaFlash = pOtaService->createCharacteristic( CHARACTERISTIC_UUID_OTA_FLASH, @@ -111,9 +181,13 @@ void Adapter::initBLEOtaService() { CHARACTERISTIC_UUID_OTA_IDENTITY, BLECharacteristic::PROPERTY_READ); - pOtaFlash->addDescriptor(new BLE2902()); + if (pOtaFlash == nullptr || pOtaIdentity == nullptr) + { + Log.errorln("OTA: failed to create characteristics"); + return false; + } - pOtaFlash->setAccessPermissions(ESP_GATT_PERM_WRITE); + pOtaFlash->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED); pOtaIdentity->setAccessPermissions(ESP_GATT_PERM_READ); pOtaFlash->setCallbacks(this); @@ -122,6 +196,8 @@ void Adapter::initBLEOtaService() { pOtaIdentity->setValue(identity, 6); pOtaService->start(); + Log.infoln("OTA: service available for five minutes"); + return true; } void Adapter::perform() { @@ -130,6 +206,13 @@ void Adapter::perform() { button.process(); } adapterStateMachine.update(); + + if (otaModeEnabled && !adapterStateMachine.isInState(otaFlashState) && + millis() - otaModeStartedAt > OTA_SESSION_TIMEOUT_MS) { + Log.warningln("OTA: authorized update window expired; rebooting"); + delay(100); + esp_restart(); + } } void Adapter::updateSendReceiveStatus() { @@ -167,37 +250,89 @@ void Adapter::onLongPressed() { } void Adapter::onShortPressed() { - Log.infoln("Short pressed button"); - // External power: nothing meaningful to do while awake. - // Wake-from-deep-sleep is handled by the ext0 wakeup pin. + Log.infoln("Short pressed button, reconnecting radio"); + bridge.reconnectRadio(); } void Adapter::onWrite(BLECharacteristic *pCharacteristic) { - String rxData = pCharacteristic->getValue(); + const size_t rxSize = pCharacteristic->getLength(); + const uint8_t *rxData = pCharacteristic->getData(); + + if (!otaModeEnabled || !otaSecurityConfigured()) { + Log.errorln("OTA: rejected outside authorized update mode"); + return; + } + + if (!otaWriteInProgress) { + if (rxSize == 0) { + Log.warningln("OTA: ignored empty start packet"); + return; + } - if (!adapterStateMachine.isInState(otaFlashState)) { Log.infoln("OTA: begin flash"); adapterStateMachine.immediateTransitionTo(otaFlashState); - esp_ota_begin(esp_ota_get_next_update_partition(NULL), OTA_SIZE_UNKNOWN, &otaHandle); + otaPartition = esp_ota_get_next_update_partition(nullptr); + if (otaPartition == nullptr) { + abortOta("no update partition"); + return; + } + + esp_err_t error = esp_ota_begin(otaPartition, OTA_SIZE_UNKNOWN, &otaHandle); + if (error != ESP_OK) { + abortOta("esp_ota_begin failed", error); + return; + } + otaWriteInProgress = true; + otaBytesWritten = 0; } - if (rxData.length() > 0) { - esp_ota_write(otaHandle, rxData.c_str(), rxData.length()); - Log.infoln("OTA: written %i bytes", rxData.length()); + if (rxSize > 0) { + esp_err_t error = esp_ota_write(otaHandle, rxData, rxSize); + if (error != ESP_OK) { + abortOta("esp_ota_write failed", error); + return; + } + otaBytesWritten += rxSize; + Log.infoln("OTA: written %i bytes total", otaBytesWritten); } else { Log.infoln("OTA: end flash"); - esp_ota_end(otaHandle); - if (esp_ota_set_boot_partition(esp_ota_get_next_update_partition(NULL)) == ESP_OK) { + esp_err_t error = esp_ota_end(otaHandle); + otaWriteInProgress = false; + otaHandle = 0; + if (error != ESP_OK) { + abortOta("image validation failed", error); + return; + } + + error = esp_ota_set_boot_partition(otaPartition); + if (error == ESP_OK) { Log.infoln("OTA: success, rebooting"); delay(2000); esp_restart(); } else { - Log.errorln("OTA: failed"); + abortOta("esp_ota_set_boot_partition failed", error); } - adapterStateMachine.transitionTo(idleState); } } +void Adapter::abortOta(const char *reason, esp_err_t error) { + Log.errorln("OTA: %s (0x%x)", reason, static_cast(error)); + if (otaWriteInProgress && otaHandle != 0) { + esp_err_t abortError = esp_ota_abort(otaHandle); + if (abortError != ESP_OK) + Log.errorln("OTA: esp_ota_abort failed (0x%x)", static_cast(abortError)); + } + otaHandle = 0; + otaPartition = nullptr; + otaBytesWritten = 0; + otaWriteInProgress = false; + otaModeEnabled = false; + statusIndicator.set(error); + statusIndicator.render(); + delay(1000); + esp_restart(); +} + void Adapter::onRead(BLECharacteristic *pCharacteristic) { Log.errorln("OTA: onRead!!!!"); } @@ -330,7 +465,9 @@ void Adapter::otaFlashEnter() { } void Adapter::otaFlashUpdate() { - // Handled by BLECharacteristicCallbacks + if (adapterStateMachine.timeInCurrentState() > OTA_SESSION_TIMEOUT_MS) { + abortOta("update timed out", ESP_ERR_TIMEOUT); + } } void Adapter::otaFlashExit() { diff --git a/src/bb-link/Adapter.h b/src/bb-link/Adapter.h index 07d23bf..2f40cfb 100644 --- a/src/bb-link/Adapter.h +++ b/src/bb-link/Adapter.h @@ -14,11 +14,13 @@ #include -#define ADAPTER_NAME "B.B. Link ATOM Lite" +#ifndef ADAPTER_NAME +#define ADAPTER_NAME "B.B. Link" +#endif #define FIRMWARE_VERSION_MAJOR 0 #define FIRMWARE_VERSION_MINOR 7 -#define FIRMWARE_VERSION_PATCH 8 +#define FIRMWARE_VERSION_PATCH 9 #define DEVICE_NAMESPACE "bb-link-hw" #define IDENTITY_KEY "identity" @@ -30,6 +32,16 @@ // Atom Lite button (GPIO39). RTC-IO capable so it can wake from deep sleep. #define ATOM_LITE_BTN_GPIO GPIO_NUM_39 +#ifndef BB_LINK_ENABLE_BLE_OTA +#define BB_LINK_ENABLE_BLE_OTA 1 +#endif + +// Unsafe unsigned BLE OTA stays disabled unless a developer explicitly opts +// in. Production builds should enable signed-app verification in ESP-IDF. +#ifndef BB_LINK_ALLOW_UNSIGNED_OTA_WITH_PHYSICAL_ACCESS +#define BB_LINK_ALLOW_UNSIGNED_OTA_WITH_PHYSICAL_ACCESS 0 +#endif + enum hardware_board_t { hardware_board_unknown = 0, hardware_board_m5atom = 3 @@ -69,11 +81,19 @@ class Adapter : public BLECharacteristicCallbacks { AdapterState otaFlashState; FSMT adapterStateMachine; - BLECharacteristic *pOtaFlash; - BLECharacteristic *pOtaIdentity; + BLECharacteristic *pOtaFlash = nullptr; + BLECharacteristic *pOtaIdentity = nullptr; esp_ota_handle_t otaHandle = 0; - - void verifyFirmware(); + const esp_partition_t *otaPartition = nullptr; + size_t otaBytesWritten = 0; + bool otaModeEnabled = false; + bool otaWriteInProgress = false; + unsigned long otaModeStartedAt = 0; + + void verifyFirmware(bool selfTestPassed); + bool otaModeRequested(); + bool otaSecurityConfigured(); + void abortOta(const char *reason, esp_err_t error = ESP_FAIL); void onLongPressed(); void onShortPressed(); void updateSendReceiveStatus(); @@ -92,7 +112,7 @@ class Adapter : public BLECharacteristicCallbacks { void otaFlashUpdate(); void otaFlashExit(); - void initBLEOtaService(); + bool initBLEOtaService(); void onWrite(BLECharacteristic *pCharacteristic); void onRead(BLECharacteristic *pCharacteristic); diff --git a/src/bb-link/Bridge.cpp b/src/bb-link/Bridge.cpp index a713578..82a1e2d 100644 --- a/src/bb-link/Bridge.cpp +++ b/src/bb-link/Bridge.cpp @@ -26,6 +26,13 @@ extern bool _isRemoteAddressSet; extern Adapter *adapter; +static void copyDeviceName(char *destination, size_t destinationSize, const char *source) +{ + if (destination == nullptr || destinationSize == 0) + return; + snprintf(destination, destinationSize, "%s", source == nullptr ? "" : source); +} + void connectToBluetooth(void *address) { Log.traceln("BTC: connecting to Bluetooth Classic interface"); @@ -71,13 +78,20 @@ Bridge::Bridge(String adapterName) : bleDisconnectedState( { this->btcDiscoveryExit(); }), btcStateMachine(btcDisconnectedState), adapterName(adapterName), - cmdQueue(10) + cmdQueue(MAX_EXTENDED_COMMANDS_PER_WRITE), + dataQueue(16) { + queueMutex = xSemaphoreCreateMutex(); } bool Bridge::init() { Log.traceln("Bridge: init"); + if (queueMutex == nullptr) + { + Log.fatalln("FATAL: failed to create BLE queue mutex"); + return false; + } rxLingerUntil = millis(); txLingerUntil = millis(); @@ -104,20 +118,55 @@ void Bridge::perform() uint8_t rxBuf[RX_BUF_SIZE]; size_t rxLen = 0; - // Process any command received from BLE - while (!cmdQueue.isEmpty()) + // Commands and KISS data share a sequence across two differently-sized + // queues. Merge the heads here so frequency changes and packet data retain + // their original BLE write order without allocating command-sized 512-byte + // queue entries. + while (true) { - Log.traceln("BLE: dequeueing extended hardware command"); - processingCmdQueue = true; - extended_hw_cmd_t cmd = cmdQueue.dequeue(); - processExtendedHardwareCommand(&cmd); + if (!lockQueues()) + { + Log.errorln("BLE: failed to lock pending queues"); + break; + } + + if (cmdQueue.isEmpty() && dataQueue.isEmpty()) + { + unlockQueues(); + break; + } + + const bool processCommand = !cmdQueue.isEmpty() && + (dataQueue.isEmpty() || cmdQueue.getHeadPtr()->sequence < dataQueue.getHeadPtr()->sequence); + + if (processCommand) + { + Log.traceln("BLE: dequeueing extended hardware command"); + processingCmdQueue = true; + queued_command_t queued = cmdQueue.dequeue(); + unlockQueues(); + processExtendedHardwareCommand(&queued.command); + processingCmdQueue = false; + continue; + } + + ble_data_chunk_t chunk = dataQueue.dequeue(); + unlockQueues(); + if (!btcStateMachine.isInState(btcConnectedState)) + { + Log.warningln("BLE: discarding queued data because radio disconnected"); + continue; + } + Log.traceln("BLE > BTC (queued): %i", chunk.size); + btSerial.write(chunk.data, chunk.size); + setTxLinger(BYTE_TRANSMIT_TIME * chunk.size); } - processingCmdQueue = false; if (isReady()) { // Buffer data available from BTC - while (btSerial.available() && rxLen < mtuSize) + const size_t notificationPayload = mtuSize > 3 ? min(static_cast(mtuSize - 3), RX_BUF_SIZE) : 20; + while (btSerial.available() && rxLen < notificationPayload) { rxBuf[rxLen++] = btSerial.read(); } @@ -132,6 +181,16 @@ void Bridge::perform() } } +bool Bridge::lockQueues() +{ + return queueMutex != nullptr && xSemaphoreTake(queueMutex, portMAX_DELAY) == pdTRUE; +} + +void Bridge::unlockQueues() +{ + xSemaphoreGive(queueMutex); +} + void Bridge::disconnect() { clearAllPendingBTCData(); @@ -139,6 +198,21 @@ void Bridge::disconnect() btSerial.disconnect(); } +void Bridge::reconnectRadio() +{ + if (remoteName[0] == '\0') + { + Log.warningln("BTC: no paired radio to reconnect"); + return; + } + + Log.infoln("BTC: manual reconnect requested"); + clearAllPendingBTCData(); + connectToPairedDevice = true; + btSerial.disconnect(); + btcStateMachine.immediateTransitionTo(btcDisconnectedState); +} + void Bridge::factoryReset() { disconnect(); @@ -214,9 +288,19 @@ bool Bridge::initBLE() BLEDevice::init(adapterName.c_str()); configureBluetoothPower(); pBLEServer = BLEDevice::createServer(); + if (pBLEServer == nullptr) + { + Log.errorln("BLE: failed to create server"); + return false; + } pBLEServer->setCallbacks(this); BLEService *pService = pBLEServer->createService(SERVICE_UUID); + if (pService == nullptr) + { + Log.errorln("BLE: failed to create KISS service"); + return false; + } pTx = pService->createCharacteristic( TX_UUID, @@ -226,6 +310,12 @@ bool Bridge::initBLE() RX_UUID, BLECharacteristic::PROPERTY_NOTIFY); + if (pTx == nullptr || pRx == nullptr) + { + Log.errorln("BLE: failed to create KISS characteristics"); + return false; + } + pRx->addDescriptor(new BLE2902()); pRx->setAccessPermissions(ESP_GATT_PERM_READ); @@ -322,7 +412,7 @@ void Bridge::lookUpLastPairedDevice() { Log.infoln("Found paired device name: %s", radioName); memcpy(remoteAddress, pairedDeviceBtAddr[i], ESP_BD_ADDR_LEN); - strcpy(remoteName, radioName.c_str()); + copyDeviceName(remoteName, sizeof(remoteName), radioName.c_str()); connectToPairedDevice = true; break; } @@ -573,7 +663,7 @@ void Bridge::processExtendedHardwareCommand(extended_hw_cmd_t *cmd) { Log.infoln("BTC: Pairing with: %s %s", device->getName().c_str(), device->getAddress().toString().c_str()); memcpy(remoteAddress, device->getAddress().getNative(), sizeof(esp_bd_addr_t)); - strcpy(remoteName, device->getName().c_str()); + copyDeviceName(remoteName, sizeof(remoteName), device->getName().c_str()); connectToPairedDevice = true; // force connections btcStateMachine.immediateTransitionTo(btcDisconnectedState); @@ -607,7 +697,7 @@ void Bridge::processExtendedHardwareCommand(extended_hw_cmd_t *cmd) { Log.traceln("BTC: extended_hw_capabilities"); uint16_t caps; - caps = useRigControl ? CAP_RIG_CTRL : 0 | CAP_FIRMWARE_VERSION; + caps = (useRigControl ? CAP_RIG_CTRL : 0) | CAP_FIRMWARE_VERSION; reply16(EXTENDED_HW_CMD_CAPABILITIES, caps); break; } @@ -617,7 +707,7 @@ void Bridge::processExtendedHardwareCommand(extended_hw_cmd_t *cmd) found_device_t paired; paired.connected = btcConnected() ? 0x01 : 0x00; memcpy(paired.address, remoteAddress, sizeof(esp_bd_addr_t)); - strcpy(paired.name, remoteName); + copyDeviceName(paired.name, sizeof(paired.name), remoteName); reply(EXTENDED_HW_CMD_GET_PAIRED_DEVICE, reinterpret_cast(&paired), 1 + sizeof(esp_bd_addr_t) + strlen(paired.name)); break; } @@ -718,7 +808,7 @@ void Bridge::onDisconnect(BLEServer *pServer) void Bridge::onMtuChanged(BLEServer *pServer, esp_ble_gatts_cb_param_t *param) { Log.traceln("BLE: onMtuChanged"); - mtuSize = param->mtu.mtu; + mtuSize = max(static_cast(23), param->mtu.mtu); Log.infoln("New MTU size: %d", mtuSize); } @@ -727,33 +817,111 @@ void Bridge::onMtuChanged(BLEServer *pServer, esp_ble_gatts_cb_param_t *param) */ void Bridge::onWrite(BLECharacteristic *pCharacteristic) { - String txValue = pCharacteristic->getValue(); + const size_t txSize = pCharacteristic->getLength(); - if (txValue.length() > 0) + if (txSize > 0) { - Log.traceln("BLE Rx: %i", txValue.length()); + Log.traceln("BLE Rx: %i", txSize); + + size_t commandCount = 0; + size_t passthroughSize = 0; + kiss_process_result_t result = kissInterceptor.process( + reinterpret_cast(pCharacteristic->getData()), + txSize, + parsedCommands, + MAX_EXTENDED_COMMANDS_PER_WRITE, + &commandCount, + kissPassthrough, + sizeof(kissPassthrough), + &passthroughSize); + + if (result != kiss_process_ok) + { + Log.errorln("BLE: KISS parser rejected write (%d)", result); + kissInterceptor.reset(); + return; + } - extended_hw_cmd_t cmd; - if (kissInterceptor.extractExtendedHardwareCommand((uint8_t *)pCharacteristic->getData(), txValue.length(), &cmd)) + if (!lockQueues()) { - Log.traceln("BLE: queueing extended hardware command"); - cmdQueue.enqueue(cmd); + Log.errorln("BLE: failed to lock extended hardware command queue"); + return; } - else if (btcStateMachine.isInState(btcConnectedState)) + + const size_t commandSlots = cmdQueue.maxQueueSize() - cmdQueue.itemCount(); + if (commandCount > commandSlots) + { + unlockQueues(); + Log.errorln("BLE: extended hardware command queue has insufficient capacity"); + kissInterceptor.reset(); + return; + } + + for (size_t i = 0; i < commandCount; ++i) { - // Drop data if we're still processing cmds - // This is to avoid sending data to the radio while it may not have changed frequency yet - if (processingCmdQueue || !cmdQueue.isEmpty()) + Log.traceln("BLE: queueing extended hardware command"); + queued_command_t queued = { nextQueueSequence++, parsedCommands[i] }; + if (!cmdQueue.enqueue(queued)) { - Log.traceln("BLE: dropping data while still processing hw commands"); + unlockQueues(); + Log.errorln("BLE: extended hardware command queue full"); + kissInterceptor.reset(); return; } + } + unlockQueues(); + + queueOrSendBLEData(kissPassthrough, passthroughSize); + } +} + +void Bridge::queueOrSendBLEData(const uint8_t *data, size_t size) +{ + if (data == nullptr || size == 0) + return; + + if (!lockQueues()) + { + Log.errorln("BLE: failed to lock pending data queue"); + return; + } + + const bool sendImmediately = btcStateMachine.isInState(btcConnectedState) && + !processingCmdQueue && cmdQueue.isEmpty() && dataQueue.isEmpty(); + if (sendImmediately) + { + unlockQueues(); + Log.traceln("BLE > BTC: %i", size); + btSerial.write(data, size); + setTxLinger(BYTE_TRANSMIT_TIME * size); + return; + } + + const size_t chunksRequired = (size + MAX_BLE_WRITE_SIZE - 1) / MAX_BLE_WRITE_SIZE; + const size_t chunkSlots = dataQueue.maxQueueSize() - dataQueue.itemCount(); + if (chunksRequired > chunkSlots) + { + unlockQueues(); + Log.errorln("BLE: pending data queue has insufficient capacity; disconnect and retry"); + return; + } - Log.traceln("BLE > BTC: %i", txValue.length()); - btSerial.write(pCharacteristic->getData(), txValue.length()); - setTxLinger(BYTE_TRANSMIT_TIME * txValue.length()); + size_t offset = 0; + while (offset < size) + { + ble_data_chunk_t chunk = {}; + chunk.sequence = nextQueueSequence++; + chunk.size = min(size - offset, sizeof(chunk.data)); + memcpy(chunk.data, data + offset, chunk.size); + if (!dataQueue.enqueue(chunk)) + { + unlockQueues(); + Log.errorln("BLE: pending data queue full; disconnect and retry"); + return; } + offset += chunk.size; } + unlockQueues(); } void Bridge::onRead(BLECharacteristic *pCharacteristic) @@ -835,6 +1003,10 @@ void Bridge::btcConnectedEnter() { Log.infoln("BTC: connected"); clearAllPendingBTCData(); + if (bleStateMachine.isInState(bleConnectedState)) + { + configureRadioForBLESession(); + } } void Bridge::btcConnectedUpdate() @@ -871,7 +1043,7 @@ void Bridge::btcDiscoveryEnter() found_device_t found; found.connected = 0x00; memcpy(found.address, pDevice->getAddress().getNative(), sizeof(esp_bd_addr_t)); - strcpy(found.name, pDevice->getName().c_str()); + copyDeviceName(found.name, sizeof(found.name), pDevice->getName().c_str()); reply(EXTENDED_HW_CMD_FOUND_DEVICE, reinterpret_cast(&found), 1 + sizeof(esp_bd_addr_t) + strlen(found.name)); } })) { @@ -902,6 +1074,14 @@ void Bridge::btcDiscoveryExit() void Bridge::bleDisconnectedEnter() { Log.infoln("BLE: disconnected"); + kissInterceptor.reset(); + if (lockQueues()) + { + while (!cmdQueue.isEmpty()) cmdQueue.dequeue(); + while (!dataQueue.isEmpty()) dataQueue.dequeue(); + processingCmdQueue = false; + unlockQueues(); + } startAdvertisingBLE(); } @@ -919,13 +1099,21 @@ void Bridge::bleConnectedEnter() Log.infoln("BLE: connected"); clearAllPendingBTCData(); + configureRadioForBLESession(); +} + +void Bridge::configureRadioForBLESession() +{ + if (useRigControl) { vfo = vfoUnknown; previousTNCMode = tncUnknown; - // Make sure there is a radio to talk to - if (btcStateMachine.isInState(btcConnectedState)) + // Make sure there is a radio to talk to. This helper is called from both + // BLE and Bluetooth Classic connect paths, so reconnecting either side + // always re-establishes KISS mode. + if (btSerial.connected()) { /* * We don't know what TNC mode the radio is in. If the KISS TNC is already on, diff --git a/src/bb-link/Bridge.h b/src/bb-link/Bridge.h index 6582b63..8b15751 100644 --- a/src/bb-link/Bridge.h +++ b/src/bb-link/Bridge.h @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include "THD7x.h" #include "FiniteStateMachine.h" @@ -24,6 +26,23 @@ const char PREFERENCES_NAMESPACE[] = "bb-link"; const uint16_t CAP_RIG_CTRL = 0x0010; const uint16_t CAP_FIRMWARE_VERSION = 0x0800; +const size_t MAX_BLE_WRITE_SIZE = 512; +const size_t MAX_KISS_PASSTHROUGH_SIZE = 1536; +const size_t MAX_EXTENDED_COMMANDS_PER_WRITE = 192; + +struct ble_data_chunk_t +{ + uint64_t sequence; + size_t size; + uint8_t data[MAX_BLE_WRITE_SIZE]; +}; + +struct queued_command_t +{ + uint64_t sequence; + extended_hw_cmd_t command; +}; + DECLARE_STATE(BLEState); DECLARE_STATE(BTCState); @@ -43,6 +62,7 @@ class Bridge : public BLECharacteristicCallbacks, public BLEServerCallbacks bool isRx(); void clearPairedDevices(); void disconnect(); + void reconnectRadio(); void factoryReset(); BLEServer * getBLEServer(); String getAdapterName(); @@ -51,12 +71,12 @@ class Bridge : public BLECharacteristicCallbacks, public BLEServerCallbacks private: String adapterName; - BTScanResults *btDeviceList; - BLEServer *pBLEServer; + BTScanResults *btDeviceList = nullptr; + BLEServer *pBLEServer = nullptr; - BLECharacteristic *pTx; - BLECharacteristic *pRx; - uint16_t mtuSize = 512; + BLECharacteristic *pTx = nullptr; + BLECharacteristic *pRx = nullptr; + uint16_t mtuSize = 23; THD7x thd7x = THD7x(btSerial); vfo_t vfo = vfoUnknown; @@ -86,8 +106,13 @@ class Bridge : public BLECharacteristicCallbacks, public BLEServerCallbacks bool useRigControl = true; Preferences preferences; - ArduinoQueue cmdQueue; - bool processingCmdQueue = false; + ArduinoQueue cmdQueue; + ArduinoQueue dataQueue; + volatile bool processingCmdQueue = false; + uint64_t nextQueueSequence = 0; + SemaphoreHandle_t queueMutex = nullptr; + extended_hw_cmd_t parsedCommands[MAX_EXTENDED_COMMANDS_PER_WRITE]; + uint8_t kissPassthrough[MAX_KISS_PASSTHROUGH_SIZE]; bool initBTC(); bool initBLE(); @@ -101,6 +126,10 @@ class Bridge : public BLECharacteristicCallbacks, public BLEServerCallbacks void processExtendedHardwareCommand(extended_hw_cmd_t *cmd); void clearStoredPairedDeviceInfo(); void clearRemoteDeviceInfo(); + void configureRadioForBLESession(); + void queueOrSendBLEData(const uint8_t *data, size_t size); + bool lockQueues(); + void unlockQueues(); void reply8(uint8_t cmd, uint8_t data); void reply16(uint8_t cmd, uint16_t data); diff --git a/src/bb-link/KISSInterceptor.cpp b/src/bb-link/KISSInterceptor.cpp index 89f4a82..2823be9 100644 --- a/src/bb-link/KISSInterceptor.cpp +++ b/src/bb-link/KISSInterceptor.cpp @@ -1,157 +1,229 @@ -#include #include "KISSInterceptor.h" +#include static const uint8_t FEND = 0xC0; static const uint8_t FESC = 0xDB; static const uint8_t TFEND = 0xDC; static const uint8_t TFESC = 0xDD; -KISSInterceptor::KISSInterceptor() +KISSInterceptor::KISSInterceptor() : pendingFrameSize(0) {} + +bool KISSInterceptor::extractExtendedHardwareCommand(uint8_t *buffer, size_t size, extended_hw_cmd_t *cmd) { + if (buffer == nullptr || cmd == nullptr || size < 4) + { + return false; + } + + for (size_t start = 0; start + 1 < size; ++start) + { + if (buffer[start] != FEND || buffer[start + 1] != CMD_HARDWARE) + continue; + + for (size_t end = start + 2; end < size; ++end) + { + if (buffer[end] == FEND) + return decodeExtendedHardwareCommand(buffer + start, end - start + 1, cmd); + } + } + return false; } -bool KISSInterceptor::extractExtendedHardwareCommand(uint8_t *buffer, size_t size, extended_hw_cmd_t *cmd) +void KISSInterceptor::reset() +{ + pendingFrameSize = 0; +} + +bool KISSInterceptor::appendPassthrough( + const uint8_t *data, + size_t size, + uint8_t *passthrough, + size_t passthroughCapacity, + size_t *passthroughSize) +{ + if (*passthroughSize + size > passthroughCapacity) + return false; + memcpy(passthrough + *passthroughSize, data, size); + *passthroughSize += size; + return true; +} + +kiss_process_result_t KISSInterceptor::process( + const uint8_t *buffer, + size_t size, + extended_hw_cmd_t *commands, + size_t commandCapacity, + size_t *commandCount, + uint8_t *passthrough, + size_t passthroughCapacity, + size_t *passthroughSize) { - // Look for frame start - for (int i = 0; i < size; i++) + if (commandCount == nullptr || passthroughSize == nullptr || + (size > 0 && buffer == nullptr) || + (commandCapacity > 0 && commands == nullptr) || + (passthroughCapacity > 0 && passthrough == nullptr)) + return kiss_process_output_overflow; + + *commandCount = 0; + *passthroughSize = 0; + + for (size_t i = 0; i < size; ++i) { - if (buffer[i] == FEND) + const uint8_t value = buffer[i]; + + if (pendingFrameSize == 0) { - // Fast check for hardware command - if (buffer[i + 1] != CMD_HARDWARE) + if (value == FEND) { - break; + pendingFrame[pendingFrameSize++] = value; } + else if (!appendPassthrough(&value, 1, passthrough, passthroughCapacity, passthroughSize)) + { + return kiss_process_output_overflow; + } + continue; + } + + if (pendingFrameSize >= MAX_FRAME_SIZE) + { + if (!appendPassthrough(pendingFrame, pendingFrameSize, passthrough, passthroughCapacity, passthroughSize) || + !appendPassthrough(&value, 1, passthrough, passthroughCapacity, passthroughSize)) + return kiss_process_output_overflow; + pendingFrameSize = 0; + continue; + } - Log.traceln("Found SET HW KISS frame start at index %d", i); - // Look for frame end - for (int j = i + 1; j < size; j++) + pendingFrame[pendingFrameSize++] = value; + if (value != FEND) + continue; + + if (pendingFrameSize == 2) + { + // Consecutive FEND bytes are KISS synchronization, not a data frame. + pendingFrameSize = 1; + continue; + } + + const bool isHardwareFrame = pendingFrameSize >= 3 && pendingFrame[1] == CMD_HARDWARE; + if (isHardwareFrame) + { + extended_hw_cmd_t cmd = {}; + if (decodeExtendedHardwareCommand(pendingFrame, pendingFrameSize, &cmd)) { - if (buffer[j] == FEND) + if (*commandCount >= commandCapacity) { - Log.traceln("Found frame end at index %d", j); - - // Copy frame to new buffer - uint8_t frame[j - i + 1]; - memcpy(frame, &buffer[i], j - i + 1); - - // Unescape frame - uint8_t unescapedBuffer[size]; - size_t unescapedSize; - if (!unescape(&buffer[i], size - i, unescapedBuffer, &unescapedSize)) - { - Log.errorln("Failed to unescape frame"); - return false; - } - - Log.traceln("Found valid hardware cmd"); - - char hexString[3 * unescapedSize + 1]; - for (int k = 0; k < unescapedSize; k++) - { - sprintf(&hexString[3 * k], "%02X ", unescapedBuffer[k]); - } - - // Display hex content of buffer - Log.traceln("Frame: %s", hexString); - - switch (unescapedBuffer[i + 2]) - { - case EXTENDED_HW_CMD_SET_FREQUENCY: - { - uint32_t frequency = (unescapedBuffer[i + 3] << 24) | (unescapedBuffer[i + 4] << 16) | - (unescapedBuffer[i + 5] << 8) | unescapedBuffer[i + 6]; - Log.infoln("Set frequency cmd: %d", frequency); - cmd->action = extended_hw_set_frequency; - cmd->data.uint32 = frequency; - return true; - } - case EXTENDED_HW_CMD_RESTORE_FREQUENCY: - Log.infoln("Restore frequency cmd"); - cmd->action = extended_hw_restore_frequency; - return true; - - case EXTENDED_HW_CMD_SET_BAUD_RATE: - { - uint8_t baud_rate = unescapedBuffer[i + 3]; - Log.infoln("Set baud rate cmd: %d", baud_rate); - cmd->action = extended_hw_set_baud_rate; - cmd->data.uint8 = baud_rate; - return true; - } - case EXTENDED_HW_CMD_START_SCAN: - Log.infoln("Start scan cmd"); - cmd->action = extended_hw_start_scan; - return true; - - case EXTENDED_HW_CMD_STOP_SCAN: - Log.infoln("Stop scan cmd"); - cmd->action = extended_hw_stop_scan; - return true; - - case EXTENDED_HW_CMD_PAIR_WITH_DEVICE: - Log.infoln("Pair with device cmd"); - cmd->action = extended_hw_pair_with_device; - memcpy(cmd->data.bytes, &unescapedBuffer[i + 3], ESP_BD_ADDR_LEN); - return true; - - case EXTENDED_HW_CMD_CLEAR_PAIRED_DEVICE: - Log.infoln("Clear paired device cmd"); - cmd->action = extended_hw_clear_paired_device; - return true; - - case EXTENDED_HW_CMD_FIRMWARE_VERSION: - Log.infoln("Firmware version cmd"); - cmd->action = extended_hw_firmware_version; - return true; - - case EXTENDED_HW_CMD_CAPABILITIES: - Log.infoln("Capabilities cmd"); - cmd->action = extended_hw_capabilities; - return true; - - case EXTENDED_HW_CMD_API_VERSION: - Log.infoln("API version cmd"); - cmd->action = extended_hw_api_version; - return true; - - case EXTENDED_HW_CMD_GET_PAIRED_DEVICE: - Log.infoln("Get paired device cmd"); - cmd->action = extended_hw_get_paired_device; - return true; - - case EXTENDED_HW_CMD_SET_RIG_CTRL: - Log.infoln("Set rig control cmd"); - cmd->action = extended_hw_set_rig_ctrl; - cmd->data.uint8 = unescapedBuffer[i + 3]; - return true; - - case EXTENDED_HW_CMD_FACTORY_RESET: - Log.infoln("Factory reset cmd"); - cmd->action = extended_hw_factory_reset; - return true; - - default: - Log.errorln("Unknown hardware cmd"); - return false; - } + pendingFrameSize = 0; + return kiss_process_command_overflow; } + commands[(*commandCount)++] = cmd; } + // Unknown or malformed hardware commands are reserved control frames and + // are deliberately not forwarded to the radio. } + else if (!appendPassthrough(pendingFrame, pendingFrameSize, passthrough, passthroughCapacity, passthroughSize)) + { + pendingFrameSize = 0; + return kiss_process_output_overflow; + } + + // A KISS FEND can end one frame and begin the next. Keep it until the next + // byte arrives; duplicate FEND delimiters are valid KISS synchronization. + pendingFrame[0] = FEND; + pendingFrameSize = 1; } - return false; + return kiss_process_ok; +} + +bool KISSInterceptor::decodeExtendedHardwareCommand(const uint8_t *frame, size_t size, extended_hw_cmd_t *cmd) +{ + if (frame == nullptr || cmd == nullptr || size < 4 || frame[0] != FEND || frame[size - 1] != FEND) + return false; + + uint8_t unescapedBuffer[MAX_FRAME_SIZE]; + size_t unescapedSize = sizeof(unescapedBuffer); + if (!unescape(const_cast(frame), size, unescapedBuffer, &unescapedSize) || + unescapedSize < 4 || unescapedBuffer[1] != CMD_HARDWARE) + return false; + + memset(cmd, 0, sizeof(*cmd)); + switch (unescapedBuffer[2]) + { + case EXTENDED_HW_CMD_SET_FREQUENCY: + if (unescapedSize != 8) return false; + cmd->action = extended_hw_set_frequency; + cmd->data.uint32 = (static_cast(unescapedBuffer[3]) << 24) | + (static_cast(unescapedBuffer[4]) << 16) | + (static_cast(unescapedBuffer[5]) << 8) | + static_cast(unescapedBuffer[6]); + return true; + case EXTENDED_HW_CMD_SET_BAUD_RATE: + if (unescapedSize != 5) return false; + cmd->action = extended_hw_set_baud_rate; + cmd->data.uint8 = unescapedBuffer[3]; + return true; + case EXTENDED_HW_CMD_PAIR_WITH_DEVICE: + if (unescapedSize != 10) return false; + cmd->action = extended_hw_pair_with_device; + memcpy(cmd->data.bytes, unescapedBuffer + 3, 6); + return true; + case EXTENDED_HW_CMD_SET_RIG_CTRL: + if (unescapedSize != 5) return false; + cmd->action = extended_hw_set_rig_ctrl; + cmd->data.uint8 = unescapedBuffer[3]; + return true; + case EXTENDED_HW_CMD_RESTORE_FREQUENCY: + cmd->action = extended_hw_restore_frequency; + break; + case EXTENDED_HW_CMD_START_SCAN: + cmd->action = extended_hw_start_scan; + break; + case EXTENDED_HW_CMD_STOP_SCAN: + cmd->action = extended_hw_stop_scan; + break; + case EXTENDED_HW_CMD_CLEAR_PAIRED_DEVICE: + cmd->action = extended_hw_clear_paired_device; + break; + case EXTENDED_HW_CMD_FIRMWARE_VERSION: + cmd->action = extended_hw_firmware_version; + break; + case EXTENDED_HW_CMD_CAPABILITIES: + cmd->action = extended_hw_capabilities; + break; + case EXTENDED_HW_CMD_API_VERSION: + cmd->action = extended_hw_api_version; + break; + case EXTENDED_HW_CMD_GET_PAIRED_DEVICE: + cmd->action = extended_hw_get_paired_device; + break; + case EXTENDED_HW_CMD_FACTORY_RESET: + cmd->action = extended_hw_factory_reset; + break; + default: + return false; + } + + return unescapedSize == 4; } bool KISSInterceptor::unescape(uint8_t *buffer, size_t size, uint8_t *result, size_t *resultSize) { + if (buffer == nullptr || result == nullptr || resultSize == nullptr) + return false; + uint8_t *src = buffer; uint8_t *dst = result; + const size_t capacity = *resultSize; while(src < buffer + size) { + if (static_cast(dst - result) >= capacity) + return false; + if (*src == FESC) { src++; + if (src >= buffer + size) + return false; if (*src == TFEND) { *dst = FEND; @@ -180,6 +252,8 @@ bool KISSInterceptor::unescape(uint8_t *buffer, size_t size, uint8_t *result, si // Output buffer is assumed to be large enough to hold the escaped data bool KISSInterceptor::escape(uint8_t *buffer, size_t size, uint8_t *result, size_t *resultSize) { + if (buffer == nullptr || result == nullptr || resultSize == nullptr) + return false; uint8_t *src = buffer; uint8_t *dst = result; size_t dstSize = *resultSize; @@ -192,7 +266,7 @@ bool KISSInterceptor::escape(uint8_t *buffer, size_t size, uint8_t *result, size *dst = FEND; dst++; - for (int i = 0; i < size; i++) + for (size_t i = 0; i < size; i++) { if (*src == FEND) { @@ -219,4 +293,4 @@ bool KISSInterceptor::escape(uint8_t *buffer, size_t size, uint8_t *result, size *resultSize = dst - result; return true; -} \ No newline at end of file +} diff --git a/src/bb-link/KISSInterceptor.h b/src/bb-link/KISSInterceptor.h index 3457fec..e2b2413 100644 --- a/src/bb-link/KISSInterceptor.h +++ b/src/bb-link/KISSInterceptor.h @@ -2,12 +2,8 @@ #ifndef KISSINTERCEPTOR_H #define KISSINTERCEPTOR_H -#include "Arduino.h" -#ifndef EPOXY_DUINO -#include "BLEDevice.h" -#else -#include "MockBLEDevice.h" -#endif +#include +#include static const uint8_t CMD_HARDWARE = 0x06; @@ -62,18 +58,47 @@ struct extended_hw_cmd_t { struct found_device_t { uint8_t connected; - esp_bd_addr_t address; + uint8_t address[6]; char name[32]; }; + +enum kiss_process_result_t : uint8_t +{ + kiss_process_ok = 0, + kiss_process_output_overflow, + kiss_process_command_overflow +}; + class KISSInterceptor { public: KISSInterceptor(); bool extractExtendedHardwareCommand(uint8_t *buffer, size_t size, extended_hw_cmd_t *cmd); + kiss_process_result_t process( + const uint8_t *buffer, + size_t size, + extended_hw_cmd_t *commands, + size_t commandCapacity, + size_t *commandCount, + uint8_t *passthrough, + size_t passthroughCapacity, + size_t *passthroughSize); + void reset(); bool escape(uint8_t *buffer, size_t size, uint8_t *result, size_t *resultSize); bool unescape(uint8_t *buffer, size_t size, uint8_t *unescapedBuffer, size_t *unescapedSize); private: + static const size_t MAX_FRAME_SIZE = 1024; + uint8_t pendingFrame[MAX_FRAME_SIZE]; + size_t pendingFrameSize; + + bool decodeExtendedHardwareCommand(const uint8_t *frame, size_t size, extended_hw_cmd_t *cmd); + bool appendPassthrough( + const uint8_t *data, + size_t size, + uint8_t *passthrough, + size_t passthroughCapacity, + size_t *passthroughSize); }; -#endif \ No newline at end of file +#endif diff --git a/src/bb-link/StatusIndicator_M5Atom.h b/src/bb-link/StatusIndicator_M5Atom.h index e3c2155..0f3b4af 100644 --- a/src/bb-link/StatusIndicator_M5Atom.h +++ b/src/bb-link/StatusIndicator_M5Atom.h @@ -62,7 +62,9 @@ class StatusIndicator_M5Atom : public StatusIndicatorBase { // https://thingpulse.com/breathing-leds-cracking-the-algorithm-behind-our-breathing-pattern/ float ledBreatheValue = (exp(sin(millis() / 2000.0 * PI)) - 0.36787944) * 108.0; - setBrightnessMaybe((uint8_t)ledBreatheValue); + uint8_t scaledBrightness = static_cast( + (ledBreatheValue / 255.0f) * STATUS_INDICATOR_DEFAULT_BRIGHTNESS); + setBrightnessMaybe(scaledBrightness); } break; @@ -108,8 +110,8 @@ class StatusIndicator_M5Atom : public StatusIndicatorBase { currentBrightness = brightness; FastLED.setBrightness(currentBrightness); + FastLED.show(); } - FastLED.show(); } void setColorMaybe(uint32_t color) @@ -118,8 +120,8 @@ class StatusIndicator_M5Atom : public StatusIndicatorBase { currentColor = color; led = CRGB(color); + FastLED.show(); } - FastLED.show(); } void tickBlink(unsigned long onPeriod, unsigned long offPeriod) diff --git a/tests/KISSInterceptorTest/Makefile b/tests/KISSInterceptorTest/Makefile index 278a1e9..c1ea88e 100644 --- a/tests/KISSInterceptorTest/Makefile +++ b/tests/KISSInterceptorTest/Makefile @@ -9,4 +9,8 @@ APP_NAME := KISSInterceptorTest DEPS += $(APP_SRC_PATH)/MockBluetoothSerial.h ARDUINO_LIBS := AUnit ArduinoLog EpoxyEsp32 EPOXY_CORE := EPOXY_CORE_ESP32 -include ~/Documents/Arduino/libraries/EpoxyDuino/EpoxyDuino.mk +EPOXYDUINO_DIR ?= $(HOME)/Documents/Arduino/libraries/EpoxyDuino +ifeq ($(wildcard $(EPOXYDUINO_DIR)/EpoxyDuino.mk),) +$(error EpoxyDuino not found; set EPOXYDUINO_DIR to its installation directory) +endif +include $(EPOXYDUINO_DIR)/EpoxyDuino.mk diff --git a/tests/Makefile b/tests/Makefile index 52fe367..6ea5608 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -1,11 +1,27 @@ -tests: +ROOT := $(abspath ..) +BUILD_DIR := $(CURDIR)/build +NATIVE_TEST := $(BUILD_DIR)/KISSInterceptorTest + +.PHONY: tests runtests epoxy-tests epoxy-runtests epoxy-clean clean + +tests: $(NATIVE_TEST) + +$(NATIVE_TEST): native/KISSInterceptorTest.cpp $(ROOT)/src/bb-link/KISSInterceptor.cpp $(ROOT)/src/bb-link/KISSInterceptor.h + mkdir -p $(BUILD_DIR) + $(CXX) -std=c++11 -Wall -Wextra -Werror -I$(ROOT)/src/bb-link \ + native/KISSInterceptorTest.cpp $(ROOT)/src/bb-link/KISSInterceptor.cpp -o $@ + +epoxy-tests: set -e; \ for i in *Test/Makefile; do \ echo '==== Making:' $$(dirname $$i); \ $(MAKE) -C $$(dirname $$i); \ done -runtests: +runtests: tests + $(NATIVE_TEST) + +epoxy-runtests: set -e; \ for i in *Test/Makefile; do \ echo '==== Running:' $$(dirname $$i); \ @@ -13,8 +29,11 @@ runtests: done clean: + $(RM) -r $(BUILD_DIR) + +epoxy-clean: set -e; \ for i in *Test/Makefile; do \ echo '==== Cleaning:' $$(dirname $$i); \ $(MAKE) -C $$(dirname $$i) clean; \ - done \ No newline at end of file + done diff --git a/tests/THD7xTest/Makefile b/tests/THD7xTest/Makefile index 0153581..2924a27 100644 --- a/tests/THD7xTest/Makefile +++ b/tests/THD7xTest/Makefile @@ -9,4 +9,8 @@ APP_NAME := THD7xTest DEPS += $(APP_SRC_PATH)/MockBluetoothSerial.h ARDUINO_LIBS := AUnit ArduinoLog EpoxyEsp32 EPOXY_CORE := EPOXY_CORE_ESP32 -include ~/Documents/Arduino/libraries/EpoxyDuino/EpoxyDuino.mk +EPOXYDUINO_DIR ?= $(HOME)/Documents/Arduino/libraries/EpoxyDuino +ifeq ($(wildcard $(EPOXYDUINO_DIR)/EpoxyDuino.mk),) +$(error EpoxyDuino not found; set EPOXYDUINO_DIR to its installation directory) +endif +include $(EPOXYDUINO_DIR)/EpoxyDuino.mk diff --git a/tests/native/KISSInterceptorTest.cpp b/tests/native/KISSInterceptorTest.cpp new file mode 100644 index 0000000..4d2cec2 --- /dev/null +++ b/tests/native/KISSInterceptorTest.cpp @@ -0,0 +1,168 @@ +#include +#include +#include + +#include "../../src/bb-link/KISSInterceptor.h" + +static extended_hw_cmd_t extract(uint8_t *frame, size_t size) +{ + KISSInterceptor interceptor; + extended_hw_cmd_t command = {}; + assert(interceptor.extractExtendedHardwareCommand(frame, size, &command)); + return command; +} + +static void testAllCommandShapes() +{ + uint8_t restore[] = {0xC0, 0x06, 0xEB, 0xC0}; + uint8_t baud[] = {0xC0, 0x06, 0xF4, 0x01, 0xC0}; + uint8_t start[] = {0xC0, 0x06, 0xEC, 0xC0}; + uint8_t stop[] = {0xC0, 0x06, 0xED, 0xC0}; + uint8_t pair[] = {0xC0, 0x06, 0xEF, 1, 2, 3, 4, 5, 6, 0xC0}; + uint8_t clear[] = {0xC0, 0x06, 0xF0, 0xC0}; + uint8_t firmware[] = {0xC0, 0x06, 0x28, 0xC0}; + uint8_t capabilities[] = {0xC0, 0x06, 0x7E, 0xC0}; + uint8_t api[] = {0xC0, 0x06, 0x7B, 0xC0}; + uint8_t paired[] = {0xC0, 0x06, 0xF1, 0xC0}; + uint8_t rig[] = {0xC0, 0x06, 0xF2, 0x01, 0xC0}; + uint8_t reset[] = {0xC0, 0x06, 0xF3, 0xC0}; + + assert(extract(restore, sizeof(restore)).action == extended_hw_restore_frequency); + assert(extract(baud, sizeof(baud)).data.uint8 == 1); + assert(extract(start, sizeof(start)).action == extended_hw_start_scan); + assert(extract(stop, sizeof(stop)).action == extended_hw_stop_scan); + extended_hw_cmd_t pairCommand = extract(pair, sizeof(pair)); + assert(pairCommand.action == extended_hw_pair_with_device); + assert(memcmp(pairCommand.data.bytes, pair + 3, 6) == 0); + assert(extract(clear, sizeof(clear)).action == extended_hw_clear_paired_device); + assert(extract(firmware, sizeof(firmware)).action == extended_hw_firmware_version); + assert(extract(capabilities, sizeof(capabilities)).action == extended_hw_capabilities); + assert(extract(api, sizeof(api)).action == extended_hw_api_version); + assert(extract(paired, sizeof(paired)).action == extended_hw_get_paired_device); + assert(extract(rig, sizeof(rig)).data.uint8 == 1); + assert(extract(reset, sizeof(reset)).action == extended_hw_factory_reset); +} + +static void testExtractAtOffset() +{ + KISSInterceptor interceptor; + uint8_t input[] = {0x55, 0xC0, 0x06, 0xEA, 0x01, 0x02, 0x03, 0x04, 0xC0, 0x66}; + extended_hw_cmd_t command = {}; + assert(interceptor.extractExtendedHardwareCommand(input, sizeof(input), &command)); + assert(command.action == extended_hw_set_frequency); + assert(command.data.uint32 == 0x01020304UL); +} + +static void testRejectsShortAndMalformedFrames() +{ + KISSInterceptor interceptor; + extended_hw_cmd_t command = {}; + uint8_t delimiterOnly[] = {0xC0}; + uint8_t shortFrequency[] = {0xC0, 0x06, 0xEA, 0x01, 0xC0}; + uint8_t danglingEscape[] = {0xC0, 0x06, 0xEA, 0xDB, 0xC0}; + assert(!interceptor.extractExtendedHardwareCommand(delimiterOnly, sizeof(delimiterOnly), &command)); + assert(!interceptor.extractExtendedHardwareCommand(shortFrequency, sizeof(shortFrequency), &command)); + assert(!interceptor.extractExtendedHardwareCommand(danglingEscape, sizeof(danglingEscape), &command)); +} + +static void testMultipleCommandsAndPassthrough() +{ + KISSInterceptor interceptor; + uint8_t input[] = { + 0xC0, 0x06, 0xEB, 0xC0, + 0xC0, 0x00, 0x11, 0x22, 0xC0, + 0xC0, 0x06, 0xEC, 0xC0 + }; + extended_hw_cmd_t commands[4] = {}; + uint8_t passthrough[64] = {}; + size_t commandCount = 0; + size_t passthroughSize = 0; + assert(interceptor.process( + input, sizeof(input), commands, 4, &commandCount, + passthrough, sizeof(passthrough), &passthroughSize) == kiss_process_ok); + assert(commandCount == 2); + assert(commands[0].action == extended_hw_restore_frequency); + assert(commands[1].action == extended_hw_start_scan); + const uint8_t expected[] = {0xC0, 0x00, 0x11, 0x22, 0xC0}; + assert(passthroughSize == sizeof(expected)); + assert(memcmp(passthrough, expected, sizeof(expected)) == 0); +} + +static void testFragmentedCommand() +{ + KISSInterceptor interceptor; + uint8_t first[] = {0xC0, 0x06, 0xEA, 0x01, 0x02}; + uint8_t second[] = {0x03, 0x04, 0xC0}; + extended_hw_cmd_t commands[1] = {}; + uint8_t passthrough[16] = {}; + size_t commandCount = 0; + size_t passthroughSize = 0; + + assert(interceptor.process( + first, sizeof(first), commands, 1, &commandCount, + passthrough, sizeof(passthrough), &passthroughSize) == kiss_process_ok); + assert(commandCount == 0 && passthroughSize == 0); + + assert(interceptor.process( + second, sizeof(second), commands, 1, &commandCount, + passthrough, sizeof(passthrough), &passthroughSize) == kiss_process_ok); + assert(commandCount == 1); + assert(commands[0].action == extended_hw_set_frequency); + assert(commands[0].data.uint32 == 0x01020304UL); + assert(passthroughSize == 0); +} + +static void testEscapedCommandPayload() +{ + KISSInterceptor interceptor; + uint8_t frame[] = {0xC0, 0x06, 0xEA, 0xDB, 0xDC, 0xDB, 0xDD, 0x03, 0x04, 0xC0}; + extended_hw_cmd_t command = {}; + assert(interceptor.extractExtendedHardwareCommand(frame, sizeof(frame), &command)); + assert(command.action == extended_hw_set_frequency); + assert(command.data.uint32 == 0xC0DB0304UL); +} + +static void testEscapeBounds() +{ + KISSInterceptor interceptor; + uint8_t input[] = {0x01, 0xC0, 0xDB}; + uint8_t tooSmall[4] = {}; + size_t tooSmallSize = sizeof(tooSmall); + assert(!interceptor.escape(input, sizeof(input), tooSmall, &tooSmallSize)); + + uint8_t dangling[] = {0xDB}; + uint8_t result[4] = {}; + size_t resultSize = sizeof(result); + assert(!interceptor.unescape(dangling, sizeof(dangling), result, &resultSize)); +} + +static void testProcessBounds() +{ + KISSInterceptor interceptor; + uint8_t command[] = {0xC0, 0x06, 0xEB, 0xC0}; + uint8_t passthrough[8] = {}; + size_t commandCount = 0; + size_t passthroughSize = 0; + assert(interceptor.process( + command, sizeof(command), nullptr, 0, &commandCount, + passthrough, sizeof(passthrough), &passthroughSize) == kiss_process_command_overflow); + + interceptor.reset(); + uint8_t data[] = {0x01, 0x02}; + assert(interceptor.process( + data, sizeof(data), nullptr, 0, &commandCount, + passthrough, 1, &passthroughSize) == kiss_process_output_overflow); +} + +int main() +{ + testExtractAtOffset(); + testAllCommandShapes(); + testRejectsShortAndMalformedFrames(); + testMultipleCommandsAndPassthrough(); + testFragmentedCommand(); + testEscapedCommandPayload(); + testEscapeBounds(); + testProcessBounds(); + return 0; +}