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

homebridge / HAP-NodeJS / 14017380819

23 Mar 2025 08:58AM UTC coverage: 64.338% (-0.7%) from 64.993%
14017380819

push

github

web-flow
updated dependencies (#1085)

1360 of 2511 branches covered (54.16%)

Branch coverage included in aggregate %.

6237 of 9297 relevant lines covered (67.09%)

312.17 hits per line

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

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

8

9
const debug = createDebug("HAP-NodeJS:ControllerStorage");
9✔
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 {
9✔
35

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

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

45
  private trackedControllers: SerializableController[] = []; // used to track controllers before data was loaded from disk
522✔
46
  private controllerData: Record<ControllerIdentifier, ControllerData> = {};
522✔
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;
522✔
57
  }
58

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

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

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

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

75
    this.queuedSaveTimeout = setTimeout(() => {
6✔
76
      this.queuedSaveTimeout = this.queuedSaveTime = undefined;
6✔
77
      this.save();
6✔
78
    }, timeout).unref();
79
    this.queuedSaveTime = Date.now() + timeout;
6✔
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) {
24✔
89
      this.linkedAccessories = [];
18✔
90
    }
91

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

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

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

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

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

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

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

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

126
    if (this.initialized) {
6✔
127
      this.enqueueSaveRequest(100);
6✔
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) {
21!
159
      throw new Error("Illegal state. Controller data wasn't loaded yet!");
×
160
    }
161

162
    const controllerData = this.controllerData[controller.controllerId()];
21✔
163
    if (controllerData) {
21!
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) {
153!
182
      throw new Error(`ControllerStorage for accessory ${this.accessoryUUID} was already initialized!`);
×
183
    }
184
    this.initialized = true;
153✔
185

186
    // storing data into our local controllerData Record
187
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
188
    data && data.forEach(saved => this.controllerData[saved.type] = saved.controllerData);
153!
189

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

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

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

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

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

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

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

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

232
    this.init(ownData);
147✔
233

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

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

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

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

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

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

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

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

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

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

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

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

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