diff --git a/src/main/java/com/facebook/openwifirrm/aggregators/Aggregator.java b/src/main/java/com/facebook/openwifirrm/aggregators/Aggregator.java new file mode 100644 index 0000000..1c40521 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/aggregators/Aggregator.java @@ -0,0 +1,28 @@ +/* + * 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.aggregators; + +/** + * Aggregates added values into one "aggregate" measure. + * + * @param the type of values being aggregated (e.g., Double). + */ +public interface Aggregator { + /** Adds {@value} to the group of values being aggregated. */ + void addValue(T value); + + /** Returns the aggregate measure of all added values. */ + T getAggregate(); + + /** Returns the number of values that are aggregated. */ + long getCount(); + + /** Remove all added values from the group of values being aggregated. */ + void reset(); +} diff --git a/src/main/java/com/facebook/openwifirrm/aggregators/MeanAggregator.java b/src/main/java/com/facebook/openwifirrm/aggregators/MeanAggregator.java new file mode 100644 index 0000000..201768e --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/aggregators/MeanAggregator.java @@ -0,0 +1,41 @@ +/* + * 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.aggregators; + +/** + * Tracks the mean of all added values. If no values are added, the mean is 0. + */ +public class MeanAggregator implements Aggregator { + + protected double mean = 0; + protected long count = 0; + + @Override + public void addValue(Double value) { + mean = ((double) count / (count + 1)) * mean + (value / (count + 1)); + count++; + } + + @Override + public Double getAggregate() { + return mean; + } + + @Override + public long getCount() { + return count; + } + + @Override + public void reset() { + mean = 0; + count = 0; + } + +} diff --git a/src/main/java/com/facebook/openwifirrm/modules/DataCollector.java b/src/main/java/com/facebook/openwifirrm/modules/DataCollector.java index 159f105..157c598 100644 --- a/src/main/java/com/facebook/openwifirrm/modules/DataCollector.java +++ b/src/main/java/com/facebook/openwifirrm/modules/DataCollector.java @@ -397,8 +397,8 @@ public class DataCollector implements Runnable { ); return false; } - List scanEntries = - UCentralUtils.parseWifiScanEntries(wifiScanResult.results); + // wifiScanResult.executed is in seconds + List scanEntries = UCentralUtils.parseWifiScanEntries(wifiScanResult.results, 1000 * wifiScanResult.executed); if (scanEntries == null) { logger.error( "Device {}: wifi scan returned unexpected result", serialNumber @@ -429,7 +429,7 @@ public class DataCollector implements Runnable { /** Insert wifi scan results into database. */ private void insertWifiScanResultsToDatabase( - String serialNumber, long ts, List entries + String serialNumber, long timestampSeconds, List entries ) { if (dbManager == null) { return; @@ -437,7 +437,7 @@ public class DataCollector implements Runnable { // Insert into database try { - dbManager.addWifiScan(serialNumber, ts, entries); + dbManager.addWifiScan(serialNumber, timestampSeconds, entries); } catch (SQLException e) { logger.error("Failed to insert wifi scan results into database", e); return; diff --git a/src/main/java/com/facebook/openwifirrm/modules/Modeler.java b/src/main/java/com/facebook/openwifirrm/modules/Modeler.java index 0e4386b..d67ce1d 100644 --- a/src/main/java/com/facebook/openwifirrm/modules/Modeler.java +++ b/src/main/java/com/facebook/openwifirrm/modules/Modeler.java @@ -83,7 +83,13 @@ public class Modeler implements Runnable { // 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. */ + /** + * The "result" of a wifiscan can include multiple responses. This maps from an + * AP (serial number) to a list of most recent wifiscan "results" where each + * "result" itself is a list of responses from other APs. + * + * @see UCentralClient#wifiScan(String, boolean) + */ public Map>> latestWifiScans = new ConcurrentHashMap<>(); @@ -309,8 +315,7 @@ public class Modeler implements Runnable { ); // Parse and validate this record - List scanEntries = - UCentralUtils.parseWifiScanEntries(record.payload); + List scanEntries = UCentralUtils.parseWifiScanEntries(record.payload, record.timestampMs); if (scanEntries == null) { continue; } diff --git a/src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java b/src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java index bf938ba..fcd4f9f 100644 --- a/src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java +++ b/src/main/java/com/facebook/openwifirrm/modules/ModelerUtils.java @@ -8,11 +8,21 @@ package com.facebook.openwifirrm.modules; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.facebook.openwifirrm.aggregators.Aggregator; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; +import com.facebook.openwifirrm.ucentral.operationelement.HTOperationElement; +import com.facebook.openwifirrm.ucentral.operationelement.VHTOperationElement; + /** * Modeler utilities. */ @@ -202,4 +212,109 @@ public class ModelerUtils { return Double.POSITIVE_INFINITY; } } + + /** + * Determines if two wifiscan entries should be aggregated without consideration + * for any kind of obsoletion period. This should handle APs that support + * pre-802.11n standards (no ht_oper and no vht_oper), 802.11n (supports ht_oper + * but no vht_oper), and 802.11ac (supports both ht_oper and vht_oper). + * Pre-802.11n, if two entries with the same bssid are in the same channel (and + * therefore frequency, since they share a one-to-one mapping), that is enough + * to aggregate them. However, 802.11n introduced channel bonding, so for + * 802.11n onwards, channel width must be checked. + * + * @return true if the entries should be aggregated + */ + private static boolean matchesForAggregation(WifiScanEntry entry1, WifiScanEntry entry2) { + // TODO test on real pre-802.11n APs (which do not have ht_oper and vht_oper) + // do not check SSID (other SSIDs can contribute to interference, and SSIDs can + // change any time) + return Objects.equals(entry1.bssid, entry2.bssid) && entry1.frequency == entry2.frequency + && entry1.channel == entry2.channel + && HTOperationElement.matchesHtForAggregation(entry1.ht_oper, entry2.ht_oper) + && VHTOperationElement.matchesVhtForAggregation(entry1.vht_oper, entry2.vht_oper); + } + + /** + * For each AP, for each other AP that sent a wifiscan entry to that AP, this + * method calculates an aggregate wifiscan entry with an aggregated RSSI. + * + * @param dataModel the data model which includes the latest wifiscan + * entries + * @param obsoletionPeriodMs for each (scanning AP, responding AP) tuple, the + * maximum amount of time (in milliseconds) it is + * worth aggregating over, starting from the most + * recent scan entry for that tuple, and working + * backwards in time. An entry exactly + * {@code obsoletionPeriodMs} ms earlier than the most + * recent entry is considered non-obsolete (i.e., the + * "non-obsolete" window is inclusive). Must be + * non-negative. + * @param agg an aggregator to calculate the aggregated RSSI + * given recent wifiscan entries' RSSIs. + * @return a map from AP serial number to a map from BSSID to an "aggregated + * wifiscan entry". This aggregated entry is the most recent entry with + * its {@code signal} attribute modified to be the aggregated signal + * value instead of the value in just the most recent entry for that (AP + * serial number, BSSID) tuple. The returned map will only contain APs + * which received at least one non-obsolete wifiscan entry from a BSS. + */ + public static Map> getAggregatedWifiScans(Modeler.DataModel dataModel, + long obsoletionPeriodMs, + Aggregator agg) { + if (obsoletionPeriodMs < 0) { + throw new IllegalArgumentException("obsoletionPeriodMs must be non-negative."); + } + Map> aggregatedWifiScans = new HashMap<>(); + for (Map.Entry>> apToScansMapEntry : dataModel.latestWifiScans.entrySet()) { + String serialNumber = apToScansMapEntry.getKey(); + List> scans = apToScansMapEntry.getValue(); + if (scans.isEmpty()) { + continue; + } + /* + * Flatten the wifiscan entries and sort in reverse chronological order. Sorting + * is done just in case the entries in the original list are not chronological + * already - although they are inserted chronologically, perhaps latency, + * synchronization, etc. could cause the actual unixTimeMs to be out-of-order. + */ + List mostRecentToOldestEntries = scans.stream().flatMap(List::stream) + .sorted((entry1, entry2) -> { + return -Long.compare(entry1.unixTimeMs, entry2.unixTimeMs); + }).collect(Collectors.toUnmodifiableList()); + // Split mostRecentToOldest into separate lists for each BSSID + // These lists will be in reverse chronological order also + Map> bssidToEntriesMap = new HashMap<>(); + for (WifiScanEntry entry : mostRecentToOldestEntries) { + if (entry.bssid == null) { + continue; + } + bssidToEntriesMap.computeIfAbsent(entry.bssid, bssid -> new ArrayList<>()).add(entry); + } + + // calculate the aggregate for each bssid for this AP + for (Map.Entry> bssidToWifiScanEntriesMapEntry : bssidToEntriesMap.entrySet()) { + String bssid = bssidToWifiScanEntriesMapEntry.getKey(); + List entries = bssidToWifiScanEntriesMapEntry.getValue(); + WifiScanEntry mostRecentEntry = entries.get(0); + agg.reset(); + for (WifiScanEntry entry : entries) { + if (mostRecentEntry.unixTimeMs - entry.unixTimeMs > obsoletionPeriodMs) { + // discard obsolete entries + break; + } + if (mostRecentEntry == entry || matchesForAggregation(mostRecentEntry, entry)) { + aggregatedWifiScans + .computeIfAbsent(serialNumber, k -> new HashMap<>()) + .computeIfAbsent(bssid, k -> new WifiScanEntry(entry)); + agg.addValue((double) entry.signal); + } + } + if (agg.getCount() > 0) { + aggregatedWifiScans.get(serialNumber).get(bssid).signal = (int) Math.round(agg.getAggregate()); + } + } + } + return aggregatedWifiScans; + } } diff --git a/src/main/java/com/facebook/openwifirrm/mysql/DatabaseManager.java b/src/main/java/com/facebook/openwifirrm/mysql/DatabaseManager.java index 4f965e7..b89f504 100644 --- a/src/main/java/com/facebook/openwifirrm/mysql/DatabaseManager.java +++ b/src/main/java/com/facebook/openwifirrm/mysql/DatabaseManager.java @@ -429,9 +429,16 @@ public class DatabaseManager { return state; } - /** Insert wifi scan results into the database. */ + /** + * Insert wifi scan results into the database. + * + * @param serialNumber serial number + * @param timestampSeconds timestamp (Unix time in seconds). + * @param entries list of wifiscan entries + * @throws SQLException + */ public void addWifiScan( - String serialNumber, long ts, List entries + String serialNumber, long timestampSeconds, List entries ) throws SQLException { if (ds == null) { return; @@ -444,7 +451,7 @@ public class DatabaseManager { "INSERT INTO `wifiscan` (`time`, `serial`) VALUES (?, ?)", Statement.RETURN_GENERATED_KEYS ); - stmt.setTimestamp(1, new Timestamp(ts * 1000)); + stmt.setTimestamp(1, new Timestamp(timestampSeconds * 1000)); stmt.setString(2, serialNumber); int rows = stmt.executeUpdate(); if (rows == 0) { diff --git a/src/main/java/com/facebook/openwifirrm/optimizers/ChannelOptimizer.java b/src/main/java/com/facebook/openwifirrm/optimizers/ChannelOptimizer.java index f195a6c..e5a6563 100644 --- a/src/main/java/com/facebook/openwifirrm/optimizers/ChannelOptimizer.java +++ b/src/main/java/com/facebook/openwifirrm/optimizers/ChannelOptimizer.java @@ -51,7 +51,7 @@ public abstract class ChannelOptimizer { } /** List of available channels per band for use. */ - protected static final Map> AVAILABLE_CHANNELS_BAND = + public static final Map> AVAILABLE_CHANNELS_BAND = new HashMap<>(); static { AVAILABLE_CHANNELS_BAND.put( diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralClient.java b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralClient.java index 5c2a19f..d540d93 100644 --- a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralClient.java +++ b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralClient.java @@ -419,7 +419,21 @@ public class UCentralClient { } } - /** Launch a wifi scan for a device (by serial number). */ + /** + * Launch a wifi scan for a device (by serial number). + *

+ * An AP can conduct a wifiscan, which can be either active or passive. In an + * active wifiscan, the AP sends out a wifiscan request and listens for + * responses from other APs. In a passive wifiscan, the AP does not send out a + * wifiscan request but instead just waits for periodic beacons from the other + * APs. (Note that neither the responses to requests (in active mode) or the + * periodic beacons are guaranteed to happen at any particular time (and it + * depends on network traffic)). + *

+ * The AP conducting the wifiscan goes through every channel and listens for + * responses/beacons. However, the responding/beaconing APs only send responses + * on channels they are currently using. + */ public CommandInfo wifiScan(String serialNumber, boolean verbose) { WifiScanRequest req = new WifiScanRequest(); req.serialNumber = serialNumber; diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralKafkaConsumer.java b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralKafkaConsumer.java index ceb8f00..ac2aabc 100644 --- a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralKafkaConsumer.java +++ b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralKafkaConsumer.java @@ -68,10 +68,14 @@ public class UCentralKafkaConsumer { /** The state payload JSON. */ public final JsonObject payload; + /** Unix time (ms). */ + public final long timestampMs; + /** Constructor. */ - public KafkaRecord(String serialNumber, JsonObject payload) { + public KafkaRecord(String serialNumber, JsonObject payload, long timestampMs) { this.serialNumber = serialNumber; this.payload = payload; + this.timestampMs = timestampMs; } } @@ -245,11 +249,12 @@ public class UCentralKafkaConsumer { "Offset {}: {} => {}", record.offset(), serialNumber, payload.toString() ); - + // record.timestamp() is empirically confirmed to be Unix time (ms) + KafkaRecord kafkaRecord = new KafkaRecord(serialNumber, payload, record.timestamp()); if (record.topic().equals(stateTopic)) { - stateRecords.add(new KafkaRecord(serialNumber, payload)); + stateRecords.add(kafkaRecord); } else if (record.topic().equals(wifiScanTopic)) { - wifiScanRecords.add(new KafkaRecord(serialNumber, payload)); + wifiScanRecords.add(kafkaRecord); } } } diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralUtils.java b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralUtils.java index f4d4dfa..e3a1822 100644 --- a/src/main/java/com/facebook/openwifirrm/ucentral/UCentralUtils.java +++ b/src/main/java/com/facebook/openwifirrm/ucentral/UCentralUtils.java @@ -16,6 +16,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import java.util.Set; import org.slf4j.Logger; @@ -23,7 +24,9 @@ import org.slf4j.LoggerFactory; import com.facebook.openwifirrm.RRMConfig; import com.facebook.openwifirrm.Utils; +import com.facebook.openwifirrm.optimizers.ChannelOptimizer; import com.facebook.openwifirrm.ucentral.models.State; +import com.facebook.openwifirrm.ucentral.models.WifiScanEntryResult; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -41,78 +44,74 @@ public class UCentralUtils { // 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 last_seen; - /** Signal strength measured in dBm */ - public int signal; - /** BSSID is the MAC address of the device */ - public String bssid; - public String ssid; - public long tsf; + /** + * Extends {@link WifiScanEntryResult} to track the response time of the entry. + */ + public static class WifiScanEntry extends WifiScanEntryResult { /** - * ht_oper is short for "high throughput operator". This field contains some - * information already present in other fields. This is because this field was - * added later in order to capture some new information but also includes some - * redundant information. 802.11 defines the HT operator and vendors may define - * additional fields. HT is supported on both the 2.4 GHz and 5 GHz bands. - * - * This field is specified as 24 bytes, but it is encoded in base64. It is - * likely the case that the first byte (the Element ID, which should be 61 for - * ht_oper) and the second byte (Length) are omitted in the wifi scan results, - * resulting in 22 bytes, which translates to a 32 byte base64 encoded String. + * Unix time in milliseconds (ms). This field is not defined in the uCentral + * API. This is added it because {@link WifiScanEntryResult#tsf} is an unknown + * time reference. */ - public String ht_oper; - /** - * vht_oper is short for "very high throughput operator". This field contains - * some information already present in other fields. This is because this field - * was added later in order to capture some new information but also includes - * some redundant information. 802.11 defines the VHT operator and vendors may - * define additional fields. VHT is supported only on the 5 GHz band. - * - * For information about about the contents of this field, its encoding, etc., - * please see the javadoc for {@link #ht_oper} first. The vht_oper likely - * operates similarly. - */ - public String vht_oper; - public int capability; - public int frequency; - /** IE = information element */ - public JsonArray ies; + public long unixTimeMs; /** Default Constructor. */ public WifiScanEntry() {} /** Copy Constructor. */ public WifiScanEntry(WifiScanEntry o) { - this.channel = o.channel; - this.last_seen = o.last_seen; - 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; - this.frequency = o.frequency; - this.ies = o.ies; + super(o); + this.unixTimeMs = o.unixTimeMs; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = super.hashCode(); + result = prime * result + Objects.hash(unixTimeMs); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!super.equals(obj)) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + WifiScanEntry other = (WifiScanEntry) obj; + return unixTimeMs == other.unixTimeMs; + } + + @Override + public String toString() { + return String.format("WifiScanEntry[signal=%d, bssid=%s, unixTimeMs=%d]", signal, bssid, unixTimeMs); } } /** * Parse a JSON wifi scan result into a list of WifiScanEntry objects. * - * Returns null if any parsing/deserialization error occurred. + * @param result result of the wifiscan + * @param timestampMs Unix time in ms + * @return list of wifiscan entries, or null if any parsing/deserialization + * error occurred. */ - public static List parseWifiScanEntries(JsonObject result) { + public static List parseWifiScanEntries(JsonObject result, long timestampMs) { List entries = new ArrayList<>(); try { JsonArray scanInfo = result .getAsJsonObject("status") .getAsJsonArray("scan"); for (JsonElement e : scanInfo) { - entries.add(gson.fromJson(e, WifiScanEntry.class)); + WifiScanEntry entry = gson.fromJson(e, WifiScanEntry.class); + entry.unixTimeMs = timestampMs; + entries.add(entry); + } } catch (Exception e) { return null; @@ -347,4 +346,22 @@ public class UCentralUtils { return ""; } } + + /** + * Converts channel number to that channel's center frequency in MHz. + * + * @param channel channel number. See + * {@link ChannelOptimizer#AVAILABLE_CHANNELS_BAND} for channels + * in each band. + * @return the center frequency of the given channel in MHz + */ + public static int channelToFrequencyMHz(int channel) { + if (ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(UCentralConstants.BAND_2G).contains(channel)) { + return 2407 + 5 * channel; + } else if (ChannelOptimizer.AVAILABLE_CHANNELS_BAND.get(UCentralConstants.BAND_5G).contains(channel)) { + return 5000 + channel; + } else { + throw new IllegalArgumentException("Must provide a valid channel."); + } + } } diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/models/WifiScanEntryResult.java b/src/main/java/com/facebook/openwifirrm/ucentral/models/WifiScanEntryResult.java new file mode 100644 index 0000000..062de71 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/models/WifiScanEntryResult.java @@ -0,0 +1,102 @@ +/* + * 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 java.util.Objects; + +import com.google.gson.JsonArray; + +/** Represents a single entry in wifi scan results. */ +public class WifiScanEntryResult { + public int channel; + public long last_seen; + /** Signal strength measured in dBm */ + public int signal; + /** BSSID is the MAC address of the device */ + public String bssid; + public String ssid; + public long tsf; + /** + * ht_oper is short for "high throughput operation element". Note that this + * field, when non-null, contains some information already present in other + * fields. This field may be null, however, since pre-802.11n BSSs do not + * support HT. For BSSs that do support HT, HT is supported on the 2G and 5G + * bands. + * + * This field is specified as 24 bytes, but it is encoded in base64. It is + * likely the case that the first byte (the Element ID, which should be 61 for + * ht_oper) and the second byte (Length) are omitted in the wifi scan results, + * resulting in 22 bytes, which translates to a 32 byte base64 encoded String. + */ + public String ht_oper; + /** + * vht_oper is short for "very high throughput operation element". Note that + * this field, when non-null, contains some information already present in other + * fields. This field may be null, however, since pre-802.11ac BSSs do not + * support HT. For BSSs that do support VHT, VHT is supported on the 5G band. + * VHT operation is controlled by both the HT operation element and the VHT + * operation element. + * + * For information about about the contents of this field, its encoding, etc., + * please see the javadoc for {@link ht_oper} first. The vht_oper likely + * operates similarly. + */ + public String vht_oper; + public int capability; + public int frequency; + /** IE = information element */ + public JsonArray ies; + + /** Default Constructor. */ + public WifiScanEntryResult() {} + + /** Copy Constructor. */ + public WifiScanEntryResult(WifiScanEntryResult o) { + this.channel = o.channel; + this.last_seen = o.last_seen; + 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; + this.frequency = o.frequency; + this.ies = o.ies; + } + + @Override + public int hashCode() { + return Objects.hash(bssid, capability, channel, frequency, ht_oper, ies, last_seen, signal, ssid, tsf, + vht_oper); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + WifiScanEntryResult other = (WifiScanEntryResult) obj; + return Objects.equals(bssid, other.bssid) && capability == other.capability && channel == other.channel + && frequency == other.frequency && Objects.equals(ht_oper, other.ht_oper) + && Objects.equals(ies, other.ies) && last_seen == other.last_seen && signal == other.signal + && Objects.equals(ssid, other.ssid) && tsf == other.tsf && Objects.equals(vht_oper, other.vht_oper); + } + + @Override + public String toString() { + return String.format("WifiScanEntryResult[signal=%d, bssid=%s]", signal, bssid); + } +} diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/operationelement/HTOperationElement.java b/src/main/java/com/facebook/openwifirrm/ucentral/operationelement/HTOperationElement.java new file mode 100644 index 0000000..b2fa5f7 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/operationelement/HTOperationElement.java @@ -0,0 +1,218 @@ +/* + * 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.operationelement; + +import java.util.Arrays; +import java.util.Objects; + +import org.apache.commons.codec.binary.Base64; + +/** + * High Throughput (HT) Operation Element, which is potentially present in + * wifiscan entries. Introduced in 802.11n (2009). + */ +public class HTOperationElement { + + /** Channel number of the primary channel. */ + private final byte primaryChannel; + /** + * Indicates the offset of the secondary channel relative to the primary + * channel. A 1 indicates that the secondary channel is above the primary + * channel. A 3 indicates that the secondary channel is below the primary + * channel. A 0 indicates that there is no secondary channel present. The value + * 2 is reserved. + */ + private final byte secondaryChannelOffset; + /** + * Defines the channel widths that can be used to transmit to the STA. With + * exceptions, false allows a 20 MHz channel width. True allows use of any + * channel width in the supported channel width set. See 802.11 for exceptions. + */ + private final boolean staChannelWidth; + /** True if RIFS is permitted; false otherwise. */ + private final boolean rifsMode; + /** + * A 0 indicates no protection mode. A 1 indicates nonmember protection mode. A + * 2 indicates 20 MHz protection mode. A 3 indicates non-HT mixed mode. + */ + private final byte htProtection; + /** + * False if all HT STAs that are associated are HT-greenfield capable or all HT + * peer mesh STAs are HT-greenfield capable; true otherwise. + */ + private final boolean nongreenfieldHtStasPresent; + /** + * Indicates if the use of protection for non-HT STAs by overlapping BSSs is + * determined to be desirable. See 802.11 for details. + */ + private final boolean obssNonHtStasPresent; + /** + * Defines the channel center frequency for a 160 or 80+80 MHz BSS bandwidth + * with NSS support less than Max VHT NSS. This is 0 for non-VHT STAs. See + * 802.11 for details. + */ + private final byte channelCenterFrequencySegment2; + /** False if no STBC beacon is transmitted; true otherwise. */ + private final boolean dualBeacon; + /** False if dual CTS protection is not required; true otherwise. */ + private final boolean dualCtsProtection; + /** False in a primary beacon. True in an STBC beacon. */ + private final boolean stbcBeacon; + /** + * Indicates the HT-MCS values that are supported by all HT STAs in the BSS. A + * bitmap where a bit is set to 1 to indicate support for that MCS and 0 + * otherwise, where bit 0 corresponds to MCS 0. + */ + private final byte[] basicHtMcsSet; + + /** + * Constructs an {@code HTOperationElement} using the given field values. See + * 802.11 for more details. + *

+ * For details about the parameters, see the javadocs for the corresponding + * member variables. + */ + public HTOperationElement(byte primaryChannel, byte secondaryChannelOffset, boolean staChannelWidth, + boolean rifsMode, byte htProtection, boolean nongreenfieldHtStasPresent, boolean obssNonHtStasPresent, + byte channelCenterFrequencySegment2, boolean dualBeacon, boolean dualCtsProtection, boolean stbcBeacon) { + /* + * XXX some combinations of these parameters may be invalid as defined by + * 802.11-2020, but this is not checked here. If fidelity to 802.11 is required, + * the caller of this method must make sure to pass in valid parameters. The + * 802.11-2020 specification has more details about the parameters. + */ + this.primaryChannel = primaryChannel; + this.secondaryChannelOffset = secondaryChannelOffset; + this.staChannelWidth = staChannelWidth; + this.rifsMode = rifsMode; + this.htProtection = htProtection; + this.nongreenfieldHtStasPresent = nongreenfieldHtStasPresent; + this.obssNonHtStasPresent = obssNonHtStasPresent; + this.channelCenterFrequencySegment2 = channelCenterFrequencySegment2; + this.dualBeacon = dualBeacon; + this.dualCtsProtection = dualCtsProtection; + this.stbcBeacon = stbcBeacon; + // the next 16 bytes are for the basic HT-MCS set + // a default is chosen; if needed, we can add a parameter to set these + this.basicHtMcsSet = new byte[16]; + } + + /** Constructor with the most used parameters. */ + public HTOperationElement(byte primaryChannel, byte secondaryChannelOffset, boolean staChannelWidth, + byte channelCenterFrequencySegment2) { + this(primaryChannel, secondaryChannelOffset, staChannelWidth, false, (byte) 0, true, false, + channelCenterFrequencySegment2, false, false, false); + } + + /** + * Constructs an {@code HTOperationElement} by decoding {@code htOper}. + * + * @param htOper a base64 encoded properly formatted HT operation element (see + * 802.11) + */ + public HTOperationElement(String htOper) { + byte[] bytes = Base64.decodeBase64(htOper); + /* + * Note that the code here may seem to read "reversed" compared to 802.11. This + * is because the bits within a byte are delivered from MSB to LSB, whereas the + * 802.11 graphic shows the bits LSB-first. At least, this is our understanding + * from looking at 802.11 and at the actual HT operation elements from the + * edgecore APs. + */ + this.primaryChannel = bytes[0]; + this.rifsMode = ((bytes[1] & 0b00001000) >>> 3) == 1; + this.staChannelWidth = ((bytes[1] & 0b00000100) >>> 2) == 1; + this.secondaryChannelOffset = (byte) (bytes[1] & 0b00000011); + byte channelCenterFrequencySegment2LastThreeBits = (byte) ((bytes[2] & 0b11100000) >>> 5); + this.obssNonHtStasPresent = ((bytes[2] & 0b00010000) >>> 4) == 1; + this.nongreenfieldHtStasPresent = ((bytes[2] & 0b00000100) >>> 2) == 1; + this.htProtection = (byte) (bytes[2] & 0b00000011); + byte channelCenterFrequencySegment2FirstFiveBits = (byte) (bytes[3] & 0b00011111); + this.channelCenterFrequencySegment2 = (byte) (((byte) (channelCenterFrequencySegment2FirstFiveBits << 3)) + | channelCenterFrequencySegment2LastThreeBits); + this.dualCtsProtection = ((bytes[4] & 0b10000000) >>> 7) == 1; + this.dualBeacon = ((bytes[4] & 0b01000000) >>> 6) == 1; + this.stbcBeacon = (bytes[5] & 0b00000001) == 1; + byte[] basicHtMcsSet = new byte[16]; + for (int i = 0; i < basicHtMcsSet.length; i++) { + basicHtMcsSet[i] = bytes[6 + i]; + } + this.basicHtMcsSet = basicHtMcsSet; + } + + /** + * Determine whether {@code this} and {@code other} "match" for the purpose of + * aggregating statistics. + * + * @param other another HT operation element + * @return true if the the operation elements "match" for the purpose of + * aggregating statistics; false otherwise. + */ + public boolean matchesForAggregation(HTOperationElement other) { + return other != null && primaryChannel == other.primaryChannel + && secondaryChannelOffset == other.secondaryChannelOffset && staChannelWidth == other.staChannelWidth + && channelCenterFrequencySegment2 == other.channelCenterFrequencySegment2; + } + + /** + * Determines whether two HT operation elements should have their statistics + * aggregated. + * + * @param htOper1 a base64 encoded properly formatted HT operation element (see + * 802.11) + * @param htOper2 a base64 encoded properly formatted HT operation element (see + * 802.11) + * @return true if the two inputs should have their statistics aggregated; false + * otherwise. + */ + public static boolean matchesHtForAggregation(String htOper1, String htOper2) { + if (Objects.equals(htOper1, htOper2)) { + return true; // true if both are null or they are equal + } + if (htOper1 == null || htOper2 == null) { + return false; // false if exactly one is null + } + HTOperationElement htOperObj1 = new HTOperationElement(htOper1); + HTOperationElement htOperObj2 = new HTOperationElement(htOper2); + return htOperObj1.matchesForAggregation(htOperObj2); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(basicHtMcsSet); + result = prime * result + Objects.hash(channelCenterFrequencySegment2, dualBeacon, dualCtsProtection, + htProtection, nongreenfieldHtStasPresent, obssNonHtStasPresent, primaryChannel, rifsMode, + secondaryChannelOffset, staChannelWidth, stbcBeacon); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + HTOperationElement other = (HTOperationElement) obj; + return Arrays.equals(basicHtMcsSet, other.basicHtMcsSet) + && channelCenterFrequencySegment2 == other.channelCenterFrequencySegment2 + && dualBeacon == other.dualBeacon && dualCtsProtection == other.dualCtsProtection + && htProtection == other.htProtection + && nongreenfieldHtStasPresent == other.nongreenfieldHtStasPresent + && obssNonHtStasPresent == other.obssNonHtStasPresent && primaryChannel == other.primaryChannel + && rifsMode == other.rifsMode && secondaryChannelOffset == other.secondaryChannelOffset + && staChannelWidth == other.staChannelWidth && stbcBeacon == other.stbcBeacon; + } +} \ No newline at end of file diff --git a/src/main/java/com/facebook/openwifirrm/ucentral/operationelement/VHTOperationElement.java b/src/main/java/com/facebook/openwifirrm/ucentral/operationelement/VHTOperationElement.java new file mode 100644 index 0000000..09c9bc6 --- /dev/null +++ b/src/main/java/com/facebook/openwifirrm/ucentral/operationelement/VHTOperationElement.java @@ -0,0 +1,156 @@ +/* + * 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.operationelement; + +import java.util.Arrays; +import java.util.Objects; + +import org.apache.commons.codec.binary.Base64; + +/** + * Very High Throughput (VHT) Operation Element, which is potentially present in + * wifiscan entries. Introduced in 802.11ac (2013). + */ +public class VHTOperationElement { + + /** False if the channel width is 20 MHz or 40 MHz; true otherwise. */ + private final boolean channelWidthIndicator; + /** + * If the channel is 20 MHz, 40 MHz, or 80 MHz wide, this parameter is the + * channel number. E.g., the channel centered at 5180 MHz is channel 36. For a + * 160 MHz wide channel, this parameter is the channel number of the 80MHz + * channel that contains the primary channel. For a 80+80 MHz wide channel, this + * parameter is the channel number of the primary channel. + */ + private final byte channel1; + /** + * This should be zero unless the channel is 160MHz or 80+80 MHz wide. If the + * channel is 160 MHz wide, this parameter is the channel number of the 160 MHz + * wide channel. If the channel is 80+80 MHz wide, this parameter is the channel + * index of the secondary 80 MHz wide channel. + */ + private final byte channel2; + /** + * An 8-element array where each element is between 0 and 4 inclusive. MCS means + * Modulation and Coding Scheme. NSS means Number of Spatial Streams. There can + * be 1, 2, ..., or 8 spatial streams. For each NSS, the corresponding element + * in the array should specify which MCSs are supported for that NSS in the + * following manner: 0 indicates support for VHT-MCS 0-7, 1 indicates support + * for VHT-MCS 0-8, 2 indicates support for VHT-MCS 0-9, and 3 indicates that no + * VHT-MCS is supported for that NSS. For the specifics of what each VHT-MCS is, + * see IEEE 802.11-2020, Table "21-29" through Table "21-60". + */ + private final byte[] vhtMcsForNss; + + /** + * Constructs a {@code VHTOperationElement} by decoding {@code vhtOper}. + * + * @param vhtOper a base64 encoded properly formatted VHT operation element (see + * 802.11 standard) + */ + public VHTOperationElement(String vhtOper) { + byte[] bytes = Base64.decodeBase64(vhtOper); + this.channelWidthIndicator = bytes[0] == 1; + this.channel1 = bytes[1]; + this.channel2 = bytes[2]; + byte[] vhtMcsForNss = new byte[8]; + vhtMcsForNss[0] = (byte) (bytes[3] >>> 6); + vhtMcsForNss[1] = (byte) ((bytes[3] & 0b00110000) >>> 4); + vhtMcsForNss[2] = (byte) ((bytes[3] & 0b00001100) >>> 2); + vhtMcsForNss[3] = (byte) (bytes[3] & 0b00000011); + vhtMcsForNss[4] = (byte) (bytes[4] >>> 6); + vhtMcsForNss[5] = (byte) ((bytes[4] & 0b00110000) >>> 4); + vhtMcsForNss[6] = (byte) ((bytes[4] & 0b00001100) >>> 2); + vhtMcsForNss[7] = (byte) (bytes[4] & 0b00000011); + this.vhtMcsForNss = vhtMcsForNss; + } + + /** + * Constructs an {@code HTOperationElement} using the given field values. See + * 802.11 for more details. + *

+ * For details about the parameters, see the javadocs for the corresponding + * member variables. + */ + public VHTOperationElement(boolean channelWidthIndicator, byte channel1, byte channel2, byte[] vhtMcsForNss) { + /* + * XXX some combinations of channelWidth, channel, channel2, and vhtMcsAtNss are + * invalid, but this is not checked here. If fidelity to 802.11 is required, the + * caller of this method must make sure to pass in valid parameters. + */ + this.channelWidthIndicator = channelWidthIndicator; + this.channel1 = channel1; + this.channel2 = channel2; + this.vhtMcsForNss = vhtMcsForNss; + } + + /** + * Determine whether {@code this} and {@code other} "match" for the purpose of + * aggregating statistics. + * + * @param other another VHT operation element + * @return true if the the operation elements "match" for the purpose of + * aggregating statistics; false otherwise. + */ + public boolean matchesForAggregation(VHTOperationElement other) { + // check everything except vhtMcsForNss + return other != null && channel1 == other.channel1 && channel2 == other.channel2 + && channelWidthIndicator == other.channelWidthIndicator; + } + + /** + * Determines whether two VHT operation elements should have their statistics + * aggregated. + * + * @param vhtOper1 a base64 encoded properly formatted VHT operation element + * (see 802.11 standard) + * + * @param vhtOper2 a base64 encoded properly formatted VHT operation element + * (see 802.11 standard) + * @return true if the two inputs should have their statistics aggregated; false + * otherwise. + */ + public static boolean matchesVhtForAggregation(String vhtOper1, String vhtOper2) { + if (Objects.equals(vhtOper1, vhtOper2)) { + return true; // true if both are null or they are equal + } + if (vhtOper1 == null || vhtOper2 == null) { + return false; // false if exactly one is null + } + VHTOperationElement vhtOperObj1 = new VHTOperationElement(vhtOper1); + VHTOperationElement vhtOperObj2 = new VHTOperationElement(vhtOper2); + return vhtOperObj1.matchesForAggregation(vhtOperObj2); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(vhtMcsForNss); + result = prime * result + Objects.hash(channel1, channel2, channelWidthIndicator); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + VHTOperationElement other = (VHTOperationElement) obj; + return channel1 == other.channel1 && channel2 == other.channel2 + && channelWidthIndicator == other.channelWidthIndicator + && Arrays.equals(vhtMcsForNss, other.vhtMcsForNss); + } +} \ No newline at end of file diff --git a/src/test/java/com/facebook/openwifirrm/aggregators/MeanAggregatorTest.java b/src/test/java/com/facebook/openwifirrm/aggregators/MeanAggregatorTest.java new file mode 100644 index 0000000..ff598a8 --- /dev/null +++ b/src/test/java/com/facebook/openwifirrm/aggregators/MeanAggregatorTest.java @@ -0,0 +1,56 @@ +/* + * 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.aggregators; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class MeanAggregatorTest { + @Test + public void testEmptyAndNonEmptyAndReset() { + final double eps = 0.000001; + + MeanAggregator agg = new MeanAggregator(); + + // default mean is 0 + assertEquals(0, agg.getAggregate(), eps); + assertEquals(0, agg.getCount()); + + // adding 0 (the mean) does not change the mean + agg.addValue(0.0); + assertEquals(0, agg.getAggregate(), eps); + assertEquals(1, agg.getCount()); + + // add an "int" + agg.addValue(1.0); + assertEquals(0.5, agg.getAggregate(), eps); + assertEquals(2, agg.getCount()); + + // add a double + agg.addValue(3.5); + assertEquals(1.5, agg.getAggregate(), eps); + assertEquals(3, agg.getCount()); + + // add a negative number + agg.addValue(-0.5); + assertEquals(1.0, agg.getAggregate(), eps); + assertEquals(4, agg.getCount()); + + // adding the mean does not change the mean + agg.addValue(1.0); + assertEquals(1.0, agg.getAggregate(), eps); + assertEquals(5, agg.getCount()); + + // test reset + agg.reset(); + assertEquals(0, agg.getAggregate(), eps); + assertEquals(0, agg.getCount()); + } +} diff --git a/src/test/java/com/facebook/openwifirrm/modules/ModelerUtilsTest.java b/src/test/java/com/facebook/openwifirrm/modules/ModelerUtilsTest.java index eba4a11..e080134 100644 --- a/src/test/java/com/facebook/openwifirrm/modules/ModelerUtilsTest.java +++ b/src/test/java/com/facebook/openwifirrm/modules/ModelerUtilsTest.java @@ -9,13 +9,22 @@ 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedList; +import java.util.Map; import org.junit.jupiter.api.Test; +import com.facebook.openwifirrm.aggregators.MeanAggregator; +import com.facebook.openwifirrm.modules.Modeler.DataModel; +import com.facebook.openwifirrm.optimizers.TestUtils; +import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; + public class ModelerUtilsTest { @Test void testErrorCase() throws Exception { @@ -77,4 +86,239 @@ public class ModelerUtilsTest { ); assertEquals(0.861, metric, 0.001); } + + @Test + void testPreDot11nAggregatedWifiScanEntry() { + final long obsoletionPeriodMs = 900000; + + final String apA = "aaaaaaaaaaaa"; + final String bssidA = "aa:aa:aa:aa:aa:aa"; + final String apB = "bbbbbbbbbbbb"; + final String bssidB = "bb:bb:bb:bb:bb:bb"; + final String apC = "cccccccccccc"; + final String bssidC = "cc:cc:cc:cc:cc:cc"; + DataModel dataModel = new DataModel(); + + // if there are no scan entries, there should be no aggregates + dataModel.latestWifiScans.put(apB, new LinkedList<>()); + dataModel.latestWifiScans.put(apC, new LinkedList<>()); + dataModel.latestWifiScans.get(apC).add(new ArrayList<>()); + assertTrue(ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()).isEmpty()); + + /* + * When only apB conducts a scan, and receives one response from apA, that + * response should be the "aggregate response" from apA to apB, and apA and apC + * should have no aggregates for any BSSID. + */ + WifiScanEntry entryAToB1 = TestUtils.createWifiScanEntryWithBssid(1, bssidA); + entryAToB1.signal = -60; + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB1)); + Map> aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, + obsoletionPeriodMs, new MeanAggregator()); + assertFalse(aggregateMap.containsKey(apA)); + assertFalse(aggregateMap.containsKey(apC)); + assertEquals(entryAToB1, aggregateMap.get(apB).get(bssidA)); + + // add another scan with one entry from apA to apB and check the aggregation + WifiScanEntry entryAToB2 = TestUtils.createWifiScanEntryWithBssid(1, bssidA); + entryAToB2.signal = -62; + entryAToB2.unixTimeMs += 60000; // 1 min later + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB2)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + WifiScanEntry expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB2); + expectedAggregatedEntryAToB.signal = -61; // average of -60 and -62 + assertFalse(aggregateMap.containsKey(apA)); + assertFalse(aggregateMap.containsKey(apC)); + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + // test the obsoletion period boundaries + // test the inclusive non-obsolete boundary + WifiScanEntry entryAToB3 = TestUtils.createWifiScanEntryWithBssid(1, bssidA); + entryAToB3.signal = -64; + entryAToB3.unixTimeMs += obsoletionPeriodMs; + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB3)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB3); + expectedAggregatedEntryAToB.signal = -62; // average of -60, -62, and -64 3; + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + // test moving the boundary by 1 ms and excluding the earliest entry + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs - 1, new MeanAggregator()); + expectedAggregatedEntryAToB.signal = -63; // average of -62 and -64 + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + // test an obsoletion period of 0 ms + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, 0, new MeanAggregator()); + expectedAggregatedEntryAToB.signal = -64; // latest rssid + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + // Test that the obsoletion period starts counting backwards from the time of + // the most recent entry for each (ap, bssid) tuple. + WifiScanEntry entryCToB1 = TestUtils.createWifiScanEntryWithBssid(1, bssidC); + entryCToB1.signal = -70; + entryCToB1.unixTimeMs += 2 * obsoletionPeriodMs; + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryCToB1)); + // an obsoletion period of 0 means to get the most recent entry for each bssid + // regardless of how far apart the entries are for different bssids + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, 0, new MeanAggregator()); + WifiScanEntry expectedAggregatedEntryCToB = new WifiScanEntry(entryCToB1); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB3); + assertEquals(expectedAggregatedEntryCToB, aggregateMap.get(apB).get(bssidC)); + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + // test multiple entries in one scan and scans from multiple APs + WifiScanEntry entryAToB4 = TestUtils.createWifiScanEntryWithBssid(1, bssidA); + entryAToB4.signal = -80; + entryAToB4.unixTimeMs += 3 * obsoletionPeriodMs; + WifiScanEntry entryCToB2 = TestUtils.createWifiScanEntryWithBssid(1, bssidC); + entryCToB2.signal = -80; + entryCToB2.unixTimeMs += 3 * obsoletionPeriodMs; + WifiScanEntry entryBToA1 = TestUtils.createWifiScanEntryWithBssid(1, bssidB); + entryBToA1.signal = -60; + entryBToA1.unixTimeMs += 3 * obsoletionPeriodMs; + WifiScanEntry entryCToA1 = TestUtils.createWifiScanEntryWithBssid(1, bssidC); + entryCToA1.signal = -60; + entryCToA1.unixTimeMs += 3 * obsoletionPeriodMs; + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryCToB2, entryAToB4)); + dataModel.latestWifiScans.put(apA, new LinkedList<>()); + dataModel.latestWifiScans.get(apA).add(Arrays.asList(entryBToA1, entryCToA1)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryCToB = new WifiScanEntry(entryCToB2); + expectedAggregatedEntryCToB.signal = -75; // average of -70 and-80 + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB4); + WifiScanEntry expectedAggregatedEntryCToA = new WifiScanEntry(entryCToA1); + WifiScanEntry expectedAggregatedEntryBToA = new WifiScanEntry(entryBToA1); + assertEquals(expectedAggregatedEntryCToB, aggregateMap.get(apB).get(bssidC)); + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + assertEquals(expectedAggregatedEntryCToA, aggregateMap.get(apA).get(bssidC)); + assertEquals(expectedAggregatedEntryBToA, aggregateMap.get(apA).get(bssidB)); + + // test that entries are not aggregated when channel information does not match + WifiScanEntry entryBToA2 = TestUtils.createWifiScanEntryWithBssid(2, bssidB); // different channel + entryBToA2.signal = -62; + entryBToA2.unixTimeMs += 3 * obsoletionPeriodMs + 1; // 1 sec after the most recent B->A response + dataModel.latestWifiScans.get(apA).add(Arrays.asList(entryBToA2)); + expectedAggregatedEntryBToA = new WifiScanEntry(entryBToA2); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + assertEquals(expectedAggregatedEntryBToA, aggregateMap.get(apA).get(bssidB)); + + // test out of order wifiscans + WifiScanEntry entryBToA3 = TestUtils.createWifiScanEntryWithBssid(2, bssidB); // different channel + entryBToA3.signal = -64; + entryBToA3.unixTimeMs += 3 * obsoletionPeriodMs - 1; + dataModel.latestWifiScans.get(apA).add(Arrays.asList(entryBToA3)); + expectedAggregatedEntryBToA = new WifiScanEntry(entryBToA2); // use the most recent entry + expectedAggregatedEntryBToA.signal = -63; // average of -62 and -64 (skipping -60, different channel) + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + assertEquals(expectedAggregatedEntryBToA, aggregateMap.get(apA).get(bssidB)); + } + + @Test + void testPostDot11nAggregatedWifiScanEntry() { + final long obsoletionPeriodMs = 900000; + + final String bssidA = "aa:aa:aa:aa:aa:aa"; + final String apB = "bbbbbbbbbbbb"; + DataModel dataModel = new DataModel(); + + // First, test that entries for different channels do not aggregate (this could + // have been tested in testPreDot11nAggregatedWifiScanEntry) + byte primaryChannel = 1; // first entry on channel 1 + String htOper = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + WifiScanEntry entryAToB1 = TestUtils.createWifiScanEntryWithWidth(bssidA, primaryChannel, htOper, null); + entryAToB1.signal = -60; + dataModel.latestWifiScans.put(apB, new LinkedList<>()); + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB1)); + Map> aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, + obsoletionPeriodMs, new MeanAggregator()); + WifiScanEntry expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB1); + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + primaryChannel = 6; // second entry on channel 6, should only aggregate this one + htOper = "BgAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + WifiScanEntry entryAToB2 = TestUtils.createWifiScanEntryWithWidth(bssidA, primaryChannel, htOper, null); + entryAToB2.signal = -62; + entryAToB2.unixTimeMs += 60000; // 1 min after previous entry + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB2)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB2); + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + primaryChannel = 1; // third entry on channel 1 again, should aggregate first and third entry + htOper = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + WifiScanEntry entryAToB3 = TestUtils.createWifiScanEntryWithWidth(bssidA, primaryChannel, htOper, null); + entryAToB3.signal = -70; + entryAToB3.unixTimeMs += 120000; // 1 min after previous entry + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB3)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB3); + expectedAggregatedEntryAToB.signal = -65; // average of -60 and -70 (would be -64 if the -62 entry was included) + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + // Test that entries with HT operation elements which differ in the relevant + // fields (channel numbers and widths) are not aggregated together. + primaryChannel = 1; + htOper = "AQUAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; // use secondary channel and wider channel + WifiScanEntry entryAToB4 = TestUtils.createWifiScanEntryWithWidth(bssidA, primaryChannel, htOper, null); + entryAToB4.signal = -72; + entryAToB4.unixTimeMs += 180000; // 1 min after previous entry + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB4)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB4); + expectedAggregatedEntryAToB.signal = -72; + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + // Test that entries with HT operation elements with differ only in irrelevant + // fields are aggregated together + htOper = "AQUAAAAAgAAAAAAAAAAAAAAAAAAAAA=="; // use different Basic HT-MCS Set field + WifiScanEntry entryAToB5 = TestUtils.createWifiScanEntryWithWidth(bssidA, primaryChannel, htOper, null); + entryAToB5.signal = -74; + entryAToB5.unixTimeMs += 240000; // 1 min after previous entry + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB5)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB5); + expectedAggregatedEntryAToB.signal = -73; // average of -72 and -74 + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + /* + * Test that entries with VHT operation elements which differ in the relevant + * fields (channel numbers and widths) are not aggregated together. Use channel + * 42 (80 MHz wide), with the primary channel being 36 (contained "within" the + * wider channel 42). + */ + primaryChannel = 36; + // use secondary channel offset field of 1 and sta channel width field of 1 + htOper = "JAUAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + String vhtOper = "ASoAAAA="; + WifiScanEntry entryAToB6 = TestUtils.createWifiScanEntryWithWidth(bssidA, primaryChannel, htOper, vhtOper); + entryAToB6.signal = -74; + entryAToB6.unixTimeMs += 300000; // 1 min after previous entry + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB6)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB6); + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + /* + * Switch to channel 50 (160 MHz wide) which still "contains" channel 36. All + * other fields stay the same. In reality, the entry's channel field may change, + * but here it remains the same, just to test vhtOper. + */ + vhtOper = "ASoyAAA="; + WifiScanEntry entryAToB7 = TestUtils.createWifiScanEntryWithWidth(bssidA, primaryChannel, htOper, vhtOper); + entryAToB7.signal = -76; + entryAToB7.unixTimeMs += 360000; // 1 min after previous entry + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB7)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB7); + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + + // Test that entries with VHT operation elements with differ only in irrelevant + // fields are aggregated together + vhtOper = "ASoygAA="; // use different Basic VHT-MCS Set and NSS Set field + WifiScanEntry entryAToB8 = TestUtils.createWifiScanEntryWithWidth(bssidA, primaryChannel, htOper, vhtOper); + entryAToB8.signal = -78; + entryAToB8.unixTimeMs += 420000; // 1 min after previous entry + dataModel.latestWifiScans.get(apB).add(Arrays.asList(entryAToB8)); + aggregateMap = ModelerUtils.getAggregatedWifiScans(dataModel, obsoletionPeriodMs, new MeanAggregator()); + expectedAggregatedEntryAToB = new WifiScanEntry(entryAToB8); + expectedAggregatedEntryAToB.signal = -77; // average of -78 and -76 + assertEquals(expectedAggregatedEntryAToB, aggregateMap.get(apB).get(bssidA)); + } } diff --git a/src/test/java/com/facebook/openwifirrm/optimizers/LeastUsedChannelOptimizerTest.java b/src/test/java/com/facebook/openwifirrm/optimizers/LeastUsedChannelOptimizerTest.java index a0885ad..dac1472 100644 --- a/src/test/java/com/facebook/openwifirrm/optimizers/LeastUsedChannelOptimizerTest.java +++ b/src/test/java/com/facebook/openwifirrm/optimizers/LeastUsedChannelOptimizerTest.java @@ -622,6 +622,7 @@ public class LeastUsedChannelOptimizerTest { deviceB, Arrays.asList( TestUtils.createWifiScanListWithWidth( + null, Arrays.asList(36, 157), Arrays.asList( "JAUWAAAAAAAAAAAAAAAAAAAAAAAAAA==", @@ -654,6 +655,7 @@ public class LeastUsedChannelOptimizerTest { deviceC, Arrays.asList( TestUtils.createWifiScanListWithWidth( + null, channelsC2, Arrays.asList( "JAUWAAAAAAAAAAAAAAAAAAAAAAAAAA==", diff --git a/src/test/java/com/facebook/openwifirrm/optimizers/TestUtils.java b/src/test/java/com/facebook/openwifirrm/optimizers/TestUtils.java index e40e619..f5743e2 100644 --- a/src/test/java/com/facebook/openwifirrm/optimizers/TestUtils.java +++ b/src/test/java/com/facebook/openwifirrm/optimizers/TestUtils.java @@ -8,6 +8,7 @@ package com.facebook.openwifirrm.optimizers; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -17,6 +18,7 @@ import java.util.TreeSet; import java.util.stream.Collectors; import com.facebook.openwifirrm.DeviceTopology; +import com.facebook.openwifirrm.ucentral.UCentralUtils; import com.facebook.openwifirrm.ucentral.UCentralUtils.WifiScanEntry; import com.facebook.openwifirrm.ucentral.models.State; import com.google.gson.Gson; @@ -26,6 +28,9 @@ public class TestUtils { /** The Gson instance. */ private static final Gson gson = new Gson(); + /** Default value for {@link WifiScanEntry#unixTimeMs} for testing. */ + public static final Instant DEFAULT_WIFISCANENTRY_TIME = Instant.parse("2022-01-01T00:00:00Z"); + /** Create a topology from the given devices in a single zone. */ public static DeviceTopology createTopology(String zone, String... devices) { DeviceTopology topology = new DeviceTopology(); @@ -69,7 +74,9 @@ public class TestUtils { public static WifiScanEntry createWifiScanEntry(int channel) { WifiScanEntry entry = new WifiScanEntry(); entry.channel = channel; + entry.frequency = UCentralUtils.channelToFrequencyMHz(channel); entry.signal = -60; + entry.unixTimeMs = TestUtils.DEFAULT_WIFISCANENTRY_TIME.toEpochMilli(); return entry; } @@ -83,10 +90,10 @@ public class TestUtils { /** 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; + final int channel = 36; + WifiScanEntry entry = createWifiScanEntry(channel); entry.bssid = bssid; - entry.signal = rssi; + entry.signal = rssi; // overwrite return entry; } @@ -100,37 +107,38 @@ public class TestUtils { } /** - * Create a wifi scan entry with the given channel - * and channel width info (in the format of HT operation and VHT operation). + * Create a wifi scan entry with the given channel and channel width info (in + * the format of HT operation and VHT operation). It is the caller's + * responsibility to make sure {@code channel}, {@code htOper}, and + * {@code vhtOper} are consistent. */ - public static WifiScanEntry createWifiScanEntryWithWidth( - int channel, - String htOper, - String vhtOper - ) { + public static WifiScanEntry createWifiScanEntryWithWidth(String bssid, int channel, String htOper, String vhtOper) { WifiScanEntry entry = new WifiScanEntry(); + entry.bssid = bssid; entry.channel = channel; + entry.frequency = UCentralUtils.channelToFrequencyMHz(channel); entry.signal = -60; entry.ht_oper = htOper; entry.vht_oper = vhtOper; + entry.unixTimeMs = TestUtils.DEFAULT_WIFISCANENTRY_TIME.toEpochMilli(); 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). + * Create a list of wifi scan entries with the given channels and channel width + * info (in the format of HT operation and VHT operation). It is the caller's + * responsibility to make sure {@code channels}, {@code htOper}, and + * {@code vhtOper} are consistent. */ - public static List createWifiScanListWithWidth( - List channels, - List htOper, - List vhtOper - ) { + public static List createWifiScanListWithWidth(String bssid, List channels, + List htOper, List vhtOper) { List 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)) + bssid, + channels.get(i), + ((i >= htOper.size()) ? null : htOper.get(i)), + ((i >= vhtOper.size()) ? null : vhtOper.get(i)) ); wifiScanResults.add(wifiScanResult); } @@ -141,10 +149,8 @@ public class TestUtils { public static WifiScanEntry createWifiScanEntryWithBssid( int channel, String bssid ) { - WifiScanEntry entry = new WifiScanEntry(); - entry.channel = channel; + WifiScanEntry entry = createWifiScanEntry(channel); entry.bssid = bssid; - entry.signal = -60; return entry; } diff --git a/src/test/java/com/facebook/openwifirrm/ucentral/operationelement/HTOperationElementTest.java b/src/test/java/com/facebook/openwifirrm/ucentral/operationelement/HTOperationElementTest.java new file mode 100644 index 0000000..62df235 --- /dev/null +++ b/src/test/java/com/facebook/openwifirrm/ucentral/operationelement/HTOperationElementTest.java @@ -0,0 +1,49 @@ +/* + * 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.operationelement; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class HTOperationElementTest { + @Test + void testGetHtOper() { + String htOper = "AQAEAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + HTOperationElement htOperObj = new HTOperationElement(htOper); + byte expectedPrimaryChannel = 1; + byte expectedSecondaryChannelOffset = 0; + boolean expectedStaChannelWidth = false; + boolean expectedRifsMode = false; + byte expectedHtProtection = 0; + boolean expectedNongreenfieldHtStasPresent = true; + boolean expectedObssNonHtStasPresent = false; + byte expectedChannelCenterFrequencySegment2 = 0; + boolean expectedDualBeacon = false; + boolean expectedDualCtsProtection = false; + boolean expectedStbcBeacon = false; + HTOperationElement expectedHtOperObj = new HTOperationElement(expectedPrimaryChannel, + expectedSecondaryChannelOffset, + expectedStaChannelWidth, expectedRifsMode, expectedHtProtection, expectedNongreenfieldHtStasPresent, + expectedObssNonHtStasPresent, expectedChannelCenterFrequencySegment2, expectedDualBeacon, + expectedDualCtsProtection, expectedStbcBeacon); + assertEquals(expectedHtOperObj, htOperObj); + + htOper = "JAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + htOperObj = new HTOperationElement(htOper); + // all fields except the primary channel and nongreenfield field are the same + expectedPrimaryChannel = 36; + expectedNongreenfieldHtStasPresent = false; + expectedHtOperObj = new HTOperationElement(expectedPrimaryChannel, expectedSecondaryChannelOffset, + expectedStaChannelWidth, expectedRifsMode, expectedHtProtection, expectedNongreenfieldHtStasPresent, + expectedObssNonHtStasPresent, expectedChannelCenterFrequencySegment2, expectedDualBeacon, + expectedDualCtsProtection, expectedStbcBeacon); + assertEquals(expectedHtOperObj, htOperObj); + } +} diff --git a/src/test/java/com/facebook/openwifirrm/ucentral/operationelement/VHTOperationElementTest.java b/src/test/java/com/facebook/openwifirrm/ucentral/operationelement/VHTOperationElementTest.java new file mode 100644 index 0000000..7a6676d --- /dev/null +++ b/src/test/java/com/facebook/openwifirrm/ucentral/operationelement/VHTOperationElementTest.java @@ -0,0 +1,48 @@ +/* + * 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.operationelement; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class VHTOperationElementTest { + + @Test + void testGetVhtOper() { + String vhtOper = "ACQAAAA="; + VHTOperationElement vhtOperObj = new VHTOperationElement(vhtOper); + boolean expectedChannelWidthIndicator = false; // 20 MHz channel width + byte expectedChannel1 = 36; + byte expectedChannel2 = 0; + byte[] expectedVhtMcsForNss = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }; + VHTOperationElement expectedVhtOperObj = new VHTOperationElement(expectedChannelWidthIndicator, expectedChannel1, expectedChannel2, expectedVhtMcsForNss); + assertEquals(expectedVhtOperObj, vhtOperObj); + + vhtOper = "AToAUAE="; + vhtOperObj = new VHTOperationElement(vhtOper); + expectedChannelWidthIndicator = true; // 80 MHz channel width + expectedChannel1 = 58; + // same channel2 + expectedVhtMcsForNss = new byte[] { 1, 1, 0, 0, 0, 0, 0, 1 }; + expectedVhtOperObj = new VHTOperationElement(expectedChannelWidthIndicator, expectedChannel1, expectedChannel2, + expectedVhtMcsForNss); + assertEquals(expectedVhtOperObj, vhtOperObj); + + vhtOper = "ASoyUAE="; + vhtOperObj = new VHTOperationElement(vhtOper); + // same channel width indicator (160 MHz channel width) + expectedChannel1 = 42; + expectedChannel2 = 50; + // same vhtMcsForNss + expectedVhtOperObj = new VHTOperationElement(expectedChannelWidthIndicator, expectedChannel1, expectedChannel2, + expectedVhtMcsForNss); + assertEquals(expectedVhtOperObj, vhtOperObj); + } +}