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.
 
 
 
 
 
 

996 lines
25 KiB

/*
HOME ASSISTANT MODULE
Original module
Copyright (C) 2017-2019 by Xose Pérez <xose dot perez at gmail dot com>
Reworked queueing and RAM usage reduction
Copyright (C) 2019-2021 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
*/
#include "espurna.h"
#if HOMEASSISTANT_SUPPORT
#include "light.h"
#include "mqtt.h"
#include "relay.h"
#include "sensor.h"
#include "ws.h"
#include <ArduinoJson.h>
#include <forward_list>
#include <memory>
namespace homeassistant {
// Output is supposed to be used as both part of the MQTT config topic and the `uniq_id` field
// TODO: manage UTF8 strings? in case we somehow receive `desc`, like it was done originally
String normalize_ascii(String&& value, bool lower = false) {
auto* ptr = const_cast<char*>(value.c_str());
for (;;) {
switch (*ptr) {
case '\0':
goto return_value;
case '0' ... '9':
case 'a' ... 'z':
break;
case 'A' ... 'Z':
if (lower) {
*ptr += 32;
}
break;
default:
*ptr = '_';
break;
}
++ptr;
}
return_value:
return std::move(value);
}
// Common data used across the discovery payloads.
// ref. https://developers.home-assistant.io/docs/entity_registry_index/
class Device {
public:
struct Strings {
Strings() = delete;
Strings(const Strings&) = delete;
Strings(Strings&&) = default;
Strings(String&& prefix_, String&& name_, const String& identifier_, const String& version_, const String& manufacturer_, const String& device_) :
prefix(std::move(prefix_)),
name(std::move(name_)),
identifier(identifier_),
version(version_),
manufacturer(manufacturer_),
device(device_)
{
name = normalize_ascii(std::move(name));
identifier = normalize_ascii(std::move(identifier), true);
}
String prefix;
String name;
String identifier;
String version;
String manufacturer;
String device;
};
using StringsPtr = std::unique_ptr<Strings>;
static constexpr size_t BufferSize { JSON_ARRAY_SIZE(1) + JSON_OBJECT_SIZE(1) + JSON_OBJECT_SIZE(5) };
using Buffer = StaticJsonBuffer<BufferSize>;
using BufferPtr = std::unique_ptr<Buffer>;
Device() = delete;
Device(const Device&) = delete;
Device(Device&&) = default;
Device(String&& prefix, String&& name, const String& identifier, const String& version, const String& manufacturer, const String& device) :
_strings(std::make_unique<Strings>(std::move(prefix), std::move(name), identifier, version, manufacturer, device)),
_buffer(std::make_unique<Buffer>()),
_root(_buffer->createObject())
{
JsonArray& ids = _root.createNestedArray("ids");
ids.add(_strings->identifier.c_str());
_root["name"] = _strings->name.c_str();
_root["sw"] = _strings->version.c_str();
_root["mf"] = _strings->manufacturer.c_str();
_root["mdl"] = _strings->device.c_str();
}
const String& name() const {
return _strings->name;
}
const String& prefix() const {
return _strings->prefix;
}
const String& identifier() const {
return _strings->identifier;
}
JsonObject& root() {
return _root;
}
private:
StringsPtr _strings;
BufferPtr _buffer;
JsonObject& _root;
};
using DevicePtr = std::unique_ptr<Device>;
using JsonBufferPtr = std::unique_ptr<DynamicJsonBuffer>;
class Context {
public:
Context() = delete;
Context(DevicePtr&& device, size_t capacity) :
_device(std::move(device)),
_capacity(capacity)
{}
const String& name() const {
return _device->name();
}
const String& prefix() const {
return _device->prefix();
}
const String& identifier() const {
return _device->identifier();
}
JsonObject& device() {
return _device->root();
}
void reset() {
_json = std::make_unique<DynamicJsonBuffer>(_capacity);
}
size_t capacity() const {
return _capacity;
}
size_t size() {
if (_json) {
return _json->size();
}
return 0;
}
JsonObject& makeObject() {
if (!_json) {
reset();
}
return _json->createObject();
}
private:
String _prefix;
DevicePtr _device;
JsonBufferPtr _json;
size_t _capacity { 0ul };
};
Context makeContext() {
auto device = std::make_unique<Device>(
getSetting("haPrefix", HOMEASSISTANT_PREFIX),
getSetting("hostname", getIdentifier()),
getIdentifier(),
getVersion(),
getManufacturer(),
getDevice()
);
return Context(std::move(device), 2048ul);
}
String quote(String&& value) {
if (value.equalsIgnoreCase("y")
|| value.equalsIgnoreCase("n")
|| value.equalsIgnoreCase("yes")
|| value.equalsIgnoreCase("no")
|| value.equalsIgnoreCase("true")
|| value.equalsIgnoreCase("false")
|| value.equalsIgnoreCase("on")
|| value.equalsIgnoreCase("off")
) {
String result;
result.reserve(value.length() + 2);
result += '"';
result += value;
result += '"';
return result;
}
return std::move(value);
}
// - Discovery object is expected to accept Context reference as input
// (and all implementations do just that)
// - topic() & message() return refs, since those *may* be called multiple times before advancing to the next 'entity'
// - We use short-hand names right away, since we don't expect this to be used to generate yaml
// - In case the object uses the JSON makeObject() as state, make sure we don't use it (state)
// and the object itself after next() or ok() return false
// - Make sure JSON state is not created on construction, but lazy-loaded as soon as it is needed.
// Meaning, we don't cause invalid refs immediatly when there are more than 1 discovery object present and we reset the storage.
class Discovery {
public:
virtual ~Discovery() {
}
virtual bool ok() const = 0;
virtual const String& topic() = 0;
virtual const String& message() = 0;
virtual bool next() = 0;
};
#if RELAY_SUPPORT
struct RelayContext {
String availability;
String payload_available;
String payload_not_available;
String payload_on;
String payload_off;
};
RelayContext makeRelayContext() {
return {
mqttTopic(MQTT_TOPIC_STATUS, false),
quote(mqttPayloadStatus(true)),
quote(mqttPayloadStatus(false)),
quote(relayPayload(PayloadStatus::On)),
quote(relayPayload(PayloadStatus::Off))
};
}
class RelayDiscovery : public Discovery {
public:
RelayDiscovery() = delete;
explicit RelayDiscovery(Context& ctx) :
_ctx(ctx),
_relay(makeRelayContext()),
_relays(relayCount())
{
if (!_relays) {
return;
}
auto& json = root();
json["dev"] = _ctx.device();
json["avty_t"] = _relay.availability.c_str();
json["pl_avail"] = _relay.payload_available.c_str();
json["pl_not_avail"] = _relay.payload_not_available.c_str();
json["pl_on"] = _relay.payload_on.c_str();
json["pl_off"] = _relay.payload_off.c_str();
}
JsonObject& root() {
if (!_root) {
_root = &_ctx.makeObject();
}
return *_root;
}
bool ok() const override {
return (_relays) && (_index < _relays);
}
const String& uniqueId() {
if (!_unique_id.length()) {
_unique_id = _ctx.identifier() + '_' + F("relay") + '_' + _index;
}
return _unique_id;
}
const String& topic() override {
if (!_topic.length()) {
_topic = _ctx.prefix();
_topic += F("/switch/");
_topic += uniqueId();
_topic += F("/config");
}
return _topic;
}
const String& message() override {
if (!_message.length()) {
auto& json = root();
json["uniq_id"] = uniqueId();
json["name"] = _ctx.name() + ' ' + _index;
json["stat_t"] = mqttTopic(MQTT_TOPIC_RELAY, _index, false);
json["cmd_t"] = mqttTopic(MQTT_TOPIC_RELAY, _index, true);
json.printTo(_message);
}
return _message;
}
bool next() override {
if (_index < _relays) {
auto current = _index;
++_index;
if ((_index > current) && (_index < _relays)) {
_unique_id = "";
_topic = "";
_message = "";
return true;
}
}
return false;
}
private:
Context& _ctx;
JsonObject* _root { nullptr };
RelayContext _relay;
unsigned char _index { 0u };
unsigned char _relays { 0u };
String _unique_id;
String _topic;
String _message;
};
#endif
// Example payload:
// {
// "brightness": 255,
// "color_temp": 155,
// "color": {
// "r": 255,
// "g": 180,
// "b": 200,
// "x": 0.406,
// "y": 0.301,
// "h": 344.0,
// "s": 29.412
// },
// "effect": "colorloop",
// "state": "ON",
// "transition": 2,
// "white_value": 150
// }
// Notice that we only support JSON schema payloads, leaving it to the user to configure special
// per-channel topics, as those don't really fit into the HASS idea of lights controls for a single device
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
class LightDiscovery : public Discovery {
public:
explicit LightDiscovery(Context& ctx) :
_ctx(ctx)
{}
JsonObject& root() {
if (!_root) {
_root = &_ctx.makeObject();
}
return *_root;
}
bool ok() const override {
return true;
}
bool next() override {
return false;
}
const String& uniqueId() {
if (!_unique_id.length()) {
_unique_id = _ctx.identifier() + '_' + F("light");
}
return _unique_id;
}
const String& topic() override {
if (!_topic.length()) {
_topic = _ctx.prefix();
_topic += F("/light/");
_topic += uniqueId();
_topic += F("/config");
}
return _topic;
}
const String& message() override {
if (!_message.length()) {
auto& json = root();
json["schema"] = "json";
json["uniq_id"] = uniqueId();
json["name"] = _ctx.name() + ' ' + F("Light");
json["stat_t"] = mqttTopic(MQTT_TOPIC_LIGHT_JSON, false);
json["cmd_t"] = mqttTopic(MQTT_TOPIC_LIGHT_JSON, true);
json["avty_t"] = mqttTopic(MQTT_TOPIC_STATUS, false);
json["pl_avail"] = quote(mqttPayloadStatus(true));
json["pl_not_avail"] = quote(mqttPayloadStatus(false));
// send `true` for every payload we support sending / receiving
// already enabled by default: "state", "transition"
json["brightness"] = true;
// Note that since we send back the values immediately, HS mode sliders
// *will jump*, as calculations of input do not always match the output.
// (especially, when gamma table is used, as we modify the results)
// In case or RGB, channel values input is expected to match the output exactly.
if (lightHasColor()) {
if (lightUseRGB()) {
json["rgb"] = true;
} else {
json["hs"] = true;
}
}
// Mired is only an input, we never send this value back
// (...besides the internally pinned value, ref. MQTT_TOPIC_MIRED. not used here though)
// - in RGB mode, we convert the temperature into a specific color
// - in CCT mode, white channels are used
if (lightHasColor() || lightUseCCT()) {
auto range = lightMiredsRange();
json["min_mirs"] = range.cold();
json["max_mirs"] = range.warm();
json["color_temp"] = true;
}
json.printTo(_message);
}
return _message;
}
private:
Context& _ctx;
JsonObject* _root { nullptr };
String _unique_id;
String _topic;
String _message;
};
bool heartbeat(heartbeat::Mask mask) {
// TODO: mask json payload specifically?
// or, find a way to detach masking from the system setting / don't use heartbeat timer
if (mask & heartbeat::Report::Light) {
DynamicJsonBuffer buffer(512);
JsonObject& root = buffer.createObject();
auto state = lightState();
root["state"] = state ? "ON" : "OFF";
if (state) {
root["brightness"] = lightBrightness();
if (lightUseCCT()) {
root["white_value"] = lightColdWhite();
}
if (lightColor()) {
auto& color = root.createNestedObject("color");
if (lightUseRGB()) {
auto rgb = lightRgb();
color["r"] = rgb.red();
color["g"] = rgb.green();
color["b"] = rgb.blue();
} else {
auto hsv = lightHsv();
color["h"] = hsv.hue();
color["s"] = hsv.saturation();
}
}
}
String message;
root.printTo(message);
String topic = mqttTopic(MQTT_TOPIC_LIGHT_JSON, false);
mqttSendRaw(topic.c_str(), message.c_str(), false);
}
return true;
}
void publishLightJson() {
heartbeat(static_cast<heartbeat::Mask>(heartbeat::Report::Light));
}
void receiveLightJson(char* payload) {
DynamicJsonBuffer buffer(1024);
JsonObject& root = buffer.parseObject(payload);
if (!root.success()) {
return;
}
if (!root.containsKey("state")) {
return;
}
auto state = root["state"].as<String>();
if (state == F("ON")) {
lightState(true);
} else if (state == F("OFF")) {
lightState(false);
} else {
return;
}
unsigned long transition { lightTransitionTime() };
if (root.containsKey("transition")) {
auto seconds = root["transition"].as<float>();
if (seconds > 0) {
transition = static_cast<unsigned long>(seconds * 1000.0);
}
}
if (root.containsKey("color_temp")) {
lightMireds(root["color_temp"].as<long>());
}
if (root.containsKey("brightness")) {
lightBrightness(root["brightness"].as<long>());
}
if (lightHasColor() && root.containsKey("color")) {
JsonObject& color = root["color"];
if (lightUseRGB()) {
lightRgb({
color["r"].as<long>(),
color["g"].as<long>(),
color["b"].as<long>()});
} else {
lightHs(
color["h"].as<long>(),
color["s"].as<long>());
}
}
if (lightUseCCT() && root.containsKey("white_value")) {
lightColdWhite(root["white_value"].as<long>());
}
lightUpdate({transition, lightTransitionStep()});
}
#endif
#if SENSOR_SUPPORT
class SensorDiscovery : public Discovery {
public:
SensorDiscovery() = delete;
explicit SensorDiscovery(Context& ctx) :
_ctx(ctx),
_magnitudes(magnitudeCount())
{}
JsonObject& root() {
if (!_root) {
_root = &_ctx.makeObject();
}
return *_root;
}
bool ok() const {
return _index < _magnitudes;
}
const String& topic() override {
if (!_topic.length()) {
_topic = _ctx.prefix();
_topic += F("/sensor/");
_topic += uniqueId();
_topic += F("/config");
}
return _topic;
}
const String& message() override {
if (!_message.length()) {
auto& json = root();
json["dev"] = _ctx.device();
json["uniq_id"] = uniqueId();
json["name"] = _ctx.name() + ' ' + name() + ' ' + localId();
json["stat_t"] = mqttTopic(magnitudeTopicIndex(_index).c_str(), false);
json["unit_of_meas"] = magnitudeUnits(_index);
json.printTo(_message);
}
return _message;
}
const String& name() {
if (!_name.length()) {
_name = magnitudeTopic(magnitudeType(_index));
}
return _name;
}
unsigned char localId() const {
return magnitudeIndex(_index);
}
const String& uniqueId() {
if (!_unique_id.length()) {
_unique_id = _ctx.identifier() + '_' + name() + '_' + localId();
}
return _unique_id;
}
bool next() override {
if (_index < _magnitudes) {
auto current = _index;
++_index;
if ((_index > current) && (_index < _magnitudes)) {
_unique_id = "";
_name = "";
_topic = "";
_message = "";
return true;
}
}
return false;
}
private:
Context& _ctx;
JsonObject* _root { nullptr };
unsigned char _index { 0u };
unsigned char _magnitudes { 0u };
String _unique_id;
String _name;
String _topic;
String _message;
};
#endif
// Reworked discovery class. Try to send and wait for MQTT QoS 1 publish ACK to continue.
// Topic and message are generated on demand and most of JSON payload is cached for re-use to save RAM.
class DiscoveryTask {
public:
using Entity = std::unique_ptr<Discovery>;
using Entities = std::forward_list<Entity>;
static constexpr int Retries { 5 };
static constexpr unsigned long WaitShortMs { 100ul };
static constexpr unsigned long WaitLongMs { 1000ul };
DiscoveryTask(bool enabled) :
_enabled(enabled)
{}
void add(Entity&& entity) {
_entities.push_front(std::move(entity));
}
template <typename T>
void add() {
_entities.push_front(std::make_unique<T>(_ctx));
}
bool retry() {
if (_retry < 0) {
return false;
}
return (--_retry > 0);
}
Context& context() {
return _ctx;
}
bool done() const {
return _entities.empty();
}
bool ok() const {
if ((_retry > 0) && !_entities.empty()) {
auto& entity = _entities.front();
return entity->ok();
}
return false;
}
template <typename T>
bool send(T&& action) {
while (!_entities.empty()) {
auto& entity = _entities.front();
if (!entity->ok()) {
_entities.pop_front();
_ctx.reset();
continue;
}
const auto* topic = entity->topic().c_str();
const auto* msg = _enabled
? entity->message().c_str()
: "";
if (action(topic, msg)) {
if (!entity->next()) {
_retry = Retries;
_entities.pop_front();
_ctx.reset();
}
return true;
}
return false;
}
return false;
}
private:
bool _enabled { false };
int _retry { Retries };
Context _ctx { makeContext() };
Entities _entities;
};
namespace internal {
using TaskPtr = std::shared_ptr<DiscoveryTask>;
using FlagPtr = std::shared_ptr<bool>;
bool retain { false };
bool enabled { false };
enum class State {
Initial,
Pending,
Sent
};
State state { State::Initial };
Ticker timer;
void send(TaskPtr ptr, FlagPtr flag_ptr);
void stop(bool done) {
timer.detach();
if (done) {
DEBUG_MSG_P(PSTR("[HA] Stopping discovery\n"));
state = State::Sent;
} else {
DEBUG_MSG_P(PSTR("[HA] Discovery error\n"));
state = State::Pending;
}
}
void schedule(unsigned long wait, TaskPtr ptr, FlagPtr flag_ptr) {
internal::timer.once_ms_scheduled(wait, [ptr, flag_ptr]() {
send(ptr, flag_ptr);
});
}
void schedule(TaskPtr ptr, FlagPtr flag_ptr) {
schedule(DiscoveryTask::WaitShortMs, ptr, flag_ptr);
}
void schedule(TaskPtr ptr) {
schedule(DiscoveryTask::WaitShortMs, ptr, std::make_shared<bool>(true));
}
void send(TaskPtr ptr, FlagPtr flag_ptr) {
auto& task = *ptr;
if (!mqttConnected() || task.done()) {
stop(true);
return;
}
auto& flag = *flag_ptr;
if (!flag) {
if (task.retry()) {
schedule(ptr, flag_ptr);
} else {
stop(false);
}
return;
}
uint16_t pid { 0u };
auto res = task.send([&](const char* topic, const char* message) {
pid = ::mqttSendRaw(topic, message, internal::retain, 1);
return pid > 0;
});
#if MQTT_LIBRARY == MQTT_LIBRARY_ASYNCMQTTCLIENT
// - async fails when disconneted and when it's buffers are filled, which should be resolved after $LATENCY
// and the time it takes for the lwip to process it. future versions use queue, but could still fail when low on RAM
// - lwmqtt will fail when disconnected (already checked above) and *will* disconnect in case publish fails.
// ::publish() will wait for the puback, so we don't have to do it ourselves. not tested.
// - pubsub will fail when it can't buffer the payload *or* the underlying WiFiClient calls fail. also not tested.
if (res) {
flag = false;
mqttOnPublish(pid, [flag_ptr]() {
(*flag_ptr) = true;
});
}
#endif
auto wait = res
? DiscoveryTask::WaitShortMs
: DiscoveryTask::WaitLongMs;
if (res || task.retry()) {
schedule(wait, ptr, flag_ptr);
return;
}
stop(false);
}
} // namespace internal
void publishDiscovery() {
if (!mqttConnected() || internal::timer.active() || (internal::state != internal::State::Pending)) {
return;
}
auto task = std::make_shared<DiscoveryTask>(internal::enabled);
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
task->add<LightDiscovery>();
#endif
#if RELAY_SUPPORT
task->add<RelayDiscovery>();
#endif
#if SENSOR_SUPPORT
task->add<SensorDiscovery>();
#endif
// only happens when nothing is configured to do the add()
if (task->done()) {
return;
}
internal::schedule(task);
}
void configure() {
bool current = internal::enabled;
internal::enabled = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
internal::retain = getSetting("haRetain", 1 == HOMEASSISTANT_RETAIN);
if (internal::enabled != current) {
internal::state = internal::State::Pending;
}
homeassistant::publishDiscovery();
}
void mqttCallback(unsigned int type, const char* topic, char* payload) {
if (MQTT_DISCONNECT_EVENT == type) {
if (internal::state == internal::State::Sent) {
internal::state = internal::State::Pending;
}
internal::timer.detach();
return;
}
if (MQTT_CONNECT_EVENT == type) {
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
::mqttSubscribe(MQTT_TOPIC_LIGHT_JSON);
#endif
::schedule_function(publishDiscovery);
return;
}
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
if (type == MQTT_MESSAGE_EVENT) {
String t = ::mqttMagnitude(topic);
if (t.equals(MQTT_TOPIC_LIGHT_JSON)) {
receiveLightJson(payload);
}
return;
}
#endif
}
namespace web {
#if WEB_SUPPORT
void onVisible(JsonObject& root) {
root["haVisible"] = 1;
}
void onConnected(JsonObject& root) {
root["haPrefix"] = getSetting("haPrefix", HOMEASSISTANT_PREFIX);
root["haEnabled"] = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
root["haRetain"] = getSetting("haRetain", 1 == HOMEASSISTANT_RETAIN);
}
bool onKeyCheck(const char* key, JsonVariant& value) {
return (strncmp(key, "ha", 2) == 0);
}
#endif
} // namespace web
} // namespace homeassistant
// This module no longer implements .yaml generation, since we can't:
// - use unique_id in the device config
// - have abbreviated keys
// - have mqtt reliably return the correct status & command payloads when it is disabled
// (yet? needs reworked configuration section or making functions read settings directly)
void haSetup() {
#if WEB_SUPPORT
wsRegister()
.onVisible(homeassistant::web::onVisible)
.onConnected(homeassistant::web::onConnected)
.onKeyCheck(homeassistant::web::onKeyCheck);
#endif
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
lightSetReportListener(homeassistant::publishLightJson);
mqttHeartbeat(homeassistant::heartbeat);
#endif
mqttRegister(homeassistant::mqttCallback);
#if TERMINAL_SUPPORT
terminalRegisterCommand(F("HA.SEND"), [](const terminal::CommandContext& ctx) {
using namespace homeassistant::internal;
state = State::Pending;
homeassistant::publishDiscovery();
terminalOK(ctx);
});
#endif
espurnaRegisterReload(homeassistant::configure);
homeassistant::configure();
}
#endif // HOMEASSISTANT_SUPPORT