diff --git a/.clang-format b/.clang-format
index c710347e7..e0b380671 100644
--- a/.clang-format
+++ b/.clang-format
@@ -1,14 +1,13 @@
---
BasedOnStyle: Chromium
-IndentWidth: '4'
-SortIncludes: 'false'
-AllowAllArgumentsOnNextLine: 'true'
+IndentWidth: 4
+SortIncludes: false
+AllowAllArgumentsOnNextLine: true
ColumnLimit: 0
-Cpp11BracedListStyle: 'true'
-AllowShortLoopsOnASingleLine: 'true'
-AllowShortIfStatementsOnASingleLine: 'true'
+Cpp11BracedListStyle: true
+AllowShortLoopsOnASingleLine: true
+AllowShortIfStatementsOnASingleLine: true
SpacesInLineCommentPrefix:
Minimum: 1
Maximum: 1
-
...
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..cfb5150f2
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,15 @@
+
+
+## Brief description what you did
+
+
+
+## Checklist
+- [ ] Kept changes minimal and limited to necessary files
+- [ ] Verified functionality remains intact and code compiles
diff --git a/.github/workflows/check_formatting.yml b/.github/workflows/check_formatting.yml
index 95f714913..c80daaecb 100644
--- a/.github/workflows/check_formatting.yml
+++ b/.github/workflows/check_formatting.yml
@@ -3,7 +3,7 @@ name: Check formatting
on: [pull_request]
jobs:
- check_date:
+ check_format:
runs-on: ubuntu-latest
name: Check formatting
strategy:
@@ -13,12 +13,20 @@ jobs:
- "firmware/application"
- "firmware/baseband"
steps:
- - uses: actions/checkout@v2
- - name: print latest_commit
- run: echo ${{ github.sha }}
- - name: clang-format Check
- uses: jidicula/clang-format-action@v4.11.0
- with:
- clang-format-version: '18'
- check-path: ${{ matrix.path }}
- fallback-style: Chromium
+ - uses: actions/checkout@v4
+
+ - name: clang-format check (repo pinned)
+ run: |
+ set -euo pipefail
+
+ CF="./tools/clang-format.sh"
+ chmod +x "$CF" || true
+
+ "$CF" --version
+
+ ROOT="${{ matrix.path }}"
+ find "$ROOT" -type f \( \
+ -name '*.c' -o -name '*.cpp' -o \
+ -name '*.h' -o -name '*.hpp' \
+ \) -print0 \
+ | xargs -0 -r "$CF" --dry-run --Werror --style=file
diff --git a/.github/workflows/create_nightly_release.yml b/.github/workflows/create_nightly_release.yml
index 4ed96367a..e15b14a6b 100644
--- a/.github/workflows/create_nightly_release.yml
+++ b/.github/workflows/create_nightly_release.yml
@@ -3,7 +3,7 @@ name: Nightly Release
on:
schedule:
- cron: "0 0 * * *"
-
+
workflow_dispatch:
jobs:
@@ -21,27 +21,36 @@ jobs:
id: should_run
continue-on-error: true
run: test -z $(git rev-list --after="24 hours" ${{ github.sha }}) && echo "should_run=false" >> $GITHUB_OUTPUT
+
build:
needs: check_date
if: ${{ needs.check_date.outputs.should_run != 'false' }}
runs-on: ubuntu-latest
+ name: build (${{ matrix.device_name }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - device: default
+ device_name: HackRF
+ cmake_args: ""
+ artifact_suffix: "_hackrf"
+ - device: portarf
+ device_name: PortaRF
+ cmake_args: "-DFLASH_MB_SIZE=2 -DFLASH_MB_LIMIT_SIZE=2"
+ artifact_suffix: "_portarf"
+ - device: hpro
+ device_name: HackRF Pro
+ cmake_args: "-DBOARD=PRALINE"
+ artifact_suffix: "_hpro"
steps:
- - name: Get current date
- id: date
- run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Get version date
id: version_date
run: echo "date=n_$(date +'%y%m%d')" >> $GITHUB_OUTPUT
- - name: Checkout
+ - name: Checkout
uses: actions/checkout@master
with:
fetch-depth: 0
- #ref: next
- # The branch, tag or SHA to checkout. When checking out the repository that
- # triggered a workflow, this defaults to the reference or SHA for that event.
- # Otherwise, uses the default branch.
- # https://github.com/actions/checkout
- # So scheduled runs will use the default branch (next) but its now possible to trigger a workflow from another branch
submodules: true
- name: Git Sumbodule Update
run: |
@@ -51,10 +60,10 @@ jobs:
- name: Make build folder
run: mkdir ${{ github.workspace }}/build
- name: Run the Docker image
- run: docker run -e VERSION_STRING=${{ steps.version_date.outputs.date }} -i -v ${{ github.workspace }}:/havoc portapack-dev
+ run: docker run -e VERSION_STRING=${{ steps.version_date.outputs.date }} -i -v ${{ github.workspace }}:/havoc portapack-dev ${{ matrix.cmake_args }}
- name: Create Small SD Card ZIP - No World Map
run: |
- mkdir -p sdcard/FIRMWARE && cp build/firmware/portapack-mayhem-firmware.bin sdcard/FIRMWARE/portapack-mayhem_${{ steps.version_date.outputs.date }}.bin && cp build/firmware/portapack-mayhem_OCI.ppfw.tar sdcard/FIRMWARE/mayhem_nightly_${{ steps.version_date.outputs.date }}_OCI.ppfw.tar && mkdir -p sdcard/APPS && cp build/firmware/application/*.ppma sdcard/APPS && cp build/firmware/standalone/*/*.ppmp sdcard/APPS && cd sdcard && zip -r ../sdcard-no-map.zip . && cd ..
+ mkdir -p sdcard/FIRMWARE && cp build/firmware/portapack-mayhem-firmware.bin sdcard/FIRMWARE/portapack-mayhem_${{ steps.version_date.outputs.date }}.bin && cp build/firmware/portapack-mayhem_OCI.ppfw.tar sdcard/FIRMWARE/mayhem_nightly_${{ steps.version_date.outputs.date }}${{ matrix.artifact_suffix }}_OCI.ppfw.tar && mkdir -p sdcard/APPS && cp build/firmware/application/*.ppma sdcard/APPS && cp build/firmware/standalone/*/*.ppmp sdcard/APPS && cd sdcard && zip -r ../sdcard-no-map.zip . && cd ..
- name: Download world map
run: |
wget https://github.com/portapack-mayhem/mayhem-firmware/releases/download/world_map/world_map.zip
@@ -63,13 +72,55 @@ jobs:
unzip world_map.zip -d sdcard/ADSB
- name: Prepare Firmware ZIP
run: |
- cp build/hackrf/firmware/hackrf_usb/hackrf_usb.dfu flashing/utils/hackrf_one_usb.dfu && cp build/hackrf/firmware/hackrf_usb/hackrf_usb.bin flashing/utils/hackrf_one_usb.bin
+ mkdir -p flashing/firmware && cp build/hackrf/firmware/hackrf_usb/hackrf_usb.dfu flashing/firmware/hackrf_usb.dfu && cp build/hackrf/firmware/hackrf_usb/hackrf_usb.bin flashing/firmware/hackrf_usb.bin
- name: Create Firmware ZIP
run: |
zip -j firmware.zip build/firmware/portapack-mayhem-firmware.bin && cd flashing && zip -r ../firmware.zip *
- name: Create SD Card ZIP
run: |
- mkdir -p sdcard/FIRMWARE && cp build/firmware/portapack-mayhem-firmware.bin sdcard/FIRMWARE/portapack-mayhem_${{ steps.version_date.outputs.date }}.bin && cp build/firmware/portapack-mayhem_OCI.ppfw.tar sdcard/FIRMWARE/mayhem_nightly_${{ steps.version_date.outputs.date }}_OCI.ppfw.tar && mkdir -p sdcard/APPS && cp build/firmware/application/*.ppma sdcard/APPS && cp build/firmware/standalone/*/*.ppmp sdcard/APPS && cd sdcard && zip -r ../sdcard.zip . && cd ..
+ mkdir -p sdcard/FIRMWARE && cp build/firmware/portapack-mayhem-firmware.bin sdcard/FIRMWARE/portapack-mayhem_${{ steps.version_date.outputs.date }}.bin && cp build/firmware/portapack-mayhem_OCI.ppfw.tar sdcard/FIRMWARE/mayhem_nightly_${{ steps.version_date.outputs.date }}${{ matrix.artifact_suffix }}_OCI.ppfw.tar && mkdir -p sdcard/APPS && cp build/firmware/application/*.ppma sdcard/APPS && cp build/firmware/standalone/*/*.ppmp sdcard/APPS && cd sdcard && zip -r ../sdcard.zip . && cd ..
+ - name: Upload firmware zip artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: firmware-${{ matrix.device }}
+ path: ./firmware.zip
+ - name: Upload SD card artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: sdcard-${{ matrix.device }}
+ path: ./sdcard.zip
+ - name: Upload SD card no-map artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: sdcard-no-map-${{ matrix.device }}
+ path: ./sdcard-no-map.zip
+ - name: Upload OCI tar artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: oci-tar-${{ matrix.device }}
+ path: build/firmware/portapack-mayhem_OCI.ppfw.tar
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Get current date
+ id: date
+ run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
+ - name: Get version date
+ id: version_date
+ run: echo "date=n_$(date +'%y%m%d')" >> $GITHUB_OUTPUT
+ - name: Checkout
+ uses: actions/checkout@master
+ with:
+ fetch-depth: 0
+ submodules: true
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
- name: Create changelog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -80,14 +131,42 @@ jobs:
echo EOF
} >> "$GITHUB_OUTPUT"
id: changelog
+ # Combine all 3 device firmware bins into a single zip with device selector bat
+ - name: Create Combined Firmware ZIP
+ run: |
+ mkdir -p firmware_combined
+ unzip artifacts/firmware-default/firmware.zip -d firmware_combined
+ mv firmware_combined/portapack-mayhem-firmware.bin firmware_combined/firmware/firmware_hackrf.bin
+ unzip -j artifacts/firmware-portarf/firmware.zip portapack-mayhem-firmware.bin -d firmware_combined/firmware
+ mv firmware_combined/firmware/portapack-mayhem-firmware.bin firmware_combined/firmware/firmware_portarf.bin
+ unzip -j artifacts/firmware-hpro/firmware.zip portapack-mayhem-firmware.bin -d firmware_combined/firmware
+ mv firmware_combined/firmware/portapack-mayhem-firmware.bin firmware_combined/firmware/firmware_hpro.bin
+ unzip -j artifacts/firmware-hpro/firmware.zip firmware/hackrf_usb.dfu firmware/hackrf_usb.bin -d firmware_combined_hpro
+ mv firmware_combined_hpro/hackrf_usb.dfu firmware_combined/firmware/hackrf_hpro_usb.dfu
+ mv firmware_combined_hpro/hackrf_usb.bin firmware_combined/firmware/hackrf_hpro_usb.bin
+ cd firmware_combined && zip -r ../firmware_all_devices.zip . && cd ..
+ - name: Prepare release assets
+ run: |
+ VER="${{ steps.version_date.outputs.date }}"
+ mkdir -p release_assets
+ # OCI tar assets
+ cp artifacts/oci-tar-default/portapack-mayhem_OCI.ppfw.tar "release_assets/OCI_hackrf_mayhem_nightly_${VER}.ppfw.tar"
+ cp artifacts/oci-tar-portarf/portapack-mayhem_OCI.ppfw.tar "release_assets/OCI_portarf_mayhem_nightly_${VER}.ppfw.tar"
+ cp artifacts/oci-tar-hpro/portapack-mayhem_OCI.ppfw.tar "release_assets/OCI_hpro_mayhem_nightly_${VER}.ppfw.tar"
+ # Combined firmware
+ cp firmware_all_devices.zip "release_assets/FIRMWARE_mayhem_nightly_${VER}.zip"
+ # SD card assets
+ cp artifacts/sdcard-default/sdcard.zip "release_assets/COPY_TO_SDCARD_hackrf_mayhem_nightly_${VER}.zip"
+ cp artifacts/sdcard-no-map-default/sdcard-no-map.zip "release_assets/COPY_TO_SDCARD_hackrf_mayhem_nightly_${VER}-no-world-map.zip"
+ cp artifacts/sdcard-portarf/sdcard.zip "release_assets/COPY_TO_SDCARD_portarf_mayhem_nightly_${VER}.zip"
+ cp artifacts/sdcard-no-map-portarf/sdcard-no-map.zip "release_assets/COPY_TO_SDCARD_portarf_mayhem_nightly_${VER}-no-world-map.zip"
+ cp artifacts/sdcard-hpro/sdcard.zip "release_assets/COPY_TO_SDCARD_hpro_mayhem_nightly_${VER}.zip"
+ cp artifacts/sdcard-no-map-hpro/sdcard-no-map.zip "release_assets/COPY_TO_SDCARD_hpro_mayhem_nightly_${VER}-no-world-map.zip"
- name: Create Release
- id: create_release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: softprops/action-gh-release@v2
with:
tag_name: nightly-tag-${{ steps.date.outputs.date }}
- release_name: Nightly Release - ${{ steps.date.outputs.date }}
+ name: Nightly Release - ${{ steps.date.outputs.date }}
body: |
**Nightly release - ${{ steps.date.outputs.date }}**
This build is the latest and greatest, although may not be the most stable as this is a nightly release.
@@ -96,43 +175,4 @@ jobs:
${{ steps.changelog.outputs.content }}
draft: false
prerelease: true
- - name: Upload Firmware TAR Asset
- id: upload-firmware-tar-asset
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: build/firmware/portapack-mayhem_OCI.ppfw.tar
- asset_name: mayhem_nightly_${{ steps.version_date.outputs.date }}_OCI.ppfw.tar
- asset_content_type: application/x-tar
- - name: Upload Firmware Asset
- id: upload-firmware-asset
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./firmware.zip
- asset_name: mayhem_nightly_${{ steps.version_date.outputs.date }}_FIRMWARE.zip
- asset_content_type: application/zip
- - name: Upload SD Card Assets
- id: upload-sd-card-asset
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./sdcard.zip
- asset_name: mayhem_nightly_${{ steps.version_date.outputs.date }}_COPY_TO_SDCARD.zip
- asset_content_type: application/zip
- - name: Upload SD Card Assets - No Map
- id: upload-sd-card-asset-no-map
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./sdcard-no-map.zip
- asset_name: mayhem_nightly_${{ steps.version_date.outputs.date }}_COPY_TO_SDCARD-no-world-map.zip
- asset_content_type: application/zip
+ files: release_assets/*
diff --git a/.github/workflows/create_stable_release.yml b/.github/workflows/create_stable_release.yml
index c6284380f..deb2e8aa1 100644
--- a/.github/workflows/create_stable_release.yml
+++ b/.github/workflows/create_stable_release.yml
@@ -1,25 +1,36 @@
name: Stable Release
-on:
+on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
+ name: build (${{ matrix.device_name }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - device: default
+ device_name: HackRF
+ cmake_args: ""
+ artifact_suffix: "_hackrf"
+ - device: portarf
+ device_name: PortaRF
+ cmake_args: "-DFLASH_MB_SIZE=2 -DFLASH_MB_LIMIT_SIZE=2"
+ artifact_suffix: "_portarf"
+ - device: hpro
+ device_name: HackRF Pro
+ cmake_args: "-DBOARD=PRALINE"
+ artifact_suffix: "_hpro"
steps:
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- - name: Checkout
+ - name: Checkout
uses: actions/checkout@master
with:
fetch-depth: 0
- #ref: next
- # The branch, tag or SHA to checkout. When checking out the repository that
- # triggered a workflow, this defaults to the reference or SHA for that event.
- # Otherwise, uses the default branch.
- # https://github.com/actions/checkout
- # So scheduled runs will use the default branch (next) but its now possible to trigger a workflow from another branch
submodules: true
- name: Git Sumbodule Update
run: |
@@ -27,18 +38,15 @@ jobs:
- name: Get version
id: version
run: echo "version=$(cat .github/workflows/version.txt)" >> $GITHUB_OUTPUT
- - name: Get past version
- id: past_version
- run: echo "past_version=$(cat .github/workflows/past_version.txt)" >> $GITHUB_OUTPUT
- name: Build the Docker image
run: docker build -t portapack-dev -f dockerfile-nogit . --tag my-image-name:$(date +%s)
- name: Make build folder
run: mkdir ${{ github.workspace }}/build
- name: Run the Docker image
- run: docker run -e VERSION_STRING=${{ steps.version.outputs.version }} -i -v ${{ github.workspace }}:/havoc portapack-dev
+ run: docker run -e VERSION_STRING=${{ steps.version.outputs.version }} -i -v ${{ github.workspace }}:/havoc portapack-dev ${{ matrix.cmake_args }}
- name: Create Small SD Card ZIP - No World Map
run: |
- mkdir -p sdcard/FIRMWARE && cp build/firmware/portapack-mayhem-firmware.bin sdcard/FIRMWARE/portapack-mayhem_${{ steps.version.outputs.version }}.bin && cp build/firmware/portapack-mayhem_OCI.ppfw.tar sdcard/FIRMWARE/mayhem_${{ steps.version.outputs.version }}_OCI.ppfw.tar && mkdir -p sdcard/APPS && cp build/firmware/application/*.ppma sdcard/APPS && cp build/firmware/standalone/*/*.ppmp sdcard/APPS && cd sdcard && zip -r ../sdcard-no-map.zip . && cd ..
+ mkdir -p sdcard/FIRMWARE && cp build/firmware/portapack-mayhem-firmware.bin sdcard/FIRMWARE/portapack-mayhem_${{ steps.version.outputs.version }}.bin && cp build/firmware/portapack-mayhem_OCI.ppfw.tar sdcard/FIRMWARE/mayhem_${{ steps.version.outputs.version }}${{ matrix.artifact_suffix }}_OCI.ppfw.tar && mkdir -p sdcard/APPS && cp build/firmware/application/*.ppma sdcard/APPS && cp build/firmware/standalone/*/*.ppmp sdcard/APPS && cd sdcard && zip -r ../sdcard-no-map.zip . && cd ..
- name: Download world map
run: |
wget https://github.com/portapack-mayhem/mayhem-firmware/releases/download/world_map/world_map.zip
@@ -47,13 +55,58 @@ jobs:
unzip world_map.zip -d sdcard/ADSB
- name: Prepare Firmware ZIP
run: |
- cp build/hackrf/firmware/hackrf_usb/hackrf_usb.dfu flashing/utils/hackrf_one_usb.dfu && cp build/hackrf/firmware/hackrf_usb/hackrf_usb.bin flashing/utils/hackrf_one_usb.bin
+ mkdir -p flashing/firmware && cp build/hackrf/firmware/hackrf_usb/hackrf_usb.dfu flashing/firmware/hackrf_usb.dfu && cp build/hackrf/firmware/hackrf_usb/hackrf_usb.bin flashing/firmware/hackrf_usb.bin
- name: Create Firmware ZIP
run: |
zip -j firmware.zip build/firmware/portapack-mayhem-firmware.bin && cd flashing && zip -r ../firmware.zip *
- name: Create SD Card ZIP
run: |
- mkdir -p sdcard/FIRMWARE && cp build/firmware/portapack-mayhem-firmware.bin sdcard/FIRMWARE/portapack-mayhem_${{ steps.version.outputs.version }}.bin && cp build/firmware/portapack-mayhem_OCI.ppfw.tar sdcard/FIRMWARE/mayhem_${{ steps.version.outputs.version }}_OCI.ppfw.tar && mkdir -p sdcard/APPS && cp build/firmware/application/*.ppma sdcard/APPS && cp build/firmware/standalone/*/*.ppmp sdcard/APPS && cd sdcard && zip -r ../sdcard.zip . && cd ..
+ mkdir -p sdcard/FIRMWARE && cp build/firmware/portapack-mayhem-firmware.bin sdcard/FIRMWARE/portapack-mayhem_${{ steps.version.outputs.version }}.bin && cp build/firmware/portapack-mayhem_OCI.ppfw.tar sdcard/FIRMWARE/mayhem_${{ steps.version.outputs.version }}${{ matrix.artifact_suffix }}_OCI.ppfw.tar && mkdir -p sdcard/APPS && cp build/firmware/application/*.ppma sdcard/APPS && cp build/firmware/standalone/*/*.ppmp sdcard/APPS && cd sdcard && zip -r ../sdcard.zip . && cd ..
+ - name: Upload firmware zip artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: firmware-${{ matrix.device }}
+ path: ./firmware.zip
+ - name: Upload SD card artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: sdcard-${{ matrix.device }}
+ path: ./sdcard.zip
+ - name: Upload SD card no-map artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: sdcard-no-map-${{ matrix.device }}
+ path: ./sdcard-no-map.zip
+ - name: Upload OCI tar artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: oci-tar-${{ matrix.device }}
+ path: build/firmware/portapack-mayhem_OCI.ppfw.tar
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Get current date
+ id: date
+ run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
+ - name: Checkout
+ uses: actions/checkout@master
+ with:
+ fetch-depth: 0
+ submodules: true
+ - name: Get version
+ id: version
+ run: echo "version=$(cat .github/workflows/version.txt)" >> $GITHUB_OUTPUT
+ - name: Get past version
+ id: past_version
+ run: echo "past_version=$(cat .github/workflows/past_version.txt)" >> $GITHUB_OUTPUT
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
- name: Create changelog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -64,21 +117,49 @@ jobs:
echo EOF
} >> "$GITHUB_OUTPUT"
id: changelog
+ # Combine all 3 device firmware bins into a single zip with device selector bat
+ - name: Create Combined Firmware ZIP
+ run: |
+ mkdir -p firmware_combined
+ unzip artifacts/firmware-default/firmware.zip -d firmware_combined
+ mv firmware_combined/portapack-mayhem-firmware.bin firmware_combined/firmware/firmware_hackrf.bin
+ unzip -j artifacts/firmware-portarf/firmware.zip portapack-mayhem-firmware.bin -d firmware_combined/firmware
+ mv firmware_combined/firmware/portapack-mayhem-firmware.bin firmware_combined/firmware/firmware_portarf.bin
+ unzip -j artifacts/firmware-hpro/firmware.zip portapack-mayhem-firmware.bin -d firmware_combined/firmware
+ mv firmware_combined/firmware/portapack-mayhem-firmware.bin firmware_combined/firmware/firmware_hpro.bin
+ unzip -j artifacts/firmware-hpro/firmware.zip firmware/hackrf_usb.dfu firmware/hackrf_usb.bin -d firmware_combined_hpro
+ mv firmware_combined_hpro/hackrf_usb.dfu firmware_combined/firmware/hackrf_hpro_usb.dfu
+ mv firmware_combined_hpro/hackrf_usb.bin firmware_combined/firmware/hackrf_hpro_usb.bin
+ cd firmware_combined && zip -r ../firmware_all_devices.zip . && cd ..
+ - name: Prepare release assets
+ run: |
+ VER="${{ steps.version.outputs.version }}"
+ mkdir -p release_assets
+ # OCI tar assets
+ cp artifacts/oci-tar-default/portapack-mayhem_OCI.ppfw.tar "release_assets/OCI_hackrf_mayhem_${VER}.ppfw.tar"
+ cp artifacts/oci-tar-portarf/portapack-mayhem_OCI.ppfw.tar "release_assets/OCI_portarf_mayhem_${VER}.ppfw.tar"
+ cp artifacts/oci-tar-hpro/portapack-mayhem_OCI.ppfw.tar "release_assets/OCI_hpro_mayhem_${VER}.ppfw.tar"
+ # Combined firmware
+ cp firmware_all_devices.zip "release_assets/FIRMWARE_mayhem_${VER}.zip"
+ # SD card assets
+ cp artifacts/sdcard-default/sdcard.zip "release_assets/COPY_TO_SDCARD_hackrf_mayhem_${VER}.zip"
+ cp artifacts/sdcard-no-map-default/sdcard-no-map.zip "release_assets/COPY_TO_SDCARD_hackrf_mayhem_${VER}-no-world-map.zip"
+ cp artifacts/sdcard-portarf/sdcard.zip "release_assets/COPY_TO_SDCARD_portarf_mayhem_${VER}.zip"
+ cp artifacts/sdcard-no-map-portarf/sdcard-no-map.zip "release_assets/COPY_TO_SDCARD_portarf_mayhem_${VER}-no-world-map.zip"
+ cp artifacts/sdcard-hpro/sdcard.zip "release_assets/COPY_TO_SDCARD_hpro_mayhem_${VER}.zip"
+ cp artifacts/sdcard-no-map-hpro/sdcard-no-map.zip "release_assets/COPY_TO_SDCARD_hpro_mayhem_${VER}-no-world-map.zip"
- name: Create Release
- id: create_release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
- release_name: Mayhem firmware ${{ steps.version.outputs.version }}
+ name: Mayhem firmware ${{ steps.version.outputs.version }}
body: |
**Stable release - ${{ steps.version.outputs.version }}**
This is a fork of the [Havoc](https://github.com/furrtek/portapack-havoc/) firmware, which itself was a fork of the [PortaPack](https://github.com/sharebrained/portapack-hackrf) firmware, an add-on for the [HackRF](http://greatscottgadgets.com/hackrf/). Please check the [readme](https://github.com/portapack-mayhem/mayhem-firmware/blob/master/README.md) for details.
## Release notes
### Revision (${{ steps.version.outputs.version }}):
${{ steps.changelog.outputs.content }}
-
+
**Full Changelog**: https://github.com/portapack-mayhem/mayhem-firmware/compare/${{ steps.past_version.outputs.past_version }}...${{ steps.version.outputs.version }}
## Installation
@@ -90,44 +171,4 @@ jobs:
For certain functionality, like external apps, the world map, GPS simulator, and others you need to uncompress (using [7-zip](https://www.7-zip.org/download.html)) the files from `mayhem_vX.Y.Z_COPY_TO_SDCARD.zip` to a FAT32 formatted MicroSD card.
draft: true
prerelease: false
- - name: Upload Firmware TAR Asset
- id: upload-firmware-tar-asset
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: build/firmware/portapack-mayhem_OCI.ppfw.tar
- asset_name: mayhem_${{ steps.version.outputs.version }}_OCI.ppfw.tar
- asset_content_type: application/x-tar
- - name: Upload Firmware Asset
- id: upload-firmware-asset
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./firmware.zip
- asset_name: mayhem_${{ steps.version.outputs.version }}_FIRMWARE.zip
- asset_content_type: application/zip
- - name: Upload SD Card Assets
- id: upload-sd-card-asset
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./sdcard.zip
- asset_name: mayhem_${{ steps.version.outputs.version }}_COPY_TO_SDCARD.zip
- asset_content_type: application/zip
- - name: Upload SD Card Assets - No Map
- id: upload-sd-card-asset-no-map
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./sdcard-no-map.zip
- asset_name: mayhem_${{ steps.version.outputs.version }}_COPY_TO_SDCARD-no-world-map.zip
- asset_content_type: application/zip
-
+ files: release_assets/*
diff --git a/.github/workflows/past_version.txt b/.github/workflows/past_version.txt
index aaf7425f4..f706a60d8 100644
--- a/.github/workflows/past_version.txt
+++ b/.github/workflows/past_version.txt
@@ -1 +1 @@
-v2.3.1
+v2.3.2
diff --git a/.github/workflows/version.txt b/.github/workflows/version.txt
index f706a60d8..8721bbc46 100644
--- a/.github/workflows/version.txt
+++ b/.github/workflows/version.txt
@@ -1 +1 @@
-v2.3.2
+v2.4.0
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0fbd8a82f..08bda7fc9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -27,6 +27,37 @@ set(CMAKE_COLOR_MAKEFILE ON)
add_compile_options(-fdiagnostics-color=always)
+#Flash size related options
+#This is the size of the flash memory we SUPPORT. Don't worry, the internl flash app has harder limits based on device type.
+if(NOT DEFINED FLASH_MB_SIZE)
+ if(BOARD STREQUAL "PRALINE")
+ set(FLASH_MB_SIZE 4) # PRALINE has 4MB flash
+ else()
+ set(FLASH_MB_SIZE 2) # Default to 2MB if not provided. For PortaRf this should be 2MB passed to cmake with -DFLASH_MB_SIZE=2
+ endif()
+endif()
+message("Configuring for FLASH_MB_SIZE=${FLASH_MB_SIZE}MB")
+#This is the flash memory size we build. This can be less, so the FW size will be less, so can be flashed with older utils.
+if(NOT DEFINED FLASH_MB_LIMIT_SIZE)
+ if(BOARD STREQUAL "PRALINE")
+ set(FLASH_MB_LIMIT_SIZE 3.5) #PRALINE 4 MB = 3.5 firmware + 0.5 MB fpga bitstream.
+ else()
+ set(FLASH_MB_LIMIT_SIZE 1) #TODO: should be ${FLASH_MB_SIZE} remove once we got a stable build for all devices we support with bigger than 1 mb flash
+ endif()
+endif()
+message("Configuring for FLASH_MB_LIMIT_SIZE=${FLASH_MB_LIMIT_SIZE}MB")
+
+if(FLASH_MB_SIZE LESS FLASH_MB_LIMIT_SIZE)
+ message(FATAL_ERROR "Error: The supported flash size is lower than the generated flash file. Build halted.")
+endif()
+
+math(EXPR FLASH_BYTES_SIZE "${FLASH_MB_SIZE} * 1024 * 1024")
+if(BOARD STREQUAL "PRALINE")
+ math(EXPR FLASH_BYTES_LIMIT_SIZE "3584 * 1024") #4 MB = 3.5 firmware + 0.5 MB fpga bitstream.
+else()
+ math(EXPR FLASH_BYTES_LIMIT_SIZE "${FLASH_MB_LIMIT_SIZE} * 1024 * 1024")
+endif()
+
option(USE_CCACHE "Enable ccache, please keep an eye on this because sometimes it's not quite stable and generated unusable binary" OFF)
project(portapack-h1)
diff --git a/README.md b/README.md
index f53ba77f8..7533f2d0e 100644
--- a/README.md
+++ b/README.md
@@ -10,16 +10,20 @@
This is a fork of the [Havoc](https://github.com/furrtek/portapack-havoc/) firmware, which itself was a fork of the [PortaPack](https://github.com/sharebrained/portapack-hackrf) firmware, an add-on for the [HackRF](http://greatscottgadgets.com/hackrf/). A fork is a derivate, in this case one that has extra features and fixes when compared to the older versions.
[
](https://github.com/portapack-mayhem/mayhem-firmware/wiki/PortaPack-Versions#new-h4m-mayhem-edition)
-[
](https://github.com/portapack-mayhem/mayhem-firmware/wiki/PortaPack-Versions#h2m-mayhem-edition)
-[
]([https://github.com/portapack-mayhem/mayhem-firmware/wiki/PortaPack-Versions#h2m-mayhem-edition](https://github.com/portapack-mayhem/mayhem-firmware/wiki/PortaPack-Versions#h1r1r2))
+[
](https://github.com/portapack-mayhem/mayhem-firmware/wiki/PortaPack-Versions#h2m-mayhem-edition)
+
# What is this?
If you are new to *HackRF+PortaPack+Mayhem*, check these:
-[
](https://share.hackrf.app/6HKX9A)
+[
](https://share.hackrf.app/SPFOZO)
+
+[
](https://share.hackrf.app/6HKX9A)
+[
](https://share.hackrf.app/X4D5TF)
+[
](https://share.hackrf.app/F9MPOO)
+[
](https://share.hackrf.app/0JUHZ6)
-[
](https://share.hackrf.app/X4D5TF) [
](https://share.hackrf.app/F9MPOO) [
](https://share.hackrf.app/0JUHZ6) [
](https://share.hackrf.app/C0J6ZR)
# Frequently Asked Questions
@@ -28,9 +32,9 @@ This repository expands upon the previous work by many people and aims to consta
## What to buy?
-:heavy_check_mark:  The fabulous H4M [complete](https://share.hackrf.app/9UMBN5) or [upgrade](https://share.hackrf.app/30CQ55), featuring numerous improvements and accessories. Sold by our friends at [OpenSourceSDRLab](https://share.hackrf.app/99SAMT). Join their giveaways on discord (check the badge on top). _EU customers_ can purchase via [Lab401](https://share.hackrf.app/0CI2CR).
+:heavy_check_mark:  The fabulous H4M [complete](https://share.hackrf.app/DF2BZS) or [upgrade](https://share.hackrf.app/OCSJYI), featuring numerous improvements and accessories. Sold by our friends at [OpenSourceSDRLab](https://share.hackrf.app/99SAMT). Join their giveaways on discord (check the badge on top). _EU customers_ can purchase via [Lab401](https://share.hackrf.app/0CI2CR).
-:heavy_check_mark: A recommended one is this [PortaPack H2](https://www.ebay.com/itm/116382397447), that includes everything you need with the plastic case "inspired" on [this](https://github.com/portapack-mayhem/mayhem-firmware/wiki/3d-printed-enclosure).
+:heavy_check_mark: A recommended one is this [PortaPack H2](https://share.hackrf.app/QT5JMB), that includes everything you need with the plastic case "inspired" on [this](https://github.com/portapack-mayhem/mayhem-firmware/wiki/3d-printed-enclosure).
:heavy_check_mark: Some individuals lean towards the [H2 with a metal enclosure](https://share.hackrf.app/24T3TO), but its advantages remain debated. Share your insights on our [wiki](https://github.com/portapack-mayhem/mayhem-firmware/wiki/Hardware-overview).
@@ -47,7 +51,10 @@ This repository expands upon the previous work by many people and aims to consta
The current **stable release** is on the [](https://github.com/portapack-mayhem/mayhem-firmware/releases/latest) page. Follow the instructions you can find in the release description. The **latest (nightly) release** can be found [here](https://github.com/portapack-mayhem/mayhem-firmware/releases/).
## How can I collaborate
-You can write [documentation](https://github.com/portapack-mayhem/mayhem-firmware/wiki), fix bugs and [answer issues](https://github.com/portapack-mayhem/mayhem-firmware/issues) or add new functionality. Please check the following [guide](https://github.com/portapack-mayhem/mayhem-firmware/wiki/How-to-collaborate) with details.
+
+We warmly welcome your contributions! Please refer to our **[Contributing Guidelines](https://github.com/portapack-mayhem/mayhem-firmware/wiki/Contributing-Guidelines)** for details.
+
+You can write [documentation](https://github.com/portapack-mayhem/mayhem-firmware/wiki), fix bugs and [answer issues](https://github.com/portapack-mayhem/mayhem-firmware/issues) or add new functionality. Please also check the following [guide](https://github.com/portapack-mayhem/mayhem-firmware/wiki/How-to-collaborate) with details.
Consider that the hardware and firmware has been created and maintain by a [lot](https://github.com/mossmann/hackrf/graphs/contributors) of [people](https://github.com/portapack-mayhem/mayhem-firmware/graphs/contributors), so always try collaborating your time and effort first. For coding related questions, if something does not fit as an issue, please join our Discord by clicking the chat badge on [top](#portapack-mayhem).
diff --git a/dockerize.sh b/dockerize.sh
index 9f25d558e..63ba8eebe 100755
--- a/dockerize.sh
+++ b/dockerize.sh
@@ -15,8 +15,11 @@
# - Get a shell inside the build image to
# inspect problems: ./dockerize.sh shell
# - Give additional parameters to the container:
-# ./dockerize.sh -j10
-# ./dockerize.sh ninja -j10
+# Additional parameters to make: ./dockerize.sh -j10
+# Use ninja instead of make: ./dockerize.sh ninja -j10
+# Addotopnal CMake parameters: ./dockerize.sh -DBOARD=PRALINE
+# Change the default build directory: ./dockerize.sh -B build-alt
+# (Alternatively --workdir works as well)
# - Use a different dockerfile:
# ./dockerize.sh build dockerfile-other
# - Use a different cpu architecture:
diff --git a/docs/CNAME b/docs/CNAME
deleted file mode 100644
index 9b75482ff..000000000
--- a/docs/CNAME
+++ /dev/null
@@ -1 +0,0 @@
-portapack.ried.cl
\ No newline at end of file
diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt
index 161be343b..ada583cb6 100644
--- a/firmware/CMakeLists.txt
+++ b/firmware/CMakeLists.txt
@@ -48,19 +48,44 @@ if(NOT ${CMAKE_CXX_COMPILER_VERSION} VERSION_EQUAL ${EXPECTED_GCC_VERSION})
set(GCC_VERSION_MISMATCH 1)
endif()
+#generate h files
+configure_file(flashsize.h.in flashsize.h)
+add_compile_options(-include ${CMAKE_CURRENT_BINARY_DIR}/flashsize.h)
+
add_subdirectory(application)
add_subdirectory(baseband)
add_subdirectory(standalone)
add_subdirectory(test)
-# NOTE: Dependencies break if the .bin files aren't included in DEPENDS. WTF, CMake?
-add_custom_command(
- OUTPUT ${FIRMWARE_FILENAME}
- COMMAND ${MAKE_SPI_IMAGE} ${application_BINARY_DIR}/application.bin ${baseband_BINARY_DIR}/baseband.img ${FIRMWARE_FILENAME}
- DEPENDS baseband application ${MAKE_SPI_IMAGE}
- ${baseband_BINARY_DIR}/baseband.img ${application_BINARY_DIR}/application.bin
- VERBATIM
-)
+if(BOARD STREQUAL "PRALINE")
+ math(EXPR PRALINE_FINAL_SIZE "4 * 1024 * 1024")
+ set(PRALINE_FPGA_BIN ${CMAKE_CURRENT_SOURCE_DIR}/../hackrf/firmware/fpga/build/praline_fpga.bin)
+ set(APPEND_FPGA_SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/tools/append_fpga_bitstream.py)
+
+ # NOTE: Dependencies break if the .bin files aren't included in DEPENDS. WTF, CMake?
+ add_custom_command(
+ OUTPUT ${FIRMWARE_FILENAME}
+ COMMAND ${MAKE_SPI_IMAGE} ${application_BINARY_DIR}/application.bin ${baseband_BINARY_DIR}/baseband.img ${FIRMWARE_FILENAME} ${FLASH_BYTES_LIMIT_SIZE}
+ # PRALINE: Append FPGA bitstream to firmware at offset 0x380000
+ # The base firmware is 3.5MB, FPGA bitstream gets appended at the end of it, final size is 4MB
+ COMMAND ${CMAKE_COMMAND} -E echo "Appending PRALINE FPGA bitstream to firmware..."
+ COMMAND python3 ${APPEND_FPGA_SCRIPT} ${FIRMWARE_FILENAME} ${PRALINE_FPGA_BIN} ${FIRMWARE_FILENAME}.tmp ${PRALINE_FINAL_SIZE}
+ COMMAND ${CMAKE_COMMAND} -E rename ${FIRMWARE_FILENAME}.tmp ${FIRMWARE_FILENAME}
+ DEPENDS baseband application ${MAKE_SPI_IMAGE}
+ ${baseband_BINARY_DIR}/baseband.img ${application_BINARY_DIR}/application.bin
+ ${PRALINE_FPGA_BIN} ${APPEND_FPGA_SCRIPT}
+ VERBATIM
+ )
+else() # Sadly this part had to be duplicated (or at least I couldn't find a better way to do it)
+ # NOTE: Dependencies break if the .bin files aren't included in DEPENDS. WTF, CMake?
+ add_custom_command(
+ OUTPUT ${FIRMWARE_FILENAME}
+ COMMAND ${MAKE_SPI_IMAGE} ${application_BINARY_DIR}/application.bin ${baseband_BINARY_DIR}/baseband.img ${FIRMWARE_FILENAME} ${FLASH_BYTES_LIMIT_SIZE}
+ DEPENDS baseband application ${MAKE_SPI_IMAGE}
+ ${baseband_BINARY_DIR}/baseband.img ${application_BINARY_DIR}/application.bin
+ VERBATIM
+ )
+endif()
add_custom_target(
firmware
diff --git a/firmware/application/CMakeLists.txt b/firmware/application/CMakeLists.txt
index 9026e3509..85466ef63 100644
--- a/firmware/application/CMakeLists.txt
+++ b/firmware/application/CMakeLists.txt
@@ -86,6 +86,9 @@ set(CPLD_20150901_DATA_CPP ${CMAKE_CURRENT_BINARY_DIR}/portapack_cpld_20150901_d
set(CPLD_20170522_SVF_PATH ${HARDWARE_PATH}/portapack_h1/cpld/20170522/output_files/portapack_h1_cpld.svf)
set(CPLD_20170522_DATA_CPP ${CMAKE_CURRENT_BINARY_DIR}/portapack_cpld_20170522_data.cpp)
+set(CPLD_H4M_SVF_PATH ${HARDWARE_PATH}/portapack_h4m/CPLD/AG256SL100/output_files/portapack_h4m_cpld.svf)
+set(CPLD_H4M_DATA_CPP ${CMAKE_CURRENT_BINARY_DIR}/portapack_cpld_h4m_data.cpp)
+
set(HACKRF_CPLD_DATA_HPP ${CMAKE_CURRENT_BINARY_DIR}/hackrf_cpld_data.hpp)
set(HACKRF_CPLD_DATA_CPP ${CMAKE_CURRENT_BINARY_DIR}/hackrf_cpld_data.cpp)
@@ -118,11 +121,16 @@ set(CSRC
usb_serial_endpoints.c
usb_serial_device_to_host.c
i2c_device_to_host.c
+ hackrf_core_mini.c
+ ${HACKRF_PATH}/firmware/common/adc.c
${HACKRF_PATH}/firmware/common/usb.c
${HACKRF_PATH}/firmware/common/usb_queue.c
${HACKRF_PATH}/firmware/hackrf_usb/usb_device.c
${HACKRF_PATH}/firmware/common/usb_request.c
${HACKRF_PATH}/firmware/common/usb_standard_request.c
+ ${HACKRF_PATH}/firmware/libopencm3/lib/lpc43xx/scu.c
+ ${HACKRF_PATH}/firmware/common/gpio_lpc.c
+ ${HACKRF_PATH}/firmware/common/platform_detect.c
${CHIBIOS}/os/various/chprintf.c
)
@@ -205,6 +213,9 @@ set(CPPSRC
irq_controls.cpp
irq_lcd_frame.cpp
irq_rtc.cpp
+ keeloq_common.cpp
+ keeloq_file.cpp
+ keeloq_keystore.cpp
log_file.cpp
metadata_file.cpp
flipper_subfile.cpp
@@ -237,6 +248,7 @@ set(CPPSRC
hw/encoder.cpp
hw/max2837.cpp
hw/max2839.cpp
+ hw/max2831.cpp
hw/max5864.cpp
hw/rffc507x.cpp
hw/rffc507x_spi.cpp
@@ -246,6 +258,7 @@ set(CPPSRC
ook_file.cpp
ui_baseband_stats_view.cpp
ui_navigation.cpp
+ ui_notifications.cpp
ui_record_view.cpp
ui_sd_card_status_view.cpp
ui/ui_alphanum.cpp
@@ -292,14 +305,11 @@ set(CPPSRC
apps/ui_mictx.cpp
apps/ui_modemsetup.cpp
apps/ui_playlist.cpp
- apps/ui_pocsag_tx.cpp
apps/ui_rds.cpp
apps/ui_recon_settings.cpp
apps/ui_recon.cpp
- apps/ui_sd_over_usb.cpp
apps/ui_search.cpp
apps/ui_settings.cpp
- apps/ui_siggen.cpp
apps/ui_sonde.cpp
apps/ui_ss_viewer.cpp
apps/ui_standalone_view.cpp
@@ -319,6 +329,7 @@ set(CPPSRC
config_mode.cpp
${CPLD_20150901_DATA_CPP}
${CPLD_20170522_DATA_CPP}
+ ${CPLD_H4M_DATA_CPP}
${HACKRF_CPLD_DATA_CPP}
ui_external_items_menu_loader.cpp
view_factory_base.cpp
@@ -404,7 +415,11 @@ set(CPPWARN "-Wall -Wextra -Wno-psabi")
# List all default C defines here, like -D_DEBUG=1
# TODO: Switch -DCRT0_INIT_DATA depending on load from RAM or SPIFI?
# NOTE: _RANDOM_TCC to kill a GCC 4.9.3 error with std::max argument types
-set(DDEFS "-DLPC43XX -DLPC43XX_M0 -D__NEWLIB__ -DHACKRF_ONE -DTOOLCHAIN_GCC -DTOOLCHAIN_GCC_ARM -D_RANDOM_TCC=0 -D'VERSION_STRING=\"${VERSION}\"' -DVERSION_MD5=${VERSION_MD5} -D'GCC_VERSION_MISMATCH=${GCC_VERSION_MISMATCH}'")
+# Use BOARD variable if set, otherwise default to HACKRF_ONE
+if(NOT DEFINED BOARD)
+ set(BOARD "HACKRF_ONE")
+endif()
+set(DDEFS "-DLPC43XX -DLPC43XX_M0 -D__NEWLIB__ -D${BOARD} -DTOOLCHAIN_GCC -DTOOLCHAIN_GCC_ARM -D_RANDOM_TCC=0 -D'VERSION_STRING=\"${VERSION}\"' -DVERSION_MD5=${VERSION_MD5} -D'GCC_VERSION_MISMATCH=${GCC_VERSION_MISMATCH}'")
# List all default ASM defines here, like -D_DEBUG=1
set(DADEFS)
@@ -462,6 +477,12 @@ add_custom_command(
DEPENDS ${EXTRACT_CPLD_DATA} ${CPLD_20170522_SVF_PATH}
)
+add_custom_command(
+ OUTPUT ${CPLD_H4M_DATA_CPP}
+ COMMAND ${EXTRACT_CPLD_DATA} ${CPLD_H4M_SVF_PATH} rev_h4m >${CPLD_H4M_DATA_CPP}
+ DEPENDS ${EXTRACT_CPLD_DATA} ${CPLD_H4M_SVF_PATH}
+)
+
add_custom_command(
OUTPUT ${HACKRF_CPLD_DATA_CPP}
COMMAND ${HACKRF_CPLD_TOOL} --xsvf ${HACKRF_CPLD_XSVF_PATH} --portapack-data ${HACKRF_CPLD_DATA_CPP}
@@ -469,6 +490,9 @@ add_custom_command(
)
add_executable(${PROJECT_NAME}.elf ${CSRC} ${CPPSRC} ${ASMSRC})
+target_link_options(${PROJECT_NAME}.elf PRIVATE
+ "LINKER:--defsym,LD_FLASH_SIZE=${FLASH_BYTES_LIMIT_SIZE}"
+)
set_target_properties(${PROJECT_NAME}.elf PROPERTIES LINK_DEPENDS ${LDSCRIPT})
add_definitions(${DEFS})
include_directories(. ${INCDIR})
diff --git a/firmware/application/app_settings.cpp b/firmware/application/app_settings.cpp
index acf93b0ff..bbae046f9 100644
--- a/firmware/application/app_settings.cpp
+++ b/firmware/application/app_settings.cpp
@@ -68,6 +68,9 @@ void BoundSetting::parse(std::string_view value) {
as() = (parsed != 0);
break;
}
+ case SettingType::Float:
+ as() = atof(value.data());
+ break;
};
}
@@ -101,6 +104,11 @@ void BoundSetting::write(File& file) const {
case SettingType::Bool:
file.write(as() ? "1" : "0", 1);
break;
+
+ case SettingType::Float: {
+ auto float_str = to_string_decimal(as(), 6);
+ file.write(float_str.c_str(), float_str.length());
+ }
}
file.write("\r\n", 2);
diff --git a/firmware/application/app_settings.hpp b/firmware/application/app_settings.hpp
index d0c30d5a3..4db5caf35 100644
--- a/firmware/application/app_settings.hpp
+++ b/firmware/application/app_settings.hpp
@@ -52,6 +52,7 @@ class BoundSetting {
U8,
String,
Bool,
+ Float
};
public:
@@ -73,6 +74,9 @@ class BoundSetting {
BoundSetting(std::string_view name, bool* target)
: name_{name}, target_{target}, type_{SettingType::Bool} {}
+ BoundSetting(std::string_view name, float* target)
+ : name_{name}, target_{target}, type_{SettingType::Float} {}
+
std::string_view name() const { return name_; }
void parse(std::string_view value);
void write(File& file) const;
diff --git a/firmware/application/apps/analog_audio_app.cpp b/firmware/application/apps/analog_audio_app.cpp
index a5c51f4a5..a1f4e224e 100644
--- a/firmware/application/apps/analog_audio_app.cpp
+++ b/firmware/application/apps/analog_audio_app.cpp
@@ -199,6 +199,55 @@ SPECOptionsView::SPECOptionsView(
view->set_spec_iq_phase_calibration_value(view->get_spec_iq_phase_calibration_value()); // initialize iq_phase_calibration in radio
}
+/* PralineOptionsView *******************************************************/
+
+#ifdef PRALINE
+PralineOptionsView::PralineOptionsView(Rect parent_rect, const Style* style) {
+ set_style(style);
+ set_parent_rect(parent_rect);
+ add_children({&label_sr, &options_sr, &label_dc, &options_dc,
+ &label_qi, &options_qi, &label_qs, &options_qs});
+
+ // READ the current state from hardware
+ fpga_reg_1 = radio::debug::fpga::register_read(1);
+
+ // INITIALIZE UI WIDGETS based on read bits
+ options_dc.set_by_value((fpga_reg_1 & 0x01) ? 1 : 0); // Bit 0
+ options_qi.set_by_value((fpga_reg_1 & 0x02) ? 1 : 0); // Bit 1
+ options_qs.set_by_value((fpga_reg_1 & 0x04) ? 1 : 0); // Bit 2
+ options_sr.set_value(receiver_model.sampling_rate());
+
+ options_sr.on_change = [this](int32_t v) { receiver_model.set_sampling_rate(static_cast(v)); };
+ options_dc.on_change = [this](size_t, OptionsField::value_t v) {
+ if (v)
+ fpga_reg_1 |= 0x01;
+ else
+ fpga_reg_1 &= ~0x01;
+ update_fpga_ctrl();
+ };
+
+ options_qi.on_change = [this](size_t, OptionsField::value_t v) {
+ if (v)
+ fpga_reg_1 |= 0x02;
+ else
+ fpga_reg_1 &= ~0x02;
+ update_fpga_ctrl();
+ };
+
+ options_qs.on_change = [this](size_t, OptionsField::value_t v) {
+ if (v)
+ fpga_reg_1 |= 0x04;
+ else
+ fpga_reg_1 &= ~0x04;
+ update_fpga_ctrl();
+ };
+}
+
+void PralineOptionsView::update_fpga_ctrl() {
+ radio::debug::fpga::register_write(1, fpga_reg_1);
+}
+#endif
+
/* AnalogAudioView *******************************************************/
AnalogAudioView::AnalogAudioView(
@@ -217,7 +266,13 @@ AnalogAudioView::AnalogAudioView(
&field_volume,
&text_ctcss,
&record_view,
- &waterfall});
+#ifdef PRALINE
+ &waterfall,
+ &button_pro
+#else
+ &waterfall
+#endif
+ });
// Filename Datetime and Frequency
record_view.set_filename_date_frequency(true);
@@ -256,6 +311,10 @@ AnalogAudioView::AnalogAudioView(
field_frequency.set_value(receiver_model.target_frequency() + offset);
};
+#ifdef PRALINE
+ button_pro.on_select = [this](Button&) { this->on_show_options_praline(); };
+#endif
+
audio::output::start();
// This call starts the correct baseband image to run
@@ -553,4 +612,11 @@ void AnalogAudioView::handle_coded_squelch(uint32_t value) {
void AnalogAudioView::on_freqchg(int64_t freq) {
field_frequency.set_value(freq);
}
+
+#ifdef PRALINE
+void AnalogAudioView::on_show_options_praline() {
+ set_options_widget(std::make_unique(options_view_rect, Theme::getInstance()->option_active));
+}
+#endif
+
} /* namespace ui */
diff --git a/firmware/application/apps/analog_audio_app.hpp b/firmware/application/apps/analog_audio_app.hpp
index 33d7b41d0..2e1b1ccfb 100644
--- a/firmware/application/apps/analog_audio_app.hpp
+++ b/firmware/application/apps/analog_audio_app.hpp
@@ -37,6 +37,38 @@ namespace ui {
class AnalogAudioView;
+#ifdef PRALINE
+class PralineOptionsView : public View {
+ public:
+ PralineOptionsView(Rect parent_rect, const Style* style);
+
+ private:
+ // Layout: SR (Sample Rate), DC (DC Block), QI (Q-Invert), QS (Quarter Shift), D (Decim)
+ Text label_sr{{UI_POS_X(0), UI_POS_Y(0), UI_POS_WIDTH(2), UI_POS_HEIGHT(1)}, "SR"};
+
+ // Parameters: Position, Length (in chars), Range, Step, and default value
+ NumberField options_sr{
+ {UI_POS_X(3), UI_POS_Y(0)},
+ 8, // Number of digits to show
+ {0, 40000000}, // Range: 0 to 40,000 kHz
+ 100, // Step: 100 Hz
+ ' ' // Fix: Single quotes for char
+ };
+
+ Text label_dc{{UI_POS_X(14), UI_POS_Y(0), UI_POS_WIDTH(2), UI_POS_HEIGHT(1)}, "DC"};
+ OptionsField options_dc{{UI_POS_X(16), UI_POS_Y(0)}, 2, {{"Of", 0}, {"On", 1}}};
+
+ Text label_qi{{UI_POS_X(20), UI_POS_Y(0), UI_POS_WIDTH(2), UI_POS_HEIGHT(1)}, "QI"};
+ OptionsField options_qi{{UI_POS_X(22), UI_POS_Y(0)}, 2, {{"Of", 0}, {"On", 1}}};
+
+ Text label_qs{{UI_POS_X(26), UI_POS_Y(0), UI_POS_WIDTH(2), UI_POS_HEIGHT(1)}, "QS"};
+ OptionsField options_qs{{UI_POS_X(28), UI_POS_Y(0)}, 2, {{"Of", 0}, {"On", 1}}};
+
+ uint8_t fpga_reg_1{0x00}; // Tracks DC, QI, QS bits
+ void update_fpga_ctrl();
+};
+#endif
+
class AMOptionsView : public View {
public:
AMOptionsView(AnalogAudioView* view, Rect parent_rect, const Style* style);
@@ -222,6 +254,11 @@ class AnalogAudioView : public View {
uint8_t get_previous_zoom_option();
void set_previous_zoom_option(uint8_t zoom);
+#ifdef PRALINE
+ Button button_pro{{UI_POS_X(12), UI_POS_Y(2), UI_POS_WIDTH(3), UI_POS_HEIGHT(1)}, "PRO"};
+ void on_show_options_praline();
+#endif
+
private:
static constexpr ui::Dim header_height = 3 * 16;
diff --git a/firmware/application/apps/ble_rx_app.cpp b/firmware/application/apps/ble_rx_app.cpp
index b16cf913b..f553061f3 100644
--- a/firmware/application/apps/ble_rx_app.cpp
+++ b/firmware/application/apps/ble_rx_app.cpp
@@ -645,6 +645,20 @@ BLERxView::BLERxView(NavigationView& nav)
options_sort.set_selected_index(sort_index, true);
options_filter.set_selected_index(filter_index, true);
+ // ------------------------------------------------------------------------------
+ // Handle Max Recent Entries
+ // ------------------------------------------------------------------------------
+
+ // Max Recent Entries UI widget
+ add_child(&label_max_entries);
+ add_child(&field_max_entries);
+ field_max_entries.set_value(max_recent_entries);
+
+ // Define what happens when the user changes the number
+ field_max_entries.on_change = [this](int32_t v) {
+ max_recent_entries = (size_t)v;
+ };
+
// Auto-configure modem for LCR RX (will be removed later)
baseband::set_btlerx(channel_number);
@@ -819,6 +833,10 @@ void BLERxView::on_data(BlePacketData* packet) {
recent.push_front(*it);
recent.erase(it);
} else {
+ // Enforce limit
+ while (recent.size() >= max_recent_entries) {
+ recent.pop_back();
+ }
recent.emplace_front(key);
truncate_entries(recent);
}
@@ -887,6 +905,9 @@ void BLERxView::on_filter_change(std::string value) {
}
void BLERxView::on_file_changed(const std::filesystem::path& new_file_path) {
+ // Clear searchList
+ searchList.clear();
+
file_path = new_file_path;
found_count = 0;
total_count = 0;
@@ -919,8 +940,10 @@ void BLERxView::on_file_changed(const std::filesystem::path& new_file_path) {
break;
}
- searchList.push_back(currentLine);
- total_count++;
+ if (searchList.size() < max_recent_entries) {
+ searchList.push_back(currentLine);
+ total_count++;
+ }
bytePos += bytesRead;
@@ -940,6 +963,21 @@ void BLERxView::on_timer() {
channel_number = (channel_number < 39) ? channel_number + 1 : 37;
}
}
+
+ // Debug: Check heap status every ~1 second
+ static int heap_check_counter = 0;
+ if (++heap_check_counter >= 60) {
+ heap_check_counter = 0;
+ size_t heap_free = chCoreStatus();
+ if (heap_free < 4096) { // Less than 4KB free
+ // Emergency: clear old entries
+ while (recent.size() > (max_recent_entries - 1)) {
+ recent.pop_back();
+ }
+ recent_entries_view.set_dirty();
+ }
+ }
+
if (ble_rx_error != BLE_RX_NO_ERROR) {
if (ble_rx_error == BLE_RX_LIST_FILENAME_EMPTY_ERROR) {
nav_.display_modal("Error", "List filename is empty !");
@@ -1059,7 +1097,6 @@ bool BLERxView::updateEntry(const BlePacketData* packet, BleRecentEntry& entry,
entry.dbValue = packet->max_dB - (receiver_model.lna() + receiver_model.vga() + (receiver_model.rf_amp() ? 14 : 0));
entry.timestamp = to_string_timestamp(rtc_time::now());
entry.dataString = data_string;
-
entry.packetData.type = packet->type;
entry.packetData.size = packet->size;
entry.packetData.dataLen = packet->dataLen;
diff --git a/firmware/application/apps/ble_rx_app.hpp b/firmware/application/apps/ble_rx_app.hpp
index 5c4ac8c14..8cb21b189 100644
--- a/firmware/application/apps/ble_rx_app.hpp
+++ b/firmware/application/apps/ble_rx_app.hpp
@@ -42,6 +42,12 @@
#include "recent_entries.hpp"
+// Add for heap debugging
+// For ChibiOS core functions
+#include "ch.h"
+#include "chcore.h"
+#include "chheap.h"
+
class BLELogger {
public:
Optional append(const std::filesystem::path& filename) {
@@ -90,6 +96,7 @@ struct BleRecentEntry {
uint64_t uniqueKey;
int dbValue;
BlePacketData packetData;
+
std::string timestamp;
std::string dataString;
std::string nameString;
@@ -426,6 +433,20 @@ class BLERxView : public View {
[this](const Message* const) {
this->on_timer();
}};
+
+ // Widget to control the list limit
+ Labels label_max_entries{
+ {{UI_POS_X(22), 10 * 8 - 2}, "List:", Theme::getInstance()->fg_light->foreground}};
+ NumberField field_max_entries{
+ {UI_POS_X(28), 10 * 8 - 2}, // Correct: Position (x, y)
+ 2, // Number of digits
+ {5, 64}, // Range (min, max) <- Coincides with max_entries
+ 1, // Step size
+ ' ' // Filler character
+ };
+
+ // Variable to store the current limit
+ size_t max_recent_entries = 25;
}; /* BLERxView */
} /* namespace ui */
diff --git a/firmware/application/apps/ble_tx_app.cpp b/firmware/application/apps/ble_tx_app.cpp
index 75e1cf879..07b5ff13c 100644
--- a/firmware/application/apps/ble_tx_app.cpp
+++ b/firmware/application/apps/ble_tx_app.cpp
@@ -199,6 +199,7 @@ void BLETxView::send_packet() {
generateRandomMacAddress(randomMac);
char advertisementData[63] = {0};
+ packets[current_packet].advertisementData[62] = '\0';
strcpy(advertisementData, packets[current_packet].advertisementData);
// TODO: Make this a checkbox.
@@ -225,7 +226,7 @@ void BLETxView::send_packet() {
uint8_t min = 0x00;
uint8_t max = 0x0F;
- hexDigit = min + std::rand() % (max - min + 1);
+ hexDigit = min + rand() % (max - min + 1);
} break;
default:
hexDigit = 0;
diff --git a/firmware/application/apps/ui_adsb_rx.cpp b/firmware/application/apps/ui_adsb_rx.cpp
index 8a536bc40..7248a9702 100644
--- a/firmware/application/apps/ui_adsb_rx.cpp
+++ b/firmware/application/apps/ui_adsb_rx.cpp
@@ -271,10 +271,17 @@ ADSBRxDetailsView::ADSBRxDetailsView(
&text_frame_pos_even,
&text_frame_pos_odd,
&button_aircraft_details,
- &button_see_map});
+ &button_see_map,
+ &opt_map_list});
text_icao_address.set(entry_.icao_str);
+ opt_map_list.on_change = [this](size_t, uint32_t mf) {
+ map_filter = mf;
+ clear_map_markers(); // reset them
+ pos_history.clear(); // erase the trail
+ };
+
button_aircraft_details.on_select = [this, &nav](Button&) {
aircraft_details_view_ = nav.push(entry_);
nav.set_on_pop([this]() {
@@ -288,7 +295,7 @@ ADSBRxDetailsView::ADSBRxDetailsView(
get_map_tag(entry_),
entry_.pos.altitude,
GeoPos::alt_unit::FEET,
- GeoPos::spd_unit::HIDDEN,
+ GeoPos::spd_unit::KNOTS,
entry_.pos.latitude,
entry_.pos.longitude,
entry_.velo.heading);
@@ -312,17 +319,107 @@ void ADSBRxDetailsView::update(const AircraftRecentEntry& entry) {
// AC Details view is showing, nothing to update.
} else if (geomap_view_) {
// Map is showing, update the current item.
+ if (map_filter == 1) {
+ // add to map trail history
+ add_map_trail(entry);
+ }
geomap_view_->update_tag(get_map_tag(entry_));
- geomap_view_->update_position(entry.pos.latitude, entry.pos.longitude, entry.velo.heading, entry.pos.altitude, entry.velo.speed);
+ geomap_view_->update_position(entry.pos.latitude, entry.pos.longitude, entry.velo.heading, entry.pos.altitude, entry.get_ground_speed());
} else {
// Details is showing, update details.
refresh_ui();
}
}
+void ADSBRxDetailsView::get_altitude_color(int32_t alt_ft, uint8_t* r, uint8_t* g, uint8_t* b) {
+ static const struct {
+ int32_t alt;
+ uint8_t r, g, b;
+ } stops[] = {
+ {0, 220, 220, 220}, // 0 ft: White/Grey (Ground)
+ {2000, 255, 255, 0}, // 2k ft: Yellow
+ {10000, 0, 255, 0}, // 10k ft: Green
+ {20000, 0, 255, 255}, // 20k ft: Cyan
+ {30000, 60, 60, 255}, // 30k ft: Blue (Lightened for visibility on Black)
+ {40000, 255, 0, 255} // 40k+ ft: Magenta
+ };
+ if (alt_ft <= stops[0].alt) {
+ *r = stops[0].r;
+ *g = stops[0].g;
+ *b = stops[0].b;
+ return;
+ }
+ const int count = sizeof(stops) / sizeof(stops[0]);
+ if (alt_ft >= stops[count - 1].alt) {
+ *r = stops[count - 1].r;
+ *g = stops[count - 1].g;
+ *b = stops[count - 1].b;
+ return;
+ }
+ for (int i = 0; i < count - 1; i++) {
+ if (alt_ft < stops[i + 1].alt) {
+ float t = (float)(alt_ft - stops[i].alt) / (stops[i + 1].alt - stops[i].alt);
+
+ *r = (uint8_t)(stops[i].r + (stops[i + 1].r - stops[i].r) * t);
+ *g = (uint8_t)(stops[i].g + (stops[i + 1].g - stops[i].g) * t);
+ *b = (uint8_t)(stops[i].b + (stops[i + 1].b - stops[i].b) * t);
+ return;
+ }
+ }
+ // Fallback: in case no interval matched, default to ground color.
+ *r = stops[0].r;
+ *g = stops[0].g;
+ *b = stops[0].b;
+}
+
void ADSBRxDetailsView::clear_map_markers() {
if (geomap_view_)
geomap_view_->clear_markers();
+ if (map_filter == 1)
+ refresh_trail_markers(); // re add trail
+}
+
+void ADSBRxDetailsView::refresh_trail_markers() {
+ if (!geomap_view_)
+ return;
+
+ geomap_view_->clear_markers();
+ for (const auto& pos : pos_history) {
+ GeoMarker marker{};
+ marker.lon = pos.lon;
+ marker.lat = pos.lat;
+ marker.angle = pos.heading;
+ marker.tag = ""; // No tag for trail points
+ uint8_t r, g, b;
+ get_altitude_color(pos.altitude, &r, &g, &b);
+ marker.color = Color(r, g, b);
+ geomap_view_->store_marker(marker);
+ }
+}
+
+void ADSBRxDetailsView::add_map_trail(const AircraftRecentEntry& entry) {
+ if (!geomap_view_)
+ return;
+ if (entry.pos.pos_valid == false)
+ return;
+
+ if (!pos_history.empty()) {
+ const auto& last_pos = pos_history.back();
+ const float THRESH_DEG = 0.009f;
+ const float THRESH_SQ = THRESH_DEG * THRESH_DEG; // 0.00002025f
+
+ float d_lat = entry.pos.latitude - last_pos.lat;
+ float d_lon = entry.pos.longitude - last_pos.lon;
+ if ((d_lat * d_lat) + (d_lon * d_lon) < THRESH_SQ) {
+ return; // Moved less than ~1000m, skip (rough estimate, varies with latitude but good enough for our purposes). This prevents adding too many points when the plane is circling or taxiing.
+ }
+ }
+
+ // Only keep the last 30 positions in the trail.
+ if (pos_history.size() >= 30)
+ pos_history.erase(pos_history.begin());
+ pos_history.push_back({entry.pos.latitude, entry.pos.longitude, entry.velo.heading, entry.pos.altitude});
+ refresh_trail_markers();
}
bool ADSBRxDetailsView::add_map_marker(const AircraftRecentEntry& entry) {
@@ -330,11 +427,16 @@ bool ADSBRxDetailsView::add_map_marker(const AircraftRecentEntry& entry) {
if (!geomap_view_)
return false;
+ if (map_filter == 1) return false; // this is the 'only me' option, so skip all others
+
GeoMarker marker{};
marker.lon = entry.pos.longitude;
marker.lat = entry.pos.latitude;
marker.angle = entry.velo.heading;
marker.tag = get_map_tag(entry);
+ uint8_t r, g, b;
+ get_altitude_color(entry.pos.altitude, &r, &g, &b);
+ marker.color = Color(r, g, b);
auto markerStored = geomap_view_->store_marker(marker);
return markerStored == MARKER_STORED;
@@ -651,14 +753,15 @@ void ADSBRxView::refresh_ui() {
// Process the entries list.
for (const auto& entry : recent) {
// Found the entry being shown in details view. Update it.
- if (entry.key() == detail_key) {
+ if (entry.key() == detail_key) { // we don't add it to the markers, since this is the currently selected and shown in the middle one. This eliminates the "shadow" marker, and saves ram
details_view->update(entry);
current_updated = true;
- }
-
- // NB: current entry also gets a marker so it shows up if map is panned.
- if (map_needs_update && entry.pos.pos_valid && entry.state <= ADSBAgeState::Recent) {
- map_needs_update = details_view->add_map_marker(entry);
+ } else {
+ // Add others only
+ // NB: current entry also gets a marker so it shows up if map is panned.
+ if (map_needs_update && entry.pos.pos_valid && entry.state <= ADSBAgeState::Recent) {
+ map_needs_update = details_view->add_map_marker(entry);
+ }
}
// Any work left to do?
diff --git a/firmware/application/apps/ui_adsb_rx.hpp b/firmware/application/apps/ui_adsb_rx.hpp
index 15280e051..c9a6e0468 100644
--- a/firmware/application/apps/ui_adsb_rx.hpp
+++ b/firmware/application/apps/ui_adsb_rx.hpp
@@ -64,9 +64,9 @@ namespace ui {
#define VEL_AIR_SUBSONIC 3
#define VEL_AIR_SUPERSONIC 4
-#define O_E_FRAME_TIMEOUT 20 // timeout between odd and even frames
-#define MARKER_UPDATE_SECONDS_OSM 10 // "other" map marker redraw interval for osm
-#define MARKER_UPDATE_SECONDS_BIN 5 // "other" map marker redraw interval for bin map
+#define O_E_FRAME_TIMEOUT 20 // timeout between odd and even frames
+#define MARKER_UPDATE_SECONDS_OSM 8 // "other" map marker redraw interval for osm
+#define MARKER_UPDATE_SECONDS_BIN 5 // "other" map marker redraw interval for bin map
/* Thresholds (in seconds) that define the transition between ages. */
struct ADSBAgeLimit {
@@ -150,6 +150,19 @@ struct AircraftRecentEntry {
age = 0;
}
+ int32_t get_ground_speed() const {
+ if (velo.valid == false) return 0;
+ if (velo.type == SPD_GND)
+ return velo.speed;
+ else if (velo.type == SPD_IAS) {
+ if (!pos.alt_valid) return velo.speed; // can't correct without altitude, so just return IAS
+ return (int32_t)(velo.speed * (1.0f + (0.02f * (pos.altitude / 1000.0f))));
+ } else if (velo.type == SPD_TAS)
+ return velo.speed; // We don't know the wind speed
+ else
+ return 0;
+ }
+
void inc_age(int delta) {
age += delta;
@@ -208,50 +221,50 @@ class ADSBRxAircraftDetailsView : public View {
private:
Labels labels{
- {{UI_POS_X(0), 1 * 16}, "ICAO:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 2 * 16}, "Registration:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 3 * 16}, "Manufacturer:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 5 * 16}, "Model:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 7 * 16}, "Type:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 8 * 16}, "Number of engines:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 9 * 16}, "Engine type:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 11 * 16}, "Owner:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 13 * 16}, "Operator:", Theme::getInstance()->fg_light->foreground}};
+ {{UI_POS_X(0), UI_POS_Y(1)}, "ICAO:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(2)}, "Registration:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(3)}, "Manufacturer:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(5)}, "Model:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(7)}, "Type:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(8)}, "Number of engines:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(9)}, "Engine type:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(11)}, "Owner:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(13)}, "Operator:", Theme::getInstance()->fg_light->foreground}};
Text text_icao_address{
- {5 * 8, 1 * 16, 6 * 8, UI_POS_HEIGHT(1)},
+ {UI_POS_X(5), UI_POS_Y(1), 6 * 8, UI_POS_HEIGHT(1)},
"-"};
Text text_registration{
- {13 * 8, 2 * 16, 8 * 8, UI_POS_HEIGHT(1)},
+ {UI_POS_X(13), UI_POS_Y(2), 8 * 8, UI_POS_HEIGHT(1)},
"-"};
Text text_manufacturer{
- {UI_POS_X(0), 4 * 16, 19 * 8, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(4), 19 * 8, UI_POS_HEIGHT(1)},
"-"};
Text text_model{
- {UI_POS_X(0), 6 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(6), screen_width, UI_POS_HEIGHT(1)},
"-"};
Text text_type{
- {5 * 8, 7 * 16, 22 * 8, UI_POS_HEIGHT(1)},
+ {UI_POS_X(5), UI_POS_Y(7), 22 * 8, UI_POS_HEIGHT(1)},
"-"};
Text text_number_of_engines{
- {18 * 8, 8 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(18), UI_POS_Y(8), screen_width, UI_POS_HEIGHT(1)},
"-"};
Text text_engine_type{
- {UI_POS_X(0), 10 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(10), screen_width, UI_POS_HEIGHT(1)},
"-"};
Text text_owner{
- {UI_POS_X(0), 12 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(12), screen_width, UI_POS_HEIGHT(1)},
"-"};
Text text_operator{
- {UI_POS_X(0), 14 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(14), screen_width, UI_POS_HEIGHT(1)},
"-"};
Button button_close{
@@ -270,11 +283,14 @@ class ADSBRxDetailsView : public View {
void focus() override;
void update(const AircraftRecentEntry& entry);
+ void get_altitude_color(int32_t alt_ft, uint8_t* r, uint8_t* g, uint8_t* b);
/* Calls forwarded to map view if shown. */
bool map_active() const { return geomap_view_; }
void clear_map_markers();
/* Adds a marker for the entry to the map. Returns true on success. */
bool add_map_marker(const AircraftRecentEntry& entry);
+ void add_map_trail(const AircraftRecentEntry& entry);
+ void refresh_trail_markers();
std::string title() const override { return "Details"; }
@@ -297,57 +313,72 @@ class ADSBRxDetailsView : public View {
// if removed from the recent entries list.
AircraftRecentEntry entry_{AircraftRecentEntry::invalid_key};
bool airline_checked{false};
+ uint8_t map_filter{0}; // 0: all, 1: only this, set by opt_map_list.
+ struct PosHistory {
+ float lat;
+ float lon;
+ uint16_t heading;
+ int32_t altitude;
+ };
+ std::vector pos_history{};
Labels labels{
- {{UI_POS_X(0), 1 * 16}, "ICAO:", Theme::getInstance()->fg_light->foreground},
- {{13 * 8, 1 * 16}, "Callsign:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 2 * 16}, "Last seen:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 3 * 16}, "Airline:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 5 * 16}, "Country:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 13 * 16}, "Even position frame:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 15 * 16}, "Odd position frame:", Theme::getInstance()->fg_light->foreground}};
+ {{UI_POS_X(0), UI_POS_Y(0)}, "ICAO:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(13), UI_POS_Y(0)}, "Callsign:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(1)}, "Last seen:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(2)}, "Airline:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(4)}, "Country:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(7)}, "Map filter:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(13)}, "Even position frame:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(15)}, "Odd position frame:", Theme::getInstance()->fg_light->foreground}};
Text text_icao_address{
- {5 * 8, 1 * 16, 6 * 8, UI_POS_HEIGHT(1)},
+ {UI_POS_X(5), UI_POS_Y(0), 6 * 8, UI_POS_HEIGHT(1)},
"-"};
Text text_callsign{
- {22 * 8, 1 * 16, 8 * 8, UI_POS_HEIGHT(1)},
+ {UI_POS_X(22), UI_POS_Y(0), 8 * 8, UI_POS_HEIGHT(1)},
"-"};
Text text_last_seen{
- {11 * 8, 2 * 16, 19 * 8, UI_POS_HEIGHT(1)},
+ {UI_POS_X(11), UI_POS_Y(1), 19 * 8, UI_POS_HEIGHT(1)},
"-"};
Text text_airline{
- {UI_POS_X(0), 4 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(3), screen_width, UI_POS_HEIGHT(1)},
"-"};
Text text_country{
- {8 * 8, 5 * 16, 22 * 8, UI_POS_HEIGHT(1)},
+ {UI_POS_X(8), UI_POS_Y(4), 22 * 8, UI_POS_HEIGHT(1)},
"-"};
Text text_infos{
- {UI_POS_X(0), 6 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(5), screen_width, UI_POS_HEIGHT(1)},
"-"};
Text text_info2{
- {UI_POS_X(0), 7 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(6), screen_width, UI_POS_HEIGHT(1)},
"-"};
Text text_frame_pos_even{
- {UI_POS_X(0), 14 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(14), screen_width, UI_POS_HEIGHT(1)},
"-"};
Text text_frame_pos_odd{
- {UI_POS_X(0), 16 * 16, screen_width, UI_POS_HEIGHT(1)},
+ {UI_POS_X(0), UI_POS_Y(16), screen_width, UI_POS_HEIGHT(1)},
"-"};
Button button_aircraft_details{
- {UI_POS_X_CENTER(12) - UI_POS_X(8), UI_POS_Y(9), UI_POS_WIDTH(12), UI_POS_HEIGHT(3)},
+ {UI_POS_X_CENTER(12) - UI_POS_X(8), UI_POS_Y(8), UI_POS_WIDTH(12), UI_POS_HEIGHT(3)},
"A/C details"};
+ OptionsField opt_map_list{
+ {UI_POS_X_CENTER(12) + UI_POS_X(8), UI_POS_Y(7)},
+ 12,
+ {{"All", 0},
+ {"Only this", 1}}};
+
Button button_see_map{
- {UI_POS_X_CENTER(12) + UI_POS_X(8), UI_POS_Y(9), UI_POS_WIDTH(12), UI_POS_HEIGHT(3)},
+ {UI_POS_X_CENTER(12) + UI_POS_X(8), UI_POS_Y(8), UI_POS_WIDTH(12), UI_POS_HEIGHT(3)},
"See on map"};
MessageHandlerRegistration message_handler_gps{
diff --git a/firmware/application/apps/ui_aprs_tx.cpp b/firmware/application/apps/ui_aprs_tx.cpp
index 021104437..92713a0b7 100644
--- a/firmware/application/apps/ui_aprs_tx.cpp
+++ b/firmware/application/apps/ui_aprs_tx.cpp
@@ -29,6 +29,7 @@
#include "baseband_api.hpp"
#include "portapack_shared_memory.hpp"
#include "portapack_persistent_memory.hpp"
+#include "ui_geomap.hpp"
#include
#include
@@ -49,10 +50,24 @@ APRSTXView::~APRSTXView() {
void APRSTXView::start_tx() {
// TODO: Clean up this API to take string_views to avoid allocations.
+ std::string new_payload = payload;
+
+ std::string gps = text_gps_coord.get();
+ std::string token = "?GPS?";
+ size_t pos = 0;
+ while ((pos = new_payload.find(token, pos)) != std::string::npos) {
+ new_payload.replace(pos, token.size(), gps);
+ pos += gps.size();
+ }
+
+ text_payload.set(new_payload);
+
+ std::string path = text_path.get();
+
make_aprs_frame(
sym_source.to_string().c_str(), num_ssid_source.value(),
sym_dest.to_string().c_str(), num_ssid_dest.value(),
- payload);
+ new_payload, path);
// uint8_t * bb_data_ptr = shared_memory.bb_data.data;
// text_payload.set(to_string_hex_array(bb_data_ptr + 56, 15));
@@ -77,6 +92,61 @@ void APRSTXView::on_tx_progress(const uint32_t progress, const bool done) {
}
}
+void APRSTXView::process_coordinates(float lat_, float lon_) {
+ std::string s;
+
+ char ns = lat_ >= 0 ? 'N' : 'S';
+ float lat = (lat_ >= 0) ? lat_ : -lat_;
+ uint32_t lat_d = (uint32_t)lat;
+ uint32_t lat_m = (uint32_t)((lat - lat_d) * 6000.0f + 0.5f);
+
+ if (lat_m >= 6000) {
+ lat_m = 0;
+ lat_d++;
+ }
+
+ s += to_string_dec_uint(lat_d, 2, '0');
+ s += to_string_dec_uint(lat_m / 100, 2, '0');
+ s += ".";
+ s += to_string_dec_uint(lat_m % 100, 2, '0');
+ s += ns;
+
+ s += "/";
+
+ char ew = lon_ >= 0 ? 'E' : 'W';
+ float lon = (lon_ >= 0) ? lon_ : -lon_;
+ uint32_t lon_d = (uint32_t)lon;
+ uint32_t lon_m = (uint32_t)((lon - lon_d) * 6000.0f + 0.5f);
+
+ if (lon_m >= 6000) {
+ lon_m = 0;
+ lon_d++;
+ }
+
+ s += to_string_dec_uint(lon_d, 3, '0');
+ s += to_string_dec_uint(lon_m / 100, 2, '0');
+ s += ".";
+ s += to_string_dec_uint(lon_m % 100, 2, '0');
+ s += ew;
+ text_gps_coord.set(s);
+}
+
+void APRSTXView::on_gps(const GPSPosDataMessage* message) {
+ if (manual_gps_mode) {
+ return; // no more update once set manually
+ }
+ if (message->lat < -90.0 || message->lat > 90.0 ||
+ message->lon < -180.0 || message->lon > 180.0) {
+ return;
+ }
+ if (message->lat == 0.0f && message->lon == 0.0f) {
+ return;
+ }
+ last_lat = message->lat;
+ last_lon = message->lon;
+ process_coordinates(message->lat, message->lon);
+}
+
APRSTXView::APRSTXView(NavigationView& nav) {
baseband::run_image(portapack::spi_flash::image_tag_afsk);
@@ -87,8 +157,33 @@ APRSTXView::APRSTXView(NavigationView& nav) {
&num_ssid_dest,
&text_payload,
&button_set,
+ &text_gps_coord,
+ &button_mangps,
+ &gps_is_manual,
+ &text_path,
+ &button_setpath,
&tx_view});
+ sym_source.set_value(symsrc);
+ num_ssid_source.set_value(ssidsrc);
+ sym_dest.set_value(symdst);
+ num_ssid_dest.set_value(ssiddst);
+ text_payload.set(payload);
+ text_path.set(path_cache);
+
+ sym_source.on_change = [this](SymField&) {
+ symsrc = sym_source.to_string();
+ };
+ num_ssid_source.on_change = [this](int32_t v) {
+ ssidsrc = v;
+ };
+ sym_dest.on_change = [this](SymField&) {
+ symdst = sym_dest.to_string();
+ };
+ num_ssid_dest.on_change = [this](int32_t v) {
+ ssiddst = v;
+ };
+
button_set.on_select = [this, &nav](Button&) {
text_prompt(
nav,
@@ -99,6 +194,17 @@ APRSTXView::APRSTXView(NavigationView& nav) {
text_payload.set(s);
});
};
+ button_setpath.on_select = [this, &nav](Button&) {
+ text_prompt(
+ nav,
+ path_cache,
+ 60,
+ ENTER_KEYBOARD_MODE_ALPHA,
+ [this](std::string& s) {
+ text_path.set(s);
+ path_cache = s;
+ });
+ };
tx_view.on_edit_frequency = [this, &nav]() {
auto new_view = nav.push(transmitter_model.target_frequency());
@@ -116,6 +222,23 @@ APRSTXView::APRSTXView(NavigationView& nav) {
tx_view.set_transmitting(false);
transmitter_model.disable();
};
+
+ button_mangps.on_select = [this, &nav](Button&) {
+ nav.push(
+ 0,
+ GeoPos::alt_unit::METERS,
+ GeoPos::spd_unit::HIDDEN,
+ last_lat,
+ last_lon,
+ [this](int32_t, float lat, float lon, int32_t) {
+ last_lat = lat;
+ last_lon = lon;
+ manual_gps_mode = true;
+ gps_is_manual.set("MANUAL");
+ process_coordinates(lat, lon);
+ });
+ };
+ // process_coordinates(last_lat, last_lon); //don't load last, so won't confuse users
}
} /* namespace ui */
diff --git a/firmware/application/apps/ui_aprs_tx.hpp b/firmware/application/apps/ui_aprs_tx.hpp
index 18f499441..769a0bcf8 100644
--- a/firmware/application/apps/ui_aprs_tx.hpp
+++ b/firmware/application/apps/ui_aprs_tx.hpp
@@ -50,59 +50,109 @@ class APRSTXView : public View {
1750000 /* bandwidth */,
AFSK_TX_SAMPLERATE /* sampling rate */
};
- app_settings::SettingsManager settings_{
- "tx_aprs", app_settings::Mode::TX};
+ std::string symsrc = "";
+ std::string symdst = "";
std::string payload{""};
+ std::string path_cache = "";
+ int32_t ssidsrc = 0;
+ int32_t ssiddst = 0;
+
+ bool manual_gps_mode = false;
+ float last_lat = 0.0f;
+ float last_lon = 0.0f;
+
+ app_settings::SettingsManager settings_{
+ "tx_aprs",
+ app_settings::Mode::TX,
+ {{"symsrc"sv, &symsrc},
+ {"symdst"sv, &symdst},
+ {"ssidsrc"sv, &ssidsrc},
+ {"ssiddst"sv, &ssiddst},
+ {"payload"sv, &payload},
+ {"last_lat"sv, &last_lat},
+ {"last_lon"sv, &last_lon},
+ {"path"sv, &path_cache}}};
void start_tx();
void generate_frame();
void generate_frame_pos();
void on_tx_progress(const uint32_t progress, const bool done);
+ void on_gps(const GPSPosDataMessage* message);
+ void process_coordinates(float lat, float lon);
Labels labels{
- {{UI_POS_X(0), 1 * 16}, "Source: SSID:", Theme::getInstance()->fg_light->foreground}, // 6 alphanum + SSID
- {{UI_POS_X(0), 2 * 16}, " Dest.: SSID:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 4 * 16}, "Info field:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(0)}, "Source: SSID:", Theme::getInstance()->fg_light->foreground}, // 6 alphanum + SSID
+ {{UI_POS_X(0), UI_POS_Y(1)}, " Dest.: SSID:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(2)}, "Path: (ex.: WIDE1-1,WIDE2-1)", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(6)}, "Info field:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(10)}, "GPS:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X_CENTER(22), UI_POS_Y(14)}, "Use ?GPS? in the info", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X_CENTER(17), UI_POS_Y(15)}, "as a placeholder.", Theme::getInstance()->fg_light->foreground},
+ };
+
+ Text text_path{
+ {UI_POS_X(0), UI_POS_Y(3), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)},
+ "WIDE1-1",
+ };
+ Text gps_is_manual{
+ {UI_POS_X(6), UI_POS_Y(10), UI_POS_WIDTH(10), UI_POS_HEIGHT(1)},
+ "",
};
SymField sym_source{
- {7 * 8, 1 * 16},
+ {UI_POS_X(7), UI_POS_Y(0)},
6,
SymField::Type::Alpha};
NumberField num_ssid_source{
- {19 * 8, 1 * 16},
+ {UI_POS_X(19), UI_POS_Y(0)},
2,
{0, 15},
1,
' '};
SymField sym_dest{
- {7 * 8, 2 * 16},
+ {UI_POS_X(7), UI_POS_Y(1)},
6,
SymField::Type::Alpha};
NumberField num_ssid_dest{
- {19 * 8, 2 * 16},
+ {UI_POS_X(19), UI_POS_Y(1)},
2,
{0, 15},
1,
' '};
Text text_payload{
- {UI_POS_X(0), 5 * 16, screen_width, 16},
- "-"};
+ {UI_POS_X(0), UI_POS_Y(7), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)},
+ ""};
Button button_set{
- {UI_POS_X(0), 6 * 16, 80, 32},
+ {UI_POS_X(0), UI_POS_Y(8), UI_POS_WIDTH(10), UI_POS_HEIGHT(2)},
"Set"};
+ Text text_gps_coord{
+ {UI_POS_X(0), UI_POS_Y(11), UI_POS_WIDTH(20), UI_POS_HEIGHT(1)},
+ "-"};
+
+ Button button_mangps{
+ {UI_POS_X(0), UI_POS_Y(12), UI_POS_WIDTH(14), UI_POS_HEIGHT(2)},
+ "Manual GPS"};
+ Button button_setpath{
+ {UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH(14), UI_POS_HEIGHT(2)},
+ "Set Path"};
TransmitterView tx_view{
(int16_t)UI_POS_Y_BOTTOM(4),
5000,
0 // disable setting bandwith, since APRS used fixed 10k bandwidth
};
+ MessageHandlerRegistration message_handler_gps{
+ Message::ID::GPSPosData,
+ [this](Message* const p) {
+ const auto message = static_cast(p);
+ this->on_gps(message);
+ }};
MessageHandlerRegistration message_handler_tx_progress{
Message::ID::TXProgress,
[this](const Message* const p) {
diff --git a/firmware/application/apps/ui_battinfo.hpp b/firmware/application/apps/ui_battinfo.hpp
index 062f163f0..5357f2578 100644
--- a/firmware/application/apps/ui_battinfo.hpp
+++ b/firmware/application/apps/ui_battinfo.hpp
@@ -98,7 +98,7 @@ class BattinfoView : public View {
"Volt"};
Button button_exit{
- {UI_POS_X_CENTER(12), 17 * 16, UI_POS_WIDTH(12), UI_POS_HEIGHT(3)},
+ {UI_POS_X_CENTER(12), UI_POS_Y_BOTTOM(4), UI_POS_WIDTH(12), UI_POS_HEIGHT(2)},
"Back"};
static msg_t static_fn(void* arg);
Thread* thread{nullptr};
diff --git a/firmware/application/apps/ui_debug.cpp b/firmware/application/apps/ui_debug.cpp
index b12ce79f0..b3dae22a4 100644
--- a/firmware/application/apps/ui_debug.cpp
+++ b/firmware/application/apps/ui_debug.cpp
@@ -25,6 +25,7 @@
#include "debug.hpp"
#include "ch.h"
+#include "hal.h"
#include "radio.hpp"
#include "string_format.hpp"
@@ -45,6 +46,11 @@ using namespace portapack;
#include "irq_controls.hpp"
+#ifdef PRALINE
+#include "max2831.hpp"
+using namespace max2831;
+#endif
+
namespace ui {
/* DebugMemoryView *******************************************************/
@@ -148,6 +154,12 @@ uint32_t RegistersWidget::reg_read(const uint32_t register_number) {
}
case CT_AUDIO:
return audio::debug::reg_read(register_number);
+#ifdef PRALINE
+ case CT_FPGA:
+ return radio::debug::fpga::register_read(register_number);
+#endif
+ case CT_SGPIO:
+ return radio::debug::sgpio::register_read(register_number);
}
}
return 0xFFFF;
@@ -175,6 +187,14 @@ void RegistersWidget::reg_write(const uint32_t register_number, const uint32_t v
case CT_AUDIO:
audio::debug::reg_write(register_number, value);
break;
+#ifdef PRALINE
+ case CT_FPGA:
+ radio::debug::fpga::register_write(register_number, value);
+ break;
+#endif
+ case CT_SGPIO:
+ // SGPIO registers are read-only for debug purposes
+ break;
}
}
}
@@ -369,6 +389,3041 @@ void DebugControlsView::focus() {
switches_widget.focus();
}
+#ifdef PRALINE
+/* RadioDiagnosticsView **************************************************/
+
+RadioDiagnosticsView::RadioDiagnosticsView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &text_title,
+ &text_lbl_rffc,
+ &text_rffc_status,
+ &text_lbl_max,
+ &text_max_status,
+ &text_lbl_adc,
+ &text_adc_status,
+ &text_lbl_fpga,
+ &text_fpga_status,
+ &text_lbl_sgpio,
+ &text_sgpio_status,
+ &text_lbl_clock,
+ &text_clock_status,
+ &text_regs_title,
+ &text_lbl_rffc_reg,
+ &text_rffc_reg,
+ &text_lbl_max_reg,
+ &text_max_reg,
+ &text_lbl_fpga_reg,
+ &text_fpga_reg,
+ &text_lbl_sgpio_reg,
+ &text_sgpio_reg,
+ &text_test_result,
+ &button_refresh,
+ &button_done,
+ });
+
+ // Set title colors
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+ text_regs_title.set_style(Theme::getInstance()->fg_yellow);
+
+#ifdef PRALINE
+ text_lbl_fpga.set("FPGA (iCE40):");
+#else
+ text_lbl_fpga.set("CPLD:");
+#endif
+
+ button_refresh.on_select = [this](Button&) {
+ update_status();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ // Initial status update
+ update_status();
+}
+
+void RadioDiagnosticsView::focus() {
+ button_refresh.focus();
+}
+
+void RadioDiagnosticsView::update_status() {
+ // Read RFFC5072 register 0 to check if it responds
+ uint32_t rffc_reg0 = radio::debug::first_if::register_read(0);
+ bool rffc_ok = (rffc_reg0 != 0x0000) && (rffc_reg0 != 0xFFFF);
+ text_rffc_status.set(rffc_ok ? "OK" : "FAIL");
+ text_rffc_status.set_style(rffc_ok ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ text_rffc_reg.set(to_string_hex(rffc_reg0, 4));
+
+ // Read MAX283x register 0 to check if it responds
+ uint32_t max_reg0 = radio::debug::second_if::register_read(0);
+ bool max_ok = (max_reg0 != 0x0000) && (max_reg0 != 0x3FFF);
+ text_max_status.set(max_ok ? "OK" : "FAIL");
+ text_max_status.set_style(max_ok ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ text_max_reg.set(to_string_hex(max_reg0, 4));
+
+ // MAX5864 has no readback - assume OK if other SPI works
+ text_adc_status.set("(no readback)");
+ text_adc_status.set_style(Theme::getInstance()->fg_medium);
+
+#ifdef PRALINE
+ // Read FPGA control register
+ uint32_t fpga_ctrl = radio::debug::fpga::register_read(1);
+ bool fpga_ok = (fpga_ctrl != 0xFF); // 0xFF = not responding
+ bool dc_block = (fpga_ctrl & 0x01) != 0;
+ text_fpga_status.set(fpga_ok ? (dc_block ? "OK DC_BLK" : "OK NO_DC") : "FAIL");
+ text_fpga_status.set_style(fpga_ok ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // Show all FPGA registers
+ uint32_t fpga_r2 = radio::debug::fpga::register_read(2);
+ uint32_t fpga_r3 = radio::debug::fpga::register_read(3);
+ text_fpga_reg.set("C:" + to_string_hex(fpga_ctrl, 2) +
+ " D:" + to_string_hex(fpga_r2, 2) +
+ " T:" + to_string_hex(fpga_r3, 2));
+#else
+ text_fpga_status.set("(CPLD)");
+ text_fpga_status.set_style(Theme::getInstance()->fg_medium);
+ text_fpga_reg.set("N/A");
+#endif
+
+ // Check SGPIO status
+ uint32_t sgpio_enable = radio::debug::sgpio::register_read(0); // CTRL_ENABLE
+ uint32_t sgpio_status = radio::debug::sgpio::register_read(4); // STATUS_1
+ bool sgpio_ok = (sgpio_enable != 0);
+ text_sgpio_status.set(sgpio_ok ? "ENABLED" : "DISABLED");
+ text_sgpio_status.set_style(sgpio_ok ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_orange);
+ text_sgpio_reg.set("EN:" + to_string_hex(sgpio_enable, 4) +
+ " ST:" + to_string_hex(sgpio_status, 4));
+
+ // Clock status - check if Si5351 is configured
+ // We can't easily read back clock status, so just show assumed state
+ text_clock_status.set("(assumed OK)");
+ text_clock_status.set_style(Theme::getInstance()->fg_medium);
+
+ // Summary
+ bool all_ok = rffc_ok && max_ok;
+#ifdef PRALINE
+ all_ok = all_ok && fpga_ok;
+#endif
+ if (all_ok) {
+ text_test_result.set("Peripherals responding. Try RX app.");
+ text_test_result.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_test_result.set("Check failed peripherals above.");
+ text_test_result.set_style(Theme::getInstance()->fg_red);
+ }
+}
+
+#ifdef PRALINE
+PralineRadioDebugView::PralineRadioDebugView(NavigationView& nav) {
+ add_children({&text_title, &text_lbl_lock, &text_lock_status,
+ &text_lbl_clk5, &text_clk5_status,
+ &text_lbl_spi, &text_spi_status, &text_lbl_fpga_ctrl, &text_fpga_ctrl,
+ &text_lbl_vaa, &text_vaa_status, &text_status_msg,
+ &button_refresh, &button_toggle_clk5, &button_done});
+
+ button_refresh.on_select = [this](Button&) { this->refresh(); };
+ button_toggle_clk5.on_select = [this](Button&) { this->toggle_clk5(); };
+ button_done.on_select = [&nav](Button&) { nav.pop(); };
+
+ refresh();
+}
+
+void PralineRadioDebugView::focus() {
+ button_refresh.focus();
+}
+
+void PralineRadioDebugView::toggle_clk5() {
+ // Si5351 Register 3 is the Output Enable mask. Bit 5 = CLK5.
+ // 0 = Enabled, 1 = Disabled.
+ uint8_t reg3 = portapack::clock_manager.si5351_read_register(3);
+ reg3 ^= 0x20; // Toggle Bit 5 (CLK5)
+ portapack::clock_manager.si5351_write_register(3, reg3);
+ refresh();
+}
+
+void PralineRadioDebugView::refresh() {
+ // 1. Check Mixer Lock Detect (GPIO6[25] / PD_11) [cite: 16, 17]
+ uint32_t gpio6_state = LPC_GPIO->PIN[6];
+ bool locked = (gpio6_state >> 25) & 1;
+ text_lock_status.set(locked ? "LOCKED (OK)" : "UNLOCKED!");
+ text_lock_status.set_style(locked ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // 2. Check Si5351 CLK5 Status (Reg 3, Bit 5) - 0 = ON, 1 = OFF
+ uint8_t si_reg3 = portapack::clock_manager.si5351_read_register(3);
+ bool clk5_on = !(si_reg3 & 0x20);
+ text_clk5_status.set(clk5_on ? "ON (40MHz)" : "OFF");
+ text_clk5_status.set_style(clk5_on ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // 3. Check SPI Bit Mode (SSP1 CR0)
+ // DSS (Data Size Select) is Bit 3:0. 0x8 = 9-bit (MAX2831), 0xF = 16-bit (Classic)
+ uint32_t cr0 = LPC_SSP1->CR0;
+ uint8_t dss = cr0 & 0x0F;
+ if (dss == 0x08)
+ text_spi_status.set("9-Bit (Pro)");
+ else
+ text_spi_status.set("Other (Err)");
+ text_spi_status.set_style((dss == 0x08) ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // 4. Check FPGA Register 1 (DC Block status) [cite: 12, 13]
+ uint8_t fpga_r1 = radio::debug::fpga::register_read(1);
+ text_fpga_ctrl.set(to_string_hex(fpga_r1, 2));
+
+ // 5. Check VAA RF Power (GPIO4[1] / P8_1) - Active LOW [cite: 16, 17]
+ uint32_t gpio4_state = LPC_GPIO->PIN[4];
+ bool vaa_on = !((gpio4_state >> 1) & 1);
+ text_vaa_status.set(vaa_on ? "ON" : "OFF");
+ text_vaa_status.set_style(vaa_on ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // Diagnostic Summary
+ if (!locked && clk5_on && vaa_on) {
+ text_status_msg.set("Clock/Pwr OK. PLL not locked. Check tuning registers.");
+ text_status_msg.set_style(Theme::getInstance()->fg_red);
+ } else if (!clk5_on) {
+ text_status_msg.set("Mixer Clock is OFF. PLL cannot lock.");
+ text_status_msg.set_style(Theme::getInstance()->fg_red);
+ } else {
+ text_status_msg.set("Hardware Link Active.");
+ text_status_msg.set_style(Theme::getInstance()->fg_green);
+ }
+}
+#endif
+
+#ifdef PRALINE
+/* WFMAudioDebugView *************************************************/
+
+WFMAudioDebugView::WFMAudioDebugView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &text_title,
+ &text_lbl_clk0,
+ &text_clk0,
+ &text_lbl_fpga_dec,
+ &text_fpga_dec,
+ &text_lbl_post_fpga,
+ &text_post_fpga,
+ &text_section1,
+ &text_lbl_reg8,
+ &text_reg8,
+ &text_lbl_lpf_bw,
+ &text_lpf_bw,
+ &text_section2,
+ &text_lbl_fpga_r1,
+ &text_fpga_r1,
+ &text_lbl_dc_q,
+ &text_dc_q,
+ &text_section3,
+ &text_lbl_expected,
+ &text_expected,
+ &text_lbl_deemph,
+ &text_deemph,
+ &text_status,
+ &text_status2,
+ &button_refresh,
+ &button_toggle_q,
+ &button_done,
+ });
+
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+ text_section1.set_style(Theme::getInstance()->fg_yellow);
+ text_section2.set_style(Theme::getInstance()->fg_yellow);
+ text_section3.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) {
+ refresh();
+ };
+
+ button_toggle_q.on_select = [this](Button&) {
+ uint32_t current = radio::debug::fpga::register_read(1);
+ uint8_t new_val = current ^ 0x02; // Toggle Q_INVERT bit
+ radio::debug::fpga::register_write(1, new_val);
+ radio::invalidate_spi_config();
+ refresh();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ refresh();
+}
+
+void WFMAudioDebugView::focus() {
+ button_refresh.focus();
+}
+
+void WFMAudioDebugView::refresh() {
+ // === Si5351 CLK0 Sample Rate ===
+ // Read MS0 parameters to calculate frequency
+ uint8_t reg44 = portapack::clock_manager.si5351_read_register(44);
+ uint8_t reg45 = portapack::clock_manager.si5351_read_register(45);
+ uint8_t reg46 = portapack::clock_manager.si5351_read_register(46);
+
+ uint8_t r_div_encoded = (reg44 >> 4) & 0x07;
+ uint32_t r_div = 1 << r_div_encoded;
+ uint32_t p1 = ((uint32_t)(reg44 & 0x03) << 16) | ((uint32_t)reg45 << 8) | reg46;
+ uint32_t ms_div = (p1 + 512) / 128;
+
+ // PLL A is 800 MHz
+ uint32_t clk0_khz = 800000 / ms_div / r_div;
+ uint32_t clk0_mhz_int = clk0_khz / 1000;
+ uint32_t clk0_khz_frac = clk0_khz % 1000;
+
+ text_clk0.set(to_string_dec_uint(clk0_mhz_int) + "." +
+ to_string_dec_uint(clk0_khz_frac / 100) +
+ to_string_dec_uint((clk0_khz_frac / 10) % 10) +
+ to_string_dec_uint(clk0_khz_frac % 10) + " MHz");
+
+ if (clk0_khz >= 3000 && clk0_khz <= 3200) {
+ text_clk0.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_clk0.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // === FPGA Decimation ===
+ uint8_t fpga_decim = radio::debug::fpga::register_read(2);
+ uint32_t fpga_div = 1 << fpga_decim;
+ text_fpga_dec.set("/" + to_string_dec_uint(fpga_div) + " (n=" + to_string_dec_uint(fpga_decim) + ")");
+
+ // === Post-FPGA Rate ===
+ uint32_t post_fpga_khz = clk0_khz / fpga_div;
+ text_post_fpga.set(to_string_dec_uint(post_fpga_khz) + " kHz");
+
+ // For WFM, post-FPGA should be >= 384 kHz for proper audio decimation
+ if (post_fpga_khz >= 384) {
+ text_post_fpga.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_post_fpga.set_style(Theme::getInstance()->fg_orange);
+ }
+
+ // === MAX2831 LPF ===
+ uint32_t reg8 = radio::debug::second_if::register_read(8);
+ text_reg8.set("0x" + to_string_hex(reg8, 4));
+
+ uint8_t lpf_coarse = reg8 & 0x03;
+ const char* lpf_names[] = {"7.5 MHz", "8.5 MHz", "15 MHz", "18 MHz"};
+ text_lpf_bw.set(lpf_names[lpf_coarse]);
+
+ // 7.5 MHz is minimum, OK for mono WFM but tight for stereo
+ if (lpf_coarse >= 1) {
+ text_lpf_bw.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_lpf_bw.set_style(Theme::getInstance()->fg_orange);
+ }
+
+ // === FPGA Control Register ===
+ uint32_t fpga_ctrl = radio::debug::fpga::register_read(1);
+ text_fpga_r1.set("0x" + to_string_hex(fpga_ctrl, 2));
+
+ bool dc_block = fpga_ctrl & 0x01;
+ bool q_invert = fpga_ctrl & 0x02;
+ uint8_t quarter_shift = (fpga_ctrl >> 2) & 0x03;
+
+ text_dc_q.set(std::string(dc_block ? "DC:ON" : "DC:OFF") +
+ " Q:" + std::string(q_invert ? "INV" : "NOR") +
+ " QS:" + to_string_dec_uint(quarter_shift));
+
+ if (dc_block) {
+ text_dc_q.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_dc_q.set_style(Theme::getInstance()->fg_orange);
+ }
+
+ // === Expected Audio Rate ===
+ // WFM typically: 3072 kHz / 64 = 48 kHz audio
+ // Or: 3072 kHz β /8 (channel) β 384 kHz β /8 (audio) β 48 kHz
+ uint32_t expected_audio = post_fpga_khz / 64; // Simplified assumption
+ text_expected.set(to_string_dec_uint(expected_audio) + " kHz (est)");
+
+ if (expected_audio >= 44 && expected_audio <= 50) {
+ text_expected.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_expected.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // === De-emphasis Status ===
+ // We can't directly read the M4 de-emphasis config, but we can indicate what SHOULD be set
+ // 75Β΅s for USA, 50Β΅s for Europe
+ text_deemph.set("Chk M4 cfg.");
+ text_deemph.set_style(Theme::getInstance()->fg_orange);
+
+ // === Status Summary ===
+ bool sample_rate_ok = (clk0_khz >= 3000 && clk0_khz <= 3200);
+ bool lpf_ok = (lpf_coarse <= 0x0F); // check against the 4-bit max
+ bool dc_ok = dc_block;
+
+ if (sample_rate_ok && lpf_ok && dc_ok) {
+ text_status.set("Hardware config looks OK.");
+ text_status.set_style(Theme::getInstance()->fg_green);
+ text_status2.set("If ringy: Chk d-emph in M4!");
+ text_status2.set_style(Theme::getInstance()->fg_orange);
+ } else {
+ std::string issues = "Issues: ";
+ if (!sample_rate_ok) issues += "SampleRate ";
+ if (!lpf_ok) issues += "LPF ";
+ if (!dc_ok) issues += "DC_Block ";
+ text_status.set(issues);
+ text_status.set_style(Theme::getInstance()->fg_red);
+ text_status2.set("Fix before checking audio.");
+ text_status2.set_style(Theme::getInstance()->fg_red);
+ }
+}
+#endif
+
+/* BasebandStatusView ******************************************************/
+
+BasebandStatusView::BasebandStatusView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &text_title,
+ &text_lbl_marker,
+ &text_marker,
+ &text_lbl_loops,
+ &text_loops,
+ &text_lbl_wait,
+ &text_wait,
+ &text_lbl_xfr,
+ &text_xfr,
+ &text_lbl_missed,
+ &text_missed,
+ &text_status_line1,
+ &text_status_line2,
+ &text_status_line3,
+ &button_refresh,
+ &button_done,
+ });
+
+ // Set title color
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) {
+ update();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ // Initial update
+ update();
+}
+
+void BasebandStatusView::focus() {
+ button_refresh.focus();
+}
+
+void BasebandStatusView::update() {
+ // Read counters from shared memory
+ uint8_t marker = shared_memory.m4_streaming_marker;
+ uint32_t loops = shared_memory.m4_baseband_loops;
+ uint32_t wait = shared_memory.m4_dma_wait_count;
+ uint32_t xfr = shared_memory.m4_dma_xfr_count;
+ uint16_t missed = shared_memory.m4_buffer_missed;
+
+ // Display counter values
+ text_marker.set(to_string_hex(marker, 2));
+ text_marker.set_style((marker == 0xAA) ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ text_loops.set(to_string_dec_uint(loops));
+ text_wait.set(to_string_dec_uint(wait));
+ text_xfr.set(to_string_dec_uint(xfr));
+ text_missed.set(to_string_dec_uint(missed));
+
+ // Status interpretation
+ if (marker == 0x00) {
+ text_status_line1.set("Thread NOT started!");
+ text_status_line2.set("M4 baseband crash.");
+ text_status_line3.set("Check thread race condition.");
+ text_status_line1.set_style(Theme::getInstance()->fg_red);
+ text_status_line2.set_style(Theme::getInstance()->fg_red);
+ text_status_line3.set_style(Theme::getInstance()->fg_red);
+ } else if (marker == 0xAA && loops == 0) {
+ text_status_line1.set("Thread started but");
+ text_status_line2.set("not looping yet.");
+ text_status_line3.set("Wait a moment...");
+ text_status_line1.set_style(Theme::getInstance()->fg_orange);
+ text_status_line2.set_style(Theme::getInstance()->fg_orange);
+ text_status_line3.set_style(Theme::getInstance()->fg_orange);
+ } else if (marker == 0xAA && xfr == 0) {
+ text_status_line1.set("Thread looping " + to_string_dec_uint(loops) + "x");
+ text_status_line2.set("But DMA NOT firing!");
+ text_status_line3.set("Check SGPIO14 enable.");
+ text_status_line1.set_style(Theme::getInstance()->fg_orange);
+ text_status_line2.set_style(Theme::getInstance()->fg_orange);
+ text_status_line3.set_style(Theme::getInstance()->fg_orange);
+ } else if (xfr > 0) {
+ text_status_line1.set("DMA WORKING!");
+ text_status_line2.set("Xfr: " + to_string_dec_uint(xfr));
+ text_status_line3.set("Data flowing to baseband.");
+ text_status_line1.set_style(Theme::getInstance()->fg_green);
+ text_status_line2.set_style(Theme::getInstance()->fg_green);
+ text_status_line3.set_style(Theme::getInstance()->fg_green);
+ }
+}
+
+/* SGPIOLiveMonitorView ****************************************************/
+
+SGPIOLiveMonitorView::SGPIOLiveMonitorView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &text_title,
+ &text_lbl_ctrl,
+ &text_ctrl,
+ &text_lbl_in,
+ &text_in,
+ &text_lbl_ss,
+ &text_ss,
+ &text_lbl_status,
+ &text_status,
+ &text_lbl_out,
+ &text_out,
+ &text_lbl_oen,
+ &text_oen,
+ &text_diag_line1,
+ &text_diag_line2,
+ &text_diag_line3,
+ &text_diag_line4,
+ &button_refresh,
+ &button_done,
+ });
+
+ // Set title color
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) {
+ update();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ // Initial update
+ update();
+}
+
+void SGPIOLiveMonitorView::focus() {
+ button_refresh.focus();
+}
+
+void SGPIOLiveMonitorView::update() {
+ // Read SGPIO registers via radio debug namespace
+ uint32_t ctrl = radio::debug::sgpio::register_read(0); // CTRL_ENABLE
+ uint32_t in_reg = radio::debug::sgpio::register_read(1); // GPIO_INREG
+ uint32_t status = radio::debug::sgpio::register_read(4); // STATUS_1
+
+ // Read registers directly from LPC_SGPIO peripheral
+ uint32_t reg_ss = LPC_SGPIO->REG_SS[0];
+ uint32_t out_reg = LPC_SGPIO->GPIO_OUTREG;
+ uint32_t oen_reg = LPC_SGPIO->GPIO_OENREG;
+
+ // Display register values
+ text_ctrl.set(to_string_hex(ctrl, 4));
+ text_in.set(to_string_hex(in_reg, 8));
+ text_ss.set(to_string_hex(reg_ss, 8));
+ text_status.set(to_string_hex(status, 4));
+ text_out.set(to_string_hex(out_reg, 4));
+ text_oen.set(to_string_hex(oen_reg, 4));
+
+ // Diagnostics based on register values
+ bool gpio_changing = (in_reg & 0xFF) != 0; // Check data pins
+ bool regss_active = (reg_ss != 0);
+ bool disable_high = (out_reg & (1U << 10)) != 0; // Bit 10 = DISABLE signal
+ bool sgpio8_high = (in_reg & (1U << 8)) != 0; // Bit 8 = SGPIO8 clock
+ bool sgpio8_output = (oen_reg & (1U << 8)) != 0; // Bit 8 = SGPIO8 direction (should be INPUT=0)
+
+ // Line 1: SGPIO8 direction check (CRITICAL - must be INPUT)
+ if (sgpio8_output) {
+ text_diag_line1.set("SGPIO8 OUTPUT! (bus conflict)");
+ text_diag_line1.set_style(Theme::getInstance()->fg_red);
+ } else if (disable_high) {
+ text_diag_line1.set("DISABLE=HIGH! FPGA stopped!");
+ text_diag_line1.set_style(Theme::getInstance()->fg_red);
+ } else {
+ text_diag_line1.set("SGPIO8=IN, DISABLE=LOW");
+ text_diag_line1.set_style(Theme::getInstance()->fg_green);
+ }
+
+ // Line 2: Clock signal status (snapshot - can't detect toggling)
+ if (disable_high) {
+ text_diag_line2.set("Clock N/A (FPGA disabled)");
+ text_diag_line2.set_style(Theme::getInstance()->fg_medium);
+ } else if (sgpio8_high) {
+ text_diag_line2.set("SGPIO8=HIGH (snapshot)");
+ text_diag_line2.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_diag_line2.set("SGPIO8=LOW (snapshot)");
+ text_diag_line2.set_style(Theme::getInstance()->fg_green);
+ }
+
+ // Line 3: REG_SS[0] capture status
+ if (!regss_active && !disable_high) {
+ text_diag_line3.set("REG_SS[0]=0 (NOT CAPTURING!)");
+ text_diag_line3.set_style(Theme::getInstance()->fg_red);
+ } else if (regss_active) {
+ text_diag_line3.set("REG_SS[0] has data");
+ text_diag_line3.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_diag_line3.set("Capture N/A (FPGA disabled)");
+ text_diag_line3.set_style(Theme::getInstance()->fg_medium);
+ }
+
+ // Line 4: Summary based on key indicators
+ if (sgpio8_output) {
+ text_diag_line4.set("FIX: Set SGPIO8 to INPUT!");
+ text_diag_line4.set_style(Theme::getInstance()->fg_red);
+ } else if (disable_high) {
+ text_diag_line4.set("FIX: Clear DISABLE bit!");
+ text_diag_line4.set_style(Theme::getInstance()->fg_red);
+ } else if (regss_active && gpio_changing) {
+ text_diag_line4.set("SGPIO capturing data");
+ text_diag_line4.set_style(Theme::getInstance()->fg_green);
+ } else if (!regss_active && gpio_changing) {
+ text_diag_line4.set("Data present, check slices");
+ text_diag_line4.set_style(Theme::getInstance()->fg_orange);
+ } else if (!regss_active) {
+ text_diag_line4.set("No data activity");
+ text_diag_line4.set_style(Theme::getInstance()->fg_orange);
+ } else {
+ text_diag_line4.set("Check DMA config");
+ text_diag_line4.set_style(Theme::getInstance()->fg_green);
+ }
+}
+
+/* RadioRxTestView ********************************************************/
+
+RadioRxTestView::RadioRxTestView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &labels,
+ &console,
+ &button_init,
+ &button_rx,
+ &button_freq,
+ &button_sgpio,
+ &button_full,
+ &button_step,
+ &button_done,
+ });
+
+ button_init.on_select = [this](Button&) {
+ run_init_test();
+ };
+
+ button_rx.on_select = [this](Button&) {
+ run_rx_mode_test();
+ };
+
+ button_freq.on_select = [this](Button&) {
+ run_freq_test();
+ };
+
+ button_sgpio.on_select = [this](Button&) {
+ run_sgpio_test();
+ };
+
+ button_full.on_select = [this](Button&) {
+ run_full_test();
+ };
+
+ button_step.on_select = [this](Button&) {
+ run_step_test();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ log("Ready. Press buttons to test.");
+ log("Init->RX->Freq->SGPIO");
+}
+
+void RadioRxTestView::focus() {
+ button_full.focus();
+}
+
+void RadioRxTestView::log(const std::string& msg) {
+ console.writeln(msg);
+}
+
+void RadioRxTestView::log_registers(const std::string& label) {
+ // RFFC5072 register 0
+ uint32_t rffc_r0 = radio::debug::first_if::register_read(0);
+
+ // MAX283x registers 0, 3, 4 (key freq regs)
+ uint32_t max_r0 = radio::debug::second_if::register_read(0);
+ uint32_t max_r3 = radio::debug::second_if::register_read(3);
+ uint32_t max_r4 = radio::debug::second_if::register_read(4);
+
+ // SGPIO
+ uint32_t sgpio_en = radio::debug::sgpio::register_read(0);
+ uint32_t sgpio_data = radio::debug::sgpio::register_read(5);
+
+ log(label);
+ log(" RFFC:" + to_string_hex(rffc_r0, 4));
+ log(" MAX r0:" + to_string_hex(max_r0, 4) +
+ " r3:" + to_string_hex(max_r3, 4) +
+ " r4:" + to_string_hex(max_r4, 4));
+ log(" SGPIO en:" + to_string_hex(sgpio_en, 4) +
+ " dat:" + to_string_hex(sgpio_data, 8));
+
+#ifdef PRALINE
+ uint32_t fpga_ctrl = radio::debug::fpga::register_read(1);
+ log(" FPGA ctrl:" + to_string_hex(fpga_ctrl, 2));
+#endif
+}
+
+void RadioRxTestView::run_init_test() {
+ console.clear(true);
+ log("=== INIT TEST ===");
+
+ log("radio::init()...");
+ radio::init();
+ radio_initialized_ = true;
+
+ log("set_baseband_rate(8M)...");
+ radio::set_baseband_rate(8000000);
+
+ // Read Si5351 status to check PLL lock
+ uint8_t si_status = portapack::clock_manager.si5351_read_status();
+ log("Si5351 status: " + to_string_hex(si_status, 2));
+ if (si_status & 0x20) {
+ log(" WARNING: PLL A unlocked!");
+ } else {
+ log(" PLL A locked OK");
+ }
+
+ // Read crystal cap register
+ uint8_t xtal_cap = portapack::clock_manager.si5351_read_register(183);
+ log("Crystal cap: " + to_string_hex(xtal_cap, 2));
+
+ log_registers("[After init]");
+ log("Init+clocks done.");
+}
+
+void RadioRxTestView::run_rx_mode_test() {
+ console.clear(true);
+ log("=== RX MODE TEST ===");
+
+ if (!radio_initialized_) {
+ log("ERROR: Run Init first!");
+ return;
+ }
+
+ log_registers("[Before RX mode]");
+
+ log("Calling set_direction(Receive)...");
+ radio::set_direction(rf::Direction::Receive);
+
+ log_registers("[After RX mode]");
+
+ // Check MAX283x mode register
+ uint32_t max_r0 = radio::debug::second_if::register_read(0);
+ log("MAX r0 after RX: " + to_string_hex(max_r0, 4));
+ log("RX mode set.");
+}
+
+void RadioRxTestView::run_freq_test() {
+ console.clear(true);
+ log("=== FREQ TEST ===");
+
+ if (!radio_initialized_) {
+ log("ERROR: Run Init first!");
+ return;
+ }
+
+ log_registers("[Before freq set]");
+
+ log("Setting " + to_string_dec_uint(test_frequency_ / 1000000) + " MHz...");
+ bool result = radio::set_tuning_frequency(test_frequency_);
+
+ log_registers("[After freq set]");
+
+ log(result ? "Freq set OK" : "Freq set FAILED");
+
+ // Show expected vs actual for MAX2831 freq regs
+ // For 433 MHz with MAX2831: F_LO = 40M * (N + F/2^20) / 2
+ // N = 43, F = ~629146 for ~433 MHz
+ log("(Expected: N~43 in r3, F_hi in r4)");
+}
+
+void RadioRxTestView::run_sgpio_test() {
+ console.clear(true);
+ log("=== SGPIO TEST (FIXED) ===");
+
+ // CRITICAL FIX: Set DISABLE=HIGH first (reference HackRF pattern)
+ LPC_SGPIO->GPIO_OENREG = (1U << 10) | (1U << 11); // SGPIO10,11 outputs
+ LPC_SGPIO->GPIO_OUTREG = (1U << 10); // DISABLE=HIGH during config
+ log("Set DISABLE=HIGH");
+
+ // Small delay for signals to settle
+ for (volatile int i = 0; i < 10000; i++) {
+ }
+
+ // NOW enable streaming (DISABLE=LOW)
+ LPC_SGPIO->GPIO_OUTREG = 0; // DISABLE=LOW, DIRECTION=LOW (RX)
+ log("Set DISABLE=LOW (streaming)");
+ for (volatile int i = 0; i < 10000; i++) {
+ }
+
+ // Read raw GPIO_INREG multiple times
+ uint32_t g[4];
+ for (int i = 0; i < 4; i++) {
+ g[i] = LPC_SGPIO->GPIO_INREG;
+ for (volatile int j = 0; j < 10000; j++) {
+ }
+ }
+
+ log("GPIO_IN:");
+ log(" " + to_string_hex(g[0], 8) + " " + to_string_hex(g[1], 8));
+ log(" " + to_string_hex(g[2], 8) + " " + to_string_hex(g[3], 8));
+
+ bool changing = (g[0] != g[1]) || (g[1] != g[2]) || (g[2] != g[3]);
+
+ if (changing) {
+ log("PASS: Data changing!");
+ } else if (g[0] == 0) {
+ log("FAIL: All zeros");
+ } else if (g[0] == 0x00000FFF) {
+ log("FAIL: 0xFFF = pull-ups");
+ log("FPGA not driving data");
+ } else {
+ log("FAIL: Static " + to_string_hex(g[0], 8));
+ }
+
+ uint32_t out = LPC_SGPIO->GPIO_OUTREG;
+ log("HOST_DIS=" + to_string_dec_uint((out >> 10) & 1));
+}
+
+void RadioRxTestView::run_full_test() {
+ console.clear(true);
+ log("=== FULL RX TEST ===");
+
+ // Step 1: Init
+ log("[1/6] Init radio...");
+ radio::init();
+ radio_initialized_ = true;
+
+ // Step 2: Set sample rate (configures Si5351 clocks!)
+ log("[2/6] Set 8M sample rate...");
+ radio::set_baseband_rate(8000000);
+
+ // Step 3: RX mode
+ log("[3/6] Set RX mode...");
+ radio::set_direction(rf::Direction::Receive);
+
+ // Step 4: Frequency - use 2437 MHz (WiFi ch6) which is in MAX2831 range
+ uint32_t wifi_freq = 2437000000;
+ log("[4/6] Set 2437 MHz (WiFi)...");
+ bool freq_ok = radio::set_tuning_frequency(wifi_freq);
+ log(freq_ok ? " Freq OK" : " Freq FAIL");
+
+ // Step 5: Configure SGPIO outputs with correct DISABLE sequence
+ log("[5/6] Configure SGPIO...");
+ // CRITICAL FIX: Set DISABLE=HIGH first
+ LPC_SGPIO->GPIO_OENREG = (1U << 10) | (1U << 11); // SGPIO10,11 as outputs
+ LPC_SGPIO->GPIO_OUTREG = (1U << 10); // DISABLE=HIGH during config
+ log(" DISABLE=HIGH");
+
+ // Delay for settle
+ for (volatile int i = 0; i < 10000; i++) {
+ }
+
+ // NOW enable streaming (DISABLE=LOW)
+ LPC_SGPIO->GPIO_OUTREG = 0; // DISABLE=LOW, DIRECTION=LOW (RX)
+ log(" DISABLE=LOW (streaming)");
+
+ // Step 6: Check raw GPIO pins
+ log("[6/6] Check GPIO pins...");
+
+ // Delay for stabilization
+ for (volatile int i = 0; i < 200000; i++) {
+ }
+
+ // Read raw GPIO_INREG multiple times
+ uint32_t g1 = LPC_SGPIO->GPIO_INREG;
+ for (volatile int i = 0; i < 10000; i++) {
+ }
+ uint32_t g2 = LPC_SGPIO->GPIO_INREG;
+ for (volatile int i = 0; i < 10000; i++) {
+ }
+ uint32_t g3 = LPC_SGPIO->GPIO_INREG;
+ for (volatile int i = 0; i < 10000; i++) {
+ }
+ uint32_t g4 = LPC_SGPIO->GPIO_INREG;
+
+ log("GPIO_IN readings:");
+ log(" " + to_string_hex(g1, 8) + " " + to_string_hex(g2, 8));
+ log(" " + to_string_hex(g3, 8) + " " + to_string_hex(g4, 8));
+
+ bool gpio_changing = (g1 != g2) || (g2 != g3) || (g3 != g4);
+ bool gpio_not_zero = (g1 != 0);
+ bool gpio_not_fff = (g1 != 0x00000FFF);
+
+ // Check Si5351 output enable register (reg 3)
+ // Bits 0-7: CLK0-7 output enable (0=enabled, 1=disabled)
+ // We want CLK0 and CLK1 enabled (bits 0,1 = 0)
+ log("---");
+
+ if (gpio_changing) {
+ log("=== PASS: Data flowing! ===");
+ } else if (gpio_not_fff && gpio_not_zero) {
+ log("=== PARTIAL: Static data ===");
+ log("FPGA outputs but no clock?");
+ } else if (!gpio_not_zero) {
+ log("=== FAIL: All zeros ===");
+ log("FPGA not driving outputs");
+ } else {
+ log("=== FAIL: All FFF (pull-ups) ===");
+ log("FPGA outputs high-Z");
+ log("Check: Si5351 CLK0/CLK1");
+ }
+
+ log_registers("[Final]");
+}
+
+bool RadioRxTestView::check_gpio_changing() {
+ uint32_t g[4];
+ for (int i = 0; i < 4; i++) {
+ g[i] = LPC_SGPIO->GPIO_INREG;
+ for (volatile int j = 0; j < 10000; j++) {
+ }
+ }
+ return (g[0] != g[1]) || (g[1] != g[2]) || (g[2] != g[3]);
+}
+
+void RadioRxTestView::run_step_test() {
+ console.clear(true);
+ log("=== STEP TEST (FIXED) ===");
+ log("Correct DISABLE sequence");
+
+ // Ensure radio is initialized
+ if (!radio_initialized_) {
+ log("Init radio...");
+ radio::init();
+ radio_initialized_ = true;
+ radio::set_baseband_rate(8000000);
+ radio::set_direction(rf::Direction::Receive);
+ radio::set_tuning_frequency(2437000000);
+ }
+
+ // Step 0: Baseline with DISABLE=HIGH first
+ log("[0] Baseline (DISABLE=HIGH)");
+ LPC_SGPIO->CTRL_ENABLE = 0; // Disable all slices
+ LPC_SGPIO->GPIO_OENREG = 0x0C00; // Bits 10, 11 outputs
+ LPC_SGPIO->GPIO_OUTREG = (1U << 10); // DISABLE=HIGH first!
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+
+ // Now enable streaming to check baseline
+ LPC_SGPIO->GPIO_OUTREG = 0x0000; // DISABLE=LOW
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+ bool step0 = check_gpio_changing();
+ log(step0 ? " PASS: Data changing" : " FAIL: Data static");
+ if (!step0) {
+ log("ABORT: Baseline broken");
+ return;
+ }
+
+ // NOW disable streaming for configuration
+ log("[Config] Set DISABLE=HIGH");
+ LPC_SGPIO->GPIO_OUTREG = (1U << 10); // DISABLE=HIGH
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+
+ // Step 1: OUT_MUX_CFG data pins - test individually
+ log("[1] OUT_MUX_CFG[0-7] data");
+ uint32_t data_out_mux = (9U << 0) | (0U << 4); // DOUT_DOUTM8A, GPIO_OE
+
+ for (size_t i = 0; i < 8; i++) {
+ uint32_t before = LPC_SGPIO->GPIO_INREG;
+ LPC_SGPIO->OUT_MUX_CFG[i] = data_out_mux;
+ for (volatile int j = 0; j < 50000; j++) {
+ }
+ uint32_t after = LPC_SGPIO->GPIO_INREG;
+ bool ok = check_gpio_changing();
+
+ log(" [" + to_string_dec_uint(i) + "] " +
+ to_string_hex(before & 0xFF, 2) + "->" +
+ to_string_hex(after & 0xFF, 2) +
+ (ok ? " OK" : " FAIL"));
+
+ if (!ok) {
+ log("CULPRIT: OUT_MUX_CFG[" + to_string_dec_uint(i) + "]");
+ return;
+ }
+ }
+ log(" All data pins PASS");
+
+ // Step 2: OUT_MUX_CFG control pins - SKIP PIN 10 (HOST_DISABLE)
+ log("[2] OUT_MUX_CFG ctrl pins");
+ log(" (skipping pin 10 - breaks)");
+
+ struct {
+ int pin;
+ uint32_t val;
+ } ctrl_pins[] = {
+ {8, (0U << 0) | (0U << 4)},
+ {9, (0U << 0) | (0U << 4)},
+ // {10, (4U << 0) | (0U << 4)}, // SKIP - causes failure
+ {11, (4U << 0) | (0U << 4)},
+ {14, (0U << 0) | (0U << 4)}};
+
+ for (auto& p : ctrl_pins) {
+ uint32_t before = LPC_SGPIO->GPIO_INREG;
+ LPC_SGPIO->OUT_MUX_CFG[p.pin] = p.val;
+ for (volatile int i = 0; i < 50000; i++) {
+ }
+ uint32_t after = LPC_SGPIO->GPIO_INREG;
+ bool ok = check_gpio_changing();
+
+ log(" [" + to_string_dec_uint(p.pin) + "] " +
+ to_string_hex(before & 0xFF, 2) + "->" +
+ to_string_hex(after & 0xFF, 2) +
+ (ok ? " OK" : " FAIL"));
+
+ if (!ok) {
+ log("CULPRIT: OUT_MUX_CFG[" + to_string_dec_uint(p.pin) + "]");
+ return;
+ }
+ }
+ log(" All ctrl pins PASS");
+
+ // Step 3: Set GPIO_OENREG for RX
+ log("[3] GPIO_OENREG full RX");
+ LPC_SGPIO->GPIO_OENREG = 0x0C00; // Keep same as baseline
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+ bool step3 = check_gpio_changing();
+ log(step3 ? " PASS" : " FAIL: Data stopped!");
+ if (!step3) {
+ log("CULPRIT: GPIO_OENREG");
+ return;
+ }
+
+ // Step 3.5: Configure slice D as clock source (CRITICAL!)
+ log("[3.5] Slice D clock source");
+ const uint32_t slice_d = 3;
+ // SGPIO_MUX_CFG: External clock from SGPIO8, qualifier from SGPIO9
+ LPC_SGPIO->SGPIO_MUX_CFG[slice_d] = (1U << 0) | (0U << 1) | (0U << 3) | (3U << 5) | (1U << 7) | (0U << 9) | (0U << 11) | (0U << 12);
+ // SLICE_MUX_CFG: 1 bit per clock, CLKGEN_MODE=1 (external clock!) <- FIX
+ LPC_SGPIO->SLICE_MUX_CFG[slice_d] = (0U << 0) | (0U << 1) | (0U << 2) | (0U << 3) | (1U << 4) | (0U << 6) | (0U << 8);
+ LPC_SGPIO->PRESET[slice_d] = 0;
+ LPC_SGPIO->COUNT[slice_d] = 0;
+ LPC_SGPIO->POS[slice_d] = (0x1F << 0) | (0x1F << 8);
+ LPC_SGPIO->REG[slice_d] = 0x11111111;
+ LPC_SGPIO->REG_SS[slice_d] = 0x11111111;
+ // Enable slice D counter
+ LPC_SGPIO->CTRL_ENABLE = (1U << slice_d);
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+ bool step3_5 = check_gpio_changing();
+ log(step3_5 ? " PASS" : " FAIL: Data stopped!");
+ if (!step3_5) {
+ log("CULPRIT: Slice D config");
+ return;
+ }
+
+ // Step 4: SGPIO_MUX_CFG slice A
+ log("[4] SGPIO_MUX_CFG[A]");
+ LPC_SGPIO->SGPIO_MUX_CFG[0] = (1U << 0) | (0U << 1) | (3U << 3) | (3U << 5) | (1U << 7) | (0U << 9) | (0U << 11) | (0U << 12); // Clock from slice D (bit3-4=3), external pin SGPIO8, qualifier SGPIO9
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+ bool step4 = check_gpio_changing();
+ log(step4 ? " PASS" : " FAIL: Data stopped!");
+ if (!step4) {
+ log("CULPRIT: SGPIO_MUX_CFG[A]");
+ return;
+ }
+
+ // Step 5: SLICE_MUX_CFG slice A
+ log("[5] SLICE_MUX_CFG[A]");
+ LPC_SGPIO->SLICE_MUX_CFG[0] = (0U << 0) | (0U << 1) | (1U << 2) | (0U << 3) | (1U << 4) | (3U << 6) | (0U << 8); // CLKGEN_MODE=1 (external clock!), PARALLEL_MODE 1 byte
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+ bool step5 = check_gpio_changing();
+ log(step5 ? " PASS" : " FAIL: Data stopped!");
+ if (!step5) {
+ log("CULPRIT: SLICE_MUX_CFG[A]");
+ return;
+ }
+
+ // Step 6: Slice A registers
+ log("[6] Slice A registers");
+ LPC_SGPIO->PRESET[0] = 0;
+ LPC_SGPIO->COUNT[0] = 0;
+ LPC_SGPIO->POS[0] = (0x1F << 0) | (0x1F << 8); // pos, pos_reset
+ LPC_SGPIO->REG[0] = 0;
+ LPC_SGPIO->REG_SS[0] = 0;
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+ bool step6 = check_gpio_changing();
+ log(step6 ? " PASS" : " FAIL: Data stopped!");
+ if (!step6) {
+ log("CULPRIT: Slice A registers");
+ return;
+ }
+
+ // Step 7: Enable slice A counter (keep slice D enabled) - still with DISABLE=HIGH
+ log("[7] Enable slices D+A");
+ LPC_SGPIO->CTRL_ENABLE = (1U << 3) | (1U << 0); // Slice D + Slice A
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+
+ // Check STATUS_1 BEFORE enabling streaming
+ uint32_t status_pre = LPC_SGPIO->STATUS_1;
+ log(" STATUS_1 (pre): " + to_string_hex(status_pre, 4));
+
+ // Step 8: Enable streaming (DISABLE=LOW) - THIS IS THE CRITICAL TEST
+ log("[8] Enable streaming (DISABLE=LOW)");
+ LPC_SGPIO->GPIO_OUTREG = 0; // DISABLE=LOW
+ for (volatile int i = 0; i < 100000; i++) {
+ }
+
+ // Check if slices become active
+ uint32_t status_post = LPC_SGPIO->STATUS_1;
+ uint32_t regss = LPC_SGPIO->REG_SS[0];
+ uint32_t count_a = LPC_SGPIO->COUNT[0];
+
+ log(" STATUS_1 (post): " + to_string_hex(status_post, 4));
+ log(" REG_SS[0]: " + to_string_hex(regss, 8));
+ log(" COUNT[0]: " + to_string_hex(count_a, 8));
+
+ bool step8 = check_gpio_changing();
+ log(step8 ? " GPIO still changing" : " GPIO stopped!");
+
+ if ((status_post & 1) && regss != 0) {
+ log("=== SUCCESS! Slice A capturing! ===");
+ } else if (status_post & 1) {
+ log("=== PARTIAL: Slice A active but REG_SS=0 ===");
+ } else {
+ log("=== FAIL: Slice A not active ===");
+ log("Expected: STATUS_1 bit 0 = 1");
+ log("Actual: STATUS_1 bit 0 = " + to_string_dec_uint(status_post & 1));
+ }
+}
+
+/* SGPIO8ClockDetectorView ***********************************************/
+
+SGPIO8ClockDetectorView::SGPIO8ClockDetectorView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &text_title,
+ &text_lbl_samples,
+ &text_samples,
+ &text_lbl_toggles,
+ &text_toggles,
+ &text_status,
+ &button_sample,
+ &button_done,
+ });
+
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+
+ button_sample.on_select = [this](Button&) {
+ sample_sgpio8();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ // Auto-sample on load
+ sample_sgpio8();
+}
+
+void SGPIO8ClockDetectorView::focus() {
+ button_sample.focus();
+}
+
+void SGPIO8ClockDetectorView::sample_sgpio8() {
+ // Sample SGPIO8 (bit 8 of GPIO_INREG) as fast as possible
+ // NOTE: Software sampling cannot accurately measure clock frequency
+ // This only detects presence/absence of clock activity
+ const int num_samples = 2000;
+ uint8_t samples[num_samples];
+
+ // Sample as fast as possible
+ for (int i = 0; i < num_samples; i++) {
+ samples[i] = (LPC_SGPIO->GPIO_INREG >> 8) & 1;
+ }
+
+ // Count toggles (transitions 0β1 or 1β0)
+ int toggles = 0;
+ for (int i = 1; i < num_samples; i++) {
+ if (samples[i] != samples[i - 1]) {
+ toggles++;
+ }
+ }
+
+ // Display first 20 samples
+ std::string sample_str;
+ for (int i = 0; i < 20 && i < num_samples; i++) {
+ sample_str += (samples[i] ? "1" : "0");
+ }
+ text_samples.set(sample_str);
+
+ // Display toggle count
+ text_toggles.set(to_string_dec_uint(toggles) + " / " +
+ to_string_dec_uint(num_samples - 1) + " transitions");
+
+ // Status interpretation - just presence detection
+ if (toggles > 100) {
+ text_status.set("CLOCK ACTIVE");
+ text_status.set_style(Theme::getInstance()->fg_green);
+ } else if (toggles > 0) {
+ text_status.set("SOME ACTIVITY (" + to_string_dec_uint(toggles) + ")");
+ text_status.set_style(Theme::getInstance()->fg_orange);
+ } else {
+ text_status.set("NO CLOCK - Stuck " +
+ std::string(samples[0] ? "HIGH" : "LOW"));
+ text_status.set_style(Theme::getInstance()->fg_red);
+ }
+}
+
+/* Si5351DebugView *******************************************************/
+
+Si5351DebugView::Si5351DebugView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({&text_title,
+ &text_status_label,
+ &text_status_value,
+ &text_pll_a_label,
+ &text_pll_a_status,
+ &text_pll_b_label,
+ &text_pll_b_status,
+ &text_sys_init_label,
+ &text_sys_init_status,
+ &text_xtal_cap_label,
+ &text_xtal_cap_value,
+ &text_clk0_label,
+ &text_clk0_status,
+ &text_clk0_freq_value,
+ &text_clk0_div_value,
+ &text_clk1_label,
+ &text_clk1_status,
+ &text_clk4_label,
+ &text_clk4_status,
+ &text_clk5_label,
+ &text_clk5_status,
+ &button_refresh,
+ &button_reset_pll,
+ &button_done});
+
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) {
+ refresh_status();
+ };
+
+ button_reset_pll.on_select = [this](Button&) {
+ reset_pll();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ // Auto-refresh on load
+ refresh_status();
+}
+
+void Si5351DebugView::focus() {
+ button_refresh.focus();
+}
+
+void Si5351DebugView::refresh_status() {
+ // Read device status register (reg 0)
+ uint8_t status = portapack::clock_manager.si5351_read_status();
+ text_status_value.set("0x" + to_string_hex(status, 2));
+
+ // Decode status bits
+ bool pll_a_locked = !(status & 0x20); // Bit 5: LOL_A (Loss of Lock A)
+ bool pll_b_locked = !(status & 0x40); // Bit 6: LOL_B (Loss of Lock B)
+ bool sys_init = (status & 0x80); // Bit 7: SYS_INIT
+ // bool los_clkin = (status & 0x10); // Bit 4: LOS (Loss of Signal) (quoted as it's computed but unused)
+
+ // PLL A status
+ if (pll_a_locked) {
+ text_pll_a_status.set("LOCKED");
+ text_pll_a_status.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_pll_a_status.set("UNLOCKED");
+ text_pll_a_status.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // PLL B status
+ if (pll_b_locked) {
+ text_pll_b_status.set("LOCKED");
+ text_pll_b_status.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_pll_b_status.set("UNLOCKED (unused)");
+ text_pll_b_status.set_style(Theme::getInstance()->fg_orange);
+ }
+
+ // SYS_INIT status
+ if (sys_init) {
+ text_sys_init_status.set("IN PROGRESS");
+ text_sys_init_status.set_style(Theme::getInstance()->fg_orange);
+ } else {
+ text_sys_init_status.set("COMPLETE");
+ text_sys_init_status.set_style(Theme::getInstance()->fg_green);
+ }
+
+ // Read crystal load capacitance (reg 183)
+ uint8_t xtal_cap = portapack::clock_manager.si5351_read_register(183);
+ text_xtal_cap_value.set("0x" + to_string_hex(xtal_cap, 2) +
+ " (" + to_string_dec_uint((xtal_cap >> 6) & 0x03) + ")");
+
+ // Read clock output enables (reg 16-23 control, reg 3 for output enable mask)
+ uint8_t output_enable_mask = portapack::clock_manager.si5351_read_register(3);
+
+ // CLK0 (bit 0 of reg 3, reg 16 for control)
+ uint8_t clk0_ctrl = portapack::clock_manager.si5351_read_register(16);
+ bool clk0_enabled = !(output_enable_mask & 0x01) && !(clk0_ctrl & 0x80);
+ text_clk0_status.set(clk0_enabled ? "ON" : "OFF");
+ text_clk0_status.set_style(clk0_enabled ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_red);
+
+ // Read MS0 multisynth parameters (registers 42-49) to calculate actual frequency
+ // Si5351 MS0 Register Layout:
+ // Reg 42: P3[15:8]
+ // Reg 43: P3[7:0]
+ // Reg 44: bits 6:4 = R_DIV[2:0], bits 1:0 = P1[17:16]
+ // Reg 45: P1[15:8]
+ // Reg 46: P1[7:0]
+ // Reg 47: bits 7:4 = P3[19:16], bits 3:0 = P2[19:16]
+ // Reg 48: P2[15:8]
+ // Reg 49: P2[7:0]
+ uint8_t reg44 = portapack::clock_manager.si5351_read_register(44);
+ uint8_t reg45 = portapack::clock_manager.si5351_read_register(45);
+ uint8_t reg46 = portapack::clock_manager.si5351_read_register(46);
+
+ // Decode R divider from bits 6:4 of register 44
+ uint8_t r_div_encoded = (reg44 >> 4) & 0x07;
+ uint32_t r_div = 1 << r_div_encoded; // R = 2^r_div_encoded
+
+ // Decode P1 (18-bit value): bits 1:0 of reg44 = P1[17:16], reg45 = P1[15:8], reg46 = P1[7:0]
+ uint32_t p1 = ((uint32_t)(reg44 & 0x03) << 16) | ((uint32_t)reg45 << 8) | reg46;
+
+ // Calculate divider: a = (P1 + 512) / 128 for integer dividers (b=0)
+ // Expected for 8 MHz: P1=5888 (0x1700), a=50
+ uint32_t ms_div = (p1 + 512) / 128; // Integer divider value
+
+ // Calculate frequency: f_out = 800 or 800 MHz / ms_div / r_div
+ uint32_t vco_khz = (clk0_ctrl & 0x20) ? 800000 : 800000; // PLLB vs PLLA
+ uint32_t freq_khz = vco_khz / ms_div / r_div; // Result in kHz
+
+ // Show P1 value and R45 for debugging
+ text_clk0_freq_value.set("F:" + to_string_dec_uint(freq_khz / 1000) + "MHz (P1:" + to_string_hex(p1, 4) + ")");
+ text_clk0_div_value.set("DIV: MS=" + to_string_dec_uint(ms_div) +
+ " R=" + to_string_dec_uint(r_div));
+
+ // Color code based on expected 8 MHz
+ if (freq_khz >= 7900 && freq_khz <= 8100) {
+ text_clk0_freq_value.set_style(Theme::getInstance()->fg_green);
+ text_clk0_div_value.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_clk0_freq_value.set_style(Theme::getInstance()->fg_red);
+ text_clk0_div_value.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // CLK1 (bit 1 of reg 3, reg 17 for control)
+ uint8_t clk1_ctrl = portapack::clock_manager.si5351_read_register(17);
+ bool clk1_enabled = !(output_enable_mask & 0x02) && !(clk1_ctrl & 0x80);
+ text_clk1_status.set(clk1_enabled ? "ON" : "OFF");
+ text_clk1_status.set_style(clk1_enabled ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_red);
+
+ // CLK4 (MAX2831 reference - bit 4 of reg 3, reg 20 for control)
+ uint8_t clk4_ctrl = portapack::clock_manager.si5351_read_register(20);
+ bool clk4_enabled = !(output_enable_mask & 0x10) && !(clk4_ctrl & 0x80);
+ text_clk4_status.set(clk4_enabled ? "ON (40MHz)" : "OFF");
+ text_clk4_status.set_style(clk4_enabled ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_red);
+
+ // CLK5 (RFFC5072 reference - bit 5 of reg 3, reg 21 for control)
+ uint8_t clk5_ctrl = portapack::clock_manager.si5351_read_register(21);
+ bool clk5_enabled = !(output_enable_mask & 0x20) && !(clk5_ctrl & 0x80);
+ text_clk5_status.set(clk5_enabled ? "ON (40MHz)" : "OFF");
+ text_clk5_status.set_style(clk5_enabled ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_red);
+}
+
+void Si5351DebugView::reset_pll() {
+ // Reset both PLLs (write to reg 177)
+ portapack::clock_manager.si5351_read_register(177); // Read first
+ portapack::clock_manager.si5351_write_register(177, 0xAC); // Reset both PLLs
+
+ // Small delay for PLL to settle
+ chThdSleepMilliseconds(10);
+
+ // Refresh status to show new lock state
+ refresh_status();
+}
+
+#ifdef PRALINE
+/* SignalPathStatusView *************************************************/
+
+SignalPathStatusView::SignalPathStatusView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &text_title,
+ &text_lbl_max_enable,
+ &text_max_enable,
+ &text_lbl_max_mode,
+ &text_max_mode,
+ &text_lbl_rf_path,
+ &text_rf_path,
+ &text_lbl_filter,
+ &text_filter,
+ &text_lbl_mixer,
+ &text_mixer,
+ &text_lbl_rf_amp,
+ &text_rf_amp,
+ &text_lbl_lna,
+ &text_lna,
+ &text_lbl_vga,
+ &text_vga,
+ &text_lbl_fpga_decim,
+ &text_fpga_decim,
+ &text_lbl_fpga_ctrl_dc_q,
+ &text_fpga_ctrl_dc_q,
+ &text_lbl_fpga_ctrl_qs,
+ &text_fpga_ctrl_qs,
+ &text_status,
+ &button_refresh,
+ &button_toggle_q,
+ &button_done,
+ });
+
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) {
+ refresh_status();
+ };
+
+ button_toggle_q.on_select = [this](Button&) {
+ static bool q_override = false;
+ q_override = !q_override;
+
+ uint8_t ctrl_reg = 0x01; // DC_BLOCK always on
+ if (q_override) {
+ ctrl_reg |= 0x02; // Force Q_INVERT ON
+ }
+
+ radio::debug::fpga::register_write(1, ctrl_reg);
+ radio::invalidate_spi_config();
+
+ refresh_status();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ // Initial update
+ refresh_status();
+}
+
+void SignalPathStatusView::focus() {
+ button_refresh.focus();
+}
+
+void SignalPathStatusView::refresh_status() {
+ // Get cached state from radio driver
+ rf::Direction direction = radio::debug::get_cached_direction();
+ bool rf_amp = radio::debug::get_cached_rf_amp();
+ int_fast8_t cached_lna = radio::debug::get_cached_lna_gain();
+ int_fast8_t cached_vga = radio::debug::get_cached_vga_gain();
+
+ // Get current band.
+ auto current_band = radio::debug::rf_path_info::get_current_band();
+ switch (current_band) {
+ case rf::path::Band::Low:
+ text_filter.set("LOW PASS");
+ text_filter.set_style(Theme::getInstance()->fg_green);
+ text_mixer.set("ENABLED");
+ text_mixer.set_style(Theme::getInstance()->fg_green);
+ break;
+
+ case rf::path::Band::Mid:
+ text_filter.set("BYPASS");
+ text_filter.set_style(Theme::getInstance()->fg_green);
+ text_mixer.set("DISABLED");
+ text_mixer.set_style(Theme::getInstance()->fg_orange);
+ break;
+
+ case rf::path::Band::High:
+ text_filter.set("HIGH PASS");
+ text_filter.set_style(Theme::getInstance()->fg_green);
+ text_mixer.set("ENABLED");
+ text_mixer.set_style(Theme::getInstance()->fg_green);
+ break;
+
+ default:
+ text_filter.set("UNKNOWN");
+ text_filter.set_style(Theme::getInstance()->fg_red);
+ text_mixer.set("UNKNOWN");
+ text_mixer.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // Read actual register values to verify
+ uint32_t max_r11 = radio::debug::second_if::register_read(11);
+
+ // Decode actual LNA gain from register (bits 6:5)
+ uint8_t lna_bits = (max_r11 >> 5) & 0x03;
+ int actual_lna_db;
+ switch (lna_bits) {
+ case 0:
+ actual_lna_db = 0;
+ break; // -33 dB from max
+ case 2:
+ actual_lna_db = 17;
+ break; // -16 dB from max
+ case 3:
+ actual_lna_db = 33;
+ break; // Maximum
+ default:
+ actual_lna_db = -1;
+ break; // Invalid
+ }
+
+ // Decode actual VGA gain from register (bits 4:0)
+ uint8_t vga_bits = (max_r11 >> 0) & 0x1F;
+ int actual_vga_db = vga_bits * 2; // 0-31 β 0-62 dB
+
+ // MAX2831 Enable/Mode (GPIO-controlled, show cached state)
+ bool rx_mode = (direction == rf::Direction::Receive);
+
+ text_max_enable.set("ENABLED cached");
+ text_max_enable.set_style(Theme::getInstance()->fg_green);
+
+ text_max_mode.set(rx_mode ? "RX cached" : "TX cached");
+ text_max_mode.set_style(rx_mode ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_orange);
+
+ // RF path direction
+ text_rf_path.set(rx_mode ? "RECEIVE" : "TRANSMIT");
+ text_rf_path.set_style(rx_mode ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_orange);
+
+ // RF amp (GPIO-controlled, show cached state)
+ text_rf_amp.set(rf_amp ? "ON cached" : "OFF cached");
+ text_rf_amp.set_style(rf_amp ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_orange);
+
+ // LNA gain - show both cached and actual
+ if (actual_lna_db == cached_lna) {
+ text_lna.set(to_string_dec_uint(actual_lna_db) + " dB");
+ text_lna.set_style(Theme::getInstance()->fg_green);
+ } else if (actual_lna_db >= 0) {
+ // MAX2831 has discrete steps: 0, 17, 33 dB
+ // Allow Β±8 dB tolerance for rounding
+ int diff = (actual_lna_db > cached_lna) ? (actual_lna_db - cached_lna) : (cached_lna - actual_lna_db);
+
+ if (diff <= 8) {
+ // Within rounding tolerance - show as OK with note
+ text_lna.set(to_string_dec_uint(actual_lna_db) + " dB (req:" +
+ to_string_dec_uint(cached_lna) + ")");
+ text_lna.set_style(Theme::getInstance()->fg_green);
+ } else {
+ // Genuine mismatch
+ text_lna.set(to_string_dec_uint(actual_lna_db) + " dB (!" +
+ to_string_dec_uint(cached_lna) + ")");
+ text_lna.set_style(Theme::getInstance()->fg_red);
+ }
+ } else {
+ text_lna.set("INVALID");
+ text_lna.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // VGA gain - show both cached and actual
+ if (actual_vga_db == cached_vga) {
+ text_vga.set(to_string_dec_uint(actual_vga_db) + " dB");
+ text_vga.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_vga.set(to_string_dec_uint(actual_vga_db) + " dB (!" +
+ to_string_dec_uint(cached_vga) + ")");
+ text_vga.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // FPGA decimation register
+ uint8_t fpga_decim = radio::debug::fpga::register_read(2);
+ text_fpga_decim.set("n=" + to_string_dec_uint(fpga_decim) +
+ " (/" + to_string_dec_uint(1 << fpga_decim) + ")");
+
+ // FPGA Register Ctrl Info
+ uint32_t fpga_ctrl = radio::debug::fpga::register_read(1);
+ // text_fpga_ctrl_dc_q.set(to_string_hex(fpga_ctrl, 2));
+ // text_fpga_ctrl_qs.set(to_string_hex(fpga_ctrl, 2));
+
+ // Decode bits
+ bool dc_block = fpga_ctrl & 0x01;
+ bool q_invert = fpga_ctrl & 0x02;
+ uint8_t quarter_shift = (fpga_ctrl >> 2) & 0x03;
+
+ // Display human-readable
+ std::string fpga_status_dc_q = "DC:" + std::string(dc_block ? "ON" : "OFF") +
+ " Q:" + std::string(q_invert ? "INV" : "NOR");
+
+ std::string fpga_status_qs = "QS:" + to_string_dec_uint(quarter_shift);
+
+ text_fpga_ctrl_dc_q.set(fpga_status_dc_q);
+ text_fpga_ctrl_qs.set(fpga_status_qs);
+
+ // Summary status
+ // Summary status - update to account for rounding tolerance
+ int lna_diff = (actual_lna_db > cached_lna) ? (actual_lna_db - cached_lna) : (cached_lna - actual_lna_db);
+ bool lna_ok = (actual_lna_db == cached_lna) || (lna_diff <= 8);
+ bool gains_match = lna_ok && (actual_vga_db == cached_vga);
+ if (rx_mode && gains_match) {
+ text_status.set("RX mode, gains verified!");
+ text_status.set_style(Theme::getInstance()->fg_green);
+ } else if (rx_mode && !gains_match) {
+ text_status.set("RX mode, gain MISMATCH!");
+ text_status.set_style(Theme::getInstance()->fg_red);
+ } else if (gains_match) {
+ text_status.set("TX mode, gains verified β");
+ text_status.set_style(Theme::getInstance()->fg_orange);
+ } else {
+ text_status.set("TX mode, gain MISMATCH!");
+ text_status.set_style(Theme::getInstance()->fg_red);
+ }
+}
+#endif
+
+#ifdef PRALINE
+/* SystemDiagnosticsView *************************************************/
+SystemDiagnosticsView::SystemDiagnosticsView(NavigationView& nav) {
+ add_children({
+ &text_title,
+ &text_lbl_sample,
+ &text_sample_rate,
+ &text_lbl_band,
+ &text_band,
+ &button_sample_2m,
+ &button_sample_4m,
+ &button_sample_8m,
+ &button_sample_20m,
+ &text_lbl_bb_filter,
+ &text_bb_filter,
+ &text_lbl_reg8,
+ &text_reg8,
+ &text_lbl_gpio,
+ &text_lbl_lpf,
+ &text_gpio_lpf,
+ &text_lbl_mix,
+ &text_gpio_mix,
+ &text_lbl_amp,
+ &text_gpio_amp,
+ &text_lbl_fpga,
+ &text_lbl_fpga_ctrl,
+ &text_fpga_ctrl,
+ &text_lbl_fpga_decode,
+ &button_toggle_q,
+ &button_toggle_dc,
+ &button_refresh,
+ &button_done,
+ });
+
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_gpio.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_fpga.set_style(Theme::getInstance()->fg_yellow);
+
+ // Sample rate buttons
+ button_sample_2m.on_select = [this](Button&) {
+ set_sample_rate(2000000);
+ };
+
+ button_sample_4m.on_select = [this](Button&) {
+ set_sample_rate(4000000);
+ };
+
+ button_sample_8m.on_select = [this](Button&) {
+ set_sample_rate(8000000);
+ };
+
+ button_sample_20m.on_select = [this](Button&) {
+ set_sample_rate(20000000);
+ };
+
+ // Q inversion toggle
+ button_toggle_q.on_select = [this](Button&) {
+ uint32_t current = radio::debug::fpga::register_read(1);
+ uint8_t new_val = current ^ 0x02; // Toggle Q_INVERT bit
+ radio::debug::fpga::register_write(1, new_val);
+ radio::invalidate_spi_config();
+ refresh();
+ };
+
+ // DC block toggle
+ button_toggle_dc.on_select = [this](Button&) {
+ uint32_t current = radio::debug::fpga::register_read(1);
+ uint8_t new_val = current ^ 0x01; // Toggle DC_BLOCK bit
+ radio::debug::fpga::register_write(1, new_val);
+ radio::invalidate_spi_config();
+ refresh();
+ };
+
+ button_refresh.on_select = [this](Button&) {
+ refresh();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ refresh();
+}
+
+void SystemDiagnosticsView::focus() {
+ button_refresh.focus();
+}
+
+void SystemDiagnosticsView::set_sample_rate(uint32_t rate) {
+ portapack::clock_manager.set_sampling_frequency(rate);
+ refresh();
+}
+
+void SystemDiagnosticsView::read_gpio_states() {
+ // Read actual GPIO port states
+ uint32_t gpio3_state = LPC_GPIO->PIN[3]; // GPIO3 for mixer
+ uint32_t gpio4_state = LPC_GPIO->PIN[4]; // GPIO4 for LPF and amp
+
+ bool mix_n_actual = (gpio3_state >> 2) & 1; // GPIO3[2]
+ bool lpf_actual = (gpio4_state >> 8) & 1; // GPIO4[8]
+ bool amp_actual = (gpio4_state >> 9) & 1; // GPIO4[9]
+
+ // Mixer is active LOW, so invert for display
+ bool mixer_enabled = !mix_n_actual;
+
+ // Display with GPIO pin numbers
+ text_gpio_lpf.set(
+ std::string(lpf_actual ? "ON" : "OFF") +
+ " (GPIO4[8]=" + to_string_dec_uint(lpf_actual ? 1 : 0) + ")");
+
+ text_gpio_mix.set(
+ std::string(mixer_enabled ? "ENABLED" : "BYPASSED") +
+ " (GPIO3[2]=" + to_string_dec_uint(mix_n_actual ? 1 : 0) + ")");
+
+ text_gpio_amp.set(
+ std::string(amp_actual ? "ON" : "OFF") +
+ " (GPIO4[9]=" + to_string_dec_uint(amp_actual ? 1 : 0) + ")");
+
+ // Color code
+ text_gpio_lpf.set_style(lpf_actual ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ text_gpio_mix.set_style(mixer_enabled ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ text_gpio_amp.set_style(amp_actual ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_orange);
+}
+
+void SystemDiagnosticsView::refresh() {
+ // Sample rate
+ uint32_t sample_rate = portapack::clock_manager.get_sampling_frequency();
+ if (sample_rate >= 1000000) {
+ text_sample_rate.set(to_string_dec_uint(sample_rate / 1000000) + " MSS");
+ } else {
+ text_sample_rate.set(to_string_dec_uint(sample_rate / 1000) + " kSS");
+ }
+
+ // Baseband filter bandwidth
+ uint32_t reg8 = radio::debug::second_if::register_read(8);
+ text_reg8.set("0x" + to_string_hex(reg8, 4));
+
+ uint8_t lpf_coarse = reg8 & 0x03; // Bits 1:0
+ const char* bw_names[] = {"7.5 MHz", "8.5 MHz", "15 MHz", "18 MHz"};
+ text_bb_filter.set(bw_names[lpf_coarse]);
+
+ // Color code - green if >= 8 MHz, red otherwise
+ if (lpf_coarse >= 1) {
+ text_bb_filter.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_bb_filter.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // Read actual GPIO pin states
+ read_gpio_states();
+
+ uint32_t gpio6_pin = LPC_GPIO->PIN[6];
+ bool rffc_locked = (gpio6_pin >> 25) & 1;
+
+ // Display it by changing one of the existing fields temporarily
+ // For example, modify the band display to show lock status:
+
+ auto current_band = radio::debug::rf_path_info::get_current_band();
+ switch (current_band) {
+ case rf::path::Band::Low:
+ text_band.set("LOW(0-2320MHz)|" + std::string(rffc_locked ? "LCK)" : "ULCK)"));
+ text_band.set_style(rffc_locked ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ break;
+ case rf::path::Band::Mid:
+ text_band.set("MID(2320-2740MHz)");
+ text_band.set_style(Theme::getInstance()->fg_green);
+ break;
+ case rf::path::Band::High:
+ text_band.set("HIGH(2740-7250MHz)");
+ text_band.set_style(Theme::getInstance()->fg_green);
+ break;
+ }
+ // FPGA control register
+ uint32_t fpga_ctrl = radio::debug::fpga::register_read(1);
+ text_fpga_ctrl.set("0x" + to_string_hex(fpga_ctrl, 2));
+
+ // Decode FPGA register bits
+ bool dc_block = fpga_ctrl & 0x01;
+ bool q_invert = fpga_ctrl & 0x02;
+ uint8_t quarter_shift = (fpga_ctrl >> 2) & 0x03;
+
+ text_lbl_fpga_decode.set(
+ "|DC:" + std::string(dc_block ? "ON" : "OFF") +
+ " Q:" + std::string(q_invert ? "INV" : "NOR") +
+ " QS:" + to_string_dec_uint(quarter_shift));
+}
+#endif
+
+#ifdef PRALINE
+/* Si5351PLLADebugView *******************************************************/
+
+Si5351PLLADebugView::Si5351PLLADebugView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({&text_title, &text_lbl_raw, &text_r26_27, &text_r28_30, &text_r31_33,
+ &text_lbl_decoded, &text_lbl_p1, &text_p1, &text_lbl_p2, &text_p2,
+ &text_lbl_p3, &text_p3, &text_lbl_calc, &text_lbl_mult, &text_mult,
+ &text_lbl_vco, &text_vco, &text_status, &button_refresh, &button_done});
+
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_raw.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_decoded.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_calc.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) { refresh(); };
+ button_done.on_select = [&nav](Button&) { nav.pop(); };
+
+ refresh();
+}
+
+void Si5351PLLADebugView::focus() {
+ button_refresh.focus();
+}
+
+void Si5351PLLADebugView::refresh() {
+ // Read PLL A parameters
+ uint8_t r26 = portapack::clock_manager.si5351_read_register(26);
+ uint8_t r27 = portapack::clock_manager.si5351_read_register(27);
+ uint8_t r28 = portapack::clock_manager.si5351_read_register(28);
+ uint8_t r29 = portapack::clock_manager.si5351_read_register(29);
+ uint8_t r30 = portapack::clock_manager.si5351_read_register(30);
+ uint8_t r31 = portapack::clock_manager.si5351_read_register(31);
+ uint8_t r32 = portapack::clock_manager.si5351_read_register(32);
+ uint8_t r33 = portapack::clock_manager.si5351_read_register(33);
+
+ // Display Raw registers
+ text_r26_27.set("R26-27 (P3 LO): " + to_string_hex(r26, 2) + " " + to_string_hex(r27, 2));
+ text_r28_30.set("R28-30 (P1): " + to_string_hex(r28, 2) + " " + to_string_hex(r29, 2) + " " + to_string_hex(r30, 2));
+ text_r31_33.set("R31-33 (P3H|P2):" + to_string_hex(r31, 2) + " " + to_string_hex(r32, 2) + " " + to_string_hex(r33, 2));
+
+ // Decode parameters
+ uint32_t pll_p1 = ((uint32_t)(r28 & 0x03) << 16) | ((uint32_t)r29 << 8) | r30;
+ uint32_t pll_p2 = ((uint32_t)(r31 & 0x0F) << 16) | ((uint32_t)r32 << 8) | r33;
+ uint32_t pll_p3 = ((uint32_t)(r31 >> 4) << 16) | ((uint32_t)r26 << 8) | r27;
+
+ text_p1.set(to_string_dec_uint(pll_p1) + " (0x" + to_string_hex(pll_p1, 5) + ")");
+ text_p2.set(to_string_dec_uint(pll_p2) + " (0x" + to_string_hex(pll_p2, 5) + ")");
+ text_p3.set(to_string_dec_uint(pll_p3) + " (0x" + to_string_hex(pll_p3, 5) + ")");
+
+ // Calculate Multiplier: M = (128 * P3 + P1 * 128 + 512 + P2) / (128 * P3)
+ // Simplified as: Multiplier = ((P1 + 512) / 128) + (P2 / P3)
+ uint32_t a = (pll_p1 + 512) / 128;
+
+ if (pll_p3 > 0) {
+ text_mult.set(to_string_dec_uint(a) + " + " + to_string_dec_uint(pll_p2) + "/" + to_string_dec_uint(pll_p3));
+ } else {
+ text_mult.set("ERR: P3=0");
+ }
+
+ // Calculate VCO Frequency (f_vco = f_xtal * Multiplier)
+ // The HackRF crystal (f_xtal) is 25 MHz.
+ if (pll_p3 > 0) {
+ uint64_t vco_num = (uint64_t)pll_p2 + (uint64_t)pll_p3 * (pll_p1 + 512);
+ uint64_t vco_den = 128ULL * pll_p3;
+ uint32_t vco_khz = (uint32_t)((25000ULL * vco_num) / vco_den);
+
+ text_vco.set(to_string_dec_uint(vco_khz / 1000) + "." + to_string_dec_uint(vco_khz % 1000, 3) + " MHz");
+
+ // Si5351 VCO range is 600-900 MHz
+ if (vco_khz >= 600000 && vco_khz <= 900000) {
+ text_vco.set_style(Theme::getInstance()->fg_green);
+ text_status.set("VCO within valid range.");
+ } else {
+ text_vco.set_style(Theme::getInstance()->fg_red);
+ text_status.set("VCO OUT OF RANGE (600-900)!");
+ }
+ }
+}
+#endif
+
+#ifdef PRALINE
+Si5351PLLBDebugView::Si5351PLLBDebugView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({&text_title,
+ &text_lbl_raw, &text_r34_35, &text_r36_38, &text_r39_41,
+ &text_lbl_decoded, &text_lbl_p1, &text_p1, &text_lbl_p2, &text_p2, &text_lbl_p3, &text_p3,
+ &text_lbl_calc, &text_lbl_vco, &text_vco,
+ &text_status, &button_refresh, &button_done});
+
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_raw.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_decoded.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_calc.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) { refresh(); };
+ button_done.on_select = [&nav](Button&) { nav.pop(); };
+ refresh();
+}
+
+void Si5351PLLBDebugView::focus() {
+ button_refresh.focus();
+}
+
+void Si5351PLLBDebugView::refresh() {
+ uint8_t r34 = portapack::clock_manager.si5351_read_register(34); // P3[15:8]
+ uint8_t r35 = portapack::clock_manager.si5351_read_register(35); // P3[7:0]
+ uint8_t r36 = portapack::clock_manager.si5351_read_register(36); // P1[17:16]
+ uint8_t r37 = portapack::clock_manager.si5351_read_register(37); // P1[15:8]
+ uint8_t r38 = portapack::clock_manager.si5351_read_register(38); // P1[7:0]
+ uint8_t r39 = portapack::clock_manager.si5351_read_register(39); // P3[19:16] | P2[19:16]
+ uint8_t r40 = portapack::clock_manager.si5351_read_register(40); // P2[15:8]
+ uint8_t r41 = portapack::clock_manager.si5351_read_register(41); // P2[7:0]
+
+ text_r34_35.set("R34-35: " + to_string_hex(r34, 2) + " " + to_string_hex(r35, 2));
+ text_r36_38.set("R36-38: " + to_string_hex(r36, 2) + " " + to_string_hex(r37, 2) + " " + to_string_hex(r38, 2));
+ text_r39_41.set("R39-41: " + to_string_hex(r39, 2) + " " + to_string_hex(r40, 2) + " " + to_string_hex(r41, 2));
+
+ uint32_t p1 = ((uint32_t)(r36 & 0x03) << 16) | ((uint32_t)r37 << 8) | r38;
+ uint32_t p2 = ((uint32_t)(r39 & 0x0F) << 16) | ((uint32_t)r40 << 8) | r41;
+ uint32_t p3 = ((uint32_t)(r39 >> 4) << 16) | ((uint32_t)r34 << 8) | r35;
+
+ text_p1.set(to_string_dec_uint(p1) + " (0x" + to_string_hex(p1, 5) + ")");
+ text_p2.set(to_string_dec_uint(p2) + " (0x" + to_string_hex(p2, 5) + ")");
+ text_p3.set(to_string_dec_uint(p3) + " (0x" + to_string_hex(p3, 5) + ")");
+
+ if (p3 > 0) {
+ uint64_t vco_num = (uint64_t)p2 + (uint64_t)p3 * (p1 + 512);
+ uint64_t vco_den = 128ULL * p3;
+ uint32_t vco_khz = (uint32_t)((25000ULL * vco_num) / vco_den);
+ text_vco.set(to_string_dec_uint(vco_khz / 1000) + "." + to_string_dec_uint(vco_khz % 1000, 3) + " MHz");
+
+ bool ok = (vco_khz >= 600000 && vco_khz <= 900000);
+ text_vco.set_style(ok ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ text_status.set(ok ? "PLL B VCO OK" : "VCO OUT OF RANGE!");
+ }
+}
+#endif
+
+#ifdef PRALINE
+/* Si5351MultiSynthDebugView *************************************************/
+
+Si5351MultiSynthDebugView::Si5351MultiSynthDebugView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &text_title,
+ &text_lbl_clk_ctrl,
+ &text_clk_ctrl,
+ &text_lbl_ms_int,
+ &text_ms_int,
+ &text_lbl_raw,
+ &text_lbl_r42_43,
+ &text_r42_43,
+ &text_lbl_r44_46,
+ &text_r44_46,
+ &text_lbl_r47_49,
+ &text_r47_49,
+ &text_lbl_decoded,
+ &text_lbl_p1,
+ &text_p1,
+ &text_lbl_p2,
+ &text_p2,
+ &text_lbl_p3,
+ &text_p3,
+ &text_lbl_rdiv,
+ &text_rdiv,
+ &text_lbl_calc,
+ &text_lbl_div,
+ &text_div,
+ &text_lbl_freq,
+ &text_freq,
+ &text_status,
+ &button_refresh,
+ &button_reset,
+ &button_frac,
+ &button_done,
+ });
+
+ text_title.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_raw.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_decoded.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_calc.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) {
+ refresh();
+ };
+
+ button_reset.on_select = [this](Button&) {
+ force_pll_reset();
+ };
+
+ button_frac.on_select = [this](Button&) {
+ force_fractional_mode();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ refresh();
+}
+
+void Si5351MultiSynthDebugView::focus() {
+ button_refresh.focus();
+}
+
+void Si5351MultiSynthDebugView::force_pll_reset() {
+ // Reset PLL A (bit 5)
+ portapack::clock_manager.si5351_write_register(177, 0x20);
+
+ // Wait for PLL to settle
+ chThdSleepMilliseconds(10);
+
+ refresh();
+}
+
+void Si5351MultiSynthDebugView::force_fractional_mode() {
+ // Force CLK0 to fractional mode
+ // Read current control register
+ uint8_t clk0_ctrl = portapack::clock_manager.si5351_read_register(16);
+
+ // Clear MS_INT bit (bit 6) to enable fractional mode
+ clk0_ctrl &= ~0x40;
+
+ // Write back
+ portapack::clock_manager.si5351_write_register(16, clk0_ctrl);
+
+ // Reset PLL to apply
+ portapack::clock_manager.si5351_write_register(177, 0x20);
+
+ chThdSleepMilliseconds(10);
+
+ refresh();
+}
+
+void Si5351MultiSynthDebugView::refresh() {
+ // === Clock Control Register 16 (CLK0) ===
+ uint8_t clk0_ctrl = portapack::clock_manager.si5351_read_register(16);
+ text_clk_ctrl.set("0x" + to_string_hex(clk0_ctrl, 2) +
+ " (" + to_string_bin(clk0_ctrl, 8) + ")");
+
+ // Decode MS_INT bit (bit 6)
+ bool ms_int = (clk0_ctrl >> 6) & 1;
+ if (ms_int) {
+ text_ms_int.set("1:INT MODE!");
+ text_ms_int.set_style(Theme::getInstance()->fg_red);
+ } else {
+ text_ms_int.set("0:Fract Mode");
+ text_ms_int.set_style(Theme::getInstance()->fg_green);
+ }
+
+ // === Read Raw MS0 Registers (42-49) ===
+ uint8_t r42 = portapack::clock_manager.si5351_read_register(42);
+ uint8_t r43 = portapack::clock_manager.si5351_read_register(43);
+ uint8_t r44 = portapack::clock_manager.si5351_read_register(44);
+ uint8_t r45 = portapack::clock_manager.si5351_read_register(45);
+ uint8_t r46 = portapack::clock_manager.si5351_read_register(46);
+ uint8_t r47 = portapack::clock_manager.si5351_read_register(47);
+ uint8_t r48 = portapack::clock_manager.si5351_read_register(48);
+ uint8_t r49 = portapack::clock_manager.si5351_read_register(49);
+
+ // Display raw registers
+ text_r42_43.set(to_string_hex(r42, 2) + " " + to_string_hex(r43, 2) +
+ " (P3[15:0])");
+ text_r44_46.set(to_string_hex(r44, 2) + " " + to_string_hex(r45, 2) +
+ " " + to_string_hex(r46, 2) + " (R|P1)");
+ text_r47_49.set(to_string_hex(r47, 2) + " " + to_string_hex(r48, 2) +
+ " " + to_string_hex(r49, 2) + " (P3|P2)");
+
+ // === Decode P1, P2, P3 ===
+ // Si5351 MS Register Layout:
+ // Reg 42: P3[15:8]
+ // Reg 43: P3[7:0]
+ // Reg 44: bits 6:4 = R_DIV[2:0], bits 1:0 = P1[17:16]
+ // Reg 45: P1[15:8]
+ // Reg 46: P1[7:0]
+ // Reg 47: bits 7:4 = P3[19:16], bits 3:0 = P2[19:16]
+ // Reg 48: P2[15:8]
+ // Reg 49: P2[7:0]
+
+ // Decode R_DIV
+ uint8_t r_div_encoded = (r44 >> 4) & 0x07;
+ uint32_t r_div = 1 << r_div_encoded;
+ text_rdiv.set("/" + to_string_dec_uint(r_div) + " (enc=" + to_string_dec_uint(r_div_encoded) + ")");
+
+ // Decode P1 (18-bit)
+ uint32_t p1 = ((uint32_t)(r44 & 0x03) << 16) | ((uint32_t)r45 << 8) | r46;
+ text_p1.set(to_string_dec_uint(p1) + " (0x" + to_string_hex(p1, 5) + ")");
+
+ // Decode P2 (20-bit)
+ uint32_t p2 = ((uint32_t)(r47 & 0x0F) << 16) | ((uint32_t)r48 << 8) | r49;
+ text_p2.set(to_string_dec_uint(p2) + " (0x" + to_string_hex(p2, 5) + ")");
+
+ // Decode P3 (20-bit)
+ uint32_t p3 = ((uint32_t)(r47 >> 4) << 16) | ((uint32_t)r42 << 8) | r43;
+ text_p3.set(to_string_dec_uint(p3) + " (0x" + to_string_hex(p3, 5) + ")");
+
+ // Color code P2/P3 based on whether fractional is being used
+ if (p2 == 0 && p3 == 1) {
+ text_p2.set_style(Theme::getInstance()->fg_orange);
+ text_p3.set_style(Theme::getInstance()->fg_orange);
+ } else if (p3 > 1) {
+ text_p2.set_style(Theme::getInstance()->fg_green);
+ text_p3.set_style(Theme::getInstance()->fg_green);
+ } else {
+ // Default/neutral style when P2/P3 don't match known patterns
+ text_p2.set_style(Theme::getInstance()->fg_light);
+ text_p3.set_style(Theme::getInstance()->fg_light);
+ }
+
+ // === Calculate Output Frequency ===
+
+ // === Calculate Multisynth Divider ===
+ // Correct Si5351 formula:
+ // MS_DIV = (P2+P3 Γ (P1 + 512)) / (128 Γ P3)
+ // MS_DIV = P2/(128*P3) + P1+512/(128*P3)
+ // x = (P1 + 512) / 128
+ // y = P2/128
+ // z = P3
+ // f_out = f_vco / MS_DIV / R_DIV
+ // a = floor((P1 + 512) / 128)
+ // k = (P1 + 512) - 128*a
+ // b = b = (P2 + c*k) / 128
+ // c = P3
+ // In the case of integer division: b=0, c=1, so MS_DIV = a
+
+ uint64_t ms_div_numerator = (uint64_t)p2 + (uint64_t)p3 * (p1 + 512);
+ uint64_t ms_div_denominator = 128ULL * p3;
+
+ uint32_t x = (p1 + 512) / 128;
+ uint32_t y = p2 / 128;
+ uint32_t z = p3;
+
+ // For display, show the full fractional value
+ // MS_DIV = (a+b)/c
+ if (p3 > 1 && p2 > 0) {
+ std::string div_str = "(" + to_string_dec_uint(x) + "+" + to_string_dec_uint(y) + ")/" + to_string_dec_uint(z);
+ text_div.set(div_str);
+ } else {
+ std::string div_str = to_string_dec_uint(x);
+ text_div.set(div_str);
+ }
+
+ // === Calculate Output Frequency ===
+
+ // f_vco = 800,000,000 (PLL A)
+ // f_out in kHz = 800,000,000 / MS_DIV / r_div / 1000
+ // = 800,000 Γ ms_div_denominator / ms_div_numerator / r_div
+
+ uint32_t freq_khz = 0;
+ if (ms_div_numerator > 0) {
+ freq_khz = (uint32_t)((800000ULL * ms_div_denominator) / ms_div_numerator / r_div);
+ }
+
+ uint32_t freq_mhz = freq_khz / 1000;
+ uint32_t freq_frac = freq_khz % 1000;
+
+ text_freq.set(to_string_dec_uint(freq_mhz) + "." +
+ to_string_dec_uint(freq_frac / 100) +
+ to_string_dec_uint((freq_frac / 10) % 10) +
+ to_string_dec_uint(freq_frac % 10) + " MHz");
+
+ // Color code based on expected ~24.576 MHz for WFM stereo
+ if (freq_khz >= 24500 && freq_khz <= 24700) {
+ text_freq.set_style(Theme::getInstance()->fg_green);
+ } else if (freq_khz >= 24000 && freq_khz <= 26000) {
+ text_freq.set_style(Theme::getInstance()->fg_orange);
+ } else {
+ text_freq.set_style(Theme::getInstance()->fg_red);
+ }
+
+ // === Status Summary ===
+ // Expected values for 24.576 MHz (3.072 MHz * 8 decimation):
+ // VCO = 800 MHz
+ // Target freq = 49.152 MHz (before R_DIV=/2)
+ // MS_DIV = 800M / 49.152M = 16.276...
+ // a = 16, b = 53, c = 192
+ // P1 = 128*16 + floor(128*53/192) - 512 = 2048 + 35 - 512 = 1571
+ // P2 = 128*53 - 192*35 = 6784 - 6720 = 64
+ // P3 = 192
+
+ if (ms_int) {
+ text_status.set("ERROR: Integer mode! P2/P3 ignored!");
+ text_status.set_style(Theme::getInstance()->fg_red);
+ } else if (p2 == 0 && p3 == 1) {
+ text_status.set("WARN P2:0,P3:1 INT Equiv");
+ text_status.set_style(Theme::getInstance()->fg_orange);
+ } else if (p3 == 192 && p2 == 64) {
+ text_status.set("GOOD Exp 3.072M values!");
+ text_status.set_style(Theme::getInstance()->fg_green);
+ } else if (freq_khz >= 24500 && freq_khz <= 24700) {
+ text_status.set("OK: Freq in range");
+ text_status.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_status.set("CHECK: P2=" + to_string_dec_uint(p2) +
+ " P3=" + to_string_dec_uint(p3));
+ text_status.set_style(Theme::getInstance()->fg_orange);
+ }
+}
+#endif
+
+#ifdef PRALINE
+PralineClockDebugView::PralineClockDebugView(NavigationView& nav)
+ : View(),
+ rows{
+ {&t0_id, &t0_ma, &t0_mode, &t0_src, &t0_ph, &t0_st},
+ {&t1_id, &t1_ma, &t1_mode, &t1_src, &t1_ph, &t1_st},
+ {&t2_id, &t2_ma, &t2_mode, &t2_src, &t2_ph, &t2_st},
+ {&t3_id, &t3_ma, &t3_mode, &t3_src, &t3_ph, &t3_st},
+ {&t4_id, &t4_ma, &t4_mode, &t4_src, &t4_ph, &t4_st},
+ {&t5_id, &t5_ma, &t5_mode, &t5_src, &t5_ph, &t5_st},
+ {&t6_id, &t6_ma, &t6_mode, &t6_src, &t6_ph, &t6_st},
+ {&t7_id, &t7_ma, &t7_mode, &t7_src, &t7_ph, &t7_st}} {
+ add_children({&text_title, &text_lbl_pll, &text_pll_status,
+ &text_lbl_afe, &text_afe_rate, &text_lbl_n, &text_n_val,
+ &text_header,
+ &t0_id, &t0_ma, &t0_mode, &t0_src, &t0_ph, &t0_st,
+ &t1_id, &t1_ma, &t1_mode, &t1_src, &t1_ph, &t1_st,
+ &t2_id, &t2_ma, &t2_mode, &t2_src, &t2_ph, &t2_st,
+ &t3_id, &t3_ma, &t3_mode, &t3_src, &t3_ph, &t3_st,
+ &t4_id, &t4_ma, &t4_mode, &t4_src, &t4_ph, &t4_st,
+ &t5_id, &t5_ma, &t5_mode, &t5_src, &t5_ph, &t5_st,
+ &t6_id, &t6_ma, &t6_mode, &t6_src, &t6_ph, &t6_st,
+ &t7_id, &t7_ma, &t7_mode, &t7_src, &t7_ph, &t7_st,
+ &button_refresh, &button_done});
+
+ button_refresh.on_select = [this](Button&) { this->refresh(); };
+ button_done.on_select = [&nav](Button&) { nav.pop(); };
+
+ refresh();
+}
+
+void PralineClockDebugView::focus() {
+ button_refresh.focus();
+}
+
+void PralineClockDebugView::refresh() {
+ // 1. System Status
+ uint8_t status = portapack::clock_manager.si5351_read_status();
+ bool pll_a = !(status & 0x20);
+ bool pll_b = !(status & 0x40);
+ text_pll_status.set(std::string(pll_a ? "A:OK " : "A:ERR ") + (pll_b ? "B:OK" : "B:ERR"));
+ text_pll_status.set_style((pll_a && pll_b) ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // 2. AFE & Decimation Info
+ uint32_t base_rate = portapack::clock_manager.get_sampling_frequency();
+ uint8_t n = portapack::clock_manager.get_resampling_n();
+ text_afe_rate.set(to_string_dec_uint(base_rate << n) + " Hz");
+ text_n_val.set(to_string_dec_uint(n));
+
+ // 3. Clock Table Decoding
+ uint8_t output_en = portapack::clock_manager.si5351_read_register(3);
+ const char* ma_lookup[] = {"2m", "4m", "6m", "8m"};
+
+ for (size_t i = 0; i < 8; i++) {
+ uint8_t ctrl = portapack::clock_manager.si5351_read_register(16 + i);
+
+ // mA (Bits 1:0)
+ rows[i].ma->set(ma_lookup[ctrl & 0x03]);
+
+ // Mode (Bit 6: 1=Integer, 0=Fractional)
+ rows[i].mode->set((ctrl & 0x40) ? "INT" : "FRAC");
+ rows[i].mode->set_style((ctrl & 0x40) ? Theme::getInstance()->fg_blue : Theme::getInstance()->fg_yellow);
+
+ // PLL Source (Bit 5: 0=PLLA, 1=PLLB)
+ rows[i].src->set((ctrl & 0x20) ? "PLLB" : "PLLA");
+ rows[i].src->set_style((ctrl & 0x20) ? Theme::getInstance()->fg_blue : Theme::getInstance()->fg_green);
+
+ // Phase (Bit 4: 1=Inverted, 0=Normal)
+ // Use 0x10 (Bit 4)
+ rows[i].phase->set((ctrl & 0x10) ? "INVRT" : "NORM ");
+ rows[i].phase->set_style((ctrl & 0x10) ? Theme::getInstance()->fg_orange : Theme::getInstance()->fg_light);
+
+ // Status (Powered On and Output Enabled)
+ bool is_on = !(ctrl & 0x80) && !(output_en & (1 << i));
+ rows[i].stat->set(is_on ? "ON" : "OFF");
+ rows[i].stat->set_style(is_on ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ }
+}
+#endif
+
+#ifdef PRALINE
+/* GPIODebugView *************************************************/
+GPIODebugView::GPIODebugView(NavigationView& nav) {
+ add_children({
+ &text_lbl_gpio4,
+ &text_lbl_mixr1,
+ &text_mixr1,
+ &text_lbl_pin4,
+ &text_pin4,
+ &text_lbl_set4,
+ &text_set4,
+ &text_lbl_lpf_bit,
+ &text_lpf_dir,
+ &text_lpf_pin,
+ &text_lpf_set,
+ &button_lpf_toggle,
+ &button_lpf_on,
+ &button_lpf_off,
+ &text_lbl_amp_bit,
+ &text_amp_dir,
+ &text_amp_pin,
+ &text_amp_set,
+ &button_amp_toggle,
+ &button_amp_on,
+ &button_amp_off,
+ &text_lbl_gpio3,
+ &text_lbl_mix_bit,
+ &text_mix_dir,
+ &text_mix_pin,
+ &text_mix_set,
+ &button_refresh,
+ &button_done,
+ });
+
+ text_lbl_gpio4.set_style(Theme::getInstance()->fg_yellow);
+ text_lbl_gpio3.set_style(Theme::getInstance()->fg_yellow);
+
+ // LPF control buttons
+ button_lpf_toggle.on_select = [this](Button&) {
+ // Read current state
+ uint32_t current = LPC_GPIO->PIN[4];
+ bool current_state = (current >> 8) & 1;
+
+ // Toggle
+ if (current_state) {
+ LPC_GPIO->CLR[4] = (1 << 8); // Clear bit 8
+ } else {
+ LPC_GPIO->SET[4] = (1 << 8); // Set bit 8
+ }
+
+ refresh();
+ };
+
+ button_lpf_on.on_select = [this](Button&) {
+ LPC_GPIO->SET[4] = (1 << 8); // Force ON
+ refresh();
+ };
+
+ button_lpf_off.on_select = [this](Button&) {
+ LPC_GPIO->CLR[4] = (1 << 8); // Force OFF
+ refresh();
+ };
+
+ // RF Amp control buttons
+ button_amp_toggle.on_select = [this](Button&) {
+ uint32_t current = LPC_GPIO->PIN[4];
+ bool current_state = (current >> 9) & 1;
+
+ if (current_state) {
+ LPC_GPIO->CLR[4] = (1 << 9);
+ } else {
+ LPC_GPIO->SET[4] = (1 << 9);
+ }
+
+ refresh();
+ };
+
+ button_amp_on.on_select = [this](Button&) {
+ LPC_GPIO->SET[4] = (1 << 9); // Force ON
+ refresh();
+ };
+
+ button_amp_off.on_select = [this](Button&) {
+ LPC_GPIO->CLR[4] = (1 << 9); // Force OFF
+ refresh();
+ };
+
+ button_refresh.on_select = [this](Button&) {
+ refresh();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ refresh();
+}
+
+void GPIODebugView::focus() {
+ button_refresh.focus();
+}
+
+void GPIODebugView::refresh() {
+ // Read GPIO4 registers
+ uint32_t gpio4_dir = LPC_GPIO->DIR[4]; // Direction: 1=output, 0=input
+ uint32_t gpio4_pin = LPC_GPIO->PIN[4]; // Actual pin state
+ uint32_t gpio4_set = LPC_GPIO->SET[4]; // What we're trying to output
+
+ // Display full registers
+ // text_dir4.set("0x" + to_string_hex(gpio4_dir, 8));
+ text_pin4.set("0x" + to_string_hex(gpio4_pin, 8));
+ text_set4.set("0x" + to_string_hex(gpio4_set, 8));
+
+ // Extract bit 8 (LPF)
+ bool lpf_dir = (gpio4_dir >> 8) & 1;
+ bool lpf_pin = (gpio4_pin >> 8) & 1;
+ bool lpf_set = (gpio4_set >> 8) & 1;
+
+ text_lpf_dir.set("DIR: " + std::string(lpf_dir ? "OUT" : "IN"));
+ text_lpf_pin.set("PIN: " + std::string(lpf_pin ? "1" : "0"));
+ text_lpf_set.set("SET: " + std::string(lpf_set ? "1" : "0"));
+
+ // Color code
+ text_lpf_dir.set_style(lpf_dir ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ text_lpf_pin.set_style(lpf_pin ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // Extract bit 9 (RF Amp)
+ bool amp_dir = (gpio4_dir >> 9) & 1;
+ bool amp_pin = (gpio4_pin >> 9) & 1;
+ bool amp_set = (gpio4_set >> 9) & 1;
+
+ text_amp_dir.set("DIR: " + std::string(amp_dir ? "OUT" : "IN"));
+ text_amp_pin.set("PIN: " + std::string(amp_pin ? "1" : "0"));
+ text_amp_set.set("SET: " + std::string(amp_set ? "1" : "0"));
+
+ text_amp_dir.set_style(amp_dir ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ text_amp_pin.set_style(amp_pin ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // Read GPIO3 (Mixer) - bit 2
+ uint32_t gpio3_dir = LPC_GPIO->DIR[3];
+ uint32_t gpio3_pin = LPC_GPIO->PIN[3];
+ uint32_t gpio3_set = LPC_GPIO->SET[3];
+
+ bool mix_dir = (gpio3_dir >> 2) & 1;
+ bool mix_pin = (gpio3_pin >> 2) & 1;
+ bool mix_set = (gpio3_set >> 2) & 1;
+
+ text_mix_dir.set("DIR: " + std::string(mix_dir ? "OUT" : "IN"));
+ text_mix_pin.set("PIN: " + std::string(mix_pin ? "1" : "0"));
+ text_mix_set.set("SET: " + std::string(mix_set ? "1" : "0"));
+
+ text_mix_dir.set_style(mix_dir ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+ text_mix_pin.set_style(mix_pin ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+
+ // Read GPIO5 (Mixer R1) - bit 2
+ uint32_t gpio3_state = LPC_GPIO->PIN[3]; // GPIO3 for mixer
+ bool mix_n_actual = (gpio3_state >> 2) & 1; // GPIO3[2]
+ uint32_t gpio5_state = LPC_GPIO->PIN[5];
+ bool mix_r10_pin = (gpio5_state >> 6) & 1; // GPIO5[6] = P2_6
+ // Mixer is active LOW, so invert for display
+ bool mixer_enabled = !mix_n_actual;
+
+ // Append to existing mixer display:
+ text_mixr1.set(
+ std::string(mixer_enabled ? "ENABLED" : "BYPASSED") +
+ " P6_3=" + to_string_dec_uint(mix_n_actual ? 1 : 0) +
+ " P2_6=" + to_string_dec_uint(mix_r10_pin ? 1 : 0));
+
+ text_mixr1.set_style(mixer_enabled ? Theme::getInstance()->fg_green : Theme::getInstance()->fg_red);
+}
+#endif
+
+#ifdef PRALINE
+/* RFFC5072StatusView *************************************************/
+
+RFFC5072StatusView::RFFC5072StatusView(NavigationView& nav)
+ : nav_(nav) {
+ add_children({
+ &text_gpio4,
+ &text_ctrl,
+ &text_lbl_enabled,
+ &text_enabled,
+ &text_lbl_freq,
+ &text_freq,
+ &text_lbl_path,
+ &text_path,
+ &text_lbl_mixer,
+ &text_mixer,
+ &text_lbl_r0,
+ &text_r0,
+ &text_lbl_r1,
+ &text_r1,
+ &text_lbl_r2,
+ &text_r2,
+ &text_lbl_n,
+ &text_n,
+ &text_lbl_lodiv,
+ &text_lodiv,
+ &text_lbl_calc,
+ &text_calc,
+ &text_status,
+ &text_status2,
+ &text_status3,
+ &text_regs_status,
+ &button_refresh,
+ &button_force,
+ &button_done,
+ });
+
+ button_refresh.on_select = [this](Button&) {
+ refresh_status();
+ };
+
+ button_force.on_select = [this](Button&) {
+ // Force ENX to OUTPUT and drive LOW
+ // LPC_GPIO->DIR[2] |= (1 << 13); // Set as OUTPUT
+ // LPC_GPIO->CLR[2] = (1 << 13); // Drive LOW (enabled)
+
+ // refresh_status();
+
+ // Disable RFFC5072
+ // uint32_t r0 = radio::debug::first_if::register_read(0);
+ // radio::debug::first_if::register_write(0, r0 & ~0x0010); // Clear ENBL
+
+ // Wait 1ms
+ // chThdSleepMilliseconds(1);
+
+ // Re-enable - this triggers new calibration
+ // radio::debug::first_if::register_write(0, r0 | 0x0010); // Set ENBL
+
+ // Wait for calibration
+ // chThdSleepMilliseconds(10);
+
+ // refresh_status();
+
+ // Force lodiv=4 (log2=2) instead of lodiv=2 (log2=1)
+ // This gives VCO = LO Γ 4 = 2595 Γ 4 = 10380 MHz - TOO HIGH!
+
+ // Actually, we need lodiv=1 which gives VCO = 2595 MHz - TOO LOW (below 2700)
+
+ // Let's try a different approach: manually write registers for VCO ~ 3500 MHz
+ // LO = 3500/2 = 1750 MHz (not useful for FM, but tests if VCO can lock)
+
+ // VCO = 3500 MHz, lodiv=2, presc=2, f_ref=40
+ // N = (VCO Γ presc) / f_ref = (3500 Γ 2) / 40 = 175
+
+ // Write P2_FREQ1: N=175, lodiv=1 (log2), presc=1 (log2)
+ // uint16_t p2_freq1 = (175 << 7) | (1 << 4) | (1 << 2);
+ // radio::debug::first_if::register_write(15, p2_freq1);
+
+ // Clear fractional part
+ // radio::debug::first_if::register_write(16, 0);
+ // radio::debug::first_if::register_write(17, 0);
+
+ // Trigger recalibration by toggling ENBL
+ // uint32_t r0 = radio::debug::first_if::register_read(0);
+ // radio::debug::first_if::register_write(0, r0 & ~0x0010);
+ // chThdSleepMilliseconds(1);
+ // radio::debug::first_if::register_write(0, r0 | 0x0010);
+ // chThdSleepMilliseconds(20);
+
+ // refresh_status();
+
+ // Test SPI SDATA direction switching
+ // PRALINE: SDATA = P9_2 = GPIO4[14]
+
+ // Check current direction
+ uint32_t dir_before = LPC_GPIO->DIR[4];
+ bool sdata_output_before = (dir_before >> 14) & 1;
+
+ // Try a register read
+ uint32_t dummy = radio::debug::first_if::register_read(0);
+ (void)dummy;
+
+ // Check direction after read
+ uint32_t dir_after = LPC_GPIO->DIR[4];
+ bool sdata_output_after = (dir_after >> 14) & 1;
+
+ // Read the actual SDATA pin state
+ uint32_t pin_state = LPC_GPIO->PIN[4];
+ bool sdata_pin = (pin_state >> 14) & 1;
+
+ text_status.set("SDATA: dir_b=" + to_string_dec_uint(sdata_output_before) +
+ " dir_a=" + to_string_dec_uint(sdata_output_after) +
+ " pin=" + to_string_dec_uint(sdata_pin) + " ");
+
+ // If both are 1 (OUTPUT), the read direction switch isn't happening
+ // if (sdata_output_before && sdata_output_after) {
+ // text_status2.set("ERROR: SDATA stuck as OUTPUT! ");
+ // text_status2.set_style(Theme::getInstance()->fg_red);
+ //} else {
+ // text_status2.set("SDATA direction OK ");
+ // text_status2.set_style(Theme::getInstance()->fg_green);
+ //}
+
+ // Test: Write a known pattern to register 0, then read back
+ // Register 0 (DEV_CTRL) default = 0xBEFA
+
+ // Step 1: Read current value
+ uint32_t before = radio::debug::first_if::register_read(0);
+
+ // Step 2: Write a different value (change ENBL bit to toggle)
+ uint32_t test_val = before ^ 0x0010; // Toggle ENBL bit
+ radio::debug::first_if::register_write(0, test_val);
+
+ // Step 3: Read back
+ uint32_t after = radio::debug::first_if::register_read(0);
+
+ // Step 4: Restore original
+ radio::debug::first_if::register_write(0, before);
+
+ // Display results
+ text_status.set("WR TEST: " + to_string_hex(before, 4) +
+ "->" + to_string_hex(test_val, 4) +
+ " rb:" + to_string_hex(after, 4));
+
+ // If after == before (not test_val), reads are broken
+ // If after == test_val, reads work
+ if (after == test_val) {
+ text_status2.set("READ-AFTER-WRITE: PASS! ");
+ text_status2.set_style(Theme::getInstance()->fg_green);
+ } else if (after == before) {
+ text_status2.set("READ-AFTER-WRITE: FAIL (no change) ");
+ text_status2.set_style(Theme::getInstance()->fg_red);
+ } else {
+ text_status2.set("READ-AFTER-WRITE: CORRUPT " + to_string_hex(after, 4) + " ");
+ text_status2.set_style(Theme::getInstance()->fg_red);
+ }
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ // Initial update
+ refresh_status();
+}
+
+void RFFC5072StatusView::focus() {
+ button_refresh.focus();
+}
+
+void RFFC5072StatusView::refresh_status() {
+ // === DIAGNOSTIC: Capture initial GPIO state ===
+ uint32_t gpio2_initial = LPC_GPIO->PIN[2];
+ uint32_t dir2_initial = LPC_GPIO->DIR[2];
+ bool enx_initial = (gpio2_initial >> 13) & 1;
+
+ // === READ RAW GPIO STATES FOR DISPLAY ===
+ uint32_t gpio2_dir = LPC_GPIO->DIR[2];
+ uint32_t gpio2_pin = LPC_GPIO->PIN[2];
+ bool enx_is_output = (gpio2_dir >> 13) & 1;
+ bool resetx_is_output = (gpio2_dir >> 14) & 1;
+
+ // === LOCK DETECT ===
+ uint32_t gpio6_pin = LPC_GPIO->PIN[6];
+ bool rffc_locked = (gpio6_pin >> 25) & 1;
+
+ // === FPGA REGISTERS (non-SPI) ===
+ uint8_t fpga_reg1 = radio::debug::fpga::register_read(1);
+ uint8_t fpga_reg2 = radio::debug::fpga::register_read(2);
+ uint8_t fpga_reg3 = radio::debug::fpga::register_read(3);
+ text_regs_status.set("FPGA R1:" + to_string_hex(fpga_reg1, 2) +
+ " R2:" + to_string_hex(fpga_reg2, 2) +
+ " R3:" + to_string_hex(fpga_reg3, 2));
+
+ // === CONTROL PINS ===
+ bool enx = (gpio2_pin >> 13) & 1;
+ bool resetx = (gpio2_pin >> 14) & 1;
+ text_ctrl.set("ENX: " + std::string(enx ? "DIS" : "EN") +
+ " O:" + std::string(enx_is_output ? "Y" : "N") +
+ " | RSTX: " + std::string(resetx ? "H" : "L") +
+ " O:" + std::string(resetx_is_output ? "Y" : "N"));
+ text_ctrl.set_style((enx == 0 && resetx == 1) ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_red);
+
+ // === DIAGNOSTIC: Check BEFORE first RFFC5072 SPI read ===
+ uint32_t gpio2_before_spi = LPC_GPIO->PIN[2];
+ bool enx_before_spi = (gpio2_before_spi >> 13) & 1;
+
+ // === RFFC5072 REGISTERS (SPI reads - this is where corruption happens) ===
+ uint32_t r0 = radio::debug::first_if::register_read(0);
+
+ // === DIAGNOSTIC: Check AFTER first read ===
+ uint32_t gpio2_after_r0 = LPC_GPIO->PIN[2];
+ bool enx_after_r0 = (gpio2_after_r0 >> 13) & 1;
+
+ uint32_t r15 = radio::debug::first_if::register_read(15);
+
+ // === DIAGNOSTIC: Check AFTER second read ===
+ uint32_t gpio2_after_r15 = LPC_GPIO->PIN[2];
+ bool enx_after_r15 = (gpio2_after_r15 >> 13) & 1;
+
+ uint32_t r16 = radio::debug::first_if::register_read(16);
+
+ // === DIAGNOSTIC: Check AFTER third read ===
+ uint32_t gpio2_final = LPC_GPIO->PIN[2];
+ uint32_t dir2_final = LPC_GPIO->DIR[2];
+ bool enx_final = (gpio2_final >> 13) & 1;
+
+ // === Display register values ===
+ text_r0.set(to_string_hex(r0, 4));
+ text_r1.set(to_string_hex(r15, 4) + " (R15)");
+ text_r2.set(to_string_hex(r16, 4) + " (R16)");
+
+ bool enabled = (r0 & 0x0010) != 0;
+ text_enabled.set(enabled ? "ENABLED" : "DISABLED");
+ text_enabled.set_style(enabled ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_red);
+
+ // === Decode frequency info (keeping existing code) ===
+ uint16_t n_int = (r15 >> 7) & 0x1FF;
+ uint8_t lodiv_sel = (r15 >> 4) & 0x07;
+ uint8_t presc_sel = (r15 >> 2) & 0x03;
+ text_n.set(to_string_dec_uint(n_int));
+
+ uint16_t lodiv_val = 1u << lodiv_sel;
+ uint16_t presc_val = 1u << presc_sel;
+ text_lodiv.set("/" + to_string_dec_uint(lodiv_val) +
+ " (P:/" + to_string_dec_uint(presc_val) + ")");
+
+ const uint32_t f_ref_mhz = 40;
+ uint32_t f_vco_mhz = (f_ref_mhz * n_int) / presc_val;
+ uint32_t f_lo_mhz = f_vco_mhz / lodiv_val;
+
+ bool vco_ok = (f_vco_mhz >= 2700) && (f_vco_mhz <= 5400);
+ bool lo_ok = (f_lo_mhz >= 85) && (f_lo_mhz <= 4200);
+ bool in_bypass_range = (f_lo_mhz >= 2320) && (f_lo_mhz <= 2740);
+
+ if (in_bypass_range) {
+ text_calc.set(to_string_dec_uint(f_lo_mhz) + " MHz (MID)");
+ text_calc.set_style(Theme::getInstance()->fg_orange);
+ } else {
+ text_calc.set(to_string_dec_uint(f_lo_mhz) + " MHz");
+ text_calc.set_style(lo_ok ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_red);
+ }
+
+ text_freq.set(to_string_dec_uint(f_vco_mhz) + " MHz VCO");
+ text_freq.set_style(vco_ok ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_red);
+
+ bool path2_active = (r0 & 0x0020) != 0;
+ text_path.set(path2_active ? "PATH2" : "PATH1");
+ text_mixer.set(path2_active ? "ACTIVE" : "INACTIVE");
+ text_mixer.set_style(path2_active ? Theme::getInstance()->fg_green
+ : Theme::getInstance()->fg_orange);
+
+ // === DIAGNOSTIC STATUS (replaces normal status) ===
+ // Read register 31 with readsel=0 (device ID)
+ radio::debug::first_if::register_write(0, (r0 & 0xFFF0) | 0x0000); // readsel=0
+ uint32_t device_id = radio::debug::first_if::register_read(31);
+
+ // Read calibration status (readback register 1)
+ // First, set DEV_CTRL.readsel = 1, then read READBACK register
+ uint32_t dev_ctrl_orig = radio::debug::first_if::register_read(0); // Save original
+
+ // Write DEV_CTRL with readsel=1 (bits 3:0)
+ radio::debug::first_if::register_write(0, (dev_ctrl_orig & 0xFFF0) | 0x0001);
+
+ // Now read the READBACK register (register address for readback)
+ uint32_t cal_status = radio::debug::first_if::register_read(31); // READBACK is at reg 31
+
+ // Decode calibration status:
+ // Bit 15: lock (should be 1)
+ // Bits 14:8: ct_cal (coarse tune calibration value, 0-127)
+ // Bits 7:1: cp_cal (charge pump calibration value)
+ // Bit 0: ctfail (1 = calibration FAILED)
+
+ bool lock_bit = (cal_status >> 15) & 1;
+ uint8_t ct_cal = (cal_status >> 8) & 0x7F;
+ uint8_t cp_cal = (cal_status >> 1) & 0x7F;
+ bool ct_fail = cal_status & 1;
+
+ // Add to refresh_status():
+ uint32_t r6 = radio::debug::first_if::register_read(6);
+ uint32_t r5 = radio::debug::first_if::register_read(5);
+ uint32_t r3 = radio::debug::first_if::register_read(3); // VCO_CTRL
+
+ // Check SDATA (GPIO4[14]) direction
+ uint32_t gpio4_dir = LPC_GPIO->DIR[4];
+ bool sdata_is_output = (gpio4_dir >> 14) & 1;
+ text_gpio4.set("GPIO4 DIR: " + to_string_hex(gpio4_dir, 8) +
+ " SDATA=" + std::string(sdata_is_output ? "OUT" : "IN"));
+
+ // Display these values
+ text_status2.set("CAL ct=" + to_string_dec_uint(ct_cal) +
+ " cp=" + to_string_dec_uint(cp_cal) +
+ (ct_fail ? " FAIL!" : " OK") +
+ " lck_b=" + to_string_dec_uint(lock_bit));
+ text_status3.set("R3:" + to_string_hex(r3, 4) +
+ " R5:" + to_string_hex(r5, 4) +
+ " R6:" + to_string_hex(r6, 4));
+
+ if (enx_initial != enx_final || dir2_initial != dir2_final) {
+ // ENX or DIR changed - report which operation caused it
+ std::string diag = "CHG: ";
+ if (enx_initial != enx_before_spi) diag += "pre ";
+ if (enx_before_spi != enx_after_r0) diag += "R0 ";
+ if (enx_after_r0 != enx_after_r15) diag += "R15 ";
+ if (enx_after_r15 != enx_final) diag += "R16 ";
+ diag += std::to_string(enx_initial) + "->" + std::to_string(enx_final);
+
+ if (dir2_initial != dir2_final) {
+ diag += " DIR!";
+ }
+
+ text_status.set(diag);
+ text_status.set_style(Theme::getInstance()->fg_red);
+
+ // Blink LED
+ /*for (int i = 0; i < 3; i++) {
+ hackrf::one::led_rx.on();
+ chThdSleepMilliseconds(100);
+ hackrf::one::led_rx.off();
+ chThdSleepMilliseconds(100);
+ }*/
+ } else {
+ // No change - normal status
+ if (!rffc_locked) {
+ text_status.set("ID 0x" + to_string_hex(device_id, 4) + " PLL UNLOCKED!");
+ text_status.set_style(Theme::getInstance()->fg_red);
+ } else if (enx == 1) {
+ text_status.set("ID 0x" + to_string_hex(device_id, 4) + " DSBLD,ENX=1!");
+ text_status.set_style(Theme::getInstance()->fg_red);
+ } else {
+ text_status.set("ID 0x" + to_string_hex(device_id, 4) + " Passed!");
+ text_status.set_style(Theme::getInstance()->fg_green);
+ }
+ }
+}
+
+/* RFFCTuningDebugView *************************************************/
+RFFCTuningDebugView::RFFCTuningDebugView(NavigationView& nav) {
+ add_children({
+ &text_title,
+ &text_lbl_called,
+ &text_called,
+ &text_lbl_req,
+ &text_req,
+ &text_lbl_exp_n,
+ &text_exp_n,
+ &text_lbl_act_n,
+ &text_act_n,
+ &text_lbl_exp_div,
+ &text_exp_div,
+ &text_lbl_act_div,
+ &text_act_div,
+ &text_lbl_calc,
+ &text_calc,
+ &text_lbl_calc_lo,
+ &text_calc_lo,
+ &text_lbl_calc_vco,
+ &text_calc_vco,
+ &text_lbl_vco,
+ &text_vco,
+ &text_lbl_n_q24,
+ &text_n_q24,
+ &text_status,
+ &button_refresh,
+ &button_done,
+ });
+
+ button_refresh.on_select = [this](Button&) {
+ refresh();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ refresh();
+}
+
+void RFFCTuningDebugView::focus() {
+ button_refresh.focus();
+}
+
+void RFFCTuningDebugView::refresh() {
+ // Get expected values from last tuning attempt
+ auto tuning = radio::debug::first_if::get_tuning_info();
+
+ // Show if set_frequency was ever called
+ if (tuning.was_called) {
+ text_called.set("YES");
+ text_called.set_style(Theme::getInstance()->fg_green);
+
+ text_req.set(to_string_dec_uint(tuning.requested_freq_mhz) + " MHz");
+ text_exp_n.set(to_string_dec_uint(tuning.expected_n));
+
+ uint16_t exp_lo = 1 << tuning.expected_lodiv;
+ uint16_t exp_pr = 1 << tuning.expected_presc;
+ text_exp_div.set(to_string_dec_uint(exp_lo) + " / " + to_string_dec_uint(exp_pr));
+ } else {
+ text_called.set("NO");
+ text_called.set_style(Theme::getInstance()->fg_red);
+ text_req.set("---");
+ text_exp_n.set("---");
+ text_exp_div.set("---");
+ }
+
+ // Read actual hardware values
+ uint32_t r15 = radio::debug::first_if::register_read(15);
+
+ uint16_t act_n = (r15 >> 7) & 0x1FF;
+ uint8_t act_lo_sel = (r15 >> 4) & 0x07;
+ uint8_t act_pr_sel = (r15 >> 2) & 0x03;
+
+ uint16_t act_lo = 1 << act_lo_sel;
+ uint16_t act_pr = 1 << act_pr_sel;
+
+ text_act_n.set(to_string_dec_uint(act_n));
+ text_act_div.set(to_string_dec_uint(act_lo) + " / " + to_string_dec_uint(act_pr));
+
+ // Calculate what this produces
+ uint32_t calc_vco = (40 * act_n) / act_pr;
+ uint32_t calc_lo = calc_vco / act_lo;
+ text_calc.set(to_string_dec_uint(calc_lo) + " MHz");
+ text_vco.set(to_string_dec_uint(tuning.calculated_vco_mhz) + " MHz");
+
+ text_calc_lo.set(to_string_dec_uint(tuning.calc_lo_freq_mhz) + " MHz");
+ text_calc_vco.set(to_string_dec_uint(tuning.calc_vco_inside_mhz) + " MHz");
+ text_n_q24.set(to_string_dec_uint(tuning.calc_n_q24 >> 24)); // Show integer part
+
+ // Status comparison
+ if (!tuning.was_called) {
+ text_status.set("RFFC Freq set NEVER called!");
+ text_status.set_style(Theme::getInstance()->fg_red);
+ } else if (act_n == tuning.expected_n) {
+ text_status.set("MATCH! Hardware as expected!");
+ text_status.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_status.set("MISMATCH! Exp:" + to_string_dec_uint(tuning.expected_n) +
+ " Act:" + to_string_dec_uint(act_n));
+ text_status.set_style(Theme::getInstance()->fg_red);
+ }
+}
+
+/* MAX2831DebugView *************************************************/
+MAX2831DebugView::MAX2831DebugView(NavigationView& nav) {
+ add_children({
+ &text_title,
+ &text_lbl_called,
+ &text_called,
+ &text_lbl_valid,
+ &text_valid,
+ &text_lbl_req,
+ &text_req,
+ &text_lbl_calc_n,
+ &text_calc_n,
+ &text_lbl_calc_frac,
+ &text_calc_frac,
+ &text_spacer,
+ &text_lbl_r3,
+ &text_r3,
+ &text_lbl_r4,
+ &text_r4,
+ &text_lbl_act_n,
+ &text_act_n,
+ &text_lbl_act_frac,
+ &text_act_frac,
+ &text_lbl_calc_freq,
+ &text_calc_freq,
+ &text_status,
+ &button_refresh,
+ &button_done,
+ });
+
+ text_spacer.set_style(Theme::getInstance()->fg_yellow);
+
+ button_refresh.on_select = [this](Button&) {
+ refresh();
+ };
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ refresh();
+}
+
+void MAX2831DebugView::focus() {
+ button_refresh.focus();
+}
+
+void MAX2831DebugView::refresh() {
+ auto info = get_max2831_info();
+
+ // Show if set_frequency was called
+ if (info.set_frequency_called) {
+ text_called.set("YES");
+ text_called.set_style(Theme::getInstance()->fg_green);
+
+ if (info.frequency_valid) {
+ text_valid.set("YES (2.3-2.6G)");
+ text_valid.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_valid.set("NO - OUT OF RANGE!");
+ text_valid.set_style(Theme::getInstance()->fg_red);
+ }
+
+ text_req.set(to_string_dec_uint(info.requested_freq_mhz) + " MHz");
+ text_calc_n.set(to_string_dec_uint(info.calculated_n));
+ text_calc_frac.set(to_string_hex(info.calculated_frac, 5));
+ } else {
+ text_called.set("NO");
+ text_called.set_style(Theme::getInstance()->fg_red);
+ text_valid.set("---");
+ text_req.set("---");
+ text_calc_n.set("---");
+ text_calc_frac.set("---");
+ }
+
+ // Read actual hardware registers
+ uint32_t r3 = radio::debug::second_if::register_read(3);
+ uint32_t r4 = radio::debug::second_if::register_read(4);
+
+ text_r3.set(to_string_hex(r3, 4));
+ text_r4.set(to_string_hex(r4, 4));
+
+ // Decode actual values from registers
+ uint16_t act_n = r3 & 0xFF;
+ uint32_t act_frac_lo = (r3 >> 8) & 0x3F;
+ uint32_t act_frac_hi = r4 & 0x3FFF;
+ uint32_t act_frac = (act_frac_hi << 6) | act_frac_lo;
+
+ text_act_n.set(to_string_dec_uint(act_n));
+ text_act_frac.set(to_string_hex(act_frac, 5));
+
+ // Calculate actual frequency from registers
+ // F_LO = 20 MHz Γ (N + Frac/2^20)
+ // For display, show integer part only
+ uint32_t calc_freq_mhz = 20 * act_n;
+ // Add fractional contribution (approximate)
+ uint32_t frac_contribution = (act_frac * 20) >> 20;
+ calc_freq_mhz += frac_contribution;
+
+ text_calc_freq.set(to_string_dec_uint(calc_freq_mhz) + " MHz");
+
+ // Status
+ if (!info.set_frequency_called) {
+ text_status.set("MAX2831 set_frequency\nNEVER called!");
+ text_status.set_style(Theme::getInstance()->fg_red);
+ } else if (!info.frequency_valid) {
+ text_status.set("Freq " + to_string_dec_uint(info.requested_freq_mhz) +
+ " MHz OUT OF RANGE!\n(need 2300-2600)");
+ text_status.set_style(Theme::getInstance()->fg_red);
+ } else if (act_n == info.calculated_n && act_frac == info.calculated_frac) {
+ text_status.set("MATCH!\nHardware = Expected");
+ text_status.set_style(Theme::getInstance()->fg_green);
+ } else {
+ text_status.set("MISMATCH!\nN: exp=" + to_string_dec_uint(info.calculated_n) +
+ " act=" + to_string_dec_uint(act_n));
+ text_status.set_style(Theme::getInstance()->fg_red);
+ }
+}
+
+#endif
+
+#endif
/* DebugPeripheralsMenuView **********************************************/
DebugPeripheralsMenuView::DebugPeripheralsMenuView(NavigationView& nav)
@@ -377,11 +3432,21 @@ DebugPeripheralsMenuView::DebugPeripheralsMenuView(NavigationView& nav)
}
void DebugPeripheralsMenuView::on_populate() {
+#ifdef PRALINE
+ const char* max283x = "MAX2831";
+#else
const char* max283x = hackrf_r9 ? "MAX2839" : "MAX2837";
+#endif
const char* si5351x = hackrf_r9 ? "Si5351A" : "Si5351C";
add_items({
{"RFFC5072", Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_peripherals_details, [this]() { nav_.push("RFFC5072", RegistersWidgetConfig{CT_RFFC5072, 31, 31, 16}); }},
+#ifdef PRALINE
+ {max283x, Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_peripherals_details, [this, max283x]() { nav_.push(max283x, RegistersWidgetConfig{CT_MAX283X, 16, 16, 14}); }},
+ {"FPGA", Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_peripherals_details, [this]() { nav_.push("FPGA (iCE40)", RegistersWidgetConfig{CT_FPGA, 6, 6, 8}); }},
+#else
{max283x, Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_peripherals_details, [this, max283x]() { nav_.push(max283x, RegistersWidgetConfig{CT_MAX283X, 32, 32, 10}); }},
+#endif
+ {"SGPIO", Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_peripherals_details, [this]() { nav_.push("SGPIO", RegistersWidgetConfig{CT_SGPIO, 6, 6, 16}); }},
{si5351x, Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_peripherals_details, [this, si5351x]() { nav_.push(si5351x, RegistersWidgetConfig{CT_SI5351, 188, 96, 8}); }},
{audio::debug::codec_name(), Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_peripherals_details, [this]() { nav_.push(audio::debug::codec_name(), RegistersWidgetConfig{CT_AUDIO, audio::debug::reg_count(), audio::debug::reg_count(), audio::debug::reg_bits()}); }},
});
@@ -413,11 +3478,49 @@ DebugMenuView::DebugMenuView(NavigationView& nav)
set_max_rows(2); // allow wider buttons
}
+#ifdef PRALINE
+/* PralineDebugMenuView *************************************************/
+
+PralineDebugMenuView::PralineDebugMenuView(NavigationView& nav)
+ : nav_(nav) {
+ set_max_rows(2); // Allows wider buttons for descriptive titles
+}
+
+void PralineDebugMenuView::on_populate() {
+ if (portapack::persistent_memory::show_gui_return_icon()) {
+ add_items({{"..", ui::Theme::getInstance()->fg_light->foreground, &bitmap_icon_previous, [this]() { nav_.pop(); }}});
+ }
+ add_items({
+ {"WFM Audio", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"Clocks", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"MSynth", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"Radio Diag", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"Radio Debug", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"Signal Path", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"System Diag", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"PLL A", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"PLL B", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"GPIO", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"RFFC Status", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"RFFC Tuning", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"MAX2831", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"Si5351", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"SGPIO Clk", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"Baseband", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"SGPIO Live", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ {"RX Test", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_peripherals, [this]() { nav_.push(); }},
+ });
+}
+#endif
+
void DebugMenuView::on_populate() {
if (portapack::persistent_memory::show_gui_return_icon()) {
add_items({{"..", ui::Theme::getInstance()->fg_light->foreground, &bitmap_icon_previous, [this]() { nav_.pop(); }}});
}
add_items({
+#ifdef PRALINE
+ {"Pro Debug", ui::Theme::getInstance()->fg_yellow->foreground, &bitmap_icon_setup, [this]() { nav_.push(); }},
+#endif
{"Buttons Test", ui::Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_controls, [this]() { nav_.push(); }},
{"M0 Stack Dump", ui::Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_memory, [this]() { stack_dump(); }},
{"Memory Dump", ui::Theme::getInstance()->fg_darkcyan->foreground, &bitmap_icon_memory, [this]() { nav_.push(); }},
@@ -515,7 +3618,7 @@ void DebugPmemView::update() {
DebugScreenTest::DebugScreenTest(NavigationView& nav)
: nav_{nav} {
set_focusable(true);
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
}
bool DebugScreenTest::on_key(const KeyEvent key) {
@@ -525,10 +3628,10 @@ bool DebugScreenTest::on_key(const KeyEvent key) {
nav_.pop();
break;
case KeyEvent::Down:
- painter.fill_rectangle({0, 0, screen_width, screen_height}, std::rand());
+ painter.fill_rectangle({0, 0, screen_width, screen_height}, rand());
break;
case KeyEvent::Left:
- pen_color = std::rand();
+ pen_color = rand();
break;
default:
break;
@@ -551,7 +3654,7 @@ bool DebugScreenTest::on_touch(const TouchEvent event) {
void DebugScreenTest::paint(Painter& painter) {
painter.fill_rectangle({0, 16, screen_width, screen_height - 16}, Theme::getInstance()->bg_darkest->foreground);
painter.draw_string({10 * 8, screen_height / 2}, *Theme::getInstance()->bg_darkest, "Use Stylus");
- pen_color = std::rand();
+ pen_color = rand();
}
/* DebugLCRView *******************************************************/
diff --git a/firmware/application/apps/ui_debug.hpp b/firmware/application/apps/ui_debug.hpp
index e6c0bf56d..85e93c710 100644
--- a/firmware/application/apps/ui_debug.hpp
+++ b/firmware/application/apps/ui_debug.hpp
@@ -93,6 +93,10 @@ typedef enum {
CT_SI5351,
CT_AUDIO,
CT_MAX17055,
+#ifdef PRALINE
+ CT_FPGA,
+#endif
+ CT_SGPIO,
} chip_type_t;
struct RegistersWidgetConfig {
@@ -373,6 +377,1025 @@ private:
};
};*/
+/* Radio Signal Path Diagnostics View
+ * Shows status of each component in the RX/TX signal chain:
+ * Antenna -> RF Path -> RFFC5072 -> MAX283x -> MAX5864 -> FPGA -> SGPIO -> MCU
+ */
+class RadioDiagnosticsView : public View {
+ public:
+ RadioDiagnosticsView(NavigationView& nav);
+
+ void focus() override;
+ std::string title() const override { return "Radio Diag"; };
+
+ private:
+ NavigationView& nav_;
+
+ void update_status();
+
+ Text text_title{{0, 0, 240, 16}, "=== Signal Path Status ==="};
+
+ Text text_lbl_rffc{{0, 20, 140, 16}, "RFFC5072 (1st IF):"};
+ Text text_rffc_status{{144, 20, 96, 16}, "---"};
+
+ Text text_lbl_max{{0, 36, 140, 16}, "MAX283x (2nd IF):"};
+ Text text_max_status{{144, 36, 96, 16}, "---"};
+
+ Text text_lbl_adc{{0, 52, 140, 16}, "MAX5864 (ADC):"};
+ Text text_adc_status{{144, 52, 96, 16}, "---"};
+
+ Text text_lbl_fpga{{0, 68, 140, 16}, "FPGA/CPLD:"};
+ Text text_fpga_status{{144, 68, 96, 16}, "---"};
+
+ Text text_lbl_sgpio{{0, 84, 140, 16}, "SGPIO:"};
+ Text text_sgpio_status{{144, 84, 96, 16}, "---"};
+
+ Text text_lbl_clock{{0, 100, 140, 16}, "Si5351 Clocks:"};
+ Text text_clock_status{{144, 100, 96, 16}, "---"};
+
+ Text text_regs_title{{0, 124, 240, 16}, "=== Key Registers ==="};
+
+ Text text_lbl_rffc_reg{{0, 144, 80, 16}, "RFFC R0:"};
+ Text text_rffc_reg{{80, 144, 160, 16}, "---"};
+
+ Text text_lbl_max_reg{{0, 160, 80, 16}, "MAX R0:"};
+ Text text_max_reg{{80, 160, 160, 16}, "---"};
+
+ Text text_lbl_fpga_reg{{0, 176, 80, 16}, "FPGA:"};
+ Text text_fpga_reg{{80, 176, 160, 16}, "---"};
+
+ Text text_lbl_sgpio_reg{{0, 192, 80, 16}, "SGPIO:"};
+ Text text_sgpio_reg{{80, 192, 160, 16}, "---"};
+
+ Text text_test_result{{0, 220, 240, 32}, ""};
+
+ Button button_refresh{
+ {8, 280, 72, 24},
+ "Refresh"};
+
+ Button button_done{
+ {168, 280, 64, 24},
+ "Done"};
+};
+
+#ifdef PRALINE
+class PralineDebugMenuView : public BtnGridView {
+ public:
+ PralineDebugMenuView(NavigationView& nav);
+ std::string title() const override { return "Pro Debug"; };
+
+ private:
+ NavigationView& nav_;
+ void on_populate() override;
+};
+
+/* Praline-Specific Radio Debug View
+ * Monitors Mixer Lock, SPI Bit Depth, and toggles Si5351 CLK5
+ */
+class PralineRadioDebugView : public View {
+ public:
+ PralineRadioDebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "Pro Radio Debug"; };
+
+ private:
+ void refresh();
+ void toggle_clk5();
+
+ Text text_title{{0, 0, 240, 16}, "=== Pro Radio Debug ==="};
+
+ // Mixer Lock (PD_11 / GPIO6[25])
+ Text text_lbl_lock{{0, 24, 124, 16}, "Mix Lock (LD):"};
+ Text text_lock_status{{126, 24, 114, 16}, "---"};
+
+ // Si5351 CLK5 (Mixer Reference)
+ Text text_lbl_clk5{{0, 40, 124, 16}, "Si5351 CLK5(Mix):"};
+ Text text_clk5_status{{126, 40, 114, 16}, "---"};
+
+ // SPI Bus configuration check
+ Text text_lbl_spi{{0, 56, 124, 16}, "SPI Bit Mode:"};
+ Text text_spi_status{{126, 56, 118, 16}, "---"};
+
+ // FPGA DC Block (Reg 1)
+ Text text_lbl_fpga_ctrl{{0, 72, 124, 16}, "FPGA Reg 1:"};
+ Text text_fpga_ctrl{{126, 72, 118, 16}, "---"};
+
+ // VAA Power Rail (P8_1 / GPIO4[1])
+ Text text_lbl_vaa{{0, 88, 124, 16}, "VAA RF Power:"};
+ Text text_vaa_status{{126, 88, 118, 16}, "---"};
+
+ Text text_status_msg{{0, 110, 240, 48}, ""};
+
+ Button button_refresh{{8, 240, 72, 24}, "Refresh"};
+ Button button_toggle_clk5{{88, 240, 72, 24}, "CLK5_T"};
+ Button button_done{{168, 240, 64, 24}, "Done"};
+};
+#endif
+
+/* WFMAudioDebugView ***************************************************/
+
+#ifdef PRALINE
+class WFMAudioDebugView : public View {
+ public:
+ WFMAudioDebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "WFM Audio Debug"; }
+
+ private:
+ void refresh();
+
+ NavigationView& nav_;
+
+ Text text_title{{0, 0, 240, 16}, "=== WFM Audio Debug ==="};
+
+ // Sample rates section
+ Text text_lbl_clk0{{0, 20, 140, 16}, "Si5351 CLK0 Rate:"};
+ Text text_clk0{{150, 20, 90, 16}, "---"};
+
+ Text text_lbl_fpga_dec{{0, 36, 140, 16}, "FPGA Decimation:"};
+ Text text_fpga_dec{{150, 36, 90, 16}, "---"};
+
+ Text text_lbl_post_fpga{{0, 52, 140, 16}, "Post-FPGA Rate:"};
+ Text text_post_fpga{{150, 52, 90, 16}, "---"};
+
+ // MAX2831 section
+ Text text_section1{{0, 72, 240, 16}, "--- MAX2831 LPF ---"};
+
+ Text text_lbl_reg8{{0, 88, 140, 16}, "Reg8 (LPF RX):"};
+ Text text_reg8{{150, 88, 90, 16}, "---"};
+
+ Text text_lbl_lpf_bw{{0, 104, 140, 16}, "LPF Bandwidth:"};
+ Text text_lpf_bw{{150, 104, 90, 16}, "---"};
+
+ // FPGA section
+ Text text_section2{{0, 124, 240, 16}, "--- FPGA Control ---"};
+
+ Text text_lbl_fpga_r1{{0, 140, 140, 16}, "Reg1 (Ctrl):"};
+ Text text_fpga_r1{{150, 140, 90, 16}, "---"};
+
+ Text text_lbl_dc_q{{0, 156, 70, 16}, "DC/Q/QS:"};
+ Text text_dc_q{{72, 156, 168, 16}, "---"};
+
+ // Audio section
+ Text text_section3{{0, 176, 240, 16}, "--- Audio Path ---"};
+
+ Text text_lbl_expected{{0, 192, 96, 16}, "Expctd Aud:"};
+ Text text_expected{{98, 192, 142, 16}, "---"};
+
+ Text text_lbl_deemph{{0, 208, 96, 16}, "D-emph Cfg:"};
+ Text text_deemph{{98, 208, 132, 16}, "---"};
+
+ // Status
+ Text text_status{{0, 228, 240, 16}, "---"};
+ Text text_status2{{0, 244, 240, 16}, "---"};
+
+ Button button_refresh{{10, 268, 70, 24}, "Refresh"};
+ Button button_toggle_q{{90, 268, 70, 24}, "Toggle Q"};
+ Button button_done{{170, 268, 60, 24}, "Done"};
+};
+#endif
+
+/* BasebandStatusView ***************************************************/
+
+class BasebandStatusView : public View {
+ public:
+ BasebandStatusView(NavigationView& nav);
+
+ void focus() override;
+ std::string title() const override { return "Baseband Status"; };
+
+ private:
+ NavigationView& nav_;
+
+ void update();
+
+ Text text_title{{0, 0, 240, 16}, "=== Baseband Counters ==="};
+
+ Text text_lbl_marker{{0, 20, 140, 16}, "Streaming Marker:"};
+ Text text_marker{{144, 20, 96, 16}, "---"};
+
+ Text text_lbl_loops{{0, 36, 140, 16}, "Baseband Loops:"};
+ Text text_loops{{144, 36, 96, 16}, "---"};
+
+ Text text_lbl_wait{{0, 52, 140, 16}, "DMA Wait Count:"};
+ Text text_wait{{144, 52, 96, 16}, "---"};
+
+ Text text_lbl_xfr{{0, 68, 140, 16}, "DMA Xfr Count:"};
+ Text text_xfr{{144, 68, 96, 16}, "---"};
+
+ Text text_lbl_missed{{0, 84, 140, 16}, "Buffer Missed:"};
+ Text text_missed{{144, 84, 96, 16}, "---"};
+
+ Text text_status_line1{{0, 110, 240, 16}, ""};
+ Text text_status_line2{{0, 126, 240, 16}, ""};
+ Text text_status_line3{{0, 142, 240, 16}, ""};
+
+ Button button_refresh{
+ {8, 280, 72, 24},
+ "Refresh"};
+
+ Button button_done{
+ {168, 280, 64, 24},
+ "Done"};
+
+ MessageHandlerRegistration message_handler_frame_sync{
+ Message::ID::DisplayFrameSync,
+ [this](const Message* const) { this->update(); }};
+};
+
+/* SGPIOLiveMonitorView ***************************************************/
+
+class SGPIOLiveMonitorView : public View {
+ public:
+ SGPIOLiveMonitorView(NavigationView& nav);
+
+ void focus() override;
+ std::string title() const override { return "SGPIO Live"; };
+
+ private:
+ NavigationView& nav_;
+
+ void update();
+
+ Text text_title{{0, 0, 240, 16}, "=== SGPIO Registers ==="};
+
+ Text text_lbl_ctrl{{0, 20, 140, 16}, "CTRL_ENABLE:"};
+ Text text_ctrl{{144, 20, 96, 16}, "---"};
+
+ Text text_lbl_in{{0, 36, 140, 16}, "GPIO_INREG:"};
+ Text text_in{{144, 36, 96, 16}, "---"};
+
+ Text text_lbl_ss{{0, 52, 140, 16}, "REG_SS[0]:"};
+ Text text_ss{{144, 52, 96, 16}, "---"};
+
+ Text text_lbl_status{{0, 68, 140, 16}, "STATUS_1:"};
+ Text text_status{{144, 68, 96, 16}, "---"};
+
+ Text text_lbl_out{{0, 84, 140, 16}, "GPIO_OUTREG:"};
+ Text text_out{{144, 84, 96, 16}, "---"};
+
+ Text text_lbl_oen{{0, 100, 140, 16}, "GPIO_OENREG:"};
+ Text text_oen{{144, 100, 96, 16}, "---"};
+
+ Text text_diag_line1{{0, 126, 240, 16}, ""};
+ Text text_diag_line2{{0, 142, 240, 16}, ""};
+ Text text_diag_line3{{0, 158, 240, 16}, ""};
+ Text text_diag_line4{{0, 174, 240, 16}, ""};
+
+ Button button_refresh{
+ {8, 280, 72, 24},
+ "Refresh"};
+
+ Button button_done{
+ {168, 280, 64, 24},
+ "Done"};
+
+ MessageHandlerRegistration message_handler_frame_sync{
+ Message::ID::DisplayFrameSync,
+ [this](const Message* const) { this->update(); }};
+};
+
+/* Radio RX Step-by-Step Test View
+ * Tests radio hardware directly without M0 baseband involvement.
+ * Logs each step to help isolate where the signal chain breaks.
+ */
+class RadioRxTestView : public View {
+ public:
+ RadioRxTestView(NavigationView& nav);
+
+ void focus() override;
+ std::string title() const override { return "RX Test"; };
+
+ private:
+ NavigationView& nav_;
+ bool radio_initialized_{false};
+ uint32_t test_frequency_{433000000}; // 433 MHz default
+
+ void log(const std::string& msg);
+ void log_registers(const std::string& label);
+ void run_init_test();
+ void run_rx_mode_test();
+ void run_freq_test();
+ void run_sgpio_test();
+ void run_full_test();
+ void run_step_test();
+ bool check_gpio_changing();
+
+ Labels labels{
+ {{0, 0}, "=== Radio RX Test ===", Theme::getInstance()->fg_yellow->foreground}};
+
+ Console console{
+ {0, 20, 240, 200}};
+
+ Button button_init{
+ {0, 224, 56, 24},
+ "Init"};
+
+ Button button_rx{
+ {60, 224, 56, 24},
+ "RX"};
+
+ Button button_freq{
+ {120, 224, 56, 24},
+ "Freq"};
+
+ Button button_sgpio{
+ {180, 224, 56, 24},
+ "SGPIO"};
+
+ Button button_full{
+ {0, 252, 56, 24},
+ "Full"};
+
+ Button button_step{
+ {60, 252, 56, 24},
+ "Step"};
+
+ Button button_done{
+ {120, 252, 112, 24},
+ "Done"};
+};
+
+/* SGPIO8 Clock Detector View
+ * Samples SGPIO8 pin to verify external clock is present.
+ * Shows toggle count and estimated frequency.
+ */
+class SGPIO8ClockDetectorView : public View {
+ public:
+ SGPIO8ClockDetectorView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "SGPIO8 Clock"; };
+
+ private:
+ NavigationView& nav_;
+
+ Text text_title{{8, 16, 224, 16}, "SGPIO8 Clock Detector"};
+ Text text_lbl_samples{{8, 48, 160, 16}, "Samples (first 20):"};
+ Text text_samples{{8, 64, 224, 16}, " "};
+ Text text_lbl_toggles{{8, 96, 160, 16}, "Toggle count:"};
+ Text text_toggles{{8, 112, 224, 16}, " "};
+ Text text_status{{8, 144, 224, 32}, " "};
+
+ Button button_sample{{8, 200, 96, 24}, "Sample"};
+ Button button_done{{128, 200, 96, 24}, "Done"};
+
+ void sample_sgpio8();
+};
+
+/* Slice Status View
+ * Shows SGPIO slice status: which slices are enabled vs active,
+ * counter values, and data capture status.
+ */
+
+/* Si5351 Debug View
+ * Dedicated diagnostic tool for Si5351 clock generator.
+ * Shows PLL lock status, clock configurations, and allows testing.
+ */
+class Si5351DebugView : public View {
+ public:
+ Si5351DebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "Si5351 Clocks"; };
+
+ private:
+ NavigationView& nav_;
+
+ Text text_title{{0, 0, 200, 16}, "Si5351 Clock Generator"};
+
+ Text text_status_label{{0, 16, 80, 16}, "Status Reg:"};
+ Text text_status_value{{96, 16, 144, 16}, ""};
+
+ Text text_pll_a_label{{0, 32, 80, 16}, "PLL A:"};
+ Text text_pll_a_status{{96, 32, 144, 16}, ""};
+
+ Text text_pll_b_label{{0, 48, 80, 16}, "PLL B:"};
+ Text text_pll_b_status{{96, 48, 144, 16}, ""};
+
+ Text text_sys_init_label{{0, 64, 80, 16}, "SYS_INIT:"};
+ Text text_sys_init_status{{96, 64, 144, 16}, ""};
+
+ Text text_xtal_cap_label{{0, 80, 80, 16}, "XTAL Cap:"};
+ Text text_xtal_cap_value{{96, 80, 144, 16}, ""};
+
+ Text text_clk0_label{{0, 96, 48, 16}, "CLK0:"};
+ Text text_clk0_status{{50, 96, 28, 16}, ""};
+ Text text_clk0_freq_value{{80, 96, 160, 16}, ""};
+ Text text_clk0_div_value{{50, 112, 190, 16}, ""};
+
+ Text text_clk1_label{{0, 128, 96, 16}, "CLK1 (SCT):"};
+ Text text_clk1_status{{112, 128, 128, 16}, ""};
+
+ Text text_clk4_label{{0, 144, 96, 16}, "CLK4 (MAX):"};
+ Text text_clk4_status{{112, 144, 128, 16}, ""};
+
+ Text text_clk5_label{{0, 160, 96, 16}, "CLK5 (RFFC):"};
+ Text text_clk5_status{{112, 160, 128, 16}, ""};
+
+ Button button_refresh{{8, 240, 72, 24}, "Refresh"};
+ Button button_reset_pll{{88, 240, 72, 24}, "Reset PLL"};
+ Button button_done{{168, 240, 64, 24}, "Done"};
+
+ void refresh_status();
+ void reset_pll();
+};
+
+#ifdef PRALINE
+/* SignalPathStatusView *************************************************/
+class SignalPathStatusView : public View {
+ public:
+ SignalPathStatusView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "Signal Path"; };
+
+ private:
+ NavigationView& nav_;
+ void refresh_status();
+
+ Text text_title{{0, 0, 240, 16}, "=== Signal Path Status ==="};
+
+ Text text_lbl_max_enable{{0, 20, 114, 16}, "MAX2831:"};
+ Text text_max_enable{{116, 20, 124, 16}, "---"};
+
+ Text text_lbl_max_mode{{0, 36, 114, 16}, "RX Mode:"};
+ Text text_max_mode{{116, 36, 124, 16}, "---"};
+
+ Text text_lbl_rf_path{{0, 52, 114, 16}, "RF Path:"};
+ Text text_rf_path{{116, 52, 124, 16}, "---"};
+
+ Text text_lbl_filter{{0, 68, 114, 16}, "Filter Band:"};
+ Text text_filter{{116, 68, 124, 16}, "---"};
+
+ Text text_lbl_mixer{{0, 84, 114, 16}, "Mixer Enable:"};
+ Text text_mixer{{116, 84, 124, 16}, "---"};
+
+ Text text_lbl_rf_amp{{0, 100, 114, 16}, "RF Amp:"};
+ Text text_rf_amp{{116, 100, 124, 16}, "---"};
+
+ Text text_lbl_lna{{0, 116, 114, 16}, "LNA Gain:"};
+ Text text_lna{{116, 116, 124, 16}, "---"};
+
+ Text text_lbl_vga{{0, 132, 114, 16}, "VGA Gain:"};
+ Text text_vga{{116, 132, 124, 16}, "---"};
+
+ Text text_lbl_fpga_decim{{0, 148, 114, 16}, "FPGA Decim:"};
+ Text text_fpga_decim{{116, 148, 124, 16}, "---"};
+
+ Text text_lbl_fpga_ctrl_dc_q{{0, 164, 114, 16}, "FPGA Ctrl:"};
+ Text text_fpga_ctrl_dc_q{{116, 164, 124, 16}, "---"};
+
+ Text text_lbl_fpga_ctrl_qs{{0, 180, 114, 16}, ""};
+ Text text_fpga_ctrl_qs{{116, 180, 124, 16}, "---"};
+
+ Text text_status{{0, 200, 240, 32}, ""};
+
+ Button button_refresh{{0, 280, 72, 24}, "Refresh"};
+ Button button_toggle_q{{88, 280, 72, 24}, "Q Inv"};
+ Button button_done{{176, 280, 64, 24}, "Done"};
+};
+#endif
+
+#ifdef PRALINE
+class GPIODebugView : public View {
+ public:
+ GPIODebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "GPIO Debug"; };
+
+ private:
+ void refresh();
+
+ // GPIO4 (LPF and RF Amp)
+ Text text_lbl_gpio4{{0, 0, 240, 16}, "Pin Diag: GPIO4 (LPF|Amp|Mix)"};
+
+ Text text_lbl_mixr1{{0, 18, 56, 16}, "MixR1:"};
+ Text text_mixr1{{58, 18, 180, 16}, "---"};
+
+ Text text_lbl_pin4{{0, 36, 114, 16}, "PIN[4] (read):"};
+ Text text_pin4{{116, 36, 124, 16}, "---"};
+
+ Text text_lbl_set4{{0, 54, 114, 16}, "SET[4] (write):"};
+ Text text_set4{{116, 54, 124, 16}, "---"};
+
+ // Bit 8 (LPF)
+ Text text_lbl_lpf_bit{{0, 78, 240, 16}, "Bit 8 (LPF - GPIO4[8]):"};
+ Text text_lpf_dir{{0, 96, 80, 16}, "DIR: ?"};
+ Text text_lpf_pin{{82, 96, 78, 16}, "PIN: ?"};
+ Text text_lpf_set{{162, 96, 78, 16}, "SET: ?"};
+
+ Button button_lpf_toggle{{8, 114, 110, 24}, "Toggle LPF"};
+ Button button_lpf_on{{122, 114, 50, 24}, "ON"};
+ Button button_lpf_off{{176, 114, 50, 24}, "OFF"};
+
+ // Bit 9 (RF Amp)
+ Text text_lbl_amp_bit{{0, 146, 240, 16}, "Bit 9 (Amp - GPIO4[9]):"};
+ Text text_amp_dir{{0, 164, 80, 16}, "DIR: ?"};
+ Text text_amp_pin{{82, 164, 78, 16}, "PIN: ?"};
+ Text text_amp_set{{162, 164, 78, 16}, "SET: ?"};
+
+ Button button_amp_toggle{{8, 182, 110, 24}, "Toggle Amp"};
+ Button button_amp_on{{122, 182, 50, 24}, "ON"};
+ Button button_amp_off{{176, 182, 50, 24}, "OFF"};
+
+ // GPIO3 (Mixer)
+ Text text_lbl_gpio3{{0, 214, 240, 16}, "--- GPIO3 (Mixer) ---"};
+ Text text_lbl_mix_bit{{0, 232, 240, 16}, "Bit 2 (MIX - GPIO3[2]):"};
+ Text text_mix_dir{{0, 250, 80, 16}, "DIR: ?"};
+ Text text_mix_pin{{82, 250, 78, 16}, "PIN: ?"};
+ Text text_mix_set{{162, 250, 78, 16}, "SET: ?"};
+
+ // Controls
+ Button button_refresh{{8, 280, 72, 24}, "Refresh"};
+ Button button_done{{168, 280, 64, 24}, "Done"};
+};
+#endif
+
+#ifdef PRALINE
+/* RFFC5072StatusView *************************************************/
+class RFFC5072StatusView : public View {
+ public:
+ RFFC5072StatusView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "RFFC5072 Status"; };
+
+ private:
+ NavigationView& nav_;
+ void refresh_status();
+
+ Text text_status{{0, 0, 240, 16}, "---"};
+
+ Text text_gpio4{{0, 16, 240, 16}, "---"};
+
+ Text text_ctrl{{0, 32, 240, 16}, "---"};
+
+ Text text_lbl_enabled{{0, 48, 114, 16}, "Status:"};
+ Text text_enabled{{116, 48, 124, 16}, "---"};
+
+ Text text_lbl_freq{{0, 64, 114, 16}, "LO Freq:"};
+ Text text_freq{{116, 64, 124, 16}, "---"};
+
+ Text text_lbl_path{{0, 80, 114, 16}, "Path:"};
+ Text text_path{{116, 80, 124, 16}, "---"};
+
+ Text text_lbl_mixer{{0, 96, 114, 16}, "Mixer:"};
+ Text text_mixer{{116, 96, 124, 16}, "---"};
+
+ Text text_lbl_r0{{0, 112, 114, 16}, "Reg 0:"};
+ Text text_r0{{116, 112, 124, 16}, "---"};
+
+ Text text_lbl_r1{{0, 128, 114, 16}, "Reg 1 (N):"};
+ Text text_r1{{116, 128, 124, 16}, "---"};
+
+ Text text_lbl_r2{{0, 144, 114, 16}, "Reg 2:"};
+ Text text_r2{{116, 144, 124, 16}, "---"};
+
+ Text text_lbl_n{{0, 160, 114, 16}, "N divider:"};
+ Text text_n{{116, 160, 124, 16}, "---"};
+
+ Text text_lbl_lodiv{{0, 176, 114, 16}, "LO divider:"};
+ Text text_lodiv{{116, 176, 124, 16}, "---"};
+
+ Text text_lbl_calc{{0, 192, 114, 16}, "Calc freq:"};
+ Text text_calc{{116, 192, 124, 16}, "---"};
+
+ Text text_regs_status{{0, 208, 240, 16}, "---"};
+
+ Text text_status2{{0, 224, 240, 16}, ""};
+ Text text_status3{{0, 240, 240, 16}, ""};
+
+ Button button_refresh{{2, 280, 72, 24}, "Refresh"};
+ Button button_force{{98, 280, 60, 24}, "SPI"};
+ Button button_done{{182, 280, 56, 24}, "Done"};
+};
+
+#ifdef PRALINE
+/* RFFCTuningDebugView *************************************************/
+class RFFCTuningDebugView : public View {
+ public:
+ RFFCTuningDebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "RFFC Tuning Debug"; };
+
+ private:
+ void refresh();
+
+ Text text_title{{0, 0, 240, 16}, "RFFC5072 Tuning Debug"};
+
+ Text text_lbl_called{{0, 18, 120, 16}, "Freq Set:"};
+ Text text_called{{122, 18, 118, 16}, "NO"};
+
+ Text text_lbl_req{{0, 38, 120, 16}, "Requested:"};
+ Text text_req{{122, 38, 118, 16}, "---"};
+
+ Text text_lbl_exp_n{{0, 56, 120, 16}, "Expected N:"};
+ Text text_exp_n{{122, 56, 118, 16}, "---"};
+
+ Text text_lbl_act_n{{0, 74, 120, 16}, "Actual N:"};
+ Text text_act_n{{122, 74, 118, 16}, "---"};
+
+ Text text_lbl_exp_div{{0, 92, 120, 16}, "Exp LO/Pres:"};
+ Text text_exp_div{{122, 92, 118, 16}, "---"};
+
+ Text text_lbl_act_div{{0, 110, 120, 16}, "Act LO/Pres:"};
+ Text text_act_div{{122, 110, 118, 16}, "---"};
+
+ Text text_lbl_calc{{0, 128, 120, 16}, "Calc LO freq:"};
+ Text text_calc{{122, 128, 118, 16}, "---"};
+
+ Text text_lbl_calc_lo{{0, 146, 120, 16}, "Calc input:"};
+ Text text_calc_lo{{122, 146, 118, 16}, "---"};
+
+ Text text_lbl_calc_vco{{0, 164, 120, 16}, "In Calc VCO:"};
+ Text text_calc_vco{{122, 164, 118, 16}, "---"};
+
+ Text text_lbl_vco{{0, 182, 120, 16}, "Calc VCO:"};
+ Text text_vco{{122, 182, 118, 16}, "---"};
+
+ Text text_lbl_n_q24{{0, 200, 120, 16}, "N (Q24):"};
+ Text text_n_q24{{122, 200, 118, 16}, "---"};
+
+ Text text_status{{0, 224, 240, 48}, ""};
+
+ Button button_refresh{{8, 280, 72, 24}, "Refresh"};
+ Button button_done{{168, 280, 64, 24}, "Done"};
+};
+
+#endif
+
+#ifdef PRALINE
+/* MAX2831DebugView *************************************************/
+
+class MAX2831DebugView : public View {
+ public:
+ MAX2831DebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "MAX2831 Debug"; };
+
+ private:
+ void refresh();
+
+ Text text_title{{0, 0, 240, 16}, "MAX2831 (2nd IF) Debug"};
+
+ Text text_lbl_called{{0, 24, 120, 16}, "Freq Set:"};
+ Text text_called{{122, 24, 118, 16}, "NO"};
+
+ Text text_lbl_valid{{0, 42, 120, 16}, "In Range:"};
+ Text text_valid{{122, 42, 118, 16}, "---"};
+
+ Text text_lbl_req{{0, 60, 120, 16}, "Requested:"};
+ Text text_req{{122, 60, 118, 16}, "---"};
+
+ Text text_lbl_calc_n{{0, 78, 120, 16}, "Calc N:"};
+ Text text_calc_n{{122, 78, 118, 16}, "---"};
+
+ Text text_lbl_calc_frac{{0, 96, 120, 16}, "Calc Frac:"};
+ Text text_calc_frac{{122, 96, 118, 16}, "---"};
+
+ Text text_spacer{{0, 114, 240, 16}, "--- Hardware Regs ---"};
+
+ Text text_lbl_r3{{0, 132, 120, 16}, "Reg 3:"};
+ Text text_r3{{122, 132, 118, 16}, "---"};
+
+ Text text_lbl_r4{{0, 150, 120, 16}, "Reg 4:"};
+ Text text_r4{{122, 150, 118, 16}, "---"};
+
+ Text text_lbl_act_n{{0, 168, 120, 16}, "Actual N:"};
+ Text text_act_n{{122, 168, 118, 16}, "---"};
+
+ Text text_lbl_act_frac{{0, 186, 120, 16}, "Actual Frac:"};
+ Text text_act_frac{{122, 186, 118, 16}, "---"};
+
+ Text text_lbl_calc_freq{{0, 204, 120, 16}, "Calc Freq:"};
+ Text text_calc_freq{{122, 204, 118, 16}, "---"};
+
+ Text text_status{{0, 228, 240, 44}, ""};
+
+ Button button_refresh{{8, 280, 72, 24}, "Refresh"};
+ Button button_done{{168, 280, 64, 24}, "Done"};
+};
+#endif
+
+#ifdef PRALINE
+class SystemDiagnosticsView : public View {
+ public:
+ SystemDiagnosticsView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "System Diagnostics"; };
+
+ private:
+ void refresh();
+ void read_gpio_states();
+ void set_sample_rate(uint32_t rate);
+
+ Text text_title{{0, 0, 240, 16}, "System Diagnostics"};
+
+ // Sample Rate Section
+ Text text_lbl_sample{{0, 24, 42, 16}, "Rate:"};
+ Text text_sample_rate{{44, 24, 50, 16}, "---"};
+ Text text_lbl_fpga_decode{{96, 24, 144, 16}, "DC:? Q:? QS:?"};
+
+ Button button_sample_2m{{2, 44, 56, 24}, "2 MSPS"};
+ Button button_sample_4m{{62, 44, 56, 24}, "4 MSPS"};
+ Button button_sample_8m{{122, 44, 56, 24}, "8 MSPS"};
+ Button button_sample_20m{{182, 44, 56, 24}, "20 MSPS"};
+
+ // Baseband Filter Section
+ Text text_lbl_bb_filter{{0, 76, 114, 16}, "BB Filter BW:"};
+ Text text_bb_filter{{116, 76, 124, 16}, "---"};
+
+ Text text_lbl_reg8{{0, 94, 114, 16}, "MAX2831 R8:"};
+ Text text_reg8{{116, 94, 124, 16}, "---"};
+
+ // GPIO States Section
+ Text text_lbl_gpio{{0, 118, 240, 16}, "--- GPIO Pin States ---"};
+
+ Text text_lbl_lpf{{0, 136, 56, 16}, "LPF:"};
+ Text text_gpio_lpf{{58, 136, 181, 16}, "---"};
+
+ Text text_lbl_mix{{0, 154, 56, 16}, "Mixer:"};
+ Text text_gpio_mix{{58, 154, 181, 16}, "---"};
+
+ Text text_lbl_amp{{0, 172, 56, 16}, "RF Amp:"};
+ Text text_gpio_amp{{58, 172, 181, 16}, "---"};
+
+ // FPGA Control Section
+ Text text_lbl_fpga{{0, 196, 240, 16}, "--- FPGA Control ---"};
+
+ Text text_lbl_fpga_ctrl{{0, 214, 114, 16}, "FPGA Reg 1:"};
+ Text text_fpga_ctrl{{116, 214, 124, 16}, "---"};
+
+ Text text_lbl_band{{0, 232, 48, 16}, "Band:"};
+ Text text_band{{50, 232, 190, 16}, "---"};
+
+ Button button_toggle_q{{2, 252, 110, 24}, "Toggle Q Inv"};
+ Button button_toggle_dc{{122, 252, 110, 24}, "Toggle DC Blk"};
+
+ // Control Buttons
+ Button button_refresh{{2, 280, 72, 24}, "Refresh"};
+ Button button_done{{168, 280, 64, 24}, "Done"};
+};
+#endif
+
+#ifdef PRALINE
+/* Si5351PLLADebugView - Debug fractional parameters for PLL A */
+class Si5351PLLADebugView : public View {
+ public:
+ Si5351PLLADebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "Si5351 PLL A Debug"; };
+
+ private:
+ NavigationView& nav_;
+ void refresh();
+
+ Text text_title{{0, 0, 240, 16}, "===PLL A Fractional Debug==="};
+
+ // Raw register values
+ Text text_lbl_raw{{0, 24, 240, 16}, "---Raw Registers (26-33)---"};
+ Text text_r26_27{{8, 40, 224, 16}, "---"};
+ Text text_r28_30{{8, 56, 224, 16}, "---"};
+ Text text_r31_33{{8, 72, 224, 16}, "---"};
+
+ // Decoded values
+ Text text_lbl_decoded{{0, 96, 240, 16}, "---Decoded Parameters---"};
+ Text text_lbl_p1{{0, 112, 48, 16}, "P1:"};
+ Text text_p1{{50, 112, 190, 16}, "---"};
+ Text text_lbl_p2{{0, 128, 48, 16}, "P2:"};
+ Text text_p2{{50, 128, 190, 16}, "---"};
+ Text text_lbl_p3{{0, 144, 48, 16}, "P3:"};
+ Text text_p3{{50, 144, 190, 16}, "---"};
+
+ // Calculated VCO
+ Text text_lbl_calc{{0, 168, 240, 16}, "---Calculated VCO---"};
+ Text text_lbl_mult{{0, 184, 80, 16}, "Multiplier:"};
+ Text text_mult{{82, 184, 158, 16}, "---"};
+ Text text_lbl_vco{{0, 200, 80, 16}, "VCO Freq:"};
+ Text text_vco{{82, 200, 158, 16}, "---"};
+
+ Text text_status{{0, 224, 240, 32}, ""};
+
+ Button button_refresh{{8, 280, 100, 24}, "Refresh"};
+ Button button_done{{132, 280, 100, 24}, "Done"};
+};
+#endif
+
+#ifdef PRALINE
+class Si5351PLLBDebugView : public View {
+ public:
+ Si5351PLLBDebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "Si5351 PLL B Debug"; };
+
+ private:
+ void refresh();
+ NavigationView& nav_;
+
+ Text text_title{{0, 0, 240, 16}, "=== PLL B Fractional Debug ==="};
+
+ Text text_lbl_raw{{0, 24, 240, 16}, "--- Raw Registers (34-41) ---"};
+ Text text_r34_35{{8, 40, 224, 16}, "---"};
+ Text text_r36_38{{8, 56, 224, 16}, "---"};
+ Text text_r39_41{{8, 72, 224, 16}, "---"};
+
+ Text text_lbl_decoded{{0, 96, 240, 16}, "--- Decoded Parameters ---"};
+ Text text_lbl_p1{{0, 112, 48, 16}, "P1:"};
+ Text text_p1{{50, 112, 190, 16}, "---"};
+ Text text_lbl_p2{{0, 128, 48, 16}, "P2:"};
+ Text text_p2{{50, 128, 190, 16}, "---"};
+ Text text_lbl_p3{{0, 144, 48, 16}, "P3:"};
+ Text text_p3{{50, 144, 190, 16}, "---"};
+
+ Text text_lbl_calc{{0, 168, 240, 16}, "--- Calculated VCO ---"};
+ Text text_lbl_vco{{0, 200, 80, 16}, "VCO Freq:"};
+ Text text_vco{{82, 200, 158, 16}, "---"};
+
+ Text text_status{{0, 224, 240, 32}, ""};
+ Button button_refresh{{8, 280, 100, 24}, "Refresh"};
+ Button button_done{{132, 280, 100, 24}, "Done"};
+};
+#endif
+
+#ifdef PRALINE
+/* Si5351MultiSynthDebugView - Debug fractional register values for MS0 */
+class Si5351MultiSynthDebugView : public View {
+ public:
+ Si5351MultiSynthDebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "Si5351 MS0 Debug"; };
+
+ private:
+ NavigationView& nav_;
+ void refresh();
+ void force_pll_reset();
+ void force_fractional_mode();
+
+ Text text_title{{0, 0, 240, 16}, "===MS0 Fractional Debug==="};
+
+ // Clock Control Register 16 (CLK0)
+ Text text_lbl_clk_ctrl{{0, 18, 100, 16}, "CLK0 Ctrl:"};
+ Text text_clk_ctrl{{102, 18, 138, 16}, "---"};
+
+ Text text_lbl_ms_int{{0, 34, 100, 16}, "MS_INT bit:"};
+ Text text_ms_int{{102, 34, 138, 16}, "---"};
+
+ // Raw register values
+ Text text_lbl_raw{{0, 54, 240, 16}, "---Raw Registers (42-49)---"};
+
+ Text text_lbl_r42_43{{0, 70, 80, 16}, "R42-43:"};
+ Text text_r42_43{{82, 70, 158, 16}, "---"};
+
+ Text text_lbl_r44_46{{0, 86, 80, 16}, "R44-46:"};
+ Text text_r44_46{{82, 86, 158, 16}, "---"};
+
+ Text text_lbl_r47_49{{0, 102, 80, 16}, "R47-49:"};
+ Text text_r47_49{{82, 102, 158, 16}, "---"};
+
+ // Decoded values
+ Text text_lbl_decoded{{0, 122, 240, 16}, "---Decoded Values---"};
+
+ Text text_lbl_p1{{0, 138, 48, 16}, "P1:"};
+ Text text_p1{{50, 138, 190, 16}, "---"};
+
+ Text text_lbl_p2{{0, 154, 48, 16}, "P2:"};
+ Text text_p2{{50, 154, 190, 16}, "---"};
+
+ Text text_lbl_p3{{0, 170, 48, 16}, "P3:"};
+ Text text_p3{{50, 170, 190, 16}, "---"};
+
+ Text text_lbl_rdiv{{0, 186, 48, 16}, "R_DIV:"};
+ Text text_rdiv{{50, 186, 190, 16}, "---"};
+
+ // Calculated frequency
+ Text text_lbl_calc{{0, 206, 240, 16}, "---Calculated Output---"};
+
+ Text text_lbl_div{{0, 222, 80, 16}, "MS Div:"};
+ Text text_div{{82, 222, 158, 16}, "---"};
+
+ Text text_lbl_freq{{0, 238, 80, 16}, "CLK0 Out:"};
+ Text text_freq{{82, 238, 158, 16}, "---"};
+
+ // Status
+ Text text_status{{0, 258, 240, 16}, ""};
+
+ Button button_refresh{{2, 280, 56, 24}, "Refresh"};
+ Button button_reset{{62, 280, 56, 24}, "PLL Rst"};
+ Button button_frac{{122, 280, 56, 24}, "Frac"};
+ Button button_done{{182, 280, 56, 24}, "Done"};
+};
+#endif
+
+#ifdef PRALINE
+class PralineClockDebugView : public View {
+ public:
+ PralineClockDebugView(NavigationView& nav);
+ void focus() override;
+ std::string title() const override { return "Pro Clock Status"; };
+
+ private:
+ void refresh();
+
+ Text text_title{{0, 0, 240, 16}, "=== Pro Clock Dashboard ==="};
+
+ // System Status
+ Text text_lbl_pll{{0, 20, 80, 16}, "PLL Lock:"};
+ Text text_pll_status{{88, 20, 152, 16}, "---"};
+
+ Text text_lbl_afe{{0, 36, 80, 16}, "AFE Rate:"};
+ Text text_afe_rate{{88, 36, 152, 16}, "---"};
+
+ Text text_lbl_n{{0, 52, 80, 16}, "Decim (n):"};
+ Text text_n_val{{88, 52, 152, 16}, "-"};
+
+ // Table Header (Width 240px, 30 characters)
+ Text text_header{{0, 72, 240, 16}, "ID mA Mode Src Phase Stat"};
+
+ // Helper structure to group row widgets for CLK0-CLK5
+ struct ClockRow {
+ Text* id;
+ Text* ma;
+ Text* mode;
+ Text* src; // PLL A, or B
+ Text* phase;
+ Text* stat;
+ };
+ std::vector rows;
+
+ // We define the actual widgets for 6 clocks
+ // Note: Layout uses 16px vertical spacing per row
+
+ // Full list of clock row entries (C0 through C5)
+ // Layout: Y starts at 88, increments by 16 per row.
+
+ // C0: MAX5864 (AFE/Sampling)
+ Text t0_id{{0, 88, 24, 16}, "C0:"}; // Column 0
+ Text t0_ma{{32, 88, 24, 16}, "-"}; // Column 4 (mA)
+ Text t0_mode{{64, 88, 48, 16}, "-"}; // Column 8 (Mode)
+ Text t0_src{{112, 88, 32, 16}, "-"}; // Column 14 (Src)
+ Text t0_ph{{160, 88, 48, 16}, "-"}; // Column 20 (Phase)
+ Text t0_st{{208, 88, 32, 16}, "-"}; // Column 26 (Stat)
+
+ // C1: iCE40 FPGA Timing
+ Text t1_id{{0, 104, 24, 16}, "C1:"};
+ Text t1_ma{{32, 104, 24, 16}, "-"};
+ Text t1_mode{{64, 104, 48, 16}, "-"};
+ Text t1_src{{112, 104, 32, 16}, "-"};
+ Text t1_ph{{160, 104, 48, 16}, "-"};
+ Text t1_st{{208, 104, 32, 16}, "-"};
+
+ // C2: LPC43xx MCU Input
+ Text t2_id{{0, 120, 24, 16}, "C2:"};
+ Text t2_ma{{32, 120, 24, 16}, "-"};
+ Text t2_mode{{64, 120, 48, 16}, "-"};
+ Text t2_src{{112, 120, 32, 16}, "-"};
+ Text t2_ph{{160, 120, 48, 16}, "-"};
+ Text t2_st{{208, 120, 32, 16}, "-"};
+
+ // C3: SG_CLK PORT 1 (Switching Regulator/Logic Sync)
+ Text t3_id{{0, 136, 24, 16}, "C3:"};
+ Text t3_ma{{32, 136, 24, 16}, "-"};
+ Text t3_mode{{64, 136, 48, 16}, "-"};
+ Text t3_src{{112, 136, 32, 16}, "-"};
+ Text t3_ph{{160, 136, 48, 16}, "-"};
+ Text t3_st{{208, 136, 32, 16}, "-"};
+
+ // C4: P_CLK (Peripheral/Expansion Clock)
+ Text t4_id{{0, 152, 24, 16}, "C4:"};
+ Text t4_ma{{32, 152, 24, 16}, "-"};
+ Text t4_mode{{64, 152, 48, 16}, "-"};
+ Text t4_src{{112, 152, 32, 16}, "-"};
+ Text t4_ph{{160, 152, 48, 16}, "-"};
+ Text t4_st{{208, 152, 32, 16}, "-"};
+
+ // C5: AUX_CLK (Auxiliary Reference)
+ Text t5_id{{0, 168, 24, 16}, "C5:"};
+ Text t5_ma{{32, 168, 24, 16}, "-"};
+ Text t5_mode{{64, 168, 48, 16}, "-"};
+ Text t5_src{{112, 168, 32, 16}, "-"};
+ Text t5_ph{{160, 168, 48, 16}, "-"};
+ Text t5_st{{208, 168, 32, 16}, "-"};
+
+ // C6: SG_CLK PORT 2 (Switching Regulator/Logic Sync)
+ Text t6_id{{0, 184, 24, 16}, "C6:"};
+ Text t6_ma{{32, 184, 24, 16}, "-"};
+ Text t6_mode{{64, 184, 48, 16}, "-"};
+ Text t6_src{{112, 184, 32, 16}, "-"};
+ Text t6_ph{{160, 184, 48, 16}, "-"};
+ Text t6_st{{208, 184, 32, 16}, "-"};
+
+ // C7: UNUSED_CLK (Internal Expansion)
+ Text t7_id{{0, 200, 24, 16}, "C7:"};
+ Text t7_ma{{32, 200, 24, 16}, "-"};
+ Text t7_mode{{64, 200, 48, 16}, "-"};
+ Text t7_src{{112, 200, 32, 16}, "-"};
+ Text t7_ph{{160, 200, 48, 16}, "-"};
+ Text t7_st{{208, 200, 32, 16}, "-"};
+
+ Button button_refresh{{8, 260, 100, 24}, "Refresh"};
+ Button button_done{{132, 260, 100, 24}, "Done"};
+};
+#endif
+
+#endif
+
class DebugPeripheralsMenuView : public BtnGridView {
public:
DebugPeripheralsMenuView(NavigationView& nav);
diff --git a/firmware/application/apps/ui_flash_utility.cpp b/firmware/application/apps/ui_flash_utility.cpp
index b15bd7bdc..c9ff3f081 100644
--- a/firmware/application/apps/ui_flash_utility.cpp
+++ b/firmware/application/apps/ui_flash_utility.cpp
@@ -45,10 +45,14 @@ bool valid_firmware_file(std::filesystem::path::string_type path) {
// test read of the whole file just to validate checksum (baseband flash code will re-read when flashing)
auto result = firmware_file.open(path.c_str());
if (!result.is_valid()) {
+ uint64_t file_size = firmware_file.size();
+ if (file_size > FLASH_ROM_SIZE) {
+ // Firmware file is larger than the flash size for this device
+ return false;
+ }
checksum = 0;
- for (uint32_t offset = 0; offset < FLASH_ROM_SIZE; offset += sizeof(read_buffer)) {
+ for (uint64_t offset = 0; offset < FLASH_ROM_SIZE && offset < file_size; offset += sizeof(read_buffer)) {
auto readResult = firmware_file.read(&read_buffer, sizeof(read_buffer));
-
if ((!readResult) || (readResult.value() != sizeof(read_buffer))) {
// File was smaller than 1MB:
// If version is such that the file SHOULD have been 1MB, call it a checksum error (otherwise say it's OK).
diff --git a/firmware/application/apps/ui_flash_utility.hpp b/firmware/application/apps/ui_flash_utility.hpp
index 0d9f8d9fa..e1a0d82d3 100644
--- a/firmware/application/apps/ui_flash_utility.hpp
+++ b/firmware/application/apps/ui_flash_utility.hpp
@@ -32,7 +32,7 @@
#include "untar.hpp"
#include
-#define FLASH_ROM_SIZE 1048576
+#define FLASH_ROM_SIZE (FLASH_SIZE_MB * 1024 * 1024)
#define FLASH_STARTING_ADDRESS 0x00000000
#define FLASH_EXPECTED_CHECKSUM 0x00000000
#define FLASH_CHECKSUM_ERROR 0xFFFFFFFF
diff --git a/firmware/application/apps/ui_looking_glass_app.cpp b/firmware/application/apps/ui_looking_glass_app.cpp
index 187c5f75c..a9d5a19fa 100644
--- a/firmware/application/apps/ui_looking_glass_app.cpp
+++ b/firmware/application/apps/ui_looking_glass_app.cpp
@@ -69,7 +69,7 @@ void GlassView::manage_beep_audio() {
}
}
-void GlassView::get_max_power(const ChannelSpectrum& spectrum, uint8_t bin, uint8_t& max_power) {
+void GlassView::get_max_power(const ChannelSpectrum& spectrum, uint16_t bin, uint8_t& max_power) {
if (mode == LOOKING_GLASS_SINGLEPASS) {
// <20MHz spectrum mode
if (bin < 120) {
@@ -91,7 +91,7 @@ void GlassView::get_max_power(const ChannelSpectrum& spectrum, uint8_t bin, uint
}
}
-rf::Frequency GlassView::get_freq_from_bin_pos(uint8_t pos) {
+rf::Frequency GlassView::get_freq_from_bin_pos(uint16_t pos) {
rf::Frequency freq_at_pos = 0;
if (mode == LOOKING_GLASS_SINGLEPASS) {
// starting from the middle, minus 8 ignored bin on each side. Since pos is [-120,120] after the (pos - 120), it's divided by screen_width(240)/2 => 120
@@ -101,7 +101,14 @@ rf::Frequency GlassView::get_freq_from_bin_pos(uint8_t pos) {
return freq_at_pos;
}
-void GlassView::on_marker_change() {
+void GlassView::on_marker_change(int8_t delta) {
+ if ((marker_pixel_index + delta) < 0)
+ marker_pixel_index = marker_pixel_index + delta + screen_width;
+ else if ((marker_pixel_index + delta) > screen_width)
+ marker_pixel_index = marker_pixel_index + delta - screen_width;
+ else
+ marker_pixel_index = marker_pixel_index + delta;
+
marker = get_freq_from_bin_pos(marker_pixel_index);
field_marker.set_text(to_string_short_freq(marker));
plot_marker(marker_pixel_index); // Refresh marker on screen
@@ -200,7 +207,7 @@ void GlassView::on_channel_spectrum(const ChannelSpectrum& spectrum) {
baseband::spectrum_streaming_stop();
// Convert bins of this spectrum slice into a representative max_power and when enough, into pixels
// we actually need screen_width (240) of those bins
- for (uint8_t bin = 0; bin < bin_length; bin++) {
+ for (uint16_t bin = 0; bin < bin_length; bin++) {
get_max_power(spectrum, bin, max_power);
if (max_power > range_max_power)
range_max_power = max_power;
@@ -208,7 +215,7 @@ void GlassView::on_channel_spectrum(const ChannelSpectrum& spectrum) {
if (bin == 119) {
uint8_t next_max_power = 0;
get_max_power(spectrum, bin + 1, next_max_power);
- for (uint8_t it = 0; it < ignore_dc; it++) {
+ for (uint16_t it = 0; it < ignore_dc; it++) {
uint8_t med_max_power = (max_power + next_max_power) / 2; // due to the way process_bins works we have to keep resetting the color
if (process_bins(&med_max_power) == true)
return; // new line signaled, return
@@ -285,7 +292,7 @@ void GlassView::on_range_changed() {
max_power = 0;
bins_hz_size = 0;
- on_marker_change();
+ on_marker_change(0);
update_range_field();
// set the sample rate and bandwidth
@@ -298,8 +305,8 @@ void GlassView::on_range_changed() {
receiver_model.set_target_frequency(f_center); // tune rx for this slice
}
-void GlassView::plot_marker(uint8_t pos) {
- uint8_t shift_y = 0;
+void GlassView::plot_marker(uint16_t pos) {
+ uint16_t shift_y = 0;
if (live_frequency_view > 0) // plot one line down when in live view
{
shift_y = 16;
@@ -381,9 +388,10 @@ GlassView::GlassView(
&button_beep_squelch,
&field_marker,
&field_trigger,
+ &button_marker_minus,
+ &button_marker_plus,
&button_jump,
&button_rst,
- &field_rx_iq_phase_cal,
&freq_stats});
load_presets(); // Load available presets from TXT files (or default).
@@ -491,13 +499,7 @@ GlassView::GlassView(
range_presets.set_selected_index(preset_index);
field_marker.on_encoder_change = [this](TextField&, EncoderEvent delta) {
- if ((marker_pixel_index + delta) < 0)
- marker_pixel_index = marker_pixel_index + delta + screen_width;
- else if ((marker_pixel_index + delta) > screen_width)
- marker_pixel_index = marker_pixel_index + delta - screen_width;
- else
- marker_pixel_index = marker_pixel_index + delta;
- on_marker_change();
+ on_marker_change(delta);
};
field_marker.on_select = [this](TextField&) {
@@ -516,6 +518,14 @@ GlassView::GlassView(
update_range_field();
};
+ button_marker_minus.on_select = [this](Button&) {
+ on_marker_change(-1);
+ };
+
+ button_marker_plus.on_select = [this](Button&) {
+ on_marker_change(1);
+ };
+
button_jump.on_select = [this](Button&) {
// Launch Audio with peak frequency.
launch_audio(max_freq_hold);
@@ -525,13 +535,6 @@ GlassView::GlassView(
reset_live_view();
};
- field_rx_iq_phase_cal.set_range(0, hackrf_r9 ? 63 : 31); // max2839 has 6 bits [0..63], max2837 has 5 bits [0..31]
- field_rx_iq_phase_cal.set_value(get_spec_iq_phase_calibration_value()); // using accessor function of AnalogAudioView to read iq_phase_calibration_value from rx_audio.ini
- field_rx_iq_phase_cal.on_change = [this](int32_t v) {
- set_spec_iq_phase_calibration_value(v); // using accessor function of AnalogAudioView to write inside SPEC submenu, register value to max283x and save it to rx_audio.ini
- };
- set_spec_iq_phase_calibration_value(get_spec_iq_phase_calibration_value()); // initialize iq_phase_calibration in radio
-
display.scroll_set_area(109, screen_height - 1); // Restart scroll on the correct coordinates
// trigger:
@@ -581,15 +584,6 @@ void GlassView::on_freqchg(int64_t freq) {
on_range_changed();
}
-uint8_t GlassView::get_spec_iq_phase_calibration_value() { // define accessor functions inside AnalogAudioView to read & write real iq_phase_calibration_value
- return iq_phase_calibration_value;
-}
-
-void GlassView::set_spec_iq_phase_calibration_value(uint8_t cal_value) { // define accessor functions
- iq_phase_calibration_value = cal_value;
- radio::set_rx_max283x_iq_phase_calibration(iq_phase_calibration_value);
-}
-
void GlassView::load_presets() {
File presets_file;
auto error = presets_file.open(looking_glass_dir / u"PRESETS.TXT");
diff --git a/firmware/application/apps/ui_looking_glass_app.hpp b/firmware/application/apps/ui_looking_glass_app.hpp
index 5d4a99af0..fee8b2c87 100644
--- a/firmware/application/apps/ui_looking_glass_app.hpp
+++ b/firmware/application/apps/ui_looking_glass_app.hpp
@@ -66,9 +66,6 @@ class GlassView : public View {
void on_hide() override;
void focus() override;
- uint8_t get_spec_iq_phase_calibration_value();
- void set_spec_iq_phase_calibration_value(uint8_t cal_value);
-
private:
NavigationView& nav_;
Gradient gradient{};
@@ -80,11 +77,10 @@ class GlassView : public View {
uint8_t filter_index = 0; // OFF
uint8_t trigger = 32;
uint8_t mode = LOOKING_GLASS_FASTSCAN;
- uint8_t live_frequency_view = 0; // Spectrum
- uint8_t live_frequency_integrate = 3; // Default (3 * old value + new_value) / 4
- uint8_t iq_phase_calibration_value{15}; // initial default RX IQ phase calibration value , used for both max2837 & max2839
- int32_t beep_squelch = 20; // range from -100 to +20, >=20 disabled
- bool beep_enabled = false; // activate on bip button click
+ uint8_t live_frequency_view = 0; // Spectrum
+ uint8_t live_frequency_integrate = 3; // Default (3 * old value + new_value) / 4
+ int32_t beep_squelch = 20; // range from -100 to +20, >=20 disabled
+ bool beep_enabled = false; // activate on bip button click
app_settings::SettingsManager settings_{
"rx_glass"sv,
app_settings::Mode::RX,
@@ -97,7 +93,6 @@ class GlassView : public View {
{"scan_mode"sv, &mode},
{"freq_view"sv, &live_frequency_view},
{"freq_integrate"sv, &live_frequency_integrate},
- {"iq_phase_calibration"sv, &iq_phase_calibration_value}, // we are saving and restoring that CAL from Settings.
{"beep_squelch"sv, &beep_squelch},
{"beep_enabled"sv, &beep_enabled},
}};
@@ -116,9 +111,9 @@ class GlassView : public View {
void update_min(int32_t v);
void update_max(int32_t v);
void update_range_field();
- void get_max_power(const ChannelSpectrum& spectrum, uint8_t bin, uint8_t& max_power);
- rf::Frequency get_freq_from_bin_pos(uint8_t pos);
- void on_marker_change();
+ void get_max_power(const ChannelSpectrum& spectrum, uint16_t bin, uint8_t& max_power);
+ rf::Frequency get_freq_from_bin_pos(uint16_t pos);
+ void on_marker_change(int8_t delta);
void retune();
bool process_bins(uint8_t* powerlevel);
void on_channel_spectrum(const ChannelSpectrum& spectrum);
@@ -128,7 +123,7 @@ class GlassView : public View {
void on_range_changed();
void reset_live_view();
void add_spectrum_pixel(uint8_t power);
- void plot_marker(uint8_t pos);
+ void plot_marker(uint16_t pos);
void load_presets();
void populate_presets();
void launch_audio(rf::Frequency center_freq);
@@ -138,7 +133,7 @@ class GlassView : public View {
rf::Frequency f_center_ini{0};
rf::Frequency marker{0};
- uint8_t marker_pixel_index{0};
+ uint16_t marker_pixel_index{0};
rf::Frequency marker_pixel_step{0};
// Size of one spectrum bin in Hz.
@@ -173,8 +168,7 @@ class GlassView : public View {
{{0, UI_POS_Y(0)}, "MIN: MAX: LNA VGA ", Theme::getInstance()->fg_light->foreground},
{{0, 1 * 16}, "RANGE: FILTER: AMP:", Theme::getInstance()->fg_light->foreground},
{{0, 2 * 16}, "P:", Theme::getInstance()->fg_light->foreground},
- {{0, 3 * 16}, "MARKER: MHz RXIQCAL", Theme::getInstance()->fg_light->foreground},
- //{{0, 4 * 16}, "RES: STEPS:", Theme::getInstance()->fg_light->foreground}};
+ {{0, 3 * 16}, "MARKER( / ): MHz", Theme::getInstance()->fg_light->foreground},
{{0, 4 * 16}, "RES: VOL:", Theme::getInstance()->fg_light->foreground}};
NumberField field_frequency_min{
@@ -223,16 +217,16 @@ class GlassView : public View {
""};
TextField field_marker{
- {7 * 8, 3 * 16, 9 * 8, 16},
+ {12 * 8, 3 * 16, 9 * 8, 16},
""};
- NumberField field_rx_iq_phase_cal{
- {28 * 8, 3 * 16},
- 2,
- {0, 63}, // 5 or 6 bits IQ CAL phase adjustment (range updated later)
- 1,
- ' ',
- };
+ Button button_marker_minus{
+ {7 * 8, 3 * 16 + 4, 1 * 8, 1 * 8},
+ "-"};
+
+ Button button_marker_plus{
+ {9 * 8, 3 * 16 + 4, 1 * 8, 1 * 8},
+ "+"};
NumberField field_trigger{
{4 * 8, 4 * 16},
diff --git a/firmware/application/apps/ui_mictx.cpp b/firmware/application/apps/ui_mictx.cpp
index 1d8e46367..01c926068 100644
--- a/firmware/application/apps/ui_mictx.cpp
+++ b/firmware/application/apps/ui_mictx.cpp
@@ -350,6 +350,12 @@ MicTXView::MicTXView(
&tx_button,
&tx_icon});
+ // disable key repeat on select
+ initial_switch_config_ = get_switches_repeat_config();
+ SwitchesState config = initial_switch_config_;
+ config[toUType(Switch::Sel)] = false;
+ set_switches_repeat_config(config);
+
set_rxbw_options();
set_rxbw_defaults(settings_.loaded());
@@ -640,6 +646,9 @@ MicTXView::MicTXView(
}
MicTXView::~MicTXView() {
+ // restore select key repeat mode
+ set_switches_repeat_config(initial_switch_config_);
+
audio::input::stop();
if (rx_enabled) { // Also turn off both (audio rx if enabled, and disable mic_loop to HP)
rxaudio(false);
diff --git a/firmware/application/apps/ui_mictx.hpp b/firmware/application/apps/ui_mictx.hpp
index cf375e4cf..770d7cf2a 100644
--- a/firmware/application/apps/ui_mictx.hpp
+++ b/firmware/application/apps/ui_mictx.hpp
@@ -90,6 +90,9 @@ class MicTXView : public View {
sampling_rate /* sampling rate */
};
+ // structure used to control key repeat
+ SwitchesState initial_switch_config_{};
+
enum Mic_Modulation : uint32_t {
MIC_MOD_NFM = 0,
MIC_MOD_WFM = 1,
@@ -301,7 +304,8 @@ class MicTXView : public View {
{
{" 8k5 ", 0}, // Initial dynamic values when we start Mic App.
{" 11k ", 1},
- {" 16k ", 2},
+ {" 12k5 ", 2},
+ {" 16k ", 3},
}};
NumberField field_squelch{
diff --git a/firmware/application/apps/ui_recon.cpp b/firmware/application/apps/ui_recon.cpp
index 255b73bb4..e0ba6a97c 100644
--- a/firmware/application/apps/ui_recon.cpp
+++ b/firmware/application/apps/ui_recon.cpp
@@ -1261,6 +1261,7 @@ size_t ReconView::change_mode(freqman_index_t new_mod) {
else
v = SPEC_MODULATION;
field_mode.set_selected_index(v);
+ return; // Important ! Guru will occur if you don't return when modifying field from within on_change !
}
last_mode = v;
change_mode(v);
diff --git a/firmware/application/apps/ui_settings.cpp b/firmware/application/apps/ui_settings.cpp
index 7d77a0716..1e9d83375 100644
--- a/firmware/application/apps/ui_settings.cpp
+++ b/firmware/application/apps/ui_settings.cpp
@@ -33,6 +33,9 @@
#include "ui_touch_calibration.hpp"
#include "ui_text_editor.hpp"
#include "ui_external_items_menu_loader.hpp"
+#include "ui_ss_viewer.hpp"
+#include "ui_fileman.hpp"
+#include "ui_sd_card_debug.hpp"
#include "portapack_persistent_memory.hpp"
#include "lpc43xx_cpp.hpp"
@@ -309,13 +312,45 @@ SetFrequencyCorrectionModel SetRadioView::form_collect() {
};
}
+/* SetTXLimitView ************************************/
+
+SetTXLimitView::SetTXLimitView(NavigationView& nav) {
+ add_children({
+ &labels,
+ &tx_gain_max_db,
+ &tx_disable_switch,
+ &tx_amp_disable_switch,
+ &button_save,
+ &button_cancel,
+ });
+
+ tx_disable_switch.set_value(pmem::config_tx_disabled());
+ tx_amp_disable_switch.set_value(pmem::config_tx_amp_disabled());
+ tx_gain_max_db.set_value(pmem::config_tx_gain_max_db());
+
+ button_save.on_select = [&nav, this](Button&) {
+ pmem::set_config_tx_disabled(tx_disable_switch.value());
+ pmem::set_config_tx_amp_disabled(tx_amp_disable_switch.value());
+ pmem::set_config_tx_gain_max_db(tx_gain_max_db.value());
+ send_system_refresh();
+ nav.pop();
+ };
+
+ button_cancel.on_select = [&nav, this](Button&) {
+ nav.pop();
+ };
+}
+
+void SetTXLimitView::focus() {
+ button_save.focus();
+}
+
/* SetUIView *********************************************/
SetUIView::SetUIView(NavigationView& nav) {
add_children({&checkbox_disable_touchscreen,
&checkbox_bloff,
&options_bloff,
- &checkbox_showsplash,
&checkbox_showclock,
&options_clockformat,
&checkbox_guireturnflag,
@@ -342,7 +377,6 @@ SetUIView::SetUIView(NavigationView& nav) {
}
checkbox_disable_touchscreen.set_value(pmem::disable_touchscreen());
- checkbox_showsplash.set_value(pmem::config_splash());
checkbox_showclock.set_value(!pmem::hide_clock());
checkbox_guireturnflag.set_value(pmem::show_gui_return_icon());
@@ -381,7 +415,6 @@ SetUIView::SetUIView(NavigationView& nav) {
pmem::set_clock_with_date(false);
}
- pmem::set_config_splash(checkbox_showsplash.value());
pmem::set_clock_hidden(!checkbox_showclock.value());
pmem::set_gui_return_icon(checkbox_guireturnflag.value());
pmem::set_disable_touchscreen(checkbox_disable_touchscreen.value());
@@ -414,7 +447,11 @@ void SetUIView::focus() {
/* SetSDCardView *********************************************/
SetSDCardView::SetSDCardView(NavigationView& nav) {
- add_children({&labels,
+ add_children({&status_labels,
+ &text_card_status,
+ &text_filesystem_type,
+ &button_more_info,
+ &labels,
&checkbox_sdcard_speed,
&button_test_sdcard_high_speed,
&text_sdcard_test_status,
@@ -423,6 +460,10 @@ SetSDCardView::SetSDCardView(NavigationView& nav) {
checkbox_sdcard_speed.set_value(pmem::config_sdcard_high_speed_io());
+ button_more_info.on_select = [&nav, this](Button&) {
+ nav.push();
+ };
+
button_test_sdcard_high_speed.on_select = [&nav, this](Button&) {
pmem::set_config_sdcard_high_speed_io(true, false);
text_sdcard_test_status.set("!! HIGH SPEED MODE ON !!");
@@ -440,7 +481,79 @@ SetSDCardView::SetSDCardView(NavigationView& nav) {
}
void SetSDCardView::focus() {
- button_save.focus();
+ button_cancel.focus();
+}
+
+void SetSDCardView::on_show() {
+ sd_card_status_signal_token = sd_card::status_signal += [this](const sd_card::Status) {
+ update_sd_card_status();
+ };
+
+ update_sd_card_status();
+}
+
+void SetSDCardView::on_hide() {
+ sd_card::status_signal -= sd_card_status_signal_token;
+}
+
+void SetSDCardView::update_sd_card_status() {
+ using sd_card::Status;
+
+ const auto status = sd_card::status();
+
+ // Update card status text
+ switch (status) {
+ case Status::NotPresent:
+ text_card_status.set("Not Inserted");
+ text_filesystem_type.set("---");
+ break;
+ case Status::Present:
+ text_card_status.set("Inserted");
+ text_filesystem_type.set("---");
+ break;
+ case Status::Mounted:
+ text_card_status.set("Mounted");
+ // Determine filesystem type
+ {
+ const auto fs_type = sd_card::fs.fs_type;
+ std::string fs_name;
+ switch (fs_type) {
+ case FS_FAT12:
+ fs_name = "FAT12";
+ break;
+ case FS_FAT16:
+ fs_name = "FAT16";
+ break;
+ case FS_FAT32:
+ fs_name = "FAT32";
+ break;
+ case FS_EXFAT:
+ fs_name = "exFAT";
+ break;
+ default:
+ fs_name = "Unknown";
+ break;
+ }
+ text_filesystem_type.set(fs_name);
+ }
+ break;
+ case Status::ConnectError:
+ text_card_status.set("Connect Error");
+ text_filesystem_type.set("---");
+ break;
+ case Status::MountError:
+ text_card_status.set("Mount Error");
+ text_filesystem_type.set("---");
+ break;
+ case Status::IOError:
+ text_card_status.set("I/O Error");
+ text_filesystem_type.set("---");
+ break;
+ default:
+ text_card_status.set("Unknown");
+ text_filesystem_type.set("---");
+ break;
+ }
}
/* SetConverterSettingsView ******************************/
@@ -1080,6 +1193,46 @@ void SetBatteryView::focus() {
button_cancel.focus();
}
+/* SetSlpash *********************************************/
+
+SetSplash::SetSplash(NavigationView& nav) {
+ add_children({&checkbox_showsplash,
+ &checkbox_randomsplash,
+ &message,
+ &button_picture_select,
+ &button_save,
+ &button_cancel});
+
+ checkbox_showsplash.set_value(pmem::config_splash());
+ splash_bmp_exists = file_exists(splash_dot_bmp);
+ checkbox_randomsplash.set_value(!splash_bmp_exists);
+ message.hidden(splash_bmp_exists);
+
+ checkbox_randomsplash.on_select = [this](Checkbox&, bool v) {
+ random_enabled = v;
+ };
+
+ button_picture_select.on_select = [this, &nav](Button&) {
+ auto ret = nav.push();
+ ret->push_dir(splash_dir);
+ };
+
+ button_save.on_select = [&nav, this](Button&) {
+ if (random_enabled == true) delete_file(splash_dot_bmp);
+ pmem::set_config_splash(checkbox_showsplash.value());
+ send_system_refresh();
+ nav.pop();
+ };
+
+ button_cancel.on_select = [&nav, this](Button&) {
+ nav.pop();
+ };
+}
+
+void SetSplash::focus() {
+ button_save.focus();
+}
+
/* SettingsMenuView **************************************/
SettingsMenuView::SettingsMenuView(NavigationView& nav)
@@ -1107,11 +1260,13 @@ void SettingsMenuView::on_populate() {
{"Freq. Correct", ui::Color::dark_cyan(), &bitmap_icon_options_radio, [this]() { nav_.push(); }},
{"P.Memory Mgmt", ui::Color::dark_cyan(), &bitmap_icon_memory, [this]() { nav_.push(); }},
{"Radio", ui::Color::dark_cyan(), &bitmap_icon_options_radio, [this]() { nav_.push(); }},
+ {"TX Limit", ui::Color::dark_cyan(), &bitmap_icon_options_radio, [this]() { nav_.push(); }},
{"SD Card", ui::Color::dark_cyan(), &bitmap_icon_sdcard, [this]() { nav_.push(); }},
{"User Interface", ui::Color::dark_cyan(), &bitmap_icon_options_ui, [this]() { nav_.push(); }},
{"Display", ui::Color::dark_cyan(), &bitmap_icon_brightness, [this]() { nav_.push(); }},
{"Menu Color", ui::Color::dark_cyan(), &bitmap_icon_brightness, [this]() { nav_.push(); }},
{"Theme", ui::Color::dark_cyan(), &bitmap_icon_setup, [this]() { nav_.push(); }},
+ {"Splash settings", ui::Color::dark_cyan(), &bitmap_icon_file_image, [this]() { nav_.push(); }},
});
if (battery::BatteryManagement::isDetected()) add_item({"Battery", ui::Color::dark_cyan(), &bitmap_icon_batt_icon, [this]() { nav_.push(); }});
diff --git a/firmware/application/apps/ui_settings.hpp b/firmware/application/apps/ui_settings.hpp
index a2f5f8e05..0a8898a03 100644
--- a/firmware/application/apps/ui_settings.hpp
+++ b/firmware/application/apps/ui_settings.hpp
@@ -34,6 +34,7 @@
#include "ui_navigation.hpp"
#include "bitmap.hpp"
#include "ff.h"
+#include "sd_card.hpp"
#include "portapack_persistent_memory.hpp"
#include "irq_controls.hpp"
@@ -264,6 +265,51 @@ class SetRadioView : public View {
SetFrequencyCorrectionModel form_collect();
};
+class SetTXLimitView : public View {
+ public:
+ SetTXLimitView(NavigationView& nav);
+
+ void focus() override;
+
+ std::string title() const override { return "TX Limit"; };
+
+ private:
+ Labels labels{
+ {{1 * 8, 1 * 16}, "Limits RF TX Gain", Theme::getInstance()->fg_light->foreground},
+ {{1 * 8, 2 * 16}, "(This may affect", Theme::getInstance()->fg_light->foreground},
+ {{1 * 8, 3 * 16}, "all applications.)", Theme::getInstance()->fg_light->foreground},
+ {{2 * 8, 12 * 16}, "TX Max Gain:", Theme::getInstance()->fg_light->foreground},
+
+ };
+
+ Checkbox tx_disable_switch{
+ {1 * 8, 6 * 16},
+ 23,
+ "Disable TX"};
+
+ Checkbox tx_amp_disable_switch{
+ {1 * 8, 8 * 16},
+ 23,
+ "Disable TX Amp"};
+
+ NumberField tx_gain_max_db{
+ {20 * 8, 12 * 16},
+ 6,
+ {0, 47},
+ 1,
+ ' ',
+ };
+
+ Button button_save{
+ {UI_POS_X_CENTER(12) - UI_POS_WIDTH(8), UI_POS_Y_BOTTOM(4), UI_POS_WIDTH(12), UI_POS_HEIGHT(2)},
+ "Save"};
+
+ Button button_cancel{
+ {UI_POS_X_CENTER(16) + UI_POS_WIDTH(8), UI_POS_Y_BOTTOM(4), UI_POS_WIDTH(12), UI_POS_HEIGHT(2)},
+ "Cancel",
+ };
+};
+
using portapack::persistent_memory::backlight_timeout_t;
class SetUIView : public View {
@@ -298,77 +344,72 @@ class SetUIView : public View {
{"1 hour", backlight_timeout_t::Timeout3600Sec},
}};
- Checkbox checkbox_showsplash{
- {3 * 8, 6 * 16},
- 20,
- "Show splash"};
-
Checkbox checkbox_showclock{
- {3 * 8, 8 * 16},
+ {3 * 8, 6 * 16},
20,
"Show clock with:"};
OptionsField options_clockformat{
- {60, 9 * 16 + 8},
+ {60, 7 * 16 + 8},
20,
{{"time only", 0},
{"time and date", 1}}};
Checkbox checkbox_guireturnflag{
- {3 * 8, 11 * 16},
+ {3 * 8, 9 * 16},
20,
"Back button in menu"};
Labels labels{
- {{3 * 8, 13 * 16}, "Show/Hide Status Icons", Theme::getInstance()->fg_light->foreground},
+ {{3 * 8, 11 * 16}, "Show/Hide Status Icons", Theme::getInstance()->fg_light->foreground},
};
ImageToggle toggle_camera{
- {2 * 8, 14 * 16 + 2, 16, 16},
+ {2 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_camera};
ImageToggle toggle_sleep{
- {4 * 8, 14 * 16 + 2, 16, 16},
+ {4 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_sleep};
ImageToggle toggle_stealth{
- {6 * 8, 14 * 16 + 2, 16, 16},
+ {6 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_stealth};
ImageToggle toggle_converter{
- {8 * 8, 14 * 16 + 2, 16, 16},
+ {8 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_upconvert};
ImageToggle toggle_bias_tee{
- {10 * 8, 14 * 16 + 2, 16, 16},
+ {10 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_biast_off};
ImageToggle toggle_clock{
- {12 * 8, 14 * 16 + 2, 8, 16},
+ {12 * 8, 12 * 16 + 2, 8, 16},
&bitmap_icon_clk_ext};
ImageToggle toggle_mute{
- {13 * 8, 14 * 16 + 2, 16, 16},
+ {13 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_speaker_and_headphones_mute};
ImageToggle toggle_speaker{
- {15 * 8, 14 * 16 + 2, 16, 16},
+ {15 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_speaker_mute};
ImageToggle toggle_battery_icon{
- {17 * 8, 14 * 16 + 2, 16, 16},
+ {17 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_batt_icon};
ImageToggle toggle_battery_text{
- {19 * 8, 14 * 16 + 2, 16, 16},
+ {19 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_batt_text};
ImageToggle toggle_fake_brightness{
- {21 * 8, 14 * 16 + 2, 16, 16},
+ {21 * 8, 12 * 16 + 2, 16, 16},
&bitmap_icon_brightness};
ImageToggle toggle_sd_card{
- {23 * 8, 14 * 16 + 2, 16, 16},
+ {23 * 8, 12 * 16 + 2, 16, 16},
&bitmap_sd_card_ok};
Button button_save{
@@ -384,36 +425,60 @@ class SetSDCardView : public View {
public:
SetSDCardView(NavigationView& nav);
+ void on_show() override;
+ void on_hide() override;
+
void focus() override;
std::string title() const override { return "SD Card"; };
private:
+ SignalToken sd_card_status_signal_token{};
+ // Status section (top half)
+ Labels status_labels{
+ {{10, 24}, "Card Status:", Theme::getInstance()->fg_light->foreground},
+ {{10, 48}, "Filesystem:", Theme::getInstance()->fg_light->foreground}};
+
+ Text text_card_status{
+ {120, 24, 110, 16},
+ ""};
+
+ Text text_filesystem_type{
+ {120, 48, 110, 16},
+ ""};
+
+ Button button_more_info{
+ {UI_POS_X_CENTER(20), 90, UI_POS_WIDTH(20), UI_POS_HEIGHT(2)},
+ "More Info"};
+
+ // Settings section (bottom half)
Labels labels{
// 01234567890123456789012345678
- {{UI_POS_X_CENTER(26), 120 - 48}, " HIGH SPEED SDCARD IO ", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X_CENTER(26), 120 - 32}, " May or may not work !! ", Theme::getInstance()->fg_light->foreground}};
+ {{UI_POS_X_CENTER(26), 140}, " HIGH SPEED SDCARD IO ", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X_CENTER(26), 156}, " May or may not work !! ", Theme::getInstance()->fg_light->foreground}};
Checkbox checkbox_sdcard_speed{
- {UI_POS_X_CENTER(26), 120},
+ {UI_POS_X_CENTER(26), 180},
20,
"enable high speed IO"};
Button button_test_sdcard_high_speed{
- {UI_POS_X_CENTER(27), 152, UI_POS_WIDTH(27), UI_POS_HEIGHT(2)},
+ {UI_POS_X_CENTER(27), 210, UI_POS_WIDTH(27), UI_POS_HEIGHT(2)},
"TEST BUTTON (NO PMEM SAVE)"};
Text text_sdcard_test_status{
- {UI_POS_X_CENTER(28), 198, UI_POS_WIDTH(28), UI_POS_HEIGHT(1)},
+ {UI_POS_X_CENTER(28), 256, UI_POS_WIDTH(28), UI_POS_HEIGHT(1)},
""};
Button button_save{
- {UI_POS_X_CENTER(12) - UI_POS_WIDTH(8), UI_POS_Y_BOTTOM(4), UI_POS_WIDTH(12), UI_POS_HEIGHT(2)},
+ {UI_POS_X_CENTER(12) - UI_POS_WIDTH(8), UI_POS_Y_BOTTOM(4), 12 * 8, 32},
"Save"};
Button button_cancel{
- {UI_POS_X_CENTER(16) + UI_POS_WIDTH(8), UI_POS_Y_BOTTOM(4), UI_POS_WIDTH(12), UI_POS_HEIGHT(2)},
+ {UI_POS_X_CENTER(12) + UI_POS_WIDTH(8), UI_POS_Y_BOTTOM(4), 12 * 8, 32},
"Cancel"};
+
+ void update_sd_card_status();
};
class SetConverterSettingsView : public View {
@@ -1024,6 +1089,43 @@ class SettingsMenuView : public BtnGridView {
void on_populate() override;
};
+class SetSplash : public View {
+ public:
+ SetSplash(NavigationView& nav);
+
+ void focus() override;
+
+ std::string title() const override { return "Splash"; };
+
+ private:
+ bool splash_bmp_exists = false;
+ bool random_enabled = false;
+
+ Checkbox checkbox_showsplash{
+ {3 * 8, 1 * 16},
+ 20,
+ "Show splash"};
+
+ Checkbox checkbox_randomsplash{
+ {3 * 8, 3 * 16},
+ 20,
+ "Random splash on boot"};
+
+ Text message{{UI_POS_X(1), UI_POS_Y(6), UI_POS_WIDTH(26), UI_POS_HEIGHT(1)}, "Pick image to stop random."};
+
+ Button button_picture_select{
+ {UI_POS_X_CENTER(8) - UI_POS_WIDTH(4), UI_POS_Y_BOTTOM(7), UI_POS_WIDTH(13), UI_POS_HEIGHT(2)},
+ "Select pict."};
+
+ Button button_save{
+ {UI_POS_X_CENTER(12) - UI_POS_WIDTH(8), UI_POS_Y_BOTTOM(4), UI_POS_WIDTH(12), UI_POS_HEIGHT(2)},
+ "Save"};
+
+ Button button_cancel{
+ {UI_POS_X_CENTER(16) + UI_POS_WIDTH(8), UI_POS_Y_BOTTOM(4), UI_POS_WIDTH(12), UI_POS_HEIGHT(2)},
+ "Cancel"};
+};
+
} /* namespace ui */
#endif /*__UI_SETTINGS_H__*/
diff --git a/firmware/application/apps/ui_ss_viewer.cpp b/firmware/application/apps/ui_ss_viewer.cpp
index 67ead2955..bc3690843 100644
--- a/firmware/application/apps/ui_ss_viewer.cpp
+++ b/firmware/application/apps/ui_ss_viewer.cpp
@@ -27,8 +27,6 @@ namespace fs = std::filesystem;
namespace ui {
-const std::filesystem::path splash_dot_bmp{u"/splash.bmp"};
-
ScreenshotViewer::ScreenshotViewer(
NavigationView& nav,
const std::filesystem::path& path)
@@ -44,7 +42,6 @@ bool ScreenshotViewer::on_key(KeyEvent) {
void ScreenshotViewer::paint(Painter& painter) {
File file{};
-
painter.fill_rectangle({0, 0, screen_width, screen_height}, Color::black());
auto show_invalid = [&]() {
diff --git a/firmware/application/apps/ui_ss_viewer.hpp b/firmware/application/apps/ui_ss_viewer.hpp
index 852513256..d35ce0708 100644
--- a/firmware/application/apps/ui_ss_viewer.hpp
+++ b/firmware/application/apps/ui_ss_viewer.hpp
@@ -28,11 +28,10 @@
#include "ui_painter.hpp"
#include "ui_widget.hpp"
#include "file.hpp"
+#include "file_path.hpp"
namespace ui {
-extern const std::filesystem::path splash_dot_bmp;
-
class ScreenshotViewer : public View {
public:
ScreenshotViewer(NavigationView& nav, const std::filesystem::path& path);
@@ -49,7 +48,6 @@ class SplashViewer : public View {
SplashViewer(NavigationView& nav, const std::filesystem::path& path);
bool on_key(KeyEvent key) override;
void paint(Painter& painter) override;
- void update_ss(void);
private:
NavigationView& nav_;
diff --git a/firmware/application/apps/ui_standalone_view.cpp b/firmware/application/apps/ui_standalone_view.cpp
index 33ab46486..540a18396 100644
--- a/firmware/application/apps/ui_standalone_view.cpp
+++ b/firmware/application/apps/ui_standalone_view.cpp
@@ -34,7 +34,7 @@
namespace ui {
void create_thread(int32_t (*fn)(void*), void* arg, size_t stack_size, int priority) {
- // TODO: collect memory on terminate, once this is used
+ // TODO: collect memory on terminate, once this is used. This is a HUGE TODO! need to call chThdWait on all of them!
chThdCreateFromHeap(NULL, stack_size, priority, fn, arg);
}
diff --git a/firmware/application/apps/ui_subghzd.cpp b/firmware/application/apps/ui_subghzd.cpp
index 6cd6318c5..027e39124 100644
--- a/firmware/application/apps/ui_subghzd.cpp
+++ b/firmware/application/apps/ui_subghzd.cpp
@@ -25,6 +25,10 @@
#include "baseband_api.hpp"
#include "string_format.hpp"
#include "file_path.hpp"
+#include "ui_textentry.hpp"
+#include "../keeloq_keystore.hpp"
+#include "../keeloq_file.hpp"
+#include "../keeloq_common.hpp"
#include "portapack_persistent_memory.hpp"
using namespace portapack;
@@ -56,6 +60,51 @@ void SubGhzDRecentEntryDetailView::update_data() {
if (cnt != SD_NO_CNT) console.writeln("Cnt: " + to_string_dec_uint(cnt));
if (entry_.data != 0) console.writeln("Data: " + to_string_hex(entry_.data));
+
+ if (entry_.sensorType == FPS_RESTAURANT_PAGER) {
+ uint8_t pager_addr = (entry_.data >> 5) & 0x0F;
+ uint8_t func_code = (entry_.data >> 1) & 0x0F;
+ console.writeln("Station: 0x" + to_string_hex(serial) + " (" + to_string_dec_uint(serial) + ")");
+ console.writeln("Pager: " + to_string_dec_uint(pager_addr));
+ if (func_code == 0x0D)
+ console.writeln("Action: Buzz");
+ else if (func_code == 0x0F)
+ console.writeln("Action: Sync");
+ else
+ console.writeln("Action: 0x" + to_string_hex(func_code));
+ }
+
+ if (entry_.sensorType == FPS_KEELOQ) {
+ console.writeln("Fix: " + to_string_hex(fix));
+ console.writeln("Encrypted: " + to_string_hex(encrypted));
+ console.writeln("Manufacturer: " + mf_name);
+
+ if (hop != SD_NO_HOP) {
+ console.writeln("Hop: " + to_string_hex(hop));
+
+ add_children({&button_save});
+
+ button_save.on_select = [this](const Button&) {
+ keeloq_file_buffer.clear();
+
+ text_prompt(
+ nav_,
+ keeloq_file_buffer,
+ 64,
+ ENTER_KEYBOARD_MODE_ALPHA,
+ [this](std::string& buffer) {
+ KeeloqData params{
+ mf_name,
+ serial,
+ (uint16_t)cnt,
+ btn};
+
+ ensure_directory(keeloq_remotes_dir);
+ write_keeloq_file(keeloq_remotes_dir / buffer + ".KEELOQ", params);
+ });
+ };
+ }
+ }
}
SubGhzDRecentEntryDetailView::SubGhzDRecentEntryDetailView(NavigationView& nav, const SubGhzDRecentEntry& entry)
@@ -242,6 +291,10 @@ const char* SubGhzDView::getSensorTypeName(FPROTO_SUBGHZD_SENSOR type) {
return "GangQi";
case FPS_MARANTEC24:
return "Marantec24";
+ case FPS_HOLTEKHT6P20B:
+ return "Holtek HT6P20B";
+ case FPS_RESTAURANT_PAGER:
+ return "Rest. Pager";
case FPS_Invalid:
default:
return "Unknown";
@@ -268,7 +321,14 @@ void RecentEntriesTable::draw(
line.reserve(30);
line = SubGhzDView::getSensorTypeName((FPROTO_SUBGHZD_SENSOR)entry.sensorType);
- line = line + " " + to_string_hex(entry.data << 32);
+ if (entry.sensorType == FPS_RESTAURANT_PAGER) {
+ uint8_t pgr = (entry.data >> 5) & 0x0F;
+ uint8_t func = (entry.data >> 1) & 0x0F;
+ line += " P:" + to_string_dec_uint(pgr) + (func == 0x0D ? " Buzz" : func == 0x0F ? " Sync"
+ : "");
+ } else {
+ line = line + " " + to_string_hex(entry.data << 32);
+ }
line.resize(columns.at(0).second, ' ');
std::string ageStr = to_string_dec_uint(entry.age);
std::string bitsStr = to_string_dec_uint(entry.bits);
@@ -301,6 +361,29 @@ void atomo_decrypt(uint8_t* buff) {
}
}
+bool SubGhzDRecentEntryDetailView::keeloq_check_decrypt(uint32_t decrypt) {
+ uint16_t end_serial = serial & 0xFF;
+
+ if ((decrypt >> 28 == btn) && (((((uint16_t)(decrypt >> 16)) & 0xFF) == end_serial) ||
+ ((((uint16_t)(decrypt >> 16)) & 0xFF) == 0))) {
+ cnt = decrypt & 0xFFFF;
+
+ return true;
+ }
+
+ return false;
+}
+
+bool SubGhzDRecentEntryDetailView::keeloq_check_decrypt_centurion(uint32_t decrypt) {
+ if ((decrypt >> 28 == btn) && ((((uint16_t)(decrypt >> 16)) & 0x3FF) == 0x1CE)) {
+ cnt = decrypt & 0xFFFF;
+
+ return true;
+ }
+
+ return false;
+}
+
const uint32_t came_twee_magic_numbers_xor[15] = {
0x0E0E0E00,
0x1D1D1D11,
@@ -589,7 +672,57 @@ void SubGhzDRecentEntryDetailView::parseProtocol() {
}
if (entry_.sensorType == FPS_KEELOQ) {
- // too many sub protocol versions, skipping. maybe in future when we'll have much more fw space
+ uint64_t data_rev = FProtoGeneral::subghz_protocol_blocks_reverse_key(entry_.data, 64);
+
+ btn = data_rev >> 60;
+ serial = (data_rev >> 32) & 0xFFFFFFF;
+ fix = data_rev >> 32;
+ encrypted = data_rev & 0xFFFFFFFF;
+
+ KeeloqKeystore keystore{};
+
+ const auto& keys = keystore.get_keys();
+
+ if (keys.empty()) {
+ return;
+ }
+
+ for (const auto& key : keys) {
+ switch (key.type) {
+ case KEELOQ_SIMPLE_LEARNING: {
+ uint32_t decrypted = keeloq_decrypt(encrypted, key.key);
+
+ if (keeloq_check_decrypt(decrypted)) {
+ mf_name = key.mf_name;
+ hop = decrypted;
+
+ return;
+ }
+
+ break;
+ }
+
+ case KEELOQ_NORMAL_LEARNING: {
+ uint64_t man = keeloq_normal_learning(fix, key.key);
+ uint32_t decrypted = keeloq_decrypt(encrypted, man);
+
+ if (key.mf_name == "Centurion" && keeloq_check_decrypt_centurion(decrypted)) {
+ mf_name = "Centurion";
+ hop = decrypted;
+
+ return;
+ } else if (keeloq_check_decrypt(decrypted)) {
+ mf_name = key.mf_name;
+ hop = decrypted;
+
+ return;
+ }
+
+ break;
+ }
+ }
+ }
+
return;
}
@@ -756,5 +889,18 @@ void SubGhzDRecentEntryDetailView::parseProtocol() {
btn = entry_.data & 0xf;
return;
}
+
+ if (entry_.sensorType == FPS_HOLTEKHT6P20B) {
+ serial = entry_.data >> 8;
+ btn = (entry_.data >> 4) & 0xF;
+ return;
+ }
+
+ if (entry_.sensorType == FPS_RESTAURANT_PAGER) {
+ // 25-bit EV1527-variant: [sysid:16][pager:4][func:4][stop:1]
+ serial = (entry_.data >> 9) & 0xFFFF; // System ID
+ // btn/cnt left as SD_NO values; friendly output in update_data()
+ return;
+ }
}
-} // namespace ui
\ No newline at end of file
+} // namespace ui
diff --git a/firmware/application/apps/ui_subghzd.hpp b/firmware/application/apps/ui_subghzd.hpp
index 0c0ed8a3f..cbf5f34e5 100644
--- a/firmware/application/apps/ui_subghzd.hpp
+++ b/firmware/application/apps/ui_subghzd.hpp
@@ -25,7 +25,11 @@
#define SD_NO_SERIAL 0xFFFFFFFF
#define SD_NO_BTN 0xFF
-#define SD_NO_CNT 0xFF
+#define SD_NO_CNT 0xFFFFFFFF
+
+#define SD_NO_HOP 0xFFFFFFFF
+#define SD_NO_FIX 0xFFFFFFFF
+#define SD_NO_ENCRYPTED 0xFFFFFFFF
#include "ui.hpp"
#include "ui_navigation.hpp"
@@ -191,6 +195,19 @@ class SubGhzDRecentEntryDetailView : public View {
uint32_t cnt = SD_NO_CNT;
uint32_t seed = 0;
+ // keeloq specific
+ std::string keeloq_file_buffer{};
+
+ uint32_t hop = SD_NO_HOP;
+ uint32_t fix = SD_NO_FIX;
+ uint32_t encrypted = SD_NO_ENCRYPTED;
+
+ std::string mf_name = "Unknown";
+
+ bool keeloq_check_decrypt(uint32_t decrypt);
+ bool keeloq_check_decrypt_centurion(uint32_t decrypt);
+ // end keeloq specific
+
Text text_type{{UI_POS_X(0), 1 * 16, 15 * 8, 16}, "?"};
Text text_id{{6 * 8, 2 * 16, 10 * 8, 16}, "?"};
@@ -207,6 +224,10 @@ class SubGhzDRecentEntryDetailView : public View {
{screen_width - 96 - 4, screen_height - 32 - 12, 96, 32},
"Done"};
+ Button button_save{
+ {4, screen_height - 32 - 12, 96, 32},
+ "Save"};
+
void parseProtocol();
};
diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp
index 7152b7430..42a172e7b 100644
--- a/firmware/application/baseband_api.cpp
+++ b/firmware/application/baseband_api.cpp
@@ -21,6 +21,7 @@
*/
#include "baseband_api.hpp"
+#include
#include "audio.hpp"
#include "tonesets.hpp"
@@ -52,12 +53,21 @@ static void send_message(const Message* const message) {
creg::m0apptxevent::assert_event();
if constexpr (check_for_message_hang) {
+#ifdef PRALINE
+ /* Timeout: ~3 seconds at typical clock speeds */
+ auto count = 200'000'000u;
+#else
auto count = UINT32_MAX;
+#endif
while (shared_memory.baseband_message && --count)
/* spin */;
if (count == 0)
+#ifdef PRALINE
+ chDbgPanic("BB Msg Timeout");
+#else
chDbgPanic("Baseband Send Fail");
+#endif
} else {
while (shared_memory.baseband_message)
/* spin */;
@@ -325,12 +335,22 @@ void set_rds_data(const uint16_t message_length) {
send_message(&message);
}
-void set_spectrum(const size_t sampling_rate, const size_t trigger) {
+void set_spectrum(
+ const size_t sampling_rate,
+ const size_t trigger) {
const WidebandSpectrumConfigMessage message{
sampling_rate, trigger};
send_message(&message);
}
+void set_time_sink(
+ const size_t sampling_rate,
+ const size_t trigger) {
+ const TimeSinkConfigMessage message{
+ sampling_rate, trigger};
+ send_message(&message);
+}
+
void set_wefax_config(uint8_t lpm = 120, uint8_t ioc = 0) {
const WeFaxRxConfigureMessage message{lpm, ioc};
send_message(&message);
@@ -368,6 +388,54 @@ void set_subghzd_config(uint8_t modulation = 0, uint32_t sampling_rate = 0) {
send_message(&message);
}
+void set_moreserx_config(uint8_t mode) {
+ const MorseRXConfigureMessage message{mode};
+ send_message(&message);
+}
+
+void set_morsetx_config(uint8_t mode, uint32_t tone, float fm_delta) {
+ const MorseTXConfigureMessage message{mode, tone, fm_delta};
+ send_message(&message);
+}
+
+void set_morsetx_key(bool key_down) {
+ const MorseTXkeyMessage message{key_down};
+ send_message(&message);
+}
+
+void set_bitstream_config(uint32_t deviation, uint8_t mode) {
+ const StreamTXConfigurationMessage message{deviation, mode};
+ send_message(&message);
+}
+
+void set_rtty_config(uint16_t baud, uint16_t shift, uint8_t* payload, uint16_t payload_length) {
+ RTTYDataMessage message{baud, shift};
+ if (payload && payload_length > 0) {
+ message.data_len = payload_length > message.max_len ? message.max_len : payload_length;
+ for (size_t i = 0; i < message.data_len; ++i) {
+ message.data[i] = payload[i];
+ }
+ }
+ send_message(&message);
+}
+
+void set_rtty_config(RTTYDataMessage& message) {
+ send_message(&message);
+}
+
+void set_epirb_tx_config(EPIRBTXDataMessage& message) {
+ send_message(&message);
+}
+
+void set_p25tx_data(const uint8_t* dibits, uint16_t frame_length) {
+ const size_t max_len = sizeof(shared_memory.bb_data.data);
+ if (frame_length > max_len) frame_length = max_len;
+ memcpy(shared_memory.bb_data.data, dibits, frame_length);
+ P25TxConfigureMessage msg{};
+ msg.frame_length = frame_length;
+ send_message(&msg);
+}
+
static bool baseband_image_running = false;
void run_image(const spi_flash::image_tag_t image_tag) {
@@ -429,7 +497,12 @@ void shutdown() {
send_message(&message);
shared_memory.application_queue.reset();
-
+ // Allow time for the shutdown message to be processed and for the baseband
+ // core to stop before starting another image. Otherwise, the M4 may still be
+ // running and cause a crash when the next image is started.
+#ifdef PRALINE
+ chThdSleepMilliseconds(20);
+#endif
baseband_image_running = false;
}
diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp
index 86d06604c..95a6b6913 100644
--- a/firmware/application/baseband_api.hpp
+++ b/firmware/application/baseband_api.hpp
@@ -97,14 +97,27 @@ void set_pocsag(int8_t baud_config = -1);
void set_adsb();
void set_jammer(const bool run, const jammer::JammerType type, const uint32_t speed);
void set_rds_data(const uint16_t message_length);
-void set_spectrum(const size_t sampling_rate, const size_t trigger);
+void set_spectrum(
+ const size_t sampling_rate,
+ const size_t trigger);
+void set_time_sink(
+ const size_t sampling_rate,
+ const size_t trigger);
void set_siggen_tone(const uint32_t tone);
void set_siggen_config(const uint32_t bw, const uint32_t shape, const uint32_t duration);
void set_spectrum_painter_config(const uint16_t width, const uint16_t height, bool update, int32_t bw);
void set_subghzd_config(uint8_t modulation, uint32_t sampling_rate);
+void set_moreserx_config(uint8_t mode);
+void set_morsetx_config(uint8_t mode, uint32_t tone, float fm_delta);
+void set_morsetx_key(bool key_down);
void set_wefax_config(uint8_t lpm, uint8_t ioc);
void set_noaaapt_config();
void set_flex_config();
+void set_bitstream_config(uint32_t deviation, uint8_t mode); // mode 0 for am, 1 for 2fsk
+void set_rtty_config(uint16_t baud, uint16_t shift, uint8_t* payload = nullptr, uint16_t payload_length = 0); // baud*100
+void set_rtty_config(RTTYDataMessage& message);
+void set_epirb_tx_config(EPIRBTXDataMessage& message);
+void set_p25tx_data(const uint8_t* dibits, uint16_t frame_length);
void request_roger_beep();
void request_rssi_beep();
diff --git a/firmware/application/bitmap.hpp b/firmware/application/bitmap.hpp
index 417cfdac9..2ae27c494 100644
--- a/firmware/application/bitmap.hpp
+++ b/firmware/application/bitmap.hpp
@@ -4297,7 +4297,7 @@ static constexpr Bitmap bitmap_icon_tpms{
{16, 16},
bitmap_icon_tpms_data};
-static constexpr uint8_t bitmap_icon_tranceivers_data[] = {
+static constexpr uint8_t bitmap_icon_transceivers_data[] = {
0x80,
0x01,
0xC0,
@@ -4331,9 +4331,9 @@ static constexpr uint8_t bitmap_icon_tranceivers_data[] = {
0xFF,
0xFF,
};
-static constexpr Bitmap bitmap_icon_tranceivers{
+static constexpr Bitmap bitmap_icon_transceivers{
{16, 16},
- bitmap_icon_tranceivers_data};
+ bitmap_icon_transceivers_data};
static constexpr uint8_t bitmap_icon_transmit_data[] = {
0x80,
diff --git a/firmware/application/clock_manager.cpp b/firmware/application/clock_manager.cpp
index 391a687d1..063492706 100644
--- a/firmware/application/clock_manager.cpp
+++ b/firmware/application/clock_manager.cpp
@@ -23,6 +23,7 @@
#include "portapack_persistent_memory.hpp"
#include "portapack_io.hpp"
+#include "portapack.hpp"
#include "hackrf_hal.hpp"
using namespace hackrf::one;
@@ -30,8 +31,20 @@ using namespace hackrf::one;
#include "lpc43xx_cpp.hpp"
using namespace lpc43xx;
+#ifdef PRALINE
+extern "C" {
+#include "fpga_bridge.h"
+}
+// Need access to ssp1_arbiter from radio namespace for FPGA related radio method dependencies.
+#include "radio.hpp"
+#endif
+
constexpr uint32_t si5351_vco_f = 800000000;
+#ifdef PRALINE
+constexpr uint32_t si5351_vco_afe_f = 800000000; // If necessary may be changed to 768 MHz for optimal for 3.072 MHz sample frequencies commonly used by apps
+#endif
+
constexpr si5351::Inputs si5351_inputs{
.f_xtal = si5351_xtal_f,
.f_clkin = si5351_clkin_f,
@@ -53,7 +66,49 @@ constexpr si5351::PLL si5351_pll_xtal_25m{
.b = 0,
.c = 1,
};
+
+#ifdef PRALINE
+// Define pll_a from 25MHz clock for stable PLL A, and AFE locked reference
+// PLL A: 800 MHz VCO (32x Multiplier for jitter-free 3.072 MHz sampling)
+constexpr si5351::PLL si5351_pll_a_afe_800m{
+ .f_in = si5351_inputs.f_xtal, // 25,000,000 Hz
+ .a = 32, // Multiplier: 25 * 32 = 800
+ .b = 0,
+ .c = 1,
+};
+// PLL A: registers (Base 34) 800 MHz VCO (For jitter-free AFE sampling frequencies)
+constexpr auto si5351_pll_a_800_reg = si5351_pll_a_afe_800m.reg(0); // Base 26
+
+static_assert(si5351_pll_a_afe_800m.f_vco() == si5351_vco_f, "PLL A XTAL frequency wrong");
+static_assert(si5351_pll_a_afe_800m.p1() == 3584, "PLL A XTAL P1 wrong");
+static_assert(si5351_pll_a_afe_800m.p2() == 0, "PLL A XTAL P2 wrong");
+static_assert(si5351_pll_a_afe_800m.p3() == 1, "PLL A XTAL P3 wrong");
+
+// Define pll_b 25MHz clock for stable PLL B
+// PLL B: 800 MHz VCO (32x Multiplier for (For stable Digital/SGPIO bus)
+constexpr si5351::PLL si5351_pll_b_800m{
+ .f_in = si5351_inputs.f_xtal, // 25,000,000 Hz
+ .a = 32, // Multiplier: 25 * 32 = 800
+ .b = 0,
+ .c = 1,
+};
+// PLL B: registers (Base 34) 800 MHz VCO (For stable Digital/SGPIO bus)
+constexpr auto si5351_pll_b_800_reg = si5351_pll_b_800m.reg(1); // Base 34
+
+static_assert(si5351_pll_b_800m.f_vco() == si5351_vco_f, "PLL B XTAL frequency wrong");
+static_assert(si5351_pll_b_800m.p1() == 3584, "PLL B XTAL P1 wrong");
+static_assert(si5351_pll_b_800m.p2() == 0, "PLL B XTAL P2 wrong");
+static_assert(si5351_pll_b_800m.p3() == 1, "PLL B XTAL P3 wrong");
+
+#else
+
+// PLL A registers (Base 26)
constexpr auto si5351_pll_a_xtal_reg = si5351_pll_xtal_25m.reg(0);
+static_assert(si5351_pll_xtal_25m.f_vco() == si5351_vco_f, "PLL XTAL frequency wrong");
+static_assert(si5351_pll_xtal_25m.p1() == 3584, "PLL XTAL P1 wrong");
+static_assert(si5351_pll_xtal_25m.p2() == 0, "PLL XTAL P2 wrong");
+static_assert(si5351_pll_xtal_25m.p3() == 1, "PLL XTAL P3 wrong");
+#endif
constexpr si5351::PLL si5351_pll_clkin_10m{
.f_in = si5351_inputs.f_clkin_out(),
@@ -64,11 +119,6 @@ constexpr si5351::PLL si5351_pll_clkin_10m{
constexpr auto si5351c_pll_b_clkin_reg = si5351_pll_clkin_10m.reg(1);
constexpr auto si5351a_pll_a_clkin_reg = si5351_pll_clkin_10m.reg(0);
-static_assert(si5351_pll_xtal_25m.f_vco() == si5351_vco_f, "PLL XTAL frequency wrong");
-static_assert(si5351_pll_xtal_25m.p1() == 3584, "PLL XTAL P1 wrong");
-static_assert(si5351_pll_xtal_25m.p2() == 0, "PLL XTAL P2 wrong");
-static_assert(si5351_pll_xtal_25m.p3() == 1, "PLL XTAL P3 wrong");
-
static_assert(si5351_pll_clkin_10m.f_vco() == si5351_vco_f, "PLL CLKIN frequency wrong");
static_assert(si5351_pll_clkin_10m.p1() == 9728, "PLL CLKIN P1 wrong");
static_assert(si5351_pll_clkin_10m.p2() == 0, "PLL CLKIN P2 wrong");
@@ -82,16 +132,16 @@ constexpr si5351::MultisynthFractional si5351_ms_18m432 {
.r_div = 1,
};
*/
-/*
-constexpr si5351::MultisynthFractional si5351_ms_0_20m {
- .f_src = si5351_vco_f,
- .a = 20,
- .b = 0,
- .c = 1,
- .r_div = 1,
+// constexpr auto si5351_ms_0_20m_reg = si5351_ms_0_20m.reg(0);
+
+constexpr si5351::MultisynthFractional si5351_ms_0_4m{
+ .f_src = si5351_vco_f, // 800,000,000 Hz
+ .a = 100, // Integer divider 100
+ .b = 0,
+ .c = 1,
+ .r_div = 1 // Final R-divider: 2^1 = 2
};
-constexpr auto si5351_ms_0_20m_reg = si5351_ms_0_20m.reg(0);
-*/
+
constexpr si5351::MultisynthFractional si5351_ms_0_8m{
.f_src = si5351_vco_f,
.a = 50,
@@ -99,8 +149,91 @@ constexpr si5351::MultisynthFractional si5351_ms_0_8m{
.c = 1,
.r_div = 1,
};
+
+#ifdef PRALINE
+// (initial 4 MHz from 800 MHz VCO: 800 / 200 = 4)
+constexpr si5351::MultisynthFractional si5351_ms_afe_4m{
+ .f_src = si5351_vco_afe_f, // 800 MHz
+ .a = 200,
+ .b = 0,
+ .c = 1,
+ .r_div = 0};
+
+// (10 MHz from 800 MHz VCO: 800 / 80 = 10)
+constexpr si5351::MultisynthFractional si5351_ms_afe_10m{
+ .f_src = si5351_vco_afe_f, // 800 MHz
+ .a = 80,
+ .b = 0,
+ .c = 1,
+ .r_div = 0};
+
+// (20 MHz from 800 MHz VCO: 800 / 40 = 20)
+constexpr si5351::MultisynthFractional si5351_ms_afe_20m{
+ .f_src = si5351_vco_afe_f,
+ .a = 40,
+ .b = 0,
+ .c = 1,
+ .r_div = 0,
+};
+
+// (25 MHz from 800 MHz VCO: 800 / 32 = 25)
+// Define xtal MultiSynth 25MHz clock for stable PLL A, and AFE locked XTAL reference
+constexpr si5351::MultisynthFractional si5351_ms_afe_25m{
+ .f_src = si5351_vco_afe_f, // 800,000,000 Hz
+ .a = 32, // 800 / 32 = 25 MHz
+ .b = 0,
+ .c = 1,
+ .r_div = 0 // No final bit-shifting
+};
+
+// (40 MHz from 800 MHz VCO: 800 / 20 = 40)
+constexpr si5351::MultisynthFractional si5351_ms_afe_40m{
+ .f_src = si5351_vco_afe_f, // 800 MHz
+ .a = 20,
+ .b = 0,
+ .c = 1,
+ .r_div = 0};
+
+// (20 MHz from 800 MHz VCO: 800 / 40 = 20)
+constexpr si5351::MultisynthFractional si5351_ms_20m{
+ .f_src = si5351_vco_f,
+ .a = 40,
+ .b = 0,
+ .c = 1,
+ .r_div = 0,
+};
+// constexpr auto si5351_ms_20m_reg = si5351_ms_20m.reg(0);
+
+// (25 MHz from 800 MHz VCO: 800 / 32 = 25)
+// Define xtal MultiSynth 25MHz clock for stable PLL B, and XTAL reference
+constexpr si5351::MultisynthFractional si5351_ms_25m{
+ .f_src = si5351_vco_f, // 800,000,000 Hz
+ .a = 32, // 800 / 32 = 25 MHz
+ .b = 0,
+ .c = 1,
+ .r_div = 0 // No final bit-shifting
+};
+
+#endif
+
constexpr auto si5351c_ms_0_8m_reg = si5351_ms_0_8m.reg(clock_generator_output_og_codec);
+#ifdef PRALINE
+// Verify compile-time values for 8 MHz config
+static_assert(si5351_ms_0_8m.p1() == 5888, "MS0 8MHz P1 should be 5888 (0x1700)");
+static_assert(si5351_ms_0_8m.p2() == 0, "MS0 8MHz P2 should be 0");
+static_assert(si5351_ms_0_8m.p3() == 1, "MS0 8MHz P3 should be 1");
+static_assert(si5351_ms_0_8m.f_out() == 8000000, "MS0 should output 8 MHz");
+
+// Verify register array encoding
+static_assert(si5351c_ms_0_8m_reg[0] == 42, "MS0 base register should be 42");
+static_assert(si5351c_ms_0_8m_reg[1] == 0x00, "MS0 reg43 P3[15:8] should be 0x00");
+static_assert(si5351c_ms_0_8m_reg[2] == 0x01, "MS0 reg44 P3[7:0] should be 0x01");
+static_assert(si5351c_ms_0_8m_reg[3] == 0x10, "MS0 reg45 R_DIV should be 0x10");
+static_assert(si5351c_ms_0_8m_reg[4] == 0x17, "MS0 reg46 P1[15:8] should be 0x17");
+static_assert(si5351c_ms_0_8m_reg[5] == 0x00, "MS0 reg47 P1[7:0] should be 0x00");
+#endif
+
constexpr si5351::MultisynthFractional si5351_ms_group{
.f_src = si5351_vco_f,
.a = 80, /* Don't care */
@@ -186,12 +319,21 @@ static_assert(si5351a_ms_int_mcu_clkin.f_out() == mcu_clkin_r9_f, "MS int MCU CL
using namespace si5351;
+#ifdef PRALINE
+static constexpr ClockControl::MultiSynthSource get_si5351a_reference_clock_generator_pll(const ClockManager::ReferenceSource reference_source) {
+ return (reference_source == ClockManager::ReferenceSource::Xtal)
+ ? ClockControl::MultiSynthSource::PLLA
+ : ClockControl::MultiSynthSource::PLLB;
+}
+#else
static constexpr ClockControl::MultiSynthSource get_si5351c_reference_clock_generator_pll(const ClockManager::ReferenceSource reference_source) {
return (reference_source == ClockManager::ReferenceSource::Xtal)
? ClockControl::MultiSynthSource::PLLA
: ClockControl::MultiSynthSource::PLLB;
}
+#endif
+#ifndef PRALINE
constexpr ClockControls si5351c_clock_control_common{{
{ClockControl::ClockCurrentDrive::_8mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, get_si5351c_reference_clock_generator_pll(ClockManager::ReferenceSource::Xtal), ClockControl::MultiSynthMode::Fractional, ClockControl::ClockPowerDown::Power_Off},
{ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Group, ClockControl::ClockInvert::Invert, get_si5351c_reference_clock_generator_pll(ClockManager::ReferenceSource::Xtal), ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
@@ -202,17 +344,43 @@ constexpr ClockControls si5351c_clock_control_common{{
{ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, get_si5351c_reference_clock_generator_pll(ClockManager::ReferenceSource::Xtal), ClockControl::MultiSynthMode::Fractional, ClockControl::ClockPowerDown::Power_Off},
{ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, get_si5351c_reference_clock_generator_pll(ClockManager::ReferenceSource::Xtal), ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
}};
+#endif
constexpr ClockControls si5351a_clock_control_common{{
+#ifdef PRALINE
+ // CLK0: MAX5864 (ADC) - 4mA, Normal PLLA Integer
+ {ClockControl::ClockCurrentDrive::_4mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_On},
+ // CLK1: SCT_CLK (iCE40 FPGA) - 4mA, Normal PLLB Integer
+ {ClockControl::ClockCurrentDrive::_4mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLB, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_On},
+ // CLK2: LPC43xx MCU - 2mA, Normal PLLA (Must be Integer for MCU stability)
+ {ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_On},
+ // CLK3: CLKOUT SMA Port P1 - 2mA, Normal PLLB Integer Power_Off
+ {ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLB, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
+ // CLK4: MAX2831 reference (40 MHz) - 4mA, Invert PLLA Integer (Required for mixer lock)
+ {ClockControl::ClockCurrentDrive::_4mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Invert, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_On},
+ // CLK5: RFFC5072 reference (40 MHz) - 4mA, Invert PLLA Integer (Required for mixer lock)
+ {ClockControl::ClockCurrentDrive::_4mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Invert, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_On},
+ // CLK6: Not used (disabled) 2mA, Normal PLLB Integer, Power_Off
+ {ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLB, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
+ // CLK7: Not used (disabled) 2mA, Normal PLLB Integer, Power_Off
+ {ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLB, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
+#else
{ClockControl::ClockCurrentDrive::_6mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
{ClockControl::ClockCurrentDrive::_4mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Fractional, ClockControl::ClockPowerDown::Power_Off},
{ClockControl::ClockCurrentDrive::_8mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
{ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
+ // CLK4: HackRF r9 - not inverted
{ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
+ // CLK5: HackRF r9 - not used
{ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
+ // CLK6: Not used
{ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
+ // CLK7: Not used
{ClockControl::ClockCurrentDrive::_2mA, ClockControl::ClockSource::MS_Self, ClockControl::ClockInvert::Normal, ClockControl::MultiSynthSource::PLLA, ClockControl::MultiSynthMode::Integer, ClockControl::ClockPowerDown::Power_Off},
+#endif
+
}};
+
ClockManager::Reference ClockManager::get_reference() const {
return reference;
}
@@ -255,9 +423,48 @@ static void portapack_tcxo_disable() {
using namespace hackrf::one;
void ClockManager::init_clock_generator() {
+#ifdef PRALINE
+ // PRALINE: Configure clock input mux GPIO
+ // GPIO0_15 (clkin_ctrl) selects GP_CLKIN source:
+ // 0 = P1 connector (external)
+ // 1 = P22 (internal Si5351 CLK2)
+ constexpr GPIO gpio_clkin_ctrl = gpio[GPIO0_15];
+ gpio_clkin_ctrl.output();
+ gpio_clkin_ctrl.write(1); // CLKIN_SIGNAL_P22 = 1 = internal Si5351 CLK2
+
+ // Also enable MCU clock gate (GPIO0_8)
+ gpio_r9_mcu_clk_en.output();
+ gpio_r9_mcu_clk_en.write(1);
+#else
+ // HackRF One r9: GPIO0_8 (mcu_clk_en) gates Si5351 CLK2/CLK7 to GP_CLKIN
+ if (hackrf_r9) {
+ gpio_r9_mcu_clk_en.output();
+ gpio_r9_mcu_clk_en.write(1);
+ }
+#endif
+
clock_generator.reset();
clock_generator.set_crystal_internal_load_capacitance(CrystalInternalLoadCapacitance::XTAL_CL_8pF);
clock_generator.enable_fanout();
+
+#ifdef PRALINE
+ /* PRALINE has Si5351A (NOT Si5351C like HackRF One OG).
+ * Must use Si5351A configuration: PLLA only, no CLKIN support.
+ *
+ * IMPORTANT: Follow HackRF reference sequence:
+ * 1. Set PLL input sources
+ * 2. Configure PLL and multisynths
+ * 3. Set clock control registers (AFTER multisynths!)
+ * 4. Reset PLLs
+ * 5. Enable outputs
+ */
+ clock_generator.set_pll_input_sources(si5351a_pll_input_sources);
+
+ /* Skip MCU CLKIN setup and reference detection for PRALINE - not applicable */
+ reference = Reference{ReferenceSource::Xtal, 0}; // PLLA
+
+ /* Clock control will be set AFTER multisynth configuration - see below */
+#else
clock_generator.set_pll_input_sources(hackrf_r9
? si5351a_pll_input_sources
: si5351c_pll_input_sources);
@@ -298,7 +505,90 @@ void ClockManager::init_clock_generator() {
si5351_clock_control_common[7].ms_src(ref_pll),
}};
clock_generator.set_clock_control(si5351_clock_control);
+#endif
+#ifdef PRALINE
+
+ /* * Praline HackRF Pro Clock Assignments (800 MHz VCO Configuration)
+ * VCO Frequency: 800,000,000 Hz (Master Reference)
+ * * CLK0: AFE_CLK (MAX5864 Codec & FPGA ADC Interface)
+ * - Note: Defines hardware sample rate. Essential for WFM purity.
+ * * CLK1: SCT_CLK (iCE40 FPGA System/Timing Clock)
+ * - Note: Timing for SGPIO data bus; scales to 2x SR in wideband modes.
+ * * CLK2: MCU_CLKIN (LPC43xx MCU External Clock Input)
+ * - Note: Synchronizes MCU processing to the RF clock tree.
+ * * CLK3: SG_CLK (Switching Regulator/Internal Logic Sync) SMA Port 1
+ * - Note: Used for internal FPGA logic/gateware synchronization.
+ * * CLK4: P_CLK (MAX2831 Peripheral/Expansion Clock)
+ * - Note: Routed to expansion headers for external hardware sync.
+ * * CLK5: AUX_CLK (RFFC5371 Auxiliary reference for secondary logic)
+ * - Note: Provides additional timing flexibility for the iCE40 FPGA.
+ * * CLK6: SG_CLK (Switching Regulator/Internal Logic Sync) SMA Port 2
+ * - Note: Used for internal FPGA logic/gateware synchronization.
+ * * CLK7: Unused / Power-Down
+ * - State: Disabled (si5351a_ms6_7_off_reg)
+ * - Note: Kept OFF to reduce EMI/RFI near the RF front-end.
+ * * CLKOUT: Optional external clock output on the header.
+ */
+
+ /* Write PLL A (800 MHz based on 25 MHz xtal for RF) and
+ * PLL B (800 MHZ based on 25 MHz xtalfor Digital)
+ * Use single-byte writes to debug I2C issues
+ */
+ {
+ // Write PLLA (Registers 26-33)
+ /* Write PLL A configuration (Base 26) */
+ const auto& pll_a = si5351_pll_a_800_reg;
+ for (size_t i = 1; i < pll_a.size(); i++) {
+ clock_generator.write_register(pll_a[0] + i - 1, pll_a[i]);
+ }
+
+ // Write PLLB (Registers 34-41)
+ /* Write PLL B configuration (Base 34) */
+ const auto& pll_b = si5351_pll_b_800_reg;
+ for (size_t i = 1; i < pll_b.size(); i++) {
+ clock_generator.write_register(pll_b[0] + i - 1, pll_b[i]);
+ }
+ }
+
+ /* Write multisynth configurations using single-byte writes */
+ // These cover all active channels on the Praline board
+ clock_generator.write_ms_single_byte(0, si5351_ms_afe_4m); // CLK0: PLL A AFE Codec (4 MHz)
+ clock_generator.write_ms_single_byte(1, si5351_ms_10m); // CLK1: PLL B SGPIO/FPGA Timing (10 MHz)
+ clock_generator.write_ms_single_byte(2, si5351_ms_afe_40m); // CLK2: PLL A Audio and MCU Input (40 MHz)
+ clock_generator.write_ms_single_byte(3, si5351_ms_0_4m); // CLK3: PLL B SMA Port 1 Logic Sync (4 MHz or 10 MHz)
+ clock_generator.write_ms_single_byte(4, si5351_ms_afe_40m); // CLK4: PLL A MAX2831 Second IF (40 MHz)
+ clock_generator.write_ms_single_byte(5, si5351_ms_afe_40m); // CLK5: PLL A RFFC5071First IF (40 MHz)
+ clock_generator.write_ms_single_byte(6, si5351_ms_0_4m); // CLK6: PLL B SMA Port 2 Logic Sync (4 MHz or 10 MHz)
+ clock_generator.write_ms_single_byte(7, si5351_ms_0_4m); // CLK7: PLL B Unused (4 MHz)
+
+ /* NOW set clock control registers (AFTER multisynths per HackRF reference) */
+ const auto ref_pll_a = ClockControl::MultiSynthSource::PLLA;
+ const auto ref_pll_b = ClockControl::MultiSynthSource::PLLB;
+ const ClockControls si5351_clock_control = ClockControls{{
+ si5351a_clock_control_common[0].ms_src(ref_pll_a),
+ si5351a_clock_control_common[1].ms_src(ref_pll_b),
+ si5351a_clock_control_common[2].ms_src(ref_pll_a),
+ si5351a_clock_control_common[3].ms_src(ref_pll_b),
+ si5351a_clock_control_common[4].ms_src(ref_pll_a),
+ si5351a_clock_control_common[5].ms_src(ref_pll_a),
+ si5351a_clock_control_common[6].ms_src(ref_pll_b),
+ si5351a_clock_control_common[7].ms_src(ref_pll_b),
+ }};
+ // clock_generator.set_clock_control(si5351_clock_control);
+ // Use single-byte writes instead of multi-byte
+ clock_generator.set_clock_control_single_byte(si5351_clock_control);
+
+ // Don't write CLKS 3, 6, and 7 multisynth
+ // Ensure CLK3 clock control has Power_Off
+ // Verify output is disabled
+ clock_generator.disable_output(3);
+ clock_generator.disable_clock(3);
+ clock_generator.disable_output(6);
+ clock_generator.disable_clock(6);
+ clock_generator.disable_output(7);
+ clock_generator.disable_clock(7);
+#else
if (hackrf_r9) {
const PLLReg pll_reg = (reference.source == ReferenceSource::Xtal)
? si5351_pll_a_xtal_reg
@@ -319,21 +609,37 @@ void ClockManager::init_clock_generator() {
clock_generator.write(si5351c_ms_5_reg);
clock_generator.write(si5351c_ms6_7_off_mcu_clkin_reg);
}
+#endif
clock_generator.reset_plls();
// Wait for PLL(s) to lock.
+#ifdef PRALINE
+ // PRALINE: Wait for 0x60 (0x20 | 0x40), PLLA and PLLB to lock (0x20 = LOL_A bit, 0x40 = LOL_B bit)
+ uint8_t device_status_mask = 0x60;
+ uint32_t pll_timeout = 100000;
+ while ((clock_generator.device_status() & device_status_mask) != 0 && pll_timeout > 0) {
+ pll_timeout--;
+ }
+ // Store PLL lock status for debugging
+ static volatile uint32_t pll_lock_timeout = pll_timeout;
+ (void)pll_lock_timeout;
+
+#else
+ // Wait for PLL(s) to lock - with timeout to prevent hang
uint8_t device_status_mask = hackrf_r9
? 0x20
: (ref_pll == ClockControl::MultiSynthSource::PLLB)
? 0x40
: 0x20;
+
while ((clock_generator.device_status() & device_status_mask) != 0);
clock_generator.set_clock_control(
clock_generator_output_mcu_clkin,
si5351_clock_control_common[clock_generator_output_mcu_clkin].ms_src(ref_pll).clk_pdn(ClockControl::ClockPowerDown::Power_On));
clock_generator.enable_output(clock_generator_output_mcu_clkin);
+#endif
}
uint32_t ClockManager::measure_gp_clkin_frequency() {
@@ -344,12 +650,16 @@ uint32_t ClockManager::measure_gp_clkin_frequency() {
}
bool ClockManager::loss_of_signal() {
+#ifdef PRALINE
+ return clock_generator.clkin_loss_of_signal();
+#else
if (hackrf_r9) {
const auto frequency = measure_gp_clkin_frequency();
return (frequency < 9850000) || (frequency > 10150000);
} else {
return clock_generator.clkin_loss_of_signal();
}
+#endif
}
ClockManager::ReferenceSource ClockManager::detect_reference_source() {
@@ -372,6 +682,17 @@ ClockManager::ReferenceSource ClockManager::detect_reference_source() {
}
ClockManager::Reference ClockManager::choose_reference() {
+#ifdef PRALINE
+ const auto detected_reference = detect_reference_source();
+
+ if ((detected_reference == ReferenceSource::External) ||
+ (detected_reference == ReferenceSource::PortaPack)) {
+ const auto frequency = measure_gp_clkin_frequency();
+ if ((frequency >= 9850000) && (frequency <= 10150000)) {
+ return {detected_reference, 10000000};
+ }
+ }
+#else
if (hackrf_r9) {
gpio_r9_clkin_en.write(1);
volatile uint32_t delay = 240000 + 24000;
@@ -390,6 +711,7 @@ ClockManager::Reference ClockManager::choose_reference() {
if (hackrf_r9) {
gpio_r9_clkin_en.write(0);
}
+#endif
portapack_tcxo_disable();
return {ReferenceSource::Xtal, 25000000};
@@ -400,6 +722,15 @@ void ClockManager::shutdown() {
}
void ClockManager::enable_codec_clocks() {
+#ifdef PRALINE
+ /* PRALINE: CLK0 (AFE_CLK) for codec/FPGA, CLK1 (SCT_CLK) for FPGA timing.
+ * Reference hackrf_core.c shows PRALINE needs both CLK0 and CLK1. */
+ clock_generator.enable_clock(clock_generator_output_og_codec); /* CLK0 MAX5864*/
+ clock_generator.enable_clock(clock_generator_output_og_cpld); /* CLK1 iCE40 FPGA*/
+ clock_generator.enable_clock(clock_generator_output_og_sgpio); /* CLK2 LPC43xx*/
+ clock_generator.enable_output_mask(
+ (1U << clock_generator_output_og_codec) | (1U << clock_generator_output_og_cpld) | (1U << clock_generator_output_og_sgpio));
+#else
if (hackrf_r9) {
clock_generator.enable_clock(clock_generator_output_r9_sgpio);
} else {
@@ -417,6 +748,7 @@ void ClockManager::enable_codec_clocks() {
clock_generator.enable_output_mask(
(1U << clock_generator_output_og_codec) | (1U << clock_generator_output_og_cpld) | (1U << clock_generator_output_og_sgpio));
}
+#endif
}
void ClockManager::disable_codec_clocks() {
@@ -424,6 +756,14 @@ void ClockManager::disable_codec_clocks() {
* be enabled for the output to come to rest at the state specified by
* CLKx_DISABLE_STATE.
*/
+#ifdef PRALINE
+ /* PRALINE: CLK0 (AFE_CLK), CLK1 (SCT_CLK), and CLK2 MCU used for codec/FPGA */
+ clock_generator.disable_output_mask(
+ (1U << clock_generator_output_og_codec) | (1U << clock_generator_output_og_cpld) | (1U << clock_generator_output_og_sgpio));
+ clock_generator.disable_clock(clock_generator_output_og_codec);
+ clock_generator.disable_clock(clock_generator_output_og_cpld);
+ clock_generator.disable_clock(clock_generator_output_og_sgpio);
+#else
if (hackrf_r9) {
clock_generator.disable_output_mask(1U << clock_generator_output_r9_sgpio);
clock_generator.disable_clock(clock_generator_output_r9_sgpio);
@@ -434,9 +774,17 @@ void ClockManager::disable_codec_clocks() {
clock_generator.disable_clock(clock_generator_output_og_cpld);
clock_generator.disable_clock(clock_generator_output_og_sgpio);
}
+#endif
}
void ClockManager::enable_if_clocks() {
+#ifdef PRALINE
+ /* PRALINE uses CLK5 (first IF) and CLK4 (second IF) */
+ clock_generator.enable_clock(clock_generator_output_og_first_if);
+ clock_generator.enable_output_mask(1U << clock_generator_output_og_first_if);
+ clock_generator.enable_clock(clock_generator_output_og_second_if);
+ clock_generator.enable_output_mask(1U << clock_generator_output_og_second_if);
+#else
if (hackrf_r9) {
clock_generator.enable_clock(clock_generator_output_r9_if);
clock_generator.enable_output_mask(1U << clock_generator_output_r9_if);
@@ -446,9 +794,16 @@ void ClockManager::enable_if_clocks() {
clock_generator.enable_clock(clock_generator_output_og_second_if);
clock_generator.enable_output_mask(1U << clock_generator_output_og_second_if);
}
+#endif
}
void ClockManager::disable_if_clocks() {
+#ifdef PRALINE
+ clock_generator.disable_output_mask(1U << clock_generator_output_og_first_if);
+ clock_generator.disable_clock(clock_generator_output_og_first_if);
+ clock_generator.disable_output_mask(1U << clock_generator_output_og_second_if);
+ clock_generator.disable_clock(clock_generator_output_og_second_if);
+#else
if (hackrf_r9) {
clock_generator.disable_output_mask(1U << clock_generator_output_r9_if);
clock_generator.disable_clock(clock_generator_output_r9_if);
@@ -458,19 +813,93 @@ void ClockManager::disable_if_clocks() {
clock_generator.disable_output_mask(1U << clock_generator_output_og_second_if);
clock_generator.disable_clock(clock_generator_output_og_second_if);
}
+#endif
}
void ClockManager::set_sampling_frequency(const uint32_t frequency) {
+#ifdef PRALINE
+ /*
+ * PRALINE sample rate strategy:
+ * 1. Maximize AFE rate to push Nyquist above MAX2831's 11.6 MHz LPF minimum
+ * 2. Use FPGA decimation to achieve desired output rate
+ * 3. Ensure AFE rate is achievable by Si5351 (clean division from 800 MHz VCO)
+ */
+
+ constexpr uint32_t MAX_AFE_RATE = 40000000; // Use 40 MHz per GSG reference
+ constexpr uint8_t MAX_N = 5;
+
+ _base_band_frequency = frequency;
+
+ uint8_t n = 0;
+ uint32_t afe_rate = frequency;
+
+ // Find the largest n where AFE rate stays within limit
+ // Start at n=0 and work up
+ while (n < MAX_N) {
+ uint32_t next_rate = afe_rate << 1;
+ if (next_rate > MAX_AFE_RATE) break;
+ afe_rate = next_rate;
+ n++;
+ }
+
+ _resampling_n = n;
+
+ // === Stop FPGA processing and flush filters ===
+ fpga_debug_register_write(1, 0x00); // Disable FPGA filters (resets CIC accumulators)
+
+ // Verify we're in RX mode before writing RX registers
+ if (fpga_get_mode() != FPGA_MODE_RX) {
+ // Either set mode or return error
+ fpga_set_mode(FPGA_MODE_RX);
+ }
+
+ // Set FPGA RX decimation register
+ fpga_debug_register_write(FPGA_REG_DECIM, n);
+
+ /* RX Mode: Register 3 is FPGA_REG_RX_DIGITAL_GAIN.
+ * We shift up by (3 * n) to compensate for CIC bit-growth.
+ * Relationship: ds = (Stages * n) - Offset
+ * For a 3-stage filter, every increment of n grows the signal by 3 bits.
+ * We subtract a baseline offset to keep the signal within 8-bit bounds.
+ * Add a baseline shift to ensure the signal isn't too quiet
+ */
+ uint8_t ds = (3 * n);
+ ds += 2;
+ fpga_debug_register_write(FPGA_REG_RX_DIGITAL_GAIN, ds);
+
+ radio::invalidate_spi_config();
+
+ // Configure Si5351 clocks
+ // CLK0: AFE_CLK (with r_div=1 for Γ·2)
+ // CLK1: SCT_CLK (with r_div=0 for Γ·1, runs at 2Γ AFE for FPGA timing)
+ // Configure Si5351 clocks using the correct AFE VCO
+ clock_generator.set_ms_frequency(0, afe_rate * 2, si5351_vco_afe_f, 1);
+ clock_generator.set_ms_frequency(1, afe_rate * 2, si5351_vco_afe_f, 0);
+
+ // === Reset PLL A for phase alignment ===
+ clock_generator.write_register(si5351::Register::PLLReset, 0x20);
+
+ // Brief delay for PLL lock and clock stability ===
+ // ~1ms at 96MHz = ~96000 cycles, use 10ms for safety
+ volatile uint32_t delay = 240000; // ~2.5ms
+ while (delay--);
+
+ // Re-enable FPGA processing with clean state ===
+ fpga_debug_register_write(1, 0x01);
+
+#else
/* Codec clock is at sampling frequency, CPLD and SGPIO clocks are at
* twice the frequency, and derived from the MS0 synth. So it's only
* necessary to change the MS0 synth frequency, and ensure the output
* is divided by two.
*/
+
if (hackrf_r9) {
clock_generator.set_ms_frequency(clock_generator_output_r9_sgpio, frequency * 2, si5351_vco_f, 0);
} else {
clock_generator.set_ms_frequency(clock_generator_output_og_codec, frequency * 2, si5351_vco_f, 1);
}
+#endif
}
void ClockManager::set_reference_ppb(const int32_t ppb) {
@@ -478,10 +907,21 @@ void ClockManager::set_reference_ppb(const int32_t ppb) {
* It is assumed an external clock coming in to CLKIN/PLLB is sufficiently accurate as to not need adjustment.
* TODO: Revisit the above policy. It may be good to allow adjustment of the external reference too.
*/
+#ifdef PRALINE
+ // On Praline, only apply if we aren't locked to a superior external 10MHz source
+ // (Assuming you have a way to detect the 10MHz presence on Praline)
+ if (reference.source == ReferenceSource::External) {
+ return;
+ }
+ constexpr uint32_t pll_multiplier = si5351_pll_a_afe_800m.a;
+#else
if (hackrf_r9 && reference.source != ReferenceSource::Xtal) {
return;
}
+
constexpr uint32_t pll_multiplier = si5351_pll_xtal_25m.a;
+#endif
+
constexpr uint32_t denominator = 1000000 / pll_multiplier;
const uint32_t new_a = (ppb >= 0) ? pll_multiplier : (pll_multiplier - 1);
const uint32_t new_b = (ppb >= 0) ? (ppb / 1000) : (denominator + (ppb / 1000));
@@ -493,8 +933,13 @@ void ClockManager::set_reference_ppb(const int32_t ppb) {
.b = new_b,
.c = new_c,
};
+
+#ifdef PRALINE
+ clock_generator.write_pll_single_byte(0, pll);
+#else
const auto pll_a_reg = pll.reg(0);
clock_generator.write(pll_a_reg);
+#endif
}
void ClockManager::start_frequency_monitor_measurement(const cgu::CLK_SEL clk_sel) {
@@ -509,8 +954,16 @@ void ClockManager::start_frequency_monitor_measurement(const cgu::CLK_SEL clk_se
}
void ClockManager::wait_For_frequency_monitor_measurement_done() {
- // FREQ_MON mechanism fails to finish if there's no clock present on selected input?!
+// FREQ_MON mechanism fails to finish if there's no clock present on selected input?!
+#ifdef PRALINE
+ // PRALINE FIX: Add timeout to prevent infinite hang
+ uint32_t timeout = 100000;
+ while (LPC_CGU->FREQ_MON.MEAS == 1 && timeout > 0) {
+ timeout--;
+ }
+#else
while (LPC_CGU->FREQ_MON.MEAS == 1);
+#endif
}
uint32_t ClockManager::get_frequency_monitor_measurement_in_hertz() {
@@ -521,6 +974,91 @@ uint32_t ClockManager::get_frequency_monitor_measurement_in_hertz() {
}
void ClockManager::start_audio_pll() {
+#ifdef PRALINE
+
+ // Control Block
+ cgu::pll0audio::ctrl({
+ .pd = 1, // Start powered down
+ .bypass = 0, // Use the PLL
+ .directi = 0, // Enable N-divider
+ .directo = 0, // Enable P-divider
+ .clken = 0, // Disable output initially
+ .frm = 0, // Normal mode
+ .autoblock = 1, // Glitchless switching
+ .pllfract_req = 1, // Integer, Disabled
+ .sel_ext = 1, // Use GP_CLKIN (CLK2)
+ .mod_pd = 0, // Modulator OFF
+ .clk_sel = cgu::CLK_SEL::GP_CLKIN,
+ });
+
+ /*
+ * Audio PLL Configuration for 48 kHz audio with 256Fs MCLK
+ * Target output: Fout = 12.288 MHz
+ *
+ * Formulas:
+ * Fout = Fin Γ MSEL / (NSEL Γ PSEL)
+ * FCO = 2 Γ Fin Γ MSEL / NSEL (must be 275-550 MHz)
+ */
+
+ /*
+ * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ * β 10 MHz INPUT (HackRF r9 compatible but interfere with fm band) β
+ * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
+ * β MSEL=3072, NSEL=125, PSEL=20 β
+ * β Fout = 10 Γ 3072 / (125 Γ 20) = 30720 / 2500 = 12.288 MHz β β
+ * β FCO = 2 Γ 10 Γ 3072 / 125 = 61440 / 125 = 491.52 MHz β β
+ * β β
+ * β Encoded values: MDEC=8308, NDEC=45, PDEC=31 β
+ * β CLK2 harmonics: 90, 100, 110 MHz (interfere with FM band!) β
+ * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ */
+
+ /*
+ // Math: (10 MHz * 3072) / (125 * 20) * 2 = 12.288MHz
+ cgu::pll0audio::mdiv({.mdec = 8308UL}); // MSEL = 3072
+ cgu::pll0audio::np_div({
+ .pdec = 31, // PSEL = 20 for 10 MHz
+ .ndec = 45 // NSEL = 125 for 10 MHz
+ });
+ */
+
+ /*
+ * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ * β 40 MHz INPUT (Recommended - avoids FM band harmonics) β
+ * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
+ * β MSEL=768, NSEL=125, PSEL=20 β
+ * β Fout = 40 Γ 768 / (125 Γ 20) = 30720 / 2500 = 12.288 MHz β β
+ * β FCO = 2 Γ 40 Γ 768 / 125 = 61440 / 125 = 491.52 MHz β β
+ * β β
+ * β Encoded values: MDEC=30542, NDEC=45, PDEC=31 β
+ * β CLK2 harmonics: 80, 120, 160 MHz (none in FM 88-108 MHz band) β
+ * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ */
+
+ // 40 MHz input β 12.288 MHz output (same as HackRF OG)
+ // Math: (40MHz * 768) / (125 * 20) * 2 = 12.288MHz
+ cgu::pll0audio::mdiv({.mdec = 30542UL}); // MSEL = 768
+ cgu::pll0audio::np_div({.pdec = 31,
+ .ndec = 45});
+
+ cgu::pll0audio::frac({.pllfract_ctrl = 0});
+ cgu::pll0audio::power_up();
+
+ // Lock and Routing (Keep as is)
+ {
+ uint32_t timeout = 100000;
+ while (!cgu::pll0audio::is_locked() && timeout > 0) {
+ timeout--;
+ }
+ }
+
+ cgu::pll0audio::clock_enable();
+ set_base_audio_clock_divider(1);
+
+ LPC_CGU->BASE_AUDIO_CLK.AUTOBLOCK = 1;
+ LPC_CGU->BASE_AUDIO_CLK.CLK_SEL = toUType(cgu::CLK_SEL::IDIVD);
+
+#else
cgu::pll0audio::ctrl({
.pd = 1,
.bypass = 0,
@@ -555,13 +1093,15 @@ void ClockManager::start_audio_pll() {
});
cgu::pll0audio::power_up();
- while (!cgu::pll0audio::is_locked());
- cgu::pll0audio::clock_enable();
+ while (!cgu::pll0audio::is_locked());
+
+ cgu::pll0audio::clock_enable();
set_base_audio_clock_divider(1);
LPC_CGU->BASE_AUDIO_CLK.AUTOBLOCK = 1;
LPC_CGU->BASE_AUDIO_CLK.CLK_SEL = toUType(cgu::CLK_SEL::IDIVD);
+#endif
}
void ClockManager::set_base_audio_clock_divider(const size_t divisor) {
@@ -570,12 +1110,46 @@ void ClockManager::set_base_audio_clock_divider(const size_t divisor) {
}
void ClockManager::stop_audio_pll() {
+#ifdef PRALINE
+ /* PRALINE: Gracefully switch audio peripherals away from the PLL branch */
+ LPC_CGU->BASE_AUDIO_CLK.PD = 1; // Power down the branch first
+ LPC_CGU->BASE_AUDIO_CLK.CLK_SEL = toUType(cgu::CLK_SEL::IRC); // Reset to safe IRC source
+#endif
+
cgu::pll0audio::clock_disable();
cgu::pll0audio::power_down();
+
+#ifdef PRALINE
+ /* PRALINE: Add a safety timeout to the unlock check to prevent potential hangs */
+ uint32_t timeout = 100000;
+ while (cgu::pll0audio::is_locked() && timeout > 0) {
+ timeout--;
+ }
+#else
while (cgu::pll0audio::is_locked());
+#endif
}
void ClockManager::enable_clock_output(bool enable) {
+#ifdef PRALINE
+ auto clkout_select = clock_generator_output_og_clkout;
+
+ if (enable) {
+ clock_generator.enable_output(clkout_select);
+ if (portapack::persistent_memory::clkout_freq() < 1000) {
+ clock_generator.set_ms_frequency(clkout_select, portapack::persistent_memory::clkout_freq() * 128000, si5351_vco_f, 7);
+ } else {
+ clock_generator.set_ms_frequency(clkout_select, portapack::persistent_memory::clkout_freq() * 1000, si5351_vco_f, 0);
+ }
+
+ auto si5351_clock_control_common = si5351a_clock_control_common;
+ const auto ref_pll = get_si5351a_reference_clock_generator_pll(reference.source);
+ clock_generator.set_clock_control(clkout_select, si5351_clock_control_common[clkout_select].ms_src(ref_pll).clk_pdn(ClockControl::ClockPowerDown::Power_On));
+ } else {
+ clock_generator.disable_output(clkout_select);
+ clock_generator.set_clock_control(clkout_select, ClockControl::power_off());
+ }
+#else
if (hackrf_r9) {
gpio_r9_clkout_en.output();
gpio_r9_clkout_en.write(enable);
@@ -604,4 +1178,5 @@ void ClockManager::enable_clock_output(bool enable) {
clock_generator.disable_output(clkout_select);
clock_generator.set_clock_control(clkout_select, ClockControl::power_off());
}
+#endif
}
diff --git a/firmware/application/clock_manager.hpp b/firmware/application/clock_manager.hpp
index 58d9944b9..6d5f7ba05 100644
--- a/firmware/application/clock_manager.hpp
+++ b/firmware/application/clock_manager.hpp
@@ -70,8 +70,21 @@ class ClockManager {
void set_sampling_frequency(const uint32_t frequency);
+ uint32_t get_sampling_frequency() const {
+ return _base_band_frequency;
+ };
+
void set_reference_ppb(const int32_t ppb);
+#ifdef PRALINE
+ uint8_t get_resampling_n() const { return _resampling_n; }
+
+ // Si5351 diagnostic methods
+ uint8_t si5351_read_status() { return clock_generator.device_status(); }
+ uint8_t si5351_read_register(uint8_t reg) { return clock_generator.read_register(reg); }
+ void si5351_write_register(uint8_t reg, uint8_t value) { clock_generator.write_register(reg, value); }
+#endif
+
uint32_t get_frequency_monitor_measurement_in_hertz();
Reference get_reference() const;
@@ -85,6 +98,8 @@ class ClockManager {
si5351::Si5351& clock_generator;
Reference reference;
+ uint32_t _base_band_frequency{20000000};
+
void set_gp_clkin_to_clkin_direct();
void start_frequency_monitor_measurement(const cgu::CLK_SEL clk_sel);
@@ -99,6 +114,10 @@ class ClockManager {
ReferenceSource detect_reference_source();
Reference choose_reference();
bool loss_of_signal();
+
+#ifdef PRALINE
+ uint8_t _resampling_n{0}; // Current decimation factor (log2)
+#endif
};
#endif /*__CLOCK_MANAGER_H__*/
diff --git a/firmware/application/event_m0.cpp b/firmware/application/event_m0.cpp
index d32fa0d3a..58560fbfe 100644
--- a/firmware/application/event_m0.cpp
+++ b/firmware/application/event_m0.cpp
@@ -256,7 +256,9 @@ ui::Widget* EventDispatcher::touch_widget(ui::Widget* const w, ui::TouchEvent ev
if (!w->hidden()) {
// To achieve reverse depth ordering (last object drawn is
// considered "top"), descend first.
- for (const auto child : w->children()) {
+ auto& children = w->children();
+ for (auto it = children.rbegin(); it != children.rend(); ++it) { // reverse, bc the lastly added will be "top" if overlaps
+ const auto& child = *it;
const auto touched_widget = touch_widget(child, event);
if (touched_widget) {
return touched_widget;
diff --git a/firmware/application/external/acars_rx/acars_app.cpp b/firmware/application/external/acars_rx/acars_app.cpp
index d7b5079d0..c88307198 100644
--- a/firmware/application/external/acars_rx/acars_app.cpp
+++ b/firmware/application/external/acars_rx/acars_app.cpp
@@ -33,6 +33,76 @@ using namespace portapack;
namespace ui::external_app::acars_rx {
+// ACARS frame field layout (0-based byte offsets):
+// [0] SOH framing byte (skipped)
+// [1..7] Aircraft registration (7 chars)
+// [8] STX framing byte (skipped)
+// [9..10] Label (2 chars)
+// [11] Block ID (1 char)
+// [12..14] Message number (3 chars)
+// [15..20] Flight ID (6 chars)
+// [21..N-3] Free-text payload (variable)
+// [N-2..N-1] CRC-16/CCITT (2 bytes, MSB first, NOT part of payload)
+//
+// Minimum valid frame: 21-byte fixed header + 2 CRC bytes = 23 bytes.
+// (A zero-length payload is legal per spec; we require at least 23 bytes.)
+static constexpr std::string::size_type kAcarsCrcLen = 2;
+static constexpr std::string::size_type kAcarsHeaderLen = 21;
+static constexpr std::string::size_type kAcarsMinLen = kAcarsHeaderLen + kAcarsCrcLen;
+
+// CRC-16/CCITT: poly 0x1021, init 0x0000, no reflection, no final XOR.
+// This is the variant used by ACARS (same as XMODEM CRC).
+static uint16_t acars_crc16(const std::string& data, std::string::size_type len) {
+ uint16_t crc = 0x0000;
+ for (std::string::size_type i = 0; i < len; ++i) {
+ crc ^= static_cast(static_cast(data[i])) << 8;
+ for (int bit = 0; bit < 8; ++bit) {
+ if (crc & 0x8000)
+ crc = static_cast((crc << 1) ^ 0x1021);
+ else
+ crc = static_cast(crc << 1);
+ }
+ }
+ return crc;
+}
+
+AcarsDecoded acars_decode(const std::string& raw) {
+ AcarsDecoded result;
+ if (raw.size() < kAcarsMinLen) {
+ result.txt = "ACARS message too short (" + std::to_string(raw.size()) +
+ " bytes, need " + std::to_string(kAcarsMinLen) + ")";
+ return result;
+ }
+
+ // Verify CRC: computed over everything except the last 2 bytes,
+ // then compared against those 2 bytes (MSB first).
+ const std::string::size_type payload_len = raw.size() - kAcarsCrcLen;
+ const uint16_t computed = acars_crc16(raw, payload_len);
+ const uint16_t received = (static_cast(static_cast(raw[raw.size() - 2])) << 8) |
+ static_cast(static_cast(raw[raw.size() - 1]));
+ result.crc_ok = (computed == received);
+
+ result.reg = raw.substr(1, 7);
+ result.label = raw.substr(9, 2);
+ result.block_id = raw[11];
+ result.msg_num = raw.substr(12, 3);
+ result.flight_id = raw.substr(15, 6);
+ // Payload sits between end of fixed header and the 2 trailing CRC bytes.
+ if (payload_len > kAcarsHeaderLen)
+ result.txt = raw.substr(kAcarsHeaderLen, payload_len - kAcarsHeaderLen);
+ return result;
+}
+
+std::string acars_format(const AcarsDecoded& msg) {
+ return std::string("ACARS Decoded Result\nCRC: ") + (msg.crc_ok ? "OK" : "FAIL") +
+ "\nRegistration: " + msg.reg +
+ "\nLabel: " + msg.label +
+ "\nBlockID: " + msg.block_id +
+ "\nMsgNum: " + msg.msg_num +
+ "\nFlightID: " + msg.flight_id +
+ "\nMessage: " + msg.txt;
+}
+
void ACARSLogger::log_str(std::string msg) {
log_file.write_entry(msg);
}
@@ -77,17 +147,16 @@ void ACARSAppView::focus() {
void ACARSAppView::on_packet(const ACARSPacketMessage* packet) {
std::string console_info;
-
if (packet->state == 255) {
// got a packet, parse it, and display
rtc::RTC datetime;
rtc_time::now(datetime);
- // todo parity error recovery
console_info = to_string_datetime(datetime, HMS);
console_info += ": ";
- console_info += packet->message;
+ std::string message{packet->message, packet->message + packet->msg_len};
+ AcarsDecoded decoded = acars_decode(message);
+ console_info += acars_format(decoded);
console.writeln(console_info);
- // Log raw data whatever it contains
if (logger && logging)
logger->log_str(console_info);
} else {
diff --git a/firmware/application/external/acars_rx/acars_app.hpp b/firmware/application/external/acars_rx/acars_app.hpp
index 8e5175ce3..b8889cc8b 100644
--- a/firmware/application/external/acars_rx/acars_app.hpp
+++ b/firmware/application/external/acars_rx/acars_app.hpp
@@ -33,6 +33,26 @@
namespace ui::external_app::acars_rx {
+// Decoded ACARS message fields extracted from a raw frame.
+// CRC-16/CCITT (poly 0x1021, init 0x0000) is verified against the two
+// trailing bytes of the raw frame; crc_ok reflects that result.
+struct AcarsDecoded {
+ bool crc_ok{false};
+ std::string reg{};
+ std::string label{};
+ std::string flight_id{};
+ std::string msg_num{};
+ char block_id{'\0'};
+ std::string txt{};
+};
+
+// Decode a raw ACARS frame: verify CRC-16/CCITT and extract fixed-offset fields.
+// Returns a partially-filled AcarsDecoded (txt error only) if the frame is too short.
+AcarsDecoded acars_decode(const std::string& raw);
+
+// Format a decoded ACARS message for display or logging.
+std::string acars_format(const AcarsDecoded& msg);
+
class ACARSLogger {
public:
Optional append(const std::filesystem::path& filename) {
@@ -106,4 +126,4 @@ class ACARSAppView : public View {
} // namespace ui::external_app::acars_rx
-#endif /*__ACARS_APP_H__*/
\ No newline at end of file
+#endif /*__ACARS_APP_H__*/
diff --git a/firmware/application/external/acars_rx/main.cpp b/firmware/application/external/acars_rx/main.cpp
index d11adcb8f..0d2af54fe 100644
--- a/firmware/application/external/acars_rx/main.cpp
+++ b/firmware/application/external/acars_rx/main.cpp
@@ -1,83 +1,83 @@
-/*
- * Copyright (C) 2023 Bernd Herzog
- *
- * This file is part of PortaPack.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; see the file COPYING. If not, write to
- * the Free Software Foundation, Inc., 51 Franklin Street,
- * Boston, MA 02110-1301, USA.
- */
-
-#include "ui.hpp"
-#include "acars_app.hpp"
-#include "ui_navigation.hpp"
-#include "external_app.hpp"
-
-namespace ui::external_app::acars_rx {
-void initialize_app(ui::NavigationView& nav) {
- nav.push();
-}
-} // namespace ui::external_app::acars_rx
-
-extern "C" {
-
-__attribute__((section(".external_app.app_acars_rx.application_information"), used)) application_information_t _application_information_acars_rx = {
- /*.memory_location = */ (uint8_t*)0x00000000,
- /*.externalAppEntry = */ ui::external_app::acars_rx::initialize_app,
- /*.header_version = */ CURRENT_HEADER_VERSION,
- /*.app_version = */ VERSION_MD5,
-
- /*.app_name = */ "ACARS",
- /*.bitmap_data = */ {
- 0x80,
- 0x01,
- 0xC0,
- 0x03,
- 0xC0,
- 0x03,
- 0xC0,
- 0x03,
- 0xC0,
- 0x03,
- 0xE0,
- 0x07,
- 0xF8,
- 0x1F,
- 0xFE,
- 0x7F,
- 0xFF,
- 0xFF,
- 0xFF,
- 0xFF,
- 0xC0,
- 0x03,
- 0xC0,
- 0x03,
- 0xC0,
- 0x03,
- 0xE0,
- 0x07,
- 0xF0,
- 0x0F,
- 0xF8,
- 0x1F,
- },
- /*.icon_color = */ ui::Color::orange().v,
- /*.menu_location = */ app_location_t::RX,
- /*.desired_menu_position = */ -1,
-
- /*.m4_app_tag = portapack::spi_flash::image_tag_acars */ {'P', 'A', 'C', 'A'},
- /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
-};
-}
+/*
+ * Copyright (C) 2023 Bernd Herzog
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "acars_app.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::acars_rx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::acars_rx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_acars_rx.application_information"), used)) application_information_t _application_information_acars_rx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::acars_rx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "ACARS",
+ /*.bitmap_data = */ {
+ 0x80,
+ 0x01,
+ 0xC0,
+ 0x03,
+ 0xC0,
+ 0x03,
+ 0xC0,
+ 0x03,
+ 0xC0,
+ 0x03,
+ 0xE0,
+ 0x07,
+ 0xF8,
+ 0x1F,
+ 0xFE,
+ 0x7F,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xC0,
+ 0x03,
+ 0xC0,
+ 0x03,
+ 0xC0,
+ 0x03,
+ 0xE0,
+ 0x07,
+ 0xF0,
+ 0x0F,
+ 0xF8,
+ 0x1F,
+ },
+ /*.icon_color = */ ui::Color::orange().v,
+ /*.menu_location = */ app_location_t::RX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_acars */ {'P', 'A', 'C', 'A'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
diff --git a/firmware/application/external/adult_toys_controller/ui_adult_toys_controller.cpp b/firmware/application/external/adult_toys_controller/ui_adult_toys_controller.cpp
index 1187b33e5..5885970ab 100644
--- a/firmware/application/external/adult_toys_controller/ui_adult_toys_controller.cpp
+++ b/firmware/application/external/adult_toys_controller/ui_adult_toys_controller.cpp
@@ -339,7 +339,7 @@ void AdultToysView::randomizeMac() {
}
void AdultToysView::randomChn() {
- channel_number = 37 + std::rand() % (39 - 37 + 1);
+ channel_number = 37 + rand() % (39 - 37 + 1);
field_frequency.set_value(get_freq_by_channel_number(channel_number));
}
diff --git a/firmware/application/external/app_manager/ui_app_manager.cpp b/firmware/application/external/app_manager/ui_app_manager.cpp
index f8ccf70fd..98107e5a9 100644
--- a/firmware/application/external/app_manager/ui_app_manager.cpp
+++ b/firmware/application/external/app_manager/ui_app_manager.cpp
@@ -62,19 +62,24 @@ AppManagerView::AppManagerView(NavigationView& nav)
auto app_name = get_app_info(menu_view.highlighted_index(), true);
auto app_id = get_app_info(menu_view.highlighted_index(), false);
- info += "Hidden:";
+ if (!app_name.empty()) {
+ info += "Hidden:";
- if (is_blacklisted(app_name)) {
- info += "Yes ";
- } else {
- info += "No ";
+ if (is_blacklisted(app_name)) {
+ info += "Yes ";
+ } else {
+ info += "No ";
+ }
}
- info += "Autostart:";
- if (is_autostart_app(app_id)) {
- info += "Yes";
- } else {
- info += "No";
+ if (!app_id.empty()) {
+ info += "Autostart:";
+
+ if (is_autostart_app(app_id)) {
+ info += "Yes";
+ } else {
+ info += "No";
+ }
}
if (info.empty()) {
@@ -82,6 +87,8 @@ AppManagerView::AppManagerView(NavigationView& nav)
}
text_app_info.set(info);
+ button_hide_unhide.set_focusable(!app_name.empty());
+ button_set_cancel_autostart.set_focusable(!app_id.empty());
};
button_hide_unhide.on_select = [this](Button&) {
@@ -120,27 +127,23 @@ void AppManagerView::refresh_list() {
};
for (auto& app : NavigationView::appList) {
- if (app.id == nullptr) continue;
-
app_list_index++;
- menu_view.add_item({app.displayName + std::string(is_autostart_app(app.id) ? padding(app.displayName) : ""),
+ menu_view.add_item({app.displayName + std::string((app.id != nullptr && is_autostart_app(app.id)) ? padding(app.displayName) : ""),
app.iconColor,
app.icon,
- [this, app_id = std::string(app.id)](KeyEvent) {
+ [this, app_id = std::string(app.id != nullptr ? app.id : "")](KeyEvent) {
button_hide_unhide.focus();
}});
}
ExternalItemsMenuLoader::load_all_external_items_callback([this, &index, &padding](ui::AppInfoConsole& app) {
- if (app.appCallName == nullptr) return;
-
app_list_index++;
- menu_view.add_item({app.appFriendlyName + std::string(is_autostart_app(app.appCallName) ? padding(app.appFriendlyName) : ""),
+ menu_view.add_item({app.appFriendlyName + std::string((app.appCallName != nullptr && is_autostart_app(app.appCallName)) ? padding(app.appFriendlyName) : ""),
ui::Color::light_grey(),
&bitmap_icon_sdcard,
- [this, app_id = std::string(app.appCallName)](KeyEvent) {
+ [this, app_id = std::string(app.appCallName != nullptr ? app.appCallName : "")](KeyEvent) {
button_hide_unhide.focus();
}});
});
@@ -156,19 +159,29 @@ void AppManagerView::hide_app() {
std::vector blacklist;
get_blacklist(blacklist);
- blacklist.push_back(get_app_info(menu_view.highlighted_index(), true));
+ auto app_name = get_app_info(menu_view.highlighted_index(), true);
+ if (app_name.empty()) return;
+
+ blacklist.push_back(app_name);
write_blacklist(blacklist);
}
void AppManagerView::unhide_app() {
std::vector blacklist;
get_blacklist(blacklist);
- blacklist.erase(std::remove(blacklist.begin(), blacklist.end(), get_app_info(menu_view.highlighted_index(), true)), blacklist.end());
+
+ auto app_name = get_app_info(menu_view.highlighted_index(), true);
+ if (app_name.empty()) return;
+
+ blacklist.erase(std::remove(blacklist.begin(), blacklist.end(), app_name), blacklist.end());
write_blacklist(blacklist);
}
void AppManagerView::hide_unhide_app() {
- if (is_blacklisted(get_app_info(menu_view.highlighted_index(), true)))
+ auto app_name = get_app_info(menu_view.highlighted_index(), true);
+ if (app_name.empty()) return;
+
+ if (is_blacklisted(app_name))
unhide_app();
else
hide_app();
@@ -218,6 +231,7 @@ void AppManagerView::set_auto_start() {
if (app_index >= app_list_index) return;
auto id_aka_app_call_name = get_app_info(app_index, false);
+ if (id_aka_app_call_name.empty()) return;
autostart_app = id_aka_app_call_name;
@@ -234,6 +248,7 @@ void AppManagerView::set_unset_autostart_app() {
if (app_index >= app_list_index) return;
auto id_aka_friendly_name = get_app_info(app_index, false);
+ if (id_aka_friendly_name.empty()) return;
if (is_autostart_app(id_aka_friendly_name))
unset_auto_start();
@@ -254,17 +269,15 @@ std::string AppManagerView::get_app_info(uint16_t index, bool is_display_name) {
std::string result;
for (auto& app : NavigationView::appList) {
- if (app.id == nullptr) continue;
if (current_index == index) {
- return is_display_name ? std::string(app.displayName) : std::string(app.id);
+ return is_display_name ? std::string(app.displayName != nullptr ? app.displayName : "") : std::string(app.id != nullptr ? app.id : "");
}
current_index++;
}
ExternalItemsMenuLoader::load_all_external_items_callback([¤t_index, &index, &result, &is_display_name](ui::AppInfoConsole& app) {
- if (app.appCallName == nullptr) return;
if (current_index == index) {
- result = is_display_name ? app.appFriendlyName : app.appCallName;
+ result = is_display_name ? (app.appFriendlyName != nullptr ? app.appFriendlyName : "") : (app.appCallName != nullptr ? app.appCallName : "");
}
current_index++;
});
diff --git a/firmware/application/external/battleship/main.cpp b/firmware/application/external/battleship/main.cpp
index 3ba40f401..f7d442520 100644
--- a/firmware/application/external/battleship/main.cpp
+++ b/firmware/application/external/battleship/main.cpp
@@ -45,7 +45,7 @@ __attribute__((section(".external_app.app_battleship.application_information"),
0xFF, 0xFF, // ################
0xFC, 0x3F, // ..############..
},
- /*.icon_color = */ ui::Color::blue().v,
+ /*.icon_color = */ ui::Color::green().v,
/*.menu_location = */ app_location_t::GAMES,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/blackjack/main.cpp b/firmware/application/external/blackjack/main.cpp
index cb27fc4ed..6306b15f4 100644
--- a/firmware/application/external/blackjack/main.cpp
+++ b/firmware/application/external/blackjack/main.cpp
@@ -43,7 +43,7 @@ __attribute__((section(".external_app.app_blackjack.application_information"), u
0x80, 0x01, // .......##.......
0x00, 0x00, // ................
},
- ui::Color::red().v, // Red color for diamonds
+ ui::Color::green().v, // Red color for diamonds
app_location_t::GAMES,
-1,
diff --git a/firmware/application/external/blackjack/ui_blackjack.cpp b/firmware/application/external/blackjack/ui_blackjack.cpp
index 6b42b19b4..77390061f 100644
--- a/firmware/application/external/blackjack/ui_blackjack.cpp
+++ b/firmware/application/external/blackjack/ui_blackjack.cpp
@@ -72,7 +72,7 @@ void BlackjackView::paint(Painter& painter) {
if (!initialized) {
initialized = true;
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
init_deck();
}
}
diff --git a/firmware/application/external/blespam/ui_blespam.cpp b/firmware/application/external/blespam/ui_blespam.cpp
index d90a290c4..93dde5ab4 100644
--- a/firmware/application/external/blespam/ui_blespam.cpp
+++ b/firmware/application/external/blespam/ui_blespam.cpp
@@ -132,7 +132,7 @@ void BLESpamView::reset() {
}
void BLESpamView::randomChn() {
- channel_number = 37 + std::rand() % (39 - 37 + 1);
+ channel_number = 37 + rand() % (39 - 37 + 1);
field_frequency.set_value(get_freq_by_channel_number(channel_number));
}
diff --git a/firmware/application/external/breakout/ui_breakout.cpp b/firmware/application/external/breakout/ui_breakout.cpp
index d894e7673..af7c07015 100644
--- a/firmware/application/external/breakout/ui_breakout.cpp
+++ b/firmware/application/external/breakout/ui_breakout.cpp
@@ -655,7 +655,7 @@ void BreakoutView::paint(Painter& painter) {
if (!initialized) {
initialized = true;
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
init_game();
}
}
diff --git a/firmware/application/external/cvs_spam/main.cpp b/firmware/application/external/cvs_spam/main.cpp
index b9ccb5f02..ae866fb1b 100644
--- a/firmware/application/external/cvs_spam/main.cpp
+++ b/firmware/application/external/cvs_spam/main.cpp
@@ -24,7 +24,7 @@ __attribute__((section(".external_app.app_cvs_spam.application_information"), us
/*.app_name = */ "CVS Spam",
/*.bitmap_data = */ {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81},
- /*.icon_color = */ ui::Color::red().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::TX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/detector_rx/main.cpp b/firmware/application/external/detector_rx/main.cpp
index 2745f970e..12e6e88f8 100644
--- a/firmware/application/external/detector_rx/main.cpp
+++ b/firmware/application/external/detector_rx/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_detector_rx.application_information"),
0x0C,
0xF0,
0x0F},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::RX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/dinogame/ui_dinogame.cpp b/firmware/application/external/dinogame/ui_dinogame.cpp
index 20a108e57..d7614ae7d 100644
--- a/firmware/application/external/dinogame/ui_dinogame.cpp
+++ b/firmware/application/external/dinogame/ui_dinogame.cpp
@@ -125,7 +125,7 @@ void DinoGameView::paint(Painter& painter) {
if (!initialized) {
initialized = true;
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
init_game();
}
}
@@ -789,8 +789,8 @@ void DinoGameView::draw_current_score() {
void DinoGameView::draw_high_score() {
auto style = *ui::Theme::getInstance()->fg_light;
- painter.fill_rectangle({UI_POS_X_RIGHT(90), 28, 90, 18}, Color::black());
- painter.draw_string({UI_POS_X_RIGHT(90), 30}, style, "HI " + score_to_string(highScore));
+ painter.fill_rectangle({UI_POS_X_RIGHT(10), 28, 90, 18}, Color::black());
+ painter.draw_string({UI_POS_X_RIGHT(10), 30}, style, "HI " + score_to_string(highScore));
}
void DinoGameView::jump() {
diff --git a/firmware/application/external/doom/main.cpp b/firmware/application/external/doom/main.cpp
index 417b7c625..e10812ab9 100644
--- a/firmware/application/external/doom/main.cpp
+++ b/firmware/application/external/doom/main.cpp
@@ -58,7 +58,7 @@ __attribute__((section(".external_app.app_doom.application_information"), used))
0x00,
0x00,
},
- ui::Color::red().v,
+ ui::Color::green().v,
app_location_t::GAMES,
-1,
{0, 0, 0, 0},
diff --git a/firmware/application/external/doom/ui_doom.cpp b/firmware/application/external/doom/ui_doom.cpp
index 275dc4b50..75ac2f8bb 100644
--- a/firmware/application/external/doom/ui_doom.cpp
+++ b/firmware/application/external/doom/ui_doom.cpp
@@ -204,11 +204,11 @@ void spawn_entity(uint8_t type, uint8_t x, uint8_t y) {
void spawn_random_entity(uint8_t type) {
if (num_entities >= MAX_ENTITIES) return;
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
uint8_t spawn_x, spawn_y;
do {
- spawn_x = std::rand() % LEVEL_WIDTH;
- spawn_y = std::rand() % LEVEL_HEIGHT;
+ spawn_x = rand() % LEVEL_WIDTH;
+ spawn_y = rand() % LEVEL_HEIGHT;
} while (get_block_at(spawn_x, spawn_y) == 0xF ||
(spawn_x == (uint8_t)player.pos.x && spawn_y == (uint8_t)player.pos.y));
@@ -227,9 +227,9 @@ void remove_entity(uint8_t index) {
bool spawned = false;
while (!spawned && attempts < 50) {
- std::srand(LPC_RTC->CTIME0 + attempts);
- uint8_t spawn_x = std::rand() % LEVEL_WIDTH;
- uint8_t spawn_y = std::rand() % LEVEL_HEIGHT;
+ srand(LPC_RTC->CTIME0 + attempts);
+ uint8_t spawn_x = rand() % LEVEL_WIDTH;
+ uint8_t spawn_y = rand() % LEVEL_HEIGHT;
int16_t dx = (int16_t)spawn_x - (int16_t)player.pos.x;
int16_t dy = (int16_t)spawn_y - (int16_t)player.pos.y;
@@ -270,9 +270,9 @@ void initialize_level() {
uint8_t attempts = 0;
while (initial_enemies < 2 && num_entities < MAX_ENTITIES && attempts < 50) {
- std::srand(LPC_RTC->CTIME0 + attempts);
- int8_t offset_x = (std::rand() % 11) - 5;
- int8_t offset_y = (std::rand() % 11) - 5;
+ srand(LPC_RTC->CTIME0 + attempts);
+ int8_t offset_x = (rand() % 11) - 5;
+ int8_t offset_y = (rand() % 11) - 5;
if (abs(offset_x) < 3 && abs(offset_y) < 3) {
attempts++;
@@ -307,10 +307,10 @@ void initialize_level() {
attempts = 0;
while (num_entities < MIN_ENTITIES && attempts < 100) {
- std::srand(LPC_RTC->CTIME0 + attempts + 100);
+ srand(LPC_RTC->CTIME0 + attempts + 100);
- uint8_t spawn_x = std::rand() % LEVEL_WIDTH;
- uint8_t spawn_y = std::rand() % LEVEL_HEIGHT;
+ uint8_t spawn_x = rand() % LEVEL_WIDTH;
+ uint8_t spawn_y = rand() % LEVEL_HEIGHT;
int16_t dx = (int16_t)spawn_x - (int16_t)player.pos.x;
int16_t dy = (int16_t)spawn_y - (int16_t)player.pos.y;
@@ -1045,7 +1045,7 @@ void DoomView::on_show() {
void DoomView::paint(Painter& painter) {
if (!initialized) {
initialized = true;
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
scene = 0;
up = down = left = right = fired = false;
jogging = view_height = 0;
@@ -1081,7 +1081,7 @@ void DoomView::paint(Painter& painter) {
int hud_y = RENDER_HEIGHT + 5;
painter.draw_string({5, hud_y}, style_yellow, "Health: " + std::to_string(player.health));
painter.draw_string({UI_POS_X_CENTER(15), hud_y}, style_red, "Kills: " + std::to_string(kills));
- painter.draw_string({UI_POS_X_RIGHT(80), hud_y}, style_blue, "Ammo: " + std::to_string(player.ammo));
+ painter.draw_string({UI_POS_X_RIGHT(10), hud_y}, style_blue, "Ammo: " + std::to_string(player.ammo));
prev_velocity_moving = (player.velocity != 0);
needs_redraw = false;
@@ -1113,7 +1113,7 @@ void DoomView::paint(Painter& painter) {
int hud_y = RENDER_HEIGHT + 5;
painter.draw_string({5, hud_y}, style_yellow, "Health: " + std::to_string(player.health));
painter.draw_string({UI_POS_X_CENTER(15), hud_y}, style_red, "Kills: " + std::to_string(kills));
- painter.draw_string({UI_POS_X_RIGHT(80), hud_y}, style_blue, "Ammo: " + std::to_string(player.ammo));
+ painter.draw_string({UI_POS_X_RIGHT(10), hud_y}, style_blue, "Ammo: " + std::to_string(player.ammo));
needs_gun_redraw = false;
}
diff --git a/firmware/application/external/epirb_rx/main.cpp b/firmware/application/external/epirb_rx/main.cpp
index 1da7b6f94..40fc5ec28 100644
--- a/firmware/application/external/epirb_rx/main.cpp
+++ b/firmware/application/external/epirb_rx/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_epirb_rx.application_information"), us
0x00,
0x00,
},
- /*.icon_color = */ ui::Color::red().v,
+ /*.icon_color = */ ui::Color::orange().v,
/*.menu_location = */ app_location_t::RX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/epirb_tx/beacon.hpp b/firmware/application/external/epirb_tx/beacon.hpp
new file mode 100644
index 000000000..c6918b488
--- /dev/null
+++ b/firmware/application/external/epirb_tx/beacon.hpp
@@ -0,0 +1,356 @@
+/*
+ * Copyright (C) 2026 Frederic BORRY - ADRASEC 31
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+#ifndef __BEACON_H__
+#define __BEACON_H__
+
+#include "portapack.hpp"
+#include "ui_epirb_tx.hpp"
+#include "location.hpp"
+
+namespace ui::external_app::epirb_tx {
+
+/**
+ * Get bits frop the provided frame
+ * @param data the frame data
+ * @param startBit the start bit number (one based to match documentation)
+ * @param endBit the end bit number (one based to match documentation)
+ * @return the selected bits (max 64)
+ */
+static uint64_t get_bits(uint8_t* data, int startBit, int endBit) {
+ uint64_t result = 0;
+ // 0 bases bit count
+ startBit--;
+ int numBits = endBit - startBit;
+
+ // get a pointer to the starting byte...
+ const uint8_t* pData = &(data[startBit / 8]);
+ uint8_t b = *pData;
+
+ // calculate the starting bit within that byte...
+ int bitOffset = 7 - (startBit % 8);
+
+ // iterate for the desired number of bits...
+ for (int i = 0; i < numBits; ++i) {
+ // make room for the next bit...
+ result <<= 1;
+ // copy the bit...
+ result |= ((b >> bitOffset) & 0x01);
+ // reached the end of the current byte?
+ if (--bitOffset < 0) {
+ b = *(++pData); // go to the next byte...
+ bitOffset = 7; // restart at the first bit in that byte...
+ }
+ }
+
+ // all done...
+ return result;
+}
+
+/**
+ * Compute a BCH code
+ * @param frame the frame data
+ * @param startBit the bit to start BCH calculation
+ * @param endBit the bit to stop calculation
+ * @param poly the BCH polynome
+ * @param polyLength the BCH polynome length
+ * @return the BCH code
+ */
+static uint64_t compute_bch(uint8_t* frame, int startBit, int endBit, unsigned long poly, int polyLength) { // Length of data to be checked (not including the BCH code)
+ int dataLength = endBit - startBit + 1;
+ // Total lengh (including the BCH code that will be padded to zeros (BCH code length is polyLengh-1))
+ int totalLength = dataLength + polyLength - 1;
+ // Start with the first polyLength bits
+ uint64_t result = get_bits(frame, startBit, startBit + polyLength - 1);
+ for (int i = polyLength; i <= totalLength; i++) { // Iterate on each bit after the first polyLength batch
+ bool firstBit = result >> (polyLength - 1);
+ if (firstBit) { // We have a leading 1 => xor the result with the poly
+ result = result ^ poly;
+ }
+ if (i < totalLength) { // Move to next bit
+ result = result << 1;
+ if (i < dataLength) { // Append next bit
+ result |= get_bits(frame, startBit + i, startBit + i);
+ } // else : 0 padding after data length
+ }
+ }
+ return result;
+}
+
+// 21 bits BCH polynomial
+#define BCH_21_POLYNOMIAL 0b1001101101100111100011UL
+#define BCH_21_POLY_LENGTH 22
+// 12 bits BCH polynomial
+#define BCH_12_POLYNOMIAL 0b1010100111001UL
+#define BCH_12_POLY_LENGTH 13
+
+/**
+ * Combyte BCH1 code for the provided frame
+ */
+static uint64_t compute_bch1(uint8_t* frame) {
+ return compute_bch(frame, 25, 85, BCH_21_POLYNOMIAL, BCH_21_POLY_LENGTH);
+}
+
+/**
+ * Combyte BCH2 code for the provided frame
+ */
+static uint64_t compute_bch2(uint8_t* frame) {
+ return compute_bch(frame, 107, 132, BCH_12_POLYNOMIAL, BCH_12_POLY_LENGTH);
+}
+
+/**
+ * Set a bit in the provided frame
+ * @param buf the frame
+ * @param bit the position of the bit to set (0 based)
+ * @param v the bit value
+ */
+static void set_bit(uint8_t* buf, int bit, bool v) {
+ int byte = bit >> 3;
+ int off = 7 - (bit & 7);
+
+ if (v)
+ buf[byte] |= (1 << off);
+ else
+ buf[byte] &= ~(1 << off);
+}
+
+/**
+ * Push bits to the provided frame
+ * @param buf the frame
+ * @param pos the current frame possition (in/out, will be incremented during the opreration)
+ * @param v the bits to push (max 64)
+ * @param n the number of bits to push
+ */
+static void push_bits(uint8_t* buf, int& pos, uint64_t v, int n) {
+ for (int i = n - 1; i >= 0; i--)
+ set_bit(buf, pos++, (v >> i) & 1);
+}
+
+/**
+ * Convert a beacon to hex string representation
+ * @param frame the frame to convert
+ * @param start true for the first half of the frame, false for the second half
+ * @return the hex string representation of the specieid half of the frame
+ */
+std::string beacon_to_hex_string(const uint8_t* frame, bool start) {
+ static const char hex[] = "0123456789ABCDEF";
+
+ std::string out;
+ out.resize(18);
+
+ int offset = start ? 0 : 9;
+
+ for (int i = 0; i < 9; i++) {
+ uint8_t b = frame[offset + i];
+
+ out[i * 2] = hex[b >> 4];
+ out[i * 2 + 1] = hex[b & 0x0F];
+ }
+
+ return out;
+}
+
+/**
+ * Generate a beacon in the provided buffer
+ * @param frame the buffer to generate the frame in
+ * @param params the beacon parameters
+ * @return the size of the generated frame
+ */
+size_t generate_beacon(uint8_t* frame, const BeaconParams& params) {
+ // Clear the content of the frame
+ memset(frame, 0, 18);
+
+ int pos = 0;
+ uint32_t deg, min, sec;
+
+ // bit sync
+ for (int i = 0; i < 15; i++)
+ push_bits(frame, pos, 1, 1);
+
+ // frame sync
+ push_bits(frame, pos, params.is_test ? 0b011010000 : 0b000101111, 9);
+
+ // PDF-1 (Protexted Data field 1)
+ int pdf1_start = pos;
+
+ push_bits(frame, pos, 1, 1); // format flag (long)
+ bool is_user = (params.protocol == BeaconProtocol::USER);
+ bool is_standard = (params.protocol == BeaconProtocol::STANDARD);
+ push_bits(frame, pos, is_user, 1); // protocol flag
+ push_bits(frame, pos, params.country, 10); // country code
+ switch (params.type) {
+ case BeaconType::EPIRB:
+ if (is_user)
+ push_bits(frame, pos, 0b010, 3);
+ else if (is_standard)
+ push_bits(frame, pos, 0b0010, 4);
+ else
+ push_bits(frame, pos, 0b1010, 4);
+ break;
+ case BeaconType::PLB:
+ if (is_user)
+ push_bits(frame, pos, 0b011, 3);
+ else if (is_standard)
+ push_bits(frame, pos, 0b0111, 4);
+ else
+ push_bits(frame, pos, 0b1011, 4);
+ break;
+ default:
+ case BeaconType::ELT:
+ if (is_user)
+ push_bits(frame, pos, 0b001, 3);
+ else if (is_standard)
+ push_bits(frame, pos, 0b0011, 4);
+ else
+ push_bits(frame, pos, 0b1000, 4);
+ break;
+ }
+
+ // Fill the rest of PDF 1 with zeros
+ while (pos < pdf1_start + 61)
+ push_bits(frame, pos, 0, 1);
+
+ if (is_user) {
+ // User Location Protocol: no position in PDF1
+ if (params.type == BeaconType::PLB) {
+ // Set bits for PLB
+ set_bit(frame, 40 - 1, 1);
+ set_bit(frame, 41 - 1, 1);
+ set_bit(frame, 42 - 1, 0);
+ }
+
+ set_bit(frame, 85 - 1, params.has_121_5);
+ } else if (is_standard) {
+ // Standard Location Protocol
+ // North / south
+ set_bit(frame, 65 - 1, params.location.south);
+ // E / W
+ set_bit(frame, 75 - 1, params.location.west);
+ pos = 66 - 1;
+ // Latitude 1/4 degrees (9 bits)
+ deg = (params.location.lat_deg << 2) + (params.location.lat_min / 15);
+ push_bits(frame, pos, deg, 9);
+ pos = 76 - 1;
+ // Longitude 1/4 degrees (10 bits)
+ deg = (params.location.long_deg << 2) + (params.location.long_min / 15);
+ push_bits(frame, pos, deg, 10);
+ pos = pdf1_start + 61;
+ } else {
+ // National Location Protocol
+ // North / south
+ set_bit(frame, 59 - 1, params.location.south);
+ // E / W
+ set_bit(frame, 72 - 1, params.location.west);
+ pos = 60 - 1;
+ // Latitude degrees (7 bits)
+ push_bits(frame, pos, params.location.lat_deg, 7);
+ // Latitude min (5 bits, 2 min increment)
+ min = params.location.lat_min / 2;
+ push_bits(frame, pos, min, 5);
+ pos = 73 - 1;
+ // Longitude degrees (8 bits)
+ push_bits(frame, pos, params.location.long_deg, 8);
+ // Longiitude min (5 bits, 2 min increment)
+ min = params.location.long_min / 2;
+ push_bits(frame, pos, min, 5);
+ pos = pdf1_start + 61;
+ }
+
+ // Set BCH1
+ uint64_t bch1 = compute_bch1(frame);
+ push_bits(frame, pos, bch1, 21);
+
+ // PDF-2 (Protexted Data Field 2)
+ int pdf2_start = pos;
+ if (is_user) {
+ // User Location Protocol
+ push_bits(frame, pos, params.is_internal, 1);
+ // Latitude N/S
+ push_bits(frame, pos, params.location.south, 1);
+ // Latitude degrees (7 bits)
+ push_bits(frame, pos, params.location.lat_deg, 7);
+ // Latitude minutes (4 bits, 4 minutes precision)
+ min = params.location.lat_min / 4;
+ push_bits(frame, pos, min, 4);
+ // Longitude E/W
+ push_bits(frame, pos, params.location.west, 1);
+ // Longitude degrees (8 bits)
+ push_bits(frame, pos, params.location.long_deg, 8);
+ // Longitude minutes (4 bits, 4 minutes precision)
+ min = params.location.long_min / 4;
+ push_bits(frame, pos, min, 4);
+ } else if (is_standard) {
+ // Standard Location Protocol
+ push_bits(frame, pos, 0b1101, 4);
+ push_bits(frame, pos, params.is_internal, 1);
+ push_bits(frame, pos, params.has_121_5, 1);
+ // Latiitude
+ // +
+ push_bits(frame, pos, 1, 1);
+ // Min
+ min = params.location.lat_min % 15;
+ push_bits(frame, pos, min, 5);
+ // Sec (4 bits, 4 sec precision)
+ sec = params.location.lat_sec / 4;
+ push_bits(frame, pos, sec, 4);
+ // Longitude
+ // +
+ push_bits(frame, pos, 1, 1);
+ // Min
+ min = params.location.long_min % 15;
+ push_bits(frame, pos, min, 5);
+ // Sec (4 bits, 4 sec precision)
+ sec = params.location.long_sec / 4;
+ push_bits(frame, pos, sec, 4);
+ } else {
+ // National Location Protocol
+ push_bits(frame, pos, 0b1101, 4);
+ push_bits(frame, pos, params.is_internal, 1);
+ push_bits(frame, pos, params.has_121_5, 1);
+ // Lat
+ // +
+ push_bits(frame, pos, 1, 1);
+ // Min
+ push_bits(frame, pos, (params.location.lat_min % 2), 2);
+ // Sec (4 bits, 4 sec precision)
+ sec = params.location.lat_sec / 4;
+ push_bits(frame, pos, sec, 4);
+ // LLon
+ // +
+ push_bits(frame, pos, 1, 1);
+ // Min
+ push_bits(frame, pos, (params.location.long_min % 2), 2);
+ // Sec (4 bits, 4 sec precision)
+ sec = params.location.long_sec / 4;
+ push_bits(frame, pos, sec, 4);
+ }
+
+ while (pos < pdf2_start + 26)
+ push_bits(frame, pos, 0, 1);
+
+ // Compute BCH 2
+ uint64_t bch2 = compute_bch2(frame);
+ push_bits(frame, pos, bch2, 12);
+ return 18;
+}
+
+} // namespace ui::external_app::epirb_tx
+
+#endif /*__BEACON_H__*/
\ No newline at end of file
diff --git a/firmware/application/external/epirb_tx/location.hpp b/firmware/application/external/epirb_tx/location.hpp
new file mode 100644
index 000000000..70d76f045
--- /dev/null
+++ b/firmware/application/external/epirb_tx/location.hpp
@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2026 Frederic BORRY - ADRASEC 31
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+#ifndef __LOCATION_H__
+#define __LOCATION_H__
+
+#include "portapack.hpp"
+#include "ui_epirb_tx.hpp"
+#include
+#include
+#include
+
+namespace ui::external_app::epirb_tx {
+
+/**
+ * Convert decimal coordinates to sexagesimal coordinates
+ * @param value the decimal value
+ * @param negative output N(false)/S(true) or E(false)/W(true) value
+ * @param deg output degrees value
+ * @param min output minutes value
+ * @param sec output seconds value
+ */
+static void decimal_to_dms(double value, bool& negative, uint16_t& deg, uint8_t& min, uint8_t& sec) {
+ negative = value < 0;
+ value = std::fabs(value);
+
+ deg = (uint16_t)value;
+
+ double m = (value - deg) * 60.0;
+ min = (uint8_t)m;
+
+ double s = (m - min) * 60.0;
+ sec = (uint8_t)(s + 0.5);
+
+ if (sec == 60) {
+ sec = 0;
+ min++;
+ }
+
+ if (min == 60) {
+ min = 0;
+ deg++;
+ }
+}
+
+/**
+ * Convert sexagesimal coordinates to decimal
+ * @param negative N(false)/S(true) or E(false)/W(true) value
+ * @param deg degrees value
+ * @param min minutes value
+ * @param sec seconds value
+ * @return value the decimal value
+ */
+static double dms_to_decimal(bool negative, uint16_t deg, uint8_t min, uint8_t sec) {
+ double v = deg + min / 60.0 + sec / 3600.0;
+ return negative ? -v : v;
+}
+
+/**
+ * Convert maidenhead locator to decima coordinates
+ * @param loc the locator
+ * @param lat output latitude
+ * @param lon output longitude
+ */
+static void maidenhead_to_decimal(const std::string& loc, float& lat, float& lon) {
+ static const double lon_step[] =
+ {
+ 20.0,
+ 2.0,
+ 1.0 / 12.0,
+ 1.0 / 120.0,
+ 1.0 / 2880.0};
+
+ static const double lat_step[] =
+ {
+ 10.0,
+ 1.0,
+ 1.0 / 24.0,
+ 1.0 / 240.0,
+ 1.0 / 5760.0};
+
+ lon = -180.0;
+ lat = -90.0;
+
+ int pairs = loc.size() / 2;
+ if (pairs > 5)
+ pairs = 5;
+
+ for (int i = 0; i < pairs; i++) {
+ char c1 = std::toupper(loc[i * 2]);
+ char c2 = std::toupper(loc[i * 2 + 1]);
+
+ double lon_size = lon_step[i];
+ double lat_size = lat_step[i];
+
+ int v1, v2;
+
+ if (i % 2 == 0) // letters
+ {
+ v1 = c1 - 'A';
+ v2 = c2 - 'A';
+ } else // digits
+ {
+ v1 = c1 - '0';
+ v2 = c2 - '0';
+ }
+
+ lon += v1 * lon_size;
+ lat += v2 * lat_size;
+ }
+
+ // Cell center
+ lon += lon_step[pairs - 1] / 2.0;
+ lat += lat_step[pairs - 1] / 2.0;
+}
+
+/**
+ * Convert decimal coordinates to maidenhead locator
+ * @param lat the latitude
+ * @param lon the longitude
+ * @param precision (optional, defaults to 8) the number of charater for the maidenhead locator
+ * @return the locator
+ */
+static std::string decimal_to_maidenhead(double lat, double lon, int precision = 8) {
+ lon += 180.0;
+ lat += 90.0;
+
+ int A = lon / 20;
+ int B = lat / 10;
+
+ lon -= A * 20;
+ lat -= B * 10;
+
+ int C = lon / 2;
+ int D = lat / 1;
+
+ lon -= C * 2;
+ lat -= D * 1;
+
+ int E = lon / (5.0 / 60.0);
+ int F = lat / (2.5 / 60.0);
+
+ lon -= E * (5.0 / 60.0);
+ lat -= F * (2.5 / 60.0);
+
+ int G = lon / (5.0 / 600.0);
+ int H = lat / (2.5 / 600.0);
+
+ std::string locator;
+
+ locator += char('A' + A);
+ locator += char('A' + B);
+
+ if (precision >= 4) {
+ locator += char('0' + C);
+ locator += char('0' + D);
+ }
+
+ if (precision >= 6) {
+ locator += char('a' + E);
+ locator += char('a' + F);
+ }
+
+ if (precision >= 8) {
+ locator += char('0' + G);
+ locator += char('0' + H);
+ }
+
+ return locator;
+}
+
+/**
+ * Init the provided Location values from its locator
+ */
+void init_from_locator(Location& loc) {
+ maidenhead_to_decimal(loc.locator, loc.latitude, loc.longitude);
+
+ decimal_to_dms(loc.latitude,
+ loc.south,
+ loc.lat_deg,
+ loc.lat_min,
+ loc.lat_sec);
+
+ decimal_to_dms(loc.longitude,
+ loc.west,
+ loc.long_deg,
+ loc.long_min,
+ loc.long_sec);
+}
+
+/**
+ * Init the provided Location values from its decimal coordinates
+ */
+void init_from_decimal(Location& loc) {
+ decimal_to_dms(loc.latitude,
+ loc.south,
+ loc.lat_deg,
+ loc.lat_min,
+ loc.lat_sec);
+
+ decimal_to_dms(loc.longitude,
+ loc.west,
+ loc.long_deg,
+ loc.long_min,
+ loc.long_sec);
+
+ loc.locator = decimal_to_maidenhead(loc.latitude, loc.longitude);
+}
+
+/**
+ * Init the provided Location values from its sexagesimal coordinates
+ */
+void init_from_dms(Location& loc) {
+ loc.latitude = dms_to_decimal(
+ loc.south,
+ loc.lat_deg,
+ loc.lat_min,
+ loc.lat_sec);
+
+ loc.longitude = dms_to_decimal(
+ loc.west,
+ loc.long_deg,
+ loc.long_min,
+ loc.long_sec);
+
+ loc.locator = decimal_to_maidenhead(loc.latitude, loc.longitude);
+}
+
+/**
+ * Convert the provided Loaction to it's latitude string (format )
+ */
+std::string to_latitude_string(const Location& location) {
+ char buffer[16];
+ // Format :
+ snprintf(buffer, sizeof(buffer), "%3d\260%02d'%02d\"%c", location.lat_deg, location.lat_min, location.lat_sec, location.south ? 'S' : 'N');
+ return std::string(buffer);
+}
+
+/**
+ * Convert the provided Loaction to it's longitude string (format )
+ */
+std::string to_longitude_string(const Location& location) {
+ char buffer[16];
+ // Format :
+ snprintf(buffer, sizeof(buffer), "%3d\260%02d'%02d\"%c", location.long_deg, location.long_min, location.long_sec, location.west ? 'W' : 'E');
+ return std::string(buffer);
+}
+
+} // namespace ui::external_app::epirb_tx
+
+#endif /*__LOCATION_H__*/
\ No newline at end of file
diff --git a/firmware/application/external/epirb_tx/main.cpp b/firmware/application/external/epirb_tx/main.cpp
new file mode 100644
index 000000000..3765b739a
--- /dev/null
+++ b/firmware/application/external/epirb_tx/main.cpp
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2026 Frederic BORRY - ADRASEC 31
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_epirb_tx.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::epirb_tx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::epirb_tx
+extern "C" {
+
+__attribute__((section(".external_app.app_epirb_tx.application_information"), used)) application_information_t _application_information_epirb_tx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::epirb_tx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "EPIRB TX",
+ /*.bitmap_data = */ {
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x7C,
+ 0x3E,
+ 0xFE,
+ 0x7F,
+ 0xFF,
+ 0xFF,
+ 0xF7,
+ 0xEF,
+ 0xE3,
+ 0xC7,
+ 0xE3,
+ 0xC7,
+ 0xE3,
+ 0xC7,
+ 0xE3,
+ 0xC7,
+ 0xF7,
+ 0xEF,
+ 0xFF,
+ 0xFF,
+ 0xFE,
+ 0x7F,
+ 0x7C,
+ 0x3E,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::green().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_epirb_tx */ {'P', 'E', 'P', 'T'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
diff --git a/firmware/application/external/epirb_tx/ui_epirb_tx.cpp b/firmware/application/external/epirb_tx/ui_epirb_tx.cpp
new file mode 100644
index 000000000..fdc1a3c19
--- /dev/null
+++ b/firmware/application/external/epirb_tx/ui_epirb_tx.cpp
@@ -0,0 +1,479 @@
+/*
+ * Copyright (C) 2026 Frederic BORRY - ADRASEC 31
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui_epirb_tx.hpp"
+
+#include "tonesets.hpp"
+#include "portapack.hpp"
+#include "baseband_api.hpp"
+#include "string_format.hpp"
+#include "file_reader.hpp"
+#include "file_path.hpp"
+#include "ui_geomap.hpp"
+#include "ui_alphanum.hpp"
+
+#include
+#include
+
+#include "beacon.hpp"
+
+using namespace portapack;
+
+namespace ui::external_app::epirb_tx {
+
+void EPIRBTXAppView::focus() {
+ button_tx.focus();
+}
+
+EPIRBTXAppView::~EPIRBTXAppView() {
+ // Restore original frequency before leaving
+ transmitter_model.set_target_frequency(original_frequency);
+ transmitter_model.disable();
+ baseband::shutdown();
+}
+
+/**
+ * Conversion from hex char to half byte
+ */
+static uint8_t hexval(char c) {
+ if (c >= '0' && c <= '9') return c - '0';
+ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+ return 0;
+}
+
+/**
+ * Convert from hex chars to uint8_t
+ */
+static uint8_t hexToByte(char high, char low) {
+ return (hexval(high) << 4) | hexval(low);
+}
+
+std::string EPIRBTXAppView::frame_to_hex_string(bool start) {
+ return beacon_to_hex_string(epirb_tx_message.data, start);
+}
+
+void EPIRBTXAppView::generate_frame(BeaconParams params) {
+ epirb_tx_message.data_len = generate_beacon(epirb_tx_message.data, params);
+}
+
+void EPIRBTXAppView::on_timer() {
+ // Timer is called on display refresh
+ if (loop) {
+ if (loop_enabled) {
+ // chTimeNow() returns milliseconds on our version of ChibiOS / Hardware
+ auto now = chTimeNow();
+ auto elapsed = ((now - last_frame_time) / 1000);
+ std::string timeout = std::to_string((uint32_t)(delay > elapsed ? delay - elapsed : 0));
+ if (timeout != text_timeout.get()) {
+ // Update timeout text every seconds
+ text_timeout.set(timeout);
+ }
+ if (now > (last_frame_time + (delay * 1000))) {
+ // Send a new frame after the delay
+ start_tx();
+ }
+ } else {
+ // Stop send loop
+ loop = false;
+ }
+ }
+}
+
+void EPIRBTXAppView::update_frame(bool updateConfig) {
+ if (mode_file) {
+ // In file mode, currently selected beacon has changed => load the new one
+ Beacon& beacon = beacons[selected_beacon];
+ // Set desciption
+ text_description.set(beacon.description.substr(0, max_text_width_ext));
+ text_description_end.set(beacon.description.size() > max_text_width_ext ? "-" + beacon.description.substr(max_text_width_ext, max_text_width_ext + max_text_width_ext - 1) : "");
+ // Udapte frame content on display
+ text_frame.set(beacon.frame.substr(0, 18));
+ text_frame_end.set(beacon.frame.size() > 18 ? beacon.frame.substr(18, 36) : "");
+ // Prepare tx configuration
+ epirb_tx_message.data_len = std::min((beacon.frame.size() / 2), 18);
+ for (uint8_t i = 0; i < epirb_tx_message.data_len; i++) {
+ epirb_tx_message.data[i] = hexToByte(
+ beacon.frame[2 * i],
+ beacon.frame[2 * i + 1]);
+ }
+ } else {
+ // In manual mode, generate frame content for current beacon params
+ generate_frame(beacon_params);
+ // Update frame content on display
+ text_frame.set(frame_to_hex_string(true));
+ text_frame_end.set(frame_to_hex_string(false));
+ }
+ if (updateConfig && send_on_change && loop) {
+ // Need to update config / send new beacon
+ if (am_enabled) {
+ // Already transmitting => update config
+ last_frame_time = chTimeNow();
+ update_config();
+ } else {
+ // Not yet transmitting => start tx
+ start_tx();
+ }
+ }
+}
+
+void EPIRBTXAppView::update_config() {
+ if (epirb_tx_message.mode_bpsk) {
+ // Already in BPSK mode => backup bpsk frequency
+ bpsk_frequency = transmitter_model.target_frequency();
+ } else {
+ // Previously in AM mode => restore bpsk frequency
+ transmitter_model.set_target_frequency(bpsk_frequency);
+ }
+ // Set mode to bpsk
+ epirb_tx_message.mode_bpsk = true;
+ // Set pre/post count
+ epirb_tx_message.pre_count = (150 * TONES_SAMPLERATE) / 1000; // 150 ms
+ epirb_tx_message.post_count = (100 * TONES_SAMPLERATE) / 1000; // 100 ms
+ // Send config to baseband
+ baseband::set_epirb_tx_config(epirb_tx_message);
+}
+
+void EPIRBTXAppView::set_tx_button_state(bool active) {
+ button_tx.set_text(active ? "START" : "STOP");
+ button_tx.set_style(active ? &style_tx_start : &style_tx_stop);
+}
+
+void EPIRBTXAppView::start_tx() {
+ last_frame_time = chTimeNow();
+ update_config();
+ loop = loop_enabled;
+ transmitter_model.enable();
+ tx_view.set_transmitting(true);
+ set_tx_button_state(false);
+ transmitting = true;
+}
+
+void EPIRBTXAppView::stop_tx() {
+ loop = false;
+ transmitter_model.disable();
+ tx_view.set_transmitting(false);
+ set_tx_button_state(true);
+ transmitting = false;
+}
+
+void EPIRBTXAppView::on_tx_progress(const uint32_t progress, const bool done) {
+ (void)progress;
+ if (done) {
+ if (am_enabled) {
+ // BPSK frame sent, switch back to 121.5 AM signal
+ epirb_tx_message.mode_bpsk = false;
+ // Backup bpsk frequency for next run
+ bpsk_frequency = transmitter_model.target_frequency();
+ // Restore am frequency
+ transmitter_model.set_target_frequency(am_frequency);
+ // Send config to baseband
+ baseband::set_epirb_tx_config(epirb_tx_message);
+ } else {
+ // End of BPSK frame
+ transmitter_model.disable();
+ tx_view.set_transmitting(false);
+ if (!loop) {
+ // Not looping => update transmission state
+ set_tx_button_state(true);
+ transmitting = false;
+ }
+ }
+ }
+}
+
+void EPIRBTXAppView::update_location(bool updateLocatorField) {
+ locator = beacon_params.location.locator;
+ if (updateLocatorField) text_field_beacon_locator.set_text(beacon_params.location.locator);
+ text_beacon_latitude_value.set(to_latitude_string(beacon_params.location));
+ text_beacon_longitude_value.set(to_longitude_string(beacon_params.location));
+}
+
+void EPIRBTXAppView::update_mode() {
+ // Hide / show widgets for file mode or manual mode
+ text_beacon.hidden(!mode_file);
+ text_description_label.hidden(!mode_file);
+ options_frame.hidden(!mode_file);
+ text_description.hidden(!mode_file);
+ text_description_end.hidden(!mode_file);
+ text_beacon_type.hidden(mode_file);
+ text_beacon_country.hidden(mode_file);
+ checkbox_beacon_internal.hidden(mode_file);
+ text_beacon_locator.hidden(mode_file);
+ text_beacon_latitude.hidden(mode_file);
+ text_beacon_latitude_value.hidden(mode_file);
+ text_beacon_longitude.hidden(mode_file);
+ text_beacon_longitude_value.hidden(mode_file);
+ button_mangps.hidden(mode_file);
+ options_beacon_type.hidden(mode_file);
+ options_beacon_protocol.hidden(mode_file);
+ options_beacon_country.hidden(mode_file);
+ text_field_beacon_locator.hidden(mode_file);
+}
+
+EPIRBTXAppView::EPIRBTXAppView(
+ NavigationView& nav) {
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+
+ add_children({&labels,
+ &options_mode,
+ &text_beacon,
+ &text_description_label,
+ &text_beacon_type,
+ &options_beacon_type,
+ &options_beacon_protocol,
+ &text_beacon_country,
+ &options_beacon_country,
+ &checkbox_beacon_internal,
+ &text_beacon_locator,
+ &text_beacon_latitude,
+ &text_beacon_latitude_value,
+ &text_beacon_longitude,
+ &text_beacon_longitude_value,
+ &button_mangps,
+ &text_field_beacon_locator,
+ &options_frame,
+ &text_description,
+ &text_description_end,
+ &text_frame,
+ &text_frame_end,
+ &text_timeout,
+ &checkbox_loop,
+ &field_delay,
+ &button_tx,
+ &checkbox_am,
+ &field_am_frequency,
+ &checkbox_send_on_change,
+ &tx_view});
+
+ text_beacon.set_style(Theme::getInstance()->fg_light);
+ text_description_label.set_style(Theme::getInstance()->fg_light);
+ text_beacon_type.set_style(Theme::getInstance()->fg_light);
+ text_beacon_country.set_style(Theme::getInstance()->fg_light);
+ text_beacon_locator.set_style(Theme::getInstance()->fg_light);
+ text_beacon_latitude.set_style(Theme::getInstance()->fg_light);
+ text_beacon_longitude.set_style(Theme::getInstance()->fg_light);
+
+ // Restore settings
+ checkbox_am.set_value(am_enabled);
+ checkbox_loop.set_value(loop_enabled);
+ checkbox_send_on_change.set_value(send_on_change);
+ options_mode.set_by_value(!mode_file);
+ transmitter_model.set_target_frequency(bpsk_frequency);
+ field_am_frequency.set_value(am_frequency);
+ field_delay.set_value(delay);
+ options_beacon_type.set_by_value(beacon_type);
+ options_beacon_protocol.set_by_value(beacon_protocol);
+ options_beacon_country.set_by_value(beacon_country);
+ checkbox_beacon_internal.set_value(beacon_internal);
+ beacon_params.type = (BeaconType)beacon_type;
+ beacon_params.has_121_5 = am_enabled;
+ beacon_params.location.locator = locator;
+ beacon_params.is_internal = beacon_internal;
+ init_from_locator(beacon_params.location);
+ update_mode();
+ update_location();
+
+ options_mode.on_change = [this](size_t index, OptionsField::value_t) {
+ mode_file = (index == 0);
+ update_mode();
+ update_frame();
+ set_dirty();
+ };
+
+ options_beacon_type.on_change = [this](size_t index, OptionsField::value_t) {
+ beacon_params.type = (BeaconType)index;
+ beacon_type = index;
+ update_frame();
+ set_dirty();
+ };
+
+ options_beacon_protocol.on_change = [this](size_t index, OptionsField::value_t) {
+ beacon_params.protocol = (BeaconProtocol)index;
+ beacon_protocol = index;
+ update_frame();
+ set_dirty();
+ };
+
+ options_beacon_country.on_change = [this](size_t, OptionsField::value_t v) {
+ beacon_params.country = v;
+ beacon_country = v;
+ update_frame();
+ set_dirty();
+ };
+
+ checkbox_beacon_internal.on_select = [this](Checkbox&, bool v) {
+ beacon_internal = v;
+ beacon_params.is_internal = v;
+ update_frame();
+ set_dirty();
+ };
+
+ text_field_beacon_locator.on_select = [this, &nav](TextField&) mutable {
+ auto te_view = nav.push(locator, 10, ENTER_KEYBOARD_MODE_ALPHA);
+ te_view->on_changed = [this](std::string& value) {
+ beacon_params.location.locator = value;
+ init_from_locator(beacon_params.location);
+ update_location();
+ update_frame();
+ set_dirty();
+ };
+ };
+
+ button_mangps.on_select = [this, &nav](Button&) {
+ nav.push(
+ 0,
+ GeoPos::alt_unit::METERS,
+ GeoPos::spd_unit::HIDDEN,
+ beacon_params.location.latitude,
+ beacon_params.location.longitude,
+ [this](int32_t, float lat, float lon, int32_t) {
+ beacon_params.location.latitude = lat;
+ beacon_params.location.longitude = lon;
+ init_from_decimal(beacon_params.location);
+ // Update locator field
+ update_location();
+ update_frame();
+ set_dirty();
+ });
+ };
+
+ field_am_frequency.on_change = [this](rf::Frequency freq) {
+ am_frequency = freq;
+ if (transmitting && !epirb_tx_message.mode_bpsk && am_enabled)
+ // Update transmitter frequency
+ transmitter_model.set_target_frequency(am_frequency);
+ };
+
+ // Load available beacons from BEACONS.TXT files (or default).
+ load_beacons();
+ // Setup options_frame content with loaded beacons
+ using option_t = std::pair;
+ using options_t = std::vector;
+ options_t entries;
+ for (const auto& beacon : beacons)
+ entries.emplace_back(beacon.title, entries.size());
+
+ options_frame.set_options(std::move(entries));
+ if (selected_beacon >= beacons.size())
+ // BEACONS.TXT file has changed since last launch, default index to 0
+ selected_beacon = 0;
+ options_frame.set_selected_index(selected_beacon);
+ options_frame.on_change = [this](size_t index, OptionsField::value_t) {
+ selected_beacon = index;
+ update_frame();
+ set_dirty();
+ };
+
+ // Init frame content / baseband param with currently setup beacon
+ update_frame(false);
+
+ checkbox_loop.on_select = [this](Checkbox&, bool v) {
+ loop_enabled = v;
+ };
+
+ field_delay.on_change = [this](int32_t v) {
+ delay = v;
+ };
+
+ checkbox_am.on_select = [this](Checkbox&, bool v) {
+ beacon_params.has_121_5 = v;
+ am_enabled = v;
+ if (!mode_file) update_frame(false);
+ };
+
+ // AM frequency field edit
+ field_am_frequency.on_edit = [this, &nav]() {
+ auto new_view = nav.push(field_am_frequency.value());
+ new_view->on_changed = [this](rf::Frequency f) {
+ field_am_frequency.set_value(f);
+ };
+ };
+
+ checkbox_send_on_change.on_select = [this](Checkbox&, bool v) {
+ send_on_change = v;
+ };
+
+ tx_view.on_edit_frequency = [this, &nav]() {
+ auto new_view = nav.push(transmitter_model.target_frequency());
+ new_view->on_changed = [this](rf::Frequency f) {
+ transmitter_model.set_target_frequency(f);
+ bpsk_frequency = f;
+ };
+ };
+
+ tx_view.on_start = [this]() {
+ start_tx();
+ };
+
+ tx_view.on_stop = [this]() {
+ stop_tx();
+ };
+
+ button_tx.on_select = [this](Button&) {
+ if (!transmitting)
+ start_tx();
+ else
+ stop_tx();
+ };
+}
+
+/**
+ * Load beacons from /EPIRB/BEACONS.TXT file on sd card
+ */
+void EPIRBTXAppView::load_beacons() {
+ File beacons_file;
+ auto error = beacons_file.open(epirb_dir / u"BEACONS.TXT");
+ beacons.clear();
+
+ if (!error) {
+ // BEACONS.TXT file exists
+ auto reader = FileLineReader(beacons_file);
+ for (const auto& line : reader) {
+ // Skip comment lines
+ if (line.length() == 0 || line[0] == '#')
+ continue;
+
+ // Split line with ';' separator
+ auto cols = split_string(line, ';');
+ if (cols.size() != 3)
+ continue;
+
+ // Read current beacon
+ Beacon beacon{};
+ beacon.title = trim(cols[0]);
+ beacon.description = trim(cols[1]);
+ // Make sure frame is not longer tha 18 bytes / 36 hex character
+ beacon.frame = trim(cols[2]).substr(0, 36);
+ size_t size = beacon.frame.size();
+ if (size <= 0)
+ continue; // Invalid line.
+ // Beacon is valid, add it to the list
+ beacons.emplace_back(std::move(beacon));
+ }
+ }
+ if (beacons.empty()) {
+ // No beacons file or empty flile: just add default beacon
+ beacons.push_back(default_beacon);
+ }
+}
+
+} // namespace ui::external_app::epirb_tx
\ No newline at end of file
diff --git a/firmware/application/external/epirb_tx/ui_epirb_tx.hpp b/firmware/application/external/epirb_tx/ui_epirb_tx.hpp
new file mode 100644
index 000000000..132e933d7
--- /dev/null
+++ b/firmware/application/external/epirb_tx/ui_epirb_tx.hpp
@@ -0,0 +1,332 @@
+/*
+ * Copyright (C) 2026 Frederic BORRY - ADRASEC 31
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#ifndef __EPIRB_TX_H__
+#define __EPIRB_TX_H__
+
+#include "app_settings.hpp"
+#include "radio_state.hpp"
+#include "ui.hpp"
+#include "ui_widget.hpp"
+#include "ui_navigation.hpp"
+#include "ui_transmitter.hpp"
+
+#include "portapack.hpp"
+#include "message.hpp"
+#include "tonesets.hpp"
+
+#define BEACON_HEXA_SIZE 36
+#define BEACON_HEXA_HALF_SIZE 18
+#define BEACON_SIZE 18
+
+namespace ui::external_app::epirb_tx {
+
+enum class BeaconType {
+ EPIRB = 0,
+ ELT = 1,
+ PLB = 2
+};
+
+enum class BeaconProtocol {
+ USER = 0,
+ STANDARD = 1,
+ NATIONAL = 2
+};
+
+struct Location {
+ std::string locator;
+ bool south;
+ uint16_t lat_deg;
+ uint8_t lat_min;
+ uint8_t lat_sec;
+ float latitude;
+ bool west;
+ uint16_t long_deg;
+ uint8_t long_min;
+ uint8_t long_sec;
+ float longitude;
+};
+
+struct BeaconParams {
+ BeaconType type;
+ BeaconProtocol protocol;
+ uint32_t country;
+ bool is_test;
+ bool is_internal;
+ bool has_121_5;
+ Location location;
+};
+
+class EPIRBTXAppView : public View {
+ public:
+ EPIRBTXAppView(NavigationView& nav);
+ ~EPIRBTXAppView();
+
+ void focus() override;
+
+ std::string title() const override { return "EPIRB TX"; };
+
+ private:
+ void start_tx();
+ void stop_tx();
+ void update_config();
+ void on_tx_progress(const uint32_t progress, const bool done);
+ void on_timer();
+ void load_beacons();
+ void set_tx_button_state(bool active);
+ std::string frame_to_hex_string(bool start);
+ void generate_frame(BeaconParams params);
+ void update_frame(bool updateConfig = true);
+ void update_mode();
+ void update_location(bool updateLocatorField = true);
+
+ struct Beacon {
+ std::string title{};
+ std::string description{};
+ std::string frame{};
+ };
+ std::vector beacons{};
+ Beacon default_beacon{"Self test", "Serial User Location Protocol", "FFFED0D6E6202820000C29FF51041775302D"};
+
+ BeaconParams beacon_params{BeaconType::ELT, BeaconProtocol::STANDARD, 227, true, true, true, {"JN03RO", false, 0, 0, 0, 0, false, 0, 0, 0, 0}};
+
+ // Currently selected beacon index (from BEACONS.TXT file / options_frame combo)
+ uint32_t selected_beacon{0};
+
+ // Frequency of the transmitter before starting the app (used to restore frequency when leaving)
+ rf::Frequency original_frequency{0};
+ // Frequency of the AM emergency signal
+ rf::Frequency am_frequency{121500000};
+ // Frequency of the 406 MHz BPSK signal
+ rf::Frequency bpsk_frequency{406025000};
+
+ // True when using a beacon from the BEACONS.TXT file
+ bool mode_file{true};
+ // True when looping on sending beacons is enabled
+ bool loop_enabled{true};
+ // True if AM emergency signal transmission is enabled
+ bool am_enabled{true};
+ // True if we want to send a new frame each time the user changes the current beacon
+ bool send_on_change{false};
+ // The current locator string
+ std::string locator{"JN03RO"};
+ // The delay between each frame when on loop mode
+ uint32_t delay{50};
+ uint8_t beacon_type{0};
+ // Currently selected beacon protocol
+ uint8_t beacon_protocol{0};
+ // Currently selected beacon country
+ uint32_t beacon_country{227};
+ // Current beacon's internal state (true for internal location system)
+ bool beacon_internal{true};
+
+ TxRadioState radio_state_{
+ 0 /* frequency */,
+ 1750000 /* bandwidth */,
+ TONES_SAMPLERATE /* sampling rate */
+ };
+ app_settings::SettingsManager settings_{
+ "tx_epirb",
+ app_settings::Mode::TX,
+ {
+ {"sbeacon"sv, &selected_beacon},
+ {"amfreq"sv, &am_frequency},
+ {"bpskfreq"sv, &bpsk_frequency},
+ {"loop"sv, &loop_enabled},
+ {"delay"sv, &delay},
+ {"file"sv, &mode_file},
+ {"am"sv, &am_enabled},
+ {"soc"sv, &send_on_change},
+ {"type"sv, &beacon_type},
+ {"proto"sv, &beacon_protocol},
+ {"country"sv, &beacon_country},
+ {"internal"sv, &beacon_internal},
+ {"locator"sv, &locator},
+ }};
+
+ // Time of the last sent frame
+ uint32_t last_frame_time{0};
+ // True when transmission is enabled
+ bool transmitting{false};
+ // True when currently looping on sending beacons
+ bool loop{false};
+
+ // Current EPIRBTXDataMessage for baseband
+ EPIRBTXDataMessage epirb_tx_message{};
+
+ const size_t max_text_width = UI_POS_WIDTH_REMAINING(6) / UI_POS_DEFAULT_WIDTH;
+ const size_t max_text_width_ext = UI_POS_WIDTH_REMAINING(0) / UI_POS_DEFAULT_WIDTH;
+
+ Labels labels{
+ {{UI_POS_X(0), UI_POS_Y(0)}, "Source:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(6)}, "Frame:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(10)}, "Next frame in s.", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(12)}, "AM frequency MHz", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(17), UI_POS_Y(9)}, "s.", Theme::getInstance()->fg_light->foreground}};
+
+ // For file mode
+ Text text_beacon{
+ {UI_POS_X(0), UI_POS_Y(1), UI_POS_WIDTH(7), UI_POS_DEFAULT_HEIGHT},
+ "Beacon:"};
+ // Beacon selection from BEACONS.TXT
+ OptionsField options_frame{
+ {UI_POS_X(7), UI_POS_Y(1)},
+ 30,
+ {}};
+ Text text_description_label{
+ {UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(12), UI_POS_DEFAULT_HEIGHT},
+ "Description:"};
+ Text text_description{
+ {UI_POS_X(0), UI_POS_Y(3), UI_POS_WIDTH_REMAINING(0), UI_POS_DEFAULT_HEIGHT},
+ ""};
+ Text text_description_end{
+ {UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH_REMAINING(0), UI_POS_DEFAULT_HEIGHT},
+ ""};
+
+ // For manual mode
+ Text text_beacon_type{
+ {UI_POS_X(0), UI_POS_Y(1), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT},
+ "Type:"};
+ Text text_beacon_country{
+ {UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT},
+ "Country:"};
+ Checkbox checkbox_beacon_internal{
+ {UI_POS_X_RIGHT(12), UI_POS_Y(2)},
+ 12,
+ "Internal",
+ true};
+ Text text_beacon_locator{
+ {UI_POS_X(0), UI_POS_Y(3), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT},
+ "Locator:"};
+ Text text_beacon_latitude{
+ {UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT},
+ "Lat.:"};
+ Text text_beacon_longitude{
+ {UI_POS_X(0), UI_POS_Y(5), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT},
+ "Long.:"};
+ Text text_beacon_latitude_value{
+ {UI_POS_X(7), UI_POS_Y(4), UI_POS_WIDTH(12), UI_POS_DEFAULT_HEIGHT},
+ ""};
+ Text text_beacon_longitude_value{
+ {UI_POS_X(7), UI_POS_Y(5), UI_POS_WIDTH(12), UI_POS_DEFAULT_HEIGHT},
+ ""};
+ OptionsField options_beacon_type{
+ {UI_POS_X(9), UI_POS_Y(1)},
+ 7,
+ {{"EPIRB", 0},
+ {"ELT", 1},
+ {"PLB", 2}}};
+ OptionsField options_beacon_protocol{
+ {UI_POS_X(9 + 7), UI_POS_Y(1)},
+ 30,
+ {{"User", 0},
+ {"Standard", 1},
+ {"National", 2}}};
+ OptionsField options_beacon_country{
+ {UI_POS_X(9), UI_POS_Y(2)},
+ 7,
+ {{"France", 227},
+ {"USA", 366},
+ {"Germany", 211},
+ {"Russia", 273},
+ {"Spain", 224},
+ {"Japan", 431},
+ {"UK", 232}}};
+ TextField text_field_beacon_locator{
+ {UI_POS_X(9), UI_POS_Y(3), UI_POS_WIDTH(10), UI_POS_DEFAULT_HEIGHT},
+ "JN03RO"};
+ Button button_mangps{
+ {UI_POS_X_RIGHT(9), UI_POS_Y(3), UI_POS_WIDTH(9), UI_POS_HEIGHT(2)},
+ "Set pos."};
+
+ // Mode selection (file/manual)
+ OptionsField options_mode{
+ {UI_POS_X(7), UI_POS_Y(0)},
+ 30,
+ {{"File (BEACONS.TXT)", 0},
+ {"Manual (Editor)", 1}}};
+
+ // Frame content
+ Text text_frame{
+ {UI_POS_X(6), UI_POS_Y(6), UI_POS_WIDTH_REMAINING(6), UI_POS_DEFAULT_HEIGHT},
+ ""};
+ Text text_frame_end{
+ {UI_POS_X(6), UI_POS_Y(7), UI_POS_WIDTH_REMAINING(6), UI_POS_DEFAULT_HEIGHT},
+ ""};
+
+ Text text_timeout{
+ {UI_POS_X(14), UI_POS_Y(10), UI_POS_WIDTH(2), UI_POS_DEFAULT_HEIGHT},
+ ""};
+
+ // Transmission settings
+ Checkbox checkbox_loop{
+ {UI_POS_X(0), UI_POS_Y(9)},
+ 10,
+ "Resend every",
+ true};
+ NumberField field_delay{
+ {UI_POS_X(15), UI_POS_Y(9)},
+ 2,
+ {1, 99},
+ 1,
+ ' '};
+ Checkbox checkbox_am{
+ {UI_POS_X(0), UI_POS_Y(11)},
+ 10,
+ "AM signal",
+ true};
+ FrequencyField field_am_frequency{
+ {UI_POS_X(13), UI_POS_Y(12)}};
+ Checkbox checkbox_send_on_change{
+ {UI_POS_X(0), UI_POS_Y(13)},
+ 14,
+ "Send on change",
+ true};
+ Button button_tx{
+ {UI_POS_X_RIGHT(9), UI_POS_Y(9), UI_POS_WIDTH(9), UI_POS_HEIGHT(2)},
+ "START"};
+ const Style& style_tx_start = *Theme::getInstance()->fg_green;
+ const Style& style_tx_stop = *Theme::getInstance()->fg_red;
+
+ // Transmitter view
+ TransmitterView tx_view{
+ (int16_t)UI_POS_Y_BOTTOM(4),
+ 10000,
+ 12};
+
+ MessageHandlerRegistration message_handler_tx_progress{
+ Message::ID::TXProgress,
+ [this](const Message* const p) {
+ const auto message = *reinterpret_cast(p);
+ this->on_tx_progress(message.progress, message.done);
+ }};
+
+ MessageHandlerRegistration message_handler_frame_sync{
+ // Use frame sync for our applcation timer callback
+ Message::ID::DisplayFrameSync,
+ [this](const Message* const) {
+ this->on_timer();
+ }};
+};
+
+} // namespace ui::external_app::epirb_tx
+
+#endif /*__EPIRB_TX_H__*/
diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake
index b0b33d87d..dfa89efc4 100644
--- a/firmware/application/external/external.cmake
+++ b/firmware/application/external/external.cmake
@@ -83,6 +83,10 @@ set(EXTCPPSRC
external/tpmsrx/main.cpp
external/tpmsrx/tpms_app.cpp
+ #tpmstx 800 bytes - TPMS transmit with editable fields
+ external/tpmstx/main.cpp
+ external/tpmstx/tpms_tx_app.cpp
+
#protoview 8 byte
external/protoview/main.cpp
external/protoview/ui_protoview.cpp
@@ -91,14 +95,22 @@ set(EXTCPPSRC
external/adsbtx/main.cpp
external/adsbtx/ui_adsb_tx.cpp
- #morse_tx 768 bytes
- external/morse_tx/main.cpp
- external/morse_tx/ui_morse.cpp
+ #morse_tx 768 bytes -- disabled because of the new morse tx app with more functions
+ #external/morse_tx/main.cpp
+ #external/morse_tx/ui_morse.cpp
#sstvtx 456 bytes
external/sstvtx/main.cpp
external/sstvtx/ui_sstvtx.cpp
+ #same_tx
+ external/same_tx/main.cpp
+ external/same_tx/ui_same_tx.cpp
+
+ #mdc_tx
+ external/mdc_tx/main.cpp
+ external/mdc_tx/ui_mdc_tx.cpp
+
#sstvrx
external/sstvrx/main.cpp
external/sstvrx/ui_sstvrx.cpp
@@ -109,8 +121,8 @@ set(EXTCPPSRC
external/random_password/sha512.cpp
#acars
- #external/acars_rx/main.cpp
- #external/acars_rx/acars_app.cpp
+ external/acars_rx/main.cpp
+ external/acars_rx/acars_app.cpp
#wefax_rx 192 bytes
external/wefax_rx/main.cpp
@@ -222,7 +234,11 @@ set(EXTCPPSRC
#gfxEQ 80 byte
external/gfxeq/main.cpp
- external/gfxeq/ui_gfxeq.cpp
+ external/gfxeq/ui_gfxeq.cpp
+
+ #waterfall designer
+ external/waterfall_designer/main.cpp
+ external/waterfall_designer/ui_waterfall_designer.cpp
#detector_rx 168 byte
external/detector_rx/main.cpp
@@ -248,6 +264,10 @@ set(EXTCPPSRC
external/epirb_rx/main.cpp
external/epirb_rx/ui_epirb_rx.cpp
+ #epirb_tx
+ external/epirb_tx/main.cpp
+ external/epirb_tx/ui_epirb_tx.cpp
+
#soundboard 272byte - 1236 bytes
external/soundboard/main.cpp
external/soundboard/soundboard_app.cpp
@@ -276,6 +296,50 @@ set(EXTCPPSRC
#subcarrx
external/subcarrx/main.cpp
external/subcarrx/ui_subcar.cpp
+
+ #siggen
+ external/siggen/main.cpp
+ external/siggen/ui_siggen.cpp
+
+ #morse_radio
+ external/morse_radio/main.cpp
+ external/morse_radio/ui_morse_radio.cpp
+
+ #morseradiotx
+ external/morseradiotx/main.cpp
+ external/morseradiotx/ui_morse_radiotx.cpp
+
+ external/keeloqtx/main.cpp
+ external/keeloqtx/ui_keeloqtx.cpp
+ #rtty_rx
+ external/rtty_rx/main.cpp
+ external/rtty_rx/ui_rtty_rx.cpp
+ external/rtty_rx/baudot.cpp
+
+ #rtty_tx
+ external/rtty_tx/main.cpp
+ external/rtty_tx/ui_rtty_tx.cpp
+ external/rtty_tx/baudot.cpp
+
+ #pocsag_tx
+ external/pocsag_tx/main.cpp
+ external/pocsag_tx/ui_pocsag_tx.cpp
+
+ #time_sink
+ external/time_sink/main.cpp
+ external/time_sink/ui_time_sink.cpp
+
+ #kiss_tnc
+ external/kiss_tnc/main.cpp
+ external/kiss_tnc/ui_kiss_tnc.cpp
+
+ #fpv_detect
+ external/fpv_detect/main.cpp
+ external/fpv_detect/ui_fpv_detect.cpp
+
+ #p25_tx
+ external/p25_tx/main.cpp
+ external/p25_tx/ui_p25_tx.cpp
)
set(EXTAPPLIST
@@ -298,13 +362,16 @@ set(EXTAPPLIST
audio_test
wardrivemap
tpmsrx
+ tpmstx
protoview
adsbtx
- morse_tx
+ #morse_tx
sstvtx
+ same_tx
+ mdc_tx
sstvrx
random_password
- # acars_rx --not working
+ acars_rx
wefax_rx
noaaapt_rx
shoppingcart_lock
@@ -332,12 +399,15 @@ set(EXTAPPLIST
scanner
level
gfxeq
+ waterfall_designer
detector_rx
+ fpv_detect
spaceinv
blackjack
battleship
ert
epirb_rx
+ epirb_tx
soundboard
game2048
bht_tx
@@ -345,4 +415,24 @@ set(EXTAPPLIST
adult_toys_controller
flex_rx
subcarrx
+ siggen
+ morse_radio
+ morseradiotx
+ keeloqtx
+ rtty_rx
+ rtty_tx
+ pocsag_tx
+ time_sink
+ kiss_tnc
+ p25_tx
)
+
+# sdusb has type conflicts with PRALINE (HackRF Pro) - add only for non-PRALINE builds
+if(NOT BOARD STREQUAL "PRALINE")
+ list(APPEND EXTCPPSRC
+ external/sdusb/main.cpp
+ external/sdusb/ui_sd_over_usb.cpp
+ )
+ list(APPEND EXTAPPLIST sdusb)
+endif()
+
diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld
index 6ee5cc178..cd61633c5 100644
--- a/firmware/application/external/external.ld
+++ b/firmware/application/external/external.ld
@@ -23,73 +23,89 @@ MEMORY
* Also need to consider processor memory map - reading 0xADxxxxxx generates a fault which may be better than unexpected behavior.
* External app address ranges below must match those in python file "external_app_info.py".
*/
- ram_external_app_afsk_rx (rwx) : org = 0xADB10000, len = 32k
- ram_external_app_calculator (rwx) : org = 0xADB20000, len = 32k
- ram_external_app_font_viewer(rwx) : org = 0xADB30000, len = 32k
- ram_external_app_blespam (rwx) : org = 0xADB40000, len = 32k
- ram_external_app_analogtv (rwx) : org = 0xADB50000, len = 32k
- ram_external_app_nrf_rx (rwx) : org = 0xADB60000, len = 32k
- ram_external_app_coasterp (rwx) : org = 0xADB70000, len = 32k
- ram_external_app_lge (rwx) : org = 0xADB80000, len = 32k
- ram_external_app_lcr (rwx) : org = 0xADB90000, len = 32k
- ram_external_app_jammer (rwx) : org = 0xADBA0000, len = 32k
- ram_external_app_gpssim (rwx) : org = 0xADBB0000, len = 32k
- ram_external_app_spainter (rwx) : org = 0xADBC0000, len = 32k
- ram_external_app_keyfob (rwx) : org = 0xADBD0000, len = 32k
- ram_external_app_tetris (rwx) : org = 0xADBE0000, len = 32k
- ram_external_app_extsensors (rwx) : org = 0xADBF0000, len = 32k
- ram_external_app_foxhunt_rx (rwx) : org = 0xADC00000, len = 32k
- ram_external_app_audio_test (rwx) : org = 0xADC10000, len = 32k
- ram_external_app_wardrivemap(rwx) : org = 0xADC20000, len = 32k
- ram_external_app_tpmsrx (rwx) : org = 0xADC30000, len = 32k
- ram_external_app_protoview (rwx) : org = 0xADC40000, len = 32k
- ram_external_app_adsbtx (rwx) : org = 0xADC50000, len = 32k
- ram_external_app_morse_tx (rwx) : org = 0xADC60000, len = 32k
- ram_external_app_sstvtx (rwx) : org = 0xADC70000, len = 32k
- ram_external_app_random_password(rwx): org = 0xADC80000, len = 32k
- ram_external_app_acars_rx (rwx) : org = 0xADC90000, len = 32k
- ram_external_app_shoppingcart_lock(rwx): org = 0xADCA0000, len = 32k
- ram_external_app_cvs_spam (rwx) : org = 0xADCB0000, len = 32k
- ram_external_app_ookbrute (rwx) : org = 0xADCC0000, len = 32k
- ram_external_app_flippertx (rwx) : org = 0xADCD0000, len = 32k
- ram_external_app_ook_editor (rwx) : org = 0xADCE0000, len = 32k
- ram_external_app_remote (rwx) : org = 0xADCF0000, len = 32k
- ram_external_app_mcu_temperature(rwx): org = 0xADD00000, len = 32k
- ram_external_app_fmradio (rwx) : org = 0xADD10000, len = 32k
- ram_external_app_tuner (rwx) : org = 0xADD20000, len = 32k
- ram_external_app_metronome (rwx) : org = 0xADD30000, len = 32k
- ram_external_app_app_manager(rwx) : org = 0xADD40000, len = 32k
- ram_external_app_hopper (rwx) : org = 0xADD50000, len = 32k
- ram_external_app_antenna_length(rwx): org = 0xADD60000, len = 32k
- ram_external_app_view_wav (rwx) : org = 0xADD70000, len = 32k
- ram_external_app_sd_wipe (rwx) : org = 0xADD80000, len = 32k
- ram_external_app_playlist_editor(rwx): org = 0xADD90000, len = 32k
- ram_external_app_snake (rwx) : org = 0xADDA0000, len = 32k
- ram_external_app_stopwatch (rwx) : org = 0xADDB0000, len = 32k
- ram_external_app_wefax_rx (rwx) : org = 0xADDC0000, len = 32k
- ram_external_app_breakout (rwx) : org = 0xADDD0000, len = 32k
- ram_external_app_doom (rwx) : org = 0xADDE0000, len = 32k
- ram_external_app_debug_pmem (rwx) : org = 0xADDF0000, len = 32k
- ram_external_app_scanner (rwx) : org = 0xADE00000, len = 32k
- ram_external_app_level (rwx) : org = 0xADE10000, len = 32k
- ram_external_app_gfxeq (rwx) : org = 0xADE20000, len = 32k
- ram_external_app_noaaapt_rx (rwx) : org = 0xADE30000, len = 32k
- ram_external_app_detector_rx (rwx) : org = 0xADE40000, len = 32k
- ram_external_app_dinogame (rwx) : org = 0xADE50000, len = 32k
- ram_external_app_spaceinv (rwx) : org = 0xADE60000, len = 32k
- ram_external_app_blackjack (rwx) : org = 0xADE70000, len = 32k
- ram_external_app_battleship (rwx) : org = 0xADE80000, len = 32k
- ram_external_app_ert (rwx) : org = 0xADE90000, len = 32k
- ram_external_app_epirb_rx (rwx) : org = 0xADEA0000, len = 32k
- ram_external_app_soundboard (rwx) : org = 0xADEB0000, len = 32k
- ram_external_app_game2048 (rwx) : org = 0xADEC0000, len = 32k
- ram_external_app_bht_tx (rwx) : org = 0xADED0000, len = 32k
- ram_external_app_morse_practice (rwx) : org = 0xADEE0000, len = 32k
- ram_external_app_adult_toys_controller (rwx) : org = 0xADEF0000, len = 32k
- ram_external_app_flex_rx (rwx) : org = 0xADF00000, len = 32k
- ram_external_app_sstvrx (rwx) : org = 0xADF10000, len = 32k
- ram_external_app_subcarrx (rwx) : org = 0xADF20000, len = 32k
-
+ ram_external_app_afsk_rx (rwx) : org = 0xADB10000, len = 32k
+ ram_external_app_calculator (rwx) : org = 0xADB20000, len = 32k
+ ram_external_app_font_viewer (rwx) : org = 0xADB30000, len = 32k
+ ram_external_app_blespam (rwx) : org = 0xADB40000, len = 32k
+ ram_external_app_analogtv (rwx) : org = 0xADB50000, len = 32k
+ ram_external_app_nrf_rx (rwx) : org = 0xADB60000, len = 32k
+ ram_external_app_coasterp (rwx) : org = 0xADB70000, len = 32k
+ ram_external_app_lge (rwx) : org = 0xADB80000, len = 32k
+ ram_external_app_lcr (rwx) : org = 0xADB90000, len = 32k
+ ram_external_app_jammer (rwx) : org = 0xADBA0000, len = 32k
+ ram_external_app_gpssim (rwx) : org = 0xADBB0000, len = 32k
+ ram_external_app_spainter (rwx) : org = 0xADBC0000, len = 32k
+ ram_external_app_keyfob (rwx) : org = 0xADBD0000, len = 32k
+ ram_external_app_tetris (rwx) : org = 0xADBE0000, len = 32k
+ ram_external_app_extsensors (rwx) : org = 0xADBF0000, len = 32k
+ ram_external_app_foxhunt_rx (rwx) : org = 0xADC00000, len = 32k
+ ram_external_app_audio_test (rwx) : org = 0xADC10000, len = 32k
+ ram_external_app_wardrivemap (rwx) : org = 0xADC20000, len = 32k
+ ram_external_app_tpmsrx (rwx) : org = 0xADC30000, len = 32k
+ ram_external_app_protoview (rwx) : org = 0xADC40000, len = 32k
+ ram_external_app_adsbtx (rwx) : org = 0xADC50000, len = 32k
+ ram_external_app_morse_tx (rwx) : org = 0xADC60000, len = 32k
+ ram_external_app_sstvtx (rwx) : org = 0xADC70000, len = 32k
+ ram_external_app_random_password (rwx) : org = 0xADC80000, len = 32k
+ ram_external_app_acars_rx (rwx) : org = 0xADC90000, len = 32k
+ ram_external_app_shoppingcart_lock (rwx) : org = 0xADCA0000, len = 32k
+ ram_external_app_cvs_spam (rwx) : org = 0xADCB0000, len = 32k
+ ram_external_app_ookbrute (rwx) : org = 0xADCC0000, len = 32k
+ ram_external_app_flippertx (rwx) : org = 0xADCD0000, len = 32k
+ ram_external_app_ook_editor (rwx) : org = 0xADCE0000, len = 32k
+ ram_external_app_remote (rwx) : org = 0xADCF0000, len = 32k
+ ram_external_app_mcu_temperature (rwx) : org = 0xADD00000, len = 32k
+ ram_external_app_fmradio (rwx) : org = 0xADD10000, len = 32k
+ ram_external_app_tuner (rwx) : org = 0xADD20000, len = 32k
+ ram_external_app_metronome (rwx) : org = 0xADD30000, len = 32k
+ ram_external_app_app_manager (rwx) : org = 0xADD40000, len = 32k
+ ram_external_app_hopper (rwx) : org = 0xADD50000, len = 32k
+ ram_external_app_antenna_length (rwx) : org = 0xADD60000, len = 32k
+ ram_external_app_view_wav (rwx) : org = 0xADD70000, len = 32k
+ ram_external_app_sd_wipe (rwx) : org = 0xADD80000, len = 32k
+ ram_external_app_playlist_editor (rwx) : org = 0xADD90000, len = 32k
+ ram_external_app_snake (rwx) : org = 0xADDA0000, len = 32k
+ ram_external_app_stopwatch (rwx) : org = 0xADDB0000, len = 32k
+ ram_external_app_wefax_rx (rwx) : org = 0xADDC0000, len = 32k
+ ram_external_app_breakout (rwx) : org = 0xADDD0000, len = 32k
+ ram_external_app_doom (rwx) : org = 0xADDE0000, len = 32k
+ ram_external_app_debug_pmem (rwx) : org = 0xADDF0000, len = 32k
+ ram_external_app_scanner (rwx) : org = 0xADE00000, len = 32k
+ ram_external_app_level (rwx) : org = 0xADE10000, len = 32k
+ ram_external_app_gfxeq (rwx) : org = 0xADE20000, len = 32k
+ ram_external_app_noaaapt_rx (rwx) : org = 0xADE30000, len = 32k
+ ram_external_app_detector_rx (rwx) : org = 0xADE40000, len = 32k
+ ram_external_app_dinogame (rwx) : org = 0xADE50000, len = 32k
+ ram_external_app_spaceinv (rwx) : org = 0xADE60000, len = 32k
+ ram_external_app_blackjack (rwx) : org = 0xADE70000, len = 32k
+ ram_external_app_battleship (rwx) : org = 0xADE80000, len = 32k
+ ram_external_app_ert (rwx) : org = 0xADE90000, len = 32k
+ ram_external_app_epirb_rx (rwx) : org = 0xADEA0000, len = 32k
+ ram_external_app_soundboard (rwx) : org = 0xADEB0000, len = 32k
+ ram_external_app_game2048 (rwx) : org = 0xADEC0000, len = 32k
+ ram_external_app_bht_tx (rwx) : org = 0xADED0000, len = 32k
+ ram_external_app_morse_practice (rwx) : org = 0xADEE0000, len = 32k
+ ram_external_app_adult_toys_controller (rwx) : org = 0xADEF0000, len = 32k
+ ram_external_app_flex_rx (rwx) : org = 0xADF00000, len = 32k
+ ram_external_app_sstvrx (rwx) : org = 0xADF10000, len = 32k
+ ram_external_app_subcarrx (rwx) : org = 0xADF20000, len = 32k
+ ram_external_app_siggen (rwx) : org = 0xADF30000, len = 32k
+ ram_external_app_sdusb (rwx) : org = 0xADF40000, len = 32k
+ ram_external_app_morse_radio (rwx) : org = 0xADF50000, len = 32k
+ ram_external_app_waterfall_designer (rwx) : org = 0xADF60000, len = 32k
+ ram_external_app_morseradiotx (rwx) : org = 0xADF70000, len = 32k
+ ram_external_app_keeloqtx (rwx) : org = 0xADF80000, len = 32k
+ ram_external_app_rtty_rx (rwx) : org = 0xADF90000, len = 32k
+ ram_external_app_rtty_tx (rwx) : org = 0xADFA0000, len = 32k
+ ram_external_app_tpmstx (rwx) : org = 0xADFB0000, len = 32k
+ ram_external_app_pocsag_tx (rwx) : org = 0xADFC0000, len = 32k
+ ram_external_app_time_sink (rwx) : org = 0xADFD0000, len = 32k
+ ram_external_app_same_tx (rwx) : org = 0xADFE0000, len = 32k
+ ram_external_app_kiss_tnc (rwx) : org = 0xADFF0000, len = 32k
+ ram_external_app_mdc_tx (rwx) : org = 0xAE000000, len = 32k
+ ram_external_app_epirb_tx (rwx) : org = 0xAE010000, len = 32k
+ ram_external_app_fpv_detect (rwx) : org = 0xAE020000, len = 32k
+ ram_external_app_p25_tx (rwx) : org = 0xAE030000, len = 32k
}
SECTIONS
@@ -130,7 +146,7 @@ SECTIONS
*(*ui*external_app*nrf_rx*);
} > ram_external_app_nrf_rx
- .external_app_coasterp : ALIGN(4) SUBALIGN(4)
+ .external_app_coasterp : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_coasterp.application_information));
*(*ui*external_app*coasterp*);
@@ -178,55 +194,61 @@ SECTIONS
*(*ui*external_app*tetris*);
} > ram_external_app_tetris
- .external_app_extsensors : ALIGN(4) SUBALIGN(4)
+ .external_app_extsensors : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_extsensors.application_information));
*(*ui*external_app*extsensors*);
} > ram_external_app_extsensors
- .external_app_foxhunt_rx : ALIGN(4) SUBALIGN(4)
+ .external_app_foxhunt_rx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_foxhunt_rx.application_information));
*(*ui*external_app*foxhunt_rx*);
} > ram_external_app_foxhunt_rx
- .external_app_audio_test : ALIGN(4) SUBALIGN(4)
+ .external_app_audio_test : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_audio_test.application_information));
*(*ui*external_app*audio_test*);
} > ram_external_app_audio_test
- .external_app_wardrivemap : ALIGN(4) SUBALIGN(4)
+ .external_app_wardrivemap : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_wardrivemap.application_information));
*(*ui*external_app*wardrivemap*);
} > ram_external_app_wardrivemap
- .external_app_tpmsrx : ALIGN(4) SUBALIGN(4)
+ .external_app_tpmsrx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_tpmsrx.application_information));
*(*ui*external_app*tpmsrx*);
} > ram_external_app_tpmsrx
- .external_app_protoview : ALIGN(4) SUBALIGN(4)
+ .external_app_tpmstx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_tpmstx.application_information));
+ *(*ui*external_app*tpmstx*);
+ } > ram_external_app_tpmstx
+
+ .external_app_protoview : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_protoview.application_information));
*(*ui*external_app*protoview*);
} > ram_external_app_protoview
- .external_app_adsbtx : ALIGN(4) SUBALIGN(4)
+ .external_app_adsbtx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_adsbtx.application_information));
*(*ui*external_app*adsbtx*);
} > ram_external_app_adsbtx
- .external_app_morse_tx : ALIGN(4) SUBALIGN(4)
+ .external_app_morse_tx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_morse_tx.application_information));
*(*ui*external_app*morse_tx*);
} > ram_external_app_morse_tx
- .external_app_sstvtx : ALIGN(4) SUBALIGN(4)
+ .external_app_sstvtx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_sstvtx.application_information));
*(*ui*external_app*sstvtx*);
@@ -244,19 +266,19 @@ SECTIONS
*(*ui*external_app*acars_rx*);
} > ram_external_app_acars_rx
- .external_app_shoppingcart_lock : ALIGN(4) SUBALIGN(4)
+ .external_app_shoppingcart_lock : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_shoppingcart_lock.application_information));
*(*ui*external_app*shoppingcart_lock*);
} > ram_external_app_shoppingcart_lock
- .external_app_cvs_spam : ALIGN(4) SUBALIGN(4)
+ .external_app_cvs_spam : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_cvs_spam.application_information));
*(*ui*external_app*cvs_spam*);
} > ram_external_app_cvs_spam
- .external_app_ookbrute : ALIGN(4) SUBALIGN(4)
+ .external_app_ookbrute : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_ookbrute.application_information));
*(*ui*external_app*ookbrute*);
@@ -268,7 +290,7 @@ SECTIONS
*(*ui*external_app*ook_editor*);
} > ram_external_app_ook_editor
- .external_app_flippertx : ALIGN(4) SUBALIGN(4)
+ .external_app_flippertx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_flippertx.application_information));
*(*ui*external_app*flippertx*);
@@ -352,13 +374,13 @@ SECTIONS
*(*ui*external_app*stopwatch*);
} > ram_external_app_stopwatch
- .external_app_wefax_rx : ALIGN(4) SUBALIGN(4)
+ .external_app_wefax_rx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_wefax_rx.application_information));
*(*ui*external_app*wefax_rx*);
} > ram_external_app_wefax_rx
- .external_app_noaaapt_rx : ALIGN(4) SUBALIGN(4)
+ .external_app_noaaapt_rx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_noaaapt_rx.application_information));
*(*ui*external_app*noaaapt_rx*);
@@ -400,6 +422,12 @@ SECTIONS
*(*ui*external_app*gfxeq*);
} > ram_external_app_gfxeq
+ .external_app_waterfall_designer : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_waterfall_designer.application_information));
+ *(*ui*external_app*waterfall_designer*);
+ } > ram_external_app_waterfall_designer
+
.external_app_detector_rx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_detector_rx.application_information));
@@ -454,14 +482,12 @@ SECTIONS
*(*ui*external_app*game2048*);
} > ram_external_app_game2048
-
.external_app_bht_tx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_bht_tx.application_information));
*(*ui*external_app*bht_tx*);
} > ram_external_app_bht_tx
-
.external_app_morse_practice : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_morse_practice.application_information));
@@ -478,7 +504,7 @@ SECTIONS
{
KEEP(*(.external_app.app_flex_rx.application_information));
*(*ui*external_app*flex_rx*);
- } > ram_external_app_flex_rx
+ } > ram_external_app_flex_rx
.external_app_sstvrx : ALIGN(4) SUBALIGN(4)
{
@@ -492,6 +518,93 @@ SECTIONS
*(*ui*external_app*subcarrx*);
} > ram_external_app_subcarrx
-
-}
+ .external_app_siggen : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_siggen.application_information));
+ *(*ui*external_app*siggen*);
+ } > ram_external_app_siggen
+ .external_app_sdusb : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_sdusb.application_information));
+ *(*ui*external_app*sdusb*);
+ } > ram_external_app_sdusb
+
+ .external_app_morse_radio : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_morse_radio.application_information));
+ *(*ui*external_app*morse_radio*);
+ } > ram_external_app_morse_radio
+
+ .external_app_morseradiotx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_morseradiotx.application_information));
+ *(*ui*external_app*morseradiotx*);
+ } > ram_external_app_morseradiotx
+
+ .external_app_keeloqtx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_keeloqtx.application_information));
+ *(*ui*external_app*keeloqtx*);
+ } > ram_external_app_keeloqtx
+
+ .external_app_rtty_rx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_rtty_rx.application_information));
+ *(*ui*external_app*rtty_rx*);
+ } > ram_external_app_rtty_rx
+
+ .external_app_rtty_tx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_rtty_tx.application_information));
+ *(*ui*external_app*rtty_tx*);
+ } > ram_external_app_rtty_tx
+
+ .external_app_pocsag_tx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_pocsag_tx.application_information));
+ *(*ui*external_app*pocsag_tx*);
+ } > ram_external_app_pocsag_tx
+
+ .external_app_time_sink : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_time_sink.application_information));
+ *(*ui*external_app*time_sink*);
+ } > ram_external_app_time_sink
+
+ .external_app_same_tx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_same_tx.application_information));
+ *(*ui*external_app*same_tx*);
+ } > ram_external_app_same_tx
+
+ .external_app_kiss_tnc : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_kiss_tnc.application_information));
+ *(*ui*external_app*kiss_tnc*);
+ } > ram_external_app_kiss_tnc
+
+ .external_app_mdc_tx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_mdc_tx.application_information));
+ *(*ui*external_app*mdc_tx*);
+ } > ram_external_app_mdc_tx
+
+ .external_app_epirb_tx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_epirb_tx.application_information));
+ *(*ui*external_app*epirb_tx*);
+ } > ram_external_app_epirb_tx
+
+ .external_app_fpv_detect : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_fpv_detect.application_information));
+ *(*ui*external_app*fpv_detect*);
+ } > ram_external_app_fpv_detect
+
+ .external_app_p25_tx : ALIGN(4) SUBALIGN(4)
+ {
+ KEEP(*(.external_app.app_p25_tx.application_information));
+ *(*ui*external_app*p25_tx*);
+ } > ram_external_app_p25_tx
+}
diff --git a/firmware/application/external/extsensors/main.cpp b/firmware/application/external/extsensors/main.cpp
index 4597dd19c..970e2b295 100644
--- a/firmware/application/external/extsensors/main.cpp
+++ b/firmware/application/external/extsensors/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_extsensors.application_information"),
0xF0,
0x0F,
},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::DEBUG,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/flex_rx/main.cpp b/firmware/application/external/flex_rx/main.cpp
index 6d6faa7dc..a475ee4b3 100644
--- a/firmware/application/external/flex_rx/main.cpp
+++ b/firmware/application/external/flex_rx/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_flex_rx.application_information"), use
0x00,
0x00,
},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::RX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/flippertx/main.cpp b/firmware/application/external/flippertx/main.cpp
index 3dde72c26..dc5f8117d 100644
--- a/firmware/application/external/flippertx/main.cpp
+++ b/firmware/application/external/flippertx/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_flippertx.application_information"), u
0xE0,
0x07,
},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::TX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/flippertx/ui_flippertx.cpp b/firmware/application/external/flippertx/ui_flippertx.cpp
index 03572ed00..45165c380 100644
--- a/firmware/application/external/flippertx/ui_flippertx.cpp
+++ b/firmware/application/external/flippertx/ui_flippertx.cpp
@@ -80,7 +80,7 @@ bool FlipperTxView::on_file_changed(std::filesystem::path new_file_path) {
return false;
}
preset = submeta.value().preset;
- if (preset != FLIPPER_PRESET_OOK) {
+ if (preset != FLIPPER_PRESET_OOK && preset != FLIPPER_PRESET_2FSK) {
field_filename.set_text("File: err, not supp. preset");
return false;
}
@@ -108,8 +108,13 @@ void FlipperTxView::stop() {
bool FlipperTxView::start() {
if (filename.empty()) return false;
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+ transmitter_model.set_sampling_rate(OOK_SAMPLERATE);
transmitter_model.enable();
button_startstop.set_text(LanguageHelper::currentMessages[LANG_STOP]);
+ uint8_t mode = 0;
+ if (preset == FLIPPER_PRESET_2FSK)
+ mode = 1;
+ baseband::set_bitstream_config(FM_DEVIATION, mode);
// start thread
replay_thread = std::make_unique(
filename,
diff --git a/firmware/application/external/flippertx/ui_flippertx.hpp b/firmware/application/external/flippertx/ui_flippertx.hpp
index 0f30ab342..9775c5ffa 100644
--- a/firmware/application/external/flippertx/ui_flippertx.hpp
+++ b/firmware/application/external/flippertx/ui_flippertx.hpp
@@ -28,6 +28,8 @@ using namespace ui;
namespace ui::external_app::flippertx {
#define OOK_SAMPLERATE 2280000U
+#define FM_DEVIATION 60000U
+
class FlipperPlayThread;
class FlipperTxView : public View {
public:
diff --git a/firmware/application/external/fmradio/ui_fmradio.cpp b/firmware/application/external/fmradio/ui_fmradio.cpp
index 73927a89e..bb4fcfbb9 100644
--- a/firmware/application/external/fmradio/ui_fmradio.cpp
+++ b/firmware/application/external/fmradio/ui_fmradio.cpp
@@ -165,7 +165,7 @@ FmRadioView::FmRadioView(NavigationView& nav)
&gr});
txt_save_help.set_focusable(false);
- txt_save_help.visible(false);
+ txt_save_help.hidden(true);
for (uint8_t i = 0; i < 12; ++i) {
if (freq_fav_list[i].frequency == 0) {
freq_fav_list[i].frequency = 87000000;
@@ -195,7 +195,7 @@ FmRadioView::FmRadioView(NavigationView& nav)
btn_fav_save.on_select = [this](Button&) {
save_fav = !save_fav;
txt_save_help.set_text(save_fav ? "Select slot" : "");
- txt_save_help.visible(save_fav);
+ txt_save_help.hidden(!save_fav);
txt_save_help.set_dirty();
};
@@ -229,9 +229,9 @@ void FmRadioView::on_btn_clicked(uint8_t i) {
freq_fav_list[i].modulation = field_modulation.selected_index_value();
freq_fav_list[i].bandwidth = radio_bw;
update_fav_btn_texts();
- txt_save_help.visible(save_fav);
+ txt_save_help.hidden(true);
txt_save_help.set_text("");
- txt_save_help.set_dirty();
+ set_dirty();
return;
}
field_frequency.set_value(freq_fav_list[i].frequency);
@@ -266,7 +266,7 @@ FmRadioView::~FmRadioView() {
}
void FmRadioView::on_audio_spectrum() {
- if (gr.visible() && audio_spectrum_data) gr.update_audio_spectrum(*audio_spectrum_data);
+ if (gr.drawn() && audio_spectrum_data) gr.update_audio_spectrum(*audio_spectrum_data);
if (audio_spectrum_data && audio_spectrum_data->db.size() <= 128) {
for (size_t i = 0; i < audio_spectrum_data->db.size(); ++i) {
audio_spectrum[i] = ((int16_t)audio_spectrum_data->db[i] - 127) * 256;
diff --git a/firmware/application/external/fpv_detect/main.cpp b/firmware/application/external/fpv_detect/main.cpp
new file mode 100644
index 000000000..d8348ec4b
--- /dev/null
+++ b/firmware/application/external/fpv_detect/main.cpp
@@ -0,0 +1,65 @@
+// Author: berkeozkir (Berke ΓzkΔ±r)
+
+#include "ui.hpp"
+#include "ui_fpv_detect.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::fpv_detect {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::fpv_detect
+
+extern "C" {
+
+__attribute__((section(".external_app.app_fpv_detect.application_information"), used)) application_information_t _application_information_fpv_detect = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::fpv_detect::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "FPV DETECT",
+ /*.bitmap_data = */
+ {
+ 0x00,
+ 0x00,
+ 0xC0,
+ 0x03,
+ 0x20,
+ 0x04,
+ 0x10,
+ 0x08,
+ 0x48,
+ 0x12,
+ 0xA4,
+ 0x25,
+ 0x54,
+ 0x2A,
+ 0xA8,
+ 0x15,
+ 0x54,
+ 0x2A,
+ 0xA4,
+ 0x25,
+ 0x48,
+ 0x12,
+ 0x10,
+ 0x08,
+ 0x20,
+ 0x04,
+ 0xC0,
+ 0x03,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::orange().v,
+ /*.menu_location = */ app_location_t::RX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_wfm_audio */ {'P', 'W', 'F', 'M'},
+ /*.m4_app_offset = */ 0x00000000,
+};
+}
diff --git a/firmware/application/external/fpv_detect/ui_fpv_detect.cpp b/firmware/application/external/fpv_detect/ui_fpv_detect.cpp
new file mode 100644
index 000000000..7fd62af94
--- /dev/null
+++ b/firmware/application/external/fpv_detect/ui_fpv_detect.cpp
@@ -0,0 +1,666 @@
+// Author: berkeozkir (Berke ΓzkΔ±r)
+
+/*
+ * FPV RX β how frequency search and lock work
+ *
+ * 1. Power metric: We use channelized power (baseband IQ magnitudeΒ² in the
+ * capture bandwidth), not raw RF RSSI. Stats come from ChannelStatsCollector
+ * over filtered IQ, so we see power in the tuned channel, not wideband.
+ *
+ * 2. Scanning: We step through FPV bands/channels (A/B/E/F/R, 8 ch each). When
+ * power on the current channel exceeds the detect threshold, we enter
+ * "Candidate" and run verification β we do not lock on a single peak.
+ *
+ * 3. Verification (making sure the drone is on that freq):
+ * - Multiple samples: verify_hits / verify_misses over several updates.
+ * - Confidence score: combines average above threshold, peak margin,
+ * hit count, and crucially neighbor_margin = center power β max(adjacent ch).
+ * - Neighbor check: we only lock if the center channel is at least
+ * MIN_NEIGHBOR_MARGIN_FOR_LOCK_DB above both adjacent channels. That
+ * avoids locking on wide/noise that looks strong on many channels.
+ * During Candidate we re-sample left/right adjacent channels so the
+ * margin is based on current measurements, not stale scan data.
+ * - Lock requires: enough hits, confidence β₯ LOCK_CONFIDENCE_MIN, and
+ * center stronger than neighbors (strong lock path has same requirement).
+ *
+ * 4. Lock hold: Once locked, we only unlock when power stays below
+ * unlock_threshold_db() long enough to drain lock_hold_ (down from
+ * LOCK_HOLD_MAX), so brief fades don't drop lock.
+ */
+
+#include "ui_fpv_detect.hpp"
+
+#include
+
+#include "baseband_api.hpp"
+#include "oversample.hpp"
+
+using namespace portapack;
+
+namespace ui::external_app::fpv_detect {
+
+static constexpr uint8_t SCAN_DWELL_FRAMES = 1;
+static constexpr uint8_t VERIFY_SAMPLE_TARGET = 5;
+static constexpr uint8_t VERIFY_MIN_HITS = 4;
+static constexpr uint8_t STRONG_LOCK_PEAK_MARGIN_DB = 8;
+static constexpr uint8_t LOCK_HOLD_MAX = 12;
+static constexpr uint8_t LOCK_CONFIDENCE_MIN = 72;
+/** Center channel must be at least this many dB above adjacent channels to lock.
+ * Ensures we lock on a real FPV carrier (narrow peak) rather than wide/noise. */
+static constexpr int32_t MIN_NEIGHBOR_MARGIN_FOR_LOCK_DB = 3;
+static constexpr uint8_t BEEP_GUARD_FRAMES = 8;
+
+int32_t FpvDetectView::clamp_value(int32_t value, int32_t low, int32_t high) {
+ if (value < low) return low;
+ if (value > high) return high;
+ return value;
+}
+
+int32_t FpvDetectView::map_value(int32_t value, int32_t from_low, int32_t from_high, int32_t to_low, int32_t to_high) {
+ const auto clamped = clamp_value(value, from_low, from_high);
+ return to_low + (clamped - from_low) * (to_high - to_low) / (from_high - from_low);
+}
+
+void FpvDetectView::focus() {
+ field_lna.focus();
+}
+
+FpvDetectView::~FpvDetectView() {
+ shared_memory.request_m4_performance_counter = 0;
+ receiver_model.disable();
+ audio::output::stop();
+ baseband::shutdown();
+}
+
+uint8_t FpvDetectView::channel_index(uint8_t band, uint8_t ch) const {
+ return static_cast(band * FPV_CHANNELS_PER_BAND + ch);
+}
+
+uint8_t FpvDetectView::current_channel_index() const {
+ return channel_index(scan_band, scan_ch);
+}
+
+int16_t FpvDetectView::detect_threshold_db() const {
+ return static_cast(detect_threshold_db_);
+}
+
+int16_t FpvDetectView::unlock_threshold_db() const {
+ return static_cast(detect_threshold_db() - 6);
+}
+
+int16_t FpvDetectView::candidate_average_db() const {
+ if (!verify_samples_) {
+ return -120;
+ }
+ return static_cast(verify_sum_db_ / verify_samples_);
+}
+
+int16_t FpvDetectView::neighbor_best_db() const {
+ int16_t best = -120;
+
+ if (candidate_ch_ > 0) {
+ const auto& left = channel_memory_[channel_index(candidate_band_, static_cast(candidate_ch_ - 1))];
+ if (left.last_db > best) {
+ best = left.last_db;
+ }
+ }
+
+ if ((candidate_ch_ + 1) < FPV_CHANNELS_PER_BAND) {
+ const auto& right = channel_memory_[channel_index(candidate_band_, static_cast(candidate_ch_ + 1))];
+ if (right.last_db > best) {
+ best = right.last_db;
+ }
+ }
+
+ return best;
+}
+
+void FpvDetectView::request_event_beep(uint32_t hz, uint32_t duration_ms) {
+ if ((frame_counter_ - last_beep_frame_) < BEEP_GUARD_FRAMES) {
+ return;
+ }
+ last_beep_frame_ = frame_counter_;
+ baseband::request_audio_beep(hz, 24000, duration_ms);
+}
+
+void FpvDetectView::retune_to(uint8_t band, uint8_t ch) {
+ scan_band = band;
+ scan_ch = ch;
+ freq_ = fpv_frequencies[band][ch];
+ receiver_model.set_target_frequency(freq_);
+ update_freq_display();
+}
+
+void FpvDetectView::step_scan() {
+ if (band_mode < FPV_NUM_BANDS) {
+ scan_band = band_mode;
+ scan_ch = static_cast((scan_ch + 1) % FPV_CHANNELS_PER_BAND);
+ } else {
+ scan_ch = static_cast(scan_ch + 1);
+ if (scan_ch >= FPV_CHANNELS_PER_BAND) {
+ scan_ch = 0;
+ scan_band = static_cast(scan_band + 1);
+ if (scan_band >= FPV_NUM_BANDS) {
+ scan_band = 0;
+ }
+ }
+ }
+
+ retune_to(scan_band, scan_ch);
+ scan_dwell_frames_ = SCAN_DWELL_FRAMES;
+}
+
+void FpvDetectView::reset_detector(bool retune_current) {
+ detect_state_ = DetectState::Scanning;
+ candidate_band_ = scan_band;
+ candidate_ch_ = scan_ch;
+ verify_samples_ = 0;
+ verify_hits_ = 0;
+ verify_misses_ = 0;
+ verify_sum_db_ = 0;
+ verify_peak_db_ = -120;
+ candidate_confidence_ = 0;
+ lock_hold_ = 0;
+ neighbor_phase_ = 0;
+ neighbors_fresh_ = false;
+ scan_dwell_frames_ = 0;
+
+ update_state_badge();
+ update_confidence_text();
+ update_status_text();
+ update_detail_text();
+ update_visual_state();
+
+ if (retune_current) {
+ retune_to(scan_band, scan_ch);
+ scan_dwell_frames_ = SCAN_DWELL_FRAMES;
+ }
+}
+
+bool FpvDetectView::is_possible_analog_carrier(const ChannelStatistics& statistics) const {
+ return statistics.max_db >= detect_threshold_db();
+}
+
+uint8_t FpvDetectView::compute_candidate_confidence() const {
+ if (!verify_samples_) {
+ return 0;
+ }
+
+ const int32_t avg_db = candidate_average_db();
+ const int32_t center_margin = avg_db - detect_threshold_db();
+ const int32_t peak_margin = verify_peak_db_ - detect_threshold_db();
+ const int32_t neighbor_margin = avg_db - neighbor_best_db();
+
+ int32_t score = 18;
+ score += center_margin * 7;
+ score += peak_margin * 4;
+ score += static_cast(verify_hits_) * 5;
+ score -= static_cast(verify_misses_) * 10;
+
+ if (neighbor_margin >= 6) {
+ score += 12;
+ } else if (neighbor_margin >= 3) {
+ score += 6;
+ } else if (neighbor_margin <= 0) {
+ score -= 8;
+ }
+
+ const auto& mem = channel_memory_[channel_index(candidate_band_, candidate_ch_)];
+ if (mem.hits >= 2) {
+ score += 4;
+ }
+
+ return static_cast(clamp_value(score, 0, 99));
+}
+
+void FpvDetectView::enter_candidate(const ChannelStatistics& statistics) {
+ detect_state_ = DetectState::Candidate;
+ candidate_band_ = scan_band;
+ candidate_ch_ = scan_ch;
+ verify_samples_ = 1;
+ verify_hits_ = 1;
+ verify_misses_ = 0;
+ verify_sum_db_ = statistics.max_db;
+ verify_peak_db_ = statistics.max_db;
+ candidate_confidence_ = compute_candidate_confidence();
+ neighbor_phase_ = 0;
+ neighbors_fresh_ = false;
+
+ retune_to(candidate_band_, candidate_ch_);
+
+ update_state_badge();
+ update_confidence_text();
+ update_status_text();
+ update_detail_text();
+ update_visual_state();
+ request_event_beep(1150, 60);
+}
+
+void FpvDetectView::evaluate_candidate_sample(const ChannelStatistics& statistics) {
+ if (detect_state_ != DetectState::Candidate) {
+ return;
+ }
+
+ ++verify_samples_;
+ verify_sum_db_ += statistics.max_db;
+ if (statistics.max_db > verify_peak_db_) {
+ verify_peak_db_ = statistics.max_db;
+ }
+
+ if (statistics.max_db >= detect_threshold_db()) {
+ ++verify_hits_;
+ } else {
+ ++verify_misses_;
+ }
+
+ candidate_confidence_ = compute_candidate_confidence();
+ update_confidence_text();
+ update_detail_text();
+
+ /* Refresh adjacent channel power so neighbor_best_db() uses current
+ * measurements instead of stale scan data. Do this before any lock decision. */
+ if (verify_samples_ >= 2 && !neighbors_fresh_) {
+ if (candidate_ch_ > 0) {
+ neighbor_phase_ = 1;
+ retune_to(candidate_band_, static_cast(candidate_ch_ - 1));
+ } else {
+ neighbor_phase_ = 2;
+ retune_to(candidate_band_, static_cast(candidate_ch_ + 1));
+ }
+ return;
+ }
+
+ /* Lock decisions use neighbor margin; only run when we have fresh neighbor data. */
+ if (!neighbors_fresh_) {
+ return;
+ }
+
+ const bool strong_lock =
+ verify_samples_ >= 3 &&
+ verify_hits_ >= 3 &&
+ verify_peak_db_ >= (detect_threshold_db() + STRONG_LOCK_PEAK_MARGIN_DB) &&
+ candidate_confidence_ >= 80 &&
+ (candidate_average_db() - neighbor_best_db()) >= MIN_NEIGHBOR_MARGIN_FOR_LOCK_DB;
+
+ if (strong_lock) {
+ enter_lock();
+ return;
+ }
+
+ if (verify_samples_ < VERIFY_SAMPLE_TARGET) {
+ return;
+ }
+
+ const bool center_stronger_than_neighbors =
+ (candidate_average_db() - neighbor_best_db()) >= MIN_NEIGHBOR_MARGIN_FOR_LOCK_DB;
+ if (verify_hits_ >= VERIFY_MIN_HITS && candidate_confidence_ >= LOCK_CONFIDENCE_MIN &&
+ center_stronger_than_neighbors) {
+ enter_lock();
+ return;
+ }
+
+ detect_state_ = DetectState::Scanning;
+ candidate_confidence_ = static_cast(candidate_confidence_ / 2);
+ verify_samples_ = 0;
+ verify_hits_ = 0;
+ verify_misses_ = 0;
+ verify_sum_db_ = 0;
+ verify_peak_db_ = -120;
+ scan_dwell_frames_ = 0;
+
+ update_state_badge();
+ update_confidence_text();
+ update_status_text();
+ update_detail_text();
+ update_visual_state();
+}
+
+void FpvDetectView::enter_lock() {
+ detect_state_ = DetectState::Locked;
+ lock_hold_ = LOCK_HOLD_MAX;
+ candidate_confidence_ = static_cast(clamp_value(candidate_confidence_, LOCK_CONFIDENCE_MIN, 99));
+
+ retune_to(candidate_band_, candidate_ch_);
+
+ update_state_badge();
+ update_confidence_text();
+ update_status_text();
+ update_detail_text();
+ update_visual_state();
+
+ request_event_beep(1850, 180);
+}
+
+void FpvDetectView::update_lock(const ChannelStatistics& statistics) {
+ if (detect_state_ != DetectState::Locked) {
+ return;
+ }
+
+ if (statistics.max_db >= unlock_threshold_db()) {
+ if (lock_hold_ < LOCK_HOLD_MAX) {
+ ++lock_hold_;
+ }
+ if (candidate_confidence_ < 99) {
+ ++candidate_confidence_;
+ }
+ } else {
+ if (lock_hold_ > 0) {
+ --lock_hold_;
+ }
+ if (candidate_confidence_ > 0) {
+ --candidate_confidence_;
+ }
+ }
+
+ update_confidence_text();
+ update_detail_text();
+
+ if (lock_hold_ > 0) {
+ return;
+ }
+
+ detect_state_ = DetectState::Scanning;
+ candidate_confidence_ = 0;
+ verify_samples_ = 0;
+ verify_hits_ = 0;
+ verify_misses_ = 0;
+ verify_sum_db_ = 0;
+ verify_peak_db_ = -120;
+ scan_dwell_frames_ = 0;
+
+ update_state_badge();
+ update_confidence_text();
+ update_status_text();
+ update_detail_text();
+ update_visual_state();
+
+ request_event_beep(650, 90);
+}
+
+void FpvDetectView::update_freq_display() {
+ char buf[28];
+ std::snprintf(buf, sizeof(buf), "%c%d %ld MHz",
+ band_labels[scan_band],
+ static_cast(scan_ch + 1),
+ static_cast(freq_ / 1000000LL));
+ text_freq.set(buf);
+}
+
+void FpvDetectView::update_state_badge() {
+ switch (detect_state_) {
+ case DetectState::Scanning:
+ text_state.set("SCANNING");
+ break;
+ case DetectState::Candidate:
+ text_state.set("VERIFY FPV");
+ break;
+ case DetectState::Locked:
+ text_state.set("DRONE FOUND");
+ break;
+ }
+}
+
+void FpvDetectView::update_confidence_text() {
+ char buf[16];
+ std::snprintf(buf, sizeof(buf), "Conf %u%%", static_cast(candidate_confidence_));
+ text_confidence.set(buf);
+}
+
+void FpvDetectView::update_status_text() {
+ char buf[40];
+
+ switch (detect_state_) {
+ case DetectState::Scanning:
+ if (band_mode < FPV_NUM_BANDS) {
+ std::snprintf(buf, sizeof(buf), "Scanning band %c for analog FPV", band_labels[band_mode]);
+ } else {
+ std::snprintf(buf, sizeof(buf), "Scanning all FPV bands");
+ }
+ break;
+
+ case DetectState::Candidate:
+ std::snprintf(buf, sizeof(buf), "VERIFYING %c%d %ld MHz",
+ band_labels[candidate_band_],
+ static_cast(candidate_ch_ + 1),
+ static_cast(fpv_frequencies[candidate_band_][candidate_ch_] / 1000000LL));
+ break;
+
+ case DetectState::Locked:
+ std::snprintf(buf, sizeof(buf), "!!! DRONE FOUND !!! %c%d %ld",
+ band_labels[candidate_band_],
+ static_cast(candidate_ch_ + 1),
+ static_cast(fpv_frequencies[candidate_band_][candidate_ch_] / 1000000LL));
+ break;
+ }
+
+ text_status.set(buf);
+}
+
+void FpvDetectView::update_detail_text() {
+ char buf[40];
+
+ switch (detect_state_) {
+ case DetectState::Scanning:
+ std::snprintf(buf, sizeof(buf), "Need >= %d dB, stable dwell", static_cast(detect_threshold_db()));
+ break;
+
+ case DetectState::Candidate:
+ std::snprintf(buf, sizeof(buf), "avg %d peak %d hits %u/%u",
+ static_cast(candidate_average_db()),
+ static_cast(verify_peak_db_),
+ static_cast(verify_hits_),
+ static_cast(verify_samples_));
+ break;
+
+ case DetectState::Locked:
+ std::snprintf(buf, sizeof(buf), "LOCKED %c%d conf %u%% hold %u",
+ band_labels[candidate_band_],
+ static_cast(candidate_ch_ + 1),
+ static_cast(candidate_confidence_),
+ static_cast(lock_hold_));
+ break;
+ }
+
+ text_detail.set(buf);
+}
+
+void FpvDetectView::update_visual_state() {
+ auto* theme = Theme::getInstance();
+
+ /*
+ * Conservative styling for cross-branch compatibility.
+ * If your branch has explicit blue/red banner theme styles,
+ * swap the styles in this function only.
+ */
+ auto normal_style = theme->bg_darkest;
+ auto attention_style = theme->fg_red;
+
+ switch (detect_state_) {
+ case DetectState::Scanning:
+ text_state.set_style(normal_style);
+ text_status.set_style(normal_style);
+ break;
+ case DetectState::Candidate:
+ text_state.set_style(normal_style);
+ text_status.set_style(normal_style);
+ break;
+ case DetectState::Locked:
+ text_state.set_style(attention_style);
+ text_status.set_style(attention_style);
+ break;
+ }
+
+ freq_stats_rssi.set_style(normal_style);
+ freq_stats_db.set_style(normal_style);
+ text_detail.set_style(normal_style);
+ text_confidence.set_style(normal_style);
+}
+
+void FpvDetectView::update_stats_text(const ChannelStatistics& statistics) {
+ rssi_graph.add_values(rssi.get_min(), rssi.get_avg(), rssi.get_max(), statistics.max_db);
+
+ auto& mem = channel_memory_[current_channel_index()];
+ mem.last_db = statistics.max_db;
+ if (statistics.max_db > mem.peak_db) {
+ mem.peak_db = statistics.max_db;
+ }
+ if (statistics.max_db >= detect_threshold_db()) {
+ if (mem.hits < 255) {
+ ++mem.hits;
+ }
+ }
+ mem.confidence = candidate_confidence_;
+
+ if (last_max_db_ != statistics.max_db) {
+ last_max_db_ = statistics.max_db;
+ char power_buf[20];
+ std::snprintf(power_buf, sizeof(power_buf), "PWR %d dB", static_cast(statistics.max_db));
+ freq_stats_db.set(power_buf);
+ rssi.set_db(statistics.max_db);
+ }
+
+ const uint8_t graph_min = rssi_graph.get_graph_min();
+ const uint8_t graph_avg = rssi_graph.get_graph_avg();
+ const uint8_t graph_max = rssi_graph.get_graph_max();
+
+ if (last_min_rssi_ != graph_min || last_avg_rssi_ != graph_avg || last_max_rssi_ != graph_max) {
+ last_min_rssi_ = graph_min;
+ last_avg_rssi_ = graph_avg;
+ last_max_rssi_ = graph_max;
+
+ char rssi_buf[22];
+ std::snprintf(rssi_buf, sizeof(rssi_buf), "RSSI %u/%u/%u",
+ static_cast(graph_min),
+ static_cast(graph_avg),
+ static_cast(graph_max));
+ freq_stats_rssi.set(rssi_buf);
+ }
+}
+
+void FpvDetectView::on_timer() {
+ ++frame_counter_;
+
+ if (detect_state_ != DetectState::Scanning) {
+ return;
+ }
+
+ if (scan_dwell_frames_ > 0) {
+ --scan_dwell_frames_;
+ return;
+ }
+
+ step_scan();
+}
+
+FpvDetectView::FpvDetectView(NavigationView& nav)
+ : nav_{nav} {
+ add_children({
+ &labels,
+ &field_lna,
+ &field_vga,
+ &field_rf_amp,
+ &field_volume,
+ &field_band,
+ &text_freq,
+ &text_state,
+ &text_confidence,
+ &text_detect_label,
+ &field_detect_threshold,
+ &freq_stats_rssi,
+ &freq_stats_db,
+ &text_status,
+ &text_detail,
+ &rssi,
+ &rssi_graph,
+ });
+
+ rssi.set_vertical_rssi(true);
+ rssi.set_peak(true, 3000);
+ rssi_graph.set_nb_columns(256);
+
+ field_detect_threshold.set_value(detect_threshold_db_);
+ field_detect_threshold.on_change = [this](int32_t v) {
+ detect_threshold_db_ = v;
+ update_detail_text();
+ };
+
+ field_band.on_change = [this](size_t, int32_t value) {
+ band_mode = static_cast(value);
+ scan_band = (band_mode < FPV_NUM_BANDS) ? band_mode : 0;
+ scan_ch = 0;
+ reset_detector(true);
+ };
+
+ change_mode();
+ field_band.set_selected_index(FPV_AUTO_SCAN_MODE);
+ reset_detector(true);
+}
+
+void FpvDetectView::on_statistics_update(const ChannelStatistics& statistics) {
+ const bool is_sampling_neighbor =
+ (detect_state_ == DetectState::Candidate && (neighbor_phase_ == 1 || neighbor_phase_ == 2));
+ if (!is_sampling_neighbor) {
+ update_stats_text(statistics);
+ }
+
+ switch (detect_state_) {
+ case DetectState::Scanning:
+ if (is_possible_analog_carrier(statistics)) {
+ enter_candidate(statistics);
+ }
+ break;
+
+ case DetectState::Candidate:
+ if (neighbor_phase_ == 1) {
+ const auto idx = channel_index(candidate_band_, static_cast(candidate_ch_ - 1));
+ channel_memory_[idx].last_db = statistics.max_db;
+ if (candidate_ch_ + 1 < FPV_CHANNELS_PER_BAND) {
+ neighbor_phase_ = 2;
+ retune_to(candidate_band_, static_cast(candidate_ch_ + 1));
+ } else {
+ neighbor_phase_ = 0;
+ neighbors_fresh_ = true;
+ retune_to(candidate_band_, candidate_ch_);
+ }
+ break;
+ }
+ if (neighbor_phase_ == 2) {
+ const auto idx = channel_index(candidate_band_, static_cast(candidate_ch_ + 1));
+ channel_memory_[idx].last_db = statistics.max_db;
+ neighbor_phase_ = 0;
+ neighbors_fresh_ = true;
+ retune_to(candidate_band_, candidate_ch_);
+ break;
+ }
+ evaluate_candidate_sample(statistics);
+ break;
+
+ case DetectState::Locked:
+ update_lock(statistics);
+ break;
+ }
+}
+
+size_t FpvDetectView::change_mode() {
+ audio::output::stop();
+ receiver_model.disable();
+ baseband::shutdown();
+
+ audio_sampling_rate = audio::Rate::Hz_24000;
+ baseband::run_image(portapack::spi_flash::image_tag_capture);
+ receiver_model.set_modulation(ReceiverModel::Mode::Capture);
+
+ baseband::set_sample_rate(FPV_RX_BW, get_oversample_rate(FPV_RX_BW));
+ auto actual_sampling_rate = get_actual_sample_rate(FPV_RX_BW);
+ receiver_model.set_sampling_rate(actual_sampling_rate);
+ receiver_model.set_baseband_bandwidth(filter_bandwidth_for_sampling_rate(actual_sampling_rate));
+
+ audio::set_rate(audio_sampling_rate);
+ audio::output::start();
+ receiver_model.set_headphone_volume(receiver_model.headphone_volume());
+ receiver_model.enable();
+
+ return 0;
+}
+
+} // namespace ui::external_app::fpv_detect
diff --git a/firmware/application/external/fpv_detect/ui_fpv_detect.hpp b/firmware/application/external/fpv_detect/ui_fpv_detect.hpp
new file mode 100644
index 000000000..a0e950273
--- /dev/null
+++ b/firmware/application/external/fpv_detect/ui_fpv_detect.hpp
@@ -0,0 +1,200 @@
+// Author: berkeozkir (Berke ΓzkΔ±r)
+
+#ifndef _UI_FPV_DETECT
+#define _UI_FPV_DETECT
+
+#include
+#include
+
+#include "analog_audio_app.hpp"
+#include "app_settings.hpp"
+#include "audio.hpp"
+#include "baseband_api.hpp"
+#include "radio_state.hpp"
+#include "receiver_model.hpp"
+#include "string_format.hpp"
+#include "ui.hpp"
+#include "ui_receiver.hpp"
+#include "ui_spectrum.hpp"
+
+namespace ui::external_app::fpv_detect {
+
+static constexpr uint8_t FPV_NUM_BANDS = 5;
+static constexpr uint8_t FPV_CHANNELS_PER_BAND = 8;
+static constexpr uint8_t FPV_TOTAL_CHANNELS = FPV_NUM_BANDS * FPV_CHANNELS_PER_BAND;
+static constexpr uint8_t FPV_AUTO_SCAN_MODE = FPV_NUM_BANDS;
+
+static constexpr int64_t fpv_frequencies[FPV_NUM_BANDS][FPV_CHANNELS_PER_BAND] = {
+ /* Band A */ {5865000000LL, 5845000000LL, 5825000000LL, 5805000000LL, 5785000000LL, 5765000000LL, 5745000000LL, 5725000000LL},
+ /* Band B */ {5733000000LL, 5752000000LL, 5771000000LL, 5790000000LL, 5809000000LL, 5828000000LL, 5847000000LL, 5866000000LL},
+ /* Band E */ {5705000000LL, 5685000000LL, 5665000000LL, 5645000000LL, 5885000000LL, 5905000000LL, 5925000000LL, 5945000000LL},
+ /* Band F */ {5740000000LL, 5760000000LL, 5780000000LL, 5800000000LL, 5820000000LL, 5840000000LL, 5860000000LL, 5880000000LL},
+ /* Band R */ {5658000000LL, 5695000000LL, 5732000000LL, 5769000000LL, 5806000000LL, 5843000000LL, 5880000000LL, 5917000000LL},
+};
+
+static constexpr char band_labels[] = {'A', 'B', 'E', 'F', 'R'};
+
+#define FPV_RX_BW 750000
+
+class FpvDetectView : public View {
+ public:
+ explicit FpvDetectView(NavigationView& nav);
+ ~FpvDetectView();
+
+ void focus() override;
+ std::string title() const override { return "FPV DETECT"; };
+
+ private:
+ enum class DetectState : uint8_t {
+ Scanning,
+ Candidate,
+ Locked,
+ };
+
+ struct ChannelMemory {
+ int16_t last_db = -120;
+ int16_t peak_db = -120;
+ uint8_t hits = 0;
+ uint8_t confidence = 0;
+ };
+
+ NavigationView& nav_;
+ RxRadioState radio_state_{};
+
+ static int32_t clamp_value(int32_t value, int32_t low, int32_t high);
+ static int32_t map_value(int32_t value, int32_t from_low, int32_t from_high, int32_t to_low, int32_t to_high);
+
+ size_t change_mode();
+ void on_statistics_update(const ChannelStatistics& statistics);
+ void on_timer();
+
+ void retune_to(uint8_t band, uint8_t ch);
+ void step_scan();
+ void reset_detector(bool retune_current);
+
+ bool is_possible_analog_carrier(const ChannelStatistics& statistics) const;
+ void enter_candidate(const ChannelStatistics& statistics);
+ void evaluate_candidate_sample(const ChannelStatistics& statistics);
+ void enter_lock();
+ void update_lock(const ChannelStatistics& statistics);
+
+ uint8_t current_channel_index() const;
+ uint8_t channel_index(uint8_t band, uint8_t ch) const;
+ uint8_t compute_candidate_confidence() const;
+ int16_t detect_threshold_db() const;
+ int16_t unlock_threshold_db() const;
+ int16_t candidate_average_db() const;
+ int16_t neighbor_best_db() const;
+
+ void request_event_beep(uint32_t hz, uint32_t duration_ms);
+ void update_freq_display();
+ void update_state_badge();
+ void update_confidence_text();
+ void update_status_text();
+ void update_detail_text();
+ void update_stats_text(const ChannelStatistics& statistics);
+ void update_visual_state();
+
+ DetectState detect_state_ = DetectState::Scanning;
+
+ uint8_t scan_band = 0;
+ uint8_t scan_ch = 0;
+ uint8_t band_mode = FPV_AUTO_SCAN_MODE;
+
+ uint8_t candidate_band_ = 0;
+ uint8_t candidate_ch_ = 0;
+ uint8_t verify_samples_ = 0;
+ uint8_t verify_hits_ = 0;
+ uint8_t verify_misses_ = 0;
+ int32_t verify_sum_db_ = 0;
+ int16_t verify_peak_db_ = -120;
+ uint8_t candidate_confidence_ = 0;
+ uint8_t lock_hold_ = 0;
+ /** During Candidate: 0=center, 1=left neighbor, 2=right neighbor. Used to
+ * re-sample adjacent channels so neighbor_margin uses current measurements. */
+ uint8_t neighbor_phase_ = 0;
+ bool neighbors_fresh_ = false;
+
+ uint8_t scan_dwell_frames_ = 0;
+ uint32_t frame_counter_ = 0;
+ uint32_t last_beep_frame_ = 0;
+
+ int16_t last_max_db_ = -127;
+ uint8_t last_min_rssi_ = 0;
+ uint8_t last_avg_rssi_ = 0;
+ uint8_t last_max_rssi_ = 0;
+
+ std::array channel_memory_{};
+
+ rf::Frequency freq_ = {fpv_frequencies[0][0]};
+ int32_t detect_threshold_db_ = -38;
+ audio::Rate audio_sampling_rate = audio::Rate::Hz_48000;
+
+ app_settings::SettingsManager settings_{
+ "rx_fpv",
+ app_settings::Mode::RX,
+ {
+ {"detect_threshold"sv, &detect_threshold_db_},
+ }};
+
+ Labels labels{
+ {{UI_POS_X(0), UI_POS_Y(0)}, "LNA: VGA: AMP: ", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X_RIGHT(6), UI_POS_Y(0)}, "VOL: ", Theme::getInstance()->fg_light->foreground},
+ };
+
+ LNAGainField field_lna{{UI_POS_X(4), UI_POS_Y(0)}};
+ VGAGainField field_vga{{UI_POS_X(11), UI_POS_Y(0)}};
+ RFAmpField field_rf_amp{{UI_POS_X(18), UI_POS_Y(0)}};
+ AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_Y(0)}};
+
+ OptionsField field_band{
+ {UI_POS_X(0), UI_POS_Y(1)},
+ 9,
+ {
+ {"Band A ", 0},
+ {"Band B ", 1},
+ {"Band E ", 2},
+ {"Band F ", 3},
+ {"Band R ", 4},
+ {"AutoScan", FPV_AUTO_SCAN_MODE},
+ }};
+
+ Text text_freq{{UI_POS_X_RIGHT(20), UI_POS_Y(1), UI_POS_WIDTH(20), UI_POS_DEFAULT_HEIGHT}, ""};
+
+ Text text_state{{UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(12), UI_POS_DEFAULT_HEIGHT}, "SCANNING"};
+ Text text_confidence{{UI_POS_X(12), UI_POS_Y(2), UI_POS_WIDTH(8), UI_POS_DEFAULT_HEIGHT}, "Conf 0%"};
+ Text text_detect_label{{UI_POS_X_RIGHT(10), UI_POS_Y(2), UI_POS_WIDTH(5), UI_POS_DEFAULT_HEIGHT}, "Thr>"};
+
+ NumberField field_detect_threshold{
+ {UI_POS_X_RIGHT(5), UI_POS_Y(2)},
+ 4,
+ {-100, 20},
+ 1,
+ ' ',
+ };
+
+ Text freq_stats_rssi{{UI_POS_X(0), UI_POS_Y(3), UI_POS_WIDTH(15), UI_POS_DEFAULT_HEIGHT}, "RSSI 0/0/0"};
+ Text freq_stats_db{{UI_POS_X_RIGHT(14), UI_POS_Y(3), UI_POS_WIDTH(14), UI_POS_DEFAULT_HEIGHT}, "PWR -120 dB"};
+
+ Text text_status{{UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH(30), UI_POS_DEFAULT_HEIGHT}, "SCANNING FOR ANALOG FPV"};
+ Text text_detail{{UI_POS_X(0), UI_POS_Y(5), UI_POS_WIDTH(30), UI_POS_DEFAULT_HEIGHT}, "Waiting for FPV-like carrier"};
+
+ RSSIGraph rssi_graph{{UI_POS_X(0), UI_POS_Y(6), UI_POS_WIDTH_REMAINING(5), UI_POS_HEIGHT_REMAINING(7)}};
+ RSSI rssi{{UI_POS_X_RIGHT(5), UI_POS_Y(6), UI_POS_WIDTH(5), UI_POS_HEIGHT_REMAINING(7)}};
+
+ MessageHandlerRegistration message_handler_stats{
+ Message::ID::ChannelStatistics,
+ [this](const Message* const p) {
+ this->on_statistics_update(static_cast(p)->statistics);
+ }};
+
+ MessageHandlerRegistration message_handler_frame_sync{
+ Message::ID::DisplayFrameSync,
+ [this](const Message* const) {
+ this->on_timer();
+ }};
+};
+
+} // namespace ui::external_app::fpv_detect
+
+#endif
diff --git a/firmware/application/external/game2048/ui_game2048.cpp b/firmware/application/external/game2048/ui_game2048.cpp
index 694d951da..4e68a5b1b 100644
--- a/firmware/application/external/game2048/ui_game2048.cpp
+++ b/firmware/application/external/game2048/ui_game2048.cpp
@@ -33,7 +33,7 @@ Game2048View::Game2048View(NavigationView& nav)
void Game2048View::on_show() {
if (!initialized) {
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
reset_game();
initialized = true;
}
diff --git a/firmware/application/external/keeloqtx/main.cpp b/firmware/application/external/keeloqtx/main.cpp
new file mode 100644
index 000000000..a7252e095
--- /dev/null
+++ b/firmware/application/external/keeloqtx/main.cpp
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2026 lifegame1lu111
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_navigation.hpp"
+#include "ui_keeloqtx.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::ui_keeloqtx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::ui_keeloqtx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_keeloqtx.application_information"), used)) application_information_t _application_information_keeloqtx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::ui_keeloqtx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+ /*.app_name = */ "KeeLoq TX",
+ /*.bitmap_data = */ {
+ 0x20,
+ 0x00,
+ 0x20,
+ 0x00,
+ 0x20,
+ 0x00,
+ 0x20,
+ 0x00,
+ 0xE0,
+ 0x07,
+ 0xF0,
+ 0x0F,
+ 0x30,
+ 0x0C,
+ 0x30,
+ 0x0C,
+ 0xF0,
+ 0x0F,
+ 0xF0,
+ 0x0F,
+ 0x70,
+ 0x0D,
+ 0xB0,
+ 0x0E,
+ 0x70,
+ 0x0D,
+ 0xB0,
+ 0x0E,
+ 0xF0,
+ 0x0F,
+ 0xE0,
+ 0x07,
+ },
+ /*.icon_color = */ ui::Color::yellow().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+ /*.m4_app_tag = portapack::spi_flash::image_tag_ook */ {'P', 'O', 'O', 'K'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
diff --git a/firmware/application/external/keeloqtx/ui_keeloqtx.cpp b/firmware/application/external/keeloqtx/ui_keeloqtx.cpp
new file mode 100644
index 000000000..eb753f15c
--- /dev/null
+++ b/firmware/application/external/keeloqtx/ui_keeloqtx.cpp
@@ -0,0 +1,248 @@
+#include "ui_keeloqtx.hpp"
+#include "ui_fileman.hpp"
+#include "baseband_api.hpp"
+#include "../../keeloq_common.hpp"
+#include "../../baseband/fprotos/fprotogeneral.hpp"
+
+using namespace portapack;
+using namespace ui;
+
+namespace fs = std::filesystem;
+
+namespace ui::external_app::ui_keeloqtx {
+
+void KeeloqTXView::focus() {
+ button_open.focus();
+}
+
+void KeeloqTXView::update_hop() {
+ hop = data.btn << 28 | (data.serial & 0x3FF) << 16 | data.counter;
+
+ if (data.mf_name == "Aprimatic") {
+ uint32_t apri_serial = data.serial;
+ uint8_t apr1 = 0;
+
+ for (uint16_t i = 1; i != 0b10000000000; i <<= 1) {
+ if (apri_serial & i) apr1++;
+ }
+
+ apri_serial &= 0b00001111111111;
+
+ if (apr1 % 2 == 0) {
+ apri_serial |= 0b110000000000;
+ }
+
+ hop = data.btn << 28 | (apri_serial & 0xFFF) << 16 | data.counter;
+ } else if (
+ data.mf_name == "DTM_Neo" || data.mf_name == "FAAC_RC,XT" || data.mf_name == "Mutanco_Mutancode" || data.mf_name == "Came_Space" || data.mf_name == "Genius_Bravo" || data.mf_name == "GSN" || data.mf_name == "Rosh" || data.mf_name == "Rossi" || data.mf_name == "Peccinin" || data.mf_name == "Steelmate" || data.mf_name == "Cardin_S449") {
+ hop = data.btn << 28 | (data.serial & 0xFFF) << 16 | data.counter;
+ } else if (
+ data.mf_name == "NICE_Smilo" || data.mf_name == "NICE_MHOUSE" || data.mf_name == "JCM_Tech") {
+ hop = data.btn << 28 | (data.serial & 0xFF) << 16 | data.counter;
+ } else if (data.mf_name == "Merlin") {
+ hop = data.btn << 28 | (0x000) << 16 | data.counter;
+ } else if (data.mf_name == "Centurion") {
+ hop = data.btn << 28 | (0x1CE) << 16 | data.counter;
+ } else if (data.mf_name == "Monarch") {
+ hop = data.btn << 28 | (0x100) << 16 | data.counter;
+ } else if (data.mf_name == "Dea_Mio") {
+ uint8_t first_disc_num = (data.serial >> 8) & 0xF;
+ uint8_t result_disc = (0xC + (first_disc_num % 4));
+
+ uint32_t dea_serial = (data.serial & 0xFF) | (((uint32_t)result_disc) << 8);
+
+ hop = data.btn << 28 | (dea_serial & 0xFFF) << 16 | data.counter;
+ }
+
+ text_hop.set(to_string_hex(hop));
+}
+
+void KeeloqTXView::update_payload() {
+ uint64_t encrypt = 0;
+
+ switch (current_key.type) {
+ case KEELOQ_SIMPLE_LEARNING: {
+ encrypt = keeloq_encrypt(hop, current_key.key);
+
+ break;
+ }
+ case KEELOQ_NORMAL_LEARNING: {
+ uint64_t man = keeloq_normal_learning(fix, current_key.key);
+
+ encrypt = keeloq_encrypt(hop, man);
+
+ break;
+ }
+ }
+
+ payload = (uint64_t)fix << 32 | encrypt;
+
+ uint64_t preview_payload = FProtoGeneral::subghz_protocol_blocks_reverse_key(payload, 64);
+
+ text_payload.set(to_string_hex(preview_payload));
+ encode_data();
+}
+
+void KeeloqTXView::encode_data() {
+ std::string fragments{};
+
+ for (uint32_t i = 0; i < 64; ++i) {
+ fragments += keeloq_fragments[bit(payload, i)];
+ }
+
+ encoded_data = KEELOQ_HEADER + fragments + "1001";
+
+ if (data.mf_name == "Sommer") {
+ pause_duration = 28;
+ } else {
+ pause_duration = 39;
+ }
+}
+
+KeeloqTXView::KeeloqTXView(NavigationView& nav)
+ : nav_{nav} {
+ baseband::run_image(portapack::spi_flash::image_tag_ook);
+
+ add_children({&labels,
+ &text_mf_name,
+ &text_serial,
+ &text_fix,
+ &text_hop,
+ &field_counter,
+ &field_button,
+ &field_repeat,
+ &text_payload,
+ &button_open,
+ &text_status,
+ &progressbar,
+ &tx_view});
+
+ field_counter.on_change = [this](uint32_t value) {
+ data.counter = (uint16_t)value;
+
+ update_hop();
+ update_payload();
+ };
+
+ field_button.on_change = [this](uint32_t value) {
+ data.btn = (uint8_t)value;
+
+ fix &= 0xFFFFFFF;
+ fix |= data.btn << 28;
+
+ text_fix.set(to_string_hex(fix));
+
+ update_hop();
+ update_payload();
+ };
+
+ field_repeat.on_change = [this](uint32_t value) {
+ repeat = value;
+ };
+
+ field_repeat.set_value(4, true);
+
+ tx_view.on_edit_frequency = [this, &nav]() {
+ auto new_view = nav.push(transmitter_model.target_frequency());
+ new_view->on_changed = [this](rf::Frequency f) {
+ transmitter_model.set_target_frequency(f);
+ };
+ };
+
+ tx_view.on_start = [this]() {
+ start_tx();
+ };
+
+ tx_view.on_stop = [this]() {
+ baseband::kill_ook();
+ stop_tx();
+ };
+
+ button_open.on_select = [this](const Button&) {
+ auto open_view = nav_.push(".KEELOQ");
+
+ ensure_directory(keeloq_remotes_dir);
+ open_view->push_dir(keeloq_remotes_dir);
+ open_view->on_changed = [this](fs::path path) {
+ nav_.set_on_pop([this, path]() {
+ if (!read_keeloq_file(path, data)) {
+ return;
+ }
+
+ file_path = path;
+
+ for (const auto& key : keystore.get_keys()) {
+ if (key.mf_name == data.mf_name) {
+ current_key = key;
+
+ break;
+ }
+ }
+
+ fix = data.btn << 28 | data.serial;
+
+ update_hop();
+
+ text_mf_name.set(data.mf_name);
+ text_serial.set(to_string_hex(data.serial));
+ text_fix.set(to_string_hex(fix));
+
+ field_counter.set_value(data.counter, false);
+ field_button.set_value(data.btn, false);
+
+ update_payload();
+
+ field_counter.focus();
+ });
+ };
+ };
+}
+
+void KeeloqTXView::start_tx() {
+ size_t bitstream_length = encoders::make_bitstream(encoded_data);
+
+ progressbar.set_max(repeat);
+ tx_view.set_transmitting(true);
+
+ transmitter_model.enable();
+
+ baseband::set_ook_data(
+ bitstream_length,
+ OOK_SAMPLERATE * (400.0 / 1000000.0),
+ repeat,
+ pause_duration);
+}
+
+void KeeloqTXView::stop_tx() {
+ transmitter_model.disable();
+ text_status.set("Ready");
+ progressbar.set_value(0);
+ tx_view.set_transmitting(false);
+
+ data.counter += 1;
+
+ field_counter.set_value(data.counter, false);
+
+ update_hop();
+ update_payload();
+
+ write_keeloq_file(file_path, data);
+}
+
+void KeeloqTXView::on_tx_progress(uint32_t progress, bool done) {
+ if (!done) {
+ text_status.set(to_string_dec_uint(progress + 1) + "/" + to_string_dec_uint(repeat));
+ progressbar.set_value(progress + 1);
+ } else {
+ chThdSleepMilliseconds(10);
+
+ stop_tx();
+ }
+}
+
+KeeloqTXView::~KeeloqTXView() {
+ transmitter_model.disable();
+ baseband::shutdown();
+}
+
+} // namespace ui::external_app::ui_keeloqtx
diff --git a/firmware/application/external/keeloqtx/ui_keeloqtx.hpp b/firmware/application/external/keeloqtx/ui_keeloqtx.hpp
new file mode 100644
index 000000000..9c081f6ce
--- /dev/null
+++ b/firmware/application/external/keeloqtx/ui_keeloqtx.hpp
@@ -0,0 +1,129 @@
+#ifndef __KEELOQTX__
+#define __KEELOQTX__
+
+#include "ui.hpp"
+#include "ui_transmitter.hpp"
+#include "transmitter_model.hpp"
+#include "file_path.hpp"
+#include "app_settings.hpp"
+#include "radio_state.hpp"
+#include "encoders.hpp"
+#include "string_format.hpp"
+#include "../../keeloq_file.hpp"
+#include "../../keeloq_keystore.hpp"
+
+#define KEELOQ_HEADER "101010101010101010101010000000000"
+
+namespace ui::external_app::ui_keeloqtx {
+
+class KeeloqTXView : public View {
+ public:
+ KeeloqTXView(NavigationView& nav);
+ ~KeeloqTXView();
+
+ void focus() override;
+
+ std::string title() const override {
+ return "KeeLoqTX";
+ }
+
+ private:
+ const std::string keeloq_fragments[2] = {"110", "100"};
+
+ NavigationView& nav_;
+
+ TxRadioState radio_state_{
+ 433920000,
+ 1750000,
+ OOK_SAMPLERATE};
+
+ app_settings::SettingsManager settings_{
+ "tx_keeloq", app_settings::Mode::TX};
+
+ std::filesystem::path file_path{};
+
+ KeeloqKeystore keystore{};
+ KeeloqKey current_key{};
+ KeeloqData data{};
+
+ uint32_t fix = 0;
+ uint32_t hop = 0;
+ uint64_t payload = 0;
+
+ void update_hop();
+
+ Labels labels{
+ {{UI_POS_X(1), UI_POS_Y(0)}, "Manufacturer:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(1), UI_POS_Y(1)}, "Serial:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(1), UI_POS_Y(2)}, "Fix:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(1), UI_POS_Y(3)}, "Hop:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(1), UI_POS_Y(4)}, "Counter:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(1), UI_POS_Y(5)}, "Button:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(1), UI_POS_Y(6)}, "Repeat:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(2), UI_POS_Y(8)}, "Payload:", Theme::getInstance()->fg_light->foreground},
+ };
+
+ Text text_mf_name{{UI_POS_X(15), UI_POS_Y(0), 128, 16}, ""};
+ Text text_serial{{UI_POS_X(9), UI_POS_Y(1), 64, 16}, "00000000"};
+ Text text_fix{{UI_POS_X(6), UI_POS_Y(2), 64, 16}, "00000000"};
+ Text text_hop{{UI_POS_X(6), UI_POS_Y(3), 64, 16}, "00000000"};
+
+ NumberField field_counter{
+ {UI_POS_X(10), UI_POS_Y(4)},
+ 5,
+ {0, 65535},
+ 1,
+ ' '};
+ NumberField field_button{
+ {UI_POS_X(9), UI_POS_Y(5)},
+ 2,
+ {0, 15},
+ 1,
+ ' '};
+ NumberField field_repeat{
+ {UI_POS_X(10), UI_POS_Y(6)},
+ 3,
+ {0, 100},
+ 1,
+ ' '};
+
+ Text text_payload{{UI_POS_X(2), UI_POS_Y(9), 128, 16}, "0000000000000000"};
+
+ void update_payload();
+
+ Button button_open{{UI_POS_X(0), UI_POS_Y(11), screen_width, 32}, "Open file"};
+
+ Text text_status{{UI_POS_X(2), UI_POS_Y_BOTTOM(7), 128, 16}, "Ready"};
+
+ ProgressBar progressbar{
+ {UI_POS_X(2), UI_POS_Y_BOTTOM(7) + 20, UI_POS_WIDTH_REMAINING(4), 16}};
+
+ TransmitterView tx_view{
+ (int16_t)UI_POS_Y_BOTTOM(4),
+ 50000,
+ 9};
+
+ std::string encoded_data{};
+
+ void encode_data();
+
+ uint32_t repeat = 4;
+ uint32_t pause_duration = 0;
+
+ void start_tx();
+ void stop_tx();
+
+ void update_progress();
+ void on_tx_progress(uint32_t progress, bool done);
+
+ MessageHandlerRegistration message_handler_tx_progress{
+ Message::ID::TXProgress,
+ [this](const Message* const p) {
+ const auto message = *reinterpret_cast(p);
+ this->on_tx_progress(message.progress, message.done);
+ }};
+};
+
+} // namespace ui::external_app::ui_keeloqtx
+
+#endif
diff --git a/firmware/application/external/kiss_tnc/main.cpp b/firmware/application/external/kiss_tnc/main.cpp
new file mode 100644
index 000000000..9c2392f9c
--- /dev/null
+++ b/firmware/application/external/kiss_tnc/main.cpp
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2024 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_kiss_tnc.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::kiss_tnc {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::kiss_tnc
+
+extern "C" {
+
+__attribute__((section(".external_app.app_kiss_tnc.application_information"), used)) application_information_t _application_information_kiss_tnc = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::kiss_tnc::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "KISS TNC",
+ /*.bitmap_data = */ {
+ // 16x16 icon - radio tower / TNC
+ 0x00,
+ 0x00,
+ 0x08,
+ 0x00,
+ 0x14,
+ 0x00,
+ 0x22,
+ 0x00,
+ 0x08,
+ 0x00,
+ 0x08,
+ 0x00,
+ 0x08,
+ 0x00,
+ 0x3E,
+ 0x00,
+ 0x22,
+ 0x00,
+ 0x22,
+ 0x00,
+ 0x22,
+ 0x00,
+ 0x22,
+ 0x00,
+ 0x3E,
+ 0x00,
+ 0x1C,
+ 0x00,
+ 0x08,
+ 0x00,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::green().v,
+ /*.menu_location = */ app_location_t::TRX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_aprs_rx */ {'P', 'A', 'P', 'R'},
+ /*.m4_app_offset = */ 0x00000000,
+};
+}
diff --git a/firmware/application/external/kiss_tnc/ui_kiss_tnc.cpp b/firmware/application/external/kiss_tnc/ui_kiss_tnc.cpp
new file mode 100644
index 000000000..4511b53e8
--- /dev/null
+++ b/firmware/application/external/kiss_tnc/ui_kiss_tnc.cpp
@@ -0,0 +1,222 @@
+/*
+ * Copyright (C) 2024 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui_kiss_tnc.hpp"
+#include "baseband_api.hpp"
+#include "portapack.hpp"
+#include "receiver_model.hpp"
+#include "transmitter_model.hpp"
+#include "string_format.hpp"
+#include "memory_map.hpp"
+#include "ax25.hpp"
+#include "spi_image.hpp"
+
+extern "C" {
+#include "usb_serial_device_to_host.h"
+}
+
+using namespace portapack;
+
+namespace ui::external_app::kiss_tnc {
+
+namespace {
+KissTncView* g_kiss_tnc_instance = nullptr;
+}
+
+void KissTncView::kiss_input_trampoline(const uint8_t* data, size_t len) {
+ if (g_kiss_tnc_instance) g_kiss_tnc_instance->on_kiss_bytes(data, len);
+}
+
+KissTncView::KissTncView(NavigationView& nav)
+ : nav_{nav} {
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+ add_children({&field_frequency,
+ &field_rf_amp,
+ &field_lna,
+ &field_vga,
+ &rssi,
+ &channel,
+ &text_status,
+ &text_usb_status,
+ &text_rx_count,
+ &text_tx_count,
+ &labels,
+ &console});
+ receiver_model.enable();
+ baseband::set_aprs(1200);
+ g_kiss_tnc_instance = this;
+ usb_input_handler_.emplace(kiss_input_trampoline);
+ update_stats();
+}
+
+KissTncView::~KissTncView() {
+ g_kiss_tnc_instance = nullptr;
+ usb_input_handler_.reset();
+ if (tx_active_) {
+ transmitter_model.disable();
+ } else {
+ receiver_model.disable();
+ }
+ baseband::shutdown();
+}
+
+void KissTncView::focus() {
+ field_frequency.focus();
+}
+
+void KissTncView::update_stats() {
+ text_rx_count.set(to_string_dec_uint(rx_count_));
+ text_tx_count.set(to_string_dec_uint(tx_count_));
+ text_usb_status.set(portapack::usb_serial.serial_connected() ? "Connected" : "Disconnected");
+}
+
+void KissTncView::send_kiss_frame(const uint8_t* data, size_t len) {
+ // Stack-allocate output buffer: max KISS overhead is 2x data + 2 FENDs + 1 cmd byte
+ uint8_t buf[512];
+ size_t i = 0;
+ buf[i++] = 0xC0; // FEND
+ buf[i++] = 0x00; // data command
+ for (size_t j = 0; j < len; j++) {
+ if (data[j] == 0xC0) {
+ if (i + 3 > sizeof(buf)) break; // need 2 for escape + 1 for final FEND
+ buf[i++] = 0xDB;
+ buf[i++] = 0xDC;
+ } else if (data[j] == 0xDB) {
+ if (i + 3 > sizeof(buf)) break; // need 2 for escape + 1 for final FEND
+ buf[i++] = 0xDB;
+ buf[i++] = 0xDD;
+ } else {
+ if (i + 2 > sizeof(buf)) break; // need 1 for data + 1 for final FEND
+ buf[i++] = data[j];
+ }
+ }
+ buf[i++] = 0xC0; // FEND
+ if (usb_input_handler_)
+ usb_input_handler_->write(buf, i);
+}
+
+void KissTncView::on_packet(const APRSPacketMessage* message) {
+ aprs::APRSPacket pkt = message->packet;
+
+ uint8_t payload_size = pkt.size();
+ if (payload_size > 2) {
+ uint8_t raw[256];
+ size_t raw_len = payload_size - 2;
+ for (size_t i = 0; i < raw_len; i++)
+ raw[i] = static_cast(pkt[i]);
+ send_kiss_frame(raw, raw_len);
+ }
+
+ console.writeln(pkt.get_source_formatted() + ">" + pkt.get_destination_formatted());
+ rx_count_++;
+ update_stats();
+}
+
+void KissTncView::on_kiss_bytes(const uint8_t* data, size_t len) {
+ for (size_t i = 0; i < len; i++) {
+ uint8_t b = data[i];
+ switch (kiss_state_) {
+ case KissState::IDLE:
+ if (b == 0xC0) kiss_state_ = KissState::CMD;
+ break;
+ case KissState::CMD:
+ if (b == 0xC0) break;
+ if (b == 0x00) {
+ kiss_idx_ = 0;
+ kiss_state_ = KissState::DATA;
+ } else {
+ kiss_state_ = KissState::IDLE;
+ }
+ break;
+ case KissState::DATA:
+ if (b == 0xC0) {
+ if (kiss_idx_ > 0) process_kiss_frame();
+ kiss_idx_ = 0;
+ kiss_state_ = KissState::CMD;
+ } else if (b == 0xDB) {
+ kiss_state_ = KissState::ESC;
+ } else if (kiss_idx_ < sizeof(kiss_buf_)) {
+ kiss_buf_[kiss_idx_++] = b;
+ }
+ break;
+ case KissState::ESC:
+ if (b == 0xDC) {
+ if (kiss_idx_ < sizeof(kiss_buf_)) kiss_buf_[kiss_idx_++] = 0xC0;
+ } else if (b == 0xDD) {
+ if (kiss_idx_ < sizeof(kiss_buf_)) kiss_buf_[kiss_idx_++] = 0xDB;
+ }
+ kiss_state_ = KissState::DATA;
+ break;
+ }
+ }
+}
+
+void KissTncView::process_kiss_frame() {
+ if (tx_active_ || kiss_idx_ == 0) return;
+ // AFSK shared buffer is 512 bytes (256 uint16_t words). Each raw byte
+ // produces ~10 encoded bits after bit-stuffing + flags, so cap at 200 bytes
+ // to ensure the bitstream plus required 0-word terminator always fits.
+ if (kiss_idx_ > 200) return;
+ start_tx();
+}
+
+void KissTncView::start_tx() {
+ tx_active_ = true;
+
+ ax25::AX25Frame frame;
+ frame.make_frame_from_raw(kiss_buf_, kiss_idx_);
+
+ rx_frequency_ = receiver_model.target_frequency();
+ receiver_model.disable();
+ baseband::shutdown();
+
+ baseband::run_image(portapack::spi_flash::image_tag_afsk);
+
+ transmitter_model.set_target_frequency(rx_frequency_);
+ transmitter_model.set_sampling_rate(AFSK_SAMPLE_RATE);
+ transmitter_model.set_baseband_bandwidth(AFSK_BASEBAND_BW);
+ transmitter_model.enable();
+
+ baseband::set_afsk_data(AFSK_SAMPLE_RATE / 1200, 1200, 2200, 1, 10000, 8);
+
+ text_status.set("Transmitting");
+ tx_count_++;
+ update_stats();
+}
+
+void KissTncView::finish_tx() {
+ transmitter_model.disable();
+ baseband::shutdown();
+
+ // Must reload from SPI flash β run_image(afsk) overwrote m4_code.base()
+ baseband::run_image(portapack::spi_flash::image_tag_aprs_rx);
+
+ receiver_model.set_target_frequency(rx_frequency_);
+ receiver_model.set_sampling_rate(APRS_RX_SAMPLE_RATE);
+ receiver_model.set_baseband_bandwidth(APRS_RX_BASEBAND_BW);
+ receiver_model.enable();
+ baseband::set_aprs(1200);
+ tx_active_ = false;
+ text_status.set("Listening");
+ update_stats();
+}
+
+} // namespace ui::external_app::kiss_tnc
diff --git a/firmware/application/external/kiss_tnc/ui_kiss_tnc.hpp b/firmware/application/external/kiss_tnc/ui_kiss_tnc.hpp
new file mode 100644
index 000000000..18168fa36
--- /dev/null
+++ b/firmware/application/external/kiss_tnc/ui_kiss_tnc.hpp
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2024 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include "ui.hpp"
+#include "ui_widget.hpp"
+#include "ui_navigation.hpp"
+#include "ui_freq_field.hpp"
+#include "ui_rssi.hpp"
+#include "radio_state.hpp"
+#include "app_settings.hpp"
+#include "message.hpp"
+#include "baseband_api.hpp"
+#include "portapack.hpp"
+#include "receiver_model.hpp"
+#include "aprs_packet.hpp"
+#include "usb_serial_host_to_device.hpp"
+
+#include
+
+namespace ui::external_app::kiss_tnc {
+
+class KissTncView : public View {
+ public:
+ KissTncView(NavigationView& nav);
+ ~KissTncView();
+
+ void focus() override;
+ std::string title() const override { return "KISS TNC"; }
+
+ private:
+ static constexpr rf::Frequency APRS_FREQ_DEFAULT = 144390000;
+ static constexpr uint32_t APRS_RX_SAMPLE_RATE = 3072000U;
+ static constexpr uint32_t APRS_RX_BASEBAND_BW = 1750000U;
+ static constexpr uint32_t AFSK_SAMPLE_RATE = 1536000U;
+ static constexpr uint32_t AFSK_BASEBAND_BW = 1750000U;
+
+ NavigationView& nav_;
+
+ // CRITICAL: RxRadioState must be declared before SettingsManager
+ RxRadioState radio_state_{
+ APRS_FREQ_DEFAULT,
+ APRS_RX_BASEBAND_BW,
+ APRS_RX_SAMPLE_RATE};
+
+ app_settings::SettingsManager settings_{"kiss_tnc", app_settings::Mode::RX};
+
+ RxFrequencyField field_frequency{
+ {0, 0},
+ nav_};
+ RFAmpField field_rf_amp{{13 * 8, 0}};
+ LNAGainField field_lna{{15 * 8, 0}};
+ VGAGainField field_vga{{18 * 8, 0}};
+ RSSI rssi{{21 * 8, 0, 6 * 8, 4}};
+ Channel channel{{21 * 8, 5, 6 * 8, 4}};
+
+ Text text_status{{0, 16, 240, 16}, "Listening"};
+ Text text_usb_status{{4 * 8, 2 * 16, 14 * 8, 16}, "Disconnected"};
+ Text text_rx_count{{3 * 8, 3 * 16, 5 * 8, 16}, "0"};
+ Text text_tx_count{{11 * 8, 3 * 16, 5 * 8, 16}, "0"};
+
+ Labels labels{{
+ {{0, 2 * 16}, "USB:", Color::light_grey()},
+ {{0, 3 * 16}, "RX:", Color::light_grey()},
+ {{8 * 8, 3 * 16}, "TX:", Color::light_grey()},
+ }};
+
+ Console console{{0, 4 * 16, 240, 180}};
+
+ enum class KissState : uint8_t { IDLE,
+ CMD,
+ DATA,
+ ESC };
+ KissState kiss_state_{KissState::IDLE};
+ uint8_t kiss_buf_[350]{};
+ size_t kiss_idx_{0};
+
+ bool tx_active_{false};
+ rf::Frequency rx_frequency_{APRS_FREQ_DEFAULT};
+ uint32_t rx_count_{0};
+ uint32_t tx_count_{0};
+
+ void on_packet(const APRSPacketMessage* message);
+ void on_kiss_bytes(const uint8_t* data, size_t len);
+ void process_kiss_frame();
+ void send_kiss_frame(const uint8_t* data, size_t len);
+ void start_tx();
+ void finish_tx();
+ void update_stats();
+
+ uint32_t frame_counter_{0};
+
+ static void kiss_input_trampoline(const uint8_t* data, size_t len);
+ std::optional usb_input_handler_{};
+
+ MessageHandlerRegistration message_handler_packet{
+ Message::ID::APRSPacket,
+ [this](Message* const p) {
+ on_packet(reinterpret_cast(p));
+ }};
+
+ MessageHandlerRegistration message_handler_tx_progress{
+ Message::ID::TXProgress,
+ [this](Message* const p) {
+ auto* msg = reinterpret_cast(p);
+ if (msg->done) finish_tx();
+ }};
+
+ MessageHandlerRegistration message_handler_frame_sync{
+ Message::ID::DisplayFrameSync,
+ [this](const Message* const) {
+ if (++frame_counter_ == 60) {
+ frame_counter_ = 0;
+ text_usb_status.set(portapack::usb_serial.serial_connected() ? "Connected" : "Disconnected");
+ }
+ }};
+};
+
+} // namespace ui::external_app::kiss_tnc
diff --git a/firmware/application/external/level/ui_level.cpp b/firmware/application/external/level/ui_level.cpp
index e8034f31d..d5608b6b3 100644
--- a/firmware/application/external/level/ui_level.cpp
+++ b/firmware/application/external/level/ui_level.cpp
@@ -137,6 +137,7 @@ LevelView::LevelView(NavigationView& nav)
else
v = SPEC_MODULATION;
field_mode.set_selected_index(v);
+ return; // Important ! Guru will occur if you don't return when modifying field from within on_change !
}
last_mode = v;
change_mode(v);
diff --git a/firmware/application/external/mdc_tx/main.cpp b/firmware/application/external/mdc_tx/main.cpp
new file mode 100644
index 000000000..93956a7f8
--- /dev/null
+++ b/firmware/application/external/mdc_tx/main.cpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2025 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_mdc_tx.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::mdc_tx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::mdc_tx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_mdc_tx.application_information"), used))
+application_information_t _application_information_mdc_tx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::mdc_tx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "MDC-1200 TX",
+ /*.bitmap_data = */ {
+ 0x00,
+ 0x00,
+ 0xFE,
+ 0x7F,
+ 0xFF,
+ 0xFF,
+ 0xBB,
+ 0xD0,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0x0B,
+ 0xE1,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xEB,
+ 0xD0,
+ 0xFF,
+ 0xFF,
+ 0xFE,
+ 0x7F,
+ 0x70,
+ 0x00,
+ 0x30,
+ 0x00,
+ 0x10,
+ 0x00,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::orange().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+ /*.m4_app_tag = */ {'P', 'A', 'F', 'T'},
+ /*.m4_app_offset = */ 0x00000000,
+};
+}
diff --git a/firmware/application/external/mdc_tx/ui_mdc_tx.cpp b/firmware/application/external/mdc_tx/ui_mdc_tx.cpp
new file mode 100644
index 000000000..44693f3ce
--- /dev/null
+++ b/firmware/application/external/mdc_tx/ui_mdc_tx.cpp
@@ -0,0 +1,289 @@
+/*
+ * Copyright (C) 2025 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui_mdc_tx.hpp"
+#include "baseband_api.hpp"
+#include "portapack.hpp"
+#include "portapack_shared_memory.hpp"
+#include "transmitter_model.hpp"
+#include "ui/ui_receiver.hpp"
+
+using namespace portapack;
+
+namespace ui::external_app::mdc_tx {
+
+static constexpr uint32_t MDC_SAMPLES_PER_BIT = AFSK_TX_SAMPLERATE / 1200U;
+static constexpr uint32_t MDC_MARK_FREQ = 1800U;
+static constexpr uint32_t MDC_SPACE_FREQ = 1200U;
+static constexpr uint32_t MDC_FM_DEVIATION = 2500U;
+
+static constexpr uint8_t MDC_PREAMBLE[7] = {0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA};
+static constexpr uint8_t MDC_SYNC[5] = {0x07, 0x09, 0x2A, 0x44, 0x6F};
+static constexpr int MDC_NUM_PREAMBLES = 3;
+
+static constexpr uint8_t MDC_DP_OP = 0x35;
+static constexpr uint8_t MDC_DP_ARG = 0x89;
+static constexpr uint8_t MDC_DP_CALL_ARG = 0x0D;
+
+static uint8_t mdc_flip8(uint8_t in) {
+ uint8_t out = 0;
+ for (int i = 0; i < 8; i++) {
+ out = static_cast((out << 1) | (in & 1));
+ in >>= 1;
+ }
+ return out;
+}
+
+static uint16_t mdc_flip16(uint16_t in) {
+ uint16_t out = 0;
+ for (int i = 0; i < 16; i++) {
+ out = static_cast((out << 1) | (in & 1));
+ in >>= 1;
+ }
+ return out;
+}
+
+static uint16_t mdc_crc(const uint8_t* data, int length) {
+ uint16_t crc = 0;
+ for (int i = 0; i < length; i++) {
+ uint16_t c = mdc_flip8(data[i]);
+ for (int j = 0x80; j != 0; j >>= 1) {
+ uint16_t bit = crc & 0x8000;
+ crc = static_cast(crc << 1);
+ if (c & static_cast(j)) bit ^= 0x8000;
+ if (bit) crc ^= 0x1021;
+ }
+ }
+ crc = mdc_flip16(crc);
+ crc ^= 0xFFFF;
+ return crc;
+}
+
+// CRC + convolutional encoding + bit interleaving for a 14-byte codeword.
+// Uses lbits_ (class member) so no large allocation hits the stack.
+void MdcTxView::pack_codeword(uint8_t cw[14]) {
+ uint16_t crc = mdc_crc(cw, 4);
+ cw[4] = static_cast(crc & 0xFF);
+ cw[5] = static_cast((crc >> 8) & 0xFF);
+ cw[6] = 0x00;
+
+ // Convolutional encoding β csr[7] is only 7 bytes, fine on stack
+ uint8_t csr[7] = {};
+ for (int i = 0; i < 7; i++) {
+ cw[i + 7] = 0;
+ for (int j = 0; j <= 7; j++) {
+ for (int k = 6; k > 0; k--) csr[k] = csr[k - 1];
+ csr[0] = (cw[i] >> j) & 1;
+ int b = csr[0] + csr[2] + csr[5] + csr[6];
+ cw[i + 7] |= static_cast((b & 1) << j);
+ }
+ }
+
+ // Bit interleaving β uses lbits_ class member (112 bytes off the stack)
+ for (int i = 0; i < 112; i++) lbits_[i] = 0;
+ int l = 0, m = 0;
+ for (int i = 0; i < 14; i++) {
+ for (int j = 0; j <= 7; j++) {
+ lbits_[l] = (cw[i] >> j) & 1;
+ l += 16;
+ if (l > 111) {
+ m++;
+ l = m;
+ }
+ }
+ }
+ l = 0;
+ for (int i = 0; i < 14; i++) {
+ cw[i] = 0;
+ for (int j = 7; j >= 0; j--) {
+ if (lbits_[l]) cw[i] |= static_cast(1 << j);
+ l++;
+ }
+ }
+}
+
+// Writes preamble + sync + one packed codeword into tx_stream_, returns length.
+int MdcTxView::build_single(uint8_t op, uint8_t arg, uint8_t id_hi, uint8_t id_lo) {
+ int idx = 0;
+ for (int i = 0; i < MDC_NUM_PREAMBLES; i++)
+ for (int j = 0; j < 7; j++) tx_stream_[idx++] = MDC_PREAMBLE[j];
+ for (int j = 0; j < 5; j++) tx_stream_[idx++] = MDC_SYNC[j];
+
+ uint8_t cw[14] = {};
+ cw[0] = op;
+ cw[1] = arg;
+ cw[2] = id_hi;
+ cw[3] = id_lo;
+ pack_codeword(cw);
+ for (int j = 0; j < 14; j++) tx_stream_[idx++] = cw[j];
+ return idx;
+}
+
+// Writes preamble + sync + two packed codewords into tx_stream_, returns length.
+int MdcTxView::build_double(uint8_t op1, uint8_t arg1, uint8_t id1_hi, uint8_t id1_lo, uint8_t op2, uint8_t arg2, uint8_t id2_hi, uint8_t id2_lo) {
+ int idx = 0;
+ for (int i = 0; i < MDC_NUM_PREAMBLES; i++)
+ for (int j = 0; j < 7; j++) tx_stream_[idx++] = MDC_PREAMBLE[j];
+ for (int j = 0; j < 5; j++) tx_stream_[idx++] = MDC_SYNC[j];
+
+ uint8_t cw1[14] = {};
+ cw1[0] = op1;
+ cw1[1] = arg1;
+ cw1[2] = id1_hi;
+ cw1[3] = id1_lo;
+ pack_codeword(cw1);
+ for (int j = 0; j < 14; j++) tx_stream_[idx++] = cw1[j];
+
+ uint8_t cw2[14] = {};
+ cw2[0] = op2;
+ cw2[1] = arg2;
+ cw2[2] = id2_hi;
+ cw2[3] = id2_lo;
+ pack_codeword(cw2);
+ for (int j = 0; j < 14; j++) tx_stream_[idx++] = cw2[j];
+ return idx;
+}
+
+MdcTxView::MdcTxView(NavigationView& nav)
+ : nav_{nav} {
+ baseband::run_image(portapack::spi_flash::image_tag_afsk);
+
+ add_children({
+ &labels_,
+ &field_op,
+ &field_id_hi,
+ &field_id_lo,
+ &field_arg,
+ &field_src_hi,
+ &field_src_lo,
+ &text_status,
+ &tx_view,
+ });
+
+ tx_view.on_start = [this]() {
+ if (!tx_active_) {
+ start_tx();
+ tx_view.set_transmitting(true);
+ }
+ };
+ tx_view.on_stop = [this]() { stop_tx(); };
+
+ transmitter_model.set_tx_gain(30);
+
+ tx_view.on_edit_frequency = [this, &nav]() {
+ auto new_view = nav.push(transmitter_model.target_frequency());
+ new_view->on_changed = [this](rf::Frequency f) {
+ transmitter_model.set_target_frequency(f);
+ };
+ };
+
+ field_op.on_change = [this](size_t, int32_t op) {
+ switch (op) {
+ case 0xFE:
+ field_arg.set_value(0x0C);
+ break; // Unstun
+ case 0x2B:
+ field_arg.set_value(0x00);
+ break; // Stun
+ case 0x63:
+ field_arg.set_value(0x85);
+ break; // Radio Check
+ case 0x11:
+ field_arg.set_value(0x8A);
+ break; // Remote Monitor
+ default:
+ field_arg.set_value(0x00);
+ break;
+ }
+ };
+}
+
+MdcTxView::~MdcTxView() {
+ transmitter_model.disable();
+ baseband::shutdown();
+}
+
+void MdcTxView::focus() {
+ field_op.focus();
+}
+
+void MdcTxView::start_tx() {
+ int32_t raw_op = field_op.selected_index_value();
+ uint8_t op = (raw_op == 0xFE) ? 0x2B : static_cast(raw_op);
+ uint8_t arg = static_cast(field_arg.value());
+ uint8_t id_hi = static_cast(field_id_hi.value());
+ uint8_t id_lo = static_cast(field_id_lo.value());
+ uint8_t src_hi = static_cast(field_src_hi.value());
+ uint8_t src_lo = static_cast(field_src_lo.value());
+
+ int stream_len;
+ bool is_double = (op == 0x80 || op == 0x81 || op == 0x82);
+ if (is_double) {
+ stream_len = build_double(MDC_DP_OP, MDC_DP_ARG, id_hi, id_lo,
+ op, MDC_DP_CALL_ARG, src_hi, src_lo);
+ } else {
+ stream_len = build_single(op, arg, id_hi, id_lo);
+ }
+
+ // NRZI encode tx_stream_ into shared memory words
+ auto* words = reinterpret_cast(shared_memory.bb_data.data);
+ uint8_t prev_bit = 0;
+ for (int i = 0; i < stream_len; i++) {
+ uint8_t raw = tx_stream_[i];
+ uint8_t nrzi = 0;
+ for (int bit = 7; bit >= 0; bit--) {
+ uint8_t rb = (raw >> bit) & 1;
+ uint8_t nb = rb ^ prev_bit;
+ prev_bit = rb;
+ nrzi = static_cast((nrzi << 1) | nb);
+ }
+ words[i] = nrzi;
+ }
+ words[stream_len] = 0;
+
+ tx_active_ = true;
+ text_status.set("TX...");
+ transmitter_model.enable();
+ baseband::set_afsk_data(
+ MDC_SAMPLES_PER_BIT,
+ MDC_MARK_FREQ,
+ MDC_SPACE_FREQ,
+ 1,
+ MDC_FM_DEVIATION,
+ 8);
+}
+
+void MdcTxView::stop_tx() {
+ baseband::kill_afsk();
+ transmitter_model.disable();
+ tx_active_ = false;
+ text_status.set("Ready");
+ tx_view.set_transmitting(false);
+}
+
+void MdcTxView::on_tx_progress(uint32_t, bool done) {
+ if (done) {
+ stop_tx();
+ text_status.set("Done!");
+ }
+}
+
+} // namespace ui::external_app::mdc_tx
diff --git a/firmware/application/external/mdc_tx/ui_mdc_tx.hpp b/firmware/application/external/mdc_tx/ui_mdc_tx.hpp
new file mode 100644
index 000000000..3fe80762e
--- /dev/null
+++ b/firmware/application/external/mdc_tx/ui_mdc_tx.hpp
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2025 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#ifndef __UI_MDC_TX_H__
+#define __UI_MDC_TX_H__
+
+#include "ui.hpp"
+#include "ui_widget.hpp"
+#include "ui_navigation.hpp"
+#include "ui_transmitter.hpp"
+#include "app_settings.hpp"
+#include "radio_state.hpp"
+#include "message.hpp"
+#include "modems.hpp"
+
+namespace ui::external_app::mdc_tx {
+
+class MdcTxView : public View {
+ public:
+ MdcTxView(NavigationView& nav);
+ ~MdcTxView();
+ MdcTxView(const MdcTxView&) = delete;
+ MdcTxView& operator=(const MdcTxView&) = delete;
+
+ void focus() override;
+ std::string title() const override { return "MDC-1200 TX"; }
+
+ private:
+ NavigationView& nav_;
+
+ // TxRadioState MUST be declared before SettingsManager
+ TxRadioState radio_state_{
+ 462562500,
+ 1750000,
+ AFSK_TX_SAMPLERATE};
+ app_settings::SettingsManager settings_{
+ "tx_mdc_tx",
+ app_settings::Mode::TX};
+
+ Labels labels_{{
+ {{UI_POS_X(0), UI_POS_Y(0)}, "Op:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(1)}, "ID:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(13), UI_POS_Y(1)}, "Arg:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(2)}, "Src:", ui::Theme::getInstance()->fg_light->foreground},
+ }};
+
+ // Only one OptionsField allowed per external app
+ // Stun=Op 0x2B Arg 0x00, Unstun=Op 0x2B Arg 0x0C (auto-set via on_change)
+ OptionsField field_op{
+ {UI_POS_X(4), UI_POS_Y(0)},
+ 14,
+ {{"PTT ID", 0x01},
+ {"Emergency", 0x00},
+ {"Radio Check", 0x63},
+ {"Stun", 0x2B},
+ {"Unstun", 0xFE}, // sentinel: op=0x2B, arg=0x0C
+ {"Call Alert", 0x81},
+ {"Sel Call 1", 0x80},
+ {"Sel Call 2", 0x82},
+ {"Status Req", 0x22},
+ {"Status Resp", 0x06},
+ {"Rem Monitor", 0x11},
+ {"Message", 0x07}}};
+
+ // Unit ID: two decimal bytes (0-255) combined into a 16-bit ID
+ NumberField field_id_hi{{UI_POS_X(4), UI_POS_Y(1)}, 3, {0, 255}, 1, '0'};
+ NumberField field_id_lo{{UI_POS_X(7), UI_POS_Y(1)}, 3, {0, 255}, 1, '0'};
+
+ // Argument byte (decimal 0-255, auto-set by op selection)
+ NumberField field_arg{{UI_POS_X(17), UI_POS_Y(1)}, 3, {0, 255}, 1, '0'};
+
+ // Source ID: two decimal bytes (0-255) combined into a 16-bit ID
+ NumberField field_src_hi{{UI_POS_X(4), UI_POS_Y(2)}, 3, {0, 255}, 1, '0'};
+ NumberField field_src_lo{{UI_POS_X(7), UI_POS_Y(2)}, 3, {0, 255}, 1, '0'};
+
+ Text text_status{{0, UI_POS_Y(3), UI_POS_MAXWIDTH, UI_POS_DEFAULT_HEIGHT}, "Ready"};
+
+ TransmitterView tx_view{
+ (int16_t)UI_POS_Y_BOTTOM(4),
+ 12500,
+ 0};
+
+ bool tx_active_{false};
+
+ // Class-owned scratch buffers β keep large allocations off the stack
+ uint8_t tx_stream_[128]{};
+ uint8_t lbits_[112]{};
+
+ void pack_codeword(uint8_t cw[14]);
+ int build_single(uint8_t op, uint8_t arg, uint8_t id_hi, uint8_t id_lo);
+ int build_double(uint8_t op1, uint8_t arg1, uint8_t id1_hi, uint8_t id1_lo, uint8_t op2, uint8_t arg2, uint8_t id2_hi, uint8_t id2_lo);
+ void start_tx();
+ void stop_tx();
+ void on_tx_progress(uint32_t progress, bool done);
+
+ MessageHandlerRegistration message_handler_tx_progress{
+ Message::ID::TXProgress,
+ [this](const Message* const p) {
+ const auto msg = *reinterpret_cast(p);
+ this->on_tx_progress(msg.progress, msg.done);
+ }};
+};
+
+} // namespace ui::external_app::mdc_tx
+
+#endif
diff --git a/firmware/application/external/morse_practice/morsedecoder.hpp b/firmware/application/external/morse_practice/morsedecoder.hpp
index 4e1058f04..bd73f936d 100644
--- a/firmware/application/external/morse_practice/morsedecoder.hpp
+++ b/firmware/application/external/morse_practice/morsedecoder.hpp
@@ -24,8 +24,7 @@
#include
#include
-
-#define MORSEDEC_ERROR ""
+#include "string_format.hpp"
namespace ui::external_app::morse_practice {
@@ -89,21 +88,28 @@ class MorseDecoder {
};
MorseDecoder() {}
- DecodeResult handleInput(int32_t duration_ms) {
+
+ DecodeResult
+ handleInput(int32_t duration_ms) {
DecodeResult result = {"", 0.0};
+
+ if (duration_ms < 5 && duration_ms > -5) return result;
+
if (duration_ms > 0) {
pulse_history_.push_back(duration_ms);
double dah_prob = getDahProbability(duration_ms);
current_sequence_ += (dah_prob > 0.5) ? '-' : '.';
last_confidence_ = (dah_prob > 0.5) ? dah_prob : (1.0 - dah_prob);
last_sequence_ = current_sequence_;
+
} else {
uint32_t gap_duration = -duration_ms;
pulse_gaps_.push_back(gap_duration);
if (gap_duration >= getInterCharThreshold() && !current_sequence_.empty()) {
result.text = lookupMorse(current_sequence_);
- result.confidence = (result.text != MORSEDEC_ERROR) ? last_confidence_ : 0.0;
+
+ result.confidence = (result.text[0] != '{') ? last_confidence_ : 0.0;
if (gap_duration >= getInterWordThreshold()) {
result.text += " ";
}
@@ -116,9 +122,9 @@ class MorseDecoder {
return result;
}
- inline double getInterElementThreshold() { return time_unit_ms_ * 2.0; }
- inline double getInterCharThreshold() { return time_unit_ms_ * 4.0; }
- inline double getInterWordThreshold() { return time_unit_ms_ * 7.0; }
+ inline double getInterElementThreshold() { return time_unit_ms_ * 0.8; }
+ inline double getInterCharThreshold() { return time_unit_ms_ * 2.5; }
+ inline double getInterWordThreshold() { return time_unit_ms_ * 6.0; }
inline double getCurrentTimeUnit() { return time_unit_ms_; }
inline std::string getLastSequence() { return last_sequence_; }
@@ -126,7 +132,7 @@ class MorseDecoder {
private:
std::string current_sequence_ = "";
std::string last_sequence_ = "";
- double time_unit_ms_ = 160.0;
+ double time_unit_ms_ = 119.0;
double last_confidence_ = 0.0;
MorseRingBuffer pulse_history_{};
MorseRingBuffer pulse_gaps_{};
@@ -136,7 +142,7 @@ class MorseDecoder {
if (seq == morse_table_[i].code)
return morse_table_[i].letter;
}
- return MORSEDEC_ERROR; // not found
+ return "{" + seq + "}"; // not found
}
double getDahProbability(uint32_t duration_ms) {
@@ -298,8 +304,8 @@ class MorseDecoder {
time_unit_ms_ = (time_unit_ms_ * (1.0 - learning_factor)) + (new_time_unit * learning_factor);
}
- size_t morse_table_size_ = 41;
- MorseEntry morse_table_[41] = {
+ size_t morse_table_size_ = 50;
+ MorseEntry morse_table_[50] = {
{".-", "A"},
{"-...", "B"},
{"-.-.", "C"},
@@ -340,7 +346,16 @@ class MorseDecoder {
{"..--..", "?"},
{"-.-.--", "!"},
{"--..--", ","},
- {"-...-", "="}};
+ {"-...-", "="},
+ {"-..-.", "/"},
+ {".--.-.", "@"},
+ {"---...", ":"},
+ {"-....-", "-"},
+ {".----.", "'"},
+ {".-..-.", "\""},
+ {"-.--.", "("},
+ {"-.--.-", ")"},
+ {".-.-.", "+"}};
};
} // namespace ui::external_app::morse_practice
diff --git a/firmware/application/external/morse_practice/ui_morse_practice.cpp b/firmware/application/external/morse_practice/ui_morse_practice.cpp
index d2b12d8ae..a92795be3 100644
--- a/firmware/application/external/morse_practice/ui_morse_practice.cpp
+++ b/firmware/application/external/morse_practice/ui_morse_practice.cpp
@@ -12,6 +12,13 @@ MorsePracticeView::MorsePracticeView(ui::NavigationView& nav)
&btn_clear,
&console_text,
&field_volume});
+
+ initial_switch_config_ = get_switches_repeat_config();
+
+ SwitchesState config = initial_switch_config_;
+ config[toUType(Switch::Sel)] = false;
+ set_switches_repeat_config(config);
+
audio::set_rate(audio::Rate::Hz_24000);
btn_tt.on_select = [this](Button&) {
@@ -43,6 +50,7 @@ MorsePracticeView::MorsePracticeView(ui::NavigationView& nav)
}
MorsePracticeView::~MorsePracticeView() {
+ set_switches_repeat_config(initial_switch_config_);
receiver_model.disable();
baseband::shutdown();
audio::output::stop();
@@ -98,7 +106,7 @@ void MorsePracticeView::writeCharToConsole(const std::string& ch, double confide
if (ch == " ") {
color_id = 0;
- } else if (ch == MORSEDEC_ERROR) {
+ } else if (ch[0] == '{') { // no match
color_id = 0;
} else {
if (confidence < 0.8)
diff --git a/firmware/application/external/morse_practice/ui_morse_practice.hpp b/firmware/application/external/morse_practice/ui_morse_practice.hpp
index 5ad19ab54..64135df03 100644
--- a/firmware/application/external/morse_practice/ui_morse_practice.hpp
+++ b/firmware/application/external/morse_practice/ui_morse_practice.hpp
@@ -47,7 +47,7 @@ class MorsePracticeView : public ui::View {
MorsePracticeView(ui::NavigationView& nav);
~MorsePracticeView();
- std::string title() { return "Morse P"; }
+ std::string title() const override { return "Morse Practice"; }
void focus() override;
void on_show() override;
@@ -68,7 +68,7 @@ class MorsePracticeView : public ui::View {
ui::Text txt_last{{UI_POS_X(0), UI_POS_Y(6), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)}, ""};
ui::Button btn_clear{{UI_POS_X(0), UI_POS_Y_BOTTOM(2), UI_POS_WIDTH(6), UI_POS_HEIGHT(1)}, "CLR"};
ui::Console console_text{{UI_POS_X(0), UI_POS_Y(7), UI_POS_MAXWIDTH, UI_POS_HEIGHT_REMAINING(10)}};
- AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_X(0)}};
+ AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_Y(0)}};
uint8_t last_color_id = 255;
uint8_t color_id = 255;
@@ -77,6 +77,7 @@ class MorsePracticeView : public ui::View {
bool button_touch = false;
bool button_was_selected = false;
bool decode_timeout_calc = false;
+ SwitchesState initial_switch_config_{};
MessageHandlerRegistration message_handler_framesync{
Message::ID::DisplayFrameSync,
diff --git a/firmware/application/external/morse_radio/main.cpp b/firmware/application/external/morse_radio/main.cpp
new file mode 100644
index 000000000..f4ff612b9
--- /dev/null
+++ b/firmware/application/external/morse_radio/main.cpp
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2026 Pezsma
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_morse_radio.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::morse_radio {
+
+void initialize_app(NavigationView& nav) {
+ nav.push();
+}
+
+} // namespace ui::external_app::morse_radio
+
+extern "C" {
+
+__attribute__((section(".external_app.app_morse_radio.application_information"), used))
+application_information_t _application_information_morse_radio = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::morse_radio::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "Morse",
+ /*.bitmap_data = */ {
+ 0x00,
+ 0x00,
+ 0xFE,
+ 0x7F,
+ 0xFF,
+ 0xFF,
+ 0xBB,
+ 0xD0,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0x0B,
+ 0xE1,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xEB,
+ 0xD0,
+ 0xFF,
+ 0xFF,
+ 0xFE,
+ 0x7F,
+ 0x70,
+ 0x00,
+ 0x30,
+ 0x00,
+ 0x10,
+ 0x00,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::yellow().v,
+ /*.menu_location = */ app_location_t::RX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_morse_radio */ {'P', 'M', 'R', 'S'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
diff --git a/firmware/application/external/morse_radio/morsedecoder.hpp b/firmware/application/external/morse_radio/morsedecoder.hpp
new file mode 100644
index 000000000..50fb01b7b
--- /dev/null
+++ b/firmware/application/external/morse_radio/morsedecoder.hpp
@@ -0,0 +1,378 @@
+#ifndef __MORSEDECODER_HPP__
+#define __MORSEDECODER_HPP__
+
+/*
+ * Copyright (C) 2025 Pezsma
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include
+#include
+#include "string_format.hpp"
+
+namespace ui::external_app::morse_radio {
+
+class MorseRingBuffer {
+ public:
+ MorseRingBuffer()
+ : head_(0), tail_(0), count_(0) {}
+
+ void push_back(const uint32_t& value) {
+ data_[head_] = value;
+ head_ = (head_ + 1) % 40;
+ if (count_ < 40) {
+ count_++;
+ } else {
+ // overwrite oldest element
+ tail_ = (tail_ + 1) % 40;
+ }
+ }
+
+ void pop_front() {
+ if (count_ > 0) {
+ tail_ = (tail_ + 1) % 40;
+ count_--;
+ }
+ }
+
+ size_t size() const { return count_; }
+ bool empty() const { return count_ == 0; }
+ const uint32_t& front() const { return data_[tail_]; }
+ // Access by index (0 = oldest)
+ uint32_t operator[](size_t idx) const {
+ return data_[(tail_ + idx) % 40];
+ }
+ // Convert to vector-like access for sorting etc.
+ void copy_to_array(uint32_t* out) const {
+ for (size_t i = 0; i < count_; ++i)
+ out[i] = (*this)[i];
+ }
+
+ void clear() {
+ head_ = 0;
+ tail_ = 0;
+ count_ = 0;
+ }
+
+ private:
+ uint32_t data_[40];
+ size_t head_;
+ size_t tail_;
+ size_t count_;
+};
+
+class MorseDecoder {
+ public:
+ struct DecodeResult {
+ std::string text = "";
+ double confidence = 0.0;
+
+ bool isValid() const {
+ return !text.empty();
+ }
+ };
+
+ struct MorseEntry {
+ std::string code;
+ std::string letter;
+ };
+
+ MorseDecoder() {}
+
+ void resetLearning() {
+ time_unit_ms_ = 119.0;
+ current_sequence_ = "";
+ last_sequence_ = "";
+ last_confidence_ = 0.0;
+ pulse_history_.clear();
+ pulse_gaps_.clear();
+ }
+
+ DecodeResult
+ handleInput(int32_t duration_ms) {
+ DecodeResult result = {"", 0.0};
+
+ if (duration_ms < 5 && duration_ms > -5) return result;
+
+ if (duration_ms > 0) {
+ pulse_history_.push_back(duration_ms);
+ double dah_prob = getDahProbability(duration_ms);
+ current_sequence_ += (dah_prob > 0.5) ? '-' : '.';
+ last_confidence_ = (dah_prob > 0.5) ? dah_prob : (1.0 - dah_prob);
+ last_sequence_ = current_sequence_;
+
+ } else {
+ uint32_t gap_duration = -duration_ms;
+ pulse_gaps_.push_back(gap_duration);
+
+ if (gap_duration >= getInterCharThreshold() && !current_sequence_.empty()) {
+ result.text = lookupMorse(current_sequence_);
+
+ result.confidence = (result.text[0] != '{') ? last_confidence_ : 0.0;
+ if (gap_duration >= getInterWordThreshold()) {
+ result.text += " ";
+ }
+ current_sequence_ = "";
+ }
+ }
+
+ updateLearning();
+
+ return result;
+ }
+
+ inline double getInterElementThreshold() { return time_unit_ms_ * 0.8; }
+ inline double getInterCharThreshold() { return time_unit_ms_ * 2.5; }
+ inline double getInterWordThreshold() { return time_unit_ms_ * 6.0; }
+
+ inline double getCurrentTimeUnit() { return time_unit_ms_; }
+ inline std::string getLastSequence() { return last_sequence_; }
+
+ private:
+ std::string current_sequence_ = "";
+ std::string last_sequence_ = "";
+ double time_unit_ms_ = 119.0;
+ double last_confidence_ = 0.0;
+ MorseRingBuffer pulse_history_{};
+ MorseRingBuffer pulse_gaps_{};
+
+ std::string lookupMorse(const std::string& seq) {
+ for (size_t i = 0; i < morse_table_size_; i++) {
+ if (seq == morse_table_[i].code)
+ return morse_table_[i].letter;
+ }
+ return "{" + seq + "}"; // not found
+ }
+
+ double getDahProbability(uint32_t duration_ms) {
+ double start_interp = 1.5 * time_unit_ms_;
+ double end_interp = 2.5 * time_unit_ms_;
+
+ if (duration_ms <= start_interp) return 0.0;
+ if (duration_ms >= end_interp) return 1.0;
+ return ((double)(duration_ms)-start_interp) / (end_interp - start_interp);
+ }
+
+ size_t findDecisionBoundary(uint32_t* sorted_data, size_t sorted_data_size) {
+ if (sorted_data_size < 4) return 0;
+
+ size_t best_split_index = 0;
+ uint32_t max_diff = 0;
+
+ for (size_t i = 1; i < sorted_data_size; ++i) {
+ uint32_t diff = sorted_data[i] - sorted_data[i - 1];
+ if (diff > sorted_data[i - 1] * 0.5 && diff > max_diff) {
+ max_diff = diff;
+ best_split_index = i;
+ }
+ }
+ return best_split_index;
+ }
+
+ bool calculatePulseUnit(double& unit, double& confidence) {
+ if (pulse_history_.size() < 10) return false;
+ uint32_t sorted_pulses[pulse_history_.size()];
+ pulse_history_.copy_to_array(sorted_pulses);
+ sort_uint32(sorted_pulses, pulse_history_.size());
+
+ size_t split_index = findDecisionBoundary(sorted_pulses, pulse_history_.size());
+ if (split_index == 0 || split_index < 3 || (pulse_history_.size() - split_index) < 2) {
+ return false;
+ }
+ double dit_sum = sum_uint32_range(sorted_pulses, 0, split_index);
+ double dah_sum = sum_uint32_range(sorted_pulses, split_index, pulse_history_.size());
+
+ double avg_dit = dit_sum / split_index;
+ double avg_dah = dah_sum / (pulse_history_.size() - split_index);
+ if (avg_dah <= avg_dit) return false;
+
+ double ratio = avg_dah / avg_dit;
+ if (ratio > 1.5 && ratio < 5.0) {
+ unit = avg_dit;
+ double tmpabs = ratio - 3.0;
+ if (tmpabs < 0) tmpabs *= -1;
+ tmpabs /= 3.0;
+ tmpabs = 1.0 - tmpabs;
+ if (tmpabs < 0) tmpabs = 0;
+ confidence = tmpabs; // 0..1
+ return true;
+ }
+
+ return false;
+ }
+
+ bool calculateGapUnit(double& unit, double& confidence) {
+ if (pulse_gaps_.size() < 10) return false;
+ double threshold = getInterElementThreshold();
+ double valid_gaps[pulse_gaps_.size()];
+ size_t valid_count = 0;
+ for (size_t i = 0; i < pulse_gaps_.size(); i++) {
+ double gap = pulse_gaps_[i];
+ if (gap <= threshold) {
+ valid_gaps[valid_count++] = gap;
+ }
+ }
+ if (valid_count < 2) {
+ return false;
+ }
+ double sum = sum_double_range(valid_gaps, 0, valid_count);
+ size_t count_to_average = valid_count;
+
+ if (count_to_average > 0) {
+ unit = sum / count_to_average;
+ confidence = 0.8;
+ return true;
+ }
+
+ return false;
+ }
+
+ double sum_uint32_range(const uint32_t* data, size_t start, size_t end) {
+ double sum = 0.0;
+ for (size_t i = start; i < end; i++) {
+ sum += data[i];
+ }
+ return sum;
+ }
+ double sum_double_range(const double* data, size_t start, size_t end) {
+ double sum = 0.0;
+ for (size_t i = start; i < end; i++) {
+ sum += data[i];
+ }
+ return sum;
+ }
+
+ void sort_uint32(uint32_t* data, size_t size) {
+ if (size < 2)
+ return;
+
+ for (size_t i = 1; i < size; i++) {
+ uint32_t key = data[i];
+ size_t j = i;
+
+ while (j > 0 && data[j - 1] > key) {
+ data[j] = data[j - 1];
+ j--;
+ }
+
+ data[j] = key;
+ }
+ }
+
+ double clamp_double(double value, double min_val, double max_val) {
+ if (value < min_val)
+ return min_val;
+ else if (value > max_val)
+ return max_val;
+ else
+ return value;
+ }
+
+ void updateLearning() {
+ double pulse_unit = -1.0, pulse_confidence = 0.0;
+ double gap_unit = -1.0, gap_confidence = 0.0;
+
+ bool pulse_success = calculatePulseUnit(pulse_unit, pulse_confidence);
+ bool gap_success = calculateGapUnit(gap_unit, gap_confidence);
+
+ double new_time_unit = -1.0;
+ if (pulse_success && pulse_confidence > 0.5) {
+ new_time_unit = pulse_unit;
+ } else if (pulse_success && gap_success) {
+ gap_confidence = 0.2;
+ double total_confidence = pulse_confidence + gap_confidence;
+ new_time_unit = (pulse_unit * pulse_confidence + gap_unit * gap_confidence) / total_confidence;
+ } else if (gap_success) {
+ new_time_unit = gap_unit;
+ } else {
+ return;
+ }
+
+ double max_change = time_unit_ms_ * 0.25;
+ new_time_unit = clamp_double(new_time_unit, time_unit_ms_ - max_change, time_unit_ms_ + max_change);
+ double DEFAULT_TIME_UNIT = 160.0;
+ double BASE_LEARNING_RATE = 0.05;
+ double MAX_LEARNING_RATE = 0.25;
+ double tudeltaabs = new_time_unit - DEFAULT_TIME_UNIT;
+ if (tudeltaabs < 0) tudeltaabs *= -1;
+ double deviation_from_default = tudeltaabs / DEFAULT_TIME_UNIT;
+ double tpp = deviation_from_default * 2.0;
+ if (tpp > 1) tpp = 1;
+ double learning_factor = BASE_LEARNING_RATE + (MAX_LEARNING_RATE - BASE_LEARNING_RATE) * tpp;
+
+ time_unit_ms_ = (time_unit_ms_ * (1.0 - learning_factor)) + (new_time_unit * learning_factor);
+ }
+
+ size_t morse_table_size_ = 50;
+ MorseEntry morse_table_[50] = {
+ {".-", "A"},
+ {"-...", "B"},
+ {"-.-.", "C"},
+ {"-..", "D"},
+ {".", "E"},
+ {"..-.", "F"},
+ {"--.", "G"},
+ {"....", "H"},
+ {"..", "I"},
+ {".---", "J"},
+ {"-.-", "K"},
+ {".-..", "L"},
+ {"--", "M"},
+ {"-.", "N"},
+ {"---", "O"},
+ {".--.", "P"},
+ {"--.-", "Q"},
+ {".-.", "R"},
+ {"...", "S"},
+ {"-", "T"},
+ {"..-", "U"},
+ {"...-", "V"},
+ {".--", "W"},
+ {"-..-", "X"},
+ {"-.--", "Y"},
+ {"--..", "Z"},
+ {".----", "1"},
+ {"..---", "2"},
+ {"...--", "3"},
+ {"....-", "4"},
+ {".....", "5"},
+ {"-....", "6"},
+ {"--...", "7"},
+ {"---..", "8"},
+ {"----.", "9"},
+ {"-----", "0"},
+ {".-.-.-", "."},
+ {"..--..", "?"},
+ {"-.-.--", "!"},
+ {"--..--", ","},
+ {"-...-", "="},
+ {"-..-.", "/"},
+ {".--.-.", "@"},
+ {"---...", ":"},
+ {"-....-", "-"},
+ {".----.", "'"},
+ {".-..-.", "\""},
+ {"-.--.", "("},
+ {"-.--.-", ")"},
+ {".-.-.", "+"}};
+};
+
+} // namespace ui::external_app::morse_radio
+
+#endif // __MORSEDECODER_HPP__
diff --git a/firmware/application/external/morse_radio/ui_morse_radio.cpp b/firmware/application/external/morse_radio/ui_morse_radio.cpp
new file mode 100644
index 000000000..42066790a
--- /dev/null
+++ b/firmware/application/external/morse_radio/ui_morse_radio.cpp
@@ -0,0 +1,282 @@
+#include "ui_morse_radio.hpp"
+#include "portapack_persistent_memory.hpp"
+
+using namespace portapack;
+
+namespace ui::external_app::morse_radio {
+
+MorseRadioView::MorseRadioView(ui::NavigationView& nav)
+ : nav_(nav) {
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+ add_children({&rssi,
+ &field_rf_amp,
+ &field_lna,
+ &field_vga,
+ &field_volume,
+ &field_frequency,
+ &txt_last,
+ &txt_speed,
+ &txt_freq,
+ &txt_clip,
+ &options_mode,
+ &btn_clear,
+ &console_text,
+ &labels,
+ &chk_log,
+ &field_squelch});
+ field_frequency.set_step(100);
+
+ btn_clear.on_select = [this](Button&) {
+ console_text.clear(true);
+ txt_last.set("");
+ time_stamp = false;
+ if (logger && save_log)
+ logger->init_daily_log(logs_dir);
+ };
+
+ field_squelch.on_change = [this](int32_t v) {
+ receiver_model.set_squelch_level(v);
+ };
+
+ options_mode.on_change = [this](size_t, int32_t mode) {
+ audio::output::stop();
+ receiver_model.disable();
+ morse_decoder_.resetLearning();
+ if (mode == MORSE_NFM) {
+ receiver_model.set_am_configuration(4);
+ receiver_model.set_modulation(ReceiverModel::Mode::NarrowbandFMAudio);
+ field_squelch.set_style(Theme::getInstance()->option_active);
+ field_squelch.set_focusable(true);
+ field_squelch.set_value(receiver_model.squelch_level());
+ audio::set_rate(audio::Rate::Hz_24000);
+ } else {
+ audio::set_rate(audio::Rate::Hz_12000);
+ receiver_model.set_modulation(ReceiverModel::Mode::AMAudio);
+ if (mode == MORSE_AM_CW || mode == MORSE_AM_DSB)
+ receiver_model.set_am_configuration(7);
+ else if (mode == MORSE_AM_USB)
+ receiver_model.set_am_configuration(9);
+ else // LSB
+ receiver_model.set_am_configuration(10);
+ field_squelch.set_style(Theme::getInstance()->fg_dark);
+ field_squelch.set_focusable(false);
+ }
+ baseband::set_moreserx_config(mode);
+ saved_mode = mode;
+
+ audio::output::start();
+ receiver_model.set_headphone_volume(receiver_model.headphone_volume()); // WM8731 hack.
+
+ receiver_model.set_sampling_rate(3072000);
+ receiver_model.set_baseband_bandwidth(1750000);
+ receiver_model.enable();
+ };
+ options_mode.set_selected_index(saved_mode);
+
+ logger = std::make_unique();
+ chk_log.on_select = [this](Checkbox&, bool save) {
+ save_log = save;
+ if (logger && save_log)
+ logger->init_daily_log(logs_dir);
+ };
+}
+
+void MorseLogger::init_daily_log(const std::filesystem::path& log_dir) {
+ auto now = rtc_time::now();
+ std::string pattern = "morse_";
+ uint16_t y = now.year();
+ pattern += (char)('0' + (y / 1000) % 10);
+ pattern += (char)('0' + (y / 100) % 10);
+ pattern += (char)('0' + (y / 10) % 10);
+ pattern += (char)('0' + (y % 10));
+
+ uint8_t m = now.month();
+ pattern += (char)('0' + (m / 10) % 10);
+ pattern += (char)('0' + (m % 10));
+
+ uint8_t d = now.day();
+ pattern += (char)('0' + (d / 10) % 10);
+ pattern += (char)('0' + (d % 10));
+
+ pattern += "_???.TXT";
+
+ auto full_pattern_path = log_dir / pattern;
+ auto final_path = next_filename_matching_pattern(full_pattern_path);
+ if (!final_path.empty()) {
+ this->append(final_path);
+ }
+}
+
+void MorseLogger::radio_set_log(const std::string& morse_mode) {
+ int64_t freq = receiver_model.target_frequency();
+ std::string header = "Freq:" + to_string_rounded_freq(freq, 4);
+ header += "MHz, ";
+ header += "RX MODE:" + morse_mode;
+ header += "\r\nMessage:";
+ log_file.write_raw(header);
+}
+
+bool MorseLogger::on_packet(const std::string& content, bool time, const std::string& morse_mode) {
+ if (!time) {
+ log_file.write_raw_no_newline("\r\n\r\n");
+ auto timestamp = to_string_datetime(rtc_time::now(), YMDHMS);
+ log_file.write_raw("[" + timestamp + "] ");
+ radio_set_log(morse_mode);
+ char_count = 0;
+ time = true;
+ }
+
+ if (content.empty()) return time;
+ std::string s;
+ for (char c : content) {
+ s = c;
+ if ((char_count >= 35 && c == ' ') || (char_count >= 47)) {
+ log_file.write_raw_no_newline("\r\n");
+
+ if (c != ' ') {
+ log_file.write_raw_no_newline(s);
+ char_count = 1;
+ } else {
+ char_count = 0;
+ }
+ } else {
+ log_file.write_raw_no_newline(s);
+ char_count++;
+ }
+ }
+
+ return time;
+}
+
+MorseRadioView::~MorseRadioView() {
+ receiver_model.disable();
+ baseband::shutdown();
+ audio::output::stop();
+}
+
+void MorseRadioView::focus() {
+ field_frequency.focus();
+ txt_clip.set_style(Theme::getInstance()->fg_red);
+ txt_clip.hidden(true);
+}
+
+void MorseRadioView::check_for_timeout() {
+ uint64_t now = chTimeNow();
+ if (last_activity_time == 0) {
+ last_activity_time = now;
+ return;
+ }
+ uint64_t quiet_duration = now - last_activity_time;
+ if (quiet_duration >= 60000) {
+ morse_decoder_.resetLearning();
+ time_stamp = false;
+ last_activity_time = now;
+ }
+}
+
+int32_t MorseRadioView::ProcessSignal(int32_t sig_time_us) {
+ int8_t sign = (sig_time_us > 0) ? 1 : ((sig_time_us < 0) ? -1 : 0);
+ int32_t result = 0;
+
+ if (last_sign_ == 0) {
+ last_sign_ = sign;
+ accumulator_us_ = sig_time_us;
+ long_pause_sent_ = false;
+ return 0;
+ }
+ if (sign == last_sign_) {
+ accumulator_us_ += sig_time_us;
+
+ if (sign < 0) {
+ int32_t threshold_us = morse_decoder_.getInterWordThreshold() * 1000;
+ if (!long_pause_sent_ && accumulator_us_ <= -threshold_us) {
+ result = accumulator_us_ / 1000;
+ long_pause_sent_ = true;
+ }
+ }
+ } else {
+ // state change
+ if (!long_pause_sent_) {
+ result = accumulator_us_ / 1000;
+ } else {
+ result = 0;
+ }
+ accumulator_us_ = sig_time_us;
+ last_sign_ = sign;
+ long_pause_sent_ = false;
+ }
+ return result;
+}
+
+void MorseRadioView::on_data(const MorseRXDataMessage* message) {
+ int32_t r;
+
+ txt_clip.hidden(!message->clipped);
+
+ for (uint8_t i = 0; i <= message->state_cnt; ++i) {
+ r = ProcessSignal(message->state_durations[i]);
+ auto result = morse_decoder_.handleInput((r != 0) ? r : 0);
+ if (result.isValid()) {
+ last_activity_time = chTimeNow(); // start reset timer on valid input
+ writeCharToConsole(result.text, result.confidence);
+ if (logger && save_log) {
+ time_stamp = logger->on_packet(result.text, time_stamp, options_mode.selected_index_name());
+ }
+ float dah_time = morse_decoder_.getCurrentTimeUnit() * 3.0f;
+ if (dah_time > 0) {
+ uint16_t wpm = (uint16_t)(3600.0f / (dah_time + 18.0f) + 0.5f);
+ txt_speed.set(to_string_dec_uint(wpm));
+ }
+ }
+ }
+}
+
+void MorseRadioView::on_freq(const MorseRXfreqMessage* message) {
+ std::string ret = " ";
+ uint32_t freq = message->measured_frequency;
+ if (freq < 301) {
+ ret = "<";
+ freq = 300;
+ }
+ if (freq > 2299) {
+ freq = 2300;
+ ret = ">";
+ }
+ if (freq < 400 || freq > 1400)
+ txt_freq.set_style(Theme::getInstance()->fg_red);
+ else if (freq <= 580 || freq >= 1220)
+ txt_freq.set_style(Theme::getInstance()->fg_yellow);
+ else
+ txt_freq.set_style(Theme::getInstance()->fg_green);
+ ret += to_string_dec_uint(freq);
+ txt_freq.set(ret);
+}
+
+void MorseRadioView::writeCharToConsole(const std::string& ch, double confidence) {
+ if (ch.empty()) {
+ return;
+ }
+
+ txt_last.set(morse_decoder_.getLastSequence().c_str());
+
+ last_color_id = color_id;
+ std::string color = "";
+
+ if (ch == " ") {
+ color_id = 0;
+ } else if (ch[0] == '{') { // no match
+ color_id = 0;
+ } else {
+ if (confidence < 0.8)
+ color_id = 1;
+ else if (confidence < 0.9)
+ color_id = 2;
+ else
+ color_id = 3;
+ }
+ color = arr_color[color_id];
+ last_color_id = color_id;
+ console_text.write(color + ch);
+}
+
+} // namespace ui::external_app::morse_radio
diff --git a/firmware/application/external/morse_radio/ui_morse_radio.hpp b/firmware/application/external/morse_radio/ui_morse_radio.hpp
new file mode 100644
index 000000000..bbf7de95e
--- /dev/null
+++ b/firmware/application/external/morse_radio/ui_morse_radio.hpp
@@ -0,0 +1,188 @@
+/*
+ * Copyright (C) 2026 Pezsma
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#ifndef __MORSE_RADIO_H__
+#define __MORSE_RADIO_H__
+
+#include "ui.hpp"
+#include "ui_widget.hpp"
+#include "ui_navigation.hpp"
+#include "string_format.hpp"
+#include "external_app.hpp"
+#include "ui_freq_field.hpp"
+#include "radio_state.hpp"
+#include "portapack.hpp"
+#include "message.hpp"
+#include "audio.hpp"
+#include "baseband_api.hpp"
+#include "receiver_model.hpp"
+#include "tone_key.hpp"
+#include "log_file.hpp"
+#include "file_path.hpp"
+#include "file.hpp"
+#include "rtc_time.hpp"
+#include "morsedecoder.hpp"
+
+#include
+
+namespace ui::external_app::morse_radio {
+
+class MorseRadioView;
+
+class MorseLogger {
+ public:
+ Optional append(const std::filesystem::path& filename) {
+ return log_file.append(filename);
+ }
+
+ void init_daily_log(const std::filesystem::path& log_dir);
+ bool on_packet(const std::string& content, bool time, const std::string& morse_mode);
+ void radio_set_log(const std::string& morse_mode);
+
+ private:
+ LogFile log_file{};
+ uint8_t char_count{0}; // log file line break
+};
+
+class MorseRadioView : public ui::View {
+ public:
+ MorseRadioView(ui::NavigationView& nav);
+ ~MorseRadioView();
+ std::string title() const override {
+ return "Morse";
+ }
+ void focus() override;
+
+ private:
+ void writeCharToConsole(const std::string& ch, double confidence);
+ void on_data(const MorseRXDataMessage* message);
+ void on_freq(const MorseRXfreqMessage* message);
+ void on_message(const Message* const p);
+ void check_for_timeout();
+ int32_t ProcessSignal(int32_t sig_time_us);
+ ui::NavigationView& nav_;
+ MorseDecoder morse_decoder_{};
+ RxRadioState radio_state_{};
+ std::unique_ptr logger{};
+
+ enum morse_modes : uint8_t {
+ MORSE_AM_CW = 0,
+ MORSE_NFM,
+ MORSE_AM_DSB,
+ MORSE_AM_USB,
+ MORSE_AM_LSB,
+ };
+ uint8_t saved_mode = MORSE_AM_CW;
+
+ app_settings::SettingsManager settings_{
+ "rx_morse_radio",
+ app_settings::Mode::RX,
+ {
+ {"cwmode"sv, &saved_mode},
+ }};
+
+ RxFrequencyField field_frequency{
+ {UI_POS_X(0), UI_POS_Y(0)},
+ nav_};
+ RFAmpField field_rf_amp{
+ {UI_POS_X(13), UI_POS_Y(0)}};
+ LNAGainField field_lna{
+ {UI_POS_X(15), UI_POS_Y(0)}};
+ VGAGainField field_vga{
+ {UI_POS_X(18), UI_POS_Y(0)}};
+ RSSI rssi{
+ {UI_POS_X(21), UI_POS_Y(0), UI_POS_WIDTH_REMAINING(24), 4}};
+
+ AudioVolumeField field_volume{
+ {UI_POS_X_RIGHT(2), UI_POS_Y(0)}};
+
+ NumberField field_squelch{
+ {UI_POS_X(10), UI_POS_Y(1)},
+ 2,
+ {0, 99},
+ 1,
+ ' ',
+ };
+ ui::Text txt_speed{{UI_POS_X(23), UI_POS_Y(1), UI_POS_WIDTH(3), UI_POS_HEIGHT(1)}, "??"};
+ ui::Text txt_last{{UI_POS_X(12), UI_POS_Y(4), UI_POS_WIDTH_REMAINING(12), UI_POS_HEIGHT(1)}, ""};
+ ui::Text txt_freq{{UI_POS_X(21), UI_POS_Y(2), UI_POS_WIDTH(5), UI_POS_HEIGHT(1)}, "??"};
+ ui::Text txt_clip{{UI_POS_X(15), UI_POS_Y(3), UI_POS_WIDTH(5), UI_POS_HEIGHT(1)}, "clipping"};
+ ui::Console console_text{{UI_POS_X(0), UI_POS_Y(5), UI_POS_MAXWIDTH, UI_POS_HEIGHT_REMAINING(7)}};
+ ui::Labels labels{
+ {{UI_POS_X(0), UI_POS_Y(1)}, "Squelch:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(15), UI_POS_Y(1)}, "Speed:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(26), UI_POS_Y(1)}, "wpm", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(4)}, "Last seq.:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(15), UI_POS_Y(2)}, "Tone:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(27), UI_POS_Y(2)}, "Hz", Theme::getInstance()->fg_light->foreground}};
+
+ ui::OptionsField options_mode{
+ {UI_POS_X(8), UI_POS_Y(2) + 4}, // +4 to align with 'Log' checkbox text
+ 6,
+ {
+ {"AM/CW", MORSE_AM_CW},
+ {"NFM", MORSE_NFM},
+ {"AM/DSB", MORSE_AM_DSB},
+ {"AM/USB", MORSE_AM_USB},
+ {"AM/LSB", MORSE_AM_LSB},
+ }};
+
+ Checkbox chk_log{{UI_POS_X(0), UI_POS_Y(2)}, 12, "Log", false};
+ ui::Button btn_clear{{UI_POS_X(0), UI_POS_Y_BOTTOM(2), UI_POS_WIDTH(6), UI_POS_HEIGHT(1)}, "CLR"};
+
+ std::string arr_color[4] = {STR_COLOR_WHITE, STR_COLOR_RED, STR_COLOR_YELLOW, STR_COLOR_GREEN};
+
+ bool long_pause_sent_{false};
+ bool save_log{false};
+ bool reset_triggered_{false};
+ bool time_stamp{false};
+ uint8_t last_color_id{255};
+ uint8_t color_id{255};
+ int8_t last_sign_{0};
+ uint8_t space_timer{0};
+ int32_t accumulator_us_{0};
+ uint64_t last_activity_time{0};
+
+ MessageHandlerRegistration message_handler_packet{
+ Message::ID::MorseRXData,
+ [this](Message* const p) {
+ const auto message = static_cast(p);
+ this->on_data(message);
+ }};
+
+ MessageHandlerRegistration message_handler_freq{
+ Message::ID::MorseRXfreq,
+ [this](Message* const p) {
+ const auto message = static_cast(p);
+ this->on_freq(message);
+ }};
+
+ MessageHandlerRegistration message_handler_framesync{
+ Message::ID::DisplayFrameSync,
+ [this](const Message* const p) {
+ (void)p;
+ this->check_for_timeout();
+ }};
+};
+
+} // namespace ui::external_app::morse_radio
+
+#endif // __MORSE_RADIO_H__
diff --git a/firmware/application/external/morseradiotx/main.cpp b/firmware/application/external/morseradiotx/main.cpp
new file mode 100644
index 000000000..c4af55f94
--- /dev/null
+++ b/firmware/application/external/morseradiotx/main.cpp
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2026 Pezsma
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_morse_radiotx.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::morseradiotx {
+
+void initialize_app(NavigationView& nav) {
+ nav.push();
+}
+
+} // namespace ui::external_app::morseradiotx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_morseradiotx.application_information"), used))
+application_information_t _application_information_morseradiotx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::morseradiotx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "Morse TX",
+ /*.bitmap_data = */ {
+ 0x00,
+ 0x00,
+ 0xFE,
+ 0x7F,
+ 0xFF,
+ 0xFF,
+ 0xBB,
+ 0xD0,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0x0B,
+ 0xE1,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xEB,
+ 0xD0,
+ 0xFF,
+ 0xFF,
+ 0xFE,
+ 0x7F,
+ 0x70,
+ 0x00,
+ 0x30,
+ 0x00,
+ 0x10,
+ 0x00,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::yellow().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_none */ {'P', 'M', 'R', 'T'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+
+} // extern "C"
diff --git a/firmware/application/external/morseradiotx/morsedecoder.hpp b/firmware/application/external/morseradiotx/morsedecoder.hpp
new file mode 100644
index 000000000..47b473f4d
--- /dev/null
+++ b/firmware/application/external/morseradiotx/morsedecoder.hpp
@@ -0,0 +1,389 @@
+#ifndef __MORSEDECODER_HPP__
+#define __MORSEDECODER_HPP__
+
+/*
+ * Copyright (C) 2025 Pezsma
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include
+#include
+#include "string_format.hpp"
+
+namespace ui::external_app::morseradiotx {
+
+class MorseRingBuffer {
+ public:
+ MorseRingBuffer()
+ : head_(0), tail_(0), count_(0) {}
+
+ void push_back(const uint32_t& value) {
+ data_[head_] = value;
+ head_ = (head_ + 1) % 40;
+ if (count_ < 40) {
+ count_++;
+ } else {
+ // overwrite oldest element
+ tail_ = (tail_ + 1) % 40;
+ }
+ }
+
+ void pop_front() {
+ if (count_ > 0) {
+ tail_ = (tail_ + 1) % 40;
+ count_--;
+ }
+ }
+
+ size_t size() const { return count_; }
+ bool empty() const { return count_ == 0; }
+ const uint32_t& front() const { return data_[tail_]; }
+ // Access by index (0 = oldest)
+ uint32_t operator[](size_t idx) const {
+ return data_[(tail_ + idx) % 40];
+ }
+ // Convert to vector-like access for sorting etc.
+ void copy_to_array(uint32_t* out) const {
+ for (size_t i = 0; i < count_; ++i)
+ out[i] = (*this)[i];
+ }
+
+ void clear() {
+ head_ = 0;
+ tail_ = 0;
+ count_ = 0;
+ }
+
+ private:
+ uint32_t data_[40];
+ size_t head_;
+ size_t tail_;
+ size_t count_;
+};
+
+class MorseDecoder {
+ public:
+ struct DecodeResult {
+ std::string text = "";
+ double confidence = 0.0;
+
+ bool isValid() const {
+ return !text.empty();
+ }
+ };
+
+ struct MorseEntry {
+ std::string code;
+ std::string letter;
+ };
+
+ MorseDecoder() {}
+
+ void resetLearning() {
+ time_unit_ms_ = 119.0;
+ current_sequence_ = "";
+ last_sequence_ = "";
+ last_confidence_ = 0.0;
+ pulse_history_.clear();
+ pulse_gaps_.clear();
+ }
+
+ const char* get_morse_pattern(char c) {
+ char upper_c = (c >= 'a' && c <= 'z') ? c - 32 : c;
+
+ for (size_t i = 0; i < morse_table_size_; ++i) {
+ if (morse_table_[i].letter[0] == upper_c) {
+ return morse_table_[i].code.c_str();
+ }
+ }
+ return nullptr;
+ }
+
+ DecodeResult
+ handleInput(int32_t duration_ms) {
+ DecodeResult result = {"", 0.0};
+
+ if (duration_ms < 5 && duration_ms > -5) return result;
+
+ if (duration_ms > 0) {
+ pulse_history_.push_back(duration_ms);
+ double dah_prob = getDahProbability(duration_ms);
+ current_sequence_ += (dah_prob > 0.5) ? '-' : '.';
+ last_confidence_ = (dah_prob > 0.5) ? dah_prob : (1.0 - dah_prob);
+ last_sequence_ = current_sequence_;
+
+ } else {
+ uint32_t gap_duration = -duration_ms;
+ pulse_gaps_.push_back(gap_duration);
+
+ if (gap_duration >= getInterCharThreshold() && !current_sequence_.empty()) {
+ result.text = lookupMorse(current_sequence_);
+
+ result.confidence = (result.text[0] != '{') ? last_confidence_ : 0.0;
+ if (gap_duration >= getInterWordThreshold()) {
+ result.text += " ";
+ }
+ current_sequence_ = "";
+ }
+ }
+
+ updateLearning();
+
+ return result;
+ }
+
+ inline double getInterElementThreshold() { return time_unit_ms_ * 0.8; }
+ inline double getInterCharThreshold() { return time_unit_ms_ * 2.5; }
+ inline double getInterWordThreshold() { return time_unit_ms_ * 6.0; }
+
+ inline double getCurrentTimeUnit() { return time_unit_ms_; }
+ inline std::string getLastSequence() { return last_sequence_; }
+
+ private:
+ std::string current_sequence_ = "";
+ std::string last_sequence_ = "";
+ double time_unit_ms_ = 119.0;
+ double last_confidence_ = 0.0;
+ MorseRingBuffer pulse_history_{};
+ MorseRingBuffer pulse_gaps_{};
+
+ std::string lookupMorse(const std::string& seq) {
+ for (size_t i = 0; i < morse_table_size_; i++) {
+ if (seq == morse_table_[i].code)
+ return morse_table_[i].letter;
+ }
+ return "{" + seq + "}"; // not found
+ }
+
+ double getDahProbability(uint32_t duration_ms) {
+ double start_interp = 1.5 * time_unit_ms_;
+ double end_interp = 2.5 * time_unit_ms_;
+
+ if (duration_ms <= start_interp) return 0.0;
+ if (duration_ms >= end_interp) return 1.0;
+ return ((double)(duration_ms)-start_interp) / (end_interp - start_interp);
+ }
+
+ size_t findDecisionBoundary(uint32_t* sorted_data, size_t sorted_data_size) {
+ if (sorted_data_size < 4) return 0;
+
+ size_t best_split_index = 0;
+ uint32_t max_diff = 0;
+
+ for (size_t i = 1; i < sorted_data_size; ++i) {
+ uint32_t diff = sorted_data[i] - sorted_data[i - 1];
+ if (diff > sorted_data[i - 1] * 0.5 && diff > max_diff) {
+ max_diff = diff;
+ best_split_index = i;
+ }
+ }
+ return best_split_index;
+ }
+
+ bool calculatePulseUnit(double& unit, double& confidence) {
+ if (pulse_history_.size() < 10) return false;
+ uint32_t sorted_pulses[pulse_history_.size()];
+ pulse_history_.copy_to_array(sorted_pulses);
+ sort_uint32(sorted_pulses, pulse_history_.size());
+
+ size_t split_index = findDecisionBoundary(sorted_pulses, pulse_history_.size());
+ if (split_index == 0 || split_index < 3 || (pulse_history_.size() - split_index) < 2) {
+ return false;
+ }
+ double dit_sum = sum_uint32_range(sorted_pulses, 0, split_index);
+ double dah_sum = sum_uint32_range(sorted_pulses, split_index, pulse_history_.size());
+
+ double avg_dit = dit_sum / split_index;
+ double avg_dah = dah_sum / (pulse_history_.size() - split_index);
+ if (avg_dah <= avg_dit) return false;
+
+ double ratio = avg_dah / avg_dit;
+ if (ratio > 1.5 && ratio < 5.0) {
+ unit = avg_dit;
+ double tmpabs = ratio - 3.0;
+ if (tmpabs < 0) tmpabs *= -1;
+ tmpabs /= 3.0;
+ tmpabs = 1.0 - tmpabs;
+ if (tmpabs < 0) tmpabs = 0;
+ confidence = tmpabs; // 0..1
+ return true;
+ }
+
+ return false;
+ }
+
+ bool calculateGapUnit(double& unit, double& confidence) {
+ if (pulse_gaps_.size() < 10) return false;
+ double threshold = getInterElementThreshold();
+ double valid_gaps[pulse_gaps_.size()];
+ size_t valid_count = 0;
+ for (size_t i = 0; i < pulse_gaps_.size(); i++) {
+ double gap = pulse_gaps_[i];
+ if (gap <= threshold) {
+ valid_gaps[valid_count++] = gap;
+ }
+ }
+ if (valid_count < 2) {
+ return false;
+ }
+ double sum = sum_double_range(valid_gaps, 0, valid_count);
+ size_t count_to_average = valid_count;
+
+ if (count_to_average > 0) {
+ unit = sum / count_to_average;
+ confidence = 0.8;
+ return true;
+ }
+
+ return false;
+ }
+
+ double sum_uint32_range(const uint32_t* data, size_t start, size_t end) {
+ double sum = 0.0;
+ for (size_t i = start; i < end; i++) {
+ sum += data[i];
+ }
+ return sum;
+ }
+ double sum_double_range(const double* data, size_t start, size_t end) {
+ double sum = 0.0;
+ for (size_t i = start; i < end; i++) {
+ sum += data[i];
+ }
+ return sum;
+ }
+
+ void sort_uint32(uint32_t* data, size_t size) {
+ if (size < 2)
+ return;
+
+ for (size_t i = 1; i < size; i++) {
+ uint32_t key = data[i];
+ size_t j = i;
+
+ while (j > 0 && data[j - 1] > key) {
+ data[j] = data[j - 1];
+ j--;
+ }
+
+ data[j] = key;
+ }
+ }
+
+ double clamp_double(double value, double min_val, double max_val) {
+ if (value < min_val)
+ return min_val;
+ else if (value > max_val)
+ return max_val;
+ else
+ return value;
+ }
+
+ void updateLearning() {
+ double pulse_unit = -1.0, pulse_confidence = 0.0;
+ double gap_unit = -1.0, gap_confidence = 0.0;
+
+ bool pulse_success = calculatePulseUnit(pulse_unit, pulse_confidence);
+ bool gap_success = calculateGapUnit(gap_unit, gap_confidence);
+
+ double new_time_unit = -1.0;
+ if (pulse_success && pulse_confidence > 0.5) {
+ new_time_unit = pulse_unit;
+ } else if (pulse_success && gap_success) {
+ gap_confidence = 0.2;
+ double total_confidence = pulse_confidence + gap_confidence;
+ new_time_unit = (pulse_unit * pulse_confidence + gap_unit * gap_confidence) / total_confidence;
+ } else if (gap_success) {
+ new_time_unit = gap_unit;
+ } else {
+ return;
+ }
+
+ double max_change = time_unit_ms_ * 0.25;
+ new_time_unit = clamp_double(new_time_unit, time_unit_ms_ - max_change, time_unit_ms_ + max_change);
+ double DEFAULT_TIME_UNIT = 160.0;
+ double BASE_LEARNING_RATE = 0.05;
+ double MAX_LEARNING_RATE = 0.25;
+ double tudeltaabs = new_time_unit - DEFAULT_TIME_UNIT;
+ if (tudeltaabs < 0) tudeltaabs *= -1;
+ double deviation_from_default = tudeltaabs / DEFAULT_TIME_UNIT;
+ double tpp = deviation_from_default * 2.0;
+ if (tpp > 1) tpp = 1;
+ double learning_factor = BASE_LEARNING_RATE + (MAX_LEARNING_RATE - BASE_LEARNING_RATE) * tpp;
+
+ time_unit_ms_ = (time_unit_ms_ * (1.0 - learning_factor)) + (new_time_unit * learning_factor);
+ }
+
+ size_t morse_table_size_ = 50;
+ MorseEntry morse_table_[50] = {
+ {".-", "A"},
+ {"-...", "B"},
+ {"-.-.", "C"},
+ {"-..", "D"},
+ {".", "E"},
+ {"..-.", "F"},
+ {"--.", "G"},
+ {"....", "H"},
+ {"..", "I"},
+ {".---", "J"},
+ {"-.-", "K"},
+ {".-..", "L"},
+ {"--", "M"},
+ {"-.", "N"},
+ {"---", "O"},
+ {".--.", "P"},
+ {"--.-", "Q"},
+ {".-.", "R"},
+ {"...", "S"},
+ {"-", "T"},
+ {"..-", "U"},
+ {"...-", "V"},
+ {".--", "W"},
+ {"-..-", "X"},
+ {"-.--", "Y"},
+ {"--..", "Z"},
+ {".----", "1"},
+ {"..---", "2"},
+ {"...--", "3"},
+ {"....-", "4"},
+ {".....", "5"},
+ {"-....", "6"},
+ {"--...", "7"},
+ {"---..", "8"},
+ {"----.", "9"},
+ {"-----", "0"},
+ {".-.-.-", "."},
+ {"..--..", "?"},
+ {"-.-.--", "!"},
+ {"--..--", ","},
+ {"-...-", "="},
+ {"-..-.", "/"},
+ {".--.-.", "@"},
+ {"---...", ":"},
+ {"-....-", "-"},
+ {".----.", "'"},
+ {".-..-.", "\""},
+ {"-.--.", "("},
+ {"-.--.-", ")"},
+ {".-.-.", "+"}};
+};
+
+} // namespace ui::external_app::morseradiotx
+
+#endif // __MORSEDECODER_HPP__
diff --git a/firmware/application/external/morseradiotx/ui_morse_radiotx.cpp b/firmware/application/external/morseradiotx/ui_morse_radiotx.cpp
new file mode 100644
index 000000000..5ef99e5c7
--- /dev/null
+++ b/firmware/application/external/morseradiotx/ui_morse_radiotx.cpp
@@ -0,0 +1,437 @@
+#include "ui_morse_radiotx.hpp"
+
+using namespace portapack;
+
+namespace ui::external_app::morseradiotx {
+
+static msg_t tx_thread_fn(void* arg) {
+ auto view = reinterpret_cast(arg);
+ chRegSetThreadName("morse_tx_thread");
+ view->transmit_morse_message();
+ return 0;
+}
+
+MorseRadiotxView::MorseRadiotxView(ui::NavigationView& nav)
+ : current_timings(calculate_morse_timings(20)),
+ nav_(nav) {
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+ add_children({&tx_view,
+ &field_volume,
+ &labels,
+ &options_mode,
+ &tone_,
+ &wpm_,
+ &txt_msg,
+ &btn_message,
+ &btn_calls,
+ &chk_trans,
+ &bandwidth,
+ &chk_callsgn,
+ &txt_last,
+ &console_text,
+ &btn_clear,
+ &btn_ptt});
+
+ initial_switch_config_ = get_switches_repeat_config();
+
+ SwitchesState config = initial_switch_config_;
+ config[toUType(Switch::Sel)] = false;
+ set_switches_repeat_config(config);
+
+ audio::set_rate(audio::Rate::Hz_24000);
+
+ btn_clear.on_select = [this](Button&) {
+ console_text.clear(true);
+ txt_last.set("");
+ };
+
+ options_mode.on_change = [this](size_t, int32_t value) {
+ current_mode = (uint8_t)value;
+ baseband::set_morsetx_config(current_mode, tone, band);
+ ui_toggle();
+ };
+
+ tone_.on_change = [this](int32_t v) {
+ tone = static_cast(v);
+ baseband::set_morsetx_config(current_mode, tone, band);
+ };
+
+ wpm_.on_change = [this](int16_t v) {
+ wpm = v;
+ current_timings = calculate_morse_timings(wpm);
+ };
+
+ bandwidth.on_change = [this](float v) {
+ band = v;
+ baseband::set_morsetx_config(current_mode, tone, band);
+ };
+
+ options_mode.set_selected_index(current_mode, true);
+ tone_.set_value(tone, true);
+ wpm_.set_value(wpm, true);
+ bandwidth.set_value(band, true);
+
+ btn_ptt.on_select = [this](Button&) {
+ if (button_touch) {
+ button_touch = false;
+ return;
+ }
+ button_was_selected = true;
+ onPress();
+ };
+
+ btn_ptt.on_touch_press = [this](Button&) {
+ button_touch = true;
+ button_was_selected = false;
+ onPress();
+ };
+ btn_ptt.on_touch_release = [this](Button&) {
+ if (chk_trans.value() && transmit) {
+ button_touch = true;
+ button_was_selected = false;
+ onRelease();
+ }
+ };
+
+ btn_message.on_select = [this, &nav](Button&) {
+ if (!transmit)
+ text_prompt(nav, msg_buffer, 27, ENTER_KEYBOARD_MODE_ALPHA, [this](std::string& buffer) {
+ msg_buffer = buffer;
+ txt_msg.set("[" + msg_buffer + "] ");
+ });
+ };
+
+ btn_calls.on_select = [this, &nav](Button&) {
+ if (!transmit) {
+ text_prompt(nav, call_sign, 10, ENTER_KEYBOARD_MODE_ALPHA, [this](std::string& buffer) {
+ call_sign = buffer;
+ if (call_sign.empty())
+ btn_calls.set_text("call sign?");
+ else
+ btn_calls.set_text(call_sign);
+ });
+ }
+ };
+
+ audio::output::start();
+ auto vol = field_volume.value();
+ field_volume.set_value(0);
+ field_volume.set_value(vol);
+
+ tx_view.on_start = [this]() {
+ if (!chk_trans.value() && msg_buffer.empty()) {
+ tx_view.set_transmitting(false);
+ return;
+ }
+ tx_view.focus();
+ if (!tx_thread && !thread_running) {
+ thread_running = true;
+ tx_thread = chThdCreateFromHeap(NULL, 1024, NORMALPRIO + 5, tx_thread_fn, this);
+ tx_view.set_transmitting(true);
+ }
+
+ else
+ tx_view.set_transmitting(false);
+ };
+
+ tx_view.on_stop = [this]() {
+ baseband::set_morsetx_key(false);
+ transmitter_model.disable();
+
+ if (tx_thread) {
+ chThdTerminate(tx_thread);
+ chThdWait(tx_thread);
+ }
+ transmit = false;
+ transmit_time = 0;
+ tx_thread = nullptr;
+ thread_running = false;
+ tx_view.set_transmitting(false);
+ ui_toggle();
+ };
+
+ tx_view.on_edit_frequency = [this, &nav]() {
+ auto new_view = nav.push(transmitter_model.target_frequency());
+ new_view->on_changed = [this](rf::Frequency f) {
+ transmitter_model.set_target_frequency(f);
+ };
+ };
+}
+
+MorseRadiotxView::~MorseRadiotxView() {
+ set_switches_repeat_config(initial_switch_config_);
+ if (tx_thread) {
+ chThdTerminate(tx_thread);
+ chThdWait(tx_thread);
+ if (!thread_running) tx_thread = nullptr;
+ }
+
+ transmitter_model.disable();
+ baseband::shutdown();
+ audio::output::stop();
+}
+
+void MorseRadiotxView::on_show() {
+ ptt_button_visibility(true);
+ start_time = 0;
+ end_time = 0;
+ transmit_time = 0;
+}
+
+void MorseRadiotxView::focus() {
+ options_mode.focus();
+}
+
+MorseRadiotxView::MorseTimings MorseRadiotxView::calculate_morse_timings(uint32_t wpm) {
+ MorseRadiotxView::MorseTimings t;
+ if (wpm < 10) wpm = 10;
+ if (wpm > 45) wpm = 45;
+ t.dot_ms = 1200 / wpm;
+
+ t.dash_ms = 3 * t.dot_ms;
+ t.symbol_gap = t.dot_ms;
+ t.char_gap = 3 * t.dot_ms;
+ t.word_gap = 7 * t.dot_ms;
+ return t;
+}
+
+void MorseRadiotxView::transmit_morse_message() {
+ std::string full_message = "";
+ // cal sign
+ if (chk_callsgn.value() && !call_sign.empty()) {
+ full_message = call_sign;
+ if (!chk_trans.value() && !msg_buffer.empty()) {
+ full_message += " " + msg_buffer;
+ }
+ } else {
+ if (!chk_trans.value()) full_message = msg_buffer;
+ }
+
+ if (full_message.empty() && !chk_trans.value()) {
+ ptt_button_visibility(false);
+ return;
+ }
+
+ // enable transmit
+ if (!transmit) {
+ transmit = true;
+ transmitter_model.enable();
+ ui_toggle();
+ }
+
+ for (size_t i = 0; i < full_message.length(); i++) {
+ if (chThdShouldTerminate()) break;
+
+ char c = full_message[i];
+
+ std::string s_char(1, c);
+ // space
+ if (c == ' ') {
+ console_text.write(" ");
+ chThdSleepMilliseconds(current_timings.word_gap);
+ continue;
+ }
+
+ const char* pattern = morse_decoder_.get_morse_pattern(c);
+
+ if (pattern != nullptr) {
+ txt_last.set(pattern);
+
+ // write blue char to console
+ console_text.write(STR_COLOR_BLUE + s_char);
+
+ // Morze (dih/dah) send
+ for (int j = 0; pattern[j] != '\0'; j++) {
+ baseband::set_morsetx_key(true);
+ if (pattern[j] == '.') {
+ chThdSleepMilliseconds(current_timings.dot_ms);
+ } else if (pattern[j] == '-') {
+ chThdSleepMilliseconds(current_timings.dash_ms);
+ }
+ if (chThdShouldTerminate()) break;
+ // pause between signs
+ baseband::set_morsetx_key(false);
+ chThdSleepMilliseconds(current_timings.symbol_gap);
+ }
+ if (chThdShouldTerminate()) break;
+ if (current_timings.char_gap > current_timings.symbol_gap) {
+ chThdSleepMilliseconds(current_timings.char_gap - current_timings.symbol_gap);
+ }
+ }
+ }
+
+ if (chk_trans.value()) ptt_button_visibility(false);
+ baseband::set_morsetx_key(false);
+ transmit_time = chTimeNow();
+ decode_timeout_calc = false;
+ thread_running = false;
+}
+
+void MorseRadiotxView::onPress() {
+ start_time = chTimeNow();
+ if (!transmit) {
+ transmit = true;
+ transmitter_model.enable();
+ }
+ baseband::set_morsetx_key(true);
+ if (end_time != 0) {
+ int64_t gap_delta = (chTimeNow() - end_time);
+ auto result = morse_decoder_.handleInput(-gap_delta);
+ if (result.isValid()) {
+ writeCharToConsole(result.text, result.confidence);
+ }
+ }
+ end_time = 0;
+ transmit_time = 0;
+ decode_timeout_calc = false;
+}
+
+void MorseRadiotxView::onRelease() {
+ end_time = chTimeNow();
+ transmit_time = end_time;
+ baseband::set_morsetx_key(false);
+ if (start_time != 0) {
+ int32_t press_delta = (end_time - start_time);
+ auto result = morse_decoder_.handleInput(press_delta);
+ if (result.isValid()) {
+ writeCharToConsole(result.text, result.confidence);
+ }
+ }
+ start_time = 0;
+ decode_timeout_calc = true;
+ baseband::request_beep_stop();
+}
+
+void MorseRadiotxView::writeCharToConsole(const std::string& ch, double confidence) {
+ if (ch.empty()) {
+ return;
+ }
+
+ txt_last.set(morse_decoder_.getLastSequence().c_str());
+
+ last_color_id = color_id;
+ std::string color = "";
+
+ if (ch == " ") {
+ color_id = 0;
+ } else if (ch[0] == '{') { // no match
+ color_id = 0;
+ } else {
+ if (confidence == 1.5)
+ color_id = 4;
+ else if (confidence < 0.8)
+ color_id = 1;
+ else if (confidence < 0.9)
+ color_id = 2;
+ else
+ color_id = 3;
+ }
+ color = arr_color[color_id];
+ last_color_id = color_id;
+ console_text.write(color + ch);
+}
+
+void MorseRadiotxView::ptt_button_visibility(bool hidden) {
+ bool hiddenorig = btn_ptt.hidden();
+ if (hiddenorig != hidden) {
+ btn_ptt.hidden(hidden);
+ if (!hidden) btn_ptt.focus();
+ if (hidden) {
+ // hack fix until widget hidig problem is not solved.
+ auto r = btn_ptt.screen_rect();
+ Painter p;
+ p.fill_rectangle_unrolled8(r, Theme::getInstance()->fg_light->background);
+ }
+ }
+}
+
+void MorseRadiotxView::ui_toggle() {
+ // 0=AM, 1=FM, 2=DSB, 3=USB, 4=LSB
+
+ if (transmit) {
+ options_mode.set_style(Theme::getInstance()->fg_dark);
+ options_mode.set_focusable(false);
+ tone_.set_style(Theme::getInstance()->fg_dark);
+ tone_.set_focusable(false);
+ wpm_.set_style(Theme::getInstance()->fg_dark);
+ wpm_.set_focusable(false);
+ btn_message.set_style(Theme::getInstance()->fg_dark);
+ btn_calls.set_style(Theme::getInstance()->fg_dark);
+ chk_trans.set_style(Theme::getInstance()->fg_dark);
+ chk_trans.set_focusable(false);
+ bandwidth.set_style(Theme::getInstance()->fg_dark);
+ bandwidth.set_focusable(false);
+ chk_callsgn.set_style(Theme::getInstance()->fg_dark);
+ chk_callsgn.set_focusable(false);
+
+ } else {
+ options_mode.set_style(Theme::getInstance()->bg_darker);
+ options_mode.set_focusable(true);
+ wpm_.set_style(Theme::getInstance()->bg_darker);
+ wpm_.set_focusable(true);
+ btn_message.set_style(Theme::getInstance()->bg_darker);
+ btn_calls.set_style(Theme::getInstance()->bg_darker);
+ chk_trans.set_style(Theme::getInstance()->bg_darker);
+ chk_trans.set_focusable(true);
+ chk_callsgn.set_style(Theme::getInstance()->bg_darker);
+ chk_callsgn.set_focusable(true);
+ ptt_button_visibility(true);
+ tone_.set_style(Theme::getInstance()->bg_darker);
+ tone_.set_focusable(true);
+
+ if (current_mode == 1) { // FM mode
+ bandwidth.set_style(Theme::getInstance()->bg_darker);
+ bandwidth.set_focusable(true);
+ } else {
+ bandwidth.set_style(Theme::getInstance()->fg_dark);
+ bandwidth.set_focusable(false);
+ }
+ } // transmit false
+}
+
+inline bool MorseRadiotxView::tx_button_held() {
+ const auto switches_state = get_switches_state();
+ return switches_state[(size_t)ui::KeyEvent::Select];
+}
+
+void MorseRadiotxView::on_framesync() {
+ if (button_was_selected && !button_touch && !tx_button_held()) {
+ button_was_selected = false;
+ onRelease();
+ }
+
+ if (end_time != 0 && decode_timeout_calc) {
+ int64_t gap_delta = (chTimeNow() - end_time);
+
+ if (gap_delta >= morse_decoder_.getInterCharThreshold()) {
+ auto result = morse_decoder_.handleInput(-(int32_t)gap_delta);
+ if (result.isValid()) {
+ writeCharToConsole(result.text, result.confidence);
+ }
+ }
+ if (gap_delta >= morse_decoder_.getInterWordThreshold()) {
+ writeCharToConsole(" ", 1.0);
+ end_time = 0;
+ decode_timeout_calc = false;
+ }
+ }
+ if (transmit_time != 0 && transmit) { // Tx disable if time is up
+ int64_t gap_delta = (chTimeNow() - transmit_time);
+ if (gap_delta >= ((morse_decoder_.getInterWordThreshold() * ((chk_trans.value()) ? 10 : 1)))) {
+ if (tx_thread) {
+ chThdTerminate(tx_thread);
+ chThdWait(tx_thread);
+ }
+ transmit = false;
+ tx_view.set_transmitting(false);
+ transmitter_model.disable();
+ thread_running = false;
+ tx_thread = nullptr;
+ transmit_time = 0;
+ if (!chk_trans.value()) writeCharToConsole(" ", 1.0);
+ ui_toggle();
+ }
+ }
+}
+
+} // namespace ui::external_app::morseradiotx
diff --git a/firmware/application/external/morseradiotx/ui_morse_radiotx.hpp b/firmware/application/external/morseradiotx/ui_morse_radiotx.hpp
new file mode 100644
index 000000000..c74bb576a
--- /dev/null
+++ b/firmware/application/external/morseradiotx/ui_morse_radiotx.hpp
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2026 Pezsma
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#ifndef __MORSE_RADIOTX_H__
+#define __MORSE_RADIOTX_H__
+
+#include "ui.hpp"
+#include "ui_widget.hpp"
+#include "ui_navigation.hpp"
+#include "ui_language.hpp"
+#include "ui_painter.hpp"
+#include "ui_freq_field.hpp"
+#include "ui_transmitter.hpp"
+#include "ui_textentry.hpp"
+#include "string_format.hpp"
+#include "morsedecoder.hpp"
+#include "irq_controls.hpp"
+#include "radio_state.hpp"
+#include "portapack.hpp"
+#include "message.hpp"
+#include "volume.hpp"
+#include "audio.hpp"
+#include "baseband_api.hpp"
+#include "external_app.hpp"
+#include "string_format.hpp"
+#include
+#include
+
+namespace ui::external_app::morseradiotx {
+
+class MorseRadiotxView : public ui::View {
+ public:
+ MorseRadiotxView(ui::NavigationView& nav);
+ ~MorseRadiotxView();
+
+ MorseRadiotxView(const MorseRadiotxView&) = delete;
+ MorseRadiotxView(MorseRadiotxView&&) = delete;
+ MorseRadiotxView& operator=(const MorseRadiotxView&) = delete;
+ MorseRadiotxView& operator=(MorseRadiotxView&&) = delete;
+
+ std::string title() const override { return "Morse Tx"; }
+ void focus() override;
+ void on_show() override;
+ void transmit_morse_message();
+
+ private:
+ struct MorseTimings {
+ uint32_t dot_ms;
+ uint32_t dash_ms;
+ uint32_t symbol_gap;
+ uint32_t char_gap;
+ uint32_t word_gap;
+ };
+
+ MorseTimings current_timings{};
+
+ std::string msg_indicator{""};
+ MorseTimings calculate_morse_timings(uint32_t wpm);
+ void onPress();
+ void onRelease();
+ void on_framesync();
+ void writeCharToConsole(const std::string& ch, double confidence);
+ void ui_toggle();
+ bool tx_button_held();
+ void ptt_button_visibility(bool hidden);
+
+ ui::NavigationView& nav_;
+ MorseDecoder morse_decoder_{};
+ TxRadioState radio_state_{};
+ Thread* tx_thread{nullptr};
+
+ std::string msg_buffer{"PORTAPACK"};
+ uint8_t current_mode{0}; // 0=AM, 1=FM, 2=DSB, 3=USB, 4=LSB
+ uint8_t wpm{20};
+ uint32_t tone{700};
+ float band{5.8};
+ std::string call_sign{""};
+
+ app_settings::SettingsManager settings_{
+ "tx_morseradio",
+ app_settings::Mode::TX,
+ {
+ {"cmode"sv, ¤t_mode},
+ {"audtone"sv, &tone},
+ {"wpm"sv, &wpm},
+ {"message"sv, &msg_buffer},
+ {"bandwith"sv, &band},
+ {"call_sign"sv, &call_sign},
+ }};
+
+ TransmitterView tx_view{
+ (int16_t)UI_POS_Y_BOTTOM(4),
+ 10000,
+ 0, false};
+
+ AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_Y_BOTTOM(5)}};
+ ui::OptionsField options_mode{
+ {UI_POS_X(5), UI_POS_Y(0)},
+ 3,
+ {{"AM", 0}, {"FM", 1}, {"DSB", 2}, {"USB", 3}, {"LSB", 4}}};
+ NumberField tone_{{UI_POS_X(14), UI_POS_Y(0)}, 4, {400, 1400}, 10, ' ', true};
+ NumberField wpm_{{UI_POS_X(25), UI_POS_Y(0)}, 2, {10, 45}, 1, ' ', true};
+ FloatField bandwidth{{UI_POS_X(20), UI_POS_Y(3)}, 4, {1.0, 16.0}, 0.1, ' ', true, 1};
+
+ ui::Text txt_msg{{UI_POS_X(0), UI_POS_Y(1), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)}, "[" + msg_buffer + "] "};
+ ui::Button btn_message{{UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(11), UI_POS_HEIGHT(1)}, "Message"};
+ ui::Button btn_calls{{UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH(11), UI_POS_HEIGHT(1)}, (call_sign.empty()) ? "call sign?" : call_sign};
+ Checkbox chk_trans{{UI_POS_X(14), UI_POS_Y(2)}, 13, "Manual trans.", true};
+ Checkbox chk_callsgn{{UI_POS_X(14), UI_POS_Y(4)}, 13, "Call sign", true};
+ ui::Text txt_last{{UI_POS_X(10), UI_POS_Y(5), UI_POS_WIDTH_REMAINING(10), UI_POS_HEIGHT(1)}, ""};
+ ui::Console console_text{{UI_POS_X(0), UI_POS_Y(7), UI_POS_MAXWIDTH, UI_POS_HEIGHT_REMAINING(14)}};
+ ui::Button btn_clear{{UI_POS_X(0), UI_POS_Y_BOTTOM(5), UI_POS_WIDTH(5), UI_POS_HEIGHT(1)}, "CLR"};
+ ui::Button btn_ptt{{UI_POS_X_CENTER(12), UI_POS_Y_BOTTOM(7), UI_POS_WIDTH(12), UI_POS_HEIGHT(3)}, "PTT"};
+
+ ui::Labels labels{
+ {{UI_POS_X(0), UI_POS_Y(0)}, "Mode:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(9), UI_POS_Y(0)}, "Tone:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(18), UI_POS_Y(0)}, "Hz", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(21), UI_POS_Y(0)}, "WPM:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(14), UI_POS_Y(3)}, "BandW:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(24), UI_POS_Y(3)}, "kHz", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(5)}, "Last seq:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(6)}, "Sent Message:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X_RIGHT(7), UI_POS_Y_BOTTOM(5)}, "Vol.:", Theme::getInstance()->fg_light->foreground},
+ };
+
+ uint8_t last_color_id{255};
+ uint8_t color_id{255};
+ std::string arr_color[4] = {STR_COLOR_WHITE, STR_COLOR_RED, STR_COLOR_YELLOW, STR_COLOR_GREEN};
+
+ bool button_touch{false};
+ bool button_was_selected{false};
+ bool decode_timeout_calc{false};
+ bool transmit{false};
+ bool thread_running = false;
+
+ uint8_t send_indicator{5};
+ int64_t start_time{0};
+ int64_t end_time{0};
+ int64_t transmit_time{0};
+ SwitchesState initial_switch_config_{};
+
+ MessageHandlerRegistration message_handler_framesync{
+ Message::ID::DisplayFrameSync,
+ [this](const Message* const p) {
+ (void)p;
+ this->on_framesync();
+ }};
+};
+
+} // namespace ui::external_app::morseradiotx
+
+#endif // __MORSE_RADIOTX_H__
diff --git a/firmware/application/external/noaaapt_rx/main.cpp b/firmware/application/external/noaaapt_rx/main.cpp
index 0e4c8e8fb..4e6e36e2a 100644
--- a/firmware/application/external/noaaapt_rx/main.cpp
+++ b/firmware/application/external/noaaapt_rx/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_noaaapt_rx.application_information"),
0x78,
0x00,
},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::RX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/ook_editor/main.cpp b/firmware/application/external/ook_editor/main.cpp
index 39692a4ab..3b64951e6 100644
--- a/firmware/application/external/ook_editor/main.cpp
+++ b/firmware/application/external/ook_editor/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_ook_editor.application_information"),
0xE0,
0x07,
},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::TX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/ookbrute/main.cpp b/firmware/application/external/ookbrute/main.cpp
index e323fda26..6eab43f7f 100644
--- a/firmware/application/external/ookbrute/main.cpp
+++ b/firmware/application/external/ookbrute/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_ookbrute.application_information"), us
0xE0,
0x07,
},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::TX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/p25_tx/main.cpp b/firmware/application/external/p25_tx/main.cpp
new file mode 100644
index 000000000..7d17ca539
--- /dev/null
+++ b/firmware/application/external/p25_tx/main.cpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2025 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_p25_tx.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::p25_tx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::p25_tx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_p25_tx.application_information"), used))
+application_information_t _application_information_p25_tx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::p25_tx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "P25 TX",
+ /*.bitmap_data = */ {
+ 0x00,
+ 0x00,
+ 0xFE,
+ 0x7F,
+ 0x02,
+ 0x40,
+ 0xFA,
+ 0x5F,
+ 0x0A,
+ 0x50,
+ 0x0A,
+ 0x50,
+ 0xFA,
+ 0x5F,
+ 0x02,
+ 0x40,
+ 0x02,
+ 0x40,
+ 0xE2,
+ 0x47,
+ 0x22,
+ 0x44,
+ 0xE2,
+ 0x47,
+ 0x02,
+ 0x40,
+ 0x02,
+ 0x40,
+ 0xFE,
+ 0x7F,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::orange().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+ /*.m4_app_tag = */ {'P', '2', '5', 'T'},
+ /*.m4_app_offset = */ 0x00000000,
+};
+}
diff --git a/firmware/application/external/p25_tx/ui_p25_tx.cpp b/firmware/application/external/p25_tx/ui_p25_tx.cpp
new file mode 100644
index 000000000..7f78d3a20
--- /dev/null
+++ b/firmware/application/external/p25_tx/ui_p25_tx.cpp
@@ -0,0 +1,282 @@
+/*
+ * Copyright (C) 2025 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui_p25_tx.hpp"
+#include "portapack.hpp"
+#include "transmitter_model.hpp"
+#include "baseband_api.hpp"
+#include "ui_freq_field.hpp"
+#include
+
+using namespace portapack;
+
+namespace ui::external_app::p25_tx {
+
+static constexpr uint8_t DUID_TSBK = 0x07;
+static constexpr uint8_t SS_TSCC = 0x02;
+
+// BCH(63,16) generator matrix from TIA-102.BAAA-A (ccemu/p25/bch.go)
+static const uint64_t bch_matrix[16] = {
+ 0x8000cd930bdd3b2aULL,
+ 0x4000ab5a8e33a6beULL,
+ 0x2000983e4cc4e874ULL,
+ 0x10004c1f2662743aULL,
+ 0x0800eb9c98ec0136ULL,
+ 0x0400b85d47ab3bb0ULL,
+ 0x02005c2ea3d59dd8ULL,
+ 0x01002e1751eaceecULL,
+ 0x0080170ba8f56776ULL,
+ 0x0040c616dfa78890ULL,
+ 0x0020630b6fd3c448ULL,
+ 0x00103185b7e9e224ULL,
+ 0x000818c2dbf4f112ULL,
+ 0x0004c1f2662743a2ULL,
+ 0x0002ad6a38ce9afbULL,
+ 0x00019b2617ba7657ULL,
+};
+
+static uint64_t bch_encode(uint16_t data) {
+ uint64_t cw = 0;
+ for (int i = 0; i < 16; i++)
+ if (data & (0x8000u >> i))
+ cw ^= bch_matrix[i];
+ return cw;
+}
+
+// CRC-CCITT bit-by-bit, init=0, final XOR=0xFFFF (ccemu/p25/crc.go)
+static uint16_t crc_ccitt_80(uint16_t high, uint64_t low) {
+ uint32_t crc = 0;
+ for (int i = 15; i >= 0; i--) {
+ crc <<= 1;
+ if (((crc >> 16) ^ ((high >> i) & 1)) & 1) crc ^= 0x1021;
+ }
+ for (int i = 63; i >= 0; i--) {
+ crc <<= 1;
+ if (((crc >> 16) ^ ((uint32_t)((low >> i) & 1))) & 1) crc ^= 0x1021;
+ }
+ return (uint16_t)((crc & 0xFFFF) ^ 0xFFFF);
+}
+
+// 1/2-rate trellis: [state][inputDibit]={outDibit0,outDibit1} (ccemu/p25/trellis.go)
+static const uint8_t trellis_table[4][4][2] = {
+ {{0, 2}, {3, 0}, {0, 1}, {3, 3}},
+ {{3, 2}, {0, 0}, {3, 1}, {0, 3}},
+ {{2, 1}, {1, 3}, {2, 2}, {1, 0}},
+ {{1, 1}, {2, 3}, {1, 2}, {2, 0}},
+};
+
+static void trellis_encode(const uint8_t* in48, uint8_t* out98) {
+ uint8_t state = 0;
+ for (int i = 0; i < 49; i++) {
+ uint8_t d = (i < 48) ? in48[i] : 0;
+ out98[i * 2 + 0] = trellis_table[state][d][0];
+ out98[i * 2 + 1] = trellis_table[state][d][1];
+ state = d;
+ }
+}
+
+static void data_interleave(const uint8_t* in98, uint8_t* out98) {
+ int idx = 0;
+ for (int j = 0; j < 97; j += 8) {
+ out98[idx++] = in98[j];
+ out98[idx++] = in98[j + 1];
+ }
+ for (int i = 2; i < 7; i += 2)
+ for (int j = 0; j < 89; j += 8) {
+ out98[idx++] = in98[i + j];
+ out98[idx++] = in98[i + j + 1];
+ }
+}
+
+static int insert_status(const uint8_t* data, int dlen, uint8_t* out, uint8_t ss) {
+ int num_ss = (dlen + 34) / 35, remaining = num_ss, oi = 0, i = 1;
+ for (int d = 0; d < dlen; d++) {
+ out[oi++] = data[d];
+ if ((i % 35 == 0) && remaining > 0) {
+ out[oi++] = ss;
+ remaining--;
+ }
+ i++;
+ }
+ while (remaining > 0) {
+ out[oi++] = 0;
+ if (i % 35 == 0) {
+ out[oi++] = ss;
+ remaining--;
+ }
+ i++;
+ }
+ return oi;
+}
+
+static void build_tsbk(uint8_t* out12, uint8_t opcode, uint64_t args) {
+ uint16_t high = (1u << 15) | ((uint16_t)opcode << 8);
+ uint16_t crc = crc_ccitt_80(high, args);
+ out12[0] = (uint8_t)(high >> 8);
+ out12[1] = (uint8_t)high;
+ for (int i = 0; i < 8; i++) out12[2 + i] = (uint8_t)(args >> (56 - 8 * i));
+ out12[10] = (uint8_t)(crc >> 8);
+ out12[11] = (uint8_t)crc;
+}
+
+static void tsbk_iden_up(uint8_t* out12, uint64_t freq_hz) {
+ uint64_t args = ((uint64_t)100 << 51) | ((uint64_t)100 << 32) | ((freq_hz / 5) & 0xFFFFFFFF);
+ build_tsbk(out12, 0x3D, args);
+}
+static void tsbk_net_status(uint8_t* out12, uint32_t wacn, uint16_t sysid) {
+ build_tsbk(out12, 0x3B, ((uint64_t)(wacn & 0xFFFFF) << 36) | ((uint64_t)(sysid & 0xFFF) << 24));
+}
+
+static void tsbk_grp_v_grant(uint8_t* out12, uint8_t chan_id, uint16_t chan_num, uint16_t tg) {
+ uint64_t ch = ((uint64_t)(chan_id & 0xF) << 12) | (chan_num & 0xFFF);
+ // opts: bit1=group
+ uint64_t args = ((uint64_t)0x02 << 56) | (ch << 40) | ((uint64_t)tg << 24);
+ build_tsbk(out12, 0x00, args);
+}
+static void tsbk_rfss_status(uint8_t* out12, uint16_t sysid, uint8_t rfssid, uint8_t siteid) {
+ build_tsbk(out12, 0x3A, ((uint64_t)(sysid & 0xFFF) << 40) | ((uint64_t)rfssid << 32) | ((uint64_t)siteid << 24));
+}
+
+static int build_frame(uint8_t* out, uint16_t nac, const uint8_t* tsbk12) {
+ uint8_t pre[160];
+ int idx = 0;
+ static const uint64_t FSW = 0x5575F5FF77FFULL;
+ for (int i = 0; i < 24; i++) pre[idx++] = (uint8_t)((FSW >> (46 - i * 2)) & 3);
+ uint64_t nid = bch_encode((uint16_t)((nac << 4) | DUID_TSBK));
+ for (int i = 0; i < 32; i++) pre[idx++] = (uint8_t)((nid >> (62 - i * 2)) & 3);
+ uint8_t in_d[48];
+ for (int i = 0; i < 12; i++) {
+ in_d[i * 4 + 0] = (tsbk12[i] >> 6) & 3;
+ in_d[i * 4 + 1] = (tsbk12[i] >> 4) & 3;
+ in_d[i * 4 + 2] = (tsbk12[i] >> 2) & 3;
+ in_d[i * 4 + 3] = (tsbk12[i] >> 0) & 3;
+ }
+ uint8_t enc[98], ilv[98];
+ trellis_encode(in_d, enc);
+ data_interleave(enc, ilv);
+ memcpy(&pre[idx], ilv, 98);
+ idx += 98;
+ return insert_status(pre, idx, out, SS_TSCC);
+}
+
+static constexpr int TX_BUF_SIZE = 512;
+
+static int fill_tx_buffer(uint8_t* buf, uint16_t nac, uint32_t wacn, uint16_t sysid, uint8_t rfssid, uint8_t siteid, uint64_t freq_hz, uint16_t tg, uint16_t vch, int& tsbk_idx) {
+ memset(buf, 0, TX_BUF_SIZE);
+ int total = 0;
+ uint8_t tsbk[12];
+ while (true) {
+ switch (tsbk_idx % 5) {
+ case 0:
+ tsbk_iden_up(tsbk, freq_hz);
+ break;
+ case 1:
+ tsbk_net_status(tsbk, wacn, sysid);
+ break;
+ case 2:
+ tsbk_rfss_status(tsbk, sysid, rfssid, siteid);
+ break;
+ case 3:
+ tsbk_grp_v_grant(tsbk, 0, vch, tg);
+ break;
+ default:
+ tsbk_net_status(tsbk, wacn, sysid);
+ break;
+ }
+ tsbk_idx++;
+ uint8_t probe[200];
+ int flen = build_frame(probe, nac, tsbk);
+ if (total + flen > TX_BUF_SIZE) break;
+ memcpy(&buf[total], probe, flen);
+ total += flen;
+ }
+ return total;
+}
+
+void P25TxView::start_tx() {
+ uint8_t dibits[TX_BUF_SIZE];
+ int len = fill_tx_buffer(dibits,
+ (uint16_t)field_nac.to_integer(), (uint32_t)field_wacn.to_integer(),
+ (uint16_t)field_sysid.to_integer(), (uint8_t)field_rfssid.to_integer(),
+ (uint8_t)field_siteid.to_integer(), (uint64_t)transmitter_model.target_frequency(),
+ (uint16_t)field_tg.to_integer(), (uint16_t)field_vch.to_integer(),
+ tsbk_idx_);
+ transmitter_model.enable();
+ tx_view.set_transmitting(true);
+ text_status.set("TX TSCC");
+ transmitting = true;
+ baseband::set_p25tx_data(dibits, (uint16_t)len);
+}
+
+void P25TxView::stop_tx() {
+ transmitting = false;
+ transmitter_model.disable();
+ tx_view.set_transmitting(false);
+ text_status.set("Ready");
+ tsbk_idx_ = 0;
+}
+
+void P25TxView::on_tx_progress(const uint32_t, const bool done) {
+ if (done && transmitting) {
+ uint8_t dibits[TX_BUF_SIZE];
+ int len = fill_tx_buffer(dibits,
+ (uint16_t)field_nac.to_integer(), (uint32_t)field_wacn.to_integer(),
+ (uint16_t)field_sysid.to_integer(), (uint8_t)field_rfssid.to_integer(),
+ (uint8_t)field_siteid.to_integer(), (uint64_t)transmitter_model.target_frequency(),
+ (uint16_t)field_tg.to_integer(), (uint16_t)field_vch.to_integer(),
+ tsbk_idx_);
+ baseband::set_p25tx_data(dibits, (uint16_t)len);
+ }
+}
+
+P25TxView::P25TxView(NavigationView& nav)
+ : nav_{nav} {
+ add_children({&labels_, &field_nac, &field_sysid, &field_wacn,
+ &field_rfssid, &field_siteid, &field_tg, &field_vch,
+ &text_status, &tx_view});
+ field_nac.set_value(0x293);
+ field_wacn.set_value(0xBEEF0);
+ field_sysid.set_value(0x001);
+ field_rfssid.set_value(1);
+ field_siteid.set_value(1);
+ field_tg.set_value(1);
+ field_vch.set_value(1);
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+ tx_view.on_start = [this]() { start_tx(); };
+ tx_view.on_stop = [this]() { stop_tx(); };
+ tx_view.on_edit_frequency = [this, &nav]() {
+ auto new_view = nav.push(transmitter_model.target_frequency());
+ new_view->on_changed = [this](rf::Frequency f) { transmitter_model.set_target_frequency(f); };
+ };
+}
+
+P25TxView::~P25TxView() {
+ if (transmitting)
+ stop_tx();
+ transmitter_model.disable();
+ baseband::shutdown();
+}
+
+void P25TxView::focus() {
+ field_nac.focus();
+}
+
+} // namespace ui::external_app::p25_tx
diff --git a/firmware/application/external/p25_tx/ui_p25_tx.hpp b/firmware/application/external/p25_tx/ui_p25_tx.hpp
new file mode 100644
index 000000000..b54f31af1
--- /dev/null
+++ b/firmware/application/external/p25_tx/ui_p25_tx.hpp
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2025 Sarah Rose
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#ifndef __UI_P25_TX
+#define __UI_P25_TX
+
+#include "ui.hpp"
+#include "ui_widget.hpp"
+#include "ui_transmitter.hpp"
+#include "ui_freq_field.hpp"
+#include "app_settings.hpp"
+#include "radio_state.hpp"
+#include "message.hpp"
+
+namespace ui::external_app::p25_tx {
+
+class P25TxView : public View {
+ public:
+ explicit P25TxView(NavigationView& nav);
+ ~P25TxView();
+
+ void focus() override;
+ std::string title() const override { return "P25 TX"; }
+
+ private:
+ void start_tx();
+ void stop_tx();
+ void on_tx_progress(const uint32_t progress, const bool done);
+ NavigationView& nav_;
+
+ TxRadioState radio_state_{
+ 155'000'000,
+ 12500,
+ 2'400'000};
+
+ app_settings::SettingsManager settings_{
+ "tx_p25_tx",
+ app_settings::Mode::TX};
+
+ Labels labels_{{
+ {{UI_POS_X(0), UI_POS_Y(0)}, "NAC:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(9), UI_POS_Y(0)}, "SYS:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(1)}, "WACN:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(2)}, "RFSS:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(9), UI_POS_Y(2)}, "SITE:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(3)}, "TG:", ui::Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(9), UI_POS_Y(3)}, "VCH:", ui::Theme::getInstance()->fg_light->foreground},
+ }};
+
+ SymField field_nac{{UI_POS_X(4), UI_POS_Y(0)}, 3, SymField::Type::Hex};
+ SymField field_sysid{{UI_POS_X(13), UI_POS_Y(0)}, 3, SymField::Type::Hex};
+ SymField field_wacn{{UI_POS_X(5), UI_POS_Y(1)}, 5, SymField::Type::Hex};
+ SymField field_rfssid{{UI_POS_X(5), UI_POS_Y(2)}, 2, SymField::Type::Hex};
+ SymField field_siteid{{UI_POS_X(14), UI_POS_Y(2)}, 2, SymField::Type::Hex};
+ SymField field_tg{{UI_POS_X(3), UI_POS_Y(3)}, 4, SymField::Type::Hex};
+ SymField field_vch{{UI_POS_X(13), UI_POS_Y(3)}, 3, SymField::Type::Hex};
+
+ Text text_status{{0, UI_POS_Y(4), UI_POS_MAXWIDTH, UI_POS_DEFAULT_HEIGHT}, "Ready"};
+
+ TransmitterView tx_view{
+ (int16_t)UI_POS_Y_BOTTOM(5),
+ 12500,
+ 0};
+
+ bool transmitting{false};
+ int tsbk_idx_{0};
+
+ MessageHandlerRegistration message_handler_tx_progress{
+ Message::ID::TXProgress,
+ [this](const Message* const p) {
+ const auto message = *reinterpret_cast(p);
+ this->on_tx_progress(message.progress, message.done);
+ }};
+};
+
+} // namespace ui::external_app::p25_tx
+
+#endif /* __UI_P25_TX */
diff --git a/firmware/application/external/pocsag_tx/main.cpp b/firmware/application/external/pocsag_tx/main.cpp
new file mode 100644
index 000000000..dc2a95fcf
--- /dev/null
+++ b/firmware/application/external/pocsag_tx/main.cpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2023 Bernd Herzog
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_pocsag_tx.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::pocsag_tx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::pocsag_tx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_pocsag_tx.application_information"), used)) application_information_t _application_information_pocsag_tx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::pocsag_tx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "POCSG TX",
+ /*.bitmap_data = */ {
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0xFC,
+ 0x3F,
+ 0xFE,
+ 0x7F,
+ 0x02,
+ 0x40,
+ 0xBA,
+ 0x45,
+ 0x02,
+ 0x40,
+ 0xFE,
+ 0x7F,
+ 0xFE,
+ 0x7F,
+ 0x92,
+ 0x7C,
+ 0x92,
+ 0x7C,
+ 0xFC,
+ 0x3F,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::green().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_fsktx */ {'P', 'F', 'S', 'K'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
diff --git a/firmware/application/apps/ui_pocsag_tx.cpp b/firmware/application/external/pocsag_tx/ui_pocsag_tx.cpp
similarity index 96%
rename from firmware/application/apps/ui_pocsag_tx.cpp
rename to firmware/application/external/pocsag_tx/ui_pocsag_tx.cpp
index b71aca63f..7c7a841aa 100644
--- a/firmware/application/apps/ui_pocsag_tx.cpp
+++ b/firmware/application/external/pocsag_tx/ui_pocsag_tx.cpp
@@ -31,7 +31,7 @@
using namespace portapack;
using namespace pocsag;
-namespace ui {
+namespace ui::external_app::pocsag_tx {
#define MAX_POCSAG_LENGTH 80
@@ -44,7 +44,7 @@ POCSAGTXView::~POCSAGTXView() {
baseband::shutdown();
}
-void POCSAGTXView::on_remote(const PocsagTosendMessage data) {
+void POCSAGTXView::on_remete(const PocsagTosendMessage data) {
// check if still sending or not
size_t tmp = 0;
if (data.baud == 1200) tmp = 1;
@@ -159,7 +159,7 @@ void POCSAGTXView::on_set_text(NavigationView& nav) {
POCSAGTXView::POCSAGTXView(
NavigationView& nav)
: nav_(nav) {
- baseband::run_image(portapack::spi_flash::image_tag_fsktx);
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
add_children({&labels,
&options_bitrate,
@@ -205,4 +205,4 @@ POCSAGTXView::POCSAGTXView(
};
}
-} /* namespace ui */
+} /* namespace ui::external_app::pocsag_tx */
diff --git a/firmware/application/apps/ui_pocsag_tx.hpp b/firmware/application/external/pocsag_tx/ui_pocsag_tx.hpp
similarity index 94%
rename from firmware/application/apps/ui_pocsag_tx.hpp
rename to firmware/application/external/pocsag_tx/ui_pocsag_tx.hpp
index daafb574e..0005688ce 100644
--- a/firmware/application/apps/ui_pocsag_tx.hpp
+++ b/firmware/application/external/pocsag_tx/ui_pocsag_tx.hpp
@@ -37,7 +37,7 @@
using namespace pocsag;
-namespace ui {
+namespace ui::external_app::pocsag_tx {
class POCSAGTXView : public View {
public:
@@ -76,7 +76,7 @@ class POCSAGTXView : public View {
void on_set_text(NavigationView& nav);
void on_tx_progress(const uint32_t progress, const bool done);
- void on_remote(const PocsagTosendMessage data);
+ void on_remete(const PocsagTosendMessage data);
bool start_tx();
Labels labels{
@@ -140,21 +140,21 @@ class POCSAGTXView : public View {
10000,
9};
+ MessageHandlerRegistration message_handler_tx_remete{
+ Message::ID::PocsagTosend,
+ [this](const Message* const p) {
+ const auto message = *reinterpret_cast(p);
+ this->on_remete(message);
+ }};
+
MessageHandlerRegistration message_handler_tx_progress{
Message::ID::TXProgress,
[this](const Message* const p) {
const auto message = *reinterpret_cast(p);
this->on_tx_progress(message.progress, message.done);
}};
-
- MessageHandlerRegistration message_handler_tx_remote{
- Message::ID::PocsagTosend,
- [this](const Message* const p) {
- const auto message = *reinterpret_cast(p);
- this->on_remote(message);
- }};
};
-} /* namespace ui */
+} /* namespace ui::external_app::pocsag_tx */
#endif /*__POCSAG_TX_H__*/
diff --git a/firmware/application/external/random_password/ui_random_password.cpp b/firmware/application/external/random_password/ui_random_password.cpp
index 5c6372eac..1c87d58f6 100644
--- a/firmware/application/external/random_password/ui_random_password.cpp
+++ b/firmware/application/external/random_password/ui_random_password.cpp
@@ -239,10 +239,10 @@ void RandomPasswordView::on_freqchg(int64_t freq) {
}
void RandomPasswordView::set_random_freq() {
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
// this is only for seed to visit random freq, the radio is still real random
- auto random_freq = 100000000 + (std::rand() % 900000000); // 100mhz to 1ghz
+ auto random_freq = 100000000 + (rand() % 900000000); // 100mhz to 1ghz
receiver_model.set_target_frequency(random_freq);
field_frequency.set_value(random_freq);
}
@@ -297,12 +297,12 @@ void RandomPasswordView::new_password() {
/// roll worker
for (int i = 0; i < password_length * 2; i += 2) {
unsigned int seed = seeds_deque[i];
- std::srand(seed);
+ srand(seed);
uint8_t rollnum = (uint8_t)(seeds_deque[i + 1] % 128);
uint8_t nu = 0;
- for (uint8_t o = 0; o < rollnum; ++o) nu = std::rand();
+ for (uint8_t o = 0; o < rollnum; ++o) nu = rand();
nu++;
- char c = charset[std::rand() % charset.length()];
+ char c = charset[rand() % charset.length()];
initial_password += c;
}
diff --git a/firmware/application/external/rtty_rx/baudot.cpp b/firmware/application/external/rtty_rx/baudot.cpp
new file mode 100644
index 000000000..bdf3e811a
--- /dev/null
+++ b/firmware/application/external/rtty_rx/baudot.cpp
@@ -0,0 +1,104 @@
+#include "baudot.hpp"
+#include
+
+namespace ui::external_app::rtty_rx {
+
+constexpr uint8_t CODE_FIGS = 0x1B;
+constexpr uint8_t CODE_LTRS = 0x1F;
+constexpr uint8_t CODE_SPACE = 0x04;
+
+char BaudotCoder::get_char_mapping(bool is_figures, uint8_t index) const {
+ if (index >= 32) return 0; // Safety check
+
+ if (is_figures) {
+ // ITA2 FIGURES Table
+ const char figures[32] = {
+ 0, '3', '\n', '-', ' ', '\'', '8', '7',
+ '\r', '$', '4', '\a', ',', '!', ':', '(',
+ '5', '+', ')', '2', '#', '6', '0', '1',
+ '9', '?', '&', 0, '.', '/', '=', 0};
+ return figures[index];
+ } else {
+ // ITA2 LETTERS Table
+ const char letters[32] = {
+ 0, 'E', '\n', 'A', ' ', 'S', 'I', 'U',
+ '\r', 'D', 'R', 'J', 'N', 'F', 'C', 'K',
+ 'T', 'Z', 'L', 'W', 'H', 'Y', 'P', 'Q',
+ 'O', 'B', 'G', 0, 'M', 'X', 'V', 0};
+ return letters[index];
+ }
+}
+
+// --- DECODE ---
+char BaudotCoder::decode(uint8_t baudotCode) {
+ uint8_t code = baudotCode & 0x1F;
+ if (code == CODE_FIGS) {
+ shiftState = FIGURES;
+ return 0;
+ }
+ if (code == CODE_LTRS) {
+ shiftState = LETTERS;
+ return 0;
+ }
+ if (usos_enabled && shiftState == FIGURES && code == CODE_SPACE) {
+ shiftState = LETTERS;
+ return ' ';
+ }
+ return get_char_mapping((shiftState == FIGURES), code);
+}
+
+void BaudotCoder::encode(const std::string& src, uint8_t* dest, uint16_t* dest_length, uint16_t dest_max_size) {
+ uint16_t idx = 0;
+
+ for (char c : src) {
+ if (idx >= dest_max_size) break;
+
+ char upper_c = std::toupper(c);
+ uint8_t code = 0;
+ bool found = false;
+ bool target_is_figures = false;
+ if (upper_c == ' ') {
+ code = CODE_SPACE;
+ found = true;
+ } else {
+ for (int i = 1; i < 32; i++) {
+ if (get_char_mapping(false, i) == upper_c) {
+ code = i;
+ found = true;
+ target_is_figures = false;
+ break;
+ }
+ }
+ if (!found) {
+ for (int i = 1; i < 32; i++) {
+ if (get_char_mapping(true, i) == upper_c) {
+ code = i;
+ found = true;
+ target_is_figures = true;
+ break;
+ }
+ }
+ }
+ }
+
+ if (found) {
+ if (target_is_figures && shiftState != FIGURES) {
+ if (idx + 1 >= dest_max_size) break;
+ dest[idx++] = CODE_FIGS;
+ shiftState = FIGURES;
+ } else if (!target_is_figures && shiftState != LETTERS && code != CODE_SPACE) {
+ if (idx + 1 >= dest_max_size) break;
+ dest[idx++] = CODE_LTRS;
+ shiftState = LETTERS;
+ }
+
+ dest[idx++] = code;
+ }
+ }
+
+ if (dest_length) {
+ *dest_length = idx;
+ }
+}
+
+} // namespace ui::external_app::rtty_rx
\ No newline at end of file
diff --git a/firmware/application/external/rtty_rx/baudot.hpp b/firmware/application/external/rtty_rx/baudot.hpp
new file mode 100644
index 000000000..9e9b5f068
--- /dev/null
+++ b/firmware/application/external/rtty_rx/baudot.hpp
@@ -0,0 +1,28 @@
+#ifndef BAUDOT_HPP
+#define BAUDOT_HPP
+
+#include
+#include
+
+namespace ui::external_app::rtty_rx {
+
+class BaudotCoder {
+ public:
+ enum ShiftState { LETTERS,
+ FIGURES };
+
+ BaudotCoder()
+ : shiftState(LETTERS) {}
+ char decode(uint8_t baudotCode);
+ void encode(const std::string& src, uint8_t* dest, uint16_t* dest_length, uint16_t dest_max_size);
+ void set_usos(bool enable) { usos_enabled = enable; } // shift == set to letters too
+
+ private:
+ ShiftState shiftState;
+ bool usos_enabled = true;
+ char get_char_mapping(bool is_figures, uint8_t index) const;
+};
+
+} // namespace ui::external_app::rtty_rx
+
+#endif // BAUDOT_HPP
\ No newline at end of file
diff --git a/firmware/application/external/rtty_rx/main.cpp b/firmware/application/external/rtty_rx/main.cpp
new file mode 100644
index 000000000..f75046ff9
--- /dev/null
+++ b/firmware/application/external/rtty_rx/main.cpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2026 HTotoo
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_rtty_rx.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::rtty_rx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::rtty_rx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_rtty_rx.application_information"), used)) application_information_t _application_information_rtty_rx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::rtty_rx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "RTTY",
+ /*.bitmap_data = */ {
+ 0x00,
+ 0x00,
+ 0xFE,
+ 0x1F,
+ 0xFE,
+ 0x3F,
+ 0x0E,
+ 0x78,
+ 0x0E,
+ 0x70,
+ 0x0E,
+ 0x70,
+ 0x0E,
+ 0x70,
+ 0x0E,
+ 0x78,
+ 0xFE,
+ 0x3F,
+ 0xFE,
+ 0x1F,
+ 0x8E,
+ 0x07,
+ 0x0E,
+ 0x0F,
+ 0x0E,
+ 0x1E,
+ 0x0E,
+ 0x3C,
+ 0x0E,
+ 0x78,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::yellow().v,
+ /*.menu_location = */ app_location_t::RX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_rttyrx */ {'P', 'R', 'T', 'R'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
\ No newline at end of file
diff --git a/firmware/application/external/rtty_rx/ui_rtty_rx.cpp b/firmware/application/external/rtty_rx/ui_rtty_rx.cpp
new file mode 100644
index 000000000..d36050dc1
--- /dev/null
+++ b/firmware/application/external/rtty_rx/ui_rtty_rx.cpp
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2026 HTotoo
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui_rtty_rx.hpp"
+
+#include "audio.hpp"
+#include "rtc_time.hpp"
+#include "baseband_api.hpp"
+#include "string_format.hpp"
+#include "portapack_persistent_memory.hpp"
+
+using namespace portapack;
+using namespace ui;
+
+// todo add timeout, so add newline and time when new data arrives after a time.
+
+namespace ui::external_app::rtty_rx {
+
+void RttyRxView::focus() {
+ field_frequency.focus();
+}
+
+RttyRxView::RttyRxView(NavigationView& nav)
+ : nav_{nav} {
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+ add_children({
+ &rssi,
+ &field_rf_amp,
+ &field_lna,
+ &field_vga,
+ &field_volume,
+ &field_frequency,
+ &labels,
+ &options_baud,
+ &console,
+ });
+ field_frequency.set_step(100);
+ audio::set_rate(audio::Rate::Hz_24000);
+ audio::output::start();
+
+ receiver_model.set_hidden_offset(0);
+ receiver_model.set_sampling_rate(3072000); // set the needed baseband SR.
+ receiver_model.set_baseband_bandwidth(1750000); // set the front-end RF BW filter.
+ receiver_model.enable();
+ console.enable_scrolling(false);
+
+ options_baud.on_change = [this](size_t, int32_t value) {
+ baseband::set_rtty_config(value, 170);
+ baud_conf = value;
+ };
+ options_baud.set_by_value(baud_conf);
+ if (baud_conf == 0) { // to trigger it when not changed
+ baseband::set_rtty_config(0, 170);
+ }
+}
+
+void RttyRxView::got_message(std::string msg) {
+ console.write(msg);
+}
+
+RttyRxView::~RttyRxView() {
+ receiver_model.set_hidden_offset(0);
+ receiver_model.disable();
+ baseband::shutdown();
+ audio::output::stop();
+}
+
+void RttyRxView::on_data(const RTTYDataMessage* message) {
+ std::string msg = "";
+ for (size_t i = 0; i < message->data_len; i++) {
+ const auto c = baudot_decoder.decode(message->data[i]);
+ if (c != 0)
+ msg += c;
+ }
+ got_message(msg);
+}
+
+} // namespace ui::external_app::rtty_rx
\ No newline at end of file
diff --git a/firmware/application/external/rtty_rx/ui_rtty_rx.hpp b/firmware/application/external/rtty_rx/ui_rtty_rx.hpp
new file mode 100644
index 000000000..47101dcbe
--- /dev/null
+++ b/firmware/application/external/rtty_rx/ui_rtty_rx.hpp
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2026 HTotoo
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#ifndef __UI_RTTY_RX_H__
+#define __UI_RTTY_RX_H__
+
+#include "ui.hpp"
+#include "ui_language.hpp"
+#include "ui_navigation.hpp"
+#include "ui_receiver.hpp"
+#include "ui_freq_field.hpp"
+#include "app_settings.hpp"
+#include "radio_state.hpp"
+#include "log_file.hpp"
+#include "utility.hpp"
+#include "ui_fileman.hpp"
+#include "file_path.hpp"
+#include "baudot.hpp"
+
+using namespace ui;
+
+namespace ui::external_app::rtty_rx {
+
+class RttyRxView : public View {
+ public:
+ RttyRxView(NavigationView& nav);
+ ~RttyRxView();
+
+ void focus() override;
+
+ std::string title() const override { return "RTTY"; };
+
+ private:
+ NavigationView& nav_;
+ RxRadioState radio_state_{};
+ uint32_t baud_conf = 0;
+ app_settings::SettingsManager settings_{
+ "rx_rtty",
+ app_settings::Mode::RX,
+ {
+ {"baud_conf"sv, &baud_conf},
+ }};
+
+ RxFrequencyField field_frequency{
+ {UI_POS_X(0), UI_POS_Y(0)},
+ nav_};
+ RFAmpField field_rf_amp{
+ {UI_POS_X(13), UI_POS_Y(0)}};
+ LNAGainField field_lna{
+ {UI_POS_X(15), UI_POS_Y(0)}};
+ VGAGainField field_vga{
+ {UI_POS_X(18), UI_POS_Y(0)}};
+ RSSI rssi{
+ {UI_POS_X(21), UI_POS_Y(0), UI_POS_WIDTH_REMAINING(24), 4}};
+ AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_Y(0)}};
+
+ Labels labels{
+ {{UI_POS_X(0), UI_POS_Y(1)}, "Baud:", Theme::getInstance()->fg_light->foreground}};
+
+ OptionsField options_baud{
+ {UI_POS_X(7), UI_POS_Y(1)},
+ 5,
+ {{"Auto", 0},
+ {"45", 4500},
+ {"45.45", 4545},
+ {"50", 5000},
+ {"75", 7500},
+ {"100", 10000},
+ {"110", 11000},
+ {"150", 15000},
+ {"200", 20000}}};
+
+ Console console{
+ {UI_POS_X(0), UI_POS_Y(2), UI_POS_MAXWIDTH, UI_POS_HEIGHT_REMAINING(3)}};
+
+ BaudotCoder baudot_decoder{};
+
+ void on_data(const RTTYDataMessage* message);
+ void got_message(std::string msg);
+
+ MessageHandlerRegistration message_handler_data{
+ Message::ID::RTTYData,
+ [this](Message* const p) {
+ const auto message = static_cast(p);
+ this->on_data(message);
+ }};
+};
+
+} // namespace ui::external_app::rtty_rx
+
+#endif /*__UI_RTTY_RX_H__*/
\ No newline at end of file
diff --git a/firmware/application/external/rtty_tx/baudot.cpp b/firmware/application/external/rtty_tx/baudot.cpp
new file mode 100644
index 000000000..be47ed2a3
--- /dev/null
+++ b/firmware/application/external/rtty_tx/baudot.cpp
@@ -0,0 +1,104 @@
+#include "baudot.hpp"
+#include
+
+namespace ui::external_app::rtty_tx {
+
+constexpr uint8_t CODE_FIGS = 0x1B;
+constexpr uint8_t CODE_LTRS = 0x1F;
+constexpr uint8_t CODE_SPACE = 0x04;
+
+char BaudotCoder::get_char_mapping(bool is_figures, uint8_t index) const {
+ if (index >= 32) return 0; // Safety check
+
+ if (is_figures) {
+ // ITA2 FIGURES Table
+ const char figures[32] = {
+ 0, '3', '\n', '-', ' ', '\'', '8', '7',
+ '\r', '$', '4', '\a', ',', '!', ':', '(',
+ '5', '+', ')', '2', '#', '6', '0', '1',
+ '9', '?', '&', 0, '.', '/', '=', 0};
+ return figures[index];
+ } else {
+ // ITA2 LETTERS Table
+ const char letters[32] = {
+ 0, 'E', '\n', 'A', ' ', 'S', 'I', 'U',
+ '\r', 'D', 'R', 'J', 'N', 'F', 'C', 'K',
+ 'T', 'Z', 'L', 'W', 'H', 'Y', 'P', 'Q',
+ 'O', 'B', 'G', 0, 'M', 'X', 'V', 0};
+ return letters[index];
+ }
+}
+
+// --- DECODE ---
+char BaudotCoder::decode(uint8_t baudotCode) {
+ uint8_t code = baudotCode & 0x1F;
+ if (code == CODE_FIGS) {
+ shiftState = FIGURES;
+ return 0;
+ }
+ if (code == CODE_LTRS) {
+ shiftState = LETTERS;
+ return 0;
+ }
+ if (usos_enabled && shiftState == FIGURES && code == CODE_SPACE) {
+ shiftState = LETTERS;
+ return ' ';
+ }
+ return get_char_mapping((shiftState == FIGURES), code);
+}
+
+void BaudotCoder::encode(const std::string& src, uint8_t* dest, uint16_t* dest_length, uint16_t dest_max_size) {
+ uint16_t idx = 0;
+
+ for (char c : src) {
+ if (idx >= dest_max_size) break;
+
+ char upper_c = std::toupper(c);
+ uint8_t code = 0;
+ bool found = false;
+ bool target_is_figures = false;
+ if (upper_c == ' ') {
+ code = CODE_SPACE;
+ found = true;
+ } else {
+ for (int i = 1; i < 32; i++) {
+ if (get_char_mapping(false, i) == upper_c) {
+ code = i;
+ found = true;
+ target_is_figures = false;
+ break;
+ }
+ }
+ if (!found) {
+ for (int i = 1; i < 32; i++) {
+ if (get_char_mapping(true, i) == upper_c) {
+ code = i;
+ found = true;
+ target_is_figures = true;
+ break;
+ }
+ }
+ }
+ }
+
+ if (found) {
+ if (target_is_figures && shiftState != FIGURES) {
+ if (idx + 1 >= dest_max_size) break;
+ dest[idx++] = CODE_FIGS;
+ shiftState = FIGURES;
+ } else if (!target_is_figures && shiftState != LETTERS && code != CODE_SPACE) {
+ if (idx + 1 >= dest_max_size) break;
+ dest[idx++] = CODE_LTRS;
+ shiftState = LETTERS;
+ }
+
+ dest[idx++] = code;
+ }
+ }
+
+ if (dest_length) {
+ *dest_length = idx;
+ }
+}
+
+} // namespace ui::external_app::rtty_tx
\ No newline at end of file
diff --git a/firmware/application/external/rtty_tx/baudot.hpp b/firmware/application/external/rtty_tx/baudot.hpp
new file mode 100644
index 000000000..7ccea500f
--- /dev/null
+++ b/firmware/application/external/rtty_tx/baudot.hpp
@@ -0,0 +1,28 @@
+#ifndef BAUDOT_HPP
+#define BAUDOT_HPP
+
+#include
+#include
+
+namespace ui::external_app::rtty_tx {
+
+class BaudotCoder {
+ public:
+ enum ShiftState { LETTERS,
+ FIGURES };
+
+ BaudotCoder()
+ : shiftState(LETTERS) {}
+ char decode(uint8_t baudotCode);
+ void encode(const std::string& src, uint8_t* dest, uint16_t* dest_length, uint16_t dest_max_size);
+ void set_usos(bool enable) { usos_enabled = enable; } // shift == set to letters too
+
+ private:
+ ShiftState shiftState;
+ bool usos_enabled = true;
+ char get_char_mapping(bool is_figures, uint8_t index) const;
+};
+
+} // namespace ui::external_app::rtty_tx
+
+#endif // BAUDOT_HPP
\ No newline at end of file
diff --git a/firmware/application/external/rtty_tx/main.cpp b/firmware/application/external/rtty_tx/main.cpp
new file mode 100644
index 000000000..9d80484e9
--- /dev/null
+++ b/firmware/application/external/rtty_tx/main.cpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2026 HTotoo
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_rtty_tx.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::rtty_tx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::rtty_tx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_rtty_tx.application_information"), used)) application_information_t _application_information_rtty_tx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::rtty_tx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "RTTY",
+ /*.bitmap_data = */ {
+ 0x00,
+ 0x00,
+ 0xFE,
+ 0x1F,
+ 0xFE,
+ 0x3F,
+ 0x0E,
+ 0x78,
+ 0x0E,
+ 0x70,
+ 0x0E,
+ 0x70,
+ 0x0E,
+ 0x70,
+ 0x0E,
+ 0x78,
+ 0xFE,
+ 0x3F,
+ 0xFE,
+ 0x1F,
+ 0x8E,
+ 0x07,
+ 0x0E,
+ 0x0F,
+ 0x0E,
+ 0x1E,
+ 0x0E,
+ 0x3C,
+ 0x0E,
+ 0x78,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::yellow().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_rttytx */ {'P', 'R', 'T', 'T'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
\ No newline at end of file
diff --git a/firmware/application/external/rtty_tx/ui_rtty_tx.cpp b/firmware/application/external/rtty_tx/ui_rtty_tx.cpp
new file mode 100644
index 000000000..8bbd552e7
--- /dev/null
+++ b/firmware/application/external/rtty_tx/ui_rtty_tx.cpp
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2026 HTotoo
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui_rtty_tx.hpp"
+
+#include "audio.hpp"
+#include "rtc_time.hpp"
+#include "baseband_api.hpp"
+#include "string_format.hpp"
+#include "portapack_persistent_memory.hpp"
+
+using namespace portapack;
+using namespace ui;
+
+namespace ui::external_app::rtty_tx {
+
+void RttyTxView::focus() {
+ options_baud.focus();
+}
+
+RttyTxView::RttyTxView(NavigationView& nav)
+ : nav_{nav} {
+ add_children({&tx_view,
+ &labels,
+ &options_baud,
+ &options_shift,
+ &btn_message,
+ &text_message,
+ &check_inverted,
+ &options_tone,
+ &text_tones});
+
+ options_baud.on_change = [this](size_t, int32_t value) {
+ baud_conf = value;
+ };
+ options_baud.set_by_value(baud_conf);
+
+ options_shift.on_change = [this](size_t, int32_t value) {
+ shift = value;
+ refresh_tones();
+ };
+ options_shift.set_by_value(shift);
+
+ btn_message.on_select = [this](Button&) {
+ text_prompt(nav_, message, 64, ENTER_KEYBOARD_MODE_ALPHA, [this](std::string& new_message) {
+ message = new_message;
+ text_message.set(message); // maybe replace to console
+ });
+ };
+ text_message.set(message);
+ options_tone.set_by_value(mark_tone);
+ options_tone.on_change = [this](size_t, int32_t value) {
+ mark_tone = value;
+ refresh_tones();
+ };
+ check_inverted.on_select = [this](Checkbox&, bool) {
+ refresh_tones();
+ };
+
+ tx_view.on_edit_frequency = [this, &nav]() {
+ auto new_view = nav.push(transmitter_model.target_frequency());
+ new_view->on_changed = [this](rf::Frequency f) {
+ transmitter_model.set_target_frequency(f);
+ };
+ };
+
+ tx_view.on_start = [this]() {
+ if (message.empty()) {
+ nav_.display_modal("Error", "Message is empty. Please set a message to transmit.");
+ return;
+ }
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+ transmitter_model.set_baseband_bandwidth(2048000);
+ baseband::set_sample_rate(2048000, OversampleRate::None);
+ transmitter_model.set_sampling_rate(2048000);
+ transmitter_model.set_baseband_bandwidth(2048000);
+ transmitter_model.enable();
+ rtty_message.baud = baud_conf;
+ rtty_message.shift = shift;
+ rtty_message.inverted = false; // check_inverted.value(); //i already invert it with the mark and space calculation
+ rtty_message.mark_tone = mark;
+ rtty_message.space_tone = space;
+ rtty_message.stopbits = stop_bits;
+ baudot_encoder.set_usos(false); // to be compatible with all
+ baudot_encoder.encode(message, rtty_message.data, &rtty_message.data_len, rtty_message.max_len);
+ baseband::set_rtty_config(rtty_message);
+ tx_view.set_transmitting(true);
+ };
+
+ tx_view.on_stop = [this]() {
+ stop();
+ };
+
+ refresh_tones();
+}
+
+void RttyTxView::refresh_tones() {
+ std::string tones_str = "";
+
+ int16_t shift = options_shift.selected_index_value();
+ if (mark_tone == -32000) {
+ mark = -shift / 2;
+ space = shift / 2;
+ } else {
+ if (mark_tone < 0) {
+ space = -(shift + abs(mark_tone));
+ } else {
+ space = shift + mark_tone;
+ }
+ mark = mark_tone;
+ }
+
+ if (check_inverted.value()) {
+ int16_t tmp = mark;
+ mark = space;
+ space = tmp;
+ }
+ tones_str = "Mark:" + to_string_dec_int(mark) + " Hz, Space:" + to_string_dec_int(space) + " Hz";
+ text_tones.set(tones_str);
+}
+
+void RttyTxView::on_tx_progress(bool done) {
+ if (done) stop();
+}
+
+void RttyTxView::stop() {
+ tx_view.set_transmitting(false);
+ transmitter_model.disable();
+ baseband::shutdown();
+}
+
+RttyTxView::~RttyTxView() {
+ stop();
+}
+
+} // namespace ui::external_app::rtty_tx
\ No newline at end of file
diff --git a/firmware/application/external/rtty_tx/ui_rtty_tx.hpp b/firmware/application/external/rtty_tx/ui_rtty_tx.hpp
new file mode 100644
index 000000000..3bf4ba435
--- /dev/null
+++ b/firmware/application/external/rtty_tx/ui_rtty_tx.hpp
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2026 HTotoo
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#ifndef __UI_RTTY_TX_H__
+#define __UI_RTTY_TX_H__
+
+#include "ui.hpp"
+#include "ui_language.hpp"
+#include "ui_navigation.hpp"
+#include "ui_transmitter.hpp"
+#include "ui_freq_field.hpp"
+#include "app_settings.hpp"
+#include "radio_state.hpp"
+#include "log_file.hpp"
+#include "utility.hpp"
+#include "ui_fileman.hpp"
+#include "file_path.hpp"
+#include "baudot.hpp"
+
+using namespace ui;
+
+namespace ui::external_app::rtty_tx {
+
+class RttyTxView : public View {
+ public:
+ RttyTxView(NavigationView& nav);
+ ~RttyTxView();
+ void focus() override;
+ std::string title() const override { return "RTTY"; };
+
+ private:
+ void on_tx_progress(bool done);
+ void stop();
+ void refresh_tones();
+
+ NavigationView& nav_;
+ RxRadioState radio_state_{};
+ uint32_t baud_conf = 4545; // default to 45.45 baud, which is common for RTTY.
+ uint32_t shift = 170; // 170 hz
+ std::string message = "PORTAPACK";
+ int32_t mark_tone = 0; // for option
+ int16_t mark = 0, space = 170;
+ uint8_t stop_bits = 3; // 2=1.0, 3=1.5, 4=2.0
+ app_settings::SettingsManager settings_{
+ "tx_rtty",
+ app_settings::Mode::TX,
+ {
+ {"baud_conf"sv, &baud_conf},
+ {"shift"sv, &shift},
+ {"message"sv, &message},
+ {"mark_tone"sv, &mark_tone},
+ }};
+
+ Labels labels{
+ {{UI_POS_X(0), UI_POS_Y(0)}, "Baud:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(14), UI_POS_Y(0)}, "Shift:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(15), UI_POS_Y(1)}, "Mark t:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(3)}, "Message:", Theme::getInstance()->fg_light->foreground}};
+
+ OptionsField options_baud{
+ {UI_POS_X(7), UI_POS_Y(0)},
+ 5,
+ {{"45", 4500},
+ {"45.45", 4545},
+ {"50", 5000},
+ {"75", 7500},
+ {"100", 10000},
+ {"110", 11000},
+ {"150", 15000},
+ {"200", 20000}}};
+
+ OptionsField options_shift{
+ {UI_POS_X(21), UI_POS_Y(0)},
+ 5,
+ {{"170", 170},
+ {"85", 85},
+ {"200", 200},
+ {"450", 450},
+ {"850", 850}}};
+
+ Checkbox check_inverted{
+ {UI_POS_X(0), UI_POS_Y(1)},
+ 10,
+ "Inverted",
+ true};
+
+ OptionsField options_tone{
+ {UI_POS_X(25), UI_POS_Y(1)},
+ 5,
+ {{"CNT", -32000},
+ {"0", 0},
+ {"2125", 2125},
+ {"-2125", -2125},
+ {"1275", 1275},
+ {"-1275", -1275}}};
+
+ Text text_tones{
+ {UI_POS_X(0), UI_POS_Y(2), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)},
+ "-"};
+
+ Button btn_message{
+ {UI_POS_X(0), UI_POS_Y(3), UI_POS_WIDTH(15), UI_POS_HEIGHT(2)},
+ "Set message"};
+
+ Text text_message{
+ {UI_POS_X(0), UI_POS_Y(5), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)},
+ ""};
+
+ TransmitterView tx_view{
+ (int16_t)UI_POS_Y_BOTTOM(4),
+ 1000,
+ 0};
+
+ RTTYDataMessage rtty_message{};
+ BaudotCoder baudot_encoder{};
+
+ MessageHandlerRegistration message_handler_tx_progress{
+ Message::ID::TXProgress,
+ [this](const Message* const p) {
+ const auto message = *reinterpret_cast(p);
+ this->on_tx_progress(message.done);
+ }};
+};
+
+} // namespace ui::external_app::rtty_tx
+
+#endif /*__UI_RTTY_TX_H__*/
\ No newline at end of file
diff --git a/firmware/application/external/same_tx/main.cpp b/firmware/application/external/same_tx/main.cpp
new file mode 100644
index 000000000..858e32ed1
--- /dev/null
+++ b/firmware/application/external/same_tx/main.cpp
@@ -0,0 +1,79 @@
+/*
+ * SAME TX - Specific Area Message Encoding Transmitter
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include "ui.hpp"
+#include "ui_same_tx.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::same_tx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::same_tx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_same_tx.application_information"), used)) application_information_t _application_information_same_tx = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::same_tx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "SAME TX",
+ /*.bitmap_data = */ {
+ // 16x16 icon - simple "!" alert symbol
+ 0x00,
+ 0x00,
+ 0x18,
+ 0x00,
+ 0x3C,
+ 0x00,
+ 0x3C,
+ 0x00,
+ 0x3C,
+ 0x00,
+ 0x3C,
+ 0x00,
+ 0x18,
+ 0x00,
+ 0x18,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x18,
+ 0x00,
+ 0x3C,
+ 0x00,
+ 0x3C,
+ 0x00,
+ 0x18,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::yellow().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_afsk */ {'P', 'A', 'F', 'T'},
+ /*.m4_app_offset = */ 0x00000000,
+};
+}
diff --git a/firmware/application/external/same_tx/ui_same_tx.cpp b/firmware/application/external/same_tx/ui_same_tx.cpp
new file mode 100644
index 000000000..849ee6c85
--- /dev/null
+++ b/firmware/application/external/same_tx/ui_same_tx.cpp
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2024 HTotoo
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+#include "ui_same_tx.hpp"
+#include "theme.hpp"
+#include "baseband_api.hpp"
+#include "portapack.hpp"
+#include "portapack_shared_memory.hpp"
+#include "transmitter_model.hpp"
+
+using namespace portapack;
+
+namespace ui::external_app::same_tx {
+
+static constexpr uint32_t SAME_SAMPLES_PER_BIT = 2949;
+static constexpr uint32_t SAME_MARK_FREQ = 2083;
+static constexpr uint32_t SAME_SPACE_FREQ = 1563;
+static constexpr uint32_t SAME_FM_DEVIATION = 5000;
+static constexpr uint8_t SAME_REPEAT = 3;
+
+static constexpr char SAME_ORG_CODES[][4] = {"WXR", "EAS", "CIV", "PEP"};
+static constexpr char SAME_EVT_CODES[][4] = {
+ "RWT",
+ "RMT",
+ "NPT",
+ "NST",
+ "NMT",
+ "EAN",
+ "EAT",
+ "NIC",
+ "ADR",
+ "AVA",
+ "AVW",
+ "BZW",
+ "CFW",
+ "CFS",
+ "DSW",
+ "EQW",
+ "EVI",
+ "FFW",
+ "FFS",
+ "FFH",
+ "FRW",
+ "HLS",
+ "HUW",
+ "HUH",
+ "SVR",
+ "TOR",
+};
+
+static uint8_t bitrev8(uint8_t b) {
+ b = (b & 0xF0) >> 4 | (b & 0x0F) << 4;
+ b = (b & 0xCC) >> 2 | (b & 0x33) << 2;
+ b = (b & 0xAA) >> 1 | (b & 0x55) << 1;
+ return b;
+}
+
+static void fmt2d(char* buf, int v) {
+ buf[0] = '0' + v / 10;
+ buf[1] = '0' + v % 10;
+}
+static void fmt3d(char* buf, int v) {
+ buf[0] = '0' + v / 100;
+ buf[1] = '0' + (v / 10) % 10;
+ buf[2] = '0' + v % 10;
+}
+
+void SameTxView::update_msg_preview() {
+ const char* org = SAME_ORG_CODES[field_org.selected_index()];
+ const char* evt = SAME_EVT_CODES[field_event_idx.value()];
+ int ss = field_fips_state.value();
+ int ccc = field_fips_county.value();
+ int dh = field_dur_h.value();
+ int dm = field_dur_m.value();
+ char buf[48];
+ char* p = buf;
+ // "WXR-RWT-SS0CCC+HHmm" (preview, no ZCZC header)
+ for (const char* s = org; *s; s++) *p++ = *s;
+ *p++ = '-';
+ for (const char* s = evt; *s; s++) *p++ = *s;
+ *p++ = '-';
+ fmt2d(p, ss);
+ p += 2;
+ *p++ = '0';
+ fmt3d(p, ccc);
+ p += 3;
+ *p++ = '+';
+ fmt2d(p, dh);
+ p += 2;
+ fmt2d(p, dm);
+ p += 2;
+ *p = '\0';
+ text_msg.set(buf);
+}
+
+void SameTxView::start_tx() {
+ const char* org = SAME_ORG_CODES[field_org.selected_index()];
+ const char* evt = SAME_EVT_CODES[field_event_idx.value()];
+ int ss = field_fips_state.value();
+ int ccc = field_fips_county.value();
+ int dh = field_dur_h.value();
+ int dm = field_dur_m.value();
+
+ // Build full SAME message: "ZCZC-ORG-EVT-SS0CCC+HHMM-0010000-STATION-"
+ char msg[64];
+ char* p = msg;
+ const char* hdr = "ZCZC-";
+ for (const char* s = hdr; *s; s++) *p++ = *s;
+ for (const char* s = org; *s; s++) *p++ = *s;
+ *p++ = '-';
+ for (const char* s = evt; *s; s++) *p++ = *s;
+ *p++ = '-';
+ fmt2d(p, ss);
+ p += 2;
+ *p++ = '0';
+ fmt3d(p, ccc);
+ p += 3;
+ *p++ = '+';
+ fmt2d(p, dh);
+ p += 2;
+ fmt2d(p, dm);
+ p += 2;
+ *p++ = '-';
+ // Issue time: fixed 0010000 (placeholder), station: SAME-TX
+ const char* tail = "0010000-SAMETX--";
+ for (const char* s = tail; *s; s++) *p++ = *s;
+ *p = '\0';
+
+ auto* words = reinterpret_cast(shared_memory.bb_data.data);
+ size_t idx = 0;
+ for (uint8_t i = 0; i < 16; i++)
+ words[idx++] = bitrev8(0xAB);
+ for (const char* c = msg; *c; c++)
+ words[idx++] = bitrev8(static_cast(*c));
+ words[idx] = 0;
+
+ tx_active_ = true;
+ text_status.set("Transmitting...");
+ transmitter_model.enable();
+ baseband::set_afsk_data(
+ SAME_SAMPLES_PER_BIT,
+ SAME_MARK_FREQ,
+ SAME_SPACE_FREQ,
+ SAME_REPEAT,
+ SAME_FM_DEVIATION,
+ 8);
+}
+
+void SameTxView::stop_tx() {
+ baseband::kill_afsk();
+ transmitter_model.disable();
+ tx_active_ = false;
+ text_status.set("Ready");
+ tx_view.set_transmitting(false);
+}
+
+void SameTxView::on_tx_progress(const uint32_t progress, const bool done) {
+ if (done) {
+ stop_tx();
+ text_status.set("Done!");
+ } else {
+ (void)progress;
+ text_status.set("TX...");
+ }
+}
+
+void SameTxView::focus() {
+ field_org.focus();
+}
+
+SameTxView::SameTxView(NavigationView& nav)
+ : nav_{nav} {
+ baseband::run_image(portapack::spi_flash::image_tag_afsk);
+
+ add_children({
+ &labels,
+ &field_org,
+ &field_event_idx,
+ &text_evt,
+ &field_fips_state,
+ &field_fips_county,
+ &field_dur_h,
+ &field_dur_m,
+ &text_msg,
+ &text_status,
+ &tx_view,
+ });
+
+ // Defaults: Ohio, county 007 (Ashtabula), 30 min
+ field_fips_state.set_value(39);
+ field_fips_county.set_value(7);
+ field_dur_h.set_value(0);
+ field_dur_m.set_value(30);
+
+ auto refresh = [this](int32_t) {
+ text_evt.set(SAME_EVT_CODES[field_event_idx.value()]);
+ update_msg_preview();
+ };
+ field_event_idx.on_change = refresh;
+ field_org.on_change = [this](size_t, int32_t) { update_msg_preview(); };
+ field_fips_state.on_change = [this](int32_t) { update_msg_preview(); };
+ field_fips_county.on_change = [this](int32_t) { update_msg_preview(); };
+ field_dur_h.on_change = [this](int32_t) { update_msg_preview(); };
+ field_dur_m.on_change = [this](int32_t) { update_msg_preview(); };
+
+ update_msg_preview();
+
+ tx_view.on_start = [this]() {
+ if (!tx_active_) {
+ start_tx();
+ tx_view.set_transmitting(true);
+ }
+ };
+ tx_view.on_stop = [this]() { stop_tx(); };
+}
+
+SameTxView::~SameTxView() {
+ transmitter_model.disable();
+ baseband::shutdown();
+}
+
+} // namespace ui::external_app::same_tx
diff --git a/firmware/application/external/same_tx/ui_same_tx.hpp b/firmware/application/external/same_tx/ui_same_tx.hpp
new file mode 100644
index 000000000..2331935d7
--- /dev/null
+++ b/firmware/application/external/same_tx/ui_same_tx.hpp
@@ -0,0 +1,84 @@
+#ifndef __UI_SAME_TX_H__
+#define __UI_SAME_TX_H__
+
+/*
+ * Copyright (C) 2024 HTotoo
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_widget.hpp"
+#include "ui_navigation.hpp"
+#include "ui_transmitter.hpp"
+#include "app_settings.hpp"
+#include "radio_state.hpp"
+#include "message.hpp"
+#include "modems.hpp"
+
+namespace ui::external_app::same_tx {
+
+class SameTxView : public View {
+ public:
+ SameTxView(NavigationView& nav);
+ ~SameTxView();
+ SameTxView(const SameTxView&) = delete;
+ SameTxView& operator=(const SameTxView&) = delete;
+ void focus() override;
+ std::string title() const override { return "SAME TX"; }
+
+ private:
+ NavigationView& nav_;
+ bool tx_active_{false};
+ void start_tx();
+ void stop_tx();
+ void on_tx_progress(const uint32_t progress, const bool done);
+ void update_msg_preview();
+
+ TxRadioState radio_state_{162550000, 1750000, AFSK_TX_SAMPLERATE};
+ app_settings::SettingsManager settings_{"tx_same", app_settings::Mode::TX};
+
+ Labels labels{{
+ {{0 * 8, 0 * 16}, "Org:", ui::Theme::getInstance()->fg_light->foreground},
+ {{8 * 8, 0 * 16}, "Evt:", ui::Theme::getInstance()->fg_light->foreground},
+ {{0 * 8, 1 * 16}, "FIPS:", ui::Theme::getInstance()->fg_light->foreground},
+ {{16 * 8, 1 * 16}, "Dur:", ui::Theme::getInstance()->fg_light->foreground},
+ }};
+ OptionsField field_org{
+ {4 * 8, 0 * 16},
+ 3,
+ {{"WXR", 0}, {"EAS", 1}, {"CIV", 2}, {"PEP", 3}}};
+ NumberField field_event_idx{{12 * 8, 0 * 16}, 2, {0, 25}, 1, '0', true};
+ Text text_evt{{15 * 8, 0 * 16, 3 * 8, 16}, "RWT"};
+ NumberField field_fips_state{{5 * 8, 1 * 16}, 2, {0, 99}, 1, '0'};
+ NumberField field_fips_county{{7 * 8 + 4, 1 * 16}, 3, {0, 999}, 1, '0'};
+ NumberField field_dur_h{{20 * 8, 1 * 16}, 2, {0, 99}, 1, '0'};
+ NumberField field_dur_m{{22 * 8, 1 * 16}, 2, {0, 59}, 1, '0'};
+ Text text_msg{{0, 2 * 16, 240, 16}, ""};
+ Text text_status{{0, 3 * 16, 240, 16}, "Ready"};
+ TransmitterView tx_view{(int16_t)UI_POS_Y_BOTTOM(4), 1000, 0};
+ MessageHandlerRegistration message_handler_tx_progress{
+ Message::ID::TXProgress,
+ [this](const Message* const p) {
+ const auto msg = *reinterpret_cast(p);
+ this->on_tx_progress(msg.progress, msg.done);
+ }};
+};
+
+} // namespace ui::external_app::same_tx
+#endif
diff --git a/firmware/application/external/scanner/ui_scanner.cpp b/firmware/application/external/scanner/ui_scanner.cpp
index 8d87b6c39..cc116eefe 100644
--- a/firmware/application/external/scanner/ui_scanner.cpp
+++ b/firmware/application/external/scanner/ui_scanner.cpp
@@ -461,6 +461,7 @@ ScannerView::ScannerView(
else
v = WFM_MODULATION;
field_mode.set_selected_index(v);
+ return; // Important ! Guru will occur if you don't return when modifying field from within on_change !
}
last_mode = v;
receiver_model.disable();
diff --git a/firmware/application/external/sd_wipe/ui_sd_wipe.cpp b/firmware/application/external/sd_wipe/ui_sd_wipe.cpp
index a482e784a..00bf9a8be 100644
--- a/firmware/application/external/sd_wipe/ui_sd_wipe.cpp
+++ b/firmware/application/external/sd_wipe/ui_sd_wipe.cpp
@@ -36,8 +36,11 @@ WipeSDView::WipeSDView(NavigationView& nav)
}
WipeSDView::~WipeSDView() {
- if (thread)
+ if (thread) {
chThdTerminate(thread);
+ chThdWait(thread);
+ thread = nullptr;
+ }
}
void WipeSDView::focus() {
diff --git a/firmware/application/external/sdusb/main.cpp b/firmware/application/external/sdusb/main.cpp
new file mode 100644
index 000000000..c1eb2fe55
--- /dev/null
+++ b/firmware/application/external/sdusb/main.cpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2024 Bernd Herzog
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_sd_over_usb.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::sdusb {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::sdusb
+
+extern "C" {
+
+__attribute__((section(".external_app.app_sdusb.application_information"), used)) application_information_t _application_information_sdusb = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::sdusb::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "SD Over USB",
+ /*.bitmap_data = */ {
+ 0xF0,
+ 0x0F,
+ 0x10,
+ 0x08,
+ 0x50,
+ 0x0A,
+ 0x10,
+ 0x08,
+ 0x10,
+ 0x08,
+ 0x10,
+ 0x08,
+ 0xF8,
+ 0x1F,
+ 0xF8,
+ 0x1F,
+ 0xF8,
+ 0x1F,
+ 0xF8,
+ 0x1F,
+ 0xF8,
+ 0x1F,
+ 0xF8,
+ 0x1F,
+ 0xF8,
+ 0x1F,
+ 0xF0,
+ 0x0F,
+ 0x80,
+ 0x01,
+ 0x80,
+ 0x01,
+ },
+ /*.icon_color = */ ui::Color::yellow().v,
+ /*.menu_location = */ app_location_t::UTILITIES,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_usb_sd */ {'P', 'U', 'S', 'B'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
diff --git a/firmware/application/apps/ui_sd_over_usb.cpp b/firmware/application/external/sdusb/ui_sd_over_usb.cpp
similarity index 92%
rename from firmware/application/apps/ui_sd_over_usb.cpp
rename to firmware/application/external/sdusb/ui_sd_over_usb.cpp
index 937809151..57a20466d 100644
--- a/firmware/application/apps/ui_sd_over_usb.cpp
+++ b/firmware/application/external/sdusb/ui_sd_over_usb.cpp
@@ -23,7 +23,7 @@
#include "ui_sd_over_usb.hpp"
#include "portapack_shared_memory.hpp"
-namespace ui {
+namespace ui::external_app::sdusb {
SdOverUsbView::SdOverUsbView(NavigationView& nav)
: nav_(nav) {
@@ -46,7 +46,7 @@ SdOverUsbView::SdOverUsbView(NavigationView& nav)
sdcStop(&SDCD1);
portapack::shutdown(true);
- m4_init(portapack::spi_flash::image_tag_usb_sd, portapack::memory::map::m4_code, false);
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
m0_halt();
/* will not return*/
};
@@ -56,4 +56,4 @@ void SdOverUsbView::focus() {
button_run.focus();
}
-} /* namespace ui */
+} // namespace ui::external_app::sdusb
diff --git a/firmware/application/apps/ui_sd_over_usb.hpp b/firmware/application/external/sdusb/ui_sd_over_usb.hpp
similarity index 96%
rename from firmware/application/apps/ui_sd_over_usb.hpp
rename to firmware/application/external/sdusb/ui_sd_over_usb.hpp
index 1c39c7e89..0784136ed 100644
--- a/firmware/application/apps/ui_sd_over_usb.hpp
+++ b/firmware/application/external/sdusb/ui_sd_over_usb.hpp
@@ -32,7 +32,7 @@
#include
-namespace ui {
+namespace ui::external_app::sdusb {
class SdOverUsbView : public View {
public:
@@ -58,6 +58,6 @@ class SdOverUsbView : public View {
"Run"};
};
-} /* namespace ui */
+} // namespace ui::external_app::sdusb
#endif /*__UI_SD_OVER_USB_H__*/
diff --git a/firmware/application/external/shoppingcart_lock/main.cpp b/firmware/application/external/shoppingcart_lock/main.cpp
index e7f483a49..874a93d25 100644
--- a/firmware/application/external/shoppingcart_lock/main.cpp
+++ b/firmware/application/external/shoppingcart_lock/main.cpp
@@ -58,7 +58,7 @@ __attribute__((section(".external_app.app_shoppingcart_lock.application_informat
0x7E,
0x00,
},
- /*.icon_color = */ ui::Color::red().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::UTILITIES,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/siggen/main.cpp b/firmware/application/external/siggen/main.cpp
new file mode 100644
index 000000000..d4c62a968
--- /dev/null
+++ b/firmware/application/external/siggen/main.cpp
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2023 Bernd Herzog
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_siggen.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::siggen {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::siggen
+extern "C" {
+
+__attribute__((section(".external_app.app_siggen.application_information"), used)) application_information_t _application_information_siggen = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::siggen::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "Signal gen",
+ /*.bitmap_data = */ {
+ 0x18,
+ 0x00,
+ 0x24,
+ 0x00,
+ 0x42,
+ 0x00,
+ 0x42,
+ 0x00,
+ 0x42,
+ 0x00,
+ 0x42,
+ 0x00,
+ 0x81,
+ 0x00,
+ 0xAB,
+ 0x6A,
+ 0x80,
+ 0x40,
+ 0x00,
+ 0x21,
+ 0x00,
+ 0x21,
+ 0x00,
+ 0x21,
+ 0x00,
+ 0x21,
+ 0x00,
+ 0x12,
+ 0x00,
+ 0x0C,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::green().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_siggen */ {'P', 'S', 'I', 'G'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
diff --git a/firmware/application/apps/ui_siggen.cpp b/firmware/application/external/siggen/ui_siggen.cpp
similarity index 97%
rename from firmware/application/apps/ui_siggen.cpp
rename to firmware/application/external/siggen/ui_siggen.cpp
index 032dbd56c..24d3e8931 100644
--- a/firmware/application/apps/ui_siggen.cpp
+++ b/firmware/application/external/siggen/ui_siggen.cpp
@@ -31,7 +31,7 @@
using namespace portapack;
-namespace ui {
+namespace ui::external_app::siggen {
void SigGenView::focus() {
options_shape.focus();
@@ -75,7 +75,7 @@ void SigGenView::on_tx_progress(const uint32_t progress, const bool done) {
SigGenView::SigGenView(
NavigationView& nav) {
- baseband::run_image(portapack::spi_flash::image_tag_siggen);
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
add_children({&labels,
&options_mod,
@@ -172,4 +172,4 @@ SigGenView::SigGenView(
};
}
-} /* namespace ui */
\ No newline at end of file
+} // namespace ui::external_app::siggen
\ No newline at end of file
diff --git a/firmware/application/apps/ui_siggen.hpp b/firmware/application/external/siggen/ui_siggen.hpp
similarity index 98%
rename from firmware/application/apps/ui_siggen.hpp
rename to firmware/application/external/siggen/ui_siggen.hpp
index ce771fff0..bac6da79a 100644
--- a/firmware/application/apps/ui_siggen.hpp
+++ b/firmware/application/external/siggen/ui_siggen.hpp
@@ -33,7 +33,7 @@
#include "portapack.hpp"
#include "message.hpp"
-namespace ui {
+namespace ui::external_app::siggen {
class SigGenView : public View {
public:
@@ -139,6 +139,6 @@ class SigGenView : public View {
}};
};
-} /* namespace ui */
+} // namespace ui::external_app::siggen
#endif /*__SIGGEN_H__*/
diff --git a/firmware/application/external/snake/ui_snake.cpp b/firmware/application/external/snake/ui_snake.cpp
index 2b62e9f77..25ba87b75 100644
--- a/firmware/application/external/snake/ui_snake.cpp
+++ b/firmware/application/external/snake/ui_snake.cpp
@@ -221,7 +221,7 @@ void SnakeView::paint(Painter& painter) {
(void)painter;
if (!initialized) {
initialized = true;
- std::srand(LPC_RTC->CTIME0);
+ srand(LPC_RTC->CTIME0);
init_game();
}
}
diff --git a/firmware/application/external/soundboard/main.cpp b/firmware/application/external/soundboard/main.cpp
index f5fd39d20..6478464e3 100644
--- a/firmware/application/external/soundboard/main.cpp
+++ b/firmware/application/external/soundboard/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_soundboard.application_information"),
0xF0,
0xFF,
},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::TX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/spainter/main.cpp b/firmware/application/external/spainter/main.cpp
index b12817a64..5e038f52a 100644
--- a/firmware/application/external/spainter/main.cpp
+++ b/firmware/application/external/spainter/main.cpp
@@ -73,7 +73,7 @@ __attribute__((section(".external_app.app_spainter.application_information"), us
0xC0,
0x01,
},
- /*.icon_color = */ ui::Color::orange().v,
+ /*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::TX,
/*.desired_menu_position = */ -1,
diff --git a/firmware/application/external/subcarrx/ui_subcar.cpp b/firmware/application/external/subcarrx/ui_subcar.cpp
index cfd6bdef0..7b88ec740 100644
--- a/firmware/application/external/subcarrx/ui_subcar.cpp
+++ b/firmware/application/external/subcarrx/ui_subcar.cpp
@@ -91,6 +91,7 @@ SubCarView::SubCarView(NavigationView& nav)
&button_clear_list,
&check_log,
&labels,
+ &options_mode,
&recent_entries_view});
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
@@ -114,11 +115,17 @@ SubCarView::SubCarView(NavigationView& nav)
recent_entries_view.on_select = [this](const SubCarRecentEntry& entry) {
nav_.push(entry);
};
- baseband::set_subghzd_config(0, receiver_model.sampling_rate()); // 0=am
- receiver_model.enable();
+
+ options_mode.on_change = [this](size_t, int32_t v) {
+ modulation = v;
+ chThdSleepMilliseconds(100); // wait for the baseband thread to process the previous config, to avoid glitchy output when switching modes
+ baseband::set_subghzd_config(modulation, receiver_model.sampling_rate());
+ };
signal_token_tick_second = rtc_time::signal_tick_second += [this]() {
on_tick_second();
};
+ options_mode.set_selected_index(modulation, true);
+ receiver_model.enable();
}
void SubCarView::on_tick_second() {
@@ -176,6 +183,8 @@ const char* SubCarView::getSensorTypeName(FPROTO_SUBCAR_SENSOR type) {
return "Fiat V0";
case FPC_BMWV0:
return "BMW V0";
+ /* case FPC_KIAV6:
+ return "Kia V6";*/
case FPC_Invalid:
default:
@@ -266,10 +275,11 @@ void SubCarRecentEntryDetailView::parseProtocol() {
if (entry_.sensorType == FPC_Invalid) return;
if (entry_.sensorType == FPC_SUZUKI) {
- uint32_t serial_button = (((entry_.data >> 32) & 0xFFF) << 20) | (entry_.data >> 12);
- serial = serial_button >> 4;
- uint8_t buttonid = serial_button & 0xF;
- cnt = (entry_.data >> 44) & 0xFFFF;
+ uint32_t data_high = (uint32_t)(entry_.data >> 32);
+ uint32_t data_low = (uint32_t)entry_.data;
+ serial = ((data_high & 0xFFF) << 16) | (data_low >> 16);
+ uint8_t buttonid = (data_low >> 12) & 0xF;
+ cnt = (data_high << 4) >> 16;
btn = to_string_dec_uint(buttonid);
return;
}
@@ -320,15 +330,78 @@ void SubCarRecentEntryDetailView::parseProtocol() {
}
if (entry_.sensorType == FPC_KIAV5) {
- serial = (uint32_t)(((entry_.data >> 32) & 0x0FFFFFFF) >> 1);
- uint8_t button = (entry_.data >> 61) & 0x07;
+ uint64_t yek = 0;
+ for (int i = 0; i < 8; i++) {
+ uint8_t byte = (entry_.data2 >> (i * 8)) & 0xFF;
+ uint8_t reversed = 0;
+ for (int b = 0; b < 8; b++) {
+ if (byte & (1 << b))
+ reversed |= (1 << (7 - b));
+ }
+ yek |= ((uint64_t)reversed << ((7 - i) * 8));
+ }
+ serial = (uint32_t)((yek >> 32) & 0x0FFFFFFF);
+ uint8_t button = (uint8_t)((yek >> 60) & 0x0F);
+ uint32_t encrypted = (uint32_t)(yek & 0xFFFFFFFF);
btn = to_string_dec_uint(button);
- cnt = (uint16_t)(entry_.data & 0xFFFF);
+ // decode
+ uint8_t keystore_bytes[] = {0x53, 0x54, 0x46, 0x52, 0x4b, 0x45, 0x30, 0x30};
+ uint8_t s0 = (encrypted & 0xFF);
+ uint8_t s1 = (encrypted >> 8) & 0xFF;
+ uint8_t s2 = (encrypted >> 16) & 0xFF;
+ uint8_t s3 = (encrypted >> 24) & 0xFF;
+ int round_index = 1;
+ for (size_t i = 0; i < 18; i++) {
+ uint8_t r = keystore_bytes[round_index] & 0xFF;
+ int steps = 8;
+ while (steps > 0) {
+ uint8_t base;
+ if ((s3 & 0x40) == 0) {
+ base = (s3 & 0x02) == 0 ? 0x74 : 0x2E;
+ } else {
+ base = (s3 & 0x02) == 0 ? 0x3A : 0x5C;
+ }
+
+ if (s2 & 0x08) {
+ base = (((base >> 4) & 0x0F) | ((base & 0x0F) << 4)) & 0xFF;
+ }
+ if (s1 & 0x01) {
+ base = ((base & 0x3F) << 2) & 0xFF;
+ }
+ if (s0 & 0x01) {
+ base = (base << 1) & 0xFF;
+ }
+
+ uint8_t temp = (s3 ^ s1) & 0xFF;
+ s3 = ((s3 & 0x7F) << 1) & 0xFF;
+ if (s2 & 0x80) {
+ s3 |= 0x01;
+ }
+ s2 = ((s2 & 0x7F) << 1) & 0xFF;
+ if (s1 & 0x80) {
+ s2 |= 0x01;
+ }
+ s1 = ((s1 & 0x7F) << 1) & 0xFF;
+ if (s0 & 0x80) {
+ s1 |= 0x01;
+ }
+ s0 = ((s0 & 0x7F) << 1) & 0xFF;
+
+ uint8_t chk = (base ^ (r ^ temp)) & 0xFF;
+ if (chk & 0x80) {
+ s0 |= 0x01;
+ }
+ r = ((r & 0x7F) << 1) & 0xFF;
+ steps--;
+ }
+ round_index = (round_index - 1) & 0x7;
+ }
+ cnt = (s0 + (s1 << 8)) & 0xFFFF;
}
if (entry_.sensorType == FPC_KIAV3V4) {
// not decrypted!
- serial = SD_NO_SERIAL; //(uint32_t)entry_.data;
+ serial = (uint32_t)entry_.data;
// uint8_t button = entry_.data2 & 0xFF;
btn = "?"; // to_string_dec_uint(button);
}
@@ -344,7 +417,7 @@ void SubCarRecentEntryDetailView::parseProtocol() {
if (entry_.sensorType == FPC_KIAV1) {
serial = (uint32_t)((entry_.data >> 24) & 0xFFFFFFFF);
uint8_t button = (uint8_t)((entry_.data >> 16) & 0xFF);
- cnt = (uint8_t)((entry_.data >> 8) & 0xFF);
+ cnt = (uint8_t)((entry_.data >> 4) & 0xF) << 8 | ((entry_.data >> 8) & 0xFF);
btn = to_string_dec_uint(button);
}
@@ -427,6 +500,13 @@ void SubCarRecentEntryDetailView::parseProtocol() {
btn = to_string_dec_uint(button);
}
+ /*if (entry_.sensorType == FPC_KIAV6) {
+ // not decrypted!
+ serial = 0;
+ btn = "?";
+ cnt = 0;
+ }*/
+
return;
}
diff --git a/firmware/application/external/subcarrx/ui_subcar.hpp b/firmware/application/external/subcarrx/ui_subcar.hpp
index a309fffa1..5fd9af62e 100644
--- a/firmware/application/external/subcarrx/ui_subcar.hpp
+++ b/firmware/application/external/subcarrx/ui_subcar.hpp
@@ -116,25 +116,27 @@ class SubCarView : public View {
4'000'000 /* sampling rate */,
ReceiverModel::Mode::AMAudio};
bool logging = false;
+ uint8_t modulation = 0;
app_settings::SettingsManager settings_{
"rx_subcar",
app_settings::Mode::RX,
{
{"log"sv, &logging},
+ {"modulationmode"sv, &modulation},
}};
SubCarRecentEntries recent{};
RFAmpField field_rf_amp{
- {13 * 8, UI_POS_Y(0)}};
+ {UI_POS_X(13), UI_POS_Y(0)}};
LNAGainField field_lna{
- {15 * 8, UI_POS_Y(0)}};
+ {UI_POS_X(15), UI_POS_Y(0)}};
VGAGainField field_vga{
- {18 * 8, UI_POS_Y(0)}};
+ {UI_POS_X(18), UI_POS_Y(0)}};
RSSI rssi{
- {21 * 8, 0, UI_POS_WIDTH_REMAINING(24), 4}};
+ {UI_POS_X(21), 0, UI_POS_WIDTH_REMAINING(24), 4}};
Channel channel{
- {21 * 8, 5, UI_POS_WIDTH_REMAINING(24), 4},
+ {UI_POS_X(21), 5, UI_POS_WIDTH_REMAINING(24), 4},
};
RxFrequencyField field_frequency{
{UI_POS_X(0), UI_POS_Y(0)},
@@ -143,18 +145,22 @@ class SubCarView : public View {
SignalToken signal_token_tick_second{};
Button button_clear_list{
- {0, 16, 7 * 8, 32},
+ {UI_POS_X(0), UI_POS_Y(1), UI_POS_WIDTH(7), UI_POS_HEIGHT(2)},
"Clear"};
Checkbox check_log{
- {10 * 8, 18},
+ {UI_POS_X(8), UI_POS_Y(1)},
3,
"Log",
true};
Labels labels{
- {{UI_POS_X_RIGHT(14), UI_POS_Y(1)}, "no fm yet :(", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(15), UI_POS_Y(1)}, "Mode:", Theme::getInstance()->fg_light->foreground},
};
+ ui::OptionsField options_mode{
+ {UI_POS_X(22), UI_POS_Y(1)},
+ 3,
+ {{"AM", 0}, {"FM", 1}}};
static constexpr auto header_height = 3 * 16;
@@ -198,16 +204,16 @@ class SubCarRecentEntryDetailView : public View {
std::string btn = "";
uint32_t cnt = SD_NO_CNT;
- Text text_type{{UI_POS_X(0), 1 * 16, 15 * 8, 16}, "?"};
- Text text_id{{6 * 8, 2 * 16, 10 * 8, 16}, "?"};
+ Text text_type{{UI_POS_X(0), UI_POS_Y(1), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "?"};
+ Text text_id{{UI_POS_X(6), UI_POS_Y(2), UI_POS_WIDTH(10), UI_POS_HEIGHT(1)}, "?"};
Console console{
- {0, 4 * 16, screen_width, screen_height - (4 * 16) - 36}};
+ {UI_POS_X(0), UI_POS_Y(4), UI_POS_MAXWIDTH, screen_height - (4 * 16) - 36}};
Labels labels{
{{UI_POS_X(0), UI_POS_Y(0)}, "Type:", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 2 * 16}, "Serial: ", Theme::getInstance()->fg_light->foreground},
- {{UI_POS_X(0), 3 * 16}, "Data:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(2)}, "Serial: ", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(3)}, "Data:", Theme::getInstance()->fg_light->foreground},
};
Button button_done{
diff --git a/firmware/application/external/tetris/ui_tetris.cpp b/firmware/application/external/tetris/ui_tetris.cpp
index 3d8886955..1f0119f90 100644
--- a/firmware/application/external/tetris/ui_tetris.cpp
+++ b/firmware/application/external/tetris/ui_tetris.cpp
@@ -605,7 +605,7 @@ void pause_game() {
}
int main() {
- std::srand(GenerateRandomSeed());
+ srand(GenerateRandomSeed());
Init();
ShowLevelMenu();
joystick.attach(&ReadJoystickForLevel, 0.3);
diff --git a/firmware/application/external/time_sink/main.cpp b/firmware/application/external/time_sink/main.cpp
new file mode 100644
index 000000000..f7f011635
--- /dev/null
+++ b/firmware/application/external/time_sink/main.cpp
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2025 Bernd Herzog
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "ui_time_sink.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::time_sink {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::time_sink
+extern "C" {
+
+__attribute__((section(".external_app.app_time_sink.application_information"), used)) application_information_t _application_information_time_sink = {
+ /*.memory_location = */ (uint8_t*)0x00000000,
+ /*.externalAppEntry = */ ui::external_app::time_sink::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "Time Sink",
+ /*.bitmap_data = */ {
+ 0x18,
+ 0x00,
+ 0x24,
+ 0x00,
+ 0x42,
+ 0x00,
+ 0x42,
+ 0x00,
+ 0x42,
+ 0x00,
+ 0x42,
+ 0x00,
+ 0x81,
+ 0x00,
+ 0xAB,
+ 0x6A,
+ 0x80,
+ 0x40,
+ 0x00,
+ 0x21,
+ 0x00,
+ 0x21,
+ 0x00,
+ 0x21,
+ 0x00,
+ 0x21,
+ 0x00,
+ 0x12,
+ 0x00,
+ 0x0C,
+ 0x00,
+ 0x00,
+ },
+ /*.icon_color = */ ui::Color::green().v,
+ /*.menu_location = */ app_location_t::RX,
+ /*.desired_menu_position = */ -1,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_none */ {'P', 'T', 'S', 'K'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
\ No newline at end of file
diff --git a/firmware/application/external/time_sink/ui_time_sink.cpp b/firmware/application/external/time_sink/ui_time_sink.cpp
new file mode 100644
index 000000000..c8be8f8cc
--- /dev/null
+++ b/firmware/application/external/time_sink/ui_time_sink.cpp
@@ -0,0 +1,352 @@
+/*
+ * Copyleft zxkmm (>) 2026
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui_time_sink.hpp"
+
+#include "baseband_api.hpp"
+#include "portapack.hpp"
+#include "ui_spectrum.hpp"
+
+#include
+
+using namespace portapack;
+
+namespace ui::external_app::time_sink {
+
+TimeSinkWaveformWidget::TimeSinkWaveformWidget(
+ Rect parent_rect,
+ const int16_t* data,
+ size_t length,
+ Color color)
+ : Widget{parent_rect},
+ data_{data},
+ length_{length},
+ color_{color} {
+ reset_cache();
+}
+
+void TimeSinkWaveformWidget::set_parent_rect(const Rect new_parent_rect) {
+ Widget::set_parent_rect(new_parent_rect);
+ reset_cache();
+}
+
+void TimeSinkWaveformWidget::on_show() {
+ reset_cache();
+ set_dirty();
+}
+
+void TimeSinkWaveformWidget::reset_cache() {
+ history_count_ = 0;
+ history_head_ = 0;
+ needs_clear_ = true;
+}
+
+void TimeSinkWaveformWidget::set_persistence_frames(uint8_t frames) {
+ const auto clamped = static_cast(std::clamp(
+ frames,
+ 1,
+ max_persistence_frames));
+ if (clamped != persistence_frames_) {
+ persistence_frames_ = clamped;
+ reset_cache();
+ set_dirty();
+ }
+}
+
+Coord TimeSinkWaveformWidget::sample_to_y(const Rect& r, int16_t sample) const {
+ const int32_t y_center = r.top() + (r.height() / 2);
+ const int32_t y_span = std::max(1, r.height() - 1);
+ const int32_t y = y_center - (static_cast(sample) * y_span) / 65536;
+ return static_cast(std::clamp(y, r.top(), r.bottom() - 1));
+}
+
+void TimeSinkWaveformWidget::paint(Painter& painter) {
+ const auto r = screen_rect();
+ const auto background = Theme::getInstance()->bg_darkest->background;
+
+ if (!r || !data_ || !length_) {
+ if (needs_clear_) {
+ painter.fill_rectangle_unrolled8(r, background);
+ needs_clear_ = false;
+ }
+ history_count_ = 0;
+ history_head_ = 0;
+ return;
+ }
+
+ const size_t columns = std::min({
+ length_,
+ max_columns,
+ static_cast(r.width()),
+ });
+ if (!columns) {
+ history_count_ = 0;
+ history_head_ = 0;
+ return;
+ }
+
+ if (needs_clear_) {
+ painter.fill_rectangle_unrolled8(r, background);
+ needs_clear_ = false;
+ history_count_ = 0;
+ history_head_ = 0;
+ }
+
+ for (size_t x = 0; x < columns; ++x) {
+ const size_t src_index = (x * length_) / columns;
+ current_y_[x] = sample_to_y(r, data_[src_index]);
+ }
+
+ // Draw first, then erase stale pixels so the trace never disappears mid-refresh.
+ for (size_t x = 0; x < columns; ++x) {
+ display.draw_pixel(
+ {static_cast(r.left() + x), current_y_[x]},
+ color_);
+ }
+
+ if (history_count_ >= persistence_frames_) {
+ const size_t expired_slot = history_head_;
+
+ for (size_t x = 0; x < columns; ++x) {
+ const auto expired_y = history_y_[expired_slot][x];
+ bool keep = (expired_y == current_y_[x]);
+
+ if (!keep) {
+ for (size_t i = 1; i < history_count_; ++i) {
+ const size_t slot = (history_head_ + i) % max_persistence_frames;
+ if (history_y_[slot][x] == expired_y) {
+ keep = true;
+ break;
+ }
+ }
+ }
+
+ if (!keep) {
+ display.draw_pixel(
+ {static_cast(r.left() + x), expired_y},
+ background);
+ }
+ }
+
+ history_head_ = (history_head_ + 1) % max_persistence_frames;
+ --history_count_;
+ }
+
+ const size_t tail_slot = (history_head_ + history_count_) % max_persistence_frames;
+ std::copy_n(current_y_.begin(), columns, history_y_[tail_slot].begin());
+ ++history_count_;
+}
+
+TimeSinkView::TimeSinkView(NavigationView& nav)
+ : nav_(nav) {
+ baseband::run_prepared_image(portapack::memory::map::m4_code.base());
+
+ add_children({
+ &labels,
+ &field_frequency,
+ &field_frequency_step,
+ &field_rf_amp,
+ &field_lna,
+ &field_vga,
+ &options_sample_rate,
+ &field_trigger,
+ &options_persistence,
+ &options_trigger_mode,
+ &field_trigger_level,
+ &waveform,
+ });
+
+ field_frequency_step.set_by_value(receiver_model.frequency_step());
+ field_frequency_step.on_change = [this](size_t, OptionsField::value_t v) {
+ receiver_model.set_frequency_step(v);
+ field_frequency.set_step(v);
+ };
+
+ options_sample_rate.set_by_nearest_value(sampling_rate);
+ sampling_rate = options_sample_rate.selected_index_value();
+ options_sample_rate.on_change = [this](size_t, OptionsField::value_t v) {
+ sampling_rate = v;
+ apply_spectrum_config();
+ };
+
+ field_trigger.set_value(trigger);
+ field_trigger.on_change = [this](int32_t v) {
+ trigger = static_cast(v);
+ apply_spectrum_config();
+ };
+
+ options_persistence.set_by_nearest_value(persistence_frames);
+ persistence_frames = options_persistence.selected_index_value();
+ waveform.set_persistence_frames(persistence_frames);
+ options_persistence.on_change = [this](size_t, OptionsField::value_t v) {
+ persistence_frames = static_cast(v);
+ waveform.set_persistence_frames(persistence_frames);
+ };
+
+ options_trigger_mode.set_by_nearest_value(trigger_mode);
+ trigger_mode = static_cast(options_trigger_mode.selected_index_value());
+ options_trigger_mode.on_change = [this](size_t, OptionsField::value_t v) {
+ trigger_mode = static_cast(v);
+ trigger_lock_valid = false;
+ };
+
+ field_trigger_level.set_value(trigger_level);
+ field_trigger_level.on_change = [this](int32_t v) {
+ trigger_level = v;
+ trigger_lock_valid = false;
+ };
+
+ receiver_model.set_squelch_level(0);
+ receiver_model.enable();
+ apply_spectrum_config();
+}
+
+TimeSinkView::~TimeSinkView() {
+ receiver_model.disable();
+ baseband::shutdown();
+}
+
+void TimeSinkView::focus() {
+ field_frequency.focus();
+}
+
+void TimeSinkView::on_show() {
+ baseband::spectrum_streaming_start();
+}
+
+void TimeSinkView::on_hide() {
+ baseband::spectrum_streaming_stop();
+}
+
+void TimeSinkView::set_parent_rect(const Rect new_parent_rect) {
+ View::set_parent_rect(new_parent_rect);
+ waveform.set_parent_rect({0, header_height, new_parent_rect.width(), new_parent_rect.height() - header_height});
+}
+
+void TimeSinkView::apply_spectrum_config() {
+ receiver_model.set_sampling_rate(sampling_rate);
+ receiver_model.set_baseband_bandwidth(filter_bandwidth_for_sampling_rate(sampling_rate));
+
+ baseband::set_time_sink(
+ sampling_rate,
+ trigger);
+}
+
+size_t TimeSinkView::find_stable_trigger_index(const ChannelSpectrum& spectrum) {
+ if (spectrum.db.size() < 2) {
+ return 0;
+ }
+
+ TriggerMode mode = TriggerMode::Rising;
+ switch (trigger_mode) {
+ case static_cast(TriggerMode::Off):
+ mode = TriggerMode::Off;
+ break;
+ case static_cast(TriggerMode::Rising):
+ mode = TriggerMode::Rising;
+ break;
+ case static_cast(TriggerMode::Falling):
+ mode = TriggerMode::Falling;
+ break;
+ default:
+ break;
+ }
+
+ if (mode == TriggerMode::Off) {
+ trigger_lock_valid = false;
+ return 0;
+ }
+
+ constexpr int32_t hysteresis = 2;
+ const int32_t threshold = std::clamp(
+ 128 + trigger_level,
+ hysteresis,
+ 255 - hysteresis);
+ const size_t center = spectrum.db.size() / 2;
+ const size_t reference_index = trigger_lock_valid ? trigger_lock_index : center;
+
+ bool found = false;
+ size_t best_index = 0;
+ size_t best_distance = spectrum.db.size();
+ const auto circular_distance = [count = spectrum.db.size()](size_t a, size_t b) -> size_t {
+ const size_t linear =
+ (a > b) ? (a - b) : (b - a);
+ return std::min(linear, count - linear);
+ };
+
+ for (size_t i = 1; i < spectrum.db.size(); ++i) {
+ const int32_t prev = spectrum.db[i - 1];
+ const int32_t curr = spectrum.db[i];
+
+ bool crossing = false;
+ if (mode == TriggerMode::Rising) {
+ crossing =
+ (prev <= (threshold - hysteresis)) &&
+ (curr >= (threshold + hysteresis));
+ } else {
+ crossing =
+ (prev >= (threshold + hysteresis)) &&
+ (curr <= (threshold - hysteresis));
+ }
+
+ if (!crossing) {
+ continue;
+ }
+
+ const size_t distance = circular_distance(i, reference_index);
+ if (!found || (distance < best_distance)) {
+ found = true;
+ best_index = i;
+ best_distance = distance;
+ }
+ }
+
+ if (found) {
+ trigger_lock_index = best_index;
+ trigger_lock_valid = true;
+ return best_index;
+ }
+
+ return trigger_lock_valid ? trigger_lock_index : 0;
+}
+
+void TimeSinkView::on_channel_spectrum(const ChannelSpectrum& spectrum) {
+ const size_t trigger_index = find_stable_trigger_index(spectrum);
+ const size_t source_count = spectrum.db.size();
+ const size_t window_size = source_count;
+
+ for (size_t x = 0; x < waveform_points; x++) {
+ const size_t offset = (x * window_size) / waveform_points;
+ const size_t src_index =
+ (trigger_index + offset) % source_count;
+ const int32_t centered = static_cast(spectrum.db[src_index]) - 128;
+ const int32_t scaled = centered * 256;
+ waveform_buffer[x] = static_cast(std::clamp(scaled, -32768, 32767));
+ }
+
+ waveform.set_dirty();
+}
+
+void TimeSinkView::on_freqchg(int64_t freq) {
+ field_frequency.set_value(freq);
+}
+
+} // namespace ui::external_app::time_sink
diff --git a/firmware/application/external/time_sink/ui_time_sink.hpp b/firmware/application/external/time_sink/ui_time_sink.hpp
new file mode 100644
index 000000000..611c555d5
--- /dev/null
+++ b/firmware/application/external/time_sink/ui_time_sink.hpp
@@ -0,0 +1,238 @@
+/*
+ * Copyleft zxkmm (>) 2026
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#ifndef __UI_TIME_SINK_APP_H__
+#define __UI_TIME_SINK_APP_H__
+
+#include "app_settings.hpp"
+#include "baseband_api.hpp"
+#include "message.hpp"
+#include "radio_state.hpp"
+#include "ui.hpp"
+#include "ui_freq_field.hpp"
+#include "ui_navigation.hpp"
+#include "ui_receiver.hpp"
+#include "ui_widget.hpp"
+
+#include
+
+namespace ui::external_app::time_sink {
+
+constexpr size_t time_sink_waveform_points = 240;
+
+class TimeSinkWaveformWidget : public Widget {
+ public:
+ TimeSinkWaveformWidget(Rect parent_rect, const int16_t* data, size_t length, Color color);
+ TimeSinkWaveformWidget(const TimeSinkWaveformWidget&) = delete;
+ TimeSinkWaveformWidget(TimeSinkWaveformWidget&&) = delete;
+ TimeSinkWaveformWidget& operator=(const TimeSinkWaveformWidget&) = delete;
+ TimeSinkWaveformWidget& operator=(TimeSinkWaveformWidget&&) = delete;
+
+ void set_parent_rect(const Rect new_parent_rect) override;
+ void on_show() override;
+ void paint(Painter& painter) override;
+ void set_persistence_frames(uint8_t frames);
+
+ private:
+ static constexpr size_t max_columns = time_sink_waveform_points;
+ static constexpr size_t max_persistence_frames = 16; // this is sad that we cant have 32 histories in ext app due to memory constraints
+
+ void reset_cache();
+ Coord sample_to_y(const Rect& r, int16_t sample) const;
+
+ const int16_t* data_;
+ size_t length_;
+ Color color_;
+ std::array current_y_{};
+ std::array, max_persistence_frames> history_y_{};
+ size_t history_count_{0};
+ size_t history_head_{0};
+ uint8_t persistence_frames_{1};
+ bool needs_clear_{true};
+};
+
+class TimeSinkView : public View {
+ public:
+ TimeSinkView(NavigationView& nav);
+ ~TimeSinkView();
+
+ TimeSinkView(const TimeSinkView&) = delete;
+ TimeSinkView(TimeSinkView&&) = delete;
+ TimeSinkView& operator=(const TimeSinkView&) = delete;
+ TimeSinkView& operator=(TimeSinkView&&) = delete;
+
+ std::string title() const override { return "Time Sink"; };
+ void focus() override;
+ void on_show() override;
+ void on_hide() override;
+ void set_parent_rect(const Rect new_parent_rect) override;
+
+ private:
+ enum class TriggerMode : uint8_t {
+ Off = 0,
+ Rising = 1,
+ Falling = 2,
+ };
+
+ static constexpr Dim header_height = 3 * 16;
+ static constexpr size_t waveform_points = time_sink_waveform_points;
+
+ NavigationView& nav_;
+ RxRadioState radio_state_{
+ 433'920'000,
+ 1'750'000,
+ 2'000'000,
+ ReceiverModel::Mode::SpectrumAnalysis};
+
+ uint32_t sampling_rate{2'000'000};
+ uint8_t trigger{0};
+ uint8_t persistence_frames{1};
+ uint8_t trigger_mode{static_cast(TriggerMode::Rising)};
+ int32_t trigger_level{0};
+ size_t trigger_lock_index{0};
+ bool trigger_lock_valid{false};
+ app_settings::SettingsManager settings_{
+ "rx_time_sink"sv,
+ app_settings::Mode::RX,
+ {
+ {"sampling_rate"sv, &sampling_rate},
+ {"trigger"sv, &trigger},
+ {"persistence_frames"sv, &persistence_frames},
+ {"trigger_mode"sv, &trigger_mode},
+ {"trigger_level"sv, &trigger_level},
+ }};
+
+ int16_t waveform_buffer[waveform_points]{0};
+ ChannelSpectrumFIFO* fifo = nullptr;
+
+ Labels labels{
+ {{UI_POS_X(0), UI_POS_Y(1)}, "SR:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(11), UI_POS_Y(1)}, "DEC:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(0), UI_POS_Y(2)}, "PST:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(9), UI_POS_Y(2)}, "TRM:", Theme::getInstance()->fg_light->foreground},
+ {{UI_POS_X(19), UI_POS_Y(2)}, "LVL:", Theme::getInstance()->fg_light->foreground},
+ };
+
+ RxFrequencyField field_frequency{
+ {UI_POS_X(0), UI_POS_Y(0)},
+ nav_};
+
+ FrequencyStepView field_frequency_step{
+ {10 * 8, UI_POS_Y(0)}};
+
+ RFAmpField field_rf_amp{
+ {16 * 8, UI_POS_Y(0)}};
+
+ LNAGainField field_lna{
+ {18 * 8, UI_POS_Y(0)}};
+
+ VGAGainField field_vga{
+ {21 * 8, UI_POS_Y(0)}};
+
+ OptionsField options_sample_rate{
+ {UI_POS_X(3), UI_POS_Y(1)},
+ 6,
+ {
+ {"1.0M ", 1'000'000},
+ {"2.0M ", 2'000'000},
+ {"5.0M ", 5'000'000},
+ {"10.0M ", 10'000'000},
+ {"20.0M ", 20'000'000},
+ }};
+
+ NumberField field_trigger{
+ {UI_POS_X(17), UI_POS_Y(1)},
+ 3,
+ {0, 128},
+ 1,
+ ' '};
+
+ OptionsField options_persistence{
+ {UI_POS_X(3), UI_POS_Y(2)},
+ 3,
+ {
+ {"1 ", 1},
+ {"2 ", 2},
+ {"4 ", 4},
+ {"8 ", 8},
+ {"16 ", 16},
+ }};
+
+ OptionsField options_trigger_mode{
+ {UI_POS_X(13), UI_POS_Y(2)},
+ 4,
+ {
+ {"Off ", static_cast(TriggerMode::Off)},
+ {"Rise", static_cast(TriggerMode::Rising)},
+ {"Fall", static_cast(TriggerMode::Falling)},
+ }};
+
+ NumberField field_trigger_level{
+ {UI_POS_X(23), UI_POS_Y(2)},
+ 4,
+ {-127, 127},
+ 1,
+ ' '};
+
+ TimeSinkWaveformWidget waveform{
+ {0, header_height, screen_width, screen_height - header_height},
+ waveform_buffer,
+ waveform_points,
+ Theme::getInstance()->fg_light->foreground};
+
+ MessageHandlerRegistration message_handler_spectrum_config{
+ Message::ID::ChannelSpectrumConfig,
+ [this](const Message* const p) {
+ const auto message = *reinterpret_cast(p);
+ this->fifo = message.fifo;
+ }};
+
+ MessageHandlerRegistration message_handler_frame_sync{
+ Message::ID::DisplayFrameSync,
+ [this](const Message* const) {
+ if (this->fifo) {
+ ChannelSpectrum spectrum{};
+ bool has_spectrum = false;
+ while (fifo->out(spectrum)) {
+ has_spectrum = true;
+ }
+ if (has_spectrum) {
+ this->on_channel_spectrum(spectrum);
+ }
+ }
+ }};
+
+ MessageHandlerRegistration message_handler_freqchg{
+ Message::ID::FreqChangeCommand,
+ [this](Message* const p) {
+ const auto message = static_cast(p);
+ this->on_freqchg(message->freq);
+ }};
+
+ void apply_spectrum_config();
+ size_t find_stable_trigger_index(const ChannelSpectrum& spectrum);
+ void on_channel_spectrum(const ChannelSpectrum& spectrum);
+ void on_freqchg(int64_t freq);
+};
+
+} // namespace ui::external_app::time_sink
+
+#endif // __UI_TIME_SINK_APP_H__
diff --git a/firmware/application/external/tpmsrx/main.cpp b/firmware/application/external/tpmsrx/main.cpp
index 552a14c06..6e429b3dc 100644
--- a/firmware/application/external/tpmsrx/main.cpp
+++ b/firmware/application/external/tpmsrx/main.cpp
@@ -75,7 +75,7 @@ __attribute__((section(".external_app.app_tpmsrx.application_information"), used
},
/*.icon_color = */ ui::Color::green().v,
/*.menu_location = */ app_location_t::RX,
- /*.desired_menu_position = */ -1,
+ /*.desired_menu_position = */ 7,
/*.m4_app_tag = portapack::spi_flash::image_tag_tpms */ {'P', 'T', 'P', 'M'},
/*.m4_app_offset = */ 0x00000000, // will be filled at compile time
diff --git a/firmware/application/external/tpmsrx/tpms_app.cpp b/firmware/application/external/tpmsrx/tpms_app.cpp
index ef31ca8fa..c75ba385c 100644
--- a/firmware/application/external/tpmsrx/tpms_app.cpp
+++ b/firmware/application/external/tpmsrx/tpms_app.cpp
@@ -42,16 +42,37 @@ std::string type(tpms::Reading::Type type) {
return to_string_dec_uint(toUType(type), 2);
}
+std::string type_name(tpms::Reading::Type type) {
+ switch (type) {
+ case tpms::Reading::Type::None:
+ return "None";
+ case tpms::Reading::Type::FLM_64:
+ return "FLM_64";
+ case tpms::Reading::Type::FLM_72:
+ return "FLM_72";
+ case tpms::Reading::Type::FLM_80:
+ return "FLM_80";
+ case tpms::Reading::Type::Schrader:
+ return "Schrader";
+ case tpms::Reading::Type::GMC_96:
+ return "GMC_96";
+ default:
+ return "Unknown";
+ }
+}
+
std::string id(tpms::TransponderID id) {
return to_string_hex(id.value(), 8);
}
std::string pressure(Pressure pressure) {
- return to_string_dec_int(units_psi ? pressure.psi() : pressure.kilopascal(), 3);
+ return to_string_dec_int(pressure_unit == PRESSURE_UNIT_PSI ? pressure.psi() : pressure_unit == PRESSURE_UNIT_BAR ? pressure.bar()
+ : pressure.kilopascal(),
+ 3);
}
std::string temperature(Temperature temperature) {
- return to_string_dec_int(units_fahr ? temperature.fahrenheit() : temperature.celsius(), 3);
+ return to_string_dec_int(temp_unit == TEMP_UNIT_CELSIUS ? temperature.celsius() : temperature.fahrenheit(), 3);
}
std::string flags(tpms::Flags flags) {
@@ -99,7 +120,8 @@ void TPMSRecentEntry::update(const tpms::Reading& reading) {
}
}
-TPMSAppView::TPMSAppView(NavigationView&) {
+TPMSAppView::TPMSAppView(NavigationView& nav)
+ : nav_{nav} {
// baseband::run_image(portapack::spi_flash::image_tag_tpms);
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
@@ -122,16 +144,21 @@ TPMSAppView::TPMSAppView(NavigationView&) {
options_band.set_by_value(receiver_model.target_frequency());
options_pressure.on_change = [this](size_t, int32_t i) {
- format::units_psi = (bool)i;
+ format::pressure_unit = (uint8_t)i;
update_view();
};
- options_pressure.set_selected_index(format::units_psi, true);
+ options_pressure.set_by_value(format::pressure_unit);
options_temperature.on_change = [this](size_t, int32_t i) {
- format::units_fahr = (bool)i;
+ format::temp_unit = (uint8_t)i;
update_view();
};
- options_temperature.set_selected_index(format::units_fahr, true);
+ options_temperature.set_by_value(format::temp_unit);
+
+ // Add on_select handler for entries
+ recent_entries_view.on_select = [this](const TPMSRecentEntry& entry) {
+ on_show_detail(entry);
+ };
logger = std::make_unique();
if (logger) {
@@ -176,6 +203,7 @@ void TPMSAppView::on_packet(const tpms::Packet& packet) {
const auto reading = reading_opt.value();
auto& entry = ::on_packet(recent, TPMSRecentEntry::Key{reading.type(), reading.id()});
entry.update(reading);
+ entry.signal_type = packet.signal_type();
recent_entries_view.set_dirty();
}
@@ -189,6 +217,116 @@ void TPMSAppView::on_show_list() {
recent_entries_view.focus();
}
+void TPMSAppView::on_show_detail(const TPMSRecentEntry& entry) {
+ nav_.push(entry);
+}
+
+// Detail view implementation
+TPMSRecentEntryDetailView::TPMSRecentEntryDetailView(NavigationView& nav, const TPMSRecentEntry& entry)
+ : nav_{nav},
+ entry_{entry} {
+ add_children({&labels,
+ &text_type,
+ &text_id,
+ &text_pressure,
+ &text_temperature,
+ &text_flags,
+ &text_count,
+ &button_done,
+ &button_save});
+
+ // Display entry data
+ text_type.set(format::type_name(entry.type));
+ text_id.set(to_string_hex(entry.id.value(), 8));
+
+ if (entry.last_pressure.is_valid()) {
+ std::string pressure_str = format::pressure(entry.last_pressure.value());
+ std::string unit_str = format::pressure_unit == PRESSURE_UNIT_PSI ? " PSI" : format::pressure_unit == PRESSURE_UNIT_BAR ? " BAR"
+ : " kPa";
+ text_pressure.set(pressure_str + unit_str);
+ } else {
+ text_pressure.set("---");
+ }
+
+ if (entry.last_temperature.is_valid()) {
+ text_temperature.set(to_string_dec_int(entry.last_temperature.value().celsius(), 3) + " C");
+ } else {
+ text_temperature.set("---");
+ }
+
+ if (entry.last_flags.is_valid()) {
+ text_flags.set(to_string_hex(entry.last_flags.value(), 2));
+ } else {
+ text_flags.set("--");
+ }
+
+ text_count.set(to_string_dec_uint(entry.received_count, 4));
+
+ button_done.on_select = [&nav](Button&) {
+ nav.pop();
+ };
+
+ button_save.on_select = [this](Button&) {
+ on_save();
+ };
+}
+
+void TPMSRecentEntryDetailView::focus() {
+ button_done.focus();
+}
+
+void TPMSRecentEntryDetailView::set_entry(const TPMSRecentEntry& entry) {
+ entry_ = entry;
+}
+
+void TPMSRecentEntryDetailView::on_save() {
+ auto timestamp = to_string_timestamp(rtc_time::now());
+ std::string file_name = "TPMS_" + timestamp + ".TXT";
+ ensure_directory(tpms_dir);
+ auto file_path = tpms_dir / file_name;
+
+ if (save_file(file_path)) {
+ nav_.display_modal("Saved", "Packet saved to:\n" + file_name);
+ } else {
+ nav_.display_modal("Error", "Failed to save\npacket");
+ }
+}
+
+bool TPMSRecentEntryDetailView::save_file(const std::filesystem::path& path) {
+ File f;
+ auto error = f.create(path);
+ if (error.is_valid())
+ return false;
+
+ // Save in format compatible with TX app
+ std::string content = "Type=" + to_string_dec_uint(toUType(entry_.type), 1) + "\n";
+ content += "ID=" + to_string_hex(entry_.id.value(), 8) + "\n";
+
+ if (entry_.last_pressure.is_valid()) {
+ content += "Pressure=" + to_string_dec_int(entry_.last_pressure.value().kilopascal(), 1) + "\n";
+ } else {
+ content += "Pressure=240\n"; // Default value
+ }
+
+ if (entry_.last_temperature.is_valid()) {
+ content += "Temperature=" + to_string_dec_int(entry_.last_temperature.value().celsius(), 1) + "\n";
+ } else {
+ content += "Temperature=25\n"; // Default value
+ }
+
+ if (entry_.last_flags.is_valid()) {
+ content += "Flags=" + to_string_hex(entry_.last_flags.value(), 2) + "\n";
+ } else {
+ content += "Flags=00\n";
+ }
+
+ // Save signal type for proper retransmission
+ content += "SignalType=" + to_string_dec_uint(toUType(entry_.signal_type), 1) + "\n";
+
+ f.write(content.c_str(), content.length());
+ return true;
+}
+
} // namespace ui::external_app::tpmsrx
namespace ui {
@@ -239,4 +377,4 @@ void RecentEntriesTable::draw(
painter.draw_string(target_rect.location(), style, line);
}
-} // namespace ui
\ No newline at end of file
+} // namespace ui
diff --git a/firmware/application/external/tpmsrx/tpms_app.hpp b/firmware/application/external/tpmsrx/tpms_app.hpp
index 432aa8123..3846b55ac 100644
--- a/firmware/application/external/tpmsrx/tpms_app.hpp
+++ b/firmware/application/external/tpmsrx/tpms_app.hpp
@@ -42,10 +42,9 @@ namespace ui::external_app::tpmsrx {
namespace format {
-static bool units_psi{false};
-static bool units_fahr{false};
-
-} /* namespace format */
+static uint8_t pressure_unit{PRESSURE_UNIT_KPA};
+static uint8_t temp_unit{TEMP_UNIT_CELSIUS};
+} // namespace format
struct TPMSRecentEntry {
using Key = std::pair;
@@ -54,6 +53,7 @@ struct TPMSRecentEntry {
tpms::Reading::Type type{invalid_key.first};
tpms::TransponderID id{invalid_key.second};
+ tpms::SignalType signal_type{tpms::SignalType::OOK_8k192_Schrader};
size_t received_count{0};
@@ -90,6 +90,64 @@ class TPMSLogger {
using TPMSRecentEntriesView = RecentEntriesView;
+class TPMSRecentEntryDetailView : public View {
+ public:
+ TPMSRecentEntryDetailView(NavigationView& nav, const TPMSRecentEntry& entry);
+
+ void set_entry(const TPMSRecentEntry& entry);
+ const TPMSRecentEntry& entry() const { return entry_; }
+
+ void focus() override;
+
+ private:
+ NavigationView& nav_;
+ TPMSRecentEntry entry_;
+
+ void on_save();
+ bool save_file(const std::filesystem::path& path);
+
+ Labels labels{
+ {{0 * 8, 1 * 16}, "Type:", Theme::getInstance()->fg_light->foreground},
+ {{0 * 8, 3 * 16}, "ID:", Theme::getInstance()->fg_light->foreground},
+ {{0 * 8, 5 * 16}, "Pressure:", Theme::getInstance()->fg_light->foreground},
+ {{0 * 8, 7 * 16}, "Temperature:", Theme::getInstance()->fg_light->foreground},
+ {{0 * 8, 9 * 16}, "Flags:", Theme::getInstance()->fg_light->foreground},
+ {{0 * 8, 11 * 16}, "Count:", Theme::getInstance()->fg_light->foreground},
+ };
+
+ Text text_type{
+ {8 * 8, 1 * 16, 20 * 8, 16},
+ ""};
+
+ Text text_id{
+ {8 * 8, 3 * 16, 20 * 8, 16},
+ ""};
+
+ Text text_pressure{
+ {12 * 8, 5 * 16, 16 * 8, 16},
+ ""};
+
+ Text text_temperature{
+ {14 * 8, 7 * 16, 14 * 8, 16},
+ ""};
+
+ Text text_flags{
+ {8 * 8, 9 * 16, 20 * 8, 16},
+ ""};
+
+ Text text_count{
+ {8 * 8, 11 * 16, 20 * 8, 16},
+ ""};
+
+ Button button_save{
+ {0 * 8, 13 * 16, 14 * 8, 32},
+ "Save"};
+
+ Button button_done{
+ {16 * 8, 13 * 16, 14 * 8, 32},
+ "Done"};
+};
+
class TPMSAppView : public View {
public:
TPMSAppView(NavigationView& nav);
@@ -106,6 +164,8 @@ class TPMSAppView : public View {
std::string title() const override { return "TPMS RX"; };
private:
+ NavigationView& nav_;
+
RxRadioState radio_state_{
314900000 /* frequency*/
,
@@ -117,8 +177,8 @@ class TPMSAppView : public View {
"rx_tpms",
app_settings::Mode::RX,
{
- {"units_psi"sv, &format::units_psi},
- {"units_fahr"sv, &format::units_fahr},
+ {"pressure_unit"sv, &format::pressure_unit},
+ {"temp_unit"sv, &format::temp_unit},
}};
MessageHandlerRegistration message_handler_packet{
@@ -154,15 +214,16 @@ class TPMSAppView : public View {
OptionsField options_pressure{
{6 * 8, UI_POS_Y(0)},
- 3,
- {{"kPa", 0},
- {"PSI", 1}}};
+ 4,
+ {{"kPa", PRESSURE_UNIT_KPA},
+ {"PSI", PRESSURE_UNIT_PSI},
+ {"BAR", PRESSURE_UNIT_BAR}}};
OptionsField options_temperature{
{10 * 8, UI_POS_Y(0)},
2,
- {{STR_DEGREES_C, 0},
- {STR_DEGREES_F, 1}}};
+ {{STR_DEGREES_C, TEMP_UNIT_CELSIUS},
+ {STR_DEGREES_F, TEMP_UNIT_FAHRENHEIT}}};
RFAmpField field_rf_amp{
{13 * 8, UI_POS_Y(0)}};
@@ -188,6 +249,7 @@ class TPMSAppView : public View {
void on_packet(const tpms::Packet& packet);
void on_show_list();
+ void on_show_detail(const TPMSRecentEntry& entry);
void update_view();
};
diff --git a/firmware/application/external/tpmstx/main.cpp b/firmware/application/external/tpmstx/main.cpp
new file mode 100644
index 000000000..1f323bff2
--- /dev/null
+++ b/firmware/application/external/tpmstx/main.cpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2026
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "ui.hpp"
+#include "tpms_tx_app.hpp"
+#include "ui_navigation.hpp"
+#include "external_app.hpp"
+
+namespace ui::external_app::tpmstx {
+void initialize_app(ui::NavigationView& nav) {
+ nav.push();
+}
+} // namespace ui::external_app::tpmstx
+
+extern "C" {
+
+__attribute__((section(".external_app.app_tpmstx.application_information"), used)) application_information_t _application_information_tpmstx = {
+ /*.memory_location = */ (uint8_t*)0x00000000, // will be filled at compile time
+ /*.externalAppEntry = */ ui::external_app::tpmstx::initialize_app,
+ /*.header_version = */ CURRENT_HEADER_VERSION,
+ /*.app_version = */ VERSION_MD5,
+
+ /*.app_name = */ "TPMS TX",
+ /*.bitmap_data = */ {
+ 0xC0,
+ 0x03,
+ 0xF0,
+ 0x0F,
+ 0x18,
+ 0x18,
+ 0xEC,
+ 0x37,
+ 0x36,
+ 0x6D,
+ 0x3A,
+ 0x59,
+ 0x4B,
+ 0xD5,
+ 0x8B,
+ 0xD3,
+ 0xCB,
+ 0xD1,
+ 0xAB,
+ 0xD2,
+ 0x9A,
+ 0x5C,
+ 0xB6,
+ 0x6C,
+ 0xEC,
+ 0x37,
+ 0x18,
+ 0x18,
+ 0xF0,
+ 0x0F,
+ 0xC0,
+ 0x03,
+ },
+ /*.icon_color = */ ui::Color::green().v,
+ /*.menu_location = */ app_location_t::TX,
+ /*.desired_menu_position = */ 6,
+
+ /*.m4_app_tag = portapack::spi_flash::image_tag_ook */ {'P', 'O', 'O', 'K'},
+ /*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+};
+}
diff --git a/firmware/application/external/tpmstx/tpms_tx_app.cpp b/firmware/application/external/tpmstx/tpms_tx_app.cpp
new file mode 100644
index 000000000..5315dc73a
--- /dev/null
+++ b/firmware/application/external/tpmstx/tpms_tx_app.cpp
@@ -0,0 +1,812 @@
+/*
+ * Copyright (C) 2026
+ *
+ * This file is part of PortaPack.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 51 Franklin Street,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#include "tpms_tx_app.hpp"
+#include "baseband_api.hpp"
+#include "portapack.hpp"
+#include "spi_image.hpp"
+#include "ui_fileman.hpp"
+#include "ui_freqman.hpp"
+#include "manchester.hpp"
+#include "rtc_time.hpp"
+#include "file_path.hpp"
+#include "encoders.hpp"
+#include "crc.hpp"
+
+using namespace portapack;
+using namespace tpms;
+
+namespace ui::external_app::tpmstx {
+
+void TPMSTXView::focus() {
+ options_packet_type.focus();
+}
+
+void TPMSTXView::update_signal_type_from_packet() {
+ // Auto-select signal type based on packet type
+ switch (packet_type_) {
+ case tpms::Reading::Type::Schrader:
+ signal_type_ = tpms::SignalType::OOK_8k192_Schrader;
+ break;
+ case tpms::Reading::Type::FLM_64:
+ case tpms::Reading::Type::FLM_72:
+ case tpms::Reading::Type::FLM_80:
+ signal_type_ = tpms::SignalType::FSK_19k2_Schrader;
+ break;
+ case tpms::Reading::Type::GMC_96:
+ signal_type_ = tpms::SignalType::OOK_8k4_Schrader;
+ break;
+ default:
+ signal_type_ = tpms::SignalType::OOK_8k192_Schrader;
+ break;
+ }
+ // Note: Don't call switch_baseband() here - it's called when needed
+}
+
+void TPMSTXView::switch_baseband() {
+ // Switch baseband image based on signal type
+ // Must shutdown first to avoid BBRunning error
+ baseband::shutdown();
+ chThdSleepMilliseconds(100);
+
+ if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
+ baseband::run_image(portapack::spi_flash::image_tag_fsktx);
+ } else {
+ baseband::run_image(portapack::spi_flash::image_tag_ook);
+ }
+
+ chThdSleepMilliseconds(100);
+}
+
+void TPMSTXView::update_packet_display() {
+ // Only update status text - field values are already set by user interaction
+ // or by explicit set_value calls when loading from file
+ std::string status = "ID:" + to_string_hex(transponder_id_, 8);
+
+ // Note protocol-specific limitations
+ if (packet_type_ == tpms::Reading::Type::Schrader) {
+ status = "ID:" + to_string_hex(transponder_id_ & 0x00FFFFFF, 6) + " (24bit)";
+ }
+
+ status += " " + to_string_dec_uint(pressure_kpa_) + "kPa";
+
+ // Check for pressure overflow and warn user
+ if (packet_type_ == tpms::Reading::Type::GMC_96) {
+ if (pressure_kpa_ > 701) {
+ status += " !MAX"; // GMC_96 max is 701 kPa (101.7 PSI)
+ }
+ } else {
+ if (pressure_kpa_ > 340) {
+ status += " !MAX"; // Schrader/FLM max is 340 kPa (49.3 PSI)
+ }
+ }
+
+ // Temperature note for protocols that support it
+ if (packet_type_ == tpms::Reading::Type::GMC_96 ||
+ packet_type_ == tpms::Reading::Type::FLM_64 ||
+ packet_type_ == tpms::Reading::Type::FLM_72 ||
+ packet_type_ == tpms::Reading::Type::FLM_80) {
+ status += " " + to_string_dec_int(temperature_c_) + "C";
+ } else {
+ status += " (no temp)";
+ }
+
+ text_status.set(status);
+}
+
+void TPMSTXView::update_field_visibility() {
+ // Schrader: Show Func, Hide Temp (no temperature support)
+ // GMC_96, FLM_xx: Hide Func, Show Temp (temperature supported, no func field)
+ if (packet_type_ == tpms::Reading::Type::Schrader) {
+ // Show Func field and label
+ label_flags.hidden(false);
+ field_flags.hidden(false);
+ // Hide Temp field, label, and unit selector
+ label_temperature.hidden(true);
+ field_temperature.hidden(true);
+ options_temperature.hidden(true);
+ // Show 24-bit ID field, hide 32-bit ID field
+ field_transponder_id_24.hidden(false);
+ field_transponder_id_32.hidden(true);
+ } else {
+ // Hide Func field and label
+ label_flags.hidden(true);
+ field_flags.hidden(true);
+ // Show Temp field, label, and unit selector
+ label_temperature.hidden(false);
+ field_temperature.hidden(false);
+ options_temperature.hidden(false);
+ // Show 32-bit ID field, hide 24-bit ID field
+ field_transponder_id_24.hidden(true);
+ field_transponder_id_32.hidden(false);
+ }
+ // Force screen refresh to clear hidden widgets
+ set_dirty();
+}
+
+void TPMSTXView::on_pressure_unit_change() {
+ // Convert displayed pressure to new unit and update field
+ units::Pressure pressure(pressure_kpa_);
+ int display_value = 0;
+
+ if (format::pressure_unit == PRESSURE_UNIT_PSI) {
+ display_value = pressure.psi();
+ } else if (format::pressure_unit == PRESSURE_UNIT_BAR) {
+ display_value = pressure.bar();
+ } else {
+ display_value = pressure.kilopascal();
+ }
+
+ field_pressure.set_value(display_value);
+}
+
+void TPMSTXView::on_temperature_unit_change() {
+ // Convert displayed temperature to new unit and update field
+ units::Temperature temperature(temperature_c_);
+ int display_value = 0;
+
+ if (format::temp_unit == TEMP_UNIT_FAHRENHEIT) {
+ display_value = temperature.fahrenheit();
+ } else {
+ display_value = temperature.celsius();
+ }
+
+ field_temperature.set_value(display_value);
+}
+
+void TPMSTXView::encode_and_transmit() {
+ if (!is_transmitting_) return;
+
+ // Build TPMS packet based on signal type
+ std::string binary_string;
+ uint32_t symbol_rate;
+ uint32_t sample_rate;
+
+ // Set sample rate based on signal type to match transmitter config
+ if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
+ sample_rate = 2280000; // 2.28 MHz for FSK (matches transmitter_model config)
+ } else {
+ sample_rate = 2000000; // 2 MHz for OOK
+ }
+
+ if (signal_type_ == tpms::SignalType::OOK_8k192_Schrader) {
+ // Schrader OOK 8k192 format (Type = Schrader)
+ // Preamble: 11*2, 01*14, 11, 10
+ // Data: 3 bits function code, 24 bits ID, 8 bits pressure, 2 bits checksum
+ // Total: 37 Manchester symbols (74 bits after encoding)
+ // NOTE: This protocol does NOT support temperature transmission
+ // NOTE: Only lower 24 bits of ID are transmitted
+ // NOTE: Function code (flags_) is stored as 3-bit value (0-7)
+ // RX will display it combined with checksum in upper nibble
+
+ symbol_rate = 8192;
+
+ // Preamble as expected by RX decoder
+ binary_string = "1111"; // 11*2
+ for (int i = 0; i < 14; i++) {
+ binary_string += "01";
+ }
+ binary_string += "1110"; // 11, 10
+
+ // Build data bits (pre-Manchester)
+ uint64_t data = 0;
+
+ // 3-bit flags (0-7, stored directly)
+ uint8_t flags_3bit = flags_ & 0x07;
+ data |= ((uint64_t)flags_3bit << 34);
+
+ // 24-bit ID (only use lower 24 bits - protocol limitation)
+ uint32_t id_24bit = transponder_id_ & 0x00FFFFFF;
+ data |= ((uint64_t)id_24bit << 10);
+
+ // 8-bit pressure (convert kPa to raw: kPa * 3/4)
+ // Clamp to prevent overflow: max raw = 255, max kPa = 255 * 4/3 = 340 (49.3 PSI)
+ uint16_t pressure_clamped = (pressure_kpa_ > 340) ? 340 : pressure_kpa_;
+ uint8_t pressure_raw = (pressure_clamped * 3 / 4);
+ data |= ((uint64_t)pressure_raw << 2);
+
+ // Calculate 2-bit checksum: sum all 2-bit pairs, result & 3 must equal 3
+ uint32_t checksum_calc = (data >> 36) & 1; // First bit
+ for (size_t i = 1; i < 37; i += 2) {
+ checksum_calc += (data >> (37 - i - 2)) & 3;
+ }
+ uint8_t checksum_2bit = (3 - (checksum_calc & 3)) & 3;
+ data |= checksum_2bit;
+
+ // Manchester encode the 37 data bits
+ for (int i = 36; i >= 0; i--) {
+ if ((data >> i) & 1) {
+ binary_string += "10"; // '1' = 10 in Manchester
+ } else {
+ binary_string += "01"; // '0' = 01 in Manchester
+ }
+ }
+
+ } else if (signal_type_ == tpms::SignalType::OOK_8k4_Schrader) {
+ // GMC_96 OOK 8k4 format
+ symbol_rate = 8400;
+
+ // Preamble: 01*40
+ for (int i = 0; i < 40; i++) {
+ binary_string += "01";
+ }
+
+ // First nibble of System ID (0x4) - part of preamble pattern match
+ // RX looks for pattern ending with: 01010101...01100101
+ // The "01100101" = Manchester for 0x4, and is consumed by pattern matcher
+ binary_string += "01"; // 0
+ binary_string += "10"; // 1
+ binary_string += "01"; // 0
+ binary_string += "01"; // 0 (0100 = 0x4)
+
+ // Remaining 20 bits of system ID (padding with zeros)
+ // These are the first 20 bits of the 76-bit payload
+ for (int i = 0; i < 20; i++) {
+ binary_string += "01"; // All zeros for padding
+ }
+
+ // 32-bit ID (Manchester encoded)
+ for (int i = 31; i >= 0; i--) {
+ if ((transponder_id_ >> i) & 1) {
+ binary_string += "10";
+ } else {
+ binary_string += "01";
+ }
+ }
+
+ // Pressure: kPa * 4/11
+ // Clamp to prevent overflow: max raw = 255, max kPa = 255 * 11/4 = 701.25 (101.7 PSI)
+ uint16_t pressure_clamped = (pressure_kpa_ > 701) ? 701 : pressure_kpa_;
+ uint8_t pressure_gmc = (pressure_clamped * 4 / 11);
+ for (int i = 7; i >= 0; i--) {
+ if ((pressure_gmc >> i) & 1) {
+ binary_string += "10";
+ } else {
+ binary_string += "01";
+ }
+ }
+
+ // Temperature: temp + 61
+ uint8_t temp_gmc = (temperature_c_ + 61) & 0xFF;
+ for (int i = 7; i >= 0; i--) {
+ if ((temp_gmc >> i) & 1) {
+ binary_string += "10";
+ } else {
+ binary_string += "01";
+ }
+ }
+
+ // Checksum: sum of all data bytes (system ID + ID + pressure + temp)
+ // System ID byte 0: (0x4 << 4) | 0x0 = 0x40
+ // System ID byte 1: 0x00
+ // System ID byte 2: 0x00
+ uint8_t checksum = 0x40; // First byte of system ID
+ checksum += 0x00; // Second byte of system ID
+ checksum += 0x00; // Third byte of system ID
+
+ // Add all 4 bytes of the ID
+ checksum += (transponder_id_ >> 24) & 0xFF;
+ checksum += (transponder_id_ >> 16) & 0xFF;
+ checksum += (transponder_id_ >> 8) & 0xFF;
+ checksum += transponder_id_ & 0xFF;
+
+ // Add pressure and temperature
+ checksum += pressure_gmc;
+ checksum += temp_gmc;
+
+ for (int i = 7; i >= 0; i--) {
+ if ((checksum >> i) & 1) {
+ binary_string += "10";
+ } else {
+ binary_string += "01";
+ }
+ }
+
+ } else if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
+ // FSK 19.2k for FLM variants
+ // Data is Manchester encoded: each data bit becomes two FSK symbols
+ // Preamble: 14 x '01' + '10' = 30 bits (matches RX pattern exactly)
+ // Payload: 160 Manchester-encoded bits (80 data bits max)
+
+ symbol_rate = 19200;
+
+ // Build preamble: 14 pairs of "01" followed by "10"
+ for (int i = 0; i < 14; i++) {
+ binary_string += "01";
+ }
+ binary_string += "10";
+
+ std::array data_bytes = {0}; // 160 bits = 20 bytes max
+ size_t num_data_bits = 0;
+
+ if (packet_type_ == tpms::Reading::Type::FLM_64) {
+ // FLM_64: 64 bits (8 bytes)
+ // Bytes 0-3: ID (32 bits)
+ // Byte 4: Pressure (8 bits)
+ // Byte 5: Temperature (8 bits, LSB 7 bits used)
+ // Byte 6: Padding
+ // Byte 7: Sum checksum of bytes 0-6 (low 8 bits)
+
+ num_data_bits = 64;
+
+ data_bytes[0] = (transponder_id_ >> 24) & 0xFF;
+ data_bytes[1] = (transponder_id_ >> 16) & 0xFF;
+ data_bytes[2] = (transponder_id_ >> 8) & 0xFF;
+ data_bytes[3] = transponder_id_ & 0xFF;
+ // Clamp pressure to prevent overflow: max 340 kPa (49.3 PSI)
+ uint16_t pressure_clamped_flm64 = (pressure_kpa_ > 340) ? 340 : pressure_kpa_;
+ data_bytes[4] = (pressure_clamped_flm64 * 3 / 4);
+ data_bytes[5] = ((temperature_c_ + 56) & 0x7F); // LSB 7 bits only
+ data_bytes[6] = 0x00; // Padding
+
+ // Addition checksum over bytes 0-6
+ uint32_t checksum = 0;
+ for (int i = 0; i < 7; i++) {
+ checksum += data_bytes[i];
+ }
+ data_bytes[7] = checksum & 0xFF;
+
+ } else if (packet_type_ == tpms::Reading::Type::FLM_72) {
+ // FLM_72: 72 bits (9 bytes)
+ // Bytes 0-3: ID (32 bits)
+ // Byte 4: Padding
+ // Byte 5: Pressure (8 bits)
+ // Byte 6: Temperature (8 bits)
+ // Bytes 7-8: CRC field - RX processes ALL 9 bytes and expects CRC result = 0
+
+ num_data_bits = 72;
+
+ data_bytes[0] = (transponder_id_ >> 24) & 0xFF;
+ data_bytes[1] = (transponder_id_ >> 16) & 0xFF;
+ data_bytes[2] = (transponder_id_ >> 8) & 0xFF;
+ data_bytes[3] = transponder_id_ & 0xFF;
+ data_bytes[4] = 0x00; // Padding
+ // Clamp pressure to prevent overflow: max 340 kPa (49.3 PSI)
+ uint16_t pressure_clamped_flm = (pressure_kpa_ > 340) ? 340 : pressure_kpa_;
+ data_bytes[5] = (pressure_clamped_flm * 3 / 4);
+ data_bytes[6] = (temperature_c_ + 56) & 0xFF;
+ data_bytes[7] = 0x00; // Set to 0 initially
+
+ // Calculate CRC over bytes 0-7, place result in byte 8
+ // When RX calculates CRC over bytes 0-8, it should get residual 0
+ CRC<8> crc{0x01, 0x00};
+ for (int i = 0; i < 8; i++) {
+ crc.process_byte(data_bytes[i]);
+ }
+ data_bytes[8] = crc.checksum() & 0xFF;
+
+ } else if (packet_type_ == tpms::Reading::Type::FLM_80) {
+ // FLM_80: 80 bits (10 bytes)
+ // Byte 0: Padding
+ // Bytes 1-4: ID (32 bits)
+ // Byte 5: Padding
+ // Byte 6: Pressure (8 bits)
+ // Byte 7: Temperature (8 bits)
+ // Bytes 8-9: CRC field - RX processes bytes 1-9 and expects CRC result = 0
+
+ num_data_bits = 80;
+
+ data_bytes[0] = 0x00; // Padding (not included in CRC)
+ data_bytes[1] = (transponder_id_ >> 24) & 0xFF;
+ data_bytes[2] = (transponder_id_ >> 16) & 0xFF;
+ data_bytes[3] = (transponder_id_ >> 8) & 0xFF;
+ data_bytes[4] = transponder_id_ & 0xFF;
+ data_bytes[5] = 0x00; // Padding
+ // Clamp pressure to prevent overflow: max 340 kPa (49.3 PSI)
+ uint16_t pressure_clamped_flm80 = (pressure_kpa_ > 340) ? 340 : pressure_kpa_;
+ data_bytes[6] = (pressure_clamped_flm80 * 3 / 4);
+ data_bytes[7] = (temperature_c_ + 56) & 0xFF;
+ data_bytes[8] = 0x00; // Padding/Reserved
+ data_bytes[9] = 0x00; // CRC byte
+
+ // Calculate CRC over bytes 1-8 (all bytes except 0 and 9)
+ // When RX calculates CRC over bytes 1-9, it should get residual 0
+ CRC<8> crc{0x01, 0x00};
+ for (int i = 1; i <= 8; i++) {
+ crc.process_byte(data_bytes[i]);
+ }
+ data_bytes[9] = crc.checksum() & 0xFF;
+ }
+
+ // Manchester-encode data bits for FSK
+ // RX ManchesterDecoder with sense=0: '1' = "10", '0' = "01"
+ size_t num_bytes = num_data_bits / 8;
+ for (size_t byte_idx = 0; byte_idx < num_bytes; byte_idx++) {
+ uint8_t byte_val = data_bytes[byte_idx];
+ for (int bit_idx = 7; bit_idx >= 0; bit_idx--) {
+ if ((byte_val >> bit_idx) & 1) {
+ binary_string += "10"; // Manchester '1'
+ } else {
+ binary_string += "01"; // Manchester '0'
+ }
+ }
+ }
+
+ // Pad payload to 160 bits with valid Manchester pairs ("01" = 0)
+ // Using proper Manchester encoding maintains clock recovery sync
+ while (binary_string.length() < 190) { // 30 preamble + 160 payload
+ binary_string += "01"; // Manchester-encoded zero
+ }
+
+ } else {
+ text_status.set("Unknown signal type");
+ stop_tx();
+ return;
+ }
+
+ // Convert binary string to bitstream
+ size_t bitstream_length = encoders::make_bitstream(binary_string);
+
+ // Calculate samples per bit (round to nearest for best timing accuracy)
+ uint32_t samples_per_bit = (sample_rate + symbol_rate / 2) / symbol_rate;
+
+ // Update status to show we're sending
+ text_status.set("TX: " + to_string_dec_uint(binary_string.length()) + " bits");
+
+ // Send via appropriate baseband (OOK or FSK)
+ if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
+ // FSK mode: 19.2 kbaud, 38.4 kHz shift
+ baseband::set_fsk_data(
+ bitstream_length,
+ samples_per_bit,
+ 38400, // 38.4 kHz shift
+ 256); // Progress notice interval
+ } else {
+ // OOK mode
+ baseband::set_ook_data(
+ bitstream_length,
+ samples_per_bit,
+ repeat_count_,
+ pause_duration_);
+ }
+}
+
+void TPMSTXView::handle_tx_complete() {
+ // For FSK (FLM packets), handle repeats at application level
+ // OOK repeats are handled by the baseband processor
+ if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
+ fsk_repeat_counter_++;
+
+ if (fsk_repeat_counter_ < repeat_count_) {
+ // More repeats needed - send the next one
+ // Update progress bar
+ progressbar.set_value(fsk_repeat_counter_);
+
+ // Wait for FSK processor to fully complete and reset
+ // This allows the shared memory bitstream to be safely rewritten
+ chThdSleepMilliseconds(50);
+
+ encode_and_transmit();
+ } else {
+ // All FSK repeats complete
+ stop_tx();
+ }
+ } else {
+ // OOK repeats are handled by baseband, just stop here
+ stop_tx();
+ }
+}
+
+void TPMSTXView::start_tx() {
+ if (is_transmitting_)
+ return;
+
+ is_transmitting_ = true;
+
+ progressbar.set_max(repeat_count_);
+ progressbar.set_value(0);
+
+ button_transmit.set_text("STOP TX");
+ text_status.set("Transmitting...");
+
+ // Initialize FSK repeat counter for application-level repeat handling
+ fsk_repeat_counter_ = 0;
+
+ // Switch to appropriate baseband before transmission
+ switch_baseband();
+
+ // Configure transmitter based on modulation type
+ if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
+ // FSK configuration
+ transmitter_model.set_sampling_rate(2280000); // 2.28 MHz for FSK
+ transmitter_model.set_baseband_bandwidth(1750000);
+ } else {
+ // OOK configuration
+ transmitter_model.set_sampling_rate(2000000); // 2 MHz for OOK
+ transmitter_model.set_baseband_bandwidth(1750000);
+ }
+
+ transmitter_model.enable();
+
+ // Start transmission
+ encode_and_transmit();
+}
+
+void TPMSTXView::stop_tx() {
+ if (!is_transmitting_)
+ return;
+
+ is_transmitting_ = false;
+ transmitter_model.disable();
+
+ button_transmit.set_text("START TX");
+ text_status.set("Transmission complete");
+ progressbar.set_value(0);
+}
+
+TPMSTXView::TPMSTXView(NavigationView& nav)
+ : nav_{nav} {
+ // Start with OOK baseband (will switch if needed)
+ baseband::run_image(portapack::spi_flash::image_tag_ook);
+
+ add_children({&labels,
+ &label_temperature,
+ &label_flags,
+ &options_frequency,
+ &options_packet_type,
+ &field_transponder_id_24,
+ &field_transponder_id_32,
+ &field_pressure,
+ &options_pressure,
+ &field_temperature,
+ &options_temperature,
+ &field_flags,
+ &field_repeat,
+ &button_load,
+ &button_save,
+ &tx_view,
+ &button_transmit,
+ &text_status,
+ &progressbar});
+
+ // Set label styles to match other labels (light grey)
+ label_temperature.set_style(Theme::getInstance()->fg_light);
+ label_flags.set_style(Theme::getInstance()->fg_light);
+
+ // Initialize values
+ options_frequency.set_by_value(transmitter_model.target_frequency());
+ options_packet_type.set_selected_index(0);
+ field_repeat.set_value(repeat_count_);
+
+ // Set initial signal type based on packet type
+ update_signal_type_from_packet();
+
+ // Initialize unit selection
+ options_pressure.set_by_value(format::pressure_unit);
+ options_temperature.set_by_value(format::temp_unit);
+
+ // Initialize field values from default member variables
+ // For pressure and temperature, use unit conversion to display in selected units
+ field_transponder_id_24.set_value(transponder_id_ & 0x00FFFFFF);
+ field_transponder_id_32.set_value(transponder_id_);
+ on_pressure_unit_change();
+ on_temperature_unit_change();
+ field_flags.set_value(flags_);
+
+ update_field_visibility();
+ update_packet_display();
+
+ // Event handlers
+ options_frequency.on_change = [this](size_t, OptionsField::value_t v) {
+ transmitter_model.set_target_frequency(v);
+ };
+
+ options_packet_type.on_change = [this](size_t, int32_t value) {
+ packet_type_ = static_cast(value);
+ update_signal_type_from_packet();
+
+ // Sync field values when switching packet types
+ if (packet_type_ == tpms::Reading::Type::Schrader) {
+ // Switching to Schrader: copy from 32-bit field to 24-bit field (masked)
+ transponder_id_ = field_transponder_id_32.to_integer() & 0x00FFFFFF;
+ field_transponder_id_24.set_value(transponder_id_);
+ } else {
+ // Switching from Schrader: copy from 24-bit field to 32-bit field
+ transponder_id_ = field_transponder_id_24.to_integer();
+ field_transponder_id_32.set_value(transponder_id_);
+ }
+
+ update_field_visibility();
+ update_packet_display();
+ };
+
+ options_pressure.on_change = [this](size_t, int32_t i) {
+ format::pressure_unit = (uint8_t)i;
+ on_pressure_unit_change();
+ };
+
+ options_temperature.on_change = [this](size_t, int32_t i) {
+ format::temp_unit = (uint8_t)i;
+ on_temperature_unit_change();
+ };
+
+ field_transponder_id_24.on_change = [this](SymField&) {
+ transponder_id_ = field_transponder_id_24.to_integer() & 0x00FFFFFF;
+ update_packet_display();
+ };
+
+ field_transponder_id_32.on_change = [this](SymField&) {
+ transponder_id_ = field_transponder_id_32.to_integer();
+ update_packet_display();
+ };
+
+ field_pressure.on_change = [this](int32_t value) {
+ // Convert from displayed unit to kPa for internal storage
+ if (format::pressure_unit == PRESSURE_UNIT_PSI) {
+ pressure_kpa_ = value * 6895 / 1000;
+ } else if (format::pressure_unit == PRESSURE_UNIT_BAR) {
+ pressure_kpa_ = value * 100;
+ } else {
+ pressure_kpa_ = value;
+ }
+ update_packet_display();
+ };
+
+ field_temperature.on_change = [this](int32_t value) {
+ // Convert from displayed unit to Celsius for internal storage
+ if (format::temp_unit == TEMP_UNIT_FAHRENHEIT) {
+ temperature_c_ = (value - 32) * 5 / 9;
+ } else {
+ temperature_c_ = value;
+ }
+ update_packet_display();
+ };
+
+ field_flags.on_change = [this](int32_t value) {
+ flags_ = value & 0x07;
+ update_packet_display();
+ };
+
+ field_repeat.on_change = [this](int32_t value) {
+ repeat_count_ = value;
+ };
+
+ button_transmit.on_select = [this](Button&) {
+ if (is_transmitting_) {
+ stop_tx();
+ } else {
+ start_tx();
+ }
+ };
+
+ button_load.on_select = [this, &nav](Button&) {
+ auto open_view = nav.push(".TXT");
+ open_view->on_changed = [this](std::filesystem::path file_path) {
+ // Load TPMS packet from file
+ File f;
+ auto error = f.open(file_path);
+ if (!error.is_valid()) {
+ char buffer[512];
+ auto result = f.read(buffer, sizeof(buffer) - 1);
+ if (result.is_ok()) {
+ buffer[result.value()] = 0;
+
+ // Parse the file format (key=value format, one per line)
+ std::string content(buffer);
+ size_t pos = 0;
+
+ auto parse_line = [&content, &pos](const std::string& key) -> std::string {
+ size_t key_pos = content.find(key + "=", pos);
+ if (key_pos != std::string::npos) {
+ size_t value_start = key_pos + key.length() + 1;
+ size_t value_end = content.find('\n', value_start);
+ if (value_end == std::string::npos) value_end = content.length();
+ return content.substr(value_start, value_end - value_start);
+ }
+ return "";
+ };
+
+ std::string type_str = parse_line("Type");
+ std::string id_str = parse_line("ID");
+ std::string pressure_str = parse_line("Pressure");
+ std::string temp_str = parse_line("Temperature");
+ std::string flags_str = parse_line("Flags");
+ std::string signal_type_str = parse_line("SignalType");
+
+ if (!type_str.empty()) {
+ int type = std::stoi(type_str);
+ if (type >= static_cast