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.

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