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.

563 lines
18 KiB

  1. /*
  2. TUYA MODULE
  3. Copyright (C) 2019 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
  4. */
  5. // ref: https://docs.tuya.com/en/mcu/mcu-protocol.html
  6. #include "tuya.h"
  7. #if TUYA_SUPPORT
  8. #include "broker.h"
  9. #include "relay.h"
  10. #include "light.h"
  11. #include <functional>
  12. #include <queue>
  13. #include <StreamString.h>
  14. #include "tuya_types.h"
  15. #include "tuya_transport.h"
  16. #include "tuya_dataframe.h"
  17. #include "tuya_protocol.h"
  18. #include "tuya_util.h"
  19. namespace Tuya {
  20. constexpr size_t SERIAL_SPEED { 9600u };
  21. constexpr unsigned char SWITCH_MAX { 8u };
  22. constexpr unsigned char DIMMER_MAX { 5u };
  23. constexpr uint32_t DISCOVERY_TIMEOUT { 1500u };
  24. constexpr uint32_t HEARTBEAT_SLOW { 9000u };
  25. constexpr uint32_t HEARTBEAT_FAST { 3000u };
  26. // --------------------------------------------
  27. struct dp_states_filter_t {
  28. using type = unsigned char;
  29. static const type NONE = 0;
  30. static const type BOOL = 1 << 0;
  31. static const type INT = 1 << 1;
  32. static const type ALL = (INT | BOOL);
  33. static type clamp(type value) {
  34. return constrain(value, NONE, ALL);
  35. }
  36. };
  37. size_t getHeartbeatInterval(Heartbeat hb) {
  38. switch (hb) {
  39. case Heartbeat::FAST:
  40. return HEARTBEAT_FAST;
  41. case Heartbeat::SLOW:
  42. return HEARTBEAT_SLOW;
  43. case Heartbeat::NONE:
  44. default:
  45. return 0;
  46. }
  47. }
  48. uint8_t getWiFiState() {
  49. uint8_t state = wifiState();
  50. if (state & WIFI_STATE_SMARTCONFIG) return 0x00;
  51. if (state & WIFI_STATE_AP) return 0x01;
  52. if (state & WIFI_STATE_STA) return 0x04;
  53. return 0x02;
  54. }
  55. // TODO: is v2 required to modify pin assigments?
  56. void updatePins(uint8_t led, uint8_t rst) {
  57. setSetting("ledGPIO0", led);
  58. setSetting("btnGPIO0", rst);
  59. //espurnaReload();
  60. }
  61. // --------------------------------------------
  62. States<bool> switchStates(SWITCH_MAX);
  63. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  64. States<uint32_t> channelStates(DIMMER_MAX);
  65. #endif
  66. // Handle DP data from the MCU, mapping incoming DP ID to the specific relay / channel ID
  67. void applySwitch() {
  68. for (unsigned char id=0; id < switchStates.size(); ++id) {
  69. relayStatus(id, switchStates[id].value);
  70. }
  71. }
  72. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  73. void applyChannel() {
  74. for (unsigned char id=0; id < channelStates.size(); ++id) {
  75. lightChannel(id, channelStates[id].value);
  76. }
  77. lightUpdate(true, true);
  78. }
  79. #endif
  80. // --------------------------------------------
  81. Transport tuyaSerial(TUYA_SERIAL);
  82. std::queue<DataFrame> outputFrames;
  83. DiscoveryTimeout discoveryTimeout(DISCOVERY_TIMEOUT);
  84. bool transportDebug = false;
  85. bool configDone = false;
  86. bool reportWiFi = false;
  87. dp_states_filter_t::type filterDP = dp_states_filter_t::NONE;
  88. String product;
  89. void showProduct() {
  90. if (product.length()) DEBUG_MSG_P(PSTR("[TUYA] Product: %s\n"), product.c_str());
  91. }
  92. inline void dataframeDebugSend(const char* tag, const DataFrame& frame) {
  93. if (!transportDebug) return;
  94. StreamString out;
  95. Output writer(out, frame.length);
  96. writer.writeHex(frame.serialize());
  97. DEBUG_MSG("[TUYA] %s: %s\n", tag, out.c_str());
  98. }
  99. void sendHeartbeat(Heartbeat hb, State state) {
  100. static uint32_t last = 0;
  101. if (millis() - last > getHeartbeatInterval(hb)) {
  102. outputFrames.emplace(Command::Heartbeat);
  103. last = millis();
  104. }
  105. }
  106. void sendWiFiStatus() {
  107. if (!reportWiFi) return;
  108. outputFrames.emplace(
  109. Command::WiFiStatus, std::initializer_list<uint8_t> { getWiFiState() }
  110. );
  111. }
  112. void pushOrUpdateState(const Type type, const DataFrame& frame) {
  113. if (Type::BOOL == type) {
  114. const DataProtocol<bool> proto(frame);
  115. switchStates.pushOrUpdate(proto.id(), proto.value());
  116. //DEBUG_MSG_P(PSTR("[TUYA] apply BOOL id=%02u value=%s\n"), proto.id(), proto.value() ? "true" : "false");
  117. } else if (Type::INT == type) {
  118. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  119. const DataProtocol<uint32_t> proto(frame);
  120. channelStates.pushOrUpdate(proto.id(), proto.value());
  121. //DEBUG_MSG_P(PSTR("[TUYA] apply INT id=%02u value=%u\n"), proto.id(), proto.value());
  122. #endif
  123. }
  124. }
  125. // XXX: sometimes we need to ignore incoming state, when not in discovery mode
  126. // ref: https://github.com/xoseperez/espurna/issues/1729#issuecomment-509234195
  127. void updateState(const Type type, const DataFrame& frame) {
  128. if (Type::BOOL == type) {
  129. if (filterDP & dp_states_filter_t::BOOL) return;
  130. const DataProtocol<bool> proto(frame);
  131. switchStates.update(proto.id(), proto.value());
  132. } else if (Type::INT == type) {
  133. if (filterDP & dp_states_filter_t::INT) return;
  134. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  135. const DataProtocol<uint32_t> proto(frame);
  136. channelStates.update(proto.id(), proto.value());
  137. #endif
  138. }
  139. }
  140. void processDP(State state, const DataFrame& frame) {
  141. // TODO: do not log protocol errors without transport debug enabled
  142. if (!frame.length) {
  143. DEBUG_MSG_P(PSTR("[TUYA] DP frame must have data\n"));
  144. return;
  145. }
  146. const Type type {dataType(frame)};
  147. if (Type::UNKNOWN == type) {
  148. if (frame.length >= 2) {
  149. DEBUG_MSG_P(PSTR("[TUYA] Unknown DP id=%u type=%u\n"), frame[0], frame[1]);
  150. } else {
  151. DEBUG_MSG_P(PSTR("[TUYA] Invalid DP frame\n"));
  152. }
  153. return;
  154. }
  155. if (State::DISCOVERY == state) {
  156. discoveryTimeout.feed();
  157. pushOrUpdateState(type, frame);
  158. } else {
  159. updateState(type, frame);
  160. }
  161. }
  162. void processFrame(State& state, const Transport& buffer) {
  163. const DataFrame frame(buffer);
  164. dataframeDebugSend("<=", frame);
  165. // initial packet has 0, do the initial setup
  166. // all after that have 1. might be a good idea to re-do the setup when that happens on boot
  167. if (frame.commandEquals(Command::Heartbeat) && (frame.length == 1)) {
  168. if (State::HEARTBEAT == state) {
  169. if ((frame[0] == 0) || !configDone) {
  170. DEBUG_MSG_P(PSTR("[TUYA] Starting configuration ...\n"));
  171. state = State::QUERY_PRODUCT;
  172. return;
  173. } else {
  174. DEBUG_MSG_P(PSTR("[TUYA] Already configured\n"));
  175. state = State::IDLE;
  176. }
  177. }
  178. sendWiFiStatus();
  179. return;
  180. }
  181. if (frame.commandEquals(Command::QueryProduct) && frame.length) {
  182. if (product.length()) {
  183. product = "";
  184. }
  185. product.reserve(frame.length);
  186. for (unsigned int n = 0; n < frame.length; ++n) {
  187. product += static_cast<char>(frame[n]);
  188. }
  189. showProduct();
  190. state = State::QUERY_MODE;
  191. return;
  192. }
  193. if (frame.commandEquals(Command::QueryMode)) {
  194. // first and second byte are GPIO pin for WiFi status and RST respectively
  195. if (frame.length == 2) {
  196. DEBUG_MSG_P(PSTR("[TUYA] Mode: ESP only, led=GPIO%02u rst=GPIO%02u\n"), frame[0], frame[1]);
  197. updatePins(frame[0], frame[1]);
  198. // ... or nothing. we need to report wifi status to the mcu via Command::WiFiStatus
  199. } else if (!frame.length) {
  200. DEBUG_MSG_P(PSTR("[TUYA] Mode: ESP & MCU\n"));
  201. reportWiFi = true;
  202. sendWiFiStatus();
  203. }
  204. state = State::QUERY_DP;
  205. return;
  206. }
  207. if (frame.commandEquals(Command::WiFiResetCfg) && !frame.length) {
  208. DEBUG_MSG_P(PSTR("[TUYA] WiFi reset request\n"));
  209. outputFrames.emplace(Command::WiFiResetCfg);
  210. return;
  211. }
  212. if (frame.commandEquals(Command::WiFiResetSelect) && (frame.length == 1)) {
  213. DEBUG_MSG_P(PSTR("[TUYA] WiFi configuration mode request: %s\n"),
  214. (frame[0] == 0) ? "Smart Config" : "AP");
  215. outputFrames.emplace(Command::WiFiResetSelect);
  216. return;
  217. }
  218. if (frame.commandEquals(Command::ReportDP) && frame.length) {
  219. processDP(state, frame);
  220. if (state == State::DISCOVERY) return;
  221. if (state == State::HEARTBEAT) return;
  222. state = State::IDLE;
  223. return;
  224. }
  225. }
  226. void processSerial(State& state) {
  227. while (tuyaSerial.available()) {
  228. tuyaSerial.read();
  229. if (tuyaSerial.done()) {
  230. processFrame(state, tuyaSerial);
  231. tuyaSerial.reset();
  232. }
  233. if (tuyaSerial.full()) {
  234. tuyaSerial.rewind();
  235. tuyaSerial.reset();
  236. }
  237. }
  238. }
  239. // Push local state data, mapping it to the appropriate DP
  240. void tuyaSendSwitch(unsigned char id) {
  241. if (id >= switchStates.size()) return;
  242. outputFrames.emplace(
  243. Command::SetDP, DataProtocol<bool>(switchStates[id].dp, switchStates[id].value).serialize()
  244. );
  245. }
  246. void tuyaSendSwitch(unsigned char id, bool value) {
  247. if (id >= switchStates.size()) return;
  248. if (value == switchStates[id].value) return;
  249. switchStates[id].value = value;
  250. tuyaSendSwitch(id);
  251. }
  252. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  253. void tuyaSendChannel(unsigned char id) {
  254. if (id >= channelStates.size()) return;
  255. outputFrames.emplace(
  256. Command::SetDP, DataProtocol<uint32_t>(channelStates[id].dp, channelStates[id].value).serialize()
  257. );
  258. }
  259. void tuyaSendChannel(unsigned char id, unsigned int value) {
  260. if (id >= channelStates.size()) return;
  261. if (value == channelStates[id].value) return;
  262. channelStates[id].value = value;
  263. tuyaSendChannel(id);
  264. }
  265. #endif
  266. void brokerCallback(const String& topic, unsigned char id, unsigned int value) {
  267. // Only process status messages for switches and channels
  268. if (!topic.equals(MQTT_TOPIC_CHANNEL)
  269. && !topic.equals(MQTT_TOPIC_RELAY)) {
  270. return;
  271. }
  272. #if (RELAY_PROVIDER == RELAY_PROVIDER_LIGHT) && (LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA)
  273. if (topic.equals(MQTT_TOPIC_CHANNEL)) {
  274. if (lightState(id) != switchStates[id].value) {
  275. tuyaSendSwitch(id, value > 0);
  276. }
  277. if (lightState(id)) tuyaSendChannel(id, value);
  278. return;
  279. }
  280. if (topic.equals(MQTT_TOPIC_RELAY)) {
  281. if (lightState(id) != switchStates[id].value) {
  282. tuyaSendSwitch(id, bool(value));
  283. }
  284. if (lightState(id)) tuyaSendChannel(id, value);
  285. return;
  286. }
  287. #elif (LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA)
  288. if (topic.equals(MQTT_TOPIC_CHANNEL)) {
  289. tuyaSendChannel(id, value);
  290. return;
  291. }
  292. if (topic.equals(MQTT_TOPIC_RELAY)) {
  293. tuyaSendSwitch(id, bool(value));
  294. return;
  295. }
  296. #else
  297. if (topic.equals(MQTT_TOPIC_RELAY)) {
  298. tuyaSendSwitch(id, bool(value));
  299. return;
  300. }
  301. #endif
  302. }
  303. // Main loop state machine. Process input data and manage output queue
  304. void tuyaLoop() {
  305. static State state = State::INIT;
  306. // running this before anything else to quickly switch to the required state
  307. processSerial(state);
  308. // go through the initial setup step-by-step, as described in
  309. // https://docs.tuya.com/en/mcu/mcu-protocol.html#21-basic-protocol
  310. switch (state) {
  311. // flush serial buffer before transmitting anything
  312. // send fast heartbeat until mcu responds with something
  313. case State::INIT:
  314. tuyaSerial.rewind();
  315. state = State::HEARTBEAT;
  316. case State::HEARTBEAT:
  317. sendHeartbeat(Heartbeat::FAST, state);
  318. break;
  319. // general info about the device (which we don't care about)
  320. case State::QUERY_PRODUCT:
  321. {
  322. outputFrames.emplace(Command::QueryProduct);
  323. state = State::IDLE;
  324. break;
  325. }
  326. // whether we control the led & button or not
  327. // TODO: make updatePins() do something!
  328. case State::QUERY_MODE:
  329. {
  330. outputFrames.emplace(Command::QueryMode);
  331. state = State::IDLE;
  332. break;
  333. }
  334. // full read-out of the data protocol values
  335. case State::QUERY_DP:
  336. {
  337. DEBUG_MSG_P(PSTR("[TUYA] Starting discovery\n"));
  338. outputFrames.emplace(Command::QueryDP);
  339. discoveryTimeout.feed();
  340. state = State::DISCOVERY;
  341. break;
  342. }
  343. // parse known data protocols until discovery timeout expires
  344. case State::DISCOVERY:
  345. {
  346. if (discoveryTimeout) {
  347. DEBUG_MSG_P(PSTR("[TUYA] Discovery finished\n"));
  348. relaySetupDummy(switchStates.size(), true);
  349. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  350. lightSetupChannels(channelStates.size());
  351. #endif
  352. configDone = true;
  353. state = State::IDLE;
  354. }
  355. break;
  356. }
  357. // initial config is done, only doing heartbeat periodically
  358. case State::IDLE:
  359. {
  360. if (switchStates.changed()) applySwitch();
  361. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  362. if (channelStates.changed()) applyChannel();
  363. #endif
  364. sendHeartbeat(Heartbeat::SLOW, state);
  365. break;
  366. }
  367. }
  368. if (TUYA_SERIAL && !outputFrames.empty()) {
  369. const DataFrame frame = std::move(outputFrames.front());
  370. dataframeDebugSend("=>", frame);
  371. tuyaSerial.write(frame.serialize());
  372. outputFrames.pop();
  373. }
  374. }
  375. // Predefined DP<->SWITCH, DP<->CHANNEL associations
  376. // Respective provider setup should be called before state restore,
  377. // so we can use dummy values
  378. void initBrokerCallback() {
  379. static bool done = false;
  380. if (done) {
  381. return;
  382. }
  383. ::StatusBroker::Register(brokerCallback);
  384. done = true;
  385. }
  386. void tuyaSetupSwitch() {
  387. initBrokerCallback();
  388. for (unsigned char n = 0; n < switchStates.capacity(); ++n) {
  389. if (!hasSetting({"tuyaSwitch", n})) break;
  390. const auto dp = getSetting({"tuyaSwitch", n}, 0);
  391. switchStates.pushOrUpdate(dp, false);
  392. }
  393. relaySetupDummy(switchStates.size());
  394. if (switchStates.size()) configDone = true;
  395. }
  396. void tuyaSyncSwitchStatus() {
  397. for (unsigned char n = 0; n < switchStates.size(); ++n) {
  398. switchStates[n].value = relayStatus(n);
  399. }
  400. }
  401. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  402. void tuyaSetupLight() {
  403. initBrokerCallback();
  404. for (unsigned char n = 0; n < channelStates.capacity(); ++n) {
  405. if (!hasSetting({"tuyaChannel", n})) break;
  406. const auto dp = getSetting({"tuyaChannel", n}, 0);
  407. channelStates.pushOrUpdate(dp, 0);
  408. }
  409. lightSetupChannels(channelStates.size());
  410. if (channelStates.size()) configDone = true;
  411. }
  412. #endif
  413. void tuyaSetup() {
  414. // Print all known DP associations
  415. #if TERMINAL_SUPPORT
  416. terminalRegisterCommand(F("TUYA.SHOW"), [](Embedis* e) {
  417. static const char fmt[] PROGMEM = "%12s%u => dp=%u value=%u\n";
  418. showProduct();
  419. for (unsigned char id=0; id < switchStates.size(); ++id) {
  420. DEBUG_MSG_P(fmt, "tuyaSwitch", id, switchStates[id].dp, switchStates[id].value);
  421. }
  422. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  423. for (unsigned char id=0; id < channelStates.size(); ++id) {
  424. DEBUG_MSG_P(fmt, "tuyaChannel", id, channelStates[id].dp, channelStates[id].value);
  425. }
  426. #endif
  427. });
  428. terminalRegisterCommand(F("TUYA.SAVE"), [](Embedis* e) {
  429. DEBUG_MSG_P(PSTR("[TUYA] Saving current configuration ...\n"));
  430. for (unsigned char n=0; n < switchStates.size(); ++n) {
  431. setSetting({"tuyaSwitch", n}, switchStates[n].dp);
  432. }
  433. #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
  434. for (unsigned char n=0; n < channelStates.size(); ++n) {
  435. setSetting({"tuyaChannel", n}, channelStates[n].dp);
  436. }
  437. #endif
  438. });
  439. #endif
  440. // Filtering for incoming data
  441. auto filter_raw = getSetting("tuyaFilter", dp_states_filter_t::NONE);
  442. filterDP = dp_states_filter_t::clamp(filter_raw);
  443. // Print all IN and OUT messages
  444. transportDebug = getSetting("tuyaDebug", true);
  445. // Install main loop method and WiFiStatus ping (only works with specific mode)
  446. TUYA_SERIAL.begin(SERIAL_SPEED);
  447. ::espurnaRegisterLoop(tuyaLoop);
  448. ::wifiRegister([](justwifi_messages_t code, char * parameter) {
  449. if ((MESSAGE_CONNECTED == code) || (MESSAGE_DISCONNECTED == code)) {
  450. sendWiFiStatus();
  451. }
  452. });
  453. }
  454. }
  455. #endif // TUYA_SUPPORT