iobroker.roborock
Version:
855 lines (731 loc) • 28.5 kB
text/typescript
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
import axios from "axios";
import * as crypto from "node:crypto";
import { Roborock } from "../main";
import { LoginV4Response, ProductV5Response } from "./apiTypes";
import { cryptoEngine } from "./cryptoEngine";
// Constants
const API_V3_SIGN = "api/v3/key/sign";
const API_V4_LOGIN_CODE = "api/v4/auth/email/login/code";
const API_V4_LOGIN_PASSWORD = "api/v4/auth/email/login/pwd";
const API_V4_EMAIL_CODE = "api/v4/email/code/send";
const API_V5_PRODUCT = "api/v5/product";
interface RegionConfig {
apiBaseUrl: string;
loginCountry: string;
loginCountryCode: string;
}
const REGION_CONFIG: Record<string, RegionConfig> = {
eu: {
apiBaseUrl: "https://euiot.roborock.com",
loginCountry: "DE",
loginCountryCode: "49",
},
us: {
apiBaseUrl: "https://usiot.roborock.com",
loginCountry: "US",
loginCountryCode: "1",
},
cn: {
apiBaseUrl: "https://cniot.roborock.com",
loginCountry: "CN",
loginCountryCode: "86",
},
asia: {
apiBaseUrl: "https://api.roborock.com",
loginCountry: "SG", // Default to Singapore for general Asia
loginCountryCode: "65",
},
};
// --------------------
// Interfaces & Types
// --------------------
interface RriotData {
u: string;
s: string;
h: string;
k: string;
r: { a: string; m: string };
}
interface UserData {
token: string;
rriot: RriotData;
}
export interface Device {
duid: string;
localKey: string;
productId: string;
name?: string;
featureSet?: number;
newFeatureSet?: string;
online: boolean;
deviceStatus: Record<string, unknown>;
pv: string;
sn?: string;
}
interface Product {
id: string;
model: string;
category: string;
name?: string;
}
interface Room {
id: number;
name: string;
}
export interface Scene {
id: number;
name: string;
enabled: boolean;
param: string; // JSON string
}
export interface SceneResponse {
result: Scene[];
}
interface HomeData {
rrHomeId: number;
products: Product[];
devices: Device[];
receivedDevices: Device[];
rooms: Room[];
}
/**
* Helper to calculate MD5 hex string
*/
function md5hex(str: string): string {
return crypto.createHash("md5").update(str).digest("hex");
}
export class http_api {
adapter: Roborock;
loginApi: AxiosInstance | null = null;
realApi: AxiosInstance | null = null;
userData: UserData | null = null;
homeData: HomeData | null = null;
homeID: number | null = null;
public productInfo: ProductV5Response | null = null;
private fwFeaturesCache = new Map<string, number[]>();
constructor(adapter: Roborock) {
this.adapter = adapter;
}
/**
* Initializes the HTTP API and authentication for Roborock Cloud.
* @see test/unit/cloud_api_specification.test.ts for the cloud login flow and Hawk signing details.
* @param clientID The client identifier.
*/
async init(clientID: string): Promise<void> {
// Initialize the login API (needed to get access to the real API)
const region = this.adapter.config.region || "eu";
const regionConfig = REGION_CONFIG[region] || REGION_CONFIG["eu"];
this.adapter.rLog("HTTP", null, "Info", "Cloud", undefined, `Initializing HTTP API with region: ${region} (${regionConfig.apiBaseUrl})`, "info");
this.loginApi = axios.create({
baseURL: regionConfig.apiBaseUrl,
headers: {
header_clientid: crypto.createHash("md5").update(this.adapter.config.username).update(clientID).digest().toString("base64"),
header_appversion: "4.57.02",
header_clientlang: "de",
header_phonemodel: "Pixel 9 Pro XL",
header_phonesystem: "Android",
},
});
// Attempt to restore session
await this.loadUserData();
await this.initializeRealApi();
try {
await this.getHomeID();
} catch (e: unknown) {
const msg = this.adapter.errorMessage(e);
if (msg && (msg.includes("invalid token") || msg.includes("auth_failed"))) {
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, "Token expired or invalid. Clearing session and re-authenticating...", "warn");
// Clear bad data
this.userData = null;
this.homeID = null;
await this.adapter.setState("UserData", { val: "", ack: true });
// Re-initialize (will force fresh login because userData is null)
await this.initializeRealApi();
await this.getHomeID();
} else {
throw e; // Rethrow other errors
}
}
}
/**
* Restores UserData from state.
*/
async loadUserData(): Promise<void> {
try {
const userDataState = await this.adapter.getStateAsync("UserData");
if (userDataState && userDataState.val) {
const data = JSON.parse(userDataState.val as string);
if (data && data.token && data.rriot) {
this.userData = data;
this.adapter.rLog("HTTP", null, "Info", "Cloud", undefined, "Restored persisted UserData.", "info");
}
}
} catch {
this.adapter.rLog("HTTP", null, "Debug", "Cloud", undefined, "No previous UserData found or invalid.", "debug");
}
}
/**
* Logs in (if necessary) and sets up the authenticated "Real API" with Hawk authentication.
*/
private loginCodeResolver: ((code: string) => void) | null = null;
public submitLoginCode(code: string): void {
if (this.loginCodeResolver) {
this.loginCodeResolver(code);
this.loginCodeResolver = null;
}
}
async initializeRealApi(): Promise<void> {
this.adapter.rLog("HTTP", null, "Debug", "Cloud", undefined, "Initializing Real API (Hawk Auth)", "debug");
if (!this.loginApi) {
throw new Error("loginApi is not initialized. Call init() first.");
}
if (!this.userData) {
try {
let usePasswordFlow = this.adapter.config.loginMethod === "password" && !!this.adapter.config.password;
// 1. Sign Request (Get K) - needed for both password and code login (for password encryption or code verification)
// Use a random 16-char string for 's' (nonce/salt)
const s = crypto.randomBytes(12).toString("base64").substring(0, 16).replace(/\+/g, "X").replace(/\//g, "Y");
const signData = await this.signRequest(s);
if (!signData) throw new Error("Failed to obtain signature key k");
const k = signData.k;
if (usePasswordFlow) {
this.adapter.rLog("HTTP", null, "Info", "Cloud", undefined, "Starting Password Login Flow...", "info");
try {
const loginResult = await this.loginByPassword(this.adapter.config.password, k, s);
if (loginResult.code === 200) {
this.userData = loginResult.data!;
this.adapter.rLog("HTTP", null, "Info", "Cloud", undefined, "Login with password successful.", "info");
} else if (loginResult.code === 2031) {
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, "Password login requires 2FA (Code 2031). Falling back to 2FA flow.", "warn");
usePasswordFlow = false;
} else {
throw new Error(`Login with password failed: ${JSON.stringify(loginResult)}`);
}
} catch (e: unknown) {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `Password login error: ${this.adapter.errorMessage(e)}`, "error");
// If explicit 2031 (handled above) or other error, we might decide to fallback or fail.
// Current logic: if it was 2031, usePasswordFlow is set to false, so we fall through to 2FA.
// If it was another error (e.g. wrong password), we should probably stop?
// For safety/flexibility, if we flagged 'false' above, we continue. If we threw, we stop.
if (usePasswordFlow) throw e;
}
}
// If not using password flow (or fell back)
if (!this.userData && !usePasswordFlow) {
this.adapter.rLog("HTTP", null, "Info", "Cloud", undefined, "Starting Direct 2FA Login Flow...", "info");
// 1. Request Email Code
await this.requestEmailCode(this.adapter.config.username);
const warning = [
"********************************************************************************",
"ATTENTION: 2FA Code required!",
`An email has been sent to ${this.adapter.config.username}.`,
"Please enter the 6-digit code into the state 'roborock.0.loginCode' immediately.",
"********************************************************************************"
].join("\n");
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, warning, "error");
// State at root: roborock.0.loginCode
const stateId = "loginCode";
await this.adapter.ensureState(stateId, { name: "2FA Login Code", write: true, type: "string", def: "" });
await this.adapter.setState(stateId, { val: "", ack: true });
await this.adapter.subscribeStatesAsync(stateId);
// 2. Wait for Code
let code = "";
try {
code = await new Promise<string>((resolve, reject) => {
this.loginCodeResolver = resolve;
// Timeout after 15 minutes
setTimeout(() => {
if (this.loginCodeResolver) {
this.loginCodeResolver = null;
reject(new Error("Timeout waiting for 2FA code"));
}
}, 15 * 60 * 1000); // 15 min
});
} catch (e: unknown) {
throw e;
}
this.adapter.rLog("HTTP", null, "Info", "Cloud", undefined, "2FA code received. Proceeding with login...", "info");
await this.adapter.unsubscribeStatesAsync(stateId);
// 3. Login with Code
// Regenerate signature to avoid expiration
const newS = crypto.randomBytes(12).toString("base64").substring(0, 16).replace(/\+/g, "X").replace(/\//g, "Y");
const newSignData = await this.signRequest(newS);
if (!newSignData) {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `Failed to re-obtain signature for 2FA.`, "error");
throw new Error("Failed to re-obtain signature for 2FA login");
}
const loginResult = await this.loginWithCode(code, newSignData.k, newS);
if (loginResult.code === 200) {
this.userData = loginResult.data!; // data IS UserData
await this.adapter.setState(stateId, { val: "", ack: true });
} else {
throw new Error(`Login with code failed: ${JSON.stringify(loginResult)}`);
}
}
if (!this.userData) {
throw new Error("Login returned empty userdata.");
}
await this.adapter.setState("UserData", { val: JSON.stringify(this.userData), ack: true });
// Load product definitions (V5 API)
try {
this.productInfo = await this.getProductInfoV5();
if (this.productInfo) {
let count = 0;
if (this.productInfo.data && this.productInfo.data.categoryDetailList) {
for (const cat of this.productInfo.data.categoryDetailList) {
if (cat.productList) count += cat.productList.length;
}
}
this.adapter.rLog("HTTP", null, "Info", "Cloud", undefined, `V5 Product Info fetched. ${count} products available.`, "info");
await this.adapter.setState("info.productInfo", { val: JSON.stringify(this.productInfo), ack: true });
}
} catch (err) {
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, `Failed to get product info V5: ${err}`, "warn");
}
} catch (error: unknown) {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `Error in initializeRealApi: ${this.adapter.errorMessage(error)}`, "error");
throw error;
}
}
if (!this.userData?.token) {
throw new Error("Failed to retrieve user token. Check login credentials.");
}
// Set global auth header for loginApi (though mostly unused after this)
this.loginApi.defaults.headers.common["Authorization"] = this.userData.token;
try {
const rriot = this.get_rriot();
// Initialize the real API with Hawk Authentication Interceptor
const realApi = axios.create({
baseURL: this.userData.rriot.r.a,
headers: {
"x-iotsdk-version": "1.0.1",
"x-app-name": "com.roborock.smart",
"x-app-version-code": "100834",
"x-app-version-name": "4.57.02",
"x-uid": this.userData.rriot.u,
"User-Agent": "UA=RRSDKAndroid/1.0.1",
},
});
realApi.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto
.randomBytes(6)
.toString("base64")
.substring(0, 6)
.replace(/[+/]/g, (m) => (m === "+" ? "X" : "Y"));
// Calculate signature
let urlPath = "";
if (config.url) {
// Handle relative URLs correctly by creating a dummy base if needed
// or using the instance's baseURL if config.url is relative
const fullUrl = axios.getUri(config);
try {
// Provide a dummy base to handle relative URLs returned by getUri
const urlObj = new URL(fullUrl, "http://dummy");
urlPath = urlObj.pathname + urlObj.search;
} catch {
// Fallback if URL construction fails
urlPath = config.url || "";
}
}
const prestr = [rriot.u, rriot.s, nonce, timestamp, md5hex(urlPath), "", ""].join(":");
const mac = crypto.createHmac("sha256", rriot.h).update(prestr).digest("base64");
config.headers["Authorization"] = `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`;
return config;
});
this.realApi = realApi;
await this.adapter.setState("info.connection", { val: true, ack: true });
} catch (error: unknown) {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `Error in initializeRealApi: ${this.adapter.errorStack(error)}`, "error");
await this.adapter.setState("info.connection", { val: false, ack: true });
}
}
async requestEmailCode(username: string): Promise<void> {
if (!this.loginApi) throw new Error("loginApi is not initialized.");
try {
const params = new URLSearchParams();
params.append("type", "login");
params.append("email", username);
params.append("platform", "");
const res = await this.loginApi.post(API_V4_EMAIL_CODE, params.toString());
if (res.data && res.data.code != 200) {
throw new Error(`Start 2FA failed: ${res.data.msg} (Code: ${res.data.code})`);
}
} catch (error: unknown) {
const err = error as { response?: { data?: unknown } };
if (err?.response?.data !== undefined) {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `Request email code failed with response: ${JSON.stringify(err.response.data)}`, "error");
}
throw error; // Re-throw exact error to be caught by caller
}
}
async signRequest(s: string): Promise<{ k: string } | null> {
if (!this.loginApi) return null;
try {
const res = await this.loginApi.post(`${API_V3_SIGN}?s=${s}`);
return res.data.data;
} catch (e: unknown) {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `SignRequest failed: ${this.adapter.errorMessage(e)}`, "error");
return null;
}
}
async loginWithCode(code: string, k: string, s: string): Promise<LoginV4Response> {
if (!this.loginApi) throw new Error("loginApi not initialized");
const headers = {
"x-mercy-k": k,
"x-mercy-ks": s
// content-type application/x-www-form-urlencoded is default for axios with URLSearchParams
};
const region = this.adapter.config.region || "eu";
const regionConfig = REGION_CONFIG[region] || REGION_CONFIG["eu"];
const params = new URLSearchParams({
country: regionConfig.loginCountry,
countryCode: regionConfig.loginCountryCode,
email: this.adapter.config.username,
code: code,
majorVersion: "14",
minorVersion: "0"
});
try {
const res = await this.loginApi.post(API_V4_LOGIN_CODE, params.toString(), { headers });
return res.data;
} catch (e: unknown) {
throw new Error(`Login with code failed: ${this.adapter.errorMessage(e)}`);
}
}
async loginByPassword(password: string, k: string, s: string): Promise<LoginV4Response> {
if (!this.loginApi) throw new Error("loginApi not initialized");
this.adapter.rLog("HTTP", null, "->", "Cloud", undefined, `Attempting Password Login for user: ${this.adapter.config.username}`, "info");
const encryptedPassword = cryptoEngine.encryptPassword(password, k);
const headers = {
"x-mercy-k": k,
"x-mercy-ks": s
};
const params = new URLSearchParams({
email: this.adapter.config.username,
password: encryptedPassword,
majorVersion: "14",
minorVersion: "0"
});
try {
const res = await this.loginApi.post(API_V4_LOGIN_PASSWORD, params.toString(), { headers });
return res.data;
} catch (e: unknown) {
const err = e as { response?: { data?: LoginV4Response } };
if (err?.response?.data) {
const errData = err.response.data as LoginV4Response;
this.adapter.rLog("HTTP", null, "<-", "Cloud", undefined, `Login Failed. Code: ${errData.code}, Msg: ${errData.msg}`, "error");
return errData;
}
throw new Error(`Login with password failed: ${this.adapter.errorMessage(e)}`);
}
}
async getProductInfoV5(): Promise<ProductV5Response | null> {
if (!this.loginApi) return null;
try {
const res = await this.loginApi.get(API_V5_PRODUCT);
if (res.data && res.data.data) {
this.productInfo = res.data; // Store it
return res.data;
}
return null;
} catch (e: unknown) {
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, `getProductInfoV5 failed: ${this.adapter.errorMessage(e)}`, "warn");
return null;
}
}
async ensureProductInfo(): Promise<void> {
if (!this.productInfo) {
this.adapter.rLog("HTTP", null, "Debug", "Cloud", undefined, "ProductInfo not present. Fetching V5 Product Info...", "debug");
await this.getProductInfoV5();
}
}
async downloadProductImages() {
if (!this.adapter.config.downloadRoborockImages) return;
await this.ensureProductInfo();
if (!this.productInfo?.data?.categoryDetailList) {
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, "Cannot download images: categoryDetailList missing.", "warn");
return;
}
for (const cat of this.productInfo.data.categoryDetailList) {
if (!cat.productList) continue;
for (const p of cat.productList) {
if (p.picurl) {
try {
const safeId = p.model.replace(/\./g, "_");
const res = await axios.get(p.picurl, { responseType: "arraybuffer" });
if (res.status === 200) {
const base64 = Buffer.from(res.data, "binary").toString("base64");
const stateId = `Products.${safeId}.image`;
await this.adapter.setObjectNotExistsAsync(stateId, {
type: "state",
common: {
name: p.model + " Image",
type: "string",
role: "value",
read: true,
write: false
},
native: {}
});
await this.adapter.setState(stateId, { val: base64, ack: true });
}
} catch (e) {
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, `Failed to download image for ${p.model}: ${e}`, "warn");
}
}
}
}
}
/**
* Retrieves the Home ID from the API.
*/
async getHomeID(): Promise<void> {
if (!this.loginApi) {
throw new Error("loginApi is not initialized. Call init() first.");
}
try {
const response = await this.loginApi.get("api/v1/getHomeDetail");
if (response.data.data) {
this.adapter.rLog("HTTP", null, "Debug", "Cloud", undefined, `getHomeDetail: ${JSON.stringify(response.data)}`, "debug");
this.homeID = response.data.data.rrHomeId;
this.adapter.rLog("HTTP", null, "Debug", "Cloud", undefined, `this.homeID: ${this.homeID}`, "debug");
} else {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `failed to get getHomeDetail: ${response.data.msg}`, "error");
if (response.data.msg === "invalid token" || response.data.code === 401) {
throw new Error("invalid token");
}
}
} catch (error: unknown) {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `Error getting HomeID: ${this.adapter.errorMessage(error)}`, "error");
throw error;
}
}
/**
* Downloads the latest Home Data (Devices, Rooms, Products) and stores it in state.
* Uses GET v3/user/homes/{homeID} only (same as Roborock app).
*/
async updateHomeData(): Promise<void> {
if (!this.loginApi) throw new Error("loginApi is not initialized. Call init() first.");
if (!this.realApi) throw new Error("realApi is not initialized. Call initializeRealApi() first");
if (this.homeID) {
try {
const res = await this.realApi.get<{ success?: boolean; result?: { id: number; products?: Product[]; devices?: Device[]; receivedDevices?: Device[]; rooms?: Room[] } }>(`v3/user/homes/${this.homeID}`);
if (res.data?.success && res.data?.result) {
const result = res.data.result;
this.homeData = {
rrHomeId: result.id,
products: result.products || [],
devices: result.devices || [],
receivedDevices: result.receivedDevices || [],
rooms: result.rooms || []
};
this.adapter.rLog("HTTP", null, "<-", "Cloud", undefined, `HomeData updated (HomeID: ${this.homeID}, Devices: ${this.homeData.devices?.length}, Received: ${this.homeData.receivedDevices?.length})`, "debug");
} else {
this.homeData = null;
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, "V3 HomeData response missing or not success.", "warn");
}
await this.adapter.setState("HomeData", { val: JSON.stringify(this.homeData), ack: true });
} catch (e: unknown) {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `Error updating HomeData: ${this.adapter.errorStack(e)}`, "error");
this.homeData = null;
}
} else {
this.adapter.rLog("HTTP", null, "Error", "Cloud", undefined, `No homeId found`, "error");
}
}
/**
* Returns the RRIOT authentication data.
*/
get_rriot(): RriotData {
if (!this.userData) {
throw new Error("this.userData is not initialized. Call updateHomeData() first");
}
return this.userData.rriot;
}
/**
* Resolves the numeric Product ID for a given model.
* Tries V3 HomeData first, then V5 ProductInfo.
*/
getProductIdByModel(modelName: string): number | null {
// 1. Try V5 ProductInfo
if (this.productInfo && this.productInfo.data && Array.isArray(this.productInfo.data.categoryDetailList)) {
for (const detail of this.productInfo.data.categoryDetailList) {
if (detail.productList && Array.isArray(detail.productList)) {
const productV5 = detail.productList.find((p) => p.model === modelName);
if (productV5) {
return Number(productV5.id);
}
}
}
}
// Log if we have info but can't find model
if (this.productInfo) {
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, `Model ${modelName} not found in V5 ProductInfo.`, "warn");
}
return null;
}
/**
* Retrieves scenes for the current home.
*/
async getScenes(): Promise<SceneResponse> {
if (!this.loginApi) throw new Error("loginApi is not initialized.");
if (!this.realApi) throw new Error("realApi is not initialized.");
return await this.realApi.get(`user/scene/home/${this.homeID}`).then((res) => res.data);
}
/**
* Stores firmware feature IDs in the cache for a specific device.
*/
public storeFwFeaturesResult(duid: string, featureIds: number[]): void {
if (Array.isArray(featureIds)) {
this.fwFeaturesCache.set(duid, featureIds);
this.adapter.rLog("HTTP", duid, "Debug", "Cloud", undefined, `Stored FW features result: ${JSON.stringify(featureIds)}`, "debug");
} else {
this.adapter.rLog("HTTP", duid, "Warn", "Cloud", undefined, `Invalid data received for storing FW features: ${JSON.stringify(featureIds)}`, "warn");
}
}
public getFwFeaturesResult(duid: string): number[] | undefined {
return this.fwFeaturesCache.get(duid);
}
/**
* Retrieves firmware update status for a device.
*/
async getFirmwareStates(duid: string): Promise<any> {
try {
if (!this.realApi) throw new Error("realApi is not initialized.");
return await this.realApi.get(`ota/firmware/${duid}/updatev2`);
} catch (error: unknown) {
throw new Error(`Error in getFirmwareStates: ${this.adapter.errorMessage(error)}`);
}
}
/**
* Returns the list of products from HomeData.
*/
getProducts(): Product[] {
if (!this.homeData) return [];
return this.homeData.products || [];
}
/**
* Returns a combined list of owned and shared devices.
* Returns an empty array if homeData is not initialized.
*/
getDevices(): Device[] {
if (!this.homeData) {
return [];
}
return [...(this.homeData.devices || []), ...(this.homeData.receivedDevices || [])];
}
getReceivedDevices(): Device[] {
if (!this.homeData) return [];
return this.homeData.receivedDevices || [];
}
/**
* Checks if a device is a shared device (not owned by the user).
*/
isSharedDevice(duid: string): boolean {
const sharedDevices = this.getReceivedDevices();
return sharedDevices.some((device) => device.duid === duid);
}
/**
* Fetches room list for a shared device (owner's rooms).
* GET user/deviceshare/query/{duid}/rooms
* @returns Array of { id, name } or [] on error / non-shared
*/
async getSharedDeviceRooms(duid: string): Promise<{ id: number; name: string }[]> {
if (!duid || !this.isSharedDevice(duid)) return [];
try {
if (!this.realApi) {
this.adapter.rLog("HTTP", duid, "Warn", "Cloud", undefined, "getSharedDeviceRooms: realApi not initialized.", "warn");
return [];
}
const res = await this.realApi.get<{ success?: boolean; result?: { id: number; name: string }[] }>(`user/deviceshare/query/${duid}/rooms`);
const result = res.data?.result;
if (Array.isArray(result)) return result;
return [];
} catch (e: any) {
this.adapter.rLog("HTTP", duid, "Warn", "Cloud", undefined, `getSharedDeviceRooms failed: ${e?.message || e}`, "warn");
return [];
}
}
/**
* Matches rooms from HomeData and optionally assigns fallback names.
*/
getMatchedRoomIDs(assignFallbackNames = false): { id: number; name: string }[] {
if (!this.homeData || !Array.isArray(this.homeData.rooms)) {
// Not throwing an error here anymore, just logging warning to prevent crashes if rooms are missing
this.adapter.rLog("HTTP", null, "Warn", "Cloud", undefined, "getMatchedRoomIDs: this.homeData.rooms is missing or invalid.", "warn");
return [];
}
let unnamedCounter = 1;
const matchedRooms = this.homeData.rooms.map((room) => {
let name = room.name?.trim();
if (!name && assignFallbackNames) {
name = `Room ${unnamedCounter++}`;
}
return {
id: room.id,
name: name || "",
};
});
if (assignFallbackNames) {
this.adapter.rLog("HTTP", null, "Info", "Cloud", undefined, `Matched ${matchedRooms.length} rooms (fallback names included)`, "info");
}
return matchedRooms;
}
/**
* Maps all devices to their local keys.
*/
getMatchedLocalKeys(): Map<string, string> {
const devices = this.getDevices();
return new Map(devices.map((device) => [device.duid, device.localKey]));
}
/**
* Finds the model name for a given device DUID.
*/
getRobotModel(duid: string): string | null {
if (!duid) {
throw new Error("Parameter duid missing in function getRobotModel");
}
const devices = this.getDevices();
try {
const products = this.getProducts();
const device = devices.find((d) => d.duid === duid);
if (!device) {
this.adapter.rLog("HTTP", duid, "Error", "Cloud", undefined, "Device not found in local homeData", "error");
return null;
}
const product = products.find((p) => p.id === device.productId);
return product ? product.model : null;
} catch (error: unknown) {
this.adapter.rLog("HTTP", duid, "Error", "Cloud", undefined, `Error in getRobotModel: ${this.adapter.errorMessage(error)}`, "error");
return null;
}
}
/**
* Finds the product category for a given device DUID.
*/
getProductCategory(duid: string): string | null {
const devices = this.getDevices();
try {
const products = this.getProducts();
const device = devices.find((d) => d.duid == duid);
if (!device) return null;
const product = products.find((p) => p.id == device.productId);
return product ? product.category : null;
} catch (error: unknown) {
this.adapter.rLog("HTTP", duid, "Error", "Cloud", undefined, `Error in getProductCategory: ${this.adapter.errorMessage(error)}`, "error");
return null;
}
}
public getFeatureSet(duid: string): number | undefined {
const allDevices = this.getDevices();
const device = allDevices.find((d) => d.duid === duid);
return device?.featureSet;
}
public getNewFeatureSet(duid: string): string | undefined {
const allDevices = this.getDevices();
const device = allDevices.find((d) => d.duid === duid);
return device?.newFeatureSet;
}
}