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.

586 lines
17 KiB

Terminal: change command-line parser (#2247) Change the underlying command line handling: - switch to a custom parser, inspired by redis / sds - update terminalRegisterCommand signature, pass only bare minimum - clean-up `help` & `commands`. update settings `set`, `get` and `del` - allow our custom test suite to run command-line tests - clean-up Stream IO to allow us to print large things into debug stream (for example, `eeprom.dump`) - send parsing errors to the debug log As a proof of concept, introduce `TERMINAL_MQTT_SUPPORT` and `TERMINAL_WEB_API_SUPPORT` - MQTT subscribes to the `<root>/cmd/set` and sends response to the `<root>/cmd`. We can't output too much, as we don't have any large-send API. - Web API listens to the `/api/cmd?apikey=...&line=...` (or PUT, params inside the body). This one is intended as a possible replacement of the `API_SUPPORT`. Internals introduce a 'task' around the AsyncWebServerRequest object that will simulate what WiFiClient does and push data into it continuously, switching between CONT and SYS. Both are experimental. We only accept a single command and not every command is updated to use Print `ctx.output` object. We are also somewhat limited by the Print / Stream overall, perhaps I am overestimating the usefulness of Arduino compatibility to such an extent :) Web API handler can also sometimes show only part of the result, whenever the command tries to yield() by itself waiting for something. Perhaps we would need to create a custom request handler for that specific use-case.
4 years ago
Terminal: change command-line parser (#2247) Change the underlying command line handling: - switch to a custom parser, inspired by redis / sds - update terminalRegisterCommand signature, pass only bare minimum - clean-up `help` & `commands`. update settings `set`, `get` and `del` - allow our custom test suite to run command-line tests - clean-up Stream IO to allow us to print large things into debug stream (for example, `eeprom.dump`) - send parsing errors to the debug log As a proof of concept, introduce `TERMINAL_MQTT_SUPPORT` and `TERMINAL_WEB_API_SUPPORT` - MQTT subscribes to the `<root>/cmd/set` and sends response to the `<root>/cmd`. We can't output too much, as we don't have any large-send API. - Web API listens to the `/api/cmd?apikey=...&line=...` (or PUT, params inside the body). This one is intended as a possible replacement of the `API_SUPPORT`. Internals introduce a 'task' around the AsyncWebServerRequest object that will simulate what WiFiClient does and push data into it continuously, switching between CONT and SYS. Both are experimental. We only accept a single command and not every command is updated to use Print `ctx.output` object. We are also somewhat limited by the Print / Stream overall, perhaps I am overestimating the usefulness of Arduino compatibility to such an extent :) Web API handler can also sometimes show only part of the result, whenever the command tries to yield() by itself waiting for something. Perhaps we would need to create a custom request handler for that specific use-case.
4 years ago
Terminal: change command-line parser (#2247) Change the underlying command line handling: - switch to a custom parser, inspired by redis / sds - update terminalRegisterCommand signature, pass only bare minimum - clean-up `help` & `commands`. update settings `set`, `get` and `del` - allow our custom test suite to run command-line tests - clean-up Stream IO to allow us to print large things into debug stream (for example, `eeprom.dump`) - send parsing errors to the debug log As a proof of concept, introduce `TERMINAL_MQTT_SUPPORT` and `TERMINAL_WEB_API_SUPPORT` - MQTT subscribes to the `<root>/cmd/set` and sends response to the `<root>/cmd`. We can't output too much, as we don't have any large-send API. - Web API listens to the `/api/cmd?apikey=...&line=...` (or PUT, params inside the body). This one is intended as a possible replacement of the `API_SUPPORT`. Internals introduce a 'task' around the AsyncWebServerRequest object that will simulate what WiFiClient does and push data into it continuously, switching between CONT and SYS. Both are experimental. We only accept a single command and not every command is updated to use Print `ctx.output` object. We are also somewhat limited by the Print / Stream overall, perhaps I am overestimating the usefulness of Arduino compatibility to such an extent :) Web API handler can also sometimes show only part of the result, whenever the command tries to yield() by itself waiting for something. Perhaps we would need to create a custom request handler for that specific use-case.
4 years ago
  1. /*
  2. HOME ASSISTANT MODULE
  3. Copyright (C) 2017-2019 by Xose Pérez <xose dot perez at gmail dot com>
  4. */
  5. #include "homeassistant.h"
  6. #if HOMEASSISTANT_SUPPORT
  7. #include "light.h"
  8. #include "mqtt.h"
  9. #include "relay.h"
  10. #include "rpc.h"
  11. #include "sensor.h"
  12. #include "utils.h"
  13. #include "ws.h"
  14. #include <Ticker.h>
  15. #include <Schedule.h>
  16. #include <ArduinoJson.h>
  17. bool _ha_enabled = false;
  18. bool _ha_send_flag = false;
  19. // -----------------------------------------------------------------------------
  20. // UTILS
  21. // -----------------------------------------------------------------------------
  22. // per yaml 1.1 spec, following scalars are converted to bool. we want the string, so quoting the output
  23. // y|Y|yes|Yes|YES|n|N|no|No|NO |true|True|TRUE|false|False|FALSE |on|On|ON|off|Off|OFF
  24. String _haFixPayload(const String& value) {
  25. if (value.equalsIgnoreCase("y")
  26. || value.equalsIgnoreCase("n")
  27. || value.equalsIgnoreCase("yes")
  28. || value.equalsIgnoreCase("no")
  29. || value.equalsIgnoreCase("true")
  30. || value.equalsIgnoreCase("false")
  31. || value.equalsIgnoreCase("on")
  32. || value.equalsIgnoreCase("off")
  33. ) {
  34. String temp;
  35. temp.reserve(value.length() + 2);
  36. temp = "\"";
  37. temp += value;
  38. temp += "\"";
  39. return temp;
  40. }
  41. return value;
  42. }
  43. String _haFixName(String&& name) {
  44. auto* ptr = const_cast<char*>(name.c_str());
  45. while (*ptr != '\0') {
  46. if (!isalnum(*ptr)) {
  47. *ptr = '_';
  48. }
  49. ++ptr;
  50. }
  51. return std::move(name);
  52. }
  53. #if (LIGHT_PROVIDER != LIGHT_PROVIDER_NONE) || (defined(ITEAD_SLAMPHER))
  54. const String switchType("light");
  55. #else
  56. const String switchType("switch");
  57. #endif
  58. // -----------------------------------------------------------------------------
  59. // Shared context object to store entity and entity registry data
  60. // -----------------------------------------------------------------------------
  61. struct ha_config_t {
  62. static const size_t DEFAULT_BUFFER_SIZE = 2048;
  63. ha_config_t(size_t size) :
  64. jsonBuffer(size),
  65. deviceConfig(jsonBuffer.createObject()),
  66. root(jsonBuffer.createObject()),
  67. name(getSetting("desc", getSetting("hostname", getIdentifier()))),
  68. identifier(getIdentifier().c_str()),
  69. version(getVersion().c_str()),
  70. manufacturer(getManufacturer().c_str()),
  71. device(getDevice().c_str())
  72. {
  73. deviceConfig.createNestedArray("identifiers").add(identifier);
  74. deviceConfig["name"] = name.c_str();
  75. deviceConfig["sw_version"] = version;
  76. deviceConfig["manufacturer"] = manufacturer;
  77. deviceConfig["model"] = device;
  78. }
  79. ha_config_t() : ha_config_t(DEFAULT_BUFFER_SIZE) {}
  80. size_t size() { return jsonBuffer.size(); }
  81. DynamicJsonBuffer jsonBuffer;
  82. JsonObject& deviceConfig;
  83. JsonObject& root;
  84. String name;
  85. const char* identifier;
  86. const char* version;
  87. const char* manufacturer;
  88. const char* device;
  89. };
  90. // -----------------------------------------------------------------------------
  91. // MQTT discovery
  92. // -----------------------------------------------------------------------------
  93. struct ha_discovery_t {
  94. constexpr static const unsigned long SEND_TIMEOUT = 1000;
  95. constexpr static const unsigned char SEND_RETRY = 5;
  96. ha_discovery_t() :
  97. _retry(SEND_RETRY)
  98. {
  99. #if SENSOR_SUPPORT
  100. _messages.reserve(magnitudeCount() + relayCount());
  101. #else
  102. _messages.reserve(relayCount());
  103. #endif
  104. }
  105. ~ha_discovery_t() {
  106. DEBUG_MSG_P(PSTR("[HA] Discovery %s\n"), empty() ? "OK" : "FAILED");
  107. }
  108. void add(String& topic, String& message) {
  109. auto msg = mqtt_msg_t { std::move(topic), std::move(message) };
  110. _messages.push_back(std::move(msg));
  111. }
  112. // We don't particulary care about the order since names have indexes?
  113. // If we ever do, use iterators to reference elems and pop the String contents instead
  114. mqtt_msg_t& next() {
  115. return _messages.back();
  116. }
  117. void pop() {
  118. _messages.pop_back();
  119. }
  120. const bool empty() const {
  121. return !_messages.size();
  122. }
  123. bool retry() {
  124. if (!_retry) return false;
  125. return --_retry;
  126. }
  127. void prepareSwitches(ha_config_t& config);
  128. #if SENSOR_SUPPORT
  129. void prepareMagnitudes(ha_config_t& config);
  130. #endif
  131. Ticker timer;
  132. std::vector<mqtt_msg_t> _messages;
  133. unsigned char _retry;
  134. };
  135. std::unique_ptr<ha_discovery_t> _ha_discovery = nullptr;
  136. void _haSendDiscovery() {
  137. if (!_ha_discovery) return;
  138. const bool connected = mqttConnected();
  139. const bool retry = _ha_discovery->retry();
  140. const bool empty = _ha_discovery->empty();
  141. if (!connected || !retry || empty) {
  142. _ha_discovery = nullptr;
  143. return;
  144. }
  145. const unsigned long ts = millis();
  146. do {
  147. if (_ha_discovery->empty()) break;
  148. auto& message = _ha_discovery->next();
  149. if (!mqttSendRaw(message.topic.c_str(), message.message.c_str())) {
  150. break;
  151. }
  152. _ha_discovery->pop();
  153. // XXX: should not reach this timeout, most common case is the break above
  154. } while (millis() - ts < ha_discovery_t::SEND_TIMEOUT);
  155. mqttSendStatus();
  156. if (_ha_discovery->empty()) {
  157. _ha_discovery = nullptr;
  158. } else {
  159. // 2.3.0: Ticker callback arguments are not preserved and once_ms_scheduled is missing
  160. // We need to use global discovery object to reschedule it
  161. // Otherwise, this would've been shared_ptr from _haSend
  162. _ha_discovery->timer.once_ms(ha_discovery_t::SEND_TIMEOUT, []() {
  163. schedule_function(_haSendDiscovery);
  164. });
  165. }
  166. }
  167. // -----------------------------------------------------------------------------
  168. // SENSORS
  169. // -----------------------------------------------------------------------------
  170. #if SENSOR_SUPPORT
  171. void _haSendMagnitude(unsigned char index, JsonObject& config) {
  172. config["name"] = _haFixName(getSetting("hostname", getIdentifier()) + String(" ") + magnitudeTopic(magnitudeType(index)));
  173. config["state_topic"] = mqttTopic(magnitudeTopicIndex(index).c_str(), false);
  174. config["unit_of_measurement"] = magnitudeUnits(index);
  175. }
  176. void ha_discovery_t::prepareMagnitudes(ha_config_t& config) {
  177. // Note: because none of the keys are erased, use a separate object to avoid accidentally sending switch data
  178. JsonObject& root = config.jsonBuffer.createObject();
  179. for (unsigned char i=0; i<magnitudeCount(); i++) {
  180. String topic = getSetting("haPrefix", HOMEASSISTANT_PREFIX) +
  181. "/sensor/" +
  182. getSetting("hostname", getIdentifier()) + "_" + String(i) +
  183. "/config";
  184. String message;
  185. if (_ha_enabled) {
  186. _haSendMagnitude(i, root);
  187. root["uniq_id"] = getIdentifier() + "_" + magnitudeTopic(magnitudeType(i)) + "_" + String(i);
  188. root["device"] = config.deviceConfig;
  189. message.reserve(root.measureLength());
  190. root.printTo(message);
  191. }
  192. add(topic, message);
  193. }
  194. }
  195. #endif // SENSOR_SUPPORT
  196. // -----------------------------------------------------------------------------
  197. // SWITCHES & LIGHTS
  198. // -----------------------------------------------------------------------------
  199. void _haSendSwitch(unsigned char i, JsonObject& config) {
  200. String name = _haFixName(getSetting("hostname", getIdentifier()));
  201. if (relayCount() > 1) {
  202. name += '_';
  203. name += i;
  204. }
  205. config.set("name", name);
  206. if (relayCount()) {
  207. config["state_topic"] = mqttTopic(MQTT_TOPIC_RELAY, i, false);
  208. config["command_topic"] = mqttTopic(MQTT_TOPIC_RELAY, i, true);
  209. config["payload_on"] = relayPayload(PayloadStatus::On);
  210. config["payload_off"] = relayPayload(PayloadStatus::Off);
  211. config["availability_topic"] = mqttTopic(MQTT_TOPIC_STATUS, false);
  212. config["payload_available"] = mqttPayloadStatus(true);
  213. config["payload_not_available"] = mqttPayloadStatus(false);
  214. }
  215. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  216. if (i == 0) {
  217. config["brightness_state_topic"] = mqttTopic(MQTT_TOPIC_BRIGHTNESS, false);
  218. config["brightness_command_topic"] = mqttTopic(MQTT_TOPIC_BRIGHTNESS, true);
  219. if (lightHasColor()) {
  220. config["rgb_state_topic"] = mqttTopic(MQTT_TOPIC_COLOR_RGB, false);
  221. config["rgb_command_topic"] = mqttTopic(MQTT_TOPIC_COLOR_RGB, true);
  222. }
  223. if (lightHasColor() || lightUseCCT()) {
  224. config["color_temp_command_topic"] = mqttTopic(MQTT_TOPIC_MIRED, true);
  225. config["color_temp_state_topic"] = mqttTopic(MQTT_TOPIC_MIRED, false);
  226. }
  227. if (lightChannels() > 3) {
  228. config["white_value_state_topic"] = mqttTopic(MQTT_TOPIC_CHANNEL, 3, false);
  229. config["white_value_command_topic"] = mqttTopic(MQTT_TOPIC_CHANNEL, 3, true);
  230. }
  231. }
  232. #endif // LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  233. }
  234. void ha_discovery_t::prepareSwitches(ha_config_t& config) {
  235. // Note: because none of the keys are erased, use a separate object to avoid accidentally sending magnitude data
  236. JsonObject& root = config.jsonBuffer.createObject();
  237. for (unsigned char i=0; i<relayCount(); i++) {
  238. String topic = getSetting("haPrefix", HOMEASSISTANT_PREFIX) +
  239. "/" + switchType +
  240. "/" + getSetting("hostname", getIdentifier()) + "_" + String(i) +
  241. "/config";
  242. String message;
  243. if (_ha_enabled) {
  244. _haSendSwitch(i, root);
  245. root["uniq_id"] = getIdentifier() + "_" + switchType + "_" + String(i);
  246. root["device"] = config.deviceConfig;
  247. message.reserve(root.measureLength());
  248. root.printTo(message);
  249. }
  250. add(topic, message);
  251. }
  252. }
  253. // -----------------------------------------------------------------------------
  254. constexpr const size_t HA_YAML_BUFFER_SIZE = 1024;
  255. void _haSwitchYaml(unsigned char index, JsonObject& root) {
  256. String output;
  257. output.reserve(HA_YAML_BUFFER_SIZE);
  258. JsonObject& config = root.createNestedObject("config");
  259. config["platform"] = "mqtt";
  260. _haSendSwitch(index, config);
  261. if (index == 0) output += "\n\n" + switchType + ":";
  262. output += "\n";
  263. bool first = true;
  264. for (auto kv : config) {
  265. if (first) {
  266. output += " - ";
  267. first = false;
  268. } else {
  269. output += " ";
  270. }
  271. output += kv.key;
  272. output += ": ";
  273. if (strncmp(kv.key, "payload_", strlen("payload_")) == 0) {
  274. output += _haFixPayload(kv.value.as<String>());
  275. } else {
  276. output += kv.value.as<String>();
  277. }
  278. output += "\n";
  279. }
  280. output += " ";
  281. root.remove("config");
  282. root["haConfig"] = output;
  283. }
  284. #if SENSOR_SUPPORT
  285. void _haSensorYaml(unsigned char index, JsonObject& root) {
  286. String output;
  287. output.reserve(HA_YAML_BUFFER_SIZE);
  288. JsonObject& config = root.createNestedObject("config");
  289. config["platform"] = "mqtt";
  290. _haSendMagnitude(index, config);
  291. if (index == 0) output += "\n\nsensor:";
  292. output += "\n";
  293. bool first = true;
  294. for (auto kv : config) {
  295. if (first) {
  296. output += " - ";
  297. first = false;
  298. } else {
  299. output += " ";
  300. }
  301. String value = kv.value.as<String>();
  302. value.replace("%", "'%'");
  303. output += kv.key;
  304. output += ": ";
  305. output += value;
  306. output += "\n";
  307. }
  308. output += " ";
  309. root.remove("config");
  310. root["haConfig"] = output;
  311. }
  312. #endif // SENSOR_SUPPORT
  313. void _haGetDeviceConfig(JsonObject& config) {
  314. config.createNestedArray("identifiers").add(getIdentifier().c_str());
  315. config["name"] = getSetting("desc", getSetting("hostname", getIdentifier()));
  316. config["manufacturer"] = getManufacturer().c_str();
  317. config["model"] = getDevice().c_str();
  318. config["sw_version"] = getVersion().c_str();
  319. }
  320. void _haSend() {
  321. // Pending message to send?
  322. if (!_ha_send_flag) return;
  323. // Are we connected?
  324. if (!mqttConnected()) return;
  325. // Are we still trying to send discovery messages?
  326. if (_ha_discovery) return;
  327. DEBUG_MSG_P(PSTR("[HA] Preparing MQTT discovery message(s)...\n"));
  328. // Get common device config / context object
  329. ha_config_t config;
  330. // We expect only one instance, create now
  331. _ha_discovery = std::make_unique<ha_discovery_t>();
  332. // Prepare all of the messages and send them in the scheduled function later
  333. _ha_discovery->prepareSwitches(config);
  334. #if SENSOR_SUPPORT
  335. _ha_discovery->prepareMagnitudes(config);
  336. #endif
  337. _ha_send_flag = false;
  338. schedule_function(_haSendDiscovery);
  339. }
  340. void _haConfigure() {
  341. const bool enabled = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
  342. _ha_send_flag = (enabled != _ha_enabled);
  343. _ha_enabled = enabled;
  344. // https://github.com/xoseperez/espurna/issues/1273
  345. // https://gitter.im/tinkerman-cat/espurna?at=5df8ad4655d9392300268a8c
  346. // TODO: ensure that this is called before _lightConfigure()
  347. // in case useCSS value is ever cached by the lights module
  348. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  349. if (enabled) {
  350. if (getSetting("useCSS", 1 == LIGHT_USE_CSS)) {
  351. setSetting("useCSS", 0);
  352. }
  353. }
  354. #endif
  355. _haSend();
  356. }
  357. #if WEB_SUPPORT
  358. bool _haWebSocketOnKeyCheck(const char * key, JsonVariant& value) {
  359. return (strncmp(key, "ha", 2) == 0);
  360. }
  361. void _haWebSocketOnVisible(JsonObject& root) {
  362. root["haVisible"] = 1;
  363. }
  364. void _haWebSocketOnConnected(JsonObject& root) {
  365. root["haPrefix"] = getSetting("haPrefix", HOMEASSISTANT_PREFIX);
  366. root["haEnabled"] = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
  367. }
  368. void _haWebSocketOnAction(uint32_t client_id, const char * action, JsonObject& data) {
  369. if (strcmp(action, "haconfig") == 0) {
  370. ws_on_send_callback_list_t callbacks;
  371. #if SENSOR_SUPPORT
  372. callbacks.reserve(magnitudeCount() + relayCount());
  373. #else
  374. callbacks.reserve(relayCount());
  375. #endif // SENSOR_SUPPORT
  376. {
  377. for (unsigned char idx=0; idx<relayCount(); ++idx) {
  378. callbacks.push_back([idx](JsonObject& root) {
  379. _haSwitchYaml(idx, root);
  380. });
  381. }
  382. }
  383. #if SENSOR_SUPPORT
  384. {
  385. for (unsigned char idx=0; idx<magnitudeCount(); ++idx) {
  386. callbacks.push_back([idx](JsonObject& root) {
  387. _haSensorYaml(idx, root);
  388. });
  389. }
  390. }
  391. #endif // SENSOR_SUPPORT
  392. if (callbacks.size()) wsPostSequence(client_id, std::move(callbacks));
  393. }
  394. }
  395. #endif // WEB_SUPPORT
  396. #if TERMINAL_SUPPORT
  397. void _haInitCommands() {
  398. terminalRegisterCommand(F("HA.CONFIG"), [](const terminal::CommandContext&) {
  399. for (unsigned char idx=0; idx<relayCount(); ++idx) {
  400. DynamicJsonBuffer jsonBuffer(1024);
  401. JsonObject& root = jsonBuffer.createObject();
  402. _haSwitchYaml(idx, root);
  403. DEBUG_MSG(root["haConfig"].as<String>().c_str());
  404. }
  405. #if SENSOR_SUPPORT
  406. for (unsigned char idx=0; idx<magnitudeCount(); ++idx) {
  407. DynamicJsonBuffer jsonBuffer(1024);
  408. JsonObject& root = jsonBuffer.createObject();
  409. _haSensorYaml(idx, root);
  410. DEBUG_MSG(root["haConfig"].as<String>().c_str());
  411. }
  412. #endif // SENSOR_SUPPORT
  413. DEBUG_MSG("\n");
  414. terminalOK();
  415. });
  416. terminalRegisterCommand(F("HA.SEND"), [](const terminal::CommandContext&) {
  417. setSetting("haEnabled", "1");
  418. _haConfigure();
  419. #if WEB_SUPPORT
  420. wsPost(_haWebSocketOnConnected);
  421. #endif
  422. terminalOK();
  423. });
  424. terminalRegisterCommand(F("HA.CLEAR"), [](const terminal::CommandContext&) {
  425. setSetting("haEnabled", "0");
  426. _haConfigure();
  427. #if WEB_SUPPORT
  428. wsPost(_haWebSocketOnConnected);
  429. #endif
  430. terminalOK();
  431. });
  432. }
  433. #endif
  434. // -----------------------------------------------------------------------------
  435. void haSetup() {
  436. _haConfigure();
  437. #if WEB_SUPPORT
  438. wsRegister()
  439. .onVisible(_haWebSocketOnVisible)
  440. .onConnected(_haWebSocketOnConnected)
  441. .onAction(_haWebSocketOnAction)
  442. .onKeyCheck(_haWebSocketOnKeyCheck);
  443. #endif
  444. #if TERMINAL_SUPPORT
  445. _haInitCommands();
  446. #endif
  447. // On MQTT connect check if we have something to send
  448. mqttRegister([](unsigned int type, const char * topic, const char * payload) {
  449. if (type == MQTT_CONNECT_EVENT) schedule_function(_haSend);
  450. if (type == MQTT_DISCONNECT_EVENT) _ha_send_flag = _ha_enabled;
  451. });
  452. // Main callbacks
  453. espurnaRegisterReload(_haConfigure);
  454. }
  455. #endif // HOMEASSISTANT_SUPPORT