From a09bcfaf06c7c9735aba2a32a0b6c806f9516c52 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Thu, 6 Aug 2026 23:03:05 -0400 Subject: [PATCH] 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. --- lua/api/core/ble.lua | 29 +++++++--- native/include/lua/providers.h | 23 ++++++-- native/src/bindings/core/ble.cpp | 90 ++++++++++++++++++++++++++------ native/test/fake_providers.h | 16 ++++-- native/test/runtime_test.cpp | 8 ++- 5 files changed, 135 insertions(+), 31 deletions(-) diff --git a/lua/api/core/ble.lua b/lua/api/core/ble.lua index dc3a424..e416389 100644 --- a/lua/api/core/ble.lua +++ b/lua/api/core/ble.lua @@ -2,10 +2,16 @@ -- Generated from native/src/bindings/core/ble.cpp. Do not edit. ----@class BleDevice ----@field name string +---@class BleObservation ---@field address string +---@field name string ---@field rssi integer +---@field payload string Raw advertisement bytes. +---@field lastSeenMs integer sys.getMillis() at last sighting. + +---@class BleFilter +---@field services? string[] Service-data UUIDs to keep. +---@field manufacturers? integer[] Manufacturer ids to keep. ---@class BleLib ble = {} @@ -23,11 +29,22 @@ function ble.deinit() end ---@return boolean function ble.isInitialized() end ----Scans for advertising devices. ----@param durationMs? integer Defaults to 3000. ----@return BleDevice[]? devices +---Starts passively observing advertisements, coalesced per device. +---@param filter? BleFilter Keep only matching adverts; nil keeps all. +---@return true? ok ---@return string? error -function ble.scan(durationMs) end +function ble.observe(filter) end + +---Stops observing and clears the snapshot. +function ble.unobserve() end + +---Whether advertisement observation is running. +---@return boolean +function ble.isObserving() end + +---The current snapshot of observed devices. +---@return BleObservation[] devices +function ble.observed() end ---Connects to a peripheral. ---@param address string diff --git a/native/include/lua/providers.h b/native/include/lua/providers.h index 297ccb5..8542b8c 100644 --- a/native/include/lua/providers.h +++ b/native/include/lua/providers.h @@ -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 services; + std::vector 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& devices) = 0; + virtual Status observe(const BleFilter& filter) = 0; + virtual void unobserve() = 0; + virtual bool isObserving() const = 0; + virtual void observed(std::vector& out) = 0; virtual Status connect(const std::string& address) = 0; virtual void disconnect() = 0; virtual bool isConnected() const = 0; diff --git a/native/src/bindings/core/ble.cpp b/native/src/bindings/core/ble.cpp index ed53af5..4349fdc 100644 --- a/native/src/bindings/core/ble.cpp +++ b/native/src/bindings/core/ble.cpp @@ -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& out) { + lua_getfield(state, index, key); + if (lua_istable(state, -1)) { + const int count = static_cast(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 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& out) { + lua_getfield(state, index, key); + if (lua_istable(state, -1)) { + const int count = static_cast(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(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 devices; + Runtime::from(state)->ble().observed(devices); lua_createtable(state, static_cast(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(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 diff --git a/native/test/fake_providers.h b/native/test/fake_providers.h index 6248392..e84cfe0 100644 --- a/native/test/fake_providers.h +++ b/native/test/fake_providers.h @@ -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& 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& 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; } diff --git a/native/test/runtime_test.cpp b/native/test/runtime_test.cpp index 7684eca..82f1e65 100644 --- a/native/test/runtime_test.cpp +++ b/native/test/runtime_test.cpp @@ -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"