/* TUYA MODULE Copyright (C) 2019 by Maxim Prokhorov */ // ref: https://docs.tuya.com/en/mcu/mcu-protocol.html #include "tuya.h" #if TUYA_SUPPORT #include "broker.h" #include "light.h" #include "relay.h" #include "rpc.h" #include #include #include #include "tuya_types.h" #include "tuya_transport.h" #include "tuya_dataframe.h" #include "tuya_protocol.h" #include "tuya_util.h" namespace Tuya { constexpr size_t SERIAL_SPEED { 9600u }; constexpr unsigned char SWITCH_MAX { 8u }; constexpr unsigned char DIMMER_MAX { 5u }; constexpr uint32_t DISCOVERY_TIMEOUT { 1500u }; constexpr uint32_t HEARTBEAT_SLOW { 9000u }; constexpr uint32_t HEARTBEAT_FAST { 3000u }; // -------------------------------------------- struct dp_states_filter_t { using type = unsigned char; static const type NONE = 0; static const type BOOL = 1 << 0; static const type INT = 1 << 1; static const type ALL = (INT | BOOL); static type clamp(type value) { return constrain(value, NONE, ALL); } }; size_t getHeartbeatInterval(Heartbeat hb) { switch (hb) { case Heartbeat::FAST: return HEARTBEAT_FAST; case Heartbeat::SLOW: return HEARTBEAT_SLOW; case Heartbeat::NONE: default: return 0; } } uint8_t getWiFiState() { uint8_t state = wifiState(); if (state & WIFI_STATE_SMARTCONFIG) return 0x00; if (state & WIFI_STATE_AP) return 0x01; if (state & WIFI_STATE_STA) return 0x04; return 0x02; } // TODO: is v2 required to modify pin assigments? void updatePins(uint8_t led, uint8_t rst) { setSetting("ledGPIO0", led); setSetting("btnGPIO0", rst); //espurnaReload(); } // -------------------------------------------- States switchStates(SWITCH_MAX); #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA States channelStates(DIMMER_MAX); #endif // Handle DP data from the MCU, mapping incoming DP ID to the specific relay / channel ID void applySwitch() { for (unsigned char id=0; id < switchStates.size(); ++id) { relayStatus(id, switchStates[id].value); } } #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA void applyChannel() { for (unsigned char id=0; id < channelStates.size(); ++id) { lightChannel(id, channelStates[id].value); } lightUpdate(true, true); } #endif // -------------------------------------------- Transport tuyaSerial(TUYA_SERIAL); std::queue outputFrames; DiscoveryTimeout discoveryTimeout(DISCOVERY_TIMEOUT); bool transportDebug = false; bool configDone = false; bool reportWiFi = false; dp_states_filter_t::type filterDP = dp_states_filter_t::NONE; String product; void showProduct() { if (product.length()) DEBUG_MSG_P(PSTR("[TUYA] Product: %s\n"), product.c_str()); } inline void dataframeDebugSend(const char* tag, const DataFrame& frame) { if (!transportDebug) return; StreamString out; Output writer(out, frame.length); writer.writeHex(frame.serialize()); DEBUG_MSG("[TUYA] %s: %s\n", tag, out.c_str()); } void sendHeartbeat(Heartbeat hb, State state) { static uint32_t last = 0; if (millis() - last > getHeartbeatInterval(hb)) { outputFrames.emplace(Command::Heartbeat); last = millis(); } } void sendWiFiStatus() { if (!reportWiFi) return; outputFrames.emplace( Command::WiFiStatus, std::initializer_list { getWiFiState() } ); } void pushOrUpdateState(const Type type, const DataFrame& frame) { if (Type::BOOL == type) { const DataProtocol proto(frame); switchStates.pushOrUpdate(proto.id(), proto.value()); //DEBUG_MSG_P(PSTR("[TUYA] apply BOOL id=%02u value=%s\n"), proto.id(), proto.value() ? "true" : "false"); } else if (Type::INT == type) { #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA const DataProtocol proto(frame); channelStates.pushOrUpdate(proto.id(), proto.value()); //DEBUG_MSG_P(PSTR("[TUYA] apply INT id=%02u value=%u\n"), proto.id(), proto.value()); #endif } } // XXX: sometimes we need to ignore incoming state, when not in discovery mode // ref: https://github.com/xoseperez/espurna/issues/1729#issuecomment-509234195 void updateState(const Type type, const DataFrame& frame) { if (Type::BOOL == type) { if (filterDP & dp_states_filter_t::BOOL) return; const DataProtocol proto(frame); switchStates.update(proto.id(), proto.value()); } else if (Type::INT == type) { if (filterDP & dp_states_filter_t::INT) return; #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA const DataProtocol proto(frame); channelStates.update(proto.id(), proto.value()); #endif } } void processDP(State state, const DataFrame& frame) { // TODO: do not log protocol errors without transport debug enabled if (!frame.length) { DEBUG_MSG_P(PSTR("[TUYA] DP frame must have data\n")); return; } const Type type {dataType(frame)}; if (Type::UNKNOWN == type) { if (frame.length >= 2) { DEBUG_MSG_P(PSTR("[TUYA] Unknown DP id=%u type=%u\n"), frame[0], frame[1]); } else { DEBUG_MSG_P(PSTR("[TUYA] Invalid DP frame\n")); } return; } if (State::DISCOVERY == state) { discoveryTimeout.feed(); pushOrUpdateState(type, frame); } else { updateState(type, frame); } } void processFrame(State& state, const Transport& buffer) { const DataFrame frame(buffer); dataframeDebugSend("<=", frame); // initial packet has 0, do the initial setup // all after that have 1. might be a good idea to re-do the setup when that happens on boot if (frame.commandEquals(Command::Heartbeat) && (frame.length == 1)) { if (State::HEARTBEAT == state) { if ((frame[0] == 0) || !configDone) { DEBUG_MSG_P(PSTR("[TUYA] Starting configuration ...\n")); state = State::QUERY_PRODUCT; return; } else { DEBUG_MSG_P(PSTR("[TUYA] Already configured\n")); state = State::IDLE; } } sendWiFiStatus(); return; } if (frame.commandEquals(Command::QueryProduct) && frame.length) { if (product.length()) { product = ""; } product.reserve(frame.length); for (unsigned int n = 0; n < frame.length; ++n) { product += static_cast(frame[n]); } showProduct(); state = State::QUERY_MODE; return; } if (frame.commandEquals(Command::QueryMode)) { // first and second byte are GPIO pin for WiFi status and RST respectively if (frame.length == 2) { DEBUG_MSG_P(PSTR("[TUYA] Mode: ESP only, led=GPIO%02u rst=GPIO%02u\n"), frame[0], frame[1]); updatePins(frame[0], frame[1]); // ... or nothing. we need to report wifi status to the mcu via Command::WiFiStatus } else if (!frame.length) { DEBUG_MSG_P(PSTR("[TUYA] Mode: ESP & MCU\n")); reportWiFi = true; sendWiFiStatus(); } state = State::QUERY_DP; return; } if (frame.commandEquals(Command::WiFiResetCfg) && !frame.length) { DEBUG_MSG_P(PSTR("[TUYA] WiFi reset request\n")); outputFrames.emplace(Command::WiFiResetCfg); return; } if (frame.commandEquals(Command::WiFiResetSelect) && (frame.length == 1)) { DEBUG_MSG_P(PSTR("[TUYA] WiFi configuration mode request: %s\n"), (frame[0] == 0) ? "Smart Config" : "AP"); outputFrames.emplace(Command::WiFiResetSelect); return; } if (frame.commandEquals(Command::ReportDP) && frame.length) { processDP(state, frame); if (state == State::DISCOVERY) return; if (state == State::HEARTBEAT) return; state = State::IDLE; return; } } void processSerial(State& state) { while (tuyaSerial.available()) { tuyaSerial.read(); if (tuyaSerial.done()) { processFrame(state, tuyaSerial); tuyaSerial.reset(); } if (tuyaSerial.full()) { tuyaSerial.rewind(); tuyaSerial.reset(); } } } // Push local state data, mapping it to the appropriate DP void tuyaSendSwitch(unsigned char id) { if (id >= switchStates.size()) return; outputFrames.emplace( Command::SetDP, DataProtocol(switchStates[id].dp, switchStates[id].value).serialize() ); } void tuyaSendSwitch(unsigned char id, bool value) { if (id >= switchStates.size()) return; if (value == switchStates[id].value) return; switchStates[id].value = value; tuyaSendSwitch(id); } #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA void tuyaSendChannel(unsigned char id) { if (id >= channelStates.size()) return; outputFrames.emplace( Command::SetDP, DataProtocol(channelStates[id].dp, channelStates[id].value).serialize() ); } void tuyaSendChannel(unsigned char id, unsigned int value) { if (id >= channelStates.size()) return; if (value == channelStates[id].value) return; channelStates[id].value = value; tuyaSendChannel(id); } #endif void brokerCallback(const String& topic, unsigned char id, unsigned int value) { // Only process status messages for switches and channels if (!topic.equals(MQTT_TOPIC_CHANNEL) && !topic.equals(MQTT_TOPIC_RELAY)) { return; } #if (RELAY_PROVIDER == RELAY_PROVIDER_LIGHT) && (LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA) if (topic.equals(MQTT_TOPIC_CHANNEL)) { if (lightState(id) != switchStates[id].value) { tuyaSendSwitch(id, value > 0); } if (lightState(id)) tuyaSendChannel(id, value); return; } if (topic.equals(MQTT_TOPIC_RELAY)) { if (lightState(id) != switchStates[id].value) { tuyaSendSwitch(id, bool(value)); } if (lightState(id)) tuyaSendChannel(id, value); return; } #elif (LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA) if (topic.equals(MQTT_TOPIC_CHANNEL)) { tuyaSendChannel(id, value); return; } if (topic.equals(MQTT_TOPIC_RELAY)) { tuyaSendSwitch(id, bool(value)); return; } #else if (topic.equals(MQTT_TOPIC_RELAY)) { tuyaSendSwitch(id, bool(value)); return; } #endif } // Main loop state machine. Process input data and manage output queue void tuyaLoop() { static State state = State::INIT; // running this before anything else to quickly switch to the required state processSerial(state); // go through the initial setup step-by-step, as described in // https://docs.tuya.com/en/mcu/mcu-protocol.html#21-basic-protocol switch (state) { // flush serial buffer before transmitting anything // send fast heartbeat until mcu responds with something case State::INIT: tuyaSerial.rewind(); state = State::HEARTBEAT; case State::HEARTBEAT: sendHeartbeat(Heartbeat::FAST, state); break; // general info about the device (which we don't care about) case State::QUERY_PRODUCT: { outputFrames.emplace(Command::QueryProduct); state = State::IDLE; break; } // whether we control the led & button or not // TODO: make updatePins() do something! case State::QUERY_MODE: { outputFrames.emplace(Command::QueryMode); state = State::IDLE; break; } // full read-out of the data protocol values case State::QUERY_DP: { DEBUG_MSG_P(PSTR("[TUYA] Starting discovery\n")); outputFrames.emplace(Command::QueryDP); discoveryTimeout.feed(); state = State::DISCOVERY; break; } // parse known data protocols until discovery timeout expires case State::DISCOVERY: { if (discoveryTimeout) { DEBUG_MSG_P(PSTR("[TUYA] Discovery finished\n")); relaySetupDummy(switchStates.size(), true); #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA lightSetupChannels(channelStates.size()); #endif configDone = true; state = State::IDLE; } break; } // initial config is done, only doing heartbeat periodically case State::IDLE: { if (switchStates.changed()) applySwitch(); #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA if (channelStates.changed()) applyChannel(); #endif sendHeartbeat(Heartbeat::SLOW, state); break; } } if (TUYA_SERIAL && !outputFrames.empty()) { const DataFrame frame = std::move(outputFrames.front()); dataframeDebugSend("=>", frame); tuyaSerial.write(frame.serialize()); outputFrames.pop(); } } // Predefined DP<->SWITCH, DP<->CHANNEL associations // Respective provider setup should be called before state restore, // so we can use dummy values void initBrokerCallback() { static bool done = false; if (done) { return; } ::StatusBroker::Register(brokerCallback); done = true; } void tuyaSetupSwitch() { initBrokerCallback(); for (unsigned char n = 0; n < switchStates.capacity(); ++n) { if (!hasSetting({"tuyaSwitch", n})) break; const auto dp = getSetting({"tuyaSwitch", n}, 0); switchStates.pushOrUpdate(dp, false); } relaySetupDummy(switchStates.size()); if (switchStates.size()) configDone = true; } void tuyaSyncSwitchStatus() { for (unsigned char n = 0; n < switchStates.size(); ++n) { switchStates[n].value = relayStatus(n); } } #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA void tuyaSetupLight() { initBrokerCallback(); for (unsigned char n = 0; n < channelStates.capacity(); ++n) { if (!hasSetting({"tuyaChannel", n})) break; const auto dp = getSetting({"tuyaChannel", n}, 0); channelStates.pushOrUpdate(dp, 0); } lightSetupChannels(channelStates.size()); if (channelStates.size()) configDone = true; } #endif void tuyaSetup() { // Print all known DP associations #if TERMINAL_SUPPORT terminalRegisterCommand(F("TUYA.SHOW"), [](Embedis* e) { static const char fmt[] PROGMEM = "%12s%u => dp=%u value=%u\n"; showProduct(); for (unsigned char id=0; id < switchStates.size(); ++id) { DEBUG_MSG_P(fmt, "tuyaSwitch", id, switchStates[id].dp, switchStates[id].value); } #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA for (unsigned char id=0; id < channelStates.size(); ++id) { DEBUG_MSG_P(fmt, "tuyaChannel", id, channelStates[id].dp, channelStates[id].value); } #endif }); terminalRegisterCommand(F("TUYA.SAVE"), [](Embedis* e) { DEBUG_MSG_P(PSTR("[TUYA] Saving current configuration ...\n")); for (unsigned char n=0; n < switchStates.size(); ++n) { setSetting({"tuyaSwitch", n}, switchStates[n].dp); } #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA for (unsigned char n=0; n < channelStates.size(); ++n) { setSetting({"tuyaChannel", n}, channelStates[n].dp); } #endif }); #endif // Filtering for incoming data auto filter_raw = getSetting("tuyaFilter", dp_states_filter_t::NONE); filterDP = dp_states_filter_t::clamp(filter_raw); // Print all IN and OUT messages transportDebug = getSetting("tuyaDebug", true); // Install main loop method and WiFiStatus ping (only works with specific mode) TUYA_SERIAL.begin(SERIAL_SPEED); ::espurnaRegisterLoop(tuyaLoop); ::wifiRegister([](justwifi_messages_t code, char * parameter) { if ((MESSAGE_CONNECTED == code) || (MESSAGE_DISCONNECTED == code)) { sendWiFiStatus(); } }); } } #endif // TUYA_SUPPORT