mirror of
https://github.com/Telecominfraproject/wlan-cloud-rrm.git
synced 2025-10-29 09:42:22 +00:00
Initial commit
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -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
|
||||
70
ALGORITHMS.md
Normal file
70
ALGORITHMS.md
Normal file
@@ -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)`.
|
||||
|
||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@@ -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"]
|
||||
95
IMPLEMENTATION.md
Normal file
95
IMPLEMENTATION.md
Normal file
@@ -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
|
||||
{
|
||||
"<zone-name>": ["<serialNumber1>", "<serialNumber2>"],
|
||||
"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/<method>` - 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
|
||||
39
LICENSE
39
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.
|
||||
|
||||
50
README.md
Normal file
50
README.md
Normal file
@@ -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/
|
||||
159
pom.xml
Normal file
159
pom.xml
Normal file
@@ -0,0 +1,159 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.facebook</groupId>
|
||||
<artifactId>openwifi-rrm</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<properties>
|
||||
<java.version>11</java.version>
|
||||
<slf4j.version>1.7.32</slf4j.version>
|
||||
<junit.version>5.7.2</junit.version>
|
||||
<mainClassName>com.facebook.openwifirrm.Launcher</mainClassName>
|
||||
</properties>
|
||||
<build>
|
||||
<finalName>openwifi-rrm</finalName>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<includes>
|
||||
<include>**/*</include>
|
||||
</includes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.0</version>
|
||||
<configuration>
|
||||
<source>${java.version}</source>
|
||||
<target>${java.version}</target>
|
||||
<encoding>UTF-8</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.22.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.1</version>
|
||||
<configuration>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>**/*.md</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>log4j:log4j</artifact>
|
||||
<excludes>
|
||||
<!-- CVE-2021-4104 -->
|
||||
<exclude>org/apache/log4j/net/JMSAppender.class</exclude>
|
||||
<!-- CVE-2019-17571 -->
|
||||
<exclude>org/apache/log4j/net/SocketServer.class</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Main-Class>${mainClassName}</Main-Class>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>${slf4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-log4j12</artifactId>
|
||||
<version>${slf4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-engine</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>info.picocli</groupId>
|
||||
<artifactId>picocli</artifactId>
|
||||
<version>4.6.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20210307</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.8.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.konghq</groupId>
|
||||
<artifactId>unirest-java</artifactId>
|
||||
<version>3.11.09</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.kafka</groupId>
|
||||
<artifactId>kafka-clients</artifactId>
|
||||
<version>2.8.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>mysql</groupId>
|
||||
<artifactId>mysql-connector-java</artifactId>
|
||||
<version>8.0.26</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
<version>4.0.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.sparkjava</groupId>
|
||||
<artifactId>spark-core</artifactId>
|
||||
<version>2.9.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-jaxrs2</artifactId>
|
||||
<version>2.1.10</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>javax.ws.rs</groupId>
|
||||
<artifactId>javax.ws.rs-api</artifactId>
|
||||
<version>2.1.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.reflections</groupId>
|
||||
<artifactId>reflections</artifactId>
|
||||
<version>0.10.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
129
src/main/java/com/facebook/openwifirrm/DeviceConfig.java
Normal file
129
src/main/java/com/facebook/openwifirrm/DeviceConfig.java
Normal file
@@ -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.
|
||||
* <p>
|
||||
* Implementation notes:
|
||||
* <ul>
|
||||
* <li>Fields must be nullable (no primitives, use boxed types instead).</li>
|
||||
* <li>This currently does not support merging of nested structures.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<Integer> location;
|
||||
|
||||
/**
|
||||
* The list of allowed channels, or null to allow all
|
||||
* (map from band to channel)
|
||||
*/
|
||||
public Map<String, List<Integer>> allowedChannels;
|
||||
|
||||
/**
|
||||
* The list of allowed channel widths, or null to allow all
|
||||
* (map from band to channel)
|
||||
*/
|
||||
public Map<String, List<Integer>> allowedChannelWidths;
|
||||
|
||||
/** The RRM-assigned channels to use (map from radio to channel) */
|
||||
public Map<String, Integer> autoChannels;
|
||||
|
||||
/**
|
||||
* The user-assigned channels to use, overriding "autoChannels"
|
||||
* (map from band to channel)
|
||||
*/
|
||||
public Map<String, Integer> userChannels;
|
||||
|
||||
/**
|
||||
* The list of allowed tx powers, or null to allow all
|
||||
* (map from band to tx power)
|
||||
*/
|
||||
public Map<String, List<Integer>> allowedTxPowers;
|
||||
|
||||
/** The RRM-assigned tx powers to use (map from radio to tx power) */
|
||||
public Map<String, Integer> autoTxPowers;
|
||||
|
||||
/** The user-assigned tx powers to use, overriding "autoTxPowers" */
|
||||
public Map<String, Integer> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
536
src/main/java/com/facebook/openwifirrm/DeviceDataManager.java
Normal file
536
src/main/java/com/facebook/openwifirrm/DeviceDataManager.java
Normal file
@@ -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<String, DeviceConfig> 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<String, String> deviceToZone = new HashMap<>();
|
||||
for (Map.Entry<String, Set<String>> 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<String, Set<String>> 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<String, DeviceConfig> getAllDeviceConfigs(String zone) {
|
||||
// Get all devices in zone
|
||||
if (zone == null || zone.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Set<String> 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<String, DeviceConfig> 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<String, DeviceConfig> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, DeviceConfig> apConfig = new TreeMap<>();
|
||||
|
||||
/** Config per "RF zone" - mid priority */
|
||||
public Map<String, DeviceConfig> zoneConfig = new TreeMap<>();
|
||||
|
||||
/** Config for all APs/zones - lowest priority */
|
||||
public DeviceConfig networkConfig = new DeviceConfig();
|
||||
}
|
||||
25
src/main/java/com/facebook/openwifirrm/DeviceTopology.java
Normal file
25
src/main/java/com/facebook/openwifirrm/DeviceTopology.java
Normal file
@@ -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<String, Set<String>> {
|
||||
private static final long serialVersionUID = -1636132862513920700L;
|
||||
|
||||
@Hidden /* prevent Jackson object mapper from generating "empty" property */
|
||||
@Override public boolean isEmpty() { return super.isEmpty(); }
|
||||
}
|
||||
243
src/main/java/com/facebook/openwifirrm/Launcher.java
Normal file
243
src/main/java/com/facebook/openwifirrm/Launcher.java
Normal file
@@ -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<Integer> {
|
||||
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 = "<FILE>",
|
||||
description = "RRM config file"
|
||||
)
|
||||
File configFile,
|
||||
|
||||
@Option(
|
||||
names = { "-t", "--topology-file" },
|
||||
paramLabel = "<FILE>",
|
||||
description = "Device topology file"
|
||||
)
|
||||
File topologyFile,
|
||||
|
||||
@Option(
|
||||
names = { "-d", "--device-config-file" },
|
||||
paramLabel = "<FILE>",
|
||||
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 = "<FILE>",
|
||||
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);
|
||||
}
|
||||
}
|
||||
140
src/main/java/com/facebook/openwifirrm/RRM.java
Normal file
140
src/main/java/com/facebook/openwifirrm/RRM.java
Normal file
@@ -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<Object>}.
|
||||
*
|
||||
* This is similar to {@link Executors#callable(Runnable)} but will log any
|
||||
* exceptions thrown and then call {@link System#exit(int)}.
|
||||
*/
|
||||
private static Callable<Object> 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<Callable<Object>> 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;
|
||||
}
|
||||
}
|
||||
185
src/main/java/com/facebook/openwifirrm/RRMConfig.java
Normal file
185
src/main/java/com/facebook/openwifirrm/RRMConfig.java
Normal file
@@ -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();
|
||||
}
|
||||
110
src/main/java/com/facebook/openwifirrm/Utils.java
Normal file
110
src/main/java/com/facebook/openwifirrm/Utils.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
964
src/main/java/com/facebook/openwifirrm/modules/ApiServer.java
Normal file
964
src/main/java/com/facebook/openwifirrm/modules/ApiServer.java
Normal file
@@ -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 <base64(<user>:<password>)>
|
||||
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<Class<?>> 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<String, Map<String, Integer>> data;
|
||||
public ChannelAllocation(Map<String, Map<String, Integer>> 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<String, Map<String, Integer>> 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<String, Map<String, Integer>> data;
|
||||
public TxPowerAllocation(Map<String, Map<String, Integer>> 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<String, Map<String, Integer>> txPowerMap =
|
||||
optimizer.computeTxPowerMap();
|
||||
if (!dryRun) {
|
||||
optimizer.applyConfig(
|
||||
deviceDataManager, configManager, txPowerMap
|
||||
);
|
||||
}
|
||||
|
||||
response.type(MediaType.APPLICATION_JSON);
|
||||
return gson.toJson(new TxPowerAllocation(txPowerMap));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, DeviceData> 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<String, ConfigListener> 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<DeviceWithStatus> 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<String> 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<String, Integer> 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<String, Integer> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, DeviceData> 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<String, DataListener> 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<KafkaRecord> records) {
|
||||
handleKafkaStateRecords(records);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleWifiScanRecords(
|
||||
List<KafkaRecord> 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<DeviceWithStatus> 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<DeviceWithStatus> 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<DeviceWithStatus> 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<WifiScanEntry> 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<Integer> 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<WifiScanEntry> 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<KafkaRecord> 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<StateRecord> 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.<interface_name>.<counter_name>"
|
||||
// - store all entries from "ssids.<N>.associations.<M>" as
|
||||
// "interface.<interface_name>.bssid.<bssid>.client.<bssid>.<counter_name>"
|
||||
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<String, JsonElement> 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<String, JsonElement> 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.<N>.<counter_name>"
|
||||
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.<counter_name>"
|
||||
// - "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<StateRecord> parseStateRecords(List<KafkaRecord> records) {
|
||||
List<StateRecord> 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<KafkaRecord> records) {
|
||||
if (dbManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<StateRecord> 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);
|
||||
}
|
||||
}
|
||||
387
src/main/java/com/facebook/openwifirrm/modules/Modeler.java
Normal file
387
src/main/java/com/facebook/openwifirrm/modules/Modeler.java
Normal file
@@ -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<KafkaRecord> records;
|
||||
|
||||
/** Constructor. */
|
||||
public InputData(InputDataType type, List<KafkaRecord> records) {
|
||||
this.type = type;
|
||||
this.records = records;
|
||||
}
|
||||
}
|
||||
|
||||
/** The blocking data queue. */
|
||||
private final BlockingQueue<InputData> 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<String, List<List<WifiScanEntry>>> latestWifiScans =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** List of latest state per device. */
|
||||
public Map<String, State> latestState = new ConcurrentHashMap<>();
|
||||
|
||||
/** List of radio info per device. */
|
||||
public Map<String, JsonArray> latestDeviceStatus = new ConcurrentHashMap<>();
|
||||
|
||||
/** List of capabilities per device. */
|
||||
public Map<String, JsonObject> 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<KafkaRecord> records) {
|
||||
dataQueue.offer(
|
||||
new InputData(InputDataType.STATE, records)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleWifiScanRecords(
|
||||
List<KafkaRecord> 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<DeviceWithStatus> 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<List<WifiScanEntry>> wifiScanList =
|
||||
dataModel.latestWifiScans.computeIfAbsent(
|
||||
record.serialNumber,
|
||||
k -> new LinkedList<>()
|
||||
);
|
||||
|
||||
// Parse and validate this record
|
||||
List<WifiScanEntry> 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<String> newRadioBandsSet = config.getRadioBandsSet(newRadioList);
|
||||
JsonArray oldRadioList = dataModel.latestDeviceStatus
|
||||
.put(serialNumber, newRadioList);
|
||||
Set<String> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java
Normal file
202
src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java
Normal file
@@ -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<Double> apLocX,
|
||||
List<Double> apLocY,
|
||||
List<Double> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<StateRecord> 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<String, State> getLatestState() throws SQLException {
|
||||
if (ds == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, State> ret = new HashMap<>();
|
||||
try (Connection conn = getConnection()) {
|
||||
// Fetch latest (device, timestamp) records
|
||||
Map<String, Timestamp> 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<String, Timestamp> e : deviceToTs.entrySet()) {
|
||||
String serial = e.getKey();
|
||||
Timestamp time = e.getValue();
|
||||
stmt.setString(1, serial);
|
||||
stmt.setTimestamp(2, time);
|
||||
|
||||
List<StateRecord> 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<StateRecord> records, long ts) {
|
||||
State state = new State();
|
||||
state.unit = state.new Unit();
|
||||
state.unit.localtime = ts;
|
||||
|
||||
// Parse each record
|
||||
Map<String, JsonObject> interfaces = new TreeMap<>();
|
||||
TreeMap<Integer, JsonObject> 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.<N>.associations.<M>
|
||||
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<Integer, JsonObject> 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<WifiScanEntry> 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<Long, List<WifiScanEntry>> 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<Long, List<WifiScanEntry>> ret = new TreeMap<>();
|
||||
try (Connection conn = getConnection()) {
|
||||
// Fetch latest N scan IDs
|
||||
Map<Long, Long> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Integer> 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<String, Integer> 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<String, List<Integer>> 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<Integer, List<Integer>> 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<Integer, List<Integer>> 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<Integer> 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<String, DeviceConfig> 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<Integer> 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<Integer> 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<String, List<WifiScanEntry>> getDeviceToWiFiScans(
|
||||
String band,
|
||||
Map<String, List<List<WifiScanEntry>>> latestWifiScans,
|
||||
Map<String, List<String>> bandsMap
|
||||
) {
|
||||
Map<String, List<WifiScanEntry>> deviceToWifiScans = new HashMap<>();
|
||||
int maxChannel = UPPER_CHANNEL_LIMIT.get(band);
|
||||
int minChannel = LOWER_CHANNEL_LIMIT.get(band);
|
||||
|
||||
for (
|
||||
Map.Entry<String, List<List<WifiScanEntry>>> 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<List<WifiScanEntry>> 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<WifiScanEntry> scanResps = wifiScanList.get(wifiScanList.size() - 1);
|
||||
List<WifiScanEntry> scanRespsFiltered = new ArrayList<WifiScanEntry>();
|
||||
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<Integer> 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<Integer> updateAvailableChannelsList(
|
||||
String band,
|
||||
String serialNumber,
|
||||
int channelWidth,
|
||||
List<Integer> availableChannelsList
|
||||
) {
|
||||
List<Integer> 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<Integer> 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<String, Integer> tempChannelMap,
|
||||
Map<String, List<WifiScanEntry>> deviceToWifiScans,
|
||||
Map<String, String> bssidsMap,
|
||||
boolean mode
|
||||
) {
|
||||
for (Map.Entry<String, Integer> 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<String, Integer> owfSignal = new HashMap<>();
|
||||
Map<Integer, Integer> channelOccupancy = new HashMap<>();
|
||||
|
||||
// Calculate the co-channel interference
|
||||
List<WifiScanEntry> 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<String, Integer> 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<String, Integer> oldChannelMap,
|
||||
Map<String, Integer> newChannelMap,
|
||||
Map<String, List<WifiScanEntry>> deviceToWifiScans,
|
||||
Map<String, String> 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<String, Integer> 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<String, Map<String, Integer>> 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<String, Map<String, Integer>> channelMap
|
||||
) {
|
||||
// Update device AP config layer
|
||||
deviceDataManager.updateDeviceApConfig(apConfig -> {
|
||||
for (
|
||||
Map.Entry<String, Map<String, Integer>> entry :
|
||||
channelMap.entrySet()
|
||||
) {
|
||||
DeviceConfig deviceConfig = apConfig.computeIfAbsent(
|
||||
entry.getKey(), k -> new DeviceConfig()
|
||||
);
|
||||
deviceConfig.autoChannels = entry.getValue();
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger config update now
|
||||
configManager.wakeUp();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<String> getSortedAPs(
|
||||
Map<String, List<WifiScanEntry>> 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<Integer, Integer> getOccupiedOverlapChannels(
|
||||
Map<Integer, Integer> occupiedChannels
|
||||
) {
|
||||
int maxChannel = UPPER_CHANNEL_LIMIT.get(BAND_2G);
|
||||
int minChannel = LOWER_CHANNEL_LIMIT.get(BAND_2G);
|
||||
Map<Integer, Integer> 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<WifiScanEntry> getScanRespsByBandwidth(
|
||||
String band,
|
||||
String serialNumber,
|
||||
int channelWidth,
|
||||
Map<String, List<WifiScanEntry>> deviceToWifiScans
|
||||
) {
|
||||
List<WifiScanEntry> 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<WifiScanEntry> scanRespsProcessed = new ArrayList<WifiScanEntry>();
|
||||
Map<Integer, Map<String, Integer>> 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<Integer, Integer> getOccupiedChannels(
|
||||
String band,
|
||||
String serialNumber,
|
||||
int channelWidth,
|
||||
List<Integer> availableChannelsList,
|
||||
Map<String, List<WifiScanEntry>> deviceToWifiScans,
|
||||
Map<String, Map<String, Integer>> channelMap,
|
||||
Map<String, String> bssidsMap
|
||||
) {
|
||||
// Find occupied channels (and # associated stations)
|
||||
Map<Integer, Integer> occupiedChannels = new TreeMap<>();
|
||||
List<WifiScanEntry> 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<Integer, Integer> 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<Integer> availableChannelsList,
|
||||
int currentChannel,
|
||||
Map<Integer, Integer> 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<Integer> 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<Integer, Integer> newOccupiedChannels = new TreeMap<>();
|
||||
for (Map.Entry<Integer,Integer> e : occupiedChannels.entrySet()) {
|
||||
if (availableChannelsList.contains(e.getKey())) {
|
||||
newOccupiedChannels.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
Map.Entry<Integer, Integer> 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<Integer> priorityMap = new HashSet<>(
|
||||
PRIORITY_CHANNELS_2G
|
||||
);
|
||||
List<Integer> 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<String, Map<String, Integer>> computeChannelMap() {
|
||||
Map<String, Map<String, Integer>> channelMap = new TreeMap<>();
|
||||
Map<String, List<String>> bandsMap = UCentralUtils
|
||||
.getBandsMap(model.latestDeviceStatus);
|
||||
|
||||
Map<String, Map<String, List<Integer>>> deviceAvailableChannels =
|
||||
UCentralUtils.getDeviceAvailableChannels(
|
||||
model.latestDeviceStatus,
|
||||
model.latestDeviceCapabilities,
|
||||
AVAILABLE_CHANNELS_BAND
|
||||
);
|
||||
|
||||
Map<String, String> bssidsMap = UCentralUtils.getBssidsMap(model.latestState);
|
||||
|
||||
for (String band : bandsMap.keySet()) {
|
||||
// Performance metrics
|
||||
Map<String, Integer> oldChannelMap = new TreeMap<>();
|
||||
Map<String, Integer> newChannelMap = new TreeMap<>();
|
||||
|
||||
// Only use last wifi scan result for APs (TODO)
|
||||
Map<String, List<WifiScanEntry>> deviceToWifiScans = getDeviceToWiFiScans(
|
||||
band, model.latestWifiScans, bandsMap
|
||||
);
|
||||
|
||||
// Order by number of nearby APs detected in wifi scan (descending)
|
||||
List<String> sortedAPs = getSortedAPs(deviceToWifiScans);
|
||||
|
||||
// Assign channel to each AP
|
||||
for (String serialNumber : sortedAPs) {
|
||||
// Get available channels of the device
|
||||
List<Integer> 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<Integer, Integer> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<List<Integer>> getPermutationsWithRepetitions(
|
||||
List<Integer> choices,
|
||||
int n
|
||||
) {
|
||||
int choicesSize = choices.size();
|
||||
int permutationsSize = (int) Math.pow(choicesSize, n);
|
||||
List<List<Integer>> 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<Integer> runLocationBasedOptimalTPC(
|
||||
int sampleSpace,
|
||||
int numOfAPs,
|
||||
List<Double> apLocX,
|
||||
List<Double> apLocY,
|
||||
List<Integer> txPowerChoices
|
||||
) {
|
||||
// Get all the permutations with repetition
|
||||
List<List<Integer>> 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<Double> 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<String, Map<String, Integer>> computeTxPowerMap() {
|
||||
// (TODO) Only support 5G radio now
|
||||
Map<String, Map<String, Integer>> txPowerMap = new TreeMap<>();
|
||||
|
||||
int numOfAPs = 0;
|
||||
int boundary = 100;
|
||||
Map<String, Integer> validAPs = new TreeMap<>();
|
||||
List<Double> apLocX = new ArrayList<>();
|
||||
List<Double> apLocY = new ArrayList<>();
|
||||
List<Integer> 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<String, State> 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<String, List<Integer>> 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<Integer> txPowerList = LocationBasedOptimalTPC.runLocationBasedOptimalTPC(
|
||||
boundary,
|
||||
numOfAPs,
|
||||
apLocX,
|
||||
apLocY,
|
||||
txPowerChoices
|
||||
);
|
||||
|
||||
// Apply the results from the optimal TPC algorithm to the config
|
||||
for (Map.Entry<String, Integer> e : validAPs.entrySet()) {
|
||||
String serialNumber = e.getKey();
|
||||
int txPower = txPowerList.get(e.getValue());
|
||||
Map<String, Integer> radioMap = new TreeMap<>();
|
||||
radioMap.put(BAND_5G, txPower);
|
||||
txPowerMap.put(serialNumber, radioMap);
|
||||
logger.info(
|
||||
"Device {}: Assigning tx power = {}",
|
||||
serialNumber,
|
||||
txPower
|
||||
);
|
||||
}
|
||||
return txPowerMap;
|
||||
}
|
||||
}
|
||||
@@ -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<String> getManagedBSSIDs(DataModel model) {
|
||||
Set<String> managedBSSIDs = new HashSet<>();
|
||||
for (Map.Entry<String, State> 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<String, List<Integer>> buildRssiMap(
|
||||
Set<String> managedBSSIDs,
|
||||
Map<String, List<List<WifiScanEntry>>> latestWifiScans
|
||||
) {
|
||||
Map<String, List<Integer>> bssidToRssiValues = new HashMap<>();
|
||||
managedBSSIDs.stream()
|
||||
.forEach(bssid -> bssidToRssiValues.put(bssid, new ArrayList<>()));
|
||||
|
||||
for (Map.Entry<String, List<List<WifiScanEntry>>> e : latestWifiScans.entrySet()) {
|
||||
List<List<WifiScanEntry>> bufferedScans = e.getValue();
|
||||
List<WifiScanEntry> 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<Integer> 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<String, Map<String, Integer>> computeTxPowerMap() {
|
||||
Map<String, Map<String, Integer>> txPowerMap = new TreeMap<>();
|
||||
|
||||
Set<String> managedBSSIDs = getManagedBSSIDs(model);
|
||||
Map<String, List<Integer>> bssidToRssiValues = buildRssiMap(managedBSSIDs, model.latestWifiScans);
|
||||
Map<String, JsonArray> 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<Integer> 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<String, Integer> radioMap = new TreeMap<>();
|
||||
radioMap.put(BAND_5G, newTxPower);
|
||||
txPowerMap.put(serialNumber, radioMap);
|
||||
}
|
||||
|
||||
return txPowerMap;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<Double> 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<Integer> 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<String, Map<String, Integer>> computeTxPowerMap() {
|
||||
Map<String, Map<String, Integer>> txPowerMap = new TreeMap<>();
|
||||
|
||||
for (Map.Entry<String, State> 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<String, Integer> radioMap = new TreeMap<>();
|
||||
radioMap.put(BAND_5G, newTxPower);
|
||||
txPowerMap.put(serialNumber, radioMap);
|
||||
}
|
||||
|
||||
return txPowerMap;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<String, Map<String, Integer>> computeChannelMap() {
|
||||
Map<String, Map<String, Integer>> channelMap = new TreeMap<>();
|
||||
Map<String, List<String>> bandsMap =
|
||||
UCentralUtils.getBandsMap(model.latestDeviceStatus);
|
||||
|
||||
Map<String, Map<String, List<Integer>>> deviceAvailableChannels =
|
||||
UCentralUtils.getDeviceAvailableChannels(
|
||||
model.latestDeviceStatus,
|
||||
model.latestDeviceCapabilities,
|
||||
AVAILABLE_CHANNELS_BAND
|
||||
);
|
||||
|
||||
Map<String, String> bssidsMap = UCentralUtils.getBssidsMap(model.latestState);
|
||||
|
||||
for (Map.Entry<String, List<String>> entry : bandsMap.entrySet()) {
|
||||
// Performance metrics
|
||||
Map<String, Integer> oldChannelMap = new TreeMap<>();
|
||||
Map<String, Integer> newChannelMap = new TreeMap<>();
|
||||
|
||||
// Use last wifi scan result for the performance metrics calculation
|
||||
String band = entry.getKey();
|
||||
Map<String, List<WifiScanEntry>> 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<Integer> availableChannelsList = new ArrayList<>(
|
||||
AVAILABLE_CHANNELS_BAND.get(band)
|
||||
);
|
||||
for (String serialNumber : entry.getValue()) {
|
||||
List<Integer> 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<Integer> newAvailableChannelsList = updateAvailableChannelsList(
|
||||
band, serialNumber, currentChannelWidth, availableChannelsList
|
||||
);
|
||||
Set<Integer> availableChannelsSet = new TreeSet<>(
|
||||
availableChannelsList
|
||||
);
|
||||
Set<Integer> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<String, Map<String, Integer>> computeTxPowerMap() {
|
||||
Map<String, Map<String, Integer>> txPowerMap = new TreeMap<>();
|
||||
for (String serialNumber : model.latestState.keySet()) {
|
||||
Map<String, Integer> 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;
|
||||
}
|
||||
}
|
||||
103
src/main/java/com/facebook/openwifirrm/optimizers/TPC.java
Normal file
103
src/main/java/com/facebook/openwifirrm/optimizers/TPC.java
Normal file
@@ -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<String, DeviceConfig> 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<String, Map<String, Integer>> 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<String, Map<String, Integer>> txPowerMap
|
||||
) {
|
||||
// Update device AP config layer
|
||||
deviceDataManager.updateDeviceApConfig(apConfig -> {
|
||||
for (
|
||||
Map.Entry<String, Map<String, Integer>> entry :
|
||||
txPowerMap.entrySet()
|
||||
) {
|
||||
DeviceConfig deviceConfig = apConfig.computeIfAbsent(
|
||||
entry.getKey(), k -> new DeviceConfig()
|
||||
);
|
||||
deviceConfig.autoTxPowers = entry.getValue();
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger config update now
|
||||
configManager.wakeUp();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<Integer, Integer> getOccupiedChannels(
|
||||
String band,
|
||||
String serialNumber,
|
||||
int channelWidth,
|
||||
List<Integer> availableChannelsList,
|
||||
Map<String, List<WifiScanEntry>> deviceToWifiScans,
|
||||
Map<String, Map<String, Integer>> channelMap,
|
||||
Map<String, String> bssidsMap
|
||||
) {
|
||||
// Find occupied channels by nonOWF APs (and # associated nonOWF APs)
|
||||
// Distinguish OWF APs from nonOWF APs
|
||||
Map<Integer, Integer> occupiedChannels = new TreeMap<>();
|
||||
List<WifiScanEntry> scanResps = getScanRespsByBandwidth(
|
||||
band,
|
||||
serialNumber,
|
||||
channelWidth,
|
||||
deviceToWifiScans
|
||||
);
|
||||
List<WifiScanEntry> scanRespsOWF = new ArrayList<WifiScanEntry>();
|
||||
|
||||
// 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<Integer, Integer> 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> getRadioBandsSet(JsonArray radioConfigList) {
|
||||
Set<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> body = new HashMap<>();
|
||||
body.put("userId", username);
|
||||
body.put("password", password);
|
||||
HttpResponse<String> 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<String> 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<String> httpGet(String endpoint) {
|
||||
return httpGet(endpoint, null);
|
||||
}
|
||||
|
||||
/** Send a GET request with query parameters. */
|
||||
private HttpResponse<String> httpGet(
|
||||
String endpoint,
|
||||
Map<String, Object> parameters
|
||||
) {
|
||||
return httpGet(
|
||||
endpoint,
|
||||
parameters,
|
||||
socketParams.connectTimeoutMs,
|
||||
socketParams.socketTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
/** Send a GET request with query parameters using given timeout values. */
|
||||
private HttpResponse<String> httpGet(
|
||||
String endpoint,
|
||||
Map<String, Object> 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<String> 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<String> 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<String, Object> parameters =
|
||||
Collections.singletonMap("command", "info");
|
||||
HttpResponse<String> 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<DeviceWithStatus> getDevices() {
|
||||
Map<String, Object> parameters =
|
||||
Collections.singletonMap("deviceWithStatus", true);
|
||||
HttpResponse<String> 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<String> 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<String> 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<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("newest", true);
|
||||
parameters.put("limit", limit);
|
||||
HttpResponse<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<KafkaRecord> records);
|
||||
|
||||
/** Handle a list of wifi scan records. */
|
||||
void handleWifiScanRecords(List<KafkaRecord> records);
|
||||
}
|
||||
|
||||
/** Kafka record listeners. */
|
||||
private Map<String, KafkaListener> 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<String, List<PartitionInfo>> topics =
|
||||
consumer.listTopics(POLL_TIMEOUT);
|
||||
logger.info("Found topics: {}", String.join(", ", topics.keySet()));
|
||||
List<String> 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<TopicPartition> partitions) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPartitionsAssigned(Collection<TopicPartition> 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<String, String> records = consumer.poll(POLL_TIMEOUT);
|
||||
logger.debug("Poll returned with {} record(s)", records.count());
|
||||
|
||||
List<KafkaRecord> stateRecords = new ArrayList<>();
|
||||
List<KafkaRecord> wifiScanRecords = new ArrayList<>();
|
||||
for (ConsumerRecord<String, String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<WifiScanEntry> parseWifiScanEntries(JsonObject result) {
|
||||
List<WifiScanEntry> 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<String, Integer> 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<String, List<String>> getBandsMap(
|
||||
Map<String, JsonArray> deviceStatus
|
||||
) {
|
||||
Map<String, List<String>> 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<String, Map<String, List<Integer>>> getDeviceAvailableChannels(
|
||||
Map<String, JsonArray> deviceStatus,
|
||||
Map<String, JsonObject> deviceCapabilities,
|
||||
Map<String, List<Integer>> defaultAvailableChannels
|
||||
) {
|
||||
Map<String, Map<String, List<Integer>>> 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<Integer> availableChannels = new ArrayList<>();
|
||||
if (capabilitesObject == null) {
|
||||
availableChannels.addAll(defaultAvailableChannels.get(band));
|
||||
} else {
|
||||
Set<Entry<String, JsonElement>> entrySet = capabilitesObject
|
||||
.entrySet();
|
||||
for (Map.Entry<String, JsonElement> 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<Integer> 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<String, String> getBssidsMap(Map<String, State> latestState) {
|
||||
Map<String, String> bssidMap = new HashMap<>();
|
||||
for (Map.Entry<String, State> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DeviceWithStatus> devicesWithStatus;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<NoteInfo> 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<StatisticsDetails> data;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
19
src/main/resources/log4j.properties
Normal file
19
src/main/resources/log4j.properties
Normal file
@@ -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
|
||||
59
src/main/resources/public/index.html
Normal file
59
src/main/resources/public/index.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!-- Swagger UI v3.52.3 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Swagger UI</title>
|
||||
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
|
||||
<style>
|
||||
html
|
||||
{
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after
|
||||
{
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
margin:0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "./openapi.yaml",
|
||||
defaultModelsExpandDepth: 0,
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
// End Swagger UI call region
|
||||
|
||||
window.ui = ui;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
75
src/main/resources/public/oauth2-redirect.html
Normal file
75
src/main/resources/public/oauth2-redirect.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Swagger UI: OAuth2 Redirect</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
'use strict';
|
||||
function run () {
|
||||
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
||||
var sentState = oauth2.state;
|
||||
var redirectUrl = oauth2.redirectUrl;
|
||||
var isValid, qp, arr;
|
||||
|
||||
if (/code|token|error/.test(window.location.hash)) {
|
||||
qp = window.location.hash.substring(1);
|
||||
} else {
|
||||
qp = location.search.substring(1);
|
||||
}
|
||||
|
||||
arr = qp.split("&");
|
||||
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
|
||||
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
||||
function (key, value) {
|
||||
return key === "" ? value : decodeURIComponent(value);
|
||||
}
|
||||
) : {};
|
||||
|
||||
isValid = qp.state === sentState;
|
||||
|
||||
if ((
|
||||
oauth2.auth.schema.get("flow") === "accessCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorization_code"
|
||||
) && !oauth2.auth.code) {
|
||||
if (!isValid) {
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "warning",
|
||||
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
|
||||
});
|
||||
}
|
||||
|
||||
if (qp.code) {
|
||||
delete oauth2.state;
|
||||
oauth2.auth.code = qp.code;
|
||||
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
||||
} else {
|
||||
let oauthErrorMsg;
|
||||
if (qp.error) {
|
||||
oauthErrorMsg = "["+qp.error+"]: " +
|
||||
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
||||
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
||||
}
|
||||
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "error",
|
||||
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
run();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3
src/main/resources/public/swagger-ui-bundle.js
Normal file
3
src/main/resources/public/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
src/main/resources/public/swagger-ui-es-bundle-core.js
Normal file
3
src/main/resources/public/swagger-ui-es-bundle-core.js
Normal file
File diff suppressed because one or more lines are too long
3
src/main/resources/public/swagger-ui-es-bundle.js
Normal file
3
src/main/resources/public/swagger-ui-es-bundle.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
src/main/resources/public/swagger-ui.css
Normal file
4
src/main/resources/public/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
3
src/main/resources/public/swagger-ui.js
Normal file
3
src/main/resources/public/swagger-ui.js
Normal file
File diff suppressed because one or more lines are too long
61
src/test/java/com/facebook/openwifirrm/DeviceConfigTest.java
Normal file
61
src/test/java/com/facebook/openwifirrm/DeviceConfigTest.java
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class DeviceConfigTest {
|
||||
@Test
|
||||
void test_isEmpty() throws Exception {
|
||||
DeviceConfig config = new DeviceConfig();
|
||||
assertTrue(config.isEmpty());
|
||||
|
||||
config.enableConfig = false;
|
||||
assertFalse(config.isEmpty());
|
||||
config.enableConfig = null;
|
||||
assertTrue(config.isEmpty());
|
||||
|
||||
config.userChannels = Collections.singletonMap("2G", 1);
|
||||
assertFalse(config.isEmpty());
|
||||
config.userChannels = new HashMap<>();
|
||||
assertFalse(config.isEmpty());
|
||||
config.userChannels = null;
|
||||
assertTrue(config.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_apply() throws Exception {
|
||||
DeviceConfig config1 = new DeviceConfig();
|
||||
DeviceConfig config2 = new DeviceConfig();
|
||||
config1.apply(config2);
|
||||
config2.apply(config1);
|
||||
assertTrue(config1.isEmpty());
|
||||
assertTrue(config2.isEmpty());
|
||||
|
||||
config1.enableRRM = true;
|
||||
config1.apply(config2);
|
||||
assertEquals(true, config1.enableRRM);
|
||||
config2.enableRRM = true;
|
||||
config1.apply(config2);
|
||||
assertEquals(true, config1.enableRRM);
|
||||
config2.enableRRM = false;
|
||||
config1.apply(config2);
|
||||
assertEquals(false, config1.enableRRM);
|
||||
config1.enableRRM = null;
|
||||
config1.apply(config2);
|
||||
assertEquals(false, config1.enableRRM);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class DeviceDataManagerTest {
|
||||
@Test
|
||||
@Order(1)
|
||||
void testTopology() throws Exception {
|
||||
final String zoneA = "test-zone-A";
|
||||
final String zoneB = "test-zone-B";
|
||||
final String zoneUnknown = "test-zone-unknown";
|
||||
final String deviceA1 = "aaaaaaaaaa01";
|
||||
final String deviceA2 = "aaaaaaaaaa02";
|
||||
final String deviceB1 = "bbbbbbbbbb01";
|
||||
final String deviceUnknown = "000000abcdef";
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
|
||||
// Empty topology
|
||||
assertFalse(deviceDataManager.isDeviceInTopology(deviceA1));
|
||||
assertNull(deviceDataManager.getDeviceZone(deviceA1));
|
||||
assertFalse(deviceDataManager.isZoneInTopology(zoneA));
|
||||
|
||||
// Create topology with zones [A, B]
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zoneA, new TreeSet<>(Arrays.asList(deviceA1, deviceA2)));
|
||||
topology.put(zoneB, new TreeSet<>(Arrays.asList(deviceB1)));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
// Test device/zone getters
|
||||
assertTrue(deviceDataManager.isDeviceInTopology(deviceA1));
|
||||
assertEquals(zoneA, deviceDataManager.getDeviceZone(deviceA1));
|
||||
assertTrue(deviceDataManager.isDeviceInTopology(deviceA2));
|
||||
assertEquals(zoneA, deviceDataManager.getDeviceZone(deviceA2));
|
||||
assertTrue(deviceDataManager.isDeviceInTopology(deviceB1));
|
||||
assertEquals(zoneB, deviceDataManager.getDeviceZone(deviceB1));
|
||||
assertFalse(deviceDataManager.isDeviceInTopology(deviceUnknown));
|
||||
assertNull(deviceDataManager.getDeviceZone(deviceUnknown));
|
||||
assertTrue(deviceDataManager.isZoneInTopology(zoneA));
|
||||
assertTrue(deviceDataManager.isZoneInTopology(zoneB));
|
||||
assertFalse(deviceDataManager.isZoneInTopology(zoneUnknown));
|
||||
|
||||
// Minimal JSON sanity check
|
||||
assertFalse(deviceDataManager.getTopologyJson().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void testTopologyErrorHandling() throws Exception {
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
|
||||
// Null/empty argument handling
|
||||
assertFalse(deviceDataManager.isDeviceInTopology(null));
|
||||
assertFalse(deviceDataManager.isDeviceInTopology(""));
|
||||
assertNull(deviceDataManager.getDeviceZone(null));
|
||||
assertNull(deviceDataManager.getDeviceZone(""));
|
||||
assertFalse(deviceDataManager.isZoneInTopology(null));
|
||||
assertFalse(deviceDataManager.isZoneInTopology(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void testTopologyExceptions() throws Exception {
|
||||
final String zone = "test-zone";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
|
||||
// Null topology
|
||||
assertThrows(NullPointerException.class, () -> {
|
||||
deviceDataManager.setTopology(null);
|
||||
});
|
||||
|
||||
// Empty zone name
|
||||
final DeviceTopology topologyEmptyZone = new DeviceTopology();
|
||||
topologyEmptyZone.put("", new TreeSet<>(Arrays.asList(deviceA)));
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setTopology(topologyEmptyZone);
|
||||
});
|
||||
|
||||
// Empty serial number
|
||||
final DeviceTopology topologyEmptySerial = new DeviceTopology();
|
||||
topologyEmptySerial.put(zone, new TreeSet<>(Arrays.asList("")));
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setTopology(topologyEmptySerial);
|
||||
});
|
||||
|
||||
// Same device in multiple zones
|
||||
final DeviceTopology topologyDupSerial = new DeviceTopology();
|
||||
final String zone2 = zone + "-copy";
|
||||
topologyDupSerial.put(zone, new TreeSet<>(Arrays.asList(deviceA)));
|
||||
topologyDupSerial.put(zone2, new TreeSet<>(Arrays.asList(deviceA)));
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setTopology(topologyDupSerial);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(101)
|
||||
void testDeviceConfig() throws Exception {
|
||||
final String zoneA = "test-zone-A";
|
||||
final String zoneB = "test-zone-B";
|
||||
final String deviceA = "aaaaaaaaaa01";
|
||||
final String deviceB = "bbbbbbbbbb01";
|
||||
final String deviceUnknown = "000000abcdef";
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
|
||||
// Create topology with zones [A, B]
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zoneA, new TreeSet<>(Arrays.asList(deviceA)));
|
||||
topology.put(zoneB, new TreeSet<>(Arrays.asList(deviceB)));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
// Update network config
|
||||
final DeviceConfig networkCfg = new DeviceConfig();
|
||||
networkCfg.enableRRM = false;
|
||||
deviceDataManager.setDeviceNetworkConfig(networkCfg);
|
||||
|
||||
// Update zone config
|
||||
final DeviceConfig zoneCfgA = new DeviceConfig();
|
||||
zoneCfgA.enableRRM = true;
|
||||
deviceDataManager.setDeviceZoneConfig(zoneA, zoneCfgA);
|
||||
|
||||
// Update device config
|
||||
final DeviceConfig apCfgA = new DeviceConfig();
|
||||
final DeviceConfig apCfgB = new DeviceConfig();
|
||||
apCfgA.allowedChannels = new HashMap<>();
|
||||
apCfgA.allowedChannels.put("2G", Arrays.asList(6, 7));
|
||||
apCfgB.allowedChannels = new HashMap<>();
|
||||
apCfgB.allowedChannels.put("2G", Arrays.asList(1, 2, 3));
|
||||
// - use setter
|
||||
deviceDataManager.setDeviceApConfig(deviceA, apCfgA);
|
||||
// - use update function
|
||||
deviceDataManager.updateDeviceApConfig(apConfig -> {
|
||||
apConfig.put(deviceB, apCfgB);
|
||||
apConfig.put(deviceUnknown, new DeviceConfig());
|
||||
});
|
||||
|
||||
// Check current layered device config
|
||||
DeviceConfig actualApCfgA = deviceDataManager.getDeviceConfig(deviceA);
|
||||
DeviceConfig actualApCfgB = deviceDataManager.getDeviceConfig(deviceB);
|
||||
assertNull(deviceDataManager.getDeviceConfig(deviceUnknown));
|
||||
assertNotNull(actualApCfgA);
|
||||
assertNotNull(actualApCfgB);
|
||||
assertTrue(actualApCfgA.enableRRM);
|
||||
assertFalse(actualApCfgB.enableRRM);
|
||||
assertEquals(2, actualApCfgA.allowedChannels.get("2G").size());
|
||||
assertEquals(3, actualApCfgB.allowedChannels.get("2G").size());
|
||||
|
||||
// Minimal JSON sanity check
|
||||
assertFalse(deviceDataManager.getDeviceLayeredConfigJson().isEmpty());
|
||||
|
||||
// Setting null config at a single layer is allowed
|
||||
deviceDataManager.setDeviceNetworkConfig(null);
|
||||
deviceDataManager.setDeviceZoneConfig(zoneA, null);
|
||||
deviceDataManager.setDeviceApConfig(deviceA, null);
|
||||
|
||||
// Setting whole layered config works (even with null fields)
|
||||
DeviceLayeredConfig nullLayeredCfg = new DeviceLayeredConfig();
|
||||
nullLayeredCfg.networkConfig = null;
|
||||
nullLayeredCfg.zoneConfig = null;
|
||||
nullLayeredCfg.apConfig = null;
|
||||
deviceDataManager.setDeviceLayeredConfig(nullLayeredCfg);
|
||||
deviceDataManager.setDeviceLayeredConfig(new DeviceLayeredConfig());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(102)
|
||||
void testDeviceConfigErrorHandling() throws Exception {
|
||||
final String zoneUnknown = "test-zone-unknown";
|
||||
final String deviceUnknown = "000000abcdef";
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
|
||||
// Unknown devices/zones (getters)
|
||||
assertNull(deviceDataManager.getDeviceConfig(deviceUnknown));
|
||||
assertNull(deviceDataManager.getAllDeviceConfigs(null));
|
||||
assertNull(deviceDataManager.getAllDeviceConfigs(zoneUnknown));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(103)
|
||||
void testDeviceConfigExceptions() throws Exception {
|
||||
final String zoneUnknown = "test-zone-unknown";
|
||||
final String deviceUnknown = "000000abcdef";
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
|
||||
// Null config
|
||||
assertThrows(NullPointerException.class, () -> {
|
||||
deviceDataManager.setDeviceLayeredConfig(null);
|
||||
});
|
||||
|
||||
// Null/empty arguments
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.getDeviceConfig(null);
|
||||
});
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.getDeviceConfig(null, zoneUnknown);
|
||||
});
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.getDeviceConfig(deviceUnknown, null);
|
||||
});
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setDeviceZoneConfig(null, null);
|
||||
});
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setDeviceZoneConfig("", null);
|
||||
});
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setDeviceApConfig(null, null);
|
||||
});
|
||||
Assertions.assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setDeviceApConfig("", null);
|
||||
});
|
||||
|
||||
// Unknown devices/zones (setters)
|
||||
final DeviceConfig cfg = new DeviceConfig();
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setDeviceZoneConfig(zoneUnknown, cfg);
|
||||
});
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
deviceDataManager.setDeviceApConfig(deviceUnknown, cfg);
|
||||
});
|
||||
}
|
||||
}
|
||||
82
src/test/java/com/facebook/openwifirrm/UtilsTest.java
Normal file
82
src/test/java/com/facebook/openwifirrm/UtilsTest.java
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class UtilsTest {
|
||||
@Test
|
||||
void test_jsonMerge() throws Exception {
|
||||
JSONObject a, b, c;
|
||||
String s;
|
||||
|
||||
// "a", "b" empty
|
||||
a = new JSONObject();
|
||||
b = new JSONObject();
|
||||
s = a.toString();
|
||||
Utils.jsonMerge(a, b);
|
||||
assertEquals(s, a.toString());
|
||||
|
||||
// "a" non-empty, "b" empty
|
||||
a = new JSONObject("{'x': 1}");
|
||||
b = new JSONObject();
|
||||
s = a.toString();
|
||||
Utils.jsonMerge(a, b);
|
||||
assertEquals(s, a.toString());
|
||||
|
||||
// "a" empty, "b" non-empty
|
||||
a = new JSONObject();
|
||||
b = new JSONObject("{'x': 1, 'y': 2}");
|
||||
s = b.toString();
|
||||
Utils.jsonMerge(a, b);
|
||||
assertEquals(s, a.toString());
|
||||
|
||||
// Regular case, non-nested objects
|
||||
a = new JSONObject("{'x': 1, 'y': 2}");
|
||||
b = new JSONObject("{'y': 4, 'z': 5}");
|
||||
c = new JSONObject("{'x': 1, 'y': 4, 'z': 5}");
|
||||
Utils.jsonMerge(a, b);
|
||||
assertEquals(c.toString(), a.toString());
|
||||
|
||||
// Regular case, nested objects
|
||||
a = new JSONObject(
|
||||
"{'x': {'x1': 1, 'x2': {'x2a': 2}}, 'y': {'y1': 3}}"
|
||||
);
|
||||
b = new JSONObject(
|
||||
"{'x': {'x1': 4, 'x2': {'x2a': 5}}, 'y': {'y2': 6}}"
|
||||
);
|
||||
c = new JSONObject(
|
||||
"{'x': {'x1': 4, 'x2': {'x2a': 5}}, 'y': {'y1': 3, 'y2': 6}}"
|
||||
);
|
||||
Utils.jsonMerge(a, b);
|
||||
assertEquals(c.toString(), a.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_macToLong() throws Exception {
|
||||
assertEquals(0x123456789abL, Utils.macToLong("01-23-45-67-89-AB"));
|
||||
assertEquals(0xba9876543210L, Utils.macToLong("ba:98:76:54:32:10"));
|
||||
assertEquals(0x123456789abcL, Utils.macToLong("1234.5678.9abc"));
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> Utils.macToLong("blah")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_longToMac() throws Exception {
|
||||
assertEquals("00:00:00:00:00:00", Utils.longToMac(0L));
|
||||
assertEquals("ff:ff:ff:ff:ff:ff", Utils.longToMac(0xffffffffffffL));
|
||||
assertEquals("ab:cd:ef:12:34:56", Utils.longToMac(0xabcdef123456L));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestInfo;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
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;
|
||||
import com.facebook.openwifirrm.mysql.DatabaseManager;
|
||||
import com.facebook.openwifirrm.ucentral.UCentralClient;
|
||||
import com.facebook.openwifirrm.ucentral.UCentralKafkaConsumer;
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import kong.unirest.HttpResponse;
|
||||
import kong.unirest.JsonNode;
|
||||
import kong.unirest.Unirest;
|
||||
import kong.unirest.json.JSONObject;
|
||||
import spark.Spark;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class ApiServerTest {
|
||||
/** The test server port. */
|
||||
private static final int TEST_PORT = spark.Service.SPARK_DEFAULT_PORT;
|
||||
|
||||
/** Test device data manager. */
|
||||
private DeviceDataManager deviceDataManager;
|
||||
|
||||
/** Test RRM config. */
|
||||
private RRMConfig rrmConfig;
|
||||
|
||||
/** Test API server instance. */
|
||||
private ApiServer server;
|
||||
|
||||
/** Test modeler instance. */
|
||||
private Modeler modeler;
|
||||
|
||||
/** The Gson instance. */
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/** Build an endpoint URL. */
|
||||
private String endpoint(String path) {
|
||||
return String.format("http://localhost:%d%s", TEST_PORT, path);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setup(TestInfo testInfo) {
|
||||
this.deviceDataManager = new DeviceDataManager();
|
||||
|
||||
// Create config
|
||||
this.rrmConfig = new RRMConfig();
|
||||
rrmConfig.moduleConfig.apiServerParams.httpPort = TEST_PORT;
|
||||
rrmConfig.moduleConfig.apiServerParams.useBasicAuth = false;
|
||||
rrmConfig.moduleConfig.apiServerParams.basicAuthUser = "";
|
||||
rrmConfig.moduleConfig.apiServerParams.basicAuthPassword = "";
|
||||
|
||||
// Create clients (null for now)
|
||||
UCentralClient client = null;
|
||||
UCentralKafkaConsumer consumer = null;
|
||||
DatabaseManager dbManager = null;
|
||||
|
||||
// Instantiate dependent instances
|
||||
ConfigManager configManager = new ConfigManager(
|
||||
rrmConfig.moduleConfig.configManagerParams,
|
||||
deviceDataManager,
|
||||
client
|
||||
);
|
||||
DataCollector dataCollector = new DataCollector(
|
||||
rrmConfig.moduleConfig.dataCollectorParams,
|
||||
deviceDataManager,
|
||||
client,
|
||||
consumer,
|
||||
configManager,
|
||||
dbManager
|
||||
);
|
||||
this.modeler = new Modeler(
|
||||
rrmConfig.moduleConfig.modelerParams,
|
||||
deviceDataManager,
|
||||
consumer,
|
||||
client,
|
||||
dataCollector,
|
||||
configManager
|
||||
);
|
||||
|
||||
// Instantiate ApiServer
|
||||
this.server = new ApiServer(
|
||||
rrmConfig.moduleConfig.apiServerParams,
|
||||
deviceDataManager,
|
||||
configManager,
|
||||
modeler
|
||||
);
|
||||
try {
|
||||
server.run();
|
||||
Spark.awaitInitialization();
|
||||
} catch (Exception e) {
|
||||
fail("Could not instantiate ApiServer.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
// Destroy ApiServer
|
||||
if (server != null) {
|
||||
server.shutdown();
|
||||
Spark.awaitStop();
|
||||
}
|
||||
server = null;
|
||||
|
||||
// Reset Unirest client
|
||||
// Without this, Unirest randomly throws:
|
||||
// kong.unirest.UnirestException: java.net.SocketException: Software caused connection abort: recv failed
|
||||
Unirest.shutDown();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void test_getTopology() throws Exception {
|
||||
// Create topology
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put("test-zone", new TreeSet<>(Arrays.asList("aaaaaaaaaaa")));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
// Fetch topology
|
||||
HttpResponse<String> resp = Unirest.get(endpoint("/api/v1/getTopology")).asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertEquals(deviceDataManager.getTopologyJson(), resp.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void test_setTopology() throws Exception {
|
||||
String url = endpoint("/api/v1/setTopology");
|
||||
|
||||
// Create topology
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put("test-zone", new TreeSet<>(Arrays.asList("aaaaaaaaaaa")));
|
||||
|
||||
// Set topology
|
||||
HttpResponse<String> resp = Unirest
|
||||
.post(url)
|
||||
.body(gson.toJson(topology))
|
||||
.asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertEquals(gson.toJson(topology), deviceDataManager.getTopologyJson());
|
||||
|
||||
// Missing/wrong parameters
|
||||
assertEquals(400, Unirest.post(url).body("not json").asString().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void test_getDeviceLayeredConfig() throws Exception {
|
||||
// Create topology and configs
|
||||
final String zone = "test-zone";
|
||||
final String ap = "aaaaaaaaaaa";
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zone, new TreeSet<>(Arrays.asList(ap)));
|
||||
deviceDataManager.setTopology(topology);
|
||||
DeviceLayeredConfig cfg = new DeviceLayeredConfig();
|
||||
cfg.networkConfig.enableRRM = true;
|
||||
DeviceConfig zoneConfig = new DeviceConfig();
|
||||
zoneConfig.enableWifiScan = false;
|
||||
cfg.zoneConfig.put(zone, zoneConfig);
|
||||
DeviceConfig apConfig = new DeviceConfig();
|
||||
apConfig.enableConfig = false;
|
||||
cfg.apConfig.put(ap, apConfig);
|
||||
deviceDataManager.setDeviceApConfig(ap, apConfig);
|
||||
|
||||
// Fetch config
|
||||
HttpResponse<String> resp = Unirest.get(endpoint("/api/v1/getDeviceLayeredConfig")).asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertEquals(deviceDataManager.getDeviceLayeredConfigJson(), resp.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void test_getDeviceConfig() throws Exception {
|
||||
String url = endpoint("/api/v1/getDeviceConfig");
|
||||
|
||||
// Create topology
|
||||
final String zone = "test-zone";
|
||||
final String ap = "aaaaaaaaaaa";
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zone, new TreeSet<>(Arrays.asList(ap)));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
// Fetch config
|
||||
HttpResponse<String> resp = Unirest.get(url + "?serial=" + ap).asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
String normalizedResp = gson.toJson(gson.fromJson(resp.getBody(), DeviceConfig.class));
|
||||
assertEquals(gson.toJson(deviceDataManager.getDeviceConfig(ap)), normalizedResp);
|
||||
|
||||
// Missing/wrong parameters
|
||||
assertEquals(400, Unirest.get(url).asString().getStatus());
|
||||
assertEquals(400, Unirest.get(url + "?serial=asdf").asString().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void test_setDeviceNetworkConfig() throws Exception {
|
||||
DeviceConfig config = new DeviceConfig();
|
||||
config.enableConfig = false;
|
||||
|
||||
// Set config
|
||||
HttpResponse<String> resp = Unirest
|
||||
.post(endpoint("/api/v1/setDeviceNetworkConfig"))
|
||||
.body(gson.toJson(config))
|
||||
.asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
DeviceLayeredConfig fullCfg = gson.fromJson(
|
||||
deviceDataManager.getDeviceLayeredConfigJson(),
|
||||
DeviceLayeredConfig.class
|
||||
);
|
||||
assertEquals(gson.toJson(config), gson.toJson(fullCfg.networkConfig));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void test_setDeviceZoneConfig() throws Exception {
|
||||
String url = endpoint("/api/v1/setDeviceZoneConfig");
|
||||
|
||||
// Create topology
|
||||
final String zone = "test-zone";
|
||||
final String ap = "aaaaaaaaaaa";
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zone, new TreeSet<>(Arrays.asList(ap)));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
DeviceConfig config = new DeviceConfig();
|
||||
config.enableConfig = false;
|
||||
|
||||
// Set config
|
||||
HttpResponse<String> resp = Unirest
|
||||
.post(url + "?zone=" + zone)
|
||||
.body(gson.toJson(config))
|
||||
.asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
DeviceLayeredConfig fullCfg = gson.fromJson(
|
||||
deviceDataManager.getDeviceLayeredConfigJson(),
|
||||
DeviceLayeredConfig.class
|
||||
);
|
||||
assertEquals(1, fullCfg.zoneConfig.size());
|
||||
assertTrue(fullCfg.zoneConfig.containsKey(zone));
|
||||
assertEquals(gson.toJson(config), gson.toJson(fullCfg.zoneConfig.get(zone)));
|
||||
|
||||
// Missing/wrong parameters
|
||||
assertEquals(400, Unirest.post(url).body(gson.toJson(config)).asString().getStatus());
|
||||
assertEquals(400, Unirest.post(url + "?zone=asdf").body(gson.toJson(config)).asString().getStatus());
|
||||
assertEquals(400, Unirest.post(url + "?zone=" + zone).body("not json").asString().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(7)
|
||||
void test_setDeviceApConfig() throws Exception {
|
||||
String url = endpoint("/api/v1/setDeviceApConfig");
|
||||
|
||||
// Create topology
|
||||
final String zone = "test-zone";
|
||||
final String ap = "aaaaaaaaaaa";
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zone, new TreeSet<>(Arrays.asList(ap)));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
DeviceConfig config = new DeviceConfig();
|
||||
config.enableConfig = false;
|
||||
|
||||
// Set config
|
||||
HttpResponse<String> resp = Unirest
|
||||
.post(url + "?serial=" + ap)
|
||||
.body(gson.toJson(config))
|
||||
.asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
DeviceLayeredConfig fullCfg = gson.fromJson(
|
||||
deviceDataManager.getDeviceLayeredConfigJson(),
|
||||
DeviceLayeredConfig.class
|
||||
);
|
||||
assertEquals(1, fullCfg.apConfig.size());
|
||||
assertTrue(fullCfg.apConfig.containsKey(ap));
|
||||
assertEquals(gson.toJson(config), gson.toJson(fullCfg.apConfig.get(ap)));
|
||||
|
||||
// Missing/wrong parameters
|
||||
assertEquals(400, Unirest.post(url).body(gson.toJson(config)).asString().getStatus());
|
||||
assertEquals(400, Unirest.post(url + "?serial=asdf").body(gson.toJson(config)).asString().getStatus());
|
||||
assertEquals(400, Unirest.post(url + "?serial=" + ap).body("not json").asString().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(8)
|
||||
void test_modifyDeviceApConfig() throws Exception {
|
||||
String url = endpoint("/api/v1/modifyDeviceApConfig");
|
||||
|
||||
// Create topology
|
||||
final String zone = "test-zone";
|
||||
final String ap = "aaaaaaaaaaa";
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zone, new TreeSet<>(Arrays.asList(ap)));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
// Set initial AP config
|
||||
DeviceConfig apConfig = new DeviceConfig();
|
||||
apConfig.enableConfig = false;
|
||||
apConfig.autoChannels = new HashMap<>();
|
||||
apConfig.autoChannels.put("2G", 7);
|
||||
apConfig.autoChannels.put("5G", 165);
|
||||
deviceDataManager.setDeviceApConfig(ap, apConfig);
|
||||
|
||||
// Construct config request
|
||||
DeviceConfig configReq = new DeviceConfig();
|
||||
configReq.enableConfig = true;
|
||||
configReq.autoTxPowers = new HashMap<>();
|
||||
configReq.autoTxPowers.put("2G", 20);
|
||||
configReq.autoTxPowers.put("5G", 28);
|
||||
|
||||
// Merge config objects (expected result)
|
||||
apConfig.apply(configReq);
|
||||
|
||||
// Set config
|
||||
HttpResponse<String> resp = Unirest
|
||||
.post(url + "?serial=" + ap)
|
||||
.body(gson.toJson(configReq))
|
||||
.asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
DeviceLayeredConfig fullCfg = gson.fromJson(
|
||||
deviceDataManager.getDeviceLayeredConfigJson(),
|
||||
DeviceLayeredConfig.class
|
||||
);
|
||||
assertTrue(fullCfg.apConfig.containsKey(ap));
|
||||
assertEquals(gson.toJson(apConfig), gson.toJson(fullCfg.apConfig.get(ap)));
|
||||
|
||||
// Missing/wrong parameters
|
||||
assertEquals(400, Unirest.post(url).body(gson.toJson(configReq)).asString().getStatus());
|
||||
assertEquals(400, Unirest.post(url + "?serial=asdf").body(gson.toJson(configReq)).asString().getStatus());
|
||||
assertEquals(400, Unirest.post(url + "?serial=" + ap).body("not json").asString().getStatus());
|
||||
assertEquals(400, Unirest.post(url + "?serial=" + ap).body("{}").asString().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(100)
|
||||
void test_currentModel() throws Exception {
|
||||
// Fetch RRM model
|
||||
HttpResponse<String> resp = Unirest.get(endpoint("/api/v1/currentModel")).asString();
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertEquals(gson.toJson(modeler.getDataModel()), resp.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(101)
|
||||
void test_optimizeChannel() throws Exception {
|
||||
String url = endpoint("/api/v1/optimizeChannel");
|
||||
|
||||
// Create topology
|
||||
final String zone = "test-zone";
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zone, new TreeSet<>(Arrays.asList("aaaaaaaaaaa")));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
// Correct requests
|
||||
final String[] modes = new String[] { "random", "least_used", "unmanaged_aware" };
|
||||
for (String mode : modes) {
|
||||
String endpoint = String.format("%s?mode=%s&zone=%s", url, mode, zone);
|
||||
HttpResponse<JsonNode> simpleResp = Unirest.get(endpoint).asJson();
|
||||
assertEquals(200, simpleResp.getStatus());
|
||||
assertNotNull(simpleResp.getBody().getObject().getJSONObject("data"));
|
||||
}
|
||||
|
||||
// Missing/wrong parameters
|
||||
assertEquals(400, Unirest.get(url).asString().getStatus());
|
||||
assertEquals(400, Unirest.get(url + "?mode=test123&zone=" + zone).asString().getStatus());
|
||||
assertEquals(400, Unirest.get(url + "?zone=asdf&mode=" + modes[0]).asString().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(102)
|
||||
void test_optimizeTxPower() throws Exception {
|
||||
String url = endpoint("/api/v1/optimizeTxPower");
|
||||
|
||||
// Create topology
|
||||
final String zone = "test-zone";
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zone, new TreeSet<>(Arrays.asList("aaaaaaaaaaa")));
|
||||
deviceDataManager.setTopology(topology);
|
||||
|
||||
// Correct requests
|
||||
final String[] modes = new String[] { "random", "measure_ap_client", "measure_ap_ap", "location_optimal" };
|
||||
for (String mode : modes) {
|
||||
String endpoint = String.format("%s?mode=%s&zone=%s", url, mode, zone);
|
||||
HttpResponse<JsonNode> simpleResp = Unirest.get(endpoint).asJson();
|
||||
assertEquals(200, simpleResp.getStatus());
|
||||
assertNotNull(simpleResp.getBody().getObject().getJSONObject("data"));
|
||||
}
|
||||
|
||||
// Missing/wrong parameters
|
||||
assertEquals(400, Unirest.get(url).asString().getStatus());
|
||||
assertEquals(400, Unirest.get(url + "?mode=test123&zone=" + zone).asString().getStatus());
|
||||
assertEquals(400, Unirest.get(url + "?zone=asdf&mode=" + modes[0]).asString().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1000)
|
||||
void testDocs() throws Exception {
|
||||
// Index page paths
|
||||
assertEquals(200, Unirest.get(endpoint("/")).asString().getStatus());
|
||||
assertEquals(200, Unirest.get(endpoint("/index.html")).asString().getStatus());
|
||||
|
||||
// OpenAPI YAML/JSON
|
||||
HttpResponse<String> yamlResp = Unirest.get(endpoint("/openapi.yaml")).asString();
|
||||
assertEquals(200, yamlResp.getStatus());
|
||||
HttpResponse<JsonNode> jsonResp = Unirest.get(endpoint("/openapi.json")).asJson();
|
||||
assertEquals(200, jsonResp.getStatus());
|
||||
// Check that we got the OpenAPI 3.x version string
|
||||
assertTrue(
|
||||
Arrays.stream(
|
||||
yamlResp.getBody().split("\\R+")
|
||||
).filter(line -> line.matches("^openapi: 3[0-9.]*$"))
|
||||
.findFirst()
|
||||
.isPresent()
|
||||
);
|
||||
assertTrue(
|
||||
jsonResp.getBody().getObject().getString("openapi").matches("^3[0-9.]*$")
|
||||
);
|
||||
// Check that we got some endpoint paths
|
||||
JSONObject paths = jsonResp.getBody().getObject().getJSONObject("paths");
|
||||
assertFalse(paths.isEmpty());
|
||||
assertTrue(paths.keys().next().startsWith("/api/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1001)
|
||||
void test404() throws Exception {
|
||||
final String fakeEndpoint = endpoint("/test123");
|
||||
assertEquals(404, Unirest.get(fakeEndpoint).asString().getStatus());
|
||||
assertEquals(404, Unirest.post(fakeEndpoint).asString().getStatus());
|
||||
assertEquals(404, Unirest.put(fakeEndpoint).asString().getStatus());
|
||||
assertEquals(404, Unirest.delete(fakeEndpoint).asString().getStatus());
|
||||
assertEquals(404, Unirest.options(fakeEndpoint).asString().getStatus());
|
||||
assertEquals(404, Unirest.head(fakeEndpoint).asString().getStatus());
|
||||
assertEquals(404, Unirest.patch(fakeEndpoint).asString().getStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.facebook.openwifirrm.mysql.StateRecord;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
public class DataCollectorTest {
|
||||
@Test
|
||||
void test_parseStateRecord() throws Exception {
|
||||
final String serialNumber = "112233445566";
|
||||
final String payloadJson =
|
||||
"{\"serial\":\"112233445566\",\"state\":{\"interfaces\":[" +
|
||||
"{\"clients\":[{\"ipv4_addresses\":[\"192.168.20.1\"]," +
|
||||
"\"ipv6_addresses\":[\"fe80:0:0:0:230:18ff:fe05:d0d0\"]," +
|
||||
"\"mac\":\"00:30:18:05:d0:d0\",\"ports\":[\"eth1\"]}," +
|
||||
"{\"ipv6_addresses\":[\"fe80:0:0:0:b54d:e239:114a:6292\"]," +
|
||||
"\"mac\":\"5c:3a:45:2d:34:d1\",\"ports\":[\"wlan0\"]}," +
|
||||
"{\"ipv6_addresses\":[\"fe80:0:0:0:d044:3aff:fe7e:1978\"]," +
|
||||
"\"mac\":\"aa:bb:cc:dd:ee:ff\",\"ports\":[\"eth1\"]}]," +
|
||||
"\"counters\":{\"collisions\":0,\"multicast\":34," +
|
||||
"\"rx_bytes\":10825,\"rx_dropped\":0,\"rx_errors\":0," +
|
||||
"\"rx_packets\":150,\"tx_bytes\":1931,\"tx_dropped\":0," +
|
||||
"\"tx_errors\":0,\"tx_packets\":6},\"dns_servers\":[\"8.8.8.8\"]," +
|
||||
"\"ipv4\":{\"addresses\":[\"192.168.16.105/20\"]," +
|
||||
"\"leasetime\":43200},\"location\":\"/interfaces/0\"," +
|
||||
"\"name\":\"up0v0\",\"ssids\":[{\"associations\":[" +
|
||||
"{\"bssid\":\"5c:3a:45:2d:34:d1\",\"connected\":2061," +
|
||||
"\"inactive\":0,\"rssi\":-73,\"rx_bytes\":225426," +
|
||||
"\"rx_packets\":1119,\"rx_rate\":{\"bitrate\":263300," +
|
||||
"\"chwidth\":80,\"mcs\":6,\"nss\":1,\"vht\":true}," +
|
||||
"\"station\":\"aa:00:00:00:00:01\",\"tx_bytes\":341611," +
|
||||
"\"tx_duration\":3243,\"tx_failed\":0,\"tx_offset\":0," +
|
||||
"\"tx_packets\":1304,\"tx_rate\":{\"bitrate\":526600," +
|
||||
"\"chwidth\":80,\"mcs\":6,\"nss\":2,\"sgi\":true,\"vht\":true}," +
|
||||
"\"tx_retries\":0}],\"bssid\":\"bb:00:00:00:00:01\"," +
|
||||
"\"counters\":{\"collisions\":0,\"multicast\":0," +
|
||||
"\"rx_bytes\":202281,\"rx_dropped\":0,\"rx_errors\":0," +
|
||||
"\"rx_packets\":1123,\"tx_bytes\":352404,\"tx_dropped\":0," +
|
||||
"\"tx_errors\":0,\"tx_packets\":1442},\"iface\":\"wlan0\"," +
|
||||
"\"mode\":\"ap\",\"phy\":\"platform/soc/c000000.wifi\"," +
|
||||
"\"radio\":{\"$ref\":\"#/radios/0\"},\"ssid\":\"test1\"}," +
|
||||
"{\"bssid\":\"cc:00:00:00:00:01\",\"counters\":{\"collisions\":0," +
|
||||
"\"multicast\":0,\"rx_bytes\":0,\"rx_dropped\":0,\"rx_errors\":0," +
|
||||
"\"rx_packets\":0,\"tx_bytes\":10056,\"tx_dropped\":0," +
|
||||
"\"tx_errors\":0,\"tx_packets\":132},\"iface\":\"wlan1\"," +
|
||||
"\"mode\":\"ap\",\"phy\":\"platform/soc/c000000.wifi+1\"," +
|
||||
"\"radio\":{\"$ref\":\"#/radios/1\"},\"ssid\":\"test1\"}]," +
|
||||
"\"uptime\":73067},{\"counters\":{\"collisions\":0," +
|
||||
"\"multicast\":0,\"rx_bytes\":0,\"rx_dropped\":0,\"rx_errors\":0," +
|
||||
"\"rx_packets\":0,\"tx_bytes\":0,\"tx_dropped\":0," +
|
||||
"\"tx_errors\":0,\"tx_packets\":0},\"ipv4\":{\"addresses\":[" +
|
||||
"\"192.168.1.1/24\"]},\"location\":\"/interfaces/1\"," +
|
||||
"\"name\":\"down1v0\",\"uptime\":73074}],\"link-state\":" +
|
||||
"{\"lan\":{\"eth1\":{\"carrier\":0},\"eth2\":{\"carrier\":0}}," +
|
||||
"\"wan\":{\"eth0\":{\"carrier\":1,\"duplex\":\"full\"," +
|
||||
"\"speed\":1000}}},\"radios\":[{\"active_ms\":72987829," +
|
||||
"\"busy_ms\":1881608,\"channel\":52,\"channel_width\":\"80\"," +
|
||||
"\"noise\":-105,\"phy\":\"platform/soc/c000000.wifi\"," +
|
||||
"\"receive_ms\":28277,\"temperature\":61,\"transmit_ms\":381608," +
|
||||
"\"tx_power\":24},{\"active_ms\":73049815,\"busy_ms\":7237038," +
|
||||
"\"channel\":11,\"channel_width\":\"20\",\"noise\":-101," +
|
||||
"\"phy\":\"platform/soc/c000000.wifi+1\",\"receive_ms\":8180," +
|
||||
"\"temperature\":61,\"transmit_ms\":316158,\"tx_power\":30}]," +
|
||||
"\"unit\":{\"load\":[0,0,0],\"localtime\":1649306810,\"memory\":" +
|
||||
"{\"buffered\":9961472,\"cached\":27217920,\"free\":757035008," +
|
||||
"\"total\":973139968},\"uptime\":73107}},\"uuid\":1648808043}";
|
||||
JsonObject payload = new Gson().fromJson(payloadJson, JsonObject.class);
|
||||
|
||||
// Parse into records
|
||||
List<StateRecord> results = new ArrayList<>();
|
||||
DataCollector.parseStateRecord(serialNumber, payload, results);
|
||||
assertEquals(51, results.size());
|
||||
assertEquals(1649306810L, results.get(0).timestamp);
|
||||
assertEquals(serialNumber, results.get(0).serial);
|
||||
|
||||
// Convert to map and check individual metrics
|
||||
Map<String, StateRecord> resultMap = new HashMap<>();
|
||||
for (StateRecord record : results) {
|
||||
resultMap.put(record.metric, record);
|
||||
}
|
||||
|
||||
// Interface counters
|
||||
StateRecord record = resultMap.get("interface.up0v0.rx_bytes");
|
||||
assertNotNull(record);
|
||||
assertEquals(10825L, record.value);
|
||||
|
||||
// Interface association counters
|
||||
// - primitive field
|
||||
record = resultMap.get(
|
||||
"interface.up0v0.bssid.bb:00:00:00:00:01.client.5c:3a:45:2d:34:d1.tx_bytes"
|
||||
);
|
||||
assertNotNull(record);
|
||||
assertEquals(341611L, record.value);
|
||||
// - rate key (number)
|
||||
record = resultMap.get(
|
||||
"interface.up0v0.bssid.bb:00:00:00:00:01.client.5c:3a:45:2d:34:d1.rx_rate.bitrate"
|
||||
);
|
||||
assertNotNull(record);
|
||||
assertEquals(263300L, record.value);
|
||||
// - rate key (boolean)
|
||||
record = resultMap.get(
|
||||
"interface.up0v0.bssid.bb:00:00:00:00:01.client.5c:3a:45:2d:34:d1.rx_rate.vht"
|
||||
);
|
||||
assertNotNull(record);
|
||||
assertEquals(1L, record.value);
|
||||
|
||||
// Radio stats
|
||||
record = resultMap.get("radio.1.noise");
|
||||
assertNotNull(record);
|
||||
assertEquals(-101L, record.value);
|
||||
|
||||
// Unit stats
|
||||
record = resultMap.get("unit.uptime");
|
||||
assertNotNull(record);
|
||||
assertEquals(73107L, record.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class ModelerUtilsTest {
|
||||
@Test
|
||||
void testErrorCase() throws Exception {
|
||||
// Out of range
|
||||
double[][][] rxPower = ModelerUtils.generateRxPower(
|
||||
500,
|
||||
4,
|
||||
new ArrayList<>(Arrays.asList(408.0, 507.0, 64.0, 457.0)),
|
||||
new ArrayList<>(Arrays.asList(317.0, 49.0, 140.0, 274.0)),
|
||||
new ArrayList<>(Arrays.asList(20.0, 20.0, 20.0, 20.0))
|
||||
);
|
||||
assertNull(rxPower);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidMetric() throws Exception {
|
||||
double[][][] rxPower = ModelerUtils.generateRxPower(
|
||||
500,
|
||||
4,
|
||||
new ArrayList<>(Arrays.asList(408.0, 453.0, 64.0, 457.0)),
|
||||
new ArrayList<>(Arrays.asList(317.0, 49.0, 140.0, 274.0)),
|
||||
new ArrayList<>(Arrays.asList(20.0, 20.0, 20.0, 20.0))
|
||||
);
|
||||
assertEquals(-108.529, rxPower[0][0][0], 0.001);
|
||||
double[][] heatMap = ModelerUtils.generateHeatMap(
|
||||
500, 4, rxPower
|
||||
);
|
||||
assertEquals(-87.494, heatMap[0][0], 0.001);
|
||||
double[][] sinr = ModelerUtils.generateSinr(
|
||||
500, 4, rxPower
|
||||
);
|
||||
assertEquals(5.995, sinr[0][0], 0.001);
|
||||
double metric = ModelerUtils.calculateTPCMetrics(
|
||||
500, heatMap, sinr
|
||||
);
|
||||
assertEquals(Double.POSITIVE_INFINITY, metric, 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidMetric() throws Exception {
|
||||
double[][][] rxPower = ModelerUtils.generateRxPower(
|
||||
500,
|
||||
4,
|
||||
new ArrayList<>(Arrays.asList(408.0, 453.0, 64.0, 457.0)),
|
||||
new ArrayList<>(Arrays.asList(317.0, 49.0, 140.0, 274.0)),
|
||||
new ArrayList<>(Arrays.asList(30.0, 30.0, 30.0, 30.0))
|
||||
);
|
||||
assertEquals(-98.529, rxPower[0][0][0], 0.001);
|
||||
double[][] heatMap = ModelerUtils.generateHeatMap(
|
||||
500, 4, rxPower
|
||||
);
|
||||
assertEquals(-77.495, heatMap[0][0], 0.001);
|
||||
double[][] sinr = ModelerUtils.generateSinr(
|
||||
500, 4, rxPower
|
||||
);
|
||||
assertEquals(12.990, sinr[0][0], 0.001);
|
||||
double metric = ModelerUtils.calculateTPCMetrics(
|
||||
500, heatMap, sinr
|
||||
);
|
||||
assertEquals(0.861, metric, 0.001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import com.facebook.openwifirrm.DeviceConfig;
|
||||
import com.facebook.openwifirrm.DeviceDataManager;
|
||||
import com.facebook.openwifirrm.modules.Modeler.DataModel;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class LeastUsedChannelOptimizerTest {
|
||||
/** Test zone name. */
|
||||
private static final String TEST_ZONE = "test-zone";
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void test5G() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String dummyBssid = "dd:dd:dd:dd:dd:dd";
|
||||
final int channelWidth = 20;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A -> No APs on current channel, so stay on it (48)
|
||||
int aExpectedChannel = 48;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(36, 40, 44, 149))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, aExpectedChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
// B -> Assign to only free channel (165)
|
||||
LinkedList<Integer> channelsB = new LinkedList<>();
|
||||
channelsB.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
int bExpectedChannel = channelsB.removeLast();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 40)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(40, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB, Arrays.asList(TestUtils.createWifiScanList(channelsB))
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, bExpectedChannel);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
// C -> No free channels, assign to least occupied (36)
|
||||
LinkedList<Integer> channelsC = new LinkedList<>();
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
int cExpectedChannel = channelsC.removeFirst();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 149)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(149, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC, Arrays.asList(TestUtils.createWifiScanList(channelsC))
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, cExpectedChannel);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
ChannelOptimizer optimizer = new LeastUsedChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void test2G() throws Exception {
|
||||
final String band = "2G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String dummyBssid = "dd:dd:dd:dd:dd:dd";
|
||||
final int channelWidth = 20;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A -> No APs on current channel, so stay on it (1)
|
||||
int aExpectedChannel = 1;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(6, 7, 8, 9, 10, 11))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, aExpectedChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
// B -> No free channels, assign to least occupied (11)
|
||||
LinkedList<Integer> channelsB = new LinkedList<>();
|
||||
channelsB.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
int bExpectedChannel = channelsB.removeLast();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 6)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(6, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB, Arrays.asList(TestUtils.createWifiScanList(channelsB))
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, bExpectedChannel);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
// C -> Assigned to only free prioritized channel (1)
|
||||
int cExpectedChannel = 1;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 6)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(6, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(6, 7, 10, 11))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, cExpectedChannel);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
ChannelOptimizer optimizer = new LeastUsedChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void testWithUserChannels() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String dummyBssid = "dd:dd:dd:dd:dd:dd";
|
||||
final int channelWidth = 20;
|
||||
int userChannel = 149;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
DeviceConfig apConfig = new DeviceConfig();
|
||||
apConfig.userChannels = new HashMap<>();
|
||||
apConfig.userChannels.put("5G", userChannel);
|
||||
deviceDataManager.setDeviceApConfig(deviceA, apConfig);
|
||||
deviceDataManager.setDeviceApConfig(deviceB, apConfig);
|
||||
deviceDataManager.setDeviceApConfig(deviceC, apConfig);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A, B, C should just be assigned to the same userChannel
|
||||
int aExpectedChannel = 48;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(36, 40, 44, 149))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, userChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
LinkedList<Integer> channelsB = new LinkedList<>();
|
||||
channelsB.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsB.removeLast();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 40)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(40, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB, Arrays.asList(TestUtils.createWifiScanList(channelsB))
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, userChannel);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
LinkedList<Integer> channelsC = new LinkedList<>();
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsC.removeFirst();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 149)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(149, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC, Arrays.asList(TestUtils.createWifiScanList(channelsC))
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, userChannel);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
ChannelOptimizer optimizer = new LeastUsedChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void testWithAllowedChannels() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String dummyBssid = "dd:dd:dd:dd:dd:dd";
|
||||
final int channelWidth = 20;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
DeviceConfig apConfig = new DeviceConfig();
|
||||
apConfig.allowedChannels = new HashMap<>();
|
||||
apConfig.allowedChannels.put("5G", Arrays.asList(48, 165));
|
||||
deviceDataManager.setDeviceApConfig(deviceA, apConfig);
|
||||
deviceDataManager.setDeviceApConfig(deviceB, apConfig);
|
||||
deviceDataManager.setDeviceApConfig(deviceC, apConfig);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A -> No APs on current channel and the current channel is in allowedChannels,
|
||||
// so stay on it (48)
|
||||
int aExpectedChannel = 48;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(36, 40, 44, 149))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, aExpectedChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
// B -> Assign to only free channel and
|
||||
// the free channel is in allowedChannels (165)
|
||||
LinkedList<Integer> channelsB = new LinkedList<>();
|
||||
channelsB.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsB.removeLast();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 40)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(40, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB, Arrays.asList(TestUtils.createWifiScanList(channelsB))
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, 165);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
// C -> No free channels, assign to least occupied in allowedChannels (48)
|
||||
LinkedList<Integer> channelsC = new LinkedList<>();
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsC.removeFirst();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 149)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(149, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC, Arrays.asList(TestUtils.createWifiScanList(channelsC))
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, 48);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
ChannelOptimizer optimizer = new LeastUsedChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void testBandwidth40() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String deviceD = "dddddddddddd";
|
||||
final String dummyBssid = "ee:ee:ee:ee:ee:ee";
|
||||
final int channelWidth = 40;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC, deviceD)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A -> No APs on current channel, so stay on it (48)
|
||||
int aExpectedChannel = 157;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(36, 40, 44, 149))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, aExpectedChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
// B -> No free channels (because the neighboring channels are occupied)
|
||||
// assign to least occupied (157)
|
||||
LinkedList<Integer> channelsB = new LinkedList<>();
|
||||
channelsB.addAll(Arrays.asList(40, 48, 153, 161));
|
||||
channelsB.addAll(Arrays.asList(40, 48, 153, 161));
|
||||
int bExpectedChannel = channelsB.removeLast() - 4; // upper extension
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 40)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(40, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB, Arrays.asList(TestUtils.createWifiScanList(channelsB))
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, bExpectedChannel);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
// C -> No free channels, assign to least occupied (36)
|
||||
LinkedList<Integer> channelsC = new LinkedList<>();
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
int cExpectedChannel = channelsC.removeFirst();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 149)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(149, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC, Arrays.asList(TestUtils.createWifiScanList(channelsC))
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, cExpectedChannel);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
// D -> Assign to only free channel for 40 MHz (157)
|
||||
LinkedList<Integer> channelsD = new LinkedList<>();
|
||||
channelsD.addAll(Arrays.asList(36, 44, 149, 157));
|
||||
int dExpectedChannel = channelsD.removeLast();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceD, TestUtils.createDeviceStatus(band, 40)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceD, TestUtils.createState(40, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceD, Arrays.asList(TestUtils.createWifiScanList(channelsD))
|
||||
);
|
||||
Map<String, Integer> radioMapD = new HashMap<>();
|
||||
radioMapD.put(band, dExpectedChannel);
|
||||
expected.put(deviceD, radioMapD);
|
||||
|
||||
ChannelOptimizer optimizer = new LeastUsedChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void testBandwidth80() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String deviceD = "dddddddddddd";
|
||||
final String deviceE = "eeeeeeeeeeee";
|
||||
final String dummyBssid = "ff:ff:ff:ff:ff:ff";
|
||||
final int channelWidth = 80;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(
|
||||
TEST_ZONE, deviceA, deviceB, deviceC, deviceD, deviceE
|
||||
)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A -> No APs on current channel, so stay on it (36)
|
||||
int aExpectedChannel = 36;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(149, 157, 165))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, aExpectedChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
// B -> Assign to only free channel (149)
|
||||
LinkedList<Integer> channelsB = new LinkedList<>();
|
||||
channelsB.addAll(Arrays.asList(40, 48, 149));
|
||||
int bExpectedChannel = channelsB.removeLast();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(36, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB, Arrays.asList(TestUtils.createWifiScanList(channelsB))
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, bExpectedChannel);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
// C -> No free channels, assign to least occupied (36)
|
||||
LinkedList<Integer> channelsC = new LinkedList<>();
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
int cExpectedChannel = channelsC.removeFirst();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 149)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(149, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC, Arrays.asList(TestUtils.createWifiScanList(channelsC))
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, cExpectedChannel);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
// D -> No free channels (because the neighboring channels are occupied)
|
||||
// assign to least occupied (149)
|
||||
LinkedList<Integer> channelsD = new LinkedList<>();
|
||||
channelsD.addAll(Arrays.asList(40, 48, 153, 161));
|
||||
channelsD.addAll(Arrays.asList(40, 48, 153, 161));
|
||||
int dExpectedChannel = channelsD.removeLast() - 12;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceD, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceD, TestUtils.createState(36, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceD, Arrays.asList(TestUtils.createWifiScanList(channelsD))
|
||||
);
|
||||
Map<String, Integer> radioMapD = new HashMap<>();
|
||||
radioMapD.put(band, dExpectedChannel);
|
||||
expected.put(deviceD, radioMapD);
|
||||
|
||||
|
||||
// E -> The allowedChannels are not valid since 80 MHz supports 36 and 149
|
||||
// The availableChannelsList will fall back to the default available channels
|
||||
// No APs on current channel, so stay on it (36)
|
||||
DeviceConfig apConfig = new DeviceConfig();
|
||||
apConfig.allowedChannels = new HashMap<>();
|
||||
apConfig.allowedChannels.put("5G", Arrays.asList(48, 165));
|
||||
deviceDataManager.setDeviceApConfig(deviceE, apConfig);
|
||||
int eExpectedChannel = 36;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceE, TestUtils.createDeviceStatus(band, eExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceE, TestUtils.createState(eExpectedChannel, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceE,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(149, 157, 165))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapE = new HashMap<>();
|
||||
radioMapE.put(band, eExpectedChannel);
|
||||
expected.put(deviceE, radioMapE);
|
||||
|
||||
ChannelOptimizer optimizer = new LeastUsedChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(7)
|
||||
void testBandwidthScan() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String dummyBssid = "dd:dd:dd:dd:dd:dd";
|
||||
final int channelWidth = 20;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A -> No APs on current channel, so stay on it (48)
|
||||
int aExpectedChannel = 48;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(36, 157))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, aExpectedChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
// B -> Same setting as A, but the scan results are bandwidth aware
|
||||
// Assign to only free channel (165)
|
||||
int bExpectedChannel = 165;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 48)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(48, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanListWithWidth(
|
||||
Arrays.asList(36, 157),
|
||||
Arrays.asList(
|
||||
"JAUWAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"nQUAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
|
||||
),
|
||||
Arrays.asList("ASoAAAA=", "AZsAAAA=")
|
||||
)
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, bExpectedChannel);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
// C -> No free channels, assign to least occupied (36)
|
||||
LinkedList<Integer> channelsC1 = new LinkedList<>(); // bandwidth-agnostic
|
||||
LinkedList<Integer> channelsC2 = new LinkedList<>(); // bandwidth-aware
|
||||
channelsC1.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
channelsC2.addAll(Arrays.asList(36, 157, 165));
|
||||
int cExpectedChannel = channelsC1.removeFirst();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 149)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(149, channelWidth, dummyBssid)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC, Arrays.asList(TestUtils.createWifiScanList(channelsC1))
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanListWithWidth(
|
||||
channelsC2,
|
||||
Arrays.asList(
|
||||
"JAUWAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"nQUAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
|
||||
),
|
||||
Arrays.asList("ASoAAAA=", "AZsAAAA=")
|
||||
)
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, cExpectedChannel);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
ChannelOptimizer optimizer = new LeastUsedChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
|
||||
import com.facebook.openwifirrm.DeviceConfig;
|
||||
import com.facebook.openwifirrm.DeviceDataManager;
|
||||
import com.facebook.openwifirrm.modules.Modeler.DataModel;
|
||||
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class LocationBasedOptimalTPCTest {
|
||||
/** Test zone name. */
|
||||
private static final String TEST_ZONE = "test-zone";
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void testPermutations() throws Exception {
|
||||
// n = 0 -> empty list
|
||||
List<List<Integer>> combinations1 = LocationBasedOptimalTPC
|
||||
.getPermutationsWithRepetitions(new ArrayList<>(Arrays.asList(29, 30)), 0);
|
||||
List<List<Integer>> expectedAnswer1 = new ArrayList<>();
|
||||
assertEquals(expectedAnswer1, combinations1);
|
||||
|
||||
// n != 0 -> n^p combinations where p = the size of choices
|
||||
List<List<Integer>> combinations2 = LocationBasedOptimalTPC
|
||||
.getPermutationsWithRepetitions(new ArrayList<>(Arrays.asList(29, 30)), 2);
|
||||
List<List<Integer>> expectedAnswer2 = new ArrayList<>();
|
||||
expectedAnswer2.add(new ArrayList<>(Arrays.asList(29, 29)));
|
||||
expectedAnswer2.add(new ArrayList<>(Arrays.asList(29, 30)));
|
||||
expectedAnswer2.add(new ArrayList<>(Arrays.asList(30, 29)));
|
||||
expectedAnswer2.add(new ArrayList<>(Arrays.asList(30, 30)));
|
||||
assertEquals(expectedAnswer2, combinations2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void testRunLocationBasedOptimalTPC() throws Exception {
|
||||
List<Integer> txPowerList = LocationBasedOptimalTPC.runLocationBasedOptimalTPC(
|
||||
500,
|
||||
4,
|
||||
new ArrayList<>(Arrays.asList(408.0, 453.0, 64.0, 457.0)),
|
||||
new ArrayList<>(Arrays.asList(317.0, 49.0, 140.0, 274.0)),
|
||||
new ArrayList<>(Arrays.asList(29, 30))
|
||||
);
|
||||
assertEquals(new ArrayList<>(Arrays.asList(30, 30, 30, 29)), txPowerList);
|
||||
|
||||
List<Integer> txPowerList2 = LocationBasedOptimalTPC.runLocationBasedOptimalTPC(
|
||||
500,
|
||||
4,
|
||||
new ArrayList<>(Arrays.asList(408.0, 453.0, 64.0, 457.0)),
|
||||
new ArrayList<>(Arrays.asList(317.0, 49.0, 140.0, 274.0)),
|
||||
new ArrayList<>(Arrays.asList(0, 1))
|
||||
);
|
||||
assertEquals(new ArrayList<>(Arrays.asList(30, 30, 30, 30)), txPowerList2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void testLocationBasedOptimalTPCSuccessCase() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String dummyBssid = "dd:dd:dd:dd:dd:dd";
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
final DeviceConfig apCfgA = new DeviceConfig();
|
||||
final DeviceConfig apCfgB = new DeviceConfig();
|
||||
final DeviceConfig apCfgC = new DeviceConfig();
|
||||
apCfgA.boundary = 500;
|
||||
apCfgA.location = new ArrayList<>(Arrays.asList(408, 317));
|
||||
apCfgA.allowedTxPowers = new HashMap<>();
|
||||
apCfgA.allowedTxPowers.put(band, Arrays.asList(29, 30));
|
||||
apCfgB.boundary = 500;
|
||||
apCfgB.location = new ArrayList<>(Arrays.asList(453, 49));
|
||||
apCfgC.boundary = 500;
|
||||
apCfgC.location = new ArrayList<>(Arrays.asList(64, 140));
|
||||
deviceDataManager.setDeviceApConfig(deviceA, apCfgA);
|
||||
deviceDataManager.setDeviceApConfig(deviceB, apCfgB);
|
||||
deviceDataManager.setDeviceApConfig(deviceC, apCfgC);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, 30);
|
||||
expected.put(deviceA, radioMapA);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, 29);
|
||||
expected.put(deviceB, radioMapB);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, 30);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
LocationBasedOptimalTPC optimizer = new LocationBasedOptimalTPC(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
|
||||
assertEquals(expected, optimizer.computeTxPowerMap()
|
||||
);
|
||||
|
||||
// deviceB is removed due to negative location data.
|
||||
// The other two still participate the algorithm.
|
||||
DeviceDataManager deviceDataManager2 = new DeviceDataManager();
|
||||
deviceDataManager2.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
final DeviceConfig apCfgA2 = new DeviceConfig();
|
||||
final DeviceConfig apCfgB2 = new DeviceConfig();
|
||||
final DeviceConfig apCfgC2 = new DeviceConfig();
|
||||
apCfgA2.boundary = 100;
|
||||
apCfgA2.location = new ArrayList<>(Arrays.asList(10, 10));
|
||||
apCfgB2.boundary = 50;
|
||||
apCfgB2.location = new ArrayList<>(Arrays.asList(-30, 10));
|
||||
apCfgC2.boundary = 100;
|
||||
apCfgC2.location = new ArrayList<>(Arrays.asList(90, 10));
|
||||
deviceDataManager2.setDeviceApConfig(deviceA, apCfgA2);
|
||||
deviceDataManager2.setDeviceApConfig(deviceB, apCfgB2);
|
||||
deviceDataManager2.setDeviceApConfig(deviceC, apCfgC2);
|
||||
|
||||
DataModel dataModel2 = new DataModel();
|
||||
dataModel2.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel2.latestState.put(
|
||||
deviceA, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel2.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel2.latestState.put(
|
||||
deviceB, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel2.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel2.latestState.put(
|
||||
deviceC, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
Map<String, Map<String, Integer>> expected2 = new HashMap<>();
|
||||
Map<String, Integer> radioMapA2 = new HashMap<>();
|
||||
radioMapA2.put(band, 30);
|
||||
expected2.put(deviceA, radioMapA2);
|
||||
Map<String, Integer> radioMapC2 = new HashMap<>();
|
||||
radioMapC2.put(band, 0);
|
||||
expected2.put(deviceC, radioMapC2);
|
||||
|
||||
LocationBasedOptimalTPC optimizer5 = new LocationBasedOptimalTPC(
|
||||
dataModel2, TEST_ZONE, deviceDataManager2
|
||||
);
|
||||
|
||||
assertEquals(expected2, optimizer5.computeTxPowerMap()
|
||||
);
|
||||
}
|
||||
@Test
|
||||
@Order(4)
|
||||
void testLocationBasedOptimalTPCFailedCase() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String dummyBssid = "dd:dd:dd:dd:dd:dd";
|
||||
|
||||
// No invalid APs, missing location data!
|
||||
DeviceDataManager deviceDataManager1 = new DeviceDataManager();
|
||||
deviceDataManager1.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
final DeviceConfig apCfgA1 = new DeviceConfig();
|
||||
final DeviceConfig apCfgB1 = new DeviceConfig();
|
||||
final DeviceConfig apCfgC1 = new DeviceConfig();
|
||||
deviceDataManager1.setDeviceApConfig(deviceA, apCfgA1);
|
||||
deviceDataManager1.setDeviceApConfig(deviceB, apCfgB1);
|
||||
deviceDataManager1.setDeviceApConfig(deviceC, apCfgC1);
|
||||
|
||||
DataModel dataModel1 = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected1 = new HashMap<>();
|
||||
|
||||
LocationBasedOptimalTPC optimizer1 = new LocationBasedOptimalTPC(
|
||||
dataModel1, TEST_ZONE, deviceDataManager1
|
||||
);
|
||||
|
||||
assertEquals(expected1, optimizer1.computeTxPowerMap()
|
||||
);
|
||||
|
||||
// Invalid boundary since it is smaller than the given location!
|
||||
DeviceDataManager deviceDataManager2 = new DeviceDataManager();
|
||||
deviceDataManager2.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
final DeviceConfig apCfgA2 = new DeviceConfig();
|
||||
final DeviceConfig apCfgB2 = new DeviceConfig();
|
||||
final DeviceConfig apCfgC2 = new DeviceConfig();
|
||||
apCfgA2.boundary = 100;
|
||||
apCfgA2.location = new ArrayList<>(Arrays.asList(10, 10));
|
||||
apCfgB2.boundary = 50;
|
||||
apCfgB2.location = new ArrayList<>(Arrays.asList(30, 10));
|
||||
apCfgC2.boundary = 100;
|
||||
apCfgC2.location = new ArrayList<>(Arrays.asList(110, 10));
|
||||
deviceDataManager2.setDeviceApConfig(deviceA, apCfgA2);
|
||||
deviceDataManager2.setDeviceApConfig(deviceB, apCfgB2);
|
||||
deviceDataManager2.setDeviceApConfig(deviceC, apCfgC2);
|
||||
|
||||
DataModel dataModel2 = new DataModel();
|
||||
dataModel2.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel2.latestState.put(
|
||||
deviceA, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel2.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel2.latestState.put(
|
||||
deviceB, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel2.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel2.latestState.put(
|
||||
deviceC, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
Map<String, Map<String, Integer>> expected2 = new HashMap<>();
|
||||
|
||||
LocationBasedOptimalTPC optimizer2 = new LocationBasedOptimalTPC(
|
||||
dataModel2, TEST_ZONE, deviceDataManager2
|
||||
);
|
||||
|
||||
assertEquals(expected2, optimizer2.computeTxPowerMap()
|
||||
);
|
||||
|
||||
// Invalid txPower choices! The intersection is an empty set.
|
||||
DeviceDataManager deviceDataManager3 = new DeviceDataManager();
|
||||
deviceDataManager3.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
final DeviceConfig apCfgA3 = new DeviceConfig();
|
||||
final DeviceConfig apCfgB3 = new DeviceConfig();
|
||||
final DeviceConfig apCfgC3 = new DeviceConfig();
|
||||
apCfgA3.boundary = 100;
|
||||
apCfgA3.location = new ArrayList<>(Arrays.asList(10, 10));
|
||||
apCfgA3.allowedTxPowers = new HashMap<>();
|
||||
apCfgA3.allowedTxPowers.put(band, Arrays.asList(1, 2));
|
||||
apCfgB3.boundary = 100;
|
||||
apCfgB3.location = new ArrayList<>(Arrays.asList(30, 10));
|
||||
apCfgB3.allowedTxPowers = new HashMap<>();
|
||||
apCfgB3.allowedTxPowers.put(band, Arrays.asList(3, 4));
|
||||
apCfgC3.boundary = 100;
|
||||
apCfgC3.location = new ArrayList<>(Arrays.asList(50, 10));
|
||||
deviceDataManager3.setDeviceApConfig(deviceA, apCfgA3);
|
||||
deviceDataManager3.setDeviceApConfig(deviceB, apCfgB3);
|
||||
deviceDataManager3.setDeviceApConfig(deviceC, apCfgC3);
|
||||
|
||||
DataModel dataModel3 = new DataModel();
|
||||
dataModel3.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel3.latestState.put(
|
||||
deviceA, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel3.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel3.latestState.put(
|
||||
deviceB, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel3.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel3.latestState.put(
|
||||
deviceC, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
Map<String, Map<String, Integer>> expected3 = new HashMap<>();
|
||||
|
||||
LocationBasedOptimalTPC optimizer3 = new LocationBasedOptimalTPC(
|
||||
dataModel3, TEST_ZONE, deviceDataManager3
|
||||
);
|
||||
|
||||
assertEquals(expected3, optimizer3.computeTxPowerMap()
|
||||
);
|
||||
|
||||
// Invalid operation! Complexity issue!
|
||||
DeviceDataManager deviceDataManager4 = new DeviceDataManager();
|
||||
deviceDataManager4.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
final DeviceConfig apCfgA4 = new DeviceConfig();
|
||||
final DeviceConfig apCfgB4 = new DeviceConfig();
|
||||
final DeviceConfig apCfgC4 = new DeviceConfig();
|
||||
apCfgA4.boundary = 100;
|
||||
apCfgA4.location = new ArrayList<>(Arrays.asList(10, 10));
|
||||
apCfgB4.boundary = 100;
|
||||
apCfgB4.location = new ArrayList<>(Arrays.asList(30, 10));
|
||||
apCfgC4.boundary = 100;
|
||||
apCfgC4.location = new ArrayList<>(Arrays.asList(50, 10));
|
||||
deviceDataManager4.setDeviceApConfig(deviceA, apCfgA4);
|
||||
deviceDataManager4.setDeviceApConfig(deviceB, apCfgB4);
|
||||
deviceDataManager4.setDeviceApConfig(deviceC, apCfgC4);
|
||||
|
||||
DataModel dataModel4 = new DataModel();
|
||||
dataModel4.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel4.latestState.put(
|
||||
deviceA, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel4.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel4.latestState.put(
|
||||
deviceB, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
dataModel4.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 36)
|
||||
);
|
||||
dataModel4.latestState.put(
|
||||
deviceC, TestUtils.createState(36, 20, dummyBssid)
|
||||
);
|
||||
Map<String, Map<String, Integer>> expected4 = new HashMap<>();
|
||||
|
||||
LocationBasedOptimalTPC optimizer4 = new LocationBasedOptimalTPC(
|
||||
dataModel4, TEST_ZONE, deviceDataManager4
|
||||
);
|
||||
|
||||
assertEquals(expected4, optimizer4.computeTxPowerMap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
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.ucentral.UCentralUtils.WifiScanEntry;
|
||||
import com.google.gson.JsonArray;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class MeasurementBasedApApTPCTest {
|
||||
/** Test zone name. */
|
||||
private static final String TEST_ZONE = "test-zone";
|
||||
private static final int MAX_TX_POWER = 30;
|
||||
private static final String BAND = "5G";
|
||||
|
||||
// Serial numbers
|
||||
private static final String DEVICE_A = "aaaaaaaaaaaa";
|
||||
private static final String DEVICE_B = "bbbbbbbbbbbb";
|
||||
private static final String DEVICE_C = "cccccccccccc";
|
||||
|
||||
private static final String BSSID_A = "aa:aa:aa:aa:aa:aa";
|
||||
private static final String BSSID_B = "bb:bb:bb:bb:bb:bb";
|
||||
private static final String BSSID_C = "cc:cc:cc:cc:cc:cc";
|
||||
private static final String BSSID_Z = "zz:zz:zz:zz:zz:zz";
|
||||
|
||||
/**
|
||||
* Creates a manager with 3 devices.
|
||||
*/
|
||||
private static DeviceDataManager createDeviceDataManager() {
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, DEVICE_A, DEVICE_B, DEVICE_C)
|
||||
);
|
||||
final DeviceConfig apCfgA = new DeviceConfig();
|
||||
final DeviceConfig apCfgB = new DeviceConfig();
|
||||
final DeviceConfig apCfgC = new DeviceConfig();
|
||||
apCfgA.boundary = 500;
|
||||
apCfgA.location = List.of(408, 317);
|
||||
apCfgA.allowedTxPowers = new HashMap<>();
|
||||
apCfgA.allowedTxPowers.put(BAND, List.of(29, 30));
|
||||
apCfgB.boundary = 500;
|
||||
apCfgB.location = List.of(453, 49);
|
||||
apCfgC.boundary = 500;
|
||||
apCfgC.location = List.of(64, 140);
|
||||
deviceDataManager.setDeviceApConfig(DEVICE_A, apCfgA);
|
||||
deviceDataManager.setDeviceApConfig(DEVICE_B, apCfgB);
|
||||
deviceDataManager.setDeviceApConfig(DEVICE_C, apCfgC);
|
||||
|
||||
return deviceDataManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data model with 3 devices.
|
||||
* All are at max_tx_power, which represents the first step in greedy TPC.
|
||||
*/
|
||||
private static DataModel createModel() {
|
||||
DataModel model = new DataModel();
|
||||
|
||||
State stateA = TestUtils.createState(1, 20, 5, 36, 20, MAX_TX_POWER, BSSID_A);
|
||||
State stateB = TestUtils.createState(1, 20, 5, 36, 20, MAX_TX_POWER, BSSID_B);
|
||||
State stateC = TestUtils.createState(1, 20, 5, 36, 20, MAX_TX_POWER, BSSID_C);
|
||||
|
||||
model.latestState.put(DEVICE_A, stateA);
|
||||
model.latestState.put(DEVICE_B, stateB);
|
||||
model.latestState.put(DEVICE_C, stateC);
|
||||
|
||||
model.latestDeviceStatus.put(DEVICE_A, TestUtils.createDeviceStatusDualBand(1, 5, 36, MAX_TX_POWER));
|
||||
model.latestDeviceStatus.put(DEVICE_B, TestUtils.createDeviceStatusDualBand(1, 5, 36, MAX_TX_POWER));
|
||||
model.latestDeviceStatus.put(DEVICE_C, TestUtils.createDeviceStatusDualBand(1, 5, 36, MAX_TX_POWER));
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private static Map<String, List<List<WifiScanEntry>>> createLatestWifiScansA() {
|
||||
Map<String, Integer> rssiFromA = Map.ofEntries(
|
||||
Map.entry(BSSID_Z, -91),
|
||||
Map.entry(BSSID_B, -81),
|
||||
Map.entry(BSSID_C, -61)
|
||||
);
|
||||
List<WifiScanEntry> wifiScanA = TestUtils.createWifiScanListWithBssid(rssiFromA);
|
||||
|
||||
Map<String, Integer> rssiFromB = Map.ofEntries(
|
||||
Map.entry(BSSID_Z, -92),
|
||||
Map.entry(BSSID_A, -72),
|
||||
Map.entry(BSSID_C, -62)
|
||||
);
|
||||
List<WifiScanEntry> wifiScanB = TestUtils.createWifiScanListWithBssid(rssiFromB);
|
||||
|
||||
Map<String, Integer> rssiFromC = Map.ofEntries(
|
||||
Map.entry(BSSID_Z, -93),
|
||||
Map.entry(BSSID_A, -73),
|
||||
Map.entry(BSSID_B, -83)
|
||||
);
|
||||
List<WifiScanEntry> wifiScanC = TestUtils.createWifiScanListWithBssid(rssiFromC);
|
||||
|
||||
Map<String, List<List<WifiScanEntry>>> latestWifiScans = new HashMap<>();
|
||||
latestWifiScans.put(DEVICE_A, List.of(wifiScanA));
|
||||
latestWifiScans.put(DEVICE_B, List.of(wifiScanB));
|
||||
latestWifiScans.put(DEVICE_C, List.of(wifiScanC));
|
||||
|
||||
return latestWifiScans;
|
||||
}
|
||||
|
||||
private static Map<String, List<List<WifiScanEntry>>> createLatestWifiScansB() {
|
||||
Map<String, Integer> rssiFromA = Map.ofEntries(
|
||||
Map.entry(BSSID_B, -65),
|
||||
Map.entry(BSSID_C, -23)
|
||||
);
|
||||
List<WifiScanEntry> wifiScanA = TestUtils.createWifiScanListWithBssid(rssiFromA);
|
||||
|
||||
Map<String, Integer> rssiFromB = Map.ofEntries(
|
||||
Map.entry(BSSID_A, -52),
|
||||
Map.entry(BSSID_C, -60)
|
||||
);
|
||||
List<WifiScanEntry> wifiScanB = TestUtils.createWifiScanListWithBssid(rssiFromB);
|
||||
|
||||
Map<String, Integer> rssiFromC = Map.ofEntries(
|
||||
Map.entry(BSSID_A, -20),
|
||||
Map.entry(BSSID_B, -63)
|
||||
);
|
||||
List<WifiScanEntry> wifiScanC = TestUtils.createWifiScanListWithBssid(rssiFromC);
|
||||
|
||||
Map<String, List<List<WifiScanEntry>>> latestWifiScans = new HashMap<>();
|
||||
latestWifiScans.put(DEVICE_A, List.of(wifiScanA));
|
||||
latestWifiScans.put(DEVICE_B, List.of(wifiScanB));
|
||||
latestWifiScans.put(DEVICE_C, List.of(wifiScanC));
|
||||
|
||||
return latestWifiScans;
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void test_getManagedBSSIDs() throws Exception {
|
||||
DataModel dataModel = createModel();
|
||||
Set<String> managedBSSIDs = MeasurementBasedApApTPC.getManagedBSSIDs(dataModel);
|
||||
assertEquals(3, managedBSSIDs.size());
|
||||
assertTrue(managedBSSIDs.contains(BSSID_A));
|
||||
assertTrue(managedBSSIDs.contains(BSSID_B));
|
||||
assertTrue(managedBSSIDs.contains(BSSID_C));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void test_getCurrentTxPower() throws Exception {
|
||||
final int expectedTxPower = 29;
|
||||
|
||||
DataModel model = new DataModel();
|
||||
model.latestDeviceStatus.put(DEVICE_A, TestUtils.createDeviceStatusDualBand(1, 5, 36, expectedTxPower));
|
||||
|
||||
JsonArray radioStatuses = model.latestDeviceStatus.get(DEVICE_A).getAsJsonArray();
|
||||
int txPower = MeasurementBasedApApTPC.getCurrentTxPower(radioStatuses);
|
||||
assertEquals(expectedTxPower, txPower);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void test_buildRssiMap() throws Exception {
|
||||
// This example includes three APs, and one AP that is unmanaged
|
||||
Set<String> bssidSet = Set.of(BSSID_A, BSSID_B, BSSID_C);
|
||||
Map<String, List<List<WifiScanEntry>>> latestWifiScans = createLatestWifiScansA();
|
||||
|
||||
Map<String, List<Integer>> rssiMap = MeasurementBasedApApTPC.buildRssiMap(bssidSet, latestWifiScans);
|
||||
|
||||
assertEquals(3, rssiMap.size());
|
||||
assertEquals(2, rssiMap.get(BSSID_A).size());
|
||||
assertEquals(2, rssiMap.get(BSSID_B).size());
|
||||
assertEquals(2, rssiMap.get(BSSID_C).size());
|
||||
|
||||
// Sanity check that values are sorted in ascending order
|
||||
assertEquals(-73, rssiMap.get(BSSID_A).get(0));
|
||||
assertEquals(-83, rssiMap.get(BSSID_B).get(0));
|
||||
assertEquals(-62, rssiMap.get(BSSID_C).get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void test_computeTxPower() throws Exception {
|
||||
// Test examples here taken from algorithm design doc from @pohanhf
|
||||
final String serialNumber = "testSerial";
|
||||
final int currentTxPower = 30;
|
||||
final int coverageThreshold = -80;
|
||||
final int nthSmallestRssi = 0;
|
||||
List<Integer> rssiValues = List.of(-66);
|
||||
|
||||
// Test examples here taken from algorithm design doc from @pohanhf
|
||||
// These are common happy path examples
|
||||
int newTxPower = MeasurementBasedApApTPC.computeTxPower(
|
||||
serialNumber,
|
||||
currentTxPower,
|
||||
rssiValues,
|
||||
coverageThreshold,
|
||||
nthSmallestRssi
|
||||
);
|
||||
assertEquals(16, newTxPower);
|
||||
|
||||
rssiValues = List.of(-62);
|
||||
newTxPower = MeasurementBasedApApTPC.computeTxPower(
|
||||
serialNumber,
|
||||
currentTxPower,
|
||||
rssiValues,
|
||||
coverageThreshold,
|
||||
nthSmallestRssi
|
||||
);
|
||||
assertEquals(12, newTxPower);
|
||||
|
||||
rssiValues = List.of(-52, -20);
|
||||
newTxPower = MeasurementBasedApApTPC.computeTxPower(
|
||||
serialNumber,
|
||||
currentTxPower,
|
||||
rssiValues,
|
||||
coverageThreshold,
|
||||
nthSmallestRssi
|
||||
);
|
||||
assertEquals(2, newTxPower);
|
||||
|
||||
// Check edge cases
|
||||
rssiValues = List.of(-30);
|
||||
newTxPower = MeasurementBasedApApTPC.computeTxPower(
|
||||
serialNumber,
|
||||
currentTxPower,
|
||||
rssiValues,
|
||||
coverageThreshold,
|
||||
nthSmallestRssi
|
||||
);
|
||||
assertEquals(0, newTxPower);
|
||||
|
||||
rssiValues = List.of();
|
||||
newTxPower = MeasurementBasedApApTPC.computeTxPower(serialNumber, 0, rssiValues, coverageThreshold, nthSmallestRssi);
|
||||
assertEquals(30, newTxPower);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void test_computeTxPowerMap() throws Exception {
|
||||
// First example here taken from algorithm design doc from @pohanhf
|
||||
DataModel dataModel = createModel();
|
||||
dataModel.latestWifiScans = createLatestWifiScansB();
|
||||
DeviceDataManager deviceDataManager = createDeviceDataManager();
|
||||
MeasurementBasedApApTPC optimizer = new MeasurementBasedApApTPC(dataModel, TEST_ZONE, deviceDataManager, -80, 0);
|
||||
|
||||
Map<String, Map<String, Integer>> txPowerMap = optimizer.computeTxPowerMap();
|
||||
|
||||
assertEquals(3, txPowerMap.size());
|
||||
assertEquals(2, txPowerMap.get(DEVICE_A).get(BAND));
|
||||
assertEquals(15, txPowerMap.get(DEVICE_B).get(BAND));
|
||||
assertEquals(10, txPowerMap.get(DEVICE_C).get(BAND));
|
||||
|
||||
// Tests an example where wifiscans are missing some data
|
||||
Map<String, Integer> rssiFromA = Map.ofEntries(Map.entry(BSSID_B, -38));
|
||||
List<WifiScanEntry> wifiScanA = TestUtils.createWifiScanListWithBssid(rssiFromA);
|
||||
|
||||
Map<String, Integer> rssiFromB = Map.ofEntries(Map.entry(BSSID_A, -39));
|
||||
List<WifiScanEntry> wifiScanB = TestUtils.createWifiScanListWithBssid(rssiFromB);
|
||||
|
||||
Map<String, List<List<WifiScanEntry>>> latestWifiScans = new HashMap<>();
|
||||
latestWifiScans.put(DEVICE_A, List.of(wifiScanA));
|
||||
latestWifiScans.put(DEVICE_B, List.of(wifiScanB));
|
||||
|
||||
dataModel = createModel();
|
||||
dataModel.latestWifiScans = latestWifiScans;
|
||||
|
||||
deviceDataManager = createDeviceDataManager();
|
||||
optimizer = new MeasurementBasedApApTPC(dataModel, TEST_ZONE, deviceDataManager);
|
||||
|
||||
txPowerMap = optimizer.computeTxPowerMap();
|
||||
|
||||
assertEquals(3, txPowerMap.size());
|
||||
assertEquals(0, txPowerMap.get(DEVICE_A).get(BAND));
|
||||
assertEquals(0, txPowerMap.get(DEVICE_B).get(BAND));
|
||||
// Since no other APs see a signal from DEVICE_C, we set the tx_power to max
|
||||
assertEquals(30, txPowerMap.get(DEVICE_C).get(BAND));
|
||||
}
|
||||
}
|
||||
@@ -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.optimizers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import com.facebook.openwifirrm.DeviceDataManager;
|
||||
import com.facebook.openwifirrm.modules.Modeler.DataModel;
|
||||
import com.facebook.openwifirrm.ucentral.models.State;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class MeasurementBasedApClientTPCTest {
|
||||
/** Test zone name. */
|
||||
private static final String TEST_ZONE = "test-zone";
|
||||
|
||||
/** Create a device state object containing the given parameters. */
|
||||
private State createState(
|
||||
String serialNumber, int curTxPower, int bandwidth, int... clientRssi
|
||||
) {
|
||||
State state = new State();
|
||||
state.radios = new JsonObject[] { new JsonObject() };
|
||||
state.radios[0].addProperty("channel", 36);
|
||||
state.radios[0].addProperty(
|
||||
"channel_width", Integer.toString(bandwidth)
|
||||
);
|
||||
state.radios[0].addProperty("tx_power", curTxPower);
|
||||
state.interfaces = new State.Interface[] { state.new Interface() };
|
||||
state.interfaces[0].ssids = new State.Interface.SSID[] {
|
||||
state.interfaces[0].new SSID()
|
||||
};
|
||||
state.interfaces[0].ssids[0].ssid = "test-ssid-" + serialNumber;
|
||||
state.interfaces[0].ssids[0].associations =
|
||||
new State.Interface.SSID.Association[clientRssi.length];
|
||||
for (int i = 0; i < clientRssi.length; i++) {
|
||||
State.Interface.SSID.Association client =
|
||||
state.interfaces[0].ssids[0].new Association();
|
||||
client.bssid = client.station = "test-client-" + i;
|
||||
client.rssi = clientRssi[i];
|
||||
state.interfaces[0].ssids[0].associations[i] = client;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void test1() throws Exception {
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String deviceD = "dddddddddddd";
|
||||
final String deviceE = "eeeeeeeeeeee";
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(
|
||||
TEST_ZONE, deviceA, deviceB, deviceC, deviceD, deviceE
|
||||
)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
dataModel.latestState.put(
|
||||
deviceA,
|
||||
createState(deviceA, 20 /*txPower*/, 20 /*bandwidth*/)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB,
|
||||
createState(deviceB, 20 /*txPower*/, 20 /*bandwidth*/, -65)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC,
|
||||
createState(deviceC, 21 /*txPower*/, 40 /*bandwidth*/, -65, -73, -58)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceD,
|
||||
createState(deviceD, 22 /*txPower*/, 20 /*bandwidth*/, -80)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceE,
|
||||
createState(deviceE, 23 /*txPower*/, 20 /*bandwidth*/, -45)
|
||||
);
|
||||
|
||||
TPC optimizer = new MeasurementBasedApClientTPC(dataModel, TEST_ZONE, deviceDataManager);
|
||||
Map<String, Map<String, Integer>> txPowerMap =
|
||||
optimizer.computeTxPowerMap();
|
||||
|
||||
// Device A: no clients
|
||||
assertEquals(10, txPowerMap.get(deviceA).get("5G"));
|
||||
|
||||
// Device B: 1 client with RSSI -65
|
||||
assertEquals(14, txPowerMap.get(deviceB).get("5G"));
|
||||
|
||||
// Device C: 3 clients with min. RSSI -73
|
||||
assertEquals(26, txPowerMap.get(deviceC).get("5G"));
|
||||
|
||||
// Device D: 1 client with RSSI -80 => set to max txPower for MCS 7
|
||||
assertEquals(28, txPowerMap.get(deviceD).get("5G"));
|
||||
|
||||
// Device E: 1 client with RSSI -45 => set to min txPower
|
||||
assertEquals(TPC.MIN_TX_POWER, txPowerMap.get(deviceE).get("5G"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import com.facebook.openwifirrm.DeviceDataManager;
|
||||
import com.facebook.openwifirrm.modules.Modeler.DataModel;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class RandomChannelInitializerTest {
|
||||
/** Test zone name. */
|
||||
private static final String TEST_ZONE = "test-zone";
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void test1() throws Exception {
|
||||
final String band = "2G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final int channelWidth = 20;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB)
|
||||
);
|
||||
|
||||
// A and B will be assigned to the same channel
|
||||
DataModel dataModel = new DataModel();
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(6, channelWidth, "ddd")
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(11, channelWidth, "eee")
|
||||
);
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, 7)
|
||||
);
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 8)
|
||||
);
|
||||
|
||||
ChannelOptimizer optimizer = new RandomChannelInitializer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
Map<String, Map<String, Integer>> channelMap =
|
||||
optimizer.computeChannelMap();
|
||||
|
||||
assertEquals(channelMap.get(deviceA), channelMap.get(deviceB));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import com.facebook.openwifirrm.DeviceDataManager;
|
||||
import com.facebook.openwifirrm.modules.Modeler.DataModel;
|
||||
import com.facebook.openwifirrm.ucentral.models.State;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class RandomTxPowerInitializerTest {
|
||||
/** Test zone name. */
|
||||
private static final String TEST_ZONE = "test-zone";
|
||||
|
||||
/** Create an empty device state object. */
|
||||
private State createState() {
|
||||
return new State();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void test1() throws Exception {
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
dataModel.latestState.put(deviceA, createState());
|
||||
dataModel.latestState.put(deviceB, createState());
|
||||
|
||||
final int txPower = 16;
|
||||
TPC optimizer = new RandomTxPowerInitializer(
|
||||
dataModel, TEST_ZONE, deviceDataManager, txPower
|
||||
);
|
||||
Map<String, Map<String, Integer>> txPowerMap =
|
||||
optimizer.computeTxPowerMap();
|
||||
|
||||
assertEquals(txPower, txPowerMap.get(deviceA).get("5G"));
|
||||
assertEquals(txPower, txPowerMap.get(deviceB).get("5G"));
|
||||
}
|
||||
}
|
||||
287
src/test/java/com/facebook/openwifirrm/optimizers/TestUtils.java
Normal file
287
src/test/java/com/facebook/openwifirrm/optimizers/TestUtils.java
Normal file
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.facebook.openwifirrm.DeviceTopology;
|
||||
import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry;
|
||||
import com.facebook.openwifirrm.ucentral.models.State;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
|
||||
public class TestUtils {
|
||||
/** The Gson instance. */
|
||||
private final static Gson gson = new Gson();
|
||||
|
||||
/** Create a topology from the given devices in a single zone. */
|
||||
public static DeviceTopology createTopology(String zone, String... devices) {
|
||||
DeviceTopology topology = new DeviceTopology();
|
||||
topology.put(zone, new TreeSet<>(Arrays.asList(devices)));
|
||||
return topology;
|
||||
}
|
||||
|
||||
/** Create a radio info entry with the given channel on a given band. */
|
||||
public static JsonArray createDeviceStatus(String band, int channel) {
|
||||
JsonArray jsonList = gson.fromJson(
|
||||
String.format(
|
||||
"[{\"band\": %s,\"channel\": %d,\"channel-mode\":\"HE\"," +
|
||||
"\"channel-width\":20,\"country\":\"CA\",\"tx-power\":20}]",
|
||||
band,
|
||||
channel
|
||||
),
|
||||
JsonArray.class
|
||||
);
|
||||
return jsonList;
|
||||
}
|
||||
|
||||
/** Create a radio info entry with the given tx powers and channels. */
|
||||
public static JsonArray createDeviceStatusDualBand(int channel2G, int txPower2G, int channel5G, int txPower5G) {
|
||||
JsonArray jsonList = gson.fromJson(
|
||||
String.format(
|
||||
"[{\"band\": \"2G\",\"channel\": %d,\"channel-mode\":\"HE\"," +
|
||||
"\"channel-width\":20,\"country\":\"CA\",\"tx-power\":%d}," +
|
||||
"{\"band\": \"5G\",\"channel\": %d,\"channel-mode\":\"HE\"," +
|
||||
"\"channel-width\":20,\"country\":\"CA\",\"tx-power\":%d}]",
|
||||
channel2G,
|
||||
txPower2G,
|
||||
channel5G,
|
||||
txPower5G
|
||||
),
|
||||
JsonArray.class
|
||||
);
|
||||
return jsonList;
|
||||
}
|
||||
|
||||
/** Create a wifi scan entry with the given channel. */
|
||||
public static WifiScanEntry createWifiScanEntry(int channel) {
|
||||
WifiScanEntry entry = new WifiScanEntry();
|
||||
entry.channel = channel;
|
||||
entry.signal = -60;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Create a list of wifi scan entries with the given channels. */
|
||||
public static List<WifiScanEntry> createWifiScanList(List<Integer> channels) {
|
||||
return channels
|
||||
.stream()
|
||||
.map(c -> createWifiScanEntry(c))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** Create a wifi scan entry with the given BSSID and RSSI. */
|
||||
public static WifiScanEntry createWifiScanEntryWithBssid(String bssid, Integer rssi) {
|
||||
WifiScanEntry entry = new WifiScanEntry();
|
||||
entry.channel = 36;
|
||||
entry.bssid = bssid;
|
||||
entry.signal = rssi;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Create a list of wifi scan entries with the BSSIDs and RSSIs. */
|
||||
public static List<WifiScanEntry> createWifiScanListWithBssid(Map<String, Integer> bssidToRssi) {
|
||||
Set<String> bssidSet = bssidToRssi.keySet();
|
||||
return bssidSet
|
||||
.stream()
|
||||
.map(bssid -> createWifiScanEntryWithBssid(bssid, bssidToRssi.get(bssid)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wifi scan entry with the given channel
|
||||
* and channel width info (in the format of HT operation and VHT operation).
|
||||
*/
|
||||
public static WifiScanEntry createWifiScanEntryWithWidth(
|
||||
int channel,
|
||||
String htOper,
|
||||
String vhtOper
|
||||
) {
|
||||
WifiScanEntry entry = new WifiScanEntry();
|
||||
entry.channel = channel;
|
||||
entry.signal = -60;
|
||||
entry.ht_oper = htOper;
|
||||
entry.vht_oper = vhtOper;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list of wifi scan entries with the given channels
|
||||
* and channel width info (in the format of HT operation and VHT operation).
|
||||
*/
|
||||
public static List<WifiScanEntry> createWifiScanListWithWidth(
|
||||
List<Integer> channels,
|
||||
List<String> htOper,
|
||||
List<String> vhtOper
|
||||
) {
|
||||
List<WifiScanEntry> wifiScanResults = new ArrayList<>();
|
||||
for (int i = 0; i < channels.size(); i++) {
|
||||
WifiScanEntry wifiScanResult = createWifiScanEntryWithWidth(
|
||||
channels.get(i),
|
||||
((i >= htOper.size()) ? null : htOper.get(i)),
|
||||
((i >= vhtOper.size()) ? null : vhtOper.get(i))
|
||||
);
|
||||
wifiScanResults.add(wifiScanResult);
|
||||
}
|
||||
return wifiScanResults;
|
||||
}
|
||||
|
||||
/** Create a wifi scan entry with the given channel and bssid. */
|
||||
public static WifiScanEntry createWifiScanEntryWithBssid(
|
||||
int channel, String bssid
|
||||
) {
|
||||
WifiScanEntry entry = new WifiScanEntry();
|
||||
entry.channel = channel;
|
||||
entry.bssid = bssid;
|
||||
entry.signal = -60;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Create a list of wifi scan entries with the given channels and bssids. */
|
||||
public static List<WifiScanEntry> createWifiScanList(
|
||||
List<Integer> channels, List<String> bssids
|
||||
) {
|
||||
List<WifiScanEntry> wifiScanList = new ArrayList<>();
|
||||
for (
|
||||
int chnIndex = 0;
|
||||
chnIndex < channels.size();
|
||||
chnIndex ++
|
||||
) {
|
||||
wifiScanList.add(createWifiScanEntryWithBssid(
|
||||
channels.get(chnIndex), bssids.get(chnIndex))
|
||||
);
|
||||
}
|
||||
return wifiScanList;
|
||||
}
|
||||
|
||||
/** Create a device state object with the given radio channel. */
|
||||
public static State createState(int channel, int channelWidth, String bssid) {
|
||||
return createState(channel, channelWidth, 20, 1, 20, 0, bssid);
|
||||
}
|
||||
|
||||
/** Create a device state object with the two given radio channels. */
|
||||
public static State createState(
|
||||
int channelA,
|
||||
int channelWidthA,
|
||||
int txPowerA,
|
||||
int channelB,
|
||||
int channelWidthB,
|
||||
int txPowerB,
|
||||
String bssid
|
||||
) {
|
||||
State state = gson.fromJson(
|
||||
"{\n" +
|
||||
" \"interfaces\": [\n" +
|
||||
" {\n" +
|
||||
" \"counters\": {\n" +
|
||||
" \"collisions\": 0,\n" +
|
||||
" \"multicast\": 6,\n" +
|
||||
" \"rx_bytes\": 13759,\n" +
|
||||
" \"rx_dropped\": 0,\n" +
|
||||
" \"rx_errors\": 0,\n" +
|
||||
" \"rx_packets\": 60,\n" +
|
||||
" \"tx_bytes\": 7051,\n" +
|
||||
" \"tx_dropped\": 0,\n" +
|
||||
" \"tx_errors\": 0,\n" +
|
||||
" \"tx_packets\": 27\n" +
|
||||
" },\n" +
|
||||
" \"location\": \"/interfaces/0\",\n" +
|
||||
" \"name\": \"up0v0\",\n" +
|
||||
" \"ssids\": [\n" +
|
||||
" {\n" +
|
||||
" \"counters\": {\n" +
|
||||
" \"collisions\": 0,\n" +
|
||||
" \"multicast\": 6,\n" +
|
||||
" \"rx_bytes\": 13759,\n" +
|
||||
" \"rx_dropped\": 0,\n" +
|
||||
" \"rx_errors\": 0,\n" +
|
||||
" \"rx_packets\": 60,\n" +
|
||||
" \"tx_bytes\": 7051,\n" +
|
||||
" \"tx_dropped\": 0,\n" +
|
||||
" \"tx_errors\": 0,\n" +
|
||||
" \"tx_packets\": 27\n" +
|
||||
" },\n" +
|
||||
" \"iface\": \"wlan0\",\n" +
|
||||
" \"mode\": \"ap\",\n" +
|
||||
" \"phy\": \"platform/soc/c000000.wifi\",\n" +
|
||||
" \"radio\": {\n" +
|
||||
" \"$ref\": \"#/radios/0\"\n" +
|
||||
" },\n" +
|
||||
" \"ssid\": \"OpenWifi_dddd\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"counters\": {\n" +
|
||||
" \"collisions\": 0,\n" +
|
||||
" \"multicast\": 0,\n" +
|
||||
" \"rx_bytes\": 0,\n" +
|
||||
" \"rx_dropped\": 0,\n" +
|
||||
" \"rx_errors\": 0,\n" +
|
||||
" \"rx_packets\": 0,\n" +
|
||||
" \"tx_bytes\": 4660,\n" +
|
||||
" \"tx_dropped\": 0,\n" +
|
||||
" \"tx_errors\": 0,\n" +
|
||||
" \"tx_packets\": 10\n" +
|
||||
" },\n" +
|
||||
" \"location\": \"/interfaces/1\",\n" +
|
||||
" \"name\": \"down1v0\"\n" +
|
||||
" }\n" +
|
||||
" ],\n" +
|
||||
" \"radios\": [\n" +
|
||||
" {\n" +
|
||||
" \"active_ms\": 564328,\n" +
|
||||
" \"busy_ms\": 36998,\n" +
|
||||
" \"noise\": 4294967193,\n" +
|
||||
" \"phy\": \"platform/soc/c000000.wifi\",\n" +
|
||||
" \"receive_ms\": 28,\n" +
|
||||
" \"temperature\": 45,\n" +
|
||||
" \"transmit_ms\": 4893\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"active_ms\": 564328,\n" +
|
||||
" \"busy_ms\": 36998,\n" +
|
||||
" \"noise\": 4294967193,\n" +
|
||||
" \"phy\": \"platform/soc/c000000.wifi\",\n" +
|
||||
" \"receive_ms\": 28,\n" +
|
||||
" \"temperature\": 45,\n" +
|
||||
" \"transmit_ms\": 4893\n" +
|
||||
" }\n" +
|
||||
" ],\n" +
|
||||
" \"unit\": {\n" +
|
||||
" \"load\": [\n" +
|
||||
" 0,\n" +
|
||||
" 0,\n" +
|
||||
" 0\n" +
|
||||
" ],\n" +
|
||||
" \"localtime\": 1632527275,\n" +
|
||||
" \"memory\": {\n" +
|
||||
" \"free\": 788930560,\n" +
|
||||
" \"total\": 973561856\n" +
|
||||
" },\n" +
|
||||
" \"uptime\": 684456\n" +
|
||||
" }\n" +
|
||||
"}",
|
||||
State.class
|
||||
);
|
||||
state.radios[0].addProperty("channel", channelA);
|
||||
state.radios[0].addProperty("channel_width", channelWidthA);
|
||||
state.radios[0].addProperty("tx_power", txPowerA);
|
||||
state.radios[1].addProperty("channel", channelB);
|
||||
state.radios[1].addProperty("channel_width", channelWidthB);
|
||||
state.radios[1].addProperty("tx_power", txPowerB);
|
||||
state.interfaces[0].ssids[0].bssid = bssid;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import com.facebook.openwifirrm.DeviceDataManager;
|
||||
import com.facebook.openwifirrm.modules.Modeler.DataModel;
|
||||
|
||||
@TestMethodOrder(OrderAnnotation.class)
|
||||
public class UnmanagedApAwareChannelOptimizerTest {
|
||||
/** Test zone name. */
|
||||
private static final String TEST_ZONE = "test-zone";
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void test5G() throws Exception {
|
||||
final String band = "5G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String bssidA = "aa:aa:aa:aa:aa:aa";
|
||||
final String bssidB = "bb:bb:bb:bb:bb:bb";
|
||||
final String bssidC = "cc:cc:cc:cc:cc:cc";
|
||||
final int channelWidth = 20;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A -> No APs on current channel, so stay on it (48)
|
||||
int aExpectedChannel = 48;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, bssidA)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(
|
||||
Arrays.asList(36, 36, 40, 44, 149, 165, 165, 165, 165, 165)
|
||||
)
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, aExpectedChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
// B -> Assign to only free channel (165)
|
||||
LinkedList<Integer> channelsB = new LinkedList<>();
|
||||
channelsB.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
int bExpectedChannel = channelsB.removeLast();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 40)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(40, channelWidth, bssidB)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB, Arrays.asList(TestUtils.createWifiScanList(channelsB))
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, bExpectedChannel);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
// C -> No free channels, assign to the channel with the least weight (48)
|
||||
// since A is on 48, the weight of channel 48 is lower than the other channels
|
||||
LinkedList<Integer> channelsC = new LinkedList<>();
|
||||
channelsC.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
LinkedList<String> bssidsC = new LinkedList<>(
|
||||
Arrays.asList(
|
||||
"dd:dd:dd:dd:dd:dd", "ee:ee:ee:ee:ee:ee", "ff:ff:ff:ff:ff:ff",
|
||||
bssidA, "gg:gg:gg:gg:gg:gg", "hh:hh:hh:hh:hh:hh",
|
||||
"ii:ii:ii:ii:ii:ii", "jj:jj:jj:jj:jj:jj", "kk:kk:kk:kk:kk:kk"
|
||||
)
|
||||
);
|
||||
int cExpectedChannel = 48;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 149)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(149, channelWidth, bssidC)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC, Arrays.asList(TestUtils.createWifiScanList(channelsC, bssidsC))
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, cExpectedChannel);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
ChannelOptimizer optimizer = new UnmanagedApAwareChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void test2G() throws Exception {
|
||||
final String band = "2G";
|
||||
final String deviceA = "aaaaaaaaaaaa";
|
||||
final String deviceB = "bbbbbbbbbbbb";
|
||||
final String deviceC = "cccccccccccc";
|
||||
final String bssidA = "aa:aa:aa:aa:aa:aa";
|
||||
final String bssidB = "bb:bb:bb:bb:bb:bb";
|
||||
final String bssidC = "cc:cc:cc:cc:cc:cc";
|
||||
final int channelWidth = 20;
|
||||
|
||||
DeviceDataManager deviceDataManager = new DeviceDataManager();
|
||||
deviceDataManager.setTopology(
|
||||
TestUtils.createTopology(TEST_ZONE, deviceA, deviceB, deviceC)
|
||||
);
|
||||
|
||||
DataModel dataModel = new DataModel();
|
||||
Map<String, Map<String, Integer>> expected = new HashMap<>();
|
||||
|
||||
// A -> No APs on current channel, so stay on it (1)
|
||||
int aExpectedChannel = 1;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceA, TestUtils.createDeviceStatus(band, aExpectedChannel)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceA, TestUtils.createState(aExpectedChannel, channelWidth, bssidA)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceA,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(6, 7, 8, 9, 10, 11))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapA = new HashMap<>();
|
||||
radioMapA.put(band, aExpectedChannel);
|
||||
expected.put(deviceA, radioMapA);
|
||||
|
||||
// B -> No free channels, assign to least occupied (11)
|
||||
LinkedList<Integer> channelsB = new LinkedList<>();
|
||||
channelsB.addAll(ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(band));
|
||||
int bExpectedChannel = channelsB.removeLast();
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceB, TestUtils.createDeviceStatus(band, 6)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceB, TestUtils.createState(6, channelWidth, bssidB)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceB, Arrays.asList(TestUtils.createWifiScanList(channelsB))
|
||||
);
|
||||
Map<String, Integer> radioMapB = new HashMap<>();
|
||||
radioMapB.put(band, bExpectedChannel);
|
||||
expected.put(deviceB, radioMapB);
|
||||
|
||||
// C -> Assigned to only free prioritized channel (1)
|
||||
int cExpectedChannel = 1;
|
||||
dataModel.latestDeviceStatus.put(
|
||||
deviceC, TestUtils.createDeviceStatus(band, 6)
|
||||
);
|
||||
dataModel.latestState.put(
|
||||
deviceC, TestUtils.createState(6, channelWidth, bssidC)
|
||||
);
|
||||
dataModel.latestWifiScans.put(
|
||||
deviceC,
|
||||
Arrays.asList(
|
||||
TestUtils.createWifiScanList(Arrays.asList(6, 7, 10, 11))
|
||||
)
|
||||
);
|
||||
Map<String, Integer> radioMapC = new HashMap<>();
|
||||
radioMapC.put(band, cExpectedChannel);
|
||||
expected.put(deviceC, radioMapC);
|
||||
|
||||
ChannelOptimizer optimizer = new UnmanagedApAwareChannelOptimizer(
|
||||
dataModel, TEST_ZONE, deviceDataManager
|
||||
);
|
||||
assertEquals(expected, optimizer.computeChannelMap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class UCentralUtilsTest {
|
||||
@Test
|
||||
void test_placeholder() throws Exception {
|
||||
assertEquals(3, 1 + 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user