edilkamin 1.6.0 → 1.6.2

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.
@@ -1,10 +1,25 @@
1
1
  import { strict as assert } from "assert";
2
2
  import axios from "axios";
3
+ import pako from "pako";
3
4
  import sinon from "sinon";
4
5
 
5
6
  import { configure, createAuthService } from "../src/library";
6
7
  import { API_URL } from "./constants";
7
8
 
9
+ /**
10
+ * Helper to create a gzip-compressed Buffer object for testing.
11
+ */
12
+ const createGzippedBuffer = (
13
+ data: unknown
14
+ ): { type: "Buffer"; data: number[] } => {
15
+ const json = JSON.stringify(data);
16
+ const compressed = pako.gzip(json);
17
+ return {
18
+ type: "Buffer",
19
+ data: Array.from(compressed),
20
+ };
21
+ };
22
+
8
23
  describe("library", () => {
9
24
  let axiosStub: sinon.SinonStub;
10
25
  const expectedToken = "mockJwtToken";
@@ -22,7 +37,7 @@ describe("library", () => {
22
37
  });
23
38
 
24
39
  describe("signIn", () => {
25
- it("should sign in and return the JWT token", async () => {
40
+ it("should sign in and return the ID token by default", async () => {
26
41
  const expectedUsername = "testuser";
27
42
  const expectedPassword = "testpassword";
28
43
  const signIn = sinon.stub().resolves({ isSignedIn: true });
@@ -30,6 +45,7 @@ describe("library", () => {
30
45
  const fetchAuthSession = sinon.stub().resolves({
31
46
  tokens: {
32
47
  idToken: { toString: () => expectedToken },
48
+ accessToken: { toString: () => "accessToken" },
33
49
  },
34
50
  });
35
51
  const authStub = {
@@ -50,6 +66,32 @@ describe("library", () => {
50
66
  assert.equal(token, expectedToken);
51
67
  });
52
68
 
69
+ it("should sign in and return the access token in legacy mode", async () => {
70
+ const expectedUsername = "testuser";
71
+ const expectedPassword = "testpassword";
72
+ const signIn = sinon.stub().resolves({ isSignedIn: true });
73
+ const signOut = sinon.stub();
74
+ const fetchAuthSession = sinon.stub().resolves({
75
+ tokens: {
76
+ accessToken: { toString: () => expectedToken },
77
+ idToken: { toString: () => "idToken" },
78
+ },
79
+ });
80
+ const authStub = {
81
+ signIn,
82
+ signOut,
83
+ fetchAuthSession,
84
+ };
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ const authService = createAuthService(authStub as any);
87
+ const token = await authService.signIn(
88
+ expectedUsername,
89
+ expectedPassword,
90
+ true // legacy mode
91
+ );
92
+ assert.equal(token, expectedToken);
93
+ });
94
+
53
95
  it("should throw an error if sign-in fails", async () => {
54
96
  const expectedUsername = "testuser";
55
97
  const expectedPassword = "testpassword";
@@ -80,6 +122,8 @@ describe("library", () => {
80
122
  describe("configure", () => {
81
123
  const expectedApi = [
82
124
  "deviceInfo",
125
+ "registerDevice",
126
+ "editDevice",
83
127
  "setPower",
84
128
  "setPowerOff",
85
129
  "setPowerOn",
@@ -273,4 +317,282 @@ describe("library", () => {
273
317
  });
274
318
  });
275
319
  });
320
+
321
+ describe("registerDevice", () => {
322
+ it("should call POST /device with correct payload", async () => {
323
+ const mockResponse = {
324
+ macAddress: "AABBCCDDEEFF",
325
+ deviceName: "Test Stove",
326
+ deviceRoom: "Living Room",
327
+ serialNumber: "EDK123",
328
+ };
329
+ const mockAxios = {
330
+ post: sinon.stub().resolves({ data: mockResponse }),
331
+ get: sinon.stub(),
332
+ put: sinon.stub(),
333
+ };
334
+ axiosStub.returns(mockAxios);
335
+ const api = configure("https://example.com/api");
336
+
337
+ const result = await api.registerDevice(
338
+ expectedToken,
339
+ "AA:BB:CC:DD:EE:FF",
340
+ "EDK123",
341
+ "Test Stove",
342
+ "Living Room"
343
+ );
344
+
345
+ assert.deepEqual(mockAxios.post.args, [
346
+ [
347
+ "device",
348
+ {
349
+ macAddress: "AABBCCDDEEFF",
350
+ deviceName: "Test Stove",
351
+ deviceRoom: "Living Room",
352
+ serialNumber: "EDK123",
353
+ },
354
+ { headers: { Authorization: `Bearer ${expectedToken}` } },
355
+ ],
356
+ ]);
357
+ assert.deepEqual(result, mockResponse);
358
+ });
359
+
360
+ it("should normalize MAC address by removing colons", async () => {
361
+ const mockAxios = {
362
+ post: sinon.stub().resolves({ data: {} }),
363
+ get: sinon.stub(),
364
+ put: sinon.stub(),
365
+ };
366
+ axiosStub.returns(mockAxios);
367
+ const api = configure("https://example.com/api");
368
+
369
+ await api.registerDevice(expectedToken, "AA:BB:CC:DD:EE:FF", "EDK123");
370
+
371
+ assert.equal(mockAxios.post.args[0][1].macAddress, "AABBCCDDEEFF");
372
+ });
373
+
374
+ it("should use empty strings as defaults for name and room", async () => {
375
+ const mockAxios = {
376
+ post: sinon.stub().resolves({ data: {} }),
377
+ get: sinon.stub(),
378
+ put: sinon.stub(),
379
+ };
380
+ axiosStub.returns(mockAxios);
381
+ const api = configure("https://example.com/api");
382
+
383
+ await api.registerDevice(expectedToken, "AABBCCDDEEFF", "EDK123");
384
+
385
+ assert.equal(mockAxios.post.args[0][1].deviceName, "");
386
+ assert.equal(mockAxios.post.args[0][1].deviceRoom, "");
387
+ });
388
+ });
389
+
390
+ describe("editDevice", () => {
391
+ it("should call PUT /device/{mac} with correct payload", async () => {
392
+ const mockResponse = {
393
+ macAddress: "AABBCCDDEEFF",
394
+ deviceName: "Updated Name",
395
+ deviceRoom: "Basement",
396
+ serialNumber: "EDK123",
397
+ };
398
+ const mockAxios = {
399
+ put: sinon.stub().resolves({ data: mockResponse }),
400
+ get: sinon.stub(),
401
+ post: sinon.stub(),
402
+ };
403
+ axiosStub.returns(mockAxios);
404
+ const api = configure("https://example.com/api");
405
+
406
+ const result = await api.editDevice(
407
+ expectedToken,
408
+ "AA:BB:CC:DD:EE:FF",
409
+ "Updated Name",
410
+ "Basement"
411
+ );
412
+
413
+ assert.deepEqual(mockAxios.put.args, [
414
+ [
415
+ "device/AABBCCDDEEFF",
416
+ {
417
+ deviceName: "Updated Name",
418
+ deviceRoom: "Basement",
419
+ },
420
+ { headers: { Authorization: `Bearer ${expectedToken}` } },
421
+ ],
422
+ ]);
423
+ assert.deepEqual(result, mockResponse);
424
+ });
425
+
426
+ it("should use empty strings as defaults for name and room", async () => {
427
+ const mockAxios = {
428
+ put: sinon.stub().resolves({ data: {} }),
429
+ get: sinon.stub(),
430
+ post: sinon.stub(),
431
+ };
432
+ axiosStub.returns(mockAxios);
433
+ const api = configure("https://example.com/api");
434
+
435
+ await api.editDevice(expectedToken, "AABBCCDDEEFF");
436
+
437
+ assert.equal(mockAxios.put.args[0][1].deviceName, "");
438
+ assert.equal(mockAxios.put.args[0][1].deviceRoom, "");
439
+ });
440
+ });
441
+
442
+ describe("deviceInfo with compressed responses", () => {
443
+ it("should decompress Buffer-encoded status field", async () => {
444
+ const statusData = {
445
+ commands: { power: true },
446
+ temperatures: { enviroment: 19, board: 25 },
447
+ };
448
+ const mockResponse = {
449
+ status: createGzippedBuffer(statusData),
450
+ nvm: {
451
+ user_parameters: {
452
+ enviroment_1_temperature: 22,
453
+ },
454
+ },
455
+ };
456
+
457
+ const mockAxios = {
458
+ get: sinon.stub().resolves({ data: mockResponse }),
459
+ };
460
+ axiosStub.returns(mockAxios);
461
+ const api = configure("https://example.com/api");
462
+
463
+ const result = await api.deviceInfo(expectedToken, "mockMacAddress");
464
+
465
+ assert.deepEqual(result.status, statusData);
466
+ });
467
+
468
+ it("should decompress Buffer-encoded nvm field", async () => {
469
+ const nvmData = {
470
+ user_parameters: {
471
+ enviroment_1_temperature: 22,
472
+ enviroment_2_temperature: 0,
473
+ enviroment_3_temperature: 0,
474
+ is_auto: false,
475
+ is_sound_active: true,
476
+ },
477
+ };
478
+ const mockResponse = {
479
+ status: {
480
+ commands: { power: true },
481
+ temperatures: { enviroment: 19 },
482
+ },
483
+ nvm: createGzippedBuffer(nvmData),
484
+ };
485
+
486
+ const mockAxios = {
487
+ get: sinon.stub().resolves({ data: mockResponse }),
488
+ };
489
+ axiosStub.returns(mockAxios);
490
+ const api = configure("https://example.com/api");
491
+
492
+ const result = await api.deviceInfo(expectedToken, "mockMacAddress");
493
+
494
+ assert.deepEqual(result.nvm, nvmData);
495
+ });
496
+
497
+ it("should handle fully compressed response (status and nvm)", async () => {
498
+ const statusData = {
499
+ commands: { power: false },
500
+ temperatures: { enviroment: 21, board: 30 },
501
+ };
502
+ const nvmData = {
503
+ user_parameters: {
504
+ enviroment_1_temperature: 20,
505
+ enviroment_2_temperature: 0,
506
+ enviroment_3_temperature: 0,
507
+ is_auto: true,
508
+ is_sound_active: false,
509
+ },
510
+ };
511
+ const mockResponse = {
512
+ status: createGzippedBuffer(statusData),
513
+ nvm: createGzippedBuffer(nvmData),
514
+ };
515
+
516
+ const mockAxios = {
517
+ get: sinon.stub().resolves({ data: mockResponse }),
518
+ };
519
+ axiosStub.returns(mockAxios);
520
+ const api = configure("https://example.com/api");
521
+
522
+ const result = await api.deviceInfo(expectedToken, "mockMacAddress");
523
+
524
+ assert.deepEqual(result.status, statusData);
525
+ assert.deepEqual(result.nvm, nvmData);
526
+ });
527
+
528
+ it("should work with getPower on compressed response", async () => {
529
+ const statusData = {
530
+ commands: { power: true },
531
+ temperatures: { enviroment: 19 },
532
+ };
533
+ const mockResponse = {
534
+ status: createGzippedBuffer(statusData),
535
+ nvm: { user_parameters: { enviroment_1_temperature: 22 } },
536
+ };
537
+
538
+ const mockAxios = {
539
+ get: sinon.stub().resolves({ data: mockResponse }),
540
+ };
541
+ axiosStub.returns(mockAxios);
542
+ const api = configure("https://example.com/api");
543
+
544
+ const result = await api.getPower(expectedToken, "mockMacAddress");
545
+
546
+ assert.equal(result, true);
547
+ });
548
+
549
+ it("should work with getEnvironmentTemperature on compressed response", async () => {
550
+ const statusData = {
551
+ commands: { power: true },
552
+ temperatures: { enviroment: 19, board: 25 },
553
+ };
554
+ const mockResponse = {
555
+ status: createGzippedBuffer(statusData),
556
+ nvm: { user_parameters: { enviroment_1_temperature: 22 } },
557
+ };
558
+
559
+ const mockAxios = {
560
+ get: sinon.stub().resolves({ data: mockResponse }),
561
+ };
562
+ axiosStub.returns(mockAxios);
563
+ const api = configure("https://example.com/api");
564
+
565
+ const result = await api.getEnvironmentTemperature(
566
+ expectedToken,
567
+ "mockMacAddress"
568
+ );
569
+
570
+ assert.equal(result, 19);
571
+ });
572
+
573
+ it("should work with getTargetTemperature on compressed response", async () => {
574
+ const nvmData = {
575
+ user_parameters: {
576
+ enviroment_1_temperature: 22,
577
+ },
578
+ };
579
+ const mockResponse = {
580
+ status: { commands: { power: true }, temperatures: { enviroment: 19 } },
581
+ nvm: createGzippedBuffer(nvmData),
582
+ };
583
+
584
+ const mockAxios = {
585
+ get: sinon.stub().resolves({ data: mockResponse }),
586
+ };
587
+ axiosStub.returns(mockAxios);
588
+ const api = configure("https://example.com/api");
589
+
590
+ const result = await api.getTargetTemperature(
591
+ expectedToken,
592
+ "mockMacAddress"
593
+ );
594
+
595
+ assert.equal(result, 22);
596
+ });
597
+ });
276
598
  });
package/src/library.ts CHANGED
@@ -3,8 +3,15 @@ import { Amplify } from "aws-amplify";
3
3
  import * as amplifyAuth from "aws-amplify/auth";
4
4
  import axios, { AxiosInstance } from "axios";
5
5
 
6
+ import { processResponse } from "./buffer-utils";
6
7
  import { API_URL } from "./constants";
7
- import { DeviceInfoType } from "./types";
8
+ import {
9
+ DeviceAssociationBody,
10
+ DeviceAssociationResponse,
11
+ DeviceInfoRawType,
12
+ DeviceInfoType,
13
+ EditDeviceAssociationBody,
14
+ } from "./types";
8
15
 
9
16
  const amplifyconfiguration = {
10
17
  aws_project_region: "eu-central-1",
@@ -19,14 +26,16 @@ const amplifyconfiguration = {
19
26
  */
20
27
  const headers = (jwtToken: string) => ({ Authorization: `Bearer ${jwtToken}` });
21
28
 
29
+ let amplifyConfigured = false;
30
+
22
31
  /**
23
32
  * Configures Amplify if not already configured.
24
- * Ensures the configuration is only applied once.
33
+ * Uses a local flag to avoid calling getConfig() which prints a warning.
25
34
  */
26
35
  const configureAmplify = () => {
27
- const currentConfig = Amplify.getConfig();
28
- if (Object.keys(currentConfig).length !== 0) return;
36
+ if (amplifyConfigured) return;
29
37
  Amplify.configure(amplifyconfiguration);
38
+ amplifyConfigured = true;
30
39
  };
31
40
 
32
41
  /**
@@ -39,12 +48,14 @@ const createAuthService = (auth: typeof amplifyAuth) => {
39
48
  * Signs in a user with the provided credentials.
40
49
  * @param {string} username - The username of the user.
41
50
  * @param {string} password - The password of the user.
51
+ * @param {boolean} [legacy=false] - If true, returns accessToken for legacy API.
42
52
  * @returns {Promise<string>} - The JWT token of the signed-in user.
43
53
  * @throws {Error} - If sign-in fails or no tokens are retrieved.
44
54
  */
45
55
  const signIn = async (
46
56
  username: string,
47
- password: string
57
+ password: string,
58
+ legacy: boolean = false
48
59
  ): Promise<string> => {
49
60
  configureAmplify();
50
61
  await auth.signOut(); // Ensure the user is signed out first
@@ -52,6 +63,10 @@ const createAuthService = (auth: typeof amplifyAuth) => {
52
63
  assert.ok(isSignedIn, "Sign-in failed");
53
64
  const { tokens } = await auth.fetchAuthSession();
54
65
  assert.ok(tokens, "No tokens found");
66
+ if (legacy) {
67
+ assert.ok(tokens.accessToken, "No access token found");
68
+ return tokens.accessToken.toString();
69
+ }
55
70
  assert.ok(tokens.idToken, "No ID token found");
56
71
  return tokens.idToken.toString();
57
72
  };
@@ -65,19 +80,21 @@ const deviceInfo =
65
80
  (axiosInstance: AxiosInstance) =>
66
81
  /**
67
82
  * Retrieves information about a device by its MAC address.
83
+ * Automatically decompresses any gzip-compressed Buffer fields in the response.
68
84
  *
69
85
  * @param {string} jwtToken - The JWT token for authentication.
70
86
  * @param {string} macAddress - The MAC address of the device.
71
87
  * @returns {Promise<DeviceInfoType>} - A promise that resolves to the device info.
72
88
  */
73
- async (jwtToken: string, macAddress: string) => {
74
- const response = await axiosInstance.get<DeviceInfoType>(
89
+ async (jwtToken: string, macAddress: string): Promise<DeviceInfoType> => {
90
+ const response = await axiosInstance.get<DeviceInfoRawType>(
75
91
  `device/${macAddress}/info`,
76
92
  {
77
93
  headers: headers(jwtToken),
78
94
  }
79
95
  );
80
- return response.data;
96
+ // Process response to decompress any gzipped Buffer fields
97
+ return processResponse(response.data) as DeviceInfoType;
81
98
  };
82
99
 
83
100
  const mqttCommand =
@@ -193,6 +210,70 @@ const setTargetTemperature =
193
210
  value: temperature,
194
211
  });
195
212
 
213
+ const registerDevice =
214
+ (axiosInstance: AxiosInstance) =>
215
+ /**
216
+ * Registers a device with the user's account.
217
+ * This must be called before other device operations will work on the new API.
218
+ *
219
+ * @param {string} jwtToken - The JWT token for authentication.
220
+ * @param {string} macAddress - The MAC address of the device (colons optional).
221
+ * @param {string} serialNumber - The device serial number.
222
+ * @param {string} deviceName - User-friendly name for the device (default: empty string).
223
+ * @param {string} deviceRoom - Room name for the device (default: empty string).
224
+ * @returns {Promise<DeviceAssociationResponse>} - A promise that resolves to the registration response.
225
+ */
226
+ async (
227
+ jwtToken: string,
228
+ macAddress: string,
229
+ serialNumber: string,
230
+ deviceName: string = "",
231
+ deviceRoom: string = ""
232
+ ): Promise<DeviceAssociationResponse> => {
233
+ const body: DeviceAssociationBody = {
234
+ macAddress: macAddress.replace(/:/g, ""),
235
+ deviceName,
236
+ deviceRoom,
237
+ serialNumber,
238
+ };
239
+ const response = await axiosInstance.post<DeviceAssociationResponse>(
240
+ "device",
241
+ body,
242
+ { headers: headers(jwtToken) }
243
+ );
244
+ return response.data;
245
+ };
246
+
247
+ const editDevice =
248
+ (axiosInstance: AxiosInstance) =>
249
+ /**
250
+ * Updates a device's name and room.
251
+ *
252
+ * @param {string} jwtToken - The JWT token for authentication.
253
+ * @param {string} macAddress - The MAC address of the device (colons optional).
254
+ * @param {string} deviceName - New name for the device (default: empty string).
255
+ * @param {string} deviceRoom - New room for the device (default: empty string).
256
+ * @returns {Promise<DeviceAssociationResponse>} - A promise that resolves to the update response.
257
+ */
258
+ async (
259
+ jwtToken: string,
260
+ macAddress: string,
261
+ deviceName: string = "",
262
+ deviceRoom: string = ""
263
+ ): Promise<DeviceAssociationResponse> => {
264
+ const normalizedMac = macAddress.replace(/:/g, "");
265
+ const body: EditDeviceAssociationBody = {
266
+ deviceName,
267
+ deviceRoom,
268
+ };
269
+ const response = await axiosInstance.put<DeviceAssociationResponse>(
270
+ `device/${normalizedMac}`,
271
+ body,
272
+ { headers: headers(jwtToken) }
273
+ );
274
+ return response.data;
275
+ };
276
+
196
277
  /**
197
278
  * Configures the library for API interactions.
198
279
  * Initializes API methods with a specified base URL.
@@ -207,6 +288,8 @@ const setTargetTemperature =
207
288
  const configure = (baseURL: string = API_URL) => {
208
289
  const axiosInstance = axios.create({ baseURL });
209
290
  const deviceInfoInstance = deviceInfo(axiosInstance);
291
+ const registerDeviceInstance = registerDevice(axiosInstance);
292
+ const editDeviceInstance = editDevice(axiosInstance);
210
293
  const setPowerInstance = setPower(axiosInstance);
211
294
  const setPowerOffInstance = setPowerOff(axiosInstance);
212
295
  const setPowerOnInstance = setPowerOn(axiosInstance);
@@ -217,6 +300,8 @@ const configure = (baseURL: string = API_URL) => {
217
300
  const setTargetTemperatureInstance = setTargetTemperature(axiosInstance);
218
301
  return {
219
302
  deviceInfo: deviceInfoInstance,
303
+ registerDevice: registerDeviceInstance,
304
+ editDevice: editDeviceInstance,
220
305
  setPower: setPowerInstance,
221
306
  setPowerOff: setPowerOffInstance,
222
307
  setPowerOn: setPowerOnInstance,
@@ -0,0 +1,64 @@
1
+ import { strict as assert } from "assert";
2
+
3
+ import {
4
+ serialNumberDisplay,
5
+ serialNumberFromHex,
6
+ serialNumberToHex,
7
+ } from "./serial-utils";
8
+
9
+ describe("serial-utils", () => {
10
+ describe("serialNumberToHex", () => {
11
+ it("should convert ASCII string to hex", () => {
12
+ assert.equal(serialNumberToHex("EDK123"), "45444b313233");
13
+ });
14
+
15
+ it("should handle empty string", () => {
16
+ assert.equal(serialNumberToHex(""), "");
17
+ });
18
+
19
+ it("should convert string with non-printable chars", () => {
20
+ const input = "EDK\x00123";
21
+ const hex = serialNumberToHex(input);
22
+ assert.equal(hex, "45444b00313233");
23
+ });
24
+ });
25
+
26
+ describe("serialNumberFromHex", () => {
27
+ it("should convert hex back to ASCII string", () => {
28
+ assert.equal(serialNumberFromHex("45444b313233"), "EDK123");
29
+ });
30
+
31
+ it("should handle empty string", () => {
32
+ assert.equal(serialNumberFromHex(""), "");
33
+ });
34
+
35
+ it("should round-trip with toHex", () => {
36
+ const original = "EDK\x00123\x1F";
37
+ const hex = serialNumberToHex(original);
38
+ const restored = serialNumberFromHex(hex);
39
+ assert.equal(restored, original);
40
+ });
41
+ });
42
+
43
+ describe("serialNumberDisplay", () => {
44
+ it("should remove non-printable characters", () => {
45
+ assert.equal(serialNumberDisplay("EDK\x00123\x1F"), "EDK123");
46
+ });
47
+
48
+ it("should collapse whitespace", () => {
49
+ assert.equal(serialNumberDisplay("EDK 123"), "EDK 123");
50
+ });
51
+
52
+ it("should trim leading and trailing whitespace", () => {
53
+ assert.equal(serialNumberDisplay(" EDK123 "), "EDK123");
54
+ });
55
+
56
+ it("should handle empty string", () => {
57
+ assert.equal(serialNumberDisplay(""), "");
58
+ });
59
+
60
+ it("should preserve normal serial numbers", () => {
61
+ assert.equal(serialNumberDisplay("EDK12345678"), "EDK12345678");
62
+ });
63
+ });
64
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Converts a raw serial number string to hex-encoded format.
3
+ * This is useful when serial numbers contain non-printable characters.
4
+ *
5
+ * @param serial - The raw serial number string
6
+ * @returns Hex-encoded string representation
7
+ *
8
+ * @example
9
+ * serialNumberToHex("EDK123") // returns "45444b313233"
10
+ */
11
+ const serialNumberToHex = (serial: string): string => {
12
+ return Buffer.from(serial, "utf-8").toString("hex");
13
+ };
14
+
15
+ /**
16
+ * Converts a hex-encoded serial number back to raw string format.
17
+ *
18
+ * @param hex - The hex-encoded serial number
19
+ * @returns Raw serial number string
20
+ *
21
+ * @example
22
+ * serialNumberFromHex("45444b313233") // returns "EDK123"
23
+ */
24
+ const serialNumberFromHex = (hex: string): string => {
25
+ return Buffer.from(hex, "hex").toString("utf-8");
26
+ };
27
+
28
+ /**
29
+ * Produces a display-friendly version of a serial number by removing
30
+ * non-printable characters and collapsing whitespace.
31
+ *
32
+ * @param serial - The raw serial number string
33
+ * @returns Display-friendly serial number
34
+ *
35
+ * @example
36
+ * serialNumberDisplay("EDK\x00123\x1F") // returns "EDK123"
37
+ */
38
+ const serialNumberDisplay = (serial: string): string => {
39
+ // Remove non-printable characters (ASCII 0-31, 127)
40
+ // Keep printable ASCII (32-126) and extended characters
41
+ return (
42
+ serial
43
+ // eslint-disable-next-line no-control-regex
44
+ .replace(/[\x00-\x1F\x7F]/g, "")
45
+ .replace(/\s+/g, " ")
46
+ .trim()
47
+ );
48
+ };
49
+
50
+ export { serialNumberDisplay, serialNumberFromHex, serialNumberToHex };