zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
465 lines (393 loc) • 18.6 kB
JavaScript
const crypto = require('crypto');
const upgradeFileIdentifier = Buffer.from([0x1E, 0xF1, 0xEE, 0x0B]);
const HttpsProxyAgent = require('https-proxy-agent');
const assert = require('assert');
const crc32 = require('buffer-crc32');
const maxTimeout = 2147483647; // +- 24 days
const imageBlockResponseDelay = 250;
const endRequestCodeLookup = {
0x00: 'success',
0x95: 'aborted by device',
0x7E: 'not authorized',
0x96: 'invalid image',
0x97: 'no data available',
0x98: 'no image available',
0x80: 'malformed command',
0x81: 'unsupported cluster command',
0x99: 'requires more image files',
};
const validSilabsCrc = 0x2144DF1C;
const eblTagHeader = 0x0;
const eblTagEncHeader = 0xfb05;
const eblTagEnd = 0xfc04;
const eblPadding = 0xff;
const gblTagHeader = 0xeb17a603;
const gblTagEnd = 0xfc0404fc;
const gblPadding = 0x0;
function getOTAEndpoint(device) {
return device.endpoints.find((e) => e.supportsOutputCluster('genOta'));
}
function parseSubElement(buffer, position) {
const tagID = buffer.readUInt16LE(position);
const length = buffer.readUInt32LE(position + 2);
const data = buffer.slice(position + 6, position + 6 + length);
return {tagID, length, data};
}
function parseImage(buffer) {
const header = {
otaUpgradeFileIdentifier: buffer.subarray(0, 4),
otaHeaderVersion: buffer.readUInt16LE(4),
otaHeaderLength: buffer.readUInt16LE(6),
otaHeaderFieldControl: buffer.readUInt16LE(8),
manufacturerCode: buffer.readUInt16LE(10),
imageType: buffer.readUInt16LE(12),
fileVersion: buffer.readUInt32LE(14),
zigbeeStackVersion: buffer.readUInt16LE(18),
otaHeaderString: buffer.toString('utf8', 20, 52),
totalImageSize: buffer.readUInt32LE(52),
};
let headerPos = 56;
if (header.otaHeaderFieldControl & 1) {
header.securityCredentialVersion = buffer.readUInt8(headerPos);
headerPos += 1;
}
if (header.otaHeaderFieldControl & 2) {
header.upgradeFileDestination = buffer.subarray(headerPos, headerPos + 8);
headerPos += 8;
}
if (header.otaHeaderFieldControl & 4) {
header.minimumHardwareVersion = buffer.readUInt16LE(headerPos);
headerPos += 2;
header.maximumHardwareVersion = buffer.readUInt16LE(headerPos);
headerPos += 2;
}
const raw = buffer.slice(0, header.totalImageSize);
assert(Buffer.compare(header.otaUpgradeFileIdentifier, upgradeFileIdentifier) === 0, 'Not an OTA file');
let position = header.otaHeaderLength;
const elements = [];
while (position < header.totalImageSize) {
const element = parseSubElement(buffer, position);
elements.push(element);
position += element.data.length + 6;
}
assert(position === header.totalImageSize, 'Size mismatch');
return {header, elements, raw};
}
function validateImageData(image) {
for (const element of image.elements) {
const {data} = element;
if (data.readUInt32BE(0) === gblTagHeader) {
validateSilabsGbl(data);
} else {
const tag = data.readUInt16BE(0);
if (tag === eblTagHeader || tag === eblTagEncHeader ) {
validateSilabsEbl(data);
}
}
}
}
function validateSilabsEbl(data) {
const dataLength = data.length;
let position = 0;
while (position + 4 <= dataLength) {
const tag = data.readUInt16BE(position);
const len = data.readUInt16BE(position + 2);
position += 4 + len;
if (tag !== eblTagEnd) {
continue;
}
for (let position2 = position; position2 < dataLength; position2++) {
assert(data.readUInt8(position2) === eblPadding, `Image padding contains invalid bytes`);
}
const calculatedCrc32 = crc32.unsigned(data.slice(0, position));
assert(calculatedCrc32 === validSilabsCrc, `Image CRC-32 is invalid`);
return;
}
throw new Error(`Image is truncated: not long enough to contain a valid tag`);
}
function validateSilabsGbl(data) {
const dataLength = data.length;
let position = 0;
while (position + 8 <= dataLength) {
const tag = data.readUInt32BE(position);
const len = data.readUInt32LE(position + 4);
position += 8 + len;
if (tag !== gblTagEnd) {
continue;
}
for (let position2 = position; position2 < dataLength; position2++) {
assert(data.readUInt8(position2) === gblPadding, `Image padding contains invalid bytes`);
}
const calculatedCrc32 = crc32.unsigned(data.slice(0, position));
assert(calculatedCrc32 === validSilabsCrc, `Image CRC-32 is invalid`);
return;
}
throw new Error(`Image is truncated: not long enough to contain a valid tag`);
}
function cancelWaiters(waiters) {
for (const waiter of Object.values(waiters)) {
if (waiter) {
waiter.cancel();
}
}
}
function sendQueryNextImageResponse(endpoint, image, logger) {
const payload = {
status: 0,
manufacturerCode: image.header.manufacturerCode,
imageType: image.header.imageType,
fileVersion: image.header.fileVersion,
imageSize: image.header.totalImageSize,
};
endpoint.commandResponse('genOta', 'queryNextImageResponse', payload).catch((e) => {
logger.debug(`Failed to send queryNextImageResponse (${e.message})`);
});
}
function imageNotify(endpoint) {
return endpoint.commandResponse('genOta', 'imageNotify', {payloadType: 0, queryJitter: 100});
}
async function requestOTA(endpoint) {
// Some devices (e.g. Insta) take very long trying to discover the correct coordinator EP for OTA.
const queryNextImageRequest = endpoint.waitForCommand('genOta', 'queryNextImageRequest', null, 30000);
try {
await imageNotify(endpoint);
return await queryNextImageRequest.promise;
} catch (e) {
queryNextImageRequest.cancel();
throw new Error(`Device didn't respond to OTA request`);
}
}
function getImageBlockResponsePayload(image, imageBlockRequest, pageOffset, pageSize) {
const start = imageBlockRequest.payload.fileOffset + pageOffset;
// When the data size is too big, OTA gets unstable, so default it to 50 bytes maximum.
// For Insta devices, OTA only works for data sizes 40 and smaller (= manufacturerCode 4474).
const maximumDataSize = imageBlockRequest.payload.manufacturerCode === 4474 ? 40 : 50;
let dataSize = Math.min(maximumDataSize, imageBlockRequest.payload.maximumDataSize);
if (pageSize) {
dataSize = Math.min(dataSize, pageSize - pageOffset);
}
let end = start + dataSize;
if (end > image.raw.length) {
end = image.raw.length;
}
return {
status: 0,
manufacturerCode: imageBlockRequest.payload.manufacturerCode,
imageType: imageBlockRequest.payload.imageType,
fileVersion: imageBlockRequest.payload.fileVersion,
fileOffset: start,
dataSize: end - start,
data: image.raw.slice(start, end),
};
}
function callOnProgress(startTime, lastUpdate, imageBlockRequest, image, logger, onProgress) {
const now = Date.now();
// Call on progress every +- 30 seconds
if (lastUpdate === null || (now - lastUpdate) > 30000) {
const totalDuration = (now - startTime) / 1000; // in seconds
const bytesPerSecond = imageBlockRequest.payload.fileOffset / totalDuration;
const remaining = (image.header.totalImageSize - imageBlockRequest.payload.fileOffset) / bytesPerSecond;
let percentage = imageBlockRequest.payload.fileOffset / image.header.totalImageSize;
percentage = Math.round(percentage * 10000) / 100;
logger.debug(`OTA update at ${percentage}%, remaining ${remaining} seconds`);
onProgress(percentage, remaining === Infinity ? null : remaining);
return now;
} else {
return lastUpdate;
}
}
async function isUpdateAvailable(device, logger, isNewImageAvailable, requestPayload, getImageMeta = null) {
logger.debug(`Check if update available for '${device.ieeeAddr}' (${device.modelID})`);
if (requestPayload === null) {
const endpoint = getOTAEndpoint(device);
assert(endpoint !== null, `Failed to find endpoint which support OTA cluster`);
logger.debug(`Using endpoint '${endpoint.ID}'`);
const request = await requestOTA(endpoint);
logger.debug(`Got OTA request '${JSON.stringify(request.payload)}'`);
requestPayload = request.payload;
}
const available = await isNewImageAvailable(requestPayload, logger, device, getImageMeta);
logger.debug(`Update available for '${device.ieeeAddr}': ${available < 0 ? 'YES' : 'NO'}`);
if (available > 0) {
logger.warn(`Firmware on '${device.ieeeAddr}' is newer than latest firmware online.`);
}
return (available < 0);
}
async function isNewImageAvailable(current, logger, device, getImageMeta) {
const meta = await getImageMeta(current, logger, device);
const [currentS, metaS] = [JSON.stringify(current), JSON.stringify(meta)];
logger.debug(`Is new image available for '${device.ieeeAddr}', current '${currentS}', latest meta '${metaS}'`);
return Math.sign(current.fileVersion - meta.fileVersion);
}
async function updateToLatest(device, logger, onProgress, getNewImage, getImageMeta = null, downloadImage = null) {
logger.debug(`Updating to latest '${device.ieeeAddr}' (${device.modelID})`);
const endpoint = getOTAEndpoint(device);
assert(endpoint !== null, `Failed to find endpoint which support OTA cluster`);
logger.debug(`Using endpoint '${endpoint.ID}'`);
const request = await requestOTA(endpoint);
logger.debug(`Got OTA request '${JSON.stringify(request.payload)}'`);
const image = await getNewImage(request.payload, logger, device, getImageMeta, downloadImage);
logger.debug(`Got new image for '${device.ieeeAddr}'`);
const waiters = {};
let lastUpdate = null;
let lastImageBlockResponse = null;
const startTime = Date.now();
return new Promise((resolve, reject) => {
const answerNextImageBlockOrPageRequest = () => {
const imageBlockRequest = endpoint.waitForCommand('genOta', 'imageBlockRequest', null, 150000);
const imagePageRequest = endpoint.waitForCommand('genOta', 'imagePageRequest', null, 150000);
waiters.imageBlockOrPageRequest = {
promise: Promise.race([imageBlockRequest.promise, imagePageRequest.promise]),
cancel: () => {
imageBlockRequest.cancel();
imagePageRequest.cancel();
},
};
waiters.imageBlockOrPageRequest.promise.then(
(imageBlockOrPageRequest) => {
let pageOffset = 0;
let pageSize = 0;
const sendImageBlockResponse = (imageBlockRequest, thenCallback, transactionSequenceNumber) => {
const payload = getImageBlockResponsePayload(image, imageBlockRequest, pageOffset, pageSize);
const now = Date.now();
const timeSinceLastImageBlockResponse = now - lastImageBlockResponse;
// Reduce network congestion by only sending imageBlockResponse min every 250ms.
const cooldownTime = Math.max(imageBlockResponseDelay - timeSinceLastImageBlockResponse, 0);
setTimeout(() => {
endpoint.commandResponse(
'genOta', 'imageBlockResponse', payload, null, transactionSequenceNumber,
).then(
() => {
pageOffset += payload.dataSize;
lastImageBlockResponse = Date.now();
thenCallback();
},
(e) => {
// Shit happens, device will probably do a new imageBlockRequest so don't care.
lastImageBlockResponse = Date.now();
thenCallback();
logger.debug(`Image block response failed (${e.message})`);
},
);
}, cooldownTime);
lastUpdate = callOnProgress(startTime, lastUpdate, imageBlockRequest, image, logger,
onProgress);
};
if ('pageSize' in imageBlockOrPageRequest.payload) {
// imagePageRequest
pageSize = imageBlockOrPageRequest.payload.pageSize;
const handleImagePageRequestBlocks = (imagePageRequest) => {
if (pageOffset < pageSize) {
sendImageBlockResponse(imagePageRequest,
() => handleImagePageRequestBlocks(imagePageRequest));
} else {
answerNextImageBlockOrPageRequest();
}
};
handleImagePageRequestBlocks(imageBlockOrPageRequest);
} else {
// imageBlockRequest
sendImageBlockResponse(imageBlockOrPageRequest, answerNextImageBlockOrPageRequest,
imageBlockOrPageRequest.header.transactionSequenceNumber);
}
},
() => {
cancelWaiters(waiters);
reject(new Error('Timeout: device did not request any image blocks'));
},
);
};
const answerNextImageRequest = () => {
waiters.nextImageRequest = endpoint.waitForCommand('genOta', 'queryNextImageRequest', null, maxTimeout);
waiters.nextImageRequest.promise.then(() => {
answerNextImageRequest();
sendQueryNextImageResponse(endpoint, image, logger);
});
};
// No need to timeout here, will already be done in answerNextImageBlockRequest
waiters.upgradeEndRequest = endpoint.waitForCommand('genOta', 'upgradeEndRequest', null, maxTimeout);
waiters.upgradeEndRequest.promise.then((data) => {
logger.debug(`Got upgrade end request for '${device.ieeeAddr}': ${JSON.stringify(data.payload)}`);
cancelWaiters(waiters);
if (data.payload.status === 0) {
const payload = {
manufacturerCode: image.header.manufacturerCode, imageType: image.header.imageType,
fileVersion: image.header.fileVersion, imageSize: image.header.totalImageSize,
currentTime: 0, upgradeTime: 1,
};
endpoint.commandResponse('genOta', 'upgradeEndResponse', payload).then(
() => {
logger.debug(`Update succeeded, waiting for device to restart`);
setTimeout(() => {
onProgress(100, null);
resolve();
}, 90 * 1000);
},
(e) => {
const message = `Upgrade end reponse failed (${e.message})`;
logger.debug(message);
reject(new Error(message));
},
);
} else {
const error = `Update failed with reason: '${endRequestCodeLookup[data.payload.status]}'`;
logger.debug(error);
reject(new Error(error));
}
});
logger.debug('Starting upgrade');
answerNextImageBlockOrPageRequest();
answerNextImageRequest();
// Notify client once more about new image, client should start sending queryNextImageRequest now
imageNotify(endpoint).catch((e) => logger.debug(`Image notify failed (${e})`));
});
}
async function getNewImage(current, logger, device, getImageMeta, downloadImage) {
const meta = await getImageMeta(current, logger, device);
logger.debug(`getNewImage for '${device.ieeeAddr}', meta ${JSON.stringify(meta)}`);
assert(meta.fileVersion > current.fileVersion, 'No new image available');
const download = downloadImage ? await downloadImage(meta, logger) :
await getAxios().get(meta.url, {responseType: 'arraybuffer'});
const checksum = (meta.sha512 || meta.sha256);
if (checksum) {
const hash = crypto.createHash(meta.sha512 ? 'sha512' : 'sha256');
hash.update(download.data);
assert(hash.digest('hex') === checksum, 'File checksum validation failed');
logger.debug(`OTA update checksum validation succeeded for '${device.ieeeAddr}'`);
}
const start = download.data.indexOf(upgradeFileIdentifier);
const image = parseImage(download.data.slice(start));
logger.debug(`getNewImage for '${device.ieeeAddr}', image header ${JSON.stringify(image.header)}`);
assert(image.header.fileVersion === meta.fileVersion, 'File version mismatch');
assert(!meta.fileSize || image.header.totalImageSize === meta.fileSize, 'Image size mismatch');
assert(image.header.manufacturerCode === current.manufacturerCode, 'Manufacturer code mismatch');
assert(image.header.imageType === current.imageType, 'Image type mismatch');
if ('minimumHardwareVersion' in image.header && 'maximumHardwareVersion' in image.header) {
assert(image.header.minimumHardwareVersion <= device.hardwareVersion &&
device.hardwareVersion <= image.header.maximumHardwareVersion, 'Hardware version mismatch');
}
validateImageData(image);
return image;
}
function getAxios() {
let config = {};
const proxy = process.env.HTTPS_PROXY;
if (proxy) {
config = {
proxy: false,
httpsAgent: new HttpsProxyAgent(proxy),
};
}
const axios = require('axios').create(config);
return axios;
}
module.exports = {
upgradeFileIdentifier,
isUpdateAvailable,
parseImage,
validateImageData,
isNewImageAvailable,
updateToLatest,
getNewImage,
getAxios,
};