Mirror of espurna firmware for wireless switches and more
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.
 
 
 
 
 
 

1549 lines
36 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-2022 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
*/
#include "espurna.h"
#if HOMEASSISTANT_SUPPORT
#include "homeassistant.h"
#include "light.h"
#include "mqtt.h"
#include "relay.h"
#include "sensor.h"
#include "web.h"
#include "ws.h"
#include <ArduinoJson.h>
#include <forward_list>
#include <memory>
namespace espurna {
namespace homeassistant {
namespace {
enum class State {
Disabled,
Enabled,
};
namespace build {
constexpr bool enabled() {
return 1 == HOMEASSISTANT_ENABLED;
}
STRING_VIEW_INLINE(Prefix, HOMEASSISTANT_PREFIX);
constexpr StringView prefix() {
return Prefix;
}
constexpr bool retain() {
return 1 == HOMEASSISTANT_RETAIN;
}
STRING_VIEW_INLINE(BirthTopic, HOMEASSISTANT_BIRTH_TOPIC);
constexpr StringView birthTopic() {
return BirthTopic;
}
STRING_VIEW_INLINE(BirthPayload, HOMEASSISTANT_BIRTH_PAYLOAD);
constexpr StringView birthPayload() {
return BirthPayload;
}
} // namespace build
namespace settings {
namespace keys {
STRING_VIEW_INLINE(Enabled, "haEnabled");
STRING_VIEW_INLINE(Prefix, "haPrefix");
STRING_VIEW_INLINE(Retain, "haRetain");
STRING_VIEW_INLINE(BirthTopic, "haBirthTopic");
STRING_VIEW_INLINE(BirthPayload, "haBirthPayload");
} // namespace keys
bool enabled() {
return getSetting(keys::Enabled, build::enabled());
}
String prefix() {
return getSetting(keys::Prefix, build::prefix());
}
bool retain() {
return getSetting(keys::Retain, build::retain());
}
String birthTopic() {
return getSetting(keys::BirthTopic, build::birthTopic());
}
String birthPayload() {
return getSetting(keys::BirthPayload, build::birthPayload());
}
namespace query {
namespace internal {
#define EXACT_VALUE(NAME, FUNC)\
String NAME () {\
return espurna::settings::internal::serialize(FUNC());\
}
EXACT_VALUE(enabled, settings::enabled)
EXACT_VALUE(retain, settings::retain)
} // namespace internal
static constexpr espurna::settings::query::Setting Settings[] PROGMEM {
{keys::Enabled, internal::enabled},
{keys::Prefix, settings::prefix},
{keys::Retain, internal::retain},
{keys::BirthTopic, settings::birthTopic},
{keys::BirthPayload, settings::birthPayload},
};
STRING_VIEW_INLINE(Prefix, "ha");
bool checkSamePrefix(espurna::StringView key) {
return espurna::settings::query::samePrefix(key, Prefix);
}
String findValueFrom(espurna::StringView key) {
return espurna::settings::query::Setting::findValueFrom(Settings, key);
}
void setup() {
::settingsRegisterQueryHandler({
.check = checkSamePrefix,
.get = findValueFrom,
});
}
} // namespace query
} // namespace settings
// 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) {
for (auto ptr = value.begin(); ptr != value.end(); ++ptr) {
switch (*ptr) {
case '\0':
goto done;
case '0' ... '9':
case 'a' ... 'z':
break;
case 'A' ... 'Z':
if (lower) {
*ptr = (*ptr + 32);
}
break;
default:
*ptr = '_';
break;
}
}
done:
return value;
}
String normalize_ascii(StringView value, bool lower) {
return normalize_ascii(String(value), lower);
}
// Common data used across the discovery payloads.
// ref. https://developers.home-assistant.io/docs/entity_registry_index/
// 'runtime' strings, may be changed in settings
struct ConfigStrings {
String name;
String identifier;
String prefix;
};
using ConfigStringsPtr = std::unique_ptr<ConfigStrings>;
ConfigStringsPtr make_config_strings() {
return ConfigStringsPtr(
new ConfigStrings{
.name = normalize_ascii(systemHostname(), false),
.identifier = normalize_ascii(systemIdentifier(), true),
.prefix = settings::prefix(),
});
}
// 'build-time' strings, always the same for current build
struct BuildStrings {
String version;
String manufacturer;
String device;
};
using BuildStringsPtr = std::unique_ptr<BuildStrings>;
BuildStringsPtr make_build_strings() {
const auto app = buildApp();
const auto hardware = buildHardware();
return BuildStringsPtr(
new BuildStrings{
.version = app.version.toString(),
.manufacturer = hardware.manufacturer.toString(),
.device = hardware.device.toString(),
});
}
class Device {
public:
// XXX: take care when adding / removing keys and values below
// - `const char*` is copied by pointer value, persistent pointers make sure
// it is valid for the duration of this objects lifetime
// - `F(...)` aka `__FlashStringHelpe` **will take more space**
// it is **copied inside of the buffer**, and will take `strlen()` bytes
// - allocating more objects **will silently corrupt** buffer region
// while there are *some* checks, current version is going to break
static constexpr size_t BufferSize { JSON_ARRAY_SIZE(1) + JSON_OBJECT_SIZE(6) };
using Buffer = StaticJsonBuffer<BufferSize>;
using BufferPtr = std::unique_ptr<Buffer>;
Device() = delete;
Device(const Device&) = delete;
Device& operator=(const Device&) = delete;
Device(Device&&) = delete;
Device& operator=(Device&&) = delete;
Device(ConfigStringsPtr config, BuildStringsPtr build) :
_config(std::move(config)),
_build(std::move(build)),
_buffer(std::make_unique<Buffer>()),
_root(_buffer->createObject())
{
_root["name"] = _config->name.c_str();
auto& ids = _root.createNestedArray("ids");
ids.add(_config->identifier.c_str());
_root["sw"] = _build->version.c_str();
_root["mf"] = _build->manufacturer.c_str();
_root["mdl"] = _build->device.c_str();
}
const String& name() const {
return _config->name;
}
const String& prefix() const {
return _config->prefix;
}
const String& identifier() const {
return _config->identifier;
}
JsonObject& root() {
return _root;
}
private:
ConfigStringsPtr _config;
BuildStringsPtr _build;
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 { nullptr };
size_t _capacity { 0ul };
};
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 prepare();
virtual bool ready() const;
virtual bool next() = 0;
};
Discovery::~Discovery() = default;
bool Discovery::prepare() {
return true;
}
bool Discovery::ready() const {
return true;
}
#if RELAY_SUPPORT
class RelayDiscovery : public Discovery {
public:
explicit RelayDiscovery(Context& ctx) :
_ctx(ctx)
{}
JsonObject& root() {
if (!_root) {
_root = &_ctx.makeObject();
}
return *_root;
}
bool ready() const override {
return _ready;
}
bool ok() const override {
return _ready
&& (_count > 0)
&& (_index < _count);
}
const String& uniqueId() {
if (!_unique_id.length()) {
_unique_id = _ctx.identifier() + '_' + F("relay") + '_' + String(_index, 10);
}
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[F("dev")] = _ctx.device();
json[F("avty_t")] = _info->availability.c_str();
json[F("pl_avail")] = _info->payload_available.c_str();
json[F("pl_not_avail")] = _info->payload_not_available.c_str();
json[F("pl_on")] = _info->payload_on.c_str();
json[F("pl_off")] = _info->payload_off.c_str();
json[F("uniq_id")] = uniqueId();
json[F("name")] = _ctx.name() + ' ' + String(_index, 10);
json[F("stat_t")] = mqttTopic(MQTT_TOPIC_RELAY, _index);
json[F("cmd_t")] = mqttTopicSetter(MQTT_TOPIC_RELAY, _index);
json.printTo(_message);
}
return _message;
}
bool prepare() override {
if (!_ready) {
_count = relayCount();
_index = 0;
_info = _makeInfo();
_ready = true;
}
return _ready;
}
bool next() override {
if (_index < _count) {
auto current = _index;
++_index;
if ((_index > current) && (_index < _count)) {
_unique_id = "";
_topic = "";
_message = "";
return true;
}
}
return false;
}
private:
struct Info {
String availability;
String payload_available;
String payload_not_available;
String payload_on;
String payload_off;
};
using InfoPtr = std::unique_ptr<Info>;
InfoPtr _makeInfo();
Context& _ctx;
JsonObject* _root { nullptr };
InfoPtr _info;
size_t _index;
size_t _count;
bool _ready { false };
String _unique_id;
String _topic;
String _message;
};
RelayDiscovery::InfoPtr RelayDiscovery::_makeInfo() {
return InfoPtr(
new Info{
.availability = mqttTopic(MQTT_TOPIC_STATUS),
.payload_available = quote(mqttPayloadStatus(true)),
.payload_not_available = quote(mqttPayloadStatus(false)),
.payload_on = quote(relayPayload(PayloadStatus::On).toString()),
.payload_off = quote(relayPayload(PayloadStatus::Off).toString()),
});
}
#endif
// Example payload:
// {
// "state": "ON",
// "brightness": 255,
// "color_mode": "rgb",
// "color": {
// "r": 255,
// "g": 180,
// "b": 200,
// },
// "transition": 2,
// }
// 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
static constexpr char Topic[] = MQTT_TOPIC_LIGHT_JSON;
class LightDiscovery : public Discovery {
public:
explicit LightDiscovery(Context& ctx) :
_ctx(ctx)
{}
JsonObject& root() {
if (!_root) {
_root = &_ctx.makeObject();
}
return *_root;
}
bool ready() const override {
return _ready;
}
bool prepare() override {
if (!_ready) {
_count = lightChannels();
_ready = true;
}
return _ready;
}
bool ok() const override {
return _ready && (_count > 0);
}
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[F("schema")] = "json";
json[F("uniq_id")] = uniqueId();
json[F("name")] = _ctx.name() + ' ' + F("Light");
json[F("stat_t")] = mqttTopic(Topic);
json[F("cmd_t")] = mqttTopicSetter(Topic);
json[F("avty_t")] = mqttTopic(MQTT_TOPIC_STATUS);
json[F("pl_avail")] = quote(mqttPayloadStatus(true));
json[F("pl_not_avail")] = quote(mqttPayloadStatus(false));
// 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.
// Since 2022.9.x we have a different payload setup
// - https://github.com/xoseperez/espurna/issues/2539
// - https://github.com/home-assistant/core/blob/2022.9.7/homeassistant/components/mqtt/light/schema_json.py
// * ignore 'onoff' and 'brightness'
// both described as 'Must be the only supported mode'
// * 'hs' is always supported, but HA UI depends on our setting and
// what gets sent in the json payload
// * 'c' and 'w' mean different things depending on *our* context
// 'rgbw' - we receive and map to 'w' to our 'warm'
// 'rgbww' - we receive and map 'c' to our 'cold' and 'w' to our 'warm'
// 'cw' / 'ww' without 'rgb' are not supported; see 'brightness' or 'color_temp'
json["brightness"] = true;
json["color_mode"] = true;
JsonArray& modes = json.createNestedArray("supported_color_modes");
if (lightHasColor()) {
modes.add("hs");
modes.add("rgb");
if (lightHasWarmWhite() && lightHasColdWhite()) {
modes.add("rgbww");
} else if (lightHasWarmWhite()) {
modes.add("rgbw");
}
}
// 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() || lightHasWhite()) {
const auto range = lightMiredsRange();
json["min_mirs"] = range.cold();
json["max_mirs"] = range.warm();
modes.add("color_temp");
modes.add("white");
}
if (!modes.size()) {
modes.add("brightness");
}
json.printTo(_message);
}
return _message;
}
private:
Context& _ctx;
JsonObject* _root { nullptr };
bool _ready { false };
size_t _count;
String _unique_id;
String _topic;
String _message;
};
void heartbeat_rgb(JsonObject& root, JsonObject& color) {
const auto rgb = lightRgb();
color["r"] = rgb.red();
color["g"] = rgb.green();
color["b"] = rgb.blue();
if (lightHasWarmWhite() && lightHasColdWhite()) {
root["color_mode"] = "rgbww";
color["c"] = lightColdWhite();
color["w"] = lightWarmWhite();
} else if (lightHasWarmWhite()) {
root["color_mode"] = "rgbw";
color["w"] = lightWarmWhite();
} else {
root["color_mode"] = "rgb";
}
}
void heartbeat_hsv(JsonObject& root, JsonObject& color) {
root["color_mode"] = "hs";
const auto hsv = lightHsv();
color["h"] = hsv.hue();
color["s"] = hsv.saturation();
}
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();
const auto state = lightState();
root["state"] = state ? "ON" : "OFF";
if (state) {
root["brightness"] = lightBrightness();
if (lightHasColor() && lightColor()) {
auto& color = root.createNestedObject("color");
if (lightUseRGB()) {
heartbeat_rgb(root, color);
} else {
heartbeat_hsv(root, color);
}
}
}
String message;
root.printTo(message);
static const auto topic = StringView(Topic).toString();
mqttSendRaw(
mqttTopic(topic).c_str(),
message.c_str(), false);
}
return true;
}
void publishLightJson() {
heartbeat(static_cast<heartbeat::Mask>(heartbeat::Report::Light));
}
void receiveLightJson(StringView payload) {
DynamicJsonBuffer buffer(1024);
JsonObject& root = buffer.parseObject(payload.begin());
if (!root.success()) {
return;
}
if (!root.containsKey("state")) {
return;
}
const auto state = root["state"].as<String>();
if (StringView("ON") == state) {
lightState(true);
} else if (StringView("OFF") == state) {
lightState(false);
} else {
return;
}
auto transition = lightTransitionTime();
if (root.containsKey("transition")) {
using LocalUnit = decltype(lightTransitionTime());
using RemoteUnit = std::chrono::duration<float>;
auto seconds = RemoteUnit(root["transition"].as<float>());
if (seconds.count() > 0.0f) {
transition = std::chrono::duration_cast<LocalUnit>(seconds);
}
}
if (root.containsKey("color_temp")) {
const auto mireds = root["color_temp"].as<long>();
lightTemperature(light::Mireds{ .value = mireds });
}
if (root.containsKey("brightness")) {
lightBrightness(root["brightness"].as<long>());
}
if (lightHasColor() && root.containsKey("color")) {
JsonObject& color = root["color"];
if (color.containsKey("h")
&& color.containsKey("s"))
{
lightHs(
color["h"].as<long>(),
color["s"].as<long>());
} else if (color.containsKey("r")
&& color.containsKey("g")
&& color.containsKey("b"))
{
lightRgb({
color["r"].as<long>(),
color["g"].as<long>(),
color["b"].as<long>()});
}
if (color.containsKey("w")) {
lightWarmWhite(color["w"].as<long>());
}
if (color.containsKey("c")) {
lightColdWhite(color["c"].as<long>());
}
}
lightUpdate({transition, lightTransitionStep()});
}
#endif
#if SENSOR_SUPPORT
class SensorDiscovery : public Discovery {
public:
explicit SensorDiscovery(Context& ctx) :
_ctx(ctx)
{}
JsonObject& root() {
if (!_root) {
_root = &_ctx.makeObject();
}
return *_root;
}
bool ready() const override {
return _ready;
}
bool ok() const override {
return _ready
&& (_count > 0)
&& (_index < _count);
}
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[F("dev")] = _ctx.device();
json[F("uniq_id")] = uniqueId();
json[F("name")] = _ctx.name() + ' ' + name() + ' ' + localId();
json[F("stat_t")] = mqttTopic(_info.topic);
json[F("unit_of_meas")] = magnitudeUnitsName(_info.units);
json.printTo(_message);
}
return _message;
}
const String& name() {
if (!_name.length()) {
_name = magnitudeTypeTopic(_info.type);
}
return _name;
}
String localId() const {
return String(_info.index, 10);
}
const String& uniqueId() {
if (!_unique_id.length()) {
_unique_id = _ctx.identifier() + '_' + name() + '_' + localId();
}
return _unique_id;
}
bool prepare() override {
if (!_ready) {
_ready = sensorReady();
if (!_ready) {
return false;
}
_count = magnitudeCount();
_index = 0;
if (_count > 0) {
_info = magnitudeInfo(_index);
}
}
return _ready;
}
bool next() override {
if (_index < _count) {
auto current = _index;
++_index;
if ((_index > current) && (_index < _count)) {
_info = magnitudeInfo(_index);
_unique_id = "";
_name = "";
_topic = "";
_message = "";
return true;
}
}
return false;
}
private:
Context& _ctx;
JsonObject* _root { nullptr };
sensor::Info _info;
size_t _count;
size_t _index;
bool _ready { false };
String _unique_id;
String _name;
String _topic;
String _message;
};
#endif
DevicePtr make_device_ptr() {
return std::make_unique<Device>(
make_config_strings(),
make_build_strings());
}
Context make_context() {
return Context(make_device_ptr(), 2048);
}
// use 5 retries and set a specific duration for each attempt
using Durations = std::array<duration::Milliseconds, 5>;
#if __cplusplus >= 201703L
#define __CONSTEXPR constexpr
#else
#define __CONSTEXPR
#endif
struct Wait {
using Value = typename Durations::value_type;
__CONSTEXPR Wait(const Durations& base) :
_begin(std::begin(base)),
_end(std::end(base)),
_it(_begin)
{}
void reset() {
_it = _begin;
}
Value value() const {
return *_it;
}
void change_next() {
_it = next_it(_it);
}
bool try_next() {
auto current = _it;
change_next();
return current != _it;
}
Value next() {
change_next();
return value();
}
__CONSTEXPR Value first() const {
return *_begin;
}
bool is_first() const {
return _it == _begin;
}
__CONSTEXPR Value last() const {
return *_end;
}
bool is_last() const {
return _it == _end;
}
__CONSTEXPR size_t count() const {
return _end - _begin;
}
private:
using Iterator = Durations::const_iterator;
Iterator next_it(Iterator value) {
const auto next = value + 1;
if (next < _end) {
return next;
}
return value;
}
Iterator _begin;
Iterator _end;
Iterator _it;
};
#undef __CONSTEXPR
// intervals between send attempts, usually long enough to push data to the network stack
static constexpr Durations ShortDurations{{
duration::Milliseconds{ 100 },
duration::Milliseconds{ 500 },
duration::Milliseconds{ 1000 },
duration::Milliseconds{ 2500 },
duration::Milliseconds{ 5000 },
}};
// longer intervals between initialization attempts, give enough time for external things
static constexpr Durations LongDurations{{
duration::Seconds{ 5 },
duration::Seconds{ 10 },
duration::Seconds{ 15 },
duration::Seconds{ 30 },
duration::Seconds{ 60 },
}};
struct Result {
using Duration = duration::Milliseconds;
enum class Value {
Error,
Ok,
Retry,
};
Result(Duration wait, Value value) :
_wait(wait),
_value(value)
{}
explicit Result(Duration wait) :
Result(wait, Value::Ok)
{}
Result() :
Result(ShortDurations.front())
{}
bool ok() const {
return _value == Value::Ok;
}
bool retry() const {
return _value == Value::Retry;
}
explicit operator bool() const {
return ok();
}
Duration wait() const {
return _wait;
}
private:
Duration _wait;
Value _value;
};
Result next_retry(Wait& wait) {
const auto retry = wait.is_last()
? Result::Value::Error
: Result::Value::Retry;
const auto value = wait.value();
wait.try_next();
return Result(value, retry);
}
// 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>;
DiscoveryTask() = delete;
DiscoveryTask(const DiscoveryTask&) = delete;
DiscoveryTask& operator=(const DiscoveryTask&) = delete;
DiscoveryTask(DiscoveryTask&&) = delete;
DiscoveryTask& operator=(DiscoveryTask&&) = delete;
DiscoveryTask(Context ctx, State state) :
_ctx(std::move(ctx)),
_state(state)
{}
void add(Entity&& entity) {
_entities.push_front(std::move(entity));
}
template <typename T>
void add() {
_entities.push_front(std::make_unique<T>(_ctx));
}
Result retry_send() {
return next_retry(_wait_short);
}
Context& context() {
return _ctx;
}
bool done() const {
return _entities.empty();
}
State state() const {
return _state;
}
template <typename T>
Result try_send_one(T&& action);
Result prepare_all();
private:
Result next_send() {
_wait_short.reset();
return Result(_wait_short.value());
}
Result stop_sending() {
return Result(
_wait_short.first(),
Result::Value::Error);
}
Context _ctx;
State _state;
Entities _entities;
Wait _wait_short { ShortDurations };
Wait _wait_long { LongDurations };
};
Result DiscoveryTask::prepare_all() {
bool prepared { true };
for (auto& entity : _entities) {
if (!entity->prepare()) {
prepared = false;
break;
}
}
if (!prepared) {
return next_retry(_wait_long);
}
return Result();
}
template <typename T>
Result DiscoveryTask::try_send_one(T&& action) {
auto it = _entities.begin();
while (it != _entities.end()) {
if (!(*it)->ok()) {
it = _entities.erase_after(
_entities.before_begin());
_ctx.reset();
continue;
}
const auto* topic = (*it)->topic().c_str();
const auto* msg = (State::Enabled == _state)
? (*it)->message().c_str()
: "";
if (action(topic, msg)) {
if (!(*it)->next()) {
it = _entities.erase_after(
_entities.before_begin());
_ctx.reset();
}
return next_send();
}
return retry_send();
}
return stop_sending();
}
using DiscoveryPtr = std::shared_ptr<DiscoveryTask>;
using FlagPtr = std::shared_ptr<bool>;
DiscoveryPtr makeDiscovery(State);
namespace internal {
bool enabled { build::enabled() };
bool retain { build::retain() };
String birthTopic;
timer::SystemTimer task;
void send(DiscoveryPtr, FlagPtr);
void schedule(duration::Milliseconds wait, DiscoveryPtr ptr, FlagPtr flag_ptr) {
task.schedule_once(
wait,
[ptr, flag_ptr]() {
send(ptr, flag_ptr);
});
}
void stop() {
DEBUG_MSG_P(PSTR("[HA] Stopping discovery\n"));
internal::task.stop();
}
void send(DiscoveryPtr discovery, FlagPtr flag_ptr) {
if (!mqttConnected() || discovery->done()) {
stop();
return;
}
auto ready = discovery->prepare_all();
if (!ready) {
if (ready.retry()) {
DEBUG_MSG_P(PSTR("[HA] Discovery not ready, retrying in %zu (ms)\n"),
ready.wait().count());
schedule(ready.wait(), discovery, flag_ptr);
} else {
stop();
}
return;
}
auto& flag = *flag_ptr;
if (!flag) {
const auto next_send = discovery->retry_send();
if (next_send.retry()) {
schedule(next_send.wait(), discovery, flag_ptr);
} else {
stop();
}
return;
}
uint16_t pid { 0u };
const auto sent = discovery->try_send_one(
[&](const char* topic, const char* message) {
pid = ::mqttSendRaw(topic, message, internal::retain, 1);
return pid > 0;
});
#if MQTT_LIBRARY == MQTT_LIBRARY_ASYNCMQTTCLIENT
// Receive acknowledgement from the broker before continuing.
// Usually a good idea in general, to avoid filling network buffers too quickly.
//
// Not needed with LWMQTT, as it is already handled and wrapped in Result
// Not supported by PubSubClient
if (sent) {
flag = false;
mqttOnPublish(
pid,
[flag_ptr]() {
(*flag_ptr) = true;
});
}
#endif
if (sent.ok() || sent.retry()) {
schedule(sent.wait(), discovery, flag_ptr);
return;
}
if (!sent) {
stop();
}
}
} // namespace internal
DiscoveryPtr makeDiscovery(State state) {
auto discovery = std::make_shared<DiscoveryTask>(
make_context(), state);
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
discovery->add<LightDiscovery>();
#endif
#if RELAY_SUPPORT
discovery->add<RelayDiscovery>();
#endif
#if SENSOR_SUPPORT
discovery->add<SensorDiscovery>();
#endif
return discovery;
}
void scheduleDiscovery(duration::Milliseconds duration, DiscoveryPtr discovery) {
DEBUG_MSG_P(PSTR("[HA] Starting discovery\n"));
internal::schedule(duration, discovery, std::make_shared<bool>(true));
}
void scheduleDiscovery(DiscoveryPtr discovery) {
scheduleDiscovery(ShortDurations.front(), discovery);
}
void publishDiscoveryForState(State state) {
if (!mqttConnected()) {
return;
}
auto discovery = makeDiscovery(state);
// only happens when nothing is configured to do the add()
if (discovery->done()) {
DEBUG_MSG_P(PSTR("[HA] No discovery task(s) available\n"));
return;
}
scheduleDiscovery(discovery);
}
void publishDiscoveryForState(bool state) {
publishDiscoveryForState(
state
? State::Enabled
: State::Disabled);
}
void publishDiscoveryForCurrentState() {
publishDiscoveryForState(internal::enabled);
}
void configure() {
auto birthTopic = settings::birthTopic();
const auto birthChanged = birthTopic != internal::birthTopic;
if (mqttConnected() && birthChanged) {
if (internal::birthTopic.length()) {
mqttUnsubscribeRaw(internal::birthTopic.c_str());
}
if (birthTopic.length()) {
mqttSubscribeRaw(birthTopic.c_str());
}
}
if (birthChanged) {
internal::birthTopic = std::move(birthTopic);
}
internal::retain = settings::retain();
const auto current = internal::enabled;
internal::enabled = settings::enabled();
if (mqttConnected() && (current != internal::enabled)) {
publishDiscoveryForState(current);
}
}
namespace mqtt {
void onDisconnected() {
internal::task.stop();
}
void onConnected() {
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
::mqttSubscribe(Topic);
#endif
::espurnaRegisterOnce(publishDiscoveryForCurrentState);
if (internal::birthTopic.length()) {
::mqttSubscribeRaw(internal::birthTopic.c_str());
}
}
void onMessage(StringView topic, StringView payload) {
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
auto t = ::mqttMagnitude(topic);
if (t.equals(Topic)) {
receiveLightJson(payload);
return;
}
#endif
if ((topic == internal::birthTopic)
&& (payload == settings::birthPayload()))
{
publishDiscoveryForCurrentState();
}
}
void callback(unsigned int type, StringView topic, StringView payload) {
if (MQTT_DISCONNECT_EVENT == type) {
onDisconnected();
return;
}
if (MQTT_CONNECT_EVENT == type) {
onConnected();
return;
}
if (type == MQTT_MESSAGE_EVENT) {
onMessage(topic, payload);
return;
}
}
} // namespace mqtt
namespace web {
#if WEB_SUPPORT
void onAction(uint32_t, const char* action, JsonObject& data) {
STRING_VIEW_INLINE(Publish, "ha-publish");
STRING_VIEW_INLINE(State, "state");
if ((Publish == action) && data.containsKey(State)) {
publishDiscoveryForState(data[State].as<bool>());
return;
}
}
void onVisible(JsonObject& root) {
wsPayloadModule(root, settings::query::Prefix);
}
void onConnected(JsonObject& root) {
root[settings::keys::Enabled] = settings::enabled();
root[settings::keys::Prefix] = settings::prefix();
root[settings::keys::Retain] = settings::retain();
root[settings::keys::BirthTopic] = settings::birthTopic();
root[settings::keys::BirthPayload] = settings::birthPayload();
}
bool onKeyCheck(StringView key, const JsonVariant&) {
return settings::query::checkSamePrefix(key);
}
#endif
} // namespace web
#if TERMINAL_SUPPORT
namespace terminal {
STRING_VIEW_INLINE(Dump, "HA");
void dump(::terminal::CommandContext&& ctx) {
settingsDump(ctx, settings::query::Settings);
}
STRING_VIEW_INLINE(Send, "HA.SEND");
void send(::terminal::CommandContext&& ctx) {
publishDiscoveryForState(State::Enabled);
terminalOK(ctx);
}
STRING_VIEW_INLINE(Clear, "HA.CLEAR");
void clear(::terminal::CommandContext&& ctx) {
publishDiscoveryForState(State::Disabled);
terminalOK(ctx);
}
static constexpr ::terminal::Command Commands[] PROGMEM {
{Dump, dump},
{Clear, clear},
{Send, send},
};
void setup() {
espurna::terminal::add(Commands);
}
} // namespace terminal
#endif
void setup() {
#if WEB_SUPPORT
wsRegister()
.onAction(web::onAction)
.onVisible(web::onVisible)
.onConnected(web::onConnected)
.onKeyCheck(web::onKeyCheck);
#endif
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
lightOnReport(publishLightJson);
mqttHeartbeat(heartbeat);
#endif
mqttRegister(mqtt::callback);
#if TERMINAL_SUPPORT
terminal::setup();
#endif
settings::query::setup();
espurnaRegisterReload(configure);
configure();
}
} // namespace
} // namespace homeassistant
} // namespace espurna
// 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() {
espurna::homeassistant::setup();
}
#endif // HOMEASSISTANT_SUPPORT