Cable Modem Resetter (ESPHome)

Just figured I’d share something I put together recently.

We had an internet outage the other day and it ended up being that the modem had just locked up or something (resetting it fixed the problem).
I figured there had to be a way to have an on-esp-device way to manage this.

This was installed on a Sonoff S31.

This depends on a custom component to perform pings: GitHub - trombik/esphome-component-ping: esphome component that sends and receives ICMP

I’ll admit I am no expert with ESPHome so ChatGPT helped with some of this. I understand it, verified before using, and tested it a bit before putting it to work - but it may not necessarily be the most efficient implementation of this functionality :man_shrugging:t2:
I also tested by blocking access to the IP I was pinging periodically and things worked as desired.

I also added an automation in HA to notify me a few minutes before a reset. I believe these notifications will work on the local network even if the internet is down, and if not at home, and I receive the notification, I’ll know its like a false positive.

esphome:
  name: cable-modem-sonoff-s31
  friendly_name: Cable Modem Sonoff S31
  libraries:
    - ESP8266WiFi
    - https://github.com/akaJes/AsyncPing#95ac7e4
  on_boot:
    - priority: -10 # Ensures this runs later in the boot process
      then:
        - logger.log:
            level: INFO
            format: "On boot: disabling ping checks (internal) for 5 minutes!"
        - delay: 5min
        - lambda: |-
            id(ping_check_enabled) = true;
        - logger.log:
            level: INFO
            format: "On boot: Ping checks enabled after successful boot (with 5 min delay)!"


esp8266:
  board: esp01_1m


# Enable logging
logger:
  baud_rate: 0 # (UART logging interferes with cse7766)
  level: DEBUG


# Enable Home Assistant API
api:
  encryption:
    key: XXXXX

ota:
  password: XXXXX

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Cable-Modem-Sonoff-S31"
    password: XXXXX

captive_portal:

external_components:
  - source:
      type: git
      url: https://github.com/trombik/esphome-component-ping # I forked this to my own controlled repo for security reasons.
      ref: main

globals:
  - id: ping_check_enabled
    type: bool
    initial_value: 'false'  # At boot we default to disable ping checks for 5 minutes.
  - id: packet_loss_above_threshold_start_time
    type: uint32_t
    initial_value: '0'
  - id: packet_loss_threshold
    type: int
    initial_value: '90'  # Packet loss threshold percentage
  - id: packet_loss_period
    type: int
    initial_value: '300'  # Seconds before packet loss causes reset
  - id: waiting_to_reenable_ping
    type: bool
    initial_value: 'false'
  - id: ha_ping_control_enabled
    type: bool
    initial_value: 'true'  # Assume ping checks are enabled by default from HA

uart:
  rx_pin: RX
  baud_rate: 4800


# Normal S31 Plug Logic
binary_sensor:
  - platform: gpio
    pin:
      number: GPIO0
      mode: INPUT_PULLUP
      inverted: True
    name: "Cable Modem Sonoff S31 Button"
    on_press:
      - switch.toggle: relay
  - platform: status
    name: "Cable Modem Sonoff S31 Status"

# Normal S31 Plug Logic
sensor:
  - platform: wifi_signal
    name: "Cable Modem Sonoff S31 WiFi Signal"
    update_interval: 60s
  - platform: cse7766
    current:
      name: "Cable Modem Sonoff S31 Current"
      accuracy_decimals: 1
      filters:
        - throttle_average: 30s
    voltage:
      name: "Cable Modem Sonoff S31 Voltage"
      accuracy_decimals: 1
      filters:
        - throttle_average: 30s
    power:
      name: "Cable Modem Sonoff S31 Power"
      accuracy_decimals: 1
      filters:
        - throttle_average: 30s
      id: my_power
  - platform: total_daily_energy
    name: "Cable Modem Sonoff S31 Daily Energy"
    power_id: my_power
    filters:
        - throttle_average: 30s

# Ping logic
  - platform: ping
    ip_address: 1.1.1.1 # Can be anything, I'd recommend a reliable DNS provider though
    num_attempts: 5    # number of packets to send
    # the timeout. however, this is not what you usually expect from `ping`
    # implementation: the timeout is also the interval to send packets. if you
    # set this value to 10 sec, and the network is fine (no packet loss), then
    # the component sends a packet at 10 sec interval, and the total time to
    # finish would be 10 sec * num_attempts = 10 * 17 = 170 sec.
    timeout: 2sec
    update_interval: 60s # the interval for checking the sensors. defaults to 60s.
    loss:
      name: Packet loss
      id: loss
  - platform: uptime
    name: "Cable Modem Sonoff S31 Uptime"
    id: esphome_uptime
    update_interval: 60s  # Adjust the update interval as needed
    filters:
      - lambda: return x / 60;  # Convert seconds to minutes
    unit_of_measurement: "minutes"


interval:
# This lambda function is executed every 5 seconds to monitor packet loss. 
# If packet loss exceeds a defined threshold (id(packet_loss_threshold)), it starts a timer to track how long the condition persists.
# - If the packet loss remains above this threshold for a duration exceeding id(packet_loss_period) seconds, and if ping checks are enabled,
#   it logs this event, then proceeds to reset the relay: turning it off for 10 seconds before turning it back on.
# - This relay reset aims to potentially correct the high packet loss condition.
# - After the relay reset, if ping checks were not already waiting to be re-enabled, it disables ping checks and sets a flag to indicate 
#   it's waiting for ping checks to be re-enabled, preventing immediate re-triggering of the condition.
# - If the packet loss drops below the threshold at any check, it logs this event and resets the start time, ready to monitor the condition afresh.
# This approach ensures actions are taken only if the packet loss condition persists, avoiding unnecessary relay resets for brief spikes in packet loss.
  - interval: 5s
    then:
      - lambda: |-
          if (id(loss).state > id(packet_loss_threshold)) {
            if (id(packet_loss_above_threshold_start_time) == 0) {
              id(packet_loss_above_threshold_start_time) = id(sntp_time).now().timestamp;
              ESP_LOGD("custom", "Packet loss exceeded threshold. Starting timer.");
            } else if ((id(sntp_time).now().timestamp - id(packet_loss_above_threshold_start_time)) >= id(packet_loss_period)) {
              if (id(ha_ping_control_enabled) && id(ping_check_enabled)) {
                ESP_LOGI("custom", "Packet loss has been above threshold for more than 3 minutes. Triggering relay reset.");
                // Reset the relay here
                id(relay).turn_off();
                delay(10000); // 10 seconds delay
                id(relay).turn_on();
                // Log relay reset
                ESP_LOGI("custom", "Relay has been reset.");
                if (!id(waiting_to_reenable_ping)) {
                  id(ping_check_enabled) = false;
                  id(waiting_to_reenable_ping) = true;
                  ESP_LOGI("custom", "Ping checks disabled, waiting to re-enable.");
                }
              }
            }
          } else {
            if (id(packet_loss_above_threshold_start_time) != 0) {
              ESP_LOGD("custom", "Packet loss dropped below threshold. Resetting timer.");
              id(packet_loss_above_threshold_start_time) = 0;
            }
          }
# This lambda function is triggered every minute to manage the re-enabling of ping checks. 
# It uses a static variable, disable_time, to track when the waiting period to re-enable ping checks began.
# - If the device is in the state of waiting to re-enable ping checks (id(waiting_to_reenable_ping) is true),
#   and if disable_time is not yet set, it records the current time in millis() as the start of the waiting period.
# - It then checks if 5 minutes (300,000 milliseconds) have passed since disable_time was set. If so, it indicates
#   the waiting period is over, and it proceeds to:
#     1. Re-enable ping checks by setting id(ping_check_enabled) to true,
#     2. Reset the flag indicating it was waiting to re-enable ping checks (id(waiting_to_reenable_ping)),
#     3. Reset disable_time and id(packet_loss_above_threshold_start_time) to 0, effectively clearing the timer and
#        packet loss monitoring state.
# - A log message is generated to indicate that ping checks have been re-enabled after the waiting period.
# This approach ensures a non-blocking wait period is observed before attempting to re-enable ping checks, 
# allowing other device functionalities to proceed uninterrupted during the wait.

  - interval: 1min
    then:
      - lambda: |-
          static unsigned long disable_time = 0;
          if (id(waiting_to_reenable_ping)) {
            if (disable_time == 0) {
              // Record the time we started waiting
              disable_time = millis();
            } else if (millis() - disable_time > 300000) { // 5 minutes in milliseconds
              // Time has passed, re-enable ping checks and reset variables
              id(ping_check_enabled) = true;
              id(waiting_to_reenable_ping) = false;
              disable_time = 0;
              id(packet_loss_above_threshold_start_time) = 0;
              ESP_LOGI("custom", "Ping checks re-enabled after waiting period.");
            }
          }


switch:
  - platform: gpio # Normal S31 Plug Switch
    name: "Cable Modem Sonoff S31 Relay"
    pin: GPIO12
    id: relay
    restore_mode: ALWAYS_ON
  - platform: template # Let's us manually disable ping checks from HA - note that pings keep happening, we just ignore them.
    name: "Cable Modem Sonoff S31 Ping Checks (Manual)"
    id: ha_control_ping_checks
    optimistic: true
    restore_mode: ALWAYS_ON
    turn_on_action:
      - lambda: |-
          id(ha_ping_control_enabled) = true;
          ESP_LOGI("custom", "Ping checks enabled via Home Assistant.");
    turn_off_action:
      - lambda: |-
          id(ha_ping_control_enabled) = false;
          ESP_LOGI("custom", "Ping checks disabled via Home Assistant.");

time:
  - platform: sntp
    id: sntp_time

status_led:
  pin: GPIO13

UPDATE (2 days later): Today was the first live-test. I noticed the internet was down 3 minutes in. 2 minutes later the S31 initiated the reset. I could have done it faster, but if I weren’t home/awake it would have taken care of it for me.
And yes, I am concerned something is up with the cable modem needing to be reset twice in less than a week.

2 Likes