Displaying Birdnet-go detections

Just out of curiosity, why the specific timeline of 7am to 7pm?

It technically includes everything before 7 AM and after 7 PM, but those values are all grouped together in the first and last bar.

I didn’t really have room for more than 12 or so sparkline bars without feeling super cramped, so I picked the most 12 active hours at my house.

For me that happens to be 7:00 a.m. to 7:00 p.m.

But if you use a super wide card in home assistant with the new formatting, you could easily have it broken out into all 24 bars.

I think there is also another bug I need to resolve where if there are no daily detections (like after midnight rolls around), the card doesn’t update (like until the first bird of the next day). But maybe that isn’t a big deal as it currently protects against the endpoint timing out?

1 Like

@Kyle, your species look almost identical to mine! Do you live somewhere in the north east? :wink:

So I finally got notifications working for new species (species that haven’t been seen in some time). For that I add the following template:

template:
  - trigger:
      - platform: mqtt
        topic: "birdnet"
    sensor:
      - unique_id: c893533c-3c06-4ebe-a5bb-da833da0a947
        name: BirdNET-Go Last Seen
        state: >
          {% set date_str = trigger.payload_json.Date %}
          {% set time_str = trigger.payload_json.Time %}
          {% set datetime_str = date_str ~ ' ' ~ time_str %}
          {{ strptime(datetime_str, '%Y-%m-%d %H:%M:%S').timestamp() | int }}
        icon: mdi:bird
        attributes:
          birds: >
              {% set date_str = trigger.payload_json.Date %}
              {% set time_str = trigger.payload_json.Time %}
              {% set datetime_str = date_str ~ ' ' ~ time_str %}
              {% set timestamp = strptime(datetime_str, '%Y-%m-%d %H:%M:%S').timestamp() | int %}
              {% set commonName = trigger.payload_json.CommonName %}
              {% set current = this.attributes.get('birds', {}) %}
              {% set lastseen = current.get(commonName, [0,0])[0] %}
              {% set new = { commonName : [timestamp, lastseen] } %}
              {{ dict(current, **new) }}

This keeps track of all bird species that have been reported as well as the last two timestamps when they were observed. With that I can have an automation triggered on the state change. I can then check if the change was triggered by a bird species that was not seen in the last 24 hours. If so, I send a notification to my phone and flash a light:

alias: Bird notification
description: ""
triggers:
  - trigger: state
    entity_id:
      - sensor.birdnet_go_events
    attribute: birds
conditions:
  - condition: template
    value_template: >-
      {{ ((state_attr('sensor.birdnet_go_events', 'birds') | dictsort(false,
      'value') | last | last | first) - (state_attr('sensor.birdnet_go_events',
      'birds') | dictsort(false, 'value') | last | last | last)) > 60*60*24 }}
actions:
  - action: notify.mobile_app_hannos_phone
    metadata: {}
    data:
      title: New bird species detected
      message: >-
        {{ state_attr('sensor.birdnet_go_events', 'birds') |     dictsort(false,
        'value') | last | first}}
  - device_id: 2ee74739cbaca227ed1b0940528d3b88
    domain: light
    entity_id: 3b3505db412f726841c8b6b800a46fce
    type: flash
mode: single
1 Like

your species look almost identical to mine! Do you live somewhere in the north east? :wink:

How did you know?? :shushing_face:

That’s an awesome approach to notify you of birds not seen in a while. I know BirdNET-Go had execute scripts for certain species detected, but this adds a nice layer to make it more related to any bird not seen in a certain period of time.

My Markdown Card Fixes/Updates

On a different note, BirdNET-Go put out a larger nightly release today. Just note that because there is no cache clearing mechanism in BirdNET-Go, you’ll need to manually clear the cache in your browser/device or just wait it out.

This nightly release includes some of my changes such as stopping the dashboard from re-directing and the inclusion of the species code in one of the analytics endpoints. (which might fix this Home Assistant Addon issue).

But it also includes the species code in one of the analytics endpoints now too!

Which means we can bring back the ebird urls to the markdown card that uses the birdnet local API.

To keep everything together, I’ll repost the other components.

Command line sensor

command_line sensor (which requires jq to be installed on the system):

- sensor:
    name: "BirdNET Summary"
    unique_id: "birdnet_summary"
    # Updated command uses jq to check input type and provide a default empty list
    command: >
      curl -s '192.168.1.100:8080/api/v2/analytics/species/daily' |
      jq 'if type == "array" then {species_list: .} else {species_list: []} end'
    # This value_template should now correctly return 0 when species_list is empty
    value_template: >
      {% if value_json is defined and value_json.species_list is iterable and value_json.species_list is not string %}
        {{ value_json.species_list | length }}
      {% else %}
        0 {# Default to 0 if structure is unexpected, though jq should prevent this #}
      {% endif %}
    unit_of_measurement: "species"
    json_attributes:
      - species_list
    scan_interval: 60 # Or adjust as needed
    command_timeout: 25

(Optional) I prevent my recorder from saving history.

recorder:
  exclude:
    entities:
      - sensor.birdnet_summary

Markdown card

My markdown card (now includes ebird links).

Note 1: It technically includes everything before 7 AM and after 7 PM, but those values are all grouped together in the first and last bar. I didn’t have room for more than 14 sparkline bars without feeling super cramped. So I picked the most 12 active hours at my house and compressed everything before and after that window.

Note 2: The ebird links will only work if you’re using a release that is at least on the Nightly Build 20250406 or newer) .

Note 3: The updated command_line sensor fixes the issue where the card wouldn’t reset properly due to the API returning null when no birds have been seen today (like right after midnight).

Note 4: This requires at least nightly-20250427 or newer.

type: markdown
content: >-
  {% set species_data = state_attr('sensor.birdnet_summary', 'species_list') %}

  {% if species_data and species_data | count > 0 %}

  Last Heard | Species | Count | Sparkline (Morning,7AM-7PM,Night)

  :-- | :-- | :-- | --

  {% for bird in species_data | sort(attribute='latest_heard', reverse=true) | sort(attribute='count', reverse=true) %}

  {#- Basic Info -#}

  {%- set time = bird.latest_heard -%}

  {%- set name = bird.common_name -%}

  {%- set count = bird.count -%}

  {%- set species_code = bird.species_code %}

  {%- set ebird_url = "https://ebird.org/species/" ~ species_code %}

  {#--- Sparkline Calculation (Full Day Aggregated - 15 slots) ---#}

  {%- set hourly = bird.hourly_counts -%}

  {%- set sparkline_str = "N/A" -%} {#- Default value -#}

  {%- if hourly is defined and hourly is iterable and hourly | count == 24 -%}
    {#- Aggregate counts: [0-8], 7..18, [21-23] (15 slots total, safe concatenation) -#}
    {%- set early_sum = hourly[0:6] | sum -%}
    {%- set middle_part = hourly[6:18] -%}
    {%- set late_sum = hourly[18:24] | sum -%}
    {%- set aggregated_counts = [early_sum] + middle_part + [late_sum] -%}

    {# Check if list has expected count before finding max #}
    {%- if aggregated_counts | count == 14 -%}
      {#- Find Max for Scaling -#}
      {%- set max_val = aggregated_counts | max -%}

      {#- Define Sparkline Characters (7 levels, U+2581 to U+2587) -#}
      {%- set spark_chars = ['▁', 'ā–‚', 'ā–ƒ', 'ā–„', 'ā–…', 'ā–†', 'ā–‡'] -%}
      {%- set sparkline = namespace(text="") -%}

      {%- for v in aggregated_counts -%}
        {#- Map value to character index (0-6), scale factor 7, max index 6 -#}
        {%- set char_index = ([0, (v * 7 / max_val)|int , 6] | sort)[1] if max_val > 0 else 0 -%} {#<-- 2. Use 7 levels for scaling/clamping #}
        {#- Append character -#}
        {%- set sparkline.text = sparkline.text ~ spark_chars[char_index] -%}
      {%- endfor -%}
      {%- set sparkline_str = sparkline.text -%}
    {%- else -%}
       {% set sparkline_str = "?" * 12 %}
    {%- endif -%}
  {%- endif -%}

  {#--- End Sparkline Calculation ---#}


  {#- Output Row -#}

  {{ today_at(time) | as_timestamp  | timestamp_custom('%H:%M', true)}} | [{{
  name }}]({{ ebird_url }}) | {{ count }} | {{ sparkline_str }}

  {% endfor %}


  ---

  {{ species_data | sum(attribute='count') | int }} Detections

  {{ species_data | count }} Species

  {% else %}

  No bird species data available or list is empty.

  {% endif %}
grid_options:
  columns: 15
  rows: auto
4 Likes

Just FYI, in the local API, it looks like in a recent release lastest_seen was changed to latest_heard.

Thanks. Updated with a note.

Has anyone managed to pull in data like # of unique species? And perhaps list last 10 unique species and date of detection or something, similarly to the markdown cards in the thread for daily detections?

Sure. I have one of those too.

I also have a filtered variant that shows only specific birds of interest, but I’ll likely post that one later as I’m trying to round up a bunch of the scripts, alerts, and cards I’ve made and include them all in a single blog post. I’m still dogfooding them before I share them out.

So this card I show above uses the /api/v2/analytics/species/summary api endpoint for your BirdNET-Go. It lists a summary of all unique species detected so far in your BirdNET-Go instance.

Make sure you’re using one of the latest nightlies for this one. As of right now, it works for Nightly Build 20250508. So this could change on earlier or later versions.

First you’ll need a new command_line sensor that fetches this data. I put mine in a command_line.yaml file, which is linked in my configuration.yaml. So my sensor looks like this:

- sensor:
    name: "BirdNET Species Summary"
    unique_id: "birdnet_species_summary"
    # Uses jq to check input type and provide a default empty list
    command: >
      curl -s 'http://<YOUR_BIRDNET_ENDPOINT>/api/v2/analytics/species/summary' |
      jq 'if type == "array" then {species_list: .} else {species_list: []} end'
    # Value_template calculates the number of species in the list
    value_template: >
      {% if value_json is defined and value_json.species_list is iterable and value_json.species_list is not string %}
        {{ value_json.species_list | length }}
      {% else %}
        0 {# Default to 0 if structure is unexpected #}
      {% endif %}
    unit_of_measurement: "species"
    json_attributes:
      - species_list
    scan_interval: 60 # Or adjust as needed
    command_timeout: 25

Also, I recommend ignoring this one with the recorder or you’re going to start getting warnings/errors since it’s a bit too large to store.

recorder:
  exclude:
    entities:
      - sensor.birdnet_species_summary

Next we’ll access is with this Markdown card. This is the whole raw markdown card:

type: markdown
content: "{% set species_data = state_attr('sensor.birdnet_species_summary', 'species_list') %} {% if species_data and species_data | count > 0 %}\nLatest Detections | &nbsp;&nbsp;&nbsp;When \n:-- | :-- {% for bird in (species_data | sort(attribute='last_heard', reverse=true))[0:11] %}\n  {%- set time = bird.last_heard -%}\n  {%- set name = bird.common_name -%}\n  {%- set species_code = bird.species_code %}\n  {%- set ebird_url = \"https://ebird.org/species/\" ~ species_code %}\n\_ {%- set last_heard_datetime = strptime(time, '%Y-%m-%d %H:%M:%S') %}\n[{{ name }}]({{ ebird_url }}) | &nbsp;&nbsp;&nbsp;{{ relative_time(last_heard_datetime) }} ago {% endfor %}\n{% else %} No recent bird data available. {% endif %}"

But this one is a bit easier to read if you paste it into the markdown content in the UI:

{% set species_data = state_attr('sensor.birdnet_species_summary', 'species_list') %} {% if species_data and species_data | count > 0 %}
Latest Detections | &nbsp;&nbsp;&nbsp;When 
:-- | :-- {% for bird in (species_data | sort(attribute='last_heard', reverse=true))[0:11] %}
  {%- set time = bird.last_heard -%}
  {%- set name = bird.common_name -%}
  {%- set species_code = bird.species_code %}
  {%- set ebird_url = "https://ebird.org/species/" ~ species_code %}
  {%- set last_heard_datetime = strptime(time, '%Y-%m-%d %H:%M:%S') %}
[{{ name }}]({{ ebird_url }}) | &nbsp;&nbsp;&nbsp;{{ relative_time(last_heard_datetime) }} ago {% endfor %}
{% else %} No recent bird data available. {% endif %}

That’s pretty much it. Change the array numbers if you want more or less. I liked having it show the relative time on the card (making it easier to quickly know when that bird last appeared without needing to check the clock every time).

2 Likes

You can make the card full width of the dashboard using the new sections view.

Thanks. I tried full-width (which worked nicely on my tablet), but it made my dashboard too empty on my desktop. I didn’t really want to mess with custom cards, or mobile breakpoints, so I just reduced the number of spark lines.

I might give editing it a try this weekend as I am interested in some nocturnal bird populations. It will be unlikely to work on a mobile phone screen though.

Wow, this is amazing. I’ll have a go, but I can see there’s a couple of problems I likely need to sort out first.

  1. How do I update to a newer version? I’m currently running 20250427-2.

  2. My analytics page is not getting populated at all. I’ve had many detections over the last few days, but nothing is being collected in the analytics page. It just has 0 across all statistics. Any ides?

EDIT: I figured out the analytics. It doesn’t seem to work through the HASS ingress link to the webUI. I had to use the http://homeassistant.local:8080 link and it’s being populated no worries!

That version should probably still work. I think nightly-20250427 is when the latest_seen label was changed to latest/last_heard that I’m using in my card. So you should be good to go.

Thanks for this reply. I’ve spent a bit of time trying to implement this and I’m stuck on getting the sensor itself to work.

Firstly, I’ve tried to implement through a separate file (command_line.yaml) as you had suggested. I’d like to keep configuration.yaml neat if I can. I tried to use ā€˜command_line: !include command_line.yaml’ to reference it, but home assistant doesn’t like that, so instead I tried reformatting just to include in configuration.yaml straight up. My code is below.

The issue is that the sensor doesn’t show up at all, and the dashboard cards you suggested just sit there with ā€˜No recent bird data available’. At least by the sensor doesn’t show up I mean I can’t find it through developer tools → states.

I guess my questions are:

  • how should I reference command_line.yaml correctly in the config.yaml file?
  • is my code correct for including directly in config.yaml instead?
  • how exactly should I be referencing my birdnet-go endpoint? I’ve tried IP_address:8080, homeassistant.local:8080, and localhost:8080.
sensor:
  - platform: command_line
    name: "BirdNET Species Summary"
    unique_id: "birdnet_species_summary"
    command: >
      curl -s 'http://xxx.xxx.x.xxx:8080/api/v2/analytics/species/summary' |
      jq 'if type == "array" then {species_list: .} else {species_list: []} end'
    value_template: >
      {% if value_json is defined and value_json.species_list is iterable and value_json.species_list is not string %}
        {{ value_json.species_list | length }}
      {% else %}
        0
      {% endif %}

I reference my command_line.yaml file like this:

in my configuration.yaml:

command_line: !include command_line.yaml

in my command_line.yaml:

- sensor:
    name: "BirdNET Species Summary"
    unique_id: "birdnet_species_summary"
    # Uses jq to check input type and provide a default empty list
    command: >
      curl -s 'http://BIRDNET_ENDPOINT_HERE/api/v2/analytics/species/summary' |
      jq 'if type == "array" then {species_list: .} else {species_list: []} end'
    # Value_template calculates the number of species in the list
    value_template: >
      {% if value_json is defined and value_json.species_list is iterable and value_json.species_list is not string %}
        {{ value_json.species_list | length }}
      {% else %}
        0 {# Default to 0 if structure is unexpected #}
      {% endif %}
    unit_of_measurement: "species"
    json_attributes:
      - species_list
    scan_interval: 60 # Or adjust as needed
    command_timeout: 25

I’m going to be making a lot of assumptions about your setup here, but to confirm you have the correct endpoint try hitting your BirdNET endpoint in the browser.

To rule some things out, I’m going to suggest by using the ip address here. (there are a lot of reasons you might want homeassistant.local instead, and you can always switch to that in the future if you figure out our other issue)

So if you can find out where your birdnet instance is hosted and which port, try visiting this in your browser. You should get a payload of information back.

http://<BIRDNET_IP>:<BIRDNET_PORT>/api/v2/analytics/species/summary

If that returns data, great! Let’s make sure your command line sensor is using the correct endpoint.

Hopefully with the formatting fixes above for the sensor, it starts to show up in Home Assistant and it starts populating data.

If not, do you see any log messages in Home Assistant or errors when it tries to fetch the data or set up the sensor?

1 Like

@Kyle Seems like you have some knowledge of the API. For me the command_line sensor is working, however I am trying to set up a notification when new species are observed. Do you know if that info is available in the api as well?

Y’all are spoiling everything I was going to write about in my blog post. lol

Good news again!

/api/v2/analytics/species/detections/new

(the comment in the code below seems to be for a previous endpoint? But the API url above works for me). Also, I can’t stress how important it is that these API endpoints might still change as it has been a while since a non-nightly release was made. So it may break down the line.

This endpoint returns new species within a given timeframe (defaults to the last 30 days if not specified).

I haven’t yet figured out how to get a list of birds that aren’t new, but instead ones I haven’t seen in a while (e.g. 60 days). But I think that will follow a similar approach to above where I use the /api/v2/analytics/species/summary endpoint and compare the last_heard date values between the old and new attribute data.

EDIT: I’ve figured out how to do the ā€œhaven’t seen this bird in 60 daysā€ notification. I’ll include that in my future blog post as there are a lot of neat scripts and dashboard cards I want to include in it.

For me, the /new endpoint returns data that looks like this (truncated):

[
    {
        "scientific_name": "Setophaga magnolia",
        "common_name": "Magnolia Warbler",
        "first_heard_date": "2025-05-18",
        "thumbnail_url": "https://static.avicommons.org/magwar-76620557-320.jpg",
        "count_in_period": 9
    },
    {
        "scientific_name": "Setophaga striata",
        "common_name": "Blackpoll Warbler",
        "first_heard_date": "2025-05-17",
        "thumbnail_url": "https://static.avicommons.org/bkpwar-abB0p0cexDmdr4da-320.jpg",
        "count_in_period": 5
    },
    {
        "scientific_name": "Setophaga ruticilla",
        "common_name": "American Redstart",
        "first_heard_date": "2025-05-13",
        "thumbnail_url": "https://static.avicommons.org/amered-339600708-320.jpg",
        "count_in_period": 206
    }
]

You should be able to create a command_line sensor for that (if you have a lot of new birds in this list, you might get those attribute size error, so you’ll want to test if this still works after excluding it from the recorder).

I haven’t tested it the notification, but it could work like this:

- sensor:
    name: "BirdNET New Bird Detections List"
    unique_id: "birdnet_new_bird_detections_list"
    # Command to fetch and process the JSON data
    # Uses jq to ensure the output is always a JSON array, even if the API returns null or an error string.
    command: >
      curl -s 'http://YOUR_BIRDNET_URL/api/v2/analytics/species/detections/new' |
      jq 'if type == "array" then {birds: .} else {birds: []} end'
    # The main state of the sensor will be the count of newly detected bird species
    value_template: >
      {% if value_json is defined and value_json.birds is iterable and value_json.birds is not string %}
        {{ value_json.birds | length }}
      {% else %}
        0 {# Default to 0 if the data is not as expected #}
      {% endif %}
    unit_of_measurement: "species"
    # Store the entire list of bird objects as an attribute
    json_attributes:
      - "birds" # This will create an attribute named 'birds' containing the full list
    scan_interval: 60
    command_timeout: 25

and then the notification. It compares the attributes from the trigger data of the old list and checks if it’s in the new list. It’s pretty hacky and I haven’t done extensive testing. But the happy-path seems to work. You can tweak it:

alias: Notify on First Sighting of New BirdNET Bird Species
description: ""
triggers:
  - trigger: state
    entity_id:
      - sensor.birdnet_new_bird_detections_list
    attribute: birds
conditions:
  - condition: template
    value_template: >-
      {{ trigger.to_state.attributes.birds is defined and
      trigger.to_state.attributes.birds is iterable and
      trigger.to_state.attributes.birds is not string }}
actions:
  - variables:
      current_birds_list: >-
        {% set cbl = trigger.to_state.attributes.get('birds') if
        trigger.to_state and trigger.to_state.attributes else none %} {% if cbl
        is iterable and cbl is not string %}
          {{ cbl }}
        {% else %}
          []
        {% endif %}
      previous_birds_list: >-
        {% set pbl = trigger.from_state.attributes.get('birds') if
        trigger.from_state and trigger.from_state.attributes else none %} {% if
        pbl is iterable and pbl is not string %}
          {{ pbl }}
        {% else %}
          []
        {% endif %}
      current_common_names: "{{ current_birds_list | map(attribute='common_name') | list }}"
      previous_common_names: "{{ previous_birds_list | map(attribute='common_name') | list }}"
  - repeat:
      for_each: "{{ current_birds_list }}"
      sequence:
        - sequence:
            - condition: template
              value_template: "{{ repeat.item.common_name not in previous_common_names }}"
            - action: notify.kyle
              metadata: {}
              data:
                title: New Bird Species Alert!
                message: >-
                  A new bird species has been added to your 'never seen' list:
                  {{ repeat.item.common_name }}.
mode: single

Last, a markdown card to go with the command_line sensor.

type: markdown
content: "{% set species_data = state_attr('sensor.birdnet_new_bird_detections_list', 'birds') %} {% if species_data and species_data | count > 0 %}\nCommon Name | &nbsp;&nbsp;Count (30d) | &nbsp;&nbsp;&nbsp;When \n:-- | :-- | :-- {% for bird in (species_data | sort(attribute='first_heard_date', reverse=true))[0:10] %}\n  {%- set time = bird.first_heard_date -%}\n  {%- set name = bird.common_name -%}\n  {%- set count = bird.count_in_period -%}\n\_ {%- set first_heard = strptime(time, '%Y-%m-%d') %}\n{{ name }} | &nbsp;&nbsp;{{count}} | &nbsp;&nbsp;&nbsp;{{ relative_time(first_heard) }} ago {% endfor %}\n{% else %} No recent bird data available. {% endif %}"

or if you want a more readable one, this is the yaml content of the markdown card:

{% set species_data = state_attr('sensor.birdnet_new_bird_detections_list', 'birds') %} {% if species_data and species_data | count > 0 %}
Common Name | &nbsp;&nbsp;Count (30d) | &nbsp;&nbsp;&nbsp;When 
:-- | :-- | :-- {% for bird in (species_data | sort(attribute='first_heard_date', reverse=true))[0:10] %}
  {%- set time = bird.first_heard_date -%}
  {%- set name = bird.common_name -%}
  {%- set count = bird.count_in_period -%}
  {%- set first_heard = strptime(time, '%Y-%m-%d') %}
{{ name }} | &nbsp;&nbsp;{{count}} | &nbsp;&nbsp;&nbsp;{{ relative_time(first_heard) }} ago {% endfor %}
{% else %} No recent bird data available. {% endif %}
1 Like

Hehe. Thanks! Will read the blog post anyway. Will try it out!

Amazing, I’ve got it working, thanks so much. Looking forward to the blog post!

Can confirm that the new species automation in Displaying Birdnet-go detections - #84 by Kyle is working just fine.

1 Like