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.

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