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.

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