Initial commit

This commit is contained in:
Jeffrey Han
2022-06-02 17:16:30 -07:00
parent 6e8b0f86c3
commit 5c8beadc16
74 changed files with 12079 additions and 21 deletions

19
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View File

@@ -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
View 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
View 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>

View 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;
}
}
}
}

View 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();
}
}

View File

@@ -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();
}

View 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(); }
}

View 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);
}
}

View 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;
}
}

View 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();
}

View 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);
}
}

View 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));
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View 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");
}
}
}

View 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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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
);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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);
}
}

View File

@@ -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);
});
}
}

View 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));
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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()
);
}
}

View File

@@ -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));
}
}

View File

@@ -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"));
}
}

View File

@@ -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));
}
}

View File

@@ -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"));
}
}

View 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;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}