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.

564 lines
18 KiB

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