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