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.

570 lines
16 KiB

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