UNPKG

iobroker.roborock

Version:
256 lines (216 loc) 9.68 kB
import type { AxiosInstance } from "axios"; import * as JSZip from "jszip"; import * as path from "node:path"; import { Roborock } from "../main"; export class AppPluginManager { adapter: Roborock; constructor(adapter: Roborock) { this.adapter = adapter; } public async downloadAppPlugins(): Promise<void> { const loginApi = this.adapter.http_api.loginApi; if (!loginApi) { this.adapter.rLog("System", null, "Error", undefined, undefined, "loginApi not initialized in AppPluginManager", "error"); return; } try { // Ensure we have V5 product info (numeric IDs) await this.adapter.http_api.ensureProductInfo(); // Future: Integrate automated plugin discovery and background updates } catch (e: unknown) { this.adapter.rLog("System", null, "Error", undefined, undefined, `Failed to prepare for app plugin download: ${this.adapter.errorMessage(e)}`, "error"); return; } } private static readonly ASSETS_BASE = "assets"; private async hasAssetsForModel(model: string): Promise<boolean> { if (!model || model === "default") return true; const versionFilePath = `${AppPluginManager.ASSETS_BASE}/${model}/version`; try { return await this.adapter.fileExistsAsync(this.adapter.name, versionFilePath); } catch { return false; } } /** Download assets for model if version file missing (startup only). */ public async downloadAssetsForModelIfMissing(model: string): Promise<void> { if (!model || model === "default") return; if (await this.hasAssetsForModel(model)) return; const loginApi = this.adapter.http_api.loginApi; if (!loginApi) return; const devices = this.adapter.http_api.getDevices() || []; const deviceWithModel = devices.find(d => this.adapter.http_api.getRobotModel(d.duid) === model); if (deviceWithModel) { await this.updateProduct(deviceWithModel.duid); return; } await this.adapter.http_api.ensureProductInfo(); const numericProductId = this.adapter.http_api.getProductIdByModel(model) || 0; if (numericProductId <= 0) return; const appPluginRequest = { apilevel: 10042, type: 2, productids: [numericProductId] }; const vacuumIDs: Record<string, string> = { [numericProductId.toString()]: model }; try { const packageData = await loginApi.post("api/v1/appplugin", appPluginRequest); if (packageData.data.code !== 200) return; const packages = packageData.data.data; for (const rr_package in packages) { let vacuumModel = vacuumIDs[packages[rr_package].productid]; if (!vacuumModel) vacuumModel = model; if (vacuumModel !== model) continue; const zipUrl = packages[rr_package].url; const newVersion = packages[rr_package].version; await this.downloadAndExtractAssetZip(loginApi, zipUrl, vacuumModel, newVersion, null); return; } } catch { // ignore } } private async downloadAndExtractAssetZip( loginApi: AxiosInstance, zipUrl: string, vacuumModel: string, newVersion: string, duid: string | null ): Promise<boolean> { const assetDir = `${AppPluginManager.ASSETS_BASE}/${vacuumModel}`; const versionFilePath = `${assetDir}/version`; let reason = ""; const versionExists = await this.adapter.fileExistsAsync(this.adapter.name, versionFilePath); if (!versionExists) { try { await this.adapter.readDirAsync(this.adapter.name, assetDir); reason = "Version file missing"; } catch { reason = "Assets missing"; } } else { const versionResult = await this.adapter.readFileAsync(this.adapter.name, versionFilePath); const content = (typeof versionResult === "object" && versionResult !== null && "file" in versionResult) ? (versionResult as { file: Buffer }).file.toString("utf8").trim() : String(versionResult).trim(); const cur = parseInt(content, 10); const rem = parseInt(newVersion, 10); if (isNaN(rem) || (!isNaN(cur) && rem <= cur)) return true; reason = `New version (${cur} → ${rem})`; } try { await this.adapter.mkdirAsync(this.adapter.name, assetDir); } catch (e) { this.adapter.rLog("System", duid ?? null, "Error", undefined, undefined, `Could not create asset directory ${assetDir}: ${e}`, "error"); return false; } this.adapter.rLog("Cloud", duid ?? null, "Info", undefined, undefined, `Downloading assets for ${vacuumModel} (${reason})...`, "info"); try { const response = await loginApi.get(zipUrl, { responseType: "arraybuffer" }); const zip = await JSZip.loadAsync(response.data); let extractedCount = 0; const filePromises: Promise<void>[] = []; const createdDirs = new Set<string>(); zip.forEach((relativePath, file) => { const isTarget = relativePath.startsWith("drawable-") || relativePath.startsWith("raw/"); if (isTarget && !file.dir) { filePromises.push((async () => { const fileContent = await file.async("nodebuffer"); if (fileContent) { const targetPath = `${assetDir}/${relativePath}`; const targetDir = path.dirname(targetPath).replace(/\\/g, "/"); if (!createdDirs.has(targetDir)) { createdDirs.add(targetDir); await this.adapter.mkdirAsync(this.adapter.name, targetDir); } await this.adapter.writeFileAsync(this.adapter.name, targetPath, fileContent as Buffer); extractedCount++; } })()); } }); await Promise.all(filePromises); if (extractedCount > 0) { this.adapter.rLog("Cloud", duid ?? null, "Info", undefined, undefined, `Extracted ${extractedCount} assets to ${assetDir}`, "info"); try { const versionBuf = Buffer.from(String(newVersion), "utf8"); await this.adapter.writeFileAsync(this.adapter.name, versionFilePath, versionBuf); } catch (e: unknown) { this.adapter.rLog("Cloud", duid ?? null, "Error", undefined, undefined, `Failed to write version file ${versionFilePath}: ${this.adapter.errorMessage(e)}`, "error"); return false; } return true; } this.adapter.rLog("Cloud", duid ?? null, "Warn", undefined, undefined, `No assets found in zip for ${vacuumModel}.`, "warn"); } catch (err: unknown) { this.adapter.rLog("Cloud", duid ?? null, "Error", undefined, undefined, `Failed to download/extract assets for ${vacuumModel}: ${this.adapter.errorMessage(err)}`, "error"); } return false; } async updateProduct(duid: string) { const loginApi = this.adapter.http_api.loginApi; if (!loginApi) { this.adapter.rLog("System", null, "Error", undefined, undefined, "loginApi not initialized in AppPluginManager", "error"); return; } const devices = this.adapter.http_api.getDevices(); const device = devices.find(d => d.duid === duid); if (!device) { this.adapter.rLog("Cloud", duid, "Warn", undefined, undefined, "Cannot update product assets: Device not found.", "warn"); return; } const appPluginRequest: any = { apilevel: 1000, type: 2 }; let numericProductId = 0; await this.adapter.http_api.ensureProductInfo(); let vacuumModel = "unknown_model"; const modelStr = this.adapter.http_api.getRobotModel(duid); if (modelStr) vacuumModel = modelStr; const resolvedId = this.adapter.http_api.getProductIdByModel(vacuumModel); if (resolvedId) { numericProductId = resolvedId; } else { const productInfo = this.adapter.http_api.productInfo; const hasV3 = !!(this.adapter.http_api.homeData && this.adapter.http_api.homeData.products); const hasV5 = !!(productInfo && productInfo.data); let v5Count = 0; if (productInfo?.data?.categoryDetailList) { for (const c of productInfo.data.categoryDetailList) { if (c.productList) v5Count += c.productList.length; } } const v5List = `V5Products(${v5Count})`; this.adapter.rLog("Cloud", duid, "Warn", undefined, undefined, `Could not find numeric ID for ${vacuumModel}. V3Products: ${hasV3}, V5Data: ${hasV5}, V5Counts: ${v5List}`, "warn"); } if (numericProductId > 0) { appPluginRequest.productids = [numericProductId]; appPluginRequest.apilevel = 10042; } else { this.adapter.rLog("Cloud", duid, "Warn", undefined, undefined, `Falling back to request with string productId ${device.productId} (might fail)`, "warn"); } const vacuumIDs: Record<string, string> = { [device.productId]: vacuumModel, [numericProductId.toString()]: vacuumModel }; try { const packageData = await loginApi.post("api/v1/appplugin", appPluginRequest); if (packageData.data.code !== 200) { this.adapter.rLog("Cloud", duid, "Warn", undefined, undefined, `AppPlugin (assets) request failed: ${JSON.stringify(packageData.data)}`, "warn"); return; } const packages = packageData.data.data; const processedModels = new Set<string>(); for (const rr_package in packages) { let vacuumModel = vacuumIDs[packages[rr_package].productid]; if (!vacuumModel) vacuumModel = "unknown"; if (vacuumModel === "unknown_model" || !vacuumModel) { const pid = packages[rr_package].productid; if (pid == numericProductId || pid == device.productId) vacuumModel = modelStr || "unknown"; } if (!vacuumModel || vacuumModel === "unknown" || processedModels.has(vacuumModel)) continue; processedModels.add(vacuumModel); // Skip if another caller already downloaded (e.g. single-flight finished) if (await this.hasAssetsForModel(vacuumModel)) continue; const zipUrl = packages[rr_package].url; const newVersion = packages[rr_package].version; await this.downloadAndExtractAssetZip(loginApi, zipUrl, vacuumModel, newVersion, duid); } } catch (e: unknown) { this.adapter.rLog("Cloud", duid, "Error", undefined, undefined, `Failed to update product assets: ${this.adapter.errorMessage(e)}`, "error"); } } }