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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<digits> 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jonathan BennettandClaude Opus 5 authored and GitHub committed 2026-10-01 21:20:09 +00:00
1 parent e0c76fd41e
commit 8c0abbd522
29 files changed
+1486 -12

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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/* \
+4
View File
@@ -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.
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<!--
Allow the unprivileged meshtasticd service user to talk to bluetoothd, so
meshtasticd can register its BLE GATT service, advertisement and pairing agent.
bluetoothd's own policy (bluetooth.conf) covers the reverse direction: it runs as
root, which may call back into any client's GATT objects.
The rule is deliberately destination-wide rather than per-interface: meshtasticd
legitimately uses GattManager1, LEAdvertisingManager1, AgentManager1, Adapter1,
Device1, ObjectManager, Properties and Introspectable, and a stale interface
allowlist fails as a hard-to-diagnose runtime denial after a BlueZ upgrade. The
scope is still bounded: only the meshtasticd user, and only toward org.bluez.
-->
<busconfig>
<policy user="meshtasticd">
<allow send_destination="org.bluez"/>
</policy>
</busconfig>
+2 -1
View File
@@ -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
+10
View File
@@ -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:"
+2
View File
@@ -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.
+1
View File
@@ -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
+1
View File
@@ -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
+8
View File
@@ -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/*
+4
View File
@@ -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
}
}
+8 -3
View File
@@ -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
+6
View File
@@ -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
+12
View File
@@ -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;
+8
View File
@@ -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
}
+27
View File
@@ -89,6 +89,7 @@ const std::map<std::string, std::set<std::string>> &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<std::string, ValueSpec> &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 &section, 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/<id>), 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<digits> is a real adapter name.
const std::string adapter = value.as<std::string>("");
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<Finding> &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,
File diff suppressed because it is too large. Load diff
+61
View File
@@ -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(<sdbus-c++/sdbus-c++.h>)
#define MESHTASTIC_LINUX_BLE 1
#include <cstddef>
#include <cstdint>
#include <memory>
/**
* 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> impl;
};
#endif
+68 -3
View File
@@ -37,6 +37,12 @@
#include <bluetooth/hci.h>
#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 <cxxabi.h>
#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<N> 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<std::string>("/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<bool>(false);
if (yamlConfig["Bluetooth"]["AdapterId"])
portduino_config.bluetooth_adapter = (yamlConfig["Bluetooth"]["AdapterId"]).as<std::string>("hci0");
}
if (yamlConfig["HostMetrics"]) {
portduino_config.hostMetrics_channel = (yamlConfig["HostMetrics"]["Channel"]).as<int>(0);
portduino_config.hostMetrics_interval = (yamlConfig["HostMetrics"]["ReportInterval"]).as<int>(0);
+13
View File
@@ -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;
+149
View File
@@ -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 <sdbus-c++/sdbus-c++.h>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>
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<sdbus::Error>;
inline bool asyncFailed(const AsyncError &error)
{
return error.has_value();
}
inline std::string asyncErrorMessage(const AsyncError &error)
{
return error ? error->getMessage() : std::string();
}
template <typename F> auto method(const char *name, F &&callback)
{
return sdbus::registerMethod(std::string(name)).implementedAs(std::forward<F>(callback));
}
template <typename F> auto property(const char *name, F &&getter)
{
return sdbus::registerProperty(std::string(name)).withGetter(std::forward<F>(getter));
}
template <typename... Items> void addVTable(sdbus::IObject &object, const char *interfaceName, Items &&...items)
{
object.addVTable(std::forward<Items>(items)...).forInterface(sdbus::InterfaceName{interfaceName});
}
inline std::unique_ptr<sdbus::IProxy> 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<void(sdbus::IObject &, const std::string &)> apply;
};
template <typename F> VTableItem method(const char *name, F &&callback)
{
return {[name = std::string(name), cb = std::forward<F>(callback)](sdbus::IObject &object, const std::string &iface) mutable {
object.registerMethod(name).onInterface(iface).implementedAs(std::move(cb));
}};
}
template <typename F> VTableItem property(const char *name, F &&getter)
{
return {[name = std::string(name), g = std::forward<F>(getter)](sdbus::IObject &object, const std::string &iface) mutable {
object.registerProperty(name).onInterface(iface).withGetter(std::move(g));
}};
}
template <typename... Items> void addVTable(sdbus::IObject &object, const char *interfaceName, Items &&...items)
{
(items.apply(object, interfaceName), ...);
object.finishRegistration();
}
inline std::unique_ptr<sdbus::IProxy> 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<sdbus::IObject> 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
+10
View File
@@ -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(<led-matrix.h>)
#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(<sdbus-c++/sdbus-c++.h>)
#define HAS_BLUETOOTH 1
#endif
+12
View File
@@ -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
@@ -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
@@ -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
+12
View File
@@ -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
+8
View File
@@ -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