@thecloudseeker/homebridge-homekit-ble-matter 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+ Copyright (c) 2026 thecloudseeker
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
20
+ OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # homebridge-homekit-ble-matter
2
+
3
+ Brings **HomeKit-only Bluetooth sensors** to **Matter** controllers such as IKEA Dirigera, Google Home, Amazon Alexa or SmartThings.
4
+
5
+ Some sensors only speak HomeKit over Bluetooth (for example the Qingping Temp & RH Monitor **H version, CGG1H**), so only Apple Home can use them. This plugin pairs with such a device the way Apple Home would, reads its values, and exposes it through Homebridge's Matter bridge.
6
+
7
+ > **Beta.** Tested with a Qingping CGG1H on a Raspberry Pi. Currently supports temperature and humidity sensors (with battery level).
8
+
9
+ ## Requirements
10
+
11
+ - Homebridge **2.4.0** or later, with Matter
12
+ - A Bluetooth adapter the Homebridge host can use (the Raspberry Pi's built-in one works)
13
+ - Node.js 20, 22 or 24
14
+
15
+ ## Setup
16
+
17
+ 1. **Remove the device from Apple Home** (accessory → Remove Accessory). A HomeKit device can only be paired with one controller this way.
18
+ 2. Install the plugin and run it as a **child bridge**. In the child bridge settings, turn **Enable Matter** on (and HomeKit off: this plugin exposes nothing over HomeKit).
19
+ 3. Restart. The log lists nearby HomeKit Bluetooth devices:
20
+ ```
21
+ Found HomeKit Bluetooth sensor 'Qingping Temp RH H' - DeviceID 41:21:14:E5:C2:25, available to pair. Add it under 'devices' to use it.
22
+ ```
23
+ 4. Add it with that **DeviceID** (not the Bluetooth address) and its **HomeKit setup code**:
24
+ ```json
25
+ {
26
+ "platform": "HomeKitBleMatter",
27
+ "devices": [
28
+ { "deviceId": "41:21:14:E5:C2:25", "name": "Bedroom", "setupCode": "128-84-842" }
29
+ ]
30
+ }
31
+ ```
32
+ 5. Restart. The plugin pairs, reads the device's structure and registers one Matter device. Add the bridge to your Matter controller with the code from the child bridge's Matter settings.
33
+
34
+ The pairing keys are stored in `<Homebridge storage>/homekit-ble-matter/`. Keep that folder: without it the device has to be factory-reset before it can be paired again.
35
+
36
+ ## Configuration
37
+
38
+ | Key | Default | Description |
39
+ |---|---|---|
40
+ | `devices[].deviceId` | | HomeKit DeviceID from the log. Required. |
41
+ | `devices[].name` | DeviceID | Name of the Matter device. |
42
+ | `devices[].setupCode` | | 8-digit HomeKit code (`123-45-678`, `12345678` or `1234 5678`). Only needed for the first pairing. |
43
+ | `pollInterval` | `10` | Minutes between reads (also per device). |
44
+ | `timeout` | `60` | Minutes without a successful read before values are reported as unavailable (also per device). |
45
+
46
+ ## How it works
47
+
48
+ - One Bluetooth scan listens to all HomeKit advertisements. When a device's advertised state number changes (HomeKit devices bump it when a value changes), it's read, at most once every 5 minutes. On top of that it's polled every `pollInterval` minutes.
49
+ - Each read is a short encrypted Bluetooth connection. Connections run one at a time.
50
+ - A device measuring both temperature and humidity becomes **one** Matter endpoint, so controllers that list every endpoint separately (IKEA Dirigera) show one device.
51
+ - If a device is factory-reset, the plugin notices (it advertises as unpaired again) and pairs again with the configured setup code.
52
+
53
+ ## Known limitations
54
+
55
+ - **New Matter `uniqueId` after each restart** for temperature+humidity devices, due to a Homebridge limitation ([homebridge/homebridge#4018](https://github.com/homebridge/homebridge/issues/4018)). IKEA Dirigera keeps the device, name and room across restarts; other controllers are untested.
56
+ - **Bluetooth is shared** with any other Bluetooth plugin on the same adapter. It worked alongside a scanning plugin in testing, but busy adapters can make connections fail; failed reads are retried after a minute.
57
+ - HomeKit's encrypted "disconnected events" aren't supported by the underlying library ([hap-controller#43](https://github.com/Apollon77/hap-controller-node/issues/43)), so change detection relies on the advertised state number plus polling.
58
+
59
+ Built on [hap-controller](https://github.com/Apollon77/hap-controller-node).
@@ -0,0 +1,83 @@
1
+ {
2
+ "pluginAlias": "HomeKitBleMatter",
3
+ "pluginType": "platform",
4
+ "singular": true,
5
+ "headerDisplay": "Pairs with HomeKit-over-Bluetooth sensors and exposes them over **Matter only**. Run this plugin as a child bridge with **Enable Matter** turned on (and HomeKit off). Nearby devices are listed in the log with their DeviceID; a device must be removed from Apple Home before this plugin can pair with it.",
6
+ "schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "devices": {
10
+ "title": "Devices",
11
+ "type": "array",
12
+ "items": {
13
+ "type": "object",
14
+ "properties": {
15
+ "deviceId": {
16
+ "title": "DeviceID",
17
+ "type": "string",
18
+ "placeholder": "e.g. 41:21:14:E5:C2:25",
19
+ "description": "The HomeKit DeviceID, as listed in the log ('Found HomeKit Bluetooth sensor … - DeviceID …'). Not the Bluetooth address.",
20
+ "required": true
21
+ },
22
+ "name": {
23
+ "title": "Name",
24
+ "type": "string",
25
+ "placeholder": "e.g. Bedroom"
26
+ },
27
+ "setupCode": {
28
+ "title": "HomeKit Setup Code",
29
+ "type": "string",
30
+ "placeholder": "e.g. 123-45-678",
31
+ "description": "The 8-digit HomeKit code on the device or its manual. Only needed for the first pairing."
32
+ },
33
+ "pollInterval": {
34
+ "title": "Poll Interval (minutes)",
35
+ "type": "integer",
36
+ "minimum": 1,
37
+ "description": "Overrides the default below for this device."
38
+ },
39
+ "timeout": {
40
+ "title": "Timeout (minutes)",
41
+ "type": "integer",
42
+ "minimum": 5,
43
+ "description": "Overrides the default below for this device."
44
+ }
45
+ }
46
+ }
47
+ },
48
+ "pollInterval": {
49
+ "title": "Poll Interval (minutes)",
50
+ "type": "integer",
51
+ "default": 10,
52
+ "minimum": 1,
53
+ "description": "How often values are read. Devices that signal changes themselves are also read when they change (at most once every 5 minutes). Each read is a Bluetooth connection, which costs the device battery."
54
+ },
55
+ "timeout": {
56
+ "title": "Timeout (minutes)",
57
+ "type": "integer",
58
+ "default": 60,
59
+ "minimum": 5,
60
+ "description": "After this long without a successful read, readings are reported as unavailable."
61
+ }
62
+ }
63
+ },
64
+ "layout": [
65
+ {
66
+ "key": "devices",
67
+ "type": "array",
68
+ "items": [
69
+ "devices[].deviceId",
70
+ "devices[].name",
71
+ "devices[].setupCode",
72
+ "devices[].pollInterval",
73
+ "devices[].timeout"
74
+ ]
75
+ },
76
+ {
77
+ "type": "fieldset",
78
+ "title": "Defaults",
79
+ "expandable": true,
80
+ "items": ["pollInterval", "timeout"]
81
+ }
82
+ ]
83
+ }
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ module.exports = (homebridge) => {
2
+ const { HomeKitBleMatterPlatform, PLUGIN_IDENTIFIER, PLATFORM_NAME } =
3
+ require("./lib/platform")(homebridge);
4
+ homebridge.registerPlatform(
5
+ PLUGIN_IDENTIFIER,
6
+ PLATFORM_NAME,
7
+ HomeKitBleMatterPlatform,
8
+ );
9
+ };
@@ -0,0 +1,365 @@
1
+ const { withTimeout } = require("./connectionQueue");
2
+ const { parseAccessoryDatabase, normalizeSetupCode } = require("./hap");
3
+
4
+ const PAIR_TIMEOUT = 90 * 1000;
5
+ const DATABASE_TIMEOUT = 90 * 1000;
6
+ const READ_TIMEOUT = 45 * 1000;
7
+ // Reads triggered by the sensor's own "something changed" signal are capped
8
+ // at one per five minutes: each read is a Bluetooth connection, which costs
9
+ // the sensor battery, and a sensor signalling every 0.1° change would
10
+ // otherwise be connected to every minute.
11
+ const MIN_READ_GAP = 5 * 60 * 1000;
12
+ const RETRY_DELAY = 60 * 1000;
13
+
14
+ // One HomeKit-over-Bluetooth sensor: pairs with it (once), learns where its
15
+ // readings live (once per firmware configuration), then reads them - on a
16
+ // poll interval, and whenever its advertisement's Global State Number (GSN)
17
+ // changes, which HomeKit accessories bump when a value changes.
18
+ //
19
+ // Advertisements are fed in by the platform (one shared BLEDiscovery for all
20
+ // sensors) via handleAdvertisement(). Connections go through the shared
21
+ // ConnectionQueue.
22
+ class BleSensor {
23
+ constructor({
24
+ config,
25
+ log,
26
+ hap,
27
+ discovery,
28
+ store,
29
+ queue,
30
+ onReady,
31
+ onReadings,
32
+ }) {
33
+ this.config = config;
34
+ this.deviceId = config.deviceId;
35
+ this.log = log;
36
+ this.hap = hap;
37
+ this.discovery = discovery;
38
+ this.store = store;
39
+ this.queue = queue;
40
+ this.onReady = onReady;
41
+ this.onReadings = onReadings;
42
+
43
+ this.state = store.load(this.deviceId) ?? {};
44
+ this.advertisement = null;
45
+ this.ready = false;
46
+ this.preparing = null;
47
+ this.reading = null;
48
+ this.lastReadAt = 0;
49
+ this.lastSuccessAt = null;
50
+ this.timedOut = false;
51
+ this.warned = new Set();
52
+ this.stopped = false;
53
+ }
54
+
55
+ get prefix() {
56
+ return `[${this.config.name}]`;
57
+ }
58
+
59
+ get pollInterval() {
60
+ return (this.config.pollInterval ?? 10) * 60 * 1000;
61
+ }
62
+
63
+ // Minutes without a successful read after which readings are reported as
64
+ // unavailable (null), so controllers don't show a frozen value forever.
65
+ get timeout() {
66
+ return (this.config.timeout ?? 60) * 60 * 1000;
67
+ }
68
+
69
+ // What's already known from a previous run, so the platform can register
70
+ // the Matter device before the sensor is even in range.
71
+ get cachedDatabase() {
72
+ return this.state.database ?? null;
73
+ }
74
+
75
+ get cachedInfo() {
76
+ return this.state.info ?? {};
77
+ }
78
+
79
+ warnOnce(key, message) {
80
+ if (this.warned.has(key)) {
81
+ return;
82
+ }
83
+ this.warned.add(key);
84
+ this.log.warn(`${this.prefix} ${message}`);
85
+ }
86
+
87
+ handleAdvertisement(service) {
88
+ if (this.stopped) {
89
+ return;
90
+ }
91
+ const previous = this.advertisement;
92
+ this.advertisement = service;
93
+ if (service.availableToPair && this.state.pairingData != null) {
94
+ // Advertising "not paired" although we hold keys: the sensor was
95
+ // factory-reset or our pairing was removed. The keys are useless now.
96
+ this.log.warn(
97
+ `${this.prefix} Sensor reports it is no longer paired (reset, or pairing removed); pairing again.`,
98
+ );
99
+ this.state.pairingData = null;
100
+ this.store.save(this.deviceId, this.state);
101
+ this.ready = false;
102
+ }
103
+ if (!this.ready) {
104
+ this.prepare();
105
+ return;
106
+ }
107
+ if (previous != null && service.CN !== previous.CN) {
108
+ // The accessory's configuration (e.g. after a firmware update) changed:
109
+ // the characteristic addressing may be stale.
110
+ this.log.info(`${this.prefix} Configuration changed, re-reading it.`);
111
+ this.ready = false;
112
+ this.state.database = null;
113
+ this.prepare();
114
+ return;
115
+ }
116
+ if (previous != null && service.GSN !== previous.GSN) {
117
+ this.requestRead("changed");
118
+ }
119
+ }
120
+
121
+ prepare() {
122
+ if (this.preparing == null) {
123
+ this.preparing = this.doPrepare()
124
+ .catch((error) => {
125
+ this.log.warn(
126
+ `${this.prefix} Setup failed, will retry on its next advertisement: ${error.message}`,
127
+ );
128
+ })
129
+ .finally(() => {
130
+ this.preparing = null;
131
+ });
132
+ }
133
+ return this.preparing;
134
+ }
135
+
136
+ async doPrepare() {
137
+ const service = this.advertisement;
138
+ if (this.state.pairingData == null) {
139
+ if (!service.availableToPair) {
140
+ this.warnOnce(
141
+ "paired-elsewhere",
142
+ "Already paired with another HomeKit controller (e.g. Apple Home). Remove it there (or factory-reset it) to let this plugin pair with it.",
143
+ );
144
+ return;
145
+ }
146
+ const setupCode = normalizeSetupCode(this.config.setupCode);
147
+ if (setupCode == null) {
148
+ this.warnOnce(
149
+ "no-setup-code",
150
+ "Not paired yet and no valid setupCode configured (8 digits, e.g. 123-45-678).",
151
+ );
152
+ return;
153
+ }
154
+ await this.pair(service, setupCode);
155
+ }
156
+ if (this.state.database == null || this.state.configNumber !== service.CN) {
157
+ await this.loadDatabase(service);
158
+ }
159
+ this.ready = true;
160
+ this.onReady(this.state.database, this.state.info ?? {});
161
+ this.requestRead("startup");
162
+ this.schedulePoll();
163
+ }
164
+
165
+ async pair(service, setupCode) {
166
+ this.log.info(`${this.prefix} Pairing...`);
167
+ await this.queue.run(async () => {
168
+ const pairMethod = await withTimeout(
169
+ this.discovery.getPairMethod(service),
170
+ PAIR_TIMEOUT,
171
+ "Reading pair method",
172
+ );
173
+ const client = new this.hap.GattClient(
174
+ service.DeviceID,
175
+ service.peripheral,
176
+ );
177
+ try {
178
+ await withTimeout(
179
+ client.pairSetup(setupCode, pairMethod),
180
+ PAIR_TIMEOUT,
181
+ "Pairing",
182
+ );
183
+ this.state.pairingData = client.getLongTermData();
184
+ } finally {
185
+ await client.close().catch(() => {});
186
+ }
187
+ });
188
+ this.store.save(this.deviceId, this.state);
189
+ this.log.info(`${this.prefix} Paired.`);
190
+ }
191
+
192
+ async loadDatabase(service) {
193
+ this.log.info(`${this.prefix} Reading accessory structure...`);
194
+ await this.queue.run(async () => {
195
+ const client = new this.hap.GattClient(
196
+ service.DeviceID,
197
+ service.peripheral,
198
+ this.state.pairingData,
199
+ );
200
+ try {
201
+ const database = parseAccessoryDatabase(
202
+ await withTimeout(
203
+ client.getAccessories(),
204
+ DATABASE_TIMEOUT,
205
+ "Reading accessory structure",
206
+ ),
207
+ this.hap,
208
+ );
209
+ const infoEntries = Object.entries(database.info);
210
+ const info = {};
211
+ if (infoEntries.length > 0) {
212
+ const { characteristics } = await withTimeout(
213
+ client.getCharacteristics(
214
+ infoEntries.map(([, address]) => address),
215
+ ),
216
+ READ_TIMEOUT,
217
+ "Reading accessory information",
218
+ );
219
+ infoEntries.forEach(([key], index) => {
220
+ const value = characteristics[index]?.value;
221
+ if (value != null && value !== "") {
222
+ info[key] = String(value);
223
+ }
224
+ });
225
+ }
226
+ this.state.database = database.readings;
227
+ this.state.info = info;
228
+ this.state.configNumber = service.CN;
229
+ } finally {
230
+ await client.close().catch(() => {});
231
+ }
232
+ });
233
+ this.store.save(this.deviceId, this.state);
234
+ const found = Object.keys(this.state.database);
235
+ this.log.info(
236
+ `${this.prefix} ${this.state.info.manufacturer ?? ""} ${this.state.info.model ?? ""}: found ${found.length > 0 ? found.join(", ") : "nothing this plugin can read"}.`.replace(
237
+ /\s+/g,
238
+ " ",
239
+ ),
240
+ );
241
+ }
242
+
243
+ requestRead(reason) {
244
+ if (!this.ready || this.stopped || this.reading != null) {
245
+ return;
246
+ }
247
+ const sinceLast = Date.now() - this.lastReadAt;
248
+ if (reason === "changed" && sinceLast < MIN_READ_GAP) {
249
+ this.log.debug(`${this.prefix} Change signalled; read deferred.`);
250
+ clearTimeout(this.deferTimer);
251
+ this.deferTimer = setTimeout(
252
+ () => this.requestRead("deferred"),
253
+ MIN_READ_GAP - sinceLast,
254
+ );
255
+ return;
256
+ }
257
+ this.reading = this.read(reason).finally(() => {
258
+ this.reading = null;
259
+ });
260
+ }
261
+
262
+ async read(reason) {
263
+ const service = this.advertisement;
264
+ const entries = Object.entries(this.state.database);
265
+ if (service == null || entries.length === 0) {
266
+ return;
267
+ }
268
+ this.lastReadAt = Date.now();
269
+ try {
270
+ const values = await this.queue.run(async () => {
271
+ const client = new this.hap.GattClient(
272
+ service.DeviceID,
273
+ service.peripheral,
274
+ this.state.pairingData,
275
+ );
276
+ try {
277
+ const { characteristics } = await withTimeout(
278
+ client.getCharacteristics(entries.map(([, address]) => address)),
279
+ READ_TIMEOUT,
280
+ "Reading values",
281
+ );
282
+ return Object.fromEntries(
283
+ entries.map(([key], index) => [key, characteristics[index]?.value]),
284
+ );
285
+ } finally {
286
+ await client.close().catch(() => {});
287
+ }
288
+ });
289
+ this.lastSuccessAt = Date.now();
290
+ this.timedOut = false;
291
+ this.warned.delete("read-failed");
292
+ this.log.debug(
293
+ `${this.prefix} Read (${reason}): ${JSON.stringify(values)}`,
294
+ );
295
+ this.onReadings(normalizeReadings(values));
296
+ this.scheduleTimeoutCheck();
297
+ } catch (error) {
298
+ this.warnOnce("read-failed", `Reading failed: ${error.message}`);
299
+ this.log.debug(`${this.prefix} Retrying in ${RETRY_DELAY / 1000}s.`);
300
+ clearTimeout(this.retryTimer);
301
+ this.retryTimer = setTimeout(
302
+ () => this.requestRead("retry"),
303
+ RETRY_DELAY,
304
+ );
305
+ this.checkTimeout();
306
+ }
307
+ }
308
+
309
+ schedulePoll() {
310
+ clearInterval(this.pollTimer);
311
+ this.pollTimer = setInterval(
312
+ () => this.requestRead("poll"),
313
+ this.pollInterval,
314
+ );
315
+ }
316
+
317
+ scheduleTimeoutCheck() {
318
+ clearTimeout(this.timeoutTimer);
319
+ this.timeoutTimer = setTimeout(() => this.checkTimeout(), this.timeout);
320
+ }
321
+
322
+ checkTimeout() {
323
+ if (
324
+ this.timedOut ||
325
+ this.lastSuccessAt == null ||
326
+ Date.now() - this.lastSuccessAt < this.timeout
327
+ ) {
328
+ return;
329
+ }
330
+ this.timedOut = true;
331
+ this.log.warn(
332
+ `${this.prefix} No successful read for ${this.timeout / 60000} minutes; reporting it as unavailable.`,
333
+ );
334
+ this.onReadings({
335
+ temperature: null,
336
+ humidity: null,
337
+ batteryLevel: null,
338
+ lowBattery: null,
339
+ });
340
+ }
341
+
342
+ stop() {
343
+ this.stopped = true;
344
+ clearInterval(this.pollTimer);
345
+ clearTimeout(this.retryTimer);
346
+ clearTimeout(this.deferTimer);
347
+ clearTimeout(this.timeoutTimer);
348
+ }
349
+ }
350
+
351
+ // HAP values → plain numbers/booleans. Characteristics the sensor doesn't
352
+ // have stay undefined (MatterSensor.update skips them).
353
+ function normalizeReadings(values) {
354
+ const number = (value) =>
355
+ value == null || Number.isNaN(Number(value)) ? undefined : Number(value);
356
+ return {
357
+ temperature: number(values.temperature),
358
+ humidity: number(values.humidity),
359
+ batteryLevel: number(values.batteryLevel),
360
+ lowBattery:
361
+ values.lowBattery == null ? undefined : Number(values.lowBattery) === 1,
362
+ };
363
+ }
364
+
365
+ module.exports = { BleSensor, normalizeReadings };
@@ -0,0 +1,30 @@
1
+ // Runs Bluetooth connections one at a time. A single adapter handles
2
+ // concurrent GATT connections poorly - and the Homebridge host may share it
3
+ // with other Bluetooth plugins - so sensors take turns.
4
+ class ConnectionQueue {
5
+ constructor() {
6
+ this.tail = Promise.resolve();
7
+ }
8
+
9
+ run(task) {
10
+ const result = this.tail.then(task, task);
11
+ this.tail = result.catch(() => {});
12
+ return result;
13
+ }
14
+ }
15
+
16
+ // Rejects if `promise` hasn't settled within `ms`. hap-controller has no
17
+ // timeouts of its own for BLE operations, and a sensor that walks out of
18
+ // range mid-read would otherwise hold the queue forever.
19
+ function withTimeout(promise, ms, description) {
20
+ let timer;
21
+ const timeout = new Promise((resolve, reject) => {
22
+ timer = setTimeout(
23
+ () => reject(new Error(`${description} timed out after ${ms / 1000}s`)),
24
+ ms,
25
+ );
26
+ });
27
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
28
+ }
29
+
30
+ module.exports = { ConnectionQueue, withTimeout };
@@ -0,0 +1,44 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ // Persists per-device state across restarts: the HomeKit pairing keys (which
5
+ // cannot be recovered - losing them means factory-resetting the sensor), the
6
+ // accessory database addressing, and the device's accessory information.
7
+ // One file per device, readable by the Homebridge user only.
8
+ class DeviceStore {
9
+ constructor(directory) {
10
+ this.directory = directory;
11
+ }
12
+
13
+ fileFor(deviceId) {
14
+ return path.join(
15
+ this.directory,
16
+ `${deviceId.replace(/[^0-9A-Za-z]/g, "")}.json`,
17
+ );
18
+ }
19
+
20
+ load(deviceId) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(this.fileFor(deviceId), "utf8"));
23
+ } catch (error) {
24
+ if (error.code === "ENOENT") {
25
+ return null;
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ save(deviceId, data) {
32
+ fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
33
+ const file = this.fileFor(deviceId);
34
+ // Write-then-rename so a crash mid-write can't leave a truncated file
35
+ // behind and lose the pairing keys.
36
+ const temporary = `${file}.tmp`;
37
+ fs.writeFileSync(temporary, JSON.stringify(data, null, 2), {
38
+ mode: 0o600,
39
+ });
40
+ fs.renameSync(temporary, file);
41
+ }
42
+ }
43
+
44
+ module.exports = { DeviceStore };
package/lib/hap.js ADDED
@@ -0,0 +1,103 @@
1
+ // Everything this plugin needs from hap-controller, loaded lazily: requiring
2
+ // hap-controller initializes noble (Bluetooth) at module load, which must not
3
+ // happen just because Homebridge loaded the plugin - only once discovery
4
+ // actually starts. Tests pass their own implementation of this shape instead.
5
+ function loadHap() {
6
+ const { BLEDiscovery, GattClient } = require("hap-controller");
7
+ const {
8
+ characteristicFromUuid,
9
+ } = require("hap-controller/lib/model/characteristic");
10
+ const { serviceFromUuid } = require("hap-controller/lib/model/service");
11
+ return { BLEDiscovery, GattClient, characteristicFromUuid, serviceFromUuid };
12
+ }
13
+
14
+ // HomeKit Accessory Protocol names (as returned by hap-controller's
15
+ // characteristicFromUuid/serviceFromUuid) for what this plugin reads.
16
+ const SERVICES = {
17
+ accessoryInformation: "public.hap.service.accessory-information",
18
+ };
19
+
20
+ const READINGS = {
21
+ temperature: "public.hap.characteristic.temperature.current",
22
+ humidity: "public.hap.characteristic.relative-humidity.current",
23
+ batteryLevel: "public.hap.characteristic.battery-level",
24
+ lowBattery: "public.hap.characteristic.status-lo-batt",
25
+ };
26
+
27
+ const INFO = {
28
+ manufacturer: "public.hap.characteristic.manufacturer",
29
+ model: "public.hap.characteristic.model",
30
+ serialNumber: "public.hap.characteristic.serial-number",
31
+ firmwareRevision: "public.hap.characteristic.firmware.revision",
32
+ };
33
+
34
+ // HomeKit accessory category advertised by sensors (HAP spec table 12-3).
35
+ const CATEGORY_SENSOR = 10;
36
+
37
+ // Reduces hap-controller's accessory database to the characteristics this
38
+ // plugin reads, keyed by READINGS/INFO name, each with the addressing a BLE
39
+ // read needs ({serviceUuid, characteristicUuid, iid, format}). Info fields
40
+ // are only taken from the accessory-information service: `name` and friends
41
+ // also appear on every other service.
42
+ function parseAccessoryDatabase(
43
+ { accessories },
44
+ { characteristicFromUuid, serviceFromUuid },
45
+ ) {
46
+ const readings = {};
47
+ const info = {};
48
+ for (const accessory of accessories ?? []) {
49
+ for (const service of accessory.services ?? []) {
50
+ const serviceName = serviceFromUuid(service.type);
51
+ for (const characteristic of service.characteristics ?? []) {
52
+ const name = characteristicFromUuid(characteristic.type);
53
+ const address = {
54
+ serviceUuid: service.type,
55
+ characteristicUuid: characteristic.type,
56
+ iid: characteristic.iid,
57
+ format: characteristic.format,
58
+ };
59
+ for (const [key, hapName] of Object.entries(READINGS)) {
60
+ if (name === hapName && readings[key] == null) {
61
+ readings[key] = address;
62
+ }
63
+ }
64
+ if (serviceName === SERVICES.accessoryInformation) {
65
+ for (const [key, hapName] of Object.entries(INFO)) {
66
+ if (name === hapName) {
67
+ info[key] = address;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return { readings, info };
75
+ }
76
+
77
+ // Accepts the setup code however it's printed or typed ("12884842",
78
+ // "1288 4842", "128-84-842") and returns HomeKit's XXX-YY-ZZZ form, or null.
79
+ function normalizeSetupCode(code) {
80
+ const digits = String(code ?? "").replace(/\D/g, "");
81
+ if (digits.length !== 8) {
82
+ return null;
83
+ }
84
+ return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5)}`;
85
+ }
86
+
87
+ // DeviceIDs are compared case-insensitively; hap-controller reports them in
88
+ // whatever case the advertisement produced.
89
+ function normalizeDeviceId(deviceId) {
90
+ return String(deviceId ?? "")
91
+ .trim()
92
+ .toUpperCase();
93
+ }
94
+
95
+ module.exports = {
96
+ loadHap,
97
+ parseAccessoryDatabase,
98
+ normalizeSetupCode,
99
+ normalizeDeviceId,
100
+ READINGS,
101
+ INFO,
102
+ CATEGORY_SENSOR,
103
+ };
@@ -0,0 +1,204 @@
1
+ const { normalizeDeviceId } = require("./hap");
2
+
3
+ // Matches homebridge-plugins/homebridge-matter's TemperatureSensorAccessory
4
+ // reference bounds (-50C to 100C). Matter rejects (rather than clamps) a
5
+ // value outside the declared range, so the range is set wide.
6
+ const MIN_MEASURED_TEMPERATURE = -5000;
7
+ const MAX_MEASURED_TEMPERATURE = 10000;
8
+
9
+ // Matter caps these string fields at 32 characters.
10
+ const MAX_MATTER_LABEL_LENGTH = 32;
11
+
12
+ // PowerSource enum values (Matter Core spec 11.7).
13
+ const POWER_SOURCE_STATUS_ACTIVE = 1;
14
+ const BAT_CHARGE_LEVEL_OK = 0;
15
+ const BAT_CHARGE_LEVEL_WARNING = 1;
16
+ const BAT_REPLACEABILITY_UNSPECIFIED = 0;
17
+
18
+ function label(value) {
19
+ return String(value).slice(0, MAX_MATTER_LABEL_LENGTH);
20
+ }
21
+
22
+ // The Matter counterpart of one HomeKit BLE sensor: ONE endpoint carrying
23
+ // whatever it measures (temperature and/or humidity) plus its battery.
24
+ //
25
+ // A single endpoint, rather than a BridgedNode with a child endpoint per
26
+ // reading, because some controllers (IKEA Dirigera) list every sensor
27
+ // endpoint as its own product. For a sensor measuring both, that needs a
28
+ // composed device type (TemperatureSensor plus the HumiditySensor's
29
+ // relativeHumidityMeasurement behavior) and a descriptor naming both device
30
+ // types. Known Homebridge limitation: a composed type is re-created on every
31
+ // restart with a new uniqueId (homebridge/homebridge#4018).
32
+ class MatterSensor {
33
+ static uuidFor(matter, deviceId) {
34
+ return matter.uuid.generate(
35
+ `homebridge-homekit-ble-matter:${normalizeDeviceId(deviceId)}`,
36
+ );
37
+ }
38
+
39
+ // `capabilities`: which of temperature / humidity / battery the sensor has.
40
+ // `info`: manufacturer/model/serialNumber/firmwareRevision read from it.
41
+ constructor(matter, log, { deviceId, name, capabilities, info }) {
42
+ this.matter = matter;
43
+ this.log = log;
44
+ this.deviceId = deviceId;
45
+ this.capabilities = capabilities;
46
+
47
+ // See the CGDK2 plugin: registerPlatformAccessories() is fire-and-forget,
48
+ // and pushes are dropped until it resolves, or for good once it rejected.
49
+ this.registered = false;
50
+ this.registrationFailed = false;
51
+
52
+ const { TemperatureSensor, HumiditySensor } = matter.deviceTypes;
53
+ const clusters = {};
54
+ let deviceType;
55
+ if (capabilities.temperature && capabilities.humidity) {
56
+ deviceType = TemperatureSensor.with(
57
+ HumiditySensor.behaviors.relativeHumidityMeasurement,
58
+ );
59
+ clusters.descriptor = {
60
+ deviceTypeList: [
61
+ {
62
+ deviceType: TemperatureSensor.deviceType,
63
+ revision: TemperatureSensor.deviceRevision,
64
+ },
65
+ {
66
+ deviceType: HumiditySensor.deviceType,
67
+ revision: HumiditySensor.deviceRevision,
68
+ },
69
+ ],
70
+ };
71
+ } else if (capabilities.humidity) {
72
+ deviceType = HumiditySensor;
73
+ } else {
74
+ deviceType = TemperatureSensor;
75
+ }
76
+ if (capabilities.temperature) {
77
+ clusters.temperatureMeasurement = {
78
+ measuredValue: null,
79
+ minMeasuredValue: MIN_MEASURED_TEMPERATURE,
80
+ maxMeasuredValue: MAX_MEASURED_TEMPERATURE,
81
+ };
82
+ }
83
+ if (capabilities.humidity) {
84
+ clusters.relativeHumidityMeasurement = {
85
+ measuredValue: null,
86
+ minMeasuredValue: 0,
87
+ maxMeasuredValue: 10000,
88
+ };
89
+ }
90
+ if (capabilities.battery) {
91
+ clusters.powerSource = {
92
+ status: POWER_SOURCE_STATUS_ACTIVE,
93
+ order: 0,
94
+ description: "Battery",
95
+ endpointList: [],
96
+ batPercentRemaining: null,
97
+ batChargeLevel: BAT_CHARGE_LEVEL_OK,
98
+ batReplacementNeeded: false,
99
+ batReplaceability: BAT_REPLACEABILITY_UNSPECIFIED,
100
+ };
101
+ }
102
+
103
+ this.accessory = {
104
+ UUID: MatterSensor.uuidFor(matter, deviceId),
105
+ displayName: label(name),
106
+ deviceType,
107
+ serialNumber: label(info.serialNumber || normalizeDeviceId(deviceId)),
108
+ manufacturer: label(info.manufacturer || "Unknown"),
109
+ model: label(info.model || "HomeKit BLE sensor"),
110
+ firmwareRevision: info.firmwareRevision || undefined,
111
+ context: { deviceId: normalizeDeviceId(deviceId) },
112
+ clusters,
113
+ };
114
+ }
115
+
116
+ toAccessories() {
117
+ return [this.accessory];
118
+ }
119
+
120
+ markRegistered() {
121
+ this.registered = true;
122
+ }
123
+
124
+ markRegistrationFailed() {
125
+ this.registrationFailed = true;
126
+ }
127
+
128
+ // Homebridge restores a cached accessory onto its existing endpoint and
129
+ // only swaps in new metadata, so a renamed device would never reach
130
+ // controllers. NodeLabel may change at runtime, so push it explicitly.
131
+ async syncNodeLabel() {
132
+ await this.pushState("bridgedDeviceBasicInformation", {
133
+ nodeLabel: this.accessory.displayName,
134
+ });
135
+ }
136
+
137
+ // `readings`: {temperature, humidity, batteryLevel, lowBattery}, any of
138
+ // them undefined when not read; null for all when the sensor timed out.
139
+ async update(readings) {
140
+ const { temperature, humidity, batteryLevel, lowBattery } = readings ?? {};
141
+ if (this.capabilities.temperature && temperature !== undefined) {
142
+ await this.pushState("temperatureMeasurement", {
143
+ measuredValue:
144
+ temperature == null ? null : Math.round(temperature * 100),
145
+ });
146
+ }
147
+ if (this.capabilities.humidity && humidity !== undefined) {
148
+ await this.pushState("relativeHumidityMeasurement", {
149
+ measuredValue:
150
+ humidity == null
151
+ ? null
152
+ : Math.round(Math.min(100, Math.max(0, humidity)) * 100),
153
+ });
154
+ }
155
+ if (
156
+ this.capabilities.battery &&
157
+ (batteryLevel !== undefined || lowBattery !== undefined)
158
+ ) {
159
+ const attributes = {};
160
+ if (batteryLevel !== undefined) {
161
+ // Half-percent units (0-200).
162
+ attributes.batPercentRemaining =
163
+ batteryLevel == null
164
+ ? null
165
+ : Math.round(Math.min(100, Math.max(0, batteryLevel)) * 2);
166
+ }
167
+ if (lowBattery !== undefined) {
168
+ attributes.batChargeLevel = lowBattery
169
+ ? BAT_CHARGE_LEVEL_WARNING
170
+ : BAT_CHARGE_LEVEL_OK;
171
+ }
172
+ await this.pushState("powerSource", attributes);
173
+ }
174
+ }
175
+
176
+ async pushState(cluster, attributes) {
177
+ if (!this.registered || this.registrationFailed) {
178
+ return;
179
+ }
180
+ try {
181
+ await this.matter.updateAccessoryState(
182
+ this.accessory.UUID,
183
+ cluster,
184
+ attributes,
185
+ );
186
+ } catch (error) {
187
+ this.log.error(
188
+ `[${this.deviceId}] Failed to update Matter ${cluster} state:`,
189
+ error,
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ // What a parsed accessory database (see parseAccessoryDatabase) can report.
196
+ function capabilitiesOf(readings) {
197
+ return {
198
+ temperature: readings.temperature != null,
199
+ humidity: readings.humidity != null,
200
+ battery: readings.batteryLevel != null || readings.lowBattery != null,
201
+ };
202
+ }
203
+
204
+ module.exports = { MatterSensor, capabilitiesOf };
@@ -0,0 +1,234 @@
1
+ const path = require("path");
2
+ const {
3
+ loadHap: defaultLoadHap,
4
+ normalizeDeviceId,
5
+ CATEGORY_SENSOR,
6
+ } = require("./hap");
7
+ const { BleSensor } = require("./bleSensor");
8
+ const { ConnectionQueue } = require("./connectionQueue");
9
+ const { DeviceStore } = require("./deviceStore");
10
+ const { MatterSensor, capabilitiesOf } = require("./matterSensor");
11
+
12
+ const PLUGIN_IDENTIFIER = "@thecloudseeker/homebridge-homekit-ble-matter";
13
+ const PLATFORM_NAME = "HomeKitBleMatter";
14
+
15
+ function asArray(value) {
16
+ return Array.isArray(value) ? value : [];
17
+ }
18
+
19
+ class HomeKitBleMatterPlatform {
20
+ constructor(log, config, api, { loadHap = defaultLoadHap } = {}) {
21
+ this.log = log;
22
+ this.config = config || {};
23
+ this.api = api;
24
+ this.loadHap = loadHap;
25
+ this.sensors = new Map();
26
+ this.matterSensors = new Map();
27
+ this.announced = new Set();
28
+
29
+ this.devices = new Map();
30
+ for (const device of asArray(this.config.devices)) {
31
+ const deviceId = normalizeDeviceId(device?.deviceId);
32
+ if (deviceId === "") {
33
+ this.log.warn("Ignoring a device entry without deviceId.");
34
+ continue;
35
+ }
36
+ this.devices.set(deviceId, {
37
+ ...device,
38
+ deviceId,
39
+ name: device.name || deviceId,
40
+ pollInterval: device.pollInterval ?? this.config.pollInterval,
41
+ timeout: device.timeout ?? this.config.timeout,
42
+ });
43
+ }
44
+
45
+ this.api.on("didFinishLaunching", () => {
46
+ try {
47
+ this.start();
48
+ } catch (error) {
49
+ this.log.error("Failed to start:", error);
50
+ }
51
+ });
52
+ this.api.on("shutdown", () => this.shutdown());
53
+ }
54
+
55
+ // Matter only: there is nothing to restore on the HomeKit side.
56
+ configureAccessory() {}
57
+
58
+ // Drops cached Matter devices whose sensor is no longer configured -
59
+ // Homebridge publishes every cached Matter accessory whether or not the
60
+ // plugin registers it again.
61
+ configureMatterAccessory(accessory) {
62
+ const deviceId = normalizeDeviceId(accessory.context?.deviceId);
63
+ if (this.devices.has(deviceId)) {
64
+ return;
65
+ }
66
+ this.log.info(
67
+ `Removing Matter device of a no longer configured sensor: ${accessory.displayName}`,
68
+ );
69
+ this.api.matter
70
+ .unregisterPlatformAccessories(PLUGIN_IDENTIFIER, PLATFORM_NAME, [
71
+ accessory,
72
+ ])
73
+ .catch((error) =>
74
+ this.log.error("Failed to remove a Matter device:", error),
75
+ );
76
+ }
77
+
78
+ // Same conditions as the CGDK2 plugin: Matter available, enabled for this
79
+ // bridge, and Homebridge 2.4.0+ (composed device types re-register
80
+ // correctly over their cached copy only from there).
81
+ get matterEnabled() {
82
+ return Boolean(
83
+ this.api.isMatterAvailable?.() &&
84
+ this.api.isMatterEnabled?.() &&
85
+ this.api.versionGreaterOrEqual?.("2.4.0"),
86
+ );
87
+ }
88
+
89
+ start() {
90
+ if (!this.matterEnabled) {
91
+ this.log.error(
92
+ "Matter is not enabled for this bridge (or Homebridge is older than 2.4.0). This plugin only exposes devices over Matter: run it as a child bridge and turn on 'Enable Matter' in its bridge settings.",
93
+ );
94
+ return;
95
+ }
96
+ if (this.devices.size === 0) {
97
+ this.log.info(
98
+ "No devices configured yet. HomeKit Bluetooth devices found nearby are listed below with their DeviceID.",
99
+ );
100
+ }
101
+
102
+ this.hap = this.loadHap();
103
+ this.discovery = new this.hap.BLEDiscovery();
104
+ const store = new DeviceStore(
105
+ path.join(this.api.user.storagePath(), "homekit-ble-matter"),
106
+ );
107
+ const queue = new ConnectionQueue();
108
+
109
+ for (const config of this.devices.values()) {
110
+ const sensor = new BleSensor({
111
+ config,
112
+ log: this.log,
113
+ hap: this.hap,
114
+ discovery: this.discovery,
115
+ store,
116
+ queue,
117
+ onReady: (database, info) =>
118
+ this.ensureMatterSensor(config, database, info),
119
+ onReadings: (readings) =>
120
+ this.matterSensors.get(config.deviceId)?.update(readings),
121
+ });
122
+ this.sensors.set(config.deviceId, sensor);
123
+ // Known from a previous run: register right away, so the Matter device
124
+ // stays published (as unavailable) even if the sensor is out of range.
125
+ if (sensor.cachedDatabase != null) {
126
+ this.ensureMatterSensor(
127
+ config,
128
+ sensor.cachedDatabase,
129
+ sensor.cachedInfo,
130
+ );
131
+ }
132
+ }
133
+
134
+ const route = (service) => {
135
+ const deviceId = normalizeDeviceId(service.DeviceID);
136
+ const sensor = this.sensors.get(deviceId);
137
+ if (sensor != null) {
138
+ sensor.handleAdvertisement(service);
139
+ } else {
140
+ this.announce(service, deviceId);
141
+ }
142
+ };
143
+ this.discovery.on("serviceUp", route);
144
+ this.discovery.on("serviceChanged", route);
145
+ this.discovery.start();
146
+ this.log.info(
147
+ `Scanning for HomeKit Bluetooth devices (${this.devices.size} configured).`,
148
+ );
149
+ }
150
+
151
+ // Lists each unconfigured HomeKit BLE device once, so users can find the
152
+ // DeviceID to put in the config.
153
+ announce(service, deviceId) {
154
+ if (this.announced.has(deviceId)) {
155
+ return;
156
+ }
157
+ this.announced.add(deviceId);
158
+ const kind =
159
+ service.ACID === CATEGORY_SENSOR ? "sensor" : `category ${service.ACID}`;
160
+ this.log.info(
161
+ `Found HomeKit Bluetooth ${kind} '${service.name || "unnamed"}' - DeviceID ${deviceId}, ${service.availableToPair ? "available to pair" : "paired with another controller"}. Add it under 'devices' to use it.`,
162
+ );
163
+ }
164
+
165
+ ensureMatterSensor(config, database, info) {
166
+ if (this.matterSensors.has(config.deviceId)) {
167
+ return;
168
+ }
169
+ const capabilities = capabilitiesOf(database);
170
+ if (!capabilities.temperature && !capabilities.humidity) {
171
+ this.log.warn(
172
+ `[${config.name}] Has no temperature or humidity reading; this plugin currently only supports temperature/humidity sensors.`,
173
+ );
174
+ return;
175
+ }
176
+ const matterSensor = new MatterSensor(this.api.matter, this.log, {
177
+ deviceId: config.deviceId,
178
+ name: config.name,
179
+ capabilities,
180
+ info,
181
+ });
182
+ this.matterSensors.set(config.deviceId, matterSensor);
183
+ // Fired off: registration is fire-and-forget on Homebridge's side anyway.
184
+ this.api.matter
185
+ .registerPlatformAccessories(
186
+ PLUGIN_IDENTIFIER,
187
+ PLATFORM_NAME,
188
+ matterSensor.toAccessories(),
189
+ )
190
+ .then(() => {
191
+ matterSensor.markRegistered();
192
+ matterSensor.syncNodeLabel();
193
+ const what = Object.entries(capabilities)
194
+ .filter(([, has]) => has)
195
+ .map(([key]) => key)
196
+ .join(", ");
197
+ this.log.info(`[${config.name}] Registered Matter device (${what}).`);
198
+ })
199
+ .catch((error) => {
200
+ matterSensor.markRegistrationFailed();
201
+ this.log.error(
202
+ `[${config.name}] Failed to register Matter device:`,
203
+ error,
204
+ );
205
+ });
206
+ }
207
+
208
+ shutdown() {
209
+ for (const sensor of this.sensors.values()) {
210
+ sensor.stop();
211
+ }
212
+ try {
213
+ this.discovery?.stop();
214
+ } catch (error) {
215
+ this.log.debug("Stopping Bluetooth discovery failed:", error);
216
+ }
217
+ }
218
+ }
219
+
220
+ module.exports = (homebridge, deps) => {
221
+ if (deps != null) {
222
+ // Tests: bind injected dependencies.
223
+ return {
224
+ HomeKitBleMatterPlatform: class extends HomeKitBleMatterPlatform {
225
+ constructor(log, config, api) {
226
+ super(log, config, api, deps);
227
+ }
228
+ },
229
+ PLUGIN_IDENTIFIER,
230
+ PLATFORM_NAME,
231
+ };
232
+ }
233
+ return { HomeKitBleMatterPlatform, PLUGIN_IDENTIFIER, PLATFORM_NAME };
234
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@thecloudseeker/homebridge-homekit-ble-matter",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Homebridge plugin that pairs with HomeKit-over-Bluetooth sensors and exposes them over Matter (IKEA Dirigera, Google Home, Alexa, SmartThings…)",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "lint": "eslint .",
8
+ "test": "node --test \"test/**/*.test.js\""
9
+ },
10
+ "author": {
11
+ "name": "thecloudseeker",
12
+ "url": "https://github.com/thecloudseeker"
13
+ },
14
+ "license": "MIT",
15
+ "keywords": [
16
+ "homebridge-plugin",
17
+ "homekit",
18
+ "bluetooth",
19
+ "ble",
20
+ "matter",
21
+ "temperature",
22
+ "humidity",
23
+ "qingping",
24
+ "supports-matter"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/thecloudseeker/homebridge-homekit-ble-matter.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/thecloudseeker/homebridge-homekit-ble-matter/issues"
32
+ },
33
+ "homepage": "https://github.com/thecloudseeker/homebridge-homekit-ble-matter#readme",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "files": [
38
+ "index.js",
39
+ "config.schema.json",
40
+ "lib/"
41
+ ],
42
+ "engines": {
43
+ "homebridge": "^2.4.0",
44
+ "node": "^20.18.0 || ^22.10.0 || ^24.0.0"
45
+ },
46
+ "dependencies": {
47
+ "hap-controller": "0.10.2"
48
+ },
49
+ "devDependencies": {
50
+ "eslint": "^10.9.1",
51
+ "eslint-config-prettier": "^10.1.8",
52
+ "eslint-plugin-prettier": "^5.5.6",
53
+ "globals": "^17.11.0",
54
+ "prettier": "^3.9.6"
55
+ }
56
+ }