node-red-contrib-xmihome
Version:
Node-RED nodes for controlling Xiaomi Mi Home devices using the xmihome library.
2 lines (1 loc) • 5.38 kB
JavaScript
import I from"xmihome";import{CACHE_TTL as B}from"xmihome/constants.js";var Z=new Map,W=new Map;class M{#F;#z;#G;#J;devices=new Map;subscriptions=new Map;disconnectTimers=new Map;endpoint={};deviceCache={devices:[],timestamp:0,error:null};constructor(J,G,F){this.#z=J,this.#G=G,this.#F=F,this.endpoint.devices=`/xmihome/${this.#z.id}/devices`,this.endpoint.auth=`/xmihome/${this.#z.id}/auth`,this.endpoint.auth_ticket=`/xmihome/${this.#z.id}/auth/submit_ticket`,this.endpoint.auth_captcha=`/xmihome/${this.#z.id}/auth/submit_captcha`,this.#F.httpAdmin.get(this.endpoint.devices,F.auth.needsPermission("xmihome-config.read"),this.#K.bind(this)),this.#F.httpAdmin.post(this.endpoint.auth,F.auth.needsPermission("xmihome-config.write"),this.#O.bind(this)),this.#F.httpAdmin.post(this.endpoint.auth_ticket,F.auth.needsPermission("xmihome-config.write"),this.#Q.bind(this)),this.#F.httpAdmin.post(this.endpoint.auth_captcha,F.auth.needsPermission("xmihome-config.write"),this.#V.bind(this)),this.#z.on("close",this.#W.bind(this))}get client(){if(!this.#J)this.#J=new I({credentials:this.#z.credentials,credentialsFile:this.#G.credentialsFile,connectionType:this.#G.connectionType==="auto"?null:this.#G.connectionType,logLevel:this.#G.debug?"debug":"none"});return this.#J}async getDevices(J=!1,G=void 0){if(Z.has(this.#z.id))return this.#z.debug("Device refresh already in progress, returning existing promise."),Z.get(this.#z.id);let F=Date.now();if(!J&&this.deviceCache.devices.length>0&&F-this.deviceCache.timestamp<B)return this.#z.debug("Using cached device list (TTL not expired)."),Promise.resolve(this.deviceCache.devices);this.#z.debug(`Initiating device cache refresh (force=${J})...`),this.deviceCache.error=null;let K=(async()=>{try{let z=await this.client.getDevices({...G&&{timeout:G}});return this.deviceCache.devices=z||[],this.deviceCache.timestamp=Date.now(),this.#z.log(`Device cache refreshed. Found ${this.deviceCache.devices.length} devices.`),this.deviceCache.devices}catch(z){throw this.#z.error(`Failed to refresh device cache: ${z.message}`,z),this.deviceCache.error=z.message||"Unknown error",z}finally{Z.delete(this.#z.id),this.#z.debug("Refresh promise removed.")}})();return Z.set(this.#z.id,K),this.#z.debug("Refresh promise created and stored."),K}async#K(J,G){try{await this.getDevices(J.query.force==="true"),G.json({devices:this.deviceCache.devices,loading:Z.has(this.#z.id),error:this.deviceCache.error,timestamp:this.deviceCache.timestamp})}catch(F){G.status(500).json({devices:this.deviceCache.devices,loading:!1,error:F.message||"Failed to refresh device list",timestamp:this.deviceCache.timestamp})}}async#O(J,G){let{username:F,password:K,country:z}=J.body;if(!F||!K||!z)return G.status(400).json({error:"Username, password, and country are required."});let Y={country:z,username:F,password:K==="__PWRD__"?this.#z.credentials.password:K},U=new I({credentials:Y,logLevel:this.#G.debug?"debug":"none"}),Q=this.#F.util.generateId(),O={res:G,resolve:null};W.set(Q,O);let X={on2fa:(V)=>{return this.#z.debug(`2FA is required. Pausing login process with stateToken: ${Q}`),new Promise(($,x)=>{if(O.resolve=$,!O.res.headersSent)O.res.json({notificationUrl:V,stateToken:Q,status:"2fa_required"});setTimeout(()=>{if(W.has(Q))W.delete(Q),x(Error("2FA prompt timed out after 5 minutes."))},B)})},onCaptcha:(V)=>{return this.#z.debug(`Captcha is required. Pausing login process with stateToken: ${Q}`),new Promise(($,x)=>{if(O.resolve=$,!O.res.headersSent)O.res.json({imageB64:V,stateToken:Q,status:"captcha_required"});setTimeout(()=>{if(W.has(Q))W.delete(Q),x(Error("Captcha prompt timed out after 5 minutes."))},B)})}};try{let V=await U.miot.login(X);if(this.#F.nodes.addCredentials(this.#z.id,{...Y,...V}),O.res&&!O.res.headersSent)O.res.json({status:"success",message:"Login successful! Deploy your changes."})}catch(V){if(this.#z.error(`Interactive login error: ${V.stack||V.message}`),O.res&&!O.res.headersSent)O.res.status(401).json({error:V.message})}finally{W.delete(Q)}}#Q(J,G){let{stateToken:F,ticket:K}=J.body;if(!F||!K)return G.status(400).json({error:"Missing parameters."});let z=W.get(F);if(z?.resolve){this.#z.debug("Resuming login with ticket."),z.res=G;let Y=z.resolve;z.resolve=null,Y(K)}else G.status(408).json({error:"Login session expired or invalid."})}#V(J,G){let{stateToken:F,captCode:K}=J.body;if(!F||!K)return G.status(400).json({error:"Missing parameters."});let z=W.get(F);if(z?.resolve){this.#z.debug("Resuming login with captcha code."),z.res=G;let Y=z.resolve;z.resolve=null,Y(K)}else G.status(408).json({error:"Login session expired or invalid."})}async#W(J,G){this.#z.debug(`Closing config node ${this.#z.id} (removed: ${!!J})`),Z.delete(this.#z.id),W.clear();let F=Object.values(this.endpoint),K=this.#F.httpAdmin._router.stack;for(let z=K.length-1;z>=0;z--)if(K[z].route&&F.includes(K[z].route.path))K.splice(z,1);if(this.#J)try{await this.#J.destroy(),this.#z.log("XiaomiMiHome client destroyed.")}catch(z){this.#z.error(`Error destroying client: ${z.message}`)}finally{this.#J=null}G()}}function b(J){J.nodes.registerType("xmihome-config",function(G){J.nodes.createNode(this,G);let F=this;F.instance=new M(F,G,J)},{credentials:{username:{type:"text"},password:{type:"password"},country:{type:"text"},userId:{type:"text"},ssecurity:{type:"text"},serviceToken:{type:"text"}}})}export{b as default,M as ConfigNode};