@flowfuse/device-agent
Version:
An Edge Agent for running Node-RED instances deployed from the FlowFuse Platform
380 lines (352 loc) • 15.3 kB
JavaScript
const mqtt = require('mqtt')
const { info, warn, debug, setMQTT, getBufferedMessages } = require('./logging/log')
const { IntervalJitter } = require('./IntervalJitter')
const EditorTunnel = require('./editor/tunnel')
const { getWSProxyAgent } = require('./utils')
const { randomInt } = require('crypto')
class MQTTClient {
/**
* MQTT Client for the FlowFuse API
* @param {import('./agent').Agent} agent
* @param {Object} config
*/
constructor (agent, config) {
/** @type {import('./agent').Agent} */
this.agent = agent
this.config = config
/** @type {IntervalJitter} */
this.heartbeat = new IntervalJitter()
/** @type {EditorTunnel} */
this.tunnel = null
this.sentInitialCheckin = false
this.initialCheckinTimeout = null
const parts = /^device:(.*):(.*)$/.exec(config.brokerUsername)
if (!parts) {
throw new Error('Invalid brokerUsername')
}
this.teamId = parts[1]
this.deviceId = parts[2]
this.activeApplication = null
this.activeProject = null
this.commandTopic = `ff/v1/${this.teamId}/d/${this.deviceId}/command`
this.statusTopic = `ff/v1/${this.teamId}/d/${this.deviceId}/status`
this.logTopic = `ff/v1/${this.teamId}/d/${this.deviceId}/logs`
this.responseTopic = `ff/v1/${this.teamId}/d/${this.deviceId}/response`
/** @type {mqtt.IClientOptions} */
this.brokerConfig = {
clientId: config.brokerUsername,
username: config.brokerUsername,
password: config.brokerPassword,
reconnectPeriod: randomInt(13000, 25000),
queueQoSZero: false
}
setMQTT(this)
}
start () {
const brokerURL = new URL(this.config.brokerURL)
if (process.env.all_proxy || process.env.http_proxy || process.env.https_proxy) {
this.brokerConfig.wsOptions = {
agent: getWSProxyAgent(this.config.brokerURL)
}
}
const connectOpts = {
protocol: brokerURL.protocol.replace(/:$/, ''),
host: brokerURL.hostname,
port: brokerURL.port
}
const opts = Object.assign({}, connectOpts, this.brokerConfig)
this.client = mqtt.connect(opts)
this.client.on('connect', () => {
info('MQTT connected')
this.client.publish(this.statusTopic, JSON.stringify(this.agent.getState()))
})
this.client.on('close', () => { })
this.client.on('reconnect', () => {
info('MQTT reconnecting to platform')
})
this.client.on('error', (err) => {
warn(`MQTT connection error: ${err.toString()}`)
})
this.client.on('message', async (topic, message, packet) => {
const _message = message.toString()
debug(`Command received. topic: '${topic}', message: ${_message}`)
try {
const msg = JSON.parse(_message)
if (msg.command === 'update') {
let refreshSettings = false
if (!this.sentInitialCheckin) {
// We haven't sent the initial checkin, but we've received
// an update; no need to resend the checkin
refreshSettings = true
this.sentInitialCheckin = true
}
if (this.initialCheckinTimeout) {
refreshSettings = true
clearTimeout(this.initialCheckinTimeout)
this.initialCheckinTimeout = null
}
if (refreshSettings && msg.mode === 'developer') {
// If this is the first checkin response, and we're in developer mode,
// trigger a reload of settings to ensure any updates are picked up
msg.reloadSettings = true
}
await this.agent.setState(msg)
} else if (msg.command === 'startLog') {
if (!this.logEnabled) {
this.client.publish(this.logTopic, JSON.stringify(getBufferedMessages()))
}
this.logEnabled = true
} else if (msg.command === 'stopLog') {
this.logEnabled = false
} else if (msg.command === 'startEditor') {
await this.startTunnel(msg.payload?.token, this.agent.editorAffinity || null, msg)
} else if (msg.command === 'stopEditor') {
// Clear the saved token
await this.saveEditorToken(null, null)
if (this.tunnel) {
info('Disabling remote editor access')
this.tunnel.close()
this.tunnel = null
}
} else if (msg.command === 'upload') {
info('Capturing device snapshot')
// upload expects a response. get the data and send it back
const response = await this.getUploadData()
this.sendCommandResponse(msg, response)
} else if (msg.command === 'action') {
await this.handleActionRequest(msg)
} else {
warn(`Unknown command type received from platform: ${msg.command}`)
}
} catch (err) {
warn(err)
warn(`Invalid command message received from platform: ${_message}`)
}
})
debug(`MQTT subscribe ${this.commandTopic}`)
this.client.subscribe([this.commandTopic])
const period = Math.ceil(this.config.interval || 60)
const jitter = Math.ceil(this.config.intervalJitter || 10)
info(`Starting MQTT heartbeat thread. Interval: ${period}s (±${jitter / 2}s)`)
// initial heartbeat to be operated at 255ms (±250ms)
this.heartbeat.start({ interval: period * 1000, jitter: jitter * 1000, firstInterval: 10, firstJitter: 500 }, () => {
this.checkIn()
})
}
/**
* Perform a device action of starting, restarting or suspending the Node-RED instance
* @param {Object} msg - the incoming message data
*/
async handleActionRequest (msg) {
const action = msg?.payload?.action || ''
try {
let result = false
let error = null
switch (action) {
case 'start':
info('Node-RED start requested')
result = await this.agent.startNR()
break
case 'restart':
info('Node-RED restart requested')
result = await this.agent.restartNR()
break
case 'suspend':
info('Node-RED suspend requested')
result = await this.agent.suspendNR()
break
default:
error = new Error(`Unsupported action requested: ${action}`)
error.code = 'unsupported_action'
throw error
}
if (result) {
this.sendCommandResponse(msg, { success: result })
} else {
throw new Error('Requested action ' + action + ' failed')
}
} catch (err) {
warn(err.toString())
debug(err)
const error = {
message: err.toString(),
code: err.code || 'unexpected_error',
error: err.message || 'Unexpected error'
}
this.sendCommandResponse(msg, { success: false, error })
}
this.agent.checkIn(3, 1000) // attempt a check in (3 retries, 1s interval)
}
stop () {
if (this.heartbeat.isRunning) {
info('Stopping MQTT heartbeat thread')
this.heartbeat.stop()
}
info('Closing MQTT connection')
setMQTT(undefined)
if (this.client) {
this.setApplication(null) // unsubscribe from application commands
this.setProject(null) // unsubscribe from application commands
this.client.end()
}
}
checkIn () {
const payload = this.agent.getState()
if (!payload) {
// No payload means we're busy updating - don't call home
return
}
if (!this.sentInitialCheckin) {
this.initialCheckinTimeout = setTimeout(() => {
warn('Timeout performing initial check-in')
// Timeout the initial checkin - tell the agent to
// carry on with what it has already got
this.agent.setState(payload).catch(err => {
warn(`Error setting existing state: ${err}`)
})
this.initialCheckinTimeout = null
}, 10000)
this.sentInitialCheckin = true
}
debug('Sending check-in message')
this.client.publish(this.statusTopic, JSON.stringify(payload))
}
sendStatus () {
const payload = this.agent.getState()
if (!payload) {
return
}
debug('Sending status message')
this.client.publish(this.statusTopic, JSON.stringify(payload))
}
async getUploadData (options) {
options = options || {}
const uploadFlows = typeof options.uploadFlows === 'boolean' ? options.uploadFlows : true // default to true
const uploadCredentials = typeof options.uploadCredentials === 'boolean' ? options.uploadCredentials : true // default to true
const uploadPackage = typeof options.uploadPackage === 'boolean' ? options.uploadPackage : true // default to true
const data = {}
data.state = this.agent.getState()
if (uploadFlows) {
// data.flows = [{ id: 'test', type: 'tab', label: 'Flow 1', disabled: false, info: '' }]
data.flows = await this.agent.getCurrentFlows()
}
if (uploadCredentials) {
data.credentials = await this.agent.getCurrentCredentials()
}
if (uploadPackage) {
data.package = await this.agent.getCurrentPackage()
}
return data
}
setApplication (application) {
if (application !== this.activeApplication) {
if (this.activeApplication) {
// - ff/v1/<team>/a/<application>/command
const topic = `ff/v1/${this.teamId}/a/${this.activeApplication}/command`
debug(`MQTT unsubscribe ${topic}`)
this.client.unsubscribe(topic)
}
this.activeApplication = application
if (this.activeApplication) {
// - ff/v1/<team>/a/<application>/command
const topic = `ff/v1/${this.teamId}/a/${this.activeApplication}/command`
debug(`MQTT subscribe ${topic}`)
this.client.subscribe(topic)
}
}
}
setProject (project) {
if (project !== this.activeProject) {
if (this.activeProject) {
// - ff/v1/<team>/p/<project>/command
const projectTopic = `ff/v1/${this.teamId}/p/${this.activeProject}/command`
debug(`MQTT unsubscribe ${projectTopic}`)
this.client.unsubscribe(projectTopic)
}
this.activeProject = project
if (this.activeProject) {
// - ff/v1/<team>/p/<project>/command
const projectTopic = `ff/v1/${this.teamId}/p/${this.activeProject}/command`
debug(`MQTT subscribe ${projectTopic}`)
this.client.subscribe(projectTopic)
}
}
}
log (logMessage) {
if (this.logEnabled) {
this.client.publish(this.logTopic, JSON.stringify(logMessage))
}
}
/**
* Sends a well formed command/response message in response to a command request
* @param {*} request The command to respond to
* @param {*} response The payload to send back to the platform
*/
sendCommandResponse (request, response) {
const correlationData = request?.correlationData
const responseTopic = request?.responseTopic || this.responseTopic
const command = request?.command
if (!correlationData || !responseTopic || !command) {
warn('Invalid command response, cannot send response to forge platform')
return
}
const message = {
teamId: this.teamId, // for message routing and verification
deviceId: this.deviceId, // for message routing and verification
command, // for command response verification
correlationData, // for correlating response with request
payload: response // the actual response payload
}
const messageJSON = JSON.stringify(message)
this.client.publish(responseTopic, messageJSON, (err) => {
if (err) {
warn(`Error sending response to command ${command}: ${err}`)
}
})
}
async startTunnel (token, affinity, msg) {
info('Enabling remote editor access')
try {
if (this.tunnel) {
this.tunnel.close()
this.tunnel = null
}
if (!this.agent.launcher) {
info('No running Node-RED instance, not starting editor')
if (msg) {
this.sendCommandResponse(msg, { connected: false, token, error: 'noNRRunning' })
}
return
}
// * Enable Device Editor (Step 6) - (forge:MQTT->device) Create the tunnel on the device
this.tunnel = EditorTunnel.create(this.config, { token, affinity })
// * Enable Device Editor (Step 7) - (device) Begin the device tunnel connect process
const result = await this.tunnel.connect()
// store the token for later use (i.e. device agent is restarted)
if (result) {
await this.saveEditorToken(token, this.tunnel.affinity)
} else {
// Failed to connect - clear the token/affinity so it can be
// refreshed
await this.saveEditorToken(null, null)
}
if (msg) {
// * Enable Device Editor (Step 10) - (device->forge:MQTT) Send a response to the platform
this.sendCommandResponse(msg, { connected: result, token, affinity: this.tunnel.affinity })
}
} catch (err) {
warn(`Error starting editor tunnel: ${err}`)
if (msg) {
this.sendCommandResponse(msg, { connected: false, token, error: err.toString() })
}
}
this.sendStatus()
}
async saveEditorToken (token, affinity) {
await this.agent?.saveEditorToken(token, affinity)
}
}
module.exports = {
newMQTTClient: (agent, config) => new MQTTClient(agent, config),
MQTTClient
}