96c1a68ee0
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.
303 lines
8.9 KiB
C++
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
|