homebridge#DynamicPlatformPlugin TypeScript Examples

The following examples show how to use homebridge#DynamicPlatformPlugin. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: platform.d.ts    From homebridge-tuya-ir with Apache License 2.0 6 votes vote down vote up
/**
 * 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 declare class TuyaIRPlatform implements DynamicPlatformPlugin {
    readonly log: Logger;
    readonly config: PlatformConfig;
    readonly api: API;
    readonly Service: typeof Service;
    readonly Characteristic: typeof Characteristic;
    readonly accessories: PlatformAccessory[];
    cachedAccessories: Map<any, any>;
    constructor(log: Logger, config: PlatformConfig, api: API);
    /**
     * 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): void;
    /**
     * 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.
     */
    discoverDevices(): void;
    discover(tuya: any, i: any, total: any): void;
}
Example #2
Source File: platform.d.ts    From homebridge-plugin-govee with Apache License 2.0 6 votes vote down vote up
/**
 * 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 declare class GoveeHomebridgePlatform implements DynamicPlatformPlugin {
    readonly log: Logger;
    readonly config: PlatformConfig;
    readonly api: API;
    readonly Service: typeof Service;
    readonly Characteristic: typeof Characteristic;
    readonly accessories: PlatformAccessory[];
    private platformStatus?;
    private readonly discoveryCache;
    constructor(log: Logger, config: PlatformConfig, api: API);
    /**
     * 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): void;
    /**
     * 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.
     */
    discoverDevices(): void;
    private goveeDiscoveredReading;
    private goveeScanStarted;
    private goveeScanStopped;
    private sanitize;
}
Example #3
Source File: wled-platform.ts    From homebridge-simple-wled with ISC License 5 votes vote down vote up
export class WLEDPlatform implements DynamicPlatformPlugin {

  accessories: PlatformAccessory[] = [];
  readonly log: Logging;
  readonly api: API;
  readonly config: PlatformConfig;
  private readonly wleds: WLED[] = [];

  constructor(log: Logging, config: PlatformConfig, api: API) {
    this.api = api;

    this.config = config;
    this.log = log;

    if (!config) {
      return;
    }

    if (!config.wleds) {
      this.log("No WLEDs have been configured.");
      return;
    }

    api.on(APIEvent.DID_FINISH_LAUNCHING, this.launchWLEDs.bind(this));
  }

  configureAccessory(accessory: PlatformAccessory): void {
    this.accessories.push(accessory);
  }

  private launchWLEDs(): void {

    for (const wled of this.config.wleds) {

      if (!wled.host) {
        this.log("No host or IP address has been configured.");
        return;
      }

      loadEffects(wled.host).then((effects) => {
          this.wleds.push(new WLED(this, wled, effects));
      }).catch((error) => {
        console.log(error)
      });

    }
  }

}
Example #4
Source File: platform.ts    From homebridge-iRobot with Apache License 2.0 4 votes vote down vote up
/**
 * 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 iRobotPlatform 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[] = [];

  constructor(
    public readonly log: Logger,
    public readonly config: PlatformConfig,
    public readonly api: API,
  ) {
    this.log.debug('Finished initializing platform:', this.config.name);

    // 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('didFinishLaunching', () => {
      log.debug('Executed didFinishLaunching callback');
      // run the method to discover / register your devices as accessories
      this.discoverDevices();
    });
  }

  /**
   * 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.info('Loading accessory from cache:', accessory.displayName);

    // add the restored accessory to the accessories cache so we can track if it has already been registered
    if (accessory.context.pluginVersion === undefined || accessory.context.pluginVersion < 3) {
      this.log.warn('Removing Old Accessory:', accessory.displayName);
      this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
    } else {
      this.accessories.push(accessory);
    }
  }

  /**
   * 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.
   */
  discoverDevices() {
    // loop over the discovered devices and register each one if it has not already been registered
    this.log.info('Logging into iRobot...');
    getRoombas(this.config, this.log).then(devices => {
      for (const device of devices) {
        //this.log.debug('Configuring device: \n', JSON.stringify(device));
        // 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.blid);
        //const accessoryType = 'iRobotPlatformAccesoryV'+device.ver;
        // see if an accessory with the same uuid has already been registered and restored from
        // the cached devices we stored in the `configureAccessory` method above
        const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);

        if (existingAccessory) {
          // the accessory already exists
          this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);

          // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.:
          // existingAccessory.context.device = device;
          // this.api.updatePlatformAccessories([existingAccessory]);

          // create the accessory handler for the restored accessory
          // this is imported from `platformAccessory.ts`
          //new iRobotPlatformAccessory(this, existingAccessory, device);
          //new platformAccessory[accessoryType](this, existingAccessory);
          switch (device.swMajor){
            case 1:
              new platformAccessory.iRobotPlatformAccessoryV1(this, existingAccessory);
              break;
            case 2:
              new platformAccessory.iRobotPlatformAccessoryV2(this, existingAccessory);
              break;
            case 3:
              new platformAccessory.iRobotPlatformAccessoryV3(this, existingAccessory);
              break;
          }
          // it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
          // remove platform accessories when no longer present
          // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
          // this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
        } else {
          // the accessory does not yet exist, so we need to create it
          this.log.info('Adding new accessory:', device.name);

          // create a new accessory
          const accessory = new this.api.platformAccessory(device.name, 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;
          accessory.context.pluginVersion = 3;
          // create the accessory handler for the newly create accessory
          // this is imported from `platformAccessory.ts`
          //new iRobotPlatformAccessory(this, accessory, device);
          //new platformAccessory[accessoryType](this, accessory);
          switch (device.ver){
            case '1':
              new platformAccessory.iRobotPlatformAccessoryV1(this, accessory);
              break;
            case '2':
              new platformAccessory.iRobotPlatformAccessoryV2(this, accessory);
              break;
            case '3':
              new platformAccessory.iRobotPlatformAccessoryV3(this, accessory);
              break;
          }
          // link the accessory to your platform
          this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
        }
      }

    }).catch((error) => {
      this.log.error(error);
    });
  }
}
Example #5
Source File: platform.ts    From homebridge-lg-thinq-ac with Apache License 2.0 4 votes vote down vote up
/**
 * 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)
    }
  }
}
Example #6
Source File: platform.ts    From homebridge-screenlogic with MIT License 4 votes vote down vote up
/**npm
 * 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 ScreenLogicPlatform 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
  private restoredAccessories: PlatformAccessory[] = []

  // bridge controller to talk to shades
  private controller: Controller

  // configuration
  public config: ScreenLogicPlatformConfig

  // fetched config
  private poolConfig?: PoolConfig

  // the time (from Date.now()) when we last initiated or did a refresh
  private lastRefreshTime = 0

  private poolTempAccessory?: TemperatureAccessory
  private spaTempAccessory?: TemperatureAccessory
  private airTempAccessory?: TemperatureAccessory

  private poolThermostatAccessory?: ThermostatAccessory
  private spaThermostatAccessory?: ThermostatAccessory

  private circuitAccessories: CircuitAccessory[] = []

  constructor(public readonly log: Logger, config: PlatformConfig, public readonly api: API) {
    this.log.debug('Finished initializing platform', PLATFORM_NAME)

    this.config = config as ScreenLogicPlatformConfig
    // do this first to make sure we have proper defaults moving forward
    this.applyConfigDefaults(config)

    this.controller = new Controller({
      log: this.log,
      ip_address: this.config.ip_address,
      port: this.config.port,
      username: this.config.username,
      password: this.config.password,
    })

    // 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('didFinishLaunching', () => {
      log.debug('Executed didFinishLaunching callback')
      // run the method to discover / register your devices as accessories
      this.discoverDevices(0)
    })
  }

  private discoverDevices(retryAttempt: number) {
    // test
    this.log.info('discoverDevices')

    const pollingInterval = 60 // TODO: get from config

    this.controller
      .getPoolConfig()
      .then(config => {
        this.poolConfig = config
        this.log.debug('got pool config', this.poolConfig)
        this.log.info(
          `discoverDevices connected: ${this.poolConfig.deviceId} ${this.poolConfig.softwareVersion}`,
        )
        this.setupDiscoveredAccessories(this.poolConfig)
      })
      .catch(err => {
        // on error, start another timeout with backoff
        const timeout = this.backoff(retryAttempt, pollingInterval)
        this.log.error(
          `discoverDevices retryAttempt: ${retryAttempt} timeout: ${timeout} error: ${err}`,
        )
        setTimeout(() => this.discoverDevices(retryAttempt + 1), timeout * 1000)
      })
  }

  private setupDiscoveredAccessories(poolConfig: PoolConfig) {
    // this is used to track active accessories uuids
    const activeAccessories = new Set<string>()

    // make pool temperature sensor if needed
    if (poolConfig.hasPool && !this.config.hidePoolTemperatureSensor) {
      this.poolTempAccessory = this.configureAccessoryType(TemperatureAccessory.makeAdaptor(), {
        displayName: POOL_TEMP_NAME,
        type: POOL_TEMP_NAME,
      } as TemperatureAccessoryContext)
      activeAccessories.add(this.poolTempAccessory.UUID)
    }

    // make spa temperature sensor if needed
    if (poolConfig.hasSpa && !this.config.hideSpaTemperatureSensor) {
      this.spaTempAccessory = this.configureAccessoryType(TemperatureAccessory.makeAdaptor(), {
        displayName: SPA_TEMP_NAME,
        type: SPA_TEMP_NAME,
      } as TemperatureAccessoryContext)
      activeAccessories.add(this.spaTempAccessory.UUID)
    }

    // make air temperature sensor if needed
    if (!this.config.hideAirTemperatureSensor) {
      this.airTempAccessory = this.configureAccessoryType(TemperatureAccessory.makeAdaptor(), {
        displayName: AIR_TEMP_NAME,
        type: AIR_TEMP_NAME,
      } as TemperatureAccessoryContext)
      activeAccessories.add(this.airTempAccessory.UUID)
    }

    // make pool thermostat if needed
    if (poolConfig.hasPool && !this.config.hidePoolThermostat) {
      this.poolThermostatAccessory = this.configureAccessoryType(
        ThermostatAccessory.makeAdaptor(),
        {
          displayName: POOL_THERMOSTAT_NAME,
          bodyType: 0,
          minSetPoint: this.normalizeTemperature(poolConfig.poolMinSetPoint),
          maxSetPoint: this.normalizeTemperature(poolConfig.poolMaxSetPoint),
        } as ThermostatAccessoryContext,
      )
      activeAccessories.add(this.poolThermostatAccessory.UUID)
    }

    // make spa thermostat if needed
    if (poolConfig.hasSpa && !this.config.hideSpaThermostat) {
      this.spaThermostatAccessory = this.configureAccessoryType(ThermostatAccessory.makeAdaptor(), {
        displayName: SPA_THERMOSTAT_NAME,
        bodyType: 1,
        minSetPoint: this.normalizeTemperature(poolConfig.spaMinSetPoint),
        maxSetPoint: this.normalizeTemperature(poolConfig.spaMaxSetPoint),
      } as ThermostatAccessoryContext)
      activeAccessories.add(this.spaThermostatAccessory.UUID)
    }

    // filter out hidden circuits
    const hiddenNames = (this.config.hidden_circuits as string) || ''
    const hiddenCircuits = new Set(hiddenNames.split(',').map(item => item.trim()))

    poolConfig.circuits = poolConfig.circuits.filter(circuit => {
      return !hiddenCircuits.has(circuit.name)
    })

    for (const circuit of poolConfig.circuits) {
      const accessory = this.configureAccessoryType(CircuitAccessory.makeAdaptor(), {
        displayName: circuit.name,
        id: circuit.id,
      } as CircuitAccessoryContext)
      this.circuitAccessories.push(accessory)
      activeAccessories.add(accessory.UUID)
    }

    if (this.config.createLightColorSwitches) {
      const accessory = this.configureAccessoryType(SetColorAccessory.makeAdaptor(), {
        displayName: 'Light Colors',
        id: 1,
      } as CircuitAccessoryContext)
      activeAccessories.add(accessory.UUID)
    }

    // unregister orphaned accessories
    const staleAccessories = this.restoredAccessories.filter(
      accessory => !activeAccessories.has(accessory.UUID),
    )

    if (staleAccessories.length) {
      const staleNames = staleAccessories.map(accessory => accessory.displayName)
      this.log.info('unregistering accessories', staleNames)
      this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, staleAccessories)
    }

    // start polling for status
    this.pollForStatus(0)
  }

  /**
   * 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.
   */
  public configureAccessory(accessory: PlatformAccessory) {
    this.log.info('Loading accessory from cache:', accessory.displayName)

    // add the restored accessory to the accessories cache so we can track if it has already been registered
    this.restoredAccessories.push(accessory)
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private configureAccessoryType<T>(adaptor: AccessoryAdaptor<T>, context: Record<string, any>): T {
    // generate a unique id for this shade based on context
    const uuid = adaptor.generateUUID(this, context)

    // see if an accessory with the same uuid has already been registered and restored from
    // the cached devices we stored in the `configureAccessory` method above
    const existingAccessory = this.restoredAccessories.find(accessory => accessory.UUID === uuid)

    if (existingAccessory) {
      // the accessory already exists
      this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName)

      // update the context if it has changed
      if (!adaptor.sameContext(context, existingAccessory.context)) {
        existingAccessory.context = context
        this.log.info('Updating existing accessory:', context.displayName)
        this.api.updatePlatformAccessories([existingAccessory])
      }
      // create the accessory handler for the restored accessory
      // this is imported from `platformAccessory.ts`
      return adaptor.factory(this, existingAccessory)
    } else {
      // the accessory does not yet exist, so we need to create it
      this.log.info('Adding new accessory:', context.displayName)

      // create a new accessory
      const accessory = new this.api.platformAccessory(context.displayName, uuid)

      // store a copy of the device object in the `accessory.context`
      accessory.context = context

      // create the accessory handler for the newly create accessory
      const newAccessory = adaptor.factory(this, accessory)

      // link the accessory to platform
      this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])

      return newAccessory
    }
  }

  /** start polling process with truncated exponential backoff: https://cloud.google.com/storage/docs/exponential-backoff */
  private pollForStatus(retryAttempt: number) {
    const pollingInterval = this.config.statusPollingSeconds

    this.refreshStatus()
      .then(() => {
        // on success, start another timeout at normal pollingInterval
        this.log.debug('_pollForStatus success, retryAttempt:', retryAttempt)
        setTimeout(() => this.pollForStatus(0), pollingInterval * 1000)
      })
      .catch(err => {
        // on error, start another timeout with backoff
        const timeout = this.backoff(retryAttempt, pollingInterval)
        this.log.error('_pollForStatus retryAttempt:', retryAttempt, 'timeout:', timeout, err)
        setTimeout(() => this.pollForStatus(retryAttempt + 1), timeout * 1000)
      })
  }

  /** gets status,  updates accessories, and resolves */
  private async refreshStatus() {
    try {
      this.lastRefreshTime = Date.now()
      const poolStatus = await this.controller.getPoolStatus()
      this.log.debug('connected:', this.poolConfig?.deviceId, '(getStatus)')
      // update all values
      this.updateAccessories(poolStatus, undefined)
      return null
    } catch (err) {
      this.updateAccessories(undefined, err)
      throw err
    }
  }

  private updateAccessories(status?: PoolStatus, _err?: Error) {
    if (status) {
      this.airTempAccessory?.updateCurrentTemperature(
        this.normalizeTemperature(status.airTemperature),
      )
      this.airTempAccessory?.updateStatusActive(true)

      this.poolTempAccessory?.updateCurrentTemperature(
        this.normalizeTemperature(status.poolTemperature),
      )
      this.poolTempAccessory?.updateStatusActive(status.isPoolActive)

      this.spaTempAccessory?.updateCurrentTemperature(
        this.normalizeTemperature(status.spaTemperature),
      )
      this.spaTempAccessory?.updateStatusActive(status.isSpaActive)

      if (this.poolThermostatAccessory) {
        this.poolThermostatAccessory.updateCurrentTemperature(
          this.normalizeTemperature(status.poolTemperature),
        )
        this.poolThermostatAccessory.updateTargetTemperature(
          this.normalizeTemperature(status.poolSetPoint),
        )
        this.poolThermostatAccessory.updateCurrentHeatingCoolingState(
          status.isPoolHeating
            ? this.Characteristic.CurrentHeatingCoolingState.HEAT
            : this.Characteristic.CurrentHeatingCoolingState.OFF,
        )

        this.poolThermostatAccessory.updateTargetHeatingCoolingState(
          this.mapHeatModeToTargetHeatingCoolingState(status.poolHeatMode),
        )
      }

      if (this.spaThermostatAccessory) {
        this.spaThermostatAccessory.updateCurrentTemperature(
          this.normalizeTemperature(status.spaTemperature),
        )
        this.spaThermostatAccessory.updateTargetTemperature(
          this.normalizeTemperature(status.spaSetPoint),
        )
        this.spaThermostatAccessory.updateCurrentHeatingCoolingState(
          status.isSpaHeating
            ? this.Characteristic.CurrentHeatingCoolingState.HEAT
            : this.Characteristic.CurrentHeatingCoolingState.OFF,
        )

        this.spaThermostatAccessory.updateTargetHeatingCoolingState(
          this.mapHeatModeToTargetHeatingCoolingState(status.spaHeatMode),
        )
      }

      for (const circuitAccessory of this.circuitAccessories) {
        circuitAccessory.updateOn(
          status.circuitState.get(circuitAccessory.context.id) ? true : false,
        )
      }
    }
  }

  /** refresh if cached values are "stale" */
  private refreshIfNeeded(): boolean {
    const now = Date.now()
    if (now - this.lastRefreshTime > 15 * 1000) {
      // set now, so we don't trigger multiple...
      this.lastRefreshTime = now
      this.refreshStatus().catch(err => {
        this.log.error('refreshIfNeeded', err)
      })
      return true
    } else {
      return false
    }
  }

  /** convenience function to add an `on('get')` handler which refreshes accessory values if needed  */
  public triggersRefreshIfNeded(service: Service, type: CharacteristicType): void {
    const characteristic = service.getCharacteristic(type)
    characteristic.on('get', (callback: CharacteristicGetCallback) => {
      // just return current cached value, and refresh if needed
      callback(null, characteristic.value)
      if (this.refreshIfNeeded()) {
        this.log.debug('triggered refresh on get', service.displayName, characteristic.displayName)
      }
    })
  }

  public setTargetTemperature(context: ThermostatAccessoryContext, temperature: number): void {
    if (this.poolConfig === undefined) {
      this.log.warn('setTargetTemperature failed: poolConfig is undefined')
      return
    }
    // need to convert from Celsius to what pool is conifigured for
    const heatPoint = this.poolConfig.isCelsius ? temperature : Math.round(temperature * 1.8 + 32)
    this.controller
      .setHeatPoint(context.bodyType, heatPoint)
      .then(() => {
        this.log.debug(
          'setTargetTemperature: successfully set target temperature: ',
          context.bodyType,
          heatPoint,
        )
      })
      .catch(err => {
        this.log.error('setTargetTemperature', err)
      })
  }

  public setTargetHeatingCoolingState(context: ThermostatAccessoryContext, state: number): void {
    this.controller
      .setHeatMode(context.bodyType, this.mapTargetHeatingCoolingStateToHeatMode(state))
      .then(() => {
        this.log.debug(
          'setTargetHeatingCoolingState: successfully set target heating/cooling state: ',
          context.bodyType,
          state,
        )
      })
      .catch(err => {
        this.log.error('setTargetHeatingCoolingState', err)
      })
  }

  public setCircuitState(context: CircuitAccessoryContext, state: boolean): void {
    this.controller
      .setCircuitState(context.id, state)
      .then(() => {
        this.log.debug('setCircuitState: successfully set circuit state: ', context.id, state)
      })
      .catch(err => {
        this.log.error('setCircuitState', err)
      })
  }

  public sendLightCommand(_context: SetColorAccessoryContext, cmd: number): void {
    this.controller
      .sendLightCommand(cmd)
      .then(() => {
        this.log.debug('sendLightCommand: successfully sent light command: ', cmd)
        // sending the light command will turn on pool/spa lights, so refresh
        setTimeout(() => {
          this.refreshStatus().catch(err => {
            this.log.error('sendLightCommand refresh', err)
          })
        }, 2500)
      })
      .catch(err => {
        this.log.error('sendLightCommand', err)
      })
  }

  public generateUUID(accessorySalt: string) {
    if (this.poolConfig !== undefined) {
      return this.api.hap.uuid.generate(this.poolConfig.deviceId + ':' + accessorySalt)
    } else {
      this.log.error('poolConfig is undefined')
      return ''
    }
  }

  private backoff(retryAttempt: number, maxTime: number): number {
    retryAttempt = Math.max(retryAttempt, 1)
    return Math.min(Math.pow(retryAttempt - 1, 2) + Math.random(), maxTime)
  }

  public accessoryInfo(): {
    manufacturer: string
    model: string
    serialNumber: string
  } {
    if (this.poolConfig) {
      return {
        manufacturer: 'Pentair',
        // store software version in model, since it doesn't follow
        // proper n.n.n format Apple requires and model is a string
        model: this.poolConfig.softwareVersion,
        serialNumber: this.poolConfig.deviceId,
      }
    } else {
      this.log.error('poolConfig is null getting accessoryInfo')
      return {
        manufacturer: 'unknown',
        model: 'unknown',
        serialNumber: '',
      }
    }
  }

  private applyConfigDefaults(config: PlatformConfig) {
    // config.ip_address
    config.port = config.port ?? 80
    // config.username
    // config.password
    // config.hidden_circuits
    config.hideAirTemperatureSensor = config.hideAirTemperatureSensor ?? false
    config.hidePoolTemperatureSensor = config.hidePoolTemperatureSensor ?? false
    config.hideSpaTemperatureSensor = config.hideSpaTemperatureSensor ?? false
    config.hidePoolThermostat = config.hidePoolThermostat ?? false
    config.hideSpaThermostat = config.hideSpaThermostat ?? false
    config.statusPollingSeconds = config.statusPollingSeconds ?? 60
    config.createLightColorSwitches = config.createLightColorSwitches ?? false
    config.disabledLightColors = config.disabledLightColors ?? []
    this.log.debug('config', this.config)
  }

  /** normalize temperature to celsius for homekit */
  normalizeTemperature(temperature: number): number {
    return this.poolConfig?.isCelsius ? temperature : (temperature - 32) / 1.8
  }

  /** map pool heat mode to thermostat target heating/coooling state  */
  mapHeatModeToTargetHeatingCoolingState(poolHeatMode: number) {
    switch (poolHeatMode) {
      case Controller.HEAT_MODE_OFF:
        return this.Characteristic.TargetHeatingCoolingState.OFF
      case Controller.HEAT_MODE_HEAT_PUMP:
        return this.Characteristic.TargetHeatingCoolingState.HEAT
      case Controller.HEAT_MODE_SOLAR_PREFERRED:
        return this.Characteristic.TargetHeatingCoolingState.AUTO
      case Controller.HEAT_MODE_SOLAR:
        return this.Characteristic.TargetHeatingCoolingState.COOL
      default:
        return this.Characteristic.TargetHeatingCoolingState.OFF
    }
  }

  /** map thermostat target heating/coooling state to pool heat mode */
  mapTargetHeatingCoolingStateToHeatMode(targetHeatingCoolingState: number) {
    switch (targetHeatingCoolingState) {
      case this.Characteristic.TargetHeatingCoolingState.OFF:
        return Controller.HEAT_MODE_OFF
      case this.Characteristic.TargetHeatingCoolingState.HEAT:
        return Controller.HEAT_MODE_HEAT_PUMP
      case this.Characteristic.TargetHeatingCoolingState.AUTO:
        return Controller.HEAT_MODE_SOLAR_PREFERRED
      case this.Characteristic.TargetHeatingCoolingState.COOL:
        return Controller.HEAT_MODE_SOLAR
      default:
        return Controller.HEAT_MODE_UNCHANGED
    }
  }
}
Example #7
Source File: platform.ts    From homebridge-eufy-security with Apache License 2.0 4 votes vote down vote up
export class EufySecurityPlatform implements DynamicPlatformPlugin {
  public readonly Service: typeof Service = this.api.hap.Service;
  public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;

  public eufyClient: EufySecurity;

  // this is used to track restored cached accessories
  public readonly accessories: PlatformAccessory[] = [];
  public readonly config: EufySecurityPlatformConfig;
  private eufyConfig: EufySecurityConfig;

  public log;

  constructor(
    public readonly hblog: Logger,
    config: PlatformConfig,
    public readonly api: API,
  ) {
    this.config = config as EufySecurityPlatformConfig;

    this.eufyConfig = {
      username: this.config.username,
      password: this.config.password,
      country: 'US',
      language: 'en',
      persistentDir: api.user.storagePath(),
      p2pConnectionSetup: 0,
      pollingIntervalMinutes: this.config.pollingIntervalMinutes ?? 10,
      eventDurationSeconds: 10,
    } as EufySecurityConfig;


    this.config.ignoreStations = this.config.ignoreStations || [];
    this.config.ignoreDevices = this.config.ignoreDevices || [];

    if (this.config.enableDetailedLogging >= 1) {

      const plugin = require('../package.json');

      this.log = bunyan.createLogger({
        name: '[EufySecurity-' + plugin.version + ']',
        hostname: '',
        streams: [{
          level: (this.config.enableDetailedLogging === 2) ? 'trace' : 'debug',
          type: 'raw',
          stream: bunyanDebugStream({
            forceColor: true,
            showProcess: false,
            showPid: false,
            showDate: (time) => {
              return '[' + time.toLocaleString('en-US') + ']';
            },
          }),
        }],
        serializers: bunyanDebugStream.serializers,
      });
      this.log.info('Eufy Security Plugin: enableDetailedLogging on');
    } else {
      this.log = hblog;
    }

    this.eufyClient = (this.config.enableDetailedLogging === 2)
      ? new EufySecurity(this.eufyConfig, this.log)
      : new EufySecurity(this.eufyConfig);

    // Removing the ability to set Off(6) waiting Bropat feedback bropat/eufy-security-client#27
    this.config.hkOff = (this.config.hkOff === 6) ? 63 : this.config.hkOff;

    // 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('didFinishLaunching', async () => {
      // await this.createConnection();
      // run the method to discover / register your devices as accessories
      await this.discoverDevices();
    });

    this.log.info('Finished initializing Eufy Security Platform');
  }

  /**
   * 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('Loading 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 discoverDevices() {
    this.log.debug('discoveringDevices');

    try {
      await this.eufyClient.connect();
      this.log.debug('EufyClient connected ' + this.eufyClient.isConnected());
    } catch (e) {
      this.log.error('Error authenticating Eufy : ', e);
    }

    if (!this.eufyClient.isConnected()) {
      this.log.error('Not connected can\'t continue!');
      return;
    }

    await this.refreshData(this.eufyClient);

    this.eufyClient.on('push connect', () => {
      this.log.debug('Push Connected!');
    });
    this.eufyClient.on('push close', () => {
      this.log.warn('Push Closed!');
    });

    const eufyStations = await this.eufyClient.getStations();
    this.log.debug('Found ' + eufyStations.length + ' stations.');

    const devices: Array<DeviceContainer> = [];

    for (const station of eufyStations) {
      this.log.debug(
        'Found Station',
        station.getSerial(),
        station.getName(),
        DeviceType[station.getDeviceType()],
        station.getLANIPAddress(),
      );

      if (this.config.ignoreStations.indexOf(station.getSerial()) !== -1) {
        this.log.debug('Device ignored');
        continue;
      }

      const deviceContainer: DeviceContainer = {
        deviceIdentifier: {
          uniqueId: station.getSerial(),
          displayName: station.getName(),
          type: station.getDeviceType(),
          station: true,
        } as DeviceIdentifier,
        eufyDevice: station,
      };
      devices.push(deviceContainer);
    }

    const eufyDevices = await this.eufyClient.getDevices();
    this.log.debug('Found ' + eufyDevices.length + ' devices.');

    for (const device of eufyDevices) {
      this.log.debug(
        'Found device',
        device.getSerial(),
        device.getName(),
        DeviceType[device.getDeviceType()],
      );

      if (this.config.ignoreStations.indexOf(device.getStationSerial()) !== -1) {
        this.log.debug('Device ignored because station is ignored');
        continue;
      }

      if (this.config.ignoreDevices.indexOf(device.getSerial()) !== -1) {
        this.log.debug('Device ignored');
        continue;
      }

      const deviceContainer: DeviceContainer = {
        deviceIdentifier: {
          uniqueId: device.getSerial(),
          displayName: device.getName(),
          type: device.getDeviceType(),
          station: false,
        } as DeviceIdentifier,
        eufyDevice: device,
      };
      devices.push(deviceContainer);
    }

    const activeAccessoryIds: string[] = [];

    // 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
      let uuid = this.api.hap.uuid.generate(device.deviceIdentifier.uniqueId);

      // Checking Device Type if it's not a station, it will be the same serial number we will find 
      // in Device list and it will create the same UUID
      if (device.deviceIdentifier.type !== DeviceType.STATION && device.deviceIdentifier.station) {
        uuid = this.api.hap.uuid.generate('s_' + device.deviceIdentifier.uniqueId);
        this.log.debug('This device is not a station. Generating a new UUID to avoid any duplicate issue');
      }

      activeAccessoryIds.push(uuid);

      // see if an accessory with the same uuid has already been registered and restored from
      // the cached devices we stored in the `configureAccessory` method above
      const existingAccessory = this.accessories.find(
        (accessory) => accessory.UUID === uuid,
      );

      if (existingAccessory) {
        // the accessory already exists

        if (this.register_accessory(
          existingAccessory,
          device.deviceIdentifier.type,
          device.eufyDevice,
          device.deviceIdentifier.station,
        )
        ) {
          this.log.info(
            'Restoring existing accessory from cache:',
            existingAccessory.displayName,
          );

          // update accessory cache with any changes to the accessory details and information
          this.api.updatePlatformAccessories([existingAccessory]);
        }

      } else {
        // the accessory does not yet exist, so we need to create it

        // create a new accessory
        const accessory = new this.api.platformAccessory(
          device.deviceIdentifier.displayName,
          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.deviceIdentifier;

        // create the accessory handler for the newly create accessory
        // this is imported from `platformAccessory.ts`
        if (
          this.register_accessory(
            accessory,
            device.deviceIdentifier.type,
            device.eufyDevice,
            device.deviceIdentifier.station,
          )
        ) {
          this.log.info(
            'Adding new accessory:',
            device.deviceIdentifier.displayName,
          );

          // link the accessory to your platform
          this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
            accessory,
          ]);
        }
      }
    }

    // Cleaning cached accessory which are no longer exist

    const staleAccessories = this.accessories.filter((item) => {
      return activeAccessoryIds.indexOf(item.UUID) === -1;
    });

    staleAccessories.forEach((staleAccessory) => {
      this.log.info(`Removing cached accessory ${staleAccessory.UUID} ${staleAccessory.displayName}`);
      this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [staleAccessory]);
    });

  }

  private register_accessory(
    accessory: PlatformAccessory,
    type: number,
    device,
    station: boolean,
  ) {

    this.log.debug(accessory.displayName, 'UUID:', accessory.UUID);

    /* Under development area

    This need to be rewrite 

    */

    if (station) {
      if (type !== DeviceType.STATION) {
        // Allowing camera but not the lock nor doorbell for now
        if (!(type === DeviceType.LOCK_BASIC
          || type === DeviceType.LOCK_ADVANCED
          || type === DeviceType.LOCK_BASIC_NO_FINGER
          || type === DeviceType.LOCK_ADVANCED_NO_FINGER
          || type === DeviceType.DOORBELL
          || type === DeviceType.BATTERY_DOORBELL
          || type === DeviceType.BATTERY_DOORBELL_2)) {
          this.log.warn(accessory.displayName, 'looks station but it\'s not could imply some errors', 'Type:', type);
          new StationAccessory(this, accessory, device as Station);
          return true;
        } else {
          return false;
        }
      }
    }

    switch (type) {
      case DeviceType.STATION:
        new StationAccessory(this, accessory, device as Station);
        break;
      case DeviceType.MOTION_SENSOR:
        new MotionSensorAccessory(this, accessory, device as MotionSensor);
        break;
      case DeviceType.CAMERA:
      case DeviceType.CAMERA2:
      case DeviceType.CAMERA_E:
      case DeviceType.CAMERA2C:
      case DeviceType.INDOOR_CAMERA:
      case DeviceType.INDOOR_PT_CAMERA:
      case DeviceType.FLOODLIGHT:
      case DeviceType.CAMERA2C_PRO:
      case DeviceType.CAMERA2_PRO:
      case DeviceType.INDOOR_CAMERA_1080:
      case DeviceType.INDOOR_PT_CAMERA_1080:
      case DeviceType.SOLO_CAMERA:
      case DeviceType.SOLO_CAMERA_PRO:
      case DeviceType.SOLO_CAMERA_SPOTLIGHT_1080:
      case DeviceType.SOLO_CAMERA_SPOTLIGHT_2K:
      case DeviceType.SOLO_CAMERA_SPOTLIGHT_SOLAR:
      case DeviceType.INDOOR_OUTDOOR_CAMERA_1080P:
      case DeviceType.INDOOR_OUTDOOR_CAMERA_1080P_NO_LIGHT:
      case DeviceType.INDOOR_OUTDOOR_CAMERA_2K:
      case DeviceType.FLOODLIGHT_CAMERA_8422:
      case DeviceType.FLOODLIGHT_CAMERA_8423:
      case DeviceType.FLOODLIGHT_CAMERA_8424:
        new CameraAccessory(this, accessory, device as Camera);
        break;
      case DeviceType.DOORBELL:
      case DeviceType.BATTERY_DOORBELL:
      case DeviceType.BATTERY_DOORBELL_2:
        new DoorbellCameraAccessory(this, accessory, device as DoorbellCamera);
        break;
      case DeviceType.SENSOR:
        new EntrySensorAccessory(this, accessory, device as EntrySensor);
        break;
      case DeviceType.KEYPAD:
        new KeypadAccessory(this, accessory, device as Keypad);
        break;
      case DeviceType.LOCK_BASIC:
      case DeviceType.LOCK_ADVANCED:
      case DeviceType.LOCK_BASIC_NO_FINGER:
      case DeviceType.LOCK_ADVANCED_NO_FINGER:
        new SmartLockAccessory(this, accessory, device as Lock);
        break;
      default:
        this.log.warn(
          'This accessory is not compatible with HomeBridge Eufy Security plugin:',
          accessory.displayName,
          'Type:',
          type,
        );
        return false;
    }
    return true;
  }

  public async refreshData(client: EufySecurity): Promise<void> {
    this.log.debug(
      `PollingInterval: ${this.eufyConfig.pollingIntervalMinutes}`,
    );
    if (client) {
      this.log.debug('Refresh data from cloud and schedule next refresh.');
      try {
        await client.refreshCloudData();
      } catch (error) {
        this.log.error('Error refreshing data from Eufy: ', error);
      }
      setTimeout(() => {
        try {
          this.refreshData(client);
        } catch (error) {
          this.log.error('Error refreshing data from Eufy: ', error);
        }
      }, this.eufyConfig.pollingIntervalMinutes * 60 * 1000);
    }
  }

  public getStationById(id: string) {
    return this.eufyClient.getStation(id);
  }
}
Example #8
Source File: platform.ts    From homebridge-tuya-ir with Apache License 2.0 4 votes vote down vote up
/**
 * 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 TuyaIRPlatform 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[] = [];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  public cachedAccessories: Map<any, any> = new Map();


  constructor(
    public readonly log: Logger,
    public readonly config: PlatformConfig,
    public readonly api: API,
  ) {
    this.log.debug('Finished initializing platform:', this.config.name);


    // 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('didFinishLaunching', () => {
      log.debug('Executed didFinishLaunching callback');
      // run the method to discover / register your devices as accessories
      this.discoverDevices();
    });
  }

  /**
   * 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.info('Loading 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);
  }

  /**
   * 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.
   */
  discoverDevices() {

    //if (!this.config.devices) return this.log.error("No devices configured. Please configure atleast one device.");
    if (!this.config.client_id) return this.log.error("Client ID is not configured. Please check your config.json");
    if (!this.config.secret) return this.log.error("Client Secret is not configured. Please check your config.json");
    if (!this.config.region) return this.log.error("Region is not configured. Please check your config.json");
    //if (!this.config.deviceId) return this.log.error("IR Blaster device ID is not configured. Please check your config.json");

    this.log.info('Starting discovery...');
    const tuya: TuyaIRDiscovery = new TuyaIRDiscovery(this.log, this.api);
    this.discover(tuya, 0, this.config.smartIR.length);
  }

  discover(tuya, i, total) {
    tuya.start(this.api, this.config, i, (devices, index) => {

      this.log.debug(JSON.stringify(devices));
      //loop over the discovered devices and register each one if it has not already been registered
      for (const device of devices) {
        if (device) {

          // 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
          device.ir_id = this.config.smartIR[index].deviceId;
          const Accessory = CLASS_DEF[device.category] || GenericAccessory;
          const uuid = this.api.hap.uuid.generate(device.id);

          // see if an accessory with the same uuid has already been registered and restored from
          // the cached devices we stored in the `configureAccessory` method above
          const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);

          if (existingAccessory) {
            // the accessory already exists
            this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);

            // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.:
            // existingAccessory.context.device = device;
            // this.api.updatePlatformAccessories([existingAccessory]);

            // create the accessory handler for the restored accessory
            // this is imported from `platformAccessory.ts`
            if (Accessory) {
              new Accessory(this, existingAccessory);
            } else {
              this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
              this.log.warn(`Removing unsupported accessory '${existingAccessory.displayName}'...`);
            }

            // it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
            // remove platform accessories when no longer present
            // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
            // this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
          } else {

            if (Accessory) {
              // the accessory does not yet exist, so we need to create it
              this.log.info('Adding new accessory:', device.name);

              // create a new accessory
              const accessory = new this.api.platformAccessory(device.name, 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 for the newly create accessory
              // this is imported from `platformAccessory.ts`
              new Accessory(this, accessory);
              // link the accessory to your platform
              this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
            } else {
              this.log.warn(`Unsupported accessory '${device.name}'...`);
            }

          }
        }
      }
      i++;
      if(i < total) {
        this.discover(tuya, i, total);
      }
    });
  }
}
Example #9
Source File: platform.ts    From homebridge-samsungtv-control2 with MIT License 4 votes vote down vote up
export class SamsungTVHomebridgePlatform implements DynamicPlatformPlugin {
  public readonly Service: typeof Service = this.api.hap.Service
  public readonly Characteristic: typeof Characteristic =
    this.api.hap.Characteristic

  public readonly tvAccessories: Array<PlatformAccessory> = []
  private devices: Array<DeviceConfig> = []

  constructor(
    public readonly log: Logger,
    public readonly config: PlatformConfig,
    public readonly api: API,
  ) {
    this.log = log
    this.config = config
    this.api = api

    this.Service = api.hap.Service
    this.Characteristic = api.hap.Characteristic

    this.log.debug(`Got config`, this.config)

    // Add devices
    api.on(APIEvent.DID_FINISH_LAUNCHING, async () => {
      const dir = path.join(api.user.storagePath(), `.${PLUGIN_NAME}`)
      this.log.debug(`Using node-persist path:`, dir)
      await storage.init({
        dir,
        logging: (...args) => this.log.debug(`${PLATFORM_NAME} db -`, ...args),
      })

      let devices = await this.discoverDevices()
      devices = await this.applyConfig(devices)
      this.devices = await this.checkDevicePairing(devices)

      // Register all TV's
      for (const device of this.devices) {
        // Log all devices so that the user knows how to configure them
        this.log.info(
          chalk`Found device {blue ${device.name}} (${device.modelName}), usn: {green ${device.usn}}`,
        )
        this.log.debug(
          `${device.name} - (ip: ${device.lastKnownIp}, mac: ${device.mac})`,
        )
        // Register it
        this.registerTV(device.usn)
      }

      // Regularly discover upnp devices and update ip's, locations for registered devices
      setInterval(async () => {
        const devices = await this.discoverDevices()
        this.devices = await this.applyConfig(devices)
        /**
         * @todo
         * add previously not registered devices
         */
      }, 1000 * 60 * 5 /* 5min */)

      /**
       * @TODO
       * Add subscriptions to update getters
       */
    })
  }

  /*
   * 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(): void {
    this.log.debug(`Configuring accessory`)
  }

  private async discoverDevices() {
    let existingDevices: Array<DeviceConfig> = await storage.getItem(
      DEVICES_KEY,
    )
    if (!Array.isArray(existingDevices)) {
      existingDevices = []
    }

    const devices: Array<DeviceConfig> = []
    const samsungTVs = await detectDevices(
      // this.log,
      this.config as SamsungPlatformConfig,
    )
    for (const tv of samsungTVs) {
      const {
        usn,
        friendlyName: name,
        modelName,
        location: lastKnownLocation,
        address: lastKnownIp,
        mac,
        capabilities,
      } = tv
      const device: DeviceConfig = {
        name,
        modelName,
        lastKnownLocation,
        lastKnownIp,
        mac,
        usn,
        delay: 500,
        capabilities,
      }
      // Check if the tv was in the devices list before
      // if so, only replace the relevant parts
      // const existingDevice = devices[usn];
      const existingDevice = existingDevices.find((d) => d.usn === usn)
      if (existingDevice) {
        this.log.debug(
          `Rediscovered previously seen device "${device.name}" (${device.modelName}), usn: "${device.usn}"`,
        )
        devices.push({
          ...existingDevice,
          modelName: device.modelName,
          lastKnownLocation: device.lastKnownLocation,
          lastKnownIp: device.lastKnownIp,
          token: device.token,
          discovered: true,
        })
      } else {
        this.log.debug(
          `Discovered new device "${device.name}" (${device.modelName}), usn: "${device.usn}"`,
        )
        devices.push({ ...device, discovered: true })
      }
    }

    // Add all existing devices that where not discovered
    for (const existingDevice of existingDevices) {
      const { usn } = existingDevice
      const device = devices.find((d) => d.usn === usn)
      if (!device) {
        this.log.debug(
          `Adding not discovered, previously seen device "${existingDevice.name}" (${existingDevice.modelName}), usn: "${existingDevice.usn}"`,
        )
        devices.push(existingDevice)
      }
    }

    // Update devices
    await storage.updateItem(DEVICES_KEY, devices)
    return devices
  }

  /**
   * Invokes pairing for all discovered devices.
   */
  private async checkDevicePairing(devices: Array<DeviceConfig>) {
    for (const device of devices) {
      // Try pairing if the device was actually discovered and not paired already
      if (!device.ignore && device.discovered) {
        try {
          const token = await remote.getPairing(device, this.log)
          if (token) {
            this.log.debug(
              `Found pairing token "${token}" for "${device.name}" (${device.modelName}), usn: "${device.usn}".`,
            )
          }
        } catch (err) {
          this.log.warn(
            `Did not receive pairing token. Either you did not click "Allow" in time or your TV might not be supported.` +
              `You might just want to restart homebridge and retry.`,
          )
        }
      }
    }
    return devices
  }

  /**
   * Adds the user modifications to each of devices
   */
  private async applyConfig(devices: Array<DeviceConfig>) {
    // Get additional options from config
    const configDevices = (this.config as SamsungPlatformConfig).devices || []
    for (const configDevice of configDevices) {
      // Search for the device in the persistent devices and overwrite the values
      const { usn } = configDevice
      const deviceIdx = devices.findIndex((d) => d.usn === usn)
      if (deviceIdx === -1) {
        this.log.debug(
          `Found config for unknown device usn: "${configDevice.usn}"`,
          configDevice,
        )
        continue
      }
      const device = devices[deviceIdx]
      this.log.debug(
        `Found config for device "${device.name}" (${device.modelName}), usn: "${device.usn}"`,
      )
      devices[deviceIdx] = {
        ...device,
        ...configDevice,
      }
    }
    return devices
  }

  private getDevice(usn) {
    const device = this.devices.find((d) => d.usn === usn)
    return device as DeviceConfig
  }

  private registerTV(usn: string) {
    const device = this.getDevice(usn)
    if (!device || device.ignore) {
      return
    }

    // generate a UUID
    const uuid = this.api.hap.uuid.generate(device.usn)

    // create the accessory
    const tvAccessory = new this.api.platformAccessory(device.name, uuid)
    tvAccessory.context = device

    this.tvAccessories.push(tvAccessory)

    // get the name
    const tvName = device.name

    // set the accessory category
    tvAccessory.category = this.api.hap.Categories.TELEVISION

    // add the tv service
    const tvService = tvAccessory.addService(this.Service.Television)
    // set the tv name, manufacturer etc.
    tvService.setCharacteristic(this.Characteristic.ConfiguredName, tvName)

    const accessoryService =
      tvAccessory.getService(this.Service.AccessoryInformation) ||
      new this.Service.AccessoryInformation()
    accessoryService
      .setCharacteristic(this.Characteristic.Model, device.modelName)
      .setCharacteristic(
        this.Characteristic.Manufacturer,
        `Samsung Electronics`,
      )
      .setCharacteristic(this.Characteristic.Name, device.name)
      .setCharacteristic(this.Characteristic.SerialNumber, device.usn)

    // set sleep discovery characteristic
    tvService.setCharacteristic(
      this.Characteristic.SleepDiscoveryMode,
      this.Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE,
    )

    // handle on / off events using the Active characteristic
    tvService
      .getCharacteristic(this.Characteristic.Active)
      .on(`get`, async (callback) => {
        this.log.debug(`${tvName} - GET Active`)
        // callback(null, false)
        try {
          const isActive = await remote.getActive(this.getDevice(usn))
          // tvService.updateCharacteristic(this.Characteristic.Active, isActive)
          callback(null, isActive)
        } catch (err) {
          this.log.warn(`${tvName} - Could not check active state`)
          callback(err)
        }
      })
      .on(`set`, async (newValue, callback) => {
        this.log.debug(`${tvName} - SET Active => setNewValue: ${newValue}`)
        try {
          await remote.setActive(this.getDevice(usn), newValue as boolean)
          tvService.updateCharacteristic(
            this.Characteristic.Active,
            newValue
              ? this.Characteristic.Active.ACTIVE
              : this.Characteristic.Active.INACTIVE,
          )
          callback(null)
        } catch (err) {
          this.log.warn(`${tvName} - Could not update active state`)
          callback(err)
        }
      })

    // Update the active state every 15 seconds
    setInterval(async () => {
      let newState = this.Characteristic.Active.ACTIVE
      try {
        const isActive = await remote.getActive(this.getDevice(usn))
        if (!isActive) {
          newState = this.Characteristic.Active.INACTIVE
        }
      } catch (err) {
        newState = this.Characteristic.Active.INACTIVE
      }
      // this.log.debug('Polled tv active state', newState);
      tvService.updateCharacteristic(this.Characteristic.Active, newState)
    }, 1000 * 15)

    const canGetBrightness = hasCapability(device, `GetBrightness`)
    const canSetBrightness = hasCapability(device, `SetBrightness`)
    if (canGetBrightness) {
      tvService
        .getCharacteristic(this.Characteristic.Brightness)
        .on(`get`, async (callback) => {
          this.log.debug(`${tvName} - GET Brightness`)
          try {
            const brightness = await remote.getBrightness(this.getDevice(usn))
            callback(null, brightness)
          } catch (err) {
            callback(err)
          }
        })
    }
    if (canSetBrightness) {
      tvService
        .getCharacteristic(this.Characteristic.Brightness)
        .on(`set`, async (newValue, callback) => {
          this.log.debug(
            `${tvName} - SET Brightness => setNewValue: ${newValue}`,
          )
          try {
            await remote.setBrightness(this.getDevice(usn), newValue as number)
            tvService.updateCharacteristic(
              this.Characteristic.Brightness,
              newValue,
            )
            callback(null)
          } catch (err) {
            callback(err)
          }
        })
    }

    // handle remote control input
    tvService
      .getCharacteristic(this.Characteristic.RemoteKey)
      .on(`set`, async (newValue, callback) => {
        try {
          switch (newValue) {
            case this.Characteristic.RemoteKey.REWIND: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: REWIND`)
              await remote.rewind(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.FAST_FORWARD: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: FAST_FORWARD`)
              await remote.fastForward(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.NEXT_TRACK: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: NEXT_TRACK`)
              break
            }
            case this.Characteristic.RemoteKey.PREVIOUS_TRACK: {
              this.log.debug(
                `${tvName} - SET Remote Key Pressed: PREVIOUS_TRACK`,
              )
              break
            }
            case this.Characteristic.RemoteKey.ARROW_UP: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: ARROW_UP`)
              await remote.arrowUp(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.ARROW_DOWN: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: ARROW_DOWN`)
              await remote.arrowDown(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.ARROW_LEFT: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: ARROW_LEFT`)
              await remote.arrowLeft(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.ARROW_RIGHT: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: ARROW_RIGHT`)
              await remote.arrowRight(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.SELECT: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: SELECT`)
              await remote.select(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.BACK: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: BACK`)
              await remote.back(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.EXIT: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: EXIT`)
              await remote.exit(this.getDevice(usn))
              break
            }
            case this.Characteristic.RemoteKey.PLAY_PAUSE: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: PLAY_PAUSE`)
              break
            }
            case this.Characteristic.RemoteKey.INFORMATION: {
              this.log.debug(`${tvName} - SET Remote Key Pressed: INFORMATION`)
              await remote.info(this.getDevice(usn))
              break
            }
          }
        } catch (err) {
          callback(err)
          return
        }
        callback(null)
      })

    /**
     * Create a speaker service to allow volume control
     */
    const speakerService = tvAccessory.addService(
      this.Service.TelevisionSpeaker,
    )

    /**
     * We have these scenarios
     * 1. GetVolume + SetVolume:
     *    => VolumeControlType.Absolute
     *    => Add Volume Characteristic with get/set
     *    => Also add VolumeSelector
     * (2.) GetVolume but (no SetVolume)
     *    ...same as 1. because SetVolume can be simulated
     *       by inc./decr. volume step by step
     *    => ~~VolumeControlType.RELATIVE_WITH_CURRENT~~
     *    => ~~Add Volume Characteristic with getter only~~
     * 3. No GetVolume upnp capabilities:
     *    => VolumeControlType.RELATIVE
     *    => Add VolumeSelector Characteristic
     */

    let volumeControlType = this.Characteristic.VolumeControlType.ABSOLUTE
    const canGetVolume = hasCapability(device, `GetVolume`)
    if (!canGetVolume) {
      volumeControlType = this.Characteristic.VolumeControlType.RELATIVE
      this.log.debug(`${tvName} - VolumeControlType RELATIVE`)
    } else {
      this.log.debug(`${tvName} - VolumeControlType ABSOLUTE`)
    }

    speakerService
      .setCharacteristic(
        this.Characteristic.Active,
        this.Characteristic.Active.ACTIVE,
      )
      .setCharacteristic(
        this.Characteristic.VolumeControlType,
        volumeControlType,
      )

    if (canGetVolume) {
      speakerService
        .getCharacteristic(this.Characteristic.Volume)
        .on(`get`, async (callback) => {
          this.log.debug(`${tvName} - GET Volume`)
          callback(null, 0)
          try {
            const volume = await remote.getVolume(this.getDevice(usn))
            tvService.updateCharacteristic(this.Characteristic.Volume, volume)
            // callback(null, volume)
          } catch (err) {
            // callback(err)
          }
        })
    }

    // When we can get the volume, we can always set the volume
    // directly or simulate it by  multiple volup/downs
    if (canGetVolume) {
      speakerService
        .getCharacteristic(this.Characteristic.Volume)
        .on(`set`, async (newValue, callback) => {
          this.log.debug(`${tvName} - SET Volume => setNewValue: ${newValue}`)
          try {
            await remote.setVolume(this.getDevice(usn), newValue as number)
            speakerService
              .getCharacteristic(this.Characteristic.Mute)
              .updateValue(false)
            callback(null)
          } catch (err) {
            callback(err)
          }
        })
    }

    // VolumeSelector can be used in all scenarios
    speakerService
      .getCharacteristic(this.Characteristic.VolumeSelector)
      .on(`set`, async (newValue, callback) => {
        this.log.debug(
          `${tvName} - SET VolumeSelector => setNewValue: ${newValue}`,
        )
        try {
          if (newValue === this.Characteristic.VolumeSelector.INCREMENT) {
            await remote.volumeUp(this.getDevice(usn))
          } else {
            await remote.volumeDown(this.getDevice(usn))
          }
          const volume = await remote.getVolume(this.getDevice(usn))
          speakerService
            .getCharacteristic(this.Characteristic.Mute)
            .updateValue(false)
          speakerService
            .getCharacteristic(this.Characteristic.Volume)
            .updateValue(volume)
          callback(null)
        } catch (err) {
          callback(err)
        }
      })

    const canGetMute = hasCapability(device, `GetMute`)
    speakerService
      .getCharacteristic(this.Characteristic.Mute)
      .on(`get`, async (callback) => {
        this.log.debug(`${tvName} - GET Mute`)
        // When mute cannot be fetched always pretend not to be muted
        // for now...
        if (!canGetMute) {
          callback(null, false)
          return
        }
        callback(null, false)
        try {
          const muted = await remote.getMute(this.getDevice(usn))
          tvService.updateCharacteristic(this.Characteristic.Mute, muted)
          // callback(null, muted)
        } catch (err) {
          // callback(err)
        }
      })
      .on(`set`, async (value, callback) => {
        this.log.debug(`${tvName} - SET Mute: ${value}`)
        try {
          await remote.setMute(this.getDevice(usn), value as boolean)
          tvService.updateCharacteristic(
            this.Characteristic.Mute,
            value as boolean,
          )
          callback(null)
        } catch (err) {
          callback(err)
        }
      })

    tvService.addLinkedService(speakerService)

    const inputSources = [
      { label: `-`, type: this.Characteristic.InputSourceType.OTHER },
      {
        label: `TV`,
        type: this.Characteristic.InputSourceType.TUNER,
        fn: remote.openTV,
      },
    ]
    const sources = [...inputSources]
    const { inputs = [] } = device
    for (const cInput of inputs) {
      // Opening apps
      if (APPS[cInput.keys]) {
        sources.push({
          label: cInput.name,
          type: this.Characteristic.InputSourceType.APPLICATION,
          fn: async (config: DeviceConfig) => {
            await remote.openApp(config, APPS[cInput.keys])
          },
        })
        continue
      }
      // Sending keys
      const keys = parseKeys(cInput, device, this.log)
      const type =
        keys.length === 1 && /^KEY_HDMI[0-4]?$/.test(keys[0])
          ? this.Characteristic.InputSourceType.HDMI
          : this.Characteristic.InputSourceType.OTHER
      sources.push({
        label: cInput.name,
        type,
        fn: async (config: DeviceConfig) => {
          await remote.sendKeys(config, keys as KEYS[])
        },
      })
    }

    // Set current input source to 0 = tv
    tvService.updateCharacteristic(this.Characteristic.ActiveIdentifier, 0)
    // handle input source changes
    let resetActiveIdentifierTimer: NodeJS.Timeout
    tvService
      .getCharacteristic(this.Characteristic.ActiveIdentifier)
      .on(`set`, async (newValue, callback) => {
        // Clear old timeout if not cleared already
        clearTimeout(resetActiveIdentifierTimer)

        // the value will be the value you set for the Identifier Characteristic
        // on the Input Source service that was selected - see input sources below.
        const inputSource = sources[newValue as any]
        this.log.debug(
          `${tvName} - SET Active Identifier => setNewValue: ${newValue} (${inputSource.label})`,
        )
        try {
          if (typeof inputSource.fn === `function`) {
            await inputSource.fn(this.getDevice(usn))
          }
          tvService.updateCharacteristic(
            this.Characteristic.ActiveIdentifier,
            newValue,
          )
        } catch (err) {
          callback(err)
          return
        }
        // Switch back to "TV" input source after 3 seconds
        resetActiveIdentifierTimer = setTimeout(() => {
          tvService.updateCharacteristic(
            this.Characteristic.ActiveIdentifier,
            0,
          )
        }, 3000)
        callback(null)
      })

    for (let i = 0; i < sources.length; ++i) {
      const { label, type } = sources[i]
      const inputService = tvAccessory.addService(
        this.Service.InputSource,
        /* `input-${i}` */ label,
        label,
      )
      inputService
        .setCharacteristic(this.Characteristic.Identifier, i)
        .setCharacteristic(this.Characteristic.ConfiguredName, label)
        .setCharacteristic(
          this.Characteristic.IsConfigured,
          this.Characteristic.IsConfigured.CONFIGURED,
        )
        .setCharacteristic(
          this.Characteristic.CurrentVisibilityState,
          this.Characteristic.CurrentVisibilityState.SHOWN,
        )
        .setCharacteristic(this.Characteristic.InputSourceType, type)
      tvService.addLinkedService(inputService)
    }

    /**
     * Publish as external accessory
     * Only one TV can exist per bridge, to bypass this limitation, you should
     * publish your TV as an external accessory.
     */
    this.api.publishExternalAccessories(PLUGIN_NAME, [tvAccessory])
  }
}
Example #10
Source File: platform.ts    From homebridge-zigbee-nt with Apache License 2.0 4 votes vote down vote up
export class ZigbeeNTHomebridgePlatform implements DynamicPlatformPlugin {
  public readonly Service: typeof Service = this.api.hap.Service;
  public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;

  private readonly accessories: Map<string, PlatformAccessory>;
  private readonly homekitAccessories: Map<string, ZigBeeAccessory>;
  private permitJoinAccessory: PermitJoinAccessory;
  public readonly PlatformAccessory: typeof PlatformAccessory;
  private client: ZigBeeClient;
  private httpServer: HttpServer;
  private touchLinkAccessory: TouchlinkAccessory;

  constructor(
    public readonly log: Logger,
    public readonly config: ZigBeeNTPlatformConfig,
    public readonly api: API
  ) {
    const packageJson = JSON.parse(
      fs.readFileSync(`${path.resolve(__dirname, '../package.json')}`, 'utf-8')
    );
    this.accessories = new Map<string, PlatformAccessory>();
    this.homekitAccessories = new Map<string, ZigBeeAccessory>();
    this.permitJoinAccessory = null;
    this.PlatformAccessory = this.api.platformAccessory;
    this.log.info(
      `Initializing platform: ${this.config.name} - v${packageJson.version} (API v${api.version})`
    );
    if (config.devices) {
      config.devices.forEach(config => {
        this.log.info(
          `Registering custom configured device ${config.manufacturer} - ${config.models.join(
            ', '
          )}`
        );
        registerAccessoryFactory(
          config.manufacturer,
          config.models,
          (
            platform: ZigbeeNTHomebridgePlatform,
            accessory: PlatformAccessory,
            client: ZigBeeClient,
            device: Device
          ) => new ConfigurableAccessory(platform, accessory, client, device, config.services)
        );
      });
    }
    this.api.on(APIEvent.DID_FINISH_LAUNCHING, () => this.startZigBee());
    this.api.on(APIEvent.SHUTDOWN, () => this.stopZigbee());
  }

  get zigBeeClient(): ZigBeeClient {
    return this.client;
  }

  public async startZigBee(): Promise<void> {
    // Create client
    this.client = new ZigBeeClient(this.log, this.config.customDeviceSettings);

    const panId =
      this.config.panId && this.config.panId < 0xffff ? this.config.panId : DEFAULT_PAN_ID;
    const database = this.config.database || path.join(this.api.user.storagePath(), './zigBee.db');
    await this.client.start({
      channel: this.config.channel,
      secondaryChannel: this.config.secondaryChannel,
      port: this.config.port,
      panId,
      database,
      adapter: this.config.adapter,
    });
    this.zigBeeClient.on('deviceAnnounce', (message: DeviceAnnouncePayload) =>
      this.handleDeviceAnnounce(message)
    );
    this.zigBeeClient.on('deviceInterview', (message: DeviceInterviewPayload) =>
      this.handleZigBeeDevInterview(message)
    );
    this.zigBeeClient.on('deviceJoined', (message: DeviceJoinedPayload) =>
      this.handleZigBeeDevJoined(message)
    );
    this.zigBeeClient.on('deviceLeave', (message: DeviceLeavePayload) =>
      this.handleZigBeeDevLeaving(message)
    );
    this.zigBeeClient.on('message', (message: MessagePayload) => this.handleZigBeeMessage(message));

    await this.handleZigBeeReady();
  }

  async stopZigbee(): Promise<void> {
    try {
      this.log.info('Stopping zigbee service');
      await this.zigBeeClient?.stop();
      this.log.info('Stopping http server');
      await this.httpServer?.stop();
      this.log.info('Successfully stopped ZigBee service');
    } catch (e) {
      this.log.error('Error while stopping ZigBee service', e);
    }
  }

  /**
   * 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): void {
    this.log.info('Loading accessory from cache:', accessory.displayName);
    this.accessories.set(accessory.UUID, accessory);
  }

  async handleZigBeeDevInterview(message: DeviceInterviewPayload): Promise<void> {
    const ieeeAddr = message.device.ieeeAddr;
    const status = message.status;
    switch (status) {
      case 'failed':
        this.log.error(
          `Interview progress ${status} for device ${this.getDeviceFriendlyName(ieeeAddr)}`
        );
        break;
      case 'started':
        this.log.info(
          `Interview progress ${status} for device ${this.getDeviceFriendlyName(ieeeAddr)}`
        );
        break;
      case 'successful':
        this.log.info(
          `Successfully interviewed device: ${message.device.manufacturerName} - ${message.device.modelID}`
        );
        await this.handleDeviceUpdate(message.device);
    }
  }

  async handleZigBeeDevJoined(message: DeviceJoinedPayload): Promise<boolean> {
    this.log.info(
      `Device joined, Adding ${this.getDeviceFriendlyName(message.device.ieeeAddr)} (${
        message.device.manufacturerName
      } - ${message.device.modelID})`
    );
    return await this.handleDeviceUpdate(message.device);
  }

  private async handleDeviceUpdate(device: Device): Promise<boolean> {
    // Ignore if the device exists
    const accessory = this.getHomekitAccessoryByIeeeAddr(device.ieeeAddr);
    if (!accessory) {
      // Wait a little bit for a database sync
      await sleep(1500);
      const uuid = await this.initDevice(device);
      return uuid !== null;
    } else {
      this.log.debug(
        `Not initializing device ${this.getDeviceFriendlyName(
          device.ieeeAddr
        )}: already mapped in Homebridge`
      );
      accessory.internalUpdate({});
    }
    return false;
  }

  generateUUID(ieeeAddr: string): string {
    return this.api.hap.uuid.generate(ieeeAddr);
  }

  async handleZigBeeDevLeaving(message: DeviceLeavePayload): Promise<boolean> {
    const ieeeAddr = message.ieeeAddr;
    // Stop permit join
    await this.permitJoinAccessory.setPermitJoin(false);
    this.log.info(`Device announced leaving and will be removed, id: ${ieeeAddr}`);
    return await this.unpairDevice(ieeeAddr);
  }

  async handleZigBeeReady(): Promise<void> {
    const info: Device = this.zigBeeClient.getCoordinator();
    this.log.info(`ZigBee platform initialized @ ${info.ieeeAddr}`);
    // Init permit join accessory
    await this.initPermitJoinAccessory();
    // Init switch to reset devices through Touchlink feature
    this.initTouchLinkAccessory();
    // Init devices
    const paired = (
      await Promise.all(
        this.zigBeeClient.getAllPairedDevices().map(device => this.initDevice(device))
      )
    ).filter(uuid => uuid !== null);

    paired.push(this.permitJoinAccessory.accessory.UUID);
    paired.push(this.touchLinkAccessory.accessory.UUID);
    const missing = difference([...this.accessories.keys()], paired);
    missing.forEach(uuid => {
      this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
        this.accessories.get(uuid),
      ]);
      this.accessories.delete(uuid);
      this.homekitAccessories.delete(uuid);
    });

    if (this.config.disableHttpServer !== true) {
      try {
        this.httpServer = new HttpServer(this.config.httpPort);
        this.httpServer.start(this);
      } catch (e) {
        this.log.error('WEB UI failed to start.', e);
      }
    } else {
      this.log.info('WEB UI disabled.');
    }
  }

  public getAccessoryByIeeeAddr(ieeeAddr: string): PlatformAccessory {
    return this.accessories.get(this.generateUUID(ieeeAddr));
  }

  public getAccessoryByUUID(uuid: string): PlatformAccessory {
    return this.accessories.get(uuid);
  }

  public getHomekitAccessoryByIeeeAddr(ieeeAddr: string): ZigBeeAccessory {
    return this.homekitAccessories.get(this.generateUUID(ieeeAddr));
  }

  public getHomekitAccessoryByUUID(uuid: string): ZigBeeAccessory {
    return this.homekitAccessories.get(uuid);
  }

  private async initDevice(device: Device): Promise<string> {
    const model = parseModelName(device.modelID);
    const manufacturer = device.manufacturerName;
    const ieeeAddr = device.ieeeAddr;
    const deviceName = `${this.getDeviceFriendlyName(
      ieeeAddr
    )} - ${model} - ${manufacturer}`;
    this.log.info(`Initializing ZigBee device: ${deviceName}`);

    if (!isAccessorySupported(device)) {
      this.log.info(
        `Unsupported ZigBee device: ${this.getDeviceFriendlyName(
          ieeeAddr
        )} - ${model} - ${manufacturer}`
      );
      return null;
    } else {
      try {
        const accessory = this.createHapAccessory(ieeeAddr);
        const homeKitAccessory = createAccessoryInstance(this, accessory, this.client, device);
        if (homeKitAccessory) {
          this.log.info('Registered device:', homeKitAccessory.friendlyName, manufacturer, model);
          await homeKitAccessory.initialize(); // init services
          this.homekitAccessories.set(accessory.UUID, homeKitAccessory);
          return accessory.UUID;
        }
      } catch (e) {
        this.log.error(`Error initializing device ${deviceName}`, e);
      }
      return null;
    }
  }

  private async initPermitJoinAccessory() {
    try {
      const accessory = this.createHapAccessory(PERMIT_JOIN_ACCESSORY_NAME);
      this.permitJoinAccessory = new PermitJoinAccessory(this, accessory, this.zigBeeClient);
      this.log.info('PermitJoin accessory successfully registered');
      if (this.config.enablePermitJoin === true) {
        await this.permitJoinAccessory.setPermitJoin(true);
      }
    } catch (e) {
      this.log.error('PermitJoin accessory not registered: ', e);
    }
  }

  private initTouchLinkAccessory() {
    try {
      const accessory = this.createHapAccessory(TOUCH_LINK_ACCESSORY_NAME);
      this.touchLinkAccessory = new TouchlinkAccessory(this, accessory, this.zigBeeClient);
      this.log.info('TouchLink accessory successfully registered');
    } catch (e) {
      this.log.error('TouchLink accessory not registered: ', e);
    }
  }

  private createHapAccessory(name: string) {
    const uuid = this.generateUUID(name);
    const existingAccessory = this.getAccessoryByUUID(uuid);
    if (existingAccessory) {
      this.log.info(`Reuse accessory from cache with uuid ${uuid} and name ${name}`);
      return existingAccessory;
    } else {
      const accessory = new this.PlatformAccessory(name, uuid);
      this.log.warn(`Registering new accessory with uuid ${uuid} and name ${name}`);
      this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
      this.accessories.set(uuid, accessory);
      return accessory;
    }
  }

  private removeAccessory(ieeeAddr: string) {
    const uuid = this.generateUUID(ieeeAddr);
    const accessory = this.accessories.get(uuid);
    if (accessory) {
      this.accessories.delete(uuid);
      this.homekitAccessories.delete(uuid);
    }
  }

  public async unpairDevice(ieeeAddr: string): Promise<boolean> {
    const result = await this.zigBeeClient.unpairDevice(ieeeAddr);
    if (result) {
      this.log.info('Device has been unpaired:', ieeeAddr);
      const accessory = this.getAccessoryByIeeeAddr(ieeeAddr);
      if (accessory) {
        this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
        this.removeAccessory(ieeeAddr);
        return true;
      }
    } else {
      this.log.error('Device has NOT been unpaired:', ieeeAddr);
    }
    return false;
  }

  private async handleDeviceAnnounce(message: DeviceAnnouncePayload): Promise<void> {
    const ieeeAddr = message.device.ieeeAddr;
    this.log.info(
      `Device announce: ${this.getDeviceFriendlyName(ieeeAddr)} (${
        message.device.manufacturerName
      } - ${message.device.modelID})`
    );
    if (message.device.interviewCompleted) {
      let uuid = this.getAccessoryByIeeeAddr(ieeeAddr)?.UUID;
      if (!uuid) {
        // Wait a little bit for a database sync
        await sleep(1500);
        uuid = await this.initDevice(message.device);
        if (!uuid) {
          this.log.warn(`Device not recognized: `, message);
          return;
        }
      }
      return this.getHomekitAccessoryByUUID(uuid).onDeviceMount();
    } else {
      this.log.warn(
        `Not initializing device ${this.getDeviceFriendlyName(
          ieeeAddr
        )}: interview process still not completed`
      );
    }
  }

  private handleZigBeeMessage(message: MessagePayload) {
    this.log.debug(
      `Zigbee message from ${this.getDeviceFriendlyName(message.device.ieeeAddr)}`,
      message
    );
    const zigBeeAccessory = this.getHomekitAccessoryByIeeeAddr(message.device.ieeeAddr);
    if (zigBeeAccessory) {
      this.client.decodeMessage(message, (ieeeAddr: string, state: DeviceState) => {
        this.log.debug(`Decoded state from incoming message`, state);
        zigBeeAccessory.internalUpdate(state);
      }); // if the message is decoded, it will call the statePublisher function
    }
  }

  public getDeviceFriendlyName(ieeeAddr: string): string {
    return (
      this.config.customDeviceSettings?.find(config => config.ieeeAddr === ieeeAddr)
        ?.friendlyName || ieeeAddr
    );
  }

  public isDeviceOnline(ieeeAddr: string): boolean {
    const zigBeeAccessory: ZigBeeAccessory = this.getHomekitAccessoryByIeeeAddr(ieeeAddr);
    if (zigBeeAccessory) {
      return zigBeeAccessory.isOnline;
    }
    return false;
  }
}
Example #11
Source File: platform.ts    From homebridge-esphome-ts with GNU General Public License v3.0 4 votes vote down vote up
// milliseconds

export class EsphomePlatform implements DynamicPlatformPlugin {
    protected readonly espDevices: EspDevice[] = [];
    protected readonly blacklistSet: Set<string>;
    protected readonly subscription: Subscription;
    protected readonly accessories: PlatformAccessory[] = [];

    constructor(
        protected readonly log: Logging,
        protected readonly config: IEsphomePlatformConfig,
        protected readonly api: API,
    ) {
        this.subscription = new Subscription();
        this.log('starting esphome');
        if (!Array.isArray(this.config.devices) && !this.config.discover) {
            this.log.error(
                'You did not specify a devices array and discovery is ' +
                    'disabled! Esphome will not provide any accessories',
            );
            this.config.devices = [];
        }
        this.blacklistSet = new Set<string>(this.config.blacklist ?? []);

        this.api.on('didFinishLaunching', () => {
            this.onHomebridgeDidFinishLaunching();
        });
        this.api.on('shutdown', () => {
            this.espDevices.forEach((device: EspDevice) => device.terminate());
            this.subscription.unsubscribe();
        });
    }

    protected onHomebridgeDidFinishLaunching(): void {
        let devices: Observable<IEsphomeDeviceConfig> = from(this.config.devices ?? []);
        if (this.config.discover) {
            const excludeConfigDevices: Set<string> = new Set();
            devices = concat(
                discoverDevices(this.config.discoveryTimeout ?? DEFAULT_DISCOVERY_TIMEOUT, this.log).pipe(
                    map((discoveredDevice) => {
                        const configDevice = this.config.devices?.find(({ host }) => host === discoveredDevice.host);
                        let deviceConfig = discoveredDevice;
                        if (configDevice) {
                            excludeConfigDevices.add(configDevice.host);
                            deviceConfig = { ...discoveredDevice, ...configDevice };
                        }

                        return {
                            ...deviceConfig,
                            // Override hostname with ip address when available
                            // to avoid issues with mDNS resolution at OS level
                            host: discoveredDevice.address ?? discoveredDevice.host,
                        };
                    }),
                ),
                // Feed into output remaining devices from config that haven't been discovered
                devices.pipe(filter(({ host }) => !excludeConfigDevices.has(host))),
            );
        }

        this.subscription.add(
            devices
                .pipe(
                    mergeMap((deviceConfig) => {
                        const device = new EspDevice(deviceConfig.host, deviceConfig.password, deviceConfig.port);
                        if (this.config.debug) {
                            this.log('Writing the raw data from your ESP Device to /tmp');
                            writeReadDataToLogFile(deviceConfig.host, device);
                        }
                        device.provideRetryObservable(
                            interval(deviceConfig.retryAfter ?? this.config.retryAfter ?? DEFAULT_RETRY_AFTER).pipe(
                                tap(() => this.log.info(`Trying to reconnect now to device ${deviceConfig.host}`)),
                            ),
                        );
                        return device.discovery$.pipe(
                            filter((value: boolean) => value),
                            take(1),
                            timeout(10 * 1000),
                            tap(() => this.addAccessories(device)),
                            catchError((err) => {
                                if (err.name === 'TimeoutError') {
                                    this.log.warn(
                                        `The device under the host ${deviceConfig.host} could not be reached.`,
                                    );
                                }
                                return of(err);
                            }),
                        );
                    }),
                )
                .subscribe(),
        );
    }

    private addAccessories(device: EspDevice): void {
        for (const key of Object.keys(device.components)) {
            const component = device.components[key];
            if (this.blacklistSet.has(component.name)) {
                this.logIfDebug(`not processing ${component.name} because it was blacklisted`);
                continue;
            }
            const componentHelper = componentHelpers.get(component.type);
            if (!componentHelper) {
                this.log(`${component.name} is currently not supported. You might want to file an issue on Github.`);
                continue;
            }
            const uuid = UUIDGen.generate(component.name);
            let newAccessory = false;
            let accessory: PlatformAccessory | undefined = this.accessories.find(
                (accessory) => accessory.UUID === uuid,
            );
            if (!accessory) {
                this.logIfDebug(`${component.name} must be a new accessory`);
                accessory = new Accessory(component.name, uuid);
                newAccessory = true;
            }
            if (!componentHelper(component, accessory)) {
                this.log(`${component.name} could not be mapped to HomeKit. Please file an issue on Github.`);
                if (!newAccessory) {
                    this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
                }
                continue;
            }

            this.log(`${component.name} discovered and setup.`);
            if (accessory && newAccessory) {
                this.accessories.push(accessory);
                this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
            }
        }
        this.logIfDebug(device.components);
    }

    public configureAccessory(accessory: PlatformAccessory): void {
        if (!this.blacklistSet.has(accessory.displayName)) {
            this.accessories.push(accessory);
            this.logIfDebug(`cached accessory ${accessory.displayName} was added`);
        } else {
            this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
            this.logIfDebug(`unregistered ${accessory.displayName} because it was blacklisted`);
        }
    }

    private logIfDebug(msg?: any, ...parameters: unknown[]): void {
        if (this.config.debug) {
            this.log(msg, parameters);
        } else {
            this.log.debug(msg, parameters);
        }
    }
}
Example #12
Source File: platform.ts    From homebridge-konnected with MIT License 4 votes vote down vote up
/**
 * HomebridgePlatform Class
 *
 * This class is the main constructor of the Konnected Homebridge plugin.
 *
 * The following operations are performed when the plugin is loaded:
 * - parse the user config
 * - retrieve existing accessories from cachedAccessories
 * - set up a listening server to listen for requests from the Konnected alarm panels
 * - set up 
 * - discovery of Konnected alarm panels on the network
 * - add Konnected alarm panels to Homebridge config
 * - provision Konnected alarm panels with zones configured if assigned
 * - CRUD accessories with characteristics in Homebridge/HomeKit if zones configured/assigned
 * - listen for zone changes and update states in runtime cache and Homebridge/Homekit
 * = react to state change requests from Homebridge/HomeKit and send actuator payload to panel
 */
export class KonnectedHomebridgePlatform implements DynamicPlatformPlugin {
  public readonly Service: typeof Service = this.api.hap.Service;
  public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;
  public readonly Accessory: typeof PlatformAccessory = this.api.platformAccessory;

  // global array of references to restored Homebridge/HomeKit accessories from the cache
  // (used in accessory cache disk reads - this is also updated when accessories are initialized)
  public readonly accessories: PlatformAccessory[] = [];

  // global object of references to initialized Homebridge/Homekit accessories
  // (used in accessory cache disk writes - don't update this often)
  public readonly konnectedPlatformAccessories = {};

  // Sensor and actuator accessories can change often, we store a non-blocking state of them in a runtime cache.
  // This avoids experiencing two performance problems:
  // 1. a 'No Response' flag on accessory tiles in HomeKit when waiting for responses from the Konnected panels for states;
  // 2. constantly/expensively reading and writing to Homebridge's accessories cache.
  // NOTE: we do not store the security system accessory here, its state is maintained in the Homebridge accessories explicitly.
  public accessoriesRuntimeCache: RuntimeCacheInterface[] = [];

  // security system UUID (we only allow one security system per homebridge instance)
  private securitySystemUUID: string = this.api.hap.uuid.generate(this.config.platform);

  // define entry delay defaults
  private entryTriggerDelay: number =
    this.config.advanced?.entryDelaySettings?.delay !== null &&
    typeof this.config.advanced?.entryDelaySettings?.delay !== 'undefined'
      ? Math.round(this.config.advanced?.entryDelaySettings?.delay) * 1000
      : 30000; // zero = instant trigger

  private entryTriggerDelayTimerHandle;

  // define exit delay defaults
  private exitTriggerDelay: number =
    this.config.advanced?.exitDelaySettings?.delay !== null &&
    typeof this.config.advanced?.exitDelaySettings?.delay !== 'undefined'
      ? Math.round(this.config.advanced?.exitDelaySettings?.delay) * 1000
      : 30000; // zero = instant arming

  private exitTriggerDelayTimerHandle1;
  private exitTriggerDelayTimerHandle2;
  private exitTriggerDelayTimerHandle3;

  // define listening server variables
  private listenerIP: string = this.config.advanced?.listenerIP ? this.config.advanced.listenerIP : ip.address(); // system defined primary network interface
  private listenerPort: number = this.config.advanced?.listenerPort ? this.config.advanced.listenerPort : 0; // zero = autochoose
  private ssdpTimeout: number = this.config.advanced?.discoveryTimeout
    ? this.config.advanced.discoveryTimeout * 1000
    : 5000; // 5 seconds

  private listenerAuth: string[] = []; // for storing random auth strings
  private ssdpDiscovering = false; // for storing state of SSDP discovery process
  private ssdpDiscoverAttempts = 0;

  constructor(public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API) {
    this.log.debug('Finished initializing platform');

    // Homebridge looks for and fires this event when it has retrieved all cached accessories from disk
    // this event is also used to init other methods for this plugin
    this.api.on('didFinishLaunching', () => {
      log.debug('Executed didFinishLaunching callback. Accessories retreived from cache...');

      // run the listening server & register the security system
      this.listeningServer();
      this.registerSecuritySystem();
      this.discoverPanels();
    });
  }

  /**
   * Homebridge's startup restoration of cached accessories from disk.
   */
  configureAccessory(accessory: PlatformAccessory) {
    this.log.info(`Loading accessory from cache: ${accessory.displayName} (${accessory.context.device.serialNumber})`);

    // add the restored accessory to the accessories cache so we can track if it has already been registered
    this.accessories.push(accessory);
  }

  /**
   * Create a listening server for status and state changes from panels and zones.
   * https://help.konnected.io/support/solutions/articles/32000026814-sensor-state-callbacks
   */
  listeningServer() {
    const app = express();
    const server = http.createServer(app);
    app.use(express.json());

    server.listen(this.listenerPort, () => {
      // store port to its global variable
      this.listenerPort = server.address()!['port'];
      this.log.info(`Listening for zone changes on ${this.listenerIP} port ${this.listenerPort}`);
    });

    // restart/crash cleanup
    const cleanup = () => {
      server.close();
      this.log.info(`Listening port ${this.listenerPort} closed and released`);
    };
    process.on('SIGINT', cleanup).on('SIGTERM', cleanup);

    const respond = (req, res) => {
      // bearer auth token not provided
      if (typeof req.headers.authorization === 'undefined') {
        this.log.error(`Authentication failed for ${req.params.id}, token missing, with request body:`, req.body);

        // send the following response
        res.status(401).json({
          success: false,
          reason: 'Authorization failed, token missing',
        });
        return;
      }

      // validate provided bearer auth token
      if (this.listenerAuth.includes(req.headers.authorization.split('Bearer ').pop())) {
        if (['POST', 'PUT'].includes(req.method)) {
          // panel request to SET the state of the switch in Homebridge/HomeKit
          // send response with success to the panel
          res.status(200).json({ success: true });
          this.updateSensorAccessoryState(req);
        } else if ('GET' === req.method) {
          // panel request to GET the state of the switch in Homebridge/HomeKit
          // send response with payload of states to the panel

          // create type interface for responsePayload variable
          interface ResponsePayload {
            success: true;
            pin?: string;
            zone?: string;
            state?: number;
          }

          // setup response payload to reply with
          const responsePayload: ResponsePayload = {
            success: true,
          };

          // default to zone for Pro panel, but may be replaced if V1-V1 panel
          let requestPanelZone = req.query.zone;

          // pins or zones assignment
          if (req.query.pin) {
            // V1-V2 panel
            // change requestPanelZone variable to the zone equivalent of a pin on V1-V2 panels
            Object.entries(ZONES_TO_PINS).find(([zone, pin]) => {
              if (pin === Number(req.query.pin)) {
                requestPanelZone = zone;
              }
            });
            responsePayload.pin = req.query.pin;
          } else if (req.query.zone) {
            // Pro panel
            responsePayload.zone = requestPanelZone;
          }

          // check the trigger state of switches based on their last runtime state in Homebridge
          this.accessoriesRuntimeCache.find((runtimeCacheAccessory) => {
            if (runtimeCacheAccessory.serialNumber === req.params.id + '-' + requestPanelZone) {
              if (['beeper', 'siren', 'strobe', 'switch'].includes(runtimeCacheAccessory.type)) {
                if (runtimeCacheAccessory.trigger === 'low' && runtimeCacheAccessory.state === 0) {
                  responsePayload.state = 1; // set to normally high (1), waiting to be triggered low (0)
                } else if (
                  runtimeCacheAccessory.trigger === 'low' &&
                  (runtimeCacheAccessory.state === 1 || runtimeCacheAccessory.state === undefined)
                ) {
                  responsePayload.state = 0; // set to triggered low (0), waiting to be normally high (1)
                } else if (
                  (runtimeCacheAccessory.trigger === 'high' || runtimeCacheAccessory.trigger === undefined) &&
                  (runtimeCacheAccessory.state === 0 || runtimeCacheAccessory.state === undefined)
                ) {
                  responsePayload.state = 0; // set to normally low (0), waiting to be triggered high (1)
                } else if (
                  (runtimeCacheAccessory.trigger === 'high' || runtimeCacheAccessory.trigger === undefined) &&
                  runtimeCacheAccessory.state === 1
                ) {
                  responsePayload.state = 1; // set to triggered high (1), waiting to be normally low (0)
                }
              } else {
                responsePayload.state =
                  typeof runtimeCacheAccessory.state !== 'undefined' ? Number(runtimeCacheAccessory.state) : 0;
              }
            }
          });
          this.log.debug(
            `Panel (${req.params.id}) requested zone '${requestPanelZone}' initial state, sending value of ${responsePayload.state}`
          );
          // return response with payload of states
          res.status(200).json(responsePayload);
        }
      } else {
        // send the following response
        res.status(401).json({
          success: false,
          reason: 'Authorization failed, token not valid',
        });

        // rediscover and reprovision panels
        if (this.ssdpDiscovering === false) {
          this.log.warn(`Received zone payload: ${req.body}`);
          this.log.warn(`Authentication failed for ${req.params.id}, token not valid`);
          this.log.warn('Authentication token:', req.headers.authorization.split('Bearer ').pop());
          this.log.warn('Rediscovering and reprovisioning panels...');
          this.discoverPanels();
        }
      }
    };

    // listen for requests at the following route/endpoint
    app
      .route('/api/konnected/device/:id')
      .put(respond) // Alarm Panel V1-V2
      .post(respond) // Alarm Panel Pro
      .get(respond); // For Actuator Requests
  }

  /**
   * Register the Security System
   *
   * There are two scenarios for the security system:
   * 1. the security system logic is handled by the plugin, the installed home security system is just reporting sensor states;
   * 2. the security system logic is handled by the installed home security system.
   *
   * We provide security system logic that allows each sensor (not temperature or humidity) to define what security mode it can trigger the alarm in,
   * with the following considerations:
   * - armed away: long countdown of beeps from piezo;
   * - armed home: short countdown of beeps from piezo;
   * - armed night: no countdown beeps from piezo;
   * - disarmed: when contact sensors change state, check an option for momentary piezo beeps for change.
   */
  registerSecuritySystem() {
    const securitySystemObject = {
      UUID: this.securitySystemUUID,
      displayName: 'Konnected Alarm',
      type: 'securitysystem',
      model: 'Konnected Security System',
      serialNumber: this.api.hap.uuid.toShortForm(this.securitySystemUUID),
      state: 0,
    };

    const existingSecuritySystem = this.accessories.find((accessory) => accessory.UUID === this.securitySystemUUID);

    if (existingSecuritySystem) {
      // then the accessory already exists
      this.log.info(
        `Updating existing accessory: ${existingSecuritySystem.displayName} (${existingSecuritySystem.context.device.serialNumber})`
      );

      // store a direct reference to the initialized accessory with service and characteristics in the KonnectedPlatformAccessories object
      this.konnectedPlatformAccessories[this.securitySystemUUID] = new KonnectedPlatformAccessory(
        this,
        existingSecuritySystem
      );

      // update security system accessory in Homebridge and HomeKit
      this.api.updatePlatformAccessories([existingSecuritySystem]);
    } else {
      // otherwise we're adding a new accessory
      this.log.info(`Adding new accessory: ${securitySystemObject.displayName} (${this.securitySystemUUID})`);

      // build Homebridge/HomeKit platform accessory
      const newSecuritySystemAccessory = new this.api.platformAccessory('Konnected Alarm', this.securitySystemUUID);
      // store security system object in the platform accessory cache
      newSecuritySystemAccessory.context.device = securitySystemObject;
      // store a direct reference to the initialized accessory with service and characteristics in the KonnectedPlatformAccessories object
      this.konnectedPlatformAccessories[this.securitySystemUUID] = new KonnectedPlatformAccessory(
        this,
        newSecuritySystemAccessory
      );

      // add security system accessory to Homebridge and HomeKit
      this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [newSecuritySystemAccessory]);
    }
  }

  /**
   * Discover alarm panels on the network.
   * @reference https://help.konnected.io/support/solutions/articles/32000026805-discovery
   *
   * Konnected SSDP Search Targets:
   * @reference Alarm Panel V1-V2: urn:schemas-konnected-io:device:Security:1
   * @reference Alarm Panel Pro: urn:schemas-konnected-io:device:Security:2
   */
  discoverPanels() {
    const ssdpClient = new client.Client();
    const ssdpUrnPartial = 'urn:schemas-konnected-io:device';
    const ssdpDeviceIDs: string[] = []; // used later for deduping SSDP reflections
    const excludedUUIDs: string[] = String(process.env.KONNECTED_EXCLUDES).split(','); // used for ignoring specific panels (mostly for development)

    // set discovery state
    this.ssdpDiscovering = true;

    // begin discovery
    ssdpClient.search('ssdp:all');

    // on discovery
    ssdpClient.on('response', (headers) => {
      // check for only Konnected devices
      if (headers.ST!.indexOf(ssdpUrnPartial) !== -1) {
        // store reported URL of panel that responded
        const ssdpHeaderLocation: string = headers.LOCATION || '';
        // extract UUID of panel from the USN string
        const panelUUID: string = headers.USN!.match(/^uuid:(.*)::.*$/i)![1] || '';

        // dedupe responses, ignore excluded panels in environment variables, and then provision panel(s)
        if (!ssdpDeviceIDs.includes(panelUUID) && !excludedUUIDs.includes(panelUUID)) {
          // get panel status object (not using async await)
          fetch(ssdpHeaderLocation.replace('Device.xml', 'status'))
            // convert response to JSON
            .then((fetchResponse) => fetchResponse.json())
            .then((panelResponseObject) => {
              // create listener object to pass back to panel when provisioning it
              const listenerObject = {
                ip: this.listenerIP,
                port: this.listenerPort,
              };

              // use the above information to construct panel in Homebridge config
              this.updateHomebridgeConfig(panelUUID, panelResponseObject);

              // if the settings property does not exist in the response,
              // then we have an unprovisioned panel
              if (Object.keys(panelResponseObject.settings).length === 0) {
                this.provisionPanel(panelUUID, panelResponseObject, listenerObject);
              } else {
                if (panelResponseObject.settings.endpoint_type === 'rest') {
                  const panelBroadcastEndpoint = new URL(panelResponseObject.settings.endpoint);

                  // if the IP address or port are not the same, reprovision endpoint component
                  if (
                    panelBroadcastEndpoint.host !== this.listenerIP ||
                    Number(panelBroadcastEndpoint.port) !== this.listenerPort
                  ) {
                    this.provisionPanel(panelUUID, panelResponseObject, listenerObject);
                  }
                } else if (panelResponseObject.settings.endpoint_type === 'aws_iot') {
                  this.log.error(
                    `ERROR: Cannot provision panel ${panelUUID} with Homebridge. Panel has previously been provisioned with another platform (Konnected Cloud, SmartThings, Home Assistant, Hubitat,. etc). Please factory reset your Konnected Alarm panel and disable any other platform connectors before associating the panel with Homebridge.`
                  );
                }
              }
            });

          // add the UUID to the deduping array
          ssdpDeviceIDs.push(panelUUID);
        }
      }
    });

    // stop discovery after a number of seconds seconds, default is 5
    setTimeout(() => {
      ssdpClient.stop();
      this.ssdpDiscovering = false;
      if (ssdpDeviceIDs.length) {
        this.log.debug('Discovery complete. Found panels:\n' + JSON.stringify(ssdpDeviceIDs, null, 2));
      } else if (this.ssdpDiscoverAttempts < 5) {
        this.ssdpDiscoverAttempts++;
        this.log.debug(
          `Discovery attempt ${this.ssdpDiscoverAttempts} could not find any panels on the network. Retrying...`
        );
        this.discoverPanels();
      } else {
        this.ssdpDiscoverAttempts = 0;
        this.log.debug(
          'Could not discover any panels on the network. Please check that your panel(s) are on the same network and that you have UPnP enabled. Visit https://help.konnected.io/support/solutions/articles/32000023644-device-discovery-troubleshooting for more information.'
        );
      }
    }, this.ssdpTimeout);
  }

  /**
   * Update Homebridge config.json with discovered panel information.
   *
   * @param panelUUID string  UUID for the panel as reported in the USN on discovery.
   * @param panelObject PanelObjectInterface  The status response object of the plugin from discovery.
   */
  updateHomebridgeConfig(panelUUID: string, panelObject: PanelObjectInterface) {
    // homebridge constants
    const config = this.api.user.configPath();
    const storage = this.api.user.storagePath();

    // get and clone config
    const existingConfig = JSON.parse(fs.readFileSync(config).toString());
    const modifiedConfig = JSON.parse(JSON.stringify(existingConfig));

    // check backups/config-backups directory exists, if not use base storage directory
    const backup = fs.existsSync(`${storage}/backups/config-backups/`)
      ? `${storage}/backups/config-backups/config.json.${new Date().getTime()}`
      : `${storage}/config.json.${new Date().getTime()}`;

    // get index of my platform
    const platform = modifiedConfig.platforms.findIndex((config: { [key: string]: unknown }) => config.platform === 'konnected');

    // if 'konnected' platform exists in the config
    if (platform >= 0) {
      // get the panels array or start with an empty array
      modifiedConfig.platforms[platform].panels = modifiedConfig.platforms[platform].panels || [];

      // find existing definition of the panel
      const platformPanelPosition = modifiedConfig.platforms[platform].panels.findIndex((panel: { [key: string]: unknown }) => panel.uuid === panelUUID);

      if (platformPanelPosition < 0) {
        // if panel doesn't exist, push to panels array and write backup and config
        modifiedConfig.platforms[platform].panels.push({
          name: (panelObject.model && panelObject.model !== '' ? panelObject.model : 'Konnected V1-V2').replace(
            /[^A-Za-z0-9\s/'":\-#.]/gi,
            ''
          ),
          uuid: panelUUID,
          ipAddress: panelObject.ip,
          port: panelObject.port,
        });
        fs.writeFileSync(backup, JSON.stringify(existingConfig, null, 4));
        fs.writeFileSync(config, JSON.stringify(modifiedConfig, null, 4));
      } else if (
        modifiedConfig.platforms[platform].panels[platformPanelPosition].uuid === panelUUID &&
        (modifiedConfig.platforms[platform].panels[platformPanelPosition].ipAddress !== panelObject.ip ||
          modifiedConfig.platforms[platform].panels[platformPanelPosition].port !== panelObject.port)
      ) {
        // if the IP address or port is the same don't update the config
        modifiedConfig.platforms[platform].panels[platformPanelPosition].name = (
          panelObject.model && panelObject.model !== '' ? panelObject.model : 'Konnected V1-V2'
        ).replace(/[^A-Za-z0-9\s/'":\-#.]/gi, '');
        modifiedConfig.platforms[platform].panels[platformPanelPosition].uuid = panelUUID;
        modifiedConfig.platforms[platform].panels[platformPanelPosition].ipAddress = panelObject.ip;
        modifiedConfig.platforms[platform].panels[platformPanelPosition].port = panelObject.port;

        fs.writeFileSync(backup, JSON.stringify(existingConfig, null, 4));
        fs.writeFileSync(config, JSON.stringify(modifiedConfig, null, 4));
      }
    }
  }

  /**
   * Provision a Konnected panel with information to communicate with this plugin
   * and to register the zones on the panel according to their configured settings in this plugin.
   * @reference https://help.konnected.io/support/solutions/articles/32000026807-device-provisioning
   *
   * @param panelUUID string  UUID for the panel as reported in the USN on discovery.
   * @param panelObject PanelObjectInterface  The status response object of the plugin from discovery.
   * @param listenerObject object  Details object for this plugin's listening server.
   */
  provisionPanel(panelUUID: string, panelObject: PanelObjectInterface, listenerObject) {
    let panelIP: string = panelObject.ip;
    let panelPort: number = panelObject.port;
    let panelBlink = true;
    let panelName;

    // if there are panels in the plugin config
    if (typeof this.config.panels !== 'undefined') {
      // loop through the available panels
      for (const configPanel of this.config.panels) {
        // isolate specific panel and make sure there are zones in that panel
        if (configPanel.uuid === panelUUID) {
          panelIP = configPanel.ipAddress ? configPanel.ipAddress : panelObject.ip;
          panelPort = configPanel.port ? configPanel.port : panelObject.port;
          panelBlink = typeof configPanel.blink !== 'undefined' ? configPanel.blink : true;
          panelName = configPanel.name ? configPanel.name : '';
        }
      }
    }

    const listeningEndpoint = `http://${listenerObject.ip}:${listenerObject.port}/api/konnected`;
    const panelSettingsEndpoint = `http://${panelIP}:${panelPort}/settings`;

    const bearerAuthToken = uuidv4(); // generate an RFC4122 compliant UUID
    this.listenerAuth.push(bearerAuthToken); // add to array for listening authorization

    const panelPayloadCore = {
      endpoint_type: 'rest',
      endpoint: listeningEndpoint,
      token: bearerAuthToken,
      blink: panelBlink,
      discovery: true,
      platform: 'Homebridge',
    };

    const panelPayloadAccessories = this.configureZones(panelUUID, panelObject);

    const panelConfigurationPayload = {
      ...panelPayloadCore,
      ...panelPayloadAccessories,
    };

    this.log.debug(`Panel ${panelName} ${panelSettingsEndpoint} rebooting with payload changes:\n` + JSON.stringify(panelConfigurationPayload, null, 2));

    const provisionPanelResponse = async (url: string) => {
      try {
        await fetch(url, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(panelConfigurationPayload),
        });
      } catch (error: unknown) {
        if (error instanceof Error) {
          if (error['errno'] === 'ECONNRESET') {
            this.log.info(
              `The panel at ${url} has disconnected and is likely rebooting to apply new provisioning settings`
            );
          } else {
            this.log.error(error['message']);
          }
        }
      }
    };
    provisionPanelResponse(panelSettingsEndpoint);
  }

  /**
   * Build the payload of zones for provisioning on a panel.
   * Store the configuration of zones in the accessoriesRuntimeCache.
   *
   * @param panelUUID string  The unique identifier for the panel itself.
   * @param panelObject PanelObjectInterface  The status response object of the plugin from discovery.
   */
  configureZones(panelUUID: string, panelObject: PanelObjectInterface) {
    const sensors: unknown[] = [];
    const dht_sensors: unknown[] = [];
    const ds18b20_sensors: unknown[] = [];
    const actuators: unknown[] = [];

    const retainedAccessories: unknown[] = [];

    // if there are panels in the plugin config
    if (typeof this.config.panels !== 'undefined') {
      // loop through the available panels
      this.config.panels.forEach((configPanel) => {
        // If there's a chipId in the panelObject, use that, or use mac address.
        // V1/V2 panels only have one interface (WiFi). Panels with chipId are Pro versions
        // with two network interfaces (WiFi & Ethernet) with separate mac addresses.
        // If one network interface goes down, the panel can fallback to the other
        // interface and the accessories lose their associated UUID, which can
        // result in duplicated accessories, half of which become non-responsive.
        const panelShortUUID: string =
          'chipId' in panelObject ? panelUUID.match(/([^-]+)$/i)![1] : panelObject.mac.replace(/:/g, '');

        // isolate specific panel and make sure there are zones in that panel
        if (configPanel.uuid === panelUUID && configPanel.zones) {
          // variable for deduping zones with the same zoneNumber (use-case: if users don't use Config UI X to generate their config)
          const existingPayloadZones: string[] = [];

          configPanel.zones.forEach((configPanelZone) => {
            // create type interface for panelZone variable
            interface PanelZone {
              pin?: string;
              zone?: string;
              trigger?: number;
              poll_interval?: number;
            }
            let panelZone: PanelZone = {};

            // assign the pin or zone
            if ('model' in panelObject) {
              // this is a Pro panel
              // check if zone is improperly assigned as the V1-V2 panel 'out' zone
              if (configPanelZone.zoneNumber === 'out') {
                this.log.warn(
                  `Invalid Zone: Konnected Pro Alarm Panels do not have a zone named ${configPanelZone.zoneNumber}, change the zone assignment to 'alarm1', 'out1', or 'alarm2_out2'.`
                );
              } else if (ZONE_TYPES.actuators.includes(configPanelZone.zoneType)) {
                // this zone is assigned as an actuator
                // validate if zone can be an actuator/switch
                if (ZONES[configPanelZone.zoneNumber].includes(configPanelZone.zoneType)) {
                  panelZone.zone = configPanelZone.zoneNumber;
                } else {
                  this.log.warn(
                    `Invalid Zone: Konnected Pro Alarm Panels cannot have zone ${configPanelZone.zoneNumber} as an actuator/switch. Try zones 1-8, 'alarm1', 'out1', or 'alarm2_out2'.`
                  );
                }
              } else {
                panelZone.zone = configPanelZone.zoneNumber;
              }
            } else {
              // this is a V1-V2 panel
              // convert zone to a pin
              if (ZONES_TO_PINS[configPanelZone.zoneNumber]) {
                // check if this zone is assigned as an actuator
                if (ZONE_TYPES.actuators.includes(configPanelZone.zoneType)) {
                  // validate if zone can be an actuator/switch
                  if (configPanelZone.zoneNumber < 6 || configPanelZone.zoneNumber === 'out') {
                    panelZone.pin = ZONES_TO_PINS[configPanelZone.zoneNumber];
                  } else {
                    this.log.warn(
                      `Invalid Zone: Konnected V1-V2 Alarm Panels cannot have zone ${configPanelZone.zoneNumber} as an actuator/switch. Try zones 1-5 or 'out'.`
                    );
                  }
                } else {
                  panelZone = {
                    pin: ZONES_TO_PINS[configPanelZone.zoneNumber],
                  };
                }
              } else {
                this.log.warn(
                  `Invalid Zone: Konnected V1-V2 Alarm Panels do not have a zone '${configPanelZone.zoneNumber}'. Try zones 1-6 or 'out'.`
                );
              }
            }

            // assign the startup trigger value
            if (configPanelZone.switchSettings?.trigger) {
              panelZone.trigger = configPanelZone.switchSettings?.trigger === 'low' ? 0 : 1;
            }

            // assign the temperature/humidity poll interval
            if (configPanelZone.environmentalSensorSettings?.pollInterval) {
              panelZone.poll_interval = configPanelZone.environmentalSensorSettings.pollInterval;
            }

            // check if the panel object is not empty (this will cause a boot loop if it's empty)
            if (Object.keys(panelZone).length > 0 && configPanelZone.enabled === true) {
              // put panelZone into the correct device type for the panel
              if (ZONE_TYPES.sensors.includes(configPanelZone.zoneType)) {
                sensors.push(panelZone);
              } else if (ZONE_TYPES.dht_sensors.includes(configPanelZone.zoneType)) {
                dht_sensors.push(panelZone);
              } else if (ZONE_TYPES.ds18b20_sensors.includes(configPanelZone.zoneType)) {
                ds18b20_sensors.push(panelZone);
              } else if (ZONE_TYPES.actuators.includes(configPanelZone.zoneType)) {
                actuators.push(panelZone);
              }
            }

            // genereate unique ID for zone
            const zoneUUID: string = this.api.hap.uuid.generate(panelShortUUID + '-' + configPanelZone.zoneNumber);

            // if there's a model in the panelObject, that means the panel is Pro
            const panelModel: string = 'model' in panelObject ? 'Pro' : 'V1-V2';

            // dedupe zones with the same zoneNumber
            if (!existingPayloadZones.includes(zoneUUID)) {
              // if not a duplicate, push the zone's UUID into the zoneCheck array
              existingPayloadZones.push(zoneUUID);

              const zoneLocation = configPanelZone.zoneLocation ? configPanelZone.zoneLocation + ' ' : '';

              // standard zone object properties
              const zoneObject: RuntimeCacheInterface = {
                UUID: zoneUUID,
                displayName: zoneLocation + TYPES_TO_ACCESSORIES[configPanelZone.zoneType][1],
                enabled: configPanelZone.enabled,
                type: configPanelZone.zoneType,
                model: panelModel + ' ' + TYPES_TO_ACCESSORIES[configPanelZone.zoneType][1],
                serialNumber: panelShortUUID + '-' + configPanelZone.zoneNumber,
                panel: panelObject,
              };
              // add invert property if configured
              if (configPanelZone.binarySensorSettings?.invert) {
                zoneObject.invert = configPanelZone.binarySensorSettings.invert;
              }
              // add audibleBeep property if configured
              if (configPanelZone.binarySensorSettings?.audibleBeep) {
                zoneObject.audibleBeep = configPanelZone.binarySensorSettings.audibleBeep;
              }
              // add trigger property if configured
              if (configPanelZone.switchSettings?.trigger) {
                zoneObject.trigger = configPanelZone.switchSettings.trigger;
              }
              // add triggerableModes property if configured
              if (configPanelZone.binarySensorSettings?.triggerableModes) {
                zoneObject.triggerableModes = configPanelZone.binarySensorSettings.triggerableModes;
              } else if (configPanelZone.switchSettings?.triggerableModes) {
                zoneObject.triggerableModes = configPanelZone.switchSettings.triggerableModes;
              }

              // store previous state from existing Homebridge's platform accessory cache state
              this.accessories.forEach((accessory) => {
                if (accessory.UUID === zoneUUID) {
                  // binary state
                  if (typeof accessory.context.device.state !== 'undefined') {
                    zoneObject.state = accessory.context.device.state;
                  }
                  // humidity state
                  if (typeof accessory.context.device.humi !== 'undefined') {
                    zoneObject.humi = accessory.context.device.humi;
                  }
                  // temperature state
                  if (typeof accessory.context.device.temp !== 'undefined') {
                    zoneObject.humi = accessory.context.device.temp;
                  }
                }
              });

              if (configPanelZone.enabled === true) {
                this.accessoriesRuntimeCache.push(zoneObject);

                // match this zone's UUID to the UUID of an accessory stored in the global accessories cache
                // store accessory object in an array of retained accessories that we don't want unregistered in Homebridge and HomeKit
                if (typeof this.accessories.find((accessory) => accessory.UUID === zoneUUID) !== 'undefined') {
                  retainedAccessories.push(this.accessories.find((accessory) => accessory.UUID === zoneUUID));
                }
              }
            } else {
              this.log.warn(
                `Duplicate Zone: Zone number '${configPanelZone.zoneNumber}' is assigned in two or more zones, please check your Homebridge configuration for panel with UUID ${panelUUID}.`
              );
            }
          }); // end forEach loop (zones)

          // Now attempt to register the zones as accessories in Homebridge and HomeKit
          this.registerAccessories(panelShortUUID, this.accessoriesRuntimeCache, retainedAccessories);
        } else if (configPanel.uuid === panelUUID && typeof configPanel.zones === 'undefined') {
          this.registerAccessories(panelShortUUID, [], []);
        }
      }); // end forEach loop (panels)
    }

    // if there are no zones defined then we use our default blank array variables above this block
    const panelZonesPayload = {
      sensors: sensors,
      dht_sensors: dht_sensors,
      ds18b20_sensors: ds18b20_sensors,
      actuators: actuators,
    };

    return panelZonesPayload;
  }

  /**
   * Control the registration of panel zones as accessories in Homebridge (and HomeKit).
   *
   * @param panelShortUUID string  The panel short UUID for the panel of zones being passed in.
   * @param zoneObjectsArray array  An array of constructed zoneObjects.
   * @param retainedAccessoriesArray array  An array of retained accessory objects.
   */
  registerAccessories(panelShortUUID, zoneObjectsArray, retainedAccessoriesArray) {
    // console.log('zoneObjectsArray', zoneObjectsArray);
    // console.log('retainedAccessoriesArray:', retainedAccessoriesArray);

    // if (Array.isArray(retainedAccessoriesArray) && retainedAccessoriesArray.length > 0) {
    //   retainedAccessoriesArray.forEach((accessory) => {
    //     if (typeof accessory !== 'undefined') {
    //       this.log.debug(`Retained accessory: ${accessory.displayName} (${accessory.context.device.serialNumber})`);
    //     }
    //   });
    // }

    // remove any stale accessories
    ///////////////////////////////

    const accessoriesToRemoveArray = this.accessories
      .filter(
        // filter in accessories with same panel
        (accessory) => accessory.context.device.serialNumber.split('-')[0] === panelShortUUID
      )
      .filter(
        // filter out retained accessories
        (accessory) => !retainedAccessoriesArray.includes(accessory)
      );

    if (Array.isArray(accessoriesToRemoveArray) && accessoriesToRemoveArray.length > 0) {
      // unregister stale or missing zones/accessories in Homebridge and HomeKit
      accessoriesToRemoveArray.forEach((accessory) => {
        this.log.info(`Removing accessory: ${accessory.displayName} (${accessory.context.device.serialNumber})`);
      });
      this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToRemoveArray);
    }

    // update or create accessories
    ///////////////////////////////

    const accessoriesToUpdateArray: PlatformAccessory[] = [];
    const accessoriesToAddArray: PlatformAccessory[] = [];

    zoneObjectsArray.forEach((panelZoneObject) => {
      // find Homebridge cached accessories with the same uuid as those in the config
      const existingAccessory = this.accessories.find((accessory) => accessory.UUID === panelZoneObject.UUID);

      if (existingAccessory && existingAccessory.context.device.UUID === panelZoneObject.UUID) {
        // then the accessory already exists
        this.log.debug(
          `Updating existing accessory: ${existingAccessory.context.device.displayName} (${existingAccessory.context.device.serialNumber})`
        );

        // update zone object in the platform accessory cache
        existingAccessory.displayName = panelZoneObject.displayName;
        existingAccessory.context.device = panelZoneObject;
        // store a direct reference to the initialized accessory with service and characteristics in the KonnectedPlatformAccessories object
        this.konnectedPlatformAccessories[panelZoneObject.UUID] = new KonnectedPlatformAccessory(
          this,
          existingAccessory
        );

        accessoriesToUpdateArray.push(existingAccessory);
      } else {
        // otherwise we're adding a new accessory
        this.log.info(`Adding new accessory: ${panelZoneObject.displayName} (${panelZoneObject.serialNumber})`);

        // build Homebridge/HomeKit platform accessory
        const newAccessory = new this.api.platformAccessory(panelZoneObject.displayName, panelZoneObject.UUID);
        // store zone object in the platform accessory cache
        newAccessory.context.device = panelZoneObject;
        // store a direct reference to the initialized accessory with service and characteristics in the KonnectedPlatformAccessories object
        this.konnectedPlatformAccessories[panelZoneObject.UUID] = new KonnectedPlatformAccessory(this, newAccessory);

        accessoriesToAddArray.push(newAccessory);
      }
    });

    if (Array.isArray(accessoriesToUpdateArray) && accessoriesToUpdateArray.length > 0) {
      // update zones/accessories in Homebridge and HomeKit
      this.api.updatePlatformAccessories(accessoriesToUpdateArray);
    }

    if (Array.isArray(accessoriesToAddArray) && accessoriesToAddArray.length > 0) {
      // add zones/accessories to Homebridge and HomeKit
      this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToAddArray);
    }
  }

  /**
   * Update the cache when a panel reports a change in the sensor zone's state.
   * Panels only report state of sensors, so this will only fire for sensors and not actuators.
   *
   * @param inboundPayload object  The request payload received for the zone at this plugin's listener REST endpoint.
   */
  updateSensorAccessoryState(inboundPayload) {
    let panelZone = '';
    let zoneState = '';
    if ('pin' in inboundPayload.body) {
      // convert a pin to a zone
      Object.entries(ZONES_TO_PINS).map(([key, value]) => {
        if (value === inboundPayload.body.pin) {
          panelZone = key;
          zoneState = JSON.stringify(inboundPayload.body) + ` (zone: ${panelZone})`;
        }
      });
    } else {
      // use the zone
      panelZone = inboundPayload.body.zone;
      zoneState = JSON.stringify(inboundPayload.body);
    }

    const zoneUUID = this.api.hap.uuid.generate(inboundPayload.params.id + '-' + panelZone);

    const existingAccessory = this.accessories.find((accessory) => accessory.UUID === zoneUUID);

    // check if the accessory already exists
    if (existingAccessory) {
      this.log.debug(
        `Panel sent update for [${existingAccessory.displayName}] (${existingAccessory.context.device.serialNumber}) with value:\n`,
        zoneState
      );

      // loop through the accessories state cache and update state and service characteristic
      this.accessoriesRuntimeCache.forEach((runtimeCacheAccessory) => {
        if (runtimeCacheAccessory.UUID === zoneUUID) {
          // this is the default state for all binary switches in HomeKit
          let defaultStateValue = 0;
          // incoming state from panel
          const inboundStateValue = inboundPayload.body.state;
          // set default result state
          let resultStateValue = inboundStateValue;

          if (!['humidtemp', 'temperature'].includes(runtimeCacheAccessory.type)) {
            // invert the value if configured to have its value inverted
            if (runtimeCacheAccessory.invert === true) {
              defaultStateValue = 1;
              resultStateValue = inboundStateValue === 0 ? 1 : 0;
              this.log.debug(
                `[${runtimeCacheAccessory.displayName}] (${runtimeCacheAccessory.serialNumber}) as ${runtimeCacheAccessory.type} inverted state from '${inboundStateValue}' to '${resultStateValue}'`
              );
            }
            // now check if the accessory should do something: e.g., trigger the alarm, produce an audible beep, etc.
            this.processSensorAccessoryActions(runtimeCacheAccessory, defaultStateValue, resultStateValue);
          }

          switch (TYPES_TO_ACCESSORIES[runtimeCacheAccessory.type][0]) {
            case 'ContactSensor':
              runtimeCacheAccessory.state = resultStateValue;
              this.konnectedPlatformAccessories[runtimeCacheAccessory.UUID].service.updateCharacteristic(
                this.Characteristic.ContactSensorState,
                resultStateValue
              );
              break;
            case 'MotionSensor':
              runtimeCacheAccessory.state = resultStateValue;
              this.konnectedPlatformAccessories[runtimeCacheAccessory.UUID].service.updateCharacteristic(
                this.Characteristic.MotionDetected,
                resultStateValue
              );
              break;
            case 'LeakSensor':
              runtimeCacheAccessory.state = resultStateValue;
              this.konnectedPlatformAccessories[runtimeCacheAccessory.UUID].service.updateCharacteristic(
                this.Characteristic.LeakDetected,
                resultStateValue
              );
              break;
            case 'SmokeSensor':
              runtimeCacheAccessory.state = resultStateValue;
              this.konnectedPlatformAccessories[runtimeCacheAccessory.UUID].service.updateCharacteristic(
                this.Characteristic.SmokeDetected,
                resultStateValue
              );
              break;
            case 'TemperatureSensor':
              runtimeCacheAccessory.temp = inboundPayload.body.temp;
              this.konnectedPlatformAccessories[runtimeCacheAccessory.UUID].service.updateCharacteristic(
                this.Characteristic.CurrentTemperature,
                runtimeCacheAccessory.temp
              );
              break;
            case 'HumiditySensor':
              runtimeCacheAccessory.temp = inboundPayload.body.temp;
              this.konnectedPlatformAccessories[runtimeCacheAccessory.UUID].service.updateCharacteristic(
                this.Characteristic.CurrentTemperature,
                runtimeCacheAccessory.temp
              );
              runtimeCacheAccessory.humi = inboundPayload.body.humi;
              this.konnectedPlatformAccessories[runtimeCacheAccessory.UUID].service.updateCharacteristic(
                this.Characteristic.CurrentRelativeHumidity,
                runtimeCacheAccessory.humi
              );
              break;

            default:
              break;
          }
        }
      });
    }
  }

  /**
   * Determine if the passed in sensor accessory should do something.
   * E.g., trigger the alarm, produce an audible beep, etc.
   *
   * @param accessory RuntimeCacheInterface  The accessory that we are basing our actions by.
   * @param defaultStateValue number  The original default state of the accessory.
   * @param resultStateValue number  The state of the accessory as updated.
   */
  processSensorAccessoryActions(accessory: RuntimeCacheInterface, defaultStateValue: number, resultStateValue: number) {
    // if the default state of the accessory is not the same as the updated state, we should process it
    if (defaultStateValue !== resultStateValue) {
      this.log.debug(
        `[${accessory.displayName}] (${accessory.serialNumber}) as '${accessory.type}' changed from its default state of ${defaultStateValue} to ${resultStateValue}`
      );

      const securitySystemAccessory = this.accessories.find((accessory) => accessory.UUID === this.securitySystemUUID);

      // check what modes the accessory has set to trigger the alarm
      if (
        accessory.triggerableModes?.includes(String(securitySystemAccessory?.context.device.state) as never) &&
        typeof this.entryTriggerDelayTimerHandle === 'undefined'
      ) {
        // accessory should trigger security system

        // find beepers and actuate audible delay sound
        this.accessoriesRuntimeCache.forEach((beeperAccessory) => {
          if (beeperAccessory.type === 'beeper') {
            if (this.config.advanced?.entryDelaySettings?.pulseDuration) {
              this.actuateAccessory(beeperAccessory.UUID, true, this.config.advanced?.entryDelaySettings);
            } else {
              this.actuateAccessory(beeperAccessory.UUID, true, {});
            }
          }
        });

        // wait the entry delay time and trigger the security system
        this.entryTriggerDelayTimerHandle = setTimeout(() => {
          this.log.debug(
            `Set [${securitySystemAccessory?.displayName}] (${securitySystemAccessory?.context.device.serialNumber}) as '${securitySystemAccessory?.context.device.type}' characteristic: 4 (triggered!)`
          );
          this.controlSecuritySystem(4);
        }, this.entryTriggerDelay);
      } else {
        // accessory is just sensing change

        // restrict it to contact or motion sensor accessories that have the audible notification setting configured
        if (['contact', 'motion'].includes(accessory.type) && accessory.audibleBeep) {
          this.accessoriesRuntimeCache.forEach((beeperAccessory) => {
            if (beeperAccessory.type === 'beeper') {
              this.actuateAccessory(beeperAccessory.UUID, true, null);
            }
          });
        }
      }
    }
  }

  /**
   * Actuate a zone on a panel based on the switch's state.
   *
   * @param zoneUUID string  HAP UUID for the switch zone accessory.
   * @param value boolean  The value of the state as represented in HomeKit (may be adjusted by config trigger settings).
   * @param inboundSwitchSettings object | null | undefined  Settings object that can override the default accessory settings.
   */
  actuateAccessory(zoneUUID: string, value: boolean, inboundSwitchSettings: Record<string, unknown> | null) {
    // retrieve the matching accessory
    const existingAccessory = this.accessories.find((accessory) => accessory.UUID === zoneUUID);

    // set the representative state in HomeKit
    this.konnectedPlatformAccessories[zoneUUID].service.updateCharacteristic(this.Characteristic.On, value);

    if (existingAccessory) {
      // loop through the plugin configuration to get the correct panel for the zone
      this.config.panels.forEach((panelObject) => {
        if (panelObject.ipAddress === existingAccessory.context.device.panel.ip) {
          // build endpoint
          let panelEndpoint =
            'http://' +
            existingAccessory.context.device.panel.ip +
            ':' +
            existingAccessory.context.device.panel.port +
            '/';

          // loop through the plugin configuration to get the zone switch settings
          panelObject.zones.forEach((zoneObject) => {
            if (zoneObject.zoneNumber === existingAccessory.context.device.serialNumber.split('-')[1]) {

              const runtimeCacheAccessory: RuntimeCacheInterface = this.accessoriesRuntimeCache.find((runtimeCacheAccessory) => runtimeCacheAccessory.UUID === zoneUUID) as never;

              let actuatorState = 0;
              if (runtimeCacheAccessory.trigger === 'low' && value === false) {
                actuatorState = 1; // set to normally high (1), waiting to be triggered low (0)
              } else if (runtimeCacheAccessory.trigger === 'low' && value === true) {
                actuatorState = 0; // set to triggered low (0), waiting to be normally high (1)
              } else if ((runtimeCacheAccessory.trigger === 'high' || runtimeCacheAccessory.trigger === undefined) && value === false) {
                actuatorState = 0; // set to normally low (0), waiting to be triggered high (1)
              } else if ((runtimeCacheAccessory.trigger === 'high' || runtimeCacheAccessory.trigger === undefined) && value === true) {
                actuatorState = 1; // set to triggered high (1), waiting to be normally low (0)
              }
              const actuatorPayload: Record<string, unknown> = {
                // explicitly convert boolean to integer for the panel payload
                state: actuatorState,
              };

              // Pro zone vs V1-V2 pin payload property assignment
              if ('model' in existingAccessory.context.device.panel) {
                // this is a Pro panel
                panelEndpoint += 'zone';
                if (ZONES[zoneObject.zoneNumber].includes('switch')) {
                  actuatorPayload.zone = zoneObject.zoneNumber;
                } else {
                  this.log.warn(
                    `Invalid Zone: Cannot actuate the zone '${zoneObject.zoneNumber}' for Konnected Pro Alarm Panels. Try zones 1-8, 'alarm1', 'out1', or 'alarm2_out2'.`
                  );
                }
              } else {
                // this is a V1-V2 panel
                panelEndpoint += 'device';
                // convert zone to a pin
                if (zoneObject.zoneNumber < 6 || zoneObject.zoneNumber === 'out') {
                  actuatorPayload!.pin = ZONES_TO_PINS[zoneObject.zoneNumber];
                } else {
                  this.log.warn(
                    `Invalid Zone: Cannot actuate the zone '${zoneObject.zoneNumber}' for Konnected V1-V2 Alarm Panels. Try zones 1-5 or 'out'.`
                  );
                }
              }

              // calculate the duration for a momentary switch to complete its triggered task (eg. sequence of pulses)
              // this calculation occurs when there are switch settings and the switch is turning 'on' (true)
              // otherwise we simply need send a default payload of 'off'
              let actuatorDuration;
              const switchSettings = inboundSwitchSettings ? inboundSwitchSettings : zoneObject.switchSettings;
              if (switchSettings && value === true) {
                if (switchSettings.pulseDuration) {
                  actuatorPayload.momentary = actuatorDuration = switchSettings.pulseDuration;
                }
                if (switchSettings.pulseRepeat && switchSettings.pulsePause) {
                  actuatorPayload.times = switchSettings.pulseRepeat;
                  actuatorPayload.pause = switchSettings.pulsePause;
                  if (switchSettings.pulseRepeat > 0) {
                    actuatorDuration =
                      actuatorDuration * switchSettings.pulseRepeat +
                      switchSettings.pulsePause * (switchSettings.pulseRepeat - 1);
                  }
                }
              }

              this.log.debug(
                `Actuating [${existingAccessory.displayName}] (${existingAccessory.context.device.serialNumber}) as '${existingAccessory.context.device.type}' with payload:\n` +
                  JSON.stringify(actuatorPayload, null, 2)
              );

              // send payload to panel to actuate, and if momentary, also change the switch state back after duration
              const actuatePanelZone = async (url: string) => {
                try {
                  const response = await fetch(url, {
                    method: 'PUT',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(actuatorPayload),
                  });
                  if (
                    response.status === 200 &&
                    ['beeper', 'siren', 'strobe', 'switch'].includes(existingAccessory.context.device.type)
                  ) {
                    // if momentary switch, reset the state after calculated duration
                    if (actuatorDuration > 0 && switchSettings.pulseRepeat !== Number(-1)) {
                      setTimeout(() => {
                        // update Homebridge/HomeKit displayed state
                        this.konnectedPlatformAccessories[zoneUUID].service.updateCharacteristic(
                          this.Characteristic.On,
                          false
                        );
                        // update the state cache
                        this.accessoriesRuntimeCache.forEach((runtimeCacheAccessory) => {
                          if (runtimeCacheAccessory.UUID === zoneUUID) {
                            runtimeCacheAccessory.state = existingAccessory.context.device.state = false;
                            this.log.debug(
                              `Set [${runtimeCacheAccessory.displayName}] (${runtimeCacheAccessory.serialNumber}) as '${runtimeCacheAccessory.type}' characteristic value: false`
                            );
                          }
                        });
                      }, actuatorDuration);
                    }
                  }
                } catch (error: unknown) {
                  if (error instanceof Error) {
                    this.log.error(error['message']);
                  }
                }
              };
              actuatePanelZone(panelEndpoint);
            }
          });
        }
      });
    }
  }

  /**
   * Arm/Disarm/Trigger the security system accessory.
   *
   * @param value number  The value to change the state of the Security System accessory to.
   */
  controlSecuritySystem(value: number) {
    // pulse settings
    const duration = 100; // change this to make the pulse longer or shorter, everything else will calculate
    const pause = 1000 - duration;
    const minDefault = 10000; // anything less than 10 seconds is just going to have beeps once a second

    clearTimeout(this.exitTriggerDelayTimerHandle1);
    clearTimeout(this.exitTriggerDelayTimerHandle2);
    clearTimeout(this.exitTriggerDelayTimerHandle3);
    delete this.exitTriggerDelayTimerHandle1;
    delete this.exitTriggerDelayTimerHandle2;
    delete this.exitTriggerDelayTimerHandle3;

    // if the security system is set to one of 0: home/stay, 1: away, 2: night
    if (value < 3) {
      // set security system TARGET based on value (then later make actual state by updating CURRENT state)
      this.konnectedPlatformAccessories[this.securitySystemUUID].service.updateCharacteristic(
        this.Characteristic.SecuritySystemTargetState,
        value
      );

      this.accessoriesRuntimeCache.forEach((runtimeCacheAccessory) => {
        if ('beeper' === runtimeCacheAccessory.type) {
          // clears any current beeping
          this.actuateAccessory(runtimeCacheAccessory.UUID, false, null);
        }
      });

      if (
        (typeof this.config.advanced?.exitDelaySettings?.audibleBeeperModes !== 'undefined' &&
          this.config.advanced?.exitDelaySettings?.audibleBeeperModes.includes(String(value))) ||
        (typeof this.config.advanced?.exitDelaySettings?.audibleBeeperModes === 'undefined' && value === 1)
      ) {
        // if the user has configured which mode should have audible beeper countdowns
        // or if not, but the security mode is being set to away, then we do some sort of default countdown
        this.accessoriesRuntimeCache.forEach((runtimeCacheAccessory) => {
          if ('beeper' === runtimeCacheAccessory.type) {
            if (this.exitTriggerDelay > minDefault) {
              // three stages of short beeper pulses (per second): 1ps > 2ps > 4ps
              this.actuateAccessory(runtimeCacheAccessory.UUID, true, {
                pulseDuration: duration,
                pulsePause: pause,
                pulseRepeat: Math.floor(this.exitTriggerDelay / 1000 / 3),
              });
              // we need to clear this if the mode changes before they complete
              // make as a variable that we can clear set timeout
              this.exitTriggerDelayTimerHandle2 = setTimeout(() => {
                this.actuateAccessory(runtimeCacheAccessory.UUID, true, {
                  pulseDuration: duration,
                  pulsePause: pause / 2,
                  pulseRepeat: Math.floor((this.exitTriggerDelay / 1000 / 3) * 2) - 1,
                });
              }, this.exitTriggerDelay / 3);
              // we need to clear this if the mode changes before they complete
              // make as a variable that we can clear set timeout
              this.exitTriggerDelayTimerHandle3 = setTimeout(() => {
                this.actuateAccessory(runtimeCacheAccessory.UUID, true, {
                  pulseDuration: duration,
                  pulsePause: pause / 4,
                  pulseRepeat: Math.floor((this.exitTriggerDelay / 1000 / 3) * 4) - 2,
                });
              }, (this.exitTriggerDelay / 3) * 2);
            } else if (this.exitTriggerDelay <= minDefault && this.exitTriggerDelay > 1000) {
              // one short pulse per second
              this.actuateAccessory(runtimeCacheAccessory.UUID, true, {
                pulseDuration: duration,
                pulsePause: pause,
                pulseRepeat: this.exitTriggerDelay / 1000,
              });
            }
          }
        });
        // wait the exit delay time and then arm security system based on value
        this.exitTriggerDelayTimerHandle1 = setTimeout(() => {
          this.konnectedPlatformAccessories[this.securitySystemUUID].service.updateCharacteristic(
            this.Characteristic.SecuritySystemCurrentState,
            value
          );
        }, this.exitTriggerDelay);
      } else {
        // immediately arm system
        this.konnectedPlatformAccessories[this.securitySystemUUID].service.updateCharacteristic(
          this.Characteristic.SecuritySystemCurrentState,
          value
        );
      }
    } else {
      // 3: disarmed, 4: alarm triggered
      this.konnectedPlatformAccessories[this.securitySystemUUID].service.updateCharacteristic(
        this.Characteristic.SecuritySystemCurrentState,
        value
      );
    }

    // store in platform accessories cache
    this.accessories.find((accessory) => {
      if (accessory.UUID === this.securitySystemUUID) {
        accessory.context.device.state = value;
      }
    });

    // if the security system is turned off, turn beepers, sirens and strobes off
    if (value === 3) {
      clearTimeout(this.entryTriggerDelayTimerHandle);
      delete this.entryTriggerDelayTimerHandle;
      this.accessoriesRuntimeCache.forEach((runtimeCacheAccessory) => {
        if (['beeper', 'siren', 'strobe'].includes(runtimeCacheAccessory.type)) {
          this.actuateAccessory(runtimeCacheAccessory.UUID, false, null);
        }
      });
    }

    // if the security system is triggered
    if (value === 4) {
      this.accessoriesRuntimeCache.forEach((runtimeCacheAccessory) => {
        // turns off the beeper
        if ('beeper' === runtimeCacheAccessory.type) {
          this.actuateAccessory(runtimeCacheAccessory.UUID, false, null);
        }
        // turns on the siren and strobe lights
        if (['siren', 'strobe'].includes(runtimeCacheAccessory.type)) {
          this.actuateAccessory(runtimeCacheAccessory.UUID, true, null);
        }
      });

      /** for future
       * @link https://www.npmjs.com/package/@noonlight/noonlight-sdk
       */
    }
  }
}
Example #13
Source File: platform.ts    From homebridge-plugin-template with Apache License 2.0 4 votes vote down vote up
/**
 * 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 ExampleHomebridgePlatform 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[] = [];

  constructor(
    public readonly log: Logger,
    public readonly config: PlatformConfig,
    public readonly api: API,
  ) {
    this.log.debug('Finished initializing platform:', this.config.name);

    // 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('didFinishLaunching', () => {
      log.debug('Executed didFinishLaunching callback');
      // run the method to discover / register your devices as accessories
      this.discoverDevices();
    });
  }

  /**
   * 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.info('Loading 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);
  }

  /**
   * 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.
   */
  discoverDevices() {

    // EXAMPLE ONLY
    // A real plugin you would discover accessories from the local network, cloud services
    // or a user-defined array in the platform config.
    const exampleDevices = [
      {
        exampleUniqueId: 'ABCD',
        exampleDisplayName: 'Bedroom',
      },
      {
        exampleUniqueId: 'EFGH',
        exampleDisplayName: 'Kitchen',
      },
    ];

    // loop over the discovered devices and register each one if it has not already been registered
    for (const device of exampleDevices) {

      // 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.exampleUniqueId);

      // see if an accessory with the same uuid has already been registered and restored from
      // the cached devices we stored in the `configureAccessory` method above
      const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);

      if (existingAccessory) {
        // the accessory already exists
        this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);

        // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.:
        // existingAccessory.context.device = device;
        // this.api.updatePlatformAccessories([existingAccessory]);

        // create the accessory handler for the restored accessory
        // this is imported from `platformAccessory.ts`
        new ExamplePlatformAccessory(this, existingAccessory);

        // it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
        // remove platform accessories when no longer present
        // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
        // this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
      } else {
        // the accessory does not yet exist, so we need to create it
        this.log.info('Adding new accessory:', device.exampleDisplayName);

        // create a new accessory
        const accessory = new this.api.platformAccessory(device.exampleDisplayName, 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 for the newly create accessory
        // this is imported from `platformAccessory.ts`
        new ExamplePlatformAccessory(this, accessory);

        // link the accessory to your platform
        this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
      }
    }
  }
}
Example #14
Source File: platform.ts    From homebridge-vieramatic with Apache License 2.0 4 votes vote down vote up
class VieramaticPlatform implements DynamicPlatformPlugin {
  readonly Service: typeof Service

  readonly Characteristic: typeof Characteristic

  readonly accessories: PlatformAccessory[] = []

  readonly storage: Storage

  constructor(
    readonly log: Logger,
    private readonly config: PlatformConfig,
    private readonly api: API
  ) {
    this.storage = new Storage(api)
    this.Characteristic = this.api.hap.Characteristic
    this.Service = this.api.hap.Service

    this.log.debug('Finished initializing platform:', this.config.platform)

    this.api.on('didFinishLaunching', async () => {
      log.debug('Executed didFinishLaunching callback')
      await this.discoverDevices()
    })
  }

  configureAccessory = (accessory: PlatformAccessory): void => {
    this.log.info('Loading accessory from cache:', accessory.displayName)
    this.accessories.push(accessory)
  }

  discoverDevices = async (): Promise<void> => {
    this.accessories.map((cachedAccessory) =>
      this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [cachedAccessory])
    )

    const devices = (this.config.tvs ?? []) as UserConfig[]

    const sanityCheck = dupeChecker(devices)
    if (Abnormal(sanityCheck)) {
      this.log.error('Aborted loading as a fatal error was found.')
      this.log.error(
        'Attempting to setup more than a single TV with same IP address: ',
        sanityCheck.error.message
      )
      this.log.error('please fix your config and then restart homebridge again!')
      return
    }

    for (const device of devices) {
      const outcome = await this.#deviceSetup(device)
      if (Abnormal(outcome)) {
        this.log.error(outcome.error.message)
        continue
      }

      this.api.publishExternalAccessories(PLUGIN_NAME, [outcome.value.accessory])
      this.log.info('successfully loaded', outcome.value.accessory.displayName)
    }
  }

  #deviceSetupPreFlight = (device: UserConfig): Outcome<void> => {
    const { ipAddress, mac } = device
    const invalid = (type: string): Error =>
      Error(`IGNORED '${ipAddress}': it has an invalid ${type} address.\n\n${prettyPrint(device)}`)

    if (!isValidIPv4(ipAddress)) return { error: invalid('ip') }
    if (mac && !isValidMACAddress(mac)) return { error: invalid('MAC') }

    return {}
  }

  #knownWorking = (ip: string): VieraSpecs => {
    if (isEmpty(this.storage.accessories)) return {}

    for (const [_, v] of Object.entries(this.storage.accessories))
      if (v.data.ipAddress === ip) return v.data.specs

    return {}
  }

  #deviceSetup = async (device: UserConfig): Promise<Outcome<VieramaticPlatformAccessory>> => {
    this.log.info("handling '%s' from config.json", device.ipAddress)

    const [ip, outcome] = [device.ipAddress, this.#deviceSetupPreFlight(device)]

    if (Abnormal(outcome)) return outcome

    const [reachable, cached] = [await VieraTV.livenessProbe(ip), this.#knownWorking(ip)]

    if (!reachable && isEmpty(cached)) {
      const error = Error(
        `IGNORING '${ip}' as it is not reachable.\n` +
          "(As we can't rely on cached data since it seems that it was never ever seen and " +
          'fully setup before)\n\n' +
          'Please make sure that your TV is powered ON and connected to the network.'
      )

      return { error }
    }
    const hasAuth = device.appId && device.encKey
    const auth = hasAuth ? ({ appId: device.appId, key: device.encKey } as VieraAuth) : undefined

    const conn = await VieraTV.connect(ip, this.log, { auth, cached })
    if (Abnormal(conn)) return conn
    const tv = conn.value

    if (device.friendlyName !== undefined) tv.specs.friendlyName = device.friendlyName
    tv.specs.friendlyName = tv.specs.friendlyName.trim()

    try {
      const accessory = new this.api.platformAccessory(
        tv.specs.friendlyName,
        tv.specs.serialNumber,
        this.api.hap.Categories.TELEVISION
      )

      accessory.context.device = tv

      const accessories = this.storage.accessories
      const firstTime = isEmpty(accessories) || !accessories[tv.specs.serialNumber]

      if (firstTime) this.log.info(`Initializing '${tv.specs.friendlyName}' first time ever.`)

      if (!device.disabledAppSupport && Abnormal(tv.apps)) {
        const err = `Unable to fetch Apps list from the TV: ${tv.apps.error.message}.`
        const ft = `Unable to finish initial setup of ${tv.specs.friendlyName}. ${err}. This TV must be powered ON and NOT in stand-by.`
        if (firstTime) return { error: Error(ft) }
        this.log.debug(err)
      }

      return { value: new VieramaticPlatformAccessory(this, accessory, device) }
    } catch (error) {
      this.log.error('device:', prettyPrint(device))
      this.log.error('specs:', prettyPrint(tv.specs))
      return { error: error as Error }
    }
  }
}
Example #15
Source File: platform.ts    From homebridge-iRobot with Apache License 2.0 4 votes vote down vote up
/**
 * 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 iRobotPlatform 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[] = [];

  constructor(
    public readonly log: Logger,
    public readonly config: PlatformConfig,
    public readonly api: API,
  ) {
    this.log.debug('Finished initializing platform:', this.config.name);

    // 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('didFinishLaunching', () => {
      log.debug('Executed didFinishLaunching callback');
      // run the method to discover / register your devices as accessories
      this.discoverDevices();
    });
  }

  /**
   * 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.info('Loading 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);
  }

  /**
   * 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.
   */
  discoverDevices() {
    if(this.config.email === undefined || this.config.password === undefined){
      this.log.warn('No email or password provided. Exiting setup');
      return;
    }

    // EXAMPLE ONLY
    // A real plugin you would discover accessories from the local network, cloud services
    // or a user-defined array in the platform config.

    // loop over the discovered devices and register each one if it has not already been registered
    for (const device of getRoombas(this.config.email, this.config.password, this.log, this.config)) {
      if(this.config.disableMultiRoom){
        device.multiRoom = false;
      }
      // 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.blid);

      // see if an accessory with the same uuid has already been registered and restored from
      // the cached devices we stored in the `configureAccessory` method above
      const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);

      if (existingAccessory) {
        // the accessory already exists
        this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);

        // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.:
        // existingAccessory.context.device = device;
        // this.api.updatePlatformAccessories([existingAccessory]);

        // create the accessory handler for the restored accessory
        // this is imported from `platformAccessory.ts`
        new iRobotPlatformAccessory(this, existingAccessory, device);

        // it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
        // remove platform accessories when no longer present
        // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
        // this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
      } else {
        if (device.ip === 'undefined') {
          return;
        }
        // the accessory does not yet exist, so we need to create it
        this.log.info('Adding new accessory:', device.name);

        // create a new accessory
        const accessory = new this.api.platformAccessory(device.name, 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 for the newly create accessory
        // this is imported from `platformAccessory.ts`
        new iRobotPlatformAccessory(this, accessory, device);

        // link the accessory to your platform
        this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
      }
    }
  }
}
Example #16
Source File: platform.ts    From homebridge-plugin-eufy-security with Apache License 2.0 4 votes vote down vote up
/**
 * 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 EufySecurityHomebridgePlatform 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 httpService: HttpService;

  private config: EufyPlatformConfig;

  constructor(
    public readonly log: Logger,
    config: PlatformConfig,
    public readonly api: API,
  ) {
    this.config = config as EufyPlatformConfig;
    // this.log.debug('Config', this.config);
    this.log.debug('Finished initializing platform:', this.config.platform);

    this.httpService = new HttpService(this.config.username, this.config.password);

    // 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('didFinishLaunching', async () => {
      if (this.config.enablePush) {
        this.log.info('push client enabled');
        await this.setupPushClient();
      } else {
        this.log.info('push client disabled');
      }

      log.debug('Executed didFinishLaunching callback');
      // run the method to discover / register your devices as accessories

      try {
        this.discoverDevices();
      } catch(error) {
        this.log.error('error while discovering devices');
        this.log.error(error);
      }
    });
  }

  async setupPushClient() {
    const storagePath = this.api.user.storagePath();
    const credentialsPath = `${storagePath}/eufy-security-credentials.json`;

    let credentials: PushCredentials;
    if (fs.existsSync(credentialsPath)) {
      this.log.info('credentials found. reusing them...');
      credentials = JSON.parse(fs.readFileSync(credentialsPath).toString());
    } else {
      // Register push credentials
      this.log.info('no credentials found. register new...');
      const pushService = new PushRegisterService();
      credentials = await pushService.createPushCredentials();
      fs.writeFileSync(credentialsPath, JSON.stringify(credentials));
      this.log.info('wait a short time (5sec)...');
      await new Promise((r) => setTimeout(r, 5000));
    }

    // Start push client
    const pushClient = await PushClient.init({
      androidId: credentials.checkinResponse.androidId,
      securityToken: credentials.checkinResponse.securityToken,
    });

    const fcmToken = credentials.gcmResponse.token;
    await new Promise((resolve) => {
      const tHandle = setTimeout(() => {
        this.log.error('registering a push token timed out');
        resolve(true);
      }, 20000);

      this.httpService
        .registerPushToken(fcmToken)
        .catch((err) => {
          clearTimeout(tHandle);
          this.log.error('failed to register push token', err);
          resolve(true);
        })
        .then(() => {
          clearTimeout(tHandle);
          this.log.debug('registered at eufy with:', fcmToken);
          resolve(true);
        });
      
    });

    setInterval(async () => {
      try {
        await this.httpService.pushTokenCheck();
      } catch (err) {
        this.log.warn('failed to confirm push token');
      }
    }, 30 * 1000);

    pushClient.connect((msg) => {
      this.log.debug('push message:', msg);
      const matchingUuid = this.api.hap.uuid.generate(msg.payload?.device_sn);
      const knownAccessory = this.accessories.find(
        (accessory) => accessory.UUID === matchingUuid,
      );
      const event_type = msg.payload?.payload?.event_type;

      if (knownAccessory) {
        if (event_type === MessageTypes.MOTION_DETECTION || event_type === MessageTypes.FACE_DETECTION) {
          // TODO: Implement motion sensor
        } else if (event_type === MessageTypes.PRESS_DOORBELL) {
          knownAccessory
            .getService(this.api.hap.Service.Doorbell)!
            .updateCharacteristic(
              this.api.hap.Characteristic.ProgrammableSwitchEvent,
              this.api.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS,
            );
        }
      }
    });
  }

  /**
   * 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.info('Loading 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);
  }

  /**
   * 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() {
    const hubs = await this.httpService.listHubs();

    for (const hub of hubs) {
      this.log.debug(
        `found hub "${hub.station_name}" (${hub.station_sn}) `,
      );

      if (this.config.ignoreHubSns?.includes(hub.station_sn)) {
        this.log.debug('ignoring hub ' + hub.station_sn);
      }
    }

    const devices = await this.httpService.listDevices();

    for (const device of devices) {
      const ignoredHub = this.config.ignoreHubSns?.includes(device.station_sn);
      const ignoredDevice = this.config.ignoreDeviceSns?.includes(device.device_sn);

      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      const { params, member, station_conn, ...strippedDevice } = device;

      this.log.debug(`found device "${device.device_name}" (${device.device_sn})
ID: ${device.device_id}
Model: ${device.device_model}
Serial Number: ${device.device_sn}
Type: ${device.device_type}
Channel: ${device.device_channel}
Hub: ${device.station_conn.station_name} (${device.station_sn})
      `);
      this.log.debug(
        `device dump: ${{
          ...strippedDevice,
          params: params.map((param) => [
            param.param_id,
            param.param_type,
            param.param_value,
          ]),
        }}`,
      );

      if (ignoredHub) {
        this.log.debug(`device is part of ignored hub "${device.station_sn}"`);
      }

      if (ignoredDevice) {
        this.log.debug(`device is ignored "${device.device_sn}"`);
      }

      if (ignoredHub || ignoredDevice) {
        continue;
      }

      const uuid = this.api.hap.uuid.generate(device.device_sn);
      const existingAccessory = this.accessories.find(
        (accessory) => accessory.UUID === uuid,
      );

      // doorbell
      if (
        [
          DeviceType.BATTERY_DOORBELL,
          DeviceType.BATTERY_DOORBELL_2,
          DeviceType.DOORBELL,
        ].includes(device.device_type)
      ) {
        if (existingAccessory) {
          // the accessory already exists
          this.log.info(
            'Restoring existing accessory from cache:',
            existingAccessory.displayName,
          );
          new DoorbellPlatformAccessory(this, existingAccessory, device);
        } else {
          // the accessory does not yet exist, so we need to create it
          this.log.info('Adding new accessory:', device.device_name);

          // create a new accessory
          const accessory = new this.api.platformAccessory(
            device.device_name,
            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 for the newly create accessory
          // this is imported from `platformAccessory.ts`
          new DoorbellPlatformAccessory(this, accessory, device);

          // link the accessory to your platform
          this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
            accessory,
          ]);
        }
      }
    }

    // @todo
    // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
    // this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
  }
}
Example #17
Source File: index.ts    From homebridge-electrolux-wellbeing with Apache License 2.0 4 votes vote down vote up
class ElectroluxWellbeingPlatform implements DynamicPlatformPlugin {
  private client?: AxiosInstance;
  private readonly log: Logging;
  private readonly api: API;
  private readonly config: PlatformConfig;
  private readonly accessories: PlatformAccessory[] = [];

  constructor(log: Logging, config: PlatformConfig, api: API) {
    this.log = log;
    this.api = api;
    this.config = config;

    api.on(APIEvent.DID_FINISH_LAUNCHING, async () => {
      if (this.needsConfiguration()) {
        this.log('Please configure this plugin first.');
        return;
      }

      //this.removeAccessories();

      try {
        this.client = await createClient({
          username: this.config.username,
          password: this.config.password,
        });
      } catch (err) {
        this.log.debug('Error while creating client', err);
        return;
      }

      const appliances = await this.getAllAppliances();
      const applianceData = await Promise.all(
        appliances.map((appliance) => this.fetchApplianceData(appliance.pncId)),
      );

      this.log.debug('Fetched: ', applianceData);

      appliances.map(({ applianceName, modelName, pncId }, i) => {
        this.addAccessory({
          pncId,
          name: applianceName,
          modelName,
          firmwareVersion: applianceData[i]?.firmwareVersion,
        });
      });

      this.updateValues(applianceData);
      setInterval(
        () => this.checkAppliances(),
        this.getPollTime(this.config.pollTime),
      );
    });
  }

  needsConfiguration(): boolean {
    return !this.config.username || !this.config.password;
  }

  getPollTime(pollTime): number {
    if (!pollTime || pollTime < 5) {
      this.log.info('Set poll time is below 5s, forcing 5s');
      return 5 * 1000;
    }

    this.log.debug(`Refreshing every ${pollTime}s`);
    return pollTime * 1000;
  }

  async checkAppliances() {
    const data = await this.fetchAppliancesData();

    this.log.debug('Fetched: ', data);
    this.updateValues(data);
  }

  async fetchAppliancesData() {
    return await Promise.all(
      this.accessories.map((accessory) =>
        this.fetchApplianceData(accessory.context.pncId),
      ),
    );
  }

  async fetchApplianceData(pncId: string): Promise<Appliance | undefined> {
    try {
      const response: { data: WellbeingApi.ApplianceData } =
        await this.client!.get(`/Appliances/${pncId}`);
      const reported = response.data.twin.properties.reported;

      return {
        pncId,
        name: response.data.applianceData.applianceName,
        modelName: response.data.applianceData.modelName,
        firmwareVersion: reported.FrmVer_NIU,
        workMode: reported.Workmode,
        filterRFID: reported.FilterRFID,
        filterLife: reported.FilterLife,
        fanSpeed: reported.Fanspeed,
        UILight: reported.UILight,
        safetyLock: reported.SafetyLock,
        ionizer: reported.Ionizer,
        sleep: reported.Sleep,
        scheduler: reported.Scheduler,
        filterType: reported.FilterType,
        version: reported['$version'],
        pm1: reported.PM1,
        pm25: reported.PM2_5,
        pm10: reported.PM10,
        tvoc: reported.TVOC,
        co2: reported.CO2,
        temp: reported.Temp,
        humidity: reported.Humidity,
        envLightLevel: reported.EnvLightLvl,
        rssi: reported.RSSI,
      };
    } catch (err) {
      this.log.info('Could not fetch appliances data: ' + err);
    }
  }

  async getAllAppliances() {
    try {
      const response: { data: WellbeingApi.Appliance[] } =
        await this.client!.get('/Domains/Appliances');
      return response.data;
    } catch (err) {
      this.log.info('Could not fetch appliances: ' + err);
      return [];
    }
  }

  async sendCommand(
    pncId: string,
    command: string,
    value: CharacteristicValue,
  ) {
    this.log.debug('sending command', {
      [command]: value,
    });

    try {
      const response = await this.client!.put(`/Appliances/${pncId}/Commands`, {
        [command]: value,
      });
      this.log.debug('command responded', response.data);
    } catch (err) {
      this.log.info('Could run command', err);
    }
  }

  updateValues(data) {
    this.accessories.map((accessory) => {
      const { pncId } = accessory.context;
      const state = this.getApplianceState(pncId, data);

      // Guard against missing data due to API request failure.
      if (!state) {
        return;
      }

      // Keep firmware revision up-to-date in case the device is updated.
      accessory
        .getService(Service.AccessoryInformation)!
        .setCharacteristic(
          Characteristic.FirmwareRevision,
          state.firmwareVersion,
        );

      accessory
        .getService(Service.TemperatureSensor)!
        .updateCharacteristic(Characteristic.CurrentTemperature, state.temp);

      accessory
        .getService(Service.HumiditySensor)!
        .updateCharacteristic(
          Characteristic.CurrentRelativeHumidity,
          state.humidity,
        );

      accessory
        .getService(Service.CarbonDioxideSensor)!
        .updateCharacteristic(Characteristic.CarbonDioxideLevel, state.co2);

      if (state.envLightLevel) {
        // Env Light Level needs to be tested with lux meter
        accessory
          .getService(Service.LightSensor)!
          .updateCharacteristic(
            Characteristic.CurrentAmbientLightLevel,
            state.envLightLevel,
          );
      }

      accessory
        .getService(Service.AirQualitySensor)!
        .updateCharacteristic(
          Characteristic.AirQuality,
          this.getAirQualityLevel(state.pm25),
        )
        .updateCharacteristic(Characteristic.PM2_5Density, state.pm25)
        .updateCharacteristic(Characteristic.PM10Density, state.pm10)
        .updateCharacteristic(
          Characteristic.VOCDensity,
          this.convertTVOCToDensity(state.tvoc),
        );

      accessory
        .getService(Service.AirPurifier)!
        .updateCharacteristic(Characteristic.FilterLifeLevel, state.filterLife)
        .updateCharacteristic(
          Characteristic.FilterChangeIndication,
          state.filterLife < 10
            ? Characteristic.FilterChangeIndication.CHANGE_FILTER
            : Characteristic.FilterChangeIndication.FILTER_OK,
        )
        .updateCharacteristic(
          Characteristic.Active,
          state.workMode !== WorkModes.Off,
        )
        .updateCharacteristic(
          Characteristic.CurrentAirPurifierState,
          this.getAirPurifierState(state.workMode),
        )
        .updateCharacteristic(
          Characteristic.TargetAirPurifierState,
          this.getAirPurifierStateTarget(state.workMode),
        )
        .updateCharacteristic(
          Characteristic.RotationSpeed,
          state.fanSpeed * FAN_SPEED_MULTIPLIER,
        )
        .updateCharacteristic(
          Characteristic.LockPhysicalControls,
          state.safetyLock,
        )
        .updateCharacteristic(Characteristic.SwingMode, state.ionizer);
    });
  }

  getApplianceState(pncId: string, data): Appliance {
    return _.find(data, { pncId });
  }

  configureAccessory(accessory: PlatformAccessory): void {
    this.log('Configuring accessory %s', accessory.displayName);

    const { pncId } = accessory.context;

    accessory.on(PlatformAccessoryEvent.IDENTIFY, () => {
      this.log('%s identified!', accessory.displayName);
    });

    accessory
      .getService(Service.AirPurifier)!
      .getCharacteristic(Characteristic.Active)
      .on(
        CharacteristicEventTypes.SET,
        (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
          const workMode = value === 1 ? WorkModes.Auto : WorkModes.Off;

          if (
            accessory
              .getService(Service.AirPurifier)!
              .getCharacteristic(Characteristic.Active).value !== value
          ) {
            this.sendCommand(pncId, 'WorkMode', workMode);
            this.log.info(
              '%s AirPurifier Active was set to: ' + workMode,
              accessory.displayName,
            );
          }

          callback();
        },
      );

    accessory
      .getService(Service.AirPurifier)!
      .getCharacteristic(Characteristic.TargetAirPurifierState)
      .on(
        CharacteristicEventTypes.SET,
        (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
          const workMode =
            value === Characteristic.TargetAirPurifierState.MANUAL
              ? WorkModes.Manual
              : WorkModes.Auto;
          this.sendCommand(pncId, 'WorkMode', workMode);
          this.log.info(
            '%s AirPurifier Work Mode was set to: ' + workMode,
            accessory.displayName,
          );
          callback();
        },
      );

    accessory
      .getService(Service.AirPurifier)!
      .getCharacteristic(Characteristic.RotationSpeed)
      .on(
        CharacteristicEventTypes.SET,
        (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
          const fanSpeed = Math.floor(
            parseInt(value.toString()) / FAN_SPEED_MULTIPLIER,
          );
          this.sendCommand(pncId, 'FanSpeed', fanSpeed);

          this.log.info(
            '%s AirPurifier Fan Speed set to: ' + fanSpeed,
            accessory.displayName,
          );
          callback();
        },
      );

    accessory
      .getService(Service.AirPurifier)!
      .getCharacteristic(Characteristic.LockPhysicalControls)
      .on(
        CharacteristicEventTypes.SET,
        (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
          if (
            accessory
              .getService(Service.AirPurifier)!
              .getCharacteristic(Characteristic.LockPhysicalControls).value !==
            value
          ) {
            this.sendCommand(pncId, 'SafetyLock', value);

            this.log.info(
              '%s AirPurifier Saftey Lock set to: ' + value,
              accessory.displayName,
            );
          }
          callback();
        },
      );

    accessory
      .getService(Service.AirPurifier)!
      .getCharacteristic(Characteristic.SwingMode)
      .on(
        CharacteristicEventTypes.SET,
        (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
          if (
            accessory
              .getService(Service.AirPurifier)!
              .getCharacteristic(Characteristic.SwingMode).value !== value
          ) {
            this.sendCommand(pncId, 'Ionizer', value);

            this.log.info(
              '%s AirPurifier Ionizer set to: ' + value,
              accessory.displayName,
            );
          }
          callback();
        },
      );

    this.accessories.push(accessory);
  }

  addAccessory({ name, modelName, pncId, firmwareVersion }) {
    const uuid = hap.uuid.generate(pncId);

    if (!this.isAccessoryRegistered(name, uuid)) {
      this.log.info('Adding new accessory with name %s', name);
      const accessory = new Accessory(name, uuid);

      accessory.context.pncId = pncId;

      accessory.addService(Service.AirPurifier);
      accessory.addService(Service.AirQualitySensor);
      accessory.addService(Service.TemperatureSensor);
      accessory.addService(Service.CarbonDioxideSensor);
      accessory.addService(Service.HumiditySensor);
      accessory.addService(Service.LightSensor);

      accessory
        .getService(Service.AccessoryInformation)!
        .setCharacteristic(Characteristic.Manufacturer, 'Electrolux')
        .setCharacteristic(Characteristic.Model, modelName)
        .setCharacteristic(Characteristic.SerialNumber, pncId)
        .setCharacteristic(Characteristic.FirmwareRevision, firmwareVersion);

      this.configureAccessory(accessory);

      this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
        accessory,
      ]);
    } else {
      this.log.info(
        'Accessory name %s already added, loading from cache ',
        name,
      );
    }
  }

  removeAccessories() {
    this.log.info('Removing all accessories');

    this.api.unregisterPlatformAccessories(
      PLUGIN_NAME,
      PLATFORM_NAME,
      this.accessories,
    );
    this.accessories.splice(0, this.accessories.length);
  }

  isAccessoryRegistered(name: string, uuid: string) {
    return !!_.find(this.accessories, { UUID: uuid });
  }

  getAirQualityLevel(pm25: number): number {
    switch (true) {
      case pm25 < 6:
        return Characteristic.AirQuality.EXCELLENT;
      case pm25 < 12:
        return Characteristic.AirQuality.GOOD;
      case pm25 < 36:
        return Characteristic.AirQuality.FAIR;
      case pm25 < 50:
        return Characteristic.AirQuality.INFERIOR;
      case pm25 >= 50:
        return Characteristic.AirQuality.POOR;
    }

    return Characteristic.AirQuality.UNKNOWN;
  }

  getAirPurifierState(workMode: WorkModes): number {
    if (workMode !== WorkModes.Off) {
      return Characteristic.CurrentAirPurifierState.PURIFYING_AIR;
    }

    return Characteristic.CurrentAirPurifierState.INACTIVE;
  }

  getAirPurifierStateTarget(workMode: WorkModes): number {
    if (workMode === WorkModes.Auto) {
      return Characteristic.TargetAirPurifierState.AUTO;
    }

    return Characteristic.TargetAirPurifierState.MANUAL;
  }

  // Best effort attempt to convert Wellbeing TVOC ppb reading to μg/m3, but we lack insight into their algorithms
  // or TVOC densities. We assume 1 ppb = 3.243 μg/m3 (see benzene @ 20C [1]) as this produces results (μg/m3) that fit
  // quite well within the defined ranges in [2].
  //
  // Wellbeing defines 1500 ppb as possibly having an effect on health when exposed to these levels for a month, [2]
  // lists 400-500 μg/m3 as _marginal_ which sounds like a close approximation. Here's an example where 1500 ppb falls
  // within the _marginal_ range.
  //
  //   1500 * 3.243 / 10 = 486.45
  //
  // Note: It's uncertain why we have to divide the result by 10 for the values to make sense, perhaps this is a
  // Wellbeing quirk, but at least the values look good.
  //
  // The maximum value shown by Wellbeing is 4000 ppb and the maximum value accepted by HomeKit is 1000 μg/m3, our
  // assumed molecular density may put the value outside of the HomeKit range, but not by much, which seems acceptable:
  //
  //  4000 * 3.243 / 10 = 1297.2
  //
  // [1] https://uk-air.defra.gov.uk/assets/documents/reports/cat06/0502160851_Conversion_Factors_Between_ppb_and.pdf
  // [2] https://myhealthyhome.info/assets/pdfs/TB531rev2TVOCInterpretation.pdf
  convertTVOCToDensity(tvocppb: number): number {
    const ugm3 = (tvocppb * 3.243) / 10;
    return Math.min(ugm3, 1000);
  }
}
Example #18
Source File: platform.ts    From homebridge-plugin-govee with Apache License 2.0 4 votes vote down vote up
/**
 * 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 GoveeHomebridgePlatform 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[] = [];

  private platformStatus?: APIEvent;
  private readonly discoveryCache = new Map();

  constructor(
    public readonly log: Logger,
    public readonly config: PlatformConfig,
    public readonly api: API
  ) {
    this.log.info("Finished initializing platform:", this.config.name);

    // 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("didFinishLaunching", () => {
      log.debug("Executed didFinishLaunching callback");
      // run the method to discover / register your devices as accessories

      this.platformStatus = APIEvent.DID_FINISH_LAUNCHING;

      this.discoverDevices();
    });

    this.api.on("shutdown", () => {
      this.platformStatus = APIEvent.SHUTDOWN;
    });
  }

  /**
   * 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.info("Loading 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);
  }

  /**
   * 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.
   */
  discoverDevices() {
    this.log.debug("Start discovery");

    if (this.config.debug) {
      GoveeDebug(true);
    }

    GoveeStartDiscovery(this.goveeDiscoveredReading.bind(this));
    GoveeRegisterScanStart(this.goveeScanStarted.bind(this));
    GoveeRegisterScanStop(this.goveeScanStopped.bind(this));

    // it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
    // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
  }

  private goveeDiscoveredReading(reading: GoveeReading) {
    this.log.debug("Govee reading", reading);

    let deviceUniqueId = reading.uuid;
    if (reading.model) {
      deviceUniqueId = reading.model;
    }

    if (!deviceUniqueId) {
      this.log.error(
        "device missing unique identifier. Govee reading: ",
        reading
      );
      return;
    }
    
    //Now check if the device is in the ignore list, if it is, skip working on it, if ignore list is empty do nothing!
    if (this.config.IgnoreDeviceNames) {
      this.log.debug("Ignore List filled, will check list!");
      const displayName = `${this.sanitize(reading.model)}`;
      const IgnoredDeviceNames = this.config.IgnoreDeviceNames;
      if (IgnoredDeviceNames.includes(displayName)) {
        //Device is in the Ignore-List so skip working on it
        this.log.debug("Device in Ignore List ", displayName, " skipping the device!");
        return;
      }
    }
    else {
      this.log.debug("Ignore List is empty, nothing to check!");
    }
 
    // discovered devices and register each one if it has not already been registered

    // 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(deviceUniqueId);

    // see if an accessory with the same uuid has already been registered and restored from
    // the cached devices we stored in the `configureAccessory` method above
    const existingAccessory = this.accessories.find(
      (accessory) => accessory.UUID === uuid
    );

    if (this.discoveryCache.has(uuid)) {
      const cachedInstance = this.discoveryCache.get(
        uuid
      ) as GoveePlatformAccessory;
      cachedInstance.updateReading(reading);
      return;
    }

    if (existingAccessory) {
      // the accessory already exists
      this.log.info(
        "Restoring existing accessory from cache:",
        existingAccessory.displayName
      );

      // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.:
      existingAccessory.context.batteryThreshold = this.config.batteryThreshold;
      existingAccessory.context.humidityOffset = this.config.humidityOffset;
      this.api.updatePlatformAccessories([existingAccessory]);

      // create the accessory handler for the restored accessory
      // this is imported from `platformAccessory.ts`
      const existingInstance = new GoveePlatformAccessory(
        this,
        existingAccessory,
        reading
      );

      this.discoveryCache.set(uuid, existingInstance);
    } else {
      const displayName = `${this.sanitize(reading.model)}`;

      // the accessory does not yet exist, so we need to create it
      this.log.info("Adding new accessory:", displayName);

      // create a new accessory
      const accessory = new this.api.platformAccessory(displayName, 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
      const contextDevice: DeviceContext = {
        address: this.sanitize(reading.address),
        model: this.sanitize(reading.model),
        uuid: reading.uuid,
      };
      accessory.context.device = contextDevice;
      accessory.context.batteryThreshold = this.config.batteryThreshold;
      accessory.context.humidityOffset = this.config.humidityOffset;

      // create the accessory handler for the newly create accessory
      // this is imported from `platformAccessory.ts`
      const newInstance = new GoveePlatformAccessory(this, accessory, reading);

      // link the accessory to your platform
      this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
        accessory,
      ]);

      this.discoveryCache.set(uuid, newInstance);
    }
  }

  private goveeScanStarted() {
    this.log.info("Govee Scan Started");
  }

  private goveeScanStopped() {
    this.log.info("Govee Scan Stopped");

    if (!this.platformStatus || this.platformStatus === APIEvent.SHUTDOWN) {
      return;
    }

    const WAIT_INTERVAL = 5000;
    // wait, and restart discovery if platform status doesn't change

    setTimeout(() => {
      if (!this.platformStatus || this.platformStatus === APIEvent.SHUTDOWN) {
        return;
      }

      this.log.warn("Govee discovery stopped while Homebridge is running.");

      this.log.info("Restart Discovery");
      GoveeStartDiscovery(this.goveeDiscoveredReading.bind(this));
    }, WAIT_INTERVAL);
  }

  private sanitize(s: string): string {
    return s.trim().replace("_", "");
  }
}
Example #19
Source File: platform.ts    From homebridge-tapo-p100 with Apache License 2.0 4 votes vote down vote up
/**
 * TapoPlatform
 * 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 default class TapoPlatform implements DynamicPlatformPlugin {
  public readonly Service: typeof Service = this.api.hap.Service;
  public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;
  public readonly FakeGatoHistoryService;

  // this is used to track restored cached accessories
  public readonly accessories: PlatformAccessory[] = [];

  public readonly config: TapoConfig;

  public customCharacteristics: {
    [key: string]: WithUUID<new () => Characteristic>;
  };

  constructor(
    public readonly log: Logger,
    config: PlatformConfig,
    public readonly api: API,
  ) {
    this.log.debug('config.json: %j', config);
    this.config = parseConfig(config);
    this.log.debug('config: %j', this.config);
    this.log.debug('Finished initializing platform:', this.config.name);
    this.customCharacteristics = Characteristics(api.hap.Characteristic);
    this.FakeGatoHistoryService = fakegato(this.api);
    // 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('didFinishLaunching', () => {
      log.debug('Executed didFinishLaunching callback');
      // run the method to discover / register your devices as accessories
      this.discoverDevices();
    });
  }

  /**
   * 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.info('Loading 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);
  }

  /**
   * 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.
   */
  discoverDevices() {

    // EXAMPLE ONLY
    // A real plugin you would discover accessories from the local network, cloud services
    // or a user-defined array in the platform config.
    const devices = this.config.discoveryOptions.devices;

    if(devices){
      // 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.host + (device.name ? device.name : ''));

        // see if an accessory with the same uuid has already been registered and restored from
        // the cached devices we stored in the `configureAccessory` method above
        const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);

        if (existingAccessory) {
          // the accessory already exists
          if (device) {
            this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);

            // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.:
            // existingAccessory.context.device = device;
            // this.api.updatePlatformAccessories([existingAccessory]);

            // create the accessory handler for the restored accessory
            // this is imported from `platformAccessory.ts`
            if(device.type && device.type.toLowerCase() === 'colorlight'){
              new L530Accessory(this.log, this, existingAccessory, device.timeout ? device.timeout : 2, device.updateInterval);
            } else if(device.type && device.type.toLowerCase() === 'light'){
              new L510EAccessory(this.log, this, existingAccessory, device.timeout ? device.timeout : 2, device.updateInterval);
            } else if(device.type && device.type.toLowerCase() === 'powerplug'){
              new P110Accessory(this.log, this, existingAccessory, device.timeout ? device.timeout : 2, device.updateInterval);
            } else{
              new P100Accessory(this.log, this, existingAccessory, device.timeout ? device.timeout : 2, device.updateInterval);
            }
      
            // update accessory cache with any changes to the accessory details and information
            this.api.updatePlatformAccessories([existingAccessory]);
          } else if (!device) {
            // it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
            // remove platform accessories when no longer present
            this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
            this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
          }
        } else {
          // the accessory does not yet exist, so we need to create it
          this.log.info('Adding new accessory:', device.host);

          let accessory:PlatformAccessory;

          // create the accessory handler for the newly create accessory
          // this is imported from `platformAccessory.ts`
          if(device.type && device.type.toLowerCase() === 'colorlight'){
            // create a new accessory
            accessory = new this.api.platformAccessory(device.name ? device.name : device.host, uuid, Categories.LIGHTBULB);
            // 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;
            new L530Accessory(this.log, this, accessory, device.timeout ? device.timeout : 2, device.updateInterval);
          } else if(device.type && device.type.toLowerCase() === 'light'){
            // create a new accessory
            accessory = new this.api.platformAccessory(device.name ? device.name : device.host, uuid, Categories.LIGHTBULB);
            // 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;
            new L510EAccessory(this.log, this, accessory, device.timeout ? device.timeout : 2, device.updateInterval);
          } else if(device.type && device.type.toLowerCase() === 'powerplug'){
            // create a new accessory
            accessory = new this.api.platformAccessory(device.name ? device.name : device.host, uuid, Categories.OUTLET);
            // 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;
            new P110Accessory(this.log, this, accessory, device.timeout ? device.timeout : 2, device.updateInterval);
          } else{
            // create a new accessory
            accessory = new this.api.platformAccessory(device.name ? device.name : device.host, uuid, Categories.OUTLET);
            // 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;
            new P100Accessory(this.log, this, accessory, device.timeout ? device.timeout : 2, device.updateInterval);
          }

          // link the accessory to your platform
          this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
        }
      }
    }
  }
}
Example #20
Source File: platform.ts    From HomebridgeMagicHome-DynamicPlatform with Apache License 2.0 4 votes vote down vote up
/**
 * 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 HomebridgeMagichomeDynamicPlatform implements DynamicPlatformPlugin {
  public readonly Service = this.api.hap.Service;
  public readonly Characteristic = this.api.hap.Characteristic;
  // this is used to track restored cached accessories
  public readonly accessories: MagicHomeAccessory[] = [];
  public count = 1;
  private periodicDiscovery: NodeJS.Timeout | null = null;

  private logs: Logs;


  constructor(
    public readonly hbLogger: Logger,
    public readonly config: PlatformConfig,
    public readonly api: API,
  ) {
    if (this.config.advancedOptions.logLevel) {
      this.logs = new Logs(hbLogger, this.config.advancedOptions.logLevel);
    } else {
      this.logs = new Logs(hbLogger);
    }

    //this.logs = getLogger();
    this.logs.warn('Finished initializing homebridge-magichome-dynamic-platform %o', loadJson<any>(join(__dirname, '../package.json'), {}).version);
    this.logs.info('If this plugin brings you joy, consider visiting GitHub and giving it a ⭐.');

    // 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.logs.debug('Executed didFinishLaunching callback');
      this.count = 1;
      // run the method to discover / register your devices as accessories
      this.discoverDevices(true);
      // Periodic scan for devices
      const shouldRediscover = this.config.advancedOptions?.periodicDiscovery ?? false;

      if (shouldRediscover) {
        this.periodicDiscovery = setInterval(() => this.discoverDevices(false), 30000);
      }
    });
  }

  /**
   * 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: MagicHomeAccessory) {

    this.logs.debug('%o - Loading accessory from cache...', this.count++, accessory.context.device.displayName);
    // set cached accessory as not recently seen 
    // if found later to be a match with a discovered device, will change to true
    accessory.context.device.restartsSinceSeen++;
    accessory.context.pendingRegistration = true;
    // add the restored accessory to the accessories cache so we can track if it has already been registered
    this.accessories.push(accessory);
  }

  /**
   * Accessories are added by one of three Methods:
   * Method One: New devices that were seen after scanning the network and are registered for the first time
   * Method Two: Cached devices that were seen after scanning the network and are added while checking for ip discrepancies 
   * Method Three: Cached devices that were not seen after scanning the network but are still added with a warning to the user
   */
  async discoverDevices(dgb: boolean | null) {
    const { isValidDeviceModel } = HomebridgeMagichomeDynamicPlatform;
    const pendingUpdate = new Set();
    const recentlyRegisteredDevices = new Set();

    let registeredDevices = 0, newDevices = 0, unseenDevices = 0, scans = 0;
    let discover = new Discover(this.logs, this.config);



    let devicesDiscovered: IDeviceDiscoveredProps[] = await discover.scan(2000);

    while (devicesDiscovered.length === 0 && scans < 5) {
      this.logs.debug('( Scan: %o ) Discovered zero devices... rescanning...', scans + 1);
      devicesDiscovered = await discover.scan(2000);
      scans++;
    }

    discover = null;
    if (devicesDiscovered.length === 0) {
      this.logs.debug('\nDiscovered zero devices!\n');
    } else {
      this.logs.debug('\nDiscovered %o devices.\n', devicesDiscovered.length);
    }

    // loop over the discovered devices and register each one if it has not already been registered
    for (const deviceDiscovered of devicesDiscovered) {
      let existingAccessory: MagicHomeAccessory = null;
      try {
        // generate a unique id for the accessory this should be generated from
        const generatedUUID = this.api.hap.uuid.generate(deviceDiscovered.uniqueId);
        // check that the device has not already been registered by checking the
        // cached devices we stored in the `configureAccessory` method above
        existingAccessory = this.accessories.find(accessory => accessory.UUID === generatedUUID);

        if (!existingAccessory) {
          if (!this.createNewAccessory(deviceDiscovered, generatedUUID)) {
            continue;
          }
          recentlyRegisteredDevices.add(deviceDiscovered.uniqueId);
          registeredDevices++;
          newDevices++;

        } else {
          // This deviceDiscovered already exist in cache!

          // Check if cached device complies to the device model,
          if (!isValidDeviceModel(existingAccessory.context.device, null)) {
            //`Device "${uniqueId}" is online, but has outdated data model. Attempting to update it. 
            this.logs.debug(`The known device "${deviceDiscovered.uniqueId}" seen during discovery has outdated data model (pre v1.8.6). Rebuilding device. `, deviceDiscovered);


            // ****** RECONSTRUCT DEVICE - patch existingAccessory with updated data *****
            const initialState = await this.getInitialState(deviceDiscovered.ipAddress, 10000);
            const deviceQueryData: IDeviceQueriedProps = await this.determineController(deviceDiscovered);

            if (!initialState || !deviceQueryData) {
              this.logs.error('Warning! Device type could not be determined for device: %o, this is usually due to an unresponsive device.\n Please restart homebridge. If the problem persists, ensure the device works in the "Magichome Pro" app.\n file an issue on github with an uploaded log\n',
                deviceDiscovered.uniqueId);
              continue;
            }

            const oldName = existingAccessory.context.displayName ||
              existingAccessory.context.device?.lightParameters?.convenientName ||
              deviceQueryData.lightParameters.convenientName;

            const rootProps = { UUID: generatedUUID, cachedIPAddress: deviceDiscovered.ipAddress, restartsSinceSeen: 0, displayName: oldName, lastKnownState: initialState };
            const deviceData: IDeviceProps = Object.assign(rootProps, deviceDiscovered, deviceQueryData);
            existingAccessory.context.device = deviceData;
            // ****** RECONSTRUCT DEVICE *****

            if (isValidDeviceModel(existingAccessory.context.device, this.logs)) {
              this.logs.debug(`[discovered+cached] Device "${deviceDiscovered.uniqueId}" successfully repaired!`);
            } else {
              this.logs.error(`[discovered+cached] Device "${deviceDiscovered.uniqueId}" was not repaired successfully. Ensure it can be controlled in the MagicHome app then restart homebridge to try again while it is online.`);
              continue;
            }

          }
          if (!this.registerExistingAccessory(deviceDiscovered, existingAccessory)) {
            continue;
          }

          // add to list of registered devices, so we can show a summary in the end
          recentlyRegisteredDevices.add(deviceDiscovered.uniqueId);
          registeredDevices++;
        }

      } catch (error) {
        this.logs.debug('[discovered+cached] platform.ts discoverDevices() accessory creation has thrown the following error: %o', error);
        this.logs.debug('[discovered+cached] The existingAccessory object is: ', existingAccessory);
        this.logs.debug('[discovered+cached] The deviceDiscovered object is: ', deviceDiscovered);
      }
    }
    //=================================================
    // End Cached Devices //


    //***************** Device Pruning Start *****************//

    //if config settings are enabled, devices that are no longer seen
    //will be pruned, removing them from the cache. Usefull for removing
    //unplugged or unresponsive accessories

    for (const accessory of this.accessories) {
      try {

        if (!isValidDeviceModel(accessory.context.device, null)) {
          // only offline, cached devices, old data model, should trigger here.
          const { uniqueId } = accessory.context.device;
          this.logs.debug(`Device "${uniqueId}" was not seen during discovery. Ensure it can be controlled in the MagicHome app. Rescan in 30 seconds...`);
          pendingUpdate.add(uniqueId);
          continue;
        }

        if (accessory.context.device?.displayName && accessory.context.device.displayName.toString().toLowerCase().includes('delete')) {
          this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
          this.logs.warn('Successfully pruned accessory: ', accessory.context.device.displayName,
            'due to being marked for deletion\n');
          continue;

          //if the config parameters for pruning are set to true, prune any devices that haven't been seen
          //for more restarts than the accepted ammount
        } else if (this.config.pruning.pruneMissingCachedAccessories || this.config.pruning.pruneAllAccessoriesNextRestart) {
          if (accessory.context.device.restartsSinceSeen >= this.config.pruning.restartsBeforeMissingAccessoriesPruned || this.config.pruning.pruneAllAccessoriesNextRestart) {
            this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
            this.logs.warn('Successfully pruned accessory:', accessory.context.device.displayName,
              'which had not being seen for (', accessory.context.device.restartsSinceSeen, ') restart(s).\n');
            continue;
          }
        }
        //simple warning to notify user that their accessory hasn't been seen in n restarts
        if (accessory.context.device.restartsSinceSeen > 0) {
          //logic for removing blacklisted devices

          if (!this.isAllowed(accessory.context.device.uniqueId)) {
            this.logs.warn('Warning! Accessory: %o will be pruned as its Unique ID: %o is blacklisted or is not whitelisted.\n',
              accessory.context.device.displayName, accessory.context.device.uniqueId);
            try {
              this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
            } catch (err) {
              this.logs.debug('Accessory: %o count not be pruned. Likely it had never been registered.\n',
                accessory.context.device.displayName, accessory.context.device.uniqueId);
            }
            continue;
          }

          this.logs.debug(`Warning! Continuing to register cached accessory "${accessory.context.device.uniqueId}" despite not being seen for ${accessory.context.device.restartsSinceSeen} restarts.`);

          // create the accessory handler
          let lightAccessory: HomebridgeMagichomeDynamicPlatformAccessory = null;
          try {
            lightAccessory = new accessoryType[accessory.context.device.lightParameters.controllerLogicType](this, accessory, this.config);
          } catch (error) {
            this.logs.error('[1] The controllerLogicType does not exist in accessoryType list. Did you migrate this? controllerLogicType=', accessory.context.device?.lightParameters?.controllerLogicType);
            this.logs.error('device object: ', accessory.context.device);
            continue;
          }

          // udpate the accessory to platform
          this.api.updatePlatformAccessories([accessory]);
          registeredDevices++;
          unseenDevices++;

        }

      } catch (error) {
        this.logs.error('platform.ts discoverDevices() accessory pruning has thrown the following error: %o', error);
        this.logs.error('The context object is: ', accessory.context);
      }
    }

    this.logs.debug('\nRegistered %o Magichome device(s). \nNew devices: %o \nCached devices that were seen this restart: %o'
      + '\nCached devices that were not seen this restart: %o\n',
    registeredDevices,
    newDevices,
    registeredDevices - newDevices - unseenDevices,
    unseenDevices);



    // Discovery summary:
    if (recentlyRegisteredDevices.size > 0) {
      const found = recentlyRegisteredDevices.size;
      const pending = Array.from(pendingUpdate).length;
      const pendingStr = pending > 0 ? ` Pending update: ${pending} devices` : '';
      this.logs.debug(`Discovery summary:  Found ${found} devices.${pendingStr}`);
    }

    this.count = 1; // reset the device logging counter
  }//discoveredDevices


  isAllowed(uniqueId): boolean {

    const blacklistedUniqueIDs = this.config.deviceManagement.blacklistedUniqueIDs;
    let isAllowed = true;
    try {

      if (blacklistedUniqueIDs !== undefined
        && this.config.deviceManagement.blacklistOrWhitelist !== undefined) {
        if (((blacklistedUniqueIDs).includes(uniqueId)
          && (this.config.deviceManagement.blacklistOrWhitelist).includes('blacklist'))
          || (!(blacklistedUniqueIDs).includes(uniqueId))
          && (this.config.deviceManagement.blacklistOrWhitelist).includes('whitelist')) {
          isAllowed = false;
        }
      }
    } catch (error) {
      this.logs.debug(error);
    }

    return isAllowed;
  }

  async getInitialState(ipAddress, _timeout = 500) {

    const transport = new Transport(ipAddress, this.config);
    try {
      let scans = 0, data;

      while (data == null && scans < 5) {

        data = await transport.getState(_timeout);
        scans++;
      }

      return data;

    } catch (error) {
      this.logs.debug(error);
    }
  }

  async determineController(discoveredDevice): Promise<IDeviceQueriedProps | null> {
    const { ipAddress } = discoveredDevice || {};
    if (typeof ipAddress !== 'string') {
      this.logs.error('Cannot determine controller because invalid IP address. Device:', discoveredDevice);
      return null;
    }
    const initialState = await this.getInitialState(ipAddress, 10000);
    if (initialState == undefined) {
      this.logs.debug('Cannot determine controller. Device unreacheable.', discoveredDevice);
      return null;
    }

    let lightParameters: ILightParameters;
    const controllerHardwareVersion = initialState.controllerHardwareVersion;
    const controllerFirmwareVersion = initialState.controllerFirmwareVersion;

    this.logs.debug('Attempting to assign controller to new device: UniqueId: %o \nIpAddress %o \nModel: %o\nHardware Version: %o \nDevice Type: %o\n',
      discoveredDevice.uniqueId, discoveredDevice.ipAddress, discoveredDevice.modelNumber, initialState.controllerHardwareVersion.toString(16), initialState.controllerFirmwareVersion.toString(16));

    //set the lightVersion so that we can give the device a useful name and later know how which protocol to use

    if (lightTypesMap.has(controllerHardwareVersion)) {
      this.logs.debug('Device %o: Hardware Version: %o with Firmware Version: %o matches known device type records',
        discoveredDevice.uniqueId,
        controllerHardwareVersion.toString(16),
        controllerFirmwareVersion.toString(16));
      lightParameters = lightTypesMap.get(controllerHardwareVersion);
    } else {
      this.logs.warn('Unknown device version number: %o... unable to create accessory.', controllerHardwareVersion.toString(16));
      this.logs.warn('Please create an issue at https://github.com/Zacknetic/HomebridgeMagicHome-DynamicPlatform/issues and upload your homebridge.log');
      return null;
    }

    this.logs.debug('Controller Logic Type assigned to %o', lightParameters.controllerLogicType);

    return {
      lightParameters,
      controllerHardwareVersion: controllerHardwareVersion,
      controllerFirmwareVersion: controllerFirmwareVersion,
    };
  }

  /**
 * Accessory Generation Method One: UUID has not been seen before. Register new accessory.
 * Accessories must only be registered once, previously created accessories
 * must not be registered again to prevent "duplicate UUID" errors.
 * @param deviceDiscovered 
 * @param generatedUUID 
 */
  async createNewAccessory(deviceDiscovered: IDeviceDiscoveredProps, generatedUUID): Promise<boolean> {
    const unsupportedModels: string[] = ['000-0000']; //AK001-ZJ210 is suported... 

    const deviceQueryData: IDeviceQueriedProps = await this.determineController(deviceDiscovered);

    if (deviceQueryData == null) {
      if (unsupportedModels.includes(deviceDiscovered.modelNumber)) {
        this.logs.warn('Warning! Discovered device did not respond to query. Device is in the unsupported device list.\nFile an issue on github requesting support. Details:', deviceDiscovered);
      } else {
        this.logs.warn('Warning! Discovered device did not respond to query. This is usually due to an unresponsive device.\nPlease restart homebridge. If the problem persists, ensure the device works in the "Magichome Pro" app.\nFile an issue on github with an uploaded log.', deviceDiscovered);
      }
      return false;
    }
    //check if device is on blacklist or is not on whitelist
    if (!this.isAllowed(deviceDiscovered.uniqueId)) {
      this.logs.warn('Warning! New device with Unique ID: %o is blacklisted or is not whitelisted.\n',
        deviceDiscovered.uniqueId);

      return false;
    }
    // if user has oped, use unique name such as "Bulb AABBCCDD"
    if (this.config.advancedOptions && this.config.advancedOptions.namesWithMacAddress) {
      const uniqueIdName = getUniqueIdName(deviceDiscovered.uniqueId, deviceQueryData.lightParameters.controllerLogicType);
      deviceQueryData.lightParameters.convenientName = uniqueIdName;
    }

    const accessory = new this.api.platformAccessory(deviceQueryData.lightParameters.convenientName, generatedUUID) as MagicHomeAccessory;

    // set its restart prune counter to 0 as it has been seen this session
    const deviceData: IDeviceProps = Object.assign({ UUID: generatedUUID, cachedIPAddress: deviceDiscovered.ipAddress, restartsSinceSeen: 0, displayName: deviceQueryData.lightParameters.convenientName }, deviceDiscovered, deviceQueryData);
    accessory.context.device = deviceData;


    this.printDeviceInfo('Registering new accessory...!', accessory);
    // link the accessory to your platform
    this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);

    // create the accessory handler
    let lightAccessory: HomebridgeMagichomeDynamicPlatformAccessory = null;
    try {
      lightAccessory = new accessoryType[accessory.context.device.lightParameters.controllerLogicType](this, accessory, this.config);
    } catch (error) {
      this.logs.error('[2] The controllerLogicType does not exist in accessoryType list. controllerLogicType=', accessory.context.device?.lightParameters?.controllerLogicType);
      this.logs.error('device object: ', accessory.context.device);
      return false;
    }
    this.accessories.push(accessory);

    return true;
  }

  /**
 * Accessory Generation Method Two: UUID has been seen before. Load from cache.
 * Test if seen accessory "is allowed" and that the IP address is identical
 * @param deviceDiscovered 
 * @param existingAccessory 
 */
  registerExistingAccessory(deviceDiscovered, existingAccessory: MagicHomeAccessory): boolean {

    if (!this.isAllowed(existingAccessory.context.device.uniqueId)) {
      this.logs.warn('Warning! Accessory: %o will be pruned as its Unique ID: %o is blacklisted or is not whitelisted.\n',
        existingAccessory.context.device.displayName, existingAccessory.context.device.uniqueId);
      this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
      return false;
    }

    // set its restart prune counter to 0 as it has been seen this session
    existingAccessory.context.device.restartsSinceSeen = 0;

    // test if the existing cached accessory ip address matches the discovered
    // accessory ip address if not, replace it
    const ipHasNotChanged = existingAccessory.context.device.cachedIPAddress === deviceDiscovered.ipAddress;
    const { pendingRegistration } = existingAccessory.context;
    const registrationComplete = !pendingRegistration;
    if (registrationComplete && ipHasNotChanged) {
      this.logs.debug(`Device ${existingAccessory.context.device.uniqueId} already registered. Registration update not required`);
      return false;
    }

    if (!ipHasNotChanged) {
      this.logs.warn('Ip address discrepancy found for accessory: %o\n Expected ip address: %o\n Discovered ip address: %o',
        existingAccessory.context.device.displayName, existingAccessory.context.device.cachedIPAddress, deviceDiscovered.ipAddress);

      // overwrite the ip address of the existing accessory to the newly disovered ip address
      existingAccessory.context.device.cachedIPAddress = deviceDiscovered.ipAddress;

      this.logs.warn('Ip address successfully reassigned to: %o\n ', existingAccessory.context.device.cachedIPAddress);
    }

    this.printDeviceInfo('Registering existing accessory: ', existingAccessory);

    // create the accessory handler
    let lightAccessory: HomebridgeMagichomeDynamicPlatformAccessory = null;
    try {
      if (!existingAccessory.context?.device?.lightParameters?.controllerLogicType || accessoryType[existingAccessory.context?.device?.lightParameters?.controllerLogicType] === undefined) {
        this.logs.error('[registerExistingAccessory] The controllerLogicType does not exist in accessoryType list. controllerLogicType=', existingAccessory.context.device?.lightParameters?.controllerLogicType);
        this.logs.error('[registerExistingAccessory] device object: ', existingAccessory.context.device);
        return false;
      }
      lightAccessory = new accessoryType[existingAccessory.context.device.lightParameters.controllerLogicType](this, existingAccessory, this.config);
    } catch (error) {
      this.logs.error('[registerExistingAccessory] The controllerLogicType does not exist in accessoryType list. controllerLogicType=', existingAccessory.context.device?.lightParameters?.controllerLogicType);
      this.logs.error('[registerExistingAccessory] device object: ', existingAccessory.context.device);
      this.logs.error(error);

      return false;
    }

    // udpate the accessory to your platform
    this.api.updatePlatformAccessories([existingAccessory]);
    existingAccessory.context.pendingRegistration = false;
    return true;
  }

  printDeviceInfo(message: string, accessory: MagicHomeAccessory) {
    const device = accessory.context.device;
    this.logs.info(`${this.count++} - ${message}[ ${device.displayName} ]`);
    this.logs.debug(`
    Controller Logic Type: ${device.lightParameters?.controllerLogicType}
    Model: ${device.modelNumber}
    Unique ID: ${device.uniqueId} 
    IP-Address: ${device.ipAddress} 
    Hardware Version: ${device.controllerHardwareVersion?.toString(16)}
    Firmware Version: ${device.controllerHardwareVersion?.toString(16)}\n`);
  }

  async send(transport, command: number[], useChecksum = true, _timeout = 200) {
    const buffer = Buffer.from(command);

    const output = await transport.send(buffer, useChecksum, _timeout);
    this.logs.debug('Recived the following response', output);

  } //send

  static isValidDeviceModel(_device: IDeviceProps, logger): boolean {
    const device = cloneDeep(_device);
    try {
      const { lightParameters } = device || {};

      const rootProps = ['UUID', 'cachedIPAddress', 'restartsSinceSeen', 'displayName', 'ipAddress', 'uniqueId', 'modelNumber', 'lightParameters', 'controllerHardwareVersion', 'controllerFirmwareVersion'];
      const lightProps = ['controllerLogicType', 'convenientName', 'simultaneousCCT', 'hasColor', 'hasBrightness'];

      const missingRootProps = rootProps.filter(k => device[k] === undefined || device[k] == null);
      const missingLightProps = lightProps.filter(k => lightParameters[k] === undefined || lightParameters[k] == null);

      const missingProps = [...missingRootProps, ...missingLightProps];

      // special case: props that can be null: 'lastKnownState'
      if (device.lastKnownState === undefined) {
        missingProps.push('lastKnownState');
      }

      if (!Object.values(ControllerTypes).includes(lightParameters.controllerLogicType)) {
        if (logger) {
          logger.error(`[isValidDeviceModel] The ContollerLogicType "${lightParameters.controllerLogicType}" is unknown.`);
        }
        return false;
      }


      if (missingProps.length > 0) {
        if (logger) {
          logger.error('[isValidDeviceModel] unable to validate device model. Missing properties: ', missingProps);
          logger.debug('\nThree things are certain:\nDeath, taxes and lost data.\nGuess which has occurred.');
        }
        return false;
      }

      return true;
    } catch (err) {
      return false;
    }

  }

}
Example #21
Source File: platform.ts    From homebridge-tasmota with Apache License 2.0 4 votes vote down vote up
export class tasmotaPlatform 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[] = [];
  private readonly defunctAccessories: PlatformAccessory[] = [];
  public readonly services: tasmotaGarageService[] | tasmotaSwitchService[] | tasmotaLightService[] | tasmotaSensorService[] |
    tasmotaBinarySensorService[] | tasmotaFanService[] = [];

  private discoveryTopicMap: DiscoveryTopicMap[] = [];
  private CustomCharacteristic;

  // Auto removal of non responding devices

  private cleanup: any;
  private timeouts = {};
  private timeoutCounter = 1;
  private debug: any;
  public FakeGatoHistoryService;
  public teleperiod = 300;

  constructor(
    public readonly log: Logger,
    public readonly config: any,
    public readonly api: API,
  ) {
    this.log.debug('Finished initializing platform:', this.config.name);

    this.cleanup = this.config['cleanup'] || 24; // Default removal of defunct devices after 24 hours
    this.debug = this.config['debug'] || false;
    this.teleperiod = this.config['teleperiod'] || 300;

    if (this.debug) {

      let namespaces = debugEnable.disable();

      // this.log("DEBUG-1", namespaces);
      if (namespaces) {
        namespaces = namespaces + ',Tasmota*';
      } else {
        namespaces = 'Tasmota*';
      }
      // this.log("DEBUG-2", namespaces);
      debugEnable.enable(namespaces);
    }

    if (this.config.override) {
      interface Injection { key: string, value: any }
      interface Injections { topic: string, injection: Injection[] }
      const injections: Injections[] = [];
      Object.keys(this.config.override).forEach((topic) => {
        const inject: Injection[] = [];
        Object.entries(this.config.override[topic]).forEach(
          ([key, value]) => {
            // debug("topic: %s, key: %s, value: %s", topic, key, value);
            const injection: Injection = { key: key, value: value };
            inject.push(injection);
          },
        );
        injections.push({ topic: topic, injection: inject });
      });
      debug('This is your override reformated to injections.');
      debug('"injections": %s\n', JSON.stringify(injections, null, 2));
    }

    /* eslint-disable */
    this.CustomCharacteristic = require('./lib/CustomCharacteristics')(this.Service, this.Characteristic);

    // 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('didFinishLaunching', () => {
      log.debug('Executed didFinishLaunching callback');
      // run the method to discover / register your devices as accessories
      debug('%d accessories for cleanup', this.defunctAccessories.length);
      if (this.defunctAccessories.length > 0) {
        this.cleanupDefunctAccessories();
      }
      this.discoverDevices();

      if (this.config.history) {

        this.FakeGatoHistoryService = fakegato(this.api);

        // Only addEntries that match the expected profile of the function.

        this.FakeGatoHistoryService.prototype.appendData = function(entry) {
          entry.time = Math.round(new Date().valueOf() / 1000);
          switch (this.accessoryType) {
            default:
              // debug('unhandled this.accessoryType', this.accessoryType);
              this.addEntry(entry);
          }
        };
      }
    });
  }

  /**
   * 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.info('Loading accessory from cache:', accessory.displayName);

    debug('Load: "%s" %d services', accessory.displayName, accessory.services.length);

    if (accessory.services.length > 1) {
      // debug('context', accessory.context);
      accessory.context.timeout = this.autoCleanup(accessory);

      // add the restored accessory to the accessories cache so we can track if it has already been registered
      this.accessories.push(accessory);
    } else {
      this.log.warn('Warning: Removing incomplete accessory definition from cache:', accessory.displayName);
      this.defunctAccessories.push(accessory);
    }
  }

  /* Check the topic against the configuration's filterList.
   */
  isTopicAllowed(topic: string, filter: string, filterAllow: Array<string>, filterDeny: Array<string>): boolean {
    // debug('isTopicFiltered', topic)
    let defaultAllow = true;
    let allowThis = false;

    if (filter) {
      defaultAllow = false;

      if (topic.match(filter)) {
        debug('isTopicFiltered matched filter', filter);
        allowThis = true;
      }
    }

    if (filterAllow) {
      defaultAllow = false;

      for (const filter of filterAllow) {
        if (topic.match(filter)) {
          debug('isTopicFiltered matched filterAllow entry', filter);
          allowThis = true;
        }
      }
    }

    if (filterDeny) {
      for (const filter of filterDeny) {
        if (topic.match(filter)) {
          debug('isTopicFiltered matched filterDeny entry', filter);
          return false;
        }
      }
    }

    return allowThis || defaultAllow;
  }

  /**
   * 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.
   */
  discoverDevices() {

    debug('discoverDevices');
    // EXAMPLE ONLY
    // A real plugin you would discover accessories from the local network, cloud services
    // or a user-defined array in the platform config.

    const mqttHost = new Mqtt(this.config);

    // debug('MqttHost', mqttHost);

    mqttHost.on('Remove', (topic) => {
      // debug('remove-0', topic);
      if (this.discoveryTopicMap[topic]) {
        const existingAccessory = this.accessories.find(accessory => accessory.UUID === this.discoveryTopicMap[topic].uuid);
        if (existingAccessory) {
          // debug('Remove', this.discoveryTopicMap[topic]);
          switch (this.discoveryTopicMap[topic].type) {
            case 'Service':
              this.serviceCleanup(this.discoveryTopicMap[topic].uniq_id, existingAccessory);
              break;
            case 'Accessory':
              this.accessoryCleanup(existingAccessory);
              break;
          }
          delete this.discoveryTopicMap[topic];
        } else {
          debug('missing accessory', topic, this.discoveryTopicMap[topic]);
        }

      } else {
        // debug('Remove failed', topic);
      }

    });

    mqttHost.on('Discovered', (topic, config) => {


      // 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

      // debug('topic', topic);
      // debug('filter', this.config.filter);
      // debug('filterList', this.config.filterList);
      if (this.isTopicAllowed(topic, this.config.filter, this.config.filterAllow, this.config.filterDeny)) {

        let message = normalizeMessage(config);
        // debug('normalizeMessage ->', message);
        if (message.dev && message.dev.ids[0]) {
          const identifier = message.dev.ids[0];      // Unique per accessory
          const uniq_id = message.uniq_id;            // Unique per service

          message = this.discoveryOveride(uniq_id, message);
          debug('Discovered ->', topic, config.name, message);
          const uuid = this.api.hap.uuid.generate(identifier);

          // see if an accessory with the same uuid has already been registered and restored from
          // the cached devices we stored in the `configureAccessory` method above
          const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);

          if (existingAccessory) {
            // the accessory already exists

            this.log.info('Found existing accessory:', message.name);
            // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.:
            // existingAccessory.context.device = device;
            // this.api.updatePlatformAccessories([existingAccessory]);

            // create the accessory handler for the restored accessory
            // this is imported from `platformAccessory.ts`

            existingAccessory.context.mqttHost = mqttHost;
            existingAccessory.context.device[uniq_id] = message;
            existingAccessory.context.identifier = identifier;

            this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };

            if (this.services[uniq_id]) {
              this.log.warn('Restoring existing service from cache:', message.name);
              this.services[uniq_id].refresh();
              switch (message.tasmotaType) {
                case 'sensor':
                  if (!message.dev_cla) { // This is the device status topic
                    this.discoveryTopicMap[topic] = { topic: topic, type: 'Accessory', uniq_id: uniq_id, uuid: uuid };
                  } else {
                    this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                  }
                  // debug('discoveryTopicMap', this.discoveryTopicMap[topic]);
                  break;
                default:
                  this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
              }
            } else {
              this.log.info('Creating service:', message.name, message.tasmotaType);
              switch (message.tasmotaType) {
                case 'sensor':
                  this.services[uniq_id] = new tasmotaSensorService(this, existingAccessory, uniq_id);
                  if (!message.dev_cla) { // This is the device status topic
                    this.discoveryTopicMap[topic] = { topic: topic, type: 'Accessory', uniq_id: uniq_id, uuid: uuid };
                  } else {
                    this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                  }
                  break;
                case 'light':
                  this.services[uniq_id] = new tasmotaLightService(this, existingAccessory, uniq_id);
                  this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                  break;
                case 'fan':
                case 'fanFixed':
                  this.services[uniq_id] = new tasmotaFanService(this, existingAccessory, uniq_id);
                  this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                  break;
                case 'switch':
                  this.services[uniq_id] = new tasmotaSwitchService(this, existingAccessory, uniq_id);
                  this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                  break;
                case 'garageDoor':
                  this.services[uniq_id] = new tasmotaGarageService(this, existingAccessory, uniq_id);
                  this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                  break;
                case 'binary_sensor':
                  this.services[uniq_id] = new tasmotaBinarySensorService(this, existingAccessory, uniq_id);
                  this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                  break;
                default:
                  this.log.warn('Warning: Unhandled Tasmota device type', message.tasmotaType);
              }
            }

            debug('discoveryDevices - this.api.updatePlatformAccessories - %d', existingAccessory.services.length);
            this.api.updatePlatformAccessories([existingAccessory]);

          } else {
            // the accessory does not yet exist, so we need to create it
            this.log.info('Adding new accessory:', message.name);

            // create a new accessory
            const accessory = new this.api.platformAccessory(message.name, 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 = {};
            accessory.context.device[uniq_id] = message;
            accessory.context.mqttHost = mqttHost;
            accessory.context.identifier = identifier;

            // create the accessory handler for the newly create accessory
            // this is imported from `platformAccessory.ts`
            switch (message.tasmotaType) {
              case 'switch':
                this.services[uniq_id] = new tasmotaSwitchService(this, accessory, uniq_id);
                this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                break;
              case 'garageDoor':
                this.services[uniq_id] = new tasmotaGarageService(this, accessory, uniq_id);
                this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                break;
              case 'light':
                this.services[uniq_id] = new tasmotaLightService(this, accessory, uniq_id);
                this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                break;
              case 'fan':
                this.services[uniq_id] = new tasmotaFanService(this, accessory, uniq_id);
                this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                break;
              case 'sensor':
                this.services[uniq_id] = new tasmotaSensorService(this, accessory, uniq_id);
                if (!message.dev_cla) { // This is the device status topic
                  this.discoveryTopicMap[topic] = { topic: topic, type: 'Accessory', uniq_id: uniq_id, uuid: uuid };
                } else {
                  this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                }
                break;
              case 'binary_sensor':
                this.services[uniq_id] = new tasmotaBinarySensorService(this, accessory, uniq_id);
                this.discoveryTopicMap[topic] = { topic: topic, type: 'Service', uniq_id: uniq_id, uuid: uuid };
                break;
              default:
                this.log.warn('Warning: Unhandled Tasmota device type', message.tasmotaType);
            }
            debug('discovery devices - this.api.registerPlatformAccessories - %d', accessory.services.length);
            if (accessory.services.length > 1) {
              this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
              this.accessories.push(accessory);
            } else {
              this.log.warn('Warning: incomplete HASS Discovery message and device definition', topic, config.name);
            }
          }

          if (this.services[uniq_id] && this.services[uniq_id].service && this.services[uniq_id].service.getCharacteristic(this.Characteristic.ConfiguredName).listenerCount('set') < 1) {

            (this.services[uniq_id].service.getCharacteristic(this.Characteristic.ConfiguredName) ||
              this.services[uniq_id].service.addCharacteristic(this.Characteristic.ConfiguredName))
              .on('set', setConfiguredName.bind(this.services[uniq_id]));
          }

        } else {
          this.log.warn('Warning: Malformed HASS Discovery message', topic, config.name);
        }
      } else {
        debug('filtered', topic);
      }
    });
  }

  discoveryOveride(uniq_id: string, message: any) {
    /* eslint-disable */

    if (this.config.override) { // pre version 0.1.0 override configuration
      // debug('override', this.config.override);
      var overrides = [];
      for (const [key, value] of Object.entries(this.config.override)) {
        // debug(`${key}: ${value}`);
        overrides[key] = value;
      }
      if (overrides[uniq_id]) {
        // debug('Merging', this.config.override[uniq_id]);
        let merged = { ...message, ...this.config.override[uniq_id] };
        // debug('Merged', merged);
        return normalizeMessage(merged);
      }
    } else if (this.config.injections) {
      // debug('injections', this.config.injections);
      this.config.injections.forEach(overide => {
        if (overide.topic === uniq_id) {
          overide.injection.forEach(inject => {
            message[inject.key] = inject.value;
          });
        }
      });
    }
    return normalizeMessage(message);
  }

  serviceCleanup(uniq_id: string, existingAccessory: PlatformAccessory) {
    // debug('service array', this.services);
    debug('serviceCleanup', uniq_id);
    if (this.services[uniq_id]) {
      if (this.services[uniq_id].service) {
        this.log.info('Removing Service', this.services[uniq_id].service.displayName);

        if (this.services[uniq_id].statusSubscribe) {
          // debug("Cleaned up listeners", mqttHost);
          // debug(this.services[uniq_id].statusSubscribe.event);
          if (this.services[uniq_id].statusSubscribe.event) {
            existingAccessory.context.mqttHost.removeAllListeners(this.services[uniq_id].statusSubscribe.event);

          } else {
            this.log.error('statusSubscribe.event missing', this.services[uniq_id].service.displayName);
          }
          if (this.services[uniq_id].availabilitySubscribe) {
            existingAccessory.context.mqttHost.removeAllListeners(this.services[uniq_id].availabilitySubscribe.event);
          } else {
            this.log.error('availabilitySubscribe missing', this.services[uniq_id].service.displayName);
          }
          // debug("Cleaned up listeners", existingAccessory.context.mqttHost);
        }

        existingAccessory.removeService(this.services[uniq_id].service);
        delete this.services[uniq_id];
        debug('serviceCleanup - this.api.updatePlatformAccessories');
        this.api.updatePlatformAccessories([existingAccessory]);
      } else {
        debug('serviceCleanup - object');
        delete this.services[uniq_id];
      }
    } else {
      debug('No service', uniq_id);
    }
  }

  autoCleanup(accessory: PlatformAccessory) {
    let timeoutID;

    // debug("autoCleanup", accessory.displayName, accessory.context.timeout, this.timeouts);
    // debug('autoCleanup \"%s\" topic %s', accessory.displayName, findVal(accessory.context.device, "stat_t"));
    if (findVal(accessory.context.device, "stat_t")) {
      if (accessory.context.timeout) {
        timeoutID = accessory.context.timeout;
        clearTimeout(this.timeouts[timeoutID]);
        delete this.timeouts[timeoutID];
      }

      timeoutID = this.timeoutCounter++;
      this.timeouts[timeoutID] = setTimeout(this.accessoryCleanup.bind(this), this.cleanup * 60 * 60 * 1000, accessory);

      return (timeoutID);
    } else {
      // debug('autoCleanup unavailable \"%s\" topic %s', accessory.displayName);
      return null;
    }
  }

  accessoryCleanup(existingAccessory: PlatformAccessory) {
    this.log.info('Removing Accessory', existingAccessory.displayName);
    // debug('Services', this.services);
    // debug('Accessory', this.discoveryTopicMap[topic]);

    // debug('FILTER', this.services.filter(x => x.UUID === this.discoveryTopicMap[topic].uuid));

    const services = this.services;
    const output: string[] = [];
    const uuid = existingAccessory.UUID;

    Object.keys(services).some((k: any) => {
      // debug(k);
      // debug(services[k].accessory.UUID);
      if (uuid === services[k].accessory.UUID) {
        output.push(k);
        // this.serviceCleanup(k);
      }
    });

    output.forEach(element => {
      this.serviceCleanup(element, existingAccessory);
    });

    // Remove accessory
    this.accessories.splice(this.accessories.findIndex(accessory => accessory.UUID === existingAccessory.UUID), 1);
    // debug('this.timeouts - before', this.timeouts);
    clearTimeout(this.timeouts[existingAccessory.context.timeout]);
    this.timeouts[existingAccessory.context.timeout] = null;
    debug('unregister - this.api.unregisterPlatformAccessories');
    this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
    // debug('this.timeouts - after', this.timeouts);
  }

  // Remove defunct accessories discovered during startup

  cleanupDefunctAccessories() {
    this.defunctAccessories.forEach(accessory => {
      debug('Removing', accessory.displayName);
      this.accessoryCleanup(accessory);
    });
  }

}
Example #22
Source File: index.ts    From homebridge-philips-air with BSD 2-Clause "Simplified" License 4 votes vote down vote up
class PhilipsAirPlatform implements DynamicPlatformPlugin {
  private readonly log: Logging;
  private readonly api: API;
  private readonly config: PhilipsAirPlatformConfig;
  private readonly timeout: number;
  private readonly cachedAccessories: Array<PlatformAccessory> = [];
  private readonly purifiers: Map<string, Purifier> = new Map();
  private readonly commandQueue: Array<Command> = [];
  private queueRunning = false;

  enqueuePromise = promisify(this.enqueueCommand);

  constructor(log: Logging, config: PlatformConfig, api: API) {
    this.log = log;
    this.config = config as unknown as PhilipsAirPlatformConfig;
    this.api = api;

    this.timeout = (this.config.timeout_seconds || 5) * 1000;

    api.on(APIEvent.DID_FINISH_LAUNCHING, this.didFinishLaunching.bind(this));
  }

  configureAccessory(accessory: PlatformAccessory): void {
    this.cachedAccessories.push(accessory);
  }

  didFinishLaunching(): void {
    const ips: Array<string> = [];
    this.config.devices.forEach((device: DeviceConfig) => {
      this.addAccessory(device);
      const uuid = hap.uuid.generate(device.ip);
      ips.push(uuid);
    });

    const badAccessories: Array<PlatformAccessory> = [];
    this.cachedAccessories.forEach(cachedAcc => {
      if (!ips.includes(cachedAcc.UUID)) {
        badAccessories.push(cachedAcc);
      }
    });
    this.removeAccessories(badAccessories);

    this.purifiers.forEach((purifier) => {
      this.enqueueCommand(CommandType.Polling, purifier);
      this.enqueueCommand(CommandType.GetFirmware, purifier);
      this.enqueueCommand(CommandType.GetStatus, purifier);
      this.enqueueCommand(CommandType.GetFilters, purifier);
    });
  }

  async storeKey(purifier: Purifier): Promise<void> {
    if (purifier.client && purifier.client instanceof HttpClient) {
      purifier.accessory.context.key = (purifier.client as HttpClient).key;
    }
  }

  async setData(purifier: Purifier, values: any, // eslint-disable-line @typescript-eslint/no-explicit-any
    callback?: (error?: Error | null | undefined) => void): Promise<void> {
    try {
      await purifier.client?.setValues(values);
      await this.storeKey(purifier);
      if (callback) {
        callback();
      }
    } catch (err) {
      if (callback) {
        callback(err);
      }
    }
  }

  async updatePolling(purifier: Purifier): Promise<void> {
    try {
      // Polling interval
      let polling = purifier.config.polling || 60;
      if (polling < 60) {
        polling = 60;
      }
      setInterval(function() {
        exec('python3 ' + pathTopyaircontrol + ' --ipaddr ' + purifier.config.ip + ' --protocol ' + purifier.config.protocol + ' --status', (error, stdout, stderr) => {
          if (error || stderr) {
            console.log(timestamp('[DD.MM.YYYY, HH:mm:ss] ') + '\x1b[36m[Philips Air] \x1b[31m[' + purifier.config.name + '] Unable to get data for polling: Error: spawnSync python3 ETIMEDOUT.\x1b[0m');
            console.log(timestamp('[DD.MM.YYYY, HH:mm:ss] ') + '\x1b[33mIf your have "Error: spawnSync python3 ETIMEDOUT" your need unplug the accessory from outlet for 10 seconds and plug again.\x1b[0m');
          }

          if (error || stderr || error && stderr) {
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-ignore
            stdout = {om: localStorage.getItem('om'), pwr: localStorage.getItem('pwr'), cl: false, aqil: localStorage.getItem('aqil'), uil: localStorage.getItem('uil'), dt: 0, dtrs: 0, mode: localStorage.getItem('mode'), func: localStorage.getItem('func'), rhset: localStorage.getItem('rhset'), 'rh': localStorage.getItem('rh'), 'temp': localStorage.getItem('temp'), pm25: localStorage.getItem('pm25'), iaql: localStorage.getItem('iaql'), aqit: 4, ddp: '1', rddp: localStorage.getItem('rddp'), err: 0, wl: localStorage.getItem('wl'), fltt1: localStorage.getItem('fltt1'), fltt2: localStorage.getItem('fltt2'), fltsts0: localStorage.getItem('fltsts0'), fltsts1: localStorage.getItem('fltsts1'), fltsts2: localStorage.getItem('fltsts2'), wicksts: localStorage.getItem('wicksts')};
            stdout = JSON.stringify(stdout);
          }
          const obj = JSON.parse(stdout);
          if (!error || !stderr || !error && !stderr) {
            localStorage.setItem('pwr', obj.pwr);
            localStorage.setItem('om', obj.om);
            localStorage.setItem('aqil', obj.aqil);
            localStorage.setItem('uil', obj.uil);
            localStorage.setItem('mode', obj.mode);
            localStorage.setItem('func', obj.func);
            localStorage.setItem('rhset', obj.rhset);
            localStorage.setItem('iaql', obj.iaql);
            localStorage.setItem('pm25', obj.pm25);
            localStorage.setItem('rh', obj.rh);
            localStorage.setItem('temp', obj.temp);
            localStorage.setItem('rddp', obj.rddp);
            localStorage.setItem('wl', obj.wl);
            localStorage.setItem('fltt1', obj.fltt1);
            localStorage.setItem('fltt2', obj.fltt2);
            localStorage.setItem('fltsts0', obj.fltsts0);
            localStorage.setItem('fltsts1', obj.fltsts1);
            localStorage.setItem('fltsts2', obj.fltsts2);
            localStorage.setItem('wicksts', obj.wicksts);
          } else {
            localStorage.setItem('pwr', 1);
            localStorage.setItem('om', '0');
            localStorage.setItem('aqil', '0');
            localStorage.setItem('uil', 0);
            localStorage.setItem('mode', 'A');
            localStorage.setItem('func', 'PH');
            localStorage.setItem('rhset', 50);
            localStorage.setItem('iaql', 1);
            localStorage.setItem('pm25', 1);
            localStorage.setItem('rh', 45);
            localStorage.setItem('temp', 25);
            localStorage.setItem('rddp', 1);
            localStorage.setItem('wl', 100);
            localStorage.setItem('fltt1', 'A3');
            localStorage.setItem('fltt2', 'C7');
            localStorage.setItem('fltsts0', 287);
            localStorage.setItem('fltsts1', 2553);
            localStorage.setItem('fltsts2', 2553);
            localStorage.setItem('wicksts', 4005);
          }

          const purifierService = purifier.accessory.getService(hap.Service.AirPurifier);
          if (purifierService) {
            const state = parseInt(obj.pwr) * 2;

            purifierService
              .updateCharacteristic(hap.Characteristic.Active, obj.pwr)
              .updateCharacteristic(hap.Characteristic.CurrentAirPurifierState, state)
              .updateCharacteristic(hap.Characteristic.LockPhysicalControls, obj.cl);
          }
          const qualityService = purifier.accessory.getService(hap.Service.AirQualitySensor);
          if (qualityService) {
            const iaql = Math.ceil(obj.iaql / 3);
            qualityService
              .updateCharacteristic(hap.Characteristic.AirQuality, iaql)
              .updateCharacteristic(hap.Characteristic.PM2_5Density, obj.pm25);
          }
          if (purifier.config.temperature_sensor) {
            const temperature_sensor = purifier.accessory.getService('Temperature');
            if (temperature_sensor) {
              temperature_sensor.updateCharacteristic(hap.Characteristic.CurrentTemperature, obj.temp);
            }
          }
          if (purifier.config.humidity_sensor) {
            const humidity_sensor = purifier.accessory.getService('Humidity');
            if (humidity_sensor) {
              humidity_sensor.updateCharacteristic(hap.Characteristic.CurrentRelativeHumidity, obj.rh);
            }
          }
          if (purifier.config.light_control) {
            const lightsService = purifier.accessory.getService('Lights');
            if (obj.pwr == '1') {
              if (lightsService) {
                lightsService
                  .updateCharacteristic(hap.Characteristic.On, obj.aqil > 0)
                  .updateCharacteristic(hap.Characteristic.Brightness, obj.aqil);
              }
            }
          }
          if (purifier.config.humidifier) {
            let water_level = 100;
            let speed_humidity = 0;
            if (obj.func == 'PH' && obj.wl == 0) {
              water_level = 0;
            }
            if (obj.pwr == '1') {
              if (obj.func == 'PH' && water_level == 100) {
                if (obj.rhset == 40) {
                  speed_humidity = 25;
                } else if (obj.rhset == 50) {
                  speed_humidity = 50;
                } else if (obj.rhset == 60) {
                  speed_humidity = 75;
                } else if (obj.rhset == 70) {
                  speed_humidity = 100;
                }
              }
            }
            const Humidifier = purifier.accessory.getService('Humidifier');
            if (Humidifier) {
              Humidifier
                .updateCharacteristic(hap.Characteristic.CurrentRelativeHumidity, obj.rh)
                .updateCharacteristic(hap.Characteristic.WaterLevel, water_level)
                .updateCharacteristic(hap.Characteristic.TargetHumidifierDehumidifierState, 1)
                .updateCharacteristic(hap.Characteristic.RelativeHumidityHumidifierThreshold, speed_humidity);
              if (water_level == 0) {
                if (obj.func != 'P') {
                  exec('airctrl --ipaddr ' + purifier.config.ip + ' --protocol ' + purifier.config.protocol + ' --func P', (error, stdout, stderr) => {
                    if (error || stderr) {
                      console.log(timestamp('[DD.MM.YYYY, HH:mm:ss] ') + '\x1b[36m[Philips Air] \x1b[31m[' + purifier.config.name + '] Unable to get data for polling: Error: spawnSync python3 ETIMEDOUT.\x1b[0m');
                      console.log(timestamp('[DD.MM.YYYY, HH:mm:ss] ') + '\x1b[33mIf your have "Error: spawnSync python3 ETIMEDOUT" your need unplug the accessory from outlet for 10 seconds and plug again.\x1b[0m');
                    }
                  });
                }
                Humidifier
                  .updateCharacteristic(hap.Characteristic.Active, 0)
                  .updateCharacteristic(hap.Characteristic.CurrentHumidifierDehumidifierState, 0)
                  .updateCharacteristic(hap.Characteristic.RelativeHumidityHumidifierThreshold, 0);
              }
            }
          }
          if (purifier.config.logger) {
            if (purifier.config.temperature_sensor) {
              if (!error || !stderr || !error && !stderr) {
                const logger_temp = fs.createWriteStream(pathToSensorFiles + 'temp.txt', {
                  flags: 'w'
                });

                logger_temp.write(obj.temp.toString());
                logger_temp.end();
              }
            }
            if (purifier.config.humidity_sensor) {
              if (!error || !stderr || !error && !stderr) {
                const logger_hum = fs.createWriteStream(pathToSensorFiles + 'hum.txt', {
                  flags: 'w'
                });

                logger_hum.write(obj.rh.toString());
                logger_hum.end();
              }
            }
          }
        });
      }, polling * 1000);
    } catch (err) {
      this.log.error('[' + purifier.config.name + '] Unable to load polling info');
    }
  }

  async updateFirmware(purifier: Purifier): Promise<void> {
    try {
      purifier.lastfirmware = Date.now();
      const firmware: PurifierFirmware = await purifier.client?.getFirmware();
      await this.storeKey(purifier);
      const accInfo = purifier.accessory.getService(hap.Service.AccessoryInformation);
      if (accInfo) {
        const name = firmware.modelid;

        accInfo
          .updateCharacteristic(hap.Characteristic.Manufacturer, 'Philips')
          .updateCharacteristic(hap.Characteristic.SerialNumber, purifier.config.ip)
          .updateCharacteristic(hap.Characteristic.Model, name)
          .updateCharacteristic(hap.Characteristic.FirmwareRevision, firmware.version);
      }
    } catch (err) {
      this.log.error('[' + purifier.config.name + '] Unable to load firmware info: ' + err);
    }
  }

  async updateFilters(purifier: Purifier): Promise<void> {
    try {
      const filters: PurifierFilters = await purifier.client?.getFilters();
      purifier.lastfilters = Date.now();
      await this.storeKey(purifier);
      const preFilter = purifier.accessory.getService('Pre-filter');
      if (preFilter) {
        const fltsts0change = filters.fltsts0 == 0;
        const fltsts0life = filters.fltsts0 / 360 * 100;

        preFilter
          .updateCharacteristic(hap.Characteristic.FilterChangeIndication, fltsts0change)
          .updateCharacteristic(hap.Characteristic.FilterLifeLevel, fltsts0life);
      }

      const carbonFilter = purifier.accessory.getService('Active carbon filter');
      if (carbonFilter) {
        const fltsts2change = filters.fltsts2 == 0;
        const fltsts2life = filters.fltsts2 / 4800 * 100;

        carbonFilter
          .updateCharacteristic(hap.Characteristic.FilterChangeIndication, fltsts2change)
          .updateCharacteristic(hap.Characteristic.FilterLifeLevel, fltsts2life);
      }

      const hepaFilter = purifier.accessory.getService('HEPA filter');
      if (hepaFilter) {
        const fltsts1change = filters.fltsts1 == 0;
        const fltsts1life = filters.fltsts1 / 4800 * 100;

        hepaFilter
          .updateCharacteristic(hap.Characteristic.FilterChangeIndication, fltsts1change)
          .updateCharacteristic(hap.Characteristic.FilterLifeLevel, fltsts1life);
      }
      if (purifier.config.humidifier) {
        const wickFilter = purifier.accessory.getService('Wick filter');
        if (wickFilter) {
          const fltwickchange = filters.wicksts == 0;
          const fltwicklife = Math.round(filters.wicksts / 4800 * 100);
          wickFilter
            .updateCharacteristic(hap.Characteristic.FilterChangeIndication, fltwickchange)
            .updateCharacteristic(hap.Characteristic.FilterLifeLevel, fltwicklife);
        }
      }
    } catch (err) {
      this.log.error('[' + purifier.config.name + '] Unable to load filter info: ' + err);
    }
  }

  async updateStatus(purifier: Purifier): Promise<void> {
    try {
      const status: PurifierStatus = await purifier.client?.getStatus();
      purifier.laststatus = Date.now();
      await this.storeKey(purifier);
      const purifierService = purifier.accessory.getService(hap.Service.AirPurifier);
      if (purifierService) {
        const state = parseInt(status.pwr) * 2;

        purifierService
          .updateCharacteristic(hap.Characteristic.Active, status.pwr)
          .updateCharacteristic(hap.Characteristic.CurrentAirPurifierState, state)
          .updateCharacteristic(hap.Characteristic.LockPhysicalControls, status.cl);
      }
      const qualityService = purifier.accessory.getService(hap.Service.AirQualitySensor);
      if (qualityService) {
        const iaql = Math.ceil(status.iaql / 3);
        qualityService
          .updateCharacteristic(hap.Characteristic.AirQuality, iaql)
          .updateCharacteristic(hap.Characteristic.PM2_5Density, status.pm25);
      }
      if (purifier.config.temperature_sensor) {
        const temperature_sensor = purifier.accessory.getService('Temperature');
        if (temperature_sensor) {
          temperature_sensor.updateCharacteristic(hap.Characteristic.CurrentTemperature, status.temp);
        }
      }
      if (purifier.config.humidity_sensor) {
        const humidity_sensor = purifier.accessory.getService('Humidity');
        if (humidity_sensor) {
          humidity_sensor.updateCharacteristic(hap.Characteristic.CurrentRelativeHumidity, status.rh);
        }
      }

      if (purifier.config.light_control) {
        const lightsService = purifier.accessory.getService('Lights');
        if (status.pwr == '1') {
          if (lightsService) {
            lightsService
              .updateCharacteristic(hap.Characteristic.On, status.aqil > 0)
              .updateCharacteristic(hap.Characteristic.Brightness, status.aqil);
          }
        }
      }
      if (purifier.config.humidifier) {
        const Humidifier = purifier.accessory.getService('Humidifier');
        if (Humidifier) {
          let speed_humidity = 0;
          let state_ph = 0;
          let water_level = 100;
          if (status.func == 'PH' && status.wl == 0) {
            water_level = 0;
          }
          if (status.pwr == '1') {
            if (status.func == 'PH' && water_level == 100) {
              state_ph = 1;
              if (status.rhset == 40) {
                speed_humidity = 25;
              } else if (status.rhset == 50) {
                speed_humidity = 50;
              } else if (status.rhset == 60) {
                speed_humidity = 75;
              } else if (status.rhset == 70) {
                speed_humidity = 100;
              }
            }
          }
          Humidifier
            .updateCharacteristic(hap.Characteristic.CurrentRelativeHumidity, status.rh)
            .updateCharacteristic(hap.Characteristic.WaterLevel, water_level)
            .updateCharacteristic(hap.Characteristic.TargetHumidifierDehumidifierState, 1);
          if (state_ph && status.rhset >= 40) {
            Humidifier
              .updateCharacteristic(hap.Characteristic.Active, state_ph)
              .updateCharacteristic(hap.Characteristic.CurrentHumidifierDehumidifierState, state_ph * 2)
              .updateCharacteristic(hap.Characteristic.RelativeHumidityHumidifierThreshold, speed_humidity);
          }
          if (water_level == 0) {
            if (status.func != 'P') {
              exec('airctrl --ipaddr ' + purifier.config.ip + ' --protocol ' + purifier.config.protocol + ' --func P', (err, stdout, stderr) => {
                if (err) {
                  return;
                }
                if (stderr) {
                  console.error('Unable to switch off purifier ' + stderr + '. If your have "sync timeout" error your need unplug the accessory from the outlet for 10 seconds.');
                }
              });
            }
          }
        }
      }
    } catch (err) {
      this.log.error('[' + purifier.config.name + '] Unable to load status info: ' + err);
    }
  }

  async setPower(accessory: PlatformAccessory, state: CharacteristicValue): Promise<void> {
    const purifier = this.purifiers.get(accessory.displayName);
    if (purifier) {
      const values = {
        pwr: (state as boolean).toString()
      };
      try {
        const status: PurifierStatus = await purifier.client?.getStatus();
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        purifier.laststatus = Date.now();
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        await this.storeKey(purifier);
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        let water_level = 100;
        if (status.func == 'PH' && status.wl == 0) {
          water_level = 0;
        }
        const purifierService = accessory.getService(hap.Service.AirPurifier);
        if (purifierService) {
          purifierService.updateCharacteristic(hap.Characteristic.CurrentAirPurifierState, state as number * 2);
        }
        if (purifier.config.humidifier) {
          if (water_level == 0) {
          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
          // @ts-ignore
            values['func'] = 'P';
          }
        }
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        await this.enqueuePromise(CommandType.SetData, purifier, values);
        if (purifier.config.light_control) {
          const lightsService = accessory.getService('Lights');
          if (lightsService) {
            if (state) {
              lightsService
                .updateCharacteristic(hap.Characteristic.On, status.aqil > 0)
                .updateCharacteristic(hap.Characteristic.Brightness, status.aqil);
            } else {
              lightsService
                .updateCharacteristic(hap.Characteristic.On, 0)
                .updateCharacteristic(hap.Characteristic.Brightness, 0);
            }
          }
        }
        if (purifier.config.humidifier) {
          const Humidifier = accessory.getService('Humidifier');
          let state_ph = 0;
          if (status.func == 'PH' && water_level == 100) {
            state_ph = 1;
          }
          if (Humidifier) {
            Humidifier.updateCharacteristic(hap.Characteristic.TargetHumidifierDehumidifierState, 1);
            if (state) {
              Humidifier
                .updateCharacteristic(hap.Characteristic.Active, state_ph)
                .updateCharacteristic(hap.Characteristic.CurrentHumidifierDehumidifierState, state_ph * 2);
            } else {
              Humidifier
                .updateCharacteristic(hap.Characteristic.Active, 0)
                .updateCharacteristic(hap.Characteristic.CurrentHumidifierDehumidifierState, 0)
                .updateCharacteristic(hap.Characteristic.RelativeHumidityHumidifierThreshold, 0);
            }
          }
        }
      } catch (err) {
        this.log.error('[' + purifier.config.name + '] Error setting power: ' + err);
      }
    }
  }

  async setBrightness(accessory: PlatformAccessory, state: CharacteristicValue): Promise<void> {
    const purifier = this.purifiers.get(accessory.displayName);

    if (purifier) {
      const values = {
        aqil: state,
        uil: state ? '1' : '0'
      };

      try {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        await this.enqueuePromise(CommandType.SetData, purifier, values);
      } catch (err) {
        this.log.error('[' + purifier.config.name + '] Error setting brightness: ' + err);
      }
    }
  }

  async setMode(accessory: PlatformAccessory, state: CharacteristicValue): Promise<void> {
    const purifier = this.purifiers.get(accessory.displayName);
    if (purifier) {
      const values = {
        mode: state ? 'P' : 'M'
      };
      if (purifier.config.allergic_func) {
        values.mode = state ? 'P' : 'A';
      } else {
        values.mode = state ? 'P' : 'M';
      }
      try {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        await this.enqueuePromise(CommandType.SetData, purifier, values);

        if (state != 0) {
          const purifierService = accessory.getService(hap.Service.AirPurifier);
          if (purifierService) {
            purifierService
              .updateCharacteristic(hap.Characteristic.RotationSpeed, 0)
              .updateCharacteristic(hap.Characteristic.TargetAirPurifierState, state);
          }
        }
      } catch (err) {
        this.log.error('[' + purifier.config.name + '] Error setting mode: ' + err);
      }
    }
  }

  async setLock(accessory: PlatformAccessory, state: CharacteristicValue): Promise<void> {
    const purifier = this.purifiers.get(accessory.displayName);

    if (purifier) {
      const values = {
        cl: state == 1
      };

      try {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        await this.enqueuePromise(CommandType.SetData, purifier, values);
      } catch (err) {
        this.log.error('[' + purifier.config.name + '] Error setting lock: ' + err);
      }
    }
  }

  async setHumidity(accessory: PlatformAccessory, state: CharacteristicValue): Promise<void> {
    const purifier = this.purifiers.get(accessory.displayName);
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const status: PurifierStatus = await purifier.client?.getStatus();
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    purifier.laststatus = Date.now();
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    await this.storeKey(purifier);
    const Humidifier = accessory.getService('Humidifier');
    if (purifier) {
      const values = {
        func: state ? 'PH' : 'P'
      };
      let water_level = 100;
      if (status.func == 'PH' && status.wl == 0) {
        water_level = 0;
      }
      let speed_humidity = 0;
      let state_ph = 0;
      if (status.func == 'PH' && water_level == 100) {
        state_ph = 1;
        if (status.rhset == 40) {
          speed_humidity = 25;
        } else if (status.rhset == 50) {
          speed_humidity = 50;
        } else if (status.rhset == 60) {
          speed_humidity = 75;
        } else if (status.rhset == 70) {
          speed_humidity = 100;
        }
      }
      try {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        await this.enqueuePromise(CommandType.SetData, purifier, values);
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        Humidifier.updateCharacteristic(hap.Characteristic.TargetHumidifierDehumidifierState, 1);
        if (state) {
          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
          Humidifier
            .updateCharacteristic(hap.Characteristic.Active, 1)
            .updateCharacteristic(hap.Characteristic.CurrentHumidifierDehumidifierState, state_ph * 2)
            .updateCharacteristic(hap.Characteristic.RelativeHumidityHumidifierThreshold, speed_humidity);
        } else {
          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
          Humidifier
            .updateCharacteristic(hap.Characteristic.Active, 0)
            .updateCharacteristic(hap.Characteristic.CurrentHumidifierDehumidifierState, 0)
            .updateCharacteristic(hap.Characteristic.RelativeHumidityHumidifierThreshold, 0);
        }
      } catch (err) {
        this.log.error('[' + purifier.config.name + '] Error setting func: ' + err);
      }
    }
  }

  async setHumidityTarget(accessory: PlatformAccessory, state: CharacteristicValue): Promise<void> {
    const purifier = this.purifiers.get(accessory.displayName);
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const status: PurifierStatus = await purifier.client?.getStatus();
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    purifier.laststatus = Date.now();
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    await this.storeKey(purifier);
    const Humidifier = accessory.getService('Humidifier');
    if (purifier) {
      const speed = state;
      const values = {
        func: state ? 'PH' : 'P',
        rhset: 40
      };
      let speed_humidity = 0;
      if (speed > 0 && speed <= 25) {
        values.rhset = 40;
        speed_humidity = 25;
      } else if (speed > 25 && speed <= 50) {
        values.rhset = 50;
        speed_humidity = 50;
      } else if (speed > 50 && speed <= 75) {
        values.rhset = 60;
        speed_humidity = 75;
      } else if (speed > 75 && speed <= 100) {
        values.rhset = 70;
        speed_humidity = 100;
      }
      try {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        await this.enqueuePromise(CommandType.SetData, purifier, values);
        if (Humidifier) {
          let water_level = 100;
          if (status.func == 'PH' && status.wl == 0) {
            water_level = 0;
          }
          Humidifier.updateCharacteristic(hap.Characteristic.TargetHumidifierDehumidifierState, 1);
          if (speed_humidity > 0) {
            Humidifier
              .updateCharacteristic(hap.Characteristic.Active, 1)
              .updateCharacteristic(hap.Characteristic.CurrentHumidifierDehumidifierState, 2)
              .updateCharacteristic(hap.Characteristic.WaterLevel, water_level)
              .updateCharacteristic(hap.Characteristic.RelativeHumidityHumidifierThreshold, speed_humidity);
          } else {
            Humidifier
              .updateCharacteristic(hap.Characteristic.Active, 0);
          }
        }
      } catch (err) {
        this.log.error('[' + purifier.config.name + '] Error setting humidifier: ' + err);
      }
    }
  }

  async setFan(accessory: PlatformAccessory, state: CharacteristicValue): Promise<void> {
    const purifier = this.purifiers.get(accessory.displayName);

    if (purifier) {
      let divisor = 25;
      let offset = 0;
      if (purifier.config.sleep_speed) {
        divisor = 20;
        offset = 1;
      }
      const speed = Math.ceil(state as number / divisor);
      if (speed > 0) {
        const values = {
          mode: 'M',
          om: ''
        };
        if (offset == 1 && speed == 1) {
          values.om = 's';
        } else if (speed < 4 + offset) {
          values.om = (speed - offset).toString();
        } else {
          values.om = 't';
        }

        try {
          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
          // @ts-ignore
          await this.enqueuePromise(CommandType.SetData, purifier, values);

          const service = accessory.getService(hap.Service.AirPurifier);
          if (service) {
            service.updateCharacteristic(hap.Characteristic.TargetAirPurifierState, 0);
          }

          if (purifier.timeout) {
            clearTimeout(purifier.timeout);
          }
          purifier.timeout = setTimeout(() => {
            if (service) {
              service.updateCharacteristic(hap.Characteristic.RotationSpeed, speed * divisor);
            }
            purifier.timeout = undefined;
          }, 1000);
        } catch (err) {
          this.log.error('[' + purifier.config.name + '] Error setting fan: ' + err);
        }
      }
    }
  }

  addAccessory(config: DeviceConfig): void {
    this.log('[' + config.name + '] Initializing accessory...');

    const uuid = hap.uuid.generate(config.ip);
    let accessory = this.cachedAccessories.find(cachedAcc => {
      return cachedAcc.UUID == uuid;
    });

    if (!accessory) {
      accessory = new Accessory(config.name, uuid);

      accessory.addService(hap.Service.AirPurifier, config.name);
      accessory.addService(hap.Service.AirQualitySensor, 'Air quality', 'Air quality');

      if (config.light_control) {
        accessory.addService(hap.Service.Lightbulb, 'Lights', 'Lights')
          .addCharacteristic(hap.Characteristic.Brightness);
      }

      accessory.addService(hap.Service.FilterMaintenance, 'Pre-filter', 'Pre-filter');
      accessory.addService(hap.Service.FilterMaintenance, 'Active carbon filter', 'Active carbon filter');
      accessory.addService(hap.Service.FilterMaintenance, 'HEPA filter', 'HEPA filter');
      if (config.temperature_sensor) {
        accessory.addService(hap.Service.TemperatureSensor, 'Temperature', 'Temperature');
      }
      if (config.humidity_sensor) {
        accessory.addService(hap.Service.HumiditySensor, 'Humidity', 'Humidity');
      }
      if (config.humidifier) {
        accessory.addService(hap.Service.HumidifierDehumidifier, 'Humidifier', 'Humidifier');
        accessory.addService(hap.Service.FilterMaintenance, 'Wick filter', 'Wick filter');
      }

      this.api.registerPlatformAccessories('homebridge-philips-air', 'philipsAir', [accessory]);
    } else {
      let lightsService = accessory.getService('Lights');

      if (config.light_control) {
        if (lightsService == undefined) {
          lightsService = accessory.addService(hap.Service.Lightbulb, 'Lights', 'Lights');
          lightsService.addCharacteristic(hap.Characteristic.Brightness);
        }
      } else if (lightsService != undefined) {
        accessory.removeService(lightsService);
      }
      const temperature_sensor = accessory.getService('Temperature');
      if (config.temperature_sensor) {
        if (temperature_sensor == undefined) {
          accessory.addService(hap.Service.TemperatureSensor, 'Temperature', 'Temperature');
        }
      } else if (temperature_sensor != undefined) {
        accessory.removeService(temperature_sensor);
      }
      const humidity_sensor = accessory.getService('Humidity');
      if (config.humidity_sensor) {
        if (humidity_sensor == undefined) {
          accessory.addService(hap.Service.HumiditySensor, 'Humidity', 'Humidity');
        }
      } else if (humidity_sensor != undefined) {
        accessory.removeService(humidity_sensor);
      }
      const Humidifier = accessory.getService('Humidifier');
      if (config.humidifier) {
        if (Humidifier == undefined) {
          accessory.addService(hap.Service.HumidifierDehumidifier, 'Humidifier', 'Humidifier');
        }
      } else if (Humidifier != undefined) {
        accessory.removeService(Humidifier);
      }
    }

    this.setService(accessory, config);

    let client: AirClient;
    switch (config.protocol) {
      case 'coap':
        client = new CoapClient(config.ip, this.timeout);
        break;
      case 'plain_coap':
        client = new PlainCoapClient(config.ip, this.timeout);
        break;
      case 'http_legacy':
        client = new HttpClientLegacy(config.ip, this.timeout);
        break;
      case 'http':
      default:
        if (accessory.context.key) {
          client = new HttpClient(config.ip, this.timeout, accessory.context.key);
        } else {
          client = new HttpClient(config.ip, this.timeout);
        }
    }

    this.purifiers.set(accessory.displayName, {
      accessory: accessory,
      client: client,
      config: config
    });
  }

  removeAccessories(accessories: Array<PlatformAccessory>): void {
    accessories.forEach(accessory => {
      this.log('[' + accessory.displayName + '] Removed from Homebridge.');
      this.api.unregisterPlatformAccessories('homebridge-philips-air', 'philipsAir', [accessory]);
    });
  }

  setService(accessory: PlatformAccessory, config: DeviceConfig): void {
    accessory.on(PlatformAccessoryEvent.IDENTIFY, () => {
      this.log('[' + accessory.displayName + '] Identify requested.');
    });

    const purifierService = accessory.getService(hap.Service.AirPurifier);
    let min_step_purifier_speed = 25;
    if (config.sleep_speed) {
      min_step_purifier_speed = 20;
    }
    if (purifierService) {
      purifierService
        .getCharacteristic(hap.Characteristic.Active)
        .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
          try {
            await this.setPower(accessory, state);
            callback();
          } catch (err) {
            callback(err);
          }
        });

      purifierService
        .getCharacteristic(hap.Characteristic.TargetAirPurifierState)
        .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
          try {
            await this.setMode(accessory, state);
            callback();
          } catch (err) {
            callback(err);
          }
        });

      purifierService
        .getCharacteristic(hap.Characteristic.LockPhysicalControls)
        .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
          try {
            await this.setLock(accessory, state);
            callback();
          } catch (err) {
            callback(err);
          }
        });

      purifierService
        .getCharacteristic(hap.Characteristic.RotationSpeed)
        .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
          try {
            await this.setFan(accessory, state);
            callback();
          } catch (err) {
            callback(err);
          }
        }).setProps({
          minValue: 0,
          maxValue: 100,
          minStep: min_step_purifier_speed
        });
    }

    if (config.light_control) {
      const lightService = accessory.getService('Lights');
      if (lightService) {
        lightService
          .getCharacteristic(hap.Characteristic.Brightness)
          .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
            try {
              await this.setBrightness(accessory, state);
              callback();
            } catch (err) {
              callback(err);
            }
          }).setProps({
            minValue: 0,
            maxValue: 100,
            minStep: 25
          });
      }
    }

    if (config.humidifier) {
      const Humidifier = accessory.getService('Humidifier');
      if (Humidifier) {
        Humidifier
          .getCharacteristic(hap.Characteristic.Active)
          .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
            try {
              await this.setHumidity(accessory, state);
              callback();
            } catch (err) {
              callback(err);
            }
          });
        Humidifier
          .getCharacteristic(hap.Characteristic.CurrentHumidifierDehumidifierState)
          .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
            try {
              await this.setHumidityTarget(accessory, state);
              await this.setHumidity(accessory, state);
              callback();
            } catch (err) {
              callback(err);
            }
          }).setProps({
            validValues: [
              hap.Characteristic.CurrentHumidifierDehumidifierState.INACTIVE,
              hap.Characteristic.CurrentHumidifierDehumidifierState.HUMIDIFYING
            ]
          });
        Humidifier
          .getCharacteristic(hap.Characteristic.TargetHumidifierDehumidifierState)
          .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
            try {
              await this.setHumidityTarget(accessory, state);
              await this.setHumidity(accessory, state);
              callback();
            } catch (err) {
              callback(err);
            }
          }).setProps({
            validValues: [
              hap.Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER
            ]
          });
        Humidifier
          .getCharacteristic(hap.Characteristic.RelativeHumidityHumidifierThreshold)
          .on('set', async(state: CharacteristicValue, callback: CharacteristicSetCallback) => {
            try {
              await this.setHumidityTarget(accessory, state);
              callback();
            } catch (err) {
              callback(err);
            }
          }).setProps({
            minValue: 0,
            maxValue: 100,
            minStep: 25
          });
      }
    }
  }

  enqueueAccessory(commandType: CommandType, accessory: PlatformAccessory): void {
    const purifier = this.purifiers.get(accessory.displayName);

    if (purifier) {
      this.enqueueCommand(commandType, purifier);
    }
  }

  enqueueCommand(commandType: CommandType, purifier: Purifier, data?: any, // eslint-disable-line @typescript-eslint/no-explicit-any
    callback?: (error?: Error | null | undefined) => void): void {
    if (commandType != CommandType.SetData) {
      const exists = this.commandQueue.find((command) => {
        return command.purifier.config.ip == purifier.config.ip && command.type == commandType;
      });
      if (exists) {
        return; // Don't enqueue commands we already have in the queue
      }
    }
    this.commandQueue.push({
      purifier: purifier,
      type: commandType,
      callback: callback,
      data: data
    });
    if (!this.queueRunning) {
      this.queueRunning = true;
      this.nextCommand();
    }
  }

  nextCommand(): void {
    const todoItem = this.commandQueue.shift();
    if (!todoItem) {
      return;
    }

    let command;
    switch (todoItem.type) {
      case CommandType.Polling:
        command = this.updatePolling(todoItem.purifier);
        break;
      case CommandType.GetFirmware:
        command = this.updateFirmware(todoItem.purifier);
        break;
      case CommandType.GetFilters:
        command = this.updateFilters(todoItem.purifier);
        break;
      case CommandType.GetStatus:
        command = this.updateStatus(todoItem.purifier);
        break;
      case CommandType.SetData:
        command = this.setData(todoItem.purifier, todoItem.data, todoItem.callback);
    }

    command.then(() => {
      if (this.commandQueue.length > 0) {
        this.nextCommand();
      } else {
        this.queueRunning = false;
      }
    });
  }
}
Example #23
Source File: index.ts    From homebridge-nest-cam with GNU General Public License v3.0 4 votes vote down vote up
class NestCamPlatform implements DynamicPlatformPlugin {
  private readonly log: Logging;
  private readonly api: API;
  private config: NestConfig;
  private options: Options;
  private readonly nestObjects: Array<NestObject> = [];
  private structures: Array<string> = [];
  private cameras: Array<string> = [];

  constructor(log: Logging, config: PlatformConfig, api: API) {
    this.log = log;
    this.api = api;
    this.config = config as NestConfig;
    this.options = new Options();

    // Need a config or plugin will not start
    if (!config) {
      return;
    }

    this.initDefaultOptions();
    api.on(APIEvent.DID_FINISH_LAUNCHING, this.didFinishLaunching.bind(this));
    api.on(APIEvent.SHUTDOWN, this.isShuttingDown.bind(this));
  }

  private initDefaultOptions(): void {
    // Setup boolean options
    Object.keys(this.options).forEach((opt) => {
      const key = opt as keyof Options;
      if (this.config.options) {
        const configVal = this.config.options[key];
        if (typeof configVal === 'undefined') {
          this.options[key] = true;
          this.log.debug(`Defaulting ${key} to true`);
        } else {
          this.options[key] = configVal;
          this.log.debug(`Using ${key} from config: ${configVal}`);
        }
      }
    });

    const structures = this.config.options?.structures;
    if (typeof structures !== 'undefined') {
      this.log.debug(`Using structures from config: ${structures}`);
      this.structures = structures;
    } else {
      this.log.debug('Defaulting structures to []');
    }

    const cameras = this.config.options?.cameras;
    if (typeof cameras !== 'undefined') {
      this.log.debug(`Using cameras from config: ${cameras}`);
      this.cameras = cameras;
    } else {
      this.log.debug('Defaulting cameras to []');
    }
  }

  configureAccessory(accessory: PlatformAccessory<Record<string, CameraInfo>>): void {
    this.log.info(`Configuring accessory ${accessory.displayName}`);

    accessory.on(PlatformAccessoryEvent.IDENTIFY, () => {
      this.log.info(`${accessory.displayName} identified!`);
    });

    const cameraInfo = accessory.context.cameraInfo;
    const camera = new NestCam(this.config, cameraInfo, this.log);
    const nestAccessory = new NestAccessory(accessory, camera, this.config, this.log, hap);
    nestAccessory.configureController();

    // Microphone configuration
    if (camera.info.capabilities.includes('audio.microphone')) {
      nestAccessory.createService(hap.Service.Microphone);
      nestAccessory.createService(hap.Service.Speaker);
      this.log.debug(`Creating microphone for ${accessory.displayName}.`);
    }

    // Doorbell configuration
    if (camera.info.capabilities.includes('indoor_chime') && this.options.doorbellAlerts) {
      nestAccessory.createService(hap.Service.Doorbell, 'Doorbell');
      this.log.debug(`Creating doorbell sensor for ${accessory.displayName}.`);
      camera.startAlertChecks();
    } else {
      nestAccessory.removeService(hap.Service.Doorbell, 'Doorbell');
    }

    // Add doorbell switch
    if (
      camera.info.capabilities.includes('indoor_chime') &&
      this.options.doorbellAlerts &&
      this.options.doorbellSwitch
    ) {
      const service = nestAccessory.createService(hap.Service.StatelessProgrammableSwitch, 'DoorbellSwitch');
      this.log.debug(`Creating doorbell switch for ${accessory.displayName}.`);
      service.getCharacteristic(hap.Characteristic.ProgrammableSwitchEvent).setProps({
        maxValue: hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS,
      });
    } else {
      nestAccessory.removeService(hap.Service.StatelessProgrammableSwitch, 'DoorbellSwitch');
    }

    // Streaming switch configuration
    if (camera.info.capabilities.includes('streaming.start-stop') && this.options.streamingSwitch) {
      nestAccessory.createSwitchService('Streaming', hap.Service.Switch, 'streaming.enabled', async (value) => {
        await nestAccessory.toggleActive(value as boolean);
      });
    } else {
      nestAccessory.removeService(hap.Service.Switch, 'Streaming');
    }

    // Chime switch configuration
    if (camera.info.capabilities.includes('indoor_chime') && this.options.chimeSwitch) {
      nestAccessory.createSwitchService('Chime', hap.Service.Switch, 'doorbell.indoor_chime.enabled', async (value) => {
        await nestAccessory.toggleChime(value as boolean);
      });
    } else {
      nestAccessory.removeService(hap.Service.Switch, 'Chime');
    }

    // Announcements switch configuration
    if (camera.info.capabilities.includes('indoor_chime') && this.options.announcementsSwitch) {
      nestAccessory.createSwitchService(
        'Announcements',
        hap.Service.Switch,
        'doorbell.chime_assist.enabled',
        async (value) => {
          await nestAccessory.toggleAnnouncements(value as boolean);
        },
      );
    } else {
      nestAccessory.removeService(hap.Service.Switch, 'Announcements');
    }

    // Audio switch configuration
    if (camera.info.capabilities.includes('audio.microphone') && this.options.audioSwitch) {
      nestAccessory.createSwitchService('Audio', hap.Service.Switch, 'audio.enabled', async (value) => {
        await nestAccessory.toggleAudio(value as boolean);
      });
    } else {
      nestAccessory.removeService(hap.Service.Switch, 'Audio');
    }

    //Update Firmware Revision
    const accessoryInformation = accessory.getService(hap.Service.AccessoryInformation);
    if (accessoryInformation) {
      accessoryInformation.setCharacteristic(hap.Characteristic.FirmwareRevision, cameraInfo.combined_software_version);
    }

    this.nestObjects.push({ accessory: accessory, camera: camera });
  }

  private async setupMotionServices(): Promise<void> {
    this.nestObjects.forEach(async (obj) => {
      const camera = obj.camera;
      const accessory = obj.accessory;
      if (accessory) {
        const nestAccessory = new NestAccessory(accessory, camera, this.config, this.log, hap);
        if (this.options.motionDetection) {
          // Motion configuration
          const services = nestAccessory.getServicesByType(hap.Service.MotionSensor);
          const alertTypes = await camera.getAlertTypes();
          // Remove invalid services
          const invalidServices = services.filter((x) => !alertTypes.includes(x.displayName));
          for (const service of invalidServices) {
            accessory.removeService(service);
          }
          alertTypes.forEach((type) => {
            if (camera.info.capabilities.includes('detectors.on_camera')) {
              nestAccessory.createService(hap.Service.MotionSensor, type);
              this.log.debug(`Creating motion sensor for ${accessory.displayName} ${type}.`);
              camera.startAlertChecks();
            }
          });
        } else {
          nestAccessory.removeAllServicesByType(hap.Service.MotionSensor);
        }
      }
    });
  }

  private cleanupAccessories(): void {
    // Remove cached cameras filtered by structure
    if (this.structures.length > 0) {
      const oldObjects = this.nestObjects.filter(
        (obj: NestObject) => !this.structures.includes(obj.camera.info.nest_structure_id.replace('structure.', '')),
      );
      oldObjects.forEach((obj) => {
        this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [obj.accessory]);
        const index = this.nestObjects.indexOf(obj);
        if (index > -1) {
          obj.camera.stopAlertChecks();
          this.nestObjects.splice(index, 1);
        }
      });
    }
    // Remove cached cameras filtered by uuid
    if (this.cameras.length > 0) {
      const oldObjects = this.nestObjects.filter((obj: NestObject) => !this.cameras.includes(obj.camera.info.uuid));
      oldObjects.forEach((obj) => {
        this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [obj.accessory]);
        const index = this.nestObjects.indexOf(obj);
        if (index > -1) {
          obj.camera.stopAlertChecks();
          this.nestObjects.splice(index, 1);
        }
      });
    }
  }

  /**
   * Filter cameras from Nest account
   */
  private filterCameras(cameras: Array<CameraInfo>): Array<CameraInfo> {
    if (this.structures.length > 0) {
      this.log.debug('Filtering cameras by structures config');
      cameras = cameras.filter((info: CameraInfo) =>
        this.structures.includes(info.nest_structure_id.replace('structure.', '')),
      );
    }
    if (this.cameras.length > 0) {
      this.log.debug('Filtering cameras by cameras config');
      cameras = cameras.filter((info: CameraInfo) => this.cameras.includes(info.uuid));
    }
    return cameras;
  }

  /**
   * Add fetched cameras from nest to Homebridge
   */
  private async addCameras(cameras: Array<CameraInfo>): Promise<void> {
    const filteredCameras = await this.filterCameras(cameras);
    filteredCameras.forEach(async (cameraInfo: CameraInfo) => {
      const uuid = hap.uuid.generate(cameraInfo.uuid);
      // Parenthesis in the name breaks HomeKit for some reason
      const displayName = cameraInfo.name.replace('(', '').replace(')', '');
      const accessory = new Accessory(displayName, uuid);
      accessory.context.cameraInfo = cameraInfo;

      const model = cameraInfo.type < ModelTypes.length ? ModelTypes[cameraInfo.type] : 'Nest Camera';
      const accessoryInformation = accessory.getService(hap.Service.AccessoryInformation);
      if (accessoryInformation) {
        accessoryInformation.setCharacteristic(hap.Characteristic.Manufacturer, 'Nest');
        accessoryInformation.setCharacteristic(hap.Characteristic.Model, model);
        accessoryInformation.setCharacteristic(hap.Characteristic.SerialNumber, cameraInfo.serial_number);
        accessoryInformation.setCharacteristic(
          hap.Characteristic.FirmwareRevision,
          cameraInfo.combined_software_version,
        );
      }

      // Only add new cameras that are not cached
      const obj = this.nestObjects.find((x: NestObject) => x.accessory.UUID === uuid);
      if (obj) {
        await obj.camera.updateData(cameraInfo);
      } else {
        this.log.debug(`New camera found: ${cameraInfo.name}`);
        this.configureAccessory(accessory); // abusing the configureAccessory here
        this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
      }
    });
  }

  async getAccessToken(): Promise<string> {
    const { refreshToken, googleAuth, nest_token } = this.config;

    if (refreshToken) {
      return await auth(refreshToken, this.config.options?.fieldTest, this.log);
    } else if (googleAuth) {
      const { apiKey, issueToken, cookies } = googleAuth;

      if (!issueToken || !cookies) {
        this.log.error('You must provide issueToken and cookies in config.json. Please see README.md for instructions');
        return '';
      }

      return await old_auth(issueToken, cookies, apiKey, this.log);
    } else if (nest_token) {
      return await nest_auth(nest_token, this.log);
    } else {
      this.log.error(
        'You must provide a refreshToken or googleAuth object in config.json. Please see README.md for instructions',
      );
      return '';
    }
  }

  async didFinishLaunching(): Promise<void> {
    const self = this;
    const accessToken = await this.getAccessToken();
    if (accessToken) {
      this.config.access_token = accessToken;
      // Nest needs to be reauthenticated about every hour
      setInterval(async () => {
        self.log.debug('Reauthenticating with config credentials');
        this.config.access_token = await this.getAccessToken();
      }, 3480000); // 58 minutes

      const cameras = await getCameras(this.config, this.log);
      await this.addCameras(cameras);
      await this.setupMotionServices();
      this.cleanupAccessories();
      const session = new NestSession(this.config, this.log);
      const cameraObjects = this.nestObjects.map((x) => x.camera);
      await session.subscribe(cameraObjects);
    } else {
      this.log.error('Unable to retrieve access token.');
    }
  }

  isShuttingDown(): void {
    const accessoryObjects = this.nestObjects.map((x) => x.accessory);
    this.api.updatePlatformAccessories(accessoryObjects);
  }
}
Example #24
Source File: index.ts    From homebridge-fordpass with GNU General Public License v3.0 4 votes vote down vote up
class FordPassPlatform implements DynamicPlatformPlugin {
  private readonly log: Logging;
  private readonly api: API;
  private readonly accessories: Array<PlatformAccessory> = [];
  private readonly vehicles: Array<Vehicle> = [];
  private config: FordpassConfig;
  private pendingLockUpdate = false;

  constructor(log: Logging, config: PlatformConfig, api: API) {
    this.log = log;
    this.api = api;
    this.config = config as FordpassConfig;

    // Need a config or plugin will not start
    if (!config) {
      return;
    }

    if (!config.username || !config.password) {
      this.log.error('Please add a userame and password to your config.json');
      return;
    }

    api.on(APIEvent.DID_FINISH_LAUNCHING, this.didFinishLaunching.bind(this));
  }

  configureAccessory(accessory: PlatformAccessory): void {
    const self = this;
    this.log.info(`Configuring accessory ${accessory.displayName}`);

    accessory.on(PlatformAccessoryEvent.IDENTIFY, () => {
      this.log.info(`${accessory.displayName} identified!`);
    });

    const vehicle = new Vehicle(accessory.context.name, accessory.context.vin, this.config, this.log);
    const fordAccessory = new FordpassAccessory(accessory);

    // Create Lock service
    const defaultState = hap.Characteristic.LockTargetState.UNSECURED;
    const lockService = fordAccessory.createService(hap.Service.LockMechanism);
    const switchService = fordAccessory.createService(hap.Service.Switch);
    const batteryService = fordAccessory.createService(
      hap.Service.Battery,
      this.config.options?.batteryName || 'Fuel Level',
    );
    lockService.setCharacteristic(hap.Characteristic.LockCurrentState, defaultState);

    lockService
      .setCharacteristic(hap.Characteristic.LockTargetState, defaultState)
      .getCharacteristic(hap.Characteristic.LockTargetState)
      .on(CharacteristicEventTypes.SET, async (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
        this.log.debug(`${value ? 'Locking' : 'Unlocking'} ${accessory.displayName}`);
        let commandId = '';
        let command = Command.LOCK;
        if (value === hap.Characteristic.LockTargetState.UNSECURED) {
          command = Command.UNLOCK;
        }
        commandId = await vehicle.issueCommand(command);

        let tries = 30;
        this.pendingLockUpdate = true;
        const self = this;
        const interval = setInterval(async () => {
          if (tries > 0) {
            const status = await vehicle.commandStatus(command, commandId);
            if (status?.status === 200) {
              lockService.updateCharacteristic(hap.Characteristic.LockCurrentState, value);
              self.pendingLockUpdate = false;
              clearInterval(interval);
            }
            tries--;
          } else {
            self.pendingLockUpdate = false;
            clearInterval(interval);
          }
        }, 1000);
        callback(undefined, value);
      })
      .on(CharacteristicEventTypes.GET, async (callback: CharacteristicGetCallback) => {
        // Return cached value immediately then update properly
        let lockNumber = hap.Characteristic.LockTargetState.UNSECURED;
        const lockStatus = vehicle?.info?.lockStatus.value;
        if (lockStatus === 'LOCKED') {
          lockNumber = hap.Characteristic.LockTargetState.SECURED;
        }
        callback(undefined, lockNumber);

        const status = await vehicle.status();
        if (status) {
          let lockNumber = hap.Characteristic.LockTargetState.UNSECURED;
          const lockStatus = status.lockStatus.value;
          if (lockStatus === 'LOCKED') {
            lockNumber = hap.Characteristic.LockTargetState.SECURED;
          }
          lockService.updateCharacteristic(hap.Characteristic.LockCurrentState, lockNumber);
          lockService.updateCharacteristic(hap.Characteristic.LockTargetState, lockNumber);
        } else {
          self.log.error(`Cannot get information for ${accessory.displayName} lock`);
        }
      });

    switchService
      .setCharacteristic(hap.Characteristic.On, false)
      .getCharacteristic(hap.Characteristic.On)
      .on(CharacteristicEventTypes.SET, async (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
        this.log.debug(`${value ? 'Starting' : 'Stopping'} ${accessory.displayName}`);
        if (value as boolean) {
          await vehicle.issueCommand(Command.START);
        } else {
          await vehicle.issueCommand(Command.STOP);
        }
        callback(undefined, value);
      })
      .on(CharacteristicEventTypes.GET, async (callback: CharacteristicGetCallback) => {
        // Return cached value immediately then update properly
        const engineStatus = vehicle?.info?.remoteStartStatus.value || 0;
        callback(undefined, engineStatus);
        const status = await vehicle.status();
        if (status) {
          let started = false;
          const engineStatus = status.remoteStartStatus.value || 0;
          if (engineStatus > 0) {
            started = true;
          }
          switchService.updateCharacteristic(hap.Characteristic.On, started);
        } else {
          self.log.error(`Cannot get information for ${accessory.displayName} engine`);
        }
      });

    batteryService
      .setCharacteristic(hap.Characteristic.BatteryLevel, 100)
      .getCharacteristic(hap.Characteristic.BatteryLevel)
      .on(CharacteristicEventTypes.GET, async (callback: CharacteristicGetCallback) => {
        // Return cached value immediately then update properly
        const fuel = vehicle?.info?.fuel?.fuelLevel;
        const battery = vehicle?.info?.batteryFillLevel?.value;
        let level = fuel || battery || 100;
        if (level > 100) {
          level = 100;
        }
        if (level < 0) {
          level = 0;
        }
        callback(undefined, level);
        const status = await vehicle.status();
        if (status) {
          const fuel = status.fuel?.fuelLevel;
          const battery = status.batteryFillLevel?.value;
          const chargingStatus = vehicle?.info?.chargingStatus?.value;
          let level = fuel || battery || 100;
          if (level > 100) {
            level = 100;
          }
          if (level < 0) {
            level = 0;
          }
          batteryService.updateCharacteristic(hap.Characteristic.BatteryLevel, level);
          if (battery) {
            if (chargingStatus === 'ChargingAC') {
              batteryService.updateCharacteristic(
                hap.Characteristic.ChargingState,
                hap.Characteristic.ChargingState.CHARGING,
              );
            } else {
              batteryService.updateCharacteristic(
                hap.Characteristic.ChargingState,
                hap.Characteristic.ChargingState.NOT_CHARGING,
              );
            }
          } else {
            batteryService.updateCharacteristic(
              hap.Characteristic.ChargingState,
              hap.Characteristic.ChargingState.NOT_CHARGEABLE,
            );
          }

          if (level < 10) {
            batteryService.updateCharacteristic(
              hap.Characteristic.StatusLowBattery,
              hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW,
            );
          } else {
            batteryService.updateCharacteristic(
              hap.Characteristic.StatusLowBattery,
              hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL,
            );
          }
        } else {
          self.log.error(`Cannot get information for ${accessory.displayName} engine`);
        }
      });
    this.vehicles.push(vehicle);
    this.accessories.push(accessory);
  }

  async didFinishLaunching(): Promise<void> {
    const self = this;
    const ford = new Connection(this.config, this.log);
    const authInfo = await ford.auth();

    if (authInfo) {
      setInterval(async () => {
        self.log.debug('Reauthenticating with refresh token');
        await ford.refreshAuth();
      }, authInfo.expires_in * 1000 - 10000);

      await this.addVehicles(ford);
      await this.updateVehicles();
      await this.refreshVehicles();

      // Vehicle info needs to be updated every minute
      setInterval(async () => {
        await self.updateVehicles();
      }, 60 * 1000);
    }
  }

  async addVehicles(connection: Connection): Promise<void> {
    const vehicles = await connection.getVehicles();
    vehicles?.forEach(async (vehicle: VehicleConfig) => {
      vehicle.vin = vehicle.vin.toUpperCase();
      const name = vehicle.nickName || vehicle.vehicleType;
      const uuid = hap.uuid.generate(vehicle.vin);
      const accessory = new Accessory(name, uuid);
      accessory.context.name = name;
      accessory.context.vin = vehicle.vin;

      const accessoryInformation = accessory.getService(hap.Service.AccessoryInformation);
      if (accessoryInformation) {
        accessoryInformation.setCharacteristic(hap.Characteristic.Manufacturer, 'Ford');
        accessoryInformation.setCharacteristic(hap.Characteristic.Model, name);
        accessoryInformation.setCharacteristic(hap.Characteristic.SerialNumber, vehicle.vin);
      }

      // Only add new cameras that are not cached
      if (!this.accessories.find((x: PlatformAccessory) => x.UUID === uuid)) {
        this.log.debug(`New vehicle found: ${name}`);
        this.configureAccessory(accessory); // abusing the configureAccessory here
        this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
      }
    });

    // Remove vehicles that were removed from config
    this.accessories.forEach((accessory: PlatformAccessory<Record<string, string>>) => {
      if (!vehicles?.find((x: VehicleConfig) => x.vin === accessory.context.vin)) {
        this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
        const index = this.accessories.indexOf(accessory);
        if (index > -1) {
          this.accessories.splice(index, 1);
          this.vehicles.slice(index, 1);
        }
      }
    });
  }

  async updateVehicles(): Promise<void> {
    this.vehicles.forEach(async (vehicle: Vehicle) => {
      const status = await vehicle.status();
      this.log.debug(`Updating info for ${vehicle.name}`);
      const lockStatus = status?.lockStatus.value;
      let lockNumber = hap.Characteristic.LockCurrentState.UNSECURED;
      if (lockStatus === 'LOCKED') {
        lockNumber = hap.Characteristic.LockCurrentState.SECURED;
      }

      const engineStatus = status?.remoteStartStatus.value || 0;
      let started = false;
      if (engineStatus > 0) {
        started = true;
      }
      const uuid = hap.uuid.generate(vehicle.vin);
      const accessory = this.accessories.find((x: PlatformAccessory) => x.UUID === uuid);

      if (!this.pendingLockUpdate) {
        const lockService = accessory?.getService(hap.Service.LockMechanism);
        lockService && lockService.updateCharacteristic(hap.Characteristic.LockCurrentState, lockNumber);
        lockService && lockService.updateCharacteristic(hap.Characteristic.LockTargetState, lockNumber);
      }

      const switchService = accessory?.getService(hap.Service.Switch);
      switchService && switchService.updateCharacteristic(hap.Characteristic.On, started);
    });
  }

  async refreshVehicles(): Promise<void> {
    this.vehicles.forEach(async (vehicle: Vehicle) => {
      if (vehicle.autoRefresh && vehicle.refreshRate && vehicle.refreshRate > 0) {
        this.log.debug(`Configuring ${vehicle.name} to refresh every ${vehicle.refreshRate} minutes.`);
        setInterval(async () => {
          this.log.debug(`Refreshing info for ${vehicle.name}`);
          await vehicle.issueCommand(Command.REFRESH);
        }, 60000 * vehicle.refreshRate);
      }
    });
  }
}