Script to warn on low battery

I couldn’t find a decent low battery warning notification for all my devices. This script checks all zwave and deconz devices and if any battery is below the threshold it will create a persistent notification.
I filter out mobile devices by checking for the is_charging attribute, otherwise these would be detected as deconz devices. Couldn’t find any other way to distinguish them.

If you want to add any other types of devices I would add the logic under get_battery_entities in a separate function.

Enable checking by adding an automation that calls this service every day or so. Provide any threshold by adding threshold: int to your service call.

def persistent_warning_message(devices):
    devices = ", ".join(devices)
    service_data = {
        "title": "Low Battery Warning",
        "notification_id": "low_battery",
        "message": f"Low battery for the following device(s): {devices}",
    }
    hass.services.call("persistent_notification", "create", service_data, False)
    logger.info(service_data["message"])


def get_battery_entities():
    def get_zwave_battery_entities():
        out = {}
        for entity_id in hass.states.entity_ids("zwave"):
            state = hass.states.get(entity_id)
            if "battery_level" in state.attributes:
                out.update({entity_id: state.attributes["battery_level"]})
        return out

    def get_deconz_battery_entities():
        out = {}
        for entity_id in hass.states.entity_ids("sensor"):
            state = hass.states.get(entity_id)
            if (
                state.attributes.get("device_class") is "battery"
                and "is_charging" not in state.attributes
            ):
                out.update({entity_id: int(state.state)})
        return out

    entities = {}
    entities.update(get_zwave_battery_entities())
    entities.update(get_deconz_battery_entities())
    return entities


def get_low_battery_entities(battery_entities, threshold):
    if len(battery_entities) is 0:
        return None
    return [
        entity_id for entity_id, value in battery_entities.items() if value < threshold
    ]


threshold = data.get("threshold", 10)
low_battery_devices = get_low_battery_entities(get_battery_entities(), threshold)
if len(low_battery_devices) > 0:
    persistent_warning_message(low_battery_devices)

5 Likes

Hi Sophof,
Hope you can help me, i get this error when i try to validate the script.

Error loading /config/configuration.yaml: mapping values are not allowed here
in “/config/script/low_battery.yaml”, line 4, column 16

It should be placed as something like “low_battery.py” in the “python_scripts” folder, did you do that? The error looks like you tried to place it as a yaml in a script folder.

See also Python Scripts - Home Assistant

From there:

Add to configuration.yaml: python_script:
Create folder /python_scripts
Create a file hello_world.py in the folder and give it this content:

So in this case place low_battery.py there and then create an automation that calls the python_script.low_battery. Provide any threshold you want by adding threshold: int to your service call.

Retracted post.

Thanks for your reply, you were correct i did not created it as a pyton_script :grimacing:
I have done as you have describe but from the log i get this:

ERROR (SyncWorker_4) [homeassistant.components.python_script.low_battery.py] Error executing script: invalid literal for int() with base 10: ‘unavailable’
File “/usr/src/homeassistant/homeassistant/components/python_script/init.py”, line 205, in execute

Can you paste your service call? You don’t have to pass a threshold, but if you do, it should be something like: threshold: 10

Have done it like this, and i have a IKEA remote with battery level on 16 connected throw deconz.
image

Ok, I checked out the error and apparently somewhere python is implicitly casting the word ‘unavailable’ to int. My assumption is that your sensor is sending unavailable as the battery level, where I was expecting a number, so that’s what is causing the error.

I’ll try and make this ignore unavailable levels (it’s the only thing that makes sense), but I think you’ll want to have a look at the ikea remote.

I got myself some ikea devices (E1524 remotes) and am testing the script out. I get this error in log.

I have fixed the problem by adding and “unavailable” not in state.state to the script

def persistent_warning_message(devices):
    devices = ", ".join(devices)
    service_data = {
        "title": "Low Battery Warning",
        "notification_id": "low_battery",
        "message": f"Low battery for the following device(s): {devices}",
    }
    hass.services.call("persistent_notification", "create", service_data, False)
    logger.info(service_data["message"])


def get_battery_entities():
    def get_zwave_battery_entities():
        out = {}
        for entity_id in hass.states.entity_ids("zwave"):
            state = hass.states.get(entity_id)
            if "battery_level" in state.attributes:
                out.update({entity_id: state.attributes["battery_level"]})
        return out

    def get_deconz_battery_entities():
        out = {}
        for entity_id in hass.states.entity_ids("sensor"):
            state = hass.states.get(entity_id)
            if (
                state.attributes.get("device_class") is "battery"
                and "is_charging" not in state.attributes
                and "unavailable" not in state.state
            ):
                out.update({entity_id: int(state.state)})
        return out

    entities = {}
    entities.update(get_zwave_battery_entities())
    entities.update(get_deconz_battery_entities())
    return entities


def get_low_battery_entities(battery_entities, threshold):
    if len(battery_entities) is 0:
        return None
    return [
        entity_id for entity_id, value in battery_entities.items() if value < threshold
    ]


threshold = data.get("threshold", 10)
low_battery_devices = get_low_battery_entities(get_battery_entities(), threshold)
if len(low_battery_devices) > 0:
    persistent_warning_message(low_battery_devices)

I haven’t made this script very user friendly, that’s for sure :grimacing:

Could you try commenting out or removing the entities.update(get_zwave_battery_entities()) line? Afaik it should work fine with that there even if you have no zwave devices, but just to be sure.

Great! Now that I look at my code that is indeed the only place where there is an explicit int cast, don’t know why I didn’t catch that.

If I’m using zha and not deconz, can I just “Find/Replace” deconz with zha in the script?

I’m not sure, but I doubt it, the reason I made a python script is since I didn’t want to test everything :stuck_out_tongue:

In essence the different functions get_zwave_battery_entities and get_deconz_battery_entities are not necessarily zwave and deconz exclusive. For zwave I just check for a battery_level attribute in zwave entities and for the deconz devices I specifically scan for entities classed as battery and specifically excluded phone battery sensors.

So as long as ZHA exposes entities in one of those 2 ways, you shouldn’t have to do much. If it works as deconz you don’t have to do anything, if it works like zwave, you only need to change the for entity_id in hass.states.entity_ids("zwave"): part and replace it with the relevant prefix (possibly “ZHA”?).

But to be honest, based on the questions I get here, I think I should just invest the time in the future to make some sort of ‘proper’ integration if at all possible.

Hi,

What would be the best way to include battery entities from zigbee2mqtt?

I don’t know how the zigbee2mqtt sensors are reported in home assistant, but assuming it is similar to deconz (since it is only a bridge) it should work without any changes.

So if your device have a sensor with device_class: battery it should work.

@sophof this has been the best solution for this problem I’ve found. Thank you!

I made a few changes to the code that others may find useful;

  • Added some additional battery states
  • Included the current charge level in the notification output
  • Added entities ‘device_tracker’ (removed zwave, so you’ll need to add that back if used)
def persistent_warning_message(devices):
    devices = "\n".join(devices)
    service_data = {
        "title": "Low Battery",
        "notification_id": "low_battery",
        "message": f"{devices}\n",
    }
    hass.services.call("persistent_notification", "create", service_data, False)
    logger.info(service_data["message"])


def get_battery_entities():
    def get_device_tracker_battery_entities():
        out = {}
        for entity_id in hass.states.entity_ids("device_tracker"):
            state = hass.states.get(entity_id)
            if "battery_level" in state.attributes:
                out.update({entity_id: state.attributes["battery_level"]})
        return out

    def get_sensor_battery_entities():
        out = {}
        for entity_id in hass.states.entity_ids("sensor"):
            state = hass.states.get(entity_id)
            if (
                state.attributes.get("device_class") is "battery"
                and "is_charging" not in state.attributes
                and "charging" not in state.state
                and "discharging" not in state.state
                and "unavailable" not in state.state
            ):
                out.update({entity_id: int(state.state)})
        return out

    entities = {}
    entities.update(get_device_tracker_battery_entities())
    entities.update(get_sensor_battery_entities())
    return entities


def get_low_battery_entities(battery_entities, threshold):
    if len(battery_entities) is 0:
        return None
    return [
        ''.join(entity_id + ': ' + str(value) + '%') for entity_id, value in battery_entities.items() if value < threshold
    ]


threshold = data.get("threshold", 10)
low_battery_devices = get_low_battery_entities(get_battery_entities(), threshold)
if len(low_battery_devices) > 0:
    persistent_warning_message(low_battery_devices)

Thanks for the kind words. I’ve been running an appdaemon solution for the past months, mostly because of the feedback I got here. It is similar in solution but a bit more robust and more configurable. It’s been running great for a while now and I’ve fixed some bugs, so I’ll probably post it soon.

I’ve been happy with how it works in general, but there’s always a few sensors in my experience that show strange behaviour. In my case I’ve found out that I have two sensors that will start ‘flip-flopping’ in between roughly 50% and 0% for days, which leads to spamming of messages. I first need a solution for those :). Also one sensor that consistently says it is empty, but keeps working fine…

Hello @sophof,
Thank for this sweet gift.
Would please mind sharing your appdeamon script ? That would be very appreciated :slight_smile:

Sure, here it is. I’ve ended up just using blacklists for some sensors I don’t want to track, but as said before, this has worked well for me so far (even after moving house recently).

import hassapi as hass
import re


class LowBatteries(hass.Hass):
    def initialize(self):
        self.battery_sensors = self.get_battery_entities()
        self.low_batteries = set()
        self.empty_batteries = set()
        self.import_low_batteries()
        self.import_empty_batteries()

        # Check if there's new low batteries that were not in the imported list.
        # Don't check empty batteries, chances are that we incorrectly see an
        # 'unavailable' as empty at this stage.
        for sensor, value in self.battery_sensors.items():
            if value > 0 and value < self.args["threshold"]:
                self.check_low_battery(sensor, "state", None, value, None)

        # Check if any low batteries have become empty during the downtime
        for sensor in self.low_batteries:
            if self.convert_state(self.battery_sensors[sensor]) == 0:
                self.battery_empty(sensor, "state", None, 0, None)

        # Check if any low or empty batteries have been replaced during the downtime
        for sensor in self.low_batteries | self.empty_batteries:
            self.check_new_battery(
                sensor, "state", None, self.battery_sensors[sensor], None
            )

        # schedule callbacks for checking low, empty and new batteries
        for sensor in self.battery_sensors:
            self.listen_state(self.check_low_battery, sensor)
            self.listen_state(
                self.battery_empty, sensor, new="unavailable", duration=600
            )
            self.listen_state(self.battery_empty, sensor, new="0")
            self.listen_state(self.check_new_battery, sensor)

        # Check for potential new devices or renaming during runtime
        self.listen_event(
            self.new_entity_added, event="entity_registry_updated", action="create"
        )
        self.listen_event(
            self.new_entity_added, event="entity_registry_updated", action="update"
        )

    def check_low_battery(self, entity, attribute, old, new, kwargs):
        state = self.convert_state(new)
        if (
            state > 0
            and state < self.args["threshold"]
            and entity not in self.low_batteries
        ):
            self.warn_low_battery(entity, state)
            self.low_batteries.add(entity)
            self.update_low_battery_list()

    def battery_empty(self, entity, attribute, old, new, kwargs):
        self.low_batteries.discard(entity)
        self.update_low_battery_list()
        self.warn_empty_battery(entity)
        self.empty_batteries.add(entity)
        self.update_empty_battery_list()

    def check_new_battery(self, entity, attribute, old, new, kwargs):
        state = self.convert_state(new)
        if state > self.args["threshold"] and (
            entity in self.low_batteries or entity in self.empty_batteries
        ):
            self.log("new or recharged battery detected for {}".format(entity))
            self.low_batteries.discard(entity)
            self.update_low_battery_list()
            self.empty_batteries.discard(entity)
            self.update_empty_battery_list()

    def new_entity_added(self, event_name, data, kwargs):
        sensor = self.get_state(data["entity_id"], attribute="all", copy=False)
        if not sensor:
            return
        if (
            self.is_battery_entity(sensor)
            and sensor["entity_id"] not in self.battery_sensors
        ):
            name = sensor["entity_id"]
            self.battery_sensors[name] = self.convert_state(sensor["state"])

            self.listen_state(self.check_low_battery, name)
            self.listen_state(self.battery_unavailable, name, new="unavailable")
            self.listen_state(self.check_new_battery, name)
            self.log("new battery sensor '{}' added".format(name))

    def get_battery_entities(self):
        batteries = {}
        sensors = self.get_state("sensor", copy=False)

        # remove excluded sensors
        for battery in self.args["excluded_sensors"]:
            sensors.pop(battery, None)

        for sensor in sensors.values():
            if self.is_battery_entity(sensor):
                batteries.update(
                    {sensor["entity_id"]: self.convert_state(sensor["state"])}
                )
        self.log("Battery sensors found: {}".format(batteries))
        return batteries

    def is_battery_entity(self, sensor):
        check = sensor["attributes"].get("device_class") == "battery"
        return check

    def warn_empty_battery(self, sensor):
        name = self.rewrite_name(sensor)
        message = "Battery {} is empty".format(name)
        title = "Empty Battery"
        for device in self.args["notification_services"]:
            self.call_service(
                device, message=message, title=title,
            )

        self.call_service(
            "persistent_notification/create", message=message, title=title,
        )

    def warn_low_battery(self, sensor, value):
        name = self.rewrite_name(sensor)
        message="Battery {} is almost empty at {}".format(name, value)
        title="Low Battery"
        for device in self.args["notification_services"]:
            self.call_service(
                device,
                message=message,
                title=title,
            )
        
        self.call_service(
            "persistent_notification/create", message=message, title=title,
        )

    def update_low_battery_list(self):
        self.call_service(
            "input_text/set_value",
            entity_id=self.args["low_battery_list"],
            value=",".join(self.low_batteries),
        )

    def update_empty_battery_list(self):
        self.call_service(
            "input_text/set_value",
            entity_id=self.args["empty_battery_list"],
            value=",".join(self.empty_batteries),
        )

    def import_low_batteries(self):
        batteries = self.get_state(self.args["low_battery_list"])
        if batteries == "" or batteries == "unknown":
            return
        else:
            self.low_batteries.update(batteries.split(","))
            self.log("low batteries: [{}] imported".format(batteries))

    def import_empty_batteries(self):
        batteries = self.get_state(self.args["empty_battery_list"])
        if batteries == "" or batteries == "unknown":
            return
        else:
            self.empty_batteries.update(batteries.split(","))
            self.log("empty batteries: [{}] imported".format(batteries))

    def rewrite_name(self, name):
        name = re.sub(r"^.+?\.", "", name)
        name = re.sub(r"_battery_level(_\d+)?$", "", name)
        return name

    def convert_state(self, value):
        if value == "unavailable":
            value = 0
        try:
            value = int(float(value))
        except ValueError:
            self.log(
                "battery state was '{}' and not an int or 'unavailable' as expected".format(
                    value, level="ERROR"
                )
            )
        return value

An example of my yaml file:

low_battery:
  module: low_batteries
  class: LowBatteries
  threshold: 10
  low_battery_list: input_text.low_batteries
  empty_battery_list: input_text.empty_batteries
  notification_services:
    - notify/mobile_app_telefoon_sjoerd
  excluded_sensors:
    - sensor.hal9000_battery_level
    - sensor.hal9000_battery_remaining
    - sensor.telefoon_sjoerd_battery_level
    - sensor.telefoon_judith_batterijniveau
    - sensor.knop_hobbykamer_power
1 Like