/*
|
|
|
|
TUYA MODULE
|
|
|
|
Copyright (C) 2019-2021 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
|
|
|
|
*/
|
|
|
|
// ref: https://docs.tuya.com/en/mcu/mcu-protocol.html
|
|
|
|
#include "tuya.h"
|
|
|
|
#if TUYA_SUPPORT
|
|
|
|
#include "light.h"
|
|
#include "relay.h"
|
|
#include "rpc.h"
|
|
|
|
#include "libs/OnceFlag.h"
|
|
|
|
#include <functional>
|
|
#include <queue>
|
|
#include <forward_list>
|
|
#include <StreamString.h>
|
|
|
|
#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<DataFrame> outputFrames;
|
|
|
|
template <typename T>
|
|
void send(unsigned char dp, T value) {
|
|
outputFrames.emplace(
|
|
Command::SetDP, DataProtocol<T>(dp, value).serialize()
|
|
);
|
|
}
|
|
|
|
// --------------------------------------------
|
|
|
|
Discovery discovery(DiscoveryTimeout);
|
|
OnceFlag configDone;
|
|
|
|
bool transportDebug { false };
|
|
bool reportWiFi { false };
|
|
bool filter { false };
|
|
|
|
String product;
|
|
std::forward_list<Config> 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* stateId) :
|
|
_channels(channels),
|
|
_stateId(stateId)
|
|
{}
|
|
|
|
void update() override {
|
|
}
|
|
|
|
// Channel values > 0 will switch the lights ON anyway
|
|
void state(bool value) override {
|
|
_last_state = value;
|
|
if (*_stateId && !value) {
|
|
send(_stateId->id(), value);
|
|
}
|
|
}
|
|
|
|
void channel(size_t channel, float value) override {
|
|
// XXX: can't handle channel values when OFF, and will turn the lights ON
|
|
if (!_last_state) {
|
|
return;
|
|
}
|
|
|
|
auto* entry = _channels.find_local(channel);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
// input dimmer channel value when lights are OFF is 16
|
|
// for the same reason as above, don't send OFF values
|
|
send(entry->dp_id, static_cast<unsigned int>(value));
|
|
}
|
|
|
|
private:
|
|
const DpMap& _channels;
|
|
bool _last_state { false };
|
|
StateId* _stateId { nullptr };
|
|
};
|
|
|
|
#endif
|
|
|
|
// --------------------------------------------
|
|
|
|
uint8_t getWiFiState() {
|
|
if (wifiConnected()) {
|
|
return 0x04;
|
|
} else if (wifiConnectable()) {
|
|
return 0x01;
|
|
}
|
|
|
|
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 <typename T>
|
|
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<uint8_t> { getWiFiState() }
|
|
);
|
|
}
|
|
}
|
|
|
|
void updateState(const DataProtocol<bool>& 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<uint32_t>& proto) {
|
|
}
|
|
|
|
// XXX: sometimes we need to ignore incoming state
|
|
// ref: https://github.com/xoseperez/espurna/issues/1729#issuecomment-509234195
|
|
template <typename T>
|
|
void updateState(Type type, const T& frame) {
|
|
if (Type::BOOL == type) {
|
|
DataProtocol<bool> proto(frame.data());
|
|
updateState(proto);
|
|
} else if (Type::INT == type) {
|
|
DataProtocol<uint32_t> 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<TuyaRelayProvider>(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<TuyaLightProvider>(channelIds));
|
|
}
|
|
#endif
|
|
|
|
error:
|
|
dps.clear();
|
|
}
|
|
|
|
template <typename T>
|
|
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();
|
|
}
|
|
|
|
DEBUG_MSG_P(PSTR("[TUYA] Starting discovery\n"));
|
|
state = State::QUERY_PRODUCT;
|
|
return;
|
|
}
|
|
|
|
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<char>(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(size_t 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(size_t 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 (size_t 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<TuyaRelayProvider>(dp))) {
|
|
break;
|
|
}
|
|
|
|
done = true;
|
|
}
|
|
|
|
if (done) {
|
|
configDone.set();
|
|
}
|
|
}
|
|
|
|
#if LIGHT_PROVIDER == LIGHT_PROVIDER_CUSTOM
|
|
|
|
void setupChannels() {
|
|
bool done { false };
|
|
for (size_t 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<TuyaLightProvider>(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([](wifi::Event event) {
|
|
switch (event) {
|
|
case wifi::Event::StationConnected:
|
|
case wifi::Event::StationDisconnected:
|
|
sendWiFiStatus();
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
#endif // TUYA_SUPPORT
|