UNPKG

zwave-js

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

256 lines 10.6 kB
import { RFRegion, ZWaveError, ZWaveErrorCodes, digest, extractFirmware, guessFirmwareFileFormat, } from "@zwave-js/core"; import { Bytes, ObjectKeyMap, formatId, getenv, padVersion, setTimer, } from "@zwave-js/shared"; function serviceURL() { return getenv("ZWAVEJS_FW_SERVICE_URL") || "https://firmware.zwave-js.io"; } const DOWNLOAD_TIMEOUT = 60000; const CHECK_TIMEOUT = 30000; // The initial check after releasing new updates can be pretty slow. Give it some time. // const MAX_FIRMWARE_SIZE = 10 * 1024 * 1024; // 10MB should be enough for any conceivable Z-Wave chip const MAX_CACHE_SECONDS = 60 * 60 * 24; // Cache for a day at max const CLEAN_CACHE_INTERVAL_MS = 60 * 60 * 1000; // Remove stale entries from the cache every hour // Cache for individual device firmware updates const deviceFirmwareCache = new ObjectKeyMap(); // Queue requests to the firmware update service. Only allow few parallel requests so we can make some use of the cache. let requestQueue; let cleanCacheTimeout; function cleanCache() { cleanCacheTimeout?.clear(); cleanCacheTimeout = undefined; const now = Date.now(); for (const [deviceKey, cached] of deviceFirmwareCache) { if (cached.staleDate < now) { deviceFirmwareCache.delete(deviceKey); } } if (deviceFirmwareCache.size > 0) { cleanCacheTimeout = setTimer(cleanCache, CLEAN_CACHE_INTERVAL_MS).unref(); } } /** Calculates cache expiry time based on response headers */ function calculateCacheExpiry(response) { // Check if we can cache the response if (response.status === 200 && response.headers.has("cache-control")) { const cacheControl = response.headers.get("cache-control"); const age = response.headers.get("age"); const date = response.headers.get("date"); let maxAge; const maxAgeMatch = cacheControl.match(/max-age=(\d+)/); if (maxAgeMatch) { maxAge = Math.max(0, parseInt(maxAgeMatch[1], 10)); } if (maxAge) { let currentAge; if (age) { currentAge = parseInt(age, 10); } else if (date) { currentAge = (Date.now() - Date.parse(date)) / 1000; } else { currentAge = 0; } currentAge = Math.max(0, currentAge); if (maxAge > currentAge) { return Date.now() + Math.min(MAX_CACHE_SECONDS, maxAge - currentAge) * 1000; } } } // Default fallback cache duration return Date.now() + (MAX_CACHE_SECONDS * 1000); } async function makeRequest(url, config) { const { default: ky } = await import("ky"); const response = await ky(url, config); const responseJson = await response.json(); return { data: responseJson, expiry: calculateCacheExpiry(response) }; } function hasExtension(pathname) { return /\.[a-z0-9_]+$/i.test(pathname); } /** Converts the RF region to a format the update service understands */ function rfRegionToUpdateServiceRegion(rfRegion) { switch (rfRegion) { case RFRegion.Europe: case RFRegion["Europe (Long Range)"]: return "europe"; case RFRegion.USA: case RFRegion["USA (Long Range)"]: return "usa"; case RFRegion["Australia/New Zealand"]: return "australia/new zealand"; case RFRegion["Hong Kong"]: return "hong kong"; case RFRegion.India: return "india"; case RFRegion.Israel: return "israel"; case RFRegion.Russia: return "russia"; case RFRegion.China: return "china"; case RFRegion.Japan: return "japan"; case RFRegion.Korea: return "korea"; } } /** * Retrieves the available firmware updates for multiple devices in a single request. * Returns a map of device keys to their respective firmware update information. * Devices missing from the returned map are not known to the firmware update service. */ export async function getAvailableFirmwareUpdatesBulk(deviceIds, options) { // Remove duplicates based on device fingerprint const uniqueDeviceIds = deviceIds.filter((device, index) => index === deviceIds.findIndex((d) => d.manufacturerId === device.manufacturerId && d.productType === device.productType && d.productId === device.productId && d.firmwareVersion === device.firmwareVersion)); const now = Date.now(); const freshDevices = []; const staleDevices = []; // Split devices into those with fresh cache and those needing updates for (const device of uniqueDeviceIds) { const cached = deviceFirmwareCache.get(device); if (cached && cached.staleDate > now) { freshDevices.push(device); } else { staleDevices.push(device); } } // If we have devices with stale cache, make a request for them if (staleDevices.length > 0) { const headers = new Headers({ "User-Agent": options.userAgent, "Content-Type": "application/json", }); if (options.apiKey) { headers.set("X-API-Key", options.apiKey); } const body = { devices: staleDevices.map((device) => ({ manufacturerId: formatId(device.manufacturerId), productType: formatId(device.productType), productId: formatId(device.productId), firmwareVersion: device.firmwareVersion, })), }; const rfRegion = rfRegionToUpdateServiceRegion(options.rfRegion); if (rfRegion) { body.region = rfRegion; } const url = `${serviceURL()}/api/v4/updates`; const config = { method: "POST", json: body, headers, timeout: CHECK_TIMEOUT, }; if (!requestQueue) { // I just love ESM const PQueue = (await import("p-queue")).default; requestQueue = new PQueue({ concurrency: 2 }); } const requestResult = await requestQueue.add(() => makeRequest(url, config)); const { data: result, expiry } = requestResult; for (const deviceResponse of result) { // Find the original device info to get the RF region const originalDevice = staleDevices.find((device) => formatId(device.manufacturerId) === deviceResponse.manufacturerId && formatId(device.productType) === deviceResponse.productType && formatId(device.productId) === deviceResponse.productId && padVersion(device.firmwareVersion) === padVersion(deviceResponse.firmwareVersion)); if (originalDevice) { const updates = deviceResponse.updates .map((update) => ({ device: originalDevice, ...update, channel: update.channel ?? "stable", })); deviceFirmwareCache.set(originalDevice, { updates, staleDate: expiry, }); } } } // Build the final result map with all requested devices that have updates const ret = new ObjectKeyMap(); for (const deviceId of uniqueDeviceIds) { const updates = deviceFirmwareCache.get(deviceId)?.updates; if (updates) { ret.set(deviceId, updates); } } // Regularly clean the cache if (!cleanCacheTimeout) { cleanCacheTimeout = setTimer(cleanCache, CLEAN_CACHE_INTERVAL_MS).unref(); } return ret; } /** * Retrieves the available firmware updates for the node with the given fingerprint. * Return an empty array if no updates are available or the device is not known to the firmware update service. */ export async function getAvailableFirmwareUpdates(deviceId, options) { // Use the bulk function for a single device const bulkResult = await getAvailableFirmwareUpdatesBulk([deviceId], options); return bulkResult.get(deviceId) || []; } export async function downloadFirmwareUpdate(file) { const [hashAlgorithm, expectedHash] = file.integrity.split(":", 2); if (hashAlgorithm !== "sha256") { throw new ZWaveError(`Unsupported hash algorithm ${hashAlgorithm} for integrity check`, ZWaveErrorCodes.Argument_Invalid); } // TODO: Make request abort-able (requires AbortController, Node 14.17+ / Node 16) // Download the firmware file const { default: ky } = await import("ky"); const downloadResponse = await ky.get(file.url, { timeout: DOWNLOAD_TIMEOUT, // TODO: figure out how to do maxContentLength: MAX_FIRMWARE_SIZE, }); const rawData = new Uint8Array(await downloadResponse.arrayBuffer()); const requestedPathname = new URL(file.url).pathname; // The response may be redirected, so the filename information may be different // from the requested URL let actualPathname; try { actualPathname = new URL(downloadResponse.url).pathname; } catch { // ignore } // Infer the file type from the content-disposition header or the filename let filename; const contentDisposition = downloadResponse.headers.get("content-disposition"); if (contentDisposition?.startsWith("attachment; filename=")) { filename = contentDisposition .split("filename=")[1] .replace(/^"/, "") .replace(/[";]$/, ""); } else if (actualPathname && hasExtension(actualPathname)) { filename = actualPathname; } else { filename = requestedPathname; } // Extract the raw data const format = guessFirmwareFileFormat(filename, rawData); const firmware = await extractFirmware(rawData, format); // Ensure the hash matches const actualHash = Bytes.view(await digest("sha-256", firmware.data)).toString("hex"); if (actualHash !== expectedHash) { throw new ZWaveError(`Integrity check failed. Expected hash ${expectedHash}, got ${actualHash}`, ZWaveErrorCodes.FWUpdateService_IntegrityCheckFailed); } return { data: firmware.data, // Don't trust the guessed firmware target, use the one from the provided info firmwareTarget: file.target, }; } //# sourceMappingURL=FirmwareUpdateService.js.map