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.
 
 
 
 
 
 

665 lines
19 KiB

/*
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