diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78ebdb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +/target +/*.log* +/device_config.json +/settings.json +/topology.json + +# Eclipse +/.settings/ +/bin/ +.metadata +.classpath +.project +.externalToolBuilders/ + +# IntelliJ +.idea/ +*.iml +*.iws +*.ipr diff --git a/ALGORITHMS.md b/ALGORITHMS.md new file mode 100644 index 0000000..c95eef7 --- /dev/null +++ b/ALGORITHMS.md @@ -0,0 +1,70 @@ +# Algorithms +This document describes the RRM algorithms implemented by this service. + +## Channel Optimization +`ChannelOptimizer` and its subclasses implement various channel optimization +algorithms. + +* `RandomChannelInitializer`: This algorithm randomly selects a channel, and + then assigns all APs to that selected channel. This is only for testing and + re-initialization. + +* `LeastUsedChannelOptimizer`: This algorithm assigns the channel of the OWF APs + based on the following logic: + 1. If no other APs are on the same channel as the OWF AP, then the algorithm + keeps the OWF AP on the same channel. + 2. If any other APs are on the same channel as the OWF AP, and the OWF AP + scan results indicate unused channels in its RF vicinity, then the + algorithm randomly assigns one of those channels to the AP. + 3. If any other APs are on the same channel as this AP and the OWF AP scan + results indicate all available channels are occupied locally, then the + algorithm assigns the channel with the least number of APs in its WiFi + scan result. + +* `UnmanagedApAwareChannelOptimizer`: Building on the least used channel + assignment algorithm, this algorithm can additionally (1) prioritize non-OWF + ("unmanaged") APs over OWF APs and (2) keep track of the current assignment. + This algorithm will try to avoid assigning the OWF APs to a channel with many + non-OWF APs and prevent assigning subsequent OWF APs to the same channel as + previously-assigned OWF APs. The assignment decisions are based on the + following logic: + 1. If no other APs are on the same channel as the OWF AP, then the algorithm + keeps the OWF AP on the same channel. + 2. If any other APs are on the same channel as the OWF AP, and the OWF AP + scan results indicate unused channels in its RF vicinity, then the + algorithm randomly assigns one of those channels to the AP. + 3. If any other APs are on the same channel as this AP and the OWF AP scan + results indicate all available channels are occupied locally, then the + algorithm assigns the channel with the least channel weight (*W*): + $$ W = (D \times N) + (1 \times M) $$ + where *D > 1* is the default weight, *N* is the number of non-OWF APs, + and *M* is the number of OWF APs. + +## Transmit Power Control +`TPC` and its subclasses implement various transmit power control algorithms. + +* `RandomTxPowerinitializer`: This algorithm randomly selects a Tx power value, + and then assigns all APs to the value. This is only for testing and + re-initialization. + +* `MeasurementBasedApClientTPC`: This algorithm tries to assign the Tx power of + the OWF APs based on the associated clients of each AP. The strategy is + described in the steps below (for each AP): + 1. Determine the operating SNR & MCS on the client side. + 2. If this SNR is greater than the minimum required SNR to achieve this MCS, + reduce the Tx power. Otherwise, no change is made. + 3. If there are multiple clients, the above decision is made based on the + client with lowest SNR. + +* `MeasurementBasedApApTPC`: This algorithm tries to assign the Tx power of the + OWF APs by getting a set of APs to transmit at a power level to minimize + overlapping coverage. The power levels of these APs will be determined by the + following steps: + 1. Through WiFi scans, collect the list of RSSIs (from *N-1* APs) reported + by every AP. + 2. For each AP, find the lowest RSSI in its list. If this is higher than + `RSSI_threshold`, decrease the Tx power of this AP by + `(RSSI_lowest - RSSI_threshold)`. + 3. If this is lower than `RSSI_threshold`, increase the Tx power of this AP + by `(RSSI_threshold - RSSI_lowest)`. + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ca28ec3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM maven:3-jdk-11 as build +WORKDIR /usr/src/java +COPY . . +RUN mvn clean package + +FROM openjdk:11 +WORKDIR /usr/src/java +COPY --from=build /usr/src/java/target/openwifi-rrm.jar /usr/local/bin/ +ENTRYPOINT ["java", "-jar", "/usr/local/bin/openwifi-rrm.jar", "run"] diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..fa83c13 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,95 @@ +# Implementation +This document provides high-level implementation details of the RRM service. + +## Framework +`Launcher` is the main class, which creates *clients* and then passes them to +the `RRM` class to run all *modules* (details below). Many of these will run in +their own threads and implement the standard `Runnable` interface. + +The application takes a JSON-serialized `RRMConfig` object as service config +(default `settings.json`). This file is created dynamically, and any new/missing +fields are appended automatically. All fields are documented in Javadoc. + +The device topology, `DeviceTopology`, is specified as groupings of APs into +disjoint "RF zones" (default `topology.json`). For example: +```JSON +{ + "": ["", ""], + "building-A": ["aaaaaaaaaa01", "aaaaaaaaaa02"] +} +``` + +Device configuration is defined in `DeviceConfig` and applied in a layered +manner via `DeviceLayeredConfig` (default `device_config.json`), merging the +following layers from least to greatest precedence: +* Base/default AP config (`DeviceConfig.createDefault()`) +* Network-wide config (`networkConfig`) +* Per-zone config (`zoneConfig`) +* Per-AP config (`apConfig`) + +Logging is handled using [SLF4J]/[Log4j] and configured in +`src/main/resources/log4j.properties`. + +## Clients +The *clients* implement connections to external services. + +### uCentral Client +`UCentralClient` implements OpenAPI HTTP client calls to the [uCentralGw] and +[uCentralSec] services using [Unirest]. Where possible, request/response models +are defined in the package `com.facebook.openwifirrm.ucentral.gw.models` and +serialized/deserialized using [Gson]. + +### uCentral Kafka Consumer +`UCentralKafkaConsumer` implements the [Apache Kafka] consumer for uCentral +topics, and passes data to other modules via listener interfaces. This is +wrapped by `KafkaConsumerRunner` to handle graceful shutdown. + +### Database Client +`DatabaseManager` handles JDBC connection details for the RRM database and +exposes methods for specific database operations. It uses the +[MySQL Connector/J] driver and [HikariCP] for connection pooling. + +## Modules +The *modules* implement the service's application logic. + +### Data Collector +`DataCollector` collects data from all OpenWiFi devices as follows: +* Issues WiFi scan commands periodically and handles responses +* Registers Kafka listeners to write records into the RRM database +* Registers config listeners to configure the stats interval in OpenWiFi devices + +### Config Manager +`ConfigManager` periodically sends config changes to OpenWiFi devices. Any +desired config changes are applied via listener interfaces, including the output +of RRM algorithms. + +### Modeler +`Modeler` subscribes to uCentral device state and wifi scan data, then prepares +it for use by an optimizer. + +### API Server +`ApiServer` is an OpenAPI HTTP server written using [Spark], exposing the +following endpoints: +* `/` - Static resources from [Swagger UI] for visualizing and interacting with + API endpoints +* `/openapi.{yaml,json}` - OpenAPI 3.0 document generated from source code using + [Swagger Core] +* `/api/v1/` - RRM API methods + +## Optimizers +The *optimizers* implement the RRM algorithms, which are described in +[ALGORITHMS.md](ALGORITHMS.md). + + +[SLF4J]: http://www.slf4j.org/ +[Log4j]: https://logging.apache.org/log4j/ +[uCentralGw]: https://github.com/Telecominfraproject/wlan-cloud-ucentralgw +[uCentralSec]: https://github.com/Telecominfraproject/wlan-cloud-ucentralsec +[Unirest]: https://github.com/kong/unirest-java +[Gson]: https://github.com/google/gson +[Apache Kafka]: https://kafka.apache.org/ +[MySQL Connector/J]: https://dev.mysql.com/doc/connector-j/8.0/en/ +[HikariCP]: https://github.com/brettwooldridge/HikariCP +[Spark]: https://sparkjava.com/ +[Swagger UI]: https://swagger.io/tools/swagger-ui/ +[Swagger Core]: https://github.com/swagger-api/swagger-core diff --git a/LICENSE b/LICENSE index 6d0f400..3d11563 100644 --- a/LICENSE +++ b/LICENSE @@ -1,29 +1,26 @@ -BSD 3-Clause License +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. -Copyright (c) 2021, Telecom Infra Project -All rights reserved. +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. + * Neither the name Meta nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3480258 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# OpenWiFi RRM Service +OpenWiFi uCentral-based radio resource management service. + +## Requirements +* **Running:** JRE 11. +* **Building:** JDK 11 and [Apache Maven]. + +## Building +``` +$ mvn package [-DskipTests] +``` +This will build a runnable JAR located at `target/openwifi-rrm.jar`. + +## Testing +``` +$ mvn test +``` +Unit tests are written using [JUnit 5]. + +## Usage +``` +$ java -jar openwifi-rrm.jar [-h] +``` +The command-line interface is implemented using [picocli]. + +Service configuration is done via a JSON file (default `settings.json`) and will +require the following data: +* uCentral credentials (`uCentralConfig`) +* Kafka broker URL (`kafkaConfig`) +* MySQL database credentials (`databaseConfig`) + +## Docker +Docker builds can be launched using the provided [Dockerfile](Dockerfile). + +## OpenAPI +This service provides an OpenAPI HTTP interface on the port specified in the +service configuration (`moduleConfig.apiServerParams`). An auto-generated +OpenAPI 3.0 document is hosted at the endpoints `/openapi.{yaml,json}`. + +## Implementation +See [IMPLEMENTATION.md](IMPLEMENTATION.md) for service architecture details and +[ALGORITHMS.md](ALGORITHMS.md) for descriptions of the RRM algorithms. + +## License +See [LICENSE](LICENSE). + + +[Apache Maven]: https://maven.apache.org/ +[JUnit 5]: https://junit.org/junit5/ +[picocli]: https://picocli.info/ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6c8563b --- /dev/null +++ b/pom.xml @@ -0,0 +1,159 @@ + + 4.0.0 + com.facebook + openwifi-rrm + 1.0.0 + + 11 + 1.7.32 + 5.7.2 + com.facebook.openwifirrm.Launcher + + + openwifi-rrm + + + src/main/resources + + **/* + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + ${java.version} + ${java.version} + UTF-8 + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + maven-shade-plugin + 3.2.1 + + + + *:* + + **/*.md + + + + log4j:log4j + + + org/apache/log4j/net/JMSAppender.class + + org/apache/log4j/net/SocketServer.class + + + + + + + ${mainClassName} + + + + false + + + + package + + shade + + + + + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.slf4j + slf4j-log4j12 + ${slf4j.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + info.picocli + picocli + 4.6.1 + + + org.json + json + 20210307 + + + com.google.code.gson + gson + 2.8.8 + + + com.konghq + unirest-java + 3.11.09 + + + org.apache.kafka + kafka-clients + 2.8.0 + + + mysql + mysql-connector-java + 8.0.26 + + + com.zaxxer + HikariCP + 4.0.3 + + + com.sparkjava + spark-core + 2.9.3 + + + io.swagger.core.v3 + swagger-jaxrs2 + 2.1.10 + + + javax.ws.rs + javax.ws.rs-api + 2.1.1 + + + org.reflections + reflections + 0.10.2 + + + diff --git a/src/main/java/com/facebook/openwifirrm/DeviceConfig.java b/src/main/java/com/facebook/openwifirrm/DeviceConfig.java new file mode 100644 index 0000000..7622b8c --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/DeviceConfig.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import io.swagger.v3.oas.annotations.Hidden; + +/** + * AP configuration model. + *

+ * Implementation notes: + *

    + *
  • Fields must be nullable (no primitives, use boxed types instead).
  • + *
  • This currently does not support merging of nested structures.
  • + *
+ */ +public class DeviceConfig { + /** Whether RRM algorithms are enabled */ + public Boolean enableRRM; + + /** Whether pushing device config is enabled */ + public Boolean enableConfig; + + /** Whether automatic wifi scans are enabled */ + public Boolean enableWifiScan; + + /** The length of a square/cube for the target area (see {@link #location}) */ + public Integer boundary; + + /** The AP location either 2D or 3D */ + public List location; + + /** + * The list of allowed channels, or null to allow all + * (map from band to channel) + */ + public Map> allowedChannels; + + /** + * The list of allowed channel widths, or null to allow all + * (map from band to channel) + */ + public Map> allowedChannelWidths; + + /** The RRM-assigned channels to use (map from radio to channel) */ + public Map autoChannels; + + /** + * The user-assigned channels to use, overriding "autoChannels" + * (map from band to channel) + */ + public Map userChannels; + + /** + * The list of allowed tx powers, or null to allow all + * (map from band to tx power) + */ + public Map> allowedTxPowers; + + /** The RRM-assigned tx powers to use (map from radio to tx power) */ + public Map autoTxPowers; + + /** The user-assigned tx powers to use, overriding "autoTxPowers" */ + public Map userTxPowers; + + /** Create the default config. */ + public static DeviceConfig createDefault() { + DeviceConfig config = new DeviceConfig(); + + config.enableRRM = true; + config.enableConfig = true; + config.enableWifiScan = true; + config.boundary = null; + config.location = null; + config.allowedChannels = null; + config.allowedChannelWidths = null; + config.autoChannels = null; + config.userChannels = null; + config.allowedTxPowers = null; + config.autoTxPowers = null; + config.userTxPowers = null; + + return config; + } + + /** Return true if all public instance fields are null (via reflection). */ + @Hidden /* prevent Jackson object mapper from generating "empty" property */ + public boolean isEmpty() { + for (Field field : getClass().getFields()) { + try { + if (field.get(this) != null) { + return false; + } + } catch (IllegalArgumentException | IllegalAccessException e) { + continue; + } + } + return true; + } + + /** + * Merge all non-null public fields of the given object into this object + * (via reflection). + */ + public void apply(DeviceConfig override) { + if (override == null) { + return; + } + for (Field field : getClass().getFields()) { + try { + Object overrideVal = field.get(override); + if (overrideVal != null) { + field.set(this, overrideVal); + } + } catch (IllegalArgumentException | IllegalAccessException e) { + continue; + } + } + } +} diff --git a/src/main/java/com/facebook/openwifirrm/DeviceDataManager.java b/src/main/java/com/facebook/openwifirrm/DeviceDataManager.java new file mode 100644 index 0000000..812ff3a --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/DeviceDataManager.java @@ -0,0 +1,536 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * Device topology and config manager. + */ +public class DeviceDataManager { + private static final Logger logger = LoggerFactory.getLogger(DeviceDataManager.class); + + /** The Gson instance. */ + private final Gson gson = new Gson(); + + /** The device topology file. */ + private final File topologyFile; + + /** The layered device config file. */ + private final File deviceLayeredConfigFile; + + /** Lock on {@link #topology}. */ + private final ReadWriteLock topologyLock = new ReentrantReadWriteLock(); + + /** Lock on {@link #deviceLayeredConfig}. */ + private final ReadWriteLock deviceLayeredConfigLock = + new ReentrantReadWriteLock(); + + /** The current device topology. */ + private DeviceTopology topology; + + /** The current layered device config. */ + private DeviceLayeredConfig deviceLayeredConfig; + + /** The cached device configs (map of serial number to computed config). */ + private Map cachedDeviceConfigs = + new ConcurrentHashMap<>(); + + /** Empty constructor without backing files (ex. for unit tests). */ + public DeviceDataManager() { + this.topologyFile = null; + this.deviceLayeredConfigFile = null; + + this.topology = new DeviceTopology(); + this.deviceLayeredConfig = new DeviceLayeredConfig(); + } + + /** + * Initialize from the given files. + * @param topologyFile the {@link DeviceTopology} file + * @param deviceLayeredConfigFile the {@link DeviceLayeredConfig} file + * @throws IOException + */ + public DeviceDataManager(File topologyFile, File deviceLayeredConfigFile) + throws IOException { + this.topologyFile = topologyFile; + this.deviceLayeredConfigFile = deviceLayeredConfigFile; + + // TODO: should we catch exceptions when reading files? + this.topology = readTopology(topologyFile); + this.deviceLayeredConfig = + readDeviceLayeredConfig(deviceLayeredConfigFile); + } + + /** + * Read the input device topology file. + * + * If the file does not exist, try to create it. + * + * @throws IOException + */ + private DeviceTopology readTopology(File topologyFile) throws IOException { + DeviceTopology topo; + if (!topologyFile.isFile()) { + // No file, write defaults to disk + logger.info( + "Topology file '{}' does not exist, creating it...", + topologyFile.getPath() + ); + topo = new DeviceTopology(); + Utils.writeJsonFile(topologyFile, topo); + } else { + // Read file + logger.info("Reading topology file '{}'", topologyFile.getPath()); + String contents = Utils.readFile(topologyFile); + topo = gson.fromJson(contents, DeviceTopology.class); + } + validateTopology(topo); + return topo; + } + + /** + * Read the input device topology file. + * + * If the file does not exist, try to create it. + * + * @throws IOException + */ + private DeviceLayeredConfig readDeviceLayeredConfig(File deviceConfigFile) + throws IOException { + DeviceLayeredConfig cfg; + if (!deviceConfigFile.isFile()) { + // No file, write defaults to disk + logger.info( + "Device config file '{}' does not exist, creating it...", + deviceConfigFile.getPath() + ); + cfg = new DeviceLayeredConfig(); + Utils.writeJsonFile(deviceConfigFile, cfg); + } else { + // Read file + logger.info( + "Reading device config file '{}'", deviceConfigFile.getPath() + ); + String contents = Utils.readFile(deviceConfigFile); + cfg = gson.fromJson(contents, DeviceLayeredConfig.class); + + // Sanitize config (NOTE: topology must be initialized!) + boolean modified = sanitizeDeviceLayeredConfig(cfg); + if (modified) { + Utils.writeJsonFile(deviceConfigFile, cfg); + } + } + return cfg; + } + + /** Write the current topology to the current file (if any). */ + private void saveTopology() { + if (topologyFile != null) { + Lock l = topologyLock.readLock(); + l.lock(); + try { + Utils.writeJsonFile(topologyFile, topology); + } catch (FileNotFoundException e) { + // Callers won't be able to deal with this, so just use an + // unchecked exception to save code + throw new RuntimeException(e); + } finally { + l.unlock(); + } + } + } + + /** Write the current device layered config to the current file (if any). */ + private void saveDeviceLayeredConfig() { + if (deviceLayeredConfigFile != null) { + Lock l = deviceLayeredConfigLock.readLock(); + l.lock(); + try { + Utils.writeJsonFile( + deviceLayeredConfigFile, deviceLayeredConfig + ); + } catch (FileNotFoundException e) { + // Callers won't be able to deal with this, so just use an + // unchecked exception to save code + throw new RuntimeException(e); + } finally { + l.unlock(); + } + } + } + + /** Validate the topology, throwing IllegalArgumentException upon error. */ + private void validateTopology(DeviceTopology topo) { + if (topo == null) { + throw new NullPointerException(); + } + + Map deviceToZone = new HashMap<>(); + for (Map.Entry> entry : topo.entrySet()) { + String zone = entry.getKey(); + if (zone.isEmpty()) { + throw new IllegalArgumentException( + "Empty zone name in topology" + ); + } + for (String serialNumber : entry.getValue()) { + if (serialNumber.isEmpty()) { + throw new IllegalArgumentException( + String.format("Empty serial number in zone '%s'", zone) + ); + } + String existingZone = deviceToZone.get(serialNumber); + if (existingZone != null) { + throw new IllegalArgumentException( + String.format( + "Device '%s' in multiple zones ('%s', '%s')", + serialNumber, existingZone, zone + ) + ); + } + deviceToZone.put(serialNumber, zone); + } + } + } + + /** + * Sanitized the device layered config, ex. removing empty entries or + * unknown APs/zones. + * + * Returns true if the input was modified. + */ + private boolean sanitizeDeviceLayeredConfig(DeviceLayeredConfig cfg) { + if (cfg == null) { + throw new NullPointerException(); + } + + boolean modified = false; + + if (cfg.networkConfig == null) { + cfg.networkConfig = new DeviceConfig(); + modified = true; + } + if (cfg.zoneConfig == null) { + cfg.zoneConfig = new TreeMap<>(); + modified = true; + } else { + modified |= cfg.zoneConfig.entrySet().removeIf(entry -> + !isZoneInTopology(entry.getKey()) /* zone doesn't exist */ || + entry.getValue().isEmpty() /* config object is empty */ + ); + } + if (cfg.apConfig == null) { + cfg.apConfig = new TreeMap<>(); + modified = true; + } else { + modified |= cfg.apConfig.entrySet().removeIf(entry -> + !isDeviceInTopology(entry.getKey()) /* AP doesn't exist */ || + entry.getValue().isEmpty() /* config object is empty */ + ); + } + + return modified; + } + + + /** Set the topology. May throw unchecked exceptions upon error. */ + public void setTopology(DeviceTopology topo) { + validateTopology(topo); + + Lock l = topologyLock.writeLock(); + l.lock(); + try { + this.topology = topo; + } finally { + l.unlock(); + } + saveTopology(); + + // Sanitize device layered config in case devices/zones changed + boolean modified = sanitizeDeviceLayeredConfig(deviceLayeredConfig); + if (modified) { + saveDeviceLayeredConfig(); + } + + // Clear cached device configs + cachedDeviceConfigs.clear(); + } + + /** Return the topology as a JSON string. */ + public String getTopologyJson() { + Lock l = topologyLock.readLock(); + l.lock(); + try { + return gson.toJson(topology); + } finally { + l.unlock(); + } + } + + /** Return the RF zone for the given device, or null if not found. */ + public String getDeviceZone(String serialNumber) { + if (serialNumber == null || serialNumber.isEmpty()) { + return null; + } + Lock l = topologyLock.readLock(); + l.lock(); + try { + for (Map.Entry> e : topology.entrySet()) { + if (e.getValue().contains(serialNumber)) { + return e.getKey(); + } + } + return null; + } finally { + l.unlock(); + } + } + + /** Return true if the given device is present in the topology. */ + public boolean isDeviceInTopology(String serialNumber) { + return getDeviceZone(serialNumber) != null; + } + + /** Return true if the given RF zone is present in the topology. */ + public boolean isZoneInTopology(String zone) { + if (zone == null || zone.isEmpty()) { + return false; + } + Lock l = topologyLock.readLock(); + l.lock(); + try { + return topology.containsKey(zone); + } finally { + l.unlock(); + } + } + + /** + * Set the device layered config. May throw unchecked exceptions upon error. + */ + public void setDeviceLayeredConfig(DeviceLayeredConfig cfg) { + sanitizeDeviceLayeredConfig(cfg); + + Lock l = deviceLayeredConfigLock.writeLock(); + l.lock(); + try { + this.deviceLayeredConfig = cfg; + } finally { + l.unlock(); + } + saveDeviceLayeredConfig(); + + // Clear cached device configs + cachedDeviceConfigs.clear(); + } + + /** Return the device config layers as a JSON string. */ + public String getDeviceLayeredConfigJson() { + Lock l = deviceLayeredConfigLock.readLock(); + l.lock(); + try { + return gson.toJson(deviceLayeredConfig); + } finally { + l.unlock(); + } + } + + /** + * Compute config for the given device by applying all config layers, or + * return null if not present in the topology. + */ + private DeviceConfig computeDeviceConfig(String serialNumber) { + return computeDeviceConfig(serialNumber, getDeviceZone(serialNumber)); + } + + /** + * Compute config for the given device by applying all config layers, or + * return null if not present in the topology. + */ + private DeviceConfig computeDeviceConfig(String serialNumber, String zone) { + if (zone == null) { + return null; + } + + Lock l = deviceLayeredConfigLock.readLock(); + l.lock(); + try { + DeviceConfig config = DeviceConfig.createDefault(); + config.apply(deviceLayeredConfig.networkConfig); + if (zone != null) { + config.apply(deviceLayeredConfig.zoneConfig.get(zone)); + } + config.apply(deviceLayeredConfig.apConfig.get(serialNumber)); + return config; + } finally { + l.unlock(); + } + } + + /** + * Return config for the given device with all config layers applied, or + * null if not present in the topology. + */ + public DeviceConfig getDeviceConfig(String serialNumber) { + if (serialNumber == null) { + throw new IllegalArgumentException("Null serialNumber"); + } + return cachedDeviceConfigs.computeIfAbsent( + serialNumber, k -> computeDeviceConfig(k) + ); + } + + /** + * Return config for the given device with all config layers applied. + * + * This method will not check if the device is present in the topology, and + * uses the supplied zone directly. + */ + public DeviceConfig getDeviceConfig(String serialNumber, String zone) { + if (serialNumber == null) { + throw new IllegalArgumentException("Null serialNumber"); + } + if (zone == null) { + throw new IllegalArgumentException("Null zone"); + } + return cachedDeviceConfigs.computeIfAbsent( + serialNumber, k -> computeDeviceConfig(k, zone) + ); + } + + /** + * Return config (with all config layers applied) for all devices in a given + * zone, or null if not present in the topology. + * @return map of serial number to computed config + */ + public Map getAllDeviceConfigs(String zone) { + // Get all devices in zone + if (zone == null || zone.isEmpty()) { + return null; + } + Set devicesInZone; + Lock l = topologyLock.readLock(); + l.lock(); + try { + devicesInZone = topology.get(zone); + } finally { + l.unlock(); + } + if (devicesInZone == null) { + return null; + } + + // Compute config for all devices + Map configMap = new HashMap<>(); + for (String serialNumber : devicesInZone) { + configMap.put(serialNumber, getDeviceConfig(serialNumber, zone)); + } + return configMap; + } + + /** Set the device network config. */ + public void setDeviceNetworkConfig(DeviceConfig networkConfig) { + Lock l = deviceLayeredConfigLock.writeLock(); + l.lock(); + try { + deviceLayeredConfig.networkConfig = networkConfig; + sanitizeDeviceLayeredConfig(deviceLayeredConfig); + } finally { + l.unlock(); + } + cachedDeviceConfigs.clear(); + saveDeviceLayeredConfig(); + } + + /** Set the device zone config for the given zone, or erase it if null. */ + public void setDeviceZoneConfig(String zone, DeviceConfig zoneConfig) { + if (zone == null || zone.isEmpty()) { + throw new IllegalArgumentException("Empty zone"); + } + if (zoneConfig != null && !isZoneInTopology(zone)) { + throw new IllegalArgumentException("Unknown zone"); + } + + Lock l = deviceLayeredConfigLock.writeLock(); + l.lock(); + try { + if (zoneConfig != null) { + deviceLayeredConfig.zoneConfig.put(zone, zoneConfig); + } else { + deviceLayeredConfig.zoneConfig.remove(zone); + } + sanitizeDeviceLayeredConfig(deviceLayeredConfig); + } finally { + l.unlock(); + } + cachedDeviceConfigs.clear(); + saveDeviceLayeredConfig(); + } + + /** Set the device AP config for the given AP, or erase it if null. */ + public void setDeviceApConfig(String serialNumber, DeviceConfig apConfig) { + if (serialNumber == null || serialNumber.isEmpty()) { + throw new IllegalArgumentException("Empty serialNumber"); + } + if (apConfig != null && !isDeviceInTopology(serialNumber)) { + throw new IllegalArgumentException("Unknown serialNumber"); + } + + Lock l = deviceLayeredConfigLock.writeLock(); + l.lock(); + try { + if (apConfig != null) { + deviceLayeredConfig.apConfig.put(serialNumber, apConfig); + } else { + deviceLayeredConfig.apConfig.remove(serialNumber); + } + sanitizeDeviceLayeredConfig(deviceLayeredConfig); + } finally { + l.unlock(); + } + cachedDeviceConfigs.remove(serialNumber); + saveDeviceLayeredConfig(); + } + + /** Device AP config layer update interface. */ + public interface DeviceApConfigFunction { + /** Update the AP config layer. */ + void update(Map apConfig); + } + + /** Apply updates to the device AP config layer (under a write lock). */ + public void updateDeviceApConfig(DeviceApConfigFunction fn) { + Lock l = deviceLayeredConfigLock.writeLock(); + l.lock(); + try { + fn.update(deviceLayeredConfig.apConfig); + sanitizeDeviceLayeredConfig(deviceLayeredConfig); + } finally { + l.unlock(); + } + cachedDeviceConfigs.clear(); + saveDeviceLayeredConfig(); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/DeviceLayeredConfig.java b/src/main/java/com/facebook/openwifirrm/DeviceLayeredConfig.java new file mode 100644 index 0000000..5ea58c6 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/DeviceLayeredConfig.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm; + +import java.util.Map; +import java.util.TreeMap; + +/** + * Layered AP configuration model. + */ +public class DeviceLayeredConfig { + /** Config per AP (by serial number) - highest priority */ + public Map apConfig = new TreeMap<>(); + + /** Config per "RF zone" - mid priority */ + public Map zoneConfig = new TreeMap<>(); + + /** Config for all APs/zones - lowest priority */ + public DeviceConfig networkConfig = new DeviceConfig(); +} diff --git a/src/main/java/com/facebook/openwifirrm/DeviceTopology.java b/src/main/java/com/facebook/openwifirrm/DeviceTopology.java new file mode 100644 index 0000000..f5eec9b --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/DeviceTopology.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm; + +import java.util.Set; +import java.util.TreeMap; + +import io.swagger.v3.oas.annotations.Hidden; + +/** + * AP topology model, mapping from "RF zone" name to a set of APs by serial + * number. + */ +public class DeviceTopology extends TreeMap> { + private static final long serialVersionUID = -1636132862513920700L; + + @Hidden /* prevent Jackson object mapper from generating "empty" property */ + @Override public boolean isEmpty() { return super.isEmpty(); } +} diff --git a/src/main/java/com/facebook/openwifirrm/Launcher.java b/src/main/java/com/facebook/openwifirrm/Launcher.java new file mode 100644 index 0000000..056a8ff --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/Launcher.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; +import java.util.concurrent.Callable; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.mysql.DatabaseManager; +import com.facebook.openwifirrm.ucentral.UCentralClient; +import com.facebook.openwifirrm.ucentral.UCentralKafkaConsumer; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +/** + * Launcher CLI. + */ +@Command( + name = "", + version = "1", + descriptionHeading = "%n", + description = "OpenWiFi uCentral-based radio resource management service.", + optionListHeading = "%nOptions:%n", + commandListHeading = "%nCommands:%n", + mixinStandardHelpOptions = true, + showDefaultValues = true +) +public class Launcher implements Callable { + private static final Logger logger = LoggerFactory.getLogger(Launcher.class); + + /** Default config file location. */ + private static final File DEFAULT_CONFIG_FILE = new File("settings.json"); + + /** Default device topology file location. */ + private static final File DEFAULT_TOPOLOGY_FILE = new File("topology.json"); + + /** Default device layered config file location. */ + private static final File DEFAULT_DEVICE_LAYERED_CONFIG_FILE = + new File("device_config.json"); + + /** + * Read the input RRM config file. + * + * If the file does not exist, try to create it using default values. + * + * If the file is missing any default fields in {@link RRMConfig}, try to + * rewrite it. + * + * @throws IOException + */ + private RRMConfig readRRMConfig(File configFile) throws IOException { + RRMConfig config; + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + if (!configFile.isFile()) { + // No file, write defaults to disk + logger.info( + "Config file '{}' does not exist, creating it...", + configFile.getPath() + ); + config = new RRMConfig(); + Utils.writeJsonFile(configFile, config); + } else { + // Read file + logger.info("Reading config file '{}'", configFile.getPath()); + String contents = Utils.readFile(configFile); + JSONObject userConfig = new JSONObject(contents); + + // In case of any added/missing values, we want to build off the + // defaults in RRMConfig, so this code gets more complex... + JSONObject fullConfig = + new JSONObject(gson.toJson(new RRMConfig())); + Utils.jsonMerge(fullConfig, userConfig); + config = gson.fromJson(fullConfig.toString(), RRMConfig.class); + + // Compare merged config with contents as read from disk + // If any differences (ex. added fields), overwrite config file + if (!fullConfig.toString().equals(userConfig.toString())) { + logger.info("Rewriting config file with new changes..."); + try (Writer writer = new FileWriter(configFile)) { + gson.toJson(config, writer); + } + } + } + return config; + } + + @Command( + name = "run", + description = "Run the RRM service.", + mixinStandardHelpOptions = true + ) + private Integer run( + @Option( + names = { "-c", "--config-file" }, + paramLabel = "", + description = "RRM config file" + ) + File configFile, + + @Option( + names = { "-t", "--topology-file" }, + paramLabel = "", + description = "Device topology file" + ) + File topologyFile, + + @Option( + names = { "-d", "--device-config-file" }, + paramLabel = "", + description = "Device layered config file" + ) + File deviceLayeredConfigFile + ) throws Exception { + // Read local files + RRMConfig config = readRRMConfig( + configFile != null ? configFile : DEFAULT_CONFIG_FILE + ); + DeviceDataManager deviceDataManager = new DeviceDataManager( + topologyFile != null ? topologyFile : DEFAULT_TOPOLOGY_FILE, + deviceLayeredConfigFile != null + ? deviceLayeredConfigFile + : DEFAULT_DEVICE_LAYERED_CONFIG_FILE + ); + + // Instantiate clients + UCentralClient client = new UCentralClient( + config.uCentralConfig.user, + config.uCentralConfig.password, + config.uCentralConfig.uCentralSecHost, + config.uCentralConfig.uCentralSecPort, + config.uCentralConfig.uCentralSocketParams + ); + UCentralKafkaConsumer consumer; + if (config.kafkaConfig.bootstrapServer.isEmpty()) { + logger.info("Kafka consumer is disabled."); + consumer = null; + } else { + consumer = new UCentralKafkaConsumer( + config.kafkaConfig.bootstrapServer, + config.kafkaConfig.groupId, + config.kafkaConfig.autoOffsetReset, + config.kafkaConfig.stateTopic, + config.kafkaConfig.wifiScanTopic + ); + } + DatabaseManager dbManager; + if (config.databaseConfig.server.isEmpty()) { + logger.info("Database manager is disabled."); + dbManager = null; + } else { + dbManager = new DatabaseManager( + config.databaseConfig.server, + config.databaseConfig.user, + config.databaseConfig.password, + config.databaseConfig.dbName, + config.databaseConfig.dataRetentionIntervalDays + ); + dbManager.init(); + } + + // Start RRM service + RRM rrm = new RRM(); + boolean success = rrm.start( + config, deviceDataManager, client, consumer, dbManager + ); + if (dbManager != null) { + dbManager.close(); + } + return success ? 0 : 1; + } + + @Command( + name = "generate-rrm-config", + description = "Generate the RRM config file.", + mixinStandardHelpOptions = true + ) + public Integer generateRRMConfig( + @Option( + names = { "-c", "--config-file" }, + paramLabel = "", + description = "RRM config file" + ) + File configFile + ) throws Exception { + if (configFile == null) { + configFile = DEFAULT_CONFIG_FILE; + } + if (configFile.exists()) { + logger.error( + "File '{}' already exists, not overwriting...", + configFile.getPath() + ); + return 1; + } else { + logger.info("Writing config file to '{}'", configFile.getPath()); + Utils.writeJsonFile(configFile, new RRMConfig()); + return 0; + } + } + + @Command( + name = "show-default-device-config", + description = "Print the default device config.", + mixinStandardHelpOptions = true + ) + public Integer showDefaultDeviceConfig() throws Exception { + Gson gson = new GsonBuilder() + .setPrettyPrinting() + .serializeNulls() // for here only!! + .create(); + logger.info(gson.toJson(DeviceConfig.createDefault())); + return 0; + } + + @Override + public Integer call() { + CommandLine.usage(this, System.out); + return 1; + } + + /** Main method. */ + public static void main(String[] args) throws Exception { + int exitCode = new CommandLine(new Launcher()).execute(args); + System.exit(exitCode); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/RRM.java b/src/main/java/com/facebook/openwifirrm/RRM.java new file mode 100644 index 0000000..100bf1f --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/RRM.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.modules.ApiServer; +import com.facebook.openwifirrm.modules.ConfigManager; +import com.facebook.openwifirrm.modules.DataCollector; +import com.facebook.openwifirrm.modules.Modeler; +import com.facebook.openwifirrm.mysql.DatabaseManager; +import com.facebook.openwifirrm.ucentral.KafkaConsumerRunner; +import com.facebook.openwifirrm.ucentral.UCentralClient; +import com.facebook.openwifirrm.ucentral.UCentralKafkaConsumer; +import com.facebook.openwifirrm.ucentral.gw.models.SystemInfoResults; + +/** + * RRM service runner. + */ +public class RRM { + private static final Logger logger = LoggerFactory.getLogger(RRM.class); + + /** The executor service instance. */ + private final ExecutorService executor = Executors.newCachedThreadPool(); + + /** + * Wrap a {@code Runnable} as a {@code Callable}. + * + * This is similar to {@link Executors#callable(Runnable)} but will log any + * exceptions thrown and then call {@link System#exit(int)}. + */ + private static Callable wrapRunnable(Runnable task) { + return () -> { + try { + task.run(); + } catch (Exception e) { + logger.error("Exception raised in task!", e); + System.exit(1); + } + return null; + }; + } + + /** Start the RRM service. */ + public boolean start( + RRMConfig config, + DeviceDataManager deviceDataManager, + UCentralClient client, + UCentralKafkaConsumer consumer, + DatabaseManager dbManager + ) { + // uCentral login + if (!client.login()) { + logger.error("uCentral login failed! Terminating..."); + return false; + } + // Check that uCentralGw is actually alive + SystemInfoResults systemInfo = client.getSystemInfo(); + if (systemInfo == null) { + logger.error( + "Failed to fetch uCentralGw system info. Terminating..." + ); + return false; + } + logger.info("uCentralGw version: {}", systemInfo.version); + + // Instantiate modules + ConfigManager configManager = new ConfigManager( + config.moduleConfig.configManagerParams, deviceDataManager, client + ); + DataCollector dataCollector = new DataCollector( + config.moduleConfig.dataCollectorParams, + deviceDataManager, + client, + consumer, + configManager, + dbManager + ); + Modeler modeler = new Modeler( + config.moduleConfig.modelerParams, + deviceDataManager, + consumer, + client, + dataCollector, + configManager + ); + ApiServer apiServer = new ApiServer( + config.moduleConfig.apiServerParams, + deviceDataManager, + configManager, + modeler + ); + KafkaConsumerRunner consumerRunner = + (consumer == null) ? null : new KafkaConsumerRunner(consumer); + + // Add shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + logger.debug("Running shutdown hook..."); + if (consumerRunner != null) { + consumerRunner.shutdown(); + } + apiServer.shutdown(); + dataCollector.shutdown(); + executor.shutdownNow(); + })); + + // Submit jobs + List> services = Arrays + .asList( + configManager, dataCollector, modeler, apiServer, consumerRunner + ) + .stream() + .filter(o -> o != null) + .map(RRM::wrapRunnable) + .collect(Collectors.toList()); + try { + executor.invokeAll(services); + } catch (InterruptedException e) { + logger.info("Execution interrupted!", e); + return true; + } + + // All jobs crashed? + return false; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/RRMConfig.java b/src/main/java/com/facebook/openwifirrm/RRMConfig.java new file mode 100644 index 0000000..777d06b --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/RRMConfig.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm; + +/** + * RRM service configuration model. + */ +public class RRMConfig { + /** + * uCentral configuration. + */ + public class UCentralConfig { + /** uCentral user */ + public String user = "tip@ucentral.com"; + + /** uCentral password */ + public String password = "openwifi"; + + /** uCentralSec host */ + public String uCentralSecHost = "127.0.0.1"; + + /** uCentralSec port */ + public int uCentralSecPort = 16001; + + /** + * uCentral socket parameters + */ + public class UCentralSocketParams { + /** Connection timeout for all requests, in ms */ + public int connectTimeoutMs = 2000; + + /** Socket timeout for all requests, in ms */ + public int socketTimeoutMs = 15000; + + /** Socket timeout for wifi scan requests, in ms */ + public int wifiScanTimeoutMs = 45000; + } + + /** uCentral socket parameters */ + public UCentralSocketParams uCentralSocketParams = + new UCentralSocketParams(); + } + + /** uCentral configuration. */ + public UCentralConfig uCentralConfig = new UCentralConfig(); + + /** + * uCentral Kafka configuration. + */ + public class KafkaConfig { + /** Kafka bootstrap host:port, or empty to disable */ + public String bootstrapServer = "127.0.0.1:9093"; + + /** Kafka topic holding uCentral state */ + public String stateTopic = "state"; + + /** Kafka topic holding uCentral wifi scan results */ + public String wifiScanTopic = "wifiscan"; + + /** Kafka consumer group ID */ + public String groupId = "rrm-service"; + + /** Kafka "auto.offset.reset" config ["earliest", "latest"] */ + public String autoOffsetReset = "latest"; + } + + /** uCentral Kafka configuration. */ + public KafkaConfig kafkaConfig = new KafkaConfig(); + + /** + * Database configuration. + */ + public class DatabaseConfig { + /** MySQL database host:port, or empty to disable */ + public String server = "127.0.0.1:3306"; + + /** MySQL database user */ + public String user = "root"; + + /** MySQL database password */ + public String password = "openwifi"; + + /** MySQL database name */ + public String dbName = "rrm"; + + /** Data retention interval in days (0 to disable) */ + public int dataRetentionIntervalDays = 14; + } + + /** Database configuration. */ + public DatabaseConfig databaseConfig = new DatabaseConfig(); + + /** + * Module configuration. + */ + public class ModuleConfig { + /** + * DataCollector parameters. + */ + public class DataCollectorParams { + /** The main logic loop interval (i.e. sleep time), in ms. */ + public int updateIntervalMs = 5000; + + /** The expected device statistics interval, in seconds. */ + public int deviceStatsIntervalSec = 60; + + /** + * The wifi scan interval (per device), in seconds (or -1 to disable + * automatic scans). + */ + public int wifiScanIntervalSec = 60; + + /** The capabilities request interval (per device), in seconds */ + public int capabilitiesIntervalSec = 3600; + + /** Number of executor threads for async tasks (ex. wifi scans). */ + public int executorThreadCount = 3; + } + + /** DataCollector parameters. */ + public DataCollectorParams dataCollectorParams = + new DataCollectorParams(); + + /** + * ConfigManager parameters. + */ + public class ConfigManagerParams { + /** The main logic loop interval (i.e. sleep time), in ms. */ + public int updateIntervalMs = 60000; + + /** Enable pushing device config changes? */ + public boolean configEnabled = true; + + /** + * The debounce interval for reconfiguring the same device, in + * seconds. + */ + public int configDebounceIntervalSec = 30; + } + + /** ConfigManager parameters. */ + public ConfigManagerParams configManagerParams = + new ConfigManagerParams(); + + /** + * Modeler parameters. + */ + public class ModelerParams { + /** Maximum rounds of wifi scan results to store per device. */ + public int wifiScanBufferSize = 10; + } + + /** Modeler parameters. */ + public ModelerParams modelerParams = new ModelerParams(); + + /** + * ApiServer parameters. + */ + public class ApiServerParams { + /** The HTTP port to listen on, or -1 to disable. */ + public int httpPort = 16789; + + /** Enable HTTP basic auth? */ + public boolean useBasicAuth = true; + + /** The HTTP basic auth username (if enabled). */ + public String basicAuthUser = "admin"; + + /** The HTTP basic auth password (if enabled). */ + public String basicAuthPassword = "openwifi"; + } + + /** ApiServer parameters. */ + public ApiServerParams apiServerParams = new ApiServerParams(); + } + + /** Module configuration. */ + public ModuleConfig moduleConfig = new ModuleConfig(); +} diff --git a/src/main/java/com/facebook/openwifirrm/Utils.java b/src/main/java/com/facebook/openwifirrm/Utils.java new file mode 100644 index 0000000..2fcc28c --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/Utils.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import org.json.JSONObject; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * Generic utility methods. + */ +public class Utils { + /** Hex value array for use in {@link #longToMac(long)}. */ + private static final char[] HEX_VALUES = "0123456789abcdef".toCharArray(); + + // This class should not be instantiated. + private Utils() {} + + /** Read a file to a UTF-8 string. */ + public static String readFile(File f) throws IOException { + byte[] b = Files.readAllBytes(f.toPath()); + return new String(b, StandardCharsets.UTF_8); + } + + /** Write a string to a file. */ + public static void writeFile(File f, String s) + throws FileNotFoundException { + try (PrintStream out = new PrintStream(new FileOutputStream(f))) { + out.println(s); + } + } + + /** Write an object to a file as pretty-printed JSON. */ + public static void writeJsonFile(File f, Object o) + throws FileNotFoundException { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + writeFile(f, gson.toJson(o)); + } + + /** Recursively merge JSONObject 'b' into 'a'. */ + public static void jsonMerge(JSONObject a, JSONObject b) { + for (String k : b.keySet()) { + Object aVal = a.has(k) ? a.get(k) : null; + Object bVal = b.get(k); + if (aVal instanceof JSONObject && bVal instanceof JSONObject) { + jsonMerge((JSONObject) aVal, (JSONObject) bVal); + } else { + a.put(k, bVal); + } + } + } + + /** + * Convert a MAC address to an integer (6-byte) representation. + * + * If the MAC address could not be parsed, throws IllegalArgumentException. + */ + public static long macToLong(String addr) throws IllegalArgumentException { + String s = addr.replace("-", "").replace(":", "").replace(".", ""); + if (s.length() != 12) { + throw new IllegalArgumentException("Invalid MAC address format"); + } + try { + return Long.parseLong(s, 16); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Convert a MAC address in integer (6-byte) representation to string + * notation. + */ + public static String longToMac(long addr) { + char[] c = new char[17]; + c[0] = HEX_VALUES[(byte)((addr >> 44) & 0xf)]; + c[1] = HEX_VALUES[(byte)((addr >> 40) & 0xf)]; + c[2] = ':'; + c[3] = HEX_VALUES[(byte)((addr >> 36) & 0xf)]; + c[4] = HEX_VALUES[(byte)((addr >> 32) & 0xf)]; + c[5] = ':'; + c[6] = HEX_VALUES[(byte)((addr >> 28) & 0xf)]; + c[7] = HEX_VALUES[(byte)((addr >> 24) & 0xf)]; + c[8] = ':'; + c[9] = HEX_VALUES[(byte)((addr >> 20) & 0xf)]; + c[10] = HEX_VALUES[(byte)((addr >> 16) & 0xf)]; + c[11] = ':'; + c[12] = HEX_VALUES[(byte)((addr >> 12) & 0xf)]; + c[13] = HEX_VALUES[(byte)((addr >> 8) & 0xf)]; + c[14] = ':'; + c[15] = HEX_VALUES[(byte)((addr >> 4) & 0xf)]; + c[16] = HEX_VALUES[(byte)(addr & 0xf)]; + return new String(c); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/modules/ApiServer.java b/src/main/java/com/facebook/openwifirrm/modules/ApiServer.java new file mode 100644 index 0000000..8c8f7e8 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/modules/ApiServer.java @@ -0,0 +1,964 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.modules; + +import java.util.Arrays; +import java.util.Base64; +import java.util.Map; +import java.util.Set; + +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import org.reflections.Reflections; +import org.reflections.util.ConfigurationBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceConfig; +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.DeviceLayeredConfig; +import com.facebook.openwifirrm.DeviceTopology; +import com.facebook.openwifirrm.RRMConfig.ModuleConfig.ApiServerParams; +import com.facebook.openwifirrm.optimizers.ChannelOptimizer; +import com.facebook.openwifirrm.optimizers.LeastUsedChannelOptimizer; +import com.facebook.openwifirrm.optimizers.LocationBasedOptimalTPC; +import com.facebook.openwifirrm.optimizers.MeasurementBasedApApTPC; +import com.facebook.openwifirrm.optimizers.MeasurementBasedApClientTPC; +import com.facebook.openwifirrm.optimizers.RandomChannelInitializer; +import com.facebook.openwifirrm.optimizers.RandomTxPowerInitializer; +import com.facebook.openwifirrm.optimizers.TPC; +import com.facebook.openwifirrm.optimizers.UnmanagedApAwareChannelOptimizer; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.jaxrs2.Reader; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.models.OpenAPI; +import spark.Request; +import spark.Response; +import spark.Route; +import spark.Spark; + +/** + * HTTP API server. + */ +@OpenAPIDefinition( + info = @Info( + title = "OpenWiFi 2.0 RRM OpenAPI", + version = "1.0.0", + description = "This document describes the API for the Radio Resource Management service." + ), + tags = { + @Tag(name = "Config"), + @Tag(name = "Optimization"), + } +) +public class ApiServer implements Runnable { + private static final Logger logger = LoggerFactory.getLogger(ApiServer.class); + + /** The module parameters. */ + private final ApiServerParams params; + + /** The device data manager. */ + private final DeviceDataManager deviceDataManager; + + /** The ConfigManager module instance. */ + private final ConfigManager configManager; + + /** The Modeler module instance. */ + private final Modeler modeler; + + /** The Gson instance. */ + private final Gson gson = new Gson(); + + /** The cached OpenAPI instance. */ + private OpenAPI openApi; + + /** Constructor. */ + public ApiServer( + ApiServerParams params, + DeviceDataManager deviceDataManager, + ConfigManager configManager, + Modeler modeler + ) { + this.params = params; + this.deviceDataManager = deviceDataManager; + this.configManager = configManager; + this.modeler = modeler; + } + + @Override + public void run() { + if (params.httpPort == -1) { + logger.info("API server is disabled."); + return; + } + + Spark.port(params.httpPort); + + // Configure API docs hosting + Spark.staticFiles.location("/public"); + Spark.get("/openapi.yaml", this::getOpenApiYaml); + Spark.get("/openapi.json", this::getOpenApiJson); + + // Install routes + Spark.before(this::beforeFilter); + Spark.after(this::afterFilter); + Spark.get("/api/v1/getTopology", new GetTopologyEndpoint()); + Spark.post("/api/v1/setTopology", new SetTopologyEndpoint()); + Spark.get( + "/api/v1/getDeviceLayeredConfig", + new GetDeviceLayeredConfigEndpoint() + ); + Spark.get("/api/v1/getDeviceConfig", new GetDeviceConfigEndpoint()); + Spark.post( + "/api/v1/setDeviceNetworkConfig", + new SetDeviceNetworkConfigEndpoint() + ); + Spark.post( + "/api/v1/setDeviceZoneConfig", new SetDeviceZoneConfigEndpoint() + ); + Spark.post( + "/api/v1/setDeviceApConfig", new SetDeviceApConfigEndpoint() + ); + Spark.post( + "/api/v1/modifyDeviceApConfig", new ModifyDeviceApConfigEndpoint() + ); + Spark.get("/api/v1/currentModel", new GetCurrentModelEndpoint()); + Spark.get("/api/v1/optimizeChannel", new OptimizeChannelEndpoint()); + Spark.get("/api/v1/optimizeTxPower", new OptimizeTxPowerEndpoint()); + + logger.info("API server listening on HTTP port {}", params.httpPort); + if (params.useBasicAuth) { + logger.info("HTTP basic auth is enabled."); + } + } + + /** Stop the server. */ + public void shutdown() { + Spark.stop(); + } + + /** Reconstructs a URL. */ + private String getFullUrl(String path, String queryString) { + return (queryString == null) + ? path + : String.format("%s?%s", path, queryString); + } + + /** + * Perform HTTP basic authentication given an expected user/password. + * + * If authentication passes, do nothing and return true. Otherwise, send an + * HTTP 401 response with a "WWW-Authenticate" header and return false. + */ + private boolean performHttpBasicAuth( + Request request, Response response, String user, String password + ) { + // Extract header: + // Authorization: Basic :)> + final String AUTH_PREFIX = "Basic "; + String authHeader = request.headers("Authorization"); + if (authHeader != null && authHeader.startsWith(AUTH_PREFIX)) { + String contents = authHeader.substring(AUTH_PREFIX.length()); + String creds = new String(Base64.getDecoder().decode(contents)); + int splitIdx = creds.indexOf(':'); + if (splitIdx != -1) { + String u = creds.substring(0, splitIdx); + String p = creds.substring(splitIdx + 1); + if (u.equals(user) && p.equals(password)) { + // auth success + return true; + } + } + } + + // auth failure + response.header("WWW-Authenticate", "Basic"); + Spark.halt(401, "Unauthorized"); + return false; + } + + /** Filter evaluated before each request. */ + private void beforeFilter(Request request, Response response) { + // Log requests + logger.debug( + "[{}] {} {}", + request.ip(), + request.requestMethod(), + getFullUrl(request.pathInfo(), request.queryString()) + ); + + // Remove "Server: Jetty" header + response.header("Server", ""); + + // HTTP basic auth (if enabled) + if (params.useBasicAuth) { + performHttpBasicAuth( + request, + response, + params.basicAuthUser, + params.basicAuthPassword + ); + } + } + + /** Filter evaluated after each request. */ + private void afterFilter(Request request, Response response) { + // Enable gzip if supported + String acceptEncoding = request.headers("Accept-Encoding"); + if (acceptEncoding != null) { + boolean gzipEnabled = Arrays + .stream(acceptEncoding.split(",")) + .map(String::trim) + .anyMatch(s -> s.equalsIgnoreCase("gzip")); + if (gzipEnabled) { + response.header("Content-Encoding", "gzip"); + } + } + } + + /** Returns the OpenAPI object, generating and caching it if needed. */ + private OpenAPI getOpenApi() { + if (openApi == null) { + // Find all annotated classes + Reflections reflections = new Reflections( + new ConfigurationBuilder().forPackages(this.getClass().getPackageName()) + ); + Set> apiClasses = + reflections.getTypesAnnotatedWith(Path.class); + apiClasses.add(this.getClass()); + + // Scan annotations + Reader reader = new Reader(new OpenAPI()); + this.openApi = reader.read(apiClasses); + } + return openApi; + } + + /** Return an OpenAPI 3.0 YAML document. */ + private String getOpenApiYaml(Request request, Response response) { + response.type(MediaType.TEXT_PLAIN); + return Yaml.pretty(getOpenApi()); + } + + /** Return an OpenAPI 3.0 JSON document. */ + private String getOpenApiJson(Request request, Response response) { + response.type(MediaType.APPLICATION_JSON); + return Json.pretty(getOpenApi()); + } + + @Path("/api/v1/getTopology") + public class GetTopologyEndpoint implements Route { + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Get device topology", + description = "Returns the device topology.", + operationId = "getTopology", + tags = {"Config"}, + responses = { + @ApiResponse( + responseCode = "200", + description = "Device topology", + content = @Content( + schema = @Schema(implementation = DeviceTopology.class) + ) + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + response.type(MediaType.APPLICATION_JSON); + return deviceDataManager.getTopologyJson(); + } + } + + @Path("/api/v1/setTopology") + public class SetTopologyEndpoint implements Route { + @POST + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Set device topology", + description = "Set the device topology.", + operationId = "setTopology", + tags = {"Config"}, + requestBody = @RequestBody( + description = "The device topology", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = DeviceTopology.class) + ) + }, + required = true + ), + responses = { + @ApiResponse( + responseCode = "200", + description = "Success" + ), + @ApiResponse( + responseCode = "400", + description = "Bad request" + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + try { + DeviceTopology topology = + gson.fromJson(request.body(), DeviceTopology.class); + deviceDataManager.setTopology(topology); + + // Revalidate data model + modeler.revalidate(); + } catch (Exception e) { + response.status(400); + return e.getMessage(); + } + return ""; + } + } + + @Path("/api/v1/getDeviceLayeredConfig") + public class GetDeviceLayeredConfigEndpoint implements Route { + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Get device layered configuration", + description = "Returns the device layered configuration.", + operationId = "getDeviceLayeredConfig", + tags = {"Config"}, + responses = { + @ApiResponse( + responseCode = "200", + description = "Device layered configuration", + content = @Content( + schema = @Schema(implementation = DeviceLayeredConfig.class) + ) + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + response.type(MediaType.APPLICATION_JSON); + return deviceDataManager.getDeviceLayeredConfigJson(); + } + } + + @Path("/api/v1/getDeviceConfig") + public class GetDeviceConfigEndpoint implements Route { + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Get device configuration", + description = "Returns the device configuration by applying all configuration layers.", + operationId = "getDeviceConfig", + tags = {"Config"}, + parameters = { + @Parameter( + name = "serial", + description = "The device serial number", + in = ParameterIn.QUERY, + schema = @Schema(type = "string"), + required = true + ) + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "Device configuration", + content = @Content( + schema = @Schema(implementation = DeviceConfig.class) + ) + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + String serialNumber = request.queryParams("serial"); + if (serialNumber == null || serialNumber.trim().isEmpty()) { + response.status(400); + return "Invalid serial number"; + } + + DeviceConfig config = + deviceDataManager.getDeviceConfig(serialNumber); + if (config == null) { + response.status(400); + return "Unknown device"; + } + + response.type(MediaType.APPLICATION_JSON); + Gson gson = new GsonBuilder().serializeNulls().create(); + return gson.toJson(config); + } + } + + @Path("/api/v1/setDeviceNetworkConfig") + public class SetDeviceNetworkConfigEndpoint implements Route { + @POST + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Set device network configuration", + description = "Set the network layer of the device configuration.", + operationId = "setDeviceNetworkConfig", + tags = {"Config"}, + requestBody = @RequestBody( + description = "The device network configuration", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = DeviceConfig.class) + ) + }, + required = true + ), + responses = { + @ApiResponse( + responseCode = "200", + description = "Success" + ), + @ApiResponse( + responseCode = "400", + description = "Bad request" + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + try { + DeviceConfig networkConfig = + gson.fromJson(request.body(), DeviceConfig.class); + deviceDataManager.setDeviceNetworkConfig(networkConfig); + configManager.wakeUp(); + + // Revalidate data model + modeler.revalidate(); + } catch (Exception e) { + response.status(400); + return e.getMessage(); + } + return ""; + } + } + + @Path("/api/v1/setDeviceZoneConfig") + public class SetDeviceZoneConfigEndpoint implements Route { + @POST + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Set device zone configuration", + description = "Set the zone layer of the network configuration for the given zone.", + operationId = "setDeviceZoneConfig", + tags = {"Config"}, + parameters = { + @Parameter( + name = "zone", + description = "The RF zone", + in = ParameterIn.QUERY, + schema = @Schema(type = "string"), + required = true + ) + }, + requestBody = @RequestBody( + description = "The device zone configuration", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = DeviceConfig.class) + ) + }, + required = true + ), + responses = { + @ApiResponse( + responseCode = "200", + description = "Success" + ), + @ApiResponse( + responseCode = "400", + description = "Bad request" + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + String zone = request.queryParams("zone"); + if (zone == null || zone.trim().isEmpty()) { + response.status(400); + return "Invalid zone"; + } + + try { + DeviceConfig zoneConfig = + gson.fromJson(request.body(), DeviceConfig.class); + deviceDataManager.setDeviceZoneConfig(zone, zoneConfig); + configManager.wakeUp(); + + // Revalidate data model + modeler.revalidate(); + } catch (Exception e) { + response.status(400); + return e.getMessage(); + } + return ""; + } + } + + @Path("/api/v1/setDeviceApConfig") + public class SetDeviceApConfigEndpoint implements Route { + @POST + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Set device AP configuration", + description = "Set the AP layer of the network configuration for the given AP.", + operationId = "setDeviceApConfig", + tags = {"Config"}, + parameters = { + @Parameter( + name = "serial", + description = "The device serial number", + in = ParameterIn.QUERY, + schema = @Schema(type = "string"), + required = true + ) + }, + requestBody = @RequestBody( + description = "The device AP configuration", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = DeviceConfig.class) + ) + }, + required = true + ), + responses = { + @ApiResponse( + responseCode = "200", + description = "Success" + ), + @ApiResponse( + responseCode = "400", + description = "Bad request" + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + String serialNumber = request.queryParams("serial"); + if (serialNumber == null || serialNumber.trim().isEmpty()) { + response.status(400); + return "Invalid serial number"; + } + + try { + DeviceConfig apConfig = + gson.fromJson(request.body(), DeviceConfig.class); + deviceDataManager.setDeviceApConfig(serialNumber, apConfig); + configManager.wakeUp(); + + // Revalidate data model + modeler.revalidate(); + } catch (Exception e) { + response.status(400); + return e.getMessage(); + } + return ""; + } + } + + @Path("/api/v1/modifyDeviceApConfig") + public class ModifyDeviceApConfigEndpoint implements Route { + @POST + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Modify device AP configuration", + description = + "Modify the AP layer of the network configuration for the given AP. " + + "Any existing fields absent from the request body will be preserved.", + operationId = "modifyDeviceApConfig", + tags = {"Config"}, + parameters = { + @Parameter( + name = "serial", + description = "The device serial number", + in = ParameterIn.QUERY, + schema = @Schema(type = "string"), + required = true + ) + }, + requestBody = @RequestBody( + description = "The device AP configuration", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = DeviceConfig.class) + ) + }, + required = true + ), + responses = { + @ApiResponse( + responseCode = "200", + description = "Success" + ), + @ApiResponse( + responseCode = "400", + description = "Bad request" + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + String serialNumber = request.queryParams("serial"); + if (serialNumber == null || serialNumber.trim().isEmpty()) { + response.status(400); + return "Invalid serial number"; + } + if (!deviceDataManager.isDeviceInTopology(serialNumber)) { + response.status(400); + return "Unknown serial number"; + } + + try { + DeviceConfig apConfig = + gson.fromJson(request.body(), DeviceConfig.class); + if (apConfig.isEmpty()) { + response.status(400); + return "No supported fields in request body"; + } + deviceDataManager.updateDeviceApConfig(configMap -> { + configMap + .computeIfAbsent(serialNumber, k -> new DeviceConfig()) + .apply(apConfig); + }); + configManager.wakeUp(); + + // Revalidate data model + modeler.revalidate(); + } catch (Exception e) { + response.status(400); + return e.getMessage(); + } + return ""; + } + } + + @Path("/api/v1/currentModel") + public class GetCurrentModelEndpoint implements Route { + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Get current RRM model", + description = "Returns the current RRM data model.", + operationId = "getCurrentModel", + tags = {"Optimization"}, + responses = { + @ApiResponse( + responseCode = "200", + description = "Data model", + content = @Content( + schema = @Schema( + // TODO: can't use Modeler.DataModel because it has + // gson class members that should not be reflected + implementation = Object.class + ) + ) + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + response.type(MediaType.APPLICATION_JSON); + return gson.toJson(modeler.getDataModel()); + } + } + + @Path("/api/v1/optimizeChannel") + public class OptimizeChannelEndpoint implements Route { + // Hack for use in @ApiResponse -> @Content -> @Schema + @SuppressWarnings("unused") + private class ChannelAllocation { + public Map> data; + public ChannelAllocation(Map> channelMap) { + this.data = channelMap; + } + } + + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Optimize channel configuration", + description = "Run channel optimizer and return the new channel allocation.", + operationId = "optimizeChannel", + tags = {"Optimization"}, + parameters = { + @Parameter( + name = "mode", + description = "The assignment algorithm to use:\n" + + "- random: random channel initialization\n" + + "- least_used: least used channel assignment\n" + + "- unmanaged_aware: unmanaged AP aware least used channel assignment\n", + in = ParameterIn.QUERY, + schema = @Schema( + type = "string", + allowableValues = {"random", "least_used", "unmanaged_aware"} + ), + required = true + ), + @Parameter( + name = "zone", + description = "The RF zone", + in = ParameterIn.QUERY, + schema = @Schema(type = "string"), + required = true + ), + @Parameter( + name = "dry_run", + description = "Do not apply changes", + in = ParameterIn.QUERY, + schema = @Schema(type = "boolean") + ) + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "Channel allocation", + content = @Content( + schema = @Schema( + implementation = ChannelAllocation.class + ) + ) + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + String mode = request.queryParamOrDefault("mode", ""); + String zone = request.queryParamOrDefault("zone", ""); + boolean dryRun = request + .queryParamOrDefault("dry_run", "") + .equalsIgnoreCase("true"); + + // Validate zone + if (!deviceDataManager.isZoneInTopology(zone)) { + response.status(400); + return "Invalid zone"; + } + + // Get ChannelOptimizer implementation + ChannelOptimizer optimizer; + switch (mode) { + case "random": + // Run random channel initializer + optimizer = new RandomChannelInitializer( + modeler.getDataModelCopy(), zone, deviceDataManager + ); + break; + case "least_used": + // Run least used channel optimizer + optimizer = new LeastUsedChannelOptimizer( + modeler.getDataModelCopy(), zone, deviceDataManager + ); + break; + case "unmanaged_aware": + // Run unmanaged AP aware least used channel optimizer + optimizer = new UnmanagedApAwareChannelOptimizer( + modeler.getDataModelCopy(), zone, deviceDataManager + ); + break; + default: + response.status(400); + return "Invalid mode"; + } + + // Compute channel map + Map> channelMap = + optimizer.computeChannelMap(); + if (!dryRun) { + optimizer.applyConfig( + deviceDataManager, configManager, channelMap + ); + } + + response.type(MediaType.APPLICATION_JSON); + return gson.toJson(new ChannelAllocation(channelMap)); + } + } + + @Path("/api/v1/optimizeTxPower") + public class OptimizeTxPowerEndpoint implements Route { + // Hack for use in @ApiResponse -> @Content -> @Schema + @SuppressWarnings("unused") + private class TxPowerAllocation { + public Map> data; + public TxPowerAllocation(Map> txPowerMap) { + this.data = txPowerMap; + } + } + + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Optimize tx power configuration", + description = "Run tx power optimizer and return the new tx power allocation.", + operationId = "optimizeTxPower", + tags = {"Optimization"}, + parameters = { + @Parameter( + name = "mode", + description = "The assignment algorithm to use:\n" + + "- random: random tx power initialier\n" + + "- measure_ap_client: measurement-based AP-client TPC algorithm\n" + + "- measure_ap_ap: measurement-based AP-AP TPC algorithm\n" + + "- location_optimal: location-based optimal TPC algorithm\n", + in = ParameterIn.QUERY, + schema = @Schema( + type = "string", + allowableValues = {"random", "measure_ap_client", "measure_ap_ap", "location_optimal"} + ), + required = true + ), + @Parameter( + name = "zone", + description = "The RF zone", + in = ParameterIn.QUERY, + schema = @Schema(type = "string"), + required = true + ), + @Parameter( + name = "dry_run", + description = "Do not apply changes", + in = ParameterIn.QUERY, + schema = @Schema(type = "boolean") + ) + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "Tx power allocation", + content = @Content( + schema = @Schema( + implementation = TxPowerAllocation.class + ) + ) + ) + } + ) + @Override + public String handle( + @Parameter(hidden = true) Request request, + @Parameter(hidden = true) Response response + ) { + String mode = request.queryParamOrDefault("mode", ""); + String zone = request.queryParamOrDefault("zone", ""); + boolean dryRun = request + .queryParamOrDefault("dry_run", "") + .equalsIgnoreCase("true"); + + // Validate zone + if (!deviceDataManager.isZoneInTopology(zone)) { + response.status(400); + return "Invalid zone"; + } + + // Get TPC implementation + TPC optimizer; + switch (mode) { + case "random": + // Run random tx power initialization + optimizer = new RandomTxPowerInitializer( + modeler.getDataModelCopy(), zone, deviceDataManager + ); + break; + case "measure_ap_client": + // Run measurement-based AP-client tx power optimization + optimizer = new MeasurementBasedApClientTPC( + modeler.getDataModelCopy(), zone, deviceDataManager + ); + break; + case "measure_ap_ap": + // Run measurement-based AP-AP tx power optimization + optimizer = new MeasurementBasedApApTPC( + modeler.getDataModelCopy(), zone, deviceDataManager + ); + break; + case "location_optimal": + // Run location-based optimal tx power optimization + optimizer = new LocationBasedOptimalTPC( + modeler.getDataModelCopy(), zone, deviceDataManager + ); + break; + default: + response.status(400); + return "Invalid mode"; + } + + // Compute tx power map + Map> txPowerMap = + optimizer.computeTxPowerMap(); + if (!dryRun) { + optimizer.applyConfig( + deviceDataManager, configManager, txPowerMap + ); + } + + response.type(MediaType.APPLICATION_JSON); + return gson.toJson(new TxPowerAllocation(txPowerMap)); + } + } +} diff --git a/src/main/java/com/facebook/openwifirrm/modules/ConfigManager.java b/src/main/java/com/facebook/openwifirrm/modules/ConfigManager.java new file mode 100644 index 0000000..6365d9e --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/modules/ConfigManager.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.modules; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceConfig; +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.RRMConfig.ModuleConfig.ConfigManagerParams; +import com.facebook.openwifirrm.ucentral.UCentralApConfiguration; +import com.facebook.openwifirrm.ucentral.UCentralClient; +import com.facebook.openwifirrm.ucentral.UCentralUtils; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceWithStatus; + +/** + * Device configuration manager module. + */ +public class ConfigManager implements Runnable { + private static final Logger logger = LoggerFactory.getLogger(ConfigManager.class); + + /** The module parameters. */ + private final ConfigManagerParams params; + + /** The device data manager. */ + private final DeviceDataManager deviceDataManager; + + /** The uCentral client. */ + private final UCentralClient client; + + /** Runtime per-device data. */ + private class DeviceData { + /** Last received device config. */ + public UCentralApConfiguration config; + + /** Last config time (in monotonic ns). */ + public Long lastConfigTimeNs; + } + + /** Map from device serial number to runtime data. */ + private Map deviceDataMap = new TreeMap<>(); + + /** The main thread reference (i.e. where {@link #run()} is invoked). */ + private Thread mainThread; + + /** Was the main thread interrupt generated by {@link #wakeUp()}? */ + private final AtomicBoolean wakeupFlag = new AtomicBoolean(false); + + /** Is the main thread sleeping? */ + private final AtomicBoolean sleepingFlag = new AtomicBoolean(false); + + /** Config listener interface. */ + public interface ConfigListener { + /** + * Process a received device config. + * + * The listener should modify the "config" parameter as necessary and + * return true if any changes were made, otherwise return false. + */ + boolean processDeviceConfig( + String serialNumber, UCentralApConfiguration config + ); + } + + /** State listeners. */ + private Map configListeners = new TreeMap<>(); + + /** Constructor. */ + public ConfigManager( + ConfigManagerParams params, + DeviceDataManager deviceDataManager, + UCentralClient client + ) { + this.params = params; + this.deviceDataManager = deviceDataManager; + this.client = client; + + // Apply RRM parameters + addConfigListener( + getClass().getSimpleName(), + new ConfigListener() { + @Override + public boolean processDeviceConfig( + String serialNumber, UCentralApConfiguration config + ) { + return applyRRMConfig(serialNumber, config); + } + } + ); + } + + @Override + public void run() { + this.mainThread = Thread.currentThread(); + + // Run application logic in a periodic loop + while (!Thread.currentThread().isInterrupted()) { + try { + runImpl(); + sleepingFlag.set(true); + Thread.sleep(params.updateIntervalMs); + wakeupFlag.set(false); + } catch (InterruptedException e) { + if (wakeupFlag.getAndSet(false)) { + // Intentional interrupt, clear flag + logger.debug("Interrupted by wakeup!"); + Thread.interrupted(); + continue; + } else { + logger.error("Interrupted!", e); + break; + } + } finally { + sleepingFlag.set(false); + } + } + logger.error("Thread terminated!"); + } + + /** Run single iteration of application logic. */ + private void runImpl() { + // Fetch device list + List devices = client.getDevices(); + if (devices == null) { + logger.error("Failed to fetch devices!"); + return; + } + logger.debug("Received device list of size = {}", devices.size()); + + long now = System.nanoTime(); + + // Apply any config updates locally + List devicesNeedingUpdate = new ArrayList<>(); + final long CONFIG_DEBOUNCE_INTERVAL_NS = + params.configDebounceIntervalSec * 1_000_000_000L; + for (DeviceWithStatus device : devices) { + // Update config structure + DeviceData data = deviceDataMap.computeIfAbsent( + device.serialNumber, k -> new DeviceData() + ); + // Update the device only when it is still connected + if (!device.connected) { + logger.info( + "Device {} is disconnected, skipping...", + device.serialNumber + ); + continue; + } + data.config = new UCentralApConfiguration(device.configuration); + + // Call listeners + boolean modified = false; + for (ConfigListener listener : configListeners.values()) { + boolean wasModified = listener.processDeviceConfig( + device.serialNumber, data.config + ); + if (wasModified) { + modified = true; + } + } + + // Check if pushing config is enabled in device config + // NOTE: run the listeners first regardless of this, as they may + // perform other module state updates, etc. + if (!isDeviceConfigEnabled(device.serialNumber)) { + logger.debug( + "Skipping config for {} (disabled in device config)", + device.serialNumber + ); + continue; + } + + // Queue config update if past debounce interval + if (modified) { + if ( + data.lastConfigTimeNs != null && + now - data.lastConfigTimeNs < CONFIG_DEBOUNCE_INTERVAL_NS + ) { + logger.debug( + "Skipping config for {} (last configured {}s ago)", + device.serialNumber, + (now - data.lastConfigTimeNs) / 1_000_000_000L + ); + continue; + } else { + devicesNeedingUpdate.add(device.serialNumber); + } + } + } + + // Send config changes to devices + if (!params.configEnabled) { + logger.trace("Config changes are disabled."); + } else if (devicesNeedingUpdate.isEmpty()) { + logger.debug("No device configs to send."); + } else { + logger.info( + "Sending config to {} device(s): {}", + devicesNeedingUpdate.size(), + String.join(", ", devicesNeedingUpdate) + ); + for (String serialNumber : devicesNeedingUpdate) { + DeviceData data = deviceDataMap.get(serialNumber); + logger.info( + "Device {}: sending new configuration...", serialNumber + ); + data.lastConfigTimeNs = System.nanoTime(); + client.configure(serialNumber, data.config.toString()); + } + } + } + + /** Return whether the given device has pushing device config enabled. */ + private boolean isDeviceConfigEnabled(String serialNumber) { + DeviceConfig deviceConfig = + deviceDataManager.getDeviceConfig(serialNumber); + if (deviceConfig == null) { + return false; + } + return deviceConfig.enableConfig; + } + + /** + * Apply RRM parameters to the device. + * + * If any changes are needed, modify the config and return true. + * Otherwise, return false. + */ + private boolean applyRRMConfig( + String serialNumber, UCentralApConfiguration config + ) { + DeviceConfig deviceConfig = + deviceDataManager.getDeviceConfig(serialNumber); + if (deviceConfig == null || !deviceConfig.enableRRM) { + return false; + } + + boolean modified = false; + + // Apply channel config + Map channelList = new HashMap<>(); + if (deviceConfig.autoChannels != null) { + channelList.putAll(deviceConfig.autoChannels); + } + if (deviceConfig.userChannels != null) { + channelList.putAll(deviceConfig.userChannels); + } + if (!channelList.isEmpty()) { + modified |= UCentralUtils.setRadioConfigField( + serialNumber, config, "channel", channelList + ); + } + + // Apply tx power config + Map txPowerList = new HashMap<>(); + if (deviceConfig.autoTxPowers != null) { + txPowerList.putAll(deviceConfig.autoTxPowers); + } + if (deviceConfig.userTxPowers != null) { + txPowerList.putAll(deviceConfig.userTxPowers); + } + if (!txPowerList.isEmpty()) { + modified |= UCentralUtils.setRadioConfigField( + serialNumber, config, "tx-power", txPowerList + ); + } + + return modified; + } + + /** + * Add/overwrite a config listener with an arbitrary identifier. + * + * The "id" string determines the order in which listeners are called. + */ + public void addConfigListener(String id, ConfigListener listener) { + logger.debug("Adding config listener: {}", id); + configListeners.put(id, listener); + } + + /** + * Remove a config listener with the given identifier, returning true if + * anything was actually removed. + */ + public boolean removeConfigListener(String id) { + logger.debug("Removing config listener: {}", id); + return (configListeners.remove(id) != null); + } + + /** Interrupt the main thread, possibly triggering an update immediately. */ + public void wakeUp() { + if (mainThread != null && mainThread.isAlive() && sleepingFlag.get()) { + wakeupFlag.set(true); + mainThread.interrupt(); + } + } +} diff --git a/src/main/java/com/facebook/openwifirrm/modules/DataCollector.java b/src/main/java/com/facebook/openwifirrm/modules/DataCollector.java new file mode 100644 index 0000000..f4142f1 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/modules/DataCollector.java @@ -0,0 +1,607 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.modules; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceConfig; +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.RRMConfig.ModuleConfig.DataCollectorParams; +import com.facebook.openwifirrm.mysql.DatabaseManager; +import com.facebook.openwifirrm.mysql.StateRecord; +import com.facebook.openwifirrm.ucentral.UCentralApConfiguration; +import com.facebook.openwifirrm.ucentral.UCentralClient; +import com.facebook.openwifirrm.ucentral.UCentralKafkaConsumer; +import com.facebook.openwifirrm.ucentral.UCentralKafkaConsumer.KafkaRecord; +import com.facebook.openwifirrm.ucentral.UCentralUtils; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; +import com.facebook.openwifirrm.ucentral.gw.models.CommandInfo; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceCapabilities; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceWithStatus; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Data collector module. + */ +public class DataCollector implements Runnable { + private static final Logger logger = LoggerFactory.getLogger(DataCollector.class); + + /** Radio keys from state records to store. */ + private static final String[] RADIO_KEYS = new String[] { + "channel", "channel_width", "noise", "tx_power" + }; + + /** AP client keys from state records to store. */ + private static final String[] CLIENT_KEYS = new String[] { + "connected", "inactive", "rssi", "rx_bytes", "rx_packets", "tx_bytes", + "tx_duration", "tx_failed", "tx_offset", "tx_packets", "tx_retries" + }; + /** AP client rate keys from state records to store. */ + private static final String[] CLIENT_RATE_KEYS = + new String[] { "rx_rate", "tx_rate" }; + + /** The module parameters. */ + private final DataCollectorParams params; + + /** The device data manager. */ + private final DeviceDataManager deviceDataManager; + + /** The uCentral client. */ + private final UCentralClient client; + + /** The database manager. */ + private final DatabaseManager dbManager; + + /** The executor service instance. */ + private final ExecutorService executor; + + /** Runtime per-device data. */ + private class DeviceData { + /** Last wifi scan time (in monotonic ns). */ + public Long lastWifiScanTimeNs; + + /** Last capabilities request time (in monotonic ns). */ + public Long lastCapabilitiesTimeNs; + } + + /** Map from device serial number to runtime data. */ + private Map deviceDataMap = new HashMap<>(); + + /** Data listener interface. */ + public interface DataListener { + /** Process a received device capabilities object. */ + void processDeviceCapabilities( + String serialNumber, DeviceCapabilities capabilities + ); + } + + /** State listeners. */ + private Map dataListeners = new TreeMap<>(); + + /** Constructor. */ + public DataCollector( + DataCollectorParams params, + DeviceDataManager deviceDataManager, + UCentralClient client, + UCentralKafkaConsumer consumer, + ConfigManager configManager, + DatabaseManager dbManager + ) { + this.params = params; + this.deviceDataManager = deviceDataManager; + this.client = client; + this.dbManager = dbManager; + this.executor = + Executors.newFixedThreadPool(params.executorThreadCount); + + // Register config hooks + configManager.addConfigListener( + getClass().getSimpleName(), + new ConfigManager.ConfigListener() { + @Override + public boolean processDeviceConfig( + String serialNumber, UCentralApConfiguration config + ) { + return sanitizeDeviceConfig(serialNumber, config); + } + } + ); + + // Register Kafka listener + if (consumer != null) { + consumer.addKafkaListener( + getClass().getSimpleName(), + new UCentralKafkaConsumer.KafkaListener() { + @Override + public void handleStateRecords(List records) { + handleKafkaStateRecords(records); + } + + @Override + public void handleWifiScanRecords( + List records + ) { + // ignored here, handled directly from UCentralClient + } + } + ); + } + } + + /** Shut down all resources. */ + public void shutdown() { + executor.shutdownNow(); + } + + @Override + public void run() { + // Run application logic in a periodic loop + while (!Thread.currentThread().isInterrupted()) { + try { + runImpl(); + Thread.sleep(params.updateIntervalMs); + } catch (InterruptedException e) { + logger.error("Interrupted!", e); + break; + } + } + logger.error("Thread terminated!"); + } + + /** Run single iteration of application logic. */ + private void runImpl() { + // Fetch device list + List devices = client.getDevices(); + if (devices == null) { + logger.error("Failed to fetch devices!"); + return; + } + int fetchSize = devices.size(); + logger.debug("Received device list of size = {}", fetchSize); + if (devices.removeIf( + device -> !deviceDataManager.isDeviceInTopology(device.serialNumber) + )) { + int removedSize = fetchSize - devices.size(); + logger.debug( + "Ignoring {} device(s) not in topology", removedSize + ); + } + for (DeviceWithStatus device : devices) { + deviceDataMap.computeIfAbsent( + device.serialNumber, k -> new DeviceData() + ); + } + + // Fetch device capabilities + fetchDeviceCapabilities(devices); + + // Perform wifi scans + scheduleWifiScans(devices); + } + + /** + * Check that device config matches expected values. + * + * If any changes are needed, modify the config and return true. + * Otherwise, return false. + */ + private boolean sanitizeDeviceConfig( + String serialNumber, UCentralApConfiguration config + ) { + boolean modified = false; + + // Check stats interval + final int STATS_INTERVAL_S = params.deviceStatsIntervalSec; + int currentStatsInterval = config.getStatisticsInterval(); + if (currentStatsInterval != STATS_INTERVAL_S) { + logger.info( + "Device {}: setting stats interval to {} (was {})", + serialNumber, STATS_INTERVAL_S, currentStatsInterval + ); + config.setStatisticsInterval(STATS_INTERVAL_S); + modified = true; + } + + return modified; + } + + /** Fetch capabilities for the given list of devices. */ + private void fetchDeviceCapabilities(List devices) { + long now = System.nanoTime(); + final long CAPABILITIES_INTERVAL_NS = + Math.max(params.capabilitiesIntervalSec, 1) * 1_000_000_000L; + + for (DeviceWithStatus device : devices) { + // Check last request time + DeviceData data = deviceDataMap.get(device.serialNumber); + if ( + data.lastCapabilitiesTimeNs != null && + now - data.lastCapabilitiesTimeNs < CAPABILITIES_INTERVAL_NS + ) { + logger.trace( + "Skipping capabilities for {} (last requested {}s ago)", + device.serialNumber, + (now - data.lastCapabilitiesTimeNs) / 1_000_000_000L + ); + continue; + } + + // Issue capabilities request (via executor, will run async eventually) + // Set "lastCapabilitiesTimeNs" now and again when the request actually runs + logger.info("Device {}: queued capabilities request", device.serialNumber); + data.lastCapabilitiesTimeNs = now; + executor.submit(() -> { + data.lastCapabilitiesTimeNs = System.nanoTime(); + performDeviceCapabilitiesRequest(device.serialNumber); + }); + } + } + + /** Schedule wifi scans for the given list of devices. */ + private void scheduleWifiScans(List devices) { + // Disabled? + if (params.wifiScanIntervalSec == -1) { + logger.trace("Automatic wifi scans are disabled."); + return; + } + + long now = System.nanoTime(); + final long WIFI_SCAN_INTERVAL_NS = + Math.max(params.wifiScanIntervalSec, 1) * 1_000_000_000L; + for (DeviceWithStatus device : devices) { + // Check if scans are enabled in device config + DeviceConfig deviceConfig = + deviceDataManager.getDeviceConfig(device.serialNumber); + if (deviceConfig == null) { + logger.trace( + "Skipping wifi scan for {} (null device config)", + device.serialNumber + ); + continue; + } + if (!deviceConfig.enableWifiScan) { + logger.trace( + "Skipping wifi scan for {} (disabled in device config)", + device.serialNumber + ); + continue; + } + + // Check last request time + DeviceData data = deviceDataMap.get(device.serialNumber); + if ( + data.lastWifiScanTimeNs != null && + now - data.lastWifiScanTimeNs < WIFI_SCAN_INTERVAL_NS + ) { + logger.trace( + "Skipping wifi scan for {} (last scanned {}s ago)", + device.serialNumber, + (now - data.lastWifiScanTimeNs) / 1_000_000_000L + ); + continue; + } + + // Issue scan command (via executor, will run async eventually) + // Set "lastWifiScanTime" now and again when the scan actually runs + logger.info("Device {}: queued wifi scan", device.serialNumber); + data.lastWifiScanTimeNs = now; + executor.submit(() -> { + data.lastWifiScanTimeNs = System.nanoTime(); + performWifiScan(device.serialNumber); + }); + } + } + + /** + * Request device capabilities and handle results. + * + * Returns true upon failure and false otherwise. + */ + private boolean performDeviceCapabilitiesRequest(String serialNumber) { + logger.info("Device {}: requesting capabilities...", serialNumber); + DeviceCapabilities capabilities = client.getCapabilities(serialNumber); + if (capabilities == null) { + logger.error("Device {}: capabilities request failed", serialNumber); + return false; + } + logger.debug( + "Device {}: capabilities response: {}", + serialNumber, + new Gson().toJson(capabilities) + ); + + // Process results + for (DataListener listener : dataListeners.values()) { + listener.processDeviceCapabilities(serialNumber, capabilities); + } + return true; + } + + /** + * Issue a wifi scan command to the given device and handle results. + * + * Returns true upon failure and false otherwise. + */ + private boolean performWifiScan(String serialNumber) { + logger.info("Device {}: performing wifi scan...", serialNumber); + CommandInfo wifiScanResult = client.wifiScan(serialNumber, true); + if (wifiScanResult == null) { + logger.error("Device {}: wifi scan request failed", serialNumber); + return false; + } + logger.debug( + "Device {}: wifi scan results: {}", + serialNumber, + new Gson().toJson(wifiScanResult) + ); + + // Process results + if (wifiScanResult.errorCode != 0) { + logger.error( + "Device {}: wifi scan returned error code {}", + serialNumber, wifiScanResult.errorCode + ); + return false; + } + if (wifiScanResult.results.entrySet().isEmpty()) { + logger.error( + "Device {}: wifi scan returned empty result set", serialNumber + ); + return false; + } + List scanEntries = + UCentralUtils.parseWifiScanEntries(wifiScanResult.results); + if (scanEntries == null) { + logger.error( + "Device {}: wifi scan returned unexpected result", serialNumber + ); + return false; + } + + // Print some processed info (not doing anything else useful here) + Set channels = scanEntries + .stream() + .map(entry -> entry.channel) + .collect(Collectors.toCollection(() -> new TreeSet<>())); + logger.info( + "Device {}: found {} network(s) on {} channel(s): {}", + serialNumber, + scanEntries.size(), + channels.size(), + channels + ); + + // Save to database + insertWifiScanResultsToDatabase( + serialNumber, wifiScanResult.executed, scanEntries + ); + + return true; + } + + /** Insert wifi scan results into database. */ + private void insertWifiScanResultsToDatabase( + String serialNumber, long ts, List entries + ) { + if (dbManager == null) { + return; + } + + // Insert into database + try { + dbManager.addWifiScan(serialNumber, ts, entries); + } catch (SQLException e) { + logger.error("Failed to insert wifi scan results into database", e); + return; + } + } + + /** Kafka state records callback. */ + private void handleKafkaStateRecords(List records) { + logger.debug("Handling {} state record(s)", records.size()); + insertStateRecordsToDatabase(records); + } + + /** Parse a single state record into individual metrics. */ + protected static void parseStateRecord( + String serialNumber, JsonObject payload, List results + ) { + JsonObject state = payload.getAsJsonObject("state"); + JsonArray interfaces = state.getAsJsonArray("interfaces"); + JsonArray radios = state.getAsJsonArray("radios"); + JsonObject unit = state.getAsJsonObject("unit"); + long localtime = unit.get("localtime").getAsLong(); + + // "interfaces" + // - store all entries from "counters" as + // "interface.." + // - store all entries from "ssids..associations." as + // "interface..bssid..client.." + for (JsonElement o1 : interfaces) { + JsonObject iface = o1.getAsJsonObject(); + String ifname = iface.get("name").getAsString(); + + JsonObject counters = iface.getAsJsonObject("counters"); + if (counters != null) { + for (Map.Entry entry : counters.entrySet()) { + String metric = String.format( + "interface.%s.%s", ifname, entry.getKey() + ); + long value = entry.getValue().getAsLong(); + results.add(new StateRecord( + localtime, metric, value, serialNumber + )); + } + } + JsonArray ssids = iface.getAsJsonArray("ssids"); + if (ssids != null) { + for (JsonElement o2 : ssids) { + JsonObject ssid = o2.getAsJsonObject(); + if (!ssid.has("bssid")) { + continue; + } + String bssid = ssid.get("bssid").getAsString(); + JsonArray associations = ssid.getAsJsonArray("associations"); + if (associations != null) { + for (JsonElement o3 : associations) { + JsonObject client = o3.getAsJsonObject(); + if (!client.has("bssid")) { + continue; + } + String clientBssid = client.get("bssid").getAsString(); + for (String s : CLIENT_KEYS) { + if (!client.has(s) || !client.get(s).isJsonPrimitive()) { + continue; + } + String metric = String.format( + "interface.%s.bssid.%s.client.%s.%s", + ifname, bssid, clientBssid, s + ); + long value = client.get(s).getAsLong(); + results.add(new StateRecord( + localtime, metric, value, serialNumber + )); + } + for (String s : CLIENT_RATE_KEYS) { + if (!client.has(s) || !client.get(s).isJsonObject()) { + continue; + } + String metricBase = String.format( + "interface.%s.bssid.%s.client.%s.%s", + ifname, bssid, clientBssid, s + ); + for ( + Map.Entry entry : + client.getAsJsonObject(s).entrySet() + ) { + String metric = String.format( + "%s.%s", metricBase, entry.getKey() + ); + long value; + if (entry.getValue().getAsJsonPrimitive().isBoolean()) { + value = entry.getValue().getAsBoolean() ? 1 : 0; + } else { + value = entry.getValue().getAsLong(); + } + results.add(new StateRecord( + localtime, metric, value, serialNumber + )); + } + } + } + } + } + } + } + + // "radios" + // - store "channel", "channel_width", "noise", "tx_power" as + // "radio.." + if (radios != null) { + for (int i = 0; i < radios.size(); i++) { + JsonObject o = radios.get(i).getAsJsonObject(); + for (String s : RADIO_KEYS) { + if (!o.has(s) || !o.get(s).isJsonPrimitive()) { + continue; + } + String metric = String.format("radio.%d.%s", i, s); + long value = o.get(s).getAsLong(); + results.add( + new StateRecord(localtime, metric, value, serialNumber) + ); + } + } + } + + // "unit" + // - store "uptime" as "unit." + // - "load.0", "load.1", "load.2" => unclear what is going on + // with these values, leaving them out for now + /* + JsonArray loadArray = unit.getAsJsonArray("load"); + for (int i = 0; i < loadArray.size(); i++) { + String metric = String.format("unit.load.%d", i); + long load = loadArray.get(i).getAsLong(); + results.add(new StateRecord(localtime, metric, load, serialNumber)); + } + */ + long uptime = unit.get("uptime").getAsLong(); + results.add( + new StateRecord(localtime, "unit.uptime", uptime, serialNumber) + ); + } + + /** Parse state records into individual metrics. */ + private static List parseStateRecords(List records) { + List results = new ArrayList<>(); + for (KafkaRecord record : records) { + try { + parseStateRecord(record.serialNumber, record.payload, results); + } catch (Exception e) { + String errMsg = String.format( + "Failed to parse state record: %s", + record.payload.toString() + ); + logger.error(errMsg, e); + continue; + } + } + return results; + } + + /** Parse state records into individual metrics and insert into database. */ + private void insertStateRecordsToDatabase(List records) { + if (dbManager == null) { + return; + } + + List dbRecords = parseStateRecords(records); + try { + dbManager.addStateRecords(dbRecords); + } catch (SQLException e) { + logger.error("Failed to insert state records into database", e); + return; + } + } + + /** + * Add/overwrite a data listener with an arbitrary identifier. + * + * The "id" string determines the order in which listeners are called. + */ + public void addDataListener(String id, DataListener listener) { + logger.debug("Adding data listener: {}", id); + dataListeners.put(id, listener); + } + + /** + * Remove a data listener with the given identifier, returning true if + * anything was actually removed. + */ + public boolean removeDataListener(String id) { + logger.debug("Removing data listener: {}", id); + return (dataListeners.remove(id) != null); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/modules/Modeler.java b/src/main/java/com/facebook/openwifirrm/modules/Modeler.java new file mode 100644 index 0000000..013acd4 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/modules/Modeler.java @@ -0,0 +1,387 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.modules; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceConfig; +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.RRMConfig.ModuleConfig.ModelerParams; +import com.facebook.openwifirrm.ucentral.UCentralApConfiguration; +import com.facebook.openwifirrm.ucentral.UCentralClient; +import com.facebook.openwifirrm.ucentral.UCentralKafkaConsumer; +import com.facebook.openwifirrm.ucentral.UCentralKafkaConsumer.KafkaRecord; +import com.facebook.openwifirrm.ucentral.UCentralUtils; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceCapabilities; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceWithStatus; +import com.facebook.openwifirrm.ucentral.gw.models.StatisticsRecords; +import com.facebook.openwifirrm.ucentral.models.State; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; + +/** + * Modeler module. + */ +public class Modeler implements Runnable { + private static final Logger logger = LoggerFactory.getLogger(Modeler.class); + + /** The module parameters. */ + private final ModelerParams params; + + /** The device data manager. */ + private final DeviceDataManager deviceDataManager; + + /** The uCentral client instance. */ + private final UCentralClient client; + + /** Kafka input data types. */ + public enum InputDataType { STATE, WIFISCAN } + + /** Kafka input data wrapper. */ + private class InputData { + /** Data type. */ + public final InputDataType type; + + /** Records. */ + public final List records; + + /** Constructor. */ + public InputData(InputDataType type, List records) { + this.type = type; + this.records = records; + } + } + + /** The blocking data queue. */ + private final BlockingQueue dataQueue = + new LinkedBlockingQueue<>(); + + /** Data model representation. */ + public static class DataModel { + // TODO: This is only a placeholder implementation. + // At minimum, we may want to aggregate recent wifi scan responses and + // keep a rolling average for stats. + + /** List of latest wifi scan results per device. */ + public Map>> latestWifiScans = + new ConcurrentHashMap<>(); + + /** List of latest state per device. */ + public Map latestState = new ConcurrentHashMap<>(); + + /** List of radio info per device. */ + public Map latestDeviceStatus = new ConcurrentHashMap<>(); + + /** List of capabilities per device. */ + public Map latestDeviceCapabilities = + new ConcurrentHashMap<>(); + } + + /** The data model. */ + public DataModel dataModel = new DataModel(); + + /** The Gson instance. */ + private final Gson gson = new Gson(); + + /** Constructor. */ + public Modeler( + ModelerParams params, + DeviceDataManager deviceDataManager, + UCentralKafkaConsumer consumer, + UCentralClient client, + DataCollector dataCollector, + ConfigManager configManager + ) { + this.params = params; + this.deviceDataManager = deviceDataManager; + this.client = client; + + // Register data hooks + dataCollector.addDataListener( + getClass().getSimpleName(), + new DataCollector.DataListener() { + @Override + public void processDeviceCapabilities( + String serialNumber, DeviceCapabilities capabilities + ) { + updateDeviceCapabilities(serialNumber, capabilities); + } + } + ); + + // Register config hooks + configManager.addConfigListener( + getClass().getSimpleName(), + new ConfigManager.ConfigListener() { + @Override + public boolean processDeviceConfig( + String serialNumber, UCentralApConfiguration config + ) { + updateDeviceConfig(serialNumber, config); + return false; + } + } + ); + + // Register Kafka listener + if (consumer != null) { + // We only push data to a blocking queue to be processed by this + // thread later, instead of the Kafka consumer thread + consumer.addKafkaListener( + getClass().getSimpleName(), + new UCentralKafkaConsumer.KafkaListener() { + @Override + public void handleStateRecords(List records) { + dataQueue.offer( + new InputData(InputDataType.STATE, records) + ); + } + + @Override + public void handleWifiScanRecords( + List records + ) { + dataQueue.offer( + new InputData(InputDataType.WIFISCAN, records) + ); + } + } + ); + } + } + + @Override + public void run() { + logger.info("Fetching initial data..."); + fetchInitialData(); + + // Poll for data until interrupted + logger.info("Modeler awaiting data..."); + while (!Thread.currentThread().isInterrupted()) { + try { + InputData inputData = dataQueue.take(); + + // Drop records here if RRM is disabled for a device + int recordCount = inputData.records.size(); + if (inputData.records.removeIf( + record -> !isRRMEnabled(record.serialNumber) + )) { + logger.debug( + "Dropping {} Kafka record(s) for non-RRM-enabled devices", + recordCount - inputData.records.size() + ); + } + + processData(inputData); + } catch (InterruptedException e) { + logger.error("Interrupted!", e); + break; + } + } + logger.error("Thread terminated!"); + } + + /** Fetch initial data (called only once). */ + private void fetchInitialData() { + // TODO: backfill data from database? + + // Fetch state from uCentralGw + List devices = client.getDevices(); + if (devices == null) { + logger.error("Failed to fetch devices!"); + return; + } + logger.debug("Received device list of size = {}", devices.size()); + for (DeviceWithStatus device : devices) { + // Check if enabled + if (!isRRMEnabled(device.serialNumber)) { + logger.debug( + "Skipping data for non-RRM-enabled device {}", + device.serialNumber + ); + continue; + } + + StatisticsRecords records = + client.getLatestStats(device.serialNumber, 1); + if (records == null || records.data.size() != 1) { + continue; + } + JsonObject state = records.data.get(0).data; + if (state != null) { + try { + State stateModel = gson.fromJson(state, State.class); + dataModel.latestState.put(device.serialNumber, stateModel); + logger.debug( + "Device {}: added initial state from uCentralGw", + device.serialNumber + ); + } catch (JsonSyntaxException e) { + logger.error( + String.format( + "Device %s: failed to deserialize state: %s", + device.serialNumber, + state + ), + e + ); + } + } + } + } + + /** Process input data. */ + private void processData(InputData data) { + switch (data.type) { + case STATE: + for (KafkaRecord record : data.records) { + JsonObject state = record.payload.getAsJsonObject("state"); + if (state != null) { + try { + State stateModel = gson.fromJson(state, State.class); + dataModel.latestState.put(record.serialNumber, stateModel); + logger.debug( + "Device {}: received state update", record.serialNumber + ); + } catch (JsonSyntaxException e) { + logger.error( + String.format( + "Device %s: failed to deserialize state: %s", + record.serialNumber, + state + ), + e + ); + } + } + } + break; + case WIFISCAN: + for (KafkaRecord record : data.records) { + List> wifiScanList = + dataModel.latestWifiScans.computeIfAbsent( + record.serialNumber, + k -> new LinkedList<>() + ); + + // Parse and validate this record + List scanEntries = + UCentralUtils.parseWifiScanEntries(record.payload); + if (scanEntries == null) { + continue; + } + + // Add to list (and truncate to max size) + while (wifiScanList.size() >= params.wifiScanBufferSize) { + wifiScanList.remove(0); + } + wifiScanList.add(scanEntries); + logger.debug( + "Device {}: received wifi scan result", record.serialNumber + ); + } + break; + } + } + + /** + * Update device capabilities into DataModel whenever there are new changes. + */ + private void updateDeviceCapabilities( + String serialNumber, DeviceCapabilities capabilities + ) { + dataModel.latestDeviceCapabilities.put( + serialNumber, capabilities.capabilities.getAsJsonObject("wifi") + ); + } + + /** + * Update device config into DataModel whenever there are new changes. + */ + private void updateDeviceConfig( + String serialNumber, UCentralApConfiguration config + ) { + // Get old vs new radios info and store the new radios info + JsonArray newRadioList = config.getRadioConfigList(); + Set newRadioBandsSet = config.getRadioBandsSet(newRadioList); + JsonArray oldRadioList = dataModel.latestDeviceStatus + .put(serialNumber, newRadioList); + Set oldRadioBandsSet = config.getRadioBandsSet(oldRadioList); + + // Print info only when there are any updates + if (!oldRadioBandsSet.equals(newRadioBandsSet)) { + logger.info( + "Device {}: the new radios list is: {} (was {}).", + serialNumber, + newRadioBandsSet.toString(), + oldRadioBandsSet.toString() + ); + } + } + + /** Return whether the given device has RRM enabled. */ + private boolean isRRMEnabled(String serialNumber) { + DeviceConfig deviceConfig = + deviceDataManager.getDeviceConfig(serialNumber); + if (deviceConfig == null) { + return false; + } + return deviceConfig.enableRRM; + } + + /** Return the current data model (direct reference). */ + public DataModel getDataModel() { + return dataModel; + } + + /** Return the current data model (deep clone via gson). */ + public DataModel getDataModelCopy() { + return gson.fromJson(gson.toJson(dataModel), DataModel.class); + } + + /** Revalidate the data model to remove any non-RRM-enabled devices. */ + public void revalidate() { + if ( + dataModel.latestWifiScans.entrySet() + .removeIf(e -> !isRRMEnabled(e.getKey())) + ) { + logger.debug("Removed some wifi scan entries from data model"); + } + if ( + dataModel.latestState.entrySet() + .removeIf(e -> !isRRMEnabled(e.getKey())) + ) { + logger.debug("Removed some state entries from data model"); + } + if ( + dataModel.latestDeviceStatus.entrySet() + .removeIf(e -> !isRRMEnabled(e.getKey())) + ) { + logger.debug("Removed some status entries from data model"); + } + if ( + dataModel.latestDeviceCapabilities.entrySet() + .removeIf(e -> !isRRMEnabled(e.getKey())) + ) { + logger.debug("Removed some capabilities entries from data model"); + } + } +} diff --git a/src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java b/src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java new file mode 100644 index 0000000..4b1890e --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.modules; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ModelerUtils { + private static final Logger logger = LoggerFactory.getLogger(ModelerUtils.class); + + /** The pathloss exponent for mapping the distance (in meters) to prop loss (in dB).*/ + private static final double PATHLOSS_EXPONENT = 2; + + /** The net loss/gain in the link budget, including all antenna gains/system loss. */ + private static final double LINKBUDGET_FACTOR = 20; + + /** The default noise power in dBm depending on the BW (should be adjusted later). */ + private static final double NOISE_POWER = -94; + + /** The guaranteed SINR threshold in dB */ + private static final double SINR_THRESHOLD = 20; + + /** The guaranteed RSL value in dBm */ + private static final double RX_THRESHOLD = -80; + + /** The pre defined target coverage in percentage. */ + private static final double COVERAGE_THRESHOLD = 70; + + // This class should not be instantiated. + private ModelerUtils() {} + + /** + * Get the RX power over the map for all the APs + * @param sampleSpace the boundary of the space + * @param numOfAPs the number of APs + * @param apLocX the location x of the APs + * @param apLocY the location y of the APs + * @param txPower the TX power of the APs + * @return the RX power of location x, location y, and AP index + */ + public static double[][][] generateRxPower( + int sampleSpace, + int numOfAPs, + List apLocX, + List apLocY, + List txPower + ) { + if ( + apLocX == null || + apLocX.size() != numOfAPs || + apLocY == null || + apLocY.size() != numOfAPs || + txPower == null || + txPower.size() != numOfAPs + ) { + throw new IllegalArgumentException("Invalid input data"); + } + + double[][][] rxPower = new double[sampleSpace][sampleSpace][numOfAPs]; + for (int xIndex = 0; xIndex < sampleSpace; xIndex++) { + for (int yIndex = 0; yIndex < sampleSpace; yIndex++) { + for (int apIndex = 0; apIndex < numOfAPs; apIndex++) { + if ( + apLocX.get(apIndex) > sampleSpace || + apLocY.get(apIndex) > sampleSpace + ) { + logger.error( + "The location of the AP is out of range." + ); + return null; + } + double distance = Math.sqrt( + Math.pow((apLocX.get(apIndex) - xIndex), 2) + + Math.pow((apLocY.get(apIndex) - yIndex), 2) + ); + double rxPowerTmp = txPower.get(apIndex) + - LINKBUDGET_FACTOR + - 20*PATHLOSS_EXPONENT*Math.log10(distance + 0.01); + rxPower[xIndex][yIndex][apIndex] = Math.min(rxPowerTmp, -30); + } + } + } + return rxPower; + } + + /** + * Get the heatmap over the map + * @param sampleSpace the boundary of the space + * @param numOfAPs the number of APs + * @param rxPower the RX power of location x, location y, and AP index + * @return the max RX power of location x and location y + */ + public static double[][] generateHeatMap( + int sampleSpace, + int numOfAPs, + double[][][] rxPower + ) { + if (rxPower == null) { + throw new IllegalArgumentException("Invalid input data"); + } + + double[][] rxPowerBest = new double[sampleSpace][sampleSpace]; + for (int xIndex = 0; xIndex < sampleSpace; xIndex++) { + for (int yIndex = 0; yIndex < sampleSpace; yIndex++) { + double rxPowerBestTmp = Double.NEGATIVE_INFINITY; + for (int apIndex = 0; apIndex < numOfAPs; apIndex++) { + rxPowerBestTmp = Math.max( + rxPowerBestTmp, + rxPower[xIndex][yIndex][apIndex] + ); + } + rxPowerBest[xIndex][yIndex] = rxPowerBestTmp; + } + } + return rxPowerBest; + } + + /** + * Get the max SINR over the map + * @param sampleSpace the boundary of the space + * @param numOfAPs the number of APs + * @param rxPower the RX power of location x, location y, and AP index + * @return the max SINR of location x and location y + */ + public static double[][] generateSinr( + int sampleSpace, + int numOfAPs, + double[][][] rxPower + ) { + if (rxPower == null) { + throw new IllegalArgumentException("Invalid input data"); + } + + double[][] sinrDB = new double[sampleSpace][sampleSpace]; + for (int xIndex = 0; xIndex < sampleSpace; xIndex++) { + for (int yIndex = 0; yIndex < sampleSpace; yIndex++) { + double maxSinr = Double.NEGATIVE_INFINITY; + for (int apIndex = 0; apIndex < numOfAPs; apIndex++) { + double denominator = 0.0; + for (int apIndex2 = 0; apIndex2 < numOfAPs; apIndex2++) { + if (apIndex == apIndex2) { + continue; + } + denominator += Math.pow( + 10, + rxPower[xIndex][yIndex][apIndex2]/10.0 + ); + } + denominator += Math.pow(10, NOISE_POWER/10.0); + double sinrLinear = Math.pow( + 10, + rxPower[xIndex][yIndex][apIndex]/10.0 + )/denominator; + maxSinr = Math.max(maxSinr, 10.0*Math.log10(sinrLinear)); + } + sinrDB[xIndex][yIndex] = maxSinr; + } + } + return sinrDB; + } + + /** + * Get the coverage metrics for the TPC algorithm design + * @param sampleSpace the boundary of the space + * @param rxPowerBest the max RX power of location x and location y + * @param sinrDB the max SINR of location x and location y + * @return the combined metric of over and under coverage, infinity if error + */ + public static double calculateTPCMetrics( + int sampleSpace, + double[][] rxPowerBest, + double[][] sinrDB + ) { + int rxPowerCount = 0; + int sinrCount = 0; + for (int xIndex = 0; xIndex < sampleSpace; xIndex++) { + for (int yIndex = 0; yIndex < sampleSpace; yIndex++) { + if (rxPowerBest[xIndex][yIndex] <= RX_THRESHOLD) { + rxPowerCount++; + } + if (sinrDB[xIndex][yIndex] <= SINR_THRESHOLD) { + sinrCount++; + } + } + } + double rxPowerPercentage = (double)rxPowerCount/Math.pow(sampleSpace, 2); + double sinrPercentage = (double)sinrCount/Math.pow(sampleSpace, 2); + if (rxPowerPercentage*100.0 < 100.0 - COVERAGE_THRESHOLD) { + return sinrPercentage; + } else { + return Double.POSITIVE_INFINITY; + } + } +} diff --git a/src/main/java/com/facebook/openwifirrm/mysql/DatabaseManager.java b/src/main/java/com/facebook/openwifirrm/mysql/DatabaseManager.java new file mode 100644 index 0000000..34b6566 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/mysql/DatabaseManager.java @@ -0,0 +1,565 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.mysql; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.Utils; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; +import com.facebook.openwifirrm.ucentral.models.State; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +/** + * Database connection manager. + */ +public class DatabaseManager { + private static final Logger logger = LoggerFactory.getLogger(DatabaseManager.class); + + /** The database host:port. */ + private final String server; + + /** The database user. */ + private final String user; + + /** The database password. */ + private final String password; + + /** The database name. */ + private final String dbName; + + /** The data retention interval in days (0 to disable). */ + private final int dataRetentionIntervalDays; + + /** The pooled data source. */ + private HikariDataSource ds; + + /** + * Constructor. + * @param server the database host:port (ex. "localhost:3306") + * @param user the database user + * @param password the database password + * @param dbName the database name + * @param dataRetentionIntervalDays the data retention interval in days (0 to disable) + */ + public DatabaseManager( + String server, String user, String password, String dbName, int dataRetentionIntervalDays + ) { + this.server = server; + this.user = user; + this.password = password; + this.dbName = dbName; + this.dataRetentionIntervalDays = dataRetentionIntervalDays; + } + + /** Run database initialization. */ + public void init() throws + InstantiationException, + IllegalAccessException, + ClassNotFoundException, + SQLException { + // Load database drivers + Class.forName("com.mysql.cj.jdbc.Driver"); + + // Create database (only place using non-pooled connection) + try ( + Connection conn = DriverManager.getConnection( + getConnectionUrl(""), user, password + ); + Statement stmt = conn.createStatement() + ) { + String sql = String.format( + "CREATE DATABASE IF NOT EXISTS `%s`", dbName + ); + stmt.executeUpdate(sql); + } + + // Configure connection pooling + initConnectionPool(); + + try ( + Connection conn = getConnection(); + Statement stmt = conn.createStatement() + ) { + // Create tables + String sql = + "CREATE TABLE IF NOT EXISTS `state` (" + + "`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, " + + "`time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "`metric` VARCHAR(255) NOT NULL, " + + "`value` BIGINT NOT NULL, " + + "`serial` VARCHAR(63) NOT NULL" + + ") ENGINE = InnoDB DEFAULT CHARSET = UTF8"; + stmt.executeUpdate(sql); + sql = + "CREATE TABLE IF NOT EXISTS `wifiscan` (" + + "`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, " + + "`time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "`serial` VARCHAR(63) NOT NULL" + + ") ENGINE = InnoDB DEFAULT CHARSET = UTF8"; + stmt.executeUpdate(sql); + sql = + "CREATE TABLE IF NOT EXISTS `wifiscan_results` (" + + "`scan_id` BIGINT NOT NULL, " + + "`bssid` BIGINT NOT NULL, " + + "`ssid` VARCHAR(32), " + + "`lastseen` BIGINT NOT NULL, " + + "`rssi` INT NOT NULL, " + + "`channel` INT NOT NULL" + + ") ENGINE = InnoDB DEFAULT CHARSET = UTF8"; + stmt.executeUpdate(sql); + + // Create clean-up event to run daily at midnight + // TODO: do we need partitioning? + final String EVENT_NAME = "RRM_DeleteOldRecords"; + if (dataRetentionIntervalDays > 0) { + // Enable the event scheduler + stmt.executeUpdate("SET GLOBAL event_scheduler = ON"); + + // To handle both cases (where the event exists or doesn't yet), + // send a no-op "CREATE EVENT" with the schedule followed by + // "ALTER EVENT" containing the actual event body + sql = + "CREATE EVENT IF NOT EXISTS " + EVENT_NAME + " " + + "ON SCHEDULE EVERY 1 DAY " + + "STARTS (CURRENT_DATE + INTERVAL 1 DAY) " + + "DO SELECT 1"; // no-op + stmt.executeUpdate(sql); + + final String oldDate = "DATE_SUB(NOW(), INTERVAL " + dataRetentionIntervalDays + " DAY)"; + sql = + "ALTER EVENT " + EVENT_NAME + " " + + "DO BEGIN " + + "DELETE FROM state WHERE DATE(time) < " + oldDate + "; " + + "DELETE FROM wifiscan WHERE DATE(time) < " + oldDate + "; " + + "DELETE wifiscan_results FROM wifiscan_results " + + "INNER JOIN wifiscan ON wifiscan_results.scan_id = wifiscan.id " + + "WHERE DATE(wifiscan.time) < " + oldDate + "; " + + "END;"; + stmt.executeUpdate(sql); + } else { + sql = "DROP EVENT IF EXISTS " + EVENT_NAME; + stmt.executeUpdate(sql); + } + } + } + + /** Initialize database connection pooling. */ + private void initConnectionPool() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(getConnectionUrl(dbName)); + config.setUsername(user); + config.setPassword(password); + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + config.addDataSourceProperty("useServerPrepStmts", "true"); + config.addDataSourceProperty("useLocalSessionState", "true"); + config.addDataSourceProperty("rewriteBatchedStatements", "true"); + config.addDataSourceProperty("cacheResultSetMetadata", "true"); + config.addDataSourceProperty("cacheServerConfiguration", "true"); + config.addDataSourceProperty("elideSetAutoCommits", "true"); + config.addDataSourceProperty("maintainTimeStats", "false"); + config.addDataSourceProperty("connectionTimeZone", "+00:00"); + ds = new HikariDataSource(config); + } + + /** Return a pooled database connection. */ + private Connection getConnection() throws SQLException { + return ds.getConnection(); + } + + /** Return a JDBC URL for the given database. */ + private String getConnectionUrl(String database) { + return String.format("jdbc:mysql://%s/%s", server, database); + } + + /** Close all database resources. */ + public void close() throws SQLException { + if (ds != null) { + ds.close(); + ds = null; + } + } + + /** Insert state record(s) into the database. */ + public void addStateRecords(List records) throws SQLException { + if (ds == null) { + return; + } + if (records.isEmpty()) { + return; + } + + long startTime = System.nanoTime(); + try (Connection conn = getConnection()) { + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO `state` (`time`, `metric`, `value`, `serial`) " + + "VALUES (?, ?, ?, ?)" + ); + + // Disable auto-commit + boolean autoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + + try { + // Insert records + for (StateRecord record : records) { + Timestamp timestamp = + new Timestamp(record.timestamp * 1000); + stmt.setTimestamp(1, timestamp); + stmt.setString(2, record.metric); + stmt.setLong(3, record.value); + stmt.setString(4, record.serial); + stmt.addBatch(); + } + stmt.executeBatch(); + + // Commit changes + conn.commit(); + + logger.debug( + "Inserted {} state row(s) in {} ms", + records.size(), + (System.nanoTime() - startTime) / 1_000_000L + ); + } finally { + // Restore auto-commit state + conn.setAutoCommit(autoCommit); + } + } + } + + /** Return the latest state records for each unique device. */ + public Map getLatestState() throws SQLException { + if (ds == null) { + return null; + } + + Map ret = new HashMap<>(); + try (Connection conn = getConnection()) { + // Fetch latest (device, timestamp) records + Map deviceToTs = new HashMap<>(); + try (Statement stmt = conn.createStatement()) { + String sql = + "SELECT `serial`, `time` FROM `state` " + + "WHERE `id` IN (SELECT MAX(`id`) FROM `state` GROUP BY `serial`)"; + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + String serial = rs.getString(1); + Timestamp time = rs.getTimestamp(2); + deviceToTs.put(serial, time); + } + } + } + + if (deviceToTs.isEmpty()) { + return ret; // empty database + } + + // For each device, query all records at latest timestamp + PreparedStatement stmt = conn.prepareStatement( + "SELECT `metric`, `value` FROM `state` WHERE `serial` = ? AND `time` = ?" + ); + for (Map.Entry e : deviceToTs.entrySet()) { + String serial = e.getKey(); + Timestamp time = e.getValue(); + stmt.setString(1, serial); + stmt.setTimestamp(2, time); + + List records = new ArrayList<>(); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String metric = rs.getString(1); + long value = rs.getLong(2); + records.add( + new StateRecord(0 /*unused*/, time.getTime(), metric, value, serial) + ); + } + } + ret.put(serial, toState(records, time.getTime())); + } + } + return ret; + } + + /** + * Find and return a JsonObject from a JsonArray by key (matching a given + * string value), or insert a new JsonObject with this key-value entry if + * not found. + */ + private JsonObject getOrAddObjectFromArray( + JsonArray a, String key, String value + ) { + JsonObject ret = null; + for (int i = 0, n = a.size(); i < n; i++) { + JsonObject o = a.get(i).getAsJsonObject(); + if (o.get(key).getAsString().equals(value)) { + ret = o; + break; + } + } + if (ret == null) { + ret = new JsonObject(); + ret.addProperty(key, value); + a.add(ret); + } + return ret; + } + + /** Convert a list of state records to a State object. */ + private State toState(List records, long ts) { + State state = new State(); + state.unit = state.new Unit(); + state.unit.localtime = ts; + + // Parse each record + Map interfaces = new TreeMap<>(); + TreeMap radios = new TreeMap<>(); + for (StateRecord record : records) { + String[] tokens = record.metric.split(Pattern.quote(".")); + switch (tokens[0]) { + case "interface": + JsonObject iface = interfaces.computeIfAbsent( + tokens[1], k -> { + JsonObject o = new JsonObject(); + o.addProperty("name", k); + return o; + } + ); + if (tokens.length == 3) { + // counters + if (!iface.has("counters")) { + iface.add("counters", new JsonObject()); + } + JsonObject counters = iface.getAsJsonObject("counters"); + counters.addProperty(tokens[2], record.value); + } else if (tokens.length == 7 || tokens.length == 8) { + // ssids..associations. + String bssid = tokens[3]; + String clientBssid = tokens[5]; + if (!iface.has("ssids")) { + iface.add("ssids", new JsonArray()); + } + JsonArray ssids = iface.getAsJsonArray("ssids"); + JsonObject ssid = + getOrAddObjectFromArray(ssids, "bssid", bssid); + if (!ssid.has("associations")) { + ssid.add("associations", new JsonArray()); + } + JsonArray associations = ssid.getAsJsonArray("associations"); + JsonObject association = + getOrAddObjectFromArray(associations, "bssid", clientBssid); + String associationKey = tokens[6]; + if (tokens.length == 7) { + // primitive field + association.addProperty(associationKey, record.value); + } else { + // object (rate key) + if (!association.has(associationKey)) { + association.add(associationKey, new JsonObject()); + } + String rateKey = tokens[7]; + if ( + rateKey.equals("sgi") || rateKey.equals("ht") || + rateKey.equals("vht") || rateKey.equals("he") + ) { + // boolean field + association.getAsJsonObject(associationKey) + .addProperty(rateKey, record.value != 0); + } else { + // number field + association.getAsJsonObject(associationKey) + .addProperty(rateKey, record.value); + } + } + } + break; + case "radio": + JsonObject radio = radios.computeIfAbsent( + Integer.parseInt(tokens[1]), k -> new JsonObject() + ); + radio.addProperty(tokens[2], record.value); + break; + case "unit": + switch (tokens[1]) { + case "uptime": + state.unit.uptime = record.value; + break; + } + break; + } + } + + Gson gson = new Gson(); + state.interfaces = interfaces.values().stream() + .map(o -> gson.fromJson(o, State.Interface.class)) + .collect(Collectors.toList()) + .toArray(new State.Interface[0]); + state.radios = new JsonObject[radios.lastKey() + 1]; + for (Map.Entry entry : radios.entrySet()) { + state.radios[entry.getKey()] = entry.getValue(); + } + return state; + } + + /** Insert wifi scan results into the database. */ + public void addWifiScan( + String serialNumber, long ts, List entries + ) throws SQLException { + if (ds == null) { + return; + } + + long startTime = System.nanoTime(); + try (Connection conn = getConnection()) { + // Insert scan entry to "wifiscan" + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO `wifiscan` (`time`, `serial`) VALUES (?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + stmt.setTimestamp(1, new Timestamp(ts * 1000)); + stmt.setString(2, serialNumber); + int rows = stmt.executeUpdate(); + if (rows == 0) { + throw new SQLException( + "Adding wifiscan entry failed (insert returned no rows)" + ); + } + + // Retrieve generated "id" column + long scanId; + try (ResultSet generatedKeys = stmt.getGeneratedKeys()) { + if (!generatedKeys.next()) { + throw new SQLException( + "Adding wifiscan entry failed (missing generated ID)" + ); + } + scanId = generatedKeys.getLong(1); + } + stmt.close(); + + // Insert scan result entries to "wifiscan_results" + stmt = conn.prepareStatement( + "INSERT INTO `wifiscan_results` (" + + "`scan_id`, `bssid`, `ssid`, `lastseen`, `rssi`, `channel`" + + ") VALUES (?, ?, ?, ?, ?, ?)" + ); + for (WifiScanEntry entry : entries) { + long bssid = 0; + try { + bssid = Utils.macToLong(entry.bssid); + } catch (IllegalArgumentException e) { /* ignore */ } + stmt.setLong(1, scanId); + stmt.setLong(2, bssid); + stmt.setString(3, entry.ssid); + stmt.setLong(4, entry.lastseen); + stmt.setInt(5, entry.signal); + stmt.setInt(6, entry.channel); + stmt.addBatch(); + } + stmt.executeBatch(); + + logger.debug( + "Inserted wifi scan id {} with {} result(s) in {} ms", + scanId, + entries.size(), + (System.nanoTime() - startTime) / 1_000_000L + ); + } + } + + /** + * Return up to the N latest wifiscan results for the given device as a map + * of timestamp to scan results. + */ + public Map> getLatestWifiScans( + String serialNumber, int count + ) throws SQLException { + if (serialNumber == null || serialNumber.isEmpty()) { + throw new IllegalArgumentException("Invalid serialNumber"); + } + if (count < 1) { + throw new IllegalArgumentException("Invalid count"); + } + + if (ds == null) { + return null; + } + + Map> ret = new TreeMap<>(); + try (Connection conn = getConnection()) { + // Fetch latest N scan IDs + Map scanIdToTs = new HashMap<>(); + PreparedStatement stmt1 = conn.prepareStatement( + "SELECT `id`, `time` FROM `wifiscan` WHERE `serial` = ? " + + "ORDER BY `id` DESC LIMIT " + count + ); + stmt1.setString(1, serialNumber); + try (ResultSet rs = stmt1.executeQuery()) { + while (rs.next()) { + long id = rs.getLong(1); + Timestamp time = rs.getTimestamp(2); + scanIdToTs.put(id, time.getTime()); + } + } + stmt1.close(); + if (scanIdToTs.isEmpty()) { + return ret; // no results + } + + // Query all scan results + try (Statement stmt2 = conn.createStatement()) { + List scanIds = scanIdToTs.keySet().stream() + .map(i -> Long.toString(i)) + .collect(Collectors.toList()); + String sql = String.format( + "SELECT * FROM `wifiscan_results` WHERE `scan_id` IN (%s)", + String.join(",", scanIds) + ); + try (ResultSet rs = stmt2.executeQuery(sql)) { + while (rs.next()) { + long scanId = rs.getLong("scan_id"); + + WifiScanEntry entry = new WifiScanEntry(); + entry.channel = rs.getInt("channel"); + entry.lastseen = rs.getLong("lastseen"); + entry.signal = rs.getInt("rssi"); + entry.bssid = Utils.longToMac(rs.getLong("bssid")); + entry.ssid = rs.getString("ssid"); + entry.tsf = scanIdToTs.getOrDefault(scanId, 0L); + + ret.computeIfAbsent(scanId, i -> new ArrayList<>()) + .add(entry); + } + } + } + } + return ret; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/mysql/StateRecord.java b/src/main/java/com/facebook/openwifirrm/mysql/StateRecord.java new file mode 100644 index 0000000..87ddeae --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/mysql/StateRecord.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.mysql; + +/** + * Representation of a record in the "state" table. + */ +public class StateRecord { + public final long id; + public final long timestamp; + public final String metric; + public final long value; + public final String serial; + + /** Constructor (with empty "id"). */ + public StateRecord( + long timestamp, String metric, long value, String serial + ) { + this(0, timestamp, metric, value, serial); + } + + /** Constructor. */ + public StateRecord( + long id, long timestamp, String metric, long value, String serial + ) { + this.id = id; + this.timestamp = timestamp; + this.metric = metric; + this.value = value; + this.serial = serial; + } + + @Override + public String toString() { + return String.format( + "%s = %d [serial=%s, ts=%d]", metric, value, serial, timestamp + ); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/ChannelOptimizer.java b/src/main/java/com/facebook/openwifirrm/optimizers/ChannelOptimizer.java new file mode 100644 index 0000000..4b9fb52 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/ChannelOptimizer.java @@ -0,0 +1,683 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceConfig; +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.ConfigManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; +import com.facebook.openwifirrm.ucentral.models.State; + +/** + * Channel optimizer base class. + */ +public abstract class ChannelOptimizer { + private static final Logger logger = LoggerFactory.getLogger(ChannelOptimizer.class); + + /** Minimum supported channel width (MHz), inclusive. */ + public static final int MIN_CHANNEL_WIDTH = 20; + + /** String of the 2.4 GHz band */ + public static final String BAND_2G = "2G"; + + /** String of the 5 GHz band */ + public static final String BAND_5G = "5G"; + + /** Map of band to the band-specific lowest available channel*/ + protected static final Map LOWER_CHANNEL_LIMIT = new HashMap<>(); + static { + LOWER_CHANNEL_LIMIT.put(BAND_2G, 1); + LOWER_CHANNEL_LIMIT.put(BAND_5G, 36); + } + + /** Map of band to the band-specific highest available channel*/ + protected static final Map UPPER_CHANNEL_LIMIT = new HashMap<>(); + static { + UPPER_CHANNEL_LIMIT.put(BAND_2G, 11); + UPPER_CHANNEL_LIMIT.put(BAND_5G, 165); + } + + /** List of available channels per band for use. */ + protected static final Map> AVAILABLE_CHANNELS_BAND = + new HashMap<>(); + static { + AVAILABLE_CHANNELS_BAND.put( + BAND_5G, + Collections.unmodifiableList( + Arrays.asList(36, 40, 44, 48, 149, 153, 157, 161, 165)) + ); + AVAILABLE_CHANNELS_BAND.put( + BAND_2G, + Collections.unmodifiableList( + Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)) + ); + } + + /** Map of channel width (MHz) to available (primary) channels */ + protected static final Map> AVAILABLE_CHANNELS_WIDTH = + new HashMap<>(); + static { + AVAILABLE_CHANNELS_WIDTH.put( + 40, + Collections.unmodifiableList( + Arrays.asList(36, 44, 52, 60, 100, 108, 116, 124, 132, 140, 149, 157)) + ); + AVAILABLE_CHANNELS_WIDTH.put( + 80, + Collections.unmodifiableList( + Arrays.asList(36, 52, 100, 116, 132, 149)) + ); + AVAILABLE_CHANNELS_WIDTH.put( + 160, + Collections.unmodifiableList( + Arrays.asList(36, 100)) + ); + } + + /** + * Map of channel number to channel width (MHz) to its corresponding (primary) channels. + * The channel width is a list of fixed numbers (20, 40, 80, 160). + * */ + private static final Map> CHANNELS_WIDTH_TO_PRIMARY = + new HashMap<>(); + static { + CHANNELS_WIDTH_TO_PRIMARY.put(36, Arrays.asList(36, 36, 36, 36)); + CHANNELS_WIDTH_TO_PRIMARY.put(40, Arrays.asList(40, 36, 36, 36)); + CHANNELS_WIDTH_TO_PRIMARY.put(44, Arrays.asList(44, 44, 36, 36)); + CHANNELS_WIDTH_TO_PRIMARY.put(48, Arrays.asList(48, 44, 36, 36)); + CHANNELS_WIDTH_TO_PRIMARY.put(52, Arrays.asList(52, 52, 52, 36)); + CHANNELS_WIDTH_TO_PRIMARY.put(56, Arrays.asList(56, 52, 52, 36)); + CHANNELS_WIDTH_TO_PRIMARY.put(60, Arrays.asList(60, 60, 52, 36)); + CHANNELS_WIDTH_TO_PRIMARY.put(64, Arrays.asList(64, 60, 52, 36)); + CHANNELS_WIDTH_TO_PRIMARY.put(100, Arrays.asList(100, 100, 100, 100)); + CHANNELS_WIDTH_TO_PRIMARY.put(104, Arrays.asList(104, 100, 100, 100)); + CHANNELS_WIDTH_TO_PRIMARY.put(108, Arrays.asList(108, 108, 100, 100)); + CHANNELS_WIDTH_TO_PRIMARY.put(112, Arrays.asList(112, 108, 100, 100)); + CHANNELS_WIDTH_TO_PRIMARY.put(116, Arrays.asList(116, 116, 116, 100)); + CHANNELS_WIDTH_TO_PRIMARY.put(120, Arrays.asList(120, 116, 116, 100)); + CHANNELS_WIDTH_TO_PRIMARY.put(124, Arrays.asList(124, 124, 116, 100)); + CHANNELS_WIDTH_TO_PRIMARY.put(128, Arrays.asList(128, 124, 116, 100)); + CHANNELS_WIDTH_TO_PRIMARY.put(132, Arrays.asList(132, 132, 132)); + CHANNELS_WIDTH_TO_PRIMARY.put(136, Arrays.asList(136, 132, 132)); + CHANNELS_WIDTH_TO_PRIMARY.put(140, Arrays.asList(140, 140, 132)); + CHANNELS_WIDTH_TO_PRIMARY.put(144, Arrays.asList(144, 140, 132)); + CHANNELS_WIDTH_TO_PRIMARY.put(149, Arrays.asList(149, 149, 149)); + CHANNELS_WIDTH_TO_PRIMARY.put(153, Arrays.asList(153, 149, 149)); + CHANNELS_WIDTH_TO_PRIMARY.put(157, Arrays.asList(157, 157, 149)); + CHANNELS_WIDTH_TO_PRIMARY.put(161, Arrays.asList(161, 157, 149)); + CHANNELS_WIDTH_TO_PRIMARY.put(165, Arrays.asList(165)); + } + + /** List of priority channels on 2.4GHz. */ + protected static final List PRIORITY_CHANNELS_2G = + Collections.unmodifiableList(Arrays.asList(1, 6, 11)); + + /** The input data model. */ + protected final DataModel model; + + /** The RF zone. */ + protected final String zone; + + /** The device configs within {@link #zone}, keyed on serial number. */ + protected final Map deviceConfigs; + + /** Constructor. */ + public ChannelOptimizer( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + this.model = model; + this.zone = zone; + this.deviceConfigs = deviceDataManager.getAllDeviceConfigs(zone); + + // Remove model entries not in the given zone + this.model.latestWifiScans.keySet().removeIf(serialNumber -> + !deviceConfigs.containsKey(serialNumber) + ); + this.model.latestState.keySet().removeIf(serialNumber -> + !deviceConfigs.containsKey(serialNumber) + ); + this.model.latestDeviceStatus.keySet().removeIf(serialNumber -> + !deviceConfigs.containsKey(serialNumber) + ); + this.model.latestDeviceCapabilities.keySet().removeIf(serialNumber -> + !deviceConfigs.containsKey(serialNumber) + ); + } + + /** + * Get the primary channel depending on the given channel and channel width. + * @param channel the current channel (from the scan result) + * @param channelWidth the channel bandwidth (MHz) + * @return the primary channel, 0 if error + */ + protected static int getPrimaryChannel( + int channel, + int channelWidth + ) { + if (CHANNELS_WIDTH_TO_PRIMARY.get(channel) == null) { + return 0; + } + + int index = (int) (Math.log(channelWidth/20) / Math.log(2)); + if (CHANNELS_WIDTH_TO_PRIMARY.get(channel).size() > index) { + return CHANNELS_WIDTH_TO_PRIMARY.get(channel).get(index); + } else { + return 0; + } + } + + /** Convert the input string into a byte array. */ + private static byte[] decodeBase64(String s) { + try { + return Base64.getDecoder().decode(s); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * Get the channel width based on the HT operation and VHT operation in wifi scan. + * @param channel the current channel (from the scan result) + * @param htOper the HT operation information element + * @param vhtOper the VHT operation information element + * @return the channel width, default = MIN_CHANNEL_WIDTH + */ + protected static int getChannelWidthFromWiFiScan( + int channel, + String htOper, + String vhtOper + ) { + if (AVAILABLE_CHANNELS_BAND.get(BAND_2G).contains(channel)) { + // 2.4G, it only supports 20 MHz + return 20; + } + if (htOper == null) { + // Obsolete OpenWiFi APs (< v2.5) + return MIN_CHANNEL_WIDTH; + } + + // Decode HT operation information element + byte[] htOperDecode = decodeBase64(htOper); + if (htOperDecode == null) { + return MIN_CHANNEL_WIDTH; + } + // The second byte of HT operation contains the channel width information. + // The third bit (so we & 4 = 100 below) in the second byte is STA channel width. + // 0 for a 20 MHz channel width, 1 for any other channel widths. + int htChannelWidth = htOperDecode[1] & 4; + + if (vhtOper == null) { + // HT mode, it only supports 20/40 MHz + // Therefore, htChannelWidth > 0 means 40 MHz, htChannelWidth = 0 means 20 MHz. + if (htChannelWidth == 0) { + return 20; + } else { + return 40; + } + } else { + // VHT/HE mode, it supports 20/40/160/8080 MHz + + // Decode VHT operation information element + byte[] vhtOperDecode = decodeBase64(vhtOper); + if (vhtOperDecode == null) { + return MIN_CHANNEL_WIDTH; + } + // The first byte of VHT operation is channel width, which takes values 0-3. + // 0 for 20 MHz or 40 MHz + // 1 for 80 MHz, 160 MHz , or 80+80 MHz BSS bandwidth + // 2 and 3 are deprecated + int vhtChannelWidth = vhtOperDecode[0] & 255; + // The second byte of VHT operation is channel center frequency segment 0. + // For 80 MHz BSS bandwidth, indicates the channel center frequency index + // for the 80 MHz channel on which the VHT BSS operates. + // For 160 MHz or 80+80 MHz BSS bandwidth, it contains the channel center + // frequency index for the first 80 MHz channel. + int channelOffset1 = vhtOperDecode[1] & 255; + // The third byte of VHT operation is channel center frequency segment 1. + // For a 20, 40, or 80 MHz BSS bandwidth, this subfield is set to 0. + // For 160 MHz or 80+80 MHz BSS bandwidth, it contains the channel center + // frequency index for the second 80 MHz channel. + int channelOffset2 = vhtOperDecode[2] & 255; + + if (htChannelWidth == 0 && vhtChannelWidth == 0) { + return 20; + } else if (htChannelWidth > 0 && vhtChannelWidth == 0) { + return 40; + } else if ( + htChannelWidth > 0 && + vhtChannelWidth == 1 && + channelOffset2 == 0 + ) { + return 80; + } else if ( + htChannelWidth > 0 && + vhtChannelWidth == 1 && + channelOffset2 != 0 + ) { + // if it is 160 MHz, it use two consecutive 80 MHz bands + // the difference of 8 means it is consecutive + if (Math.abs(channelOffset1 - channelOffset2) == 8) { + return 160; + } else { + return 8080; + } + } else { + return MIN_CHANNEL_WIDTH; + } + } + + } + + /** + * Get the actual covered channels of a neighboring AP + * based on the given channel and channel width. + * @param channel the current channel (from the scan result) + * @param primaryChannel the primary channel corresponding to the channelWidth + * @param channelWidth the channel bandwidth (MHz) + * @return the list of the covered channels, the current channel if it is 2.4 GHz or error + */ + protected static List getCoveredChannels( + int channel, + int primaryChannel, + int channelWidth + ) { + if (primaryChannel == 0) { + // if it is 2.4 GHz or the AP doesn't support this feature + return Arrays.asList(channel); + } + int numOfChannels = channelWidth/20; + List coveredChannels = new ArrayList<>(numOfChannels); + for (int index = 0; index < numOfChannels; index++) { + coveredChannels.add(primaryChannel + index * 4); + } + return coveredChannels; + } + + /** + * Get the filtered and reorganized wifiscan results per device. + * @param band the operational band + * @param latestWifiScans the raw wifiscan results from upstream + * @param bandsMap the participated OWF APs on this band + * @return map of device (serial number) to wifiscan results + */ + protected static Map> getDeviceToWiFiScans( + String band, + Map>> latestWifiScans, + Map> bandsMap + ) { + Map> deviceToWifiScans = new HashMap<>(); + int maxChannel = UPPER_CHANNEL_LIMIT.get(band); + int minChannel = LOWER_CHANNEL_LIMIT.get(band); + + for ( + Map.Entry>> e : + latestWifiScans.entrySet() + ) { + String serialNumber = e.getKey(); + + if (!bandsMap.get(band).contains(serialNumber)) { + // 1. Filter out APs without radio on a specific band + logger.debug( + "Device {}: No {} radio, skipping...", + serialNumber, + band + ); + continue; + } + + List> wifiScanList = e.getValue(); + if (wifiScanList.isEmpty()) { + // 2. Filter out APs with empty scan results + logger.debug( + "Device {}: Empty wifi scan results, skipping...", + serialNumber + ); + continue; + } + + // 1. Remove the wifi scan results on different bands + // 2. Duplicate the wifi scan result from a channel to multiple channels + // if the neighboring AP is using a wider bandwidth (> 20 MHz) + List scanResps = wifiScanList.get(wifiScanList.size() - 1); + List scanRespsFiltered = new ArrayList(); + for (WifiScanEntry entry : scanResps) { + if (entry.channel <= maxChannel && entry.channel >= minChannel) { + int channelWidth = getChannelWidthFromWiFiScan( + entry.channel, + entry.ht_oper, + entry.vht_oper + ); + int primaryChannel = getPrimaryChannel(entry.channel, channelWidth); + List coveredChannels = + getCoveredChannels(entry.channel, primaryChannel, channelWidth); + for (Integer newChannel : coveredChannels) { + WifiScanEntry newEntry = new WifiScanEntry(entry); + newEntry.channel = newChannel; + scanRespsFiltered.add(newEntry); + } + } + } + + if (scanRespsFiltered.size() == 0) { + // 3. Filter out APs with empty scan results (on a particular band) + logger.debug( + "Device {}: Empty wifi scan results on {} band, skipping..", + serialNumber, + band + ); + continue; + } + + deviceToWifiScans.put( + serialNumber, scanRespsFiltered + ); + } + return deviceToWifiScans; + } + + /** + * Get the current channel and channel width (MHz) of the device (from state data). + * @param band the operational band + * @param serialNumber the device + * @param state the latest state of all the devices + * @return the current channel and channel width (MHz) of the device + */ + protected static int[] getCurrentChannel( + String band, + String serialNumber, + State state + ) { + int maxChannel = UPPER_CHANNEL_LIMIT.get(band); + int minChannel = LOWER_CHANNEL_LIMIT.get(band); + int currentChannel = 0; + int currentChannelWidth = MIN_CHANNEL_WIDTH; + // Use the channel value to check the corresponding radio for the band + for ( + int radioIndex = 0; + radioIndex < state.radios.length; + radioIndex++ + ) { + int tempChannel = state.radios[radioIndex] + .get("channel") + .getAsInt(); + if (tempChannel <= maxChannel && tempChannel >= minChannel) { + currentChannel = tempChannel; + currentChannelWidth = state.radios[radioIndex] + .get("channel_width") + .getAsInt(); + break; + } + } + return new int[] {currentChannel, currentChannelWidth}; + } + + /** + * Update the available channels based on bandwidth-specific, user, allowed channels + * (the last two are from deviceConfig). + * @param band the operational band + * @param serialNumber the device + * @param channelWidth the channel bandwidth (MHz) + * @param availableChannelsList the available channels of the device + * @return the updated available channels of the device + */ + protected List updateAvailableChannelsList( + String band, + String serialNumber, + int channelWidth, + List availableChannelsList + ) { + List newAvailableChannelsList = new ArrayList<>(availableChannelsList); + + // Update the available channels if the bandwidth info is taken into account + if (band.equals(BAND_5G) && channelWidth > 20) { + newAvailableChannelsList.retainAll( + AVAILABLE_CHANNELS_WIDTH.getOrDefault(channelWidth, availableChannelsList) + ); + } + + // Update the available channels based on user channels or allowed channels + DeviceConfig deviceCfg = deviceConfigs.get(serialNumber); + if (deviceCfg == null) { + return newAvailableChannelsList; + } + if ( + deviceCfg.userChannels != null && + deviceCfg.userChannels.get(band) != null + ) { + newAvailableChannelsList = Arrays.asList( + deviceCfg.userChannels.get(band) + ); + logger.debug( + "Device {}: userChannels {}", + serialNumber, + deviceCfg.userChannels.get(band) + ); + } else if ( + deviceCfg.allowedChannels != null && + deviceCfg.allowedChannels.get(band) != null + ) { + List allowedChannels = deviceCfg.allowedChannels.get(band); + logger.debug( + "Device {}: allowedChannels {}", + serialNumber, + allowedChannels + ); + newAvailableChannelsList.retainAll(allowedChannels); + } + + // If the intersection of the above steps gives an empty list, + // turn back to use the default available channels list + if (newAvailableChannelsList.isEmpty()) { + logger.debug( + "Device {}: the updated availableChannelsList is empty!!! " + + "userChannels or allowedChannels might be invalid " + + "Fall back to the default available channels list" + ); + if (band.equals(BAND_5G) && channelWidth > 20) { + newAvailableChannelsList = new ArrayList<>(availableChannelsList); + newAvailableChannelsList.retainAll( + AVAILABLE_CHANNELS_WIDTH.getOrDefault(channelWidth, availableChannelsList) + ); + } else { + newAvailableChannelsList = availableChannelsList; + } + } + logger.debug( + "Device {}: the updated availableChannelsList is {}", + serialNumber, + newAvailableChannelsList + ); + return newAvailableChannelsList; + } + + /** + * Calculate the performance metrics based on the given assignment. + * @param tempChannelMap the map of device (serial number) to its given channel + * @param deviceToWifiScans the map of device (serial number) to wifiscan results + * @param bssidsMap the map of bssid to device (serial number) + * @param mode true for the new assignment and false for the current assignment + */ + protected void calculatePerfMetrics( + Map tempChannelMap, + Map> deviceToWifiScans, + Map bssidsMap, + boolean mode + ) { + for (Map.Entry e: tempChannelMap.entrySet()) { + String serialNumber = e.getKey(); + int channel = e.getValue(); + double avgInterferenceDB = 0.0; + double sumInterference = 0.0; + double maxInterferenceDB = Double.NEGATIVE_INFINITY; + double numEntries = 0.0; + Map owfSignal = new HashMap<>(); + Map channelOccupancy = new HashMap<>(); + + // Calculate the co-channel interference + List scanResps = deviceToWifiScans.get(serialNumber); + if (scanResps != null) { + for (WifiScanEntry entry : scanResps) { + // Store the detected signal of the OWF APs + // for the new assignment calculation + if (mode && bssidsMap.containsKey(entry.bssid)) { + owfSignal.put(bssidsMap.get(entry.bssid), entry.signal); + continue; + } + channelOccupancy.compute( + entry.channel, (k, v) -> (v == null) ? 1 : v + 1 + ); + if (entry.channel == channel) { + double signal = entry.signal; + avgInterferenceDB += signal; + sumInterference += Math.pow(10.0, signal/10.0); + maxInterferenceDB = Math.max(maxInterferenceDB, signal); + numEntries += 1.0; + } + } + } + + // Calculate the co-channel interference and channel occupancy + // based on the new assignment of the OWF APs + if (mode) { + for (Map.Entry f: tempChannelMap.entrySet()) { + String nSerialNumber = f.getKey(); + int nChannel = f.getValue(); + if ( + serialNumber == nSerialNumber || + owfSignal.get(nSerialNumber) == null + ) { + continue; + } + // If the nearby OWF AP is able to be detected by this AP, + // it should be part of the channel occupancy calculation. + channelOccupancy.compute( + nChannel, (k, v) -> (v == null) ? 1 : v + 1 + ); + // Only if the nearby OWF AP is on the same channel of this AP, + // it contributes to the "co-channel" interference. + if (channel == nChannel) { + double signal = owfSignal.get(nSerialNumber); + avgInterferenceDB += signal; + sumInterference += Math.pow(10.0, signal/10.0); + maxInterferenceDB = Math.max(maxInterferenceDB, signal); + numEntries += 1.0; + } + } + } + + // Add self into the channel occupancy calculation + channelOccupancy.compute( + channel, (k, v) -> (v == null) ? 1 : v + 1 + ); + + // Log the interference info + logger.info( + "Device {} on channel {} with average interference: {}, " + + "sum interference: {}, max interference: {}, number of nearby APs: {}", + serialNumber, + channel, + avgInterferenceDB/numEntries, + 10*Math.log10(sumInterference), + maxInterferenceDB, + numEntries + ); + + // Log the channel occupancy info + logger.info( + "Device {}: its view of channel occupancy: {}", + serialNumber, + channelOccupancy + ); + } + } + + /** + * Log the performance metrics before and after the algorithm. + * @param oldChannelMap the map of device (serial number) to its current channel + * @param newChannelMap the map of device (serial number) to its new channel + * @param deviceToWifiScans the map of device (serial number) to wifiscan results + * @param bssidsMap the map of bssid to device (serial number) + */ + protected void logPerfMetrics( + Map oldChannelMap, + Map newChannelMap, + Map> deviceToWifiScans, + Map bssidsMap + ) { + // Calculate the old performance + calculatePerfMetrics(oldChannelMap, deviceToWifiScans, bssidsMap, false); + + // Calculate the new performance + calculatePerfMetrics(newChannelMap, deviceToWifiScans, bssidsMap, true); + + // Calculate the number of channel changes + int numOfChannelChanges = 0; + for (Map.Entry e : newChannelMap.entrySet()) { + // Check whether the channel is changed or not + // The key of the newChannelMap and oldChannelMap is the same + int newChannel = e.getValue(); + int oldChannel = oldChannelMap.getOrDefault(e.getKey(), 0); + if (newChannel != oldChannel) { + numOfChannelChanges++; + } + } + logger.info( + "Total number of channel changes: {}", + numOfChannelChanges + ); + } + + /** + * Compute channel assignments. + * @return the map of devices (by serial number) to radio to channel + */ + public abstract Map> computeChannelMap(); + + /** + * Program the given channel map into the AP config and notify the config + * manager. + * + * @param deviceDataManager the DeviceDataManager instance + * @param configManager the ConfigManager instance + * @param channelMap the map of devices (by serial number) to radio to channel + */ + public void applyConfig( + DeviceDataManager deviceDataManager, + ConfigManager configManager, + Map> channelMap + ) { + // Update device AP config layer + deviceDataManager.updateDeviceApConfig(apConfig -> { + for ( + Map.Entry> entry : + channelMap.entrySet() + ) { + DeviceConfig deviceConfig = apConfig.computeIfAbsent( + entry.getKey(), k -> new DeviceConfig() + ); + deviceConfig.autoChannels = entry.getValue(); + } + }); + + // Trigger config update now + configManager.wakeUp(); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/LeastUsedChannelOptimizer.java b/src/main/java/com/facebook/openwifirrm/optimizers/LeastUsedChannelOptimizer.java new file mode 100644 index 0000000..d37674a --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/LeastUsedChannelOptimizer.java @@ -0,0 +1,405 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; +import com.facebook.openwifirrm.ucentral.UCentralUtils; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; +import com.facebook.openwifirrm.ucentral.models.State; + +/** + * Least used channel optimizer. + *

+ * Randomly assign APs to the least loaded channels. + */ +public class LeastUsedChannelOptimizer extends ChannelOptimizer { + private static final Logger logger = LoggerFactory.getLogger(LeastUsedChannelOptimizer.class); + + /** The window size for overlapping channels. */ + protected static final int OVERLAP_WINDOW = 4; + + /** The PRNG instance. */ + protected final Random rng = new Random(); + + /** Constructor. */ + public LeastUsedChannelOptimizer( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + super(model, zone, deviceDataManager); + } + + /** + * Get the sorted APs list to determine the visit ordering. + * @param deviceToWifiScans the filtered and reorganized wifiscan results + * @return list of the name of the sorted APs + */ + protected static List getSortedAPs( + Map> deviceToWifiScans + ) { + return deviceToWifiScans.entrySet() + .stream() + .sorted( + (e1, e2) -> + Integer.compare(e2.getValue().size(), e1.getValue().size()) + ) + .map(e -> e.getKey()) + .collect(Collectors.toList()); + } + + /** + * Update the occupied channel info to include the overlapping channels (for 2.4G). + * @param occupiedChannels the current occupied channels info of the device + * @return map of channel to score/weight + */ + protected static Map getOccupiedOverlapChannels( + Map occupiedChannels + ) { + int maxChannel = UPPER_CHANNEL_LIMIT.get(BAND_2G); + int minChannel = LOWER_CHANNEL_LIMIT.get(BAND_2G); + Map occupiedOverlapChannels = new TreeMap<>(); + for (int overlapChannel : AVAILABLE_CHANNELS_BAND.get(BAND_2G)) { + int occupancy = 0; + int windowStart = Math.max( + minChannel, + overlapChannel - OVERLAP_WINDOW + ); + int windowEnd = Math.min( + maxChannel, + overlapChannel + OVERLAP_WINDOW + ); + for (int i = windowStart; i <= windowEnd; i++) { + // Sum up # STAs/APs for a channel within a window + occupancy += occupiedChannels.getOrDefault(i, 0); + } + if (occupancy != 0) { + occupiedOverlapChannels.put(overlapChannel, occupancy); + } + } + return occupiedOverlapChannels; + } + + /** + * Get the wifiscan results based on the bandwidth info + * @param band the operational band + * @param serialNumber the device + * @param channelWidth the channel bandwidth (MHz) + * @param deviceToWifiScans the filtered and reorganized wifiscan results + * @return the wifiscan results on the bandwidth-specific primary channels + */ + protected List getScanRespsByBandwidth( + String band, + String serialNumber, + int channelWidth, + Map> deviceToWifiScans + ) { + List scanResps = deviceToWifiScans.get(serialNumber); + + // 2.4G only supports 20 MHz bandwidth + if (band.equals(BAND_2G)) { + return scanResps; + } + + // Aggregate the scan results into the primary channels based on the bandwidth info + // For example, if the scan results are channels 36 and 40 and channel width is 40, + // the aggregated scan results will change the one on channel 40 to channel 36 by + // checking CHANNELS_WIDTH_TO_PRIMARY. + List scanRespsProcessed = new ArrayList(); + Map> channelDeviceMap = new TreeMap<>(); + for (WifiScanEntry entry : scanResps) { + int primaryChannel = getPrimaryChannel(entry.channel, channelWidth); + if (primaryChannel == 0) { + continue; + } + if (channelDeviceMap.get(primaryChannel) != null) { + continue; + } + WifiScanEntry newEntry = new WifiScanEntry(entry); + newEntry.channel = primaryChannel; + scanRespsProcessed.add(newEntry); + } + return scanRespsProcessed; + } + + /** + * Get the current occupied channel info of the device. + * @param band the operational band + * @param serialNumber the device + * @param channelWidth the channel bandwidth (MHz) + * @param availableChannelsList the available channels of the device + * @param deviceToWifiScans the filtered and reorganized wifiscan results + * @return map of channel to score/weight/# APs + */ + protected Map getOccupiedChannels( + String band, + String serialNumber, + int channelWidth, + List availableChannelsList, + Map> deviceToWifiScans, + Map> channelMap, + Map bssidsMap + ) { + // Find occupied channels (and # associated stations) + Map occupiedChannels = new TreeMap<>(); + List scanResps = getScanRespsByBandwidth( + band, + serialNumber, + channelWidth, + deviceToWifiScans + ); + + // Get the occupied channels information + for (WifiScanEntry entry : scanResps) { + occupiedChannels.compute( + entry.channel, (k, v) -> (v == null) ? 1 : v + 1 + ); + } + + // For 2.4G, we prioritize the orthogonal channels + // by considering the overlapping channels + if (band.equals(BAND_2G)) { + Map occupiedOverlapChannels = + getOccupiedOverlapChannels(occupiedChannels); + occupiedChannels = new TreeMap<>(occupiedOverlapChannels); + } + logger.debug( + "Device {}: Occupied channels: {} with total # entries: {}", + serialNumber, + occupiedChannels.keySet().toString(), + occupiedChannels.values().stream().mapToInt(i -> i).sum() + ); + return occupiedChannels; + } + + /** + * Get a new/current channel for the device. + * @param band the operational band + * @param serialNumber the device + * @param availableChannelsList the available channels of the device + * @param currentChannel the current channel of the device (for comparison) + * @param occupiedChannels the occupied channels info of the device + * @return the new/current channel of the device + */ + protected int getNewChannel( + String band, + String serialNumber, + List availableChannelsList, + int currentChannel, + Map occupiedChannels + ) { + int newChannel = 0; + + // If userChannel is specified or the availableChannelsList only has one element + if (availableChannelsList.size() == 1) { + newChannel = availableChannelsList.get(0); + logger.info( + "Device {}: only one channel is available, assigning to {}", + serialNumber, + newChannel + ); + return newChannel; + } + + // If no APs on the same channel, keep this channel + if ( + !occupiedChannels.containsKey(currentChannel) && + availableChannelsList.contains(currentChannel) + ) { + logger.info( + "Device {}: No APs on current channel {}, assigning to {}", + serialNumber, + currentChannel, + currentChannel + ); + newChannel = currentChannel; + } else { + // Remove occupied channels from list of possible channels + List candidateChannels = + new ArrayList<>(availableChannelsList); + candidateChannels.removeAll(occupiedChannels.keySet()); + if (candidateChannels.isEmpty()) { + // No free channels: assign AP to least occupied channel + // Need to update the occupied channels based on the available channels + Map newOccupiedChannels = new TreeMap<>(); + for (Map.Entry e : occupiedChannels.entrySet()) { + if (availableChannelsList.contains(e.getKey())) { + newOccupiedChannels.put(e.getKey(), e.getValue()); + } + } + Map.Entry entry = + newOccupiedChannels.entrySet() + .stream() + .min( + (a, b) -> + Integer.compare(a.getValue(), b.getValue())) + .get(); + logger.info( + "Device {}: No free channels, assigning to least " + + "weighted/occupied channel {} (weight: {}), {}", + serialNumber, + entry.getKey(), + entry.getValue(), + newOccupiedChannels + ); + newChannel = entry.getKey(); + } else { + // Prioritize channels 1, 6, and/or 11 for 2G + // if any of them is in the candidate list + if (band.equals(BAND_2G)) { + Set priorityMap = new HashSet<>( + PRIORITY_CHANNELS_2G + ); + List priorityChannels = new ArrayList<>(); + for ( + int chnIndex = 0; + chnIndex < candidateChannels.size(); + chnIndex++ + ) { + int tempChannel = candidateChannels.get(chnIndex); + if (priorityMap.contains(tempChannel)) { + priorityChannels.add(tempChannel); + } + } + if (!priorityChannels.isEmpty()) { + logger.info( + "Device {}: Update candidate channels to {} (was {})", + serialNumber, + priorityChannels, + candidateChannels + ); + candidateChannels = priorityChannels; + } + } + // Randomly assign to any free channel + int channelIndex = rng.nextInt(candidateChannels.size()); + newChannel = candidateChannels.get(channelIndex); + logger.info( + "Device {}: Assigning to random free channel {} (from " + + "available list: {})", + serialNumber, + newChannel, + candidateChannels.toString() + ); + } + } + return newChannel; + } + + @Override + public Map> computeChannelMap() { + Map> channelMap = new TreeMap<>(); + Map> bandsMap = UCentralUtils + .getBandsMap(model.latestDeviceStatus); + + Map>> deviceAvailableChannels = + UCentralUtils.getDeviceAvailableChannels( + model.latestDeviceStatus, + model.latestDeviceCapabilities, + AVAILABLE_CHANNELS_BAND + ); + + Map bssidsMap = UCentralUtils.getBssidsMap(model.latestState); + + for (String band : bandsMap.keySet()) { + // Performance metrics + Map oldChannelMap = new TreeMap<>(); + Map newChannelMap = new TreeMap<>(); + + // Only use last wifi scan result for APs (TODO) + Map> deviceToWifiScans = getDeviceToWiFiScans( + band, model.latestWifiScans, bandsMap + ); + + // Order by number of nearby APs detected in wifi scan (descending) + List sortedAPs = getSortedAPs(deviceToWifiScans); + + // Assign channel to each AP + for (String serialNumber : sortedAPs) { + // Get available channels of the device + List availableChannelsList = deviceAvailableChannels + .get(band).get(serialNumber); + if (availableChannelsList == null || availableChannelsList.isEmpty()) { + availableChannelsList = AVAILABLE_CHANNELS_BAND.get(band); + } + + // Get current channel of the device + State state = model.latestState.get(serialNumber); + if (state == null) { + logger.debug( + "Device {}: No state found, skipping...", + serialNumber + ); + continue; + } + if (state.radios == null || state.radios.length == 0) { + logger.debug( + "Device {}: No radios found, skipping...", + serialNumber + ); + continue; + } + int[] currentChannelInfo = getCurrentChannel(band, serialNumber, state); + int currentChannel = currentChannelInfo[0]; + int currentChannelWidth = currentChannelInfo[1]; + // Filter out APs if the number of radios in the state and config mismatches + // Happen when an AP's radio is enabled/disabled on the fly + if (currentChannel == 0) { + logger.debug( + "Device {}: No {} radio, skipping...", + serialNumber, + band + ); + continue; + } + + // Get the occupied channels info of the device + Map occupiedChannels = getOccupiedChannels( + band, serialNumber, currentChannelWidth, availableChannelsList, + deviceToWifiScans, channelMap, bssidsMap + ); + + // Update the availableChannelsList by usersChannels and allowedChannels + availableChannelsList = updateAvailableChannelsList( + band, serialNumber, currentChannelWidth, availableChannelsList + ); + + // Get a (new) channel of the device + int newChannel = getNewChannel( + band, serialNumber, availableChannelsList, + currentChannel, occupiedChannels + ); + + channelMap.computeIfAbsent( + serialNumber, k -> new TreeMap<>() + ) + .put(band, newChannel); + + // Gather the info for the performance metrics + oldChannelMap.put(serialNumber, currentChannel); + newChannelMap.put(serialNumber, newChannel); + } + // Get and log the performance metrics + logPerfMetrics(oldChannelMap, newChannelMap, deviceToWifiScans, bssidsMap); + } + + return channelMap; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/LocationBasedOptimalTPC.java b/src/main/java/com/facebook/openwifirrm/optimizers/LocationBasedOptimalTPC.java new file mode 100644 index 0000000..9973031 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/LocationBasedOptimalTPC.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceConfig; +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; +import com.facebook.openwifirrm.ucentral.models.State; +import com.facebook.openwifirrm.modules.ModelerUtils; + +/** + * Location-based optimal TPC algorithm. + *

+ * Assign tx power based on an exhaustive search algorithm given the AP location. + */ +public class LocationBasedOptimalTPC extends TPC { + private static final Logger logger = LoggerFactory.getLogger(LocationBasedOptimalTPC.class); + + /** Constructor. */ + public LocationBasedOptimalTPC( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + super(model, zone, deviceDataManager); + } + + /** + * Iterative way to generate permutations with repetitions + * @param choices all the choices to be considered + * @param n the number of repetitions + * @return the list of all the combinations + */ + protected static List> getPermutationsWithRepetitions( + List choices, + int n + ) { + int choicesSize = choices.size(); + int permutationsSize = (int) Math.pow(choicesSize, n); + List> permutations = new ArrayList<>(permutationsSize); + for (int index = 0; index < n; index++) { + int choiceIndex = 0; + int switchIndex = permutationsSize / (int) Math.pow(choicesSize, index + 1); + for (int pIndex = 0; pIndex < permutationsSize; pIndex++) { + if (index == 0) { + permutations.add(new ArrayList<>(n)); + } + if (pIndex != 0 && pIndex % switchIndex == 0) { + choiceIndex = (choiceIndex + 1) % choicesSize; + } + permutations.get(pIndex).add(choices.get(choiceIndex)); + } + } + return permutations; + } + + /** + * Get the optimal TX power for all the participant APs + * @param sampleSpace the boundary of the space + * @param numOfAPs the number of APs + * @param apLocX the location x of the APs + * @param apLocY the location y of the APs + * @param txPowerChoices the TX power options in consideration + * @return the TX power of each device + */ + public static List runLocationBasedOptimalTPC( + int sampleSpace, + int numOfAPs, + List apLocX, + List apLocY, + List txPowerChoices + ) { + // Get all the permutations with repetition + List> permutations = + getPermutationsWithRepetitions(txPowerChoices, numOfAPs); + int optimalIndex = permutations.size(); + double optimalMetric = Double.POSITIVE_INFINITY; + logger.info( + "Number of TX power combinations: {}", + permutations.size() + ); + + // Iterate all the combinations and get the metrics + // Record the combination yielding the minimum metric (optimal) + for (int pIndex = 0; pIndex < permutations.size(); pIndex++) { + List txPowerTemp = permutations + .get(pIndex) + .stream() + .mapToDouble(i->i) + .boxed() + .collect(Collectors.toList());; + double[][][] rxPower = ModelerUtils + .generateRxPower(sampleSpace, numOfAPs, apLocX, apLocY, txPowerTemp); + double[][] heatMap = ModelerUtils + .generateHeatMap(sampleSpace, numOfAPs, rxPower); + double[][] sinr = ModelerUtils + .generateSinr(sampleSpace, numOfAPs, rxPower); + double metric = ModelerUtils + .calculateTPCMetrics(sampleSpace, heatMap, sinr); + if (metric < optimalMetric) { + optimalMetric = metric; + optimalIndex = pIndex; + } + } + if (optimalIndex == permutations.size()) { + return Collections.nCopies(numOfAPs, 30); + } else { + return permutations.get(optimalIndex); + } + } + + @Override + public Map> computeTxPowerMap() { + // (TODO) Only support 5G radio now + Map> txPowerMap = new TreeMap<>(); + + int numOfAPs = 0; + int boundary = 100; + Map validAPs = new TreeMap<>(); + List apLocX = new ArrayList<>(); + List apLocY = new ArrayList<>(); + List txPowerChoices = IntStream + .rangeClosed(MIN_TX_POWER, MAX_TX_POWER) + .boxed() + .collect(Collectors.toList()); + + // Filter out the invalid APs (e.g., no radio, no location data) + // Update txPowerChoices, boundary, apLocX, apLocY for the optimization + for (Map.Entry e : model.latestState.entrySet()) { + String serialNumber = e.getKey(); + State state = e.getValue(); + + // Ignore the device if its radio is not active + if (state.radios == null || state.radios.length == 0) { + logger.debug( + "Device {}: No radios found, skipping...", serialNumber + ); + continue; + } + // Ignore the device if the location data is missing + DeviceConfig deviceCfg = deviceConfigs.get(serialNumber); + if (deviceCfg == null || deviceCfg.location == null) { + logger.debug( + "Device {}: No location data, skipping...", serialNumber + ); + continue; + } + // (TODO) We currently only support 2D map. Need to support 3D later. + // Generate the required location data for the optimization + if ( + deviceCfg.location.size() == 2 && + deviceCfg.location.get(0) >= 0 && + deviceCfg.location.get(1) >= 0 + ) { + apLocX.add(deviceCfg.location.get(0).doubleValue()); + apLocY.add(deviceCfg.location.get(1).doubleValue()); + validAPs.put(serialNumber, numOfAPs); + numOfAPs++; + } else { + logger.error( + "Device {}: the location data is invalid, skipping...", + serialNumber + ); + continue; + } + + // Update the txPowerChoices for the optimization + Map> allowedTxPowers = deviceCfg.allowedTxPowers; + if (allowedTxPowers != null && allowedTxPowers.get(BAND_5G) != null) { + txPowerChoices.retainAll(allowedTxPowers.get(BAND_5G)); + } + + // Update the boundary for the optimization + if (deviceCfg.boundary != null) { + boundary = Math.max(boundary, deviceCfg.boundary); + } + } + + // Report error if none of the APs has the location data or active + if (apLocX.isEmpty()) { + logger.error("No valid APs, missing location data or inactive APs!"); + return txPowerMap; + } + + // Report error if the boundary is smaller than the given location + if ( + Collections.max(apLocX).intValue() > boundary || + Collections.max(apLocY).intValue() > boundary + ) { + logger.error("Invalid boundary: {}!", boundary); + return txPowerMap; + } + + // Report error if the size of the txPower choices is 0. + if (txPowerChoices.isEmpty()) { + logger.error("Invalid txPower choices! It is empty!"); + return txPowerMap; + } + + // Report error if the number of combinations is too high (>1000). + if (Math.pow(txPowerChoices.size(), numOfAPs) > 1000) { + logger.error( + "Invalid operation: complexity issue!! Number of combinations: {}", + (int) Math.pow(txPowerChoices.size(), numOfAPs) + ); + return txPowerMap; + } + + // Run the optimal TPC algorithm + List txPowerList = LocationBasedOptimalTPC.runLocationBasedOptimalTPC( + boundary, + numOfAPs, + apLocX, + apLocY, + txPowerChoices + ); + + // Apply the results from the optimal TPC algorithm to the config + for (Map.Entry e : validAPs.entrySet()) { + String serialNumber = e.getKey(); + int txPower = txPowerList.get(e.getValue()); + Map radioMap = new TreeMap<>(); + radioMap.put(BAND_5G, txPower); + txPowerMap.put(serialNumber, radioMap); + logger.info( + "Device {}: Assigning tx power = {}", + serialNumber, + txPower + ); + } + return txPowerMap; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/MeasurementBasedApApTPC.java b/src/main/java/com/facebook/openwifirrm/optimizers/MeasurementBasedApApTPC.java new file mode 100644 index 0000000..e654fa1 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/MeasurementBasedApApTPC.java @@ -0,0 +1,260 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; +import com.facebook.openwifirrm.ucentral.models.State; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Measurement-based AP-AP TPC algorithm. + * + * TODO: support both 2G and 5G + * TODO: implement the channel-specific TPC operation + */ +public class MeasurementBasedApApTPC extends TPC { + private static final Logger logger = LoggerFactory.getLogger(MeasurementBasedApApTPC.class); + + /** + * Default coverage threshold between APs, in dBm. + * + * This has been picked because various devices try to roam below this + * threshold. iOS devices try to roam to another device below -70dBm. + * Other devices roam below -75dBm or -80dBm, so a conservative threshold + * of -70dBm has been selected. + */ + public static final int DEFAULT_COVERAGE_THRESHOLD = -70; + + /** + * Default Nth smallest RSSI is used for Tx power calculation. + */ + public static final int DEFAULT_NTH_SMALLEST_RSSI = 0; + + + /** coverage threshold between APs, in dB */ + private final int coverageThreshold; + + /** Nth smallest RSSI is used for Tx power calculation */ + private final int nthSmallestRssi; + + /** Constructor. */ + public MeasurementBasedApApTPC( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + this(model, zone, deviceDataManager, DEFAULT_COVERAGE_THRESHOLD, DEFAULT_NTH_SMALLEST_RSSI); + } + + /** Constructor. */ + public MeasurementBasedApApTPC( + DataModel model, + String zone, + DeviceDataManager deviceDataManager, + int coverageThreshold, + int nthSmallestRssi + ) { + super(model, zone, deviceDataManager); + + if (coverageThreshold > MAX_TX_POWER) { + throw new RuntimeException("Invalid coverage threshold " + coverageThreshold); + } + this.coverageThreshold = coverageThreshold; + this.nthSmallestRssi = nthSmallestRssi; + } + + /** + * Retrieve BSSIDs of APs we are managing + */ + protected static Set getManagedBSSIDs(DataModel model) { + Set managedBSSIDs = new HashSet<>(); + for (Map.Entry e : model.latestState.entrySet()) { + State state = e.getValue(); + if (state.interfaces == null) { + continue; + } + for (State.Interface iface : state.interfaces) { + if (iface.ssids == null) { + continue; + } + for (State.Interface.SSID ssid : iface.ssids) { + if (ssid.bssid == null) { + continue; + } + managedBSSIDs.add(ssid.bssid); + } + } + } + return managedBSSIDs; + } + + /** + * Get the current 5G tx power for an AP using the latest device status + * @param latestDeviceStatus JsonArray containing radio config for the AP + */ + protected static int getCurrentTxPower(JsonArray latestDeviceStatus) { + for (int radioIndex = 0; radioIndex < latestDeviceStatus.size(); radioIndex++) { + JsonElement e = latestDeviceStatus.get(radioIndex); + if (!e.isJsonObject()) { + return 0; + } + JsonObject radioObject = e.getAsJsonObject(); + String band = radioObject.get("band").getAsString(); + if (band.equals("5G")) { + return radioObject.get("tx-power").getAsInt(); + } + } + + return 0; + } + + protected static boolean isChannel5G(int channel) { + return (36 <= channel && channel <= 165); + } + + /** + * Get a map from BSSID to the received signal strength at neighboring APs (RSSI). + * List of RSSIs are returned in sorted, ascending order. + * + * If no neighboring APs have received signal from a source, then it gets an + * entry in the map with an empty list of RSSI values. + * + * @param managedBSSIDs set of all BSSIDs of APs we are managing + */ + protected static Map> buildRssiMap( + Set managedBSSIDs, + Map>> latestWifiScans + ) { + Map> bssidToRssiValues = new HashMap<>(); + managedBSSIDs.stream() + .forEach(bssid -> bssidToRssiValues.put(bssid, new ArrayList<>())); + + for (Map.Entry>> e : latestWifiScans.entrySet()) { + List> bufferedScans = e.getValue(); + List latestScan = bufferedScans.get(bufferedScans.size() - 1); + + // At a given AP, if we receive a signal from ap_2, then it gets added to the rssi list for ap_2 + latestScan.stream() + .filter(entry -> (managedBSSIDs.contains(entry.bssid) && isChannel5G(entry.channel))) + .forEach(entry -> { + bssidToRssiValues.get(entry.bssid).add(entry.signal); + }); + } + bssidToRssiValues.values().stream() + .forEach(rssiList -> Collections.sort(rssiList)); + return bssidToRssiValues; + } + + /** + * Compute adjusted tx power (dBm) based on inputs. + * @param currentTxPower the current tx power (dBm) + * @param rssiValues RSSI values received by managed neighboring APs in ascending order + */ + protected static int computeTxPower( + String serialNumber, + int currentTxPower, + List rssiValues, + int coverageThreshold, + int nthSmallestRssi + ) { + if (rssiValues.isEmpty()) { + return MAX_TX_POWER; + } + + // We may not optimize for the closest AP, but the Nth closest + int targetRSSI = rssiValues.get(Math.min(rssiValues.size() - 1, nthSmallestRssi)); + int txDelta = MAX_TX_POWER - currentTxPower; + // Represents the highest possible RSSI to be received by that neighboring AP + int estimatedRSSI = targetRSSI + txDelta; + int newTxPower = MAX_TX_POWER + coverageThreshold - estimatedRSSI; + // Bound tx_power by [MIN_TX_POWER, MAX_TX_POWER] + if (newTxPower > MAX_TX_POWER) { + logger.info( + "Device {}: computed tx power > maximum {}, using maximum", + serialNumber, + MAX_TX_POWER + ); + newTxPower = MAX_TX_POWER; + } else if (newTxPower < MIN_TX_POWER) { + logger.info( + "Device {}: computed tx power < minimum {}, using minimum", + serialNumber, + MIN_TX_POWER + ); + newTxPower = MIN_TX_POWER; + } + return newTxPower; + } + + @Override + public Map> computeTxPowerMap() { + Map> txPowerMap = new TreeMap<>(); + + Set managedBSSIDs = getManagedBSSIDs(model); + Map> bssidToRssiValues = buildRssiMap(managedBSSIDs, model.latestWifiScans); + Map allStatuses = model.latestDeviceStatus; + for (String serialNumber : allStatuses.keySet()) { + State state = model.latestState.get(serialNumber); + if (state == null || state.radios == null || state.radios.length == 0) { + logger.debug( + "Device {}: No radios found, skipping...", serialNumber + ); + continue; + } + if (state.interfaces == null || state.interfaces.length == 0) { + logger.debug( + "Device {}: No interfaces found, skipping...", serialNumber + ); + continue; + } + if (state.interfaces[0].ssids == null || state.interfaces[0].ssids.length == 0) { + logger.debug( + "Device {}: No SSIDs found, skipping...", serialNumber + ); + continue; + } + JsonArray radioStatuses = allStatuses.get(serialNumber).getAsJsonArray(); + int currentTxPower = getCurrentTxPower(radioStatuses); + String bssid = state.interfaces[0].ssids[0].bssid; + List rssiValues = bssidToRssiValues.get(bssid); + logger.debug("Device <{}> : BSSID <{}>", serialNumber, bssid); + for (int rssi : rssiValues) { + logger.debug(" Neighbor received RSSI: {}", rssi); + } + int newTxPower = computeTxPower( + serialNumber, + currentTxPower, + rssiValues, + coverageThreshold, + nthSmallestRssi + ); + logger.debug(" New tx_power: {}", newTxPower); + + Map radioMap = new TreeMap<>(); + radioMap.put(BAND_5G, newTxPower); + txPowerMap.put(serialNumber, radioMap); + } + + return txPowerMap; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/MeasurementBasedApClientTPC.java b/src/main/java/com/facebook/openwifirrm/optimizers/MeasurementBasedApClientTPC.java new file mode 100644 index 0000000..47c8d30 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/MeasurementBasedApClientTPC.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; +import com.facebook.openwifirrm.ucentral.models.State; +import com.google.gson.JsonObject; + +/** + * Measurement-based AP-client algorithm. + *

+ * Assign tx power based on client RSSI and a fixed target MCS index. + */ +public class MeasurementBasedApClientTPC extends TPC { + private static final Logger logger = LoggerFactory.getLogger(MeasurementBasedApClientTPC.class); + + /** Default target MCS index. */ + public static final int DEFAULT_TARGET_MCS = 8; + + /** Default tx power. */ + public static final int DEFAULT_TX_POWER = 10; + + /** Mapping of MCS index to required SNR (dB) in 802.11ac. */ + private static final List MCS_TO_SNR = Collections.unmodifiableList( + Arrays.asList( + /* MCS 0 */ 5.0, + /* MCS 1 */ 7.5, + /* MCS 2 */ 10.0, + /* MCS 3 */ 12.5, + /* MCS 4 */ 15.0, + /* MCS 5 */ 17.5, + /* MCS 6 */ 20.0, + /* MCS 7 */ 22.5, + /* MCS 8 */ 25.0, + /* MCS 9 */ 27.5 + ) + ); + + /** The target MCS index. */ + private final int targetMcs; + + /** Constructor (uses default target MCS index). */ + public MeasurementBasedApClientTPC( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + this(model, zone, deviceDataManager, DEFAULT_TARGET_MCS); + } + + /** Constructor. */ + public MeasurementBasedApClientTPC( + DataModel model, + String zone, + DeviceDataManager deviceDataManager, + int targetMcs + ) { + super(model, zone, deviceDataManager); + + if (targetMcs < 0 || targetMcs >= MCS_TO_SNR.size()) { + throw new RuntimeException("Invalid target MCS " + targetMcs); + } + this.targetMcs = targetMcs; + } + + /** + * Compute adjusted tx power (dBm) based on inputs. + * @param mcs the MCS index + * @param currentTxPower the current tx power (dBm) + * @param clientRssi the minimum client RSSI (dBm) + * @param bandwidth the channel bandwidth (Hz) + */ + private double computeTxPower( + int mcs, int currentTxPower, int clientRssi, int bandwidth + ) { + // Tx power adjusted [dBm] = + // SNR_min [dB] + Tx power [dBm] - R [dBm] + NP [dBm] + NF [dB] - M [dB] + double SNR_min = // Signal-to-noise ratio minimum [dB] + MCS_TO_SNR.get(mcs); + final double k = 1.38e-23; // Boltzmann's constant + final double T = 290; // Temperature + double B = bandwidth; // Bandwidth (Hz) + double NP = // Noise power (dBm) => 10*log_10(K*T*B*1000) + 10.0 * Math.log10(k * T * B * 1000.0); + final double NF = 6; // Noise floor (6dB) + final double M = 2; // Margin (2dB) + + return SNR_min + currentTxPower - clientRssi + NP + NF - M; + } + + /** Compute adjusted tx power (dBm) for the given device. */ + private int computeTxPowerForDevice( + String serialNumber, State state, int radioIndex + ) { + // Find current tx power and bandwidth + JsonObject radio = state.radios[radioIndex]; + int currentTxPower = + radio.has("tx_power") && !radio.get("tx_power").isJsonNull() + ? radio.get("tx_power").getAsInt() + : 0; + int channelWidth = 1_000_000 /* convert MHz to Hz */ * ( + radio.has("channel_width") && + !radio.get("channel_width").isJsonNull() + ? radio.get("channel_width").getAsInt() + : 20 + ); + + // Find minimum client RSSI + List clientRssiList = new ArrayList<>(); + if (state.interfaces != null) { + for (State.Interface iface : state.interfaces) { + if (iface.ssids == null) { + continue; + } + for (State.Interface.SSID ssid : iface.ssids) { + if (ssid.associations == null) { + continue; + } + for ( + State.Interface.SSID.Association client : + ssid.associations + ) { + logger.debug( + "Device {}: SSID '{}' => client {} with rssi {}", + serialNumber, + ssid.ssid != null ? ssid.ssid : "", + client.bssid != null ? client.bssid : "", + client.rssi + ); + clientRssiList.add(client.rssi); + } + } + } + } + // TODO: revisit this part to have a better logic + if (clientRssiList.isEmpty()) { + logger.info( + "Device {}: no clients, assigning default tx power {} (was {})", + serialNumber, + DEFAULT_TX_POWER, + currentTxPower + ); + return DEFAULT_TX_POWER; // no clients + } + int clientRssi = Collections.min(clientRssiList); + + // Compute new tx power + int newTxPower; + int mcs = targetMcs; + do { + double computedTxPower = + computeTxPower(mcs, currentTxPower, clientRssi, channelWidth); + + // APs only accept integer tx power, so take ceiling + newTxPower = (int) Math.ceil(computedTxPower); + + logger.info( + "Device {}: computed tx power (for mcs={}, currentTxPower={}, rssi={}, bandwidth={}) = {}, ceil() = {}", + serialNumber, + mcs, + currentTxPower, + clientRssi, + channelWidth, + computedTxPower, + newTxPower + ); + + // If this exceeds max tx power, repeat for (MCS - 1) + if (newTxPower > MAX_TX_POWER) { + logger.info( + "Device {}: computed tx power > maximum {}, trying with mcs - 1", + serialNumber, + MAX_TX_POWER + ); + if (--mcs >= 0) { + continue; + } else { + logger.info( + "Device {}: already at lowest MCS, setting to minimum tx power {}", + serialNumber, + MIN_TX_POWER + ); + newTxPower = MIN_TX_POWER; + } + } + + // If this is below min tx power, set to min + else if (newTxPower < MIN_TX_POWER) { + logger.info( + "Device {}: computed tx power < minimum {}, using minimum", + serialNumber, + MIN_TX_POWER + ); + newTxPower = MIN_TX_POWER; + } + + break; + } while (true); + + logger.info( + "Device {}: assigning tx power = {} (was {})", + serialNumber, + newTxPower, + currentTxPower + ); + return newTxPower; + } + + @Override + public Map> computeTxPowerMap() { + Map> txPowerMap = new TreeMap<>(); + + for (Map.Entry e : model.latestState.entrySet()) { + String serialNumber = e.getKey(); + State state = e.getValue(); + + if (state.radios == null || state.radios.length == 0) { + logger.debug( + "Device {}: No radios found, skipping...", serialNumber + ); + continue; + } + // TODO: only using first radio + final int radioIndex = 0; + + int newTxPower = + computeTxPowerForDevice(serialNumber, state, radioIndex); + Map radioMap = new TreeMap<>(); + radioMap.put(BAND_5G, newTxPower); + txPowerMap.put(serialNumber, radioMap); + } + + return txPowerMap; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/RandomChannelInitializer.java b/src/main/java/com/facebook/openwifirrm/optimizers/RandomChannelInitializer.java new file mode 100644 index 0000000..b7c1664 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/RandomChannelInitializer.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; +import com.facebook.openwifirrm.ucentral.UCentralUtils; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; +import com.facebook.openwifirrm.ucentral.models.State; + +/** + * Random channel initializer. + *

+ * Randomly assign APs to the same channel. + */ +public class RandomChannelInitializer extends ChannelOptimizer { + private static final Logger logger = LoggerFactory.getLogger(RandomChannelInitializer.class); + + /** The PRNG instance. */ + private final Random rng = new Random(); + + /** Constructor. */ + public RandomChannelInitializer( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + super(model, zone, deviceDataManager); + } + + @Override + public Map> computeChannelMap() { + Map> channelMap = new TreeMap<>(); + Map> bandsMap = + UCentralUtils.getBandsMap(model.latestDeviceStatus); + + Map>> deviceAvailableChannels = + UCentralUtils.getDeviceAvailableChannels( + model.latestDeviceStatus, + model.latestDeviceCapabilities, + AVAILABLE_CHANNELS_BAND + ); + + Map bssidsMap = UCentralUtils.getBssidsMap(model.latestState); + + for (Map.Entry> entry : bandsMap.entrySet()) { + // Performance metrics + Map oldChannelMap = new TreeMap<>(); + Map newChannelMap = new TreeMap<>(); + + // Use last wifi scan result for the performance metrics calculation + String band = entry.getKey(); + Map> deviceToWifiScans = getDeviceToWiFiScans( + band, model.latestWifiScans, bandsMap + ); + + // Get the common available channels for all the devices + // to get the valid result for single channel assignment + // If the intersection is empty, then turn back to the default channels list + List availableChannelsList = new ArrayList<>( + AVAILABLE_CHANNELS_BAND.get(band) + ); + for (String serialNumber : entry.getValue()) { + List deviceChannelsList = deviceAvailableChannels + .get(band).get(serialNumber); + if (deviceChannelsList == null || deviceChannelsList.isEmpty()) { + deviceChannelsList = AVAILABLE_CHANNELS_BAND.get(band); + } + availableChannelsList.retainAll(deviceChannelsList); + } + if (availableChannelsList == null || availableChannelsList.isEmpty()) { + availableChannelsList = AVAILABLE_CHANNELS_BAND.get(band); + logger.debug( + "The intersection of the device channels lists is empty!!! " + + "Fall back to the default channels list" + ); + } + + // Randomly assign all the devices to the same channel + int channelIndex = rng.nextInt(availableChannelsList.size()); + int newChannel = availableChannelsList.get(channelIndex); + + for (String serialNumber : entry.getValue()) { + State state = model.latestState.get(serialNumber); + if (state == null) { + logger.debug( + "Device {}: No state found, skipping...", + serialNumber + ); + continue; + } + if (state.radios == null || state.radios.length == 0) { + logger.debug( + "Device {}: No radios found, skipping...", + serialNumber + ); + continue; + } + int[] currentChannelInfo = getCurrentChannel(band, serialNumber, state); + int currentChannel = currentChannelInfo[0]; + int currentChannelWidth = currentChannelInfo[1]; + if (currentChannel == 0) { + // Filter out APs if the number of radios in the state and config mismatches + // Happen when an AP's radio is enabled/disabled on the fly + logger.debug( + "Device {}: No {} radio, skipping...", + serialNumber, + band + ); + continue; + } + + // Log the notice when the updated one and the original one are not equal + List newAvailableChannelsList = updateAvailableChannelsList( + band, serialNumber, currentChannelWidth, availableChannelsList + ); + Set availableChannelsSet = new TreeSet<>( + availableChannelsList + ); + Set newAvailableChannelsSet = new TreeSet<>( + newAvailableChannelsList + ); + if (!availableChannelsSet.equals(newAvailableChannelsSet)) { + logger.info( + "Device {}: userChannels/allowedChannels are disabled in " + + "single channel assignment.", + serialNumber + ); + } + + channelMap.computeIfAbsent( + serialNumber, k -> new TreeMap<>() + ) + .put(band, newChannel); + logger.info( + "Device {}: Assigning to random free channel {} (from " + + "available list: {})", + serialNumber, + newChannel, + availableChannelsList.toString() + ); + + // Gather the info for the performance metrics + oldChannelMap.put(serialNumber, currentChannel); + newChannelMap.put(serialNumber, newChannel); + } + // Get and log the performance metrics + logPerfMetrics(oldChannelMap, newChannelMap, deviceToWifiScans, bssidsMap); + } + + return channelMap; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/RandomTxPowerInitializer.java b/src/main/java/com/facebook/openwifirrm/optimizers/RandomTxPowerInitializer.java new file mode 100644 index 0000000..8e3bf6c --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/RandomTxPowerInitializer.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.Map; +import java.util.TreeMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; + +/** + * Random tx power initializer. + *

+ * Random picks a single tx power and assigns the value to all APs. + */ +public class RandomTxPowerInitializer extends TPC { + private static final Logger logger = LoggerFactory.getLogger(RandomTxPowerInitializer.class); + + /** Default tx power. */ + public static final int DEFAULT_TX_POWER = 23; + + /** The fixed tx power (dBm). */ + private final int txPower; + + /** Constructor (uses default tx power). */ + public RandomTxPowerInitializer( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + this(model, zone, deviceDataManager, DEFAULT_TX_POWER); + } + + /** Constructor. */ + public RandomTxPowerInitializer( + DataModel model, + String zone, + DeviceDataManager deviceDataManager, + int txPower + ) { + super(model, zone, deviceDataManager); + this.txPower = txPower; + } + + @Override + public Map> computeTxPowerMap() { + Map> txPowerMap = new TreeMap<>(); + for (String serialNumber : model.latestState.keySet()) { + Map radioMap = new TreeMap<>(); + radioMap.put(BAND_5G, txPower); + txPowerMap.put(serialNumber, radioMap); + } + if (!txPowerMap.isEmpty()) { + logger.info( + "Device {}: Assigning tx power = {}", + String.join(", ", txPowerMap.keySet()), + txPower + ); + } + return txPowerMap; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/TPC.java b/src/main/java/com/facebook/openwifirrm/optimizers/TPC.java new file mode 100644 index 0000000..83c21a5 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/TPC.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.Map; + +import com.facebook.openwifirrm.DeviceConfig; +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.ConfigManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; + +/** + * TPC (Transmit Power Control) base class. + */ +public abstract class TPC { + /** Minimum supported tx power (dBm), inclusive. */ + public static final int MIN_TX_POWER = 0; + + /** Maximum supported tx power (dBm), inclusive. */ + public static final int MAX_TX_POWER = 30; + + /** String of the 2.4 GHz band */ + public static final String BAND_2G = "2G"; + + /** String of the 5 GHz band */ + public static final String BAND_5G = "5G"; + + /** The input data model. */ + protected final DataModel model; + + /** The RF zone. */ + protected final String zone; + + /** The device configs within {@link #zone}, keyed on serial number. */ + protected final Map deviceConfigs; + + /** Constructor. */ + public TPC( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + this.model = model; + this.zone = zone; + this.deviceConfigs = deviceDataManager.getAllDeviceConfigs(zone); + + // TODO!! Actually use device configs (allowedTxPowers, userTxPowers) + + // Remove model entries not in the given zone + this.model.latestWifiScans.keySet().removeIf(serialNumber -> + !deviceConfigs.containsKey(serialNumber) + ); + this.model.latestState.keySet().removeIf(serialNumber -> + !deviceConfigs.containsKey(serialNumber) + ); + this.model.latestDeviceStatus.keySet().removeIf(serialNumber -> + !deviceConfigs.containsKey(serialNumber) + ); + this.model.latestDeviceCapabilities.keySet().removeIf(serialNumber -> + !deviceConfigs.containsKey(serialNumber) + ); + } + + /** + * Compute tx power assignments. + * @return the map of devices (by serial number) to radio to tx power + */ + public abstract Map> computeTxPowerMap(); + + /** + * Program the given tx power map into the AP config and notify the config + * manager. + * + * @param deviceDataManager the DeviceDataManager instance + * @param configManager the ConfigManager instance + * @param channelMap the map of devices (by serial number) to radio to tx power + */ + public void applyConfig( + DeviceDataManager deviceDataManager, + ConfigManager configManager, + Map> txPowerMap + ) { + // Update device AP config layer + deviceDataManager.updateDeviceApConfig(apConfig -> { + for ( + Map.Entry> entry : + txPowerMap.entrySet() + ) { + DeviceConfig deviceConfig = apConfig.computeIfAbsent( + entry.getKey(), k -> new DeviceConfig() + ); + deviceConfig.autoTxPowers = entry.getValue(); + } + }); + + // Trigger config update now + configManager.wakeUp(); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/UnmanagedApAwareChannelOptimizer.java b/src/main/java/com/facebook/openwifirrm/optimizers/UnmanagedApAwareChannelOptimizer.java new file mode 100644 index 0000000..6a7c183 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/optimizers/UnmanagedApAwareChannelOptimizer.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.optimizers; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.DeviceDataManager; +import com.facebook.openwifirrm.modules.Modeler.DataModel; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; + +/** + * Unmanaged AP aware least used channel optimizer. + *

+ * Randomly assign APs to the channel with the least channel weight, + * where channel weight = DEFAULT_WEIGHT * (number of unmanaged APs) + (number of managed APs). + */ +public class UnmanagedApAwareChannelOptimizer extends LeastUsedChannelOptimizer { + private static final Logger logger = LoggerFactory.getLogger(UnmanagedApAwareChannelOptimizer.class); + + /** The default weight for nonOWF APs. */ + private static final int DEFAULT_WEIGHT = 2; + + /** Constructor. */ + public UnmanagedApAwareChannelOptimizer( + DataModel model, String zone, DeviceDataManager deviceDataManager + ) { + super(model, zone, deviceDataManager); + } + + @Override + protected Map getOccupiedChannels( + String band, + String serialNumber, + int channelWidth, + List availableChannelsList, + Map> deviceToWifiScans, + Map> channelMap, + Map bssidsMap + ) { + // Find occupied channels by nonOWF APs (and # associated nonOWF APs) + // Distinguish OWF APs from nonOWF APs + Map occupiedChannels = new TreeMap<>(); + List scanResps = getScanRespsByBandwidth( + band, + serialNumber, + channelWidth, + deviceToWifiScans + ); + List scanRespsOWF = new ArrayList(); + + // Remove OWF APs here + for (WifiScanEntry entry : scanResps) { + if (bssidsMap.containsKey(entry.bssid)) { + scanRespsOWF.add(entry); + } else { + occupiedChannels.compute( + entry.channel, + (k, v) -> (v == null) ? DEFAULT_WEIGHT : v + DEFAULT_WEIGHT + ); + } + } + logger.debug( + "Device {}: Occupied channels for nonOWF APs: {} " + + "with total weight: {}", + serialNumber, + occupiedChannels.keySet().toString(), + occupiedChannels.values().stream().mapToInt(i -> i).sum() + ); + + // Find occupied channels by OWF APs (and # associated OWF APs) + for (WifiScanEntry entry: scanRespsOWF) { + String nSerialNumber = bssidsMap.get(entry.bssid); + int assignedChannel = channelMap + .getOrDefault(nSerialNumber, new HashMap<>()) + .getOrDefault(band, 0); + // 0 means the bssid has not been assigned yet. + if (assignedChannel == 0) { + continue; + } + logger.debug( + "Device {}: Neighbor device: {} on channel {}", + serialNumber, + nSerialNumber, + assignedChannel + ); + occupiedChannels.compute( + assignedChannel, (k, v) -> (v == null) ? 1 : v + 1 + ); + } + logger.debug( + "Device {}: Occupied channels for all APs: {} " + + "with total weight: {}", + serialNumber, + occupiedChannels.keySet().toString(), + occupiedChannels.values().stream().mapToInt(i -> i).sum() + ); + + // For 2.4G, we prioritize the orthogonal channels + // by considering the overlapping channels + if (band.equals(BAND_2G)) { + Map occupiedOverlapChannels = + getOccupiedOverlapChannels(occupiedChannels); + occupiedChannels = new TreeMap<>(occupiedOverlapChannels); + logger.debug( + "Device {}: Occupied channels for 2G APs: {} " + + "with total weight: {}", + serialNumber, + occupiedChannels.keySet().toString(), + occupiedChannels.values().stream().mapToInt(i -> i).sum() + ); + } + return occupiedChannels; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/KafkaConsumerRunner.java b/src/main/java/com/facebook/openwifirrm/ucentral/KafkaConsumerRunner.java new file mode 100644 index 0000000..fa11203 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/KafkaConsumerRunner.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.kafka.common.errors.WakeupException; + +/** + * Kafka consumer runner. + */ +public class KafkaConsumerRunner implements Runnable { + /** Interrupt hook. */ + private final AtomicBoolean closed = new AtomicBoolean(false); + + /** The Kafka consumer instance. */ + private final UCentralKafkaConsumer consumer; + + /** Run with the given consumer instance. */ + public KafkaConsumerRunner(UCentralKafkaConsumer consumer) { + this.consumer = consumer; + } + + @Override + public void run() { + try { + consumer.subscribe(); + while (!closed.get()) { + consumer.poll(); + } + } catch (WakeupException e) { + // Ignore exception if closing + if (!closed.get()) { + throw e; + } + } finally { + consumer.close(); + } + } + + /** Shutdown hook which can be called from a separate thread. */ + public void shutdown() { + closed.set(true); + consumer.wakeup(); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralApConfiguration.java b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralApConfiguration.java new file mode 100644 index 0000000..32ff754 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralApConfiguration.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral; + +import java.util.HashSet; +import java.util.Set; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Wrapper around uCentral AP configuration. + */ +public class UCentralApConfiguration { + private final JsonObject config; + + /** Constructor from JSON string. */ + public UCentralApConfiguration(String configJson) { + this.config = new Gson().fromJson(configJson, JsonObject.class); + } + + /** Constructor from JsonObject (makes deep copy). */ + public UCentralApConfiguration(JsonObject config) { + this.config = config.deepCopy(); + } + + @Override + public String toString() { + return config.toString(); + } + + /** Serialize the configuration to JSON using the given Gson instance. */ + public String toString(Gson gson) { + return gson.toJson(config); + } + + /** Return the number of radios, or -1 if the field is missing/malformed. */ + public int getRadioCount() { + if (!config.has("radios") || !config.get("radios").isJsonArray()) { + return -1; + } + return config.getAsJsonArray("radios").size(); + } + + /** Return all info in the radio config */ + public JsonArray getRadioConfigList() { + if (!config.has("radios") || !config.get("radios").isJsonArray()) { + return null; + } + return config.getAsJsonArray("radios"); + } + + /** Return all the operational bands of an AP (from the radio config) */ + public Set getRadioBandsSet(JsonArray radioConfigList) { + Set radioBandsSet = new HashSet<>(); + if (radioConfigList == null) { + return radioBandsSet; + } + for (int radioIndex = 0; radioIndex < radioConfigList.size(); radioIndex++) { + JsonElement e = radioConfigList.get(radioIndex); + if (!e.isJsonObject()) { + return radioBandsSet; + } + JsonObject radioObject = e.getAsJsonObject(); + String band = radioObject.get("band").getAsString(); + radioBandsSet.add(band); + } + return radioBandsSet; + } + + /** Return the radio config at the given index. */ + public JsonObject getRadioConfig(int index) { + if (getRadioCount() < index) { + return null; + } + JsonElement e = config.getAsJsonArray("radios").get(index); + if (!e.isJsonObject()) { + return null; + } + return e.getAsJsonObject(); + } + + /** Set radio config at the given index. Adds empty objects as needed. */ + public void setRadioConfig(int index, JsonObject radioConfig) { + int radioCount = getRadioCount(); + if (radioCount == -1) { + config.add("radios", new JsonArray()); + radioCount = 0; + } + JsonArray radios = config.getAsJsonArray("radios"); + for (int i = radioCount; i <= index; i++) { + // insert empty objects as needed + radios.add(new JsonObject()); + } + radios.set(index, radioConfig); + } + + /** + * Return the statistics interval (in seconds), or -1 if the field is + * missing/malformed. + */ + public int getStatisticsInterval() { + try { + return config + .getAsJsonObject("metrics") + .getAsJsonObject("statistics") + .get("interval") + .getAsInt(); + } catch (Exception e) { + return -1; + } + } + + /** Set the statistics interval to the given value (in seconds). */ + public void setStatisticsInterval(int intervalSec) { + if (!config.has("metrics") || !config.get("metrics").isJsonObject()) { + config.add("metrics", new JsonObject()); + } + JsonObject metrics = config.getAsJsonObject("metrics"); + if ( + !metrics.has("statistics") || + !metrics.get("statistics").isJsonObject() + ) { + metrics.add("statistics", new JsonObject()); + } + JsonObject statistics = metrics.getAsJsonObject("statistics"); + statistics.addProperty("interval", intervalSec); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralClient.java b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralClient.java new file mode 100644 index 0000000..c79f7bc --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralClient.java @@ -0,0 +1,423 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.RRMConfig.UCentralConfig.UCentralSocketParams; +import com.facebook.openwifirrm.ucentral.gw.models.CommandInfo; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceCapabilities; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceConfigureRequest; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceListWithStatus; +import com.facebook.openwifirrm.ucentral.gw.models.DeviceWithStatus; +import com.facebook.openwifirrm.ucentral.gw.models.StatisticsRecords; +import com.facebook.openwifirrm.ucentral.gw.models.SystemInfoResults; +import com.facebook.openwifirrm.ucentral.gw.models.WifiScanRequest; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import kong.unirest.Config; +import kong.unirest.FailedResponse; +import kong.unirest.GetRequest; +import kong.unirest.HttpRequestSummary; +import kong.unirest.HttpRequestWithBody; +import kong.unirest.HttpResponse; +import kong.unirest.Interceptor; +import kong.unirest.Unirest; +import kong.unirest.UnirestException; + +/** + * uCentral OpenAPI client. + */ +public class UCentralClient { + private static final Logger logger = LoggerFactory.getLogger(UCentralClient.class); + + static { + Unirest.config() + // TODO currently disabling SSL/TLS cert verification + .verifySsl(false) + + // Suppress unchecked exceptions (ex. SocketTimeoutException), + // instead sending a (fake) FailedResponse. + .interceptor(new Interceptor() { + @SuppressWarnings("rawtypes") + @Override + public HttpResponse onFail( + Exception e, + HttpRequestSummary request, + Config config + ) throws UnirestException { + String errMsg = String.format( + "Request failed: %s %s", + request.getHttpMethod(), + request.getUrl() + ); + logger.error(errMsg, e); + return new FailedResponse(e); + } + }); + } + + /** Gson instance */ + private final Gson gson = new Gson(); + + /** uCentral username */ + private final String username; + /** uCentral password */ + private final String password; + /** uCentralSec host */ + private final String uCentralSecHost; + /** uCentralSec port */ + private final int uCentralSecPort; + /** Socket parameters */ + private final UCentralSocketParams socketParams; + + /** Access token */ + private String accessToken; + /** uCentralGw URL */ + private String uCentralGwUrl; + + /** + * Constructor. + * @param username uCentral username + * @param password uCentral password + * @param uCentralSecHost uCentralSec host + * @param uCentralSecPort uCentralSec port + * @param socketParams Socket parameters + */ + public UCentralClient( + String username, + String password, + String uCentralSecHost, + int uCentralSecPort, + UCentralSocketParams socketParams + ) { + this.username = username; + this.password = password; + this.uCentralSecHost = uCentralSecHost; + this.uCentralSecPort = uCentralSecPort; + this.socketParams = socketParams; + } + + /** Return uCentralSec URL using the given endpoint. */ + private String makeUCentralSecUrl(String endpoint) { + return String.format( + "https://%s:%d/api/v1/%s", + uCentralSecHost, uCentralSecPort, endpoint + ); + } + + /** Return uCentralGw URL using the given endpoint. */ + private String makeUCentralGwUrl(String endpoint) { + return String.format("%s/api/v1/%s", uCentralGwUrl, endpoint); + } + + /** Perform login and uCentralGw endpoint retrieval. */ + public boolean login() { + // Make request + String url = makeUCentralSecUrl("oauth2"); + Map body = new HashMap<>(); + body.put("userId", username); + body.put("password", password); + HttpResponse response = Unirest.post(url) + .header("accept", "application/json") + .body(body) + .asString(); + if (!response.isSuccess()) { + logger.error( + "Login failed: Response code {}", response.getStatus() + ); + return false; + } + + // Parse access token from response + JSONObject respBody; + try { + respBody = new JSONObject(response.getBody()); + } catch (JSONException e) { + logger.error("Login failed: Unexpected response", e); + logger.debug("Response body: {}", response.getBody()); + return false; + } + if (!respBody.has("access_token")) { + logger.error("Login failed: Missing access token"); + logger.debug("Response body: {}", respBody.toString()); + return false; + } + this.accessToken = respBody.getString("access_token"); + logger.info("Login successful as user: {}", username); + logger.debug("Access token: {}", accessToken); + + // Find uCentral gateway URL + return findGateway(); + } + + /** Find uCentralGw URL from uCentralSec. */ + private boolean findGateway() { + // Make request + String url = makeUCentralSecUrl("systemEndpoints"); + HttpResponse response = Unirest.get(url) + .header("accept", "application/json") + .header("Authorization", "Bearer " + accessToken) + .asString(); + if (!response.isSuccess()) { + logger.error( + "/systemEndpoints failed: Response code {}", + response.getStatus() + ); + return false; + } + + // Parse endpoints from response + JSONObject respBody; + JSONArray endpoints; + try { + respBody = new JSONObject(response.getBody()); + endpoints = respBody.getJSONArray("endpoints"); + } catch (JSONException e) { + logger.error("/systemEndpoints failed: Unexpected response", e); + logger.debug("Response body: {}", response.getBody()); + return false; + } + for (Object o : endpoints) { + JSONObject endpoint = (JSONObject) o; + if ( + endpoint.optString("type").equals("owgw") && endpoint.has("uri") + ) { + this.uCentralGwUrl = endpoint.getString("uri"); + logger.info("Using uCentral gateway URL: {}", uCentralGwUrl); + return true; + } + } + logger.error("/systemEndpoints failed: Missing uCentral gateway URL"); + logger.debug("Response body: {}", respBody.toString()); + return false; + } + + /** Send a GET request. */ + @SuppressWarnings("unused") + private HttpResponse httpGet(String endpoint) { + return httpGet(endpoint, null); + } + + /** Send a GET request with query parameters. */ + private HttpResponse httpGet( + String endpoint, + Map parameters + ) { + return httpGet( + endpoint, + parameters, + socketParams.connectTimeoutMs, + socketParams.socketTimeoutMs + ); + } + + /** Send a GET request with query parameters using given timeout values. */ + private HttpResponse httpGet( + String endpoint, + Map parameters, + int connectTimeoutMs, + int socketTimeoutMs + ) { + String url = makeUCentralGwUrl(endpoint); + GetRequest req = Unirest.get(url) + .header("accept", "application/json") + .header("Authorization", "Bearer " + accessToken) + .connectTimeout(connectTimeoutMs) + .socketTimeout(socketTimeoutMs); + if (parameters != null) { + return req.queryString(parameters).asString(); + } else { + return req.asString(); + } + } + + /** Send a POST request with a JSON body. */ + private HttpResponse httpPost(String endpoint, Object body) { + return httpPost( + endpoint, + body, + socketParams.connectTimeoutMs, + socketParams.socketTimeoutMs + ); + } + + /** Send a POST request with a JSON body using given timeout values. */ + private HttpResponse httpPost( + String endpoint, + Object body, + int connectTimeoutMs, + int socketTimeoutMs + ) { + String url = makeUCentralGwUrl(endpoint); + HttpRequestWithBody req = Unirest.post(url) + .header("accept", "application/json") + .header("Authorization", "Bearer " + accessToken) + .connectTimeout(connectTimeoutMs) + .socketTimeout(socketTimeoutMs); + if (body != null) { + req.header("Content-Type", "application/json"); + return req.body(body).asString(); + } else { + return req.asString(); + } + } + + /** Get uCentralGw system info. */ + public SystemInfoResults getSystemInfo() { + Map parameters = + Collections.singletonMap("command", "info"); + HttpResponse response = httpGet("system", parameters); + if (!response.isSuccess()) { + logger.error("Error: {}", response.getBody()); + return null; + } + try { + return gson.fromJson(response.getBody(), SystemInfoResults.class); + } catch (JsonSyntaxException e) { + String errMsg = String.format( + "Failed to deserialize to SystemInfoResults: %s", + response.getBody() + ); + logger.error(errMsg, e); + return null; + } + } + + /** Get a list of devices. */ + public List getDevices() { + Map parameters = + Collections.singletonMap("deviceWithStatus", true); + HttpResponse response = httpGet("devices", parameters); + if (!response.isSuccess()) { + logger.error("Error: {}", response.getBody()); + return null; + } + try { + return gson.fromJson( + response.getBody(), DeviceListWithStatus.class + ).devicesWithStatus; + } catch (JsonSyntaxException e) { + String errMsg = String.format( + "Failed to deserialize to DeviceListWithStatus: %s", + response.getBody() + ); + logger.error(errMsg, e); + return null; + } + } + + /** Launch a wifi scan for a device (by serial number). */ + public CommandInfo wifiScan(String serialNumber, boolean verbose) { + WifiScanRequest req = new WifiScanRequest(); + req.serialNumber = serialNumber; + req.verbose = verbose; + HttpResponse response = httpPost( + String.format("device/%s/wifiscan", serialNumber), + req, + socketParams.connectTimeoutMs, + socketParams.wifiScanTimeoutMs + ); + if (!response.isSuccess()) { + logger.error("Error: {}", response.getBody()); + return null; + } + try { + return gson.fromJson(response.getBody(), CommandInfo.class); + } catch (JsonSyntaxException e) { + String errMsg = String.format( + "Failed to deserialize to CommandInfo: %s", response.getBody() + ); + logger.error(errMsg, e); + return null; + } + } + + /** Configure a device (by serial number). */ + public CommandInfo configure(String serialNumber, String configuration) { + DeviceConfigureRequest req = new DeviceConfigureRequest(); + req.serialNumber = serialNumber; + req.UUID = ThreadLocalRandom.current().nextLong(); + req.configuration = configuration; + HttpResponse response = httpPost( + String.format("device/%s/configure", serialNumber), req + ); + if (!response.isSuccess()) { + logger.error("Error: {}", response.getBody()); + return null; + } + try { + return gson.fromJson(response.getBody(), CommandInfo.class); + } catch (JsonSyntaxException e) { + String errMsg = String.format( + "Failed to deserialize to CommandInfo: %s", response.getBody() + ); + logger.error(errMsg, e); + return null; + } + } + + /** + * Return the given number of latest statistics from a device (by serial + * number). + */ + public StatisticsRecords getLatestStats(String serialNumber, int limit) { + Map parameters = new HashMap<>(); + parameters.put("newest", true); + parameters.put("limit", limit); + HttpResponse response = httpGet( + String.format("device/%s/statistics", serialNumber), parameters + ); + if (!response.isSuccess()) { + logger.error("Error: {}", response.getBody()); + return null; + } + try { + return gson.fromJson(response.getBody(), StatisticsRecords.class); + } catch (JsonSyntaxException e) { + String errMsg = String.format( + "Failed to deserialize to StatisticsRecords: %s", + response.getBody() + ); + logger.error(errMsg, e); + return null; + } + } + + /** Launch a get capabilities command for a device (by serial number). */ + public DeviceCapabilities getCapabilities(String serialNumber) { + HttpResponse response = httpGet( + String.format("device/%s/capabilities", serialNumber) + ); + if (!response.isSuccess()) { + logger.error("Error: {}", response.getBody()); + return null; + } + try { + return gson.fromJson(response.getBody(), DeviceCapabilities.class); + } catch (JsonSyntaxException e) { + String errMsg = String.format( + "Failed to deserialize to DeviceCapabilities: %s", response.getBody() + ); + logger.error(errMsg, e); + return null; + } + } +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralKafkaConsumer.java b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralKafkaConsumer.java new file mode 100644 index 0000000..5ce94c0 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralKafkaConsumer.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; +import java.util.stream.Collectors; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRebalanceListener; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.PartitionInfo; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * Kafka consumer for uCentral. + */ +public class UCentralKafkaConsumer { + private static final Logger logger = LoggerFactory.getLogger(UCentralKafkaConsumer.class); + + /** Poll timeout duration. */ + private static final Duration POLL_TIMEOUT = Duration.ofMillis(10000); + + /** The consumer instance. */ + private final KafkaConsumer consumer; + + /** The uCentral state topic. */ + private final String stateTopic; + + /** The uCentral wifi scan results topic. */ + private final String wifiScanTopic; + + /** The Gson instance. */ + private final Gson gson = new Gson(); + + /** Representation of Kafka record. */ + public static class KafkaRecord { + /** The device serial number. */ + public final String serialNumber; + + /** The state payload JSON. */ + public final JsonObject payload; + + /** Constructor. */ + public KafkaRecord(String serialNumber, JsonObject payload) { + this.serialNumber = serialNumber; + this.payload = payload; + } + } + + /** Kafka record listener interface. */ + public interface KafkaListener { + /** Handle a list of state records. */ + void handleStateRecords(List records); + + /** Handle a list of wifi scan records. */ + void handleWifiScanRecords(List records); + } + + /** Kafka record listeners. */ + private Map kafkaListeners = new TreeMap<>(); + + /** + * Constructor. + * @param bootstrapServer the Kafka bootstrap server + * @param groupId the Kafka consumer group ID + * @param autoOffsetReset the "auto.offset.reset" config + * @param stateTopic the uCentral state topic (or empty/null to skip) + * @param wifiScanTopic the uCentral wifiscan topic (or empty/null to skip) + */ + public UCentralKafkaConsumer( + String bootstrapServer, + String groupId, + String autoOffsetReset, + String stateTopic, + String wifiScanTopic + ) { + this.stateTopic = stateTopic; + this.wifiScanTopic = wifiScanTopic; + + // Set properties + Properties props = new Properties(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServer); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put( + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + StringDeserializer.class.getName() + ); + props.put( + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + StringDeserializer.class.getName() + ); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset); + props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "1000"); + + // Create consumer instance + this.consumer = new KafkaConsumer<>(props); + + logger.info("Using Kafka bootstrap server: {}", bootstrapServer); + } + + /** Subscribe to topic(s). */ + public void subscribe() { + Map> topics = + consumer.listTopics(POLL_TIMEOUT); + logger.info("Found topics: {}", String.join(", ", topics.keySet())); + List subscribeTopics = Arrays.asList(stateTopic, wifiScanTopic) + .stream() + .filter(t -> t != null && !t.isEmpty()) + .collect(Collectors.toList()); + for (String topic : subscribeTopics) { + if (!topics.containsKey(topic)) { + throw new RuntimeException("Topic not found: " + topic); + } + } + consumer.subscribe(subscribeTopics, new ConsumerRebalanceListener() { + @Override + public void onPartitionsRevoked(Collection partitions) { + // ignore + } + + @Override + public void onPartitionsAssigned(Collection partitions) { + logger.info( + "Received {} partition assignment(s): {}", + partitions.size(), + partitions.stream() + .map( + p -> + String.format("%s=%d", p.topic(), p.partition()) + ) + .collect(Collectors.joining(", ")) + ); + if (partitions.size() != subscribeTopics.size()) { + // As of Kafka v2.8.0, we see multi-topic subscribe() calls + // often failing to assign partitions to some topics. + // + // Keep trying to unsubscribe/resubscribe until it works... + // TODO a better solution? + logger.error( + "Missing topics in partition assignment! " + + "Resubscribing..." + ); + consumer.unsubscribe(); + subscribe(); + } + } + }); + } + + /** Poll for data. */ + public void poll() { + ConsumerRecords records = consumer.poll(POLL_TIMEOUT); + logger.debug("Poll returned with {} record(s)", records.count()); + + List stateRecords = new ArrayList<>(); + List wifiScanRecords = new ArrayList<>(); + for (ConsumerRecord record : records) { + // Parse payload JSON + JsonObject payload = null; + try { + JsonObject o = + gson.fromJson(record.value(), JsonObject.class); + payload = o.getAsJsonObject("payload"); + } catch (Exception e) { + // uCentralGw pushes invalid JSON for empty messages + logger.trace( + "Offset {}: Invalid payload JSON", record.offset() + ); + continue; + } + if (payload == null) { + logger.trace("Offset {}: No payload", record.offset()); + continue; + } + if (!payload.isJsonObject()) { + logger.trace( + "Offset {}: Payload not an object", record.offset() + ); + continue; + } + + // Process records by topic + String serialNumber = record.key(); + logger.trace( + "Offset {}: {} => {}", + record.offset(), serialNumber, payload.toString() + ); + if (record.topic().equals(stateTopic)) { + stateRecords.add(new KafkaRecord(serialNumber, payload)); + } else if (record.topic().equals(wifiScanTopic)) { + wifiScanRecords.add(new KafkaRecord(serialNumber, payload)); + } + } + + // Call listeners + if (!stateRecords.isEmpty()) { + for (KafkaListener listener : kafkaListeners.values()) { + listener.handleStateRecords(stateRecords); + } + } + if (!wifiScanRecords.isEmpty()) { + for (KafkaListener listener : kafkaListeners.values()) { + listener.handleWifiScanRecords(wifiScanRecords); + } + } + + // Commit offset + consumer.commitAsync(); + } + + /** + * Add/overwrite a Kafka listener with an arbitrary identifier. + * + * The "id" string determines the order in which listeners are called. + */ + public void addKafkaListener(String id, KafkaListener listener) { + logger.debug("Adding Kafka listener: {}", id); + kafkaListeners.put(id, listener); + } + + /** + * Remove a Kafka listener with the given identifier, returning true if + * anything was actually removed. + */ + public boolean removeKafkaListener(String id) { + logger.debug("Removing Kafka listener: {}", id); + return (kafkaListeners.remove(id) != null); + } + + /** Wakeup the consumer. */ + public void wakeup() { + consumer.wakeup(); + } + + /** Close the consumer. */ + public void close() { + consumer.close(); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralUtils.java b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralUtils.java new file mode 100644 index 0000000..8932e49 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralUtils.java @@ -0,0 +1,301 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.facebook.openwifirrm.ucentral.models.State; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * uCentral utility methods/structures. + */ +public class UCentralUtils { + private static final Logger logger = LoggerFactory.getLogger(UCentralUtils.class); + + /** The Gson instance. */ + private static final Gson gson = new Gson(); + + // This class should not be instantiated. + private UCentralUtils() {} + + /** Represents a single entry in wifi scan results. */ + public static class WifiScanEntry { + public int channel; + public long lastseen; + public int signal; + public String bssid; + public String ssid; + public long tsf; + public String ht_oper; + public String vht_oper; + public int capability; + + /** Default Constructor. */ + public WifiScanEntry() {} + + /** Copy Constructor. */ + public WifiScanEntry(WifiScanEntry o) { + this.channel = o.channel; + this.lastseen = o.lastseen; + this.signal = o.signal; + this.bssid = o.bssid; + this.ssid = o.ssid; + this.tsf = o.tsf; + this.ht_oper = o.ht_oper; + this.vht_oper = o.vht_oper; + this.capability = o.capability; + } + } + + /** + * Parse a JSON wifi scan result into a list of WifiScanEntry objects. + * + * Returns null if any parsing/deserialization error occurred. + */ + public static List parseWifiScanEntries(JsonObject result) { + List entries = new ArrayList<>(); + try { + JsonArray scanInfo = result + .getAsJsonObject("status") + .getAsJsonArray("scan"); + for (JsonElement e : scanInfo) { + entries.add(gson.fromJson(e, WifiScanEntry.class)); + } + } catch (Exception e) { + return null; + } + return entries; + } + + /** + * Set all radios config of an AP to a given value. + * + * Returns true if changed, or false if unchanged for any reason. + */ + public static boolean setRadioConfigField( + String serialNumber, + UCentralApConfiguration config, + String fieldName, + Map newValueList + ) { + boolean wasModified = false; + int radioCount = config.getRadioCount(); + + // Iterate all the radios of an AP to find the corresponding band + for (int radioIndex = 0; radioIndex < radioCount; radioIndex++) { + JsonObject radioConfig = config.getRadioConfig(radioIndex); + String operationalBand = radioConfig.get("band").getAsString(); + + if (!newValueList.containsKey(operationalBand)) { + continue; + } + + // If the field doesn't exist in config, we generate the fieldName and + // assign the new value to it. + int newValue = newValueList.get(operationalBand); + if (!radioConfig.has(fieldName)) { + radioConfig.addProperty(fieldName, newValue); + config.setRadioConfig(radioIndex, radioConfig); + logger.info( + "Device {}: setting {} {} to {} (was empty)", + serialNumber, + operationalBand, + fieldName, + newValue + ); + wasModified = true; + continue; + } + + // Compare vs. existing value + int currentValue = radioConfig.get(fieldName).getAsInt(); + if (currentValue == newValue) { + logger.info( + "Device {}: {} {} is already {}", + serialNumber, + operationalBand, + fieldName, + newValue + ); + } else { + // Update to new value + radioConfig.addProperty(fieldName, newValue); + config.setRadioConfig(radioIndex, radioConfig); + logger.info( + "Device {}: setting {} {} to {} (was {})", + serialNumber, + operationalBand, + fieldName, + newValue, + currentValue + ); + wasModified = true; + } + } + return wasModified; + } + + /** + * Get the APs on a band who participate in an optimization algorithm. + * Get the info from the configuration field in deviceStatus + * (Since the State doesn't explicitly show the "band" info) + * + * Returns the results map + */ + public static Map> getBandsMap( + Map deviceStatus + ) { + Map> bandsMap = new HashMap<>(); + + for (String serialNumber : deviceStatus.keySet()) { + JsonArray radioList = deviceStatus.get(serialNumber).getAsJsonArray(); + for (int radioIndex = 0; radioIndex < radioList.size(); radioIndex++) { + JsonElement e = radioList.get(radioIndex); + if (!e.isJsonObject()) { + return null; + } + JsonObject radioObject = e.getAsJsonObject(); + String band = radioObject.get("band").getAsString(); + bandsMap + .computeIfAbsent(band, k -> new ArrayList<>()) + .add(serialNumber); + } + } + + return bandsMap; + } + + /** + * Get the capabilities of the APs who participate in an optimization algorithm. + * + * @param deviceStatus map of {device, status} + * @param deviceCapabilities map of {device, capabilities info} + * @param defaultAvailableChannels map of {band, list of available channels} + * + * @return the results map of {band, {device, list of available channels}} + */ + public static Map>> getDeviceAvailableChannels( + Map deviceStatus, + Map deviceCapabilities, + Map> defaultAvailableChannels + ) { + Map>> deviceAvailableChannels = + new HashMap<>(); + + for (String serialNumber : deviceStatus.keySet()) { + JsonArray radioList = deviceStatus.get(serialNumber).getAsJsonArray(); + for (int radioIndex = 0; radioIndex < radioList.size(); radioIndex++) { + JsonElement e = radioList.get(radioIndex); + if (!e.isJsonObject()) { + return null; + } + JsonObject radioObject = e.getAsJsonObject(); + String band = radioObject.get("band").getAsString(); + + JsonObject capabilitesObject = deviceCapabilities.get(serialNumber); + List availableChannels = new ArrayList<>(); + if (capabilitesObject == null) { + availableChannels.addAll(defaultAvailableChannels.get(band)); + } else { + Set> entrySet = capabilitesObject + .entrySet(); + for (Map.Entry f : entrySet) { + String bandInsideObject = f.getValue() + .getAsJsonObject() + .get("band") + .getAsString(); + if (bandInsideObject.equals(band)) { + // (TODO) Remove the following dfsChannels code block + // when the DFS channels are available + Set dfsChannels = new HashSet<>(); + try { + JsonArray channelInfo = f.getValue() + .getAsJsonObject() + .get("dfs_channels") + .getAsJsonArray(); + + for (JsonElement d : channelInfo) { + dfsChannels.add(d.getAsInt()); + } + } catch (Exception d) {} + try { + JsonArray channelInfo = f.getValue() + .getAsJsonObject() + .get("channels") + .getAsJsonArray(); + for (JsonElement c : channelInfo) { + int channel = c.getAsInt(); + if (!dfsChannels.contains(channel)) { + availableChannels.add(channel); + } + } + } catch (Exception c) { + availableChannels + .addAll(defaultAvailableChannels.get(band)); + } + } + } + } + + deviceAvailableChannels.computeIfAbsent( + band, k -> new HashMap<>() + ).put( + serialNumber, availableChannels + ); + } + } + return deviceAvailableChannels; + } + + /** + * Get the mapping between bssids and APs. + * Get the info from the State data + * + * Returns the results map + */ + public static Map getBssidsMap(Map latestState) { + Map bssidMap = new HashMap<>(); + for (Map.Entry e: latestState.entrySet()) { + State state = e.getValue(); + for ( + int interfaceIndex = 0; + interfaceIndex < state.interfaces.length; + interfaceIndex++ + ) { + if (state.interfaces[interfaceIndex].ssids == null) { + continue; + } + for ( + int ssidIndex = 0; + ssidIndex < state.interfaces[interfaceIndex].ssids.length; + ssidIndex++ + ) { + bssidMap.put( + state.interfaces[interfaceIndex].ssids[ssidIndex].bssid, + e.getKey() + ); + } + } + } + return bssidMap; + } +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/CommandInfo.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/CommandInfo.java new file mode 100644 index 0000000..b7d6bb7 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/CommandInfo.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +import com.google.gson.JsonObject; + +public class CommandInfo { + public String UUID; + public String command; + public JsonObject details; + public String serialNumber; + public long submitted; + public long executed; + public long completed; + public long when; + public String errorText; + public JsonObject results; + public long errorCode; + public String submittedBy; + public String status; + public long custom; + public long waitingForFile; + public long attachFile; + public long attachSize; + public String attachType; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceCapabilities.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceCapabilities.java new file mode 100644 index 0000000..faeccb2 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceCapabilities.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +import com.google.gson.JsonObject; + +public class DeviceCapabilities { + public JsonObject capabilities; + public long firstUpdate; + public long lastUpdate; + public String serialNumber; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceConfigureRequest.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceConfigureRequest.java new file mode 100644 index 0000000..529f0d6 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceConfigureRequest.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +public class DeviceConfigureRequest { + public String serialNumber; + public long UUID; + public String configuration; + public long when; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceListWithStatus.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceListWithStatus.java new file mode 100644 index 0000000..6beeaa1 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceListWithStatus.java @@ -0,0 +1,15 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +import java.util.List; + +public class DeviceListWithStatus { + public List devicesWithStatus; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceType.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceType.java new file mode 100644 index 0000000..7277f38 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceType.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +public enum DeviceType { + AP, SWITCH, IOT, MESH +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceWithStatus.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceWithStatus.java new file mode 100644 index 0000000..e769a3c --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/DeviceWithStatus.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +import java.util.List; + +import com.google.gson.JsonObject; + +public class DeviceWithStatus { + public String owner; + public String location; + public String venue; + public String serialNumber; + public DeviceType deviceType; + public String macAddress; + public String manufacturer; + public long UUID; + public JsonObject configuration; + public String compatible; + public String fwUpdatePolicy; + public List notes; + public long createdTimestamp; + public long lastConfigurationChange; + public long lastConfigurationDownload; + public long lastFWUpdate; + public String firmware; + public boolean connected; + public String ipAddress; + public long txBytes; + public long rxBytes; + public long associations_2G; + public long associations_5G; + public String devicePassword; + // TODO: uCentralGw returns "lastContact" as string when uninitialized + //public long lastContact; + public long messageCount; + public VerifiedCertificate verifiedCertificate; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/NoteInfo.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/NoteInfo.java new file mode 100644 index 0000000..aba83c8 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/NoteInfo.java @@ -0,0 +1,15 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +public class NoteInfo { + public long created; + public String createdBy; + public String note; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/StatisticsDetails.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/StatisticsDetails.java new file mode 100644 index 0000000..0d2ea67 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/StatisticsDetails.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +import com.google.gson.JsonObject; + +public class StatisticsDetails { + public String serialNumber; + public long recorded; + public long UUID; + public JsonObject data; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/StatisticsRecords.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/StatisticsRecords.java new file mode 100644 index 0000000..d6da6db --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/StatisticsRecords.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +import java.util.List; + +public class StatisticsRecords { + public String serialNumber; + public List data; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/SystemInfoResults.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/SystemInfoResults.java new file mode 100644 index 0000000..57e402f --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/SystemInfoResults.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +import com.google.gson.JsonArray; + +public class SystemInfoResults { + public String version; + public long uptime; + public long start; + public String os; + public int processors; + public String hostname; + public JsonArray certificates; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/VerifiedCertificate.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/VerifiedCertificate.java new file mode 100644 index 0000000..5541b73 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/VerifiedCertificate.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +public enum VerifiedCertificate { + NO_CERTIFICATE, VALID_CERTIFICATE, MISMATCH_SERIAL, VERIFIED +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/WifiScanRequest.java b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/WifiScanRequest.java new file mode 100644 index 0000000..75ff031 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/gw/models/WifiScanRequest.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.gw.models; + +import com.google.gson.JsonObject; + +public class WifiScanRequest { + public String serialNumber; + public boolean verbose; + public boolean activeScan; + public JsonObject selector; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/models/DeviceCapabilities.java b/src/main/java/com/facebook/openwifirrm/ucentral/models/DeviceCapabilities.java new file mode 100644 index 0000000..a308b69 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/models/DeviceCapabilities.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.models; + +import com.google.gson.JsonObject; +import com.google.gson.annotations.SerializedName; + +public class DeviceCapabilities { + public String compatible; + public String model; + public String platform; + public JsonObject network; + @SerializedName("switch") public JsonObject switch_; + public JsonObject wifi; +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/models/State.java b/src/main/java/com/facebook/openwifirrm/ucentral/models/State.java new file mode 100644 index 0000000..6fbd61e --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/models/State.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.openwifirrm.ucentral.models; + +import com.google.gson.JsonObject; +import com.google.gson.annotations.SerializedName; + +public class State { + public class Interface { + public class Client { + public String mac; + public String[] ipv4_addresses; + public String[] ipv6_addresses; + public String[] ports; + // TODO last_seen + } + public class SSID { + public class Association { + public class Rate { + public long bitrate; + public int chwidth; + public boolean sgi; + public boolean ht; + public boolean vht; + public boolean he; + public int mcs; + public int nss; + public int he_gi; + public int he_dcm; + } + public String bssid; + public String station; + public long connected; + public long inactive; + public int rssi; + public long rx_bytes; + public long rx_packets; + public Rate rx_rate; + public long tx_bytes; + public long tx_duration; + public long tx_failed; + public long tx_offset; + public long tx_packets; + public Rate tx_rate; + public long tx_retries; + public int ack_signal; + public int ack_signal_avg; + public JsonObject[] tid_stats; // TODO: see cfg80211_tid_stats + } + + public Association[] associations; + public String bssid; + public String ssid; + public Counters counters; + public String iface; + public String mode; + public String phy; + public JsonObject radio; + } + public class Counters { + public long collisions; + public long multicast; + public long rx_bytes; + public long rx_packets; + public long rx_errors; + public long rx_dropped; + public long tx_bytes; + public long tx_packets; + public long tx_errors; + public long tx_dropped; + } + + public Client[] clients; + public SSID[] ssids; + public Counters counters; + public String location; + public String name; + public String ntp_server; + public String[] dns_servers; + public long uptime; + // TODO + public JsonObject ipv4; + public JsonObject ipv6; + public JsonObject[] lldp; + // TODO ports ? + } + public Interface[] interfaces; + + public class Unit { + public class Memory { + public long buffered; + public long cached; + public long free; + public long total; + } + + public double[] load; + public long localtime; + public Memory memory; + public long uptime; + } + public Unit unit; + + // TODO + public JsonObject[] radios; + @SerializedName("link-state") public JsonObject linkState; + public JsonObject gps; + public JsonObject poe; +} diff --git a/src/main/resources/log4j.properties b/src/main/resources/log4j.properties new file mode 100644 index 0000000..569a5fb --- /dev/null +++ b/src/main/resources/log4j.properties @@ -0,0 +1,19 @@ +log4j.rootLogger=ERROR, stdout, file + +log4j.logger.com.facebook.openwifirrm=DEBUG +log4j.logger.org.apache.kafka=INFO + +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd'T'HH:mm:ss.SSS} %-5p [%c{1}:%L] - %m%n + +log4j.appender.file=org.apache.log4j.RollingFileAppender +log4j.appender.file.Threshold=INFO +log4j.appender.file.File=./openwifi-rrm.log +log4j.appender.file.MaxFileSize=50MB +log4j.appender.file.MaxBackupIndex=10 +log4j.appender.file.Append=true +log4j.appender.file.Encoding=UTF-8 +log4j.appender.file.layout=org.apache.log4j.PatternLayout +log4j.appender.file.layout.ConversionPattern=%d{yyyy-MM-dd'T'HH:mm:ss.SSS} %-5p [%c{1}:%L] - %m%n diff --git a/src/main/resources/public/index.html b/src/main/resources/public/index.html new file mode 100644 index 0000000..83d20bf --- /dev/null +++ b/src/main/resources/public/index.html @@ -0,0 +1,59 @@ + + + + + + Swagger UI + + + + + +

+ + + + + + diff --git a/src/main/resources/public/oauth2-redirect.html b/src/main/resources/public/oauth2-redirect.html new file mode 100644 index 0000000..64b171f --- /dev/null +++ b/src/main/resources/public/oauth2-redirect.html @@ -0,0 +1,75 @@ + + + + Swagger UI: OAuth2 Redirect + + + + + diff --git a/src/main/resources/public/swagger-ui-bundle.js b/src/main/resources/public/swagger-ui-bundle.js new file mode 100644 index 0000000..f0c8fd3 --- /dev/null +++ b/src/main/resources/public/swagger-ui-bundle.js @@ -0,0 +1,3 @@ +/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SwaggerUIBundle=t():e.SwaggerUIBundle=t()}(this,(function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist",n(n.s=543)}([function(e,t,n){"use strict";e.exports=n(134)},function(e,t,n){e.exports=function(){"use strict";var e=Array.prototype.slice;function t(e,t){t&&(e.prototype=Object.create(t.prototype)),e.prototype.constructor=e}function n(e){return i(e)?e:J(e)}function r(e){return s(e)?e:K(e)}function o(e){return u(e)?e:Y(e)}function a(e){return i(e)&&!c(e)?e:G(e)}function i(e){return!(!e||!e[p])}function s(e){return!(!e||!e[f])}function u(e){return!(!e||!e[h])}function c(e){return s(e)||u(e)}function l(e){return!(!e||!e[d])}t(r,n),t(o,n),t(a,n),n.isIterable=i,n.isKeyed=s,n.isIndexed=u,n.isAssociative=c,n.isOrdered=l,n.Keyed=r,n.Indexed=o,n.Set=a;var p="@@__IMMUTABLE_ITERABLE__@@",f="@@__IMMUTABLE_KEYED__@@",h="@@__IMMUTABLE_INDEXED__@@",d="@@__IMMUTABLE_ORDERED__@@",m="delete",v=5,g=1<>>0;if(""+n!==t||4294967295===n)return NaN;t=n}return t<0?A(e)+t:t}function k(){return!0}function j(e,t,n){return(0===e||void 0!==n&&e<=-n)&&(void 0===t||void 0!==n&&t>=n)}function T(e,t){return P(e,t,0)}function I(e,t){return P(e,t,t)}function P(e,t,n){return void 0===e?n:e<0?Math.max(0,t+e):void 0===t?e:Math.min(t,e)}var N=0,M=1,R=2,D="function"==typeof Symbol&&Symbol.iterator,L="@@iterator",B=D||L;function F(e){this.next=e}function U(e,t,n,r){var o=0===e?t:1===e?n:[t,n];return r?r.value=o:r={value:o,done:!1},r}function q(){return{value:void 0,done:!0}}function z(e){return!!H(e)}function V(e){return e&&"function"==typeof e.next}function W(e){var t=H(e);return t&&t.call(e)}function H(e){var t=e&&(D&&e[D]||e[L]);if("function"==typeof t)return t}function $(e){return e&&"number"==typeof e.length}function J(e){return null==e?ie():i(e)?e.toSeq():ce(e)}function K(e){return null==e?ie().toKeyedSeq():i(e)?s(e)?e.toSeq():e.fromEntrySeq():se(e)}function Y(e){return null==e?ie():i(e)?s(e)?e.entrySeq():e.toIndexedSeq():ue(e)}function G(e){return(null==e?ie():i(e)?s(e)?e.entrySeq():e:ue(e)).toSetSeq()}F.prototype.toString=function(){return"[Iterator]"},F.KEYS=N,F.VALUES=M,F.ENTRIES=R,F.prototype.inspect=F.prototype.toSource=function(){return this.toString()},F.prototype[B]=function(){return this},t(J,n),J.of=function(){return J(arguments)},J.prototype.toSeq=function(){return this},J.prototype.toString=function(){return this.__toString("Seq {","}")},J.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},J.prototype.__iterate=function(e,t){return pe(this,e,t,!0)},J.prototype.__iterator=function(e,t){return fe(this,e,t,!0)},t(K,J),K.prototype.toKeyedSeq=function(){return this},t(Y,J),Y.of=function(){return Y(arguments)},Y.prototype.toIndexedSeq=function(){return this},Y.prototype.toString=function(){return this.__toString("Seq [","]")},Y.prototype.__iterate=function(e,t){return pe(this,e,t,!1)},Y.prototype.__iterator=function(e,t){return fe(this,e,t,!1)},t(G,J),G.of=function(){return G(arguments)},G.prototype.toSetSeq=function(){return this},J.isSeq=ae,J.Keyed=K,J.Set=G,J.Indexed=Y;var Z,X,Q,ee="@@__IMMUTABLE_SEQ__@@";function te(e){this._array=e,this.size=e.length}function ne(e){var t=Object.keys(e);this._object=e,this._keys=t,this.size=t.length}function re(e){this._iterable=e,this.size=e.length||e.size}function oe(e){this._iterator=e,this._iteratorCache=[]}function ae(e){return!(!e||!e[ee])}function ie(){return Z||(Z=new te([]))}function se(e){var t=Array.isArray(e)?new te(e).fromEntrySeq():V(e)?new oe(e).fromEntrySeq():z(e)?new re(e).fromEntrySeq():"object"==typeof e?new ne(e):void 0;if(!t)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+e);return t}function ue(e){var t=le(e);if(!t)throw new TypeError("Expected Array or iterable object of values: "+e);return t}function ce(e){var t=le(e)||"object"==typeof e&&new ne(e);if(!t)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+e);return t}function le(e){return $(e)?new te(e):V(e)?new oe(e):z(e)?new re(e):void 0}function pe(e,t,n,r){var o=e._cache;if(o){for(var a=o.length-1,i=0;i<=a;i++){var s=o[n?a-i:i];if(!1===t(s[1],r?s[0]:i,e))return i+1}return i}return e.__iterateUncached(t,n)}function fe(e,t,n,r){var o=e._cache;if(o){var a=o.length-1,i=0;return new F((function(){var e=o[n?a-i:i];return i++>a?q():U(t,r?e[0]:i-1,e[1])}))}return e.__iteratorUncached(t,n)}function he(e,t){return t?de(t,e,"",{"":e}):me(e)}function de(e,t,n,r){return Array.isArray(t)?e.call(r,n,Y(t).map((function(n,r){return de(e,n,r,t)}))):ve(t)?e.call(r,n,K(t).map((function(n,r){return de(e,n,r,t)}))):t}function me(e){return Array.isArray(e)?Y(e).map(me).toList():ve(e)?K(e).map(me).toMap():e}function ve(e){return e&&(e.constructor===Object||void 0===e.constructor)}function ge(e,t){if(e===t||e!=e&&t!=t)return!0;if(!e||!t)return!1;if("function"==typeof e.valueOf&&"function"==typeof t.valueOf){if((e=e.valueOf())===(t=t.valueOf())||e!=e&&t!=t)return!0;if(!e||!t)return!1}return!("function"!=typeof e.equals||"function"!=typeof t.equals||!e.equals(t))}function ye(e,t){if(e===t)return!0;if(!i(t)||void 0!==e.size&&void 0!==t.size&&e.size!==t.size||void 0!==e.__hash&&void 0!==t.__hash&&e.__hash!==t.__hash||s(e)!==s(t)||u(e)!==u(t)||l(e)!==l(t))return!1;if(0===e.size&&0===t.size)return!0;var n=!c(e);if(l(e)){var r=e.entries();return t.every((function(e,t){var o=r.next().value;return o&&ge(o[1],e)&&(n||ge(o[0],t))}))&&r.next().done}var o=!1;if(void 0===e.size)if(void 0===t.size)"function"==typeof e.cacheResult&&e.cacheResult();else{o=!0;var a=e;e=t,t=a}var p=!0,f=t.__iterate((function(t,r){if(n?!e.has(t):o?!ge(t,e.get(r,b)):!ge(e.get(r,b),t))return p=!1,!1}));return p&&e.size===f}function be(e,t){if(!(this instanceof be))return new be(e,t);if(this._value=e,this.size=void 0===t?1/0:Math.max(0,t),0===this.size){if(X)return X;X=this}}function _e(e,t){if(!e)throw new Error(t)}function we(e,t,n){if(!(this instanceof we))return new we(e,t,n);if(_e(0!==n,"Cannot step a Range by 0"),e=e||0,void 0===t&&(t=1/0),n=void 0===n?1:Math.abs(n),tr?q():U(e,o,n[t?r-o++:o++])}))},t(ne,K),ne.prototype.get=function(e,t){return void 0===t||this.has(e)?this._object[e]:t},ne.prototype.has=function(e){return this._object.hasOwnProperty(e)},ne.prototype.__iterate=function(e,t){for(var n=this._object,r=this._keys,o=r.length-1,a=0;a<=o;a++){var i=r[t?o-a:a];if(!1===e(n[i],i,this))return a+1}return a},ne.prototype.__iterator=function(e,t){var n=this._object,r=this._keys,o=r.length-1,a=0;return new F((function(){var i=r[t?o-a:a];return a++>o?q():U(e,i,n[i])}))},ne.prototype[d]=!0,t(re,Y),re.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);var n=W(this._iterable),r=0;if(V(n))for(var o;!(o=n.next()).done&&!1!==e(o.value,r++,this););return r},re.prototype.__iteratorUncached=function(e,t){if(t)return this.cacheResult().__iterator(e,t);var n=W(this._iterable);if(!V(n))return new F(q);var r=0;return new F((function(){var t=n.next();return t.done?t:U(e,r++,t.value)}))},t(oe,Y),oe.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);for(var n,r=this._iterator,o=this._iteratorCache,a=0;a=r.length){var t=n.next();if(t.done)return t;r[o]=t.value}return U(e,o,r[o++])}))},t(be,Y),be.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},be.prototype.get=function(e,t){return this.has(e)?this._value:t},be.prototype.includes=function(e){return ge(this._value,e)},be.prototype.slice=function(e,t){var n=this.size;return j(e,t,n)?this:new be(this._value,I(t,n)-T(e,n))},be.prototype.reverse=function(){return this},be.prototype.indexOf=function(e){return ge(this._value,e)?0:-1},be.prototype.lastIndexOf=function(e){return ge(this._value,e)?this.size:-1},be.prototype.__iterate=function(e,t){for(var n=0;n=0&&t=0&&nn?q():U(e,a++,i)}))},we.prototype.equals=function(e){return e instanceof we?this._start===e._start&&this._end===e._end&&this._step===e._step:ye(this,e)},t(xe,n),t(Ee,xe),t(Se,xe),t(Ce,xe),xe.Keyed=Ee,xe.Indexed=Se,xe.Set=Ce;var Ae="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function(e,t){var n=65535&(e|=0),r=65535&(t|=0);return n*r+((e>>>16)*r+n*(t>>>16)<<16>>>0)|0};function Oe(e){return e>>>1&1073741824|3221225471&e}function ke(e){if(!1===e||null==e)return 0;if("function"==typeof e.valueOf&&(!1===(e=e.valueOf())||null==e))return 0;if(!0===e)return 1;var t=typeof e;if("number"===t){if(e!=e||e===1/0)return 0;var n=0|e;for(n!==e&&(n^=4294967295*e);e>4294967295;)n^=e/=4294967295;return Oe(n)}if("string"===t)return e.length>Fe?je(e):Te(e);if("function"==typeof e.hashCode)return e.hashCode();if("object"===t)return Ie(e);if("function"==typeof e.toString)return Te(e.toString());throw new Error("Value type "+t+" cannot be hashed.")}function je(e){var t=ze[e];return void 0===t&&(t=Te(e),qe===Ue&&(qe=0,ze={}),qe++,ze[e]=t),t}function Te(e){for(var t=0,n=0;n0)switch(e.nodeType){case 1:return e.uniqueID;case 9:return e.documentElement&&e.documentElement.uniqueID}}var Re,De="function"==typeof WeakMap;De&&(Re=new WeakMap);var Le=0,Be="__immutablehash__";"function"==typeof Symbol&&(Be=Symbol(Be));var Fe=16,Ue=255,qe=0,ze={};function Ve(e){_e(e!==1/0,"Cannot perform this action with an infinite size.")}function We(e){return null==e?ot():He(e)&&!l(e)?e:ot().withMutations((function(t){var n=r(e);Ve(n.size),n.forEach((function(e,n){return t.set(n,e)}))}))}function He(e){return!(!e||!e[Je])}t(We,Ee),We.of=function(){var t=e.call(arguments,0);return ot().withMutations((function(e){for(var n=0;n=t.length)throw new Error("Missing value for key: "+t[n]);e.set(t[n],t[n+1])}}))},We.prototype.toString=function(){return this.__toString("Map {","}")},We.prototype.get=function(e,t){return this._root?this._root.get(0,void 0,e,t):t},We.prototype.set=function(e,t){return at(this,e,t)},We.prototype.setIn=function(e,t){return this.updateIn(e,b,(function(){return t}))},We.prototype.remove=function(e){return at(this,e,b)},We.prototype.deleteIn=function(e){return this.updateIn(e,(function(){return b}))},We.prototype.update=function(e,t,n){return 1===arguments.length?e(this):this.updateIn([e],t,n)},We.prototype.updateIn=function(e,t,n){n||(n=t,t=void 0);var r=vt(this,xn(e),t,n);return r===b?void 0:r},We.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):ot()},We.prototype.merge=function(){return ft(this,void 0,arguments)},We.prototype.mergeWith=function(t){return ft(this,t,e.call(arguments,1))},We.prototype.mergeIn=function(t){var n=e.call(arguments,1);return this.updateIn(t,ot(),(function(e){return"function"==typeof e.merge?e.merge.apply(e,n):n[n.length-1]}))},We.prototype.mergeDeep=function(){return ft(this,ht,arguments)},We.prototype.mergeDeepWith=function(t){var n=e.call(arguments,1);return ft(this,dt(t),n)},We.prototype.mergeDeepIn=function(t){var n=e.call(arguments,1);return this.updateIn(t,ot(),(function(e){return"function"==typeof e.mergeDeep?e.mergeDeep.apply(e,n):n[n.length-1]}))},We.prototype.sort=function(e){return zt(pn(this,e))},We.prototype.sortBy=function(e,t){return zt(pn(this,t,e))},We.prototype.withMutations=function(e){var t=this.asMutable();return e(t),t.wasAltered()?t.__ensureOwner(this.__ownerID):this},We.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new S)},We.prototype.asImmutable=function(){return this.__ensureOwner()},We.prototype.wasAltered=function(){return this.__altered},We.prototype.__iterator=function(e,t){return new et(this,e,t)},We.prototype.__iterate=function(e,t){var n=this,r=0;return this._root&&this._root.iterate((function(t){return r++,e(t[1],t[0],n)}),t),r},We.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?rt(this.size,this._root,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},We.isMap=He;var $e,Je="@@__IMMUTABLE_MAP__@@",Ke=We.prototype;function Ye(e,t){this.ownerID=e,this.entries=t}function Ge(e,t,n){this.ownerID=e,this.bitmap=t,this.nodes=n}function Ze(e,t,n){this.ownerID=e,this.count=t,this.nodes=n}function Xe(e,t,n){this.ownerID=e,this.keyHash=t,this.entries=n}function Qe(e,t,n){this.ownerID=e,this.keyHash=t,this.entry=n}function et(e,t,n){this._type=t,this._reverse=n,this._stack=e._root&&nt(e._root)}function tt(e,t){return U(e,t[0],t[1])}function nt(e,t){return{node:e,index:0,__prev:t}}function rt(e,t,n,r){var o=Object.create(Ke);return o.size=e,o._root=t,o.__ownerID=n,o.__hash=r,o.__altered=!1,o}function ot(){return $e||($e=rt(0))}function at(e,t,n){var r,o;if(e._root){var a=x(_),i=x(w);if(r=it(e._root,e.__ownerID,0,void 0,t,n,a,i),!i.value)return e;o=e.size+(a.value?n===b?-1:1:0)}else{if(n===b)return e;o=1,r=new Ye(e.__ownerID,[[t,n]])}return e.__ownerID?(e.size=o,e._root=r,e.__hash=void 0,e.__altered=!0,e):r?rt(o,r):ot()}function it(e,t,n,r,o,a,i,s){return e?e.update(t,n,r,o,a,i,s):a===b?e:(E(s),E(i),new Qe(t,r,[o,a]))}function st(e){return e.constructor===Qe||e.constructor===Xe}function ut(e,t,n,r,o){if(e.keyHash===r)return new Xe(t,r,[e.entry,o]);var a,i=(0===n?e.keyHash:e.keyHash>>>n)&y,s=(0===n?r:r>>>n)&y;return new Ge(t,1<>>=1)i[s]=1&n?t[a++]:void 0;return i[r]=o,new Ze(e,a+1,i)}function ft(e,t,n){for(var o=[],a=0;a>1&1431655765))+(e>>2&858993459))+(e>>4)&252645135,e+=e>>8,127&(e+=e>>16)}function yt(e,t,n,r){var o=r?e:C(e);return o[t]=n,o}function bt(e,t,n,r){var o=e.length+1;if(r&&t+1===o)return e[t]=n,e;for(var a=new Array(o),i=0,s=0;s=wt)return ct(e,u,r,o);var f=e&&e===this.ownerID,h=f?u:C(u);return p?s?c===l-1?h.pop():h[c]=h.pop():h[c]=[r,o]:h.push([r,o]),f?(this.entries=h,this):new Ye(e,h)}},Ge.prototype.get=function(e,t,n,r){void 0===t&&(t=ke(n));var o=1<<((0===e?t:t>>>e)&y),a=this.bitmap;return 0==(a&o)?r:this.nodes[gt(a&o-1)].get(e+v,t,n,r)},Ge.prototype.update=function(e,t,n,r,o,a,i){void 0===n&&(n=ke(r));var s=(0===t?n:n>>>t)&y,u=1<=xt)return pt(e,f,c,s,d);if(l&&!d&&2===f.length&&st(f[1^p]))return f[1^p];if(l&&d&&1===f.length&&st(d))return d;var m=e&&e===this.ownerID,g=l?d?c:c^u:c|u,_=l?d?yt(f,p,d,m):_t(f,p,m):bt(f,p,d,m);return m?(this.bitmap=g,this.nodes=_,this):new Ge(e,g,_)},Ze.prototype.get=function(e,t,n,r){void 0===t&&(t=ke(n));var o=(0===e?t:t>>>e)&y,a=this.nodes[o];return a?a.get(e+v,t,n,r):r},Ze.prototype.update=function(e,t,n,r,o,a,i){void 0===n&&(n=ke(r));var s=(0===t?n:n>>>t)&y,u=o===b,c=this.nodes,l=c[s];if(u&&!l)return this;var p=it(l,e,t+v,n,r,o,a,i);if(p===l)return this;var f=this.count;if(l){if(!p&&--f0&&r=0&&e>>t&y;if(r>=this.array.length)return new kt([],e);var o,a=0===r;if(t>0){var i=this.array[r];if((o=i&&i.removeBefore(e,t-v,n))===i&&a)return this}if(a&&!o)return this;var s=Lt(this,e);if(!a)for(var u=0;u>>t&y;if(o>=this.array.length)return this;if(t>0){var a=this.array[o];if((r=a&&a.removeAfter(e,t-v,n))===a&&o===this.array.length-1)return this}var i=Lt(this,e);return i.array.splice(o+1),r&&(i.array[o]=r),i};var jt,Tt,It={};function Pt(e,t){var n=e._origin,r=e._capacity,o=qt(r),a=e._tail;return i(e._root,e._level,0);function i(e,t,n){return 0===t?s(e,n):u(e,t,n)}function s(e,i){var s=i===o?a&&a.array:e&&e.array,u=i>n?0:n-i,c=r-i;return c>g&&(c=g),function(){if(u===c)return It;var e=t?--c:u++;return s&&s[e]}}function u(e,o,a){var s,u=e&&e.array,c=a>n?0:n-a>>o,l=1+(r-a>>o);return l>g&&(l=g),function(){for(;;){if(s){var e=s();if(e!==It)return e;s=null}if(c===l)return It;var n=t?--l:c++;s=i(u&&u[n],o-v,a+(n<=e.size||t<0)return e.withMutations((function(e){t<0?Ft(e,t).set(0,n):Ft(e,0,t+1).set(t,n)}));t+=e._origin;var r=e._tail,o=e._root,a=x(w);return t>=qt(e._capacity)?r=Dt(r,e.__ownerID,0,t,n,a):o=Dt(o,e.__ownerID,e._level,t,n,a),a.value?e.__ownerID?(e._root=o,e._tail=r,e.__hash=void 0,e.__altered=!0,e):Nt(e._origin,e._capacity,e._level,o,r):e}function Dt(e,t,n,r,o,a){var i,s=r>>>n&y,u=e&&s0){var c=e&&e.array[s],l=Dt(c,t,n-v,r,o,a);return l===c?e:((i=Lt(e,t)).array[s]=l,i)}return u&&e.array[s]===o?e:(E(a),i=Lt(e,t),void 0===o&&s===i.array.length-1?i.array.pop():i.array[s]=o,i)}function Lt(e,t){return t&&e&&t===e.ownerID?e:new kt(e?e.array.slice():[],t)}function Bt(e,t){if(t>=qt(e._capacity))return e._tail;if(t<1<0;)n=n.array[t>>>r&y],r-=v;return n}}function Ft(e,t,n){void 0!==t&&(t|=0),void 0!==n&&(n|=0);var r=e.__ownerID||new S,o=e._origin,a=e._capacity,i=o+t,s=void 0===n?a:n<0?a+n:o+n;if(i===o&&s===a)return e;if(i>=s)return e.clear();for(var u=e._level,c=e._root,l=0;i+l<0;)c=new kt(c&&c.array.length?[void 0,c]:[],r),l+=1<<(u+=v);l&&(i+=l,o+=l,s+=l,a+=l);for(var p=qt(a),f=qt(s);f>=1<p?new kt([],r):h;if(h&&f>p&&iv;g-=v){var b=p>>>g&y;m=m.array[b]=Lt(m.array[b],r)}m.array[p>>>v&y]=h}if(s=f)i-=f,s-=f,u=v,c=null,d=d&&d.removeBefore(r,0,i);else if(i>o||f>>u&y;if(_!==f>>>u&y)break;_&&(l+=(1<o&&(c=c.removeBefore(r,u,i-l)),c&&fa&&(a=c.size),i(u)||(c=c.map((function(e){return he(e)}))),r.push(c)}return a>e.size&&(e=e.setSize(a)),mt(e,t,r)}function qt(e){return e>>v<=g&&i.size>=2*a.size?(r=(o=i.filter((function(e,t){return void 0!==e&&s!==t}))).toKeyedSeq().map((function(e){return e[0]})).flip().toMap(),e.__ownerID&&(r.__ownerID=o.__ownerID=e.__ownerID)):(r=a.remove(t),o=s===i.size-1?i.pop():i.set(s,void 0))}else if(u){if(n===i.get(s)[1])return e;r=a,o=i.set(s,[t,n])}else r=a.set(t,i.size),o=i.set(i.size,[t,n]);return e.__ownerID?(e.size=r.size,e._map=r,e._list=o,e.__hash=void 0,e):Wt(r,o)}function Jt(e,t){this._iter=e,this._useKeys=t,this.size=e.size}function Kt(e){this._iter=e,this.size=e.size}function Yt(e){this._iter=e,this.size=e.size}function Gt(e){this._iter=e,this.size=e.size}function Zt(e){var t=bn(e);return t._iter=e,t.size=e.size,t.flip=function(){return e},t.reverse=function(){var t=e.reverse.apply(this);return t.flip=function(){return e.reverse()},t},t.has=function(t){return e.includes(t)},t.includes=function(t){return e.has(t)},t.cacheResult=_n,t.__iterateUncached=function(t,n){var r=this;return e.__iterate((function(e,n){return!1!==t(n,e,r)}),n)},t.__iteratorUncached=function(t,n){if(t===R){var r=e.__iterator(t,n);return new F((function(){var e=r.next();if(!e.done){var t=e.value[0];e.value[0]=e.value[1],e.value[1]=t}return e}))}return e.__iterator(t===M?N:M,n)},t}function Xt(e,t,n){var r=bn(e);return r.size=e.size,r.has=function(t){return e.has(t)},r.get=function(r,o){var a=e.get(r,b);return a===b?o:t.call(n,a,r,e)},r.__iterateUncached=function(r,o){var a=this;return e.__iterate((function(e,o,i){return!1!==r(t.call(n,e,o,i),o,a)}),o)},r.__iteratorUncached=function(r,o){var a=e.__iterator(R,o);return new F((function(){var o=a.next();if(o.done)return o;var i=o.value,s=i[0];return U(r,s,t.call(n,i[1],s,e),o)}))},r}function Qt(e,t){var n=bn(e);return n._iter=e,n.size=e.size,n.reverse=function(){return e},e.flip&&(n.flip=function(){var t=Zt(e);return t.reverse=function(){return e.flip()},t}),n.get=function(n,r){return e.get(t?n:-1-n,r)},n.has=function(n){return e.has(t?n:-1-n)},n.includes=function(t){return e.includes(t)},n.cacheResult=_n,n.__iterate=function(t,n){var r=this;return e.__iterate((function(e,n){return t(e,n,r)}),!n)},n.__iterator=function(t,n){return e.__iterator(t,!n)},n}function en(e,t,n,r){var o=bn(e);return r&&(o.has=function(r){var o=e.get(r,b);return o!==b&&!!t.call(n,o,r,e)},o.get=function(r,o){var a=e.get(r,b);return a!==b&&t.call(n,a,r,e)?a:o}),o.__iterateUncached=function(o,a){var i=this,s=0;return e.__iterate((function(e,a,u){if(t.call(n,e,a,u))return s++,o(e,r?a:s-1,i)}),a),s},o.__iteratorUncached=function(o,a){var i=e.__iterator(R,a),s=0;return new F((function(){for(;;){var a=i.next();if(a.done)return a;var u=a.value,c=u[0],l=u[1];if(t.call(n,l,c,e))return U(o,r?c:s++,l,a)}}))},o}function tn(e,t,n){var r=We().asMutable();return e.__iterate((function(o,a){r.update(t.call(n,o,a,e),0,(function(e){return e+1}))})),r.asImmutable()}function nn(e,t,n){var r=s(e),o=(l(e)?zt():We()).asMutable();e.__iterate((function(a,i){o.update(t.call(n,a,i,e),(function(e){return(e=e||[]).push(r?[i,a]:a),e}))}));var a=yn(e);return o.map((function(t){return mn(e,a(t))}))}function rn(e,t,n,r){var o=e.size;if(void 0!==t&&(t|=0),void 0!==n&&(n===1/0?n=o:n|=0),j(t,n,o))return e;var a=T(t,o),i=I(n,o);if(a!=a||i!=i)return rn(e.toSeq().cacheResult(),t,n,r);var s,u=i-a;u==u&&(s=u<0?0:u);var c=bn(e);return c.size=0===s?s:e.size&&s||void 0,!r&&ae(e)&&s>=0&&(c.get=function(t,n){return(t=O(this,t))>=0&&ts)return q();var e=o.next();return r||t===M?e:U(t,u-1,t===N?void 0:e.value[1],e)}))},c}function on(e,t,n){var r=bn(e);return r.__iterateUncached=function(r,o){var a=this;if(o)return this.cacheResult().__iterate(r,o);var i=0;return e.__iterate((function(e,o,s){return t.call(n,e,o,s)&&++i&&r(e,o,a)})),i},r.__iteratorUncached=function(r,o){var a=this;if(o)return this.cacheResult().__iterator(r,o);var i=e.__iterator(R,o),s=!0;return new F((function(){if(!s)return q();var e=i.next();if(e.done)return e;var o=e.value,u=o[0],c=o[1];return t.call(n,c,u,a)?r===R?e:U(r,u,c,e):(s=!1,q())}))},r}function an(e,t,n,r){var o=bn(e);return o.__iterateUncached=function(o,a){var i=this;if(a)return this.cacheResult().__iterate(o,a);var s=!0,u=0;return e.__iterate((function(e,a,c){if(!s||!(s=t.call(n,e,a,c)))return u++,o(e,r?a:u-1,i)})),u},o.__iteratorUncached=function(o,a){var i=this;if(a)return this.cacheResult().__iterator(o,a);var s=e.__iterator(R,a),u=!0,c=0;return new F((function(){var e,a,l;do{if((e=s.next()).done)return r||o===M?e:U(o,c++,o===N?void 0:e.value[1],e);var p=e.value;a=p[0],l=p[1],u&&(u=t.call(n,l,a,i))}while(u);return o===R?e:U(o,a,l,e)}))},o}function sn(e,t){var n=s(e),o=[e].concat(t).map((function(e){return i(e)?n&&(e=r(e)):e=n?se(e):ue(Array.isArray(e)?e:[e]),e})).filter((function(e){return 0!==e.size}));if(0===o.length)return e;if(1===o.length){var a=o[0];if(a===e||n&&s(a)||u(e)&&u(a))return a}var c=new te(o);return n?c=c.toKeyedSeq():u(e)||(c=c.toSetSeq()),(c=c.flatten(!0)).size=o.reduce((function(e,t){if(void 0!==e){var n=t.size;if(void 0!==n)return e+n}}),0),c}function un(e,t,n){var r=bn(e);return r.__iterateUncached=function(r,o){var a=0,s=!1;function u(e,c){var l=this;e.__iterate((function(e,o){return(!t||c0}function dn(e,t,r){var o=bn(e);return o.size=new te(r).map((function(e){return e.size})).min(),o.__iterate=function(e,t){for(var n,r=this.__iterator(M,t),o=0;!(n=r.next()).done&&!1!==e(n.value,o++,this););return o},o.__iteratorUncached=function(e,o){var a=r.map((function(e){return e=n(e),W(o?e.reverse():e)})),i=0,s=!1;return new F((function(){var n;return s||(n=a.map((function(e){return e.next()})),s=n.some((function(e){return e.done}))),s?q():U(e,i++,t.apply(null,n.map((function(e){return e.value}))))}))},o}function mn(e,t){return ae(e)?t:e.constructor(t)}function vn(e){if(e!==Object(e))throw new TypeError("Expected [K, V] tuple: "+e)}function gn(e){return Ve(e.size),A(e)}function yn(e){return s(e)?r:u(e)?o:a}function bn(e){return Object.create((s(e)?K:u(e)?Y:G).prototype)}function _n(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):J.prototype.cacheResult.call(this)}function wn(e,t){return e>t?1:e=0;n--)t={value:arguments[n],next:t};return this.__ownerID?(this.size=e,this._head=t,this.__hash=void 0,this.__altered=!0,this):Kn(e,t)},Vn.prototype.pushAll=function(e){if(0===(e=o(e)).size)return this;Ve(e.size);var t=this.size,n=this._head;return e.reverse().forEach((function(e){t++,n={value:e,next:n}})),this.__ownerID?(this.size=t,this._head=n,this.__hash=void 0,this.__altered=!0,this):Kn(t,n)},Vn.prototype.pop=function(){return this.slice(1)},Vn.prototype.unshift=function(){return this.push.apply(this,arguments)},Vn.prototype.unshiftAll=function(e){return this.pushAll(e)},Vn.prototype.shift=function(){return this.pop.apply(this,arguments)},Vn.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):Yn()},Vn.prototype.slice=function(e,t){if(j(e,t,this.size))return this;var n=T(e,this.size);if(I(t,this.size)!==this.size)return Se.prototype.slice.call(this,e,t);for(var r=this.size-n,o=this._head;n--;)o=o.next;return this.__ownerID?(this.size=r,this._head=o,this.__hash=void 0,this.__altered=!0,this):Kn(r,o)},Vn.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?Kn(this.size,this._head,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},Vn.prototype.__iterate=function(e,t){if(t)return this.reverse().__iterate(e);for(var n=0,r=this._head;r&&!1!==e(r.value,n++,this);)r=r.next;return n},Vn.prototype.__iterator=function(e,t){if(t)return this.reverse().__iterator(e);var n=0,r=this._head;return new F((function(){if(r){var t=r.value;return r=r.next,U(e,n++,t)}return q()}))},Vn.isStack=Wn;var Hn,$n="@@__IMMUTABLE_STACK__@@",Jn=Vn.prototype;function Kn(e,t,n,r){var o=Object.create(Jn);return o.size=e,o._head=t,o.__ownerID=n,o.__hash=r,o.__altered=!1,o}function Yn(){return Hn||(Hn=Kn(0))}function Gn(e,t){var n=function(n){e.prototype[n]=t[n]};return Object.keys(t).forEach(n),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(t).forEach(n),e}Jn[$n]=!0,Jn.withMutations=Ke.withMutations,Jn.asMutable=Ke.asMutable,Jn.asImmutable=Ke.asImmutable,Jn.wasAltered=Ke.wasAltered,n.Iterator=F,Gn(n,{toArray:function(){Ve(this.size);var e=new Array(this.size||0);return this.valueSeq().__iterate((function(t,n){e[n]=t})),e},toIndexedSeq:function(){return new Kt(this)},toJS:function(){return this.toSeq().map((function(e){return e&&"function"==typeof e.toJS?e.toJS():e})).__toJS()},toJSON:function(){return this.toSeq().map((function(e){return e&&"function"==typeof e.toJSON?e.toJSON():e})).__toJS()},toKeyedSeq:function(){return new Jt(this,!0)},toMap:function(){return We(this.toKeyedSeq())},toObject:function(){Ve(this.size);var e={};return this.__iterate((function(t,n){e[n]=t})),e},toOrderedMap:function(){return zt(this.toKeyedSeq())},toOrderedSet:function(){return Ln(s(this)?this.valueSeq():this)},toSet:function(){return jn(s(this)?this.valueSeq():this)},toSetSeq:function(){return new Yt(this)},toSeq:function(){return u(this)?this.toIndexedSeq():s(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Vn(s(this)?this.valueSeq():this)},toList:function(){return St(s(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(e,t){return 0===this.size?e+t:e+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+t},concat:function(){return mn(this,sn(this,e.call(arguments,0)))},includes:function(e){return this.some((function(t){return ge(t,e)}))},entries:function(){return this.__iterator(R)},every:function(e,t){Ve(this.size);var n=!0;return this.__iterate((function(r,o,a){if(!e.call(t,r,o,a))return n=!1,!1})),n},filter:function(e,t){return mn(this,en(this,e,t,!0))},find:function(e,t,n){var r=this.findEntry(e,t);return r?r[1]:n},forEach:function(e,t){return Ve(this.size),this.__iterate(t?e.bind(t):e)},join:function(e){Ve(this.size),e=void 0!==e?""+e:",";var t="",n=!0;return this.__iterate((function(r){n?n=!1:t+=e,t+=null!=r?r.toString():""})),t},keys:function(){return this.__iterator(N)},map:function(e,t){return mn(this,Xt(this,e,t))},reduce:function(e,t,n){var r,o;return Ve(this.size),arguments.length<2?o=!0:r=t,this.__iterate((function(t,a,i){o?(o=!1,r=t):r=e.call(n,r,t,a,i)})),r},reduceRight:function(e,t,n){var r=this.toKeyedSeq().reverse();return r.reduce.apply(r,arguments)},reverse:function(){return mn(this,Qt(this,!0))},slice:function(e,t){return mn(this,rn(this,e,t,!0))},some:function(e,t){return!this.every(tr(e),t)},sort:function(e){return mn(this,pn(this,e))},values:function(){return this.__iterator(M)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(e,t){return A(e?this.toSeq().filter(e,t):this)},countBy:function(e,t){return tn(this,e,t)},equals:function(e){return ye(this,e)},entrySeq:function(){var e=this;if(e._cache)return new te(e._cache);var t=e.toSeq().map(er).toIndexedSeq();return t.fromEntrySeq=function(){return e.toSeq()},t},filterNot:function(e,t){return this.filter(tr(e),t)},findEntry:function(e,t,n){var r=n;return this.__iterate((function(n,o,a){if(e.call(t,n,o,a))return r=[o,n],!1})),r},findKey:function(e,t){var n=this.findEntry(e,t);return n&&n[0]},findLast:function(e,t,n){return this.toKeyedSeq().reverse().find(e,t,n)},findLastEntry:function(e,t,n){return this.toKeyedSeq().reverse().findEntry(e,t,n)},findLastKey:function(e,t){return this.toKeyedSeq().reverse().findKey(e,t)},first:function(){return this.find(k)},flatMap:function(e,t){return mn(this,cn(this,e,t))},flatten:function(e){return mn(this,un(this,e,!0))},fromEntrySeq:function(){return new Gt(this)},get:function(e,t){return this.find((function(t,n){return ge(n,e)}),void 0,t)},getIn:function(e,t){for(var n,r=this,o=xn(e);!(n=o.next()).done;){var a=n.value;if((r=r&&r.get?r.get(a,b):b)===b)return t}return r},groupBy:function(e,t){return nn(this,e,t)},has:function(e){return this.get(e,b)!==b},hasIn:function(e){return this.getIn(e,b)!==b},isSubset:function(e){return e="function"==typeof e.includes?e:n(e),this.every((function(t){return e.includes(t)}))},isSuperset:function(e){return(e="function"==typeof e.isSubset?e:n(e)).isSubset(this)},keyOf:function(e){return this.findKey((function(t){return ge(t,e)}))},keySeq:function(){return this.toSeq().map(Qn).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(e){return this.toKeyedSeq().reverse().keyOf(e)},max:function(e){return fn(this,e)},maxBy:function(e,t){return fn(this,t,e)},min:function(e){return fn(this,e?nr(e):ar)},minBy:function(e,t){return fn(this,t?nr(t):ar,e)},rest:function(){return this.slice(1)},skip:function(e){return this.slice(Math.max(0,e))},skipLast:function(e){return mn(this,this.toSeq().reverse().skip(e).reverse())},skipWhile:function(e,t){return mn(this,an(this,e,t,!0))},skipUntil:function(e,t){return this.skipWhile(tr(e),t)},sortBy:function(e,t){return mn(this,pn(this,t,e))},take:function(e){return this.slice(0,Math.max(0,e))},takeLast:function(e){return mn(this,this.toSeq().reverse().take(e).reverse())},takeWhile:function(e,t){return mn(this,on(this,e,t))},takeUntil:function(e,t){return this.takeWhile(tr(e),t)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=ir(this))}});var Zn=n.prototype;Zn[p]=!0,Zn[B]=Zn.values,Zn.__toJS=Zn.toArray,Zn.__toStringMapper=rr,Zn.inspect=Zn.toSource=function(){return this.toString()},Zn.chain=Zn.flatMap,Zn.contains=Zn.includes,Gn(r,{flip:function(){return mn(this,Zt(this))},mapEntries:function(e,t){var n=this,r=0;return mn(this,this.toSeq().map((function(o,a){return e.call(t,[a,o],r++,n)})).fromEntrySeq())},mapKeys:function(e,t){var n=this;return mn(this,this.toSeq().flip().map((function(r,o){return e.call(t,r,o,n)})).flip())}});var Xn=r.prototype;function Qn(e,t){return t}function er(e,t){return[t,e]}function tr(e){return function(){return!e.apply(this,arguments)}}function nr(e){return function(){return-e.apply(this,arguments)}}function rr(e){return"string"==typeof e?JSON.stringify(e):String(e)}function or(){return C(arguments)}function ar(e,t){return et?-1:0}function ir(e){if(e.size===1/0)return 0;var t=l(e),n=s(e),r=t?1:0;return sr(e.__iterate(n?t?function(e,t){r=31*r+ur(ke(e),ke(t))|0}:function(e,t){r=r+ur(ke(e),ke(t))|0}:t?function(e){r=31*r+ke(e)|0}:function(e){r=r+ke(e)|0}),r)}function sr(e,t){return t=Ae(t,3432918353),t=Ae(t<<15|t>>>-15,461845907),t=Ae(t<<13|t>>>-13,5),t=Ae((t=(t+3864292196|0)^e)^t>>>16,2246822507),t=Oe((t=Ae(t^t>>>13,3266489909))^t>>>16)}function ur(e,t){return e^t+2654435769+(e<<6)+(e>>2)|0}return Xn[f]=!0,Xn[B]=Zn.entries,Xn.__toJS=Zn.toObject,Xn.__toStringMapper=function(e,t){return JSON.stringify(t)+": "+rr(e)},Gn(o,{toKeyedSeq:function(){return new Jt(this,!1)},filter:function(e,t){return mn(this,en(this,e,t,!1))},findIndex:function(e,t){var n=this.findEntry(e,t);return n?n[0]:-1},indexOf:function(e){var t=this.keyOf(e);return void 0===t?-1:t},lastIndexOf:function(e){var t=this.lastKeyOf(e);return void 0===t?-1:t},reverse:function(){return mn(this,Qt(this,!1))},slice:function(e,t){return mn(this,rn(this,e,t,!1))},splice:function(e,t){var n=arguments.length;if(t=Math.max(0|t,0),0===n||2===n&&!t)return this;e=T(e,e<0?this.count():this.size);var r=this.slice(0,e);return mn(this,1===n?r:r.concat(C(arguments,2),this.slice(e+t)))},findLastIndex:function(e,t){var n=this.findLastEntry(e,t);return n?n[0]:-1},first:function(){return this.get(0)},flatten:function(e){return mn(this,un(this,e,!1))},get:function(e,t){return(e=O(this,e))<0||this.size===1/0||void 0!==this.size&&e>this.size?t:this.find((function(t,n){return n===e}),void 0,t)},has:function(e){return(e=O(this,e))>=0&&(void 0!==this.size?this.size===1/0||e1)try{return decodeURIComponent(t[1])}catch(e){console.error(e)}return null}function Pe(e){return t=e.replace(/\.[^./]*$/,""),Y()(J()(t));var t}function Ne(e,t,n,r,a){if(!t)return[];var s=[],u=t.get("nullable"),c=t.get("required"),p=t.get("maximum"),h=t.get("minimum"),d=t.get("type"),m=t.get("format"),g=t.get("maxLength"),b=t.get("minLength"),w=t.get("uniqueItems"),x=t.get("maxItems"),E=t.get("minItems"),S=t.get("pattern"),C=n||!0===c,A=null!=e;if(u&&null===e||!d||!(C||A&&"array"===d||!(!C&&!A)))return[];var O="string"===d&&e,k="array"===d&&l()(e)&&e.length,j="array"===d&&W.a.List.isList(e)&&e.count(),T=[O,k,j,"array"===d&&"string"==typeof e&&e,"file"===d&&e instanceof se.a.File,"boolean"===d&&(e||!1===e),"number"===d&&(e||0===e),"integer"===d&&(e||0===e),"object"===d&&"object"===i()(e)&&null!==e,"object"===d&&"string"==typeof e&&e],I=P()(T).call(T,(function(e){return!!e}));if(C&&!I&&!r)return s.push("Required field is not provided"),s;if("object"===d&&(null===a||"application/json"===a)){var N,M=e;if("string"==typeof e)try{M=JSON.parse(e)}catch(e){return s.push("Parameter string value must be valid JSON"),s}if(t&&t.has("required")&&Ee(c.isList)&&c.isList()&&y()(c).call(c,(function(e){void 0===M[e]&&s.push({propKey:e,error:"Required property not found"})})),t&&t.has("properties"))y()(N=t.get("properties")).call(N,(function(e,t){var n=Ne(M[t],e,!1,r,a);s.push.apply(s,o()(f()(n).call(n,(function(e){return{propKey:t,error:e}}))))}))}if(S){var R=function(e,t){if(!new RegExp(t).test(e))return"Value must follow pattern "+t}(e,S);R&&s.push(R)}if(E&&"array"===d){var D=function(e,t){var n;if(!e&&t>=1||e&&e.lengtht)return v()(n="Array must not contain more then ".concat(t," item")).call(n,1===t?"":"s")}(e,x);L&&s.push({needRemove:!0,error:L})}if(w&&"array"===d){var B=function(e,t){if(e&&("true"===t||!0===t)){var n=Object(V.fromJS)(e),r=n.toSet();if(e.length>r.size){var o=Object(V.Set)();if(y()(n).call(n,(function(e,t){_()(n).call(n,(function(t){return Ee(t.equals)?t.equals(e):t===e})).size>1&&(o=o.add(t))})),0!==o.size)return f()(o).call(o,(function(e){return{index:e,error:"No duplicates allowed."}})).toArray()}}}(e,w);B&&s.push.apply(s,o()(B))}if(g||0===g){var F=function(e,t){var n;if(e.length>t)return v()(n="Value must be no longer than ".concat(t," character")).call(n,1!==t?"s":"")}(e,g);F&&s.push(F)}if(b){var U=function(e,t){var n;if(e.lengtht)return"Value must be less than ".concat(t)}(e,p);q&&s.push(q)}if(h||0===h){var z=function(e,t){if(e2&&void 0!==arguments[2]?arguments[2]:{},r=n.isOAS3,o=void 0!==r&&r,a=n.bypassRequiredCheck,i=void 0!==a&&a,s=e.get("required"),u=Object(le.a)(e,{isOAS3:o}),c=u.schema,l=u.parameterContentMediaType;return Ne(t,c,s,i,l)},Re=function(e,t,n){if(e&&(!e.xml||!e.xml.name)){if(e.xml=e.xml||{},!e.$$ref)return e.type||e.items||e.properties||e.additionalProperties?'\n\x3c!-- XML example cannot be generated; root element name is undefined --\x3e':null;var r=e.$$ref.match(/\S*\/(\S+)$/);e.xml.name=r[1]}return Object(ie.memoizedCreateXMLExample)(e,t,n)},De=[{when:/json/,shouldStringifyTypes:["string"]}],Le=["object"],Be=function(e,t,n,r){var a=Object(ie.memoizedSampleFromSchema)(e,t,r),s=i()(a),u=S()(De).call(De,(function(e,t){var r;return t.when.test(n)?v()(r=[]).call(r,o()(e),o()(t.shouldStringifyTypes)):e}),Le);return te()(u,(function(e){return e===s}))?M()(a,null,2):a},Fe=function(e,t,n,r){var o,a=Be(e,t,n,r);try{"\n"===(o=me.a.dump(me.a.load(a),{lineWidth:-1}))[o.length-1]&&(o=T()(o).call(o,0,o.length-1))}catch(e){return console.error(e),"error: could not generate yaml example"}return o.replace(/\t/g," ")},Ue=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:void 0;return e&&Ee(e.toJS)&&(e=e.toJS()),r&&Ee(r.toJS)&&(r=r.toJS()),/xml/.test(t)?Re(e,n,r):/(yaml|yml)/.test(t)?Fe(e,n,t,r):Be(e,n,t,r)},qe=function(){var e={},t=se.a.location.search;if(!t)return{};if(""!=t){var n=t.substr(1).split("&");for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(r=n[r].split("="),e[decodeURIComponent(r[0])]=r[1]&&decodeURIComponent(r[1])||"")}return e},ze=function(t){return(t instanceof e?t:e.from(t.toString(),"utf-8")).toString("base64")},Ve={operationsSorter:{alpha:function(e,t){return e.get("path").localeCompare(t.get("path"))},method:function(e,t){return e.get("method").localeCompare(t.get("method"))}},tagsSorter:{alpha:function(e,t){return e.localeCompare(t)}}},We=function(e){var t=[];for(var n in e){var r=e[n];void 0!==r&&""!==r&&t.push([n,"=",encodeURIComponent(r).replace(/%20/g,"+")].join(""))}return t.join("&")},He=function(e,t,n){return!!Q()(n,(function(n){return re()(e[n],t[n])}))};function $e(e){return"string"!=typeof e||""===e?"":Object(H.sanitizeUrl)(e)}function Je(e){return!(!e||D()(e).call(e,"localhost")>=0||D()(e).call(e,"127.0.0.1")>=0||"none"===e)}function Ke(e){if(!W.a.OrderedMap.isOrderedMap(e))return null;if(!e.size)return null;var t=B()(e).call(e,(function(e,t){return U()(t).call(t,"2")&&x()(e.get("content")||{}).length>0})),n=e.get("default")||W.a.OrderedMap(),r=(n.get("content")||W.a.OrderedMap()).keySeq().toJS().length?n:null;return t||r}var Ye=function(e){return"string"==typeof e||e instanceof String?z()(e).call(e).replace(/\s/g,"%20"):""},Ge=function(e){return ce()(Ye(e).replace(/%20/g,"_"))},Ze=function(e){return _()(e).call(e,(function(e,t){return/^x-/.test(t)}))},Xe=function(e){return _()(e).call(e,(function(e,t){return/^pattern|maxLength|minLength|maximum|minimum/.test(t)}))};function Qe(e,t){var n,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){return!0};if("object"!==i()(e)||l()(e)||null===e||!t)return e;var o=A()({},e);return y()(n=x()(o)).call(n,(function(e){e===t&&r(o[e],e)?delete o[e]:o[e]=Qe(o[e],t,r)})),o}function et(e){if("string"==typeof e)return e;if(e&&e.toJS&&(e=e.toJS()),"object"===i()(e)&&null!==e)try{return M()(e,null,2)}catch(t){return String(e)}return null==e?"":e.toString()}function tt(e){return"number"==typeof e?e.toString():e}function nt(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.returnAll,r=void 0!==n&&n,o=t.allowHashes,a=void 0===o||o;if(!W.a.Map.isMap(e))throw new Error("paramToIdentifier: received a non-Im.Map parameter as input");var i,s,u,c=e.get("name"),l=e.get("in"),p=[];e&&e.hashCode&&l&&c&&a&&p.push(v()(i=v()(s="".concat(l,".")).call(s,c,".hash-")).call(i,e.hashCode()));l&&c&&p.push(v()(u="".concat(l,".")).call(u,c));return p.push(c),r?p:p[0]||""}function rt(e,t){var n,r=nt(e,{returnAll:!0});return _()(n=f()(r).call(r,(function(e){return t[e]}))).call(n,(function(e){return void 0!==e}))[0]}function ot(){return it(fe()(32).toString("base64"))}function at(e){return it(de()("sha256").update(e).digest("base64"))}function it(e){return e.replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}var st=function(e){return!e||!(!ge(e)||!e.isEmpty())}}).call(this,n(64).Buffer)},function(e,t){e.exports=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")},e.exports.default=e.exports,e.exports.__esModule=!0},function(e,t,n){var r=n(242);function o(e,t){for(var n=0;n1?t-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:r,n=null,a=null;return function(){return o(t,n,arguments)||(a=e.apply(null,arguments)),n=arguments,a}}))},function(e,t,n){e.exports=n(668)},function(e,t,n){var r=n(177),o=n(572);function a(t){return"function"==typeof r&&"symbol"==typeof o?(e.exports=a=function(e){return typeof e},e.exports.default=e.exports,e.exports.__esModule=!0):(e.exports=a=function(e){return e&&"function"==typeof r&&e.constructor===r&&e!==r.prototype?"symbol":typeof e},e.exports.default=e.exports,e.exports.__esModule=!0),a(t)}e.exports=a,e.exports.default=e.exports,e.exports.__esModule=!0},function(e,t,n){e.exports=n(598)},function(e,t,n){e.exports=n(596)},function(e,t,n){"use strict";var r=n(40),o=n(129).f,a=n(363),i=n(34),s=n(108),u=n(68),c=n(56),l=function(e){var t=function(t,n,r){if(this instanceof e){switch(arguments.length){case 0:return new e;case 1:return new e(t);case 2:return new e(t,n)}return new e(t,n,r)}return e.apply(this,arguments)};return t.prototype=e.prototype,t};e.exports=function(e,t){var n,p,f,h,d,m,v,g,y=e.target,b=e.global,_=e.stat,w=e.proto,x=b?r:_?r[y]:(r[y]||{}).prototype,E=b?i:i[y]||(i[y]={}),S=E.prototype;for(f in t)n=!a(b?f:y+(_?".":"#")+f,e.forced)&&x&&c(x,f),d=E[f],n&&(m=e.noTargetGet?(g=o(x,f))&&g.value:x[f]),h=n&&m?m:t[f],n&&typeof d==typeof h||(v=e.bind&&n?s(h,r):e.wrap&&n?l(h):w&&"function"==typeof h?s(Function.call,h):h,(e.sham||h&&h.sham||d&&d.sham)&&u(v,"sham",!0),E[f]=v,w&&(c(i,p=y+"Prototype")||u(i,p,{}),i[p][f]=h,e.real&&S&&!S[f]&&u(S,f,h)))}},function(e,t,n){e.exports=n(601)},function(e,t,n){e.exports=n(402)},function(e,t,n){var r=n(449),o=n(450),a=n(854),i=n(856),s=n(860),u=n(862),c=n(867),l=n(242),p=n(3);function f(e,t){var n=r(e);if(o){var s=o(e);t&&(s=a(s).call(s,(function(t){return i(e,t).enumerable}))),n.push.apply(n,s)}return n}e.exports=function(e){for(var t=1;t>",i=function(){invariant(!1,"ImmutablePropTypes type checking code is stripped in production.")};i.isRequired=i;var s=function(){return i};function u(e){var t=typeof e;return Array.isArray(e)?"array":e instanceof RegExp?"object":e instanceof o.Iterable?"Immutable."+e.toSource().split(" ")[0]:t}function c(e){function t(t,n,r,o,i,s){for(var u=arguments.length,c=Array(u>6?u-6:0),l=6;l4)}function l(e){var t=e.get("swagger");return"string"==typeof t&&i()(t).call(t,"2.0")}function p(e){return function(t,n){return function(r){return n&&n.specSelectors&&n.specSelectors.specJson?c(n.specSelectors.specJson())?u.a.createElement(e,o()({},r,n,{Ori:t})):u.a.createElement(t,r):(console.warn("OAS3 wrapper: couldn't get spec"),null)}}}},function(e,t,n){e.exports=n(592)},function(e,t){e.exports=function(e){try{return!!e()}catch(e){return!0}}},function(e,t,n){"use strict";var r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable;function i(e){if(null==e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}e.exports=function(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;if("0123456789"!==Object.getOwnPropertyNames(t).map((function(e){return t[e]})).join(""))return!1;var r={};return"abcdefghijklmnopqrst".split("").forEach((function(e){r[e]=e})),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},r)).join("")}catch(e){return!1}}()?Object.assign:function(e,t){for(var n,s,u=i(e),c=1;c0){var o=v()(n).call(n,(function(e){return console.error(e),e.line=e.fullPath?_(w,e.fullPath):null,e.path=e.fullPath?e.fullPath.join("."):null,e.level="error",e.type="thrown",e.source="resolver",y()(e,"message",{enumerable:!0,value:e.message}),e}));a.newThrownErrBatch(o)}return r.updateResolved(t)}))}},Se=[],Ce=Y()(u()(f.a.mark((function e(){var t,n,r,o,a,i,s,c,l,p,h,m,g,b,w,E,C,O;return f.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(t=Se.system){e.next=4;break}return console.error("debResolveSubtrees: don't have a system to operate on, aborting."),e.abrupt("return");case 4:if(n=t.errActions,r=t.errSelectors,o=t.fn,a=o.resolveSubtree,i=o.fetch,s=o.AST,c=void 0===s?{}:s,l=t.specSelectors,p=t.specActions,a){e.next=8;break}return console.error("Error: Swagger-Client did not provide a `resolveSubtree` method, doing nothing."),e.abrupt("return");case 8:return h=c.getLineNumberForPath?c.getLineNumberForPath:function(){},m=l.specStr(),g=t.getConfigs(),b=g.modelPropertyMacro,w=g.parameterMacro,E=g.requestInterceptor,C=g.responseInterceptor,e.prev=11,e.next=14,_()(Se).call(Se,function(){var e=u()(f.a.mark((function e(t,o){var s,c,p,g,_,O,j,T,I;return f.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.next=2,t;case 2:return s=e.sent,c=s.resultMap,p=s.specWithCurrentSubtrees,e.next=7,a(p,o,{baseDoc:l.url(),modelPropertyMacro:b,parameterMacro:w,requestInterceptor:E,responseInterceptor:C});case 7:if(g=e.sent,_=g.errors,O=g.spec,r.allErrors().size&&n.clearBy((function(e){var t;return"thrown"!==e.get("type")||"resolver"!==e.get("source")||!x()(t=e.get("fullPath")).call(t,(function(e,t){return e===o[t]||void 0===o[t]}))})),d()(_)&&_.length>0&&(j=v()(_).call(_,(function(e){return e.line=e.fullPath?h(m,e.fullPath):null,e.path=e.fullPath?e.fullPath.join("."):null,e.level="error",e.type="thrown",e.source="resolver",y()(e,"message",{enumerable:!0,value:e.message}),e})),n.newThrownErrBatch(j)),!O||!l.isOAS3()||"components"!==o[0]||"securitySchemes"!==o[1]){e.next=15;break}return e.next=15,S.a.all(v()(T=A()(I=k()(O)).call(I,(function(e){return"openIdConnect"===e.type}))).call(T,function(){var e=u()(f.a.mark((function e(t){var n,r;return f.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return n={url:t.openIdConnectUrl,requestInterceptor:E,responseInterceptor:C},e.prev=1,e.next=4,i(n);case 4:(r=e.sent)instanceof Error||r.status>=400?console.error(r.statusText+" "+n.url):t.openIdConnectData=JSON.parse(r.text),e.next=11;break;case 8:e.prev=8,e.t0=e.catch(1),console.error(e.t0);case 11:case"end":return e.stop()}}),e,null,[[1,8]])})));return function(t){return e.apply(this,arguments)}}()));case 15:return Z()(c,o,O),Z()(p,o,O),e.abrupt("return",{resultMap:c,specWithCurrentSubtrees:p});case 18:case"end":return e.stop()}}),e)})));return function(t,n){return e.apply(this,arguments)}}(),S.a.resolve({resultMap:(l.specResolvedSubtree([])||Object(z.Map)()).toJS(),specWithCurrentSubtrees:l.specJson().toJS()}));case 14:O=e.sent,delete Se.system,Se=[],e.next=22;break;case 19:e.prev=19,e.t0=e.catch(11),console.error(e.t0);case 22:p.updateResolvedSubtree([],O.resultMap);case 23:case"end":return e.stop()}}),e,null,[[11,19]])}))),35),Ae=function(e){return function(t){var n;T()(n=v()(Se).call(Se,(function(e){return e.join("@@")}))).call(n,e.join("@@"))>-1||(Se.push(e),Se.system=t,Ce())}};function Oe(e,t,n,r,o){return{type:re,payload:{path:e,value:r,paramName:t,paramIn:n,isXml:o}}}function ke(e,t,n,r){return{type:re,payload:{path:e,param:t,value:n,isXml:r}}}var je=function(e,t){return{type:me,payload:{path:e,value:t}}},Te=function(){return{type:me,payload:{path:[],value:Object(z.Map)()}}},Ie=function(e,t){return{type:ae,payload:{pathMethod:e,isOAS3:t}}},Pe=function(e,t,n,r){return{type:oe,payload:{pathMethod:e,paramName:t,paramIn:n,includeEmptyValue:r}}};function Ne(e){return{type:fe,payload:{pathMethod:e}}}function Me(e,t){return{type:he,payload:{path:e,value:t,key:"consumes_value"}}}function Re(e,t){return{type:he,payload:{path:e,value:t,key:"produces_value"}}}var De=function(e,t,n){return{payload:{path:e,method:t,res:n},type:ie}},Le=function(e,t,n){return{payload:{path:e,method:t,req:n},type:se}},Be=function(e,t,n){return{payload:{path:e,method:t,req:n},type:ue}},Fe=function(e){return{payload:e,type:ce}},Ue=function(e){return function(t){var n,r,o=t.fn,a=t.specActions,i=t.specSelectors,s=t.getConfigs,c=t.oas3Selectors,l=e.pathName,p=e.method,h=e.operation,m=s(),g=m.requestInterceptor,y=m.responseInterceptor,b=h.toJS();h&&h.get("parameters")&&P()(n=A()(r=h.get("parameters")).call(r,(function(e){return e&&!0===e.get("allowEmptyValue")}))).call(n,(function(t){if(i.parameterInclusionSettingFor([l,p],t.get("name"),t.get("in"))){e.parameters=e.parameters||{};var n=Object(X.B)(t,e.parameters);(!n||n&&0===n.size)&&(e.parameters[t.get("name")]="")}}));if(e.contextUrl=W()(i.url()).toString(),b&&b.operationId?e.operationId=b.operationId:b&&l&&p&&(e.operationId=o.opId(b,l,p)),i.isOAS3()){var _,w=M()(_="".concat(l,":")).call(_,p);e.server=c.selectedServer(w)||c.selectedServer();var x=c.serverVariables({server:e.server,namespace:w}).toJS(),E=c.serverVariables({server:e.server}).toJS();e.serverVariables=D()(x).length?x:E,e.requestContentType=c.requestContentType(l,p),e.responseContentType=c.responseContentType(l,p)||"*/*";var S,C=c.requestBodyValue(l,p),O=c.requestBodyInclusionSetting(l,p);if(C&&C.toJS)e.requestBody=A()(S=v()(C).call(C,(function(e){return z.Map.isMap(e)?e.get("value"):e}))).call(S,(function(e,t){return(d()(e)?0!==e.length:!Object(X.q)(e))||O.get(t)})).toJS();else e.requestBody=C}var k=B()({},e);k=o.buildRequest(k),a.setRequest(e.pathName,e.method,k);var j=function(){var t=u()(f.a.mark((function t(n){var r,o;return f.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,g.apply(undefined,[n]);case 2:return r=t.sent,o=B()({},r),a.setMutatedRequest(e.pathName,e.method,o),t.abrupt("return",r);case 6:case"end":return t.stop()}}),t)})));return function(e){return t.apply(this,arguments)}}();e.requestInterceptor=j,e.responseInterceptor=y;var T=U()();return o.execute(e).then((function(t){t.duration=U()()-T,a.setResponse(e.pathName,e.method,t)})).catch((function(t){"Failed to fetch"===t.message&&(t.name="",t.message='**Failed to fetch.** \n**Possible Reasons:** \n - CORS \n - Network Failure \n - URL scheme must be "http" or "https" for CORS request.'),a.setResponse(e.pathName,e.method,{error:!0,err:Object(H.serializeError)(t)})}))}},qe=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=e.path,n=e.method,r=i()(e,Q);return function(e){var a=e.fn.fetch,i=e.specSelectors,s=e.specActions,u=i.specJsonWithResolvedSubtrees().toJS(),c=i.operationScheme(t,n),l=i.contentTypeValues([t,n]).toJS(),p=l.requestContentType,f=l.responseContentType,h=/xml/i.test(p),d=i.parameterValues([t,n],h).toJS();return s.executeRequest(o()(o()({},r),{},{fetch:a,spec:u,pathName:t,method:n,parameters:d,requestContentType:p,scheme:c,responseContentType:f}))}};function ze(e,t){return{type:le,payload:{path:e,method:t}}}function Ve(e,t){return{type:pe,payload:{path:e,method:t}}}function We(e,t,n){return{type:ve,payload:{scheme:e,path:t,method:n}}}},function(e,t){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},function(e,t,n){var r=n(37);e.exports=!r((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]}))},function(e,t,n){var r=n(177),o=n(245),a=n(244),i=n(187);e.exports=function(e,t){var n=void 0!==r&&o(e)||e["@@iterator"];if(!n){if(a(e)||(n=i(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var s=0,u=function(){};return{s:u,n:function(){return s>=e.length?{done:!0}:{done:!1,value:e[s++]}},e:function(e){throw e},f:u}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var c,l=!0,p=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return l=e.done,e},e:function(e){p=!0,c=e},f:function(){try{l||null==n.return||n.return()}finally{if(p)throw c}}}},e.exports.default=e.exports,e.exports.__esModule=!0},function(e,t){var n=Array.isArray;e.exports=n},function(e,t,n){var r;!function(){"use strict";var n={}.hasOwnProperty;function o(){for(var e=[],t=0;t=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(s[n]=e[n])}return s},e.exports.default=e.exports,e.exports.__esModule=!0},function(e,t,n){"use strict";n.r(t),n.d(t,"UPDATE_SELECTED_SERVER",(function(){return r})),n.d(t,"UPDATE_REQUEST_BODY_VALUE",(function(){return o})),n.d(t,"UPDATE_REQUEST_BODY_VALUE_RETAIN_FLAG",(function(){return a})),n.d(t,"UPDATE_REQUEST_BODY_INCLUSION",(function(){return i})),n.d(t,"UPDATE_ACTIVE_EXAMPLES_MEMBER",(function(){return s})),n.d(t,"UPDATE_REQUEST_CONTENT_TYPE",(function(){return u})),n.d(t,"UPDATE_RESPONSE_CONTENT_TYPE",(function(){return c})),n.d(t,"UPDATE_SERVER_VARIABLE_VALUE",(function(){return l})),n.d(t,"SET_REQUEST_BODY_VALIDATE_ERROR",(function(){return p})),n.d(t,"CLEAR_REQUEST_BODY_VALIDATE_ERROR",(function(){return f})),n.d(t,"CLEAR_REQUEST_BODY_VALUE",(function(){return h})),n.d(t,"setSelectedServer",(function(){return d})),n.d(t,"setRequestBodyValue",(function(){return m})),n.d(t,"setRetainRequestBodyValueFlag",(function(){return v})),n.d(t,"setRequestBodyInclusion",(function(){return g})),n.d(t,"setActiveExamplesMember",(function(){return y})),n.d(t,"setRequestContentType",(function(){return b})),n.d(t,"setResponseContentType",(function(){return _})),n.d(t,"setServerVariableValue",(function(){return w})),n.d(t,"setRequestBodyValidateError",(function(){return x})),n.d(t,"clearRequestBodyValidateError",(function(){return E})),n.d(t,"initRequestBodyValidateError",(function(){return S})),n.d(t,"clearRequestBodyValue",(function(){return C}));var r="oas3_set_servers",o="oas3_set_request_body_value",a="oas3_set_request_body_retain_flag",i="oas3_set_request_body_inclusion",s="oas3_set_active_examples_member",u="oas3_set_request_content_type",c="oas3_set_response_content_type",l="oas3_set_server_variable_value",p="oas3_set_request_body_validate_error",f="oas3_clear_request_body_validate_error",h="oas3_clear_request_body_value";function d(e,t){return{type:r,payload:{selectedServerUrl:e,namespace:t}}}function m(e){var t=e.value,n=e.pathMethod;return{type:o,payload:{value:t,pathMethod:n}}}var v=function(e){var t=e.value,n=e.pathMethod;return{type:a,payload:{value:t,pathMethod:n}}};function g(e){var t=e.value,n=e.pathMethod,r=e.name;return{type:i,payload:{value:t,pathMethod:n,name:r}}}function y(e){var t=e.name,n=e.pathMethod,r=e.contextType,o=e.contextName;return{type:s,payload:{name:t,pathMethod:n,contextType:r,contextName:o}}}function b(e){var t=e.value,n=e.pathMethod;return{type:u,payload:{value:t,pathMethod:n}}}function _(e){var t=e.value,n=e.path,r=e.method;return{type:c,payload:{value:t,path:n,method:r}}}function w(e){var t=e.server,n=e.namespace,r=e.key,o=e.val;return{type:l,payload:{server:t,namespace:n,key:r,val:o}}}var x=function(e){var t=e.path,n=e.method,r=e.validationErrors;return{type:p,payload:{path:t,method:n,validationErrors:r}}},E=function(e){var t=e.path,n=e.method;return{type:f,payload:{path:t,method:n}}},S=function(e){var t=e.pathMethod;return{type:f,payload:{path:t[0],method:t[1]}}},C=function(e){var t=e.pathMethod;return{type:h,payload:{pathMethod:t}}}},function(e,t,n){var r=n(61),o={}.hasOwnProperty;e.exports=Object.hasOwn||function(e,t){return o.call(r(e),t)}},function(e,t,n){"use strict";var r=!("undefined"==typeof window||!window.document||!window.document.createElement),o={canUseDOM:r,canUseWorkers:"undefined"!=typeof Worker,canUseEventListeners:r&&!(!window.addEventListener&&!window.attachEvent),canUseViewport:r&&!!window.screen,isInWorker:!r};e.exports=o},function(e,t){e.exports=function(e){var t=typeof e;return null!=e&&("object"==t||"function"==t)}},function(e,t,n){"use strict";n.d(t,"b",(function(){return m})),n.d(t,"e",(function(){return v})),n.d(t,"c",(function(){return y})),n.d(t,"a",(function(){return b})),n.d(t,"d",(function(){return _}));var r=n(49),o=n.n(r),a=n(18),i=n.n(a),s=n(2),u=n.n(s),c=n(58),l=n.n(c),p=n(357),f=n.n(p),h=function(e){return String.prototype.toLowerCase.call(e)},d=function(e){return e.replace(/[^\w]/gi,"_")};function m(e){var t=e.openapi;return!!t&&f()(t,"3")}function v(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"",r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},o=r.v2OperationIdCompatibilityMode;if(!e||"object"!==i()(e))return null;var a=(e.operationId||"").replace(/\s/g,"");return a.length?d(e.operationId):g(t,n,{v2OperationIdCompatibilityMode:o})}function g(e,t){var n,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},o=r.v2OperationIdCompatibilityMode;if(o){var a,i,s=u()(a="".concat(t.toLowerCase(),"_")).call(a,e).replace(/[\s!@#$%^&*()_+=[{\]};:<>|./?,\\'""-]/g,"_");return(s=s||u()(i="".concat(e.substring(1),"_")).call(i,t)).replace(/((_){2,})/g,"_").replace(/^(_)*/g,"").replace(/([_])*$/g,"")}return u()(n="".concat(h(t))).call(n,d(e))}function y(e,t){var n;return u()(n="".concat(h(t),"-")).call(n,e)}function b(e,t){return e&&e.paths?function(e,t){return function(e,t,n){if(!e||"object"!==i()(e)||!e.paths||"object"!==i()(e.paths))return null;var r=e.paths;for(var o in r)for(var a in r[o])if("PARAMETERS"!==a.toUpperCase()){var s=r[o][a];if(s&&"object"===i()(s)){var u={spec:e,pathName:o,method:a.toUpperCase(),operation:s},c=t(u);if(n&&c)return u}}return}(e,t,!0)||null}(e,(function(e){var n=e.pathName,r=e.method,o=e.operation;if(!o||"object"!==i()(o))return!1;var a=o.operationId;return[v(o,n,r),y(n,r),a].some((function(e){return e&&e===t}))})):null}function _(e){var t=e.spec,n=t.paths,r={};if(!n||t.$$normalized)return e;for(var a in n){var i=n[a];if(l()(i)){var s=i.parameters,c=function(e){var n=i[e];if(!l()(n))return"continue";var c=v(n,a,e);if(c){r[c]?r[c].push(n):r[c]=[n];var p=r[c];if(p.length>1)p.forEach((function(e,t){var n;e.__originalOperationId=e.__originalOperationId||e.operationId,e.operationId=u()(n="".concat(c)).call(n,t+1)}));else if(void 0!==n.operationId){var f=p[0];f.__originalOperationId=f.__originalOperationId||n.operationId,f.operationId=c}}if("parameters"!==e){var h=[],d={};for(var m in t)"produces"!==m&&"consumes"!==m&&"security"!==m||(d[m]=t[m],h.push(d));if(s&&(d.parameters=s,h.push(d)),h.length){var g,y=o()(h);try{for(y.s();!(g=y.n()).done;){var b=g.value;for(var _ in b)if(n[_]){if("parameters"===_){var w,x=o()(b[_]);try{var E=function(){var e=w.value;n[_].some((function(t){return t.name&&t.name===e.name||t.$ref&&t.$ref===e.$ref||t.$$ref&&t.$$ref===e.$$ref||t===e}))||n[_].push(e)};for(x.s();!(w=x.n()).done;)E()}catch(e){x.e(e)}finally{x.f()}}}else n[_]=b[_]}}catch(e){y.e(e)}finally{y.f()}}}};for(var p in i)c(p)}}return t.$$normalized=!0,e}},function(e,t,n){"use strict";n.r(t),n.d(t,"NEW_THROWN_ERR",(function(){return o})),n.d(t,"NEW_THROWN_ERR_BATCH",(function(){return a})),n.d(t,"NEW_SPEC_ERR",(function(){return i})),n.d(t,"NEW_SPEC_ERR_BATCH",(function(){return s})),n.d(t,"NEW_AUTH_ERR",(function(){return u})),n.d(t,"CLEAR",(function(){return c})),n.d(t,"CLEAR_BY",(function(){return l})),n.d(t,"newThrownErr",(function(){return p})),n.d(t,"newThrownErrBatch",(function(){return f})),n.d(t,"newSpecErr",(function(){return h})),n.d(t,"newSpecErrBatch",(function(){return d})),n.d(t,"newAuthErr",(function(){return m})),n.d(t,"clear",(function(){return v})),n.d(t,"clearBy",(function(){return g}));var r=n(147),o="err_new_thrown_err",a="err_new_thrown_err_batch",i="err_new_spec_err",s="err_new_spec_err_batch",u="err_new_auth_err",c="err_clear",l="err_clear_by";function p(e){return{type:o,payload:Object(r.serializeError)(e)}}function f(e){return{type:a,payload:e}}function h(e){return{type:i,payload:e}}function d(e){return{type:s,payload:e}}function m(e){return{type:u,payload:e}}function v(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return{type:c,payload:e}}function g(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){return!0};return{type:l,payload:e}}},function(e,t,n){var r=n(107);e.exports=function(e){return Object(r(e))}},function(e,t){"function"==typeof Object.create?e.exports=function(e,t){e.super_=t,e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}})}:e.exports=function(e,t){e.super_=t;var n=function(){};n.prototype=t.prototype,e.prototype=new n,e.prototype.constructor=e}},function(e,t,n){var r=n(64),o=r.Buffer;function a(e,t){for(var n in e)t[n]=e[n]}function i(e,t,n){return o(e,t,n)}o.from&&o.alloc&&o.allocUnsafe&&o.allocUnsafeSlow?e.exports=r:(a(r,t),t.Buffer=i),a(o,i),i.from=function(e,t,n){if("number"==typeof e)throw new TypeError("Argument must not be a number");return o(e,t,n)},i.alloc=function(e,t,n){if("number"!=typeof e)throw new TypeError("Argument must be a number");var r=o(e);return void 0!==t?"string"==typeof n?r.fill(t,n):r.fill(t):r.fill(0),r},i.allocUnsafe=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return o(e)},i.allocUnsafeSlow=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return r.SlowBuffer(e)}},function(e,t,n){"use strict";(function(e){var r=n(588),o=n(589),a=n(377);function i(){return u.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function s(e,t){if(i()=i())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+i().toString(16)+" bytes");return 0|e}function d(e,t){if(u.isBuffer(e))return e.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(e)||e instanceof ArrayBuffer))return e.byteLength;"string"!=typeof e&&(e=""+e);var n=e.length;if(0===n)return 0;for(var r=!1;;)switch(t){case"ascii":case"latin1":case"binary":return n;case"utf8":case"utf-8":case void 0:return q(e).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*n;case"hex":return n>>>1;case"base64":return z(e).length;default:if(r)return q(e).length;t=(""+t).toLowerCase(),r=!0}}function m(e,t,n){var r=!1;if((void 0===t||t<0)&&(t=0),t>this.length)return"";if((void 0===n||n>this.length)&&(n=this.length),n<=0)return"";if((n>>>=0)<=(t>>>=0))return"";for(e||(e="utf8");;)switch(e){case"hex":return T(this,t,n);case"utf8":case"utf-8":return A(this,t,n);case"ascii":return k(this,t,n);case"latin1":case"binary":return j(this,t,n);case"base64":return C(this,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return I(this,t,n);default:if(r)throw new TypeError("Unknown encoding: "+e);e=(e+"").toLowerCase(),r=!0}}function v(e,t,n){var r=e[t];e[t]=e[n],e[n]=r}function g(e,t,n,r,o){if(0===e.length)return-1;if("string"==typeof n?(r=n,n=0):n>2147483647?n=2147483647:n<-2147483648&&(n=-2147483648),n=+n,isNaN(n)&&(n=o?0:e.length-1),n<0&&(n=e.length+n),n>=e.length){if(o)return-1;n=e.length-1}else if(n<0){if(!o)return-1;n=0}if("string"==typeof t&&(t=u.from(t,r)),u.isBuffer(t))return 0===t.length?-1:y(e,t,n,r,o);if("number"==typeof t)return t&=255,u.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?o?Uint8Array.prototype.indexOf.call(e,t,n):Uint8Array.prototype.lastIndexOf.call(e,t,n):y(e,[t],n,r,o);throw new TypeError("val must be string, number or Buffer")}function y(e,t,n,r,o){var a,i=1,s=e.length,u=t.length;if(void 0!==r&&("ucs2"===(r=String(r).toLowerCase())||"ucs-2"===r||"utf16le"===r||"utf-16le"===r)){if(e.length<2||t.length<2)return-1;i=2,s/=2,u/=2,n/=2}function c(e,t){return 1===i?e[t]:e.readUInt16BE(t*i)}if(o){var l=-1;for(a=n;as&&(n=s-u),a=n;a>=0;a--){for(var p=!0,f=0;fo&&(r=o):r=o;var a=t.length;if(a%2!=0)throw new TypeError("Invalid hex string");r>a/2&&(r=a/2);for(var i=0;i>8,o=n%256,a.push(o),a.push(r);return a}(t,e.length-n),e,n,r)}function C(e,t,n){return 0===t&&n===e.length?r.fromByteArray(e):r.fromByteArray(e.slice(t,n))}function A(e,t,n){n=Math.min(e.length,n);for(var r=[],o=t;o239?4:c>223?3:c>191?2:1;if(o+p<=n)switch(p){case 1:c<128&&(l=c);break;case 2:128==(192&(a=e[o+1]))&&(u=(31&c)<<6|63&a)>127&&(l=u);break;case 3:a=e[o+1],i=e[o+2],128==(192&a)&&128==(192&i)&&(u=(15&c)<<12|(63&a)<<6|63&i)>2047&&(u<55296||u>57343)&&(l=u);break;case 4:a=e[o+1],i=e[o+2],s=e[o+3],128==(192&a)&&128==(192&i)&&128==(192&s)&&(u=(15&c)<<18|(63&a)<<12|(63&i)<<6|63&s)>65535&&u<1114112&&(l=u)}null===l?(l=65533,p=1):l>65535&&(l-=65536,r.push(l>>>10&1023|55296),l=56320|1023&l),r.push(l),o+=p}return function(e){var t=e.length;if(t<=O)return String.fromCharCode.apply(String,e);var n="",r=0;for(;r0&&(e=this.toString("hex",0,n).match(/.{2}/g).join(" "),this.length>n&&(e+=" ... ")),""},u.prototype.compare=function(e,t,n,r,o){if(!u.isBuffer(e))throw new TypeError("Argument must be a Buffer");if(void 0===t&&(t=0),void 0===n&&(n=e?e.length:0),void 0===r&&(r=0),void 0===o&&(o=this.length),t<0||n>e.length||r<0||o>this.length)throw new RangeError("out of range index");if(r>=o&&t>=n)return 0;if(r>=o)return-1;if(t>=n)return 1;if(this===e)return 0;for(var a=(o>>>=0)-(r>>>=0),i=(n>>>=0)-(t>>>=0),s=Math.min(a,i),c=this.slice(r,o),l=e.slice(t,n),p=0;po)&&(n=o),e.length>0&&(n<0||t<0)||t>this.length)throw new RangeError("Attempt to write outside buffer bounds");r||(r="utf8");for(var a=!1;;)switch(r){case"hex":return b(this,e,t,n);case"utf8":case"utf-8":return _(this,e,t,n);case"ascii":return w(this,e,t,n);case"latin1":case"binary":return x(this,e,t,n);case"base64":return E(this,e,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return S(this,e,t,n);default:if(a)throw new TypeError("Unknown encoding: "+r);r=(""+r).toLowerCase(),a=!0}},u.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var O=4096;function k(e,t,n){var r="";n=Math.min(e.length,n);for(var o=t;or)&&(n=r);for(var o="",a=t;an)throw new RangeError("Trying to access beyond buffer length")}function N(e,t,n,r,o,a){if(!u.isBuffer(e))throw new TypeError('"buffer" argument must be a Buffer instance');if(t>o||te.length)throw new RangeError("Index out of range")}function M(e,t,n,r){t<0&&(t=65535+t+1);for(var o=0,a=Math.min(e.length-n,2);o>>8*(r?o:1-o)}function R(e,t,n,r){t<0&&(t=4294967295+t+1);for(var o=0,a=Math.min(e.length-n,4);o>>8*(r?o:3-o)&255}function D(e,t,n,r,o,a){if(n+r>e.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("Index out of range")}function L(e,t,n,r,a){return a||D(e,0,n,4),o.write(e,t,n,r,23,4),n+4}function B(e,t,n,r,a){return a||D(e,0,n,8),o.write(e,t,n,r,52,8),n+8}u.prototype.slice=function(e,t){var n,r=this.length;if((e=~~e)<0?(e+=r)<0&&(e=0):e>r&&(e=r),(t=void 0===t?r:~~t)<0?(t+=r)<0&&(t=0):t>r&&(t=r),t0&&(o*=256);)r+=this[e+--t]*o;return r},u.prototype.readUInt8=function(e,t){return t||P(e,1,this.length),this[e]},u.prototype.readUInt16LE=function(e,t){return t||P(e,2,this.length),this[e]|this[e+1]<<8},u.prototype.readUInt16BE=function(e,t){return t||P(e,2,this.length),this[e]<<8|this[e+1]},u.prototype.readUInt32LE=function(e,t){return t||P(e,4,this.length),(this[e]|this[e+1]<<8|this[e+2]<<16)+16777216*this[e+3]},u.prototype.readUInt32BE=function(e,t){return t||P(e,4,this.length),16777216*this[e]+(this[e+1]<<16|this[e+2]<<8|this[e+3])},u.prototype.readIntLE=function(e,t,n){e|=0,t|=0,n||P(e,t,this.length);for(var r=this[e],o=1,a=0;++a=(o*=128)&&(r-=Math.pow(2,8*t)),r},u.prototype.readIntBE=function(e,t,n){e|=0,t|=0,n||P(e,t,this.length);for(var r=t,o=1,a=this[e+--r];r>0&&(o*=256);)a+=this[e+--r]*o;return a>=(o*=128)&&(a-=Math.pow(2,8*t)),a},u.prototype.readInt8=function(e,t){return t||P(e,1,this.length),128&this[e]?-1*(255-this[e]+1):this[e]},u.prototype.readInt16LE=function(e,t){t||P(e,2,this.length);var n=this[e]|this[e+1]<<8;return 32768&n?4294901760|n:n},u.prototype.readInt16BE=function(e,t){t||P(e,2,this.length);var n=this[e+1]|this[e]<<8;return 32768&n?4294901760|n:n},u.prototype.readInt32LE=function(e,t){return t||P(e,4,this.length),this[e]|this[e+1]<<8|this[e+2]<<16|this[e+3]<<24},u.prototype.readInt32BE=function(e,t){return t||P(e,4,this.length),this[e]<<24|this[e+1]<<16|this[e+2]<<8|this[e+3]},u.prototype.readFloatLE=function(e,t){return t||P(e,4,this.length),o.read(this,e,!0,23,4)},u.prototype.readFloatBE=function(e,t){return t||P(e,4,this.length),o.read(this,e,!1,23,4)},u.prototype.readDoubleLE=function(e,t){return t||P(e,8,this.length),o.read(this,e,!0,52,8)},u.prototype.readDoubleBE=function(e,t){return t||P(e,8,this.length),o.read(this,e,!1,52,8)},u.prototype.writeUIntLE=function(e,t,n,r){(e=+e,t|=0,n|=0,r)||N(this,e,t,n,Math.pow(2,8*n)-1,0);var o=1,a=0;for(this[t]=255&e;++a=0&&(a*=256);)this[t+o]=e/a&255;return t+n},u.prototype.writeUInt8=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,1,255,0),u.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),this[t]=255&e,t+1},u.prototype.writeUInt16LE=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,2,65535,0),u.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):M(this,e,t,!0),t+2},u.prototype.writeUInt16BE=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,2,65535,0),u.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):M(this,e,t,!1),t+2},u.prototype.writeUInt32LE=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,4,4294967295,0),u.TYPED_ARRAY_SUPPORT?(this[t+3]=e>>>24,this[t+2]=e>>>16,this[t+1]=e>>>8,this[t]=255&e):R(this,e,t,!0),t+4},u.prototype.writeUInt32BE=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,4,4294967295,0),u.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):R(this,e,t,!1),t+4},u.prototype.writeIntLE=function(e,t,n,r){if(e=+e,t|=0,!r){var o=Math.pow(2,8*n-1);N(this,e,t,n,o-1,-o)}var a=0,i=1,s=0;for(this[t]=255&e;++a>0)-s&255;return t+n},u.prototype.writeIntBE=function(e,t,n,r){if(e=+e,t|=0,!r){var o=Math.pow(2,8*n-1);N(this,e,t,n,o-1,-o)}var a=n-1,i=1,s=0;for(this[t+a]=255&e;--a>=0&&(i*=256);)e<0&&0===s&&0!==this[t+a+1]&&(s=1),this[t+a]=(e/i>>0)-s&255;return t+n},u.prototype.writeInt8=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,1,127,-128),u.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),e<0&&(e=255+e+1),this[t]=255&e,t+1},u.prototype.writeInt16LE=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,2,32767,-32768),u.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):M(this,e,t,!0),t+2},u.prototype.writeInt16BE=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,2,32767,-32768),u.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):M(this,e,t,!1),t+2},u.prototype.writeInt32LE=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,4,2147483647,-2147483648),u.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8,this[t+2]=e>>>16,this[t+3]=e>>>24):R(this,e,t,!0),t+4},u.prototype.writeInt32BE=function(e,t,n){return e=+e,t|=0,n||N(this,e,t,4,2147483647,-2147483648),e<0&&(e=4294967295+e+1),u.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):R(this,e,t,!1),t+4},u.prototype.writeFloatLE=function(e,t,n){return L(this,e,t,!0,n)},u.prototype.writeFloatBE=function(e,t,n){return L(this,e,t,!1,n)},u.prototype.writeDoubleLE=function(e,t,n){return B(this,e,t,!0,n)},u.prototype.writeDoubleBE=function(e,t,n){return B(this,e,t,!1,n)},u.prototype.copy=function(e,t,n,r){if(n||(n=0),r||0===r||(r=this.length),t>=e.length&&(t=e.length),t||(t=0),r>0&&r=this.length)throw new RangeError("sourceStart out of bounds");if(r<0)throw new RangeError("sourceEnd out of bounds");r>this.length&&(r=this.length),e.length-t=0;--o)e[o+t]=this[o+n];else if(a<1e3||!u.TYPED_ARRAY_SUPPORT)for(o=0;o>>=0,n=void 0===n?this.length:n>>>0,e||(e=0),"number"==typeof e)for(a=t;a55295&&n<57344){if(!o){if(n>56319){(t-=3)>-1&&a.push(239,191,189);continue}if(i+1===r){(t-=3)>-1&&a.push(239,191,189);continue}o=n;continue}if(n<56320){(t-=3)>-1&&a.push(239,191,189),o=n;continue}n=65536+(o-55296<<10|n-56320)}else o&&(t-=3)>-1&&a.push(239,191,189);if(o=null,n<128){if((t-=1)<0)break;a.push(n)}else if(n<2048){if((t-=2)<0)break;a.push(n>>6|192,63&n|128)}else if(n<65536){if((t-=3)<0)break;a.push(n>>12|224,n>>6&63|128,63&n|128)}else{if(!(n<1114112))throw new Error("Invalid code point");if((t-=4)<0)break;a.push(n>>18|240,n>>12&63|128,n>>6&63|128,63&n|128)}}return a}function z(e){return r.toByteArray(function(e){if((e=function(e){return e.trim?e.trim():e.replace(/^\s+|\s+$/g,"")}(e).replace(F,"")).length<2)return"";for(;e.length%4!=0;)e+="=";return e}(e))}function V(e,t,n,r){for(var o=0;o=t.length||o>=e.length);++o)t[o+n]=e[o];return o}}).call(this,n(52))},function(e,t,n){e.exports=n(671)},function(e,t,n){e.exports=n(887)},function(e,t,n){var r=n(179),o=n(107);e.exports=function(e){return r(o(e))}},function(e,t,n){var r=n(48),o=n(69),a=n(106);e.exports=r?function(e,t,n){return o.f(e,t,a(1,n))}:function(e,t,n){return e[t]=n,e}},function(e,t,n){var r=n(48),o=n(362),a=n(53),i=n(180),s=Object.defineProperty;t.f=r?s:function(e,t,n){if(a(e),t=i(t,!0),a(n),o)try{return s(e,t,n)}catch(e){}if("get"in n||"set"in n)throw TypeError("Accessors not supported");return"value"in n&&(e[t]=n.value),e}},function(e,t,n){var r=n(130),o=Math.min;e.exports=function(e){return e>0?o(r(e),9007199254740991):0}},function(e,t,n){var r=n(34),o=n(40),a=function(e){return"function"==typeof e?e:void 0};e.exports=function(e,t){return arguments.length<2?a(r[e])||a(o[e]):r[e]&&r[e][t]||o[e]&&o[e][t]}},function(e,t,n){var r=n(408),o="object"==typeof self&&self&&self.Object===Object&&self,a=r||o||Function("return this")();e.exports=a},function(e,t,n){"use strict";e.exports={debugTool:null}},function(e,t,n){"use strict";function r(e){return null==e}var o={isNothing:r,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(e){return Array.isArray(e)?e:r(e)?[]:[e]},repeat:function(e,t){var n,r="";for(n=0;ns&&(t=r-s+(a=" ... ").length),n-r>s&&(n=r+s-(i=" ...").length),{str:a+e.slice(t,n).replace(/\t/g,"→")+i,pos:r-t+a.length}}function c(e,t){return o.repeat(" ",t-e.length)+e}var l=function(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var n,r=/\r?\n|\r|\0/g,a=[0],i=[],s=-1;n=r.exec(e.buffer);)i.push(n.index),a.push(n.index+n[0].length),e.position<=n.index&&s<0&&(s=a.length-2);s<0&&(s=a.length-1);var l,p,f="",h=Math.min(e.line+t.linesAfter,i.length).toString().length,d=t.maxLength-(t.indent+h+3);for(l=1;l<=t.linesBefore&&!(s-l<0);l++)p=u(e.buffer,a[s-l],i[s-l],e.position-(a[s]-a[s-l]),d),f=o.repeat(" ",t.indent)+c((e.line-l+1).toString(),h)+" | "+p.str+"\n"+f;for(p=u(e.buffer,a[s],i[s],e.position,d),f+=o.repeat(" ",t.indent)+c((e.line+1).toString(),h)+" | "+p.str+"\n",f+=o.repeat("-",t.indent+h+3+p.pos)+"^\n",l=1;l<=t.linesAfter&&!(s+l>=i.length);l++)p=u(e.buffer,a[s+l],i[s+l],e.position-(a[s]-a[s+l]),d),f+=o.repeat(" ",t.indent)+c((e.line+l+1).toString(),h)+" | "+p.str+"\n";return f.replace(/\n$/,"")},p=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],f=["scalar","sequence","mapping"];var h=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===p.indexOf(t))throw new s('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===f.indexOf(this.kind))throw new s('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function d(e,t){var n=[];return e[t].forEach((function(e){var t=n.length;n.forEach((function(n,r){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=r)})),n[t]=e})),n}function m(e){return this.extend(e)}m.prototype.extend=function(e){var t=[],n=[];if(e instanceof h)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new s("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof h))throw new s("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new s("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new s("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof h))throw new s("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var r=Object.create(m.prototype);return r.implicit=(this.implicit||[]).concat(t),r.explicit=(this.explicit||[]).concat(n),r.compiledImplicit=d(r,"implicit"),r.compiledExplicit=d(r,"explicit"),r.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function r(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.push(e)):n[e.kind][e.tag]=n.fallback[e.tag]=e}for(e=0,t=arguments.length;e=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),A=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var O=/^[-+]?[0-9]+e/;var k=new h("tag:yaml.org,2002:float",{kind:"scalar",resolve:function(e){return null!==e&&!(!A.test(e)||"_"===e[e.length-1])},construct:function(e){var t,n;return n="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===n?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:n*parseFloat(t,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||o.isNegativeZero(e))},represent:function(e,t){var n;if(isNaN(e))switch(t){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(t){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(t){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(o.isNegativeZero(e))return"-0.0";return n=e.toString(10),O.test(n)?n.replace("e",".e"):n},defaultStyle:"lowercase"}),j=_.extend({implicit:[w,x,C,k]}),T=j,I=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),P=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var N=new h("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function(e){return null!==e&&(null!==I.exec(e)||null!==P.exec(e))},construct:function(e){var t,n,r,o,a,i,s,u,c=0,l=null;if(null===(t=I.exec(e))&&(t=P.exec(e)),null===t)throw new Error("Date resolve error");if(n=+t[1],r=+t[2]-1,o=+t[3],!t[4])return new Date(Date.UTC(n,r,o));if(a=+t[4],i=+t[5],s=+t[6],t[7]){for(c=t[7].slice(0,3);c.length<3;)c+="0";c=+c}return t[9]&&(l=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(l=-l)),u=new Date(Date.UTC(n,r,o,a,i,s,c)),l&&u.setTime(u.getTime()-l),u},instanceOf:Date,represent:function(e){return e.toISOString()}});var M=new h("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function(e){return"<<"===e||null===e}}),R="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var D=new h("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,r=0,o=e.length,a=R;for(n=0;n64)){if(t<0)return!1;r+=6}return r%8==0},construct:function(e){var t,n,r=e.replace(/[\r\n=]/g,""),o=r.length,a=R,i=0,s=[];for(t=0;t>16&255),s.push(i>>8&255),s.push(255&i)),i=i<<6|a.indexOf(r.charAt(t));return 0===(n=o%4*6)?(s.push(i>>16&255),s.push(i>>8&255),s.push(255&i)):18===n?(s.push(i>>10&255),s.push(i>>2&255)):12===n&&s.push(i>>4&255),new Uint8Array(s)},predicate:function(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function(e){var t,n,r="",o=0,a=e.length,i=R;for(t=0;t>18&63],r+=i[o>>12&63],r+=i[o>>6&63],r+=i[63&o]),o=(o<<8)+e[t];return 0===(n=a%3)?(r+=i[o>>18&63],r+=i[o>>12&63],r+=i[o>>6&63],r+=i[63&o]):2===n?(r+=i[o>>10&63],r+=i[o>>4&63],r+=i[o<<2&63],r+=i[64]):1===n&&(r+=i[o>>2&63],r+=i[o<<4&63],r+=i[64],r+=i[64]),r}}),L=Object.prototype.hasOwnProperty,B=Object.prototype.toString;var F=new h("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,r,o,a,i=[],s=e;for(t=0,n=s.length;t>10),56320+(e-65536&1023))}for(var ae=new Array(256),ie=new Array(256),se=0;se<256;se++)ae[se]=re(se)?1:0,ie[se]=re(se);function ue(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||W,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function ce(e,t){var n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=l(n),new s(t,n)}function le(e,t){throw ce(e,t)}function pe(e,t){e.onWarning&&e.onWarning.call(null,ce(e,t))}var fe={YAML:function(e,t,n){var r,o,a;null!==e.version&&le(e,"duplication of %YAML directive"),1!==n.length&&le(e,"YAML directive accepts exactly one argument"),null===(r=/^([0-9]+)\.([0-9]+)$/.exec(n[0]))&&le(e,"ill-formed argument of the YAML directive"),o=parseInt(r[1],10),a=parseInt(r[2],10),1!==o&&le(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=a<2,1!==a&&2!==a&&pe(e,"unsupported YAML version of the document")},TAG:function(e,t,n){var r,o;2!==n.length&&le(e,"TAG directive accepts exactly two arguments"),r=n[0],o=n[1],Y.test(r)||le(e,"ill-formed tag handle (first argument) of the TAG directive"),H.call(e.tagMap,r)&&le(e,'there is a previously declared suffix for "'+r+'" tag handle'),G.test(o)||le(e,"ill-formed tag prefix (second argument) of the TAG directive");try{o=decodeURIComponent(o)}catch(t){le(e,"tag prefix is malformed: "+o)}e.tagMap[r]=o}};function he(e,t,n,r){var o,a,i,s;if(t1&&(e.result+=o.repeat("\n",t-1))}function _e(e,t){var n,r,o=e.tag,a=e.anchor,i=[],s=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=i),r=e.input.charCodeAt(e.position);0!==r&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,le(e,"tab characters must not be used in indentation")),45===r)&&ee(e.input.charCodeAt(e.position+1));)if(s=!0,e.position++,ge(e,!0,-1)&&e.lineIndent<=t)i.push(null),r=e.input.charCodeAt(e.position);else if(n=e.line,Ee(e,t,3,!1,!0),i.push(e.result),ge(e,!0,-1),r=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&0!==r)le(e,"bad indentation of a sequence entry");else if(e.lineIndentt?m=1:e.lineIndent===t?m=0:e.lineIndentt?m=1:e.lineIndent===t?m=0:e.lineIndentt)&&(g&&(i=e.line,s=e.lineStart,u=e.position),Ee(e,t,4,!0,o)&&(g?m=e.result:v=e.result),g||(me(e,f,h,d,m,v,i,s,u),d=m=v=null),ge(e,!0,-1),c=e.input.charCodeAt(e.position)),(e.line===a||e.lineIndent>t)&&0!==c)le(e,"bad indentation of a mapping entry");else if(e.lineIndent=0))break;0===a?le(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):l?le(e,"repeat of an indentation width identifier"):(p=t+a-1,l=!0)}if(Q(i)){do{i=e.input.charCodeAt(++e.position)}while(Q(i));if(35===i)do{i=e.input.charCodeAt(++e.position)}while(!X(i)&&0!==i)}for(;0!==i;){for(ve(e),e.lineIndent=0,i=e.input.charCodeAt(e.position);(!l||e.lineIndentp&&(p=e.lineIndent),X(i))f++;else{if(e.lineIndent0){for(o=i,a=0;o>0;o--)(i=ne(s=e.input.charCodeAt(++e.position)))>=0?a=(a<<4)+i:le(e,"expected hexadecimal character");e.result+=oe(a),e.position++}else le(e,"unknown escape sequence");n=r=e.position}else X(s)?(he(e,n,r,!0),be(e,ge(e,!1,t)),n=r=e.position):e.position===e.lineStart&&ye(e)?le(e,"unexpected end of the document within a double quoted scalar"):(e.position++,r=e.position)}le(e,"unexpected end of the stream within a double quoted scalar")}(e,h)?g=!0:!function(e){var t,n,r;if(42!==(r=e.input.charCodeAt(e.position)))return!1;for(r=e.input.charCodeAt(++e.position),t=e.position;0!==r&&!ee(r)&&!te(r);)r=e.input.charCodeAt(++e.position);return e.position===t&&le(e,"name of an alias node must contain at least one character"),n=e.input.slice(t,e.position),H.call(e.anchorMap,n)||le(e,'unidentified alias "'+n+'"'),e.result=e.anchorMap[n],ge(e,!0,-1),!0}(e)?function(e,t,n){var r,o,a,i,s,u,c,l,p=e.kind,f=e.result;if(ee(l=e.input.charCodeAt(e.position))||te(l)||35===l||38===l||42===l||33===l||124===l||62===l||39===l||34===l||37===l||64===l||96===l)return!1;if((63===l||45===l)&&(ee(r=e.input.charCodeAt(e.position+1))||n&&te(r)))return!1;for(e.kind="scalar",e.result="",o=a=e.position,i=!1;0!==l;){if(58===l){if(ee(r=e.input.charCodeAt(e.position+1))||n&&te(r))break}else if(35===l){if(ee(e.input.charCodeAt(e.position-1)))break}else{if(e.position===e.lineStart&&ye(e)||n&&te(l))break;if(X(l)){if(s=e.line,u=e.lineStart,c=e.lineIndent,ge(e,!1,-1),e.lineIndent>=t){i=!0,l=e.input.charCodeAt(e.position);continue}e.position=a,e.line=s,e.lineStart=u,e.lineIndent=c;break}}i&&(he(e,o,a,!1),be(e,e.line-s),o=a=e.position,i=!1),Q(l)||(a=e.position+1),l=e.input.charCodeAt(++e.position)}return he(e,o,a,!1),!!e.result||(e.kind=p,e.result=f,!1)}(e,h,1===n)&&(g=!0,null===e.tag&&(e.tag="?")):(g=!0,null===e.tag&&null===e.anchor||le(e,"alias node should not have any properties")),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):0===m&&(g=u&&_e(e,d))),null===e.tag)null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);else if("?"===e.tag){for(null!==e.result&&"scalar"!==e.kind&&le(e,'unacceptable node kind for ! tag; it should be "scalar", not "'+e.kind+'"'),c=0,l=e.implicitTypes.length;c"),null!==e.result&&f.kind!==e.kind&&le(e,"unacceptable node kind for !<"+e.tag+'> tag; it should be "'+f.kind+'", not "'+e.kind+'"'),f.resolve(e.result,e.tag)?(e.result=f.construct(e.result,e.tag),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):le(e,"cannot resolve a node with !<"+e.tag+"> explicit tag")}return null!==e.listener&&e.listener("close",e),null!==e.tag||null!==e.anchor||g}function Se(e){var t,n,r,o,a=e.position,i=!1;for(e.version=null,e.checkLineBreaks=e.legacy,e.tagMap=Object.create(null),e.anchorMap=Object.create(null);0!==(o=e.input.charCodeAt(e.position))&&(ge(e,!0,-1),o=e.input.charCodeAt(e.position),!(e.lineIndent>0||37!==o));){for(i=!0,o=e.input.charCodeAt(++e.position),t=e.position;0!==o&&!ee(o);)o=e.input.charCodeAt(++e.position);for(r=[],(n=e.input.slice(t,e.position)).length<1&&le(e,"directive name must not be less than one character in length");0!==o;){for(;Q(o);)o=e.input.charCodeAt(++e.position);if(35===o){do{o=e.input.charCodeAt(++e.position)}while(0!==o&&!X(o));break}if(X(o))break;for(t=e.position;0!==o&&!ee(o);)o=e.input.charCodeAt(++e.position);r.push(e.input.slice(t,e.position))}0!==o&&ve(e),H.call(fe,n)?fe[n](e,n,r):pe(e,'unknown document directive "'+n+'"')}ge(e,!0,-1),0===e.lineIndent&&45===e.input.charCodeAt(e.position)&&45===e.input.charCodeAt(e.position+1)&&45===e.input.charCodeAt(e.position+2)?(e.position+=3,ge(e,!0,-1)):i&&le(e,"directives end mark is expected"),Ee(e,e.lineIndent-1,4,!1,!0),ge(e,!0,-1),e.checkLineBreaks&&J.test(e.input.slice(a,e.position))&&pe(e,"non-ASCII line breaks are interpreted as content"),e.documents.push(e.result),e.position===e.lineStart&&ye(e)?46===e.input.charCodeAt(e.position)&&(e.position+=3,ge(e,!0,-1)):e.position=55296&&r<=56319&&t+1=56320&&n<=57343?1024*(r-55296)+n-56320+65536:r}function ze(e){return/^\n* /.test(e)}function Ve(e,t,n,r,o,a,i,s){var u,c,l=0,p=null,f=!1,h=!1,d=-1!==r,m=-1,v=Be(c=qe(e,0))&&c!==je&&!Le(c)&&45!==c&&63!==c&&58!==c&&44!==c&&91!==c&&93!==c&&123!==c&&125!==c&&35!==c&&38!==c&&42!==c&&33!==c&&124!==c&&61!==c&&62!==c&&39!==c&&34!==c&&37!==c&&64!==c&&96!==c&&function(e){return!Le(e)&&58!==e}(qe(e,e.length-1));if(t||i)for(u=0;u=65536?u+=2:u++){if(!Be(l=qe(e,u)))return 5;v=v&&Ue(l,p,s),p=l}else{for(u=0;u=65536?u+=2:u++){if(10===(l=qe(e,u)))f=!0,d&&(h=h||u-m-1>r&&" "!==e[m+1],m=u);else if(!Be(l))return 5;v=v&&Ue(l,p,s),p=l}h=h||d&&u-m-1>r&&" "!==e[m+1]}return f||h?n>9&&ze(e)?5:i?2===a?5:2:h?4:3:!v||i||o(e)?2===a?5:2:1}function We(e,t,n,r,o){e.dump=function(){if(0===t.length)return 2===e.quotingType?'""':"''";if(!e.noCompatMode&&(-1!==Ie.indexOf(t)||Pe.test(t)))return 2===e.quotingType?'"'+t+'"':"'"+t+"'";var a=e.indent*Math.max(1,n),i=-1===e.lineWidth?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-a),u=r||e.flowLevel>-1&&n>=e.flowLevel;switch(Ve(t,u,e.indent,i,(function(t){return function(e,t){var n,r;for(n=0,r=e.implicitTypes.length;n"+He(t,e.indent)+$e(Re(function(e,t){var n,r,o=/(\n+)([^\n]*)/g,a=(s=e.indexOf("\n"),s=-1!==s?s:e.length,o.lastIndex=s,Je(e.slice(0,s),t)),i="\n"===e[0]||" "===e[0];var s;for(;r=o.exec(e);){var u=r[1],c=r[2];n=" "===c[0],a+=u+(i||n||""===c?"":"\n")+Je(c,t),i=n}return a}(t,i),a));case 5:return'"'+function(e){for(var t,n="",r=0,o=0;o=65536?o+=2:o++)r=qe(e,o),!(t=Te[r])&&Be(r)?(n+=e[o],r>=65536&&(n+=e[o+1])):n+=t||Ne(r);return n}(t)+'"';default:throw new s("impossible error: invalid scalar style")}}()}function He(e,t){var n=ze(e)?String(t):"",r="\n"===e[e.length-1];return n+(r&&("\n"===e[e.length-2]||"\n"===e)?"+":r?"":"-")+"\n"}function $e(e){return"\n"===e[e.length-1]?e.slice(0,-1):e}function Je(e,t){if(""===e||" "===e[0])return e;for(var n,r,o=/ [^ ]/g,a=0,i=0,s=0,u="";n=o.exec(e);)(s=n.index)-a>t&&(r=i>a?i:s,u+="\n"+e.slice(a,r),a=r+1),i=s;return u+="\n",e.length-a>t&&i>a?u+=e.slice(a,i)+"\n"+e.slice(i+1):u+=e.slice(a),u.slice(1)}function Ke(e,t,n,r){var o,a,i,s="",u=e.tag;for(o=0,a=n.length;o tag resolver accepts not "'+c+'" style');r=u.represent[c](t,c)}e.dump=r}return!0}return!1}function Ge(e,t,n,r,o,a,i){e.tag=null,e.dump=n,Ye(e,n,!1)||Ye(e,n,!0);var u,c=Oe.call(e.dump),l=r;r&&(r=e.flowLevel<0||e.flowLevel>t);var p,f,h="[object Object]"===c||"[object Array]"===c;if(h&&(f=-1!==(p=e.duplicates.indexOf(n))),(null!==e.tag&&"?"!==e.tag||f||2!==e.indent&&t>0)&&(o=!1),f&&e.usedDuplicates[p])e.dump="*ref_"+p;else{if(h&&f&&!e.usedDuplicates[p]&&(e.usedDuplicates[p]=!0),"[object Object]"===c)r&&0!==Object.keys(e.dump).length?(!function(e,t,n,r){var o,a,i,u,c,l,p="",f=e.tag,h=Object.keys(n);if(!0===e.sortKeys)h.sort();else if("function"==typeof e.sortKeys)h.sort(e.sortKeys);else if(e.sortKeys)throw new s("sortKeys must be a boolean or a function");for(o=0,a=h.length;o1024)&&(e.dump&&10===e.dump.charCodeAt(0)?l+="?":l+="? "),l+=e.dump,c&&(l+=De(e,t)),Ge(e,t+1,u,!0,c)&&(e.dump&&10===e.dump.charCodeAt(0)?l+=":":l+=": ",p+=l+=e.dump));e.tag=f,e.dump=p||"{}"}(e,t,e.dump,o),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var r,o,a,i,s,u="",c=e.tag,l=Object.keys(n);for(r=0,o=l.length;r1024&&(s+="? "),s+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),Ge(e,t,i,!1,!1)&&(u+=s+=e.dump));e.tag=c,e.dump="{"+u+"}"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else if("[object Array]"===c)r&&0!==e.dump.length?(e.noArrayIndent&&!i&&t>0?Ke(e,t-1,e.dump,o):Ke(e,t,e.dump,o),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var r,o,a,i="",s=e.tag;for(r=0,o=n.length;r",e.dump=u+" "+e.dump)}return!0}function Ze(e,t){var n,r,o=[],a=[];for(Xe(e,o,a),n=0,r=a.length;n",'"',"`"," ","\r","\n","\t"]),l=["'"].concat(c),p=["%","/","?",";","#"].concat(l),f=["/","?","#"],h=/^[+a-z0-9A-Z_-]{0,63}$/,d=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,m={javascript:!0,"javascript:":!0},v={javascript:!0,"javascript:":!0},g={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0},y=n(1077);function b(e,t,n){if(e&&o.isObject(e)&&e instanceof a)return e;var r=new a;return r.parse(e,t,n),r}a.prototype.parse=function(e,t,n){if(!o.isString(e))throw new TypeError("Parameter 'url' must be a string, not "+typeof e);var a=e.indexOf("?"),s=-1!==a&&a127?N+="x":N+=P[M];if(!N.match(h)){var D=T.slice(0,O),L=T.slice(O+1),B=P.match(d);B&&(D.push(B[1]),L.unshift(B[2])),L.length&&(b="/"+L.join(".")+b),this.hostname=D.join(".");break}}}this.hostname.length>255?this.hostname="":this.hostname=this.hostname.toLowerCase(),j||(this.hostname=r.toASCII(this.hostname));var F=this.port?":"+this.port:"",U=this.hostname||"";this.host=U+F,this.href+=this.host,j&&(this.hostname=this.hostname.substr(1,this.hostname.length-2),"/"!==b[0]&&(b="/"+b))}if(!m[x])for(O=0,I=l.length;O0)&&n.host.split("@"))&&(n.auth=j.shift(),n.host=n.hostname=j.shift());return n.search=e.search,n.query=e.query,o.isNull(n.pathname)&&o.isNull(n.search)||(n.path=(n.pathname?n.pathname:"")+(n.search?n.search:"")),n.href=n.format(),n}if(!E.length)return n.pathname=null,n.search?n.path="/"+n.search:n.path=null,n.href=n.format(),n;for(var C=E.slice(-1)[0],A=(n.host||e.host||E.length>1)&&("."===C||".."===C)||""===C,O=0,k=E.length;k>=0;k--)"."===(C=E[k])?E.splice(k,1):".."===C?(E.splice(k,1),O++):O&&(E.splice(k,1),O--);if(!w&&!x)for(;O--;O)E.unshift("..");!w||""===E[0]||E[0]&&"/"===E[0].charAt(0)||E.unshift(""),A&&"/"!==E.join("/").substr(-1)&&E.push("");var j,T=""===E[0]||E[0]&&"/"===E[0].charAt(0);S&&(n.hostname=n.host=T?"":E.length?E.shift():"",(j=!!(n.host&&n.host.indexOf("@")>0)&&n.host.split("@"))&&(n.auth=j.shift(),n.host=n.hostname=j.shift()));return(w=w||n.host&&E.length)&&!T&&E.unshift(""),E.length?n.pathname=E.join("/"):(n.pathname=null,n.path=null),o.isNull(n.pathname)&&o.isNull(n.search)||(n.path=(n.pathname?n.pathname:"")+(n.search?n.search:"")),n.auth=e.auth||n.auth,n.slashes=n.slashes||e.slashes,n.href=n.format(),n},a.prototype.parseHost=function(){var e=this.host,t=s.exec(e);t&&(":"!==(t=t[0])&&(this.port=t.substr(1)),e=e.substr(0,e.length-t.length)),e&&(this.hostname=e)}},function(e,t,n){"use strict";n.r(t),n.d(t,"SHOW_AUTH_POPUP",(function(){return h})),n.d(t,"AUTHORIZE",(function(){return d})),n.d(t,"LOGOUT",(function(){return m})),n.d(t,"PRE_AUTHORIZE_OAUTH2",(function(){return v})),n.d(t,"AUTHORIZE_OAUTH2",(function(){return g})),n.d(t,"VALIDATE",(function(){return y})),n.d(t,"CONFIGURE_AUTH",(function(){return b})),n.d(t,"RESTORE_AUTHORIZATION",(function(){return _})),n.d(t,"showDefinitions",(function(){return w})),n.d(t,"authorize",(function(){return x})),n.d(t,"authorizeWithPersistOption",(function(){return E})),n.d(t,"logout",(function(){return S})),n.d(t,"logoutWithPersistOption",(function(){return C})),n.d(t,"preAuthorizeImplicit",(function(){return A})),n.d(t,"authorizeOauth2",(function(){return O})),n.d(t,"authorizeOauth2WithPersistOption",(function(){return k})),n.d(t,"authorizePassword",(function(){return j})),n.d(t,"authorizeApplication",(function(){return T})),n.d(t,"authorizeAccessCodeWithFormParams",(function(){return I})),n.d(t,"authorizeAccessCodeWithBasicAuthentication",(function(){return P})),n.d(t,"authorizeRequest",(function(){return N})),n.d(t,"configureAuth",(function(){return M})),n.d(t,"restoreAuthorization",(function(){return R})),n.d(t,"persistAuthorizationIfNeeded",(function(){return D}));var r=n(18),o=n.n(r),a=n(32),i=n.n(a),s=n(20),u=n.n(s),c=n(94),l=n.n(c),p=n(26),f=n(5),h="show_popup",d="authorize",m="logout",v="pre_authorize_oauth2",g="authorize_oauth2",y="validate",b="configure_auth",_="restore_authorization";function w(e){return{type:h,payload:e}}function x(e){return{type:d,payload:e}}var E=function(e){return function(t){var n=t.authActions;n.authorize(e),n.persistAuthorizationIfNeeded()}};function S(e){return{type:m,payload:e}}var C=function(e){return function(t){var n=t.authActions;n.logout(e),n.persistAuthorizationIfNeeded()}},A=function(e){return function(t){var n=t.authActions,r=t.errActions,o=e.auth,a=e.token,s=e.isValid,u=o.schema,c=o.name,l=u.get("flow");delete p.a.swaggerUIRedirectOauth2,"accessCode"===l||s||r.newAuthErr({authId:c,source:"auth",level:"warning",message:"Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"}),a.error?r.newAuthErr({authId:c,source:"auth",level:"error",message:i()(a)}):n.authorizeOauth2WithPersistOption({auth:o,token:a})}};function O(e){return{type:g,payload:e}}var k=function(e){return function(t){var n=t.authActions;n.authorizeOauth2(e),n.persistAuthorizationIfNeeded()}},j=function(e){return function(t){var n=t.authActions,r=e.schema,o=e.name,a=e.username,i=e.password,s=e.passwordType,c=e.clientId,l=e.clientSecret,p={grant_type:"password",scope:e.scopes.join(" "),username:a,password:i},h={};switch(s){case"request-body":!function(e,t,n){t&&u()(e,{client_id:t});n&&u()(e,{client_secret:n})}(p,c,l);break;case"basic":h.Authorization="Basic "+Object(f.a)(c+":"+l);break;default:console.warn("Warning: invalid passwordType ".concat(s," was passed, not including client id and secret"))}return n.authorizeRequest({body:Object(f.b)(p),url:r.get("tokenUrl"),name:o,headers:h,query:{},auth:e})}};var T=function(e){return function(t){var n=t.authActions,r=e.schema,o=e.scopes,a=e.name,i=e.clientId,s=e.clientSecret,u={Authorization:"Basic "+Object(f.a)(i+":"+s)},c={grant_type:"client_credentials",scope:o.join(" ")};return n.authorizeRequest({body:Object(f.b)(c),name:a,url:r.get("tokenUrl"),auth:e,headers:u})}},I=function(e){var t=e.auth,n=e.redirectUrl;return function(e){var r=e.authActions,o=t.schema,a=t.name,i=t.clientId,s=t.clientSecret,u=t.codeVerifier,c={grant_type:"authorization_code",code:t.code,client_id:i,client_secret:s,redirect_uri:n,code_verifier:u};return r.authorizeRequest({body:Object(f.b)(c),name:a,url:o.get("tokenUrl"),auth:t})}},P=function(e){var t=e.auth,n=e.redirectUrl;return function(e){var r=e.authActions,o=t.schema,a=t.name,i=t.clientId,s=t.clientSecret,u=t.codeVerifier,c={Authorization:"Basic "+Object(f.a)(i+":"+s)},l={grant_type:"authorization_code",code:t.code,client_id:i,redirect_uri:n,code_verifier:u};return r.authorizeRequest({body:Object(f.b)(l),name:a,url:o.get("tokenUrl"),auth:t,headers:c})}},N=function(e){return function(t){var n,r=t.fn,a=t.getConfigs,s=t.authActions,c=t.errActions,p=t.oas3Selectors,f=t.specSelectors,h=t.authSelectors,d=e.body,m=e.query,v=void 0===m?{}:m,g=e.headers,y=void 0===g?{}:g,b=e.name,_=e.url,w=e.auth,x=(h.getConfigs()||{}).additionalQueryStringParams;if(f.isOAS3()){var E=p.serverEffectiveValue(p.selectedServer());n=l()(_,E,!0)}else n=l()(_,f.url(),!0);"object"===o()(x)&&(n.query=u()({},n.query,x));var S=n.toString(),C=u()({Accept:"application/json, text/plain, */*","Content-Type":"application/x-www-form-urlencoded","X-Requested-With":"XMLHttpRequest"},y);r.fetch({url:S,method:"post",headers:C,query:v,body:d,requestInterceptor:a().requestInterceptor,responseInterceptor:a().responseInterceptor}).then((function(e){var t=JSON.parse(e.data),n=t&&(t.error||""),r=t&&(t.parseError||"");e.ok?n||r?c.newAuthErr({authId:b,level:"error",source:"auth",message:i()(t)}):s.authorizeOauth2WithPersistOption({auth:w,token:t}):c.newAuthErr({authId:b,level:"error",source:"auth",message:e.statusText})})).catch((function(e){var t=new Error(e).message;if(e.response&&e.response.data){var n=e.response.data;try{var r="string"==typeof n?JSON.parse(n):n;r.error&&(t+=", error: ".concat(r.error)),r.error_description&&(t+=", description: ".concat(r.error_description))}catch(e){}}c.newAuthErr({authId:b,level:"error",source:"auth",message:t})}))}};function M(e){return{type:b,payload:e}}function R(e){return{type:_,payload:e}}var D=function(){return function(e){var t=e.authSelectors;if((0,e.getConfigs)().persistAuthorization){var n=t.authorized();localStorage.setItem("authorized",i()(n.toJS()))}}}},function(e,t,n){var r=n(1048);e.exports=function(e){for(var t=1;tS;S++)if((h||S in w)&&(b=x(y=w[S],S,_),e))if(t)A[S]=b;else if(b)switch(e){case 3:return!0;case 5:return y;case 6:return S;case 2:u.call(A,y)}else switch(e){case 4:return!1;case 7:u.call(A,y)}return p?-1:c||l?l:A}};e.exports={forEach:c(0),map:c(1),filter:c(2),some:c(3),every:c(4),find:c(5),findIndex:c(6),filterOut:c(7)}},function(e,t,n){n(159);var r=n(576),o=n(40),a=n(100),i=n(68),s=n(133),u=n(41)("toStringTag");for(var c in r){var l=o[c],p=l&&l.prototype;p&&a(p)!==u&&i(p,u,c),s[c]=s.Array}},function(e,t,n){"use strict";e.exports={current:null}},function(e,t){e.exports=function(e){return null!=e&&"object"==typeof e}},function(e,t){var n,r,o=e.exports={};function a(){throw new Error("setTimeout has not been defined")}function i(){throw new Error("clearTimeout has not been defined")}function s(e){if(n===setTimeout)return setTimeout(e,0);if((n===a||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:a}catch(e){n=a}try{r="function"==typeof clearTimeout?clearTimeout:i}catch(e){r=i}}();var u,c=[],l=!1,p=-1;function f(){l&&u&&(l=!1,u.length?c=u.concat(c):p=-1,c.length&&h())}function h(){if(!l){var e=s(f);l=!0;for(var t=c.length;t;){for(u=c,c=[];++p1)for(var n=1;n0&&"/"!==t[0]}));function Se(e,t,n){var r;t=t||[];var o=we.apply(void 0,u()(r=[e]).call(r,i()(t))).get("parameters",Object(I.List)());return x()(o).call(o,(function(e,t){var r=n&&"body"===t.get("in")?t.get("value_xml"):t.get("value");return e.set(Object(T.A)(t,{allowHashes:!1}),r)}),Object(I.fromJS)({}))}function Ce(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";if(I.List.isList(e))return A()(e).call(e,(function(e){return I.Map.isMap(e)&&e.get("in")===t}))}function Ae(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";if(I.List.isList(e))return A()(e).call(e,(function(e){return I.Map.isMap(e)&&e.get("type")===t}))}function Oe(e,t){var n,r;t=t||[];var o=z(e).getIn(u()(n=["paths"]).call(n,i()(t)),Object(I.fromJS)({})),a=e.getIn(u()(r=["meta","paths"]).call(r,i()(t)),Object(I.fromJS)({})),s=ke(e,t),c=o.get("parameters")||new I.List,l=a.get("consumes_value")?a.get("consumes_value"):Ae(c,"file")?"multipart/form-data":Ae(c,"formData")?"application/x-www-form-urlencoded":void 0;return Object(I.fromJS)({requestContentType:l,responseContentType:s})}function ke(e,t){var n,r;t=t||[];var o=z(e).getIn(u()(n=["paths"]).call(n,i()(t)),null);if(null!==o){var a=e.getIn(u()(r=["meta","paths"]).call(r,i()(t),["produces_value"]),null),s=o.getIn(["produces",0],null);return a||s||"application/json"}}function je(e,t){var n;t=t||[];var r=z(e),a=r.getIn(u()(n=["paths"]).call(n,i()(t)),null);if(null!==a){var s=t,c=o()(s,1)[0],l=a.get("produces",null),p=r.getIn(["paths",c,"produces"],null),f=r.getIn(["produces"],null);return l||p||f}}function Te(e,t){var n;t=t||[];var r=z(e),a=r.getIn(u()(n=["paths"]).call(n,i()(t)),null);if(null!==a){var s=t,c=o()(s,1)[0],l=a.get("consumes",null),p=r.getIn(["paths",c,"consumes"],null),f=r.getIn(["consumes"],null);return l||p||f}}var Ie=function(e,t,n){var r=e.get("url").match(/^([a-z][a-z0-9+\-.]*):/),o=k()(r)?r[1]:null;return e.getIn(["scheme",t,n])||e.getIn(["scheme","_defaultScheme"])||o||""},Pe=function(e,t,n){var r;return d()(r=["http","https"]).call(r,Ie(e,t,n))>-1},Ne=function(e,t){var n;t=t||[];var r=e.getIn(u()(n=["meta","paths"]).call(n,i()(t),["parameters"]),Object(I.fromJS)([])),o=!0;return f()(r).call(r,(function(e){var t=e.get("errors");t&&t.count()&&(o=!1)})),o},Me=function(e,t){var n,r,o={requestBody:!1,requestContentType:{}},a=e.getIn(u()(n=["resolvedSubtrees","paths"]).call(n,i()(t),["requestBody"]),Object(I.fromJS)([]));return a.size<1||(a.getIn(["required"])&&(o.requestBody=a.getIn(["required"])),f()(r=a.getIn(["content"]).entrySeq()).call(r,(function(e){var t=e[0];if(e[1].getIn(["schema","required"])){var n=e[1].getIn(["schema","required"]).toJS();o.requestContentType[t]=n}}))),o},Re=function(e,t,n,r){var o;if((n||r)&&n===r)return!0;var a=e.getIn(u()(o=["resolvedSubtrees","paths"]).call(o,i()(t),["requestBody","content"]),Object(I.fromJS)([]));if(a.size<2||!n||!r)return!1;var s=a.getIn([n,"schema","properties"],Object(I.fromJS)([])),c=a.getIn([r,"schema","properties"],Object(I.fromJS)([]));return!!s.equals(c)};function De(e){return I.Map.isMap(e)?e:new I.Map}},function(e,t,n){"use strict";(function(t){var r=n(894),o=n(895),a=/^[A-Za-z][A-Za-z0-9+-.]*:\/\//,i=/^([a-z][a-z0-9.+-]*:)?(\/\/)?([\\/]+)?([\S\s]*)/i,s=new RegExp("^[\\x09\\x0A\\x0B\\x0C\\x0D\\x20\\xA0\\u1680\\u180E\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200A\\u202F\\u205F\\u3000\\u2028\\u2029\\uFEFF]+");function u(e){return(e||"").toString().replace(s,"")}var c=[["#","hash"],["?","query"],function(e,t){return f(t.protocol)?e.replace(/\\/g,"/"):e},["/","pathname"],["@","auth",1],[NaN,"host",void 0,1,1],[/:(\d+)$/,"port",void 0,1],[NaN,"hostname",void 0,1,1]],l={hash:1,query:1};function p(e){var n,r=("undefined"!=typeof window?window:void 0!==t?t:"undefined"!=typeof self?self:{}).location||{},o={},i=typeof(e=e||r);if("blob:"===e.protocol)o=new d(unescape(e.pathname),{});else if("string"===i)for(n in o=new d(e,{}),l)delete o[n];else if("object"===i){for(n in e)n in l||(o[n]=e[n]);void 0===o.slashes&&(o.slashes=a.test(e.href))}return o}function f(e){return"file:"===e||"ftp:"===e||"http:"===e||"https:"===e||"ws:"===e||"wss:"===e}function h(e,t){e=u(e),t=t||{};var n,r=i.exec(e),o=r[1]?r[1].toLowerCase():"",a=!!r[2],s=!!r[3],c=0;return a?s?(n=r[2]+r[3]+r[4],c=r[2].length+r[3].length):(n=r[2]+r[4],c=r[2].length):s?(n=r[3]+r[4],c=r[3].length):n=r[4],"file:"===o?c>=2&&(n=n.slice(2)):f(o)?n=r[4]:o?a&&(n=n.slice(2)):c>=2&&f(t.protocol)&&(n=r[4]),{protocol:o,slashes:a||f(o),slashesCount:c,rest:n}}function d(e,t,n){if(e=u(e),!(this instanceof d))return new d(e,t,n);var a,i,s,l,m,v,g=c.slice(),y=typeof t,b=this,_=0;for("object"!==y&&"string"!==y&&(n=t,t=null),n&&"function"!=typeof n&&(n=o.parse),a=!(i=h(e||"",t=p(t))).protocol&&!i.slashes,b.slashes=i.slashes||a&&t.slashes,b.protocol=i.protocol||t.protocol||"",e=i.rest,("file:"===b.protocol||!i.slashes&&(i.protocol||i.slashesCount<2||!f(b.protocol)))&&(g[3]=[/(.*)/,"pathname"]);_=4?[t[0],t[1],t[2],t[3],"".concat(t[0],".").concat(t[1]),"".concat(t[0],".").concat(t[2]),"".concat(t[0],".").concat(t[3]),"".concat(t[1],".").concat(t[0]),"".concat(t[1],".").concat(t[2]),"".concat(t[1],".").concat(t[3]),"".concat(t[2],".").concat(t[0]),"".concat(t[2],".").concat(t[1]),"".concat(t[2],".").concat(t[3]),"".concat(t[3],".").concat(t[0]),"".concat(t[3],".").concat(t[1]),"".concat(t[3],".").concat(t[2]),"".concat(t[0],".").concat(t[1],".").concat(t[2]),"".concat(t[0],".").concat(t[1],".").concat(t[3]),"".concat(t[0],".").concat(t[2],".").concat(t[1]),"".concat(t[0],".").concat(t[2],".").concat(t[3]),"".concat(t[0],".").concat(t[3],".").concat(t[1]),"".concat(t[0],".").concat(t[3],".").concat(t[2]),"".concat(t[1],".").concat(t[0],".").concat(t[2]),"".concat(t[1],".").concat(t[0],".").concat(t[3]),"".concat(t[1],".").concat(t[2],".").concat(t[0]),"".concat(t[1],".").concat(t[2],".").concat(t[3]),"".concat(t[1],".").concat(t[3],".").concat(t[0]),"".concat(t[1],".").concat(t[3],".").concat(t[2]),"".concat(t[2],".").concat(t[0],".").concat(t[1]),"".concat(t[2],".").concat(t[0],".").concat(t[3]),"".concat(t[2],".").concat(t[1],".").concat(t[0]),"".concat(t[2],".").concat(t[1],".").concat(t[3]),"".concat(t[2],".").concat(t[3],".").concat(t[0]),"".concat(t[2],".").concat(t[3],".").concat(t[1]),"".concat(t[3],".").concat(t[0],".").concat(t[1]),"".concat(t[3],".").concat(t[0],".").concat(t[2]),"".concat(t[3],".").concat(t[1],".").concat(t[0]),"".concat(t[3],".").concat(t[1],".").concat(t[2]),"".concat(t[3],".").concat(t[2],".").concat(t[0]),"".concat(t[3],".").concat(t[2],".").concat(t[1]),"".concat(t[0],".").concat(t[1],".").concat(t[2],".").concat(t[3]),"".concat(t[0],".").concat(t[1],".").concat(t[3],".").concat(t[2]),"".concat(t[0],".").concat(t[2],".").concat(t[1],".").concat(t[3]),"".concat(t[0],".").concat(t[2],".").concat(t[3],".").concat(t[1]),"".concat(t[0],".").concat(t[3],".").concat(t[1],".").concat(t[2]),"".concat(t[0],".").concat(t[3],".").concat(t[2],".").concat(t[1]),"".concat(t[1],".").concat(t[0],".").concat(t[2],".").concat(t[3]),"".concat(t[1],".").concat(t[0],".").concat(t[3],".").concat(t[2]),"".concat(t[1],".").concat(t[2],".").concat(t[0],".").concat(t[3]),"".concat(t[1],".").concat(t[2],".").concat(t[3],".").concat(t[0]),"".concat(t[1],".").concat(t[3],".").concat(t[0],".").concat(t[2]),"".concat(t[1],".").concat(t[3],".").concat(t[2],".").concat(t[0]),"".concat(t[2],".").concat(t[0],".").concat(t[1],".").concat(t[3]),"".concat(t[2],".").concat(t[0],".").concat(t[3],".").concat(t[1]),"".concat(t[2],".").concat(t[1],".").concat(t[0],".").concat(t[3]),"".concat(t[2],".").concat(t[1],".").concat(t[3],".").concat(t[0]),"".concat(t[2],".").concat(t[3],".").concat(t[0],".").concat(t[1]),"".concat(t[2],".").concat(t[3],".").concat(t[1],".").concat(t[0]),"".concat(t[3],".").concat(t[0],".").concat(t[1],".").concat(t[2]),"".concat(t[3],".").concat(t[0],".").concat(t[2],".").concat(t[1]),"".concat(t[3],".").concat(t[1],".").concat(t[0],".").concat(t[2]),"".concat(t[3],".").concat(t[1],".").concat(t[2],".").concat(t[0]),"".concat(t[3],".").concat(t[2],".").concat(t[0],".").concat(t[1]),"".concat(t[3],".").concat(t[2],".").concat(t[1],".").concat(t[0])]:void 0),g[r]}function b(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=arguments.length>2?arguments[2]:void 0,r=e.filter((function(e){return"token"!==e})),o=y(r);return o.reduce((function(e,t){return f()({},e,n[t])}),t)}function _(e){return e.join(" ")}function w(e){var t=e.node,n=e.stylesheet,r=e.style,o=void 0===r?{}:r,a=e.useInlineStyles,i=e.key,s=t.properties,u=t.type,c=t.tagName,l=t.value;if("text"===u)return l;if(c){var p,h=function(e,t){var n=0;return function(r){return n+=1,r.map((function(r,o){return w({node:r,stylesheet:e,useInlineStyles:t,key:"code-segment-".concat(n,"-").concat(o)})}))}}(n,a);if(a){var m=Object.keys(n).reduce((function(e,t){return t.split(".").forEach((function(t){e.includes(t)||e.push(t)})),e}),[]),g=s.className&&s.className.includes("token")?["token"]:[],y=s.className&&g.concat(s.className.filter((function(e){return!m.includes(e)})));p=f()({},s,{className:_(y)||void 0,style:b(s.className,Object.assign({},s.style,o),n)})}else p=f()({},s,{className:_(s.className)});var x=h(t.children);return d.a.createElement(c,v()({key:i},p),x)}}var x=/\n/g;function E(e){var t=e.codeString,n=e.codeStyle,r=e.containerStyle,o=void 0===r?{float:"left",paddingRight:"10px"}:r,a=e.numberStyle,i=void 0===a?{}:a,s=e.startingLineNumber;return d.a.createElement("code",{style:Object.assign({},n,o)},function(e){var t=e.lines,n=e.startingLineNumber,r=e.style;return t.map((function(e,t){var o=t+n;return d.a.createElement("span",{key:"line-".concat(t),className:"react-syntax-highlighter-line-number",style:"function"==typeof r?r(o):r},"".concat(o,"\n"))}))}({lines:t.replace(/\n$/,"").split("\n"),style:i,startingLineNumber:s}))}function S(e,t){return{type:"element",tagName:"span",properties:{key:"line-number--".concat(e),className:["comment","linenumber","react-syntax-highlighter-line-number"],style:t},children:[{type:"text",value:e}]}}function C(e,t,n){var r,o={display:"inline-block",minWidth:(r=n,"".concat(r.toString().length,".25em")),paddingRight:"1em",textAlign:"right",userSelect:"none"},a="function"==typeof e?e(t):e;return f()({},o,a)}function A(e){var t=e.children,n=e.lineNumber,r=e.lineNumberStyle,o=e.largestLineNumber,a=e.showInlineLineNumbers,i=e.lineProps,s=void 0===i?{}:i,u=e.className,c=void 0===u?[]:u,l=e.showLineNumbers,p=e.wrapLongLines,h="function"==typeof s?s(n):s;if(h.className=c,n&&a){var d=C(r,n,o);t.unshift(S(n,d))}return p&l&&(h.style=f()({},h.style,{display:"flex"})),{type:"element",tagName:"span",properties:h,children:t}}function O(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],r=0;r2&&void 0!==arguments[2]?arguments[2]:[];return A({children:e,lineNumber:t,lineNumberStyle:s,largestLineNumber:i,showInlineLineNumbers:o,lineProps:n,className:a,showLineNumbers:r,wrapLongLines:u})}function m(e,t){if(r&&t&&o){var n=C(s,t,i);e.unshift(S(t,n))}return e}function v(e,n){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[];return t||r.length>0?d(e,n,r):m(e,n)}for(var g=function(){var e=l[h],t=e.children[0].value;if(t.match(x)){var n=t.split("\n");n.forEach((function(t,o){var i=r&&p.length+a,s={type:"text",value:"".concat(t,"\n")};if(0===o){var u=v(l.slice(f+1,h).concat(A({children:[s],className:e.properties.className})),i);p.push(u)}else if(o===n.length-1){if(l[h+1]&&l[h+1].children&&l[h+1].children[0]){var c=A({children:[{type:"text",value:"".concat(t)}],className:e.properties.className});l.splice(h+1,0,c)}else{var d=v([s],i,e.properties.className);p.push(d)}}else{var m=v([s],i,e.properties.className);p.push(m)}})),f=h}h++};h .hljs-title":{color:"#88C0D0"},"hljs-keyword":{color:"#81A1C1"},"hljs-literal":{color:"#81A1C1"},"hljs-symbol":{color:"#81A1C1"},"hljs-number":{color:"#B48EAD"},"hljs-regexp":{color:"#EBCB8B"},"hljs-string":{color:"#A3BE8C"},"hljs-title":{color:"#8FBCBB"},"hljs-params":{color:"#D8DEE9"},"hljs-bullet":{color:"#81A1C1"},"hljs-code":{color:"#8FBCBB"},"hljs-emphasis":{fontStyle:"italic"},"hljs-formula":{color:"#8FBCBB"},"hljs-strong":{fontWeight:"bold"},"hljs-link:hover":{textDecoration:"underline"},"hljs-quote":{color:"#4C566A"},"hljs-comment":{color:"#4C566A"},"hljs-doctag":{color:"#8FBCBB"},"hljs-meta":{color:"#5E81AC"},"hljs-meta-keyword":{color:"#5E81AC"},"hljs-meta-string":{color:"#A3BE8C"},"hljs-attr":{color:"#8FBCBB"},"hljs-attribute":{color:"#D8DEE9"},"hljs-builtin-name":{color:"#81A1C1"},"hljs-name":{color:"#81A1C1"},"hljs-section":{color:"#88C0D0"},"hljs-tag":{color:"#81A1C1"},"hljs-variable":{color:"#D8DEE9"},"hljs-template-variable":{color:"#D8DEE9"},"hljs-template-tag":{color:"#5E81AC"},"abnf .hljs-attribute":{color:"#88C0D0"},"abnf .hljs-symbol":{color:"#EBCB8B"},"apache .hljs-attribute":{color:"#88C0D0"},"apache .hljs-section":{color:"#81A1C1"},"arduino .hljs-built_in":{color:"#88C0D0"},"aspectj .hljs-meta":{color:"#D08770"},"aspectj > .hljs-title":{color:"#88C0D0"},"bnf .hljs-attribute":{color:"#8FBCBB"},"clojure .hljs-name":{color:"#88C0D0"},"clojure .hljs-symbol":{color:"#EBCB8B"},"coq .hljs-built_in":{color:"#88C0D0"},"cpp .hljs-meta-string":{color:"#8FBCBB"},"css .hljs-built_in":{color:"#88C0D0"},"css .hljs-keyword":{color:"#D08770"},"diff .hljs-meta":{color:"#8FBCBB"},"ebnf .hljs-attribute":{color:"#8FBCBB"},"glsl .hljs-built_in":{color:"#88C0D0"},"groovy .hljs-meta:not(:first-child)":{color:"#D08770"},"haxe .hljs-meta":{color:"#D08770"},"java .hljs-meta":{color:"#D08770"},"ldif .hljs-attribute":{color:"#8FBCBB"},"lisp .hljs-name":{color:"#88C0D0"},"lua .hljs-built_in":{color:"#88C0D0"},"moonscript .hljs-built_in":{color:"#88C0D0"},"nginx .hljs-attribute":{color:"#88C0D0"},"nginx .hljs-section":{color:"#5E81AC"},"pf .hljs-built_in":{color:"#88C0D0"},"processing .hljs-built_in":{color:"#88C0D0"},"scss .hljs-keyword":{color:"#81A1C1"},"stylus .hljs-keyword":{color:"#81A1C1"},"swift .hljs-meta":{color:"#D08770"},"vim .hljs-built_in":{color:"#88C0D0",fontStyle:"italic"},"yaml .hljs-meta":{color:"#D08770"}},obsidian:{hljs:{display:"block",overflowX:"auto",padding:"0.5em",background:"#282b2e",color:"#e0e2e4"},"hljs-keyword":{color:"#93c763",fontWeight:"bold"},"hljs-selector-tag":{color:"#93c763",fontWeight:"bold"},"hljs-literal":{color:"#93c763",fontWeight:"bold"},"hljs-selector-id":{color:"#93c763"},"hljs-number":{color:"#ffcd22"},"hljs-attribute":{color:"#668bb0"},"hljs-code":{color:"white"},"hljs-class .hljs-title":{color:"white"},"hljs-section":{color:"white",fontWeight:"bold"},"hljs-regexp":{color:"#d39745"},"hljs-link":{color:"#d39745"},"hljs-meta":{color:"#557182"},"hljs-tag":{color:"#8cbbad"},"hljs-name":{color:"#8cbbad",fontWeight:"bold"},"hljs-bullet":{color:"#8cbbad"},"hljs-subst":{color:"#8cbbad"},"hljs-emphasis":{color:"#8cbbad"},"hljs-type":{color:"#8cbbad",fontWeight:"bold"},"hljs-built_in":{color:"#8cbbad"},"hljs-selector-attr":{color:"#8cbbad"},"hljs-selector-pseudo":{color:"#8cbbad"},"hljs-addition":{color:"#8cbbad"},"hljs-variable":{color:"#8cbbad"},"hljs-template-tag":{color:"#8cbbad"},"hljs-template-variable":{color:"#8cbbad"},"hljs-string":{color:"#ec7600"},"hljs-symbol":{color:"#ec7600"},"hljs-comment":{color:"#818e96"},"hljs-quote":{color:"#818e96"},"hljs-deletion":{color:"#818e96"},"hljs-selector-class":{color:"#A082BD"},"hljs-doctag":{fontWeight:"bold"},"hljs-title":{fontWeight:"bold"},"hljs-strong":{fontWeight:"bold"}},"tomorrow-night":{"hljs-comment":{color:"#969896"},"hljs-quote":{color:"#969896"},"hljs-variable":{color:"#cc6666"},"hljs-template-variable":{color:"#cc6666"},"hljs-tag":{color:"#cc6666"},"hljs-name":{color:"#cc6666"},"hljs-selector-id":{color:"#cc6666"},"hljs-selector-class":{color:"#cc6666"},"hljs-regexp":{color:"#cc6666"},"hljs-deletion":{color:"#cc6666"},"hljs-number":{color:"#de935f"},"hljs-built_in":{color:"#de935f"},"hljs-builtin-name":{color:"#de935f"},"hljs-literal":{color:"#de935f"},"hljs-type":{color:"#de935f"},"hljs-params":{color:"#de935f"},"hljs-meta":{color:"#de935f"},"hljs-link":{color:"#de935f"},"hljs-attribute":{color:"#f0c674"},"hljs-string":{color:"#b5bd68"},"hljs-symbol":{color:"#b5bd68"},"hljs-bullet":{color:"#b5bd68"},"hljs-addition":{color:"#b5bd68"},"hljs-title":{color:"#81a2be"},"hljs-section":{color:"#81a2be"},"hljs-keyword":{color:"#b294bb"},"hljs-selector-tag":{color:"#b294bb"},hljs:{display:"block",overflowX:"auto",background:"#1d1f21",color:"#c5c8c6",padding:"0.5em"},"hljs-emphasis":{fontStyle:"italic"},"hljs-strong":{fontWeight:"bold"}}},Q=o()(X),ee=function(e){return i()(Q).call(Q,e)?X[e]:(console.warn("Request style '".concat(e,"' is not available, returning default instead")),Z)}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.File=t.Blob=t.FormData=void 0;const r="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:window;t.FormData=r.FormData,t.Blob=r.Blob,t.File=r.File},function(e,t){e.exports=!0},function(e,t,n){var r=n(239),o=n(69).f,a=n(68),i=n(56),s=n(548),u=n(41)("toStringTag");e.exports=function(e,t,n,c){if(e){var l=n?e:e.prototype;i(l,u)||o(l,u,{configurable:!0,value:t}),c&&!r&&a(l,"toString",s)}}},function(e,t,n){var r=n(239),o=n(153),a=n(41)("toStringTag"),i="Arguments"==o(function(){return arguments}());e.exports=r?o:function(e){var t,n,r;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(n=function(e,t){try{return e[t]}catch(e){}}(t=Object(e),a))?n:i?o(t):"Object"==(r=o(t))&&"function"==typeof t.callee?"Arguments":r}},function(e,t,n){"use strict";e.exports=function(e){if("function"!=typeof e)throw new TypeError(e+" is not a function");return e}},function(e,t,n){e.exports=n(679)},function(e,t,n){"use strict";function r(e){return function(e){try{return!!JSON.parse(e)}catch(e){return null}}(e)?"json":null}n.d(t,"a",(function(){return r}))},function(e,t,n){"use strict";n.r(t),n.d(t,"UPDATE_LAYOUT",(function(){return o})),n.d(t,"UPDATE_FILTER",(function(){return a})),n.d(t,"UPDATE_MODE",(function(){return i})),n.d(t,"SHOW",(function(){return s})),n.d(t,"updateLayout",(function(){return u})),n.d(t,"updateFilter",(function(){return c})),n.d(t,"show",(function(){return l})),n.d(t,"changeMode",(function(){return p}));var r=n(5),o="layout_update_layout",a="layout_update_filter",i="layout_update_mode",s="layout_show";function u(e){return{type:o,payload:e}}function c(e){return{type:a,payload:e}}function l(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return e=Object(r.v)(e),{type:s,payload:{thing:e,shown:t}}}function p(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";return e=Object(r.v)(e),{type:i,payload:{thing:e,mode:t}}}},function(e,t,n){var r=n(422),o=n(163),a=n(194),i=n(50),s=n(116),u=n(195),c=n(162),l=n(251),p=Object.prototype.hasOwnProperty;e.exports=function(e){if(null==e)return!0;if(s(e)&&(i(e)||"string"==typeof e||"function"==typeof e.splice||u(e)||l(e)||a(e)))return!e.length;var t=o(e);if("[object Map]"==t||"[object Set]"==t)return!e.size;if(c(e))return!r(e).length;for(var n in e)if(p.call(e,n))return!1;return!0}},function(e,t){e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},function(e,t){e.exports=function(e){if(null==e)throw TypeError("Can't call method on "+e);return e}},function(e,t,n){var r=n(77);e.exports=function(e,t,n){if(r(e),void 0===t)return e;switch(n){case 0:return function(){return e.call(t)};case 1:return function(n){return e.call(t,n)};case 2:return function(n,r){return e.call(t,n,r)};case 3:return function(n,r,o){return e.call(t,n,r,o)}}return function(){return e.apply(t,arguments)}}},function(e,t,n){var r=n(71);e.exports=r("navigator","userAgent")||""},function(e,t,n){var r,o=n(53),a=n(232),i=n(235),s=n(158),u=n(367),c=n(227),l=n(183),p=l("IE_PROTO"),f=function(){},h=function(e){return"