From ff7d232ee6ed86b69e7abfa139a8d83af07c620b Mon Sep 17 00:00:00 2001
From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Date: Mon, 10 Feb 2025 13:53:26 +1100
Subject: [PATCH] [logger] Add runtime level select (#8222)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
---
 CODEOWNERS                                    |  1 +
 esphome/components/logger/__init__.py         | 48 ++++++++++++++++---
 esphome/components/logger/logger.cpp          | 27 ++++++-----
 esphome/components/logger/logger.h            | 18 ++++---
 esphome/components/logger/select/__init__.py  | 29 +++++++++++
 .../logger/select/logger_level_select.cpp     | 27 +++++++++++
 .../logger/select/logger_level_select.h       | 15 ++++++
 esphome/core/defines.h                        |  3 ++
 .../logger/common-default_uart.yaml           | 12 ++++-
 9 files changed, 154 insertions(+), 26 deletions(-)
 create mode 100644 esphome/components/logger/select/__init__.py
 create mode 100644 esphome/components/logger/select/logger_level_select.cpp
 create mode 100644 esphome/components/logger/select/logger_level_select.h

diff --git a/CODEOWNERS b/CODEOWNERS
index eab02efffb..d4b3d7eff9 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -242,6 +242,7 @@ esphome/components/lightwaverf/* @max246
 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
 esphome/components/lock/* @esphome/core
 esphome/components/logger/* @esphome/core
+esphome/components/logger/select/* @clydebarrow
 esphome/components/ltr390/* @latonita @sjtrny
 esphome/components/ltr501/* @latonita
 esphome/components/ltr_als_ps/* @latonita
diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py
index 6e92777058..a89bf95c77 100644
--- a/esphome/components/logger/__init__.py
+++ b/esphome/components/logger/__init__.py
@@ -35,7 +35,7 @@ from esphome.const import (
     PLATFORM_RP2040,
     PLATFORM_RTL87XX,
 )
-from esphome.core import CORE, EsphomeError, Lambda, coroutine_with_priority
+from esphome.core import CORE, Lambda, coroutine_with_priority
 
 CODEOWNERS = ["@esphome/core"]
 logger_ns = cg.esphome_ns.namespace("logger")
@@ -77,6 +77,9 @@ USB_SERIAL_JTAG = "USB_SERIAL_JTAG"
 USB_CDC = "USB_CDC"
 DEFAULT = "DEFAULT"
 
+CONF_INITIAL_LEVEL = "initial_level"
+CONF_LOGGER_ID = "logger_id"
+
 UART_SELECTION_ESP32 = {
     VARIANT_ESP32: [UART0, UART1, UART2],
     VARIANT_ESP32S2: [UART0, UART1, USB_CDC],
@@ -154,11 +157,11 @@ def uart_selection(value):
 
 
 def validate_local_no_higher_than_global(value):
-    global_level = value.get(CONF_LEVEL, "DEBUG")
+    global_level = LOG_LEVEL_SEVERITY.index(value[CONF_LEVEL])
     for tag, level in value.get(CONF_LOGS, {}).items():
-        if LOG_LEVEL_SEVERITY.index(level) > LOG_LEVEL_SEVERITY.index(global_level):
-            raise EsphomeError(
-                f"The local log level {level} for {tag} must be less severe than the global log level {global_level}."
+        if LOG_LEVEL_SEVERITY.index(level) > global_level:
+            raise cv.Invalid(
+                f"The configured log level for {tag} ({level}) must be no more severe than the global log level {value[CONF_LEVEL]}."
             )
     return value
 
@@ -209,6 +212,7 @@ CONFIG_SCHEMA = cv.All(
                     cv.string: is_log_level,
                 }
             ),
+            cv.Optional(CONF_INITIAL_LEVEL): is_log_level,
             cv.Optional(CONF_ON_MESSAGE): automation.validate_automation(
                 {
                     cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LoggerMessageTrigger),
@@ -227,7 +231,14 @@ CONFIG_SCHEMA = cv.All(
 @coroutine_with_priority(90.0)
 async def to_code(config):
     baud_rate = config[CONF_BAUD_RATE]
-    log = cg.new_Pvariable(config[CONF_ID], baud_rate, config[CONF_TX_BUFFER_SIZE])
+    level = config[CONF_LEVEL]
+    initial_level = LOG_LEVELS[config.get(CONF_INITIAL_LEVEL, level)]
+    log = cg.new_Pvariable(
+        config[CONF_ID],
+        baud_rate,
+        config[CONF_TX_BUFFER_SIZE],
+    )
+    cg.add(log.set_log_level(initial_level))
     if CONF_HARDWARE_UART in config:
         cg.add(
             log.set_uart_selection(
@@ -239,7 +250,6 @@ async def to_code(config):
     for tag, level in config[CONF_LOGS].items():
         cg.add(log.set_log_level(tag, LOG_LEVELS[level]))
 
-    level = config[CONF_LEVEL]
     cg.add_define("USE_LOGGER")
     this_severity = LOG_LEVEL_SEVERITY.index(level)
     cg.add_build_flag(f"-DESPHOME_LOG_LEVEL={LOG_LEVELS[level]}")
@@ -367,3 +377,27 @@ async def logger_log_action_to_code(config, action_id, template_arg, args):
 
     lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void)
     return cg.new_Pvariable(action_id, template_arg, lambda_)
+
+
+@automation.register_action(
+    "logger.set_level",
+    LambdaAction,
+    cv.maybe_simple_value(
+        {
+            cv.GenerateID(CONF_LOGGER_ID): cv.use_id(Logger),
+            cv.Required(CONF_LEVEL): is_log_level,
+            cv.Optional(CONF_TAG): cv.string,
+        },
+        key=CONF_LEVEL,
+    ),
+)
+async def logger_set_level_to_code(config, action_id, template_arg, args):
+    level = LOG_LEVELS[config[CONF_LEVEL]]
+    logger = await cg.get_variable(config[CONF_LOGGER_ID])
+    if tag := config.get(CONF_TAG):
+        text = str(cg.statement(logger.set_log_level(tag, level)))
+    else:
+        text = str(cg.statement(logger.set_log_level(level)))
+
+    lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void)
+    return cg.new_Pvariable(action_id, template_arg, lambda_)
diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp
index 36934c7459..79fc4cf499 100644
--- a/esphome/components/logger/logger.cpp
+++ b/esphome/components/logger/logger.cpp
@@ -105,12 +105,9 @@ int HOT Logger::level_for(const char *tag) {
   // Uses std::vector<> for low memory footprint, though the vector
   // could be sorted to minimize lookup times. This feature isn't used that
   // much anyway so it doesn't matter too much.
-  for (auto &it : this->log_levels_) {
-    if (it.tag == tag) {
-      return it.level;
-    }
-  }
-  return ESPHOME_LOG_LEVEL;
+  if (this->log_levels_.count(tag) != 0)
+    return this->log_levels_[tag];
+  return this->current_level_;
 }
 
 void HOT Logger::log_message_(int level, const char *tag, int offset) {
@@ -167,9 +164,7 @@ void Logger::loop() {
 #endif
 
 void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; }
-void Logger::set_log_level(const std::string &tag, int log_level) {
-  this->log_levels_.push_back(LogLevelOverride{tag, log_level});
-}
+void Logger::set_log_level(const std::string &tag, int log_level) { this->log_levels_[tag] = log_level; }
 
 #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY)
 UARTSelection Logger::get_uart() const { return this->uart_; }
@@ -183,18 +178,28 @@ const char *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", "CONFIG", "DE
 
 void Logger::dump_config() {
   ESP_LOGCONFIG(TAG, "Logger:");
-  ESP_LOGCONFIG(TAG, "  Level: %s", LOG_LEVELS[ESPHOME_LOG_LEVEL]);
+  ESP_LOGCONFIG(TAG, "  Max Level: %s", LOG_LEVELS[ESPHOME_LOG_LEVEL]);
+  ESP_LOGCONFIG(TAG, "  Initial Level: %s", LOG_LEVELS[this->current_level_]);
 #ifndef USE_HOST
   ESP_LOGCONFIG(TAG, "  Log Baud Rate: %" PRIu32, this->baud_rate_);
   ESP_LOGCONFIG(TAG, "  Hardware UART: %s", get_uart_selection_());
 #endif
 
   for (auto &it : this->log_levels_) {
-    ESP_LOGCONFIG(TAG, "  Level for '%s': %s", it.tag.c_str(), LOG_LEVELS[it.level]);
+    ESP_LOGCONFIG(TAG, "  Level for '%s': %s", it.first.c_str(), LOG_LEVELS[it.second]);
   }
 }
 void Logger::write_footer_() { this->write_to_buffer_(ESPHOME_LOG_RESET_COLOR, strlen(ESPHOME_LOG_RESET_COLOR)); }
 
+void Logger::set_log_level(int level) {
+  if (level > ESPHOME_LOG_LEVEL) {
+    level = ESPHOME_LOG_LEVEL;
+    ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_LEVELS[ESPHOME_LOG_LEVEL]);
+  }
+  this->current_level_ = level;
+  this->level_callback_.call(level);
+}
+
 Logger *global_logger = nullptr;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
 
 }  // namespace logger
diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h
index b55cfb0771..c4c873e020 100644
--- a/esphome/components/logger/logger.h
+++ b/esphome/components/logger/logger.h
@@ -1,11 +1,12 @@
 #pragma once
 
 #include <cstdarg>
-#include <vector>
+#include <map>
 #include "esphome/core/automation.h"
 #include "esphome/core/component.h"
 #include "esphome/core/defines.h"
 #include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
 
 #ifdef USE_ARDUINO
 #if defined(USE_ESP8266) || defined(USE_ESP32)
@@ -74,8 +75,11 @@ class Logger : public Component {
   UARTSelection get_uart() const;
 #endif
 
+  /// Set the default log level for this logger.
+  void set_log_level(int level);
   /// Set the log level of the specified tag.
   void set_log_level(const std::string &tag, int log_level);
+  int get_log_level() { return this->current_level_; }
 
   // ========== INTERNAL METHODS ==========
   // (In most use cases you won't need these)
@@ -88,6 +92,9 @@ class Logger : public Component {
   /// Register a callback that will be called for every log message sent
   void add_on_log_callback(std::function<void(int, const char *, const char *)> &&callback);
 
+  // add a listener for log level changes
+  void add_listener(std::function<void(int)> &&callback) { this->level_callback_.add(std::move(callback)); }
+
   float get_setup_priority() const override;
 
   void log_vprintf_(int level, const char *tag, int line, const char *format, va_list args);  // NOLINT
@@ -159,17 +166,14 @@ class Logger : public Component {
 #ifdef USE_ESP_IDF
   uart_port_t uart_num_;
 #endif
-  struct LogLevelOverride {
-    std::string tag;
-    int level;
-  };
-  std::vector<LogLevelOverride> log_levels_;
+  std::map<std::string, int> log_levels_{};
   CallbackManager<void(int, const char *, const char *)> log_callback_{};
+  int current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE};
   /// Prevents recursive log calls, if true a log message is already being processed.
   bool recursion_guard_ = false;
   void *main_task_ = nullptr;
+  CallbackManager<void(int)> level_callback_{};
 };
-
 extern Logger *global_logger;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
 
 class LoggerMessageTrigger : public Trigger<int, const char *, const char *> {
diff --git a/esphome/components/logger/select/__init__.py b/esphome/components/logger/select/__init__.py
new file mode 100644
index 0000000000..b1fc881537
--- /dev/null
+++ b/esphome/components/logger/select/__init__.py
@@ -0,0 +1,29 @@
+import esphome.codegen as cg
+from esphome.components import select
+import esphome.config_validation as cv
+from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_BUG
+from esphome.core import CORE
+from esphome.cpp_helpers import register_component, register_parented
+
+from .. import CONF_LOGGER_ID, LOG_LEVEL_SEVERITY, Logger, logger_ns
+
+CODEOWNERS = ["@clydebarrow"]
+
+LoggerLevelSelect = logger_ns.class_("LoggerLevelSelect", select.Select, cg.Component)
+
+CONFIG_SCHEMA = select.select_schema(
+    LoggerLevelSelect, icon=ICON_BUG, entity_category=ENTITY_CATEGORY_CONFIG
+).extend(
+    {
+        cv.GenerateID(CONF_LOGGER_ID): cv.use_id(Logger),
+    }
+)
+
+
+async def to_code(config):
+    levels = LOG_LEVEL_SEVERITY
+    index = levels.index(CORE.config[CONF_LOGGER][CONF_LEVEL])
+    levels = levels[: index + 1]
+    var = await select.new_select(config, options=levels)
+    await register_parented(var, config[CONF_LOGGER_ID])
+    await register_component(var, config)
diff --git a/esphome/components/logger/select/logger_level_select.cpp b/esphome/components/logger/select/logger_level_select.cpp
new file mode 100644
index 0000000000..b71a6e02a2
--- /dev/null
+++ b/esphome/components/logger/select/logger_level_select.cpp
@@ -0,0 +1,27 @@
+#include "logger_level_select.h"
+
+namespace esphome {
+namespace logger {
+
+void LoggerLevelSelect::publish_state(int level) {
+  auto value = this->at(level);
+  if (!value) {
+    return;
+  }
+  Select::publish_state(value.value());
+}
+
+void LoggerLevelSelect::setup() {
+  this->parent_->add_listener([this](int level) { this->publish_state(level); });
+  this->publish_state(this->parent_->get_log_level());
+}
+
+void LoggerLevelSelect::control(const std::string &value) {
+  auto level = this->index_of(value);
+  if (!level)
+    return;
+  this->parent_->set_log_level(level.value());
+}
+
+}  // namespace logger
+}  // namespace esphome
diff --git a/esphome/components/logger/select/logger_level_select.h b/esphome/components/logger/select/logger_level_select.h
new file mode 100644
index 0000000000..2c92c84d13
--- /dev/null
+++ b/esphome/components/logger/select/logger_level_select.h
@@ -0,0 +1,15 @@
+#pragma once
+
+#include "esphome/components/select/select.h"
+#include "esphome/core/component.h"
+#include "esphome/components/logger/logger.h"
+namespace esphome {
+namespace logger {
+class LoggerLevelSelect : public Component, public select::Select, public Parented<Logger> {
+ public:
+  void publish_state(int level);
+  void setup() override;
+  void control(const std::string &value) override;
+};
+}  // namespace logger
+}  // namespace esphome
diff --git a/esphome/core/defines.h b/esphome/core/defines.h
index 8407391bce..dc0ac3c1e8 100644
--- a/esphome/core/defines.h
+++ b/esphome/core/defines.h
@@ -14,6 +14,9 @@
 #define ESPHOME_PROJECT_VERSION_30 "v2"
 #define ESPHOME_VARIANT "ESP32"
 
+// logger
+#define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE
+
 // Feature flags
 #define USE_ALARM_CONTROL_PANEL
 #define USE_AUDIO_FLAC_SUPPORT
diff --git a/tests/components/logger/common-default_uart.yaml b/tests/components/logger/common-default_uart.yaml
index 70b485daac..e8b56043eb 100644
--- a/tests/components/logger/common-default_uart.yaml
+++ b/tests/components/logger/common-default_uart.yaml
@@ -1,7 +1,17 @@
 esphome:
   on_boot:
     then:
-      - logger.log: Hello world
+      - logger.log:
+          level: warn
+          format: "Warning: Logger level is %d"
+          args: [id(logger_id).get_log_level()]
+      - logger.set_level: WARN
 
 logger:
+  id: logger_id
   level: DEBUG
+  initial_level: INFO
+
+select:
+  - platform: logger
+    name: Logger Level