edilkamin 1.6.1 → 1.7.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.
package/src/library.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import { strict as assert } from "assert";
2
2
  import { Amplify } from "aws-amplify";
3
3
  import * as amplifyAuth from "aws-amplify/auth";
4
+ import { cognitoUserPoolsTokenProvider } from "aws-amplify/auth/cognito";
4
5
  import axios, { AxiosInstance } from "axios";
5
6
 
7
+ import { processResponse } from "./buffer-utils";
6
8
  import { API_URL } from "./constants";
7
- import { DeviceInfoType } from "./types";
9
+ import {
10
+ DeviceAssociationBody,
11
+ DeviceAssociationResponse,
12
+ DeviceInfoRawType,
13
+ DeviceInfoType,
14
+ EditDeviceAssociationBody,
15
+ } from "./types";
8
16
 
9
17
  const amplifyconfiguration = {
10
18
  aws_project_region: "eu-central-1",
@@ -19,14 +27,25 @@ const amplifyconfiguration = {
19
27
  */
20
28
  const headers = (jwtToken: string) => ({ Authorization: `Bearer ${jwtToken}` });
21
29
 
30
+ let amplifyConfigured = false;
31
+
22
32
  /**
23
33
  * Configures Amplify if not already configured.
24
- * Ensures the configuration is only applied once.
34
+ * Uses a local flag to avoid calling getConfig() which prints a warning.
35
+ * @param {object} [storage] - Optional custom storage adapter for token persistence
25
36
  */
26
- const configureAmplify = () => {
27
- const currentConfig = Amplify.getConfig();
28
- if (Object.keys(currentConfig).length !== 0) return;
37
+ const configureAmplify = (storage?: {
38
+ setItem: (key: string, value: string) => Promise<void>;
39
+ getItem: (key: string) => Promise<string | null>;
40
+ removeItem: (key: string) => Promise<void>;
41
+ clear: () => Promise<void>;
42
+ }) => {
43
+ if (amplifyConfigured) return;
29
44
  Amplify.configure(amplifyconfiguration);
45
+ if (storage) {
46
+ cognitoUserPoolsTokenProvider.setKeyValueStorage(storage);
47
+ }
48
+ amplifyConfigured = true;
30
49
  };
31
50
 
32
51
  /**
@@ -39,12 +58,14 @@ const createAuthService = (auth: typeof amplifyAuth) => {
39
58
  * Signs in a user with the provided credentials.
40
59
  * @param {string} username - The username of the user.
41
60
  * @param {string} password - The password of the user.
61
+ * @param {boolean} [legacy=false] - If true, returns accessToken for legacy API.
42
62
  * @returns {Promise<string>} - The JWT token of the signed-in user.
43
63
  * @throws {Error} - If sign-in fails or no tokens are retrieved.
44
64
  */
45
65
  const signIn = async (
46
66
  username: string,
47
- password: string
67
+ password: string,
68
+ legacy: boolean = false,
48
69
  ): Promise<string> => {
49
70
  configureAmplify();
50
71
  await auth.signOut(); // Ensure the user is signed out first
@@ -52,32 +73,62 @@ const createAuthService = (auth: typeof amplifyAuth) => {
52
73
  assert.ok(isSignedIn, "Sign-in failed");
53
74
  const { tokens } = await auth.fetchAuthSession();
54
75
  assert.ok(tokens, "No tokens found");
76
+ if (legacy) {
77
+ assert.ok(tokens.accessToken, "No access token found");
78
+ return tokens.accessToken.toString();
79
+ }
55
80
  assert.ok(tokens.idToken, "No ID token found");
56
81
  return tokens.idToken.toString();
57
82
  };
58
- return { signIn };
83
+
84
+ /**
85
+ * Retrieves the current session, refreshing tokens if necessary.
86
+ * Requires a prior successful signIn() call.
87
+ * @param {boolean} [forceRefresh=false] - Force token refresh even if valid
88
+ * @param {boolean} [legacy=false] - If true, returns accessToken for legacy API
89
+ * @returns {Promise<string>} - The JWT token (idToken or accessToken)
90
+ * @throws {Error} - If no session exists (user needs to sign in)
91
+ */
92
+ const getSession = async (
93
+ forceRefresh: boolean = false,
94
+ legacy: boolean = false,
95
+ ): Promise<string> => {
96
+ configureAmplify();
97
+ const { tokens } = await auth.fetchAuthSession({ forceRefresh });
98
+ assert.ok(tokens, "No session found - please sign in first");
99
+ if (legacy) {
100
+ assert.ok(tokens.accessToken, "No access token found");
101
+ return tokens.accessToken.toString();
102
+ }
103
+ assert.ok(tokens.idToken, "No ID token found");
104
+ return tokens.idToken.toString();
105
+ };
106
+
107
+ return { signIn, getSession };
59
108
  };
60
109
 
61
110
  // Create the default auth service using amplifyAuth
62
- const { signIn } = createAuthService(amplifyAuth);
111
+ const { signIn, getSession } = createAuthService(amplifyAuth);
63
112
 
64
113
  const deviceInfo =
65
114
  (axiosInstance: AxiosInstance) =>
66
115
  /**
67
116
  * Retrieves information about a device by its MAC address.
117
+ * Automatically decompresses any gzip-compressed Buffer fields in the response.
68
118
  *
69
119
  * @param {string} jwtToken - The JWT token for authentication.
70
120
  * @param {string} macAddress - The MAC address of the device.
71
121
  * @returns {Promise<DeviceInfoType>} - A promise that resolves to the device info.
72
122
  */
73
- async (jwtToken: string, macAddress: string) => {
74
- const response = await axiosInstance.get<DeviceInfoType>(
123
+ async (jwtToken: string, macAddress: string): Promise<DeviceInfoType> => {
124
+ const response = await axiosInstance.get<DeviceInfoRawType>(
75
125
  `device/${macAddress}/info`,
76
126
  {
77
127
  headers: headers(jwtToken),
78
- }
128
+ },
79
129
  );
80
- return response.data;
130
+ // Process response to decompress any gzipped Buffer fields
131
+ return processResponse(response.data) as DeviceInfoType;
81
132
  };
82
133
 
83
134
  const mqttCommand =
@@ -87,7 +138,7 @@ const mqttCommand =
87
138
  axiosInstance.put(
88
139
  "mqtt/command",
89
140
  { mac_address: macAddress, ...payload },
90
- { headers: headers(jwtToken) }
141
+ { headers: headers(jwtToken) },
91
142
  );
92
143
 
93
144
  const setPower =
@@ -193,6 +244,70 @@ const setTargetTemperature =
193
244
  value: temperature,
194
245
  });
195
246
 
247
+ const registerDevice =
248
+ (axiosInstance: AxiosInstance) =>
249
+ /**
250
+ * Registers a device with the user's account.
251
+ * This must be called before other device operations will work on the new API.
252
+ *
253
+ * @param {string} jwtToken - The JWT token for authentication.
254
+ * @param {string} macAddress - The MAC address of the device (colons optional).
255
+ * @param {string} serialNumber - The device serial number.
256
+ * @param {string} deviceName - User-friendly name for the device (default: empty string).
257
+ * @param {string} deviceRoom - Room name for the device (default: empty string).
258
+ * @returns {Promise<DeviceAssociationResponse>} - A promise that resolves to the registration response.
259
+ */
260
+ async (
261
+ jwtToken: string,
262
+ macAddress: string,
263
+ serialNumber: string,
264
+ deviceName: string = "",
265
+ deviceRoom: string = "",
266
+ ): Promise<DeviceAssociationResponse> => {
267
+ const body: DeviceAssociationBody = {
268
+ macAddress: macAddress.replace(/:/g, ""),
269
+ deviceName,
270
+ deviceRoom,
271
+ serialNumber,
272
+ };
273
+ const response = await axiosInstance.post<DeviceAssociationResponse>(
274
+ "device",
275
+ body,
276
+ { headers: headers(jwtToken) },
277
+ );
278
+ return response.data;
279
+ };
280
+
281
+ const editDevice =
282
+ (axiosInstance: AxiosInstance) =>
283
+ /**
284
+ * Updates a device's name and room.
285
+ *
286
+ * @param {string} jwtToken - The JWT token for authentication.
287
+ * @param {string} macAddress - The MAC address of the device (colons optional).
288
+ * @param {string} deviceName - New name for the device (default: empty string).
289
+ * @param {string} deviceRoom - New room for the device (default: empty string).
290
+ * @returns {Promise<DeviceAssociationResponse>} - A promise that resolves to the update response.
291
+ */
292
+ async (
293
+ jwtToken: string,
294
+ macAddress: string,
295
+ deviceName: string = "",
296
+ deviceRoom: string = "",
297
+ ): Promise<DeviceAssociationResponse> => {
298
+ const normalizedMac = macAddress.replace(/:/g, "");
299
+ const body: EditDeviceAssociationBody = {
300
+ deviceName,
301
+ deviceRoom,
302
+ };
303
+ const response = await axiosInstance.put<DeviceAssociationResponse>(
304
+ `device/${normalizedMac}`,
305
+ body,
306
+ { headers: headers(jwtToken) },
307
+ );
308
+ return response.data;
309
+ };
310
+
196
311
  /**
197
312
  * Configures the library for API interactions.
198
313
  * Initializes API methods with a specified base URL.
@@ -207,6 +322,8 @@ const setTargetTemperature =
207
322
  const configure = (baseURL: string = API_URL) => {
208
323
  const axiosInstance = axios.create({ baseURL });
209
324
  const deviceInfoInstance = deviceInfo(axiosInstance);
325
+ const registerDeviceInstance = registerDevice(axiosInstance);
326
+ const editDeviceInstance = editDevice(axiosInstance);
210
327
  const setPowerInstance = setPower(axiosInstance);
211
328
  const setPowerOffInstance = setPowerOff(axiosInstance);
212
329
  const setPowerOnInstance = setPowerOn(axiosInstance);
@@ -217,6 +334,8 @@ const configure = (baseURL: string = API_URL) => {
217
334
  const setTargetTemperatureInstance = setTargetTemperature(axiosInstance);
218
335
  return {
219
336
  deviceInfo: deviceInfoInstance,
337
+ registerDevice: registerDeviceInstance,
338
+ editDevice: editDeviceInstance,
220
339
  setPower: setPowerInstance,
221
340
  setPowerOff: setPowerOffInstance,
222
341
  setPowerOn: setPowerOnInstance,
@@ -227,4 +346,11 @@ const configure = (baseURL: string = API_URL) => {
227
346
  };
228
347
  };
229
348
 
230
- export { configure, createAuthService, headers, signIn };
349
+ export {
350
+ configure,
351
+ configureAmplify,
352
+ createAuthService,
353
+ getSession,
354
+ headers,
355
+ signIn,
356
+ };
@@ -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 };
@@ -0,0 +1,78 @@
1
+ import { promises as fs } from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ const TOKEN_DIR = path.join(os.homedir(), ".edilkamin");
6
+ const TOKEN_FILE = path.join(TOKEN_DIR, "session.json");
7
+
8
+ interface StoredData {
9
+ [key: string]: string;
10
+ }
11
+
12
+ /**
13
+ * Custom storage adapter for AWS Amplify that persists to file system.
14
+ * Used for CLI to maintain sessions between invocations.
15
+ */
16
+ export const createFileStorage = () => {
17
+ let cache: StoredData = {};
18
+ let loaded = false;
19
+
20
+ const ensureDir = async (): Promise<void> => {
21
+ try {
22
+ await fs.mkdir(TOKEN_DIR, { recursive: true, mode: 0o700 });
23
+ } catch {
24
+ // Directory may already exist
25
+ }
26
+ };
27
+
28
+ const load = async (): Promise<void> => {
29
+ if (loaded) return;
30
+ try {
31
+ const data = await fs.readFile(TOKEN_FILE, "utf-8");
32
+ cache = JSON.parse(data);
33
+ } catch {
34
+ cache = {};
35
+ }
36
+ loaded = true;
37
+ };
38
+
39
+ const save = async (): Promise<void> => {
40
+ await ensureDir();
41
+ await fs.writeFile(TOKEN_FILE, JSON.stringify(cache), {
42
+ encoding: "utf-8",
43
+ mode: 0o600,
44
+ });
45
+ };
46
+
47
+ return {
48
+ setItem: async (key: string, value: string): Promise<void> => {
49
+ await load();
50
+ cache[key] = value;
51
+ await save();
52
+ },
53
+ getItem: async (key: string): Promise<string | null> => {
54
+ await load();
55
+ return cache[key] ?? null;
56
+ },
57
+ removeItem: async (key: string): Promise<void> => {
58
+ await load();
59
+ delete cache[key];
60
+ await save();
61
+ },
62
+ clear: async (): Promise<void> => {
63
+ cache = {};
64
+ await save();
65
+ },
66
+ };
67
+ };
68
+
69
+ /**
70
+ * Clears all stored session data.
71
+ */
72
+ export const clearSession = async (): Promise<void> => {
73
+ try {
74
+ await fs.unlink(TOKEN_FILE);
75
+ } catch {
76
+ // File may not exist
77
+ }
78
+ };
package/src/types.ts CHANGED
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Represents a Node.js Buffer object serialized to JSON.
3
+ * This format is used by the Edilkamin API for gzip-compressed fields.
4
+ */
5
+ interface BufferEncodedType {
6
+ type: "Buffer";
7
+ data: number[];
8
+ }
9
+
1
10
  interface CommandsType {
2
11
  power: boolean;
3
12
  }
@@ -27,9 +36,60 @@ interface DeviceInfoType {
27
36
  };
28
37
  }
29
38
 
39
+ /**
40
+ * Raw device info response that may contain Buffer-encoded compressed fields.
41
+ * Used internally before processing; external callers receive DeviceInfoType.
42
+ */
43
+ interface DeviceInfoRawType {
44
+ status: StatusType | BufferEncodedType;
45
+ nvm:
46
+ | {
47
+ user_parameters: UserParametersType;
48
+ }
49
+ | BufferEncodedType;
50
+ component_info?: BufferEncodedType | Record<string, unknown>;
51
+ }
52
+
53
+ /**
54
+ * Request body for registering a device with a user account.
55
+ * All fields are required by the API.
56
+ */
57
+ interface DeviceAssociationBody {
58
+ macAddress: string;
59
+ deviceName: string;
60
+ deviceRoom: string;
61
+ serialNumber: string;
62
+ }
63
+
64
+ /**
65
+ * Request body for editing a device's name and room.
66
+ * MAC address is specified in the URL path, not the body.
67
+ * Serial number cannot be changed after registration.
68
+ */
69
+ interface EditDeviceAssociationBody {
70
+ deviceName: string;
71
+ deviceRoom: string;
72
+ }
73
+
74
+ /**
75
+ * Response from device registration endpoint.
76
+ * Structure based on Android app behavior - may need adjustment after testing.
77
+ */
78
+ interface DeviceAssociationResponse {
79
+ macAddress: string;
80
+ deviceName: string;
81
+ deviceRoom: string;
82
+ serialNumber: string;
83
+ }
84
+
30
85
  export type {
86
+ BufferEncodedType,
31
87
  CommandsType,
88
+ DeviceAssociationBody,
89
+ DeviceAssociationResponse,
90
+ DeviceInfoRawType,
32
91
  DeviceInfoType,
92
+ EditDeviceAssociationBody,
33
93
  StatusType,
34
94
  TemperaturesType,
35
95
  UserParametersType,