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.

851 lines
20 KiB

  1. /*
  2. HOME ASSISTANT MODULE
  3. Original module
  4. Copyright (C) 2017-2019 by Xose Pérez <xose dot perez at gmail dot com>
  5. Reworked queueing and RAM usage reduction
  6. Copyright (C) 2019-2021 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
  7. */
  8. #include "espurna.h"
  9. #if HOMEASSISTANT_SUPPORT
  10. #include "light.h"
  11. #include "mqtt.h"
  12. #include "relay.h"
  13. #include "sensor.h"
  14. #include "ws.h"
  15. #include <ArduinoJson.h>
  16. #include <forward_list>
  17. #include <memory>
  18. namespace homeassistant {
  19. // Output is supposed to be used as both part of the MQTT config topic and the `uniq_id` field
  20. // TODO: manage UTF8 strings? in case we somehow receive `desc`, like it was done originally
  21. String normalize_ascii(String&& value, bool lower = false) {
  22. auto* ptr = const_cast<char*>(value.c_str());
  23. for (;;) {
  24. switch (*ptr) {
  25. case '\0':
  26. goto return_value;
  27. case '0' ... '9':
  28. case 'a' ... 'z':
  29. break;
  30. case 'A' ... 'Z':
  31. if (lower) {
  32. *ptr += 32;
  33. }
  34. break;
  35. default:
  36. *ptr = '_';
  37. break;
  38. }
  39. ++ptr;
  40. }
  41. return_value:
  42. return std::move(value);
  43. }
  44. // Common data used across the discovery payloads.
  45. // ref. https://developers.home-assistant.io/docs/entity_registry_index/
  46. class Device {
  47. public:
  48. struct Strings {
  49. Strings() = delete;
  50. Strings(const Strings&) = delete;
  51. Strings(Strings&&) = default;
  52. Strings(String&& prefix_, String&& name_, const String& identifier_, const String& version_, const String& manufacturer_, const String& device_) :
  53. prefix(std::move(prefix_)),
  54. name(std::move(name_)),
  55. identifier(identifier_),
  56. version(version_),
  57. manufacturer(manufacturer_),
  58. device(device_)
  59. {
  60. name = normalize_ascii(std::move(name));
  61. identifier = normalize_ascii(std::move(identifier), true);
  62. }
  63. String prefix;
  64. String name;
  65. String identifier;
  66. String version;
  67. String manufacturer;
  68. String device;
  69. };
  70. using StringsPtr = std::unique_ptr<Strings>;
  71. static constexpr size_t BufferSize { JSON_ARRAY_SIZE(1) + JSON_OBJECT_SIZE(1) + JSON_OBJECT_SIZE(5) };
  72. using Buffer = StaticJsonBuffer<BufferSize>;
  73. using BufferPtr = std::unique_ptr<Buffer>;
  74. Device() = delete;
  75. Device(const Device&) = delete;
  76. Device(Device&&) = default;
  77. Device(String&& prefix, String&& name, const String& identifier, const String& version, const String& manufacturer, const String& device) :
  78. _strings(std::make_unique<Strings>(std::move(prefix), std::move(name), identifier, version, manufacturer, device)),
  79. _buffer(std::make_unique<Buffer>()),
  80. _root(_buffer->createObject())
  81. {
  82. JsonArray& ids = _root.createNestedArray("ids");
  83. ids.add(_strings->identifier.c_str());
  84. _root["name"] = _strings->name.c_str();
  85. _root["sw"] = _strings->version.c_str();
  86. _root["mf"] = _strings->manufacturer.c_str();
  87. _root["mdl"] = _strings->device.c_str();
  88. }
  89. const String& name() const {
  90. return _strings->name;
  91. }
  92. const String& prefix() const {
  93. return _strings->prefix;
  94. }
  95. const String& identifier() const {
  96. return _strings->identifier;
  97. }
  98. JsonObject& root() {
  99. return _root;
  100. }
  101. private:
  102. StringsPtr _strings;
  103. BufferPtr _buffer;
  104. JsonObject& _root;
  105. };
  106. using DevicePtr = std::unique_ptr<Device>;
  107. using JsonBufferPtr = std::unique_ptr<DynamicJsonBuffer>;
  108. class Context {
  109. public:
  110. Context() = delete;
  111. Context(DevicePtr&& device, size_t capacity) :
  112. _device(std::move(device)),
  113. _capacity(capacity)
  114. {}
  115. const String& name() const {
  116. return _device->name();
  117. }
  118. const String& prefix() const {
  119. return _device->prefix();
  120. }
  121. const String& identifier() const {
  122. return _device->identifier();
  123. }
  124. JsonObject& device() {
  125. return _device->root();
  126. }
  127. void reset() {
  128. _json = std::make_unique<DynamicJsonBuffer>(_capacity);
  129. }
  130. size_t capacity() const {
  131. return _capacity;
  132. }
  133. size_t size() {
  134. if (_json) {
  135. return _json->size();
  136. }
  137. return 0;
  138. }
  139. JsonObject& makeObject() {
  140. if (!_json) {
  141. reset();
  142. }
  143. return _json->createObject();
  144. }
  145. private:
  146. String _prefix;
  147. DevicePtr _device;
  148. JsonBufferPtr _json;
  149. size_t _capacity { 0ul };
  150. };
  151. Context makeContext() {
  152. auto device = std::make_unique<Device>(
  153. getSetting("haPrefix", HOMEASSISTANT_PREFIX),
  154. getSetting("hostname", getIdentifier()),
  155. getIdentifier(),
  156. getVersion(),
  157. getManufacturer(),
  158. getDevice()
  159. );
  160. return Context(std::move(device), 2048ul);
  161. }
  162. String quote(String&& value) {
  163. if (value.equalsIgnoreCase("y")
  164. || value.equalsIgnoreCase("n")
  165. || value.equalsIgnoreCase("yes")
  166. || value.equalsIgnoreCase("no")
  167. || value.equalsIgnoreCase("true")
  168. || value.equalsIgnoreCase("false")
  169. || value.equalsIgnoreCase("on")
  170. || value.equalsIgnoreCase("off")
  171. ) {
  172. String result;
  173. result.reserve(value.length() + 2);
  174. result += '"';
  175. result += value;
  176. result += '"';
  177. return result;
  178. }
  179. return std::move(value);
  180. }
  181. // - Discovery object is expected to accept Context reference as input
  182. // (and all implementations do just that)
  183. // - topic() & message() return refs, since those *may* be called multiple times before advancing to the next 'entity'
  184. // - We use short-hand names right away, since we don't expect this to be used to generate yaml
  185. // - In case the object uses the JSON makeObject() as state, make sure we don't use it (state)
  186. // and the object itself after next() or ok() return false
  187. // - Make sure JSON state is not created on construction, but lazy-loaded as soon as it is needed.
  188. // Meaning, we don't cause invalid refs immediatly when there are more than 1 discovery object present and we reset the storage.
  189. class Discovery {
  190. public:
  191. virtual ~Discovery() {
  192. }
  193. virtual bool ok() const = 0;
  194. virtual const String& topic() = 0;
  195. virtual const String& message() = 0;
  196. virtual bool next() = 0;
  197. };
  198. #if RELAY_SUPPORT
  199. struct RelayContext {
  200. String availability;
  201. String payload_available;
  202. String payload_not_available;
  203. String payload_on;
  204. String payload_off;
  205. };
  206. RelayContext makeRelayContext() {
  207. return {
  208. mqttTopic(MQTT_TOPIC_STATUS, false),
  209. quote(mqttPayloadStatus(true)),
  210. quote(mqttPayloadStatus(false)),
  211. quote(relayPayload(PayloadStatus::On)),
  212. quote(relayPayload(PayloadStatus::Off))
  213. };
  214. }
  215. class RelayDiscovery : public Discovery {
  216. public:
  217. RelayDiscovery() = delete;
  218. explicit RelayDiscovery(Context& ctx) :
  219. _ctx(ctx),
  220. _relay(makeRelayContext()),
  221. _relays(relayCount())
  222. {
  223. if (!_relays) {
  224. return;
  225. }
  226. auto& json = root();
  227. json["dev"] = _ctx.device();
  228. json["avty_t"] = _relay.availability.c_str();
  229. json["pl_avail"] = _relay.payload_available.c_str();
  230. json["pl_not_avail"] = _relay.payload_not_available.c_str();
  231. json["pl_on"] = _relay.payload_on.c_str();
  232. json["pl_off"] = _relay.payload_off.c_str();
  233. }
  234. JsonObject& root() {
  235. if (!_root) {
  236. _root = &_ctx.makeObject();
  237. }
  238. return *_root;
  239. }
  240. bool ok() const override {
  241. return (_relays) && (_index < _relays);
  242. }
  243. const String& uniqueId() {
  244. if (!_unique_id.length()) {
  245. _unique_id = _ctx.identifier() + '_' + F("relay") + '_' + _index;
  246. }
  247. return _unique_id;
  248. }
  249. const String& topic() override {
  250. if (!_topic.length()) {
  251. _topic = _ctx.prefix();
  252. _topic += F("/switch/");
  253. _topic += uniqueId();
  254. _topic += F("/config");
  255. }
  256. return _topic;
  257. }
  258. const String& message() override {
  259. if (!_message.length()) {
  260. auto& json = root();
  261. json["uniq_id"] = uniqueId();
  262. json["name"] = _ctx.name() + ' ' + _index;
  263. json["stat_t"] = mqttTopic(MQTT_TOPIC_RELAY, _index, false);
  264. json["cmd_t"] = mqttTopic(MQTT_TOPIC_RELAY, _index, true);
  265. json.printTo(_message);
  266. }
  267. return _message;
  268. }
  269. bool next() override {
  270. if (_index < _relays) {
  271. auto current = _index;
  272. ++_index;
  273. if ((_index > current) && (_index < _relays)) {
  274. _unique_id = "";
  275. _topic = "";
  276. _message = "";
  277. return true;
  278. }
  279. }
  280. return false;
  281. }
  282. private:
  283. Context& _ctx;
  284. JsonObject* _root { nullptr };
  285. RelayContext _relay;
  286. unsigned char _index { 0u };
  287. unsigned char _relays { 0u };
  288. String _unique_id;
  289. String _topic;
  290. String _message;
  291. };
  292. #endif
  293. // Example payload:
  294. // {
  295. // "brightness": 255,
  296. // "color_temp": 155,
  297. // "color": {
  298. // "r": 255,
  299. // "g": 180,
  300. // "b": 200,
  301. // "x": 0.406,
  302. // "y": 0.301,
  303. // "h": 344.0,
  304. // "s": 29.412
  305. // },
  306. // "effect": "colorloop",
  307. // "state": "ON",
  308. // "transition": 2,
  309. // "white_value": 150
  310. // }
  311. // Notice that we only support JSON schema payloads, leaving it to the user to configure special
  312. // per-channel topics, as those don't really fit into the HASS idea of lights controls for a single device
  313. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  314. static_assert(
  315. (MQTT_LIBRARY != MQTT_LIBRARY_ASYNCMQTTCLIENT) ||
  316. ((TCP_MSS == 1460) && (MQTT_LIBRARY == MQTT_LIBRARY_ASYNCMQTTCLIENT)),
  317. "Can't reliably send / receive JSON payloads with small TCP buffers"
  318. );
  319. class LightDiscovery : public Discovery {
  320. public:
  321. explicit LightDiscovery(Context& ctx) :
  322. _ctx(ctx)
  323. {}
  324. JsonObject& root() {
  325. if (!_root) {
  326. _root = &_ctx.makeObject();
  327. }
  328. return *_root;
  329. }
  330. bool ok() const override {
  331. return true;
  332. }
  333. bool next() override {
  334. return false;
  335. }
  336. const String& uniqueId() {
  337. if (!_unique_id.length()) {
  338. _unique_id = _ctx.identifier() + '_' + F("light");
  339. }
  340. return _unique_id;
  341. }
  342. const String& topic() override {
  343. if (!_topic.length()) {
  344. _topic = _ctx.prefix();
  345. _topic += F("/light/");
  346. _topic += uniqueId();
  347. _topic += F("/config");
  348. }
  349. return _topic;
  350. }
  351. const String& message() override {
  352. if (!_message.length()) {
  353. auto& json = root();
  354. json["schema"] = "json";
  355. json["uniq_id"] = uniqueId();
  356. json["name"] = _ctx.name() + ' ' + F("Light");
  357. json["stat_t"] = mqttTopic(MQTT_TOPIC_LIGHT_JSON, false);
  358. json["cmd_t"] = mqttTopic(MQTT_TOPIC_LIGHT_JSON, true);
  359. json["avty_t"] = mqttTopic(MQTT_TOPIC_STATUS, false);
  360. json["pl_avail"] = quote(mqttPayloadStatus(true));
  361. json["pl_not_avail"] = quote(mqttPayloadStatus(false));
  362. // ref. SUPPORT_... flags throughout the light component
  363. // send `true` for every payload we support sending / receiving
  364. // already enabled by default: "state", "transition"
  365. // TODO: handle "rgb", "color_temp" and "white_value"
  366. json["brightness"] = true;
  367. if (lightHasColor()) {
  368. json["rgb"] = true;
  369. }
  370. if (lightHasColor() || lightUseCCT()) {
  371. json["max_mireds"] = LIGHT_WARMWHITE_MIRED;
  372. json["min_mireds"] = LIGHT_COLDWHITE_MIRED;
  373. json["color_temp"] = true;
  374. }
  375. json.printTo(_message);
  376. }
  377. return _message;
  378. }
  379. private:
  380. Context& _ctx;
  381. JsonObject* _root { nullptr };
  382. String _unique_id;
  383. String _topic;
  384. String _message;
  385. };
  386. void publishLightJson() {
  387. if (!mqttConnected()) {
  388. return;
  389. }
  390. DynamicJsonBuffer buffer(512);
  391. JsonObject& root = buffer.createObject();
  392. root["state"] = lightState() ? "ON" : "OFF";
  393. root["brightness"] = lightBrightness();
  394. String message;
  395. root.printTo(message);
  396. String topic = mqttTopic(MQTT_TOPIC_LIGHT_JSON, false);
  397. mqttSendRaw(topic.c_str(), message.c_str(), false);
  398. }
  399. void receiveLightJson(char* payload) {
  400. DynamicJsonBuffer buffer(1024);
  401. JsonObject& root = buffer.parseObject(payload);
  402. if (!root.success()) {
  403. return;
  404. }
  405. if (!root.containsKey("state")) {
  406. return;
  407. }
  408. auto state = root["state"].as<String>();
  409. if (state == F("ON")) {
  410. lightState(true);
  411. } else if (state == F("OFF")) {
  412. lightState(false);
  413. } else {
  414. return;
  415. }
  416. unsigned long transition { lightTransitionTime() };
  417. if (root.containsKey("transition")) {
  418. auto seconds = root["transition"].as<float>();
  419. if (seconds > 0) {
  420. transition = static_cast<unsigned long>(seconds * 1000.0);
  421. }
  422. }
  423. if (root.containsKey("brightness")) {
  424. lightBrightness(root["brightness"].as<long>());
  425. }
  426. // TODO: handle "rgb", "color_temp" and "white_value"
  427. lightUpdate({transition, lightTransitionStep()});
  428. }
  429. #endif
  430. #if SENSOR_SUPPORT
  431. class SensorDiscovery : public Discovery {
  432. public:
  433. SensorDiscovery() = delete;
  434. explicit SensorDiscovery(Context& ctx) :
  435. _ctx(ctx),
  436. _magnitudes(magnitudeCount())
  437. {}
  438. JsonObject& root() {
  439. if (!_root) {
  440. _root = &_ctx.makeObject();
  441. }
  442. return *_root;
  443. }
  444. bool ok() const {
  445. return _index < _magnitudes;
  446. }
  447. const String& topic() override {
  448. if (!_topic.length()) {
  449. _topic = _ctx.prefix();
  450. _topic += F("/sensor/");
  451. _topic += uniqueId();
  452. _topic += F("/config");
  453. }
  454. return _topic;
  455. }
  456. const String& message() override {
  457. if (!_message.length()) {
  458. auto& json = root();
  459. json["dev"] = _ctx.device();
  460. json["uniq_id"] = uniqueId();
  461. json["name"] = _ctx.name() + ' ' + name() + ' ' + localId();
  462. json["stat_t"] = mqttTopic(magnitudeTopicIndex(_index).c_str(), false);
  463. json["unit_of_meas"] = magnitudeUnits(_index);
  464. json.printTo(_message);
  465. }
  466. return _message;
  467. }
  468. const String& name() {
  469. if (!_name.length()) {
  470. _name = magnitudeTopic(magnitudeType(_index));
  471. }
  472. return _name;
  473. }
  474. unsigned char localId() const {
  475. return magnitudeIndex(_index);
  476. }
  477. const String& uniqueId() {
  478. if (!_unique_id.length()) {
  479. _unique_id = _ctx.identifier() + '_' + name() + '_' + localId();
  480. }
  481. return _unique_id;
  482. }
  483. bool next() override {
  484. if (_index < _magnitudes) {
  485. auto current = _index;
  486. ++_index;
  487. if ((_index > current) && (_index < _magnitudes)) {
  488. _unique_id = "";
  489. _name = "";
  490. _topic = "";
  491. _message = "";
  492. return true;
  493. }
  494. }
  495. return false;
  496. }
  497. private:
  498. Context& _ctx;
  499. JsonObject* _root { nullptr };
  500. unsigned char _index { 0u };
  501. unsigned char _magnitudes { 0u };
  502. String _unique_id;
  503. String _name;
  504. String _topic;
  505. String _message;
  506. };
  507. #endif
  508. // Reworked discovery class. Continiously schedules itself until we have no more entities to send.
  509. // Topic and message are generated on demand and most of JSON payload is cached for re-use.
  510. // (both, to avoid manually generating JSON and to avoid possible UTF8 issues when concatenating char raw strings)
  511. class DiscoveryTask {
  512. public:
  513. using Entity = std::unique_ptr<Discovery>;
  514. using Entities = std::forward_list<Entity>;
  515. using Action = std::function<bool(const String&, const String&)>;
  516. static constexpr int Retries { 5 };
  517. DiscoveryTask(bool enabled, Action action) :
  518. _enabled(enabled),
  519. _action(action)
  520. {}
  521. void add(Entity&& entity) {
  522. _entities.push_front(std::move(entity));
  523. }
  524. template <typename T>
  525. void add() {
  526. _entities.push_front(std::make_unique<T>(_ctx));
  527. }
  528. Context& context() {
  529. return _ctx;
  530. }
  531. bool empty() const {
  532. return _entities.empty();
  533. }
  534. bool operator()() {
  535. if (!mqttConnected() || _entities.empty()) {
  536. return false;
  537. }
  538. auto& entity = _entities.front();
  539. if (!entity->ok()) {
  540. _entities.pop_front();
  541. _ctx.reset();
  542. return true;
  543. }
  544. const auto* topic = entity->topic().c_str();
  545. const auto* msg = _enabled
  546. ? entity->message().c_str()
  547. : "";
  548. auto res = _action(topic, msg);
  549. if (!res) {
  550. if (--_retry < 0) {
  551. DEBUG_MSG_P(PSTR("[HASS] Discovery failed after %d retries\n"), Retries);
  552. return false;
  553. }
  554. DEBUG_MSG_P(PSTR("[HASS] Sending failed, retrying %d / %d\n"), (Retries - _retry), Retries);
  555. return true;
  556. }
  557. _retry = Retries;
  558. if (entity->next()) {
  559. return true;
  560. }
  561. _entities.pop_front();
  562. if (!_entities.empty()) {
  563. _ctx.reset();
  564. return true;
  565. }
  566. return false;
  567. }
  568. private:
  569. bool _enabled { false };
  570. int _retry { Retries };
  571. Context _ctx { makeContext() };
  572. Action _action;
  573. Entities _entities;
  574. };
  575. namespace internal {
  576. constexpr unsigned long interval { 100ul };
  577. bool retain { false };
  578. bool enabled { false };
  579. bool sent { false };
  580. Ticker timer;
  581. } // namespace internal
  582. bool mqttSend(const String& topic, const String& message) {
  583. return ::mqttSendRaw(topic.c_str(), message.c_str(), internal::retain) > 0;
  584. }
  585. bool enabled() {
  586. return internal::enabled;
  587. }
  588. void publishDiscovery() {
  589. static bool busy { false };
  590. if (busy) {
  591. return;
  592. }
  593. if (internal::sent) {
  594. return;
  595. }
  596. bool current = internal::enabled;
  597. internal::enabled = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
  598. internal::retain = getSetting("haRetain", 1 == HOMEASSISTANT_RETAIN);
  599. if (current != internal::enabled) {
  600. auto task = std::make_shared<DiscoveryTask>(internal::enabled, homeassistant::mqttSend);
  601. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  602. task->add<LightDiscovery>();
  603. #endif
  604. #if RELAY_SUPPORT
  605. task->add<RelayDiscovery>();
  606. #endif
  607. #if SENSOR_SUPPORT
  608. task->add<SensorDiscovery>();
  609. #endif
  610. if (task->empty()) {
  611. return;
  612. }
  613. internal::timer.attach_ms(internal::interval, [task]() {
  614. if (!(*task)()) {
  615. internal::timer.detach();
  616. internal::sent = true;
  617. busy = false;
  618. }
  619. });
  620. }
  621. }
  622. void mqttCallback(unsigned int type, const char* topic, char* payload) {
  623. if (MQTT_DISCONNECT_EVENT == type) {
  624. internal::sent = false;
  625. return;
  626. }
  627. if (MQTT_CONNECT_EVENT == type) {
  628. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  629. ::mqttSubscribe(MQTT_TOPIC_LIGHT_JSON);
  630. schedule_function(publishLightJson);
  631. #endif
  632. schedule_function(publishDiscovery);
  633. return;
  634. }
  635. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  636. if (type == MQTT_MESSAGE_EVENT) {
  637. String t = ::mqttMagnitude(topic);
  638. if (t.equals(MQTT_TOPIC_LIGHT_JSON)) {
  639. receiveLightJson(payload);
  640. }
  641. return;
  642. }
  643. #endif
  644. }
  645. namespace web {
  646. #if WEB_SUPPORT
  647. void onVisible(JsonObject& root) {
  648. root["haVisible"] = 1;
  649. }
  650. void onConnected(JsonObject& root) {
  651. root["haPrefix"] = getSetting("haPrefix", HOMEASSISTANT_PREFIX);
  652. root["haEnabled"] = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
  653. root["haRetain"] = getSetting("haRetain", 1 == HOMEASSISTANT_RETAIN);
  654. }
  655. bool onKeyCheck(const char* key, JsonVariant& value) {
  656. return (strncmp(key, "ha", 2) == 0);
  657. }
  658. #endif
  659. } // namespace web
  660. } // namespace homeassistant
  661. void haSetup() {
  662. #if WEB_SUPPORT
  663. wsRegister()
  664. .onVisible(homeassistant::web::onVisible)
  665. .onConnected(homeassistant::web::onConnected)
  666. .onKeyCheck(homeassistant::web::onKeyCheck);
  667. #endif
  668. #if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
  669. lightSetReportListener(homeassistant::publishLightJson);
  670. #endif
  671. mqttRegister(homeassistant::mqttCallback);
  672. espurnaRegisterReload([]() {
  673. if (mqttConnected()) {
  674. homeassistant::publishDiscovery();
  675. }
  676. });
  677. }
  678. #endif // HOMEASSISTANT_SUPPORT