Last active
May 2, 2019 01:40
-
-
Save tripflex/3eff9c425f8b0c037c40f5744e46c319 to your computer and use it in GitHub Desktop.
Google Core IoT checkDeviceOnline/isDeviceOnline - Check if a device is online using Firebase Function, parsing timestamps or using iot commands
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Example code to call this function | |
// const checkDeviceOnline = functions.httpsCallable('checkDeviceOnline'); | |
// Include 'current' key for 'current' online status to force update on db with delta | |
// const isOnline = await checkDeviceOnline({ deviceID: 'XXXX', current: true }) | |
export const checkDeviceOnline = functions.https.onCall(async (data, context) => { | |
if (!context.auth) { | |
throw new functions.https.HttpsError('failed-precondition', 'You must be logged in to call this function!'); | |
} | |
// deviceID is passed in deviceID object key | |
const deviceID = data.deviceID | |
const dbUpdate = (isOnline) => { | |
if (('wasOnline' in data) && data.wasOnline !== isOnline) { | |
db.collection("devices").doc(deviceID).update({ online: isOnline }) | |
} | |
return isOnline | |
} | |
const deviceLastSeen = () => { | |
// We only want to use these to determine "latest seen timestamp" | |
const stamps = ["lastHeartbeatTime", "lastEventTime", "lastStateTime", "lastConfigAckTime", "deviceAckTime"] | |
return stamps.map(key => moment(data[key], "YYYY-MM-DDTHH:mm:ssZ").unix()).filter(epoch => !isNaN(epoch) && epoch > 0).sort().reverse().shift() | |
} | |
await dm.setAuth() | |
const iotDevice: any = await dm.getDevice(deviceID) | |
if (!iotDevice) { | |
throw new functions.https.HttpsError('failed-get-device', 'Failed to get device!'); | |
} | |
console.log('iotDevice', iotDevice) | |
// If there is no error status and there is last heartbeat time, assume device is online | |
if (!iotDevice.lastErrorStatus && iotDevice.lastHeartbeatTime) { | |
return dbUpdate(true) | |
} | |
// Add iotDevice.config.deviceAckTime to root of object | |
// For some reason in all my tests, I NEVER receive anything on lastConfigAckTime, so this is my workaround | |
if (iotDevice.config && iotDevice.config.deviceAckTime) iotDevice.deviceAckTime = iotDevice.config.deviceAckTime | |
// If there is a last error status, let's make sure it's not a stale (old) one | |
const lastSeenEpoch = deviceLastSeen() | |
const errorEpoch = iotDevice.lastErrorTime ? moment(iotDevice.lastErrorTime, "YYYY-MM-DDTHH:mm:ssZ").unix() : false | |
console.log('lastSeen:', lastSeenEpoch, 'errorEpoch:', errorEpoch) | |
// Device should be online, the error timestamp is older than latest timestamp for heartbeat, state, etc | |
if (lastSeenEpoch && errorEpoch && (lastSeenEpoch > errorEpoch)) { | |
return dbUpdate(true) | |
} | |
// error status code 4 matches | |
// lastErrorStatus.code = 4 | |
// lastErrorStatus.message = mqtt: SERVER: The connection was closed because MQTT keep-alive check failed. | |
// will also be 4 for other mqtt errors like command not sent (qos 1 not acknowledged, etc) | |
if (iotDevice.lastErrorStatus && iotDevice.lastErrorStatus.code && iotDevice.lastErrorStatus.code === 4) { | |
return dbUpdate(false) | |
} | |
return dbUpdate(false) | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const functions = require("firebase-functions") | |
const admin = require("firebase-admin") | |
const moment = require("moment") | |
import { DeviceManager } from "./DeviceManager" | |
// rename config_example.ts to config.ts and set the registry for use in these functions | |
import config from "./config" | |
// create a device manager instance with a registry id, optionally pass a region | |
const dm = new DeviceManager(config.registryId) | |
admin.initializeApp() | |
const db = admin.firestore() | |
// Set date settings for lastest version of fb | |
db.settings({ timestampsInSnapshots: true }) | |
export { | |
functions, | |
admin, | |
moment, | |
DeviceManager, | |
config, | |
dm, | |
db | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const { google } = require('googleapis') | |
interface Credential { }; | |
interface Device { | |
id: string; | |
name?: string; | |
readonly numId?: string; | |
credentials: Credential[]; | |
"config": any[]; | |
"lastHeartbeatTime": string; | |
"lastEventTime": string; | |
"lastStateTime": string; | |
"lastConfigAckTime": string; | |
"lastConfigSendTime": string; | |
// "blocked": boolean, | |
"lastErrorTime": string; | |
"lastErrorStatus": { | |
object(Status) | |
} | |
// { | |
// object(DeviceConfig) | |
// }, | |
// "state": { | |
// object(DeviceState) | |
// }, | |
// "metadata": { | |
// string: string, | |
// ... | |
// }, | |
} | |
async function getADC() { | |
const res = await google.auth.getApplicationDefault(); | |
let auth = res.credential; | |
if (auth.createScopedRequired && auth.createScopedRequired()) { | |
const scopes = ['https://www.googleapis.com/auth/cloud-platform']; | |
auth = auth.createScoped(scopes); | |
} | |
const projectId = res.projectId as string; | |
console.log('GetADC ProjectID: ' + projectId); | |
return { | |
auth, | |
projectId | |
}; | |
} | |
export class DeviceManager { | |
client: any; | |
ready: Boolean = false; | |
private parentName: string = ''; | |
private registryName: string = ''; | |
private project: string = ''; | |
async setAuth() { | |
// console.log('set auth'); | |
if (!this.ready) { | |
// console.log('client not set'); | |
const adc = await getADC(); | |
// console.log('setting client'); | |
this.client = google.cloudiot({ | |
version: 'v1', | |
auth: adc.auth | |
}); | |
this.project = adc.projectId; | |
this.parentName = `projects/${this.project}/locations/${this.region}`; | |
this.registryName = `${this.parentName}/registries/${this.registryId}` | |
this.ready = true; | |
} else { | |
console.log('client already set'); | |
} | |
} | |
constructor(private registryId: string, private region: string = 'us-central1') { } | |
createDevice(device: any) { | |
return new Promise((resolve, reject) => { | |
const request = { | |
parent: this.registryName, | |
resource: device | |
} | |
this.client.projects.locations.registries.devices.create(request, (err: any, data: any) => { | |
if (err) { | |
console.error(err); | |
reject(err); | |
} else { | |
// console.log('device created'); | |
resolve(data); | |
} | |
}); | |
}); | |
} | |
updateDevice(deviceId: string, device: any, updateMask?: any) { | |
return new Promise((resolve, reject) => { | |
const request = { | |
name: `${this.registryName}/devices/${deviceId}`, | |
resource: device, | |
} | |
if (updateMask) { | |
// tslint:disable-next-line: no-any | |
(request as any)['updateMask'] = updateMask; | |
} | |
this.client.projects.locations.registries.devices.patch(request, (err: any, resp: any) => { | |
if (err) { | |
console.error(err); | |
reject(err); | |
} else { | |
resolve(resp.data); | |
}; | |
}); | |
}); | |
} | |
deleteDevice(deviceId: string) { | |
return new Promise((resolve, reject) => { | |
console.log("delete"); | |
this.client.projects.locations.registries.devices.delete({ name: `${this.registryName}/devices/${deviceId}` }, (err: any, resp: any) => { | |
if (err) { | |
console.error(err); | |
reject(err); | |
} else { | |
resolve(resp.data); | |
}; | |
}); | |
}); | |
} | |
// sendConfig(deviceId:string, config) {} | |
getState(deviceId:string) { | |
return new Promise((resolve, reject) => { | |
this.client.projects.locations.registries.devices.states.list({ name: `${this.registryName}/devices/${deviceId}`, numStates: 1 }, (err: any, resp: any) => { | |
if (err) { | |
console.error(err); | |
reject(err); | |
} else { | |
resolve(resp.data); | |
}; | |
}); | |
}); | |
} | |
getDevice(deviceId: string, fields?: string ) { | |
return new Promise((resolve, reject) => { | |
this.client.projects.locations.registries.devices.get({ name: `${this.registryName}/devices/${deviceId}`, fields: fields }, (err: any, resp: any) => { | |
if (err) { | |
console.error(err); | |
reject(err); | |
} else { | |
resolve(resp.data); | |
}; | |
}); | |
}); | |
} | |
sendCommand(deviceId: string, commandMessage?: any, subfolder: string = 'firebase' ) { | |
return new Promise((resolve, reject) => { | |
const request = { | |
name: `${this.registryName}/devices/${deviceId}`, | |
binaryData: Buffer.from(JSON.stringify(commandMessage)).toString("base64"), | |
subfolder: subfolder | |
} | |
this.client.projects.locations.registries.devices.sendCommandToDevice( request, (err: any, resp: any) => { | |
if (err) { | |
console.log('Could not send command:', request); | |
console.log('Error: ', err); | |
reject(err); | |
} else { | |
resolve(resp); | |
}; | |
}); | |
}); | |
} | |
listDevices(pageToken?: string) { | |
return new Promise((resolve, reject) => { | |
const request: any = { | |
parent: this.registryName, | |
//resource: body | |
pageSize: 50, | |
}; | |
if (pageToken) { | |
request['pageToken'] = pageToken; | |
} | |
// console.log(request); | |
// console.log(this.client); | |
this.client.projects.locations.registries.devices.list(request, (err: any, resp: any) => { | |
if (err) { | |
console.error(err); | |
reject(err); | |
} else { | |
resolve(resp.data); | |
}; | |
}); | |
}); | |
} | |
updateConfig(deviceId: string, config: any) { | |
return new Promise((resolve, reject) => { | |
const request = { | |
name: `${this.registryName}/devices/${deviceId}`, | |
binaryData: Buffer.from(JSON.stringify(config)).toString("base64"), | |
} | |
this.client.projects.locations.registries.devices.modifyCloudToDeviceConfig(request, (err: any, resp: any) => { | |
if (err) { | |
console.error(err); | |
reject(err); | |
} else { | |
resolve(resp.data); | |
}; | |
}); | |
}); | |
} | |
updateConfigBinary(deviceId: string, config: Buffer) { | |
return new Promise((resolve, reject) => { | |
const request = { | |
name: `${this.registryName}/devices/${deviceId}`, | |
binaryData: config.toString("base64"), | |
} | |
this.client.projects.locations.registries.devices.modifyCloudToDeviceConfig(request, (err: any, resp: any) => { | |
if (err) { | |
console.error(err); | |
reject(err); | |
} else { | |
resolve(resp.data); | |
}; | |
}); | |
}); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const isDeviceOnline = functions.https.onCall(async (data, context) => { | |
if (!context.auth) { | |
throw new functions.https.HttpsError('failed-precondition', 'You must be logged in to call this function!'); | |
} | |
// deviceID is passed in deviceID object key | |
const deviceID = data.deviceID | |
await dm.setAuth() | |
const dbUpdate = (isOnline) => { | |
if (('wasOnline' in data) && data.wasOnline !== isOnline) { | |
console.log( 'updating db', deviceID, isOnline ) | |
db.collection("devices").doc(deviceID).update({ online: isOnline }) | |
} else { | |
console.log('NOT updating db', deviceID, isOnline) | |
} | |
return isOnline | |
} | |
try { | |
await dm.sendCommand(deviceID, 'alive?', 'alive') | |
console.log('Assuming device is online after succesful alive? command') | |
return dbUpdate(true) | |
} catch (error) { | |
console.log("Unable to send alive? command", error) | |
return dbUpdate(false) | |
} | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment