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.

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