/* TUYA MODULE Copyright (C) 2019-2021 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 #include "tuya_types.h" #include "tuya_transport.h" #include "tuya_dataframe.h" #include "tuya_protocol.h" #include "tuya_util.h" namespace tuya { bool operator<(const DataFrame& lhs, const DataFrame& rhs) { if ((lhs.command() != Command::Heartbeat) && (rhs.command() == Command::Heartbeat)) { return true; } return false; } constexpr unsigned long SerialSpeed { 9600u }; constexpr unsigned long DiscoveryTimeout { 1500u }; constexpr unsigned long HeartbeatSlow { 9000u }; constexpr unsigned long HeartbeatFast { 3000u }; constexpr unsigned long HeartbeatVeryFast { 200u }; constexpr unsigned long HeartbeatIncrement { 200u }; struct Config { Config(const Config&) = delete; Config(Config&& other) noexcept : key(std::move(other.key)), value(std::move(other.value)) {} Config(String&& key_, String&& value_) noexcept : key(std::move(key_)), value(std::move(value_)) {} String key; String value; }; Transport tuyaSerial(TUYA_SERIAL); std::priority_queue outputFrames; template void send(unsigned char dp, T value) { outputFrames.emplace( Command::SetDP, DataProtocol(dp, value).serialize() ); } // -------------------------------------------- Discovery discovery(DiscoveryTimeout); OnceFlag configDone; bool transportDebug { false }; bool reportWiFi { false }; bool filter { false }; String product; std::forward_list config; DpMap switchIds; #if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM DpMap channelIds; StateId channelStateId; #endif // -------------------------------------------- class TuyaRelayProvider : public RelayProviderBase { public: explicit TuyaRelayProvider(unsigned char dp) : _dp(dp) {} const char* id() const { return "tuya"; } void change(bool status) { send(_dp, status); } private: unsigned char _dp; }; #if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM class TuyaLightProvider : public LightProvider { public: TuyaLightProvider() = default; explicit TuyaLightProvider(const DpMap& channels) : _channels(channels) {} explicit TuyaLightProvider(const DpMap& channels, StateId* state) : _channels(channels), _state(state) {} void update() override { } void state(bool status) override { if (_state && *_state) { _state->filter(false); if (!status) { send(_state->id(), status); } } } void channel(unsigned char channel, double value) override { auto* entry = _channels.find_local(channel); if (!entry) { return; } // tuya dimmer is precious, and can't handle 0...some-kind-of-threshold // just ignore it, the associated switch will handle turning it off auto rounded = static_cast(value); if (rounded <= 0x10) { return; } // Filtering for incoming data // ref. https://github.com/xoseperez/espurna/issues/2222 // TODO: should be fixed when relay & channel transition states are implemented as transactions? if (_state && *_state) { _state->filter(true); } send(entry->dp_id, rounded); } private: const DpMap& _channels; StateId* _state { nullptr }; }; #endif // -------------------------------------------- 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; } // -------------------------------------------- void addConfig(String&& key, String&& value) { Config kv{std::move(key), std::move(value)}; config.push_front(std::move(kv)); } void updatePins(uint8_t led, uint8_t rst) { static bool done { false }; if (!done) { addConfig("ledGPIO0", String(led)); addConfig("btnGPIO0", String(rst)); done = true; } } void showProduct() { DEBUG_MSG_P(PSTR("[TUYA] Product: %s\n"), product.length() ? product.c_str() : "(unknown)"); } template void dataframeDebugSend(const char* tag, const T& 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()); } unsigned long heartbeatInterval(Heartbeat heartbeat) { static unsigned long interval { 0ul }; switch (heartbeat) { case Heartbeat::Boot: if (interval < HeartbeatFast) { interval += HeartbeatIncrement; } else { interval = HeartbeatFast; } break; case Heartbeat::Fast: interval = HeartbeatFast; break; case Heartbeat::Slow: interval = HeartbeatSlow; break; case Heartbeat::None: interval = 0; break; } return interval; } void sendHeartbeat(Heartbeat heartbeat) { static unsigned long interval = 0ul; static unsigned long last = millis() + 1ul; if (millis() - last > interval) { interval = heartbeatInterval(heartbeat); last = millis(); outputFrames.emplace(Command::Heartbeat); } } void sendWiFiStatus() { if (reportWiFi) { outputFrames.emplace( Command::WiFiStatus, std::initializer_list { getWiFiState() } ); } } void updateState(const DataProtocol& proto) { #if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM if (channelStateId && (channelStateId.id() == proto.id())) { // See above. Ignore the selected state ID while we are sending the data, // to avoid resetting the state to ON while we are turning OFF // (and vice versa) if (!channelStateId.filter()) { lightState(proto.value()); } return; } #endif auto* entry = switchIds.find_dp(proto.id()); if (!entry) { return; } relayStatus(entry->local_id, proto.value()); } void updateState(const DataProtocol& proto) { } // XXX: sometimes we need to ignore incoming state // ref: https://github.com/xoseperez/espurna/issues/1729#issuecomment-509234195 template void updateState(Type type, const T& frame) { if (Type::BOOL == type) { DataProtocol proto(frame.data()); updateState(proto); } else if (Type::INT == type) { DataProtocol proto(frame.data()); updateState(proto); } } void updateDiscovered(Discovery&& discovery) { auto& dps = discovery.get(); if (configDone) { goto error; } for (auto& dp : dps) { switch (dp.type) { case Type::BOOL: if (!switchIds.add(relayCount(), dp.id)) { DEBUG_MSG_P(PSTR("[TUYA] Switch for DP id=%u already exists\n"), dp.id); goto error; } if (!relayAdd(std::make_unique(dp.id))) { DEBUG_MSG_P(PSTR("[TUYA] Cannot add relay for DP id=%u\n"), dp.id); goto error; } break; case Type::INT: #if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM if (!channelIds.add(lightChannels(), dp.id)) { DEBUG_MSG_P(PSTR("[TUYA] Channel for DP id=%u already exists\n"), dp.id); goto error; } if (!lightAdd()) { DEBUG_MSG_P(PSTR("[TUYA] Cannot add channel for DP id=%u\n"), dp.id); goto error; } #endif break; default: break; } } #if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM if (channelIds.size()) { lightSetProvider(std::make_unique(channelIds)); } #endif error: dps.clear(); } template void processDP(State state, const T& frame) { auto 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) { discovery.add(type, frame[0]); } else if (!filter) { updateState(type, frame); } } void processFrame(State& state, const Transport& buffer) { const DataFrameView 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.command() == Command::Heartbeat) && (frame.length() == 1)) { if (State::BOOT == state) { if (!configDone) { DEBUG_MSG_P(PSTR("[TUYA] Attempting to configure the board ...\n")); #if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM setupChannels(); #endif setupSwitches(); } if (!configDone) { DEBUG_MSG_P(PSTR("[TUYA] Starting discovery\n")); state = State::QUERY_PRODUCT; return; } state = State::IDLE; } sendWiFiStatus(); return; } if ((frame.command() == 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.command() == 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.command() == Command::WiFiResetCfg) && !frame.length()) { DEBUG_MSG_P(PSTR("[TUYA] WiFi reset request\n")); outputFrames.emplace(Command::WiFiResetCfg); return; } if ((frame.command() == 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.command() == Command::ReportDP) && frame.length()) { processDP(state, frame); if (state == State::DISCOVERY) return; if (state == State::BOOT) 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(); } } } // Main loop state machine. Process input data and manage output queue void loop() { 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::BOOT; case State::BOOT: sendHeartbeat(Heartbeat::Boot); 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] Querying DP(s)\n")); outputFrames.emplace(Command::QueryDP); discovery.feed(); state = State::DISCOVERY; break; } // parse known data protocols until discovery timeout expires case State::DISCOVERY: { if (discovery) { DEBUG_MSG_P(PSTR("[TUYA] Discovery finished\n")); updateDiscovered(std::move(discovery)); state = State::IDLE; } break; } // initial config is done, only doing heartbeat periodically case State::IDLE: { sendHeartbeat(Heartbeat::Slow); break; } } if (TUYA_SERIAL && !outputFrames.empty()) { auto& frame = outputFrames.top(); dataframeDebugSend("=>", frame); tuyaSerial.write(frame.serialize()); outputFrames.pop(); } } namespace build { constexpr unsigned char channelDpId(unsigned char index) { return (index == 0) ? TUYA_CH1_DPID : (index == 1) ? TUYA_CH2_DPID : (index == 2) ? TUYA_CH3_DPID : (index == 3) ? TUYA_CH4_DPID : (index == 4) ? TUYA_CH5_DPID : 0u; } constexpr unsigned char switchDpId(unsigned char index) { return (index == 0) ? TUYA_SW1_DPID : (index == 1) ? TUYA_SW2_DPID : (index == 2) ? TUYA_SW3_DPID : (index == 3) ? TUYA_SW4_DPID : (index == 4) ? TUYA_SW5_DPID : (index == 5) ? TUYA_SW6_DPID : (index == 6) ? TUYA_SW7_DPID : (index == 7) ? TUYA_SW8_DPID : 0u; } constexpr unsigned char channelStateDpId() { return TUYA_CH_STATE_DPID; } } // namespace build // Predefined DP<->SWITCH, DP<->CHANNEL associations // Respective provider setup should be called before state restore, // so we can use dummy values void setupSwitches() { bool done { false }; for (unsigned char id = 0; id < RelaysMax; ++id) { auto dp = getSetting({"tuyaSwitch", id}, build::switchDpId(id)); if (!dp) { break; } if (!switchIds.add(relayCount(), dp)) { break; } if (!relayAdd(std::make_unique(dp))) { break; } done = true; } if (done) { configDone.set(); } } #if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM void setupChannels() { bool done { false }; for (unsigned char id = 0; id < Light::ChannelsMax; ++id) { auto dp = getSetting({"tuyaChannel", id}, build::channelDpId(id)); if (!dp) { break; } if (!channelIds.add(lightChannels(), dp)) { break; } if (!lightAdd()) { break; } done = true; } if (done) { channelStateId = getSetting("tuyaChanState", build::channelStateDpId()); lightSetProvider(std::make_unique(channelIds, &channelStateId)); } if (done) { configDone.set(); } } #endif void setup() { // Print all known DP associations #if TERMINAL_SUPPORT terminalRegisterCommand(F("TUYA.SHOW"), [](const terminal::CommandContext& ctx) { ctx.output.printf_P(PSTR("Product: %s\n"), product.length() ? product.c_str() : "(unknown)"); ctx.output.println(F("\nConfig:")); for (auto& kv : config) { ctx.output.printf_P(PSTR("\"%s\" => \"%s\"\n"), kv.key.c_str(), kv.value.c_str()); } ctx.output.println(F("\nKnown DP(s):")); #if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM if (channelStateId) { ctx.output.printf_P(PSTR("%u (bool) => lights state\n"), channelStateId.id()); } for (auto& entry : channelIds.map()) { ctx.output.printf_P(PSTR("%u (int) => %d (channel)\n"), entry.dp_id, entry.local_id); } #endif for (auto& entry : switchIds.map()) { ctx.output.printf_P(PSTR("%u (bool) => %d (relay)\n"), entry.dp_id, entry.local_id); } }); terminalRegisterCommand(F("TUYA.SAVE"), [](const terminal::CommandContext&) { for (auto& kv : config) { setSetting(kv.key, kv.value); } }); #endif // Print all IN and OUT messages transportDebug = getSetting("tuyaDebug", 1 == TUYA_DEBUG_ENABLED); // Whether to ignore the incoming state messages filter = getSetting("tuyaFilter", 1 == TUYA_FILTER_ENABLED); // Install main loop method and WiFiStatus ping (only works with specific mode) TUYA_SERIAL.begin(SerialSpeed); ::espurnaRegisterLoop(loop); ::wifiRegister([](justwifi_messages_t code, char * parameter) { if ((MESSAGE_CONNECTED == code) || (MESSAGE_DISCONNECTED == code)) { sendWiFiStatus(); } }); } } #endif // TUYA_SUPPORT