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.

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