Files
slate32/src/host/providers_ble.cpp
T
evan 96c1a68ee0 feat(apps): add BLETemp, reading sensor temps from BLE adverts
Wire the host side of the new advertisement observer (continuous scan,
bounded staleness map, ingestion filter) and add a BLETemp app that
decodes ATC_MiThermometer adverts (service data 0x181A, ATC1441 and pvvx
formats) from ble.observed(). Migrate the Settings/Ble picker off the
removed blocking scan onto observe()/observed().

Remove the Hello and Scroll demo apps; BLETemp now serves as the
tree-owned-scrolling example in the docs.

AGENTS.md gains a Design Stance: this is pre-release firmware, so a wrong
abstraction gets redesigned, not extended with a compat shim.

BLE cannot run in the emulator, so the app logic is covered by host tests
(test/bletemp.lua) against fake_device.
2026-08-06 23:03:31 -04:00

303 lines
8.9 KiB
C++

#include "providers.h"
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <WiFi.h>
#include "../heaplog.h"
#include <cstdio>
#include <cstring>
#include <mutex>
namespace slate {
namespace {
using esp32lua::Status;
// A distant temp sensor must not lose its slot to a nearer phone, so the map is
// bounded by staleness, not RSSI: when full, the least-recently-seen entry is
// evicted. Filtering at ingestion keeps unwanted devices out entirely.
constexpr size_t MAX_OBSERVED = 32;
struct Observation {
std::string name;
std::string payload;
int32_t rssi;
int32_t lastSeenMs;
uint8_t addressType;
};
// onResult fires on the NimBLE host task while observed()/connect() run on the
// Lua task, so every touch of the map takes the lock. No Lua is called from
// here -- the tree samples the snapshot on its own cadence instead.
class Observer : public NimBLEScanCallbacks {
public:
void setFilter(const esp32lua::BleFilter& next) {
std::lock_guard<std::mutex> guard(mutex);
filter = next;
devices.clear();
}
void clear() {
std::lock_guard<std::mutex> guard(mutex);
devices.clear();
}
void snapshot(std::vector<esp32lua::BleObservation>& out) {
std::lock_guard<std::mutex> guard(mutex);
out.clear();
out.reserve(devices.size());
for (const auto& entry : devices)
out.push_back({entry.first, entry.second.name, entry.second.rssi,
entry.second.payload, entry.second.lastSeenMs});
}
bool addressType(const std::string& address, uint8_t& type) {
std::lock_guard<std::mutex> guard(mutex);
const auto found = devices.find(address);
if (found == devices.end())
return false;
type = found->second.addressType;
return true;
}
void onResult(const NimBLEAdvertisedDevice* device) override {
if (!matches(device))
return;
Observation entry;
entry.name = device->haveName() ? device->getName() : "";
const std::vector<uint8_t>& payload = device->getPayload();
entry.payload.assign(payload.begin(), payload.end());
entry.rssi = device->getRSSI();
entry.lastSeenMs = static_cast<int32_t>(millis());
entry.addressType = device->getAddressType();
std::lock_guard<std::mutex> guard(mutex);
const std::string address = device->getAddress().toString();
if (devices.find(address) == devices.end() &&
devices.size() >= MAX_OBSERVED)
evictStalest();
devices[address] = entry;
}
private:
bool matches(const NimBLEAdvertisedDevice* device) const {
if (filter.services.empty() && filter.manufacturers.empty())
return true;
for (const std::string& uuid : filter.services)
if (!device->getServiceData(NimBLEUUID(uuid.c_str())).empty())
return true;
if (!filter.manufacturers.empty() && device->haveManufacturerData()) {
const std::string mfg = device->getManufacturerData();
if (mfg.size() >= 2) {
const int32_t id = static_cast<uint8_t>(mfg[0]) |
(static_cast<uint8_t>(mfg[1]) << 8);
for (int32_t want : filter.manufacturers)
if (want == id)
return true;
}
}
return false;
}
void evictStalest() {
auto stalest = devices.begin();
for (auto it = devices.begin(); it != devices.end(); ++it)
if (it->second.lastSeenMs < stalest->second.lastSeenMs)
stalest = it;
devices.erase(stalest);
}
std::mutex mutex;
std::map<std::string, Observation> devices;
esp32lua::BleFilter filter;
};
Observer observer;
bool validAddress(const std::string& address) {
if (address.length() != 17)
return false;
for (size_t at = 0; at < address.length(); at++) {
if (at % 3 == 2) {
if (address[at] != ':')
return false;
} else if (!((address[at] >= '0' && address[at] <= '9') ||
(address[at] >= 'a' && address[at] <= 'f') ||
(address[at] >= 'A' && address[at] <= 'F'))) {
return false;
}
}
return true;
}
} // namespace
Status Ble::init(const std::string* name) {
if (isInitialized())
return Status::success();
WiFi.mode(WIFI_OFF);
logHeap("pre-ble");
if (!NimBLEDevice::init(name ? *name : "Slate32"))
return Status::failure("cannot initialize BLE");
logHeap("ble-on");
return Status::success();
}
void Ble::deinit() {
if (!isInitialized())
return;
disconnect();
stopAdvertising();
unobserve();
NimBLEDevice::deinit(true);
logHeap("ble-off");
}
bool Ble::isInitialized() const { return NimBLEDevice::isInitialized(); }
void bleShutdown() {
if (!NimBLEDevice::isInitialized())
return;
NimBLEDevice::deinit(true);
Serial.println("[ble] released for wifi");
}
Status Ble::observe(const esp32lua::BleFilter& filter) {
if (!isInitialized())
return Status::failure("BLE is not initialized");
observer.setFilter(filter);
NimBLEScan* scan = NimBLEDevice::getScan();
scan->setActiveScan(false);
scan->setMaxResults(0);
// wantDuplicates: a beacon re-advertises its latest reading; drop the filter
// and we would see each sensor once and never update its value.
scan->setScanCallbacks(&observer, true);
scan->setDuplicateFilter(false);
if (!scan->isScanning() && !scan->start(0, false))
return Status::failure("cannot start BLE scan");
return Status::success();
}
void Ble::unobserve() {
if (!isInitialized())
return;
NimBLEScan* scan = NimBLEDevice::getScan();
if (scan->isScanning())
scan->stop();
scan->setScanCallbacks(nullptr);
observer.clear();
}
bool Ble::isObserving() const {
return isInitialized() && NimBLEDevice::getScan()->isScanning();
}
void Ble::observed(std::vector<esp32lua::BleObservation>& out) {
observer.snapshot(out);
}
Status Ble::connect(const std::string& address) {
if (!isInitialized())
return Status::failure("BLE is not initialized");
if (!validAddress(address))
return Status::failure("invalid BLE address");
disconnect();
client = NimBLEDevice::createClient();
if (!client)
return Status::failure("cannot create BLE client");
uint8_t addressType = BLE_ADDR_PUBLIC;
observer.addressType(address, addressType);
if (!client->connect(NimBLEAddress(address, addressType))) {
NimBLEDevice::deleteClient(client);
client = nullptr;
return Status::failure("cannot connect to BLE device");
}
return Status::success();
}
void Ble::disconnect() {
if (!client)
return;
if (client->isConnected())
client->disconnect();
NimBLEDevice::deleteClient(client);
client = nullptr;
}
bool Ble::isConnected() const {
return isInitialized() && client && client->isConnected();
}
Status Ble::read(const std::string& service, const std::string& characteristic,
std::string& value) {
if (!isConnected())
return Status::failure("BLE device is not connected");
NimBLERemoteService* remoteService = client->getService(service.c_str());
if (!remoteService)
return Status::failure("BLE service not found");
NimBLERemoteCharacteristic* remoteCharacteristic =
remoteService->getCharacteristic(characteristic.c_str());
if (!remoteCharacteristic)
return Status::failure("BLE characteristic not found");
if (!remoteCharacteristic->canRead())
return Status::failure("BLE characteristic is not readable");
value = static_cast<std::string>(remoteCharacteristic->readValue());
return Status::success();
}
Status Ble::write(const std::string& service, const std::string& characteristic,
const std::string& value) {
if (!isConnected())
return Status::failure("BLE device is not connected");
NimBLERemoteService* remoteService = client->getService(service.c_str());
if (!remoteService)
return Status::failure("BLE service not found");
NimBLERemoteCharacteristic* remoteCharacteristic =
remoteService->getCharacteristic(characteristic.c_str());
if (!remoteCharacteristic)
return Status::failure("BLE characteristic not found");
if (!remoteCharacteristic->canWrite() &&
!remoteCharacteristic->canWriteNoResponse())
return Status::failure("BLE characteristic is not writable");
if (!remoteCharacteristic->writeValue(
reinterpret_cast<const uint8_t*>(value.data()), value.size(),
remoteCharacteristic->canWrite()))
return Status::failure("BLE write failed");
return Status::success();
}
Status Ble::startAdvertising(const std::string* name) {
if (!isInitialized())
return Status::failure("BLE is not initialized");
NimBLEAdvertising* bleAdvertising = NimBLEDevice::getAdvertising();
if (advertising)
bleAdvertising->stop();
bleAdvertising->clearData();
if (name && !bleAdvertising->setName(*name))
return Status::failure("BLE advertising name is too long");
if (!bleAdvertising->start())
return Status::failure("cannot start BLE advertising");
advertising = true;
return Status::success();
}
void Ble::stopAdvertising() {
if (!isInitialized() || !advertising)
return;
NimBLEDevice::getAdvertising()->stop();
advertising = false;
}
} // namespace slate