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?
@Kyle, your species look almost identical to mine! Do you live somewhere in the north east? ![]()
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
your species look almost identical to mine! Do you live somewhere in the north east?
How did you know?? ![]()
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
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 | 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 }}) | {{ 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 | 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 }}) | {{ 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).
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.
-
How do I update to a newer version? Iām currently running 20250427-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?
@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 | Count (30d) | 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 }} | {{count}} | {{ 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 | Count (30d) | 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 }} | {{count}} | {{ relative_time(first_heard) }} ago {% endfor %}
{% else %} No recent bird data available. {% endif %}
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.


