iobroker.renault 0.0.23 → 0.0.24
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/README.md +11 -6
- package/admin/i18n/de/translations.json +2 -0
- package/admin/i18n/en/translations.json +2 -0
- package/admin/jsonConfig.json +31 -0
- package/io-package.json +30 -2
- package/main.js +80 -10
- package/package.json +16 -15
package/README.md
CHANGED
|
@@ -9,17 +9,18 @@
|
|
|
9
9
|
|
|
10
10
|
**Tests:** 
|
|
11
11
|
|
|
12
|
-
## Renault / Dacia adapter for ioBroker
|
|
12
|
+
## Renault / Dacia / Alpine adapter for ioBroker
|
|
13
13
|
|
|
14
|
-
This adapter connects ioBroker to the My Renault / My Dacia cloud and exposes vehicle status data (battery, charging, HVAC, mileage, ...) as well as remote commands (HVAC start, charging start/stop, force refresh) for compatible Renault and
|
|
14
|
+
This adapter connects ioBroker to the My Renault / My Dacia / My Alpine cloud and exposes vehicle status data (battery, charging, HVAC, mileage, ...) as well as remote commands (HVAC start, charging start/stop, force refresh) for compatible Renault, Dacia and Alpine models such as the Renault Zoe, Megane E-Tech, Kangoo E-Tech, the Dacia Spring and the Alpine A290.
|
|
15
15
|
|
|
16
16
|
## Installation / Login
|
|
17
17
|
|
|
18
18
|
1. Install the adapter via the ioBroker admin UI.
|
|
19
|
-
2. Open the adapter configuration and enter the credentials of your **My Renault** (or **My Dacia**) account: app email and app password.
|
|
20
|
-
3.
|
|
21
|
-
4.
|
|
22
|
-
5.
|
|
19
|
+
2. Open the adapter configuration and enter the credentials of your **My Renault** (or **My Dacia** / **My Alpine**) account: app email and app password.
|
|
20
|
+
3. Select the **brand** matching your app: `Renault / Dacia` or `Alpine` (they use separate accounts and API keys).
|
|
21
|
+
4. Set the **country** to the two-letter country code matching your account (e.g. `de`, `fr`, `it`, `es`).
|
|
22
|
+
5. Optionally set the polling **interval** in minutes and the **API key** (leave empty for auto-detect).
|
|
23
|
+
6. Save and the instance will start polling.
|
|
23
24
|
|
|
24
25
|
## Remote control
|
|
25
26
|
|
|
@@ -47,6 +48,10 @@ ioBroker forum: <https://forum.iobroker.net/topic/48074/test-adapter-renault-v0-
|
|
|
47
48
|
### **WORK IN PROGRESS**
|
|
48
49
|
-->
|
|
49
50
|
|
|
51
|
+
### 0.0.24
|
|
52
|
+
|
|
53
|
+
- (TA2k) add Alpine support: brand selection (Alpine accounts use the same Renault Gigya/Kamereon tenant, only product/account type MYALPINE differs)
|
|
54
|
+
|
|
50
55
|
### 0.0.23
|
|
51
56
|
|
|
52
57
|
- (TA2k) align API headers with My Renault Android app, drop EOL Node 18/20, migrate admin UI to jsonConfig
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
"App Email": "App E-Mail",
|
|
4
4
|
"App Password": "App Passwort",
|
|
5
5
|
"Country": "Land",
|
|
6
|
+
"Brand": "Marke",
|
|
7
|
+
"Renault/Dacia and Alpine use different accounts and API keys": "Renault/Dacia und Alpine nutzen unterschiedliche Konten und API-Schlüssel",
|
|
6
8
|
"Two-letter country code, e.g. de, fr, it, es": "Zweistelliger Ländercode, z.B. de, fr, it, es",
|
|
7
9
|
"Polling": "Abfrage",
|
|
8
10
|
"Update interval in minutes": "Aktualisierungsintervall in Minuten",
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
"App Email": "App Email",
|
|
4
4
|
"App Password": "App Password",
|
|
5
5
|
"Country": "Country",
|
|
6
|
+
"Brand": "Brand",
|
|
7
|
+
"Renault/Dacia and Alpine use different accounts and API keys": "Renault/Dacia and Alpine use different accounts and API keys",
|
|
6
8
|
"Two-letter country code, e.g. de, fr, it, es": "Two-letter country code, e.g. de, fr, it, es",
|
|
7
9
|
"Polling": "Polling",
|
|
8
10
|
"Update interval in minutes": "Update interval in minutes",
|
package/admin/jsonConfig.json
CHANGED
|
@@ -28,6 +28,22 @@
|
|
|
28
28
|
"lg": 4,
|
|
29
29
|
"xl": 4
|
|
30
30
|
},
|
|
31
|
+
"brand": {
|
|
32
|
+
"type": "select",
|
|
33
|
+
"label": "Brand",
|
|
34
|
+
"help": "Renault/Dacia and Alpine use different accounts and API keys",
|
|
35
|
+
"default": "renault",
|
|
36
|
+
"options": [
|
|
37
|
+
{ "label": "Renault / Dacia", "value": "renault" },
|
|
38
|
+
{ "label": "Alpine", "value": "alpine" }
|
|
39
|
+
],
|
|
40
|
+
"newLine": true,
|
|
41
|
+
"xs": 12,
|
|
42
|
+
"sm": 12,
|
|
43
|
+
"md": 4,
|
|
44
|
+
"lg": 2,
|
|
45
|
+
"xl": 2
|
|
46
|
+
},
|
|
31
47
|
"country": {
|
|
32
48
|
"type": "text",
|
|
33
49
|
"label": "Country",
|
|
@@ -67,6 +83,21 @@
|
|
|
67
83
|
"lg": 4,
|
|
68
84
|
"xl": 4
|
|
69
85
|
},
|
|
86
|
+
"chargeHistoryLimit": {
|
|
87
|
+
"type": "number",
|
|
88
|
+
"label": "Charge history entries to keep",
|
|
89
|
+
"help": "Number of most recent entries kept per charge-history endpoint (0 = unlimited)",
|
|
90
|
+
"min": 0,
|
|
91
|
+
"max": 1000,
|
|
92
|
+
"default": 100,
|
|
93
|
+
"hidden": "data.disableChargeFetching === true",
|
|
94
|
+
"newLine": true,
|
|
95
|
+
"xs": 12,
|
|
96
|
+
"sm": 12,
|
|
97
|
+
"md": 4,
|
|
98
|
+
"lg": 2,
|
|
99
|
+
"xl": 2
|
|
100
|
+
},
|
|
70
101
|
"_apiHeader": {
|
|
71
102
|
"type": "header",
|
|
72
103
|
"text": "API Key",
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "renault",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.24",
|
|
5
5
|
"news": {
|
|
6
|
+
"0.0.24": {
|
|
7
|
+
"en": "add Alpine support (select brand Alpine in the settings)",
|
|
8
|
+
"de": "Alpine-Unterstützung hinzugefügt (Marke Alpine in den Einstellungen wählen)",
|
|
9
|
+
"ru": "Добавлена поддержка Alpine (выберите марку Alpine в настройках)",
|
|
10
|
+
"pt": "Suporte para Alpine adicionado (selecione a marca Alpine nas definições)",
|
|
11
|
+
"nl": "Alpine-ondersteuning toegevoegd (selecteer merk Alpine in de instellingen)",
|
|
12
|
+
"fr": "Prise en charge d'Alpine ajoutée (sélectionnez la marque Alpine dans les paramètres)",
|
|
13
|
+
"it": "Aggiunto supporto Alpine (seleziona il marchio Alpine nelle impostazioni)",
|
|
14
|
+
"es": "Añadido soporte para Alpine (seleccione la marca Alpine en los ajustes)",
|
|
15
|
+
"pl": "Dodano obsługę Alpine (wybierz markę Alpine w ustawieniach)",
|
|
16
|
+
"uk": "Додано підтримку Alpine (виберіть марку Alpine в налаштуваннях)",
|
|
17
|
+
"zh-cn": "新增 Alpine 支持(在设置中选择品牌 Alpine)"
|
|
18
|
+
},
|
|
6
19
|
"0.0.23": {
|
|
7
20
|
"en": "align API headers with My Renault Android app (User-Agent okhttp/5.3.0, X-Amzn-Trace-Id)",
|
|
8
21
|
"de": "API-Header an My-Renault-Android-App angeglichen (User-Agent okhttp/5.3.0, X-Amzn-Trace-Id)",
|
|
@@ -163,10 +176,12 @@
|
|
|
163
176
|
"native": {
|
|
164
177
|
"username": "",
|
|
165
178
|
"country": "de",
|
|
179
|
+
"brand": "renault",
|
|
166
180
|
"password": "",
|
|
167
181
|
"apiKeyUpdate": "",
|
|
168
182
|
"interval": 10,
|
|
169
|
-
"disableChargeFetching": false
|
|
183
|
+
"disableChargeFetching": false,
|
|
184
|
+
"chargeHistoryLimit": 100
|
|
170
185
|
},
|
|
171
186
|
"objects": [],
|
|
172
187
|
"instanceObjects": [
|
|
@@ -190,6 +205,19 @@
|
|
|
190
205
|
"def": false
|
|
191
206
|
},
|
|
192
207
|
"native": {}
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"_id": "info.migrationV1",
|
|
211
|
+
"type": "state",
|
|
212
|
+
"common": {
|
|
213
|
+
"role": "indicator",
|
|
214
|
+
"name": "Migration V1 (charge history forceIndex) executed",
|
|
215
|
+
"type": "boolean",
|
|
216
|
+
"read": true,
|
|
217
|
+
"write": false,
|
|
218
|
+
"def": false
|
|
219
|
+
},
|
|
220
|
+
"native": {}
|
|
193
221
|
}
|
|
194
222
|
]
|
|
195
223
|
}
|
package/main.js
CHANGED
|
@@ -38,7 +38,9 @@ class Renault extends utils.Adapter {
|
|
|
38
38
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
39
39
|
return v.toString(16);
|
|
40
40
|
});
|
|
41
|
-
|
|
41
|
+
const brand = this.brand || 'renault';
|
|
42
|
+
const version = brand === 'alpine' ? '6.10.1' : '6.11.2';
|
|
43
|
+
return 'build=' + brand + '-android-' + version + ';trId=' + uuid;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -55,10 +57,18 @@ class Renault extends utils.Adapter {
|
|
|
55
57
|
this.reLoginTimeout = null;
|
|
56
58
|
this.refreshTokenTimeout = null;
|
|
57
59
|
this.country = this.config.country || 'de';
|
|
60
|
+
this.brand = this.config.brand || 'renault';
|
|
58
61
|
this.session = {};
|
|
59
|
-
//DE API Key
|
|
62
|
+
//DE API Key (shared by Renault, Dacia and Alpine - same Gigya/Kamereon tenant)
|
|
60
63
|
this.apiKey = '3_VgdkgtIRH3AdHvJm-cjV2ug2EFE0lxt0IJzMC4MFqZjFpn_GYFXVdNZ19L7wZX0N';
|
|
61
64
|
this.apiKeyUpdate = 'YjkKtHmGfaceeuExUDKGxrLZGGvtVS0J';
|
|
65
|
+
if (this.brand === 'alpine') {
|
|
66
|
+
this.product = 'MYALPINE';
|
|
67
|
+
this.accountTypes = ['MYALPINE'];
|
|
68
|
+
} else {
|
|
69
|
+
this.product = 'MYRENAULT';
|
|
70
|
+
this.accountTypes = ['MYRENAULT', 'MYDACIA'];
|
|
71
|
+
}
|
|
62
72
|
try {
|
|
63
73
|
await this.requestClient({
|
|
64
74
|
method: 'get',
|
|
@@ -88,6 +98,7 @@ class Renault extends utils.Adapter {
|
|
|
88
98
|
|
|
89
99
|
if (this.session.id_token && this.session_data && this.account) {
|
|
90
100
|
await this.getDeviceList();
|
|
101
|
+
await this.migrateChargeHistoryV1();
|
|
91
102
|
await this.updateDevices();
|
|
92
103
|
this.updateInterval = setInterval(
|
|
93
104
|
async () => {
|
|
@@ -169,7 +180,11 @@ class Renault extends utils.Adapter {
|
|
|
169
180
|
});
|
|
170
181
|
await this.requestClient({
|
|
171
182
|
method: 'post',
|
|
172
|
-
url:
|
|
183
|
+
url:
|
|
184
|
+
'https://apis.renault.com/myr/api/v1/connection?&country=DE&product=' +
|
|
185
|
+
this.product +
|
|
186
|
+
'&locale=de-DE&displayAccounts=' +
|
|
187
|
+
this.product,
|
|
173
188
|
headers: {
|
|
174
189
|
'Content-Type': 'application/json',
|
|
175
190
|
Accept: '*/*',
|
|
@@ -182,8 +197,9 @@ class Renault extends utils.Adapter {
|
|
|
182
197
|
.then((res) => {
|
|
183
198
|
this.log.debug(JSON.stringify(res.data));
|
|
184
199
|
|
|
200
|
+
const accountTypes = this.accountTypes;
|
|
185
201
|
const filteredAccounts = res.data.currentUser.accounts.filter(function (el) {
|
|
186
|
-
return (el.accountType
|
|
202
|
+
return accountTypes.includes(el.accountType) && el.accountStatus === 'ACTIVE';
|
|
187
203
|
});
|
|
188
204
|
if (filteredAccounts.length === 0) {
|
|
189
205
|
this.log.error('No Account found');
|
|
@@ -259,7 +275,12 @@ class Renault extends utils.Adapter {
|
|
|
259
275
|
|
|
260
276
|
const remoteArray = [
|
|
261
277
|
{ command: 'actions/hvac-start', name: 'True = Start, False = Stop' },
|
|
262
|
-
{
|
|
278
|
+
{
|
|
279
|
+
command: 'hvac-temperature',
|
|
280
|
+
name: 'HVAC Temperature',
|
|
281
|
+
type: /** @type {ioBroker.CommonType} */ ('number'),
|
|
282
|
+
role: 'value',
|
|
283
|
+
},
|
|
263
284
|
{ command: 'actions/charging-start', name: 'True = Start, False = Stop' },
|
|
264
285
|
{ command: 'charge/pause-resume', name: 'True = Start, False = Stop' },
|
|
265
286
|
{ command: 'charge/start', name: 'True = Start, False = Stop' },
|
|
@@ -270,7 +291,7 @@ class Renault extends utils.Adapter {
|
|
|
270
291
|
type: 'state',
|
|
271
292
|
common: {
|
|
272
293
|
name: remote.name || '',
|
|
273
|
-
type: remote.type || 'boolean',
|
|
294
|
+
type: /** @type {ioBroker.CommonType} */ (remote.type || 'boolean'),
|
|
274
295
|
role: remote.role || 'button',
|
|
275
296
|
write: true,
|
|
276
297
|
read: true,
|
|
@@ -289,12 +310,40 @@ class Renault extends utils.Adapter {
|
|
|
289
310
|
});
|
|
290
311
|
}
|
|
291
312
|
|
|
313
|
+
/**
|
|
314
|
+
* One-shot migration: with v0.0.24 the charge-history and charges endpoints switched from
|
|
315
|
+
* date-named channels (e.g. `20220802`, `2022-04-22T11:46:38Z`) to numeric forceIndex slots.
|
|
316
|
+
* Without cleanup the old objects would linger forever next to the new ones. The marker state
|
|
317
|
+
* `info.migrationV1` ensures this only runs once per instance.
|
|
318
|
+
*/
|
|
319
|
+
async migrateChargeHistoryV1() {
|
|
320
|
+
try {
|
|
321
|
+
const marker = await this.getStateAsync('info.migrationV1');
|
|
322
|
+
if (marker && marker.val === true) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.log.info('Running charge history migration V1: cleaning up legacy date-named channels');
|
|
326
|
+
for (const vin of this.deviceArray) {
|
|
327
|
+
for (const path of ['charge-history', 'charges']) {
|
|
328
|
+
await this.delObjectAsync(vin + '.' + path, { recursive: true }).catch(() => {});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
await this.setState('info.migrationV1', { val: true, ack: true });
|
|
332
|
+
this.log.info('Charge history migration V1 done');
|
|
333
|
+
} catch (e) {
|
|
334
|
+
this.log.warn('Charge history migration V1 failed: ' + e);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
292
338
|
async updateDevices() {
|
|
293
339
|
if (!this.account.accountId) {
|
|
294
340
|
this.log.error('No accountId found');
|
|
295
341
|
return;
|
|
296
342
|
}
|
|
297
343
|
const curDate = new Date().toISOString().split('T')[0];
|
|
344
|
+
// Charge history: limit start date to ~1 year back (My Renault app paginates yearly).
|
|
345
|
+
// Keeping the range bounded prevents the API from returning years of data on every poll.
|
|
346
|
+
const historyStart = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
298
347
|
|
|
299
348
|
const statusArray = [
|
|
300
349
|
{
|
|
@@ -405,22 +454,28 @@ class Renault extends utils.Adapter {
|
|
|
405
454
|
url:
|
|
406
455
|
'https://api-wired-prod-1-euw1.wrd-aws.com/commerce/v1/accounts/' +
|
|
407
456
|
this.account.accountId +
|
|
408
|
-
'/kamereon/kca/car-adapter/v1/cars/$vin/charge-history?type=day&start=
|
|
457
|
+
'/kamereon/kca/car-adapter/v1/cars/$vin/charge-history?type=day&start=' +
|
|
458
|
+
historyStart +
|
|
459
|
+
'&end=' +
|
|
409
460
|
curDate +
|
|
410
461
|
'&country=' +
|
|
411
462
|
this.country,
|
|
412
463
|
desc: 'Charging history of the car',
|
|
464
|
+
isHistory: true,
|
|
413
465
|
});
|
|
414
466
|
statusArray.push({
|
|
415
467
|
path: 'charges',
|
|
416
468
|
url:
|
|
417
469
|
'https://api-wired-prod-1-euw1.wrd-aws.com/commerce/v1/accounts/' +
|
|
418
470
|
this.account.accountId +
|
|
419
|
-
'/kamereon/kca/car-adapter/v1/cars/$vin/charges?start=
|
|
471
|
+
'/kamereon/kca/car-adapter/v1/cars/$vin/charges?start=' +
|
|
472
|
+
historyStart +
|
|
473
|
+
'&end=' +
|
|
420
474
|
curDate +
|
|
421
475
|
'&country=' +
|
|
422
476
|
this.country,
|
|
423
477
|
desc: 'Charges of the car',
|
|
478
|
+
isHistory: true,
|
|
424
479
|
});
|
|
425
480
|
}
|
|
426
481
|
|
|
@@ -444,7 +499,7 @@ class Renault extends utils.Adapter {
|
|
|
444
499
|
url: url,
|
|
445
500
|
headers: { ...headers, 'X-Amzn-Trace-Id': this.buildTraceId() },
|
|
446
501
|
})
|
|
447
|
-
.then((res) => {
|
|
502
|
+
.then(async (res) => {
|
|
448
503
|
this.log.debug(JSON.stringify(res.data));
|
|
449
504
|
if (!res.data) {
|
|
450
505
|
return;
|
|
@@ -454,9 +509,24 @@ class Renault extends utils.Adapter {
|
|
|
454
509
|
data = res.data.data.attributes;
|
|
455
510
|
}
|
|
456
511
|
|
|
457
|
-
|
|
512
|
+
let forceIndex = undefined;
|
|
458
513
|
const preferedArrayName = undefined;
|
|
459
514
|
|
|
515
|
+
// Charge history endpoints return arrays keyed by date — without forceIndex
|
|
516
|
+
// json2iob creates a new channel per date and never cleans them up. Cap to the
|
|
517
|
+
// configured limit (default 100, matches the My Renault app pagination) and use
|
|
518
|
+
// numeric indices so old entries are overwritten on each poll. The one-shot
|
|
519
|
+
// migrateChargeHistoryV1() removes the legacy date-named channels on first start.
|
|
520
|
+
if (element.isHistory) {
|
|
521
|
+
forceIndex = true;
|
|
522
|
+
const arrayKey = element.path === 'charge-history' ? 'chargeSummaries' : 'charges';
|
|
523
|
+
const limit = Number(this.config.chargeHistoryLimit);
|
|
524
|
+
const cap = Number.isFinite(limit) && limit > 0 ? limit : 0;
|
|
525
|
+
if (cap > 0 && data && Array.isArray(data[arrayKey]) && data[arrayKey].length > cap) {
|
|
526
|
+
data = { ...data, [arrayKey]: data[arrayKey].slice(-cap) };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
460
530
|
this.json2iob.parse(vin + '.' + element.path, data, {
|
|
461
531
|
forceIndex: forceIndex,
|
|
462
532
|
preferedArrayName: preferedArrayName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.renault",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
4
4
|
"description": "Adapter for Renault and Renault",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TA2k",
|
|
@@ -25,27 +25,28 @@
|
|
|
25
25
|
"url": "https://github.com/TA2k/ioBroker.renault"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@iobroker/adapter-core": "^3.3
|
|
29
|
-
"axios": "^1.
|
|
28
|
+
"@iobroker/adapter-core": "^3.4.3",
|
|
29
|
+
"axios": "^1.20.0",
|
|
30
30
|
"json-bigint": "^1.0.0",
|
|
31
|
-
"json2iob": "^2.6.
|
|
32
|
-
"qs": "^6.
|
|
31
|
+
"json2iob": "^2.6.26",
|
|
32
|
+
"qs": "^6.16.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@alcalzone/release-script": "^5.2.
|
|
35
|
+
"@alcalzone/release-script": "^5.2.1",
|
|
36
36
|
"@alcalzone/release-script-plugin-iobroker": "^5.2.0",
|
|
37
|
-
"@alcalzone/release-script-plugin-license": "^5.2.
|
|
37
|
+
"@alcalzone/release-script-plugin-license": "^5.2.2",
|
|
38
38
|
"@alcalzone/release-script-plugin-manual-review": "^5.2.0",
|
|
39
|
-
"@eslint/eslintrc": "^3.3.
|
|
39
|
+
"@eslint/eslintrc": "^3.3.7",
|
|
40
40
|
"@eslint/js": "^10.0.1",
|
|
41
|
-
"@iobroker/testing": "^5.
|
|
42
|
-
"@tsconfig/node18": "^18.2.
|
|
43
|
-
"@types/
|
|
44
|
-
"
|
|
41
|
+
"@iobroker/testing": "^5.3.0",
|
|
42
|
+
"@tsconfig/node18": "^18.2.7",
|
|
43
|
+
"@types/mocha": "^10.0.10",
|
|
44
|
+
"@types/node": "^25.9.6",
|
|
45
|
+
"eslint": "^10.10.0",
|
|
45
46
|
"eslint-config-prettier": "^10.1.8",
|
|
46
|
-
"eslint-plugin-prettier": "^5.5.
|
|
47
|
-
"globals": "^17.
|
|
48
|
-
"prettier": "^3.
|
|
47
|
+
"eslint-plugin-prettier": "^5.5.6",
|
|
48
|
+
"globals": "^17.12.0",
|
|
49
|
+
"prettier": "^3.9.6",
|
|
49
50
|
"typescript": "~6.0.3"
|
|
50
51
|
},
|
|
51
52
|
"main": "main.js",
|