node-red-contrib-alice 2.3.6 → 3.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/nodes/alice-device.html +3 -0
- package/nodes/alice.js +192 -47
- package/package.json +3 -2
- package/src/alice-device.html +3 -0
- package/src/alice.ts +235 -47
- package/.claude/settings.local.json +0 -11
- package/CLAUDE.md +0 -54
package/nodes/alice-device.html
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
{
|
|
18
18
|
options: [
|
|
19
19
|
{ value: "devices.types.light", label: "Light" },
|
|
20
|
+
{ value: "devices.types.light.lamp", label: "Light Lamp" },
|
|
20
21
|
{ value: "devices.types.light.ceiling", label: "Light Сeiling" },
|
|
21
22
|
{ value: "devices.types.light.strip", label: "Light Strip" },
|
|
22
23
|
{ value: "devices.types.socket", label: "Socket" },
|
|
23
24
|
{ value: "devices.types.switch", label: "Switch" },
|
|
25
|
+
{ value: "devices.types.switch.relay", label: "Relay" },
|
|
24
26
|
{ value: "devices.types.thermostat", label: "Thermostat" },
|
|
25
27
|
{ value: "devices.types.thermostat.ac", label: "Air conditioning" },
|
|
26
28
|
{ value: "devices.types.media_device", label: "Multimedia" },
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
{ value: "devices.types.openable", label: "Door, gate, window, shutters" },
|
|
36
38
|
{ value: "devices.types.openable.curtain", label: "Curtains, blinds" },
|
|
37
39
|
{ value: "devices.types.openable.valve", label: "Valve (ball valve)" },
|
|
40
|
+
{ value: "devices.types.openable.door_lock", label: "Door lock" },
|
|
38
41
|
{ value: "devices.types.humidifier", label: "Humidifier" },
|
|
39
42
|
{ value: "devices.types.purifier", label: "Air purifier" },
|
|
40
43
|
{ value: "devices.types.ventilation", label: "Ventilation" },
|
package/nodes/alice.js
CHANGED
|
@@ -3,14 +3,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
const axios_1 = __importDefault(require("axios"));
|
|
6
|
-
const
|
|
6
|
+
const ws_1 = __importDefault(require("ws"));
|
|
7
|
+
const WS_URL = process.env.ALICE_WS_URL || "wss://ws.nodered-home.ru/v1/ws";
|
|
8
|
+
const RECONNECT_MIN_MS = 1000;
|
|
9
|
+
const RECONNECT_MAX_MS = 60000;
|
|
10
|
+
const RECONNECT_JITTER_MS = 10000;
|
|
11
|
+
const WATCHDOG_TIMEOUT_MS = 90000;
|
|
12
|
+
const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
13
|
+
const CLOSE_FAILSAFE_MS = 1500;
|
|
7
14
|
module.exports = (RED) => {
|
|
8
15
|
function AliceService(config) {
|
|
9
16
|
RED.nodes.createNode(this, config);
|
|
10
17
|
this.debug("Starting Alice service... ID: " + this.id);
|
|
11
18
|
const email = this.credentials.email;
|
|
12
|
-
const login = this.credentials.id;
|
|
13
|
-
const password = this.credentials.password;
|
|
14
19
|
const token = this.credentials.token;
|
|
15
20
|
const suburl = Buffer.from(email).toString('base64');
|
|
16
21
|
RED.httpAdmin.get("/noderedhome/" + suburl + "/clearalldevice", (_req, res) => {
|
|
@@ -55,49 +60,146 @@ module.exports = (RED) => {
|
|
|
55
60
|
this.error("Authentication is required!!!");
|
|
56
61
|
return;
|
|
57
62
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
let accessToken;
|
|
64
|
+
try {
|
|
65
|
+
accessToken = JSON.parse(token).access_token;
|
|
66
|
+
if (!accessToken) {
|
|
67
|
+
throw new Error("access_token is empty");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (_err) {
|
|
71
|
+
this.error("Authentication token is corrupted, please re-authenticate in node settings!!!");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let ws = null;
|
|
75
|
+
let closing = false;
|
|
76
|
+
let reconnectDelay = RECONNECT_MIN_MS;
|
|
77
|
+
let reconnectTimer = null;
|
|
78
|
+
let watchdogTimer = null;
|
|
79
|
+
const stopWatchdog = () => {
|
|
80
|
+
if (watchdogTimer) {
|
|
81
|
+
clearTimeout(watchdogTimer);
|
|
82
|
+
watchdogTimer = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const resetWatchdog = () => {
|
|
86
|
+
stopWatchdog();
|
|
87
|
+
watchdogTimer = setTimeout(() => {
|
|
88
|
+
watchdogTimer = null;
|
|
89
|
+
this.debug("WS watchdog: no activity for " + WATCHDOG_TIMEOUT_MS + "ms, terminating connection");
|
|
90
|
+
if (ws) {
|
|
91
|
+
ws.terminate();
|
|
92
|
+
}
|
|
93
|
+
}, WATCHDOG_TIMEOUT_MS);
|
|
94
|
+
};
|
|
95
|
+
const scheduleReconnect = () => {
|
|
96
|
+
if (closing || reconnectTimer) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const delay = reconnectDelay + Math.floor(Math.random() * RECONNECT_JITTER_MS);
|
|
100
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
101
|
+
this.debug("WS reconnect scheduled in " + delay + "ms");
|
|
102
|
+
reconnectTimer = setTimeout(() => {
|
|
103
|
+
reconnectTimer = null;
|
|
104
|
+
connect();
|
|
105
|
+
}, delay);
|
|
106
|
+
};
|
|
107
|
+
const connect = () => {
|
|
108
|
+
if (closing) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.debug("Connecting to WS gateway: " + WS_URL);
|
|
112
|
+
const socket = new ws_1.default(WS_URL, {
|
|
113
|
+
headers: { Authorization: "Bearer " + accessToken },
|
|
114
|
+
handshakeTimeout: HANDSHAKE_TIMEOUT_MS
|
|
115
|
+
});
|
|
116
|
+
ws = socket;
|
|
117
|
+
socket.on("open", () => {
|
|
118
|
+
this.debug("WS connection opened, waiting for hello");
|
|
119
|
+
resetWatchdog();
|
|
120
|
+
});
|
|
121
|
+
socket.on("ping", () => {
|
|
122
|
+
resetWatchdog();
|
|
123
|
+
});
|
|
124
|
+
socket.on("message", (raw) => {
|
|
125
|
+
resetWatchdog();
|
|
126
|
+
let frame;
|
|
127
|
+
try {
|
|
128
|
+
frame = JSON.parse(raw.toString());
|
|
129
|
+
}
|
|
130
|
+
catch (_err) {
|
|
131
|
+
this.warn("WS: invalid JSON frame ignored");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!frame || typeof frame !== "object") {
|
|
135
|
+
this.warn("WS: unexpected frame ignored");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
switch (frame.type) {
|
|
139
|
+
case "hello":
|
|
140
|
+
this.debug("WS gateway connected (hello received)");
|
|
141
|
+
reconnectDelay = RECONNECT_MIN_MS;
|
|
142
|
+
this.emit('online');
|
|
143
|
+
break;
|
|
144
|
+
case "command":
|
|
145
|
+
this.trace("Incoming command devId:" + frame.devId + " timestamp:" + new Date().getTime());
|
|
146
|
+
if (!Array.isArray(frame.payload)) {
|
|
147
|
+
this.warn("WS: command frame without payload array ignored. devId:" + frame.devId);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (frame.devId) {
|
|
151
|
+
try {
|
|
152
|
+
this.emit(String(frame.devId), frame.payload);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
this.warn("WS: command listener error: " + (err instanceof Error ? err.message : String(err)));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
case "message":
|
|
160
|
+
if (typeof frame.text === "string") {
|
|
161
|
+
this.warn(frame.text);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
this.trace("WS: message frame without text ignored");
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
this.trace("WS: unknown frame type ignored: " + frame.type);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
socket.on("unexpected-response", (req, res) => {
|
|
172
|
+
stopWatchdog();
|
|
173
|
+
if (res.statusCode === 401) {
|
|
174
|
+
this.error("WS gateway rejected token (401): токен недействителен, переавторизуйтесь в настройках ноды");
|
|
73
175
|
}
|
|
74
176
|
else {
|
|
75
|
-
this.
|
|
177
|
+
this.debug("WS unexpected server response: HTTP " + res.statusCode);
|
|
76
178
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.debug("Yandex IOT client subscribed to the command");
|
|
179
|
+
req.destroy();
|
|
180
|
+
if (ws === socket) {
|
|
181
|
+
ws = null;
|
|
182
|
+
}
|
|
183
|
+
this.emit('offline');
|
|
184
|
+
scheduleReconnect();
|
|
84
185
|
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
186
|
+
socket.on("error", (err) => {
|
|
187
|
+
this.debug("WS error: " + err.message);
|
|
188
|
+
});
|
|
189
|
+
socket.on("close", (code, reason) => {
|
|
190
|
+
stopWatchdog();
|
|
191
|
+
if (code === 4429) {
|
|
192
|
+
this.warn("WS gateway closed connection (4429): превышен лимит подключений — не более 5 на пользователя");
|
|
193
|
+
}
|
|
194
|
+
this.debug("WS connection closed. code:" + code + " reason:" + reason.toString());
|
|
195
|
+
if (ws === socket) {
|
|
196
|
+
ws = null;
|
|
197
|
+
}
|
|
198
|
+
this.emit('offline');
|
|
199
|
+
scheduleReconnect();
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
connect();
|
|
101
203
|
this.on('offline', () => {
|
|
102
204
|
this.isOnline = false;
|
|
103
205
|
});
|
|
@@ -105,17 +207,60 @@ module.exports = (RED) => {
|
|
|
105
207
|
this.isOnline = true;
|
|
106
208
|
});
|
|
107
209
|
this.on('close', (done) => {
|
|
210
|
+
closing = true;
|
|
211
|
+
if (reconnectTimer) {
|
|
212
|
+
clearTimeout(reconnectTimer);
|
|
213
|
+
reconnectTimer = null;
|
|
214
|
+
}
|
|
215
|
+
stopWatchdog();
|
|
108
216
|
this.emit('offline');
|
|
217
|
+
const socket = ws;
|
|
218
|
+
ws = null;
|
|
219
|
+
if (!socket || socket.readyState === ws_1.default.CLOSED) {
|
|
220
|
+
done();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
let doneCalled = false;
|
|
224
|
+
const finish = () => {
|
|
225
|
+
if (!doneCalled) {
|
|
226
|
+
doneCalled = true;
|
|
227
|
+
done();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
socket.once('close', finish);
|
|
231
|
+
try {
|
|
232
|
+
socket.close(1000);
|
|
233
|
+
}
|
|
234
|
+
catch (_err) {
|
|
235
|
+
socket.terminate();
|
|
236
|
+
}
|
|
109
237
|
setTimeout(() => {
|
|
110
|
-
|
|
111
|
-
|
|
238
|
+
if (socket.readyState !== ws_1.default.CLOSED) {
|
|
239
|
+
socket.terminate();
|
|
240
|
+
}
|
|
241
|
+
finish();
|
|
242
|
+
}, CLOSE_FAILSAFE_MS).unref();
|
|
112
243
|
});
|
|
113
|
-
this.send2gate = (path, data,
|
|
244
|
+
this.send2gate = (path, data, _retain) => {
|
|
114
245
|
this.trace("Outgoing: " + path);
|
|
115
|
-
|
|
246
|
+
const arrPath = path.split('/');
|
|
247
|
+
const devId = arrPath[arrPath.length - 1];
|
|
248
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
249
|
+
this.debug("WS not connected, command_result dropped. devId:" + devId);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
let payload;
|
|
253
|
+
try {
|
|
254
|
+
payload = JSON.parse(data);
|
|
255
|
+
}
|
|
256
|
+
catch (_err) {
|
|
257
|
+
this.warn("send2gate: invalid JSON data ignored. devId:" + devId);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
ws.send(JSON.stringify({ v: 1, type: "command_result", devId: devId, payload: payload }));
|
|
116
261
|
};
|
|
117
262
|
this.getToken = () => {
|
|
118
|
-
return
|
|
263
|
+
return accessToken;
|
|
119
264
|
};
|
|
120
265
|
}
|
|
121
266
|
RED.nodes.registerType("alice-service", AliceService, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-alice",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"start": "npm run build && node-red",
|
|
@@ -44,11 +44,12 @@
|
|
|
44
44
|
"homepage": "https://github.com/efa2000/node-red-contrib-alice#readme",
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"axios": "^1.4.0",
|
|
47
|
-
"
|
|
47
|
+
"ws": "^8.18.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^20.11.16",
|
|
51
51
|
"@types/node-red": "^1.3.4",
|
|
52
|
+
"@types/ws": "^8.5.12",
|
|
52
53
|
"nodemon": "^3.0.3",
|
|
53
54
|
"typescript": "^5.3.3"
|
|
54
55
|
},
|
package/src/alice-device.html
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
{
|
|
18
18
|
options: [
|
|
19
19
|
{ value: "devices.types.light", label: "Light" },
|
|
20
|
+
{ value: "devices.types.light.lamp", label: "Light Lamp" },
|
|
20
21
|
{ value: "devices.types.light.ceiling", label: "Light Сeiling" },
|
|
21
22
|
{ value: "devices.types.light.strip", label: "Light Strip" },
|
|
22
23
|
{ value: "devices.types.socket", label: "Socket" },
|
|
23
24
|
{ value: "devices.types.switch", label: "Switch" },
|
|
25
|
+
{ value: "devices.types.switch.relay", label: "Relay" },
|
|
24
26
|
{ value: "devices.types.thermostat", label: "Thermostat" },
|
|
25
27
|
{ value: "devices.types.thermostat.ac", label: "Air conditioning" },
|
|
26
28
|
{ value: "devices.types.media_device", label: "Multimedia" },
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
{ value: "devices.types.openable", label: "Door, gate, window, shutters" },
|
|
36
38
|
{ value: "devices.types.openable.curtain", label: "Curtains, blinds" },
|
|
37
39
|
{ value: "devices.types.openable.valve", label: "Valve (ball valve)" },
|
|
40
|
+
{ value: "devices.types.openable.door_lock", label: "Door lock" },
|
|
38
41
|
{ value: "devices.types.humidifier", label: "Humidifier" },
|
|
39
42
|
{ value: "devices.types.purifier", label: "Air purifier" },
|
|
40
43
|
{ value: "devices.types.ventilation", label: "Ventilation" },
|
package/src/alice.ts
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import { NodeAPI } from "node-red";
|
|
2
2
|
import axios from "axios";
|
|
3
|
-
import
|
|
3
|
+
import WebSocket from "ws";
|
|
4
4
|
import { AliceServiceConfig, AliceServiceNode } from "./types.js";
|
|
5
5
|
|
|
6
|
+
// Адрес WebSocket-шлюза. Для отладки и тестов можно переопределить
|
|
7
|
+
// через переменную окружения ALICE_WS_URL.
|
|
8
|
+
const WS_URL = process.env.ALICE_WS_URL || "wss://ws.nodered-home.ru/v1/ws";
|
|
9
|
+
|
|
10
|
+
// Реконнект: экспоненциальный backoff от 1 до 60 секунд (×2) + джиттер 0–10 секунд
|
|
11
|
+
const RECONNECT_MIN_MS = 1000;
|
|
12
|
+
const RECONNECT_MAX_MS = 60000;
|
|
13
|
+
const RECONNECT_JITTER_MS = 10000;
|
|
14
|
+
|
|
15
|
+
// Watchdog: сервер шлёт ping каждые 30 секунд; если ~90 секунд нет ни ping,
|
|
16
|
+
// ни сообщений — считаем соединение мёртвым и рвём его сами
|
|
17
|
+
const WATCHDOG_TIMEOUT_MS = 90000;
|
|
18
|
+
|
|
19
|
+
// Таймаут WS-хендшейка: если сервер принял TCP, но не ответил на upgrade,
|
|
20
|
+
// библиотека ws сама эмитит error+close — дальше штатный реконнект
|
|
21
|
+
const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
22
|
+
|
|
23
|
+
// Failsafe при закрытии ноды: если сервер не завершил close-хендшейк
|
|
24
|
+
// за это время — рвём сокет и отпускаем done()
|
|
25
|
+
const CLOSE_FAILSAFE_MS = 1500;
|
|
26
|
+
|
|
27
|
+
// Кадр протокола WS-шлюза (JSON text frame)
|
|
28
|
+
interface GateFrame {
|
|
29
|
+
v?: number;
|
|
30
|
+
type?: string;
|
|
31
|
+
reqId?: string;
|
|
32
|
+
devId?: string;
|
|
33
|
+
payload?: unknown;
|
|
34
|
+
text?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
6
37
|
export = (RED: NodeAPI): void => {
|
|
7
38
|
function AliceService(this: AliceServiceNode, config: AliceServiceConfig): void {
|
|
8
39
|
RED.nodes.createNode(this, config);
|
|
9
40
|
this.debug("Starting Alice service... ID: " + this.id);
|
|
10
41
|
|
|
11
42
|
const email = this.credentials.email;
|
|
12
|
-
const login = this.credentials.id;
|
|
13
|
-
const password = this.credentials.password;
|
|
14
43
|
const token = this.credentials.token;
|
|
15
44
|
|
|
16
45
|
//вызов для удаления всех устройств
|
|
@@ -61,54 +90,166 @@ export = (RED: NodeAPI): void => {
|
|
|
61
90
|
return;
|
|
62
91
|
}
|
|
63
92
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
})
|
|
93
|
+
// Токен хранится как JSON-строка OAuth-ответа Яндекса
|
|
94
|
+
let accessToken: string;
|
|
95
|
+
try {
|
|
96
|
+
accessToken = JSON.parse(token).access_token;
|
|
97
|
+
if (!accessToken) {
|
|
98
|
+
throw new Error("access_token is empty");
|
|
99
|
+
}
|
|
100
|
+
} catch (_err) {
|
|
101
|
+
this.error("Authentication token is corrupted, please re-authenticate in node settings!!!");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
72
104
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
105
|
+
// ---------- WebSocket-транспорт (замена MQTT / Yandex IoT Core) ----------
|
|
106
|
+
|
|
107
|
+
let ws: WebSocket | null = null; // текущее соединение
|
|
108
|
+
let closing = false; // нода закрывается — реконнект запрещён
|
|
109
|
+
let reconnectDelay = RECONNECT_MIN_MS; // текущая база backoff
|
|
110
|
+
let reconnectTimer: NodeJS.Timeout | null = null;
|
|
111
|
+
let watchdogTimer: NodeJS.Timeout | null = null;
|
|
112
|
+
|
|
113
|
+
const stopWatchdog = () => {
|
|
114
|
+
if (watchdogTimer) {
|
|
115
|
+
clearTimeout(watchdogTimer);
|
|
116
|
+
watchdogTimer = null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Любая активность от сервера (ping или сообщение) сбрасывает watchdog
|
|
121
|
+
const resetWatchdog = () => {
|
|
122
|
+
stopWatchdog();
|
|
123
|
+
watchdogTimer = setTimeout(() => {
|
|
124
|
+
watchdogTimer = null;
|
|
125
|
+
this.debug("WS watchdog: no activity for " + WATCHDOG_TIMEOUT_MS + "ms, terminating connection");
|
|
126
|
+
if (ws) {
|
|
127
|
+
ws.terminate(); // событие close запустит обычный реконнект
|
|
82
128
|
}
|
|
129
|
+
}, WATCHDOG_TIMEOUT_MS);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const scheduleReconnect = () => {
|
|
133
|
+
if (closing || reconnectTimer) {
|
|
134
|
+
return;
|
|
83
135
|
}
|
|
84
|
-
|
|
136
|
+
const delay = reconnectDelay + Math.floor(Math.random() * RECONNECT_JITTER_MS);
|
|
137
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
138
|
+
this.debug("WS reconnect scheduled in " + delay + "ms");
|
|
139
|
+
reconnectTimer = setTimeout(() => {
|
|
140
|
+
reconnectTimer = null;
|
|
141
|
+
connect();
|
|
142
|
+
}, delay);
|
|
143
|
+
};
|
|
85
144
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
145
|
+
const connect = () => {
|
|
146
|
+
if (closing) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.debug("Connecting to WS gateway: " + WS_URL);
|
|
150
|
+
const socket = new WebSocket(WS_URL, {
|
|
151
|
+
headers: { Authorization: "Bearer " + accessToken },
|
|
152
|
+
handshakeTimeout: HANDSHAKE_TIMEOUT_MS
|
|
91
153
|
});
|
|
92
|
-
|
|
154
|
+
ws = socket;
|
|
93
155
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
socket.on("open", () => {
|
|
157
|
+
// online эмитим не здесь, а по кадру hello: сервер может закрыть
|
|
158
|
+
// уже открытое соединение кодом 4429 (превышен лимит подключений)
|
|
159
|
+
this.debug("WS connection opened, waiting for hello");
|
|
160
|
+
resetWatchdog();
|
|
161
|
+
});
|
|
98
162
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
163
|
+
// Стандартный WS ping от сервера (pong библиотека ws шлёт сама)
|
|
164
|
+
socket.on("ping", () => {
|
|
165
|
+
resetWatchdog();
|
|
166
|
+
});
|
|
103
167
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
168
|
+
socket.on("message", (raw) => {
|
|
169
|
+
resetWatchdog();
|
|
170
|
+
let frame: GateFrame;
|
|
171
|
+
try {
|
|
172
|
+
frame = JSON.parse(raw.toString());
|
|
173
|
+
} catch (_err) {
|
|
174
|
+
this.warn("WS: invalid JSON frame ignored");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!frame || typeof frame !== "object") {
|
|
178
|
+
this.warn("WS: unexpected frame ignored");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
switch (frame.type) {
|
|
182
|
+
case "hello":
|
|
183
|
+
// Подтверждение авторизации — только теперь считаем себя онлайн
|
|
184
|
+
this.debug("WS gateway connected (hello received)");
|
|
185
|
+
reconnectDelay = RECONNECT_MIN_MS; // успешный коннект — сбрасываем backoff
|
|
186
|
+
this.emit('online');
|
|
187
|
+
break;
|
|
188
|
+
case "command":
|
|
189
|
+
// payload — массив capability-объектов, как раньше в MQTT-топике команд
|
|
190
|
+
this.trace("Incoming command devId:" + frame.devId + " timestamp:" + new Date().getTime());
|
|
191
|
+
if (!Array.isArray(frame.payload)) {
|
|
192
|
+
this.warn("WS: command frame without payload array ignored. devId:" + frame.devId);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (frame.devId) {
|
|
196
|
+
// исключение в слушателе не должно убивать обработку кадров на сокете
|
|
197
|
+
try {
|
|
198
|
+
this.emit(String(frame.devId), frame.payload);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
this.warn("WS: command listener error: " + (err instanceof Error ? err.message : String(err)));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
case "message":
|
|
205
|
+
// Служебное сообщение от шлюза (замена message-топика)
|
|
206
|
+
if (typeof frame.text === "string") {
|
|
207
|
+
this.warn(frame.text);
|
|
208
|
+
} else {
|
|
209
|
+
this.trace("WS: message frame without text ignored");
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
default:
|
|
213
|
+
this.trace("WS: unknown frame type ignored: " + frame.type);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
107
216
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
217
|
+
// Сервер отверг подключение на этапе upgrade (не 101)
|
|
218
|
+
socket.on("unexpected-response", (req, res) => {
|
|
219
|
+
stopWatchdog();
|
|
220
|
+
if (res.statusCode === 401) {
|
|
221
|
+
this.error("WS gateway rejected token (401): токен недействителен, переавторизуйтесь в настройках ноды");
|
|
222
|
+
} else {
|
|
223
|
+
this.debug("WS unexpected server response: HTTP " + res.statusCode);
|
|
224
|
+
}
|
|
225
|
+
req.destroy();
|
|
226
|
+
if (ws === socket) {
|
|
227
|
+
ws = null;
|
|
228
|
+
}
|
|
229
|
+
this.emit('offline');
|
|
230
|
+
scheduleReconnect(); // backoff не сбрасываем — продолжаем попытки
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
socket.on("error", (err) => {
|
|
234
|
+
// за error всегда следует close — реконнект планируется там
|
|
235
|
+
this.debug("WS error: " + err.message);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
socket.on("close", (code, reason) => {
|
|
239
|
+
stopWatchdog();
|
|
240
|
+
if (code === 4429) {
|
|
241
|
+
this.warn("WS gateway closed connection (4429): превышен лимит подключений — не более 5 на пользователя");
|
|
242
|
+
}
|
|
243
|
+
this.debug("WS connection closed. code:" + code + " reason:" + reason.toString());
|
|
244
|
+
if (ws === socket) {
|
|
245
|
+
ws = null;
|
|
246
|
+
}
|
|
247
|
+
this.emit('offline');
|
|
248
|
+
scheduleReconnect();
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
connect();
|
|
112
253
|
|
|
113
254
|
this.on('offline', () => {
|
|
114
255
|
this.isOnline = false;
|
|
@@ -119,19 +260,66 @@ export = (RED: NodeAPI): void => {
|
|
|
119
260
|
});
|
|
120
261
|
|
|
121
262
|
this.on('close', (done: () => void) => {
|
|
263
|
+
closing = true;
|
|
264
|
+
if (reconnectTimer) {
|
|
265
|
+
clearTimeout(reconnectTimer);
|
|
266
|
+
reconnectTimer = null;
|
|
267
|
+
}
|
|
268
|
+
stopWatchdog();
|
|
122
269
|
this.emit('offline');
|
|
270
|
+
const socket = ws;
|
|
271
|
+
ws = null;
|
|
272
|
+
if (!socket || socket.readyState === WebSocket.CLOSED) {
|
|
273
|
+
done();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// done() зовём по фактическому закрытию сокета: иначе при redeploy новая
|
|
277
|
+
// нода коннектится раньше, чем сервер освободил слот, и ловит 4429
|
|
278
|
+
let doneCalled = false;
|
|
279
|
+
const finish = () => {
|
|
280
|
+
if (!doneCalled) {
|
|
281
|
+
doneCalled = true;
|
|
282
|
+
done();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
socket.once('close', finish);
|
|
286
|
+
try {
|
|
287
|
+
socket.close(1000);
|
|
288
|
+
} catch (_err) {
|
|
289
|
+
socket.terminate();
|
|
290
|
+
}
|
|
291
|
+
// страховка: если сервер не завершил close-хендшейк — рвём сокет и отпускаем done
|
|
123
292
|
setTimeout(() => {
|
|
124
|
-
|
|
125
|
-
|
|
293
|
+
if (socket.readyState !== WebSocket.CLOSED) {
|
|
294
|
+
socket.terminate();
|
|
295
|
+
}
|
|
296
|
+
finish();
|
|
297
|
+
}, CLOSE_FAILSAFE_MS).unref();
|
|
126
298
|
});
|
|
127
299
|
|
|
128
|
-
|
|
300
|
+
// Сигнатура сохранена для совместимости с alice-device:
|
|
301
|
+
// path — старый MQTT-топик ($me/device/events/{devId}), retain не используется
|
|
302
|
+
this.send2gate = (path: string, data: string, _retain: boolean) => {
|
|
129
303
|
this.trace("Outgoing: " + path);
|
|
130
|
-
|
|
304
|
+
const arrPath = path.split('/');
|
|
305
|
+
const devId = arrPath[arrPath.length - 1];
|
|
306
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
307
|
+
// эквивалент MQTT publish с qos 0 в офлайне — молча дропаем
|
|
308
|
+
this.debug("WS not connected, command_result dropped. devId:" + devId);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
let payload: unknown;
|
|
312
|
+
try {
|
|
313
|
+
payload = JSON.parse(data);
|
|
314
|
+
} catch (_err) {
|
|
315
|
+
this.warn("send2gate: invalid JSON data ignored. devId:" + devId);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
ws.send(JSON.stringify({ v: 1, type: "command_result", devId: devId, payload: payload }));
|
|
131
319
|
};
|
|
132
320
|
|
|
133
321
|
this.getToken = () => {
|
|
134
|
-
return
|
|
322
|
+
return accessToken;
|
|
135
323
|
};
|
|
136
324
|
}
|
|
137
325
|
|
package/CLAUDE.md
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
Этот файл содержит руководство для Claude Code (claude.ai/code) при работе с кодом в данном репозитории.
|
|
4
|
-
|
|
5
|
-
## Обзор проекта
|
|
6
|
-
|
|
7
|
-
Плагин для Node-RED (`node-red-contrib-alice`), интегрирующий IoT-устройства, управляемые через Node-RED, с голосовым помощником Яндекс Алиса для управления умным домом. Опубликован в npm как `node-red-contrib-alice`.
|
|
8
|
-
|
|
9
|
-
## Команды сборки и разработки
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npm run build # Компиляция TypeScript (tsc) + копирование HTML из src/ в nodes/
|
|
13
|
-
npm start # Сборка и запуск Node-RED
|
|
14
|
-
npm run copy-html # Копирование src/*.html в nodes/
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Все исходники (`.ts` + `.html`) находятся в `src/`. Папка `nodes/` — только скомпилированный вывод (JS + скопированные HTML). При `npm run build` TypeScript компилируется в JS, а HTML копируется в `nodes/`.
|
|
18
|
-
|
|
19
|
-
Общие типы и интерфейсы вынесены в `src/types.ts`.
|
|
20
|
-
|
|
21
|
-
## Архитектура
|
|
22
|
-
|
|
23
|
-
### Паттерн нод Node-RED
|
|
24
|
-
|
|
25
|
-
Каждый тип ноды состоит из пары файлов: `.ts` (логика) и `.html` (UI конфигурации):
|
|
26
|
-
|
|
27
|
-
- **Сервисная нода** (`alice.ts`): Центральный узел — управляет OAuth Яндекса, MQTT-подключением к `mqtts://mqtt.cloud.yandex.net:8883`, HTTP-эндпоинтами администрирования, валидацией подписки. Выступает как источник событий для всех нод устройств.
|
|
28
|
-
- **Нода устройства** (`alice-device.ts`): Группирует умения/сенсоры, управляет регистрацией устройства на шлюзе (`api.nodered-home.ru`), дебаунсит обновления состояния (1000мс).
|
|
29
|
-
- **Ноды умений** (`alice-onoff`, `alice-range`, `alice-color`, `alice-mode`, `alice-togle`, `alice-video`): Двунаправленные — принимают голосовые команды от Алисы и отправляют обновления состояния на шлюз.
|
|
30
|
-
- **Нода сенсора** (`alice-sensor`): Передача свойств только для чтения (температура, влажность и т.д.).
|
|
31
|
-
- **Нода событий** (`alice-event`): Обнаруживает изменения состояния и запускает потоки.
|
|
32
|
-
|
|
33
|
-
### Коммуникация
|
|
34
|
-
|
|
35
|
-
- **MQTT**: Команды устройствам в реальном времени через Yandex IoT Cloud
|
|
36
|
-
- **HTTP REST**: Конфигурация устройств и синхронизация состояния со шлюзом `api.nodered-home.ru`
|
|
37
|
-
- **OAuth 2.0**: Аутентификация через Яндекс, обмен токенами через `nodered-home.ru/api/v1/getyatoken`
|
|
38
|
-
|
|
39
|
-
### Структура HTML UI
|
|
40
|
-
|
|
41
|
-
Каждый `.html` файл содержит два блока скриптов:
|
|
42
|
-
1. `<script>` — клиентская логика (регистрация в редакторе Node-RED, REST-вызовы)
|
|
43
|
-
2. `<script type="text/x-red" data-template-name="...">` — шаблон формы
|
|
44
|
-
|
|
45
|
-
### Основные зависимости
|
|
46
|
-
|
|
47
|
-
- `mqtt` — MQTT-клиент для Yandex IoT Cloud
|
|
48
|
-
- `axios` — HTTP-клиент для вызовов API шлюза
|
|
49
|
-
|
|
50
|
-
## Заметки
|
|
51
|
-
|
|
52
|
-
- Опечатка `alice-togle` (вместо `toggle`) — намеренная, это зарегистрированное имя типа ноды
|
|
53
|
-
- В проекте отсутствуют тесты
|
|
54
|
-
- Комментарии в коде и строки UI написаны преимущественно на русском языке
|