feat(ble): replace blocking scan with advertisement observation

The one-shot scan kept only the top six devices by RSSI and dropped the
advertisement payload, so a distant beacon lost its slot to nearby phones
and its data was unreachable -- wrong on every axis for reading sensors
that broadcast in their adverts.

Replace it with a continuous observer: observe(filter)/observed()/
unobserve()/isObserving(). BleObservation carries the raw advertisement
bytes for Lua to parse, and BleFilter keeps only adverts matching a
service-data UUID or manufacturer id.
This commit is contained in:
2026-08-06 23:03:05 -04:00
parent eac4084f80
commit a09bcfaf06
5 changed files with 135 additions and 31 deletions
+20 -3
View File
@@ -246,10 +246,24 @@ public:
virtual Status forget() = 0;
};
struct BleDevice {
std::string name;
// One sighting of an advertising device, coalesced to its latest advert. The
// payload is the raw advertisement bytes; parsing the AD structures (and any
// beacon format inside them) is the caller's job, so a new sensor format never
// touches this seam.
struct BleObservation {
std::string address;
std::string name;
int32_t rssi;
std::string payload;
int32_t lastSeenMs;
};
// Kept adverts must carry one of these service-data UUIDs or one of these
// manufacturer ids; an empty filter keeps everything. Applied before an advert
// is buffered, so unwanted devices never occupy a slot.
struct BleFilter {
std::vector<std::string> services;
std::vector<int32_t> manufacturers;
};
class BleProvider {
@@ -258,7 +272,10 @@ public:
virtual Status init(const std::string* name) = 0;
virtual void deinit() = 0;
virtual bool isInitialized() const = 0;
virtual Status scan(int32_t durationMs, std::vector<BleDevice>& devices) = 0;
virtual Status observe(const BleFilter& filter) = 0;
virtual void unobserve() = 0;
virtual bool isObserving() const = 0;
virtual void observed(std::vector<BleObservation>& out) = 0;
virtual Status connect(const std::string& address) = 0;
virtual void disconnect() = 0;
virtual bool isConnected() const = 0;
+75 -15
View File
@@ -1,8 +1,14 @@
// @lua-module ble BleLib
// @lua-preamble ---@class BleDevice
// @lua-preamble ---@field name string
// @lua-preamble ---@class BleObservation
// @lua-preamble ---@field address string
// @lua-preamble ---@field name string
// @lua-preamble ---@field rssi integer
// @lua-preamble ---@field payload string Raw advertisement bytes.
// @lua-preamble ---@field lastSeenMs integer sys.getMillis() at last sighting.
// @lua-preamble
// @lua-preamble ---@class BleFilter
// @lua-preamble ---@field services? string[] Service-data UUIDs to keep.
// @lua-preamble ---@field manufacturers? integer[] Manufacturer ids to keep.
#include "../helpers.h"
@@ -30,21 +36,67 @@ int isInitialized(lua_State* state) {
return 1;
}
int scan(lua_State* state) {
const int32_t durationMs = optionalInt(state, 1, 3000);
luaL_argcheck(state, durationMs > 0, 1, "must be positive");
void readStringArray(lua_State* state, int index, const char* key,
std::vector<std::string>& out) {
lua_getfield(state, index, key);
if (lua_istable(state, -1)) {
const int count = static_cast<int>(lua_rawlen(state, -1));
for (int at = 1; at <= count; at++) {
lua_rawgeti(state, -1, at);
if (lua_type(state, -1) == LUA_TSTRING)
out.push_back(lua_tostring(state, -1));
lua_pop(state, 1);
}
}
lua_pop(state, 1);
}
std::vector<BleDevice> devices;
const Status status = Runtime::from(state)->ble().scan(durationMs, devices);
if (!status.ok)
return pushError(state, status.error);
void readIntArray(lua_State* state, int index, const char* key,
std::vector<int32_t>& out) {
lua_getfield(state, index, key);
if (lua_istable(state, -1)) {
const int count = static_cast<int>(lua_rawlen(state, -1));
for (int at = 1; at <= count; at++) {
lua_rawgeti(state, -1, at);
if (lua_type(state, -1) == LUA_TNUMBER)
out.push_back(static_cast<int32_t>(lua_tointeger(state, -1)));
lua_pop(state, 1);
}
}
lua_pop(state, 1);
}
int observe(lua_State* state) {
BleFilter filter;
if (lua_istable(state, 1)) {
readStringArray(state, 1, "services", filter.services);
readIntArray(state, 1, "manufacturers", filter.manufacturers);
}
return pushStatus(state, Runtime::from(state)->ble().observe(filter));
}
int unobserve(lua_State* state) {
Runtime::from(state)->ble().unobserve();
return 0;
}
int isObserving(lua_State* state) {
lua_pushboolean(state, Runtime::from(state)->ble().isObserving());
return 1;
}
int observed(lua_State* state) {
std::vector<BleObservation> devices;
Runtime::from(state)->ble().observed(devices);
lua_createtable(state, static_cast<int>(devices.size()), 0);
for (size_t at = 0; at < devices.size(); at++) {
lua_createtable(state, 0, 3);
setField(state, "name", devices[at].name);
lua_createtable(state, 0, 5);
setField(state, "address", devices[at].address);
setField(state, "name", devices[at].name);
setField(state, "rssi", devices[at].rssi);
setField(state, "payload", devices[at].payload);
setField(state, "lastSeenMs", devices[at].lastSeenMs);
lua_rawseti(state, -2, static_cast<lua_Integer>(at + 1));
}
return 1;
@@ -108,11 +160,19 @@ const luaL_Reg FUNCTIONS[] = {
// --- Whether the BLE stack is running.
// @return boolean
{"isInitialized", isInitialized},
// --- Scans for advertising devices.
// @param durationMs integer|nil Defaults to 3000.
// @return BleDevice[]|nil devices
// --- Starts passively observing advertisements, coalesced per device.
// @param filter BleFilter|nil Keep only matching adverts; nil keeps all.
// @return true|nil ok
// @return string|nil error
{"scan", scan},
{"observe", observe},
// --- Stops observing and clears the snapshot.
{"unobserve", unobserve},
// --- Whether advertisement observation is running.
// @return boolean
{"isObserving", isObserving},
// --- The current snapshot of observed devices.
// @return BleObservation[] devices
{"observed", observed},
// --- Connects to a peripheral.
// @param address string
// @return true|nil ok
+11 -5
View File
@@ -276,9 +276,10 @@ struct Wifi : WifiProvider {
};
struct Ble : BleProvider {
int32_t duration = 0;
std::string value;
bool initialized = false;
bool observing = false;
BleFilter filter;
Status init(const std::string*) override {
initialized = true;
@@ -286,12 +287,17 @@ struct Ble : BleProvider {
}
void deinit() override { initialized = false; }
bool isInitialized() const override { return initialized; }
Status scan(int32_t durationMs, std::vector<BleDevice>& devices) override {
duration = durationMs;
const BleDevice device = {"tag", "aa:bb", -60};
devices.push_back(device);
Status observe(const BleFilter& next) override {
filter = next;
observing = true;
return Status::success();
}
void unobserve() override { observing = false; }
bool isObserving() const override { return observing; }
void observed(std::vector<BleObservation>& out) override {
const BleObservation device = {"aa:bb", "tag", -60, "payload", 1234};
out.push_back(device);
}
Status connect(const std::string&) override { return Status::success(); }
void disconnect() override {}
bool isConnected() const override { return true; }
+6 -2
View File
@@ -121,10 +121,14 @@ int main() {
run(state, "assert(not ble.isInitialized())\n"
"assert(ble.init())\n"
"assert(ble.isInitialized())\n"
"assert(ble.scan()[1].address == 'aa:bb')\n"
"assert(ble.observe({ services = { '181A' } }))\n"
"assert(ble.isObserving())\n"
"assert(ble.observed()[1].address == 'aa:bb')\n"
"assert(ble.observed()[1].payload == 'payload')\n"
"assert(#ble.read('svc', 'chr') == 3)\n"
"assert(ble.write('svc', 'chr', 'x\\0y'))");
assert(bench.ble.duration == 3000);
assert(bench.ble.filter.services.size() == 1);
assert(bench.ble.filter.services[0] == "181A");
assert(bench.ble.value.size() == 3);
run(state, "fired = 0\n"