• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.car.internal.property;
18 
19 import static com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport.DUMP_INFO;
20 import static com.android.car.internal.property.CarPropertyHelper.propertyIdsToString;
21 import static com.android.car.internal.util.ArrayUtils.convertToIntArray;
22 import static com.android.car.internal.util.DebugUtils.toAreaIdString;
23 
24 import android.annotation.Nullable;
25 import android.car.VehiclePropertyIds;
26 import android.util.ArrayMap;
27 import android.util.ArraySet;
28 import android.util.Log;
29 import android.util.Slog;
30 
31 import com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport;
32 import com.android.car.internal.util.IndentingPrintWriter;
33 import com.android.car.internal.util.PairSparseArray;
34 
35 import java.util.ArrayList;
36 import java.util.List;
37 import java.util.Objects;
38 import java.util.Set;
39 import java.util.TreeSet;
40 
41 /**
42  * This class manages [{propertyId, areaId} -> RateInfoForClients] map and maintains two states:
43  * a current state and a staged state. The staged state represents the proposed changes. After
44  * the changes are applied to the lower layer, caller either uses {@link #commit} to replace
45  * the curren state with the staged state, or uses {@link #dropCommit} to drop the staged state.
46  *
47  * A common pattern is
48  *
49  * ```
50  * synchronized (mLock) {
51  *   mSubscriptionManager.stageNewOptions(...);
52  *   // Optionally stage some other options.
53  *   mSubscriptionManager.stageNewOptions(...);
54  *   // Optionally stage unregistration.
55  *   mSubscriptionManager.stageUnregister(...);
56  *
57  *   mSubscriptionManager.diffBetweenCurrentAndStage(...);
58  *   try {
59  *     // Apply the diff.
60  *   } catch (Exception e) {
61  *     mSubscriptionManager.dropCommit();
62  *     throw e;
63  *   }
64  *   mSubscriptionManager.commit();
65  * }
66  * ```
67  *
68  * This class is not thread-safe.
69  *
70  * @param <ClientType> A class representing a client.
71  */
72 public final class SubscriptionManager<ClientType> {
73     private static final String TAG = SubscriptionManager.class.getSimpleName();
74     private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
75     private static final boolean VERBOSE = Log.isLoggable(TAG, Log.VERBOSE);
76 
77     private static final class RateInfo {
78         public final float updateRateHz;
79         public final boolean enableVariableUpdateRate;
80         public final float resolution;
81 
RateInfo(float updateRateHz, boolean enableVariableUpdateRate, float resolution)82         RateInfo(float updateRateHz, boolean enableVariableUpdateRate, float resolution) {
83             this.updateRateHz = updateRateHz;
84             this.enableVariableUpdateRate = enableVariableUpdateRate;
85             this.resolution = resolution;
86         }
87 
88         @Override
toString()89         public String toString() {
90             return String.format(
91                     "RateInfo{updateRateHz: %f, enableVur: %b, resolution: %f}", updateRateHz,
92                     enableVariableUpdateRate, resolution);
93         }
94 
95         @Override
equals(Object other)96         public boolean equals(Object other) {
97             if (this == other) {
98                 return true;
99             }
100             if (!(other instanceof RateInfo)) {
101                 return false;
102             }
103             RateInfo that = (RateInfo) other;
104             return updateRateHz == that.updateRateHz
105                     && enableVariableUpdateRate == that.enableVariableUpdateRate
106                     && resolution == that.resolution;
107         }
108 
109         @Override
hashCode()110         public int hashCode() {
111             return Objects.hash(updateRateHz, enableVariableUpdateRate, resolution);
112         }
113     }
114 
115     /**
116      * This class provides an abstraction for all the clients and their subscribed rate for a
117      * specific {propertyId, areaId} pair.
118      */
119     private static final class RateInfoForClients<ClientType> {
120         private final ArrayMap<ClientType, RateInfo> mRateInfoByClient;
121         // An ordered set for all update rates to provide efficient add, remove and get max update
122         // rate.
123         private final TreeSet<Float> mUpdateRatesHz;
124         private final ArrayMap<Float, Integer> mClientCountByUpdateRateHz;
125         private final TreeSet<Float> mResolutions;
126         private final ArrayMap<Float, Integer> mClientCountByResolution;
127         // How many clients has enabled variable update rate. We can only enable variable update
128         // rate in the underlying layer if all clients enable VUR.
129         private int mEnableVariableUpdateRateCount;
130 
RateInfoForClients()131         RateInfoForClients() {
132             mRateInfoByClient = new ArrayMap<>();
133             mUpdateRatesHz = new TreeSet<>();
134             mClientCountByUpdateRateHz = new ArrayMap<>();
135             mResolutions = new TreeSet<>();
136             mClientCountByResolution = new ArrayMap<>();
137         }
138 
RateInfoForClients(RateInfoForClients other)139         RateInfoForClients(RateInfoForClients other) {
140             mRateInfoByClient = new ArrayMap<>(other.mRateInfoByClient);
141             mUpdateRatesHz = new TreeSet<>(other.mUpdateRatesHz);
142             mClientCountByUpdateRateHz = new ArrayMap<>(other.mClientCountByUpdateRateHz);
143             mResolutions = new TreeSet<>(other.mResolutions);
144             mClientCountByResolution = new ArrayMap<>(other.mClientCountByResolution);
145             mEnableVariableUpdateRateCount = other.mEnableVariableUpdateRateCount;
146         }
147 
148         /**
149          * Gets the max update rate for this {propertyId, areaId}.
150          */
getMaxUpdateRateHz()151         private float getMaxUpdateRateHz() {
152             return mUpdateRatesHz.last();
153         }
154 
155         /**
156          * Gets the min required resolution for this {propertyId, areaId}.
157          */
getMinRequiredResolution()158         private float getMinRequiredResolution() {
159             return mResolutions.first();
160         }
161 
isVariableUpdateRateEnabledForAllClients()162         private boolean isVariableUpdateRateEnabledForAllClients() {
163             return mEnableVariableUpdateRateCount == mRateInfoByClient.size();
164         }
165 
166         /**
167          * Gets the combined rate info for all clients.
168          *
169          * We use the max update rate, min required resolution, and only enable VUR if all clients
170          * enable.
171          */
getCombinedRateInfo()172         RateInfo getCombinedRateInfo() {
173             return new RateInfo(getMaxUpdateRateHz(), isVariableUpdateRateEnabledForAllClients(),
174                     getMinRequiredResolution());
175         }
176 
getClients()177         Set<ClientType> getClients() {
178             return mRateInfoByClient.keySet();
179         }
180 
getUpdateRateHz(ClientType client)181         float getUpdateRateHz(ClientType client) {
182             return mRateInfoByClient.get(client).updateRateHz;
183         }
184 
isVariableUpdateRateEnabled(ClientType client)185         boolean isVariableUpdateRateEnabled(ClientType client) {
186             return mRateInfoByClient.get(client).enableVariableUpdateRate;
187         }
188 
getResolution(ClientType client)189         float getResolution(ClientType client) {
190             return mRateInfoByClient.get(client).resolution;
191         }
192 
193         /**
194          * Adds a new client for this {propertyId, areaId}.
195          */
add(ClientType client, float updateRateHz, boolean enableVariableUpdateRate, float resolution)196         void add(ClientType client, float updateRateHz, boolean enableVariableUpdateRate,
197                  float resolution) {
198             // Clear the existing updateRateHz for the client if exists.
199             remove(client);
200             // Store the new rate info.
201             mRateInfoByClient.put(client,
202                     new RateInfo(updateRateHz, enableVariableUpdateRate, resolution));
203 
204             if (enableVariableUpdateRate) {
205                 mEnableVariableUpdateRateCount++;
206             }
207 
208             if (!mClientCountByUpdateRateHz.containsKey(updateRateHz)) {
209                 mUpdateRatesHz.add(updateRateHz);
210                 mClientCountByUpdateRateHz.put(updateRateHz, 1);
211             } else {
212                 mClientCountByUpdateRateHz.put(updateRateHz,
213                         mClientCountByUpdateRateHz.get(updateRateHz) + 1);
214             }
215 
216             if (!mClientCountByResolution.containsKey(resolution)) {
217                 mResolutions.add(resolution);
218                 mClientCountByResolution.put(resolution, 1);
219             } else {
220                 mClientCountByResolution.put(resolution,
221                         mClientCountByResolution.get(resolution) + 1);
222             }
223         }
224 
remove(ClientType client)225         void remove(ClientType client) {
226             if (!mRateInfoByClient.containsKey(client)) {
227                 return;
228             }
229             RateInfo rateInfo = mRateInfoByClient.get(client);
230             if (rateInfo.enableVariableUpdateRate) {
231                 mEnableVariableUpdateRateCount--;
232             }
233             float updateRateHz = rateInfo.updateRateHz;
234             if (mClientCountByUpdateRateHz.containsKey(updateRateHz)) {
235                 int newCount = mClientCountByUpdateRateHz.get(updateRateHz) - 1;
236                 if (newCount == 0) {
237                     mClientCountByUpdateRateHz.remove(updateRateHz);
238                     mUpdateRatesHz.remove(updateRateHz);
239                 } else {
240                     mClientCountByUpdateRateHz.put(updateRateHz, newCount);
241                 }
242             }
243             float resolution = rateInfo.resolution;
244             if (mClientCountByResolution.containsKey(resolution)) {
245                 int newCount = mClientCountByResolution.get(resolution) - 1;
246                 if (newCount == 0) {
247                     mClientCountByResolution.remove(resolution);
248                     mResolutions.remove(resolution);
249                 } else {
250                     mClientCountByResolution.put(resolution, newCount);
251                 }
252             }
253 
254             mRateInfoByClient.remove(client);
255         }
256 
isEmpty()257         boolean isEmpty() {
258             return mRateInfoByClient.isEmpty();
259         }
260     }
261 
262     PairSparseArray<RateInfoForClients<ClientType>> mCurrentRateInfoByClientByPropIdAreaId =
263             new PairSparseArray<>();
264     PairSparseArray<RateInfoForClients<ClientType>> mStagedRateInfoByClientByPropIdAreaId =
265             new PairSparseArray<>();
266     ArraySet<int[]> mStagedAffectedPropIdAreaIds = new ArraySet<>();
267 
268     /**
269      * Prepares new subscriptions.
270      *
271      * This apply the new subscribe options in the staging area without actually committing them.
272      * Client should call {@link #diffBetweenCurrentAndStage} to get the difference between current
273      * and the staging state. Apply them to the lower layer, and either commit the change after
274      * the operation succeeds or drop the change after the operation failed.
275      */
stageNewOptions(ClientType client, List<CarSubscription> options)276     public void stageNewOptions(ClientType client, List<CarSubscription> options) {
277         if (DBG) {
278             Slog.d(TAG, "stageNewOptions: options: " + options);
279         }
280 
281         cloneCurrentToStageIfClean();
282 
283         for (int i = 0; i < options.size(); i++) {
284             CarSubscription option = options.get(i);
285             int propertyId = option.propertyId;
286             for (int areaId : option.areaIds) {
287                 mStagedAffectedPropIdAreaIds.add(new int[]{propertyId, areaId});
288                 if (mStagedRateInfoByClientByPropIdAreaId.get(propertyId, areaId) == null) {
289                     mStagedRateInfoByClientByPropIdAreaId.put(propertyId, areaId,
290                             new RateInfoForClients<>());
291                 }
292                 mStagedRateInfoByClientByPropIdAreaId.get(propertyId, areaId).add(
293                         client, option.updateRateHz, option.enableVariableUpdateRate,
294                         option.resolution);
295             }
296         }
297     }
298 
299     /**
300      * Prepares unregistration for list of property IDs.
301      *
302      * This apply the unregistration in the staging area without actually committing them.
303      */
stageUnregister(ClientType client, ArraySet<Integer> propertyIdsToUnregister)304     public void stageUnregister(ClientType client, ArraySet<Integer> propertyIdsToUnregister) {
305         if (DBG) {
306             Slog.d(TAG, "stageUnregister: propertyIdsToUnregister: " + propertyIdsToString(
307                     propertyIdsToUnregister));
308         }
309 
310         cloneCurrentToStageIfClean();
311 
312         for (int i = 0; i < propertyIdsToUnregister.size(); i++) {
313             int propertyId = propertyIdsToUnregister.valueAt(i);
314             ArraySet<Integer> areaIds =
315                     mStagedRateInfoByClientByPropIdAreaId.getSecondKeysForFirstKey(propertyId);
316             for (int j = 0; j < areaIds.size(); j++) {
317                 int areaId = areaIds.valueAt(j);
318                 mStagedAffectedPropIdAreaIds.add(new int[]{propertyId, areaId});
319                 RateInfoForClients<ClientType> rateInfoForClients =
320                         mStagedRateInfoByClientByPropIdAreaId.get(propertyId, areaId);
321                 if (rateInfoForClients == null) {
322                     Slog.e(TAG, "The property: " + VehiclePropertyIds.toString(propertyId)
323                             + ", area ID: " + toAreaIdString(propertyId, areaId)
324                             + " was not registered, do nothing");
325                     continue;
326                 }
327                 rateInfoForClients.remove(client);
328                 if (rateInfoForClients.isEmpty()) {
329                     mStagedRateInfoByClientByPropIdAreaId.remove(propertyId, areaId);
330                 }
331             }
332         }
333     }
334 
335     /**
336      * Commit the staged changes.
337      *
338      * This will replace the current state with the staged state. This should be called after the
339      * changes are applied successfully to the lower layer.
340      */
commit()341     public void commit() {
342         if (mStagedAffectedPropIdAreaIds.isEmpty()) {
343             if (VERBOSE) {
344                 Slog.v(TAG, "No changes has been staged, nothing to commit");
345             }
346             return;
347         }
348         // Drop the current state.
349         mCurrentRateInfoByClientByPropIdAreaId = mStagedRateInfoByClientByPropIdAreaId;
350         mStagedAffectedPropIdAreaIds.clear();
351     }
352 
353     /**
354      * Drop the staged changes.
355      *
356      * This should be called after the changes failed to apply to the lower layer.
357      */
dropCommit()358     public void dropCommit() {
359         if (mStagedAffectedPropIdAreaIds.isEmpty()) {
360             if (DBG) {
361                 Slog.d(TAG, "No changes has been staged, nothing to drop");
362             }
363             return;
364         }
365         // Drop the staged state.
366         mStagedRateInfoByClientByPropIdAreaId = mCurrentRateInfoByClientByPropIdAreaId;
367         mStagedAffectedPropIdAreaIds.clear();
368     }
369 
getCurrentSubscribedPropIds()370     public ArraySet<Integer> getCurrentSubscribedPropIds() {
371         return new ArraySet<Integer>(mCurrentRateInfoByClientByPropIdAreaId.getFirstKeys());
372     }
373 
374     /**
375      * Clear both the current state and staged state.
376      */
clear()377     public void clear() {
378         mStagedRateInfoByClientByPropIdAreaId.clear();
379         mCurrentRateInfoByClientByPropIdAreaId.clear();
380         mStagedAffectedPropIdAreaIds.clear();
381     }
382 
383     /**
384      * Gets all the subscription clients for the given propertyID, area ID pair.
385      *
386      * This uses the current state.
387      */
getClients(int propertyId, int areaId)388     public @Nullable Set<ClientType> getClients(int propertyId, int areaId) {
389         if (!mCurrentRateInfoByClientByPropIdAreaId.contains(propertyId, areaId)) {
390             return null;
391         }
392         return mCurrentRateInfoByClientByPropIdAreaId.get(propertyId, areaId).getClients();
393     }
394 
395     /**
396      * Dumps the state.
397      */
398     @ExcludeFromCodeCoverageGeneratedReport(reason = DUMP_INFO)
dump(IndentingPrintWriter writer)399     public void dump(IndentingPrintWriter writer) {
400         writer.println("Current subscription states:");
401         dumpStates(writer, mCurrentRateInfoByClientByPropIdAreaId);
402         writer.println("Staged subscription states:");
403         dumpStates(writer, mStagedRateInfoByClientByPropIdAreaId);
404     }
405 
406     /**
407      * Calculates the difference between the staged state and current state.
408      *
409      * @param outDiffSubscriptions The output subscriptions that has changed. This includes
410      *      both new subscriptions and updated subscriptions with a new update rate.
411      * @param outPropertyIdsToUnsubscribe The output property IDs that need to be unsubscribed.
412      */
diffBetweenCurrentAndStage(List<CarSubscription> outDiffSubscriptions, List<Integer> outPropertyIdsToUnsubscribe)413     public void diffBetweenCurrentAndStage(List<CarSubscription> outDiffSubscriptions,
414             List<Integer> outPropertyIdsToUnsubscribe) {
415         if (mStagedAffectedPropIdAreaIds.isEmpty()) {
416             if (VERBOSE) {
417                 Slog.v(TAG, "No changes has been staged, no diff");
418             }
419             return;
420         }
421         ArraySet<Integer> possiblePropIdsToUnsubscribe = new ArraySet<>();
422         PairSparseArray<RateInfo> diffRateInfoByPropIdAreaId = new PairSparseArray<>();
423         for (int i = 0; i < mStagedAffectedPropIdAreaIds.size(); i++) {
424             int[] propIdAreaId = mStagedAffectedPropIdAreaIds.valueAt(i);
425             int propertyId = propIdAreaId[0];
426             int areaId = propIdAreaId[1];
427 
428             if (!mStagedRateInfoByClientByPropIdAreaId.contains(propertyId, areaId)) {
429                 // The [PropertyId, areaId] is no longer subscribed.
430                 if (DBG) {
431                     Slog.d(TAG, String.format("The property: %s, areaId: %s is no longer "
432                                     + "subscribed", VehiclePropertyIds.toString(propertyId),
433                             toAreaIdString(propertyId, areaId)));
434                 }
435                 possiblePropIdsToUnsubscribe.add(propertyId);
436                 continue;
437             }
438 
439             RateInfo newCombinedRateInfo = mStagedRateInfoByClientByPropIdAreaId
440                     .get(propertyId, areaId).getCombinedRateInfo();
441 
442             if (!mCurrentRateInfoByClientByPropIdAreaId.contains(propertyId, areaId)
443                     || !(mCurrentRateInfoByClientByPropIdAreaId
444                             .get(propertyId, areaId).getCombinedRateInfo()
445                             .equals(newCombinedRateInfo))) {
446                 if (DBG) {
447                     Slog.d(TAG, String.format(
448                             "New combined subscription rate info for property: %s, areaId: %s, %s",
449                             VehiclePropertyIds.toString(propertyId),
450                             toAreaIdString(propertyId, areaId), newCombinedRateInfo));
451                 }
452                 diffRateInfoByPropIdAreaId.put(propertyId, areaId, newCombinedRateInfo);
453                 continue;
454             }
455         }
456         outDiffSubscriptions.addAll(getCarSubscription(diffRateInfoByPropIdAreaId));
457         for (int i = 0; i < possiblePropIdsToUnsubscribe.size(); i++) {
458             int possiblePropIdToUnsubscribe = possiblePropIdsToUnsubscribe.valueAt(i);
459             if (mStagedRateInfoByClientByPropIdAreaId.getSecondKeysForFirstKey(
460                     possiblePropIdToUnsubscribe).isEmpty()) {
461                 // We should only unsubscribe the property if all area IDs are unsubscribed.
462                 if (DBG) {
463                     Slog.d(TAG, String.format(
464                             "All areas for the property: %s are no longer subscribed, "
465                             + "unsubscribe it", VehiclePropertyIds.toString(
466                                     possiblePropIdToUnsubscribe)));
467                 }
468                 outPropertyIdsToUnsubscribe.add(possiblePropIdToUnsubscribe);
469             }
470         }
471     }
472 
473     /**
474      * Generates the {@code CarSubscription} instances.
475      *
476      * Converts [[propId, areaId] -> updateRateHz] map to
477      * [propId -> [updateRateHz -> list of areaIds]] and then generates subscribe option for each
478      * updateRateHz for each propId.
479      *
480      * @param diffRateInfoByPropIdAreaId A [[propId, areaId] -> updateRateHz] map.
481      */
getCarSubscription( PairSparseArray<RateInfo> diffRateInfoByPropIdAreaId)482     private static List<CarSubscription> getCarSubscription(
483             PairSparseArray<RateInfo> diffRateInfoByPropIdAreaId) {
484         List<CarSubscription> carSubscriptions = new ArrayList<>();
485         ArraySet<Integer> propertyIds = diffRateInfoByPropIdAreaId.getFirstKeys();
486         for (int propertyIdIndex = 0; propertyIdIndex < propertyIds.size(); propertyIdIndex++) {
487             int propertyId = propertyIds.valueAt(propertyIdIndex);
488             ArraySet<Integer> areaIds = diffRateInfoByPropIdAreaId.getSecondKeysForFirstKey(
489                     propertyId);
490 
491             // Group the areaIds by RateInfo.
492             ArrayMap<RateInfo, List<Integer>> areaIdsByRateInfo = new ArrayMap<>();
493             for (int i = 0; i < areaIds.size(); i++) {
494                 int areaId = areaIds.valueAt(i);
495                 RateInfo rateInfo = diffRateInfoByPropIdAreaId.get(propertyId, areaId);
496                 if (!areaIdsByRateInfo.containsKey(rateInfo)) {
497                     areaIdsByRateInfo.put(rateInfo, new ArrayList<>());
498                 }
499                 areaIdsByRateInfo.get(rateInfo).add(areaId);
500             }
501 
502             // Convert each update rate to a new CarSubscription.
503             for (int i = 0; i < areaIdsByRateInfo.size(); i++) {
504                 CarSubscription option = new CarSubscription();
505                 option.propertyId = propertyId;
506                 option.areaIds = convertToIntArray(areaIdsByRateInfo.valueAt(i));
507                 option.updateRateHz = areaIdsByRateInfo.keyAt(i).updateRateHz;
508                 option.enableVariableUpdateRate =
509                         areaIdsByRateInfo.keyAt(i).enableVariableUpdateRate;
510                 option.resolution = areaIdsByRateInfo.keyAt(i).resolution;
511                 carSubscriptions.add(option);
512             }
513         }
514 
515         return carSubscriptions;
516     }
517 
cloneCurrentToStageIfClean()518     private void cloneCurrentToStageIfClean() {
519         if (!mStagedAffectedPropIdAreaIds.isEmpty()) {
520             // The current state is not clean, we already cloned once. We allow staging multiple
521             // commits before final commit/drop.
522             return;
523         }
524 
525         mStagedRateInfoByClientByPropIdAreaId = new PairSparseArray<>();
526         for (int i = 0; i < mCurrentRateInfoByClientByPropIdAreaId.size(); i++) {
527             int[] keyPair = mCurrentRateInfoByClientByPropIdAreaId.keyPairAt(i);
528             mStagedRateInfoByClientByPropIdAreaId.put(keyPair[0], keyPair[1],
529                     new RateInfoForClients<>(
530                             mCurrentRateInfoByClientByPropIdAreaId.valueAt(i)));
531         }
532     }
533 
dumpStates(IndentingPrintWriter writer, PairSparseArray<RateInfoForClients<ClientType>> states)534     private static <ClientType> void dumpStates(IndentingPrintWriter writer,
535             PairSparseArray<RateInfoForClients<ClientType>> states) {
536         for (int i = 0; i < states.size(); i++) {
537             int[] propIdAreaId = states.keyPairAt(i);
538             RateInfoForClients<ClientType> rateInfoForClients = states.valueAt(i);
539             int propertyId = propIdAreaId[0];
540             int areaId = propIdAreaId[1];
541             Set<ClientType> clients = states.get(propertyId, areaId).getClients();
542             writer.println("property: " + VehiclePropertyIds.toString(propertyId)
543                     + ", area ID: " + toAreaIdString(propertyId, areaId) + " is registered by "
544                     + clients.size()
545                     + " client(s).");
546             writer.increaseIndent();
547             for (ClientType client : clients) {
548                 writer.println("Client " + client + ": Subscribed at "
549                         + rateInfoForClients.getUpdateRateHz(client) + " hz"
550                         + ", enableVur: "
551                         + rateInfoForClients.isVariableUpdateRateEnabled(client)
552                         + ", resolution: " + rateInfoForClients.getResolution(client));
553             }
554             writer.decreaseIndent();
555         }
556     }
557 }
558