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.