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.