diff --git a/CODEOWNERS b/CODEOWNERS index c630db7948..db49afa144 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -352,6 +352,7 @@ esphome/components/st7701s/* @clydebarrow esphome/components/st7735/* @SenexCrenshaw esphome/components/st7789v/* @kbx81 esphome/components/st7920/* @marsjan155 +esphome/components/status_indicator/* @nielsnl68 esphome/components/substitutions/* @esphome/core esphome/components/sun/* @OttoWinter esphome/components/sun_gtil2/* @Mat931 diff --git a/esphome/components/status_indicator/__init__.py b/esphome/components/status_indicator/__init__.py new file mode 100644 index 0000000000..884f3c6fe5 --- /dev/null +++ b/esphome/components/status_indicator/__init__.py @@ -0,0 +1,164 @@ +import esphome.config_validation as cv +import esphome.codegen as cg +import esphome.automation as auto +from esphome.const import ( + CONF_ID, + CONF_PRIORITY, + CONF_GROUP, + CONF_TRIGGER_ID, + CONF_ON_TURN_OFF, + CONF_VALUE, +) +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + schema_extractor, +) + +from esphome.core import coroutine_with_priority + +CODEOWNERS = ["@nielsnl68"] + +status_led_ns = cg.esphome_ns.namespace("status_indicator") +StatusIndicator = status_led_ns.class_("StatusIndicator", cg.Component) +StatusTrigger = status_led_ns.class_("StatusTrigger", auto.Trigger.template()) +StatusAction = status_led_ns.class_("StatusAction", auto.Action) +CONF_TRIGGER_LIST = { + "on_app_error": True, + "on_clear_app_error": True, + "on_app_warning": True, + "on_clear_app_warning": True, + "on_network_connected": True, + "on_network_disconnected": True, + "on_wifi_ap_enabled": True, + "on_wifi_ap_disabled": True, + "on_api_connected": True, + "on_api_disconnected": True, + "on_mqtt_connected": True, + "on_mgtt_disconnected": True, + "on_custom_status": False, +} +CONF_WHICH = "which" + + +def trigger_setup(Single): + return auto.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StatusTrigger), + cv.Optional(CONF_GROUP, default=""): cv.string, + cv.Optional(CONF_PRIORITY, default=0): cv.int_range(0, 10), + }, + single=Single, + ) + + +def add_default_triggers(): + result = {} + + for trigger, single in CONF_TRIGGER_LIST.items(): + result[cv.Optional(trigger)] = trigger_setup(single) + return cv.Schema(result) + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(StatusIndicator), + cv.Required(CONF_ON_TURN_OFF): trigger_setup(True), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(add_default_triggers()) +) + + +async def add_trigger(var, conf, key): + trigger = cg.new_Pvariable( + conf[CONF_TRIGGER_ID], + var, + conf[CONF_GROUP], + conf[CONF_PRIORITY], + conf[CONF_TRIGGER_ID].id, + ) + await auto.build_automation(trigger, [], conf) + if key is not None: + cg.add(var.set_trigger(key, trigger)) + + +@coroutine_with_priority(80.0) +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await add_trigger(var, config.get(CONF_ON_TURN_OFF), CONF_ON_TURN_OFF) + + for trigger_name, single in CONF_TRIGGER_LIST.items(): + conf = config.get(trigger_name, None) + if conf is not None: + if single: + await add_trigger(var, conf, trigger_name) + else: + for conf in config.get(trigger_name, []): + await add_trigger(var, conf, None) + + +def maybe_simple_valuex(*validators, **kwargs): + key = kwargs.pop("key", CONF_VALUE) + validator = cv.All(*validators) + + @schema_extractor("maybe") + def validate(value): + if value == SCHEMA_EXTRACT: + return (validator, key) + + if isinstance(value, dict): + return validator(value) + return validator({key: value}) + + return validate + + +@auto.register_action( + "status.push", + StatusAction, + maybe_simple_valuex( + { + cv.GenerateID(CONF_ID): cv.use_id(StatusIndicator), + cv.Required(CONF_TRIGGER_ID): cv.use_id(StatusTrigger), + }, + key=CONF_TRIGGER_ID, + ), +) +async def status_action_push_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + + await cg.register_parented(var, config[CONF_ID]) + + cg.add(var.set_state(True)) + trigger = await cg.get_variable(config[CONF_TRIGGER_ID]) + cg.add(var.set_trigger(trigger)) + return var + + +@auto.register_action( + "status.pop", + StatusAction, + maybe_simple_valuex( + { + cv.GenerateID(CONF_ID): cv.use_id(StatusIndicator), + cv.Optional(CONF_GROUP): cv.string, + cv.Optional(CONF_TRIGGER_ID): cv.use_id(StatusTrigger), + }, + cv.has_exactly_one_key(CONF_GROUP, CONF_TRIGGER_ID), + key=CONF_TRIGGER_ID, + ), +) +async def status_action_pop_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + cg.add(var.set_state(False)) + if CONF_TRIGGER_ID in config: + trigger = await cg.get_variable(config[CONF_TRIGGER_ID]) + cg.add(var.set_trigger(trigger)) + else: + cg.add(var.set_group(config[CONF_GROUP])) + + return var diff --git a/esphome/components/status_indicator/_test/.gitignore b/esphome/components/status_indicator/_test/.gitignore new file mode 100644 index 0000000000..d8b4157aef --- /dev/null +++ b/esphome/components/status_indicator/_test/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/esphome/components/status_indicator/_test/test.yaml b/esphome/components/status_indicator/_test/test.yaml new file mode 100644 index 0000000000..73ee01c26d --- /dev/null +++ b/esphome/components/status_indicator/_test/test.yaml @@ -0,0 +1,146 @@ +--- +esphome: + name: m5stack-atom-echo + +esp32: + board: m5stack-atom + +logger: + level: DEBUG + +light: + - platform: esp32_rmt_led_strip + id: led + pin: GPIO27 + default_transition_length: 0s + chipset: SK6812 + num_leds: 1 + rgb_order: grb + rmt_channel: 0 + effects: + - pulse: + name: "Slow Pulse" + transition_length: 250ms + update_interval: 250ms + min_brightness: 50% + max_brightness: 100% + - pulse: + name: "Fast Pulse" + transition_length: 100ms + update_interval: 100ms + min_brightness: 50% + max_brightness: 100% + +wifi: + ssid: LumenIOT24 + password: Lum3nS0fT + +binary_sensor: + - platform: gpio + pin: + number: GPIO39 + inverted: true + name: Button + disabled_by_default: true + entity_category: diagnostic + id: button + on_press: + - status.push: custom_status2 + - status.push: custom_status3 + - status.push: custom_status1 + on_release: + - status.pop: + group: "custom" + - delay: 5s + - status.pop: custom_status1 + +status_indicator: + on_turn_off: # Manditory + - light.turn_off: + id: led + + on_app_error: + then: + - light.turn_on: + id: led + red: 100% + green: 0% + blue: 0% + brightness: 100% + effect: None + + on_app_warning: + then: + - light.turn_on: + id: led + red: 100% + green: 0% + blue: 0% + brightness: 100% + effect: Slow Pulse + + on_wifi_ap_enabled: + then: + - light.turn_on: + id: led + red: 100% + green: 100% + blue: 50% + brightness: 100% + effect: None + + on_network_disconnected: + then: + - light.turn_on: + id: led + red: 100% + green: 0% + blue: 0% + brightness: 100% + effect: Fast Pulse + + on_api_disconnected: + then: + - light.turn_on: + id: led + red: 100% + green: 100% + blue: 0% + brightness: 100% + effect: Fast Pulse + + on_custom_status: + - trigger_id: custom_status1 + priority: 1 + then: + - light.turn_on: + id: led + red: 0% + green: 100% + blue: 0% + brightness: 100% + effect: None + - trigger_id: custom_status2 + group: custom + priority: 3 + then: + - light.turn_on: + id: led + red: 50% + green: 0% + blue: 100% + brightness: 100% + effect: None + - trigger_id: custom_status3 + group: custom1 + priority: 1 + then: + - light.turn_on: + id: led + red: 100% + green: 100% + blue: 50% + brightness: 100% + effect: Fast pulse + - delay: 10s + - status.pop: custom_status3 diff --git a/esphome/components/status_indicator/status_indicator.cpp b/esphome/components/status_indicator/status_indicator.cpp new file mode 100644 index 0000000000..9ff57aa308 --- /dev/null +++ b/esphome/components/status_indicator/status_indicator.cpp @@ -0,0 +1,213 @@ +#include "status_indicator.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +#ifdef USE_ETHERNET +#include "esphome/components/ethernet/ethernet_component.h" +#endif +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif +#ifdef USE_MQTT +#include "esphome/components/mqtt/mqtt_client.h" +#endif +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif + +namespace esphome { +namespace status_indicator { + +bool has_network() { +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr) + return true; +#endif + +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) + return true; +#endif + +#ifdef USE_HOST + return true; // Assume its connected +#endif + return false; +} + +bool is_connected() { +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) + return true; +#endif + +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) + return wifi::global_wifi_component->is_connected(); +#endif + +#ifdef USE_HOST + return true; // Assume its connected +#endif + return false; +} + +static const char *const TAG = "status_indicator"; + +void StatusIndicator::dump_config() { + ESP_LOGCONFIG(TAG, "Status Indicator supports:"); + for (auto i = this->triggers_.begin(); i != this->triggers_.end(); i++) { + ESP_LOGCONFIG(TAG, " * %s: %s", i->first.c_str(), i->second->get_info().c_str()); + } +} +void StatusIndicator::loop() { + std::string status{""}; + if ((App.get_app_state() & STATUS_LED_ERROR) != 0u) { + status = "on_app_error"; + this->status_.on_error = 1; + } else if (this->status_.on_error) { + status = "on_clear_app_error"; + this->status_.on_error = 0; + } + + if (has_network()) { +#ifdef USE_WIFI + if (status.empty() && wifi::global_wifi_component->is_ap_enabled()) { + status = "on_wifi_ap_enabled"; + this->status_.on_wifi_ap = 1; + } else if (this->status_.on_wifi_ap) { + status = "on_wifi_ap_disabled"; + this->status_.on_wifi_ap = 0; + } +#endif + + if (status.empty() && not is_connected()) { + status = "on_network_disconnected"; + this->status_.on_network = 1; + } else if (this->status_.on_network) { + status = "on_network_connected"; + this->status_.on_network = 0; + } + +#ifdef USE_API + if (status.empty() && api::global_api_server != nullptr && not api::global_api_server->is_connected()) { + status = "on_api_disconnected"; + this->status_.on_api = 1; + } else if (this->status_.on_api) { + status = "on_api_connected"; + this->status_.on_api = 0; + } +#endif +#ifdef USE_MQTT + if (status.empty() && mqtt::global_mqtt_client != nullptr && not mqtt::global_mqtt_client->is_connected()) { + status = "on_mqtt_disconnected"; + this->status_.on_mqtt = 1; + } else if (this->status_.on_mqtt) { + status = "on_mqtt_connected"; + this->status_.on_mqtt = 0; + } +#endif + } + if (status.empty() && (App.get_app_state() & STATUS_LED_WARNING) != 0u) { + status = "on_app_warning"; + this->status_.on_warning = 1; + } else if (this->status_.on_warning) { + status = "on_clear_app_warning"; + this->status_.on_warning = 0; + } + + if (this->current_status_ != status) { + if (this->current_trigger_ != nullptr and this->current_trigger_->is_action_running() and !status.empty()) { + this->current_trigger_->stop_action(); + } + StatusTrigger *oldtrigger = this->current_trigger_; + + if (!status.empty() && this->triggers_.count(status) == 1) { + this->current_trigger_ = get_trigger(status); + } else if (!this->stack_.empty()) { + this->current_trigger_ = this->stack_.back(); + status = "on_custom_status"; + } else { + this->current_trigger_ = get_trigger("on_turn_off"); + status = "on_turn_off"; + } + if (oldtrigger != this->current_trigger_) { + ESP_LOGI(TAG, "<>> %s->%s", status.c_str(), this->current_trigger_->get_name().c_str()); + this->current_trigger_->trigger(); + } + this->current_status_ = status; + } +} + +float StatusIndicator::get_setup_priority() const { return setup_priority::HARDWARE; } +float StatusIndicator::get_loop_priority() const { return 50.0f; } + +StatusTrigger *StatusIndicator::get_trigger(const std::string &key) { + auto search = this->triggers_.find(key); + if (search != this->triggers_.end()) { + return search->second; + } else { + return nullptr; + } +} + +void StatusIndicator::set_trigger(const std::string &key, StatusTrigger *trigger) { this->triggers_[key] = trigger; } + +void StatusIndicator::push_trigger(StatusTrigger *trigger) { + this->pop_trigger(trigger, true); + ESP_LOGD(TAG, "Push ID: %s", trigger->get_info().c_str()); + + for (auto i = this->stack_.begin(); i != this->stack_.end(); ++i) { + StatusTrigger *st = *i; + if (trigger->get_priority() < st->get_priority()) { + this->stack_.insert(i, trigger); + this->current_status_ = "update me"; + // log_triggers_(); + return; + } + } + this->stack_.push_back(trigger); + this->current_status_ = "update me"; + // log_triggers_(); +} + +void StatusIndicator::pop_trigger(StatusTrigger *trigger, bool incl_group) { + incl_group = incl_group && !trigger->get_group().empty(); + ESP_LOGD(TAG, "Pop by ID: %s || %s", trigger->get_info().c_str(), YESNO(incl_group)); + std::string group = trigger->get_group(); + for (auto i = this->stack_.begin(); i != this->stack_.end();) { + StatusTrigger *st = *i; + if ((incl_group && group == st->get_group()) || (trigger == st)) { + this->stack_.erase(i); + this->current_status_ = "update me"; + } else { + ++i; + } + } + // log_triggers_(); +} + +void StatusIndicator::pop_trigger(const std::string &group) { + ESP_LOGD(TAG, "Pop by group: %s", group.c_str()); + + for (auto i = this->stack_.begin(); i != this->stack_.end();) { + StatusTrigger *st = *i; + if (group == st->get_group()) { + this->stack_.erase(i); + this->current_status_ = "update me"; + } else { + ++i; + } + } + // log_triggers_(); +} + +void StatusIndicator::log_triggers_() { + for (auto *st : this->stack_) { + ESP_LOGD(TAG, "%s", st->get_info().c_str()); + } + ESP_LOGD(TAG, "----------------------------- %d ----", this->stack_.size()); +} + +} // namespace status_indicator +} // namespace esphome diff --git a/esphome/components/status_indicator/status_indicator.h b/esphome/components/status_indicator/status_indicator.h new file mode 100644 index 0000000000..6f2fb84a68 --- /dev/null +++ b/esphome/components/status_indicator/status_indicator.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/light/automation.h" + +namespace esphome { +namespace status_indicator { +class StatusTrigger; + +union StatusFlags { + struct { + int on_error : 1; + int on_warning : 1; + int on_network : 1; + int on_api : 1; + int on_mqtt : 1; + int on_wifi_ap : 1; + }; + int setter = 0; +}; + +class StatusIndicator : public Component { + public: + void dump_config() override; + void loop() override; + + float get_setup_priority() const override; + float get_loop_priority() const override; + + StatusTrigger *get_trigger(const std::string &key); + void set_trigger(const std::string &key, StatusTrigger *trigger); + void push_trigger(StatusTrigger *trigger); + void pop_trigger(StatusTrigger *trigger, bool incl_group = false); + void pop_trigger(const std::string &group); + + protected: + void log_triggers_(); + std::string current_status_{""}; + StatusTrigger *current_trigger_{nullptr}; + StatusFlags status_; + std::map triggers_{}; + std::vector stack_{}; +}; + +class StatusTrigger : public Trigger<> { + public: + explicit StatusTrigger(StatusIndicator *parent, std::string group, uint32_t priority, std::string name) + : parent_(parent), name_(std::move(name)), group_(std::move(group)), priority_(priority) {} + std::string get_group() { return this->group_; } + uint32_t get_priority() { return this->priority_; } + std::string get_name() { return this->name_; } + std::string get_info() { return this->name_ + " \t| " + this->group_ + " \t| " + std::to_string(this->priority_); } + + protected: + StatusIndicator *parent_; + std::string name_; /// Minimum length of click. 0 means no minimum. + std::string group_; /// Minimum length of click. 0 means no minimum. + uint32_t priority_; /// Maximum length of click. 0 means no maximum. +}; + +template class StatusCondition : public Condition { + public: + StatusCondition(StatusIndicator *parent, bool state) : parent_(parent), state_(state) {} + bool check(Ts... x) override { return (this->parent_->status_.setter == 0) == this->state_; } + + protected: + StatusIndicator *parent_; + bool state_; +}; + +template class StatusAction : public Action, public Parented { + public: + void set_state(bool state) { this->state_ = state; } + void set_trigger(StatusTrigger *trigger) { this->trigger_ = trigger; } + void set_group(const std::string &group) { this->group_ = group; } + + void play(Ts... x) override { + if (this->state_) { + if (this->trigger_ != nullptr) { + this->parent_->push_trigger(this->trigger_); + } + } else if (this->group_ != "") { + this->parent_->pop_trigger(this->group_); + } else if (this->trigger_ != nullptr) { + this->parent_->pop_trigger(this->trigger_, false); + } + } + + protected: + StatusIndicator *indicator_; + StatusTrigger *trigger_{nullptr}; + std::string group_{""}; + bool state_{false}; +}; + +} // namespace status_indicator +} // namespace esphome diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 075e683bb5..4b071a50e2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -201,6 +201,8 @@ void WiFiComponent::loop() { WiFiComponent::WiFiComponent() { global_wifi_component = this; } bool WiFiComponent::has_ap() const { return this->has_ap_; } +bool WiFiComponent::is_ap_enabled() const { return this->ap_setup_; } + bool WiFiComponent::has_sta() const { return !this->sta_.empty(); } void WiFiComponent::set_fast_connect(bool fast_connect) { this->fast_connect_ = fast_connect; } #ifdef USE_WIFI_11KV_SUPPORT diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 133fa2970c..07cf1b3c85 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -251,6 +251,7 @@ class WiFiComponent : public Component { bool has_sta() const; bool has_ap() const; + bool is_ap_enabled() const; #ifdef USE_WIFI_11KV_SUPPORT void set_btm(bool btm);