From 8c0abbd522d7e201bffeedbd3963d44cb44ba013 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 1 Oct 2026 21:20:09 +0000 Subject: [PATCH] feat(portduino): BLE peripheral support via BlueZ for meshtasticd (Raspberry Pi) (#11396) * feat(portduino): BLE peripheral support via BlueZ for meshtasticd on Linux Adds the standard Meshtastic BLE service (toRadio/fromRadio/fromNum/logRadio) to the Linux native target, so a Raspberry Pi running meshtasticd can be paired and used over BLE like any other Meshtastic device. Implementation: a new LinuxBluetooth backend registers a GATT application, LE advertisement and pairing agent with bluetoothd over the org.bluez D-Bus APIs, using sdbus-c++ (both the 1.x and 2.x major versions, via a small compat shim - Debian bookworm/Ubuntu 24.04 ship 1.x, trixie/Fedora ship 2.x). When the sdbus-c++ dev package is absent the whole backend compiles out via __has_include, the same optional-dependency idiom as the ulfius webserver. Threading follows the NimbleBluetooth model, simplified: the sdbus event loop runs its own thread, and all PhoneAPI calls happen on the main thread. Writes queue to the main loop; reads park the D-Bus reply and are completed from the main thread after queued writes, so write-then-read clients see their answer without any busy-waiting. Enablement is a double opt-in: a new `Bluetooth:` config.yaml section (Enabled, default false; AdapterId, default hci0) must turn BLE on for the host, and the regular device config bluetooth.enabled must be on. The config-check schema and fixtures cover the new section. Pairing honors config.bluetooth.mode: NO_PIN maps to a NoInputNoOutput just-works agent; RANDOM_PIN to DisplayOnly with the kernel-generated passkey shown on screen/log via the existing BluetoothStatus plumbing. FIXED_PIN falls back to random-passkey semantics with a warning - BlueZ does not support forcing a passkey. PIN modes enforce encrypt-authenticated-read/write on all mesh characteristics. Packaging: install a D-Bus system policy so the meshtasticd user may talk to org.bluez, add it to the bluetooth group, order the unit after bluetooth.service, and add libsdbus-c++-dev to debian/rpm/docker/CI deps. Verified in-container against a mock bluetoothd: registration flow, GATT tree enumeration, advertisement properties, and a full config download (ToRadio wantConfig -> 47 FromRadio packets) through the D-Bus bridge. Real-hardware pairing/notify testing on a Pi still pending. Known limitations (v1): meshtasticd must be restarted if bluetoothd restarts; FIXED_PIN degrades to a random passkey; getRssi() returns 0 (same as nRF52). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ * fix(portduino): BLE fixes from first real-hardware pass (Pi CM5 + RAK6421) Findings from testing PR #11396 on a Raspberry Pi CM5 (Pi OS trixie, BlueZ/sdbus-c++ 2.1 - the v2 compat path) with a RAK6421 HAT and an Android phone: - getMacAddr() leaked its HCI socket on every call and never closed it, and on failure returned without touching the caller's buffer - which getDeviceName() passed in uninitialized. Close the socket on all paths and cache the MAC after the first successful read; it cannot change at runtime and this now runs on every bluetoothd property read. - getDeviceName() zero-initializes its MAC buffer, and LinuxBluetooth snapshots the name once at setup() on the main thread: the advertisement's LocalName getter runs on the D-Bus event-loop thread and getDeviceName()'s static buffer is not thread-safe. - Restore NimBLE-style config-phase packet prefetch (depth 3). The initial port answered every FromRadio read with a D-Bus -> main-loop round trip, which made the config download noticeably slow; ReadValue now answers straight from the prefetch queue on the event-loop thread, with NimBLE's safety rules (never in STATE_SEND_PACKETS, writes always observed before reads, queue cleared on disconnect). - Set advertising MinInterval/MaxInterval to 20-100ms (BlueZ >= 5.71; older versions ignore the properties). btmon showed the kernel default of 1.28s otherwise, and Android's background-connect scan windows are sparse enough that tap-to-connect took 8-14s; 20ms is the same floor NimBLE uses on ESP32. Verified on hardware: scan, passkey pairing, connect, config download, reconnect after bond wipe. Also diagnosed (no code change): the node identity MAC comes from the RAK HAT EEPROM by design, so the BLE name suffix follows the HAT rather than the BT adapter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ * fix(portduino): address CodeRabbit review on BLE support - setBluetoothEnable: handle disable before the config gate, so a running BLE stack is always stoppable even after the device config turns Bluetooth off underneath it - getMacAddr: read the adapter configured as Bluetooth.AdapterId instead of hardcoding hci0, falling back to hci0 for unparseable names - systemd unit: Wants=bluetooth.service so bluetoothd is pulled up when present (After= only orders, it does not start it) - debian/rpm: Recommends: bluez as the runtime contract for BLE - dbus policy: document why the org.bluez rule is destination-wide rather than a per-interface allowlist Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ * fix(portduino): show the BLE pairing code on BaseUI screens onDisplayPasskey published the passkey to bluetoothStatus and triggered PowerFSM, but never called screen->startAlert(), so on BaseUI the code only ever reached the log. BluetoothStatus has no BaseUI consumer -- only InkHUD's PairingApplet and StatusLEDModule read it -- so a Pi driving a HUB75/OLED panel showed nothing while BlueZ sat waiting for the user to type a code they could not see. NimBLE and nRF52 draw it via startAlert(); this adds the missing half for Linux. The agent callbacks run on the sdbus event-loop thread while the screen is owned by the main thread, so the passkey is handed over as a pending flag and drawn from runOnce(), matching the existing disconnectCleanupPending pattern rather than reaching into the screen from the event loop. Dismissed on all four exits, so a stale code cannot stick on an always-on panel: Paired -> true (newly watched in PropertiesChanged, which previously only looked at Connected), agent Cancel, peer disconnect (moved out of the lastGone branch so a peer leaving mid-pairing clears the code even when another device is still connected), and doDeinit() -- applied inline there because runOnce() may never be scheduled again after teardown. Verified on a Pi 5 + BlueZ 5.66 in RANDOM_PIN mode: the code renders on a HUB75 panel and clears once the phone completes pairing. Co-Authored-By: Claude Opus 5 (1M context) * ci: install libsdbus-c++-dev for the native test build setup-native-test landed on develop while this branch was adding libsdbus-c++-dev to setup-native, so the new action's "full setup-native list" of C libraries is missing it. Without the package the test job builds with HAS_BLUETOOTH 0 and never compiles LinuxBluetooth.cpp. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ * fix(portduino): warn when a factory reset cannot clear BLE bonds factoryReset(eraseBleBonds) silently did nothing on Linux when the BLE backend was not running, so the reset reported success while the host's pairings stayed. Removing them needs a live connection to bluetoothd that a disabled backend never opened, so say so rather than imply they went. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ * fix(portduino): gate the factory-reset bond clear on an enabled backend setup() leaves linuxBluetooth allocated with its bus torn down when it throws, so a pointer check alone let factoryReset log "Clear bluetooth bonds" for a clear that clearBonds() then declined to perform. isEnabled() is only true after setup() completes, which routes that case to the warning that says the bonds were left alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ * style: reformat under clang-format 20 #11909 moved trunk from clang-format 16 to 20, which spaces C-style casts differently and reindents the comment above the HAS_WIFI block. Both files are ones this branch already touches, and trunk's fmt linter grades whole files, so its check fails until they are reformatted. No behaviour change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ * fix(portduino): three BLE config and lifecycle fixes from review Bluetooth config keys are now assigned individually rather than per section. loadConfig() runs once for every file in config.d, so reading an absent key as its default let a later file that named only one of them silently reset the other: `AdapterId: hci1` alone turned Bluetooth off, and `Enabled: true` alone dragged the adapter back to hci0. Only what a file actually states should override what an earlier one set. A backend that failed to come up is now retried. setup() can leave linuxBluetooth non-null but disabled - bluetoothd not ready, adapter missing, policy refusing - and every later enable then called resumeAdvertising(), which returns immediately while disabled. A transient failure at boot kept BLE off until the process restarted. doSetup() already opens with `if (enabled) return` and tears the bus down on every failure path, so calling it again is safe. Bluetooth.AdapterId is now checked for the hci form. LinuxBluetooth uses the value verbatim as the BlueZ object path while the MAC fallback reads only the leading hciN, so "hci1junk" looks plausible, yields a MAC, then finds no adapter and BLE never comes up. Covered by a new fixture and suite case, which is the kind of silent no-op that directory exists to catalogue. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude --- .github/actions/setup-base/action.yml | 2 +- .github/actions/setup-native-test/action.yml | 2 +- .github/actions/setup-native/action.yml | 2 +- Dockerfile | 4 +- bin/config-dist.yaml | 4 + bin/dbus-1/meshtasticd.conf | 20 + bin/meshtasticd.service | 3 +- bin/test-config-check.sh | 10 + debian/control | 2 + debian/meshtasticd.install | 1 + debian/meshtasticd.postinst | 1 + meshtasticd.spec.rpkg | 8 + src/RedirectablePrint.cpp | 4 + src/main.cpp | 11 +- src/main.h | 6 + src/mesh/NodeDB.cpp | 12 + src/modules/AdminModule.cpp | 8 + src/platform/portduino/ConfigCheck.cpp | 27 + src/platform/portduino/LinuxBluetooth.cpp | 1012 +++++++++++++++++ src/platform/portduino/LinuxBluetooth.h | 61 + src/platform/portduino/PortduinoGlue.cpp | 71 +- src/platform/portduino/PortduinoGlue.h | 13 + src/platform/portduino/SdbusCompat.h | 149 +++ src/platform/portduino/architecture.h | 10 + test/fixtures/portduino-config/README.md | 12 + .../bluetooth-bad-adapter.yaml | 12 + .../bluetooth-bad-enabled.yaml | 11 + test/fixtures/portduino-config/bluetooth.yaml | 12 + variants/native/portduino.ini | 8 + 29 files changed, 1486 insertions(+), 12 deletions(-) create mode 100644 bin/dbus-1/meshtasticd.conf create mode 100644 src/platform/portduino/LinuxBluetooth.cpp create mode 100644 src/platform/portduino/LinuxBluetooth.h create mode 100644 src/platform/portduino/SdbusCompat.h create mode 100644 test/fixtures/portduino-config/bluetooth-bad-adapter.yaml create mode 100644 test/fixtures/portduino-config/bluetooth-bad-enabled.yaml create mode 100644 test/fixtures/portduino-config/bluetooth.yaml diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml index 80dda73d6..9605eb263 100644 --- a/.github/actions/setup-base/action.yml +++ b/.github/actions/setup-base/action.yml @@ -13,7 +13,7 @@ runs: shell: bash run: | sudo apt-get -y update --fix-missing - sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release + sudo apt-get install -y cppcheck libbluetooth-dev libsdbus-c++-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release - name: Setup Python uses: actions/setup-python@v7 diff --git a/.github/actions/setup-native-test/action.yml b/.github/actions/setup-native-test/action.yml index 247832d72..357b661f6 100644 --- a/.github/actions/setup-native-test/action.yml +++ b/.github/actions/setup-native-test/action.yml @@ -24,7 +24,7 @@ runs: set -euo pipefail sudo apt-get -y update --fix-missing sudo apt-get install -y ccache lcov \ - libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \ + libbluetooth-dev libsdbus-c++-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \ libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libcurl4-gnutls-dev - name: Install PlatformIO diff --git a/.github/actions/setup-native/action.yml b/.github/actions/setup-native/action.yml index 2c478f927..27d00973a 100644 --- a/.github/actions/setup-native/action.yml +++ b/.github/actions/setup-native/action.yml @@ -11,4 +11,4 @@ runs: - name: Install libs needed for native build shell: bash run: | - sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libcurl4-gnutls-dev libnotify-dev + sudo apt-get install -y libbluetooth-dev libsdbus-c++-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libcurl4-gnutls-dev libnotify-dev diff --git a/Dockerfile b/Dockerfile index 619c0ed93..a1f5a9fdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apt-get update && apt-get install --no-install-recommends -y \ curl wget g++ zip git ca-certificates pkg-config \ python3-pip python3-grpc-tools \ - libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \ + libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libsdbus-c++-dev libi2c-dev libuv1-dev \ libcurl4-gnutls-dev libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \ libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev libnotify-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ @@ -54,7 +54,7 @@ ENV TZ=Etc/UTC USER root RUN apt-get update && apt-get --no-install-recommends -y install \ - libc-bin libc6 libgpiod3 libyaml-cpp0.8 libjsoncpp26 libi2c0 libuv1t64 libusb-1.0-0-dev \ + libc-bin libc6 libgpiod3 libyaml-cpp0.8 libjsoncpp26 libsdbus-c++2 libi2c0 libuv1t64 libusb-1.0-0-dev \ libcurl4t64 liborcania2.3 libulfius2.7t64 libssl3t64 \ libx11-6 libinput10 libxkbcommon-x11-0 libsdl2-2.0-0 libnotify4 \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index 276808045..a591c1ba2 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -250,6 +250,10 @@ Webserver: # SSLKey: /etc/meshtasticd/ssl/private_key.pem # Path to SSL Key, generated if not present # SSLCert: /etc/meshtasticd/ssl/certificate.pem # Path to SSL Certificate, generated if not present +Bluetooth: +# Enabled: true # Advertise a BLE peripheral via BlueZ, like other Meshtastic devices (Linux only) +# AdapterId: hci0 # BlueZ adapter to use + HostMetrics: # ReportInterval: 30 # Interval in minutes between HostMetrics report packets, or 0 for disabled # Channel: 0 # channel to send Host Metrics over. Defaults to the primary channel. diff --git a/bin/dbus-1/meshtasticd.conf b/bin/dbus-1/meshtasticd.conf new file mode 100644 index 000000000..8ba80bb47 --- /dev/null +++ b/bin/dbus-1/meshtasticd.conf @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/bin/meshtasticd.service b/bin/meshtasticd.service index 8ca32a8aa..e86a43c88 100644 --- a/bin/meshtasticd.service +++ b/bin/meshtasticd.service @@ -1,6 +1,7 @@ [Unit] Description=Meshtastic %i Daemon -After=network-online.target +After=network-online.target bluetooth.service +Wants=bluetooth.service StartLimitInterval=200 StartLimitBurst=5 diff --git a/bin/test-config-check.sh b/bin/test-config-check.sh index 7cee12d7a..96c4531b4 100755 --- a/bin/test-config-check.sh +++ b/bin/test-config-check.sh @@ -147,6 +147,16 @@ assert "empty sections are not a fault" 0 empty-sections.yaml check \ assert "warnings alone do not fail the run" 0 unknown-key.yaml check \ "unknown key 'Lora.Frequency'" \ "Result: 0 errors, 1 warning" +# On a build without BLE support the same file additionally reports the build-time +# gap as a warning, so only the error count is asserted here. +assert "bluetooth section parses" 0 bluetooth.yaml check \ + "Result: 0 errors," +assert "bluetooth bad Enabled is silently defaulted" 0 bluetooth-bad-enabled.yaml check \ + "Bluetooth.Enabled is not a true/false value" \ + "Result: 0 errors, 1 warning" +assert "bluetooth AdapterId must name a real adapter" 0 bluetooth-bad-adapter.yaml check \ + "is not a BlueZ adapter name" \ + "Result: 0 errors, 1 warning" echo echo "module names are matched exactly:" diff --git a/debian/control b/debian/control index 2b06c2604..84383fad4 100644 --- a/debian/control +++ b/debian/control @@ -17,6 +17,7 @@ Build-Depends: debhelper-compat (= 13), libjsoncpp-dev, libgpiod-dev, libbluetooth-dev, + libsdbus-c++-dev, libusb-1.0-0-dev, libi2c-dev, libuv1-dev, @@ -40,6 +41,7 @@ Architecture: any Depends: adduser, ${misc:Depends}, ${shlibs:Depends} +Recommends: bluez Description: Meshtastic daemon for communicating with Meshtastic devices Meshtastic is an off-grid text communication platform that uses inexpensive LoRa radios. diff --git a/debian/meshtasticd.install b/debian/meshtasticd.install index 3a30f1d7c..f3b855502 100644 --- a/debian/meshtasticd.install +++ b/debian/meshtasticd.install @@ -5,6 +5,7 @@ bin/config.d/* etc/meshtasticd/available.d bin/meshtasticd.service lib/systemd/system bin/meshtasticd-start.sh usr/bin +bin/dbus-1/meshtasticd.conf usr/share/dbus-1/system.d web/* usr/share/meshtasticd/web diff --git a/debian/meshtasticd.postinst b/debian/meshtasticd.postinst index fe0dbc332..a12436be7 100755 --- a/debian/meshtasticd.postinst +++ b/debian/meshtasticd.postinst @@ -37,6 +37,7 @@ case "$1" in getent group video >/dev/null 2>/dev/null && adduser meshtasticd video >/dev/null 2>/dev/null getent group audio >/dev/null 2>/dev/null && adduser meshtasticd audio >/dev/null 2>/dev/null getent group input >/dev/null 2>/dev/null && adduser meshtasticd input >/dev/null 2>/dev/null + getent group bluetooth >/dev/null 2>/dev/null && adduser meshtasticd bluetooth >/dev/null 2>/dev/null # migrate /root/.portduino to /var/lib/meshtasticd/.portduino diff --git a/meshtasticd.spec.rpkg b/meshtasticd.spec.rpkg index 8fb6190aa..19fe76243 100644 --- a/meshtasticd.spec.rpkg +++ b/meshtasticd.spec.rpkg @@ -37,6 +37,7 @@ BuildRequires: pkgconfig(yaml-cpp) BuildRequires: pkgconfig(jsoncpp) BuildRequires: pkgconfig(libgpiod) BuildRequires: pkgconfig(bluez) +BuildRequires: pkgconfig(sdbus-c++) BuildRequires: pkgconfig(libusb-1.0) BuildRequires: libi2c-devel BuildRequires: pkgconfig(libuv) @@ -62,6 +63,8 @@ BuildRequires: pkgconfig(libbsd-overlay) %endif Requires: systemd-udev +# bluetoothd and the 'bluetooth' group, needed for BLE peripheral support +Recommends: bluez # Declare that this package provides the user/group it creates in %pre # Required for Fedora 43+ which tracks users/groups as RPM dependencies @@ -109,6 +112,9 @@ install -D -m 0644 bin/meshtasticd.service %{buildroot}%{_unitdir}/meshtasticd.s # Install meshtasticd start wrapper install -D -m 0755 bin/meshtasticd-start.sh %{buildroot}%{_bindir}/meshtasticd-start.sh +# Install D-Bus policy (lets the meshtasticd user talk to bluetoothd for BLE) +install -D -m 0644 bin/dbus-1/meshtasticd.conf %{buildroot}%{_datadir}/dbus-1/system.d/meshtasticd.conf + # Install the web files under /usr/share/meshtasticd/web mkdir -p %{buildroot}%{_datadir}/meshtasticd/web cp -r web/* %{buildroot}%{_datadir}/meshtasticd/web @@ -135,6 +141,7 @@ getent group i2c > /dev/null && usermod -a -G i2c %{meshtasticd_user} > /dev/nul getent group video > /dev/null && usermod -a -G video %{meshtasticd_user} > /dev/null getent group audio > /dev/null && usermod -a -G audio %{meshtasticd_user} > /dev/null getent group input > /dev/null && usermod -a -G input %{meshtasticd_user} > /dev/null +getent group bluetooth > /dev/null && usermod -a -G bluetooth %{meshtasticd_user} > /dev/null exit 0 %triggerin -- meshtasticd < 2.6.9 @@ -174,6 +181,7 @@ fi %config(noreplace) %{_sysconfdir}/meshtasticd/config.yaml %config %{_sysconfdir}/meshtasticd/available.d/* %{_unitdir}/meshtasticd.service +%attr(0644,root,root) %{_datadir}/dbus-1/system.d/meshtasticd.conf %dir %{_datadir}/meshtasticd %dir %{_datadir}/meshtasticd/web %{_datadir}/meshtasticd/web/* diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 893da9486..4ddd1ae52 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -235,6 +235,8 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ isBleConnected = nimbleBluetooth && nimbleBluetooth->isActive() && nimbleBluetooth->isConnected(); #elif defined(ARCH_NRF52) isBleConnected = nrf52Bluetooth != nullptr && nrf52Bluetooth->isConnected(); +#elif defined(MESHTASTIC_LINUX_BLE) + isBleConnected = linuxBluetooth != nullptr && linuxBluetooth->isEnabled() && linuxBluetooth->isConnected(); #endif if (isBleConnected) { auto thread = concurrency::OSThread::currentThread; @@ -251,6 +253,8 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ nimbleBluetooth->sendLog(buffer.get(), size); #elif defined(ARCH_NRF52) nrf52Bluetooth->sendLog(buffer.get(), size); +#elif defined(MESHTASTIC_LINUX_BLE) + linuxBluetooth->sendLog(buffer.get(), size); #endif } } diff --git a/src/main.cpp b/src/main.cpp index d9f481877..02bf4c7c9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -76,6 +76,10 @@ NimbleBluetooth *nimbleBluetooth = nullptr; NRF52Bluetooth *nrf52Bluetooth = nullptr; #endif +#ifdef MESHTASTIC_LINUX_BLE +LinuxBluetooth *linuxBluetooth = nullptr; +#endif + #ifdef MESHTASTIC_ENABLE_APPROTECT #include "security/APProtect.h" #endif @@ -279,7 +283,7 @@ const char *firmware_version = optstr(APP_VERSION_SHORT); const char *getDeviceName() { - uint8_t dmac[6]; + uint8_t dmac[6] = {0}; getMacAddr(dmac); @@ -1361,8 +1365,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() // No bluetooth on these targets (yet): // Pico W / 2W may get it at some point -// Portduino and ESP32-C6 are excluded because we don't have a working bluetooth stacks integrated yet. -#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6) || !HAS_BLUETOOTH +// ESP32-C6 is excluded because we don't have a working bluetooth stack integrated yet. +// Portduino only has BLE when built against BlueZ/sdbus-c++, so it falls out via !HAS_BLUETOOTH. +#if defined(ARCH_RP2040) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6) || !HAS_BLUETOOTH deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_BLUETOOTH_CONFIG; #endif diff --git a/src/main.h b/src/main.h index 9d7849c0c..02f8c34a0 100644 --- a/src/main.h +++ b/src/main.h @@ -20,6 +20,12 @@ extern NimbleBluetooth *nimbleBluetooth; #include "NRF52Bluetooth.h" extern NRF52Bluetooth *nrf52Bluetooth; #endif +#ifdef ARCH_PORTDUINO +#include "platform/portduino/LinuxBluetooth.h" // self-guards; defines MESHTASTIC_LINUX_BLE when BLE is compiled in +#ifdef MESHTASTIC_LINUX_BLE +extern LinuxBluetooth *linuxBluetooth; +#endif +#endif #if !MESHTASTIC_EXCLUDE_I2C #include "detect/ScanI2CTwoWire.h" #endif diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index a28dd072a..d90f88fa2 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -952,6 +952,18 @@ bool NodeDB::factoryReset(bool eraseBleBonds) bond_print_list(BLE_GAP_ROLE_CENTRAL); Bluefruit.Periph.clearBonds(); Bluefruit.Central.clearBonds(); +#endif +#ifdef MESHTASTIC_LINUX_BLE + // isEnabled(), not just the pointer: a setup() that threw leaves the object + // allocated with its bus torn down, and clearBonds() needs a live connection. + if (linuxBluetooth && linuxBluetooth->isEnabled()) { + LOG_INFO("Clear bluetooth bonds"); + linuxBluetooth->clearBonds(); + } else { + // BlueZ bonds live in the host adapter's store, not ours, so there is no + // removing them from here without that connection. + LOG_WARN("BLE off, host bluetooth bonds left in place"); + } #endif } return true; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 4a6503066..6aff797fc 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1819,6 +1819,11 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r if (config.bluetooth.enabled && nrf52Bluetooth) { conn.bluetooth.is_connected = nrf52Bluetooth->isConnected(); } +#elif defined(MESHTASTIC_LINUX_BLE) + if (config.bluetooth.enabled && linuxBluetooth) { + conn.bluetooth.is_connected = linuxBluetooth->isConnected(); + conn.bluetooth.rssi = linuxBluetooth->getRssi(); + } #endif #endif conn.has_serial = true; // No serial-less devices @@ -2508,6 +2513,9 @@ void disableBluetooth() #elif defined(ARCH_NRF52) if (nrf52Bluetooth) nrf52Bluetooth->shutdown(); +#elif defined(MESHTASTIC_LINUX_BLE) + if (linuxBluetooth) + linuxBluetooth->deinit(); #endif #endif } diff --git a/src/platform/portduino/ConfigCheck.cpp b/src/platform/portduino/ConfigCheck.cpp index 950c063b9..7cd5fc514 100644 --- a/src/platform/portduino/ConfigCheck.cpp +++ b/src/platform/portduino/ConfigCheck.cpp @@ -89,6 +89,7 @@ const std::map> &schema() {"I2C", {"I2CDevice"}}, {"Logging", {"LogLevel", "TraceFile", "JSONFile", "JSONFileRotate", "JSONFilter", "AsciiLogs"}}, {"Webserver", {"Port", "RootPath", "SSLCert", "SSLKey"}}, + {"Bluetooth", {"Enabled", "AdapterId"}}, {"HostMetrics", {"ReportInterval", "Channel", "UserStringCommand"}}, // Read by packaging/menu tooling rather than by meshtasticd itself. {"Meta", {}}, @@ -592,6 +593,8 @@ const std::map &valueSpecs() {"Webserver.RootPath", {kString, false}}, {"Webserver.SSLCert", {kString, false}}, {"Webserver.SSLKey", {kString, false}}, + {"Bluetooth.Enabled", {kBool, false}}, + {"Bluetooth.AdapterId", {kString, false}}, {"HostMetrics.ReportInterval", {kInt, false}}, {"HostMetrics.Channel", {kInt, false}}, {"HostMetrics.UserStringCommand", {kString, false}}, @@ -894,6 +897,19 @@ void checkSection(const std::string &file, const std::string §ion, const YAM if (value.IsSequence()) for (const auto &pin : value) checkPinNode(file, section + "." + key, pin, findings); + } else if (section == "Bluetooth" && key == "AdapterId") { + // LinuxBluetooth uses this verbatim as the BlueZ object path (/org/bluez/), while the + // MAC fallback only reads the leading hciN. A value like "hci1junk" therefore looks + // plausible, yields a MAC, and then finds no adapter -- BLE just never comes up. Only + // hci is a real adapter name. + const std::string adapter = value.as(""); + const bool wellFormed = adapter.rfind("hci", 0) == 0 && adapter.size() > 3 && + adapter.find_first_not_of("0123456789", 3) == std::string::npos; + if (!wellFormed) + findings.push_back({kWarn, file, lineOf(value), + "Bluetooth.AdapterId '" + adapter + + "' is not a BlueZ adapter name. It must be hci followed by digits (hci0, hci1); " + "anything else leaves no /org/bluez entry to attach to and Bluetooth stays off"}); } else if (key == "JoystickButtons") { checkJoystickButtons(file, value, findings); } else if ((section == "Lora" && kLoraPinKeys.count(key)) || @@ -1217,6 +1233,17 @@ void checkMergedConfig(const PathIndex &paths, std::vector &findings) "startup. Rebuild with hzeller/rpi-rgb-led-matrix installed (it provides rgbmatrix.pc)"}); #endif +#if !HAS_BLUETOOTH + // Same class of build-time gap as HUB75 above, but only a warning: BLE + // quietly stays off rather than aborting startup. + if (portduino_config.bluetooth_enabled) + findings.push_back({kWarn, merged, 0, + "Bluetooth.Enabled is true but this meshtasticd " + "was built without BLE support, so Bluetooth " + "stays off. Rebuild with libsdbus-c++-dev " + "installed (it provides sdbus-c++.pc)"}); +#endif + if (portduino_config.lora_cs_pin.enabled && !portduino_config.lora_spi_dev.empty() && portduino_config.lora_spi_dev != "ch341") findings.push_back({kInfo, merged, 0, diff --git a/src/platform/portduino/LinuxBluetooth.cpp b/src/platform/portduino/LinuxBluetooth.cpp new file mode 100644 index 000000000..94dead499 --- /dev/null +++ b/src/platform/portduino/LinuxBluetooth.cpp @@ -0,0 +1,1012 @@ +#include "LinuxBluetooth.h" + +#ifdef MESHTASTIC_LINUX_BLE + +#include "BluetoothCommon.h" +#include "BluetoothStatus.h" +#include "PortduinoGlue.h" +#include "PowerFSM.h" +#include "SdbusCompat.h" +#include "concurrency/OSThread.h" +#include "main.h" +#include "mesh/NodeDB.h" +#include "mesh/PhoneAPI.h" +#include "mesh/mesh-pb-constants.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* + BLE peripheral via bluetoothd, over the org.bluez D-Bus APIs. + + Threading model (a simplification of the one documented at length in + src/nimble/NimbleBluetooth.cpp): the sdbus-c++ event loop runs in its own + thread and executes every GATT/agent/signal callback, while ALL PhoneAPI calls + happen on the main thread inside runOnce(), so the rest of the codebase stays + effectively single-threaded. + + PHONE -> RADIO: WriteValue (event-loop thread) pushes into fromPhoneQueue and + wakes the main loop; runOnce() pops and calls handleToRadio(). + + RADIO -> PHONE: ReadValue (event-loop thread) parks the D-Bus reply in + readResult and wakes the main loop; runOnce() first drains fromPhoneQueue + (clients send a write and immediately read its answer, so writes must land + first), then calls getFromRadio() and completes the parked reply from the main + thread. Unlike NimBLE there is no busy-wait: a parked D-Bus method reply is + exactly the deferred-response primitive that NimBLE's + onReadCallbackIsWaitingForData flag emulates. + + fromNum/logRadio notifications are BlueZ PropertiesChanged("Value") signals, + emitted from the main thread; sdbus-c++ serializes access to the connection + internally. +*/ + +namespace +{ + +constexpr const char *kBluezService = "org.bluez"; +constexpr const char *kBluezRootPath = "/"; +constexpr const char *kBluezManagerPath = "/org/bluez"; +constexpr const char *kGattAppPath = "/org/meshtastic/gatt"; +constexpr const char *kServicePath = "/org/meshtastic/gatt/service0"; +constexpr const char *kToRadioPath = "/org/meshtastic/gatt/service0/char0"; +constexpr const char *kFromRadioPath = "/org/meshtastic/gatt/service0/char1"; +constexpr const char *kFromNumPath = "/org/meshtastic/gatt/service0/char2"; +constexpr const char *kLogRadioPath = "/org/meshtastic/gatt/service0/char3"; +constexpr const char *kAdvertPath = "/org/meshtastic/advertisement0"; +constexpr const char *kAgentPath = "/org/meshtastic/agent"; + +constexpr const char *kIfaceAdapter = "org.bluez.Adapter1"; +constexpr const char *kIfaceDevice = "org.bluez.Device1"; +constexpr const char *kIfaceGattManager = "org.bluez.GattManager1"; +constexpr const char *kIfaceGattService = "org.bluez.GattService1"; +constexpr const char *kIfaceGattChar = "org.bluez.GattCharacteristic1"; +constexpr const char *kIfaceAdvManager = "org.bluez.LEAdvertisingManager1"; +constexpr const char *kIfaceAdvert = "org.bluez.LEAdvertisement1"; +constexpr const char *kIfaceAgentManager = "org.bluez.AgentManager1"; +constexpr const char *kIfaceAgent = "org.bluez.Agent1"; +constexpr const char *kIfaceObjectManager = "org.freedesktop.DBus.ObjectManager"; +constexpr const char *kIfaceProperties = "org.freedesktop.DBus.Properties"; + +constexpr size_t kFromPhoneQueueDepth = 3; + +using PropertyMap = std::map; +using InterfaceMap = std::map; +using ManagedObjects = std::map; + +uint16_t offsetOption(const PropertyMap &options) +{ + auto it = options.find("offset"); + return it == options.end() ? 0 : it->second.get(); +} + +bool pinPairing() +{ + return config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN; +} + +void publishStatus(meshtastic::BluetoothStatus::ConnectionState state) +{ + if (!bluetoothStatus) + return; + meshtastic::BluetoothStatus newStatus(state); + bluetoothStatus->updateStatus(&newStatus); +} + +} // namespace + +struct LinuxBluetooth::Impl final : public PhoneAPI, public concurrency::OSThread { + explicit Impl(std::string adapter) + : concurrency::OSThread("LinuxBluetooth"), adapterPath("/org/bluez/" + adapter), adapterId(std::move(adapter)) + { + api_type = TYPE_BLE; + } + + const std::string adapterPath; + const std::string adapterId; + + // D-Bus plumbing. Objects/proxies are created on the main thread in setup() + // and only destroyed in doDeinit() after the event loop has been stopped. + std::unique_ptr conn; + std::unique_ptr gattRoot, service, toRadioChar, fromRadioChar, fromNumChar, logRadioChar, advert, agent; + std::unique_ptr adapterProxy, bluezRootProxy, agentManagerProxy; + + std::atomic enabled{false}; // setup() finished successfully + std::atomic advertising{false}; // advertisement currently registered + std::atomic agentRegistered{false}; + std::atomic appRegistered{false}; + std::atomic draining{false}; // deinit in progress: fail reads fast + + // Connected/known devices, mutated on the event-loop thread, read from the + // main thread. + std::mutex devMutex; + std::set connectedDevices; + std::map> deviceProxies; + + // PHONE -> RADIO queue (WriteValue -> handleToRadio) + std::mutex fromPhoneMutex; + std::atomic fromPhoneQueueSize{0}; + std::array, kFromPhoneQueueDepth> fromPhoneQueue{}; + std::array fromPhoneQueueLens{}; + // Duplicate-write suppression, event-loop thread only (see NimbleBluetooth's + // lastToRadio) + uint8_t lastToRadio[MAX_TO_FROM_RADIO_SIZE] = {0}; + size_t lastToRadioLen = 0; + + // RADIO -> PHONE parked read (ReadValue reply completed from the main thread) + std::mutex readMutex; + bool readPending = false; + sdbus::Result> readResult; + std::vector lastFromRadio; // last packet served at offset 0, for blob-read tails + // Packets prefetched by the main loop during the config phase, so ReadValue can be + // answered immediately on the event-loop thread instead of paying a main-loop + // round trip per packet (NimBLE's preloading; see runOnceToPhoneCanPreloadNextPacket + // there for why this must not happen in STATE_SEND_PACKETS). Guarded by readMutex. + std::deque> prefetched; + static constexpr size_t kPrefetchDepth = 3; + + // Snapshot of getDeviceName() taken on the main thread in doSetup(): the + // advertisement's LocalName getter runs on the event-loop thread, and + // getDeviceName() returns a static buffer that is not thread-safe. + std::string deviceName; + + // Notify state for fromNum and logRadio + std::atomic fromNumNotifying{false}; + std::atomic logNotifying{false}; + std::mutex valueMutex; // guards the two cached Value buffers below + std::vector fromNumValue{0, 0, 0, 0}; + std::vector logValue; + + std::atomic disconnectCleanupPending{false}; + std::atomic fixedPinWarned{false}; + + // Pairing-code alert. The agent callbacks run on the event-loop thread, but the + // screen is owned by the main thread, so the passkey is handed over as a pending + // flag and drawn from runOnce() -- same pattern as disconnectCleanupPending. + std::atomic passkeyShowPending{false}; + std::atomic passkeyHidePending{false}; + std::atomic pendingPasskey{0}; + bool passkeyShowing = false; // main thread only + + // ---------------------------------------------------------------- PhoneAPI + // glue + + bool checkIsConnected() override + { + std::lock_guard guard(devMutex); + return !connectedDevices.empty(); + } + + void onNowHasData(uint32_t fromRadioNum) override + { + PhoneAPI::onNowHasData(fromRadioNum); + + { + std::lock_guard guard(valueMutex); + fromNumValue = {static_cast(fromRadioNum & 0xff), static_cast((fromRadioNum >> 8) & 0xff), + static_cast((fromRadioNum >> 16) & 0xff), static_cast((fromRadioNum >> 24) & 0xff)}; + } + if (fromNumNotifying && fromNumChar) + sdbuscompat::emitPropertiesChanged(*fromNumChar, kIfaceGattChar, "Value"); + } + + int32_t runOnce() override + { + if (disconnectCleanupPending.exchange(false)) { + close(); // reset the PhoneAPI session state on the main thread + { + std::lock_guard guard(fromPhoneMutex); + fromPhoneQueueSize = 0; + } + std::lock_guard guard(readMutex); + prefetched.clear(); + } + + updatePasskeyAlert(); + + // Writes before reads: clients send a ToRadio write and immediately read + // the response, so the parked read must observe the write's effect. + drainFromPhoneQueue(); + completeParkedRead(); + refillPrefetch(); + + return INT32_MAX; // woken explicitly by the event-loop thread + } + + /// Put the pairing code on screen, or take it back down. Main thread only. + /// Hide is applied before show so a code that arrives in the same pass as a + /// stale dismissal still ends up visible. + void updatePasskeyAlert() + { +#if HAS_SCREEN + const bool hide = passkeyHidePending.exchange(false); + const bool show = passkeyShowPending.exchange(false); + if (!hide && !show) + return; + if (!screen) { + passkeyShowing = false; + return; + } + if (hide && passkeyShowing && !show) { + screen->endAlert(); + passkeyShowing = false; + } + if (show) { + const uint32_t passkey = pendingPasskey.load(); + screen->startAlert([passkey](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { + char btPIN[16] = "888888"; + snprintf(btPIN, sizeof(btPIN), "%06u", passkey); + int x_offset = display->width() / 2; + int y_offset = display->height() <= 80 ? 0 : 12; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_MEDIUM); + display->drawString(x_offset + x, y_offset + y, "Bluetooth"); + + display->setFont(FONT_SMALL); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5; + display->drawString(x_offset + x, y_offset + y, "Enter this code"); + + display->setFont(FONT_LARGE); + char pin[8]; + snprintf(pin, sizeof(pin), "%.3s %.3s", btPIN, btPIN + 3); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_SMALL - 5 : y_offset + FONT_HEIGHT_SMALL + 5; + display->drawString(x_offset + x, y_offset + y, pin); + + display->setFont(FONT_SMALL); + char deviceName[64]; + snprintf(deviceName, sizeof(deviceName), "Name: %s", getDeviceName()); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_LARGE - 6 : y_offset + FONT_HEIGHT_LARGE + 5; + display->drawString(x_offset + x, y_offset + y, deviceName); + }); + passkeyShowing = true; + } +#endif + } + + /// Called from the event-loop thread when the pairing code should come down + /// (pairing finished, canceled, or the peer went away). + void dismissPasskey() + { + passkeyHidePending = true; + wakeMainLoop(); + } + + void refillPrefetch() + { + // Only outside STATE_SEND_PACKETS: during config the client will definitely + // read every packet (and re-reads nothing on reconnect), while in + // STATE_SEND_PACKETS a packet fetched early would be lost if the phone + // disconnects before reading it. + while (PhoneAPI::isConnected() && !isSendingPackets()) { + { + std::lock_guard guard(readMutex); + if (prefetched.size() >= kPrefetchDepth) + return; + } + // LOCK ORDER: getFromRadio() may emit a fromNum notify (a D-Bus call), so + // it must not run under readMutex (see completeParkedRead). + uint8_t buf[meshtastic_FromRadio_size] = {0}; + size_t numBytes = getFromRadio(buf); + if (numBytes == 0) + return; + std::lock_guard guard(readMutex); + prefetched.emplace_back(buf, buf + numBytes); + } + } + + void drainFromPhoneQueue() + { + while (fromPhoneQueueSize > 0) { + uint8_t buf[MAX_TO_FROM_RADIO_SIZE]; + size_t len; + { + std::lock_guard guard(fromPhoneMutex); + len = fromPhoneQueueLens[0]; + memcpy(buf, fromPhoneQueue[0].data(), len); + size_t queued = fromPhoneQueueSize.load(); + for (size_t i = 1; i < queued; i++) { + memcpy(fromPhoneQueue[i - 1].data(), fromPhoneQueue[i].data(), fromPhoneQueueLens[i]); + fromPhoneQueueLens[i - 1] = fromPhoneQueueLens[i]; + } + if (fromPhoneQueueSize > 0) + fromPhoneQueueSize--; + } + handleToRadio(buf, len); + } + } + + void completeParkedRead() + { + { + // A prefetched packet must be served before anything newly fetched, or + // the stream reorders. + std::unique_lock lk(readMutex); + if (!readPending) + return; + if (!prefetched.empty()) { + std::vector packet = std::move(prefetched.front()); + prefetched.pop_front(); + lastFromRadio = packet; + auto result = std::move(readResult); + readPending = false; + lk.unlock(); + result.returnResults(packet); + return; + } + } + + // LOCK ORDER: getFromRadio() can emit a fromNum notify (a D-Bus call), and + // the event-loop thread takes readMutex inside its dispatch lock, so + // calling into sdbus while holding readMutex would deadlock. Fetch the + // packet unlocked, then re-take the lock only to hand it to the parked + // reply. + uint8_t buf[meshtastic_FromRadio_size] = {0}; + size_t numBytes = getFromRadio(buf); + + std::unique_lock lk(readMutex); + if (!readPending) { + // A disconnect raced us and already failed the read; the packet is lost, + // which a disconnect implies anyway (PhoneAPI session state gets reset). + return; + } + lastFromRadio.assign(buf, buf + numBytes); + auto result = std::move(readResult); + readPending = false; + lk.unlock(); + + // A zero-length reply is correct here: any pending write has already been + // handled, and in STATE_SEND_PACKETS clients poll-read until they get 0 + // bytes. + result.returnResults(std::vector(buf, buf + numBytes)); + } + + // ------------------------------------------------- event-loop thread + // callbacks + + void onToRadioWrite(std::vector value, const PropertyMap &options) + { + if (offsetOption(options) != 0) + throw sdbuscompat::dbusError("org.bluez.Error.NotSupported", "offset writes not supported"); + if (value.empty() || value.size() > MAX_TO_FROM_RADIO_SIZE) + throw sdbuscompat::dbusError("org.bluez.Error.InvalidValueLength", "bad ToRadio length"); + + if (value.size() == lastToRadioLen && memcmp(lastToRadio, value.data(), value.size()) == 0) { + LOG_DEBUG("BLE drop duplicate ToRadio packet (%u bytes)", (unsigned)value.size()); + return; + } + if (fromPhoneQueueSize >= kFromPhoneQueueDepth) { + // Push back on the client rather than dropping silently; it will retry. + throw sdbuscompat::dbusError("org.bluez.Error.InProgress", "ToRadio queue full"); + } + memcpy(lastToRadio, value.data(), value.size()); + lastToRadioLen = value.size(); + { + std::lock_guard guard(fromPhoneMutex); + size_t at = fromPhoneQueueSize.load(); + memcpy(fromPhoneQueue[at].data(), value.data(), value.size()); + fromPhoneQueueLens[at] = value.size(); + fromPhoneQueueSize++; + } + wakeMainLoop(); + } + + void onFromRadioRead(sdbus::Result> &&result, const PropertyMap &options) + { + uint16_t offset = offsetOption(options); + + if (draining) { + result.returnResults(std::vector()); + return; + } + + std::unique_lock lk(readMutex); + if (offset > 0) { + // Blob-read continuation of the packet we served at offset 0; do not + // consume a new packet from PhoneAPI. + std::vector tail; + if (offset < lastFromRadio.size()) + tail.assign(lastFromRadio.begin() + offset, lastFromRadio.end()); + lk.unlock(); + result.returnResults(tail); + return; + } + if (readPending) { + // Shouldn't happen (ATT serializes reads), but never strand a reply. + auto stale = std::move(readResult); + readPending = false; + stale.returnError(sdbuscompat::dbusError("org.bluez.Error.Failed", "superseded by a newer read")); + } + if (!prefetched.empty() && fromPhoneQueueSize == 0) { + // Answer straight from the config-phase prefetch queue - no main-loop + // round trip. Skipped when a write is still queued, so a + // write-then-read client never reads past its own write. + std::vector packet = std::move(prefetched.front()); + prefetched.pop_front(); + lastFromRadio = packet; + lk.unlock(); + result.returnResults(packet); + wakeMainLoop(); // top the prefetch queue back up + return; + } + readResult = std::move(result); + readPending = true; + lk.unlock(); + wakeMainLoop(); + } + + std::vector onFromNumRead(const PropertyMap &) + { + std::lock_guard guard(valueMutex); + return fromNumValue; + } + + std::vector onLogRadioRead(const PropertyMap &) + { + std::lock_guard guard(valueMutex); + return logValue; + } + + void wakeMainLoop() + { + setIntervalFromNow(0); + concurrency::mainDelay.interrupt(); + } + + // ------------------------------------------------------------ device + // tracking + + void trackDevice(const std::string &path, bool connectedNow) + { + // LOCK ORDER: devMutex must stay a leaf on the main thread (the event-loop + // thread takes it inside its dispatch lock), so the proxy - a D-Bus + // operation - is created outside the lock. + { + std::lock_guard guard(devMutex); + if (connectedNow) + connectedDevices.insert(path); + if (deviceProxies.count(path) != 0) + return; + } + auto proxy = sdbuscompat::makeProxy(*conn, kBluezService, path); + proxy->uponSignal("PropertiesChanged") + .onInterface(kIfaceProperties) + .call([this, path](const std::string &iface, const PropertyMap &changed, const std::vector &) { + if (iface != kIfaceDevice) + return; + // Pairing succeeded -- the code has served its purpose. Handled before + // Connected so a bond that completes without a state change still + // clears the alert. + auto paired = changed.find("Paired"); + if (paired != changed.end() && paired->second.get()) + dismissPasskey(); + auto it = changed.find("Connected"); + if (it != changed.end()) + onDeviceConnectedChanged(path, it->second.get()); + }); + sdbuscompat::finishProxy(*proxy); + std::lock_guard guard(devMutex); + if (deviceProxies.count(path) == 0) + deviceProxies[path] = std::move(proxy); + } + + void onDeviceConnectedChanged(const std::string &path, bool connected) + { + bool lastGone = false; + { + std::lock_guard guard(devMutex); + if (connected) + connectedDevices.insert(path); + else + connectedDevices.erase(path); + lastGone = connectedDevices.empty(); + } + LOG_INFO("BLE %s %s", connected ? "connect" : "disconnect", path.c_str()); + + if (connected) { + publishStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); + } else { + // The peer left mid-pairing; the code on screen is dead either way. + dismissPasskey(); + if (lastGone) { + publishStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); + lastToRadioLen = 0; // event-loop thread owns this buffer + failParkedRead(); + disconnectCleanupPending = true; + wakeMainLoop(); + } + } + } + + void failParkedRead() + { + std::unique_lock lk(readMutex); + if (!readPending) + return; + auto result = std::move(readResult); + readPending = false; + lk.unlock(); + result.returnResults(std::vector()); + } + + void onInterfacesAdded(const sdbus::ObjectPath &path, const InterfaceMap &interfaces) + { + auto it = interfaces.find(kIfaceDevice); + if (it == interfaces.end() || !belongsToAdapter(path)) + return; + bool connectedNow = false; + auto prop = it->second.find("Connected"); + if (prop != it->second.end()) + connectedNow = prop->second.get(); + trackDevice(path, connectedNow); + if (connectedNow) + publishStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); + } + + void onInterfacesRemoved(const sdbus::ObjectPath &path, const std::vector &interfaces) + { + if (std::find(interfaces.begin(), interfaces.end(), kIfaceDevice) == interfaces.end()) + return; + onDeviceConnectedChanged(path, false); + std::lock_guard guard(devMutex); + deviceProxies.erase(path); + } + + bool belongsToAdapter(const std::string &path) const { return path.rfind(adapterPath + "/", 0) == 0; } + + // -------------------------------------------------------------------- + // pairing + + void onDisplayPasskey(const sdbus::ObjectPath &device, uint32_t passkey, uint16_t entered) + { + if (entered > 0) + return; // progress updates while the peer types; the code is already + // showing + char formatted[8]; + snprintf(formatted, sizeof(formatted), "%06u", passkey); + LOG_INFO("BLE pairing request from %s: enter passkey %s", device.c_str(), formatted); + powerFSM.trigger(EVENT_BLUETOOTH_PAIR); + if (bluetoothStatus) { + meshtastic::BluetoothStatus newStatus{std::string(formatted)}; + bluetoothStatus->updateStatus(&newStatus); + } + pendingPasskey = passkey; + passkeyShowPending = true; + wakeMainLoop(); + } + + // ----------------------------------------------------------------- lifecycle + + void doSetup() + { + if (enabled) + return; + try { + conn = sdbus::createSystemBusConnection(); + conn->enterEventLoopAsync(); + + bluezRootProxy = sdbuscompat::makeProxy(*conn, kBluezService, kBluezRootPath); + bluezRootProxy->uponSignal("InterfacesAdded") + .onInterface(kIfaceObjectManager) + .call([this](const sdbus::ObjectPath &path, const InterfaceMap &ifaces) { onInterfacesAdded(path, ifaces); }); + bluezRootProxy->uponSignal("InterfacesRemoved") + .onInterface(kIfaceObjectManager) + .call([this](const sdbus::ObjectPath &path, const std::vector &ifaces) { + onInterfacesRemoved(path, ifaces); + }); + sdbuscompat::finishProxy(*bluezRootProxy); + + ManagedObjects objects; + bluezRootProxy->callMethod("GetManagedObjects").onInterface(kIfaceObjectManager).storeResultsTo(objects); + if (objects.find(sdbus::ObjectPath{adapterPath}) == objects.end()) { + LOG_ERROR("BLE adapter %s not found in BlueZ; Bluetooth stays off", adapterId.c_str()); + teardownBus(); + return; + } + + deviceName = getDeviceName(); + + adapterProxy = sdbuscompat::makeProxy(*conn, kBluezService, adapterPath); + sdbuscompat::finishProxy(*adapterProxy); + adapterProxy->setProperty("Powered").onInterface(kIfaceAdapter).toValue(true); + try { + adapterProxy->setProperty("Alias").onInterface(kIfaceAdapter).toValue(deviceName); + adapterProxy->setProperty("Pairable").onInterface(kIfaceAdapter).toValue(true); + } catch (const sdbus::Error &e) { + LOG_WARN("BLE could not set adapter alias/pairable: %s", e.what()); + } + + if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN && + !fixedPinWarned.exchange(true)) + LOG_WARN("BLE fixed PIN is not supported by BlueZ; a random passkey " + "will be shown instead"); + + exportGattTree(); + exportAgent(); + exportAdvertisement(); + + registerApplication(); + registerAgent(); + + // Track already-known devices (and any live connection) before + // advertising. + for (const auto &entry : objects) { + auto dev = entry.second.find(kIfaceDevice); + if (dev == entry.second.end() || !belongsToAdapter(entry.first)) + continue; + bool connectedNow = false; + auto prop = dev->second.find("Connected"); + if (prop != dev->second.end()) + connectedNow = prop->second.get(); + trackDevice(entry.first, connectedNow); + } + + enabled = true; + registerAdvertisement(); + LOG_INFO("BLE ready on %s as '%s' (%s pairing)", adapterId.c_str(), deviceName.c_str(), + pinPairing() ? "passkey" : "just-works"); + } catch (const sdbus::Error &e) { + LOG_ERROR("BLE setup failed (%s: %s); Bluetooth stays off", e.getName().c_str(), e.getMessage().c_str()); + teardownBus(); + } + } + + void exportGattTree() + { + gattRoot = sdbuscompat::makeObject(*conn, kGattAppPath); + gattRoot->addObjectManager(); + + service = sdbuscompat::makeObject(*conn, kServicePath); + sdbuscompat::addVTable(*service, kIfaceGattService, + sdbuscompat::property("UUID", [] { return std::string(MESH_SERVICE_UUID); }), + sdbuscompat::property("Primary", [] { return true; })); + + const bool pin = pinPairing(); + const std::vector readFlags{pin ? "encrypt-authenticated-read" : "read"}; + const std::vector writeFlags{pin ? "encrypt-authenticated-write" : "write"}; + std::vector notifyFlags = readFlags; + notifyFlags.push_back("notify"); + + toRadioChar = sdbuscompat::makeObject(*conn, kToRadioPath); + sdbuscompat::addVTable( + *toRadioChar, kIfaceGattChar, + sdbuscompat::method("WriteValue", [this](std::vector value, + PropertyMap options) { onToRadioWrite(std::move(value), options); }), + sdbuscompat::property("UUID", [] { return std::string(TORADIO_UUID); }), + sdbuscompat::property("Service", [] { return sdbus::ObjectPath{kServicePath}; }), + sdbuscompat::property("Flags", [writeFlags] { return writeFlags; })); + + fromRadioChar = sdbuscompat::makeObject(*conn, kFromRadioPath); + sdbuscompat::addVTable( + *fromRadioChar, kIfaceGattChar, + sdbuscompat::method("ReadValue", [this](sdbus::Result> result, + PropertyMap options) { onFromRadioRead(std::move(result), options); }), + sdbuscompat::property("UUID", [] { return std::string(FROMRADIO_UUID); }), + sdbuscompat::property("Service", [] { return sdbus::ObjectPath{kServicePath}; }), + sdbuscompat::property("Flags", [readFlags] { return readFlags; })); + + fromNumChar = sdbuscompat::makeObject(*conn, kFromNumPath); + sdbuscompat::addVTable(*fromNumChar, kIfaceGattChar, + sdbuscompat::method("ReadValue", [this](PropertyMap options) { return onFromNumRead(options); }), + sdbuscompat::method("StartNotify", [this] { fromNumNotifying = true; }), + sdbuscompat::method("StopNotify", [this] { fromNumNotifying = false; }), + sdbuscompat::property("UUID", [] { return std::string(FROMNUM_UUID); }), + sdbuscompat::property("Service", [] { return sdbus::ObjectPath{kServicePath}; }), + sdbuscompat::property("Flags", [notifyFlags] { return notifyFlags; }), + sdbuscompat::property("Notifying", [this] { return fromNumNotifying.load(); }), + sdbuscompat::property("Value", [this] { + std::lock_guard guard(valueMutex); + return fromNumValue; + })); + + logRadioChar = sdbuscompat::makeObject(*conn, kLogRadioPath); + sdbuscompat::addVTable(*logRadioChar, kIfaceGattChar, + sdbuscompat::method("ReadValue", [this](PropertyMap options) { return onLogRadioRead(options); }), + sdbuscompat::method("StartNotify", [this] { logNotifying = true; }), + sdbuscompat::method("StopNotify", [this] { logNotifying = false; }), + sdbuscompat::property("UUID", [] { return std::string(LOGRADIO_UUID); }), + sdbuscompat::property("Service", [] { return sdbus::ObjectPath{kServicePath}; }), + sdbuscompat::property("Flags", [notifyFlags] { return notifyFlags; }), + sdbuscompat::property("Notifying", [this] { return logNotifying.load(); }), + sdbuscompat::property("Value", [this] { + std::lock_guard guard(valueMutex); + return logValue; + })); + } + + void exportAgent() + { + agent = sdbuscompat::makeObject(*conn, kAgentPath); + sdbuscompat::addVTable( + *agent, kIfaceAgent, sdbuscompat::method("Release", [] {}), + sdbuscompat::method("RequestPinCode", + [](sdbus::ObjectPath) -> std::string { + throw sdbuscompat::dbusError("org.bluez.Error.Rejected", "display-only device"); + }), + sdbuscompat::method("DisplayPinCode", [](sdbus::ObjectPath, std::string) {}), + sdbuscompat::method("RequestPasskey", + [](sdbus::ObjectPath) -> uint32_t { + throw sdbuscompat::dbusError("org.bluez.Error.Rejected", "display-only device"); + }), + sdbuscompat::method("DisplayPasskey", [this](sdbus::ObjectPath device, uint32_t passkey, + uint16_t entered) { onDisplayPasskey(device, passkey, entered); }), + sdbuscompat::method("RequestConfirmation", + [](sdbus::ObjectPath, uint32_t) { + // Never expected with our capabilities; with a + // PIN mode configured, silently confirming would + // bypass MITM. + if (pinPairing()) + throw sdbuscompat::dbusError("org.bluez.Error.Rejected", "passkey required"); + }), + sdbuscompat::method("RequestAuthorization", [](sdbus::ObjectPath) {}), + sdbuscompat::method("AuthorizeService", [](sdbus::ObjectPath, std::string) {}), sdbuscompat::method("Cancel", [this] { + LOG_INFO("BLE pairing canceled"); + dismissPasskey(); + if (!checkIsConnected()) + publishStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); + })); + } + + void exportAdvertisement() + { + advert = sdbuscompat::makeObject(*conn, kAdvertPath); + sdbuscompat::addVTable(*advert, kIfaceAdvert, sdbuscompat::method("Release", [] {}), + sdbuscompat::property("Type", [] { return std::string("peripheral"); }), + sdbuscompat::property("ServiceUUIDs", [] { return std::vector{MESH_SERVICE_UUID}; }), + sdbuscompat::property("LocalName", [this] { return deviceName; }), + sdbuscompat::property("Discoverable", [] { return true; }), + // Without these, the kernel default advertising interval of + // 1.28s applies and a central can take many seconds just to + // establish a link. Milliseconds. 20ms matches NimBLE's floor + // on ESP32: Android's background connects listen in sparse + // scan windows, and only an aggressive advertiser lands in + // them quickly. Power cost is irrelevant on a mains-powered + // host. Ignored by BlueZ < 5.71. + sdbuscompat::property("MinInterval", [] { return static_cast(20); }), + sdbuscompat::property("MaxInterval", [] { return static_cast(100); })); + } + + void registerApplication() + { + // Must be async: before replying, bluetoothd calls GetManagedObjects back + // on our connection, and a synchronous call would sit on the connection + // until timeout. + adapterProxy->callMethodAsync("RegisterApplication") + .onInterface(kIfaceGattManager) + .withArguments(sdbus::ObjectPath{kGattAppPath}, PropertyMap{}) + .uponReplyInvoke([](sdbuscompat::AsyncError error) { + if (sdbuscompat::asyncFailed(error)) + LOG_ERROR("BLE GATT registration failed: %s", sdbuscompat::asyncErrorMessage(error).c_str()); + else + LOG_INFO("BLE GATT service registered"); + }); + appRegistered = true; + } + + void registerAgent() + { + agentManagerProxy = sdbuscompat::makeProxy(*conn, kBluezService, kBluezManagerPath); + sdbuscompat::finishProxy(*agentManagerProxy); + const std::string capability = pinPairing() ? "DisplayOnly" : "NoInputNoOutput"; + agentManagerProxy->callMethod("RegisterAgent") + .onInterface(kIfaceAgentManager) + .withArguments(sdbus::ObjectPath{kAgentPath}, capability); + agentRegistered = true; + try { + // Make our agent answer this host's pairing requests while BLE is + // enabled; without this, headless systems have no agent at all and + // pairing fails. + agentManagerProxy->callMethod("RequestDefaultAgent") + .onInterface(kIfaceAgentManager) + .withArguments(sdbus::ObjectPath{kAgentPath}); + } catch (const sdbus::Error &e) { + LOG_WARN("BLE could not become default pairing agent: %s", e.getMessage().c_str()); + } + } + + void registerAdvertisement() + { + if (!enabled || advertising) + return; + advertising = true; + // Async for the same reason as RegisterApplication: bluetoothd reads our + // advertisement object's properties before replying. + adapterProxy->callMethodAsync("RegisterAdvertisement") + .onInterface(kIfaceAdvManager) + .withArguments(sdbus::ObjectPath{kAdvertPath}, PropertyMap{}) + .uponReplyInvoke([this](sdbuscompat::AsyncError error) { + if (sdbuscompat::asyncFailed(error)) { + advertising = false; + LOG_ERROR("BLE could not start advertising: %s", sdbuscompat::asyncErrorMessage(error).c_str()); + } else { + LOG_INFO("BLE advertising as '%s'", deviceName.c_str()); + } + }); + } + + void unregisterAdvertisement() + { + if (!enabled || !advertising) + return; + try { + adapterProxy->callMethod("UnregisterAdvertisement") + .onInterface(kIfaceAdvManager) + .withArguments(sdbus::ObjectPath{kAdvertPath}); + } catch (const sdbus::Error &e) { + LOG_WARN("BLE could not stop advertising: %s", e.getMessage().c_str()); + } + advertising = false; + LOG_INFO("BLE advertising stopped"); + } + + void doDeinit() + { + if (!conn) + return; + draining = true; + // Runs on the main thread (setBluetoothEnable / AdminModule), so the alert can + // be torn down inline -- runOnce() may never be scheduled again after this. + passkeyHidePending = true; + updatePasskeyAlert(); + failParkedRead(); + unregisterAdvertisement(); + if (agentRegistered.exchange(false)) { + try { + agentManagerProxy->callMethod("UnregisterAgent") + .onInterface(kIfaceAgentManager) + .withArguments(sdbus::ObjectPath{kAgentPath}); + } catch (const sdbus::Error &) { + } + } + if (appRegistered.exchange(false)) { + try { + adapterProxy->callMethod("UnregisterApplication") + .onInterface(kIfaceGattManager) + .withArguments(sdbus::ObjectPath{kGattAppPath}); + } catch (const sdbus::Error &) { + } + } + enabled = false; + teardownBus(); + publishStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); + LOG_INFO("BLE disabled"); + } + + void teardownBus() + { + if (conn) + conn->leaveEventLoop(); + { + // Move the proxies out so their (D-Bus) destruction happens without + // holding devMutex - the main thread must never call into sdbus under + // that lock. + std::map> doomed; + std::lock_guard guard(devMutex); + doomed.swap(deviceProxies); + connectedDevices.clear(); + } + agentManagerProxy.reset(); + adapterProxy.reset(); + bluezRootProxy.reset(); + gattRoot.reset(); + service.reset(); + toRadioChar.reset(); + fromRadioChar.reset(); + fromNumChar.reset(); + logRadioChar.reset(); + advert.reset(); + agent.reset(); + conn.reset(); + draining = false; + enabled = false; + advertising = false; + agentRegistered = false; + appRegistered = false; + } + + void doClearBonds() + { + if (!conn) { + LOG_WARN("BLE clearBonds: Bluetooth is not running, nothing to clear"); + return; + } + try { + ManagedObjects objects; + bluezRootProxy->callMethod("GetManagedObjects").onInterface(kIfaceObjectManager).storeResultsTo(objects); + for (const auto &entry : objects) { + auto dev = entry.second.find(kIfaceDevice); + if (dev == entry.second.end() || !belongsToAdapter(entry.first)) + continue; + auto paired = dev->second.find("Paired"); + if (paired == dev->second.end() || !paired->second.get()) + continue; + LOG_INFO("BLE removing bond %s", entry.first.c_str()); + try { + adapterProxy->callMethod("RemoveDevice").onInterface(kIfaceAdapter).withArguments(entry.first); + } catch (const sdbus::Error &e) { + LOG_WARN("BLE could not remove %s: %s", entry.first.c_str(), e.getMessage().c_str()); + } + } + } catch (const sdbus::Error &e) { + LOG_ERROR("BLE clearBonds failed: %s", e.getMessage().c_str()); + } + } + + void doSendLog(const uint8_t *logMessage, size_t length) + { + // CAUTION: called from the logger; never LOG_* in here (infinite + // recursion). + if (!enabled || !logNotifying || !logRadioChar || length == 0) + return; + if (length > MAX_TO_FROM_RADIO_SIZE) + length = MAX_TO_FROM_RADIO_SIZE; + { + std::lock_guard guard(valueMutex); + logValue.assign(logMessage, logMessage + length); + } + sdbuscompat::emitPropertiesChanged(*logRadioChar, kIfaceGattChar, "Value"); + } +}; + +LinuxBluetooth::LinuxBluetooth() : impl(new Impl(portduino_config.bluetooth_adapter)) {} + +LinuxBluetooth::~LinuxBluetooth() +{ + if (impl) + impl->doDeinit(); +} + +void LinuxBluetooth::setup() +{ + impl->doSetup(); +} + +void LinuxBluetooth::shutdown() +{ + impl->unregisterAdvertisement(); +} + +void LinuxBluetooth::resumeAdvertising() +{ + impl->registerAdvertisement(); +} + +void LinuxBluetooth::deinit() +{ + impl->doDeinit(); +} + +void LinuxBluetooth::clearBonds() +{ + impl->doClearBonds(); +} + +bool LinuxBluetooth::isConnected() +{ + return impl->checkIsConnected(); +} + +int LinuxBluetooth::getRssi() +{ + return 0; // not exposed by BlueZ for connected peers; same answer as NRF52 +} + +bool LinuxBluetooth::isEnabled() +{ + return impl->enabled; +} + +void LinuxBluetooth::sendLog(const uint8_t *logMessage, size_t length) +{ + impl->doSendLog(logMessage, length); +} + +#endif // MESHTASTIC_LINUX_BLE diff --git a/src/platform/portduino/LinuxBluetooth.h b/src/platform/portduino/LinuxBluetooth.h new file mode 100644 index 000000000..fd49c7bae --- /dev/null +++ b/src/platform/portduino/LinuxBluetooth.h @@ -0,0 +1,61 @@ +#pragma once + +#include "configuration.h" + +// BLE peripheral support for meshtasticd, implemented against BlueZ's D-Bus +// GATT, advertising and agent APIs via sdbus-c++. Compiled only on Linux hosts +// with the sdbus-c++ dev package present (same optional-dependency idiom as the +// ulfius webserver); everywhere else this header is empty and HAS_BLUETOOTH +// stays 0. +#if defined(ARCH_PORTDUINO) && defined(PORTDUINO_LINUX_HARDWARE) && __has_include() + +#define MESHTASTIC_LINUX_BLE 1 + +#include +#include +#include + +/** + * Runs the standard Meshtastic BLE service (toRadio/fromRadio/fromNum/logRadio) + * through bluetoothd. Method names deliberately match + * NimbleBluetooth/NRF52Bluetooth so the cross-platform call sites + * (setBluetoothEnable, AdminModule, RedirectablePrint, ...) read the same on + * every architecture. + * + * Note this intentionally does not derive from BluetoothApi: no call site uses + * that base polymorphically (each platform is reached through its own concrete + * global pointer), and the base's declared-but-undefined virtuals make its + * vtable/typeinfo unlinkable in unoptimized RTTI builds such as the native + * coverage env. + */ +class LinuxBluetooth +{ + public: + LinuxBluetooth(); + ~LinuxBluetooth(); + + /// Connect to BlueZ, export the GATT application, register agent + + /// advertisement. Failure (no adapter, D-Bus policy denial, no bluetoothd) + /// logs and leaves BLE off. + void setup(); + /// Stop advertising only; an established connection stays up (PowerFSM dark + /// states). + void shutdown(); + /// Re-register the advertisement after shutdown(). + void resumeAdvertising(); + /// Full teardown: unregister everything and drop the bus connection. + void deinit(); + + void clearBonds(); + bool isConnected(); + int getRssi(); + /// setup() succeeded and deinit() has not run. + bool isEnabled(); + void sendLog(const uint8_t *logMessage, size_t length); + + private: + struct Impl; + std::unique_ptr impl; +}; + +#endif diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 9c908a4bc..2db89bc68 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -37,6 +37,12 @@ #include #endif +#include "LinuxBluetooth.h" +#ifdef MESHTASTIC_LINUX_BLE +#include "mesh/NodeDB.h" // config.bluetooth.enabled +extern LinuxBluetooth *linuxBluetooth; // defined in main.cpp +#endif + #ifdef PORTDUINO_LINUX_HARDWARE #include #endif @@ -152,7 +158,36 @@ char stdoutBuffer[512]; // FIXME - move setBluetoothEnable into a HALPlatform class void setBluetoothEnable(bool enable) { - // not needed +#ifdef MESHTASTIC_LINUX_BLE + // Disable is not gated on the config flags: if BLE is running it must always be + // stoppable, even after the device config was switched off underneath it. + if (!enable) { + if (linuxBluetooth) { + // Stop advertising only; a live phone connection survives PowerFSM state + // dips. + linuxBluetooth->shutdown(); + } + return; + } + // Opt-in twice: the config.yaml Bluetooth section must enable BLE on this + // host, and the regular device config (like every other platform) must have + // Bluetooth on. + if (!portduino_config.bluetooth_enabled || !config.bluetooth.enabled) + return; + if (!linuxBluetooth) { + LOG_INFO("Init LinuxBluetooth (adapter %s)", portduino_config.bluetooth_adapter.c_str()); + linuxBluetooth = new LinuxBluetooth(); + linuxBluetooth->setup(); + } else if (!linuxBluetooth->isEnabled()) { + // The backend exists but never came up -- bluetoothd was not ready, the adapter was + // missing, or policy refused us. resumeAdvertising() returns immediately while disabled, so + // without this a transient failure at boot would keep BLE off until the process restarted. + LOG_INFO("Retry LinuxBluetooth setup (adapter %s)", portduino_config.bluetooth_adapter.c_str()); + linuxBluetooth->setup(); + } else { + linuxBluetooth->resumeAdvertising(); + } +#endif } void cpuDeepSleep(uint32_t msecs) @@ -295,9 +330,23 @@ void getMacAddr(uint8_t *dmac) return; } else { #ifdef PORTDUINO_LINUX_HARDWARE + // Cache after the first successful read. The adapter address can't change at + // runtime, this now gets called from BLE property getters on every bluetoothd + // read (not just at startup), and the socket used to leak one fd per call. + static uint8_t cachedMac[6]; + static bool macCached = false; + if (macCached) { + memcpy(dmac, cachedMac, 6); + return; + } struct hci_dev_info di = {0}; - di.dev_id = 0; - bdaddr_t bdaddr; + // Read the adapter configured for BLE (Bluetooth.AdapterId) so the node + // identity matches the advertised adapter; a name that doesn't parse as + // hci falls back to hci0, preserving the pre-BLE behavior. + unsigned adapterIndex = 0; + if (sscanf(portduino_config.bluetooth_adapter.c_str(), "hci%u", &adapterIndex) != 1) + adapterIndex = 0; + di.dev_id = adapterIndex; int btsock; btsock = socket(AF_BLUETOOTH, SOCK_RAW, 1); if (btsock < 0) { // If anything fails, just return with the default value @@ -305,8 +354,10 @@ void getMacAddr(uint8_t *dmac) } if (ioctl(btsock, HCIGETDEVINFO, (void *)&di)) { + close(btsock); return; } + close(btsock); dmac[0] = di.bdaddr.b[5]; dmac[1] = di.bdaddr.b[4]; @@ -314,6 +365,8 @@ void getMacAddr(uint8_t *dmac) dmac[3] = di.bdaddr.b[2]; dmac[4] = di.bdaddr.b[1]; dmac[5] = di.bdaddr.b[0]; + memcpy(cachedMac, dmac, 6); + macCached = true; #elif defined(__APPLE__) // No BlueZ on macOS, but we can fall back to the host's primary // network interface MAC. `en0` is Wi-Fi on every shipping Mac @@ -1351,6 +1404,18 @@ bool loadConfig(const char *configPath) (yamlConfig["Webserver"]["SSLCert"]).as("/etc/meshtasticd/ssl/certificate.pem"); } + if (yamlConfig["Bluetooth"]) { + // Assign per key, not per section. loadConfig() runs once for every file in config.d, so + // reading an absent key as its default would let a later file that names only one of + // these silently reset the other -- `AdapterId: hci1` alone would turn Bluetooth off, + // and `Enabled: true` alone would drag the adapter back to hci0. Only what a file + // actually says should override what an earlier one set. + if (yamlConfig["Bluetooth"]["Enabled"]) + portduino_config.bluetooth_enabled = (yamlConfig["Bluetooth"]["Enabled"]).as(false); + if (yamlConfig["Bluetooth"]["AdapterId"]) + portduino_config.bluetooth_adapter = (yamlConfig["Bluetooth"]["AdapterId"]).as("hci0"); + } + if (yamlConfig["HostMetrics"]) { portduino_config.hostMetrics_channel = (yamlConfig["HostMetrics"]["Channel"]).as(0); portduino_config.hostMetrics_interval = (yamlConfig["HostMetrics"]["ReportInterval"]).as(0); diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 31838d42a..8b59c6c02 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -265,6 +265,11 @@ extern struct portduino_config_struct { std::string webserver_ssl_cert_path = "/etc/meshtasticd/ssl/certificate.pem"; int webserverport = -1; + // Bluetooth (BLE peripheral via BlueZ; Linux only, and only when built with + // sdbus-c++) + bool bluetooth_enabled = false; + std::string bluetooth_adapter = "hci0"; + // HostMetrics std::string hostMetrics_user_command = ""; int hostMetrics_interval = 0; @@ -641,6 +646,14 @@ extern struct portduino_config_struct { out << YAML::EndMap; // Webserver } + // Bluetooth + if (bluetooth_enabled) { + out << YAML::Key << "Bluetooth" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "Enabled" << YAML::Value << bluetooth_enabled; + out << YAML::Key << "AdapterId" << YAML::Value << bluetooth_adapter; + out << YAML::EndMap; // Bluetooth + } + // HostMetrics if (hostMetrics_user_command != "") { out << YAML::Key << "HostMetrics" << YAML::Value << YAML::BeginMap; diff --git a/src/platform/portduino/SdbusCompat.h b/src/platform/portduino/SdbusCompat.h new file mode 100644 index 000000000..240f4707e --- /dev/null +++ b/src/platform/portduino/SdbusCompat.h @@ -0,0 +1,149 @@ +#pragma once + +/** + * Thin compatibility layer over the sdbus-c++ 1.x and 2.x APIs, so + * LinuxBluetooth.cpp can be written once against a single registration/proxy + * syntax. + * + * The 2.x major release (Debian trixie, Fedora, current Raspberry Pi OS) + * replaced the 1.x fluent object-registration API + * (registerMethod().onInterface().implementedAs() + finishRegistration()) with + * vtable items (addVTable(items...).forInterface()), and made + * bus/interface/member names strong types. 1.x (Debian bookworm ships 1.2, + * Ubuntu 24.04 ships 1.4) predates both. MESHTASTIC_SDBUS_CPP_V2 is set from + * pkg-config in variants/native/portduino.ini. + * + * Only what LinuxBluetooth.cpp actually uses is shimmed. + */ + +#include + +#include +#include +#include +#include +#include +#include + +namespace sdbuscompat +{ + +#ifdef MESHTASTIC_SDBUS_CPP_V2 + +// Error argument of an async method-reply callback (2.x passes an optional, 1.x +// a pointer). +using AsyncError = std::optional; +inline bool asyncFailed(const AsyncError &error) +{ + return error.has_value(); +} +inline std::string asyncErrorMessage(const AsyncError &error) +{ + return error ? error->getMessage() : std::string(); +} + +template auto method(const char *name, F &&callback) +{ + return sdbus::registerMethod(std::string(name)).implementedAs(std::forward(callback)); +} + +template auto property(const char *name, F &&getter) +{ + return sdbus::registerProperty(std::string(name)).withGetter(std::forward(getter)); +} + +template void addVTable(sdbus::IObject &object, const char *interfaceName, Items &&...items) +{ + object.addVTable(std::forward(items)...).forInterface(sdbus::InterfaceName{interfaceName}); +} + +inline std::unique_ptr makeProxy(sdbus::IConnection &connection, const char *destination, const std::string &path) +{ + return sdbus::createProxy(connection, sdbus::ServiceName{destination}, sdbus::ObjectPath{path}); +} + +inline sdbus::Error dbusError(const char *name, const char *message) +{ + return sdbus::Error(sdbus::Error::Name{name}, message); +} + +inline void emitPropertiesChanged(sdbus::IObject &object, const char *interfaceName, const char *propertyName) +{ + object.emitPropertiesChangedSignal(interfaceName, {sdbus::PropertyName{propertyName}}); +} + +#else // sdbus-c++ 1.x + +using AsyncError = const sdbus::Error *; +inline bool asyncFailed(AsyncError error) +{ + return error != nullptr; +} +inline std::string asyncErrorMessage(AsyncError error) +{ + return error ? error->getMessage() : std::string(); +} + +// 1.x has no vtable-item concept; emulate one with type-erased "apply this +// registration to that object" closures so call sites read the same as the 2.x +// path. +struct VTableItem { + std::function apply; +}; + +template VTableItem method(const char *name, F &&callback) +{ + return {[name = std::string(name), cb = std::forward(callback)](sdbus::IObject &object, const std::string &iface) mutable { + object.registerMethod(name).onInterface(iface).implementedAs(std::move(cb)); + }}; +} + +template VTableItem property(const char *name, F &&getter) +{ + return {[name = std::string(name), g = std::forward(getter)](sdbus::IObject &object, const std::string &iface) mutable { + object.registerProperty(name).onInterface(iface).withGetter(std::move(g)); + }}; +} + +template void addVTable(sdbus::IObject &object, const char *interfaceName, Items &&...items) +{ + (items.apply(object, interfaceName), ...); + object.finishRegistration(); +} + +inline std::unique_ptr makeProxy(sdbus::IConnection &connection, const char *destination, const std::string &path) +{ + return sdbus::createProxy(connection, destination, path); +} + +inline sdbus::Error dbusError(const char *name, const char *message) +{ + return sdbus::Error(name, message); +} + +inline void emitPropertiesChanged(sdbus::IObject &object, const char *interfaceName, const char *propertyName) +{ + object.emitPropertiesChangedSignal(interfaceName, {std::string(propertyName)}); +} + +#endif + +inline std::unique_ptr makeObject(sdbus::IConnection &connection, const std::string &path) +{ + // sdbus::ObjectPath derives from std::string in both major versions, so this + // one needs no #if: 1.x takes std::string, 2.x takes ObjectPath. + return sdbus::createObject(connection, sdbus::ObjectPath{path}); +} + +inline void finishProxy(sdbus::IProxy &proxy) +{ +#ifndef MESHTASTIC_SDBUS_CPP_V2 + // 1.x defers uponSignal() subscriptions until finishRegistration(); 2.x + // subscribes immediately and has no such method. + proxy.finishRegistration(); +#else + (void)proxy; +#endif +} + +} // namespace sdbuscompat diff --git a/src/platform/portduino/architecture.h b/src/platform/portduino/architecture.h index 82578e448..9a9798ebf 100644 --- a/src/platform/portduino/architecture.h +++ b/src/platform/portduino/architecture.h @@ -38,4 +38,14 @@ // HAS_HUB75_NATIVE stays undefined and the backend compiles out. See src/graphics/HUB75Display.cpp. #if defined(ARCH_PORTDUINO) && __has_include() #define HAS_HUB75_NATIVE 1 +#endif + +// BLE peripheral support (BlueZ over D-Bus) on Linux turns on automatically +// when the sdbus-c++ dev package is installed (wired up in +// variants/native/portduino.ini). When absent, HAS_BLUETOOTH stays at +// configuration.h's default of 0 and the whole backend compiles out. Runtime +// activation additionally requires `Bluetooth: Enabled: true` in config.yaml. +// See src/platform/portduino/LinuxBluetooth.cpp. +#if defined(PORTDUINO_LINUX_HARDWARE) && __has_include() +#define HAS_BLUETOOTH 1 #endif \ No newline at end of file diff --git a/test/fixtures/portduino-config/README.md b/test/fixtures/portduino-config/README.md index f0e386ed3..4a73ed0a0 100644 --- a/test/fixtures/portduino-config/README.md +++ b/test/fixtures/portduino-config/README.md @@ -149,6 +149,18 @@ is `uint16_t[22]`, so extra points are dropped and out-of-range values wrap. | `statusmessage-long.yaml` | Copied into a `char[80]`, so it is safe but silently shortened to 79 characters. | | `configdir-missing.yaml` | **Crash regression guard** - an unreadable `ConfigDirectory` used to abort meshtasticd (and `--check`) with SIGABRT via an uncaught `filesystem_error`. | +## Bluetooth + +BLE support is compiled in only when sdbus-c++ is present, so a valid `Bluetooth:` +section is clean on a BLE build and reports the build-time gap as a warning on a +non-BLE build. The assertions only test what holds either way. + +| File | Expected | +| ---------------------------- | ------------------------------------------------------------------------------------------------ | +| `bluetooth.yaml` | The section parses, both keys are known, and no error is invented. | +| `bluetooth-bad-enabled.yaml` | `Enabled: maybe` is read with a fallback: silently replaced by `false`, so BLE never turns on. | +| `bluetooth-bad-adapter.yaml` | `hci1junk` passes the MAC fallback but is not a BlueZ object path, so BLE silently never starts. | + ## MAC address The MAC no longer determines NodeNum - that comes from the public key - but a MAC diff --git a/test/fixtures/portduino-config/bluetooth-bad-adapter.yaml b/test/fixtures/portduino-config/bluetooth-bad-adapter.yaml new file mode 100644 index 000000000..0e6bc1185 --- /dev/null +++ b/test/fixtures/portduino-config/bluetooth-bad-adapter.yaml @@ -0,0 +1,12 @@ +# FAULT: AdapterId is not a BlueZ adapter name. The MAC fallback reads only the leading +# hci1 and is happy, but LinuxBluetooth uses the whole string as the object path and finds +# no /org/bluez/hci1junk, so BLE silently never comes up. Expect a WARNING and exit 0. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Bluetooth: + Enabled: true + AdapterId: hci1junk diff --git a/test/fixtures/portduino-config/bluetooth-bad-enabled.yaml b/test/fixtures/portduino-config/bluetooth-bad-enabled.yaml new file mode 100644 index 000000000..0f10d4391 --- /dev/null +++ b/test/fixtures/portduino-config/bluetooth-bad-enabled.yaml @@ -0,0 +1,11 @@ +# FAULT: Bluetooth.Enabled is not a true/false value. It is read with a fallback, so +# the value is silently replaced by the default (false) and BLE never turns on. +# Expect a WARNING and exit 0. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Bluetooth: + Enabled: maybe diff --git a/test/fixtures/portduino-config/bluetooth.yaml b/test/fixtures/portduino-config/bluetooth.yaml new file mode 100644 index 000000000..f70f02f25 --- /dev/null +++ b/test/fixtures/portduino-config/bluetooth.yaml @@ -0,0 +1,12 @@ +# CLEAN on a build with BLE support (sdbus-c++ present); on a build without it the +# checker reports the build-time gap as a warning instead. The suite asserts only what +# holds either way: the section parses, the keys are known, and no error is invented. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Bluetooth: + Enabled: true + AdapterId: hci0 diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index d0bfcc88c..ebc0d514a 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -68,6 +68,14 @@ build_flags = -D_FORTIFY_SOURCE=2 -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 -DPORTDUINO_LINUX_HARDWARE + ; BLE peripheral support via BlueZ D-Bus needs sdbus-c++; when the dev package is + ; absent these lines emit nothing and BLE compiles out (HAS_BLUETOOTH stays 0, + ; see src/platform/portduino/architecture.h). The second line flags the sdbus-c++ + ; 2.x API for src/platform/portduino/SdbusCompat.h. Scriptlet lines must not sit + ; last in this list: native_base appends "-I ..." to the final line inline, and a + ; trailing scriptlet would swallow it into the shell command. + !pkg-config --cflags --libs sdbus-c++ --silence-errors || : + !pkg-config --exists 'sdbus-c++ >= 2' && echo '-DMESHTASTIC_SDBUS_CPP_V2' || : -lstdc++fs -lbluetooth -lgpiod