import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge';

import { PLATFORM_NAME, PLUGIN_NAME } from './settings';
import { ZONES, ZONES_TO_PINS, ZONE_TYPES, TYPES_TO_ACCESSORIES } from './constants';
import { PanelObjectInterface, RuntimeCacheInterface } from './interfaces';
// import { ReplaceCircular } from './utilities';
import { KonnectedPlatformAccessory } from './platformAccessory';

import client from 'node-ssdp';      // for devices discovery
import express from 'express';       // for the listening API
import fetch from 'node-fetch';      // for making calls to the device
import http from 'http';             // for creating a listening server
import fs from 'fs';                 // for working with the filesystem
import ip from 'ip';                 // for getting active IP on the system
import { v4 as uuidv4 } from 'uuid'; // for handling UUIDs and creating auth tokens
import { URL } from 'url';

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