Displaying Birdnet-go detections

Hi @tom_l

Are you sure about this?

I restarted several times and did not lose the latest value (sensor contents), just the recorded history. From my understanding of the recorder integration, it is all about persisting the state history, not the current/latest value itself.

As I am storing the day’s history in the latest value, it seems to work well.

The mqtt sensor will lose all previous detections after a restart.

@jetpacktuxedo Could you share a bit more about your solution with a RTSP stream from a Raspberry Pi?

For example the config file you used for MediaMTX?
And how the service is setup ?

I tried to find some more information but it is not very clear to me on how to set this up… :frowning:

Can you (or anyone I guess) tell me how you did that? I mean without getting a full rtsp camera and mic setup? I only need a mic really.

(this will hopefully help @nickrout too… I don’t have a camera in this setup at all, but I send a live spectrogram as a video feed to the rtsp instance)

Unless I’m looking in the wrong place (it’s been a bit…) I think my mediamtx config is literally just

protocols: [tcp]
paths:
  all:
    source: publisher

The config for the systemd service that runs mediamtx looks like this:

[Unit]
Description=rtsp server

[Service]
Restart=on-failure
RestartSec=10
Type=simple
User=root
Group=root
WorkingDirectory=/
ExecStart=/usr/bin/mediamtx

[Install]
WantedBy=multi-user.target

The config for my systemd service that pushes the mic input into the rtsp feed via ffmpeg is this:

[Unit]
Description=Streaming Microphone Input To RTSP

[Service]
Restart=on-failure
RestartSec=10
Type=simple
User=root
Group=root
MemoryHigh=1G
MemoryMax=1.5G
WorkingDirectory=/
ExecStart=ffmpeg -hide_banner -f alsa -ac 1 -i "hw:CARD=Device,DEV=0" -filter_complex "[0:a]showspectrum=s=hd480:mode=combined:slide=scroll:saturation=0.5:color=rainbow:scale=log,fps=15,format=yuv420p[v]" -map "[v]" -map 0:a -c:v libx264 -c:a aac -b:a 128k -f rtsp -rtsp_transport tcp rtsp://localhost:8554/birds

[Install]
WantedBy=multi-user.target

(obviously you could exclude the spectrogram generation portions of that ffmpeg call)

All of that runs on a raspberry pi in my back yard with a mic attached.

Then on my actual server indoors I am running birdnet-go in a docker container with the rtsp section in the config file set like this:

    rtsp:
        transport: tcp # RTSP Transport Protocol
        urls:
            - rtsp://IP.of.my.pi:8554/birds
2 Likes

I’ve been working on getting Birdnet-Go set up on my instance. This thread has been very helpful for me and I wanted to share my dashboard as it stands after a few days. Since I upload my results to Birdweather, I decided to utilize their graphql API and primarily use that data for my dashboard instead of the Birdnet MQTT sensors described in the add-on. I am pretty happy with my results up to now.

I decided to use a command_line curl command for the API. I found the API documentation to be less than stellar, but here is where I have landed for now:

command_line:
  - sensor:
      name: "Birdweather Detections Station n1234"
      unique_id: birdweather_detections_station_n1234

      command: >
        curl -s \
          -H "Content-Type: application/json" \
          -H "Authorization: Bearer xxxxxxxxxxx" \
          --data '{
              "query": "query StationDetections($stationId: ID!, $first: Int) { station(id: $stationId) { id detections(first: $first) { totalCount nodes { timestamp species { id commonName imageUrl scientificName } speciesId confidence } } } }",
              "variables": { "stationId": "n1234", "first": 100 }
            }' \
          "https://app.birdweather.com/graphql" | jq --arg now "$(date -Is)" '{
            stationId: .data.station.id,
            totalDetections: .data.station.detections.totalCount,
            lastDetection: (
              .data.station.detections.nodes |
              map(.timestamp) |  # Extract all timestamps
              max  # Find the maximum (latest) timestamp
            ),
            species: (
              .data.station.detections.nodes |
              group_by(.species.commonName) |
              map(
                {
                  speciesName: .[0].species.commonName,
                  totalCount: length,
                  lastSpeciesDetection: (
                    sort_by(.timestamp) |  # Sort by timestamp within the group
                    .[length - 1].timestamp   # Get the last (latest) timestamp
                  ),
                  scientificName: .[0].species.scientificName,
                  imageUrl: .[0].species.imageUrl
                }
              )
            ),
            lastResponse: $now
          }'
      scan_interval: 60
      unit_of_measurement: ''
      value_template: >
        {{ value_json.totalDetections }}
      json_attributes:
        - totalDetections
        - stationId
        - lastDetection
        - lastResponse
        - species

This gets me: total detections for my station, the last detection datetime, the last API response datetime, my station ID, and the last 100 detections grouped by common name, and including the total count detected, the last time the species was detected, the scientific name for the species, and image URL for the species all in a single sensor.

I started with markdown cards for displaying the top 100 detections, but changed to flex-table-card for a cleaner (and theoretically easier to set up) look. I created two layouts, one for desktop/tablet and one for mobile view. Slightly different but providing the same information.

Desktop dashboard:

For desktop, I use flex-table-card and card_mod to move it to a horizontal view:

type: custom:flex-table-card
title: 100 Most Recent Detections
entities:
  - entity: sensor.birdweather_detections_station_nnnnn
columns:
  - name: ""
    data: species.imageUrl
    modify: "'<img src=\"' + x + '\"style: width=75%; height=auto;\">'"
  - name: ""
    data: species
    modify: >
      "<strong><span style=\"text-transform: uppercase;\">" + x.speciesName +
      "</span></strong>" + 
      "<br>" + 
      "Count: " + x.totalCount +
      "<br>" + 
      "Scientific Name: " + x.scientificName +
      "<br>" + 
      "Last Detection: " + x.lastSpeciesDetection.substring(0, 10)
search: true
grid_options:
  columns: 48
  rows: auto
card_mod:
  style: |
    table {
      display:table !important;
    }
    tbody {
      display: grid !important;
      grid-template-columns: repeat(4, 1fr);
      grid-gap: 10px;
    }
    tr {
      grid-column: auto;
    }
    td {
      float: none !important;
      display: table-cell !important;
      width: 50% !important;
      max-width: none !important;
    }
    thead {
      display: none !important; 
    }
    tfoot {
      display: none !important;
    }

The other cards are just the sensor and markdown cards.

Mobile dashboard:

type: custom:flex-table-card
title: 100 Most Recent Detections
entities:
  - entity: sensor.birdweather_detections_station_12159
columns:
  - name: ""
    data: species.imageUrl
    modify: "'<img src=\"' + x + '\"style: width=75%; height=auto;\">'"
  - name: ""
    data: species
    modify: >
      "<strong><span style=\"text-transform: uppercase;\">" + x.speciesName +
      "</span></strong>" + 
      "<br>" + 
      "Count: " + x.totalCount +
      "<br>" + 
      "Scientific Name: " + x.scientificName +
      "<br>" + 
      "Last Detection: " + x.lastSpeciesDetection.substring(0, 10)
search: true
grid_options:
  columns: 24
  rows: auto

1 Like

For what it’s worth, instead of using this:

- platform: time
  at: "00:00:00"
  id: reset

I had to use this:

- trigger: time_pattern
  # This will update every night
  hours: 0
  minutes: 0

In the first trigger, I think time is converting the text into a proper “timestamp”. Because there is no timezone attached to it, I think it’s defaulting to UTC. So instead of resetting at my midnight, it resets whenever UTC hits midnight (which is the middle of the day for me).

So instead, I use time_pattern where I think it’s properly using my server’s midnight time.

The above example came from the docs.

Ref: Template - Home Assistant

No, it is assumed to be local time. If you are running HA in a VM ensure your host OS has the correct time and time zone set as well as HA.

I have many time triggers set like that. They all trigger at the local time.

1 Like

Weird. I’m not sure what’s going on then. I’m running it inside of a docker container.

I’ve got plenty of other automations and sensors that have no problem getting the correct time. Even my system logs show the correct time. My time zone correctly shows up in the system settings in home assistant.

But it only seems to be when I set up the sensor trigger using the time platform here that it appears to be doing something different.

Which also confuses me because time_pattern worked correctly.

I guess I’ll need to check again to see if I’m passing in the correct time from the host machine.

What does the automation trace say?

I checked my container. the timezone is getting passed into it correctly.

Here are one of my traces for an automation that uses the time platform.

A trigger for noon, that triggered at noon.

I have a second theory now.

I was using the attribute based implementation that somebody poster earlier. I had implemented it that way, and also turned off the recorder. At first, my testing showed it worked just fine and survived reboots since the current state was always saved (just no previous states).

However, I recall that I had a log warning about not being able to write anymore to the entity due to the attribute size being too large. I’m wondering if because this happened later in the day (and if I happened to reboot after this log), it made it appear that my reset was happening earlier than it should?

I’ll test my current implementation of using time_pattern for a few more days. If I notice this happens again later in the day, then I can completely rule out the time platform issue.

Thanks for all the comments in this thread! They made it very easy to get some birds onto my dashboard.

But I was thinking. Rather than listening to MQTT posts for individual detections and then doing the statistics on home assistant, wouldn’t it be easier to let BirdNet-Go do all that hard work (since it’s doing it already) and just use HA to parse the data? You wouldn’t get instantaneous notifications about birds, of course.

To see what I mean, check out v2 of the BirdNet-Go api: http://yourbirdnetgo-url/api/v2/analytics/species/summary which provides something like this:

[{"scientific_name":"Quiscalus quiscula","common_name":"Common Grackle","count":3847,"first_seen":"2025-03-19 08:16:59","last_seen":"2025-03-31 12:32:20","avg_confidence":0.7534598388354562,"max_confidence":1,"thumbnail_url":"https://upload.wikimedia.org/wikipedia/commons/thumb/6/6f/Common_grackle_in_PP_%2836732%29.jpg/400px-Common_grackle_in_PP_%2836732%29.jpg"},
{"scientific_name":"Cardinalis cardinalis","common_name":"Northern Cardinal","count":1123,"first_seen":"2025-03-19 09:06:12","last_seen":"2025-03-31 13:46:19","avg_confidence":0.6219412288512912,"max_confidence":0.99,"thumbnail_url":"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Male_northern_cardinal_in_Central_Park_%2852612%29.jpg/500px-Male_northern_cardinal_in_Central_Park_%2852612%29.jpg"},
...]
2 Likes

This is what I do for my dashboards. I use a command line curl sensor. This is my current sensor:

command_line:
  - sensor:
      name: "Birdweather Detections Station xxxxxx"
      unique_id: birdweather_detections_station_xxxxxx
      availability: "{{ value_json is defined }}"
      command: >
        curl -s \
          -H "Content-Type: application/json" \
          --data '{
              "query": "query StationDetections($stationId: ID!, $first: Int) { station(id: $stationId) { id detections(first: $first) { totalCount nodes { timestamp species { id commonName imageUrl scientificName ebirdUrl } speciesId confidence } } } }",
              "variables": { "stationId": "xxxxxx", "first": 100 }
            }' \
          "https://app.birdweather.com/graphql" | jq --arg now "$(date -Is)" '{
            stationId: .data.station.id,
            totalDetections: .data.station.detections.totalCount,
            lastDetection: (
              .data.station.detections.nodes |
              map(.timestamp) |  # Extract all timestamps
              max  # Find the maximum (latest) timestamp
            ),
            mostRecentSpeciesName: (
              .data.station.detections.nodes[0].species.commonName // "None" # Species name of the most recent bird
            ),
            mostRecentSpeciesImageUrl: (
              .data.station.detections.nodes[0].species.imageUrl // "None" # Image URL of the most recent bird
            ),
            mostRecentSpeciesEbirdUrl: (
              .data.station.detections.nodes[0].species.ebirdUrl // "None" # Image URL of the most recent bird
            ),
            species: (
              .data.station.detections.nodes |
              group_by(.species.commonName) |
              map(
                {
                  speciesName: .[0].species.commonName,
                  totalCount: length,
                  lastSpeciesDetection: (
                    sort_by(.timestamp) |  # Sort by timestamp within the group
                    .[length - 1].timestamp   # Get the last (latest) timestamp
                  ),
                  scientificName: .[0].species.scientificName,
                  imageUrl: .[0].species.imageUrl,
                  ebirdUrl: .[0].species.ebirdUrl
                }
              )
            ),
            lastResponse: $now
          }'
      scan_interval: 59
      unit_of_measurement: ''
      value_template: >
        {{ value_json.totalDetections }}
      json_attributes:
        - totalDetections
        - stationId
        - mostRecentSpeciesName
        - mostRecentSpeciesImageUrl
        - mostRecentSpeciesEbirdUrl
        - lastDetection
        - lastResponse
        - species

Thanks for all of the ideas!

I threw this together with some help from gemini to make the sparkline.

I’m using the /api/v2/analytics/species/daily endpoint on my BirdNET-Go instance and populating my card with that. I store the JSON in the sensor attribute and pull it out for markdown card.

I added a sparkline (I think it’s 7AM - 7PM, but I could be off by an hour). I clamp any values before or after those times so it isn’t lost.

This is taking in the hourly_counts values from the api endpoint.

First, make a command_line sensor (this requires that jq is installed on Home Assistant deployment and available to it. I have no idea if all installation types include that.):

command_line:
  - sensor:
      # Updated name to match your sensor
      name: "BirdNET Summary"
      unique_id: "birdnet_summary"
      command: "curl -s 'http://192.168.1.100:8080/api/v2/analytics/species/daily' | jq '{species_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 %}
          unavailable
        {% endif %}
      unit_of_measurement: "species"
      json_attributes:
        - species_list
      scan_interval: 60 # Or adjust as needed
      command_timeout: 25

I also prevent my recorder from saving the history for the sensor because why bother?

(optional)

recorder:
  exclude:
    entities:
      - sensor.birdnet_summary

Reboot. (also, you can decrease the scan interval and instead trigger a sensor update from the mqtt sensor if you want).

and this is my ugly markdown card.

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

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

  Last Seen | Species | Count | Sparkline (7AM-7PM)

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

  {% for bird in species_data %}

  {#- Basic Info -#}

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

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

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


  {#--- 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 = ['▁', '▂', '▃', '▄', '▅', '▆', '▇'] -%} {#<-- 1. Removed full block '█' #}
      {%- 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 }} | {{ 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 %}

Wishlist:

  • I miss a lot is that I don’t get the ebird hyperlinks anymore on the species name because the API doesn’t expose the ebird id at that endpoint. I’m sure I could hack together a wikipedia search query or the sort. Let me know if someone has a working idea!
  • Minor tweaks for the UI. But it’s functional right now.
2 Likes

Love the sparkline.

Can you get the eBird species code from that endpoint? The eBird URL is just ebird.org/species/ + ebirdSpeciesCode like ebird.org/species/norcal for Northern Cardinal.

That’s something I want to add. Right now the API endpoint doesn’t return the species code which is required to build the URL.

But I’ve started a PR to add that.

1 Like

4 posts were split to a new topic: Birdnet-go issuse

Wishlist:

  • I miss a lot is that I don’t get the ebird hyperlinks anymore on the species name because the API doesn’t expose the ebird id at that endpoint. I’m sure I could hack together a wikipedia search query or the sort. Let me know if someone has a working idea!

Here’s wikipedia links if that is what you’re after Displaying Birdnet-go detections - #22 by Quaternion

Just realized you are using the local API. That’s cool. Missed that it was available. I am using the birdweather API to get my station’s detections.

Thanks! And like the other user mentioned, this is using the local API exposed by BirdNET-Go.

I did end up getting working ebird urls by exposing the species_code in my recent PR.

Now I can render the hyperlinks just like before using a template similar to this:

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

I’ll post the updated markdown after it gets merged just in case there are any changes to the code. I’d rather not confuse people with features that aren’t there yet.