diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..326ca85 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +# Keep GitHub Actions in the CI workflow up to date. (The Flipper FAP and the +# Android app pull their dependencies from ufbt/Gradle at build time and are +# not tracked here.) +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e492590 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + # spectrum_scraper is pure Python standard library — compile-check it and + # confirm the bundled allocation CSV parses with the expected columns. + python: + name: Python (spectrum_scraper) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Byte-compile scraper + run: python -m py_compile spectrum_scraper/*.py + - name: Validate spectrum.csv header + rows + run: | + python - <<'PY' + import csv, sys + path = "android/www/spectrum.csv" + with open(path, newline="", encoding="utf-8") as f: + r = csv.DictReader(f) + required = {"freq_low_mhz", "freq_high_mhz"} + missing = required - set(r.fieldnames or []) + assert not missing, f"spectrum.csv missing columns: {missing}" + n = 0 + for row in r: + float(row["freq_low_mhz"]); float(row["freq_high_mhz"]) + n += 1 + assert n > 0, "spectrum.csv has no data rows" + print(f"spectrum.csv OK: {n} rows, columns={r.fieldnames}") + PY + + # Web UI is vanilla JS (no build step) — syntax-check the sources. + web: + name: Web UI (syntax) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Syntax-check JS + run: node --check android/www/app.js + + # Build the Flipper FAP exactly as a maintainer would, with ufbt. + fap: + name: Flipper FAP (ufbt build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install ufbt + run: pip install ufbt + - name: Fetch Flipper SDK + run: ufbt update + - name: Build FAP + working-directory: flipper + run: ufbt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..66a0545 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to Mhz_Localiser are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project aims to follow semantic-ish versioning tied to the FAP/APK version. + +## [2.1] - 2026-08-13 + +### Added +- **Bluetooth LE transport.** The Flipper FAP now streams the same CSV telemetry over + **both USB CDC and Bluetooth LE** simultaneously. The Android app can connect over either — + fully wireless, no cable required. +- **Dual-transport picker** in the app drawer (USB / Bluetooth); the solver, map, and + allocation lookup behave identically on either transport. +- **BLE device picker** that lists nearby Flippers (matched by serial-service UUID and by + name, so custom-named units without the "Flipper" prefix are found). The chosen device + address is remembered for one-tap reconnect. +- **On-device Bluetooth status** on the Flipper running screen: `BT:off` / `BT:adv` / `BT:ok`. +- **RSSI smoothing** — exponential moving-average filter on the live stream to damp + single-sample multipath spikes before capture. +- **Selectable environment / path-loss exponent `n`** (open field / suburban / urban / + dense / indoor, or a custom value) in the drawer. +- **Automatic outlier rejection** — leave-one-out residual check excludes likely-multipath + captures before the final solve. +- **Live capture-geometry hint** warning when captures are too collinear for a good fix. +- **Session persistence** — captures and settings survive an app restart. +- Live allocation sync driven by the Flipper stream, auto-detected frequency, and a + display-only allocation view. + +### Fixed +- BLE reliability: force the LE transport during pairing, self-heal advertising after a + stack restart, refresh Android's stale GATT service cache before discovery, and bind the + correct data characteristic (TX / indicate rather than the flow-control notify char). +- Tab navigation: the live stream no longer forces the app back to the Allocation tab, + so the Triangulator view stays put while data streams. + +### Changed +- `application.fam` now requires the `bt` service; FAP version bumped to 2.1. +- README, protocol docs, and requirements updated for dual-transport operation. + +## [2.0] + +### Changed +- Flipper FAP is **manual-frequency only** — the preset menu (315 / 433 / 868 / 915 MHz) + was removed and the app opens directly on the digit editor for faster startup. +- Smaller FAP binary (preset table and menu renderer eliminated). +- Android plugin auto-reconnects over USB if the link drops. +- Enriched README; tightened `.gitignore`. + +## [1.0] + +### Added +- Initial release: Sub-GHz RSSI logger FAP streaming CSV over USB CDC. +- Android triangulation app: live readout, GPS + RSSI capture, Nelder–Mead least-squares + solver with RMS error, Leaflet map. +- Offline spectrum allocation lookup (~2,450 rows: ITU R1/R2/R3, USA federal + non-federal, + per-country EU) bundled as `spectrum.csv`, with the Python `spectrum_scraper` tooling. + +[2.1]: https://github.com/TFD-42/Mhz_Localiser/releases +[2.0]: https://github.com/TFD-42/Mhz_Localiser/releases +[1.0]: https://github.com/TFD-42/Mhz_Localiser/releases diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0cadf0d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,52 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Scope + +This Code of Conduct applies within all community spaces — the repository, its +issues, pull requests, and discussions — and also applies when an individual is +officially representing the project in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers via GitHub — by opening a private report on +the repository, or by contacting the maintainer [@TFD-42](https://github.com/TFD-42). +All complaints will be reviewed and investigated promptly and fairly. Maintainers +are obligated to respect the privacy and security of the reporter. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, available at +. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d33df1d..b200147 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,9 +16,9 @@ This lets us calibrate the path-loss exponent guidance and document failure mode ## Code -- **Flipper FAP** (`Build/rf_logger/`) — keep dependencies to `furi`, `furi_hal`, `gui`, `storage`, `notification`. No external libraries. -- **Android plugin** (`Build/android_app/plugin/`) — `usb-serial-for-android` only. Avoid adding heavyweight deps. -- **Web UI** (`Build/android_app/www/`) — vanilla JS + Leaflet. No build step, no framework. +- **Flipper FAP** (`flipper/`) — keep dependencies to `furi`, `furi_hal`, `gui`, `storage`, `notification`, `bt`. No external libraries. +- **Android plugins** (`android/plugin/`) — `FlipperSerialPlugin` (USB, via `usb-serial-for-android`) and `FlipperBlePlugin` (Bluetooth, via the `android.bluetooth` framework — no extra dep). Both expose the same `connect`/`disconnect`/`data`/`status` interface; keep them interchangeable. Avoid adding heavyweight deps. +- **Web UI** (`android/www/`) — vanilla JS + Leaflet. No build step, no framework. Run `clang-format` on C files and stick to 4-space indent / 100-col width. diff --git a/README.md b/README.md index bc7a415..5c73ac0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,16 @@ # Mhz_Localiser -**RF signal triangulation + spectrum allocation lookup** — Flipper Zero streams live RSSI over USB to an Android app that logs GPS + signal, estimates the transmitter location on a map, and lets you look up the regulatory allocation (USA, ITU, EU per country) of any frequency you observe. +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![CI](https://github.com/TFD-42/Mhz_Localiser/actions/workflows/ci.yml/badge.svg)](https://github.com/TFD-42/Mhz_Localiser/actions/workflows/ci.yml) +![Platform: Flipper Zero](https://img.shields.io/badge/platform-Flipper%20Zero-FF8200) +![Android 8.0+](https://img.shields.io/badge/Android-8.0%2B-3DDC84?logo=android&logoColor=white) +![Link: USB · BLE](https://img.shields.io/badge/link-USB%20%C2%B7%20BLE-5A29E4?logo=bluetooth&logoColor=white) +![Spectrum DB: offline](https://img.shields.io/badge/spectrum%20DB-offline-2ea043) +![Status: beta](https://img.shields.io/badge/status-beta-yellow) + +**RF signal triangulation + spectrum allocation lookup** — Flipper Zero streams live RSSI over **USB or Bluetooth LE** to an Android app that logs GPS + signal, estimates the transmitter location on a map, and lets you look up the regulatory allocation (USA, ITU, EU per country) of any frequency you observe. + +**Contents:** [How it works](#how-it-works) · [What's new in v2.1](#whats-new-in-v21) · [Operational workflow](#operational-workflow) · [Field validation](#field-validation) · [Physical limitations](#physical-limitations) · [vs. real RF gear](#comparison-with-real-rf-equipment) · [Use cases](#realistic-use-cases) · [Quick start](#quick-start) · [Allocation list](#allocation-list) · [Build from source](#build-from-source) · [Data protocol](#data-protocol-usb--ble) ChatGPT Image 16 mai 2026 à 03_50_03 @@ -24,7 +34,8 @@ Mhz_Localiser/ │ │ ├── styles.css mobile-first dark theme │ │ └── spectrum.csv ~2 450 spectrum allocations (offline) │ └── plugin/ -│ └── FlipperSerialPlugin.java Native USB-CDC bridge +│ ├── FlipperSerialPlugin.java Native USB-CDC bridge +│ └── FlipperBlePlugin.java Native Bluetooth-LE bridge (GATT serial) │ ├── spectrum_scraper/ ← Python tooling that builds spectrum.csv │ ├── enrich_spectrum.py baseline → long-form CSV (EFIS optional) @@ -37,6 +48,21 @@ Mhz_Localiser/ └── LICENSE MIT ``` +## What's new in v2.1 + +| Change | Detail | +|--------|--------| +| **Bluetooth LE streaming** | The Flipper now streams the same CSV over **both USB and BLE** simultaneously. Go fully wireless — no cable between Flipper and phone. | +| **Dual transport picker** | Choose **USB** or **Bluetooth** in the drawer; the solver, map, and allocation lookup are identical on either. | +| **BLE device picker** | Scans for the Flipper by serial-service UUID and by name; custom-named units (no "Flipper" prefix) are listed and the chosen device is remembered for one-tap reconnect. | +| **Robust BLE connect** | Forces the LE transport, self-heals advertising, refreshes Android's stale GATT cache, and binds the correct data characteristic (INDICATE) — so it survives re-pairs and firmware re-flashes. | +| **On-device BT status** | The Flipper running screen shows `BT:off / BT:adv / BT:ok` so you can see the link state at a glance. | +| **RSSI smoothing** | Exponential moving-average filter on the live stream damps single-sample multipath spikes before capture. | +| **Selectable environment / path-loss `n`** | Pick open-field / suburban / urban / dense / indoor (or a custom `n`) in the drawer — the single biggest lever on accuracy. | +| **Automatic outlier rejection** | Leave-one-out residual check flags and excludes likely-multipath captures before the final solve. | +| **Live capture-geometry hint** | Warns when captures are too collinear (ambiguous fix) so you spread out for better geometry. | +| **Session persistence** | Captures + settings survive an app restart (stored locally), so a field session isn't lost. | + ## What's new in v2 | Change | Detail | @@ -51,8 +77,8 @@ Mhz_Localiser/ ## How it works -- **Flipper Zero** runs `rf_logger.fap` — a Sub-GHz RSSI logger that continuously samples signal strength on a chosen frequency and streams readings over USB as CSV. -- **Android app** (`RF_Triangulator.apk`) connects to the Flipper via USB-C, reads the live RSSI stream, and logs GPS + signal captures on a map. +- **Flipper Zero** runs `rf_logger.fap` — a Sub-GHz RSSI logger that continuously samples signal strength on a chosen frequency and streams readings as CSV over **USB CDC and Bluetooth LE at the same time**. +- **Android app** (`RF_Triangulator.apk`) connects to the Flipper over **USB-C or Bluetooth LE** (your choice), reads the live RSSI stream, and logs GPS + signal captures on a map. - With 3 or more captures from different positions, the app runs a **Nelder-Mead least-squares solver** to estimate the transmitter location. layout-collage-1779109255380 @@ -69,17 +95,18 @@ Mhz_Localiser/ │ │ │ CC1101 chip ──► RSSI register ──► rf_logger.fap │ │ (Sub-GHz) sampled @ 5 Hz (FAP app) │ -└────────────────────────────┬────────────────────────────────────┘ - │ USB CDC-ACM serial (ch1) - │ 115200 baud, dual CDC - │ CSV: ts_ms, req_hz, act_hz, - │ rssi_dbm, rssi_raw, lqi, n - ▼ +└──────────────┬───────────────────────────────┬─────────────────┘ + │ USB CDC-ACM (ch1) │ BLE GATT serial + │ 115200 baud, dual CDC │ service 0xfe60, + │ │ TX char indicate + │ CSV: ts_ms, req_hz, act_hz, rssi_dbm, rssi_raw, lqi, n + ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Android App │ │ │ -│ FlipperSerialPlugin ──► USB serial reader (Capacitor) │ -│ (native Java) parses CSV stream, auto-reconnect │ +│ FlipperSerialPlugin ─┐ │ +│ FlipperBlePlugin ─┴► transport picker (USB / BLE) │ +│ (native Java) parses CSV stream, auto-reconnect │ │ │ │ Geolocation API ──► GPS coordinates │ │ │ @@ -107,11 +134,14 @@ Launch Back — exit app Running state - └─► Live RSSI streamed over USB CSV to Android + └─► Live RSSI streamed over USB + BLE CSV to Android + Status line shows BT:off / BT:adv / BT:ok OK — toggle SD card logging on/off Back — return to frequency entry screen ``` +> **Bluetooth note:** enable **Settings → Bluetooth** on the Flipper so the app can advertise. Keep the official Flipper mobile app closed while connected here — only one BLE central can hold the link at a time. + ## Operational Workflow Most people imagine: *"point the Flipper, app finds the source instantly."* @@ -264,13 +294,22 @@ Grant **Location** permission when prompted (required for GPS capture). ### Step 3 — Connect and capture +Open **RF Triangulator** → tap ☰ and pick your transport: + +**USB (wired)** 1. Plug the Flipper into your Android phone with a USB-C cable. -2. Open **RF Triangulator** → tap ☰ → **Connect Flipper**. -3. Accept the USB permission dialog. The app reconnects automatically if USB drops. -4. The top panel mirrors the Flipper: frequency, RSSI, signal bar. -5. Walk to a position and tap **Capture here** — the app logs GPS + live RSSI. -6. Repeat from **3+ different positions** surrounding the suspected transmitter. -7. The drawer shows the **triangulation estimate** with RMS error once you have 3+ captures. +2. Transport **USB** → **Connect Flipper** → accept the USB permission dialog. Auto-reconnects if USB drops. + +**Bluetooth (wireless)** +1. On the Flipper: **Settings → Bluetooth ON**, then launch RF Logger (status shows `BT:adv`). +2. Transport **Bluetooth (BLE)** → **Connect Flipper** → pick your Flipper from the list → accept pairing on both devices. Its address is remembered for one-tap reconnect next time. + +Then, on either transport: + +3. The top panel mirrors the Flipper: frequency, RSSI, signal bar. +4. Walk to a position and tap **Capture here** — the app logs GPS + live RSSI. +5. Repeat from **3+ different positions** surrounding the suspected transmitter. +6. The drawer shows the **triangulation estimate** with RMS error once you have 3+ captures. Tune the **environment / path-loss `n`** there for your surroundings. ### Auto-capture mode @@ -328,11 +367,13 @@ cd android # Output: app/build/outputs/apk/debug/app-debug.apk ``` -Stack: **Capacitor** · **usb-serial-for-android** (CDC-ACM) · **Leaflet** (OpenStreetMap) +Stack: **Capacitor** · **usb-serial-for-android** (CDC-ACM) · **Android BLE** (`BluetoothGatt`, GATT serial) · **Leaflet** (OpenStreetMap) + +Both native bridges (`FlipperSerialPlugin.java`, `FlipperBlePlugin.java`) must be registered in `MainActivity` and expose the same `connect`/`disconnect`/`data`/`status` interface, so the web UI treats USB and BLE as interchangeable. -## USB Serial Protocol +## Data Protocol (USB + BLE) -Flipper streams CSV over USB CDC-ACM channel 1 at **115200 baud**: +Flipper streams the **same CSV** over USB CDC-ACM channel 1 at **115200 baud** and over **Bluetooth LE** (GATT serial service `0000fe60-…`, data on the TX characteristic `0000fe61-…` via indications). The Android side parses both identically: ``` # RF_LOGGER_DBG req=433920000 act=433920000 @@ -351,23 +392,23 @@ ts_ms,req_hz,act_hz,rssi_dbm,rssi_raw,lqi,n | `lqi` | uint8 | Link Quality Indicator | | `n` | uint32 | Sample counter | -Sample rate: **200 ms (5 Hz)**. The Android app auto-detects frequency from `req_hz` — no manual input on the phone side needed. +Sample rate: **200 ms (5 Hz)**. The Android app auto-detects frequency from `req_hz` — no manual input on the phone side needed. BLE notify packets are reassembled by newline, so line framing is transport-independent. ## Security - No API keys, tokens, or credentials in this codebase. -- No hardcoded IP addresses — all communication is local USB only. -- USB permission via standard Android intent (`UsbManager.requestPermission`). -- Location permission requested at runtime only. +- No hardcoded IP addresses — all communication is local (USB or Bluetooth LE), device-to-phone only. +- USB permission via standard Android intent (`UsbManager.requestPermission`); Bluetooth via runtime `BLUETOOTH_SCAN`/`BLUETOOTH_CONNECT` permissions (scan flagged `neverForLocation`). +- Location permission requested at runtime only (for GPS capture). - No outbound network requests from the app (map tiles load from OpenStreetMap via WebView only). ## Requirements | Component | Requirement | |--------------|-------------| -| Flipper Zero | Firmware 0.97+ (official or Unleashed) | -| Android | 8.0+ (API 26), USB OTG support | -| USB cable | USB-C to USB-C (or USB-A OTG adapter) | +| Flipper Zero | Firmware 0.97+ (official or Unleashed); Bluetooth ON for wireless mode | +| Android | 8.0+ (API 26); USB OTG **or** Bluetooth LE | +| Connection | USB-C cable (USB-A OTG adapter), **or** Bluetooth LE — no cable needed | | GPS | Required for capture; indoor = poor accuracy | ## License diff --git a/SECURITY.md b/SECURITY.md index 3b1b726..de4f533 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,9 +14,9 @@ We aim to acknowledge reports within 7 days and provide a fix or mitigation time In scope: -- The Flipper FAP (`Build/rf_logger/`) — memory safety, SD-card path handling. -- The Android Capacitor plugin (`Build/android_app/plugin/`) — USB intent handling, line parsing, permission flow. -- The web UI (`Build/android_app/www/`) — file import parser, exported data integrity. +- The Flipper FAP (`flipper/`) — memory safety, SD-card path handling, BLE/USB stream handling. +- The Android Capacitor plugins (`android/plugin/`) — USB intent handling, BLE GATT/pairing flow, line parsing, permission flow. +- The web UI (`android/www/`) — file import parser, exported data integrity. Out of scope: diff --git a/SETUP.md b/SETUP.md index 50c6c45..29f716f 100644 --- a/SETUP.md +++ b/SETUP.md @@ -81,6 +81,54 @@ VID 0x0483 PID 0x5740 ``` Permissions are requested at runtime via a `BroadcastReceiver`. No additional Android manifest entry is required beyond the standard Capacitor USB-host feature. +### BLE support (FlipperBlePlugin) + +`android/plugin/FlipperBlePlugin.java` adds a Bluetooth Low Energy transport +alongside USB. It exposes the same JS API and event shapes as +`FlipperSerialPlugin`, so the web UI switches between them with a drawer +setting (Transport: USB cable / Bluetooth). + +Build wiring (in addition to the steps above): + +```java +// MainActivity.java +registerPlugin(FlipperBlePlugin.class); +``` + +```xml + + + + + + + +``` + +No extra Gradle dependency — the plugin uses only `android.bluetooth` +framework APIs. + +Runtime notes: +- Bluetooth must be enabled on the Flipper (**Settings → Bluetooth**). +- The Flipper's BLE Serial characteristics are encrypted; the first connect + triggers Android's system pairing dialog. The plugin retries notification + setup automatically once bonding completes. +- `connect()` with no arguments scans ~4 s and picks the strongest device + advertising a `Flipper*` name; `connect({address})` targets a specific one. +- Auto-reconnect retries every 2 s after a link drop, matching the USB + plugin's behavior. + +**Current limitation — firmware side pending:** with stock firmware the BLE +Serial link carries the Flipper **CLI**, not the `rf_logger` CSV stream. +`rf_logger.c` currently writes only to USB CDC. Streaming the CSV over BLE +requires a firmware change (write the same sample lines through the BLE +serial profile), which must first be validated against the Flipper SDK with +`ufbt` — the BLE profile APIs available to third-party FAPs are not +guaranteed stable across firmware versions. Until that lands, the BLE +transport connects and streams whatever the serial service carries, but +triangulation data only flows over USB. + ## 3. spectrum_scraper (mhz_allocator) Pure stdlib — no `pip install` needed. diff --git a/android/README.md b/android/README.md index b506e77..a013514 100644 --- a/android/README.md +++ b/android/README.md @@ -1,33 +1,34 @@ # android — Capacitor sources for RF Triangulator -This is the project-specific Capacitor content: the web UI in `www/` and the native Java plugin in `plugin/`. The full Android Studio project structure (gradle files, AndroidManifest.xml, MainActivity.java, etc.) is regenerated from `package.json` + `npx cap add android` — see top-level `SETUP.md`. +This is the project-specific Capacitor content: the web UI in `www/` and the native +Java plugins in `plugin/`. The full Android Studio project structure (gradle files, +AndroidManifest.xml, MainActivity.java, etc.) is regenerated from `package.json` + +`npx cap add android` — see top-level `SETUP.md`. ## Files ``` www/ ├── index.html App shell with two tabs (Triangulator, Allocation List) -├── app.js Logic: USB plugin bridge, map, capture, Nelder-Mead solver, allocation lookup +├── app.js Transport bridge (USB/BLE), map, capture, Nelder-Mead solver, +│ RSSI smoothing, path-loss n, outlier rejection, allocation lookup ├── styles.css Dark theme, mobile-first └── spectrum.csv ~2 450 spectrum allocations (offline lookup table) plugin/ -└── FlipperSerialPlugin.java Capacitor plugin: USB host, CDC-ACM driver, CSV parser +├── FlipperSerialPlugin.java Capacitor plugin: USB host (CDC-ACM), CSV line framing +└── FlipperBlePlugin.java Capacitor plugin: Bluetooth LE (GATT serial), CSV line framing ``` ## Build -See top-level `SETUP.md` for the full Capacitor bootstrap. Once the project is scaffolded: +See top-level `SETUP.md` for the full Capacitor bootstrap. Once the project is +scaffolded: ```bash -# In your Capacitor project root after copying www/ + plugin npx cap sync android cd android && ./gradlew assembleDebug # Output: app/build/outputs/apk/debug/app-debug.apk -``` - -Install on a connected device: -```bash adb install -r app/build/outputs/apk/debug/app-debug.apk ``` @@ -35,9 +36,10 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk ### Triangulator tab (default) - Leaflet map fullscreen -- Top bar mirrors live Flipper readout: frequency + RSSI + signal bar +- Top bar mirrors live Flipper readout: frequency + RSSI (smoothed) + signal bar - Bottom bar: **Capture here**, **Auto-capture**, **Solve** -- Drawer (☰): connect/disconnect Flipper, import/export, capture list, estimate readout +- Drawer (☰): **transport picker (USB / Bluetooth)**, connect/disconnect, import/export, + environment / path-loss `n`, Tx power, outlier toggle, capture list, estimate readout ### Allocation List tab Two modes selectable from a dropdown: @@ -45,51 +47,64 @@ Two modes selectable from a dropdown: | Mode | Inputs | Behaviour | |---|---|---| | **List by Region / Country** | Country select, Region select | Live filter as you change selections | -| **List by MHz** | Text input (`433.92`, `2.4 GHz`, `88-108 MHz`, `868 kHz`) + Country select | Search on button click or Enter. Suggests nearest covered bands if value falls in a gap | +| **List by MHz** | Text input (`433.92`, `2.4 GHz`, `88-108 MHz`, `868 kHz`) + Country select | Search on button click or Enter. Suggests nearest covered bands if the value falls in a gap | -Results table columns: **MHz range / Country / Region / Service / Status / Application / Source**. PRIMARY in green, Secondary in orange. Tooltip on hover shows the typical-devices list and the regulator note. +Results columns: **MHz range / Country / Region / Service / Status / Application / +Source**. PRIMARY in green, Secondary in orange. The table tracks the live Flipper +frequency in the background (it no longer force-switches tabs). Limited to 500 rows +on screen; refine filters to see more. -Limited to 500 rows on screen for performance; refine filters to see more. +## plugin/ — two interchangeable transports -## plugin/ — FlipperSerialPlugin.java +Both plugins expose the **same JS API and event shapes**, so `app.js` treats USB and +BLE as drop-in replacements and everything downstream of line parsing is +transport-agnostic: -Capacitor plugin bridging the WebView to USB-CDC serial. Methods exposed to JS: - -| Method | Purpose | +| Method / event | Purpose | |---|---| -| `connect()` | Find a Flipper (VID 0x0483 / PID 0x5740), request USB permission, open the second CDC port | -| `disconnect()` | Stop reader thread, close port | -| `startStream()` | Spawn a background thread that reads bytes, splits on `\n`, parses CSV, emits `rssiData` events | -| `stopStream()` | Kill the reader thread | -| `sendCommand({cmd})` | Write a text command to the Flipper (reserved for future use) | -| `getLatestRssi()` | Synchronous getter for the last parsed sample | - -### CSV parser -Accepts both formats the firmware has used over time: -- v1 (decimal MHz): `1234,433.920,433.920,-85,0x57,12,1` -- v2 (integer Hz): `1234,433920000,433920000,-85,0x57,12,1` - -Detection: presence of `.` in the second field. The plugin also recognises the firmware retune marker (`# RF_LOGGER_DBG req=… act=…`) and emits a freq-change event so the UI updates instantly without waiting for the next sample. - -### Dependencies -Add to `android/app/build.gradle`: +| `connect()` | USB: find the Flipper (VID `0x0483` / PID `0x5740`), request permission, open the 2nd CDC port. BLE: scan/pick the Flipper, pair, subscribe to the TX characteristic. | +| `disconnect()` | Close the port / GATT connection | +| `isConnected()` | Current connection state | +| `listDevices()` / `scan()` | Enumerate candidate Flippers (USB drivers / BLE advertisers) | +| `addListener('data', …)` | Emits `{ line }` per newline-terminated CSV row | +| `addListener('status', …)` | Emits `{ state: "connected" \| "disconnected" \| "error", message? }` | + +Both share the same newline framing (`\n`-terminated, `\r` stripped), so a CSV line +split across USB chunks or BLE notify packets is reassembled identically. + +### FlipperBlePlugin notes +- Uses only `android.bluetooth` framework APIs — **no extra Gradle dependency**. +- Targets the Flipper serial GATT service `8fe5b3d5-…-7acc60fe0000` and its **TX + characteristic** `19ed82ae-…-228e61fe0000` (data arrives via **indications**, not + notifications — the flow-control characteristic is the notify one and must not be + chosen). UUIDs are little-endian on the wire (byte array reversed). +- Refreshes Android's cached GATT table before discovery, uses the API-33+ + `onCharacteristicChanged(…, byte[])` callback, forces the LE transport when pairing, + and offers a device picker for custom-named Flippers (remembered for reconnect). + +### Dependencies (`android/app/build.gradle`) ```gradle dependencies { - implementation 'com.github.mik3y:usb-serial-for-android:3.7.0' + implementation 'com.github.mik3y:usb-serial-for-android:3.7.0' // USB only; BLE needs no dep } ``` +(and the JitPack repository in the root `build.gradle`). -Register the plugin in `android/app/src/main/java/com/mhzlocalise/rftriangulator/MainActivity.java`: +### Register both plugins (`MainActivity.java`) ```java -import com.mhzlocalise.rftriangulator.FlipperSerialPlugin; - @Override public void onCreate(Bundle savedInstanceState) { registerPlugin(FlipperSerialPlugin.class); + registerPlugin(FlipperBlePlugin.class); super.onCreate(savedInstanceState); } ``` +### Manifest permissions +USB host feature + location (GPS capture) + BLE (`BLUETOOTH_SCAN` flagged +`neverForLocation`, `BLUETOOTH_CONNECT`, and the pre-Android-12 fallbacks). See +`SETUP.md` for the exact block. + ## License MIT — see top-level LICENSE. diff --git a/android/plugin/FlipperBlePlugin.java b/android/plugin/FlipperBlePlugin.java new file mode 100644 index 0000000..6ea180a --- /dev/null +++ b/android/plugin/FlipperBlePlugin.java @@ -0,0 +1,614 @@ +package com.mhzlocalise.rftriangulator; + +/* + * FlipperBlePlugin + * ---------------- + * Capacitor native plugin that bridges the WebView UI to the Flipper Zero's + * BLE Serial GATT service. Mirrors FlipperSerialPlugin's JS API exactly so + * app.js can treat USB and BLE as interchangeable transports: + * + * FlipperBle.scan() -> { devices: [{name, address, rssi}] } + * FlipperBle.connect() -> { connected: true, deviceName } + * optional { address } (no address: scans and picks the + * strongest device named "Flipper*") + * FlipperBle.disconnect() -> { connected: false } + * FlipperBle.isConnected() -> { connected: boolean } + * addListener('data', cb) -> emits { line } per newline-terminated line + * addListener('status', cb) -> emits { state: "connected"|"disconnected"|"error", message? } + * + * Uses only android.bluetooth framework APIs — no extra Gradle dependency. + * + * NOTES + * - The Flipper's BLE Serial service is the same GATT service the stock + * firmware uses for CLI-over-BLE. Service UUID below is from the official + * firmware (ble_glue serial_service). TX/RX characteristic direction is + * resolved AT RUNTIME by property (NOTIFY = flipper->phone stream) rather + * than by hardcoded char UUID, so a firmware-side UUID reshuffle cannot + * silently break us. + * - The serial characteristics are encrypted: the first connect triggers + * Android's system pairing dialog. A bond-state receiver retries + * notification setup once bonding completes. + * - IMPORTANT: with stock firmware, this link carries the Flipper CLI, not + * the rf_logger CSV. Firmware-side BLE output from rf_logger.fap is a + * separate change (see SETUP.md, "BLE support"). + */ + +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelUuid; +import android.util.Log; + +import com.getcapacitor.JSObject; +import com.getcapacitor.PermissionState; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.annotation.PermissionCallback; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@CapacitorPlugin( + name = "FlipperBle", + permissions = { + @Permission(alias = "bluetooth", strings = { + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + }), + // Pre-Android-12 BLE scanning requires location + @Permission(alias = "location", strings = { + Manifest.permission.ACCESS_FINE_LOCATION, + }), + }) +public class FlipperBlePlugin extends Plugin { + + private static final String TAG = "FlipperBle"; + + /* Flipper Zero BLE Serial service (flipperzero-firmware ble_glue, + * serial_service_uuid.inc). The TX characteristic carries the + * peripheral->central data stream and uses INDICATE; the flow-control + * characteristic uses NOTIFY — subscribing to the wrong one yields a + * live connection with zero data, so we target TX explicitly. */ + /* BLE 128-bit UUIDs are little-endian on the wire, so the string form is + * the firmware byte array REVERSED. Confirmed against the service list + * Android actually discovers on the device. */ + private static final UUID SERIAL_SERVICE_UUID = + UUID.fromString("8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000"); + private static final UUID SERIAL_TX_CHAR_UUID = // Flipper -> phone (indicate) + UUID.fromString("19ed82ae-ed21-4c9d-4145-228e61fe0000"); + private static final UUID CCCD_UUID = + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + + private static final String NAME_PREFIX = "Flipper"; + private static final long SCAN_WINDOW_MS = 4000; + private static final long RECONNECT_DELAY_MS = 2000; // matches USB plugin behavior + private static final int REQUEST_MTU = 247; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final StringBuilder lineBuf = new StringBuilder(256); + + private BluetoothGatt gatt; + private BluetoothDevice targetDevice; + private BluetoothGattCharacteristic notifyChar; + private boolean streamReady = false; + private boolean userDisconnect = false; + private PluginCall pendingConnect; + private int failStreak = 0; // connects that dropped before the stream started + + /* The Flipper serial service is encrypted and several Android stacks + * will NOT auto-pair on an encrypted CCCD write — so we bond explicitly + * before opening GATT. This receiver drives the flow: bond lands -> + * connect (or retry notifications); pairing rejected -> fail loudly. */ + private final BroadcastReceiver bondReceiver = new BroadcastReceiver() { + @Override public void onReceive(Context ctx, Intent intent) { + if (!BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(intent.getAction())) return; + BluetoothDevice dev = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if (dev == null || targetDevice == null + || !dev.getAddress().equals(targetDevice.getAddress())) return; + int state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1); + int prev = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1); + if (state == BluetoothDevice.BOND_BONDED) { + if (gatt == null) { + try { + gatt = targetDevice.connectGatt(getContext(), false, gattCallback); + } catch (SecurityException ignored) {} + } else if (!streamReady) { + enableNotifications(); + } + } else if (state == BluetoothDevice.BOND_NONE && prev == BluetoothDevice.BOND_BONDING) { + failConnect("Pairing was rejected or canceled. Retry and accept the pairing " + + "dialog on BOTH the phone and the Flipper screen."); + } + } + }; + + @Override + public void load() { + getContext().registerReceiver(bondReceiver, + new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)); + } + + @Override + protected void handleOnDestroy() { + try { getContext().unregisterReceiver(bondReceiver); } catch (Exception ignored) {} + userDisconnect = true; + closeGatt(); + } + + /* ---------------- JS-exposed methods ---------------- */ + + @PluginMethod + public void scan(PluginCall call) { + if (!ensurePermissions(call, "scanPermCallback")) return; + doScan(call, null); + } + + @PluginMethod + public void connect(PluginCall call) { + if (!ensurePermissions(call, "connectPermCallback")) return; + doConnect(call); + } + + @PluginMethod + public void disconnect(PluginCall call) { + userDisconnect = true; + closeGatt(); + emitStatus("disconnected", null); + JSObject ret = new JSObject(); + ret.put("connected", false); + call.resolve(ret); + } + + @PluginMethod + public void isConnected(PluginCall call) { + JSObject ret = new JSObject(); + ret.put("connected", streamReady); + call.resolve(ret); + } + + /* ---------------- permissions ---------------- */ + + private boolean ensurePermissions(PluginCall call, String callbackName) { + if (getPermissionState("bluetooth") == PermissionState.GRANTED + && getPermissionState("location") == PermissionState.GRANTED) { + return true; + } + requestPermissionForAliases(new String[]{"bluetooth", "location"}, call, callbackName); + return false; + } + + @PermissionCallback + private void scanPermCallback(PluginCall call) { + if (getPermissionState("bluetooth") == PermissionState.GRANTED) doScan(call, null); + else call.reject("Bluetooth permission denied"); + } + + @PermissionCallback + private void connectPermCallback(PluginCall call) { + if (getPermissionState("bluetooth") == PermissionState.GRANTED) doConnect(call); + else call.reject("Bluetooth permission denied"); + } + + /* ---------------- scan ---------------- */ + + private interface ScanDone { void onDone(Map found, java.util.Set seenNames); } + + private void doScan(PluginCall call, ScanDone chain) { + BluetoothAdapter adapter = adapter(); + if (adapter == null || !adapter.isEnabled()) { + call.reject("Bluetooth is off"); + return; + } + BluetoothLeScanner scanner = adapter.getBluetoothLeScanner(); + if (scanner == null) { call.reject("BLE scanner unavailable"); return; } + + final Map found = new HashMap<>(); // likely Flippers + final Map all = new HashMap<>(); // every advertiser seen + final java.util.Set seenNames = new java.util.HashSet<>(); + final ScanCallback cb = new ScanCallback() { + @Override public void onScanResult(int type, ScanResult result) { + String addr = result.getDevice().getAddress(); + String name = advertisedName(result); + seenNames.add(name != null ? name : addr); + ScanResult prevAll = all.get(addr); + if (prevAll == null || result.getRssi() > prevAll.getRssi()) all.put(addr, result); + /* "Likely Flipper": name prefix (default-named units) OR the + * serial service UUID in the adv. Custom-named Flippers + * advertise the bare name (e.g. "MyFlipper") — prefix matching + * alone misses them, hence the picker fallback in the UI. */ + boolean matches = name != null + && name.regionMatches(true, 0, NAME_PREFIX, 0, NAME_PREFIX.length()); + if (!matches && result.getScanRecord() != null + && result.getScanRecord().getServiceUuids() != null) { + matches = result.getScanRecord().getServiceUuids() + .contains(new ParcelUuid(SERIAL_SERVICE_UUID)); + } + if (!matches) return; + ScanResult prev = found.get(addr); + if (prev == null || result.getRssi() > prev.getRssi()) { + found.put(addr, result); + } + } + }; + + try { + ScanSettings settings = new ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build(); + scanner.startScan(null, settings, cb); + } catch (SecurityException e) { + call.reject("Scan not permitted: " + e.getMessage()); + return; + } + + handler.postDelayed(() -> { + try { scanner.stopScan(cb); } catch (Exception ignored) {} + if (chain != null) { chain.onDone(found, seenNames); return; } + /* Listing mode returns EVERY advertiser so the UI can offer a + * manual picker when auto-match fails (custom-named Flippers). */ + JSONArray arr = new JSONArray(); + for (ScanResult r : all.values()) { + try { + String addr = r.getDevice().getAddress(); + JSONObject o = new JSONObject(); + o.put("name", advertisedName(r)); + o.put("address", addr); + o.put("rssi", r.getRssi()); + o.put("likelyFlipper", found.containsKey(addr)); + arr.put(o); + } catch (Exception ignored) {} + } + JSObject ret = new JSObject(); + ret.put("devices", arr); + call.resolve(ret); + }, SCAN_WINDOW_MS); + } + + /* ---------------- connect ---------------- */ + + private void doConnect(PluginCall call) { + BluetoothAdapter adapter = adapter(); + if (adapter == null || !adapter.isEnabled()) { + call.reject("Bluetooth is off"); + return; + } + String address = call.getString("address"); + if (address != null && !address.isEmpty()) { + try { + openGatt(adapter.getRemoteDevice(address), call); + } catch (IllegalArgumentException e) { + call.reject("Bad BLE address: " + address); + } + return; + } + // No address given: scan and pick the strongest "Flipper*" advertiser. + doScan(call, (found, seenNames) -> { + ScanResult best = null; + for (ScanResult r : found.values()) { + if (best == null || r.getRssi() > best.getRssi()) best = r; + } + if (best == null) { + String msg = "No Flipper found over BLE. Is Bluetooth enabled on the Flipper (Settings > Bluetooth)?"; + if (!seenNames.isEmpty()) msg += " Nearby BLE devices seen: " + seenNames; + call.reject(msg); + return; + } + openGatt(best.getDevice(), call); + }); + } + + private void openGatt(BluetoothDevice device, PluginCall call) { + closeGatt(); + userDisconnect = false; + failStreak = 0; + targetDevice = device; + pendingConnect = call; + try { + /* A stuck half-bond (e.g. from an aborted classic-PIN attempt) + * blocks LE pairing — cancel it before connecting. */ + if (device.getBondState() == BluetoothDevice.BOND_BONDING) { + try { device.getClass().getMethod("cancelBondProcess").invoke(device); } + catch (Exception ignored) {} + } + /* ALWAYS force the LE transport. With TRANSPORT_AUTO an + * address-constructed device has unknown type, so Android + * attempts a classic BR/EDR bond -> legacy PIN dialog -> dead + * end (the Flipper is LE-only). Bonding is initiated lazily + * over the LE link instead — the Flipper sends an SMP Security + * Request on connect, and the descriptor-write fallback below + * covers stacks that ignore it. */ + gatt = device.connectGatt(getContext(), false, gattCallback, + BluetoothDevice.TRANSPORT_LE); + } catch (SecurityException e) { + pendingConnect = null; + call.reject("Connect not permitted: " + e.getMessage()); + return; + } + final PluginCall thisCall = call; + handler.postDelayed(() -> { + if (pendingConnect == thisCall && !streamReady) { + failConnect("Connect/pairing timed out. If a pairing dialog appeared, " + + "accept it on both devices and retry."); + } + }, 35000); + } + + private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() { + @Override + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + /* Clear Android's cached GATT table before discovery. Across + * our earlier connects/re-pairs/re-flashes Android cached a + * stale service list (only 0x1801 Generic Attribute), so the + * Flipper serial service 0xfe60 was invisible. refresh() is a + * hidden API — reflection is the only way to reach it. */ + refreshGattCache(g); + handler.postDelayed(() -> { + if (gatt != g) return; + if (!g.requestMtu(REQUEST_MTU)) g.discoverServices(); + }, 600); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + boolean wasReady = streamReady; + streamReady = false; + notifyChar = null; + try { g.close(); } catch (Exception ignored) {} + if (gatt == g) gatt = null; + if (userDisconnect) return; + if (wasReady) { + failStreak = 0; + emitStatus("disconnected", null); + } else { + /* Dropped before the stream ever started. A stale bond + * (old keys from the official Flipper app) looks exactly + * like this: instant drop, every time. */ + failStreak++; + if (failStreak >= 3) { + targetDevice = null; // stop the reconnect loop + failConnect("BLE drops before the stream starts — pairing is likely stale. " + + "Forget 'Flipper' in Android Bluetooth settings AND on the Flipper " + + "(Settings > Bluetooth > Forget All Paired Devices), then reconnect."); + return; + } + } + // Same auto-reconnect contract as the USB plugin: retry every 2 s + if (targetDevice != null) { + handler.postDelayed(() -> { + if (!userDisconnect && gatt == null && targetDevice != null) { + try { + gatt = targetDevice.connectGatt(getContext(), false, gattCallback); + } catch (SecurityException ignored) {} + } + }, RECONNECT_DELAY_MS); + } + } + } + + @Override + public void onMtuChanged(BluetoothGatt g, int mtu, int status) { + g.discoverServices(); + } + + @Override + public void onServicesDiscovered(BluetoothGatt g, int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + failConnect("Service discovery failed: " + status); + return; + } + StringBuilder svcList = new StringBuilder(); + for (BluetoothGattService s : g.getServices()) svcList.append(s.getUuid()).append(' '); + Log.i(TAG, "discovered services: " + svcList); + BluetoothGattService svc = g.getService(SERIAL_SERVICE_UUID); + if (svc == null) { + // UUID-drift fallback: any service exposing the TX char, else + // any service with an INDICATE/NOTIFY characteristic. + for (BluetoothGattService s : g.getServices()) { + if (s.getCharacteristic(SERIAL_TX_CHAR_UUID) != null) { svc = s; break; } + } + if (svc == null) { + for (BluetoothGattService s : g.getServices()) { + if (pickStreamChar(s) != null) { svc = s; break; } + } + } + } + if (svc == null) { + failConnect("No serial service on device"); + return; + } + /* Prefer the exact TX characteristic; fall back to any char that + * can push data (INDICATE preferred, then NOTIFY). The flow-control + * char is NOTIFY-only and must NOT be chosen when TX exists. */ + notifyChar = svc.getCharacteristic(SERIAL_TX_CHAR_UUID); + if (notifyChar == null) notifyChar = pickStreamChar(svc); + if (notifyChar == null) { + failConnect("No data (TX) characteristic in serial service"); + return; + } + Log.i(TAG, "svc=" + svc.getUuid() + " txChar=" + notifyChar.getUuid() + + " props=0x" + Integer.toHexString(notifyChar.getProperties())); + enableNotifications(); + } + + @Override + public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor d, int status) { + Log.i(TAG, "onDescriptorWrite status=" + status + " (0=OK) uuid=" + d.getUuid()); + if (status == BluetoothGatt.GATT_SUCCESS) { + streamReady = true; + failStreak = 0; + String name = safeName(targetDevice); + emitStatus("connected", name); + if (pendingConnect != null) { + JSObject ret = new JSObject(); + ret.put("connected", true); + ret.put("deviceName", name); + pendingConnect.resolve(ret); + pendingConnect = null; + } + } else { + /* Encrypted characteristic: the write fails until bonded. + * Kick off bonding explicitly — some stacks never do it + * on their own. bondReceiver retries once bonded. */ + try { + if (targetDevice != null + && targetDevice.getBondState() == BluetoothDevice.BOND_NONE) { + emitStatus("pairing", safeName(targetDevice)); + targetDevice.createBond(); + } + } catch (SecurityException ignored) {} + } + } + + /* API 33+ (Android 13+): the value arrives as a parameter here. + * On API 36 the deprecated no-value overload below gets a null/stale + * getValue(), so this overload is what actually delivers data. */ + @Override + public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic c, byte[] value) { + if (value != null && value.length > 0) feedBytes(value); + } + + @Override + @SuppressWarnings("deprecation") + public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic c) { + byte[] data = c.getValue(); + if (data != null && data.length > 0) feedBytes(data); + } + }; + + /* The data characteristic that pushes bytes to us: TX uses INDICATE, + * flow-control uses NOTIFY. Prefer INDICATE so we never bind flow-control + * by accident; fall back to NOTIFY for firmware that reshuffles this. */ + private static BluetoothGattCharacteristic pickStreamChar(BluetoothGattService svc) { + BluetoothGattCharacteristic notify = null; + for (BluetoothGattCharacteristic c : svc.getCharacteristics()) { + int p = c.getProperties(); + if ((p & BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) return c; + if (notify == null && (p & BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) notify = c; + } + return notify; + } + + private void enableNotifications() { + if (gatt == null || notifyChar == null) return; + try { + gatt.setCharacteristicNotification(notifyChar, true); + BluetoothGattDescriptor cccd = notifyChar.getDescriptor(CCCD_UUID); + if (cccd != null) { + // Match the descriptor value to the characteristic's mode: + // TX is INDICATE, so enabling NOTIFICATION on it would silently + // deliver nothing — the exact failure we just diagnosed. + boolean indicate = (notifyChar.getProperties() + & BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0; + cccd.setValue(indicate + ? BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + : BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + gatt.writeDescriptor(cccd); + } else { + failConnect("Data characteristic has no CCCD descriptor"); + } + } catch (SecurityException e) { + failConnect("Notification setup not permitted: " + e.getMessage()); + } + } + + /* Same newline framing as FlipperSerialPlugin.onNewData — BLE notify + * packets fragment lines arbitrarily, the buffer reassembles them. */ + private void feedBytes(byte[] data) { + for (byte b : data) { + char ch = (char) (b & 0xFF); + if (ch == '\n') { + String line = lineBuf.toString().trim(); + lineBuf.setLength(0); + if (!line.isEmpty()) { + JSObject o = new JSObject(); + o.put("line", line); + notifyListeners("data", o); + } + } else if (ch != '\r') { + lineBuf.append(ch); + if (lineBuf.length() > 1024) lineBuf.setLength(0); /* runaway guard */ + } + } + } + + /* ---------------- helpers ---------------- */ + + private BluetoothAdapter adapter() { + BluetoothManager m = (BluetoothManager) getContext().getSystemService(Context.BLUETOOTH_SERVICE); + return m == null ? null : m.getAdapter(); + } + + /* Hidden BluetoothGatt.refresh() — forces the platform to drop its cached + * service table and re-read it from the device on the next discovery. */ + private static void refreshGattCache(BluetoothGatt g) { + try { + java.lang.reflect.Method m = g.getClass().getMethod("refresh"); + Object ok = m.invoke(g); + Log.i(TAG, "gatt refresh() -> " + ok); + } catch (Exception e) { + Log.w(TAG, "gatt refresh() unavailable: " + e); + } + } + + /* device.getName() is often null for unbonded devices mid-scan; the + * advertised name from the scan record is the reliable source. */ + private static String advertisedName(ScanResult r) { + String n = r.getScanRecord() != null ? r.getScanRecord().getDeviceName() : null; + if (n == null || n.isEmpty()) { + try { n = r.getDevice().getName(); } catch (SecurityException ignored) {} + } + return n; + } + + private String safeName(BluetoothDevice d) { + try { return d != null && d.getName() != null ? d.getName() : "Flipper"; } + catch (SecurityException e) { return "Flipper"; } + } + + private void failConnect(String msg) { + Log.w(TAG, msg); + emitStatus("error", msg); + if (pendingConnect != null) { pendingConnect.reject(msg); pendingConnect = null; } + closeGatt(); + } + + private synchronized void closeGatt() { + streamReady = false; + notifyChar = null; + if (gatt != null) { + try { gatt.disconnect(); } catch (Exception ignored) {} + try { gatt.close(); } catch (Exception ignored) {} + gatt = null; + } + lineBuf.setLength(0); + } + + private void emitStatus(String state, String message) { + JSObject o = new JSObject(); + o.put("state", state); + if (message != null) o.put("message", message); + notifyListeners("status", o); + } +} diff --git a/android/www/app.js b/android/www/app.js index fb8df5f..dee14db 100644 --- a/android/www/app.js +++ b/android/www/app.js @@ -1,10 +1,11 @@ /* * RF Triangulator — front-end logic * --------------------------------- - * Listens to the FlipperSerial Capacitor plugin for live CSV rows, - * mirrors freq + RSSI in the top bar, lets the user capture {lat,lon,rssi,freq} - * tuples via GPS, draws them on a Leaflet map, and runs a Nelder-Mead - * least-squares solver to estimate the transmitter location. + * Listens to the FlipperSerial (USB) or FlipperBle (Bluetooth) Capacitor + * plugin for live CSV rows, mirrors freq + RSSI in the top bar, lets the + * user capture {lat,lon,rssi,freq} tuples via GPS, draws them on a Leaflet + * map, and runs a Nelder-Mead least-squares solver to estimate the + * transmitter location. Sessions persist to localStorage. * * The path-loss distance model is: * d = 10^( (Tx_dBm - RSSI_dBm) / (10 * n) ) @@ -17,17 +18,58 @@ /* ----------------------------- state ----------------------------- */ const state = { connected: false, + transport: 'usb', // 'usb' | 'ble' + bleAddr: null, // remembered BLE device address (picker choice) freqHz: null, - rssi: null, - captures: [], // {id, lat, lon, rssi, freq, source} - estimate: null, // {lat, lon, rms} + rssi: null, // last raw sample + rssiSmooth: null, // EMA-filtered value used for display + captures + lastSampleAt: 0, + captures: [], // {id, lat, lon, rssi, freq, source, outlier?} + estimate: null, // {lat, lon, rms, excluded} autoTimer: null, autoInterval: 2000, n: 3.0, txDbm: 10, + autoExclude: true, nextId: 1, }; +/* ----------------------------- persistence ----------------------------- */ +const STORE_KEY = 'rft_session_v1'; + +function saveSession() { + try { + localStorage.setItem(STORE_KEY, JSON.stringify({ + captures: state.captures, + nextId: state.nextId, + n: state.n, + txDbm: state.txDbm, + autoExclude: state.autoExclude, + transport: state.transport, + bleAddr: state.bleAddr, + })); + } catch (e) { /* quota/unavailable — session stays in memory only */ } +} + +function loadSession() { + try { + const raw = localStorage.getItem(STORE_KEY); + if (!raw) return; + const s = JSON.parse(raw); + if (Array.isArray(s.captures)) { + state.captures = s.captures.filter(c => + isFinite(c.lat) && isFinite(c.lon) && isFinite(c.rssi)); + } + const maxId = state.captures.reduce((m, c) => Math.max(m, c.id || 0), 0); + state.nextId = Math.max(s.nextId || 1, maxId + 1); + if (isFinite(s.n) && s.n >= 1.5 && s.n <= 6) state.n = s.n; + if (isFinite(s.txDbm)) state.txDbm = s.txDbm; + if (typeof s.autoExclude === 'boolean') state.autoExclude = s.autoExclude; + if (s.transport === 'usb' || s.transport === 'ble') state.transport = s.transport; + if (typeof s.bleAddr === 'string' && s.bleAddr) state.bleAddr = s.bleAddr; + } catch (e) { /* corrupt store — start fresh */ } +} + /* ----------------------------- map ----------------------------- */ const map = L.map('map', { zoomControl: true }).setView([48.8566, 2.3522], 14); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { @@ -53,7 +95,14 @@ navigator.geolocation.watchPosition(pos => { }, err => console.warn('geo', err), { enableHighAccuracy: true, maximumAge: 1000 }); /* ----------------------------- bridge ----------------------------- */ -const Bridge = window.Capacitor?.Plugins?.FlipperSerial; +/* Two native transports expose the same {connect, disconnect} methods and + * the same 'data' / 'status' event shapes, so everything downstream of + * handleSerialLine is transport-agnostic. */ +const Bridges = { + usb: window.Capacitor?.Plugins?.FlipperSerial, + ble: window.Capacitor?.Plugins?.FlipperBle, +}; +function activeBridge() { return Bridges[state.transport]; } function setConnDot(on) { state.connected = on; @@ -63,15 +112,20 @@ function setConnDot(on) { dot.title = on ? 'connected' : 'disconnected'; } -if (Bridge) { - Bridge.addListener('status', ev => { +for (const [name, bridge] of Object.entries(Bridges)) { + if (!bridge) continue; + bridge.addListener('status', ev => { + if (name !== state.transport) return; setConnDot(ev.state === 'connected'); }); - Bridge.addListener('data', ev => { + bridge.addListener('data', ev => { + if (name !== state.transport) return; handleSerialLine(ev.line); }); } +const SMOOTH_ALPHA = 0.3; // EMA at 5 Hz → ~0.6 s time constant + function handleSerialLine(line) { if (!line || line.startsWith('#') || line.startsWith('ts_ms')) return; // ts_ms,req_hz,act_hz,rssi_dbm,rssi_raw,lqi,n @@ -80,7 +134,15 @@ function handleSerialLine(line) { const reqHz = parseInt(parts[1], 10); const rssi = parseInt(parts[3], 10); if (!isFinite(rssi)) return; - state.rssi = rssi; + const now = Date.now(); + // A retune or a stream gap makes the old average meaningless — reset it + if (isFinite(reqHz) && reqHz !== state.freqHz) state.rssiSmooth = null; + if (now - state.lastSampleAt > 2000) state.rssiSmooth = null; + state.lastSampleAt = now; + state.rssi = rssi; + state.rssiSmooth = state.rssiSmooth === null + ? rssi + : SMOOTH_ALPHA * rssi + (1 - SMOOTH_ALPHA) * state.rssiSmooth; if (isFinite(reqHz)) state.freqHz = reqHz; renderReadout(); // Auto-sync allocation list with live frequency @@ -93,44 +155,62 @@ function renderReadout() { fEl.textContent = state.freqHz ? (state.freqHz / 1e6).toFixed(2) + ' MHz' : '— MHz'; - rEl.textContent = state.rssi !== null ? state.rssi + ' dBm' : '— dBm'; - const fill = Math.max(0, Math.min(100, ((state.rssi ?? -120) + 120) / 90 * 100)); + const shown = state.rssiSmooth ?? state.rssi; + rEl.textContent = shown !== null ? shown.toFixed(1) + ' dBm' : '— dBm'; + const fill = Math.max(0, Math.min(100, ((shown ?? -120) + 120) / 90 * 100)); document.getElementById('barFill').style.width = fill + '%'; } /* ----------------------------- capture ----------------------------- */ function capture(source = 'manual') { - if (state.rssi === null) { toast('No live RSSI yet'); return; } + const rssi = state.rssiSmooth ?? state.rssi; + if (rssi === null) { toast('No live RSSI yet'); return; } navigator.geolocation.getCurrentPosition(pos => { const c = { id: state.nextId++, lat: pos.coords.latitude, lon: pos.coords.longitude, - rssi: state.rssi, + rssi: Math.round(rssi * 10) / 10, freq: state.freqHz, source, }; state.captures.push(c); - drawCapture(c); - renderCaptureList(); solve(); }, err => toast('GPS error: ' + err.message), { enableHighAccuracy: true, maximumAge: 0, timeout: 8000 }); } function drawCapture(c) { - const m = L.marker([c.lat, c.lon]).addTo(layerCaptures) + if (c.outlier) { + L.circleMarker([c.lat, c.lon], { + radius: 7, color: '#f85149', fillColor: '#f85149', fillOpacity: 0.4, + }).addTo(layerCaptures).bindPopup(`#${c.id} ${c.rssi} dBm — excluded (multipath?)`); + return; // no distance circle: its radius is what the solver rejected + } + L.marker([c.lat, c.lon]).addTo(layerCaptures) .bindPopup(`#${c.id} ${c.rssi} dBm`); const d = rssiToDistance(c.rssi); L.circle([c.lat, c.lon], { radius: d, color: '#888', weight: 1, fillOpacity: 0.05 }) .addTo(layerCircles); } +function redrawAll() { + layerCaptures.clearLayers(); + layerCircles.clearLayers(); + for (const c of state.captures) drawCapture(c); +} + function renderCaptureList() { const ol = document.getElementById('captureList'); ol.innerHTML = ''; for (const c of state.captures) { const li = document.createElement('li'); - li.textContent = `${c.rssi} dBm — ${c.lat.toFixed(5)}, ${c.lon.toFixed(5)} (${rssiToDistance(c.rssi).toFixed(0)} m)`; + const base = `${c.rssi} dBm — ${c.lat.toFixed(5)}, ${c.lon.toFixed(5)} (${rssiToDistance(c.rssi).toFixed(0)} m)`; + if (c.outlier) { + li.className = 'outlier'; + li.textContent = '⚠ ' + base + ' — excluded'; + } else { + li.textContent = base; + } ol.appendChild(li); } document.getElementById('estN').textContent = state.captures.length; @@ -153,9 +233,9 @@ function haversine(lat1, lon1, lat2, lon2) { return 2 * R_EARTH * Math.asin(Math.sqrt(a)); } -function residualSum([lat, lon]) { +function residualSum([lat, lon], caps) { let s = 0; - for (const c of state.captures) { + for (const c of caps) { const d_obs = rssiToDistance(c.rssi); const d_est = haversine(lat, lon, c.lat, c.lon); const r = d_est - d_obs; @@ -211,35 +291,140 @@ function nelderMead(f, x0, opts = {}) { return simplex[0]; } -function solve() { - if (state.captures.length < 3) { - state.estimate = null; - document.getElementById('estLat').textContent = '—'; - document.getElementById('estLon').textContent = '—'; - document.getElementById('estRms').textContent = '—'; - if (estimateMarker) { map.removeLayer(estimateMarker); estimateMarker = null; } - return; - } +function runSolver(caps) { /* weighted centroid as starting guess */ let wsum = 0, lat0 = 0, lon0 = 0; - for (const c of state.captures) { + for (const c of caps) { const w = Math.pow(10, c.rssi / 10); /* stronger = higher weight */ wsum += w; lat0 += w * c.lat; lon0 += w * c.lon; } lat0 /= wsum; lon0 /= wsum; + return nelderMead(p => residualSum(p, caps), [lat0, lon0], { step: 1e-3 }); +} + +/* Leave-one-out outlier rejection. A residual-vs-RMS test fails here + * because a gross multipath outlier inflates the very RMS it is judged + * against (masking). Instead: re-solve without each capture in turn; if + * dropping one collapses the RMS, that capture was poisoning the fit. + * Repeats until no removal helps, the fit is already tight (<= 25 m), or + * only 3 captures remain. */ +const OUTLIER_IMPROVE_RATIO = 0.5; +const OUTLIER_FLOOR_M = 25; + +function rejectOutliers(caps) { + let kept = caps.slice(); + let sol = runSolver(kept); + while (state.autoExclude && kept.length - 1 >= 3) { + const rms = Math.sqrt(sol.v / kept.length); + if (rms <= OUTLIER_FLOOR_M) break; + let best = null; + for (let i = 0; i < kept.length; i++) { + const subset = kept.filter((_, j) => j !== i); + const s = runSolver(subset); + const r = Math.sqrt(s.v / subset.length); + if (!best || r < best.rms) best = { i, sol: s, rms: r }; + } + if (best.rms >= OUTLIER_IMPROVE_RATIO * rms) break; + kept[best.i].outlier = true; + kept = kept.filter((_, j) => j !== best.i); + sol = best.sol; + } + return { sol, kept }; +} + +function solve() { + const caps = state.captures; + caps.forEach(c => { delete c.outlier; }); + let est = null; + + if (caps.length >= 3) { + const { sol, kept } = rejectOutliers(caps); + est = { + lat: sol.p[0], lon: sol.p[1], + rms: Math.sqrt(sol.v / kept.length), + excluded: caps.length - kept.length, + }; + } + + state.estimate = est; + renderEstimate(); + redrawAll(); + renderCaptureList(); + renderGeometry(); + saveSession(); +} - const sol = nelderMead(residualSum, [lat0, lon0], { step: 1e-3 }); - const rms = Math.sqrt(sol.v / state.captures.length); - state.estimate = { lat: sol.p[0], lon: sol.p[1], rms }; +function renderEstimate() { + const est = state.estimate; + const hadMarker = estimateMarker !== null; + if (estimateMarker) { map.removeLayer(estimateMarker); estimateMarker = null; } + if (!est) { + document.getElementById('estLat').textContent = '—'; + document.getElementById('estLon').textContent = '—'; + document.getElementById('estRms').textContent = '—'; + document.getElementById('estExcl').textContent = '0'; + return; + } + document.getElementById('estLat').textContent = est.lat.toFixed(6); + document.getElementById('estLon').textContent = est.lon.toFixed(6); + document.getElementById('estRms').textContent = est.rms.toFixed(1); + document.getElementById('estExcl').textContent = est.excluded; + estimateMarker = L.marker([est.lat, est.lon], { title: 'Estimated Tx' }) + .addTo(map).bindPopup(`Estimated Tx
RMS: ${est.rms.toFixed(1)} m`); + if (!hadMarker) estimateMarker.openPopup(); +} - document.getElementById('estLat').textContent = sol.p[0].toFixed(6); - document.getElementById('estLon').textContent = sol.p[1].toFixed(6); - document.getElementById('estRms').textContent = rms.toFixed(1); +/* ----------------------------- capture geometry ----------------------------- */ +/* Two failure modes are checked: + * 1. Collinearity (PCA aspect ratio of the point cloud in meters) — a line + * of captures gives a mirror-ambiguous fix. Angular coverage alone + * misses this: points north and south of the line's own centroid look + * like they surround it. + * 2. Angular coverage around the centroid — captures bunched on one side + * of the source give an ambiguous fix even with a low RMS. */ +function geometryInfo() { + const caps = state.captures.filter(c => !c.outlier); + if (caps.length < 3) return null; + let lat0 = 0, lon0 = 0; + for (const c of caps) { lat0 += c.lat; lon0 += c.lon; } + lat0 /= caps.length; lon0 /= caps.length; + + const mPerLat = 110540, mPerLon = 111320 * Math.cos(toRad(lat0)); + let sxx = 0, syy = 0, sxy = 0; + for (const c of caps) { + const x = (c.lon - lon0) * mPerLon; + const y = (c.lat - lat0) * mPerLat; + sxx += x * x; syy += y * y; sxy += x * y; + } + const tr = sxx + syy, det = sxx * syy - sxy * sxy; + const disc = Math.sqrt(Math.max(0, tr * tr / 4 - det)); + const lMax = tr / 2 + disc, lMin = tr / 2 - disc; + const aspect = lMax > 0 ? Math.sqrt(Math.max(0, lMin) / lMax) : 0; + if (aspect < 0.25) return { label: 'poor — captures form a line', cls: 'geom-poor' }; + + /* Coverage is judged around the estimated Tx when available: captures all + * on one side of the SOURCE always surround their own centroid, so the + * centroid is only a fallback. */ + const ref = state.estimate ?? { lat: lat0, lon: lon0 }; + const bearings = caps.map(c => + (Math.atan2((c.lon - ref.lon) * Math.cos(toRad(lat0)), c.lat - ref.lat) * 180 / Math.PI + 360) % 360 + ).sort((a, b) => a - b); + let maxGap = 0; + for (let i = 0; i < bearings.length; i++) { + const next = i + 1 < bearings.length ? bearings[i + 1] : bearings[0] + 360; + maxGap = Math.max(maxGap, next - bearings[i]); + } + const coverage = 360 - maxGap; + if (coverage >= 180) return { label: 'good', cls: 'geom-good' }; + if (coverage >= 100) return { label: 'fair — spread out more', cls: 'geom-fair' }; + return { label: 'poor — surround the source', cls: 'geom-poor' }; +} - if (estimateMarker) map.removeLayer(estimateMarker); - estimateMarker = L.marker([sol.p[0], sol.p[1]], { - title: 'Estimated Tx', - }).addTo(map).bindPopup(`Estimated Tx
RMS: ${rms.toFixed(1)} m`).openPopup(); +function renderGeometry() { + const el = document.getElementById('estGeom'); + const g = geometryInfo(); + el.className = g ? g.cls : ''; + el.textContent = g ? g.label : '—'; } /* ----------------------------- file import ----------------------------- */ @@ -330,12 +515,122 @@ document.getElementById('autoBtn').onclick = () => { } }; document.getElementById('connectBtn').onclick = async () => { - if (!Bridge) { toast('Native bridge unavailable'); return; } - try { await Bridge.connect(); } catch (e) { toast('Connect failed: ' + e); } + const bridge = activeBridge(); + if (!bridge) { toast('Native bridge unavailable'); return; } + if (state.transport === 'ble') { await bleConnect(bridge); return; } + try { await bridge.connect(); } catch (e) { toast('Connect failed: ' + e); } }; + +/* BLE connect: remembered device first, then auto-match, then manual picker. + * Custom-named Flippers (e.g. "MyFlipper") don't advertise the "Flipper" + * prefix, so auto-match can fail — the picker covers that case once and + * the choice is remembered for every later session. */ +async function bleConnect(bridge) { + if (state.bleAddr) { + try { await bridge.connect({ address: state.bleAddr }); return; } + catch (e) { toast('Saved Flipper unreachable, rescanning…'); } + } + try { await bridge.connect(); return; } + catch (e) { + const msg = String(e.message || e); + if (!/No Flipper found/i.test(msg)) { toast('Connect failed: ' + msg); return; } + } + let devices = []; + try { + toast('Scanning nearby devices…'); + devices = (await bridge.scan()).devices || []; + } catch (e) { toast('Scan failed: ' + e); return; } + if (!devices.length) { + toast('No BLE devices at all. On the Flipper check RF Logger shows BT:adv, and close the official Flipper app (it hijacks the link).'); + return; + } + showBlePicker(bridge, devices); +} + +function showBlePicker(bridge, devices) { + const old = document.getElementById('blePicker'); + if (old) old.remove(); + devices.sort((a, b) => (b.likelyFlipper - a.likelyFlipper) || (b.rssi - a.rssi)); + const wrap = document.createElement('div'); + wrap.id = 'blePicker'; + wrap.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:2000;display:flex;align-items:center;justify-content:center;'; + const box = document.createElement('div'); + box.style.cssText = 'background:#161b22;border:1px solid #30363d;border-radius:10px;max-width:88vw;max-height:70vh;overflow:auto;padding:14px;min-width:260px;'; + box.innerHTML = '

Pick your Flipper

' + + '

Its name is the one on the Flipper\'s screen (custom names have no "Flipper" prefix).

'; + for (const d of devices) { + const b = document.createElement('button'); + b.style.cssText = 'display:block;width:100%;text-align:left;margin:4px 0;padding:10px;background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:8px;font-size:14px;'; + if (d.likelyFlipper) b.style.borderColor = '#2ea043'; + b.innerHTML = `${d.name || '(no name)'}` + + `${d.rssi} dBm` + + `
${d.address}${d.likelyFlipper ? ' · likely Flipper' : ''}`; + b.onclick = async () => { + wrap.remove(); + toast('Connecting to ' + (d.name || d.address) + '…'); + try { + await bridge.connect({ address: d.address }); + state.bleAddr = d.address; + saveSession(); + } catch (e) { toast('Connect failed: ' + (e.message || e)); } + }; + box.appendChild(b); + } + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + cancel.style.cssText = 'display:block;width:100%;margin-top:10px;padding:10px;background:transparent;color:#7d8590;border:1px solid #30363d;border-radius:8px;'; + cancel.onclick = () => wrap.remove(); + box.appendChild(cancel); + wrap.appendChild(box); + document.body.appendChild(wrap); +} document.getElementById('disconnectBtn').onclick = async () => { - if (Bridge) await Bridge.disconnect(); + const bridge = activeBridge(); + if (bridge) await bridge.disconnect(); }; + +const transportSel = document.getElementById('transportSel'); +transportSel.onchange = async () => { + const prev = Bridges[state.transport]; + if (prev && state.connected) { try { await prev.disconnect(); } catch (e) {} } + setConnDot(false); + state.transport = transportSel.value; + saveSession(); +}; + +/* ---- solver settings ---- */ +const ENV_PRESETS = { open: 2.0, suburban: 2.7, urban: 3.0, dense: 3.5, indoor: 4.5 }; +const envSel = document.getElementById('envSel'); +const nInput = document.getElementById('nInput'); +const txInput = document.getElementById('txInput'); +const outlierChk = document.getElementById('outlierChk'); + +envSel.onchange = () => { + const n = ENV_PRESETS[envSel.value]; + if (n === undefined) return; // "custom" — keep current value + state.n = n; + nInput.value = n; + solve(); +}; +nInput.onchange = () => { + const v = parseFloat(nInput.value); + if (!isFinite(v) || v < 1.5 || v > 6) { nInput.value = state.n; return; } + state.n = v; + const preset = Object.entries(ENV_PRESETS).find(([, pv]) => pv === v); + envSel.value = preset ? preset[0] : 'custom'; + solve(); +}; +txInput.onchange = () => { + const v = parseFloat(txInput.value); + if (!isFinite(v)) { txInput.value = state.txDbm; return; } + state.txDbm = v; + solve(); +}; +outlierChk.onchange = () => { + state.autoExclude = outlierChk.checked; + solve(); +}; + document.getElementById('loadBtn').onclick = () => document.getElementById('fileInput').click(); document.getElementById('fileInput').onchange = e => { const file = e.target.files[0]; if (!file) return; @@ -343,8 +638,8 @@ document.getElementById('fileInput').onchange = e => { r.onload = () => { const imported = parseImported(r.result); if (!imported.length) { toast('Nothing parseable in file'); return; } - for (const c of imported) { state.captures.push(c); drawCapture(c); } - renderCaptureList(); solve(); + for (const c of imported) state.captures.push(c); + solve(); toast(`Imported ${imported.length} captures`); }; r.readAsText(file); @@ -352,13 +647,25 @@ document.getElementById('fileInput').onchange = e => { document.getElementById('exportCsvBtn').onclick = exportCsv; document.getElementById('exportJsonBtn').onclick = exportJson; document.getElementById('clearBtn').onclick = () => { - state.captures = []; state.estimate = null; - layerCaptures.clearLayers(); layerCircles.clearLayers(); - if (estimateMarker) { map.removeLayer(estimateMarker); estimateMarker = null; } - renderCaptureList(); solve(); + state.captures = []; + solve(); }; + +/* ---- boot: restore last session, reflect settings into inputs ---- */ +loadSession(); +nInput.value = state.n; +txInput.value = state.txDbm; +outlierChk.checked = state.autoExclude; +{ + const preset = Object.entries(ENV_PRESETS).find(([, pv]) => pv === state.n); + envSel.value = preset ? preset[0] : 'custom'; +} +transportSel.value = state.transport; renderReadout(); -renderCaptureList(); +solve(); +if (state.captures.length) { + map.fitBounds(L.latLngBounds(state.captures.map(c => [c.lat, c.lon])).pad(0.3)); +} })(); /* ===================================================================== */ @@ -644,23 +951,15 @@ renderCaptureList(); function hideEmpty() { $('allocEmpty').classList.add('hidden'); } /* ---- Live sync from Flipper stream --------------------------------- */ + /* Keep the allocation table in step with the live frequency, but NEVER + * force a tab switch: the stream arrives at 5 Hz, so auto-activating the + * allocation tab trapped the user there — every packet yanked them back + * off the Triangulator. The table is updated in place so it's already + * current whenever the user chooses to open the Allocation tab. */ window.syncAllocByFreq = function(freqHz) { - if (allocations.length === 0) return; // Not loaded yet + if (allocations.length === 0) return; // not loaded until the tab is first opened const freqMhz = freqHz / 1e6; const matches = allocations.filter((a) => a.freq_low_mhz <= freqMhz && freqMhz <= a.freq_high_mhz); - // Auto-switch to allocation tab and display matches - const allocTabBtn = $$('#tabs .tab').find(btn => btn.dataset.tab === 'alloc'); - const mapTabBtn = $$('#tabs .tab').find(btn => btn.dataset.tab === 'map'); - if (allocTabBtn && mapTabBtn && !allocTabBtn.classList.contains('active')) { - // Only auto-switch if currently on map tab - allocTabBtn.classList.add('active'); - mapTabBtn.classList.remove('active'); - tabPanels.alloc.classList.add('active'); - tabPanels.map.classList.remove('active'); - if (typeof map !== 'undefined' && map.invalidateSize) { - setTimeout(() => map.invalidateSize(), 50); - } - } renderTable(matches); }; })(); diff --git a/android/www/index.html b/android/www/index.html index 584dfbe..a4babbc 100644 --- a/android/www/index.html +++ b/android/www/index.html @@ -97,6 +97,15 @@

RF Triangulator

+

Connection

+ + +

Solver settings

+ + + + +

Estimate

captures: 0
+
excluded: 0
+
geometry:
lat:
lon:
rms err: m
diff --git a/android/www/styles.css b/android/www/styles.css index 0b72929..a604dff 100644 --- a/android/www/styles.css +++ b/android/www/styles.css @@ -62,6 +62,13 @@ main { position: absolute; top: 64px; bottom: 56px; left: 0; right: 0; } #drawer label { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; margin: 6px 0; } #drawer input[type="number"] { width: 80px; background: #0e1116; color: #e6edf3; border: 1px solid #30363d; border-radius: 4px; padding: 4px 8px; } +#drawer select { background: #0e1116; color: #e6edf3; border: 1px solid #30363d; border-radius: 4px; padding: 6px 8px; font-size: 13px; max-width: 190px; } +#drawer input[type="checkbox"] { width: 18px; height: 18px; accent-color: #1f6feb; } + +#captureList li.outlier { color: #f85149; opacity: 0.8; } +.geom-good { color: #3fb950; } +.geom-fair { color: #d29922; } +.geom-poor { color: #f85149; } #estimateBox { background: #0e1116; border: 1px solid #30363d; border-radius: 6px; padding: 10px; font-size: 13px; line-height: 1.6; } #estimateBox b { font-variant-numeric: tabular-nums; } diff --git a/artifacts/RF_Triangulator.apk b/artifacts/RF_Triangulator.apk index bc8cbc3..14e4827 100644 Binary files a/artifacts/RF_Triangulator.apk and b/artifacts/RF_Triangulator.apk differ diff --git a/artifacts/rf_logger.fap b/artifacts/rf_logger.fap index 749a464..c3acedc 100644 Binary files a/artifacts/rf_logger.fap and b/artifacts/rf_logger.fap differ diff --git a/flipper/README.md b/flipper/README.md index a0fc86c..b562d9c 100644 --- a/flipper/README.md +++ b/flipper/README.md @@ -1,6 +1,8 @@ # flipper — Flipper Zero FAP source Source for `rf_logger.fap`, the Sub-GHz RSSI logger that runs on the Flipper Zero. +It samples the CC1101 RSSI at 5 Hz and streams it as CSV over **USB CDC and +Bluetooth LE simultaneously**, and can also log to the SD card. ## Build @@ -17,23 +19,22 @@ Output: `dist/rf_logger.fap`. Copy to your Flipper SD card under `/ext/apps/Sub- | File | Description | |---|---| -| `rf_logger.c` | Application logic: state machine, viewport drawing, input handling, USB CDC, SD logging, sub-GHz tuning | -| `application.fam` | ufbt manifest: appid, name, category, icon, version, dependencies | +| `rf_logger.c` | Application logic: state machine, viewport drawing, input handling, USB CDC + BLE serial streaming, SD logging, Sub-GHz tuning | +| `application.fam` | ufbt manifest: appid, name, category, icon, version, dependencies (`gui`, `subghz`, `bt`) | | `rf_logger_icon.png` | 10x10 1-bit icon shown in the Apps menu | ## Architecture +The app is a **two-state machine** — there is no preset menu (removed in v2). It +opens directly on the manual frequency editor and BLE advertising starts at launch. + ``` -StateMenu (preset list: 315 / 433.92 / 868.35 / 915 / Manual) - │ - └─[OK on "Manual MHz…"]──► StateManualEntry (XXX.XX MHz digit editor) - │ - └─[OK]────────────┐ -StateMenu │ - │ ▼ - └─[OK on preset]────────────────────────────► StateRunning (scan + log + stream) - │ - └─[Back]──► StateMenu +Launch ─► StateManualEntry (XXX.XX MHz digit editor, default 433.92) + │ BLE: profile started + advertising (self-healing keepalive) + │ + └─[OK]──► StateRunning (sample @ 5 Hz → USB CDC + BLE + optional SD) + │ status line: BT:off / BT:adv / BT:ok + └─[Back]──► StateManualEntry ``` ## Manual MHz digit editor @@ -46,12 +47,43 @@ Layout: `XXX.XX MHz` with a 1-pixel underline under the active digit. | ↓ | Decrement selected digit | | ← | Move cursor left | | → | Move cursor right | -| OK | Start scan at the displayed frequency | -| Back | Cancel, return to preset menu | +| OK | Start scanning at the displayed frequency | +| Back | Exit the app | + +Range is clamped to **300.00 – 928.00 MHz** (CC1101 Sub-GHz). Digit changes are +independent — rolling a `9` up gives `0` of the same rank, never carries into the +neighbour digit (calculator-style, not number-spinner-style). + +Cursor starts on the **ones-of-MHz** digit (position 2), initial value `433.92 MHz`. + +In **StateRunning**: **OK** toggles SD-card logging on/off, **Back** returns to the +frequency editor. + +## USB CDC + +When entering StateRunning the app takes over the USB stack with `usb_cdc_dual` +(two CDC ACM interfaces). Interface 0 keeps the Flipper CLI live (so `qFlipper` +etc. keep working); interface 1 streams the CSV at 5 Hz. On exit, the original USB +config is restored. -Range is clamped to **300.00 – 928.00 MHz** (CC1101 Sub-GHz). Digit changes are independent — rolling a `9` up gives `0` of the same rank, never carries into the neighbour digit. This matches user expectation for a position-by-position editor (calculator-style, not number-spinner-style). +## Bluetooth LE -Cursor starts on the **ones-of-MHz** digit (position 2), with the initial value at `433.92 MHz`. +At launch the app swaps the BLE stack to its own **Serial profile** instance +(`ble_profile_serial`, the same GATT serial service the stock CLI-over-BLE uses) +and starts advertising. Every sample line is also pushed to the connected central +via `ble_profile_serial_tx`, so the phone receives identical CSV whether it is +connected over USB or BLE. + +- Advertising is **self-healing**: a keepalive re-advertises every ~2 s while not + connected, and retries the profile swap if it failed at launch — a settings + toggle, key wipe ("Forget All Paired Devices"), or stack restart no longer + leaves the app silently un-advertised. +- The running screen shows the link state: `BT:off` / `BT:adv` / `BT:ok`. +- The default BLE profile is restored on exit. +- Requires **Settings → Bluetooth = ON** on the Flipper. Only one BLE central can + hold the link, so keep the official Flipper mobile app closed while connected. + +The `bt` service dependency is declared in `application.fam`. ## SD logging @@ -64,18 +96,16 @@ ts_ms,req_hz,act_hz,rssi_dbm,rssi_raw,lqi,n The directory is created on first use via `storage_simply_mkdir`. -## USB CDC - -When entering StateRunning, the app takes over the USB stack with `usb_cdc_dual` (two CDC ACM interfaces). Interface 0 keeps the Flipper CLI live (so `qFlipper` etc. keep working); interface 1 streams the CSV at 5 Hz. On exit, the original USB config is restored. - -## Code size +## CSV format ``` -329 lines — original (preset-only) -448 lines — current (preset + manual digit editor) +ts_ms,req_hz,act_hz,rssi_dbm,rssi_raw,lqi,n ``` -Compiled binary: ~8 700 bytes (.fap), well within the ~64 KB FAP limit. +`ts_ms` uptime · `req_hz`/`act_hz` requested vs. tuned frequency · `rssi_dbm` +signal · `rssi_raw` CC1101 register byte · `lqi` link quality · `n` sample counter +(resets on retune). Lines are terminated with `\r\n`; the same bytes go to USB, BLE, +and SD. Compiled binary: well within the ~64 KB FAP limit. ## License diff --git a/flipper/application.fam b/flipper/application.fam index c5e9c7c..81e8200 100644 --- a/flipper/application.fam +++ b/flipper/application.fam @@ -8,7 +8,7 @@ App( fap_icon="rf_logger_icon.png", fap_icon_assets="images", fap_author="Mhz_Localise", - fap_version="1.0", - fap_description="Sub-GHz RSSI logger. Streams CSV over USB and writes to SD card.", - requires=["gui", "subghz"], + fap_version="2.1", + fap_description="Sub-GHz RSSI logger. Streams CSV over USB and BLE, writes to SD card.", + requires=["gui", "subghz", "bt"], ) diff --git a/flipper/rf_logger.c b/flipper/rf_logger.c index b37e870..cc0c0c0 100644 --- a/flipper/rf_logger.c +++ b/flipper/rf_logger.c @@ -3,6 +3,12 @@ * * CSV over USB CDC ch1 (ch0 stays as CLI). Takes over the USB stack with * dual-CDC when entering Running state, restores on exit. + * + * The same CSV lines are also streamed over BLE: at launch the app swaps + * the BLE stack to its own Serial profile instance (same GATT service the + * stock CLI-over-BLE uses, so phone-side code is identical for both) and + * transmits every sample to the connected central. Default profile is + * restored on exit. Requires Bluetooth ON in Flipper settings to advertise. */ #include @@ -10,11 +16,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #define TAG "rf_logger" @@ -57,8 +66,66 @@ typedef struct { Storage* storage; FuriHalUsbInterface* prev_usb; bool usb_taken; + Bt* bt; + FuriHalBleProfileBase* ble_profile; + volatile BtStatus bt_status; + uint32_t next_adv_kick; + uint32_t next_profile_retry; } RfLoggerApp; +/* ---- BLE serial streaming ---- */ + +static void bt_status_cb(BtStatus status, void* ctx) { + RfLoggerApp* app = ctx; + app->bt_status = status; +} + +static void ble_start(RfLoggerApp* app) { + app->bt = furi_record_open(RECORD_BT); + bt_disconnect(app->bt); + furi_delay_ms(200); // let the stack settle before the profile swap + app->ble_profile = bt_profile_start(app->bt, ble_profile_serial, NULL); + if(app->ble_profile) { + bt_set_status_changed_callback(app->bt, bt_status_cb, app); + furi_hal_bt_start_advertising(); + } +} + +/* Advertising is not fire-and-forget: a settings toggle, key wipe + * ("Forget All Paired Devices") or stack restart silently kills it. + * Called from the main loop — re-kicks advertising while not connected, + * and retries the profile swap if it failed at launch (e.g. BT was off). */ +static void ble_keepalive(RfLoggerApp* app) { + uint32_t now = furi_get_tick(); + if(!app->bt) return; + if(!app->ble_profile) { + if(now >= app->next_profile_retry) { + app->next_profile_retry = now + 10000; + app->ble_profile = bt_profile_start(app->bt, ble_profile_serial, NULL); + if(app->ble_profile) { + bt_set_status_changed_callback(app->bt, bt_status_cb, app); + furi_hal_bt_start_advertising(); + } + } + return; + } + if(now >= app->next_adv_kick) { + app->next_adv_kick = now + 2000; + if(app->bt_status != BtStatusConnected) furi_hal_bt_start_advertising(); + } +} + +static void ble_stop(RfLoggerApp* app) { + if(!app->bt) return; + bt_set_status_changed_callback(app->bt, NULL, NULL); + bt_disconnect(app->bt); + furi_delay_ms(200); + bt_profile_restore_default(app->bt); + furi_record_close(RECORD_BT); + app->bt = NULL; + app->ble_profile = NULL; +} + static void usb_take(RfLoggerApp* app) { if(app->usb_taken) return; app->prev_usb = furi_hal_usb_get_config(); @@ -156,6 +223,12 @@ static void sample_once(RfLoggerApp* app) { (unsigned)app->lqi, (unsigned long)app->n); if(len > 0) { furi_hal_cdc_send(VCP_DATA_CH, (uint8_t*)line, (uint16_t)len); + /* Don't gate BLE TX on a cached status enum — that callback proved + * unreliable for a custom profile, silently starving the phone. + * The serial profile itself returns false when there's no subscriber, + * so an unconditional call is safe and self-correcting. */ + if(app->ble_profile) + ble_profile_serial_tx(app->ble_profile, (uint8_t*)line, (uint16_t)len); if(app->sd_logging) log_write_line(app, line); } } @@ -246,10 +319,13 @@ static void draw_running(Canvas* canvas, RfLoggerApp* app) { draw_signal_bar(canvas, 4, 44, 120, 8, app->rssi_dbm); - char info[32]; - snprintf(info, sizeof(info), "n=%lu lqi=%u %s", + const char* bt_s = app->bt_status == BtStatusConnected ? "BT:ok" : + app->bt_status == BtStatusAdvertising ? "BT:adv" : + "BT:off"; + char info[48]; + snprintf(info, sizeof(info), "n=%lu lqi=%u %s %s", (unsigned long)app->n, (unsigned)app->lqi, - app->sd_logging ? "SD:on" : "SD:off"); + app->sd_logging ? "SD:on" : "SD:off", bt_s); canvas_draw_str(canvas, 4, 62, info); } @@ -336,6 +412,8 @@ int32_t rf_logger_app(void* p) { view_port_input_callback_set(app->viewport, input_cb, app); gui_add_view_port(app->gui, app->viewport, GuiLayerFullscreen); + ble_start(app); + bool exit = false; uint32_t next_sample = 0; while(!exit) { @@ -360,12 +438,14 @@ int32_t rf_logger_app(void* p) { next_sample = now + SAMPLE_PERIOD_MS; } } + ble_keepalive(app); view_port_update(app->viewport); } subghz_stop(); log_close(app); usb_release(app); + ble_stop(app); gui_remove_view_port(app->gui, app->viewport); view_port_free(app->viewport); furi_message_queue_free(app->input_queue);