ESPHome always run into safe mode after adding HTTP request & interval

Hi all, I’m having my esphome running on a NodeMCU ESP32 to control a smart lock for auto-lock & remote unlock. Everything works well up until I added an interval HTTP request to allow for remote unlock.

Problem: The ESP32 would always show a barebone web server layout once every few hours, which I guess is a safe mode? In this state, I can’t do any OTA either from a PC (error timeout) or from the web interface (success, but still in barebone mode). Instead, the firmware can get back on its own if I reboot the board.


For the remote unlock part, I’m using Telegram bot to access an “/open” command via polling. I have an interval check to retrieve the latest message.

The strange thing is that I already disabled safe_mode component just to make sure, but it still happens regardless, so I’m not sure if the condition here is “safe mode” or not. Here’s the screenshot of the web server:

One thing I noticed is that the HTTP request inside the polling interval can take a while, it would always warn about ~2000ms execution time. Maybe that’s related?

10:29:21 [W] [component:453] interval took a long time for an operation (3964 ms)
10:29:21 [W] [component:456] Components should block for at most 30 ms
10:29:21 [W] [component:453] api took a long time for an operation (3970 ms)
10:29:21 [W] [component:456] Components should block for at most 30 ms

In any case, my code is below. Any help is appreciated, thanks! Do note that I’ve switched my board around and the issue is consistent across all of them.

substitutions:
  telegram_token: !secret telegram_bot_token
  telegram_chat: !secret telegram_chat_id
  use_battery: true
    # No battery: Auto-close on power.
    # Battery: Close on door sensor.
  door_open_notify_time: 15
    # In seconds

esphome:
  name: smart-door
  on_boot:
    - priority: 550
      then: # No-battery mode: Power connected = closed
        - if:
            condition:
              lambda: return ${0 if use_battery else 1};
            then:
              - button.press: door_close

esp32:
  board: nodemcu-32s
  framework:
    type: esp-idf
    sdkconfig_options:
        CONFIG_LWIP_MAX_SOCKETS: '16'

safe_mode:
  disabled: true

# Enable logging
logger:
  logs:
    # Prevent component from spamming the logs
    http_request.idf: ERROR

# Enable Home Assistant API
api:
  password: ""

ota:
  - platform: esphome
    password: ""

wifi:
  ssid: "Ab-Apart"
  password: !secret wifi_password
  fast_connect: true

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Smart-Door Fallback Hotspot"
    password: !secret ap_password

captive_portal:

http_request:

time:
  - platform: sntp
    id: time_now
    timezone: Europe/Berlin
    on_time_sync:
      - if:
          condition:
            lambda: return id(pending_close_time) == true;
          then:
            # Update the previously pending close time
            - globals.set:
                id: global_close_time
                value: !lambda |-
                  time_t currTime = id(time_now).now().timestamp;
                  return currTime;
            - globals.set:
                id: pending_close_time
                value: "false"
globals:
  - id: global_open_time
    type: time_t
    restore_value: true
    initial_value: ""
  - id: global_close_time
    type: time_t
    restore_value: true
    initial_value: ""
  - id: last_update_id
    type: int
    restore_value: true
    initial_value: "0"
  - id: pending_close_time
    type: bool
    restore_value: false
    initial_value: "false"
  - id: door_open_notify_sent
    type: bool
    restore_value: false
    initial_value: "false"
  - id: telegram_poll_counter
    type: int
    restore_value: false
    initial_value: "0"

# UI Controls
number:
  - platform: template
    id: servo_duration
    name: Servo Rotation Duration
    min_value: 1
    restore_value: true
    initial_value: 4.5
    max_value: 6
    step: 0.5
    optimistic: true
    web_server:
      sorting_group_id: sorting_group_servo_settings
  - platform: template
    id: servo_number
    name: Servo Control
    min_value: -100
    initial_value: 0
    max_value: 100
    step: 1
    optimistic: true
    web_server:
      sorting_group_id: sorting_group_servo_settings
    set_action:
      # Detach if given value is 0
      then:
        - servo.write:
            id: servo_hw
            level: !lambda 'return x / 100.0;'
        - if:
            condition:
              lambda: 'return x == 0;'
            then:
              - delay: !lambda 'return 50;'
              - servo.detach:
                  id: servo_hw
              - logger.log:
                  format: "Servo detached"

button:
  # Most components have REST API, read more: https://esphome.io/web-api/#api-rest
  - platform: template
    name: Open
    id: door_open
    # Equivalent: http://smart-door.local/button/open/press (using name, not id)
    web_server:
      sorting_group_id: sorting_group_door_actions
    on_press:
      # Move servo until the door is opened, or timeout
      if:
        condition: # Only if not currently moving
          lambda: 'return id(servo_number).state == 0;'
        then:
        - globals.set:
            id: global_open_time
            value: !lambda |-
              time_t currTime = id(time_now).now().timestamp;
              return currTime;
        - number.set:
            id: servo_number
            value: -100
        - wait_until:
            condition:
              binary_sensor.is_on: door_is_open
            timeout: !lambda 'return id(servo_duration).state * 1000 + 1000;'
        # Continue opening a bit after the door is opened
        - delay: !lambda 'return 1500;'
        - number.set:
            id: servo_number
            value: 0
  - platform: template
    name: Close
    id: door_close
    web_server:
      sorting_group_id: sorting_group_door_actions
    on_press:
      # Only trigger closing if the door is not currently opening
      - if:
          condition:
            time.has_time:
          then:
            - globals.set:
                id: global_close_time
                value: !lambda |-
                  time_t currTime = id(time_now).now().timestamp;
                  return currTime;
          else:
            - globals.set:
                id: pending_close_time
                value: "true"
      - if:
          condition:
            lambda: 'return id(servo_number).state >= 0;'
          then:
            - number.set:
                id: servo_number
                value: 100
            - delay: !lambda 'return id(servo_duration).state * 1000;'
            - number.set:
                id: servo_number
                value: 0
  - platform: template
    name: Zero
    id: servo_zero
    web_server:
      sorting_group_id: sorting_group_servo_settings
    on_press:
      then:
        - number.set:
            id: servo_number
            value: 0
  # Hidden button to trigger status message (called from interval)
  - platform: template
    id: send_status_message
    internal: true
    on_press:
      then:
        - http_request.post:
            url: "https://api.telegram.org/bot${telegram_token}/sendMessage"
            request_headers:
              Content-Type: application/json
            json: |-
              std::string status_emoji = id(door_is_open).state ? "🟥" : "🟩";
              std::string status_text = id(door_is_open).state ? "OPEN" : "CLOSED";
              // ... etc.

interval:
  - interval: 1s
    then:
      # Check if door still opens: Door is open and more than 5 seconds since
      - if:
          condition:
            lambda: return id(door_is_open).state && !id(door_open_notify_sent) && id(time_now).now().timestamp - id(global_open_time) > ${door_open_notify_time};
          then:
            - globals.set:
                id: door_open_notify_sent
                value: "true"
            - logger.log:
                format: "Door is opened for more than ${door_open_notify_time} seconds"
            - http_request.post:
                url: "https://api.telegram.org/bot${telegram_token}/sendMessage"
                request_headers:
                  Content-Type: application/json
                json: |-
                  std::string message = "*Warning: Door is OPEN 🟥* \n";

                  root["chat_id"] = ${telegram_chat};
                  root["text"] = message;
                  root["parse_mode"] = "Markdown";

      # Poll Telegram for new messages every 1 second (or 5s when door is closed). This allows faster detection of open => close.
      - lambda: |-
          // Increment counter
          id(telegram_poll_counter) = id(telegram_poll_counter) + 1;
          
          // If door is open, only poll every 5 seconds (every 5th trigger)
          // If door is closed, poll every second
          bool should_poll = (!id(door_is_open).state) || (id(telegram_poll_counter) >= 5);
          if (should_poll) {
            id(telegram_poll_counter) = 0;  // Reset counter
          }
      - if:
          condition:
            lambda: return id(telegram_poll_counter) == 0;
          then:
            - http_request.get:
                url: "https://api.telegram.org/bot${telegram_token}/getUpdates?offset=-1&limit=1"
                capture_response: true
                on_response:
                  - if:
                      condition:
                        lambda: return response->status_code == 200;
                      then:
                        - lambda: |-
                            json::parse_json(body, [](JsonObject root) -> bool {
                              // code here
                            });
                      else:
                        - logger.log:
                            format: "Telegram API error: %d"
                            args: ['response->status_code']

text_sensor:
  # Just for displaying globals in web UI
  - platform: template
    id: time_close
    name: Door Last Closed
    lambda: |-
      char str[30];
      time_t currTime = id(global_close_time);
      strftime(str, sizeof(str), "%Y-%m-%d %H:%M", localtime(&currTime));
      return std::string(str);
    update_interval: 30s
  - platform: template
    id: time_open
    name: Door Last Opened
    lambda: |-
      char str[30];
      time_t currTime = id(global_open_time);
      strftime(str, sizeof(str), "%Y-%m-%d %H:%M", localtime(&currTime));
      return std::string(str);
    update_interval: 30s

# Hardwares
binary_sensor:
  # Optional, only when using a battery (always on)
  - platform: gpio
    id: door_is_open
    name: Door is Open
    device_class: door
    pin:
      number: GPIO12
      mode:
        input: true
        pullup: true
        # Means door is closed on release
    filters:
      - delayed_off: 100ms
    on_release:
      then: # Low = closed. Auto-lock when door is closed
        - if:
            # If previously we sent open warning, and not it's closed, then send new status
            condition:
              lambda: return id(door_open_notify_sent);
            then:
              - globals.set:
                  id: door_open_notify_sent
                  value: "false"
              - button.press: send_status_message
        - if:
            condition:
              lambda: return ${1 if use_battery else 0};
            then:
              - button.press: door_close
  - platform: gpio
    id: door_button
    name: Door Button
    device_class: door
    pin:
      number: GPIO14
      inverted: true
      mode:
        input: true
        pullup: true
    filters:
      - delayed_on_off: 100ms
    on_press:
      then: # High = pressed. Open the door
        - button.press: door_open

servo:
  - id: servo_hw
    output: servo_pwm_output

output:
  - platform: ledc
    id: servo_pwm_output
    pin: GPIO13
    frequency: 50Hz

web_server:
  version: 3
  port: 80
  sorting_groups:
    - id: sorting_group_door_actions
      name: "Door Actions"
      sorting_weight: 10
    - id: sorting_group_servo_settings
      name: "Servo Settings"
      sorting_weight: 20

Quite likely, if you try to make request every second and request takes 2s, outcome is frozen device.

Yeah that is to be expected, and I don’t mind that (planning to use 2x ESP32 for parallel processing later). But even when I increase the interval to say 10s, this issue still appear.

In this case, I also set the polling when the door is closed to every 5 tick of interval, so 5 seconds.

Set you logger to debug level, maybe you get better picture of what’s going on…

I would be really careful with lambda functions as they might have memory implications and locks. You can check memory with my little guide, if you want to be sure about it.

Hope you find the problem cause!

2 Likes

It’s already at debug level, 99% just message on that warning and [D] [text_sensor:085] 'Door Last Opened': Sending state '2026-01-05 18:32', but issue is I don’t even know what happen by the time it’s restarting/rebooting as everything is wiped out. I might catch it if I keep monitoring the web server, but that in its own might affect the situation.

For now is it correct that the empty web server page I see is basically my esphome in safe_mode state? Even though I disabled it? Or is it something different?

Woahhh that’s cool that you can monitor mem usage. Thanks!

I read also on reset_reason but haven’t added it, thankfully your codeblock contains it too.

I don’t know. I have web_server on all my esphome devices, but I have never experienced something similar. But logs would tell you that as well…

1 Like

Nikop: many thanks for your guide from my side, too, it will come very helpful.

What i wonder, though is: could we put this code constantly into each esp module or is that not so-good idea? I’d add this code into all my esp’s, so if anything goes wrong on any of my modules i’d see right away…

I’ve left all checking/ debug away from my esp devices when all seems to be good. That way some devices can run multi months without exhausting memory etc. Remember, those little things have very very little memory :slight_smile:, especially if you really do not need “captive_portal” etc. When one only checks everything through HASS frontend, those http-servers in esp-device is kind of non necessary.

I see what you mean. Every little thing can add to unreliable work, you’re right.
I don’t use captive portal, nor ap. I use web server just on some, but mainly not.

So, i’ll just try to prepare things as easy as i can - in a way that i’ll only add “include” file or similar…
Thanks!

BTW. are you sure that code in your guide(advanced one) is correct? I get quite some errors, from logger:

  level: WARN
  logs:
    heap_auto: DEBUG
    low_memory_check: DEBUG

error: heap_auto → logger level can’t be less than global level, so i’ve had to set general level from WARN to DEBUG

then in globals i get several errors about initial value has to be a string (did you forget quotes)…

Please start your own thread for unrelated issue, don’t hijack another person’s thread.

Have no luck getting it from logs, maybe the problem happens moment before it gets down (thus I can’t even see the logs).

I tested again today without web_server & captive_portal. I thought it went well, almost 24h without this issue, but it just went down again just now.

Before disabling it, I tried @nikop debug and I see my heap memory is plenty, hundreds of KB. Reset reason is not useful either because it’s in “blank”/safe_mode unless I restart it. So I have to find a way to store the 2nd last restart reason (not even sure if that’s possible, or useful).

Maybe I need to rework my electrical stuff at this point.

Did you still get the “ota update web-server” on your post#1 screenshot?

Also, I dont think this can be valid:

If I enable the web_server, yes I still see the basic “ota” page.

All the JSON & lambda functions are valid, don’t worry about them. Everything is 100% functional except this strange behavior.

Tho I think I found the solution, it was indeed my power system.

I have a charger + battery configuration. Everything works well with this in a more simplified system (when I didn’t have HTTP interval).

Now, I tried using purely just a charger without battery, and it has no issue with blank firmware (yet).

It’s kinda strange because in the charger+battery configuration, the issue happens even when it’s connected to the charger (door is closed). So, ideally with or without battery shouldn’t affect this.

But I guess the inclusion of the battery might mean there’re moments the battery is charging = not enough current to the board. Probably because my charger can’t supply enough current for both of them when the battery requires charging.

Power issues are common, it doesn’t explain your initial statement though.

Possibly because the HTTP request is more computationally intensive? Also unsure tbh. I suspected that because the API request always takes 2500ms+, meanwhile calling it directly in my PC is almost instant (<100ms).

Hmmm, nevermind it happens again. Some of the last logs point toward the ESP just simply gone. And when it come back up, it’s just the blank/simple firmware:

[08:53:41.775][D][text_sensor:085]: 'Door Last Closed': Sending state '2026-01-11 05:40'
[08:53:41.788][D][text_sensor:085]: 'Door Last Opened': Sending state '2026-01-09 17:25'
[08:53:51.717][D][sensor:131]: 'Heap Free': Sending state 211840.00000 B with 0 decimals of accuracy
INFO Processing unexpected disconnect from ESPHome API for smart-door @ 192.168.0.125
WARNING Disconnected from API
INFO Successfully resolved smart-door @ 192.168.0.125 in 0.000s
WARNING Can't connect to ESPHome API for smart-door @ 192.168.0.125: Error connecting to [AddrInfo(family=<AddressFamily.AF_INET: 2>, type=<SocketKind.SOCK_STREAM: 1>, proto=6, sockaddr=IPv4Sockaddr(address='192.168.0.125', port=6053))]: [WinError 1231] The network location cannot be reached. For information about network troubleshooting, see Windows Help (SocketAPIError)  
INFO Trying to connect to smart-door @ 192.168.0.125 in the background
INFO Successfully connected to smart-door @ 192.168.0.125 in 0.104s
INFO Successfully connected to smart-door @ 192.168.0.125 in 0.077s
INFO Successfully connected to smart-door @ 192.168.0.125 in 0.132s
INFO Successfully connected to smart-door @ 192.168.0.125 in 0.103s
INFO Successfully connected to smart-door @ 192.168.0.125 in 0.074s