UNPKG

bt-sensors-plugin-sk

Version:

Bluetooth Sensors for Signalk - see https://www.npmjs.com/package/bt-sensors-plugin-sk#supported-sensors for a list of supported sensors

589 lines (483 loc) 16.7 kB
import Form from '@rjsf/core' ; import validator from '@rjsf/validator-ajv8'; import ReactHtmlParser from 'react-html-parser'; import React from 'react' import {useEffect, useState} from 'react' import {Button, Grid, Snackbar } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { BluetoothConnected, SignalCellular1Bar, SignalCellular2Bar, SignalCellular3Bar, SignalCellular4Bar, SignalCellular0Bar, SignalCellularConnectedNoInternet0Bar } from '@material-ui/icons'; import BatteryGauge from 'react-battery-gauge'; const log = (type) => console.log.bind(console, type); import ListGroup from 'react-bootstrap/ListGroup'; import Tabs from 'react-bootstrap/Tabs'; import Tab from 'react-bootstrap/Tab'; import { ListGroupItem } from 'react-bootstrap'; import ProgressBar from 'react-bootstrap/ProgressBar'; export function BTConfig (props) { const _uiSchema= { "ui:options": {label: false}, "paths":{ enableMarkdownInDescription:true }, 'title': { 'ui:widget': 'hidden' }, } const baseUISchema = { "ui:field": "LayoutGridField", "ui:layoutGrid": { "ui:row":[ { "ui:row": { "className": "row", "children": [ { "ui:columns": { "className": "col-xs-4", "children": [ "adapter", "transport", "duplicateData", "discoveryTimeout", "discoveryInterval" ] } } ] } } ] } } const useStyles = makeStyles((theme) => ({ root: { '& > *': { margin: theme.spacing(1), }, }, })); const [baseSchema, setBaseSchema] = useState({}) const [baseData, setBaseData] = useState({}) const [schema, setSchema] = useState({}) const [ uiSchema, setUISchema] = useState(_uiSchema ) const [sensorData, setSensorData] = useState() const [sensorClassChanged, setSensorClassChanged] = useState(false) const [enableSchema, setEnableSchema] = useState(true) const [sensorMap, setSensorMap ] = useState(new Map()) const [progress, setProgress ] = useState({ "progress":0, "maxTimeout": 100, "deviceCount":0, "totalDevices":0}) const [pluginState, setPluginState ] = useState("unknown") const [error, setError ] = useState() const [snackbarOpen, setSnackbarOpen] = useState(false) const [snackbarMessage, setSnackbarMessage] = useState("") const classes = useStyles(); function sendJSONData(cmd, data){ const headers = new Headers(); headers.append("Content-Type", "application/json"); return fetch(`/plugins/bt-sensors-plugin-sk/${cmd}`, { credentials: 'include', method: 'POST', body: JSON.stringify(data), headers:headers }) } async function fetchJSONData(path, data = {}) { let result; try { // Convert data object to query string const query = Object.keys(data).length ? '?' + new URLSearchParams(data).toString() : ''; result = await fetch(`/plugins/bt-sensors-plugin-sk/${path}${query}`, { credentials: 'include', method: 'GET' }); } catch (e) { result = { status: 500, statusText: e.toString() }; } return result; } async function getSensors(){ const response = await fetchJSONData("getSensors") if (response.status!=200){ throw new Error(`Unable get sensor data: ${response.statusText} (${response.status}) `) } const json = await response.json() return json } async function getSensorInfo(mac, sensorClass){ const response = await fetchJSONData("getSensorInfo",{mac_address: mac, class: sensorClass}) if (response.status!=200){ debugger throw new Error(`Unable get sensor info: ${response.statusText} (${response.status}) `) } const json = await response.json() return json } async function getBaseData(){ const response = await fetchJSONData("getBaseData") if (response.status!=200){ throw new Error(`Unable to get base data: ${response.statusText} (${response.status}) `) } const json = await response.json() json.schema.htmlDescription=<div>{ReactHtmlParser(json.schema.htmlDescription)}<p></p></div> return json } async function getProgress(){ const response = await fetchJSONData("getProgress") if (response.status!=200){ throw new Error(`Unable to get progress: ${response.statusText} (${response.status}) `) } const json = await response.json() return json } function updateSensorData(data){ sendJSONData("updateSensorData", data).then((response)=>{ if (response.status != 200) { throw new Error(response.statusText) } setSensorMap((sm)=>{sm.delete(data.mac_address); return new Map(sm) }) setSchema( {} ) }) } function undoChanges(mac) { sensorMap.get(mac)._changesMade = false sensorMap.get(mac).config = JSON.parse(JSON.stringify(sensorMap.get(mac).configCopy)) setSensorData( sensorMap.get(mac).config ) } function removeSensorData(mac){ try{ sendJSONData("removeSensorData", {mac_address:mac} ).then((response)=>{ if (response.status != 200) { throw new Error(response.statusText) } }) setSensorMap((sm)=>{sm.delete(mac); return new Map(sm) }) setSchema( {} ) } catch {(e)=> setError( `Couldn't remove ${mac}: ${e}`) } } function updateBaseData(data){ setSensorMap(new Map()) //setSensorList({}) sendJSONData("updateBaseData", data).then( (response )=>{ if (response.status != 200) { setError(`Unable to update base data: ${response.statusText} (${response.status})`) } }) } useEffect(()=>{ let eventSource=null fetchJSONData("getPluginState").then( async (response)=> { function newSensorEvent(event){ let json = JSON.parse(event.data) console.log(`New sensor: ${json.info.mac}`) setSensorMap( (_sm)=> { //if (!_sm.has(json.info.mac)) _sm.set(json.info.mac, json) return new Map(_sm) } ) } function sensorChangedEvent(event){ console.log("sensorchanged") const json = JSON.parse(event.data) console.log(json) setSensorMap( (_sm) => { const sensor = _sm.get(json.mac) if (sensor) Object.assign(sensor.info, json ) return new Map(_sm) }) } if (response.status==404) { setPluginState("unknown") throw new Error("unable to get plugin state") } const json = await response.json() eventSource = new EventSource("/plugins/bt-sensors-plugin-sk/sse", { withCredentials: true }) eventSource.addEventListener("newsensor", (event) => { newSensorEvent(event) }); eventSource.addEventListener("sensorchanged", (event) => { sensorChangedEvent(event) }); eventSource.addEventListener("progress", (event) => { const json = JSON.parse(event.data) setProgress(json) }); eventSource.addEventListener("pluginstate", (event) => { const json = JSON.parse(event.data) setPluginState(json.state) }); setPluginState(json.state); (async ()=>{ const sensors = await getSensors() setSensorMap ( new Map(sensors.map((sensor)=>[sensor.info.mac,sensor])) ) })() }) .catch( (e) => { setError(e.message) } ) return () => { console.log("Closing connection to SSE") eventSource.close() }; },[]) useEffect(()=>{ if (!sensorClassChanged) return if (!(sensorData && sensorMap) ) return const _sensor = sensorMap.get(sensorData.mac_address) if (_sensor && schema && sensorData && Object.hasOwn(sensorData,"params" )){ if (_sensor.info.class == "UNKNOWN" && sensorData.params.sensorClass && sensorData.params.sensorClass != "UNKNOWN") { setEnableSchema(false) setSnackbarMessage(`Please wait. Fetching schema for ${sensorData.params.sensorClass}...`) getSensorInfo(sensorData.mac_address, sensorData.params.sensorClass).then((json)=>{ setSchema(json.schema) }).catch((e)=>{ alert(e.message) }) .finally(()=>{ setSnackbarMessage("") setEnableSchema(true) setSensorClassChanged(false) }) } } },[sensorClassChanged]) useEffect(()=>{ if (snackbarMessage=="") setSnackbarOpen(false) else { setSnackbarOpen(true) } },[snackbarMessage]) useEffect(()=>{ if (pluginState=="started") { getBaseData().then((json) => { setBaseSchema(json.schema); setBaseData(json.data); }).catch((e)=>{ setError(e.message) }) getProgress().then((json)=>{ setProgress(json) }).catch((e)=>{ setError(e.message) }) } else{ setBaseSchema({}) setBaseData({}) } },[pluginState]) function confirmDelete(mac){ const sensor = sensorMap.get(mac) const result = !hasConfig(sensor) || window.confirm(`Delete configuration for ${sensor.info.name}?`) if (result) removeSensorData(mac) } function signalStrengthIcon(sensor){ if (sensor.info.connected) return <BluetoothConnected/> if (sensor.info.lastContactDelta ==null || sensor.info.lastContactDelta > sensor.config.discoveryTimeout) return <SignalCellularConnectedNoInternet0Bar/> if (sensor.info.signalStrength > 80) return <SignalCellular4Bar/> if (sensor.info.signalStrength > 60) return <SignalCellular3Bar/> if (sensor.info.signalStrength > 40) return <SignalCellular2Bar/> if (sensor.info.signalStrength > 20) return <SignalCellular1Bar/> return <SignalCellular0Bar/> } function batteryIcon(sensor){ const batteryStrength = sensor.info.batteryStrength if (batteryStrength===undefined) return "" else return <BatteryGauge size={22} orientation='vertical' customization={{ batteryBody: { fill: 'silver', strokeColor: 'silver', strokeWidth: 2, }, batteryCap: { fill: 'silver', strokeColor: 'silver', cornerRadius: 3, strokeWidth: 0, capToBodyRatio: 0.4, }, readingText: { lightContrastColor: 'purple', darkContrastColor: 'yellow', lowBatteryColor: 'red', fontFamily: 'Arial', fontSize: 16, }, }} value={batteryStrength*100}/> } function hasConfig(sensor){ return Object.keys(sensor.configCopy).length>0; } function batteryStrength(sensor){ const bs = sensor.info?.batteryStrength if (bs) return `BATT: ${bs*100}%` else return "" } function createListGroupItem(sensor){ const config = hasConfig(sensor) return <ListGroupItem action onClick={()=>{ sensor.config.mac_address=sensor.info.mac setSchema(sensor.schema) setSensorData(sensor.config) } }> <div class="d-flex justify-content-between"> <div class="d-flex" style={config?{fontWeight:"normal"}:{fontStyle:"italic"}}> {`${sensor._changesMade?"*":""}${sensor.info.name} MAC: ${sensor.info.mac} RSSI: ${ifNullNaN(sensor.info.RSSI)} ${batteryStrength(sensor)}` } {batteryIcon(sensor)} </div> <div class="d-flex "> { `${sensor.info.state} ${sensor.info.error?" (ERROR)": "" }` } { signalStrengthIcon(sensor) } </div> </div> </ListGroupItem> } function devicesInDomain(domain){ return Array.from(sensorMap.entries()).filter((entry)=>entry[1].info.domain===domain) } function ifNullNaN(value){ return value==null? NaN : value } function getTabs(){ const sensorDomains = [... (new Set([...sensorMap.entries()].map((entry)=>{ return entry[1].info.domain})))].sort() const cd = Array.from(sensorMap.entries()).filter((entry)=>hasConfig(entry[1])) let sensorList={} sensorList["_configured"]= cd.length==0? "Select a device from its domain tab (Electrical etc.) and configure it.": cd.map((entry) => { return createListGroupItem(sensorMap.get(entry[0])) }) sensorDomains.forEach((d)=>{ sensorList[d]=devicesInDomain(d).map((entry) => { return createListGroupItem(sensorMap.get(entry[0])) }) }) return Object.keys(sensorList).map((domain)=> {return getTab(domain, sensorList[domain])}) } function getTab(key, sensorList){ let title = key.slice(key.charAt(0)==="_"?1:0) return <Tab eventKey={key} title={`${title.charAt(0).toUpperCase()}${title.slice(1)}${typeof sensorList=='string'?'':' ('+sensorList.length+')'}` } > <ListGroup style={{ maxHeight: '300px', overflowY: 'auto' }}> {sensorList} </ListGroup> </Tab> } function openInNewTab (url) { window.open(url, "_blank", "noreferrer"); } if (pluginState=="stopped" || pluginState=="unknown") return (<h3>Enable plugin to see configuration</h3>) else return( <div> <Snackbar anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }} onClose={() => setSnackbarOpen(false)} open={snackbarOpen} message={snackbarMessage} key={"snackbar"} /> <div className={classes.root}> <Button variant="contained" onClick={()=>{openInNewTab("https://github.com/naugehyde/bt-sensors-plugin-sk/tree/1.2.0-beta#configuration")}}>Documentation</Button> <Button variant="contained" onClick={()=>{openInNewTab("https://github.com/naugehyde/bt-sensors-plugin-sk/issues/new/choose")}}>Report Issue</Button> <Button variant="contained" onClick={()=>{openInNewTab("https://discord.com/channels/1170433917761892493/1295425963466952725" )}}>Discord Thread</Button> <p></p> <p></p> </div> <hr style={{"width":"100%","height":"1px","color":"gray","background-color":"gray","text-align":"left","margin-left":0}}></hr> {error?<h2 style={{color: 'red'}}>{error}</h2>:""} <Form schema={baseSchema} validator={validator} uiSchema={baseUISchema} onChange={(e) => setBaseData(e.formData)} onSubmit={ ({ formData }, e) => { updateBaseData(formData); setSchema({}) } } onError={log('errors')} formData={baseData} /> <hr style={{"width":"100%","height":"1px","color":"gray","background-color":"gray","text-align":"left","margin-left":0}}></hr> <p></p> <p></p> { (progress.deviceCount<progress.totalDevices)? <ProgressBar max={progress.maxTimeout} now={progress.progress} />:"" } <p></p> <Tabs defaultActiveKey="_configured" id="domain-tabs" className="mb-3" > {getTabs()} </Tabs> <div style= {{ paddingLeft: 10, paddingTop: 10, display: (Object.keys(schema).length==0)? "none" :"" }}> <Grid container direction="column" style={{spacing:5}}> <Grid item><h2>{schema?.title}</h2><p></p></Grid> <Grid item>{ReactHtmlParser(schema?.htmlDescription)}</Grid> </Grid> <fieldset disabled={!enableSchema}> <Form schema={schema} validator={validator} uiSchema={uiSchema} onChange={(e,id) => { const s = sensorMap.get(e.formData.mac_address) if(s) { s._changesMade=true s.config = e.formData setSensorData(e.formData) } if (id=="root_params_sensorClass") { setSensorClassChanged(true) } } } onSubmit={({ formData }, e) => { updateSensorData(formData) alert("Changes saved") }} onError={log('errors')} formData={sensorData}> <div className={classes.root}> <Button type='submit' color="primary" variant="contained">Save</Button> <Button variant="contained" onClick={()=>{undoChanges(sensorData.mac_address)}}>Undo</Button> <Button variant="contained" color="secondary" onClick={(e)=>confirmDelete(sensorData.mac_address)}>Delete</Button> </div> </Form> </fieldset> </div> </div> ) } export default BTConfig