import { readFileSync, writeFileSync } from 'fs' import { APIEvent, Service, Characteristic } from 'homebridge' import type { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, } from 'homebridge' import { PLATFORM_NAME, PLUGIN_NAME } from './settings' import { LgAirConditionerPlatformAccessory } from './platformAccessory' import ThinqApi from './thinq/api' import ThinqAuth, { ThinqAuthConfig } from './thinq/auth' import { ThinqConfig, PartialThinqConfig } from './thinq/thinqConfig' const AUTH_REFRESH_INTERVAL = 10 * 60 * 1000 // 10 minutes export type HomebridgeLgThinqPlatformConfig = { remove_offline_devices_on_boot?: boolean } & ThinqAuthConfig /** * HomebridgePlatform * This class is the main constructor for your plugin, this is where you should * parse the user config and discover/register accessories with Homebridge. */ export class HomebridgeLgThinqPlatform implements DynamicPlatformPlugin { public readonly Service: typeof Service = this.api.hap.Service public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic // this is used to track restored cached accessories public readonly accessories: PlatformAccessory[] = [] public thinqAuth: ThinqAuth | undefined public thinqApi: ThinqApi | undefined private didFinishLaunching: Promise<void> private handleFinishedLaunching?: () => void constructor( public readonly log: Logger, public readonly config: PlatformConfig & HomebridgeLgThinqPlatformConfig, public readonly api: API, ) { this.didFinishLaunching = new Promise((resolve) => { // Store the resolver locally. // Steps that depend on this can `await didFinishLaunching`. // When Homebridge is finishes launching, this will be called to resolve. this.handleFinishedLaunching = resolve }) this.log.debug('Finished initializing platform:', this.config.name) this.initialize() // When this event is fired it means Homebridge has restored all cached accessories from disk. // Dynamic Platform plugins should only register new accessories after this event was fired, // in order to ensure they weren't added to homebridge already. This event can also be used // to start discovery of new accessories. this.api.on(APIEvent.DID_FINISH_LAUNCHING, () => { this.log.debug('Executed didFinishLaunching callback') if (this.handleFinishedLaunching) { this.handleFinishedLaunching() } }) } /** * This function is invoked when homebridge restores cached accessories from disk at startup. * It should be used to setup event handlers for characteristics and update respective values. */ configureAccessory(accessory: PlatformAccessory) { this.log.debug('Restoring accessory from cache:', accessory.displayName) // add the restored accessory to the accessories cache so we can track if it has already been registered this.accessories.push(accessory) } async initialize() { try { const thinqConfig = await this.initializeThinqConfig() this.thinqAuth = ThinqAuth.fromConfig(this.log, thinqConfig, this.config) this.thinqApi = new ThinqApi(thinqConfig, this.thinqAuth) await this.inititializeAuth() this.startRefreshTokenInterval() this.discoverDevicesWhenReady() } catch (error) { this.log.error('Error initializing platform', `${error}`) this.log.debug('Full error', error) } } async initializeThinqConfig() { const partialThinqConfig: PartialThinqConfig = { // If a user installs via the homebridge UI, these values // may not be guaranteed countryCode: this.config.country_code || 'US', languageCode: this.config.language_code || 'en-US', } const gatewayUri = await ThinqApi.getGatewayUri(partialThinqConfig) const thinqConfig: ThinqConfig = { apiBaseUri: gatewayUri.result.thinq2Uri, accessTokenUri: `https://${partialThinqConfig.countryCode.toLowerCase()}.lgeapi.com/oauth/1.0/oauth2/token`, redirectUri: `https://kr.m.lgaccount.com/login/iabClose`, authorizationUri: `${gatewayUri.result.empSpxUri}/login/signIn`, countryCode: partialThinqConfig.countryCode, languageCode: partialThinqConfig.languageCode, } return thinqConfig } async inititializeAuth() { this.updateAndReplaceConfig() const redirectedUrl = this.config.auth_redirected_url as unknown if (this.thinqAuth?.getIsLoggedIn()) { this.log.info('Already logged into ThinQ') await this.refreshAuth() } else if (typeof redirectedUrl === 'string' && redirectedUrl !== '') { this.log.info('Initiating auth with provided redirect URL') try { await this.thinqAuth!.processLoginResult(redirectedUrl) this.updateAndReplaceConfig() } catch (error) { this.log.error('Error setting refresh token', error) throw error } } else { this.log.debug( 'Redirected URL not stored in config and no existing auth state. Skipping initializeAuth().', ) throw new Error('Auth not ready yet, please log in.') } } private startRefreshTokenInterval() { setInterval(() => this.refreshAuth(), AUTH_REFRESH_INTERVAL) } private async refreshAuth() { this.log.debug('refreshAuth()') try { await this.thinqAuth!.initiateRefreshToken() this.updateAndReplaceConfig() } catch (error) { if ( error instanceof Object && // @ts-expect-error TS2339 from upgrade to Typescript 4.5, proven to work on-device regardless error.body instanceof Object && // @ts-expect-error TS2339 from upgrade to Typescript 4.5, proven to work on-device regardless error.body.error instanceof Object && // @ts-expect-error TS2339 from upgrade to Typescript 4.5, proven to work on-device regardless error.body.error.code === 'LG.OAUTH.EC.4001' ) { this.log.error( 'Login credentials have expired!\n\n' + 'Please re-configure the plugin:\n' + ' 1. Login again\n' + ' 2. Update the "redirected URL" in the config\n' + ' 3. Restart Homebridge\n', ) this.thinqAuth?.clearStoredToken() this.updateAndReplaceConfig() } else { this.log.error('Failed to refresh token', `${error}`) } } } private async discoverDevicesWhenReady() { await this.didFinishLaunching // run the method to discover / register your devices as accessories try { await this.discoverDevices() } catch (error) { const errorString = `${error}` this.log.error('Error discovering devices', `${error}`) if (errorString.includes('status code 400')) { this.log.error( 'This can sometimes indicate the LG App has new agreements you must accept. If so:\n' + ' 1. Open the native LG App and sign in as usual\n' + ' 2. If an agreement pops up, review and accept it if appropriate\n' + ' 3. Restart Homebridge\n' + 'If there are no agreements to accept, try restarting Homebridge.\n' + "If that still doesn't work, delete the config for this accessory and restart Homebridge to initiate a full reset.", ) } } } /** * This is an example method showing how to register discovered accessories. * Accessories must only be registered once, previously created accessories * must not be registered again to prevent "duplicate UUID" errors. */ async discoverDevices() { if (!this.thinqAuth?.getIsLoggedIn()) { this.log.info('Not logged in; skipping discoverDevices()') return } const dashboardResponse = await this.thinqApi!.getDashboard() this.log.debug('dashboardResponse', dashboardResponse) this.log.info( `Discover found ${dashboardResponse.result.item.length} total devices`, ) const devices = dashboardResponse.result.item.filter((item) => { if (typeof item !== 'object') { this.log.debug('Item is not an object, ignoring') return false } if (item.deviceType !== 401) { // Air Conditioners have a 401 device type this.log.debug(`deviceType is ${item.deviceType}, ignoring`) return false } if (item.platformType !== 'thinq2') { this.log.error( `"${item.alias}" (model ${item.modelName}) uses the ${item.platformType} platform, which is not supported. ` + `Please see https://github.com/sman591/homebridge-lg-thinq-ac/issues/4 for updates.`, ) return false } return true }) // Keep a running list of all accessories we register or know were already registered const matchedAccessories: PlatformAccessory[] = [] // loop over the discovered devices and register each one if it has not already been registered for (const device of devices) { // generate a unique id for the accessory this should be generated from // something globally unique, but constant, for example, the device serial // number or MAC address const uuid = this.api.hap.uuid.generate(device.deviceId) const matchingAccessories = this.accessories.filter( (accessory) => accessory.UUID === uuid, ) if (matchingAccessories.length > 0) { this.log.info('Existing accessory:', device.alias) // check that the device has not already been registered by checking the // cached devices we stored in the `configureAccessory` method above for (const accessory of matchingAccessories) { accessory.context.device = device matchedAccessories.push(accessory) new LgAirConditionerPlatformAccessory(this, accessory) } } else if (!device.online) { this.log.info( `Accessory "${device.alias}" is offline and will not be added.`, ) } else { this.log.info('Registering new accessory:', device.alias) // create a new accessory const accessory = new this.api.platformAccessory(device.alias, uuid) // store a copy of the device object in the `accessory.context` // the `context` property can be used to store any data about the accessory you may need accessory.context.device = device // create the accessory handler // this is imported from `platformAccessory.ts` new LgAirConditionerPlatformAccessory(this, accessory) // link the accessory to your platform this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [ accessory, ]) // push into accessory cache this.accessories.push(accessory) matchedAccessories.push(accessory) } } // Unregister offline accessories if desired (set by config) if (this.config.remove_offline_devices_on_boot) { this.log.info('Attempting to remove offline devices from HomeKit...') // Only remove once when config is enabled this.config.remove_offline_devices_on_boot = false this.updateAndReplaceConfig() let wasADeviceRemoved = false this.accessories.forEach((accessory) => { const deviceContext = accessory.context?.device if (deviceContext?.online === false) { this.log.info( `Removing offline device "${accessory.displayName}" from HomeKit. If you need this device again, please restart Homebridge.`, ) // @ts-expect-error This is a hack const jsInstance = accessory.jsInstance as | LgAirConditionerPlatformAccessory | undefined if ( jsInstance && jsInstance instanceof LgAirConditionerPlatformAccessory ) { jsInstance?.unregisterAccessory() wasADeviceRemoved = true } else { this.log.warn( `Device "${accessory.displayName}" is offline, but could not be removed. Please file a bug with homebridge-lg-thinq-ac.`, ) } } }) if (!wasADeviceRemoved) { this.log.warn( 'remove_offline_devices_on_boot was attempted but no offline devices were found', ) } } // Unregister accessories that weren't matched from the API response. // This helps clean up devices which: // - You no longer have connected to your account // - Were mistakenly registered in an older version of this plugin but aren't actually supported this.accessories.forEach((accessory) => { const didMatchAccessory = matchedAccessories.some( (matchedAccessory) => matchedAccessory.UUID === accessory.UUID, ) if (!didMatchAccessory) { this.log.info( 'Un-registering unknown accessory:', accessory.displayName, ) this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [ accessory, ]) } }) } getRefreshIntervalMinutes() { const fallbackDefault = 1 try { const parsedValue = parseFloat(this.config.refresh_interval) if (parsedValue > 0.1 && parsedValue < 100000) { return parsedValue } } catch (error) { this.log.error('Failed to parse refresh_interval from config', error) } this.log.debug('Using fallback refresh interval') return fallbackDefault } updateAndReplaceConfig() { const configPath = this.api.user.configPath() const configString = readFileSync(configPath).toString() try { const config = JSON.parse(configString) // this.log.debug('config', config) DO NOT COMMIT THIS -- it could accidentally leak into GitHub issue reports const platforms = config.platforms.filter( (platform: Record<string, string>) => platform.platform === 'LgThinqAirConditioner', ) const authConfig = this.thinqAuth!.serializeToConfig() const generalConfig = { remove_offline_devices_on_boot: this.config.remove_offline_devices_on_boot || false, } const platformConfig: Required<HomebridgeLgThinqPlatformConfig> = { ...authConfig, ...generalConfig, } for (const platform of platforms) { Object.assign(platform, platformConfig) } writeFileSync(configPath, JSON.stringify(config)) } catch (error) { this.log.error('Failed to store updated config', `${error}`) this.log.debug('Full error:', error) } } }