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

/*
TUYA MODULE
Copyright (C) 2019 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 "broker.h"
#include "light.h"
#include "relay.h"
#include "rpc.h"
#include <functional>
#include <queue>
#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 {
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<bool> switchStates(SWITCH_MAX);
#if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
States<uint32_t> 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<DataFrame> 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<uint8_t> { getWiFiState() }
);
}
void pushOrUpdateState(const Type type, const DataFrame& frame) {
if (Type::BOOL == type) {
const DataProtocol<bool> 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<uint32_t> 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<bool> 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<uint32_t> 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<char>(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<bool>(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<uint32_t>(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