/**
* @fileoverview JavaScript functions for interacting with micro:bit microcontrollers over WebBluetooth
* (Only works in Chrome browsers; Pages must be either HTTPS or local)
*/
const onDataTIMEOUT = 1000 // Timeout after 1 second of no data (and expecting more)
const dataBurstSIZE = 100 // Number of packets to request at in a burst
const progressPacketThreshold = 10 // More than 10 packets and report progress of transfer
/**
* @constant {string} SERVICE_UUID - UUID of the micro:bit service
*/
const SERVICE_UUID = "accb4ce4-8a4b-11ed-a1eb-0242ac120002" // BLE Service
const serviceCharacteristics = new Map(
[
["accb4f64-8a4b-11ed-a1eb-0242ac120002", "securityChar"], // Security Read, Notify
["accb50a4-8a4b-11ed-a1eb-0242ac120002", "passphraseChar"], // Passphrase Write
["accb520c-8a4b-11ed-a1eb-0242ac120002", "dataLenChar"], // Data Length Read, Notify
["accb53ba-8a4b-11ed-a1eb-0242ac120002", "dataChar"], // Data Notify
["accb552c-8a4b-11ed-a1eb-0242ac120002", "dataReqChar"], // Data Request Write
["accb5946-8a4b-11ed-a1eb-0242ac120002", "eraseChar"], // Erase Write
["accb5be4-8a4b-11ed-a1eb-0242ac120002", "usageChar"], // Usage Read, Notify
["accb5dd8-8a4b-11ed-a1eb-0242ac120002", "timeChar"] // Time Read
]);
/**
* Retrieve task
* @private
*/
class retrieveTask {
/**
* Task for the retrieval queue for data
* @param {*} start 16-byte aligned start index (actual data index is "start*16")
* @param {*} length Number of 16-byte segments to retrieve
* @param {*} progress Progress of the task (0-100) at the start of this bundle or null (-1) if not shown
* @param {*} final indicator of final bundle for request
* @param {*} success Callback function for success (completion)
* @private
*/
constructor(start, length, progress = -1, final, success = null) {
this.start = start // Start index of the data
this.segments = new Array(length) // Segment data
this.processed = 0 // Number of segments processed
this.progress = progress
this.final = final
this.success = success
}
}
/**
* Class to manage an individual micro:bit device
*/
class uBit extends EventTarget {
/**
* Constructor for a micro:bit object
* @param {uBitManager} manager
* @hideconstructor
*/
constructor(manager) {
super()
// Device Identification data
this.id = null;
this.label = null;
this.name = null;
// Authentication data
this.password = null
this.passwordAttempts = 0
// Object ownership
this.manager = manager
// "CSV" raw packets and overall length of data on device
this.rawData = []
this.dataLength = null
// Managing Data retrieval
this.onDataTimeoutHandler = -1 // Also tracks if a read is in progress
this.retrieveQueue = []
// Parsing data
this.nextDataAfterReboot = false
this.bytesProcessed = 0
this.headers = []
this.indexOfTime = 0
this.fullHeaders = []
this.rows = []
// Connection Management
this.firstConnectionUpdate = false
// Bind Callback methods (all BLE callbacks)
this.onConnect = this.onConnect.bind(this)
this.onDataLength = this.onDataLength.bind(this)
this.onSecurity = this.onSecurity.bind(this)
this.onData = this.onData.bind(this)
this.onUsage = this.onUsage.bind(this)
this.onDisconnect = this.onDisconnect.bind(this)
// Bind timeout callbacks
this.onDataTimeout = this.onDataTimeout.bind(this)
// Bind internal callbacks
this.onConnectionSyncCompleted = this.onConnectionSyncCompleted.bind(this)
// Connection state management setup
this.disconnected()
}
/**
*
* @returns {string[]} Array of headers for the data (do NOT mutate)
*/
getHeaders() {
return this.fullHeaders
}
/**
*
* @returns {number} The number of rows of data
*/
getDataLength() {
return this.rows.length
}
/**
*
* @param {number} start Start row (inclusive)
* @param {number} end End row (exclusive)
* @returns Rows from start (inclusive) to end (inclusive) (do NOT mutate data)
*/
getData(start = 0, end = this.rows.length) {
return this.rows.slice(start, end)
}
/**
* Set the label for the device
* @param {string} label
*/
setLabel(label) {
this.label = label
}
/**
*
* @returns {string} The label for the device
*/
getLabel() {
return this.label || this.name
}
/**
* Get the data as a CSV representation
* This is the full, augmnted data. The first column will be the miro:bit name (not label), then an indiator
* of the reboot, then the wall-clock time (UTC stamp in ISO format if it can reliably be identified),
* then the microbit's clock time, then the data.
* @returns {string} The CSV of the augmented data
*/
getCSV() {
let headers = this.fullHeaders.join(",") + "\n"
let data = this.rows.map( r => r.join(",")).join("\n")
return headers+data
}
/**
* Get the raw (micro:bit) data as a CSV representation. This should match the CSV retrieved from
* accessing the Micro:bit as a USB drive
* @returns {string} The CSV of the raw data
*/
getRawCSV() {
return this.rawData.join('')
}
/**
* Request an erase (if connected & authorized)
*/
sendErase() {
//console.log(`sendErase`)
if(this.device && this.device.gatt && this.device.gatt.connected) {
let dv = new DataView(new ArrayBuffer(5))
let i = 0
for(let c of "ERASE") {
dv.setUint8(i++, c.charCodeAt(0))
}
this.eraseChar.writeValue(dv)
}
}
/**
* Request authorization (if connected)
*
* A correct password will be retained for future connections
*
* @param {string} password The password to send
*/
sendAuthorization(password) {
//console.log(`sendAuthorization: ${password}`)
if(this.device && this.device.gatt && this.device.gatt.connected) {
let dv = new DataView(new ArrayBuffer(password.length))
let i = 0
for(let c of password) {
dv.setUint8(i++, c.charCodeAt(0))
}
this.passphraseChar.writeValue(dv)
this.password = password
}
}
/**
* Request a disconnect
*/
disconnect() {
if(this.device && this.device.gatt && this.device.gatt.connected) {
this.device.gatt.disconnect()
}
}
/* ******************* Private Methods ******************* */
/**
* Clear the "onData" timeout
* @private
*/
clearDataTimeout() {
// console.log(`clearDataTimeout: handler ID ${this.onDataTimeoutHandler}`)
if(this.onDataTimeoutHandler!=-1) {
clearTimeout(this.onDataTimeoutHandler)
this.onDataTimeoutHandler = -1
}
}
/**
* set the "onData" timeout
* @private
*/
setDataTimeout() {
this.clearDataTimeout()
this.onDataTimeoutHandler = setTimeout(this.onDataTimeout, onDataTIMEOUT)
// console.log(`setDataTimeout: handler ID ${this.onDataTimeoutHandler}`)
}
/**
* Callback for "onData" timeout (checks to see if transfer is complete)
* @private
*/
onDataTimeout() {
// Stuff to do when onData is done
if(this.onDataTimeoutHandler!=-1) {
console.log("onDataTimeout")
this.clearDataTimeout()
this.checkChunk()
}
}
/**
* Do a BLE request for the data (to be streamed)
* @param {int} start 16-byte aligned start index (actual data index is "start*16")
* @param {int} length Number of 16-byte segments to retrieve
* @private
*/
async requestSegment(start, length) {
// console.log(`requestSegment: Requesting @ ${start} ${length} *16 bytes`)
if(this.device && this.device.gatt && this.device.gatt.connected) {
let dv = new DataView(new ArrayBuffer(8))
dv.setUint32(0, start*16, true)
dv.setUint32(4, length*16, true)
await this.dataReqChar.writeValue(dv)
this.clearDataTimeout()
this.setDataTimeout()
}
}
/**
* Notify of progress in retrieving large block of data
* @param {int} progress Progress of the task (0-100)
* @private
*/
notifyDataProgress(progress) {
/**
* @event progress
* @type {object}
* @property {uBit} detail.device The device that has an update on progress
* @property {int} detail.progress Progress on total data transfer [0-100]
*/
this.manager.dispatchEvent(new CustomEvent("progress", {detail: {device:this, progress:progress}}))
}
/**
* Notify that new data is available
* @private
*/
notifyDataReady() {
/**
* @event data-ready
* @type {object}
* @property {uBit} detail.device The device that has new data
*/
this.manager.dispatchEvent(new CustomEvent("data-ready", {detail: {device:this}}))
}
/**
* Retrieve a range of data and re-request until it's all delivered.
* Assuming to be non-overlapping calls. I.e. this won't be called again until all data is delivered
* @param {*} start 16-byte aligned start index (actual data index is "start*16")
* @param {*} length Number of 16-byte segments to retrieve
* @private
*/
retrieveChunk(start, length, success = null) {
//console.log(`retrieveChunk: Retrieving @${start} ${length} *16 bytes`)
if(start*16>this.dataLength) {
console.log(`retrieveChunk: Start index ${start} is beyond end of data`)
return
}
if(start + length > Math.ceil(this.dataLength/16)) {
console.log(`retrieveChunk: Requested data extends beyond end of data`)
// return
}
// Break it down into smaller units if needed
let noPending = this.retrieveQueue.length == 0
let progressIndicator = length>progressPacketThreshold
let numBursts = Math.ceil(length / dataBurstSIZE)
let remainingData = length
let thisRequest = 0
while(remainingData > 0) {
let thisLength = Math.min(remainingData, dataBurstSIZE)
let finalRequest = thisRequest == numBursts-1
let newTask = new retrieveTask(start,
thisLength,
progressIndicator ? Math.floor(thisRequest/numBursts*100) : -1,
finalRequest,
finalRequest ? success : null)
this.retrieveQueue.push(newTask)
start += thisLength
remainingData -= thisLength
thisRequest++
}
// If nothing is being processed now, start it
if(noPending) {
this.startNextRetrieve()
}
}
/**
* Callback of actions to do on connection
* @param {BLEService} service
* @param {BLECharacteristics} chars
* @param {BLEDevice} device
* @private
*/
async onConnect(service, chars, device) {
// Add identity values if not already set (neither expected to change)
this.id = this.id || device.id
this.name = this.name || device.name
// Bluetooth & connection configuration
this.device = device
this.chars = chars
this.service = service
this.passwordAttempts = 0
this.nextDataAfterReboot = false
this.firstConnectionUpdate = true
this.chars.forEach(element => {
let charName = serviceCharacteristics.get(element.uuid)
if(charName!=null) {
this[charName] = element
} else {
console.log(`Char not found: ${element.uuid}`)
}
});
// Connect / disconnect handlers
/**
* @event connected
* @type {object}
* @property {uBit} detail.device The device that has successfully connected
*/
this.manager.dispatchEvent(new CustomEvent("connected", {detail: {device: this}}))
this.device.addEventListener('gattserverdisconnected', () => {
this.onDisconnect()}, {once:true});
this.securityChar.addEventListener('characteristicvaluechanged', this.onSecurity)
await this.securityChar.startNotifications()
}
/**
* Callback of actions to do when authorized
* @private
*/
async onAuthorized() {
// Subscribe to characteristics / notifications
// Initial reads (need to be before notifies
let time = await this.timeChar.readValue()
let msTime = Math.round(Number(time.getBigUint64(0,true))/1000) // Conver us Time to ms
// Compute the date/time that the micro:bit started in seconds since epoch start (as N.NN s)
this.mbRebootTime = Date.now() - msTime
this.dataChar.addEventListener('characteristicvaluechanged', this.onData)
await this.dataChar.startNotifications()
this.usageChar.addEventListener('characteristicvaluechanged', this.onUsage)
await this.usageChar.startNotifications()
// Enabling notifications will get current length;
// Getting current length will retrieve all "new" data since last retrieve
this.dataLenChar.addEventListener('characteristicvaluechanged', this.onDataLength)
await this.dataLenChar.startNotifications()
}
/**
* Remove this device
*/
remove() {
this.manager.removeDevice(this.id)
// Remove any listeners
this.device && this.device.removeEventListener('gattserverdisconnected', this.onDisconnect)
this.dataChar && this.dataChar.removeEventListener('characteristicvaluechanged', this.onData)
this.dataLenChar && this.dataLenChar.removeEventListener('characteristicvaluechanged', this.onDataLength)
this.usageChar && this.usageChar.removeEventListener('characteristicvaluechanged', this.onUsage)
this.securityChar && this.securityChar.removeEventListener('characteristicvaluechanged', this.onSecurity)
// If connected, disconnect
this.device && this.device.gatt.connected && this.device.gatt.disconnect()
// Discard any data, etc.
this.rawData = []
this.rows = []
this.dataLength = 0
this.bytesProcessed = 0
// Make sure all references are cleared
this.disconnected()
}
/**
* Refresh (reload) all data from micro:bit (removes all local data)
*/
refreshData() {
this.rawData = []
this.dataLength = 0
this.bytesProcessed = 0 // Reset to beginning of processing
this.discardRetrieveQueue() // Clear any pending requests
this.bytesProcessed = 0
this.headers = []
this.indexOfTime = 0
this.fullHeaders = []
this.rows = []
/**
* @event graph-cleared
* @type {object}
* @property {uBit} detail.device The device that clear all data (completed an erase at some time)
*/
this.manager.dispatchEvent(new CustomEvent("graph-cleared", {detail: {device: this}}))
}
/**
*
* @param {event} event The event data
* @private
*/
onDataLength(event) {
// Updated length / new data
let length = event.target.value.getUint32(0,true)
// console.log(`New Length: ${length} (was ${this.dataLength})`)
// If there's new data, update
if(this.dataLength != length) {
// Probably erased. Retrieve it all
if(length<this.dataLength) {
console.log("Log smaller than expected. Retrieving all data")
this.refreshData()
}
// Get the index of the last known value (since last update)
// floor(n/16) = index of last full segment
// ceil(n/16) = index of last segment total (or count of total segments)
let lastIndex = Math.floor(this.dataLength/16) // Index of first non-full segment
let totalSegments = Math.ceil(length/16) // Total segments _now_
this.dataLength = length;
// Retrieve checks dataLength; Must update it first;
this.retrieveChunk(lastIndex,
totalSegments-lastIndex,
this.onConnectionSyncCompleted)
}
}
/**
* Update data with wall clock time.
* @private
*/
processTime() {
// Add in clock times (if possible)
// console.log("Adding times")
if(this.firstConnectionUpdate==false && this.indexOfTime!=-1) {
let start = this.rows.length-1
// console.log(`Start: ${start}`)
// Valid index, wtc time is null
while(start>=0 && this.rows[start][2]==null) {
// Until a "Reboot" or another time is set
let sampleTime = this.mbRebootTime + Math.round(this.rows[start][3]*1000)
let timeString = new Date(sampleTime).toISOString()
// console.log(`Setting time for row ${start} to ${timeString}`)
this.rows[start][2] = timeString
this.updatedRow(start)
// Don't update rows before "Reboot"
if(this.rows[start][1]!=null) {
break;
}
//console.log(`Row: ${this.rows[start]}`)
start--
}
}
}
/**
* Post event to indicate a row of data has changed or been added
* @private
*/
updatedRow(rowIndex) {
/**
* @event row-updated
* @type {object}
* @property {uBit} detail.device The device that has an update on a row of data
* @property {int} detail.row the index of the row that has been updated (may be a new row)
* @property {string[]} detail.data the current data for the row
* @property {headers[]} detail.headers the headers for the row (same order as data)
*/
this.manager.dispatchEvent(new CustomEvent("row-updated", {detail: {
device: this,
row: rowIndex,
data: this.rows[rowIndex],
headers: this.fullHeaders
}}))
}
/**
* A block of data is ready to be parsed
* @private
*/
parseData() {
//console.log("parseData")
// Bytes processed always ends on a newline
let index = Math.floor(this.bytesProcessed/16)
let offset = this.bytesProcessed%16
let partialItem = this.rawData[index].substring(offset)
let mergedData = partialItem + this.rawData.slice(index+1).join("")
// console.log(`mergedData: ${mergedData}`)
let lines = mergedData.split("\n")
let startRow = this.rows.length
lines.pop() // Discard the last / partial line
for(let line of lines) {
//console.log(`parsing line: ${line}`)
if(line == "Reboot") {
//console.dir(`Reboot`)
this.nextDataAfterReboot = true
} else if(line.includes("Time")) {
//console.log(`Header: ${line}`)
let parts = line.split(",")
if(parts.length != this.headers.length) {
// New Header!
this.headers = parts
this.indexOfTime = parts.findIndex((element) => element.includes("Time"))
this.fullHeaders = ["Microbit Label", "Reboot Before Data", "Time (local)"]
if(this.indexOfTime != -1) {
this.fullHeaders = this.fullHeaders.concat(parts)
} else {
// Time then data
this.fullHeaders = this.fullHeaders.concat(parts[this.indexOfTime])
this.fullHeaders = this.fullHeaders.concat(parts.slice(0, this.indexOfTime))
this.fullHeaders = this.fullHeaders.concat(parts.slice(this.indexOfTime+1))
}
//console.log(`Full Headers now: ${this.fullHeaders}`)
/**
* @event headers-updated
* @type {object}
* @property {uBit} detail.device The device that has an update on the headers
* @property {string[]} detail.headers the new headers for the device
*/
this.manager.dispatchEvent(new CustomEvent("headers-updated", {detail: {device: this, headers: this.fullHeaders}}))
}
} else {
let parts = line.split(",")
//console.log(`Data: ${parts} ${parts.length} ${this.headers.length}`)
if(parts.length < this.headers.length) {
console.log(`Invalid line: ${line} ${this.bytesProcessed}`)
} else {
let time = null
if(this.indexOfTime!=-1) {
time = parts[this.indexOfTime]
}
parts = parts.slice(0, this.indexOfTime).concat(parts.slice(this.indexOfTime+1))
// name, reboot, local time, time, data...
let newRow = [this.getLabel(), this.nextDataAfterReboot ? "true" : null, null, time].concat(parts)
// console.log(`New Row: ${newRow}`)
this.rows.push(newRow)
this.nextDataAfterReboot = false
}
}
}
this.processTime()
// If we've already done the first connection...
if(this.firstConnectionUpdate==false) {
this.notifyDataReady()
}
// Advance by total contents of lines and newlines
this.bytesProcessed += lines.length + lines.reduce((a, b) => a + b.length, 0)
// Notify any listeners
for(let i=startRow; i<this.rows.length; i++) {
this.updatedRow(i)
}
}
/**
* Callback when a security message is received
* @param {event}} event The BLE security data
* @private
*/
onSecurity(event) {
let value = event.target.value.getUint8()
if(value!=0) {
this.onAuthorized()
} else {
if(this.password!=null && this.passwordAttempts==0) {
// If we're on the first connect and we have a stored password, try it
this.sendAuthorization(this.password)
this.passwordAttempts++
} else {
// Need a password or password didn't work
/**
* @event unauthorized
* @type {object}
* @property {uBit} detail.device The device that is not authorized (must provide valid password to use device. See {@link uBit#sendAuthorization})
*/
this.manager.dispatchEvent(new CustomEvent("unauthorized", {detail: {device: this}}))
}
}
}
/**
* Start the next data request (if there is one pending)
* @private
*/
startNextRetrieve() {
// If there's another one queued up, start it
if(this.retrieveQueue.length>0) {
// Request the next chunk
let nextRetrieve = this.retrieveQueue[0]
this.requestSegment(nextRetrieve.start, nextRetrieve.segments.length)
// Post the progress of the next transaction
if(nextRetrieve.progress>=0) {
this.notifyDataProgress(nextRetrieve.progress)
}
}
}
/**
* Initial data request on connection (or reconnect) is done (or at least being checked)
* @private
*/
onConnectionSyncCompleted() {
if(this.firstConnectionUpdate) {
//console.log("onConnectionSyncCompleted")
this.firstConnectionUpdate = false
this.processTime()
this.notifyDataReady()
}
}
/**
* Process the data from a retrieveTask that has completed (all data available)
* @param {retrieveTask} retrieve The retrieve task to try to check/process
* @private
*/
processChunk(retrieve) {
// If final packet and we care about progress, send completion notification
// console.log(`processChunk: ${retrieve.progress} ${retrieve.final} ${retrieve.success} ${retrieve.segments.length}`)
if(retrieve.progress>=0 && retrieve.final) {
this.notifyDataProgress(100)
}
// Pop off the retrieval task
this.retrieveQueue.shift()
// Start the next one (if any)
this.startNextRetrieve()
// Copy data from this to raw data
for(let i=0;i<retrieve.segments.length;i++) {
if(retrieve.segments[i]==null) {
console.log(`ERROR: Null segment: ${i}`)
}
this.rawData[retrieve.start+i] = retrieve.segments[i]
}
this.parseData()
// If we're done with the entire transaction, call the completion handler if one
if(retrieve.success) {
retrieve.success()
}
}
/**
* A retrieveTask is done. Check to see if it's complete and ready for processing (if not, make more requests)
* @private
*/
checkChunk() {
// console.log("checkChunk")
if(this.retrieveQueue.length==0) {
console.log('No retrieve queue')
return
}
let retrieve = this.retrieveQueue[0]
// If done
if(retrieve.processed==retrieve.segments.length) {
this.processChunk(retrieve)
} else {
// Advance to next missing packet
while(retrieve.processed<retrieve.segments.length && retrieve.segments[retrieve.processed]!=null) {
retrieve.processed = retrieve.processed+1
}
// If there's a non-set segment, request it
if(retrieve.processed<retrieve.segments.length) {
// Identify the run length of the missing piece(s)
let length = 1;
while(retrieve.processed+length<retrieve.segments.length &&
retrieve.segments[retrieve.processed+length]==null ) {
length++
}
// Request them
this.requestSegment(retrieve.start+retrieve.processed, length)
} else {
// No missing segments. Process it
this.processChunk(retrieve)
}
}
}
/**
* Process the data notification from the device
* @param {event} event BLE data event is available
* @private
*/
onData(event) {
// Stop any timer from running
this.clearDataTimeout()
// If we're not trying to get data, ignore it
if(this.retrieveQueue.length==0) {
return;
}
// First four bytes are index/offset this is in reply to...
let dv = event.target.value
if(dv.byteLength>=4) {
let index = dv.getUint32(0,true)
let text =''
for(let i=4;i<dv.byteLength;i++) {
let val = dv.getUint8(i)
if(val!=0) {
text += String.fromCharCode(val)
}
}
// console.log(`Text at ${index}: ${text}`)
// console.log(`Hex: ${showHex(dv)}`)
let retrieve = this.retrieveQueue[0]
// if(Math.random()<.01) {
// console.log("Dropped Packet")
// } else {
// console.dir(retrieve)
let segmentIndex = (index/16 - retrieve.start);
// console.log(`Index: ${index} Start: ${retrieve.start} index: ${segmentIndex}`)
if(segmentIndex == retrieve.processed)
retrieve.processed++
if(retrieve.segments[segmentIndex]!=null) {
console.log(`ERROR: Segment already set ${segmentIndex}: "${retrieve.segments[segmentIndex]}" "${text}" `)
if(retrieve.segments[segmentIndex].length!=text.length && retrieve.segments[segmentIndex]!=text) {
console.log("Segment is ok (duplicate / overlap")
} else {
console.log("Duplicate segment")
}
}
if(segmentIndex>=0 && segmentIndex<retrieve.segments.length) {
retrieve.segments[segmentIndex] = text
} else {
console.log(`ERROR: Segment out of range ${segmentIndex} (max ${retrieve.segments.length}`)
}
// } // END Dropped packet test
// Not done: Set the timeout
this.setDataTimeout()
} else if(event.target.value.byteLength==0) {
// Done: Do the check / processing (timer already cancelled)
// console.log("Terminal packet.")
// if(Math.random()<.10) {
this.checkChunk()
// } else {
// // Simulate timeout
// console.log("Dropped terminal packet")
// this.setDataTimeout()
// }
} else {
console.log(`ERROR: Unexpected data length ${event.target.value.byteLength}`)
}
}
/**
* Process an update on the BLE usage characteristics
*
* @param {event} event The BLE event useage data
* @private
*/
onUsage(event) {
let value = event.target.value.getUint16(0, true)/10.0
/**
* @event log-usage
* @type {object}
* @property {uBit} detail.device The device that has an update on progress
* @property {int} detail.percent Percent of space currently in use [0.0-100.0]
*/
this.manager.dispatchEvent(new CustomEvent("log-usage", {detail: {device: this, percent: value}} ))
}
/**
* Process an update on the BLE disconnection event
* @private
*/
onDisconnect() {
this.device.gatt.disconnect()
this.disconnected()
/**
* @event disconnected
* @type {object}
* @property {uBit} detail.device The device that has disconnected
*/
this.manager.dispatchEvent(new CustomEvent("disconnected", {detail: {device:this}} ))
}
/**
* Discard any pending retrieve tasks (and mark any in-progress as complete)
* @private
*/
discardRetrieveQueue() {
// If there's a transfer in-progress, notify it is completed
if(this.retrieveQueue.length>0 && this.retrieveQueue[0].progress>=0) {
this.notifyDataProgress(100)
}
while(this.retrieveQueue.pop()) {}
}
/**
* Update state variables for a disconnected state
* @private
*/
disconnected() {
this.device = null
this.service = null
this.chars = null
// Individual characteristics
this.securityChar = null
this.passphraseChar = null
this.dataLenChar = null
this.dataChar = null
this.dataReqChar = null
this.eraseChar = null
this.usageChar = null
this.timeChar = null
// Update data to reflect what we actually have
this.dataLength = Math.max(0, (this.rawData.length-1)*16)
this.discardRetrieveQueue()
this.mbRebootTime = null
this.clearDataTimeout()
}
}
/**
* Manager for uBit devices
* @fires connected
* @fires disconnected
* @fires data-ready
* @fires progress
* @fires log-usage
* @fires unauthorized
* @fires graph-cleared
* @fires row-updated
* @fires device-list-changed
* @fires headers-updated
*/
class uBitManager extends EventTarget {
/**
* Constructor a Manager for uBit devices
*/
constructor() {
super()
// Map of devices
this.devices = new Map()
this.connect = this.connect.bind(this)
// TODO: Review save/restore
// this.restoreDevices()
// this.saveDevices = this.saveDevices.bind(this)
// window.addEventListener("beforeunload", this.saveDevices, false);
}
/**
* Restore devices from local storage
* @private
*/
restoreDevices() {
let saved = localStorage.getItem("devices")
if(saved) {
saved = JSON.parse(saved)
for(let item of saved) {
let uB = new uBit(this)
uB.label = item.label
uB.rawData = item.rawData
uB.parseData()
uB.dataLength = item.rawData.reduce((a,b)=>a+b.length, 0)
uB.password = item.password
uB.passwordAttempts = item.passwordAttempts
this.devices.set(item.id, uB)
}
}
}
/**
* Save devices to local storage
* @private
*/
saveDevices() {
// Create object of: id -> {name, , rawData}
let savedDetails = []
for(let [id, uB] of this.devices) {
console.log(`Saving ${id}`)
savedDetails.push( {id: id, label: uB.label, rawData: uB.rawData, password: uB.password, passwordAttempts: uB.passwordAttempts})
}
let saved = JSON.stringify(savedDetails)
localStorage.setItem("devices", saved)
}
/**
* Connect to a device
*/
async connect() {
let device = await navigator.bluetooth.requestDevice({filters:[{namePrefix:"uBit"}], optionalServices: [SERVICE_UUID]});
let server = await device.gatt.connect()
let services = await server.getPrimaryServices()
services = services.filter( u => u.uuid == SERVICE_UUID)
if(services.length>0) {
let service = services[0]
let chars = await service.getCharacteristics()
// Add or update the device
let uB = this.devices.get(device.id)
if(!uB){
uB = new uBit(this)
this.devices.set(device.id, uB)
this.notifyDeviceListChanged()
}
await uB.onConnect(service, chars, device)
} else {
await uB.devices.gatt.disconnect()
console.warn("No service found!")
}
}
/**
* Get a map of ids to all known devices
* @returns Map of unique device id to device (devices that have been connected to in the past) (do NOT mutate)
*/
getDevices() {
return this.devices
}
/**
* Notify listeners that the device list has changed
* @private
*/
notifyDeviceListChanged() {
/**
* The list of devices has changed (device added or removed)
* @event device-list-changed
* @type {object}
* @property {uBit} detail.devices Updated map of device ids to devices
*/
this.dispatchEvent(new CustomEvent("device-list-changed", {detail: {devices: this.devices}} ))
}
removeDevice(id) {
this.devices.delete(id)
this.notifyDeviceListChanged()
}
}