bt-sensors-plugin-sk
Version:
Bluetooth Sensors for Signalk - see https://www.npmjs.com/package/bt-sensors-plugin-sk for a list of supported classes
668 lines (584 loc) • 19.2 kB
JavaScript
const packageInfo = require("./package.json")
const {createBluetooth} = require('node-ble')
const {Variant} = require('dbus-next')
const {bluetooth, destroy} = createBluetooth()
const BTSensor = require('./BTSensor.js')
const BLACKLISTED = require('./sensor_classes/BlackListedDevice.js')
const { createChannel, createSession } = require("better-sse");
const { clearTimeout } = require('timers')
const loadClassMap = require('./classLoader.js')
class MissingSensor {
constructor(config){
this.config=config
this.addPath=BTSensor.prototype.addPath.bind(this)
this.addParameter=BTSensor.prototype.addParameter.bind(this)
this.addDefaultPath=BTSensor.prototype.addDefaultPath.bind(this)
this.addDefaultParam=BTSensor.prototype.addDefaultParam.bind(this)
this.getPath=BTSensor.prototype.getPath.bind(this)
this.getJSONSchema = BTSensor.prototype.getJSONSchema.bind(this)
this.initSchema = BTSensor.prototype.initSchema.bind(this)
this.initSchema()
var keys = Object.keys(config?.paths??{})
keys.forEach((key)=>{
this.addPath(key,
{ type: config.paths[key]?.type??'string',
title: config.paths[key].title} )
} )
keys = Object.keys(config?.params??{})
keys.forEach((key)=>{
this.addParameter(key,
{ type: config.params[key]?.type??'string',
title: config.params[key].title
})
this[key]=config.params[key]
})
this.mac_address = config.mac_address
}
hasGATT(){
return this.config.gattParams
}
initGATTConnection(){
}
getGATTDescription(){
return ""
}
getMetadata(){
return this.metadata
}
getMacAddress(){
return this.mac_address
}
getDomain(){
return BTSensor.SensorDomains.unknown
}
getDescription(){
return ""
}
getName(){
return `${this?.name??"Unknown device"} (OUT OF RANGE)`
}
getDisplayName(){
return `(${this.getName()} ${this.getMacAddress()})`
}
getRSSI(){
return NaN
}
stopListening(){}
listen(){}
isActive(){
return false
}
elapsedTimeSinceLastContact(){
return NaN
}
getSignalStrength(){
return NaN
}
}
module.exports = function (app) {
var deviceConfigs=[]
var starts=0
var plugin = {};
plugin.id = 'bt-sensors-plugin-sk';
plugin.name = 'BT Sensors plugin';
plugin.description = 'Plugin to communicate with and update paths to BLE Sensors in Signalk';
app.debug(`Loading plugin ${packageInfo.version}`)
plugin.schema = {
type: "object",
htmlDescription:
`<h2><a href="https://github.com/naugehyde/bt-sensors-plugin-sk/tree/1.2.0-beta#configuration" target="_blank">Plugin Documenation</a><p/><a href="https://github.com/naugehyde/bt-sensors-plugin-sk/issues/new/choose" target="_blank">Report an issue</a><p/><a href="https://discord.com/channels/1170433917761892493/1295425963466952725" target="_blank">Discord thread</a></h2>
`,
required:["adapter","discoveryTimeout", "discoveryInterval"],
properties: {
adapter: {title: "Bluetooth adapter",
type: "string", default: "hci0"},
transport: {title: "Transport ",
type: "string", enum: ["auto","le","bredr"], default: "le", enumNames:["Auto", "LE-Bluetooth Low Energy", "BR/EDR Bluetooth basic rate/enhanced data rate"]},
duplicateData: {title: "Set scanner to report duplicate data", type: "boolean", default: false, },
discoveryTimeout: {title: "Default device discovery timeout (in seconds)",
type: "integer", default: 30,
minimum: 10,
maximum: 3600
},
discoveryInterval: {title: "Scan for new devices interval (in seconds-- 0 for no new device scanning)",
type: "integer",
default: 10,
minimum: 0
},
}
}
plugin.started=false
var discoveryIntervalID, progressID, progressTimeoutID, deviceHealthID
var adapter
const channel = createChannel()
const classMap = loadClassMap(app)
const sensorMap=new Map()
plugin.start = async function (options, restartPlugin) {
plugin.started=true
var adapterID=options.adapter
var foundConfiguredDevices=0
plugin.registerWithRouter = function(router) {
router.post('/updateSensorData', async (req, res) => {
app.debug(req.body)
const i = deviceConfigs.findIndex((p)=>p.mac_address==req.body.mac_address)
if (i<0){
if (!options.peripherals){
if (!options.hasOwnProperty("peripherals"))
options.peripherals=[]
options.peripherals=[]
}
options.peripherals.push(req.body)
} else {
options.peripherals[i] = req.body
}
app.savePluginOptions(
options, async () => {
app.debug('Plugin options saved')
res.status(200).json({message: "Sensor updated"})
const sensor = sensorMap.get(req.body.mac_address)
if (sensor) {
removeSensorFromList(sensor)
if (sensor.isActive())
await sensor.stopListening()
initConfiguredDevice(req.body)
}
}
)
});
router.post('/removeSensorData', async (req, res) => {
app.debug(req.body)
const sensor = sensorMap.get(req.body.mac_address)
if (!sensor) {
res.status(404).json({message: "Sensor not found"})
return
}
const i = deviceConfigs.findIndex((p)=>p.mac_address==req.body.mac_address)
if (i>=0){
deviceConfigs.splice(i,1)
}
if (sensor.isActive())
await sensor.stopListening()
if (sensorMap.has(req.body.mac_address))
sensorMap.delete(req.body.mac_address)
app.savePluginOptions(
options, () => {
app.debug('Plugin options saved')
res.status(200).json({message: "Sensor updated"})
channel.broadcast({},"resetSensors")
}
)
});
router.post('/updateBaseData', async (req, res) => {
app.debug(req.body)
Object.assign(options,req.body)
app.savePluginOptions(
options, () => {
app.debug('Plugin options saved')
res.status(200).json({message: "Plugin updated"})
channel.broadcast({},"pluginRestarted")
restartPlugin(options)
}
)
});
router.get('/getDomains', (req, res) => {
res.status(200).json(
BTSensor.SensorDomains
);
})
router.get('/getBaseData', (req, res) => {
res.status(200).json(
{
schema: plugin.schema,
data: {
adapter: options.adapter,
transport: options.transport,
duplicateData: options.duplicateData,
discoveryTimeout: options.discoveryTimeout,
discoveryInterval: options.discoveryInterval
}
}
);
})
router.get('/getSensors', (req, res) => {
app.debug("Sending sensors")
const t = sensorsToJSON()
res.status(200).json(t)
});
router.get('/getProgress', (req, res) => {
app.debug("Sending progress")
const json = {"progress":foundConfiguredDevices/deviceConfigs.length, "maxTimeout": 1,
"deviceCount":foundConfiguredDevices,
"totalDevices": deviceConfigs.length}
res.status(200).json(json)
});
router.get('/getPluginState', async (req, res) => {
res.status(200).json({
"connectionId": Date.now(),
"state":(plugin.started?"started":"stopped")
})
});
router.get("/sse", async (req, res) => {
const session = await createSession(req,res)
channel.register(session)
req.on("close", ()=>{
app.debug("deregistering session")
channel.deregister(session)
})
});
};
function sensorsToJSON(){
return Array.from(
Array.from(sensorMap.values()).filter((s)=>!(s instanceof BLACKLISTED) ).map(
sensorToJSON
))
}
function getSensorInfo(sensor){
return { mac: sensor.getMacAddress(),
name: sensor.getName(),
domain: sensor.getDomain().name,
RSSI: sensor.getRSSI(),
signalStrength: sensor.getSignalStrength(),
lastContactDelta: sensor.elapsedTimeSinceLastContact()
}
}
function sensorToJSON(sensor){
const config = getDeviceConfig(sensor.getMacAddress())
const schema = sensor.getJSONSchema()
schema.htmlDescription = sensor.getDescription()
return {
info: getSensorInfo(sensor),
schema: schema,
config: config?config:{}
}
}
async function startScanner(options) {
const transport = options?.transport??"le"
const duplicateData = options?.duplicateData??false
app.debug("Starting scan...");
//Use adapter.helper directly to get around Adapter::startDiscovery()
//filter options which can cause issues with Device::Connect()
//turning off Discovery
//try {await adapter.startDiscovery()}
try{
if (transport) {
app.debug(`Setting Bluetooth transport option to ${transport}. DuplicateData to ${duplicateData}`)
await adapter.helper.callMethod('SetDiscoveryFilter', {
Transport: new Variant('s', transport),
DuplicateData: new Variant('b', duplicateData)
})
}
await adapter.helper.callMethod('StartDiscovery')
}
catch (error){
app.debug(error)
}
}
function updateSensor(sensor){
channel.broadcast(getSensorInfo(sensor), "sensorchanged")
}
function removeSensorFromList(sensor){
sensorMap.delete(sensor.getMacAddress())
channel.broadcast({mac:sensor.getMacAddress()},"removesensor")
}
function addSensorToList(sensor){
app.debug(`adding sensor to list ${sensor.getMacAddress()}`)
sensorMap.set(sensor.getMacAddress(),sensor)
channel.broadcast(sensorToJSON(sensor),"newsensor");
}
function deviceNameAndAddress(config){
return `${config?.name??""}${config.name?" at ":""}${config.mac_address}`
}
function createSensor(adapter, config) {
return new Promise( ( resolve, reject )=>{
var s
const startNumber=starts
//app.debug(`Waiting on ${deviceNameAndAddress(config)}`)
adapter.waitDevice(config.mac_address,(config?.discoveryTimeout??30)*1000)
.then(async (device)=> {
if (startNumber != starts ) {
return
}
s = await instantiateSensor(device,config)
if (!s)
reject("Unable to create sensor")
else
if (s instanceof BLACKLISTED)
reject ( `Device is blacklisted (${s.reasonForBlacklisting()}).`)
else{
//app.debug(`Adding sensor to list ${config.mac_address}`)
addSensorToList(s)
s._lastRSSI=-1*Infinity
s.on("RSSI",(()=>{
if (Date.now()-s._lastRSSI > 30000) { //only update RSSI on client every 30 seconds
//app.debug(`Updating ${s.getMacAddress()} RSSI after ${Date.now()-s._lastRSSI} ms`)
s._lastRSSI=Date.now()
updateSensor(s)
}
}))
resolve(s)
}
})
.catch((e)=>{
if (s)
s.stopListening()
if (startNumber == starts ) {
app.debug(`Unable to communicate with device ${deviceNameAndAddress(config)} Reason: ${e?.message??e}`)
app.debug(e)
reject( e?.message??e )
}
})})
}
function getDeviceConfig(mac){
return deviceConfigs.find((p)=>p.mac_address==mac)
}
async function getClassFor(device,config){
if (config.params?.sensorClass){
const c = classMap.get(config.params.sensorClass)
if (c==null)
throw new Error ("Cannot find class "+config.params.sensorClass)
return c
}
for (var [clsName, cls] of classMap) {
if (clsName.startsWith("_")) continue
const c = await cls.identify(device)
if (c) {
if (Object.hasOwn(config, "params")) {
config.params.sensorClass=clsName
}
return c
}
}
return classMap.get('UNKNOWN')
}
async function instantiateSensor(device,config){
try{
const c = await getClassFor(device,config)
c.debug=app.debug
const sensor = new c(device, config?.params, config?.gattParams)
sensor.debug=app.debug
sensor.app=app
sensor._adapter=adapter //HACK!
await sensor.init()
return sensor
}
catch(error){
const msg = `Unable to instantiate ${await BTSensor.getDeviceProp(device,"Address")}: ${error.message} `
app.debug(msg)
app.debug(error)
app.setPluginError(msg)
return null
}
}
function initConfiguredDevice(deviceConfig){
const startNumber=starts
app.setPluginStatus(`Initializing ${deviceNameAndAddress(deviceConfig)}`);
if (!deviceConfig.discoveryTimeout)
deviceConfig.discoveryTimeout = options.discoveryTimeout
createSensor(adapter, deviceConfig).then((sensor)=>{
if (startNumber != starts ) {
return
}
if (deviceConfig.active) {
app.setPluginStatus(`Listening to ${++foundConfiguredDevices} sensors.`);
sensor.activate(deviceConfig, plugin)
}
})
.catch((error)=>
{
if (deviceConfig.unconfigured) return
if (startNumber != starts ) {
return
}
const msg =`Sensor at ${deviceConfig.mac_address} unavailable. Reason: ${error}`
app.debug(msg)
app.debug(error)
if (deviceConfig.active)
app.setPluginError(msg)
const sensor=new MissingSensor(deviceConfig)
++foundConfiguredDevices
addSensorToList(sensor) //add sensor to list with known options
})
}
function findDevices (discoveryTimeout) {
const startNumber = starts
app.setPluginStatus("Scanning for new Bluetooth devices...");
adapter.devices().then( (macs)=>{
if (startNumber != starts ) {
return
}
for (const mac of macs) {
var deviceConfig = getDeviceConfig(mac)
const sensor = sensorMap.get(mac)
if (sensor) {
if (sensor instanceof MissingSensor){
removeSensorFromList(sensor)
initConfiguredDevice(deviceConfig)
}
} else {
if (!deviceConfig) {
deviceConfig = {mac_address: mac,
discoveryTimeout: discoveryTimeout,
active: false, unconfigured: true}
initConfiguredDevice(deviceConfig)
}
}
}
})
}
function findDeviceLoop(discoveryTimeout, discoveryInterval, immediate=true ){
if (immediate)
findDevices(discoveryTimeout)
discoveryIntervalID =
setInterval( findDevices, discoveryInterval*1000, discoveryTimeout)
}
channel.broadcast({state:"started"},"pluginstate")
if (!adapterID || adapterID=="")
adapterID = "hci0"
//Check if Adapter has changed since last start()
if (adapter) {
if (adapter.adapter!=adapterID) {
adapter.helper._propsProxy.removeAllListeners()
adapter=null
}
}
//Connect to adapter
if (!adapter){
app.debug(`Connecting to bluetooth adapter ${adapterID}`);
adapter = await bluetooth.getAdapter(adapterID)
//Set up DBUS listener to monitor Powered status of current adapter
await adapter.helper._prepare()
adapter.helper._propsProxy.on('PropertiesChanged', async (iface,changedProps,invalidated) => {
app.debug(changedProps)
if (Object.hasOwn(changedProps,"Powered")){
if (changedProps.Powered.value==false) {
if (plugin.started){ //only call stop() if plugin is started
app.setPluginStatus(`Bluetooth Adapter ${adapterID} turned off. Plugin disabled.`)
await plugin.stop()
}
} else {
await restartPlugin(options)
}
}
})
if (!await adapter.isPowered()) {
app.debug(`Bluetooth Adapter ${adapterID} not powered on.`)
app.setPluginError(`Bluetooth Adapter ${adapterID} not powered on.`)
await plugin.stop()
return
}
}
sensorMap.clear()
if (channel){
channel.broadcast({state:"started"},"pluginstate")
}
deviceConfigs=options?.peripherals??[]
if (plugin.stopped) {
plugin.stopped=false
}
const activeAdapters = await bluetooth.activeAdapters()
plugin.schema.properties.adapter.enum=[]
plugin.schema.properties.adapter.enumNames=[]
for (a of activeAdapters){
plugin.schema.properties.adapter.enum.push(a.adapter)
plugin.schema.properties.adapter.enumNames.push(`${a.adapter} @ ${ await a.getAddress()} (${await a.getName()})`)
}
await startScanner(options)
if (starts>0){
app.debug(`Plugin ${packageInfo.version} restarting...`);
} else {
app.debug(`Plugin ${packageInfo.version} started` )
}
starts++
if (!await adapter.isDiscovering())
try{
await startScanner(options)
} catch (e){
app.debug(`Error starting scan: ${e.message}`)
}
if (!(deviceConfigs===undefined)){
const maxTimeout=Math.max(...deviceConfigs.map((dc)=>dc?.discoveryTimeout??options.discoveryTimeout))
var progress = 0
if (progressID==null)
progressID = setInterval(()=>{
channel.broadcast({"progress":++progress, "maxTimeout": maxTimeout, "deviceCount":foundConfiguredDevices, "totalDevices": deviceConfigs.length},"progress")
if ( foundConfiguredDevices==deviceConfigs.length){
progressID,progressTimeoutID = null
clearTimeout(progressTimeoutID)
clearInterval(progressID)
progressID = null
}
},1000);
if (progressTimeoutID==null)
progressTimeoutID = setTimeout(()=> {
if (progressID) {
clearInterval(progressID);
progressID=null
channel.broadcast({"progress":maxTimeout, "maxTimeout": maxTimeout, "deviceCount":foundConfiguredDevices, "totalDevices": deviceConfigs.length},"progress")
}
}, (maxTimeout+1)*1000);
for (const config of deviceConfigs) {
initConfiguredDevice(config)
}
}
const minTimeout=Math.min(...deviceConfigs.map((dc)=>dc?.discoveryTimeout??options.discoveryTimeout))
const intervalTimeout = ((minTimeout==Infinity)?(options?.discoveryTimeout??plugin.schema.properties.discoveryTimeout.default):minTimeout)*1000
deviceHealthID = setInterval( ()=> {
sensorMap.forEach((sensor)=>{
const config = getDeviceConfig(sensor.getMacAddress())
const dt = config?.discoveryTimeout??options.discoveryTimeout
const lc=sensor.elapsedTimeSinceLastContact()
if (lc > dt) {
//app.debug(`${sensor.getMacAddress()} not heard from in ${lc} seconds`)
channel.broadcast(getSensorInfo(sensor), "sensorchanged")
}
})
}, intervalTimeout)
if (!options.hasOwnProperty("discoveryInterval" )) //no config -- first run
options.discoveryInterval = plugin.schema.properties.discoveryInterval.default
if (options.discoveryInterval && !discoveryIntervalID)
findDeviceLoop(options?.discoveryTimeout??plugin.schema.properties.discoveryTimeout.default,
options.discoveryInterval)
}
plugin.stop = async function () {
app.debug("Stopping plugin")
plugin.stopped=true
plugin.started=false
channel.broadcast({state:"stopped"},"pluginstate")
if (discoveryIntervalID) {
clearInterval(discoveryIntervalID)
discoveryIntervalID=null
}
if (progressID) {
clearInterval(progressID)
progressID=null
}
if (progressTimeoutID) {
clearTimeout(progressTimeoutID)
progressTimeoutID=null
}
if ((sensorMap)){
for await (const sensorEntry of sensorMap.entries()) {
try{
await sensorEntry[1].stopListening()
app.debug(`No longer listening to ${sensorEntry[0]}`)
}
catch (e){
app.debug(`Error stopping listening to ${sensorEntry[0]}: ${e.message}`)
}
}
}
sensorMap.clear()
if (adapter) {
adapter.helper._propsProxy.removeAllListeners()
if( await adapter.isDiscovering())
try{
await adapter.stopDiscovery()
app.debug('Scan stopped')
} catch (e){
app.debug(`Error stopping scan: ${e.message}`)
}
}
app.debug('BT Sensors plugin stopped')
}
return plugin;
}