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