• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

homebridge / HAP-NodeJS / 15519396747

08 Jun 2025 02:22PM UTC coverage: 64.324% (-0.01%) from 64.338%
15519396747

push

github

web-flow
bring `latest` up to date with `release-0.x` (#1093)

1354 of 2503 branches covered (54.1%)

Branch coverage included in aggregate %.

12 of 19 new or added lines in 4 files covered. (63.16%)

2 existing lines in 1 file now uncovered.

6242 of 9306 relevant lines covered (67.08%)

208.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

49.14
/src/lib/model/ControllerStorage.ts
1
import { MacAddress } from "../../types";
2
import util from "util";
6✔
3
import createDebug from "debug";
6✔
4
import { ControllerIdentifier, SerializableController } from "../controller";
5
import { Accessory } from "../Accessory";
6
import { HAPStorage } from "./HAPStorage";
6✔
7

8

9
const debug = createDebug("HAP-NodeJS:ControllerStorage");
6✔
10

11
interface StorageLayout {
12
  accessories: Record<string, StoredControllerData[]>, // indexed by accessory UUID
13
}
14

15
interface StoredControllerData {
16
  type: ControllerIdentifier, // this field is called type out of history
17
  controllerData: ControllerData,
18
}
19

20
interface ControllerData {
21
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
  data: any,
23
  /*
24
  This property and the exact sequence this property is accessed solves the following problems:
25
    - Orphaned ControllerData won't be there forever and gets cleared at some point
26
    - When storage is loaded, there is no fixed time frame after which Controllers need to be configured
27
   */
28
  purgeOnNextLoad?: boolean,
29
}
30

31
/**
32
 * @group Model
33
 */
34
export class ControllerStorage {
6✔
35

36
  private readonly accessoryUUID: string;
37
  private initialized = false;
348✔
38

39
  // ----- properties only set in parent storage object ------
40
  private username?: MacAddress;
41
  private fileCreated = false;
348✔
42
  purgeUnidentifiedAccessoryData = true;
348✔
43
  // ---------------------------------------------------------
44

45
  private trackedControllers: SerializableController[] = []; // used to track controllers before data was loaded from disk
348✔
46
  private controllerData: Record<ControllerIdentifier, ControllerData> = {};
348✔
47
  private restoredAccessories?: Record<string, StoredControllerData[]>; // indexed by accessory UUID
48

49
  private parent?: ControllerStorage;
50
  private linkedAccessories?: ControllerStorage[];
51

52
  private queuedSaveTimeout?: NodeJS.Timeout;
53
  private queuedSaveTime?: number;
54

55
  public constructor(accessory: Accessory) {
56
    this.accessoryUUID = accessory.UUID;
348✔
57
  }
58

59
  private enqueueSaveRequest(timeout = 0): void {
×
60
    if (this.parent) {
4!
61
      this.parent.enqueueSaveRequest(timeout);
×
62
      return;
×
63
    }
64

65
    const plannedTime = Date.now() + timeout;
4✔
66

67
    if (this.queuedSaveTimeout) {
4!
68
      if (plannedTime <= (this.queuedSaveTime ?? 0)) {
×
69
        return;
×
70
      }
71

72
      clearTimeout(this.queuedSaveTimeout);
×
73
    }
74

75
    this.queuedSaveTimeout = setTimeout(() => {
4✔
76
      this.queuedSaveTimeout = this.queuedSaveTime = undefined;
4✔
77
      this.save();
4✔
78
    }, timeout).unref();
79
    this.queuedSaveTime = Date.now() + timeout;
4✔
80
  }
81

82
  /**
83
   * Links a bridged accessory to the ControllerStorage of the bridge accessory.
84
   *
85
   * @param accessory
86
   */
87
  public linkAccessory(accessory: Accessory): void {
88
    if (!this.linkedAccessories) {
16✔
89
      this.linkedAccessories = [];
12✔
90
    }
91

92
    const storage = accessory.controllerStorage;
16✔
93
    this.linkedAccessories.push(storage);
16✔
94
    storage.parent = this;
16✔
95

96
    const saved = this.restoredAccessories && this.restoredAccessories[accessory.UUID];
16!
97
    if (this.initialized) {
16✔
98
      storage.init(saved);
4✔
99
    }
100
  }
101

102
  public trackController(controller: SerializableController): void {
103
    controller.setupStateChangeDelegate(this.handleStateChange.bind(this, controller)); // setup delegate
14✔
104

105
    if (!this.initialized) { // track controller if data isn't loaded yet
14✔
106
      this.trackedControllers.push(controller);
2✔
107
    } else {
108
      this.restoreController(controller);
12✔
109
    }
110
  }
111

112
  public untrackController(controller: SerializableController): void {
113
    const index = this.trackedControllers.indexOf(controller);
4✔
114
    if (index !== -1) { // remove from trackedControllers if storage wasn't initialized yet
4!
115
      this.trackedControllers.splice(index, 1);
×
116
    }
117

118
    controller.setupStateChangeDelegate(undefined); // remove association with this storage object
4✔
119

120
    this.purgeControllerData(controller);
4✔
121
  }
122

123
  public purgeControllerData(controller: SerializableController): void {
124
    delete this.controllerData[controller.controllerId()];
4✔
125

126
    if (this.initialized) {
4✔
127
      this.enqueueSaveRequest(100);
4✔
128
    }
129
  }
130

131
  private handleStateChange(controller: SerializableController) {
132
    const id = controller.controllerId();
×
133
    const serialized = controller.serialize();
×
134

135
    if (!serialized) { // can be undefined when controller wishes to delete data
×
136
      delete this.controllerData[id];
×
137
    } else {
138
      const controllerData = this.controllerData[id];
×
139

140
      if (!controllerData) {
×
141
        this.controllerData[id] = {
×
142
          data: serialized,
143
        };
144
      } else {
145
        controllerData.data = serialized;
×
146
      }
147
    }
148

149
    if (this.initialized) { // only save if data was loaded
×
150
      // run save data "async", as handleStateChange call will probably always be caused by a http request
151
      // this should improve our response time
152
      this.enqueueSaveRequest(100);
×
153
    }
154
  }
155

156

157
  private restoreController(controller: SerializableController) {
158
    if (!this.initialized) {
14!
159
      throw new Error("Illegal state. Controller data wasn't loaded yet!");
×
160
    }
161

162
    const controllerData = this.controllerData[controller.controllerId()];
14✔
163
    if (controllerData) {
14!
164
      try {
×
165
        controller.deserialize(controllerData.data);
×
166
      } catch (error) {
167
        console.warn(`Could not initialize controller of type '${controller.controllerId()}' from data stored on disk. Resetting to default: ${error.stack}`);
×
168
        controller.handleFactoryReset();
×
169
      }
170
      controllerData.purgeOnNextLoad = undefined;
×
171
    }
172
  }
173

174
  /**
175
   * Called when this particular Storage object is feed with data loaded from disk.
176
   * This method is only called once.
177
   *
178
   * @param data - array of {@link StoredControllerData}. undefined if nothing was stored on disk for this particular storage object
179
   */
180
  private init(data?: StoredControllerData[]) {
181
    if (this.initialized) {
102!
182
      throw new Error(`ControllerStorage for accessory ${this.accessoryUUID} was already initialized!`);
×
183
    }
184
    this.initialized = true;
102✔
185

186
    // storing data into our local controllerData Record
187
    if (data) {
102!
NEW
188
      data.forEach(saved => this.controllerData[saved.type] = saved.controllerData);
×
189
    }
190

191
    const restoredControllers: ControllerIdentifier[] = [];
102✔
192
    this.trackedControllers.forEach(controller => {
102✔
193
      this.restoreController(controller);
2✔
194
      restoredControllers.push(controller.controllerId());
2✔
195
    });
196
    this.trackedControllers.splice(0, this.trackedControllers.length); // clear tracking list
102✔
197

198
    let purgedData = false;
102✔
199
    Object.entries(this.controllerData).forEach(([id, data]) => {
102✔
200
      if (data.purgeOnNextLoad) {
×
201
        delete this.controllerData[id];
×
202
        purgedData = true;
×
203
        return;
×
204
      }
205

206
      if (!restoredControllers.includes(id)) {
×
207
        data.purgeOnNextLoad = true;
×
208
      }
209
    });
210

211
    if (purgedData) {
102!
212
      this.enqueueSaveRequest(500);
×
213
    }
214
  }
215

216
  public load(username: MacAddress): void { // will be called once accessory gets published
217
    if (this.username) {
98!
218
      throw new Error("ControllerStorage was already loaded!");
×
219
    }
220
    this.username = username;
98✔
221

222
    const key = ControllerStorage.persistKey(username);
98✔
223
    const saved: StorageLayout | undefined = HAPStorage.storage().getItem(key);
98✔
224

225
    let ownData;
226
    if (saved) {
98!
227
      this.fileCreated = true;
×
228

229
      ownData = saved.accessories[this.accessoryUUID];
×
230
      delete saved.accessories[this.accessoryUUID];
×
231
    }
232

233
    this.init(ownData);
98✔
234

235
    if (this.linkedAccessories) {
98!
236
      this.linkedAccessories.forEach(linkedStorage => {
×
237
        const savedData = saved && saved.accessories[linkedStorage.accessoryUUID];
×
238
        linkedStorage.init(savedData);
×
239

240
        if (saved) {
×
241
          delete saved.accessories[linkedStorage.accessoryUUID];
×
242
        }
243
      });
244
    }
245

246
    if (saved && Object.keys(saved.accessories).length > 0) {
98!
247
      if (!this.purgeUnidentifiedAccessoryData) {
×
248
        this.restoredAccessories = saved.accessories; // save data for controllers which aren't linked yet
×
249
      } else {
250
        debug("Purging unidentified controller data for bridge %s", username);
×
251
      }
252
    }
253
  }
254

255
  public save(): void {
256
    if (this.parent) {
4!
257
      this.parent.save();
×
258
      return;
×
259
    }
260

261
    if (!this.initialized) {
4!
262
      throw new Error("ControllerStorage has not yet been loaded!");
×
263
    }
264
    if (!this.username) {
4!
265
      throw new Error("Cannot save controllerData for a storage without a username!");
×
266
    }
267

268
    const accessories: Record<string, Record<ControllerIdentifier, ControllerData>> = {
4✔
269
      [this.accessoryUUID]: this.controllerData,
270
    };
271
    if (this.linkedAccessories) { // grab data from all linked storage objects
4✔
272
      this.linkedAccessories.forEach(accessory => accessories[accessory.accessoryUUID] = accessory.controllerData);
2✔
273
    }
274

275
    // TODO removed accessories won't ever be deleted?
276
    const accessoryData: Record<string, StoredControllerData[]> = this.restoredAccessories || {};
4✔
277
    Object.entries(accessories).forEach(([uuid, controllerData]) => {
4✔
278
      const entries = Object.entries(controllerData);
4✔
279

280
      if (entries.length > 0) {
4!
281
        accessoryData[uuid] = entries.map(([id, data]) => ({
×
282
          type: id,
283
          controllerData: data,
284
        }));
285
      }
286
    });
287

288
    const key = ControllerStorage.persistKey(this.username);
4✔
289
    if (Object.keys(accessoryData).length > 0) {
4!
290
      const saved: StorageLayout = {
×
291
        accessories: accessoryData,
292
      };
293

294
      this.fileCreated = true;
×
295
      HAPStorage.storage().setItemSync(key, saved);
×
296
    } else if (this.fileCreated) {
4!
297
      this.fileCreated = false;
×
298
      HAPStorage.storage().removeItemSync(key);
×
299
    }
300
  }
301

302
  static persistKey(username: MacAddress): string {
303
    return util.format("ControllerStorage.%s.json", username.replace(/:/g, "").toUpperCase());
404✔
304
  }
305

306
  static remove(username: MacAddress): void {
307
    const key = ControllerStorage.persistKey(username);
302✔
308
    HAPStorage.storage().removeItemSync(key);
302✔
309
  }
310

311
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc