iobroker.roborock
Version:
256 lines (216 loc) • 9.68 kB
text/typescript
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");
}
}
}