Fork of the espurna firmware for `mhsw` switches
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

753 lines
20 KiB

6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
  1. /*
  2. WEBSOCKET MODULE
  3. Copyright (C) 2016-2019 by Xose Pérez <xose dot perez at gmail dot com>
  4. */
  5. #if WEB_SUPPORT
  6. #include <ESPAsyncTCP.h>
  7. #include <ESPAsyncWebServer.h>
  8. #include <ArduinoJson.h>
  9. #include <Ticker.h>
  10. #include <vector>
  11. #include "libs/WebSocketIncommingBuffer.h"
  12. AsyncWebSocket _ws("/ws");
  13. Ticker _web_defer;
  14. // -----------------------------------------------------------------------------
  15. // WS callbacks
  16. // -----------------------------------------------------------------------------
  17. ws_callbacks_t _ws_callbacks;
  18. struct ws_counter_t {
  19. ws_counter_t() : current(0), start(0), stop(0) {}
  20. ws_counter_t(uint32_t start, uint32_t stop) :
  21. current(start), start(start), stop(stop) {}
  22. void reset() {
  23. current = start;
  24. }
  25. void next() {
  26. if (current < stop) {
  27. ++current;
  28. }
  29. }
  30. bool done() {
  31. return (current >= stop);
  32. }
  33. uint32_t current;
  34. uint32_t start;
  35. uint32_t stop;
  36. };
  37. struct ws_data_t {
  38. enum mode_t {
  39. SEQUENCE,
  40. ALL
  41. };
  42. ws_data_t(const ws_on_send_callback_f& cb) :
  43. storage(new ws_on_send_callback_list_t {cb}),
  44. client_id(0),
  45. mode(ALL),
  46. callbacks(*storage.get()),
  47. counter(0, 1)
  48. {}
  49. ws_data_t(const uint32_t client_id, const ws_on_send_callback_list_t& callbacks, mode_t mode = SEQUENCE) :
  50. client_id(client_id),
  51. mode(mode),
  52. callbacks(callbacks),
  53. counter(0, callbacks.size())
  54. {}
  55. bool done() {
  56. return counter.done();
  57. }
  58. void sendAll(JsonObject& root) {
  59. while (!counter.done()) counter.next();
  60. for (auto& callback : callbacks) {
  61. callback(root);
  62. }
  63. }
  64. void sendCurrent(JsonObject& root) {
  65. callbacks[counter.current](root);
  66. counter.next();
  67. }
  68. void send(JsonObject& root) {
  69. switch (mode) {
  70. case SEQUENCE: sendCurrent(root); break;
  71. case ALL: sendAll(root); break;
  72. }
  73. }
  74. std::unique_ptr<ws_on_send_callback_list_t> storage;
  75. const uint32_t client_id;
  76. const mode_t mode;
  77. const ws_on_send_callback_list_t& callbacks;
  78. ws_counter_t counter;
  79. };
  80. std::queue<ws_data_t> _ws_client_data;
  81. // -----------------------------------------------------------------------------
  82. // WS authentication
  83. // -----------------------------------------------------------------------------
  84. struct ws_ticket_t {
  85. IPAddress ip;
  86. unsigned long timestamp = 0;
  87. };
  88. ws_ticket_t _ws_tickets[WS_BUFFER_SIZE];
  89. void _onAuth(AsyncWebServerRequest *request) {
  90. webLog(request);
  91. if (!webAuthenticate(request)) return request->requestAuthentication();
  92. IPAddress ip = request->client()->remoteIP();
  93. unsigned long now = millis();
  94. unsigned short index;
  95. for (index = 0; index < WS_BUFFER_SIZE; index++) {
  96. if (_ws_tickets[index].ip == ip) break;
  97. if (_ws_tickets[index].timestamp == 0) break;
  98. if (now - _ws_tickets[index].timestamp > WS_TIMEOUT) break;
  99. }
  100. if (index == WS_BUFFER_SIZE) {
  101. request->send(429);
  102. } else {
  103. _ws_tickets[index].ip = ip;
  104. _ws_tickets[index].timestamp = now;
  105. request->send(200, "text/plain", "OK");
  106. }
  107. }
  108. bool _wsAuth(AsyncWebSocketClient * client) {
  109. IPAddress ip = client->remoteIP();
  110. unsigned long now = millis();
  111. unsigned short index = 0;
  112. for (index = 0; index < WS_BUFFER_SIZE; index++) {
  113. if ((_ws_tickets[index].ip == ip) && (now - _ws_tickets[index].timestamp < WS_TIMEOUT)) break;
  114. }
  115. if (index == WS_BUFFER_SIZE) {
  116. return false;
  117. }
  118. return true;
  119. }
  120. // -----------------------------------------------------------------------------
  121. // Debug
  122. // -----------------------------------------------------------------------------
  123. #if DEBUG_WEB_SUPPORT
  124. struct ws_debug_msg_t {
  125. ws_debug_msg_t(const char* prefix, const char* message) :
  126. prefix(prefix), message(message)
  127. {}
  128. String prefix;
  129. String message;
  130. };
  131. struct ws_debug_t {
  132. ws_debug_t(size_t capacity) :
  133. flush(false),
  134. current(0),
  135. capacity(capacity)
  136. {
  137. messages.reserve(capacity);
  138. }
  139. void add(const char* prefix, const char* message) {
  140. if (current >= capacity) {
  141. flush = true;
  142. send();
  143. }
  144. messages.emplace(messages.begin() + current, prefix, message);
  145. flush = true;
  146. ++current;
  147. }
  148. void send() {
  149. if (!flush) return;
  150. // ref: http://arduinojson.org/v5/assistant/
  151. // {"weblog": {"msg":[...],"pre":[...]}}
  152. DynamicJsonBuffer jsonBuffer(2*JSON_ARRAY_SIZE(messages.size()) + JSON_OBJECT_SIZE(1) + JSON_OBJECT_SIZE(2));
  153. JsonObject& root = jsonBuffer.createObject();
  154. JsonObject& weblog = root.createNestedObject("weblog");
  155. JsonArray& msg = weblog.createNestedArray("msg");
  156. JsonArray& pre = weblog.createNestedArray("pre");
  157. for (auto& message : messages) {
  158. pre.add(message.prefix.c_str());
  159. msg.add(message.message.c_str());
  160. }
  161. wsSend(root);
  162. messages.clear();
  163. current = 0;
  164. flush = false;
  165. }
  166. bool flush;
  167. size_t current;
  168. const size_t capacity;
  169. std::vector<ws_debug_msg_t> messages;
  170. };
  171. // TODO: move to the headers?
  172. constexpr const size_t WS_DEBUG_MSG_BUFFER = 8;
  173. ws_debug_t _ws_debug(WS_DEBUG_MSG_BUFFER);
  174. bool wsDebugSend(const char* prefix, const char* message) {
  175. if (!wsConnected()) return false;
  176. _ws_debug.add(prefix, message);
  177. return true;
  178. }
  179. #endif
  180. // Check the existing setting before saving it
  181. // TODO: this should know of the default values, somehow?
  182. // TODO: move webPort handling somewhere else?
  183. bool _wsStore(const String& key, const String& value) {
  184. if (key == "webPort") {
  185. if ((value.toInt() == 0) || (value.toInt() == 80)) {
  186. return delSetting(key);
  187. }
  188. }
  189. if (value != getSetting(key)) {
  190. return setSetting(key, value);
  191. }
  192. return false;
  193. }
  194. // -----------------------------------------------------------------------------
  195. // Store indexed key (key0, key1, etc.) from array
  196. // -----------------------------------------------------------------------------
  197. bool _wsStore(const String& key, JsonArray& value) {
  198. bool changed = false;
  199. unsigned char index = 0;
  200. for (auto element : value) {
  201. if (_wsStore(key + index, element.as<String>())) changed = true;
  202. index++;
  203. }
  204. // Delete further values
  205. for (unsigned char i=index; i<SETTINGS_MAX_LIST_COUNT; i++) {
  206. if (!delSetting(key, index)) break;
  207. changed = true;
  208. }
  209. return changed;
  210. }
  211. bool _wsCheckKey(const String& key, JsonVariant& value) {
  212. for (auto& callback : _ws_callbacks.on_keycheck) {
  213. if (callback(key.c_str(), value)) return true;
  214. // TODO: remove this to call all OnKeyCheckCallbacks with the
  215. // current key/value
  216. }
  217. return false;
  218. }
  219. void _wsParse(AsyncWebSocketClient *client, uint8_t * payload, size_t length) {
  220. //DEBUG_MSG_P(PSTR("[WEBSOCKET] Parsing: %s\n"), length ? (char*) payload : "");
  221. // Get client ID
  222. uint32_t client_id = client->id();
  223. // Check early for empty object / nothing
  224. if ((length == 0) || (length == 1)) {
  225. return;
  226. }
  227. if ((length == 3) && (strcmp((char*) payload, "{}") == 0)) {
  228. return;
  229. }
  230. // Parse JSON input
  231. // TODO: json buffer should be pretty efficient with the non-const payload,
  232. // most of the space is taken by the object key references
  233. DynamicJsonBuffer jsonBuffer(512);
  234. JsonObject& root = jsonBuffer.parseObject((char *) payload);
  235. if (!root.success()) {
  236. DEBUG_MSG_P(PSTR("[WEBSOCKET] JSON parsing error\n"));
  237. wsSend_P(client_id, PSTR("{\"message\": 3}"));
  238. return;
  239. }
  240. // Check actions -----------------------------------------------------------
  241. const char* action = root["action"];
  242. if (action) {
  243. DEBUG_MSG_P(PSTR("[WEBSOCKET] Requested action: %s\n"), action);
  244. if (strcmp(action, "reboot") == 0) {
  245. deferredReset(100, CUSTOM_RESET_WEB);
  246. return;
  247. }
  248. if (strcmp(action, "reconnect") == 0) {
  249. _web_defer.once_ms(100, wifiDisconnect);
  250. return;
  251. }
  252. if (strcmp(action, "factory_reset") == 0) {
  253. DEBUG_MSG_P(PSTR("\n\nFACTORY RESET\n\n"));
  254. resetSettings();
  255. deferredReset(100, CUSTOM_RESET_FACTORY);
  256. return;
  257. }
  258. JsonObject& data = root["data"];
  259. if (data.success()) {
  260. // Callbacks
  261. for (auto& callback : _ws_callbacks.on_action) {
  262. callback(client_id, action, data);
  263. }
  264. // Restore configuration via websockets
  265. if (strcmp(action, "restore") == 0) {
  266. if (settingsRestoreJson(data)) {
  267. wsSend_P(client_id, PSTR("{\"message\": 5}"));
  268. } else {
  269. wsSend_P(client_id, PSTR("{\"message\": 4}"));
  270. }
  271. }
  272. return;
  273. }
  274. };
  275. // Check configuration -----------------------------------------------------
  276. JsonObject& config = root["config"];
  277. if (config.success()) {
  278. DEBUG_MSG_P(PSTR("[WEBSOCKET] Parsing configuration data\n"));
  279. String adminPass;
  280. bool save = false;
  281. for (auto kv: config) {
  282. bool changed = false;
  283. String key = kv.key;
  284. JsonVariant& value = kv.value;
  285. // Check password
  286. if (key == "adminPass") {
  287. if (!value.is<JsonArray&>()) continue;
  288. JsonArray& values = value.as<JsonArray&>();
  289. if (values.size() != 2) continue;
  290. if (values[0].as<String>().equals(values[1].as<String>())) {
  291. String password = values[0].as<String>();
  292. if (password.length() > 0) {
  293. setSetting(key, password);
  294. save = true;
  295. wsSend_P(client_id, PSTR("{\"action\": \"reload\"}"));
  296. }
  297. } else {
  298. wsSend_P(client_id, PSTR("{\"message\": 7}"));
  299. }
  300. continue;
  301. }
  302. if (!_wsCheckKey(key, value)) {
  303. delSetting(key);
  304. continue;
  305. }
  306. // Store values
  307. if (value.is<JsonArray&>()) {
  308. if (_wsStore(key, value.as<JsonArray&>())) changed = true;
  309. } else {
  310. if (_wsStore(key, value.as<String>())) changed = true;
  311. }
  312. // Update flags if value has changed
  313. if (changed) {
  314. save = true;
  315. }
  316. }
  317. // Save settings
  318. if (save) {
  319. // Callbacks
  320. espurnaReload();
  321. // Persist settings
  322. saveSettings();
  323. wsSend_P(client_id, PSTR("{\"message\": 8}"));
  324. } else {
  325. wsSend_P(client_id, PSTR("{\"message\": 9}"));
  326. }
  327. }
  328. }
  329. void _wsUpdate(JsonObject& root) {
  330. root["heap"] = getFreeHeap();
  331. root["uptime"] = getUptime();
  332. root["rssi"] = WiFi.RSSI();
  333. root["loadaverage"] = systemLoadAverage();
  334. #if ADC_MODE_VALUE == ADC_VCC
  335. root["vcc"] = ESP.getVcc();
  336. #endif
  337. #if NTP_SUPPORT
  338. if (ntpSynced()) root["now"] = now();
  339. #endif
  340. }
  341. void _wsDoUpdate(bool reset = false) {
  342. static unsigned long last = millis();
  343. if (reset) {
  344. last = millis() + WS_UPDATE_INTERVAL;
  345. return;
  346. }
  347. if (millis() - last > WS_UPDATE_INTERVAL) {
  348. last = millis();
  349. wsSend(_wsUpdate);
  350. }
  351. }
  352. bool _wsOnKeyCheck(const char * key, JsonVariant& value) {
  353. if (strncmp(key, "ws", 2) == 0) return true;
  354. if (strncmp(key, "admin", 5) == 0) return true;
  355. if (strncmp(key, "hostname", 8) == 0) return true;
  356. if (strncmp(key, "desc", 4) == 0) return true;
  357. if (strncmp(key, "webPort", 7) == 0) return true;
  358. return false;
  359. }
  360. void _wsOnConnected(JsonObject& root) {
  361. char chipid[7];
  362. snprintf_P(chipid, sizeof(chipid), PSTR("%06X"), ESP.getChipId());
  363. root["webMode"] = WEB_MODE_NORMAL;
  364. root["app_name"] = APP_NAME;
  365. root["app_version"] = APP_VERSION;
  366. root["app_build"] = buildTime();
  367. #if defined(APP_REVISION)
  368. root["app_revision"] = APP_REVISION;
  369. #endif
  370. root["manufacturer"] = MANUFACTURER;
  371. root["chipid"] = String(chipid);
  372. root["mac"] = WiFi.macAddress();
  373. root["bssid"] = WiFi.BSSIDstr();
  374. root["channel"] = WiFi.channel();
  375. root["device"] = DEVICE;
  376. root["hostname"] = getSetting("hostname");
  377. root["desc"] = getSetting("desc");
  378. root["network"] = getNetwork();
  379. root["deviceip"] = getIP();
  380. root["sketch_size"] = ESP.getSketchSize();
  381. root["free_size"] = ESP.getFreeSketchSpace();
  382. root["sdk"] = ESP.getSdkVersion();
  383. root["core"] = getCoreVersion();
  384. root["btnDelay"] = getSetting("btnDelay", BUTTON_DBLCLICK_DELAY).toInt();
  385. root["webPort"] = getSetting("webPort", WEB_PORT).toInt();
  386. root["wsAuth"] = getSetting("wsAuth", WS_AUTHENTICATION).toInt() == 1;
  387. #if TERMINAL_SUPPORT
  388. root["cmdVisible"] = 1;
  389. #endif
  390. root["hbMode"] = getSetting("hbMode", HEARTBEAT_MODE).toInt();
  391. root["hbInterval"] = getSetting("hbInterval", HEARTBEAT_INTERVAL).toInt();
  392. _wsDoUpdate(true);
  393. }
  394. void wsSend(JsonObject& root) {
  395. // TODO: avoid serializing twice?
  396. size_t len = root.measureLength();
  397. AsyncWebSocketMessageBuffer* buffer = _ws.makeBuffer(len);
  398. if (buffer) {
  399. root.printTo(reinterpret_cast<char*>(buffer->get()), len + 1);
  400. _ws.textAll(buffer);
  401. }
  402. }
  403. void wsSend(uint32_t client_id, JsonObject& root) {
  404. AsyncWebSocketClient* client = _ws.client(client_id);
  405. if (client == nullptr) return;
  406. // TODO: avoid serializing twice?
  407. size_t len = root.measureLength();
  408. AsyncWebSocketMessageBuffer* buffer = _ws.makeBuffer(len);
  409. if (buffer) {
  410. root.printTo(reinterpret_cast<char*>(buffer->get()), len + 1);
  411. client->text(buffer);
  412. }
  413. }
  414. void _wsConnected(uint32_t client_id) {
  415. const bool changePassword = (USE_PASSWORD && WEB_FORCE_PASS_CHANGE)
  416. ? getAdminPass().equals(ADMIN_PASS)
  417. : false;
  418. if (changePassword) {
  419. StaticJsonBuffer<JSON_OBJECT_SIZE(1)> jsonBuffer;
  420. JsonObject& root = jsonBuffer.createObject();
  421. root["webMode"] = WEB_MODE_PASSWORD;
  422. wsSend(client_id, root);
  423. return;
  424. }
  425. wsPostAll(client_id, _ws_callbacks.on_visible);
  426. wsPostSequence(client_id, _ws_callbacks.on_connected);
  427. wsPostSequence(client_id, _ws_callbacks.on_data);
  428. }
  429. void _wsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len){
  430. if (type == WS_EVT_CONNECT) {
  431. client->_tempObject = nullptr;
  432. #ifndef NOWSAUTH
  433. if (!_wsAuth(client)) {
  434. wsSend_P(client->id(), PSTR("{\"message\": 10}"));
  435. DEBUG_MSG_P(PSTR("[WEBSOCKET] Validation check failed\n"));
  436. client->close();
  437. return;
  438. }
  439. #endif
  440. IPAddress ip = client->remoteIP();
  441. DEBUG_MSG_P(PSTR("[WEBSOCKET] #%u connected, ip: %d.%d.%d.%d, url: %s\n"), client->id(), ip[0], ip[1], ip[2], ip[3], server->url());
  442. _wsConnected(client->id());
  443. wifiReconnectCheck();
  444. } else if(type == WS_EVT_DISCONNECT) {
  445. DEBUG_MSG_P(PSTR("[WEBSOCKET] #%u disconnected\n"), client->id());
  446. if (client->_tempObject) {
  447. delete (WebSocketIncommingBuffer *) client->_tempObject;
  448. }
  449. wifiReconnectCheck();
  450. } else if(type == WS_EVT_ERROR) {
  451. DEBUG_MSG_P(PSTR("[WEBSOCKET] #%u error(%u): %s\n"), client->id(), *((uint16_t*)arg), (char*)data);
  452. } else if(type == WS_EVT_PONG) {
  453. DEBUG_MSG_P(PSTR("[WEBSOCKET] #%u pong(%u): %s\n"), client->id(), len, len ? (char*) data : "");
  454. } else if(type == WS_EVT_DATA) {
  455. //DEBUG_MSG_P(PSTR("[WEBSOCKET] #%u data(%u): %s\n"), client->id(), len, len ? (char*) data : "");
  456. if (!client->_tempObject) return;
  457. WebSocketIncommingBuffer *buffer = (WebSocketIncommingBuffer *)client->_tempObject;
  458. AwsFrameInfo * info = (AwsFrameInfo*)arg;
  459. buffer->data_event(client, info, data, len);
  460. }
  461. }
  462. // TODO: make this generic loop method to queue important ws messages?
  463. // or, if something uses ticker / async ctx to send messages,
  464. // it needs a retry mechanism built into the callback object
  465. void _wsHandleClientData() {
  466. if (_ws_client_data.empty()) return;
  467. auto& data = _ws_client_data.front();
  468. AsyncWebSocketClient* ws_client = _ws.client(data.client_id);
  469. if (!ws_client) {
  470. _ws_client_data.pop();
  471. return;
  472. }
  473. // wait until we can send the next batch of messages
  474. // XXX: enforce that callbacks send only one message per iteration
  475. if (ws_client->queueIsFull()) {
  476. return;
  477. }
  478. // XXX: block allocation will try to create *2 next time,
  479. // likely failing and causing wsSend to reference empty objects
  480. // XXX: arduinojson6 will not do this, but we may need to use per-callback buffers
  481. constexpr const size_t BUFFER_SIZE = 3192;
  482. DynamicJsonBuffer jsonBuffer(BUFFER_SIZE);
  483. JsonObject& root = jsonBuffer.createObject();
  484. data.send(root);
  485. if (data.client_id) {
  486. wsSend(data.client_id, root);
  487. } else {
  488. wsSend(root);
  489. }
  490. yield();
  491. if (data.done()) {
  492. // push the queue and finally allow incoming messages
  493. _ws_client_data.pop();
  494. ws_client->_tempObject = new WebSocketIncommingBuffer(_wsParse, true);
  495. }
  496. }
  497. void _wsLoop() {
  498. if (!wsConnected()) return;
  499. _wsDoUpdate();
  500. _wsHandleClientData();
  501. #if DEBUG_WEB_SUPPORT
  502. _ws_debug.send();
  503. #endif
  504. }
  505. // -----------------------------------------------------------------------------
  506. // Public API
  507. // -----------------------------------------------------------------------------
  508. bool wsConnected() {
  509. return (_ws.count() > 0);
  510. }
  511. bool wsConnected(uint32_t client_id) {
  512. return _ws.hasClient(client_id);
  513. }
  514. ws_callbacks_t& wsRegister() {
  515. return _ws_callbacks;
  516. }
  517. void wsSend(ws_on_send_callback_f callback) {
  518. if (_ws.count() > 0) {
  519. DynamicJsonBuffer jsonBuffer(512);
  520. JsonObject& root = jsonBuffer.createObject();
  521. callback(root);
  522. wsSend(root);
  523. }
  524. }
  525. void wsSend(const char * payload) {
  526. if (_ws.count() > 0) {
  527. _ws.textAll(payload);
  528. }
  529. }
  530. void wsSend_P(PGM_P payload) {
  531. if (_ws.count() > 0) {
  532. char buffer[strlen_P(payload)];
  533. strcpy_P(buffer, payload);
  534. _ws.textAll(buffer);
  535. }
  536. }
  537. void wsSend(uint32_t client_id, ws_on_send_callback_f callback) {
  538. AsyncWebSocketClient* client = _ws.client(client_id);
  539. if (client == nullptr) return;
  540. DynamicJsonBuffer jsonBuffer(512);
  541. JsonObject& root = jsonBuffer.createObject();
  542. callback(root);
  543. wsSend(client_id, root);
  544. }
  545. void wsSend(uint32_t client_id, const char * payload) {
  546. _ws.text(client_id, payload);
  547. }
  548. void wsSend_P(uint32_t client_id, PGM_P payload) {
  549. char buffer[strlen_P(payload)];
  550. strcpy_P(buffer, payload);
  551. _ws.text(client_id, buffer);
  552. }
  553. void wsPost(const ws_on_send_callback_f& cb) {
  554. _ws_client_data.emplace(cb);
  555. }
  556. void wsPostAll(uint32_t client_id, const ws_on_send_callback_list_t& cbs) {
  557. _ws_client_data.emplace(client_id, cbs, ws_data_t::ALL);
  558. }
  559. void wsPostAll(const ws_on_send_callback_list_t& cbs) {
  560. _ws_client_data.emplace(0, cbs, ws_data_t::ALL);
  561. }
  562. void wsPostSequence(uint32_t client_id, const ws_on_send_callback_list_t& cbs) {
  563. _ws_client_data.emplace(client_id, cbs, ws_data_t::SEQUENCE);
  564. }
  565. void wsPostSequence(const ws_on_send_callback_list_t& cbs) {
  566. _ws_client_data.emplace(0, cbs, ws_data_t::SEQUENCE);
  567. }
  568. void wsSetup() {
  569. _ws.onEvent(_wsEvent);
  570. webServer()->addHandler(&_ws);
  571. // CORS
  572. const String webDomain = getSetting("webDomain", WEB_REMOTE_DOMAIN);
  573. DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", webDomain);
  574. if (!webDomain.equals("*")) {
  575. DefaultHeaders::Instance().addHeader("Access-Control-Allow-Credentials", "true");
  576. }
  577. webServer()->on("/auth", HTTP_GET, _onAuth);
  578. wsRegister()
  579. .onConnected(_wsOnConnected)
  580. .onKeyCheck(_wsOnKeyCheck);
  581. espurnaRegisterLoop(_wsLoop);
  582. }
  583. #endif // WEB_SUPPORT