node-red-contrib-samsung-smartthings 1.0.0
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 +189 -0
- package/README.md +108 -0
- package/docker-compose.yml +15 -0
- package/examples/basic-device-control.json +182 -0
- package/examples/energy-monitoring.json +187 -0
- package/icons/smartthings.svg +1 -0
- package/lib/smartthings-client.js +187 -0
- package/nodes/command/smartthings-command.html +219 -0
- package/nodes/command/smartthings-command.js +96 -0
- package/nodes/config/icons/smartthings.svg +1 -0
- package/nodes/config/smartthings-config.html +98 -0
- package/nodes/config/smartthings-config.js +144 -0
- package/nodes/devices/smartthings-devices.html +156 -0
- package/nodes/devices/smartthings-devices.js +60 -0
- package/nodes/events/smartthings-events.html +247 -0
- package/nodes/events/smartthings-events.js +227 -0
- package/nodes/locations/smartthings-locations.html +98 -0
- package/nodes/locations/smartthings-locations.js +45 -0
- package/nodes/rooms/smartthings-rooms.html +116 -0
- package/nodes/rooms/smartthings-rooms.js +53 -0
- package/nodes/scenes/smartthings-scenes.html +142 -0
- package/nodes/scenes/smartthings-scenes.js +63 -0
- package/nodes/status/smartthings-status.html +163 -0
- package/nodes/status/smartthings-status.js +61 -0
- package/package.json +55 -0
- package/test/smartthings-client.test.js +270 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const sinon = require('sinon');
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
const { SmartThingsClient, SmartThingsError } = require('../lib/smartthings-client');
|
|
5
|
+
|
|
6
|
+
describe('SmartThingsClient', function () {
|
|
7
|
+
|
|
8
|
+
let client;
|
|
9
|
+
let axiosStub;
|
|
10
|
+
|
|
11
|
+
beforeEach(function () {
|
|
12
|
+
client = new SmartThingsClient('test-token-123', 'https://api.smartthings.com');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(function () {
|
|
16
|
+
sinon.restore();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('constructor', function () {
|
|
20
|
+
it('should set token and default base URL', function () {
|
|
21
|
+
const c = new SmartThingsClient('my-token');
|
|
22
|
+
expect(c.token).to.equal('my-token');
|
|
23
|
+
expect(c.baseUrl).to.equal('https://api.smartthings.com');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should strip trailing slashes from base URL', function () {
|
|
27
|
+
const c = new SmartThingsClient('tk', 'https://example.com/api///');
|
|
28
|
+
expect(c.baseUrl).to.equal('https://example.com/api');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should create axios instance with auth header', function () {
|
|
32
|
+
expect(client.http.defaults.headers['Authorization']).to.equal('Bearer test-token-123');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('getLocations', function () {
|
|
37
|
+
it('should return items array from API response', async function () {
|
|
38
|
+
const locations = [
|
|
39
|
+
{ locationId: 'loc-1', name: 'Home' },
|
|
40
|
+
{ locationId: 'loc-2', name: 'Office' }
|
|
41
|
+
];
|
|
42
|
+
sinon.stub(client.http, 'request').resolves({ data: { items: locations } });
|
|
43
|
+
|
|
44
|
+
const result = await client.getLocations();
|
|
45
|
+
expect(result).to.deep.equal(locations);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return empty array when items is missing', async function () {
|
|
49
|
+
sinon.stub(client.http, 'request').resolves({ data: {} });
|
|
50
|
+
const result = await client.getLocations();
|
|
51
|
+
expect(result).to.deep.equal([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('getLocation', function () {
|
|
56
|
+
it('should fetch a single location', async function () {
|
|
57
|
+
const location = { locationId: 'loc-1', name: 'Home' };
|
|
58
|
+
sinon.stub(client.http, 'request').resolves({ data: location });
|
|
59
|
+
|
|
60
|
+
const result = await client.getLocation('loc-1');
|
|
61
|
+
expect(result).to.deep.equal(location);
|
|
62
|
+
expect(client.http.request.calledOnce).to.be.true;
|
|
63
|
+
expect(client.http.request.firstCall.args[0].url).to.equal('/v1/locations/loc-1');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('getRooms', function () {
|
|
68
|
+
it('should fetch rooms for a location', async function () {
|
|
69
|
+
const rooms = [{ roomId: 'rm-1', name: 'Living Room' }];
|
|
70
|
+
sinon.stub(client.http, 'request').resolves({ data: { items: rooms } });
|
|
71
|
+
|
|
72
|
+
const result = await client.getRooms('loc-1');
|
|
73
|
+
expect(result).to.deep.equal(rooms);
|
|
74
|
+
expect(client.http.request.firstCall.args[0].url).to.equal('/v1/locations/loc-1/rooms');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('getDevices', function () {
|
|
79
|
+
it('should fetch all devices without filters', async function () {
|
|
80
|
+
const devices = [{ deviceId: 'dev-1', label: 'Light' }];
|
|
81
|
+
sinon.stub(client.http, 'request').resolves({ data: { items: devices } });
|
|
82
|
+
|
|
83
|
+
const result = await client.getDevices();
|
|
84
|
+
expect(result).to.deep.equal(devices);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should pass locationId and capability as params', async function () {
|
|
88
|
+
sinon.stub(client.http, 'request').resolves({ data: { items: [] } });
|
|
89
|
+
|
|
90
|
+
await client.getDevices({ locationId: 'loc-1', capability: 'switch' });
|
|
91
|
+
const params = client.http.request.firstCall.args[0].params;
|
|
92
|
+
expect(params.locationId).to.equal('loc-1');
|
|
93
|
+
expect(params.capability).to.equal('switch');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should filter by roomId client-side', async function () {
|
|
97
|
+
const devices = [
|
|
98
|
+
{ deviceId: 'dev-1', roomId: 'rm-1' },
|
|
99
|
+
{ deviceId: 'dev-2', roomId: 'rm-2' },
|
|
100
|
+
{ deviceId: 'dev-3', roomId: 'rm-1' }
|
|
101
|
+
];
|
|
102
|
+
sinon.stub(client.http, 'request').resolves({ data: { items: devices } });
|
|
103
|
+
|
|
104
|
+
const result = await client.getDevices({ roomId: 'rm-1' });
|
|
105
|
+
expect(result).to.have.lengthOf(2);
|
|
106
|
+
expect(result[0].deviceId).to.equal('dev-1');
|
|
107
|
+
expect(result[1].deviceId).to.equal('dev-3');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('getDeviceStatus', function () {
|
|
112
|
+
it('should fetch full device status', async function () {
|
|
113
|
+
const status = { components: { main: { switch: { switch: { value: 'on' } } } } };
|
|
114
|
+
sinon.stub(client.http, 'request').resolves({ data: status });
|
|
115
|
+
|
|
116
|
+
const result = await client.getDeviceStatus('dev-1');
|
|
117
|
+
expect(result).to.deep.equal(status);
|
|
118
|
+
expect(client.http.request.firstCall.args[0].url).to.equal('/v1/devices/dev-1/status');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('getDeviceCapabilityStatus', function () {
|
|
123
|
+
it('should fetch specific capability status', async function () {
|
|
124
|
+
const status = { energy: { value: 142.5, unit: 'kWh' } };
|
|
125
|
+
sinon.stub(client.http, 'request').resolves({ data: status });
|
|
126
|
+
|
|
127
|
+
const result = await client.getDeviceCapabilityStatus('dev-1', 'main', 'energyMeter');
|
|
128
|
+
expect(result).to.deep.equal(status);
|
|
129
|
+
expect(client.http.request.firstCall.args[0].url)
|
|
130
|
+
.to.equal('/v1/devices/dev-1/components/main/capabilities/energyMeter/status');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('executeCommands', function () {
|
|
135
|
+
it('should send commands to a device', async function () {
|
|
136
|
+
sinon.stub(client.http, 'request').resolves({ data: {} });
|
|
137
|
+
|
|
138
|
+
const cmd = { component: 'main', capability: 'switch', command: 'on' };
|
|
139
|
+
await client.executeCommands('dev-1', cmd);
|
|
140
|
+
|
|
141
|
+
const callArgs = client.http.request.firstCall.args[0];
|
|
142
|
+
expect(callArgs.method).to.equal('POST');
|
|
143
|
+
expect(callArgs.url).to.equal('/v1/devices/dev-1/commands');
|
|
144
|
+
expect(callArgs.data.commands).to.deep.equal([cmd]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should wrap array commands correctly', async function () {
|
|
148
|
+
sinon.stub(client.http, 'request').resolves({ data: {} });
|
|
149
|
+
|
|
150
|
+
const cmds = [
|
|
151
|
+
{ component: 'main', capability: 'switch', command: 'on' },
|
|
152
|
+
{ component: 'main', capability: 'switchLevel', command: 'setLevel', arguments: [50] }
|
|
153
|
+
];
|
|
154
|
+
await client.executeCommands('dev-1', cmds);
|
|
155
|
+
|
|
156
|
+
const callArgs = client.http.request.firstCall.args[0];
|
|
157
|
+
expect(callArgs.data.commands).to.deep.equal(cmds);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('getScenes', function () {
|
|
162
|
+
it('should fetch scenes', async function () {
|
|
163
|
+
const scenes = [{ sceneId: 'sc-1', sceneName: 'Good Night' }];
|
|
164
|
+
sinon.stub(client.http, 'request').resolves({ data: { items: scenes } });
|
|
165
|
+
|
|
166
|
+
const result = await client.getScenes();
|
|
167
|
+
expect(result).to.deep.equal(scenes);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should pass locationId param when provided', async function () {
|
|
171
|
+
sinon.stub(client.http, 'request').resolves({ data: { items: [] } });
|
|
172
|
+
|
|
173
|
+
await client.getScenes('loc-1');
|
|
174
|
+
expect(client.http.request.firstCall.args[0].params.locationId).to.equal('loc-1');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('executeScene', function () {
|
|
179
|
+
it('should POST to execute a scene', async function () {
|
|
180
|
+
sinon.stub(client.http, 'request').resolves({ data: { status: 'success' } });
|
|
181
|
+
|
|
182
|
+
const result = await client.executeScene('sc-1');
|
|
183
|
+
expect(result.status).to.equal('success');
|
|
184
|
+
expect(client.http.request.firstCall.args[0].method).to.equal('POST');
|
|
185
|
+
expect(client.http.request.firstCall.args[0].url).to.equal('/v1/scenes/sc-1/execute');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('testConnection', function () {
|
|
190
|
+
it('should return true on successful connection', async function () {
|
|
191
|
+
sinon.stub(client.http, 'request').resolves({ data: { items: [] } });
|
|
192
|
+
const result = await client.testConnection();
|
|
193
|
+
expect(result).to.be.true;
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('error handling', function () {
|
|
198
|
+
it('should throw SmartThingsError on API error', async function () {
|
|
199
|
+
sinon.stub(client.http, 'request').rejects({
|
|
200
|
+
response: {
|
|
201
|
+
status: 403,
|
|
202
|
+
statusText: 'Forbidden',
|
|
203
|
+
data: { message: 'Insufficient permissions' },
|
|
204
|
+
headers: {}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await client.getLocations();
|
|
210
|
+
expect.fail('Should have thrown');
|
|
211
|
+
} catch (err) {
|
|
212
|
+
expect(err).to.be.instanceOf(SmartThingsError);
|
|
213
|
+
expect(err.statusCode).to.equal(403);
|
|
214
|
+
expect(err.message).to.contain('Insufficient permissions');
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should throw SmartThingsError on network error', async function () {
|
|
219
|
+
sinon.stub(client.http, 'request').rejects(new Error('Network Error'));
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await client.getLocations();
|
|
223
|
+
expect.fail('Should have thrown');
|
|
224
|
+
} catch (err) {
|
|
225
|
+
expect(err).to.be.instanceOf(SmartThingsError);
|
|
226
|
+
expect(err.message).to.contain('Network Error');
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('retry logic', function () {
|
|
232
|
+
it('should retry on 500 errors up to MAX_RETRIES', async function () {
|
|
233
|
+
this.timeout(15000);
|
|
234
|
+
const stub = sinon.stub(client.http, 'request');
|
|
235
|
+
stub.onFirstCall().rejects({
|
|
236
|
+
response: { status: 500, statusText: 'Internal Server Error', data: {}, headers: {} }
|
|
237
|
+
});
|
|
238
|
+
stub.onSecondCall().rejects({
|
|
239
|
+
response: { status: 500, statusText: 'Internal Server Error', data: {}, headers: {} }
|
|
240
|
+
});
|
|
241
|
+
stub.onThirdCall().resolves({ data: { items: [{ locationId: 'loc-1' }] } });
|
|
242
|
+
|
|
243
|
+
sinon.stub(client, '_sleep').resolves();
|
|
244
|
+
|
|
245
|
+
const result = await client.getLocations();
|
|
246
|
+
expect(result).to.have.lengthOf(1);
|
|
247
|
+
expect(stub.callCount).to.equal(3);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should fail after MAX_RETRIES exceeded', async function () {
|
|
251
|
+
this.timeout(15000);
|
|
252
|
+
const stub = sinon.stub(client.http, 'request');
|
|
253
|
+
for (let i = 0; i < 4; i++) {
|
|
254
|
+
stub.onCall(i).rejects({
|
|
255
|
+
response: { status: 500, statusText: 'Internal Server Error', data: {}, headers: {} }
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
sinon.stub(client, '_sleep').resolves();
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await client.getLocations();
|
|
263
|
+
expect.fail('Should have thrown');
|
|
264
|
+
} catch (err) {
|
|
265
|
+
expect(err).to.be.instanceOf(SmartThingsError);
|
|
266
|
+
expect(err.statusCode).to.equal(500);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|