edilkamin 1.3.0 → 1.3.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.
@@ -0,0 +1,17 @@
1
+ name: CLI Tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ tests:
9
+ runs-on: ubuntu-latest
10
+ timeout-minutes: 5
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: "20.x"
16
+ - run: yarn install --no-ignore-optional
17
+ - run: yarn cli --help
@@ -0,0 +1,34 @@
1
+ name: Documentation
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: "20.x"
16
+ - name: git config
17
+ run: |
18
+ git config user.name documentation-deploy-action
19
+ git config user.email documentation-deploy-action@@users.noreply.github.com
20
+ git remote set-url origin https://${{github.actor}}:${{github.token}}@github.com/${{github.repository}}.git
21
+ - run: yarn install
22
+ - run: yarn typedoc src/index.ts --out /tmp/docs
23
+ - name: deploy documentation
24
+ run: |
25
+ git ls-remote --exit-code . origin/gh-pages \
26
+ && git checkout -b gh-pages \
27
+ || git checkout --orphan gh-pages
28
+ git reset --hard
29
+ git pull --set-upstream origin gh-pages || echo probably first commit
30
+ cp --recursive /tmp/docs/. .
31
+ echo /node_modules > .gitignore
32
+ git add --all
33
+ git commit --all --message ":memo: docs: Update generated documentation"
34
+ git push origin gh-pages
@@ -0,0 +1,21 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: "20.x"
16
+ registry-url: "https://registry.npmjs.org"
17
+ - run: yarn install
18
+ - run: yarn build
19
+ - run: npm publish
20
+ env:
21
+ NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
@@ -0,0 +1,18 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
+ with:
14
+ node-version: "20.x"
15
+ - run: yarn install
16
+ - run: yarn lint
17
+ - run: yarn build
18
+ - run: npm publish --dry-run
@@ -0,0 +1,41 @@
1
+ # How to release
2
+
3
+ This is documenting the release process.
4
+
5
+ We're also using [semantic versioning](https://semver.org/) where `major.minor.patch` should be set accordingly.
6
+
7
+ ```sh
8
+ VERSION=major.minor.patch
9
+ ```
10
+
11
+ ## Update package.json and tag
12
+
13
+ Update the [package.json](../package.json) `version` to match the new release version.
14
+
15
+ ```sh
16
+ sed --regexp-extended 's/"version": "(.+)"/"version": "'$VERSION'"/' --in-place package.json
17
+ ```
18
+
19
+ Then commit and tag:
20
+
21
+ ```sh
22
+ git commit -a -m ":bookmark: $VERSION"
23
+ git tag -a $VERSION -m ":bookmark: $VERSION"
24
+ ```
25
+
26
+ Push everything including tags:
27
+
28
+ ```sh
29
+ git push
30
+ git push --tags
31
+ ```
32
+
33
+ ## Publish to npm
34
+
35
+ Publication to npm happens automatically from GitHub Actions on tag push.
36
+ Alternatively it can be done manually via:
37
+
38
+ ```sh
39
+ yarn build
40
+ npm publish
41
+ ```
@@ -0,0 +1,143 @@
1
+ # Edilkamin Stove Reverse Engineering
2
+
3
+ Edilkamin pellet stoves remote control reverse engineering.
4
+ This is documenting my journey to reverse engineering [The Mind Edilkamin app](https://play.google.com/store/apps/details?id=com.edilkamin.stufe).
5
+ The goal was to be able to control the stove wirelessly without having to use the proprietary app.
6
+
7
+ ## APK download & decompiling
8
+
9
+ The steps to decompile an APK are mainly described in this article:
10
+ [Reverse Engineering Sodexo's API](https://medium.com/@andre.miras/reverse-engineering-sodexos-api-d13710b7bf0d)
11
+
12
+ Here we summarize some of them.
13
+
14
+ - APK used: https://play.google.com/store/apps/details?id=com.edilkamin.stufe
15
+ - version: 1.2.3 (19 November 2021)
16
+
17
+ Assuming the APK is already loaded on the Android device, proceed as below to download on the computer.
18
+ Get the APK on device path:
19
+
20
+ ```sh
21
+ adb shell pm list packages -f | grep edilkamin
22
+ ```
23
+
24
+ Output:
25
+
26
+ ```
27
+ package:/data/app/com.edilkamin.stufe-Di57pxUTs3wzjQF0dIxeEQ==/base.apk=com.edilkamin.stufe
28
+ ```
29
+
30
+ Copy to the computer:
31
+
32
+ ```sh
33
+ adb shell cp /data/app/com.edilkamin.stufe-Di57pxUTs3wzjQF0dIxeEQ==/base.apk /sdcard/
34
+ adb pull /sdcard/base.apk .
35
+ ```
36
+
37
+ Decompile (jadx v1.3.3):
38
+
39
+ ```sh
40
+ jadx --output-dir base base.apk
41
+ ```
42
+
43
+ ## Looking into `strings.xml`
44
+
45
+ Often an interesting starting point is the `base/resources/res/values/strings.xml` file.
46
+ We find the usual `google_api_key` and `google_app_id`, but no endpoint prefix or anything that
47
+ will be used in the short terms.
48
+
49
+ ## Let's `grep` through the source
50
+
51
+ The APK source contains a lot of thirdparty code, but the actual application code is located in:
52
+ `base/sources/com/edilkamin/stufe/`
53
+
54
+ Let's `grep` into it looking for some endpoints:
55
+
56
+ ```sh
57
+ cd base/sources/com/edilkamin/stufe/ && grep -irE 'http(s)://' .
58
+ ```
59
+
60
+ Output extract:
61
+
62
+ ```
63
+ ./network/EdilkaminApiServiceKt.java: public static final String BASE_URL = "https://s5zsjtooy4.execute-api.eu-central-1.amazonaws.com/test/";
64
+ ./network/EdilkaminApiServiceKt.java: public static final String PROD_URL = "https://fxtj7xkgc6.execute-api.eu-central-1.amazonaws.com/prod/";
65
+ ```
66
+
67
+ Other interesting files found and their extract:
68
+
69
+ - `base/sources/com/edilkamin/stufe/network/ApiService.java`:
70
+
71
+ ```java
72
+ @PUT("device/{mac_address}")
73
+ Object editAssociation(@Header("Authorization") String str, @Path("mac_address") String str2, @Body EditDeviceAssociationBody editDeviceAssociationBody, Continuation<Object> continuation);
74
+
75
+ @GET("device/{macAddress}/info")
76
+ Object getFireplaceInfo(@Path("macAddress") String str, Continuation<? super GeneralResponse> continuation);
77
+ ```
78
+
79
+ - `base/resources/res/raw/amplifyconfiguration.json`:
80
+
81
+ ```json
82
+ "Default": {
83
+ "PoolId": "eu-central-1_BYmQ2VBlo",
84
+ "AppClientId": "7sc1qltkqobo3ddqsk4542dg2h",
85
+ "Region": "eu-central-1"
86
+ }
87
+ ```
88
+
89
+ ## Poking the /prod/ endpoint around
90
+
91
+ ```sh
92
+ curl https://fxtj7xkgc6.execute-api.eu-central-1.amazonaws.com/prod/
93
+ ```
94
+
95
+ Output (status code 403):
96
+
97
+ ```json
98
+ { "message": "Missing Authentication Token" }
99
+ ```
100
+
101
+ Let's try an unauthenticated endpoint then, remember that file `network/ApiService.java`.
102
+
103
+ ```sh
104
+ curl --verbose https://fxtj7xkgc6.execute-api.eu-central-1.amazonaws.com/prod/device/AA:BB:CC:DD:EE:FF/info
105
+ ```
106
+
107
+ Output (status code 404):
108
+
109
+ ```json
110
+ {}
111
+ ```
112
+
113
+ That looks already promising.
114
+ After poking that endpoint around and using the real device MAC address we get a valid response.
115
+ Note how the MAC address is all lower case and no column:
116
+
117
+ ```sh
118
+ curl --verbose https://fxtj7xkgc6.execute-api.eu-central-1.amazonaws.com/prod/device/aabbccddeeff/info
119
+ ```
120
+
121
+ Output (status code 200):
122
+
123
+ ```json
124
+ {"mac_address":"aabbccddeeff","pk":1,"component_info":{"temp_umidity_voc_probe_3":...}}
125
+ ```
126
+
127
+ Bingo!
128
+
129
+ ## Turn the stove on
130
+
131
+ ```sh
132
+ curl --verbose --request PUT --header "Content-Type: application/json" \
133
+ --data '{"mac_address":"aabbccddeeff", "name": "power", "value": 1}' \
134
+ https://fxtj7xkgc6.execute-api.eu-central-1.amazonaws.com/prod/mqtt/command
135
+ ```
136
+
137
+ ## Note on Security
138
+
139
+ It seems like most endpoints let you read info or control the stove without any authentication.
140
+ All we need is a valid device MAC address.
141
+ Don't leak your MAC address or people can potentially control your stove.
142
+
143
+ October 2022 update: the endpoints got updated to require a JWT token and the stoves are linked to the account.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edilkamin",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,9 +13,6 @@
13
13
  "format": "prettier --write src docs .github *.md",
14
14
  "build": "tsc"
15
15
  },
16
- "files": [
17
- "/dist"
18
- ],
19
16
  "repository": {
20
17
  "type": "git",
21
18
  "url": "git+https://github.com/AndreMiras/edilkamin.js.git"
package/src/cli.ts ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { signIn, configure } from "./library";
3
+ import { Command } from "commander";
4
+ import readline from "readline";
5
+ import { version } from "../package.json";
6
+
7
+ const promptPassword = (): Promise<string> => {
8
+ const rl = readline.createInterface({
9
+ input: process.stdin,
10
+ output: process.stdout,
11
+ terminal: true,
12
+ });
13
+ return new Promise((resolve) => {
14
+ rl.question("Enter password: ", (password) => {
15
+ // Hide the password input
16
+ readline.moveCursor(process.stdout, 0, -1);
17
+ readline.clearLine(process.stdout, 0);
18
+ rl.close();
19
+ resolve(password);
20
+ });
21
+ // Disable input echoing for password
22
+ process.stdin.on("data", (char) => {
23
+ if (char.toString("hex") === "0d0a") return; // Enter key
24
+ process.stdout.write("*");
25
+ });
26
+ });
27
+ };
28
+
29
+ /**
30
+ * Adds common options (username and password) to a command.
31
+ * @param command The command to which options should be added.
32
+ * @returns The command with options added.
33
+ */
34
+ const addCommonOptions = (command: Command): Command =>
35
+ command
36
+ .requiredOption("-u, --username <username>", "Username")
37
+ .option("-p, --password <password>", "Password");
38
+
39
+ const createProgram = (): Command => {
40
+ const program = new Command();
41
+ program
42
+ .name("edilkamin-cli")
43
+ .description("CLI tool for interacting with the Edilkamin API")
44
+ .version(version);
45
+ // Command: signIn
46
+ addCommonOptions(
47
+ program.command("signIn").description("Sign in and retrieve a JWT token")
48
+ ).action(async (options) => {
49
+ const { username, password } = options;
50
+ const pwd = password || (await promptPassword());
51
+ const jwtToken = await signIn(username, pwd);
52
+ console.log("JWT Token:", jwtToken);
53
+ });
54
+ // Command: deviceInfo
55
+ addCommonOptions(
56
+ program
57
+ .command("deviceInfo")
58
+ .description("Retrieve device info for a specific MAC address")
59
+ .requiredOption("-m, --mac <macAddress>", "MAC address of the device")
60
+ ).action(async (options) => {
61
+ const { username, password, mac } = options;
62
+ const pwd = password || (await promptPassword());
63
+ const jwtToken = await signIn(username, pwd);
64
+ const api = configure(); // Use the default API configuration
65
+ const deviceInfo = await api.deviceInfo(jwtToken, mac);
66
+ console.log("Device Info:", deviceInfo.data);
67
+ });
68
+ return program;
69
+ };
70
+
71
+ const main = (): void => {
72
+ const program = createProgram();
73
+ program.parse(process.argv);
74
+ };
75
+
76
+ if (require.main === module) {
77
+ main();
78
+ }
79
+
80
+ export { main };
@@ -0,0 +1,4 @@
1
+ const API_URL =
2
+ "https://fxtj7xkgc6.execute-api.eu-central-1.amazonaws.com/prod/";
3
+
4
+ export { API_URL };
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { configure } from "./library";
2
+
3
+ export { API_URL } from "./constants";
4
+
5
+ export {
6
+ CommandsType,
7
+ DeviceInfoType,
8
+ StatusType,
9
+ TemperaturesType,
10
+ UserParametersType,
11
+ } from "./types";
12
+
13
+ export { signIn, configure } from "./library";
14
+
15
+ export const { deviceInfo, setPower, setPowerOff, setPowerOn } = configure();
@@ -0,0 +1,77 @@
1
+ import { strict as assert } from "assert";
2
+ import sinon from "sinon";
3
+ import { Amplify } from "aws-amplify";
4
+ import axios from "axios";
5
+ import { signIn, configure } from "../src/library";
6
+
7
+ describe("library", () => {
8
+ let axiosStub: sinon.SinonStub;
9
+
10
+ beforeEach(() => {
11
+ axiosStub = sinon.stub(axios, "create").returns({
12
+ get: sinon.stub(),
13
+ put: sinon.stub(),
14
+ } as any);
15
+ });
16
+
17
+ afterEach(() => {
18
+ sinon.restore();
19
+ });
20
+
21
+ describe("configure", () => {
22
+ it("should create API methods with the correct baseURL", () => {
23
+ const baseURL = "https://example.com/api";
24
+ const api = configure(baseURL);
25
+ assert.ok(axiosStub.calledOnce);
26
+ assert.deepEqual(axiosStub.firstCall.args[0], { baseURL });
27
+ assert.deepEqual(Object.keys(api), [
28
+ "deviceInfo",
29
+ "setPower",
30
+ "setPowerOff",
31
+ "setPowerOn",
32
+ ]);
33
+ });
34
+ });
35
+
36
+ describe("API Methods", () => {
37
+ it("should call axios for deviceInfo", async () => {
38
+ const mockAxios = {
39
+ get: sinon
40
+ .stub()
41
+ .resolves({ data: { id: "123", name: "Mock Device" } }),
42
+ };
43
+ axiosStub.returns(mockAxios as any);
44
+ const api = configure("https://example.com/api");
45
+ const result = await api.deviceInfo("mockToken", "mockMacAddress");
46
+ assert.ok(mockAxios.get.calledOnce);
47
+ assert.equal(
48
+ mockAxios.get.firstCall.args[0],
49
+ "device/mockMacAddress/info"
50
+ );
51
+ assert.deepEqual(mockAxios.get.firstCall.args[1], {
52
+ headers: { Authorization: "Bearer mockToken" },
53
+ });
54
+ assert.deepEqual(result.data, { id: "123", name: "Mock Device" });
55
+ });
56
+
57
+ it("should call axios for setPowerOn", async () => {
58
+ const mockAxios = {
59
+ put: sinon.stub().resolves({ status: 200 }),
60
+ };
61
+ axiosStub.returns(mockAxios as any);
62
+ const api = configure("https://example.com/api");
63
+ const result = await api.setPowerOn("mockToken", "mockMacAddress");
64
+ assert.ok(mockAxios.put.calledOnce);
65
+ assert.equal(mockAxios.put.firstCall.args[0], "mqtt/command");
66
+ assert.deepEqual(mockAxios.put.firstCall.args[1], {
67
+ mac_address: "mockMacAddress",
68
+ name: "power",
69
+ value: 1,
70
+ });
71
+ assert.deepEqual(mockAxios.put.firstCall.args[2], {
72
+ headers: { Authorization: "Bearer mockToken" },
73
+ });
74
+ assert.equal(result.status, 200);
75
+ });
76
+ });
77
+ });
package/src/library.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { strict as assert } from "assert";
2
+ import { Amplify } from "aws-amplify";
3
+ import * as amplifyAuth from "aws-amplify/auth";
4
+ import axios, { AxiosInstance } from "axios";
5
+ import { DeviceInfoType } from "./types";
6
+ import { API_URL } from "./constants";
7
+
8
+ const amplifyconfiguration = {
9
+ aws_project_region: "eu-central-1",
10
+ aws_user_pools_id: "eu-central-1_BYmQ2VBlo",
11
+ aws_user_pools_web_client_id: "7sc1qltkqobo3ddqsk4542dg2h",
12
+ };
13
+ Amplify.configure(amplifyconfiguration);
14
+
15
+ const headers = (jwtToken: string) => ({ Authorization: `Bearer ${jwtToken}` });
16
+
17
+ /**
18
+ * Sign in to return the JWT token.
19
+ */
20
+ const signIn = async (username: string, password: string): Promise<string> => {
21
+ const { isSignedIn, nextStep } = await amplifyAuth.signIn({
22
+ username,
23
+ password,
24
+ });
25
+ assert.ok(isSignedIn);
26
+ const { tokens } = await amplifyAuth.fetchAuthSession();
27
+ assert.ok(tokens);
28
+ return tokens.accessToken.toString();
29
+ };
30
+
31
+ const deviceInfo =
32
+ (axiosInstance: AxiosInstance) => (jwtToken: string, macAddress: string) =>
33
+ axiosInstance.get<DeviceInfoType>(`device/${macAddress}/info`, {
34
+ headers: headers(jwtToken),
35
+ });
36
+
37
+ const mqttCommand =
38
+ (axiosInstance: AxiosInstance) =>
39
+ (jwtToken: string, macAddress: string, payload: any) =>
40
+ axiosInstance.put(
41
+ "mqtt/command",
42
+ { mac_address: macAddress, ...payload },
43
+ { headers: headers(jwtToken) }
44
+ );
45
+
46
+ const setPower =
47
+ (axiosInstance: AxiosInstance) =>
48
+ (jwtToken: string, macAddress: string, value: number) =>
49
+ mqttCommand(axiosInstance)(jwtToken, macAddress, { name: "power", value });
50
+
51
+ const setPowerOn =
52
+ (axiosInstance: AxiosInstance) => (jwtToken: string, macAddress: string) =>
53
+ setPower(axiosInstance)(jwtToken, macAddress, 1);
54
+ const setPowerOff =
55
+ (axiosInstance: AxiosInstance) => (jwtToken: string, macAddress: string) =>
56
+ setPower(axiosInstance)(jwtToken, macAddress, 0);
57
+
58
+ const configure = (baseURL: string = API_URL) => {
59
+ const axiosInstance = axios.create({ baseURL });
60
+ const deviceInfoInstance = deviceInfo(axiosInstance);
61
+ const setPowerInstance = setPower(axiosInstance);
62
+ const setPowerOffInstance = setPowerOff(axiosInstance);
63
+ const setPowerOnInstance = setPowerOn(axiosInstance);
64
+ return {
65
+ deviceInfo: deviceInfoInstance,
66
+ setPower: setPowerInstance,
67
+ setPowerOff: setPowerOffInstance,
68
+ setPowerOn: setPowerOnInstance,
69
+ };
70
+ };
71
+
72
+ const defaultApi = configure();
73
+
74
+ export { signIn, configure };
package/src/types.ts ADDED
@@ -0,0 +1,36 @@
1
+ interface CommandsType {
2
+ power: boolean;
3
+ }
4
+
5
+ interface TemperaturesType {
6
+ board: number;
7
+ enviroment: number;
8
+ }
9
+
10
+ interface StatusType {
11
+ commands: CommandsType;
12
+ temperatures: TemperaturesType;
13
+ }
14
+
15
+ interface UserParametersType {
16
+ enviroment_1_temperature: number;
17
+ enviroment_2_temperature: number;
18
+ enviroment_3_temperature: number;
19
+ is_auto: boolean;
20
+ is_sound_active: boolean;
21
+ }
22
+
23
+ interface DeviceInfoType {
24
+ status: StatusType;
25
+ nvm: {
26
+ user_parameters: UserParametersType;
27
+ };
28
+ }
29
+
30
+ export type {
31
+ CommandsType,
32
+ DeviceInfoType,
33
+ StatusType,
34
+ TemperaturesType,
35
+ UserParametersType,
36
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "target": "es2015",
5
+ "declaration": true,
6
+ "outDir": "./dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "resolveJsonModule": true,
11
+ "preserveConstEnums": true
12
+ },
13
+ "include": [
14
+ "src/**/*.ts"
15
+ ]
16
+ }
package/dist/package.json DELETED
@@ -1,50 +0,0 @@
1
- {
2
- "name": "edilkamin",
3
- "version": "1.3.0",
4
- "description": "",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
7
- "scripts": {
8
- "cli": "ts-node src/cli.ts",
9
- "cli:debug": "node --inspect --require ts-node/register/transpile-only src/cli.ts",
10
- "test": "mocha --require ts-node/register src/*.test.ts",
11
- "test:debug": "mocha --require ts-node/register/transpile-only --inspect src/*.test.ts",
12
- "lint": "prettier --check src docs .github *.md",
13
- "format": "prettier --write src docs .github *.md",
14
- "build": "tsc"
15
- },
16
- "files": [
17
- "/dist"
18
- ],
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/AndreMiras/edilkamin.js.git"
22
- },
23
- "author": "Andre Miras",
24
- "license": "MIT",
25
- "bugs": {
26
- "url": "https://github.com/AndreMiras/edilkamin.js/issues"
27
- },
28
- "homepage": "https://github.com/AndreMiras/edilkamin.js#readme",
29
- "bin": {
30
- "edilkamin": "dist/cli.js"
31
- },
32
- "dependencies": {
33
- "aws-amplify": "^6.10.0",
34
- "axios": "^0.26.0"
35
- },
36
- "devDependencies": {
37
- "@aws-amplify/cli": "^7.6.21",
38
- "@types/mocha": "^10.0.10",
39
- "@types/sinon": "^17.0.3",
40
- "mocha": "^10.8.2",
41
- "prettier": "^2.5.1",
42
- "sinon": "^19.0.2",
43
- "ts-node": "^10.9.1",
44
- "typedoc": "^0.27.2",
45
- "typescript": "^5.7.2"
46
- },
47
- "optionalDependencies": {
48
- "commander": "^12.1.0"
49
- }
50
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes