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.

579 lines
17 KiB

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