Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions BTSensor.js
Original file line number Diff line number Diff line change
Expand Up @@ -692,16 +692,17 @@ class BTSensor extends EventEmitter {
}
}

if (this._adapter?.helper?.callMethod) {
try {

/* CAUTION: HACK AHEAD
/* CAUTION: HACK AHEAD

Bluez for some cockamamie reason (It's 2025 for chrissake.
Bluez for some cockamamie reason (It's 2025 for chrissake.
BLUETOOTH ISN'T JUST FOR DESKTOPS ANYMORE, BLUEZ DEVS!)
SUSPENDS scanning while connected to a device.

The next line of code gives the scanner a kick in the arse,
starting it up again so, I dunno, another device might be able
The next line of code gives the scanner a kick in the arse,
starting it up again so, I dunno, another device might be able
to connect and sensor classes could maybe get beacon updates.

You know, the little things.
Expand All @@ -711,11 +712,12 @@ class BTSensor extends EventEmitter {
//await this._adapter.helper.callMethod('SetDiscoveryFilter', {
// Transport: new Variant('s', this._adapter?._transport??"le")
//})
await this._adapter.helper.callMethod('StartDiscovery')
await this._adapter.helper.callMethod('StartDiscovery')

} catch (e){
//probably ignorable error. probably.
console.log(e)
}
}
/* END HACK*/

Expand Down
170 changes: 170 additions & 0 deletions BleApiDevice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
const EventEmitter = require('node:events')

// Mimics dbus-next Variant so BTSensor.valueIfVariant() unwraps correctly
// and sensor classes (e.g. VictronSensor) can access .value
class Variant {
constructor(signature, value) {
this.signature = signature
this.value = value
}
}

class BleApiDeviceHelper extends EventEmitter {
constructor(device) {
super()
this._device = device
}

_prepare() {
// no-op: no D-Bus to prepare
}

async callMethod(method) {
if (method === 'Connect') {
await this._device._connectGATT()
} else if (method === 'Disconnect') {
await this._device._disconnectGATT()
}
}

removeListeners() {
this.removeAllListeners()
}
}

class BleApiDevice extends EventEmitter {
constructor(mac, app, pluginId) {
super()
this._mac = mac.toUpperCase()
this._app = app
this._pluginId = pluginId
this._name = null
this._rssi = NaN
this._manufacturerData = null
this._serviceData = null
this._uuids = []
this._connectable = false
this._gattConnection = null
this._gattServer = null

this.helper = new BleApiDeviceHelper(this)

// _propsProxy mimics the D-Bus org.freedesktop.DBus.Properties interface.
// GetAll() returns {propName: Variant(value)} — BTSensor.getDeviceProps()
// unwraps via rawProps[propKey].value
this._propsProxy = {
GetAll: () => {
return {
Address: new Variant('s', this._mac),
Name: new Variant('s', this._name),
RSSI: new Variant('n', this._rssi),
ManufacturerData: new Variant('a{qv}', this._manufacturerData),
ServiceData: new Variant('a{sv}', this._serviceData),
UUIDs: new Variant('as', this._uuids),
Paired: new Variant('b', false),
Connected: new Variant('b', this._gattConnection?.connected ?? false)
}
},
Get: (iface, prop) => {
const all = this._propsProxy.GetAll()
return all[prop] ?? null
}
}
}

updateFromAdvertisement(adv) {
if (adv.name) this._name = adv.name
this._rssi = adv.rssi
if (adv.connectable !== undefined) this._connectable = adv.connectable
if (adv.serviceUuids) this._uuids = adv.serviceUuids

// Build props matching D-Bus PropertiesChanged format:
// RSSI: Variant('n', number)
// ManufacturerData: Variant('a{qv}', {companyId: Variant('ay', Buffer)})
// ServiceData: Variant('a{sv}', {uuid: Variant('ay', Buffer)})
// This ensures BTSensor.valueIfVariant() and sensor classes work identically
const props = {}
props.RSSI = new Variant('n', adv.rssi)

if (adv.manufacturerData) {
const md = {}
for (const [companyId, hexString] of Object.entries(adv.manufacturerData)) {
const numericId = parseInt(companyId)
md[numericId] = new Variant('ay', Buffer.from(hexString, 'hex'))
}
this._manufacturerData = md
props.ManufacturerData = new Variant('a{qv}', md)
}

if (adv.serviceData) {
const sd = {}
for (const [uuid, hexString] of Object.entries(adv.serviceData)) {
sd[uuid] = new Variant('ay', Buffer.from(hexString, 'hex'))
}
this._serviceData = sd
props.ServiceData = new Variant('a{sv}', sd)
}

this.helper.emit('PropertiesChanged', props)
}

async _connectGATT() {
if (this._gattConnection && this._gattConnection.connected) return

this._gattConnection = await this._app.bleApi.connectGATT(
this._mac,
this._pluginId
)

this._gattConnection.onDisconnect(() => {
this.helper.emit('PropertiesChanged', {
Connected: new Variant('b', false)
})
this.emit('disconnect', { connected: false })
})

this.helper.emit('PropertiesChanged', {
Connected: new Variant('b', true)
})
this.emit('connect', { connected: true })
}

async _disconnectGATT() {
if (this._gattConnection) {
await this._gattConnection.disconnect()
this._gattConnection = null
}
}

async gatt() {
if (!this._gattServer) {
const BleApiGattServer = require('./BleApiGattServer')
this._gattServer = new BleApiGattServer(this)
}
return this._gattServer
}

async connect() {
await this._connectGATT()
}

async disconnect() {
await this._disconnectGATT()
}

isConnected() {
return this._gattConnection?.connected ?? false
}

removeAllListeners(event) {
super.removeAllListeners(event)
return this
}

stopListening() {
this.removeAllListeners()
this.helper.removeListeners()
}
}

module.exports = BleApiDevice
77 changes: 77 additions & 0 deletions BleApiDiscovery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const BleApiDevice = require('./BleApiDevice')

class BleApiDiscovery {
constructor(app, pluginId) {
this._app = app
this._pluginId = pluginId
this._devices = new Map() // MAC -> BleApiDevice
this._unsubscribe = null
this._waiters = [] // {mac, resolve, reject, timeoutId}
}

start() {
this._unsubscribe = this._app.bleApi.onAdvertisement(
this._pluginId,
(adv) => this._onAdvertisement(adv)
)
}

stop() {
if (this._unsubscribe) {
this._unsubscribe()
this._unsubscribe = null
}
for (const waiter of this._waiters) {
clearTimeout(waiter.timeoutId)
waiter.reject(new Error('BLE API discovery stopped'))
}
this._waiters = []
}

_onAdvertisement(adv) {
const mac = adv.mac.toUpperCase()

let device = this._devices.get(mac)
if (!device) {
device = new BleApiDevice(mac, this._app, this._pluginId)
this._devices.set(mac, device)
}
device.updateFromAdvertisement(adv)

// Resolve any pending waitDevice() calls for this MAC
const pending = this._waiters.filter((w) => w.mac === mac)
for (const waiter of pending) {
clearTimeout(waiter.timeoutId)
waiter.resolve(device)
}
this._waiters = this._waiters.filter((w) => w.mac !== mac)
}

devices() {
return Array.from(this._devices.keys())
}

waitDevice(mac, timeout) {
const upperMac = mac.toUpperCase()
const existing = this._devices.get(upperMac)
if (existing) {
return Promise.resolve(existing)
}

return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this._waiters = this._waiters.filter((w) => w !== waiter)
reject(
new Error(
`Device ${mac} not found within ${timeout}ms via BLE API`
)
)
}, timeout)

const waiter = { mac: upperMac, resolve, reject, timeoutId }
this._waiters.push(waiter)
})
}
}

module.exports = BleApiDiscovery
Loading