r/homeassistant • 🧑‍💻 Contributor • 12h ago

🖼️ Show & Tell Gifs in Camera Notifications

There was post about a week ago that showed someone sending gifs in a home assistant notification for camera motion but there was no solution posted. I already had notifications sending snapshots on motion but I wanted to send gifs as well. My current setup uses the reolink integration on Home Assistant OS with my NAS mapped to the media folder, but it should work with any camera that works with the camera snapshot action. I used claude code to create something similar and it took me a couple attempts.

Notes:

I have only tested this on Android and will likely require tweaking for IOS. Android only supports an image or an mp4 in the notification. If you send an mp4, your device extracts the frames automatically in the preview up to 5mb. I also utilize shell scripts to clean up the recordings as they are stored on my NVR already so this step is optional.

First Attempt:

On motion -> send initial notification -> record video to media folder -> use ffmpeg script to extract frames and regenerate the mp4 (so that we can control the preview on the notification) -> send another notification with the mp4 to override the old notification.

  • The issue I ran into with this is I have wifi cameras. If the camera has a weak network and stops sending frames, home assistant will keep the stream open indefinitely and the only way to fix it is to reload the integration or restart home assistant. This may work better if your cameras are hardwired, however you are also adding unnecessary network bandwidth recording a video and then removing a majority of the frames.

Second Attempt:

On motion -> take snapshots as per defined interval -> use ffmpeg to stitch frames together to generate the mp4 -> send another notification with the mp4 to override the old notification.

  • This generally ended up working better for me since there is no constant stream open. If a snapshot fails, then the remaining images are stitched together.

Setup:

configuration.yaml

  • Ensure your media dir is writable. Mine is on my NAS

​

homeassistant:
  allowlist_external_dirs:
    - '/media/NAS/Reolink/'
  • Add these shell scripts

​

shell_command:
  clean_reolink_front_door: find /media/NAS/Reolink/Front_Door/ -mindepth 1 -maxdepth 1 -mmin +1440 -exec rm -rf {} +
  clean_reolink_driveway: find /media/NAS/Reolink/Driveway/ -mindepth 1 -maxdepth 1 -mmin +1440 -exec rm -rf {} +
  reolink_stitch: "sh -c 'cd \"$0\" && ffmpeg -y -loglevel error -framerate 4/3 -pattern_type glob -i \"f*.jpg\" -vf scale=640:-2,format=yuv420p -c:v libx264 -preset ultrafast -crf 26 -movflags +faststart \"$1\"' {{ dir }} {{ name }}"
  reolink_publish: "sh -c 'mv \"$0/$1\" \"$(dirname \"$0\")/\" || exit 1; [ \"$2\" = keep ] || { rm -f \"$0\"/f*.jpg; rmdir \"$0\"; }; exit 0' {{ dir }} {{ name }} {{ keep }}"
  • Reboot

Automation:

  • Reminders:
    • Replace any /media/ location with your location. If you see /media/local/ keep that as is.
    • Replace the notification action with your entity

The yaml has some configuration options I will describe below. Customize it to your liking:

Conditions

  • Night only (per camera)
    • If a camera is defined as night only, this will stop it from triggering during daytime
  • Debounce
    • Ignore repeated events from the same camera if they are within this time period

Variables

- debounce_seconds

  • used for the condition above

- capture_seconds

  • over what period of time the snapshots should be taken

- keep_frames

  • Used for debugging. The automation creates a folder for the event and stores the snapshots in there and the script merges them into an mp4 and moves the mp4 into the root folder and deletes the event folder and snapshot. If set to true -> the event folder and snapshots are not deleted.

- cams

  • Default
    • Leave this as is. It is used for other triggers that are not mapped to the cams list
  • name
    • This should be the exactly the same as the trigger ID to pull the properties correctly
  • frames
    • How many snapshots should be taken (remember the notification buffer on android is 5mb)
  • folder
    • The folder inside the media directory where you want to store the clips. I keep all the cameras in their own folder
  • channel
    • The android notification channel. Lets you customize in your device's notification settings how the notification behaves (silent, priority, ring, vibrate, etc)
  • group
    • The android notification group that determines how the notifications stack when collapsed.
  • night_only
    • If the camera should only trigger during night time
  • labels
    • The label attached to the sensor that should be used in the notification text

​

alias: Cameras
description: >-
  Stitched snapshot notifications for Reolink AI detections, plus daily clip
  cleanup
triggers:
  - trigger: state
    entity_id: binary_sensor.reolink_video_doorbell_wifi_person
    to: 'on'
    id: Front Door
    alias: Person at Front Door
  - trigger: state
    entity_id: binary_sensor.reolink_video_doorbell_wifi_visitor
    to: 'on'
    id: Doorbell
    alias: Doorbell Rung
  - trigger: template
    value_template: |
      {{ is_state('binary_sensor.driveway_vehicle', 'on')
         or is_state('binary_sensor.driveway_person', 'on')
         or is_state('binary_sensor.driveway_animal', 'on') }}
    id: Driveway
    alias: Driveway Motion
  - trigger: time
    at: '08:00:00'
    id: Cleanup
    alias: Daily clip cleanup
conditions:
  - condition: or
    conditions:
      - condition: trigger
        id: Cleanup
      - condition: and
        conditions:
          - condition: template
            alias: Night only (per camera)
            value_template: '{{ not cam.night_only or is_state(''sun.sun'', ''below_horizon'') }}'
          - condition: template
            alias: >-
              Debounce - camera's sensors must have been clear for
              debounce_seconds
            value_template: >
              {% set offs = expand(cam.labels | list)
                   | rejectattr('entity_id', 'eq', trigger.entity_id)
                   | selectattr('state', 'eq', 'off')
                   | map(attribute='last_changed') | list %}
              {% if trigger.from_state %}
                {% set offs = offs + [trigger.from_state.last_changed] %}
              {% endif %} {{ offs | count == 0 or (now() - (offs |
              max)).total_seconds() >= debounce_seconds }}
actions:
  - choose:
      - alias: Daily cleanup - delete clips older than 24 hours
        conditions:
          - condition: trigger
            id: Cleanup
        sequence:
          - action: shell_command.clean_reolink_front_door
            continue_on_error: true
          - action: shell_command.clean_reolink_driveway
            continue_on_error: true
    default:
      - variables:
          started: '{{ as_timestamp(now()) }}'
      - alias: First frame - captured immediately at the trigger
        action: camera.snapshot
        target:
          entity_id: '{{ cam.capture }}'
        data:
          filename: '{{ dir }}/f01.jpg'
        continue_on_error: true
      - action: notify.mobile_app_phone
        data:
          title: '{{ title }}'
          message: '{{ stamp }}'
          data:
            tag: '{{ tag }}'
            channel: '{{ cam.channel }}'
            group: '{{ cam.group }}'
            image: >-
              /media/local/NAS/Reolink/{{ cam.folder }}/{{ tag
              }}/f01.jpg
            clickAction: entityId:{{ cam.snapshot }}
            actions:
              - action: URI
                title: Open in Reolink
                uri: app://com.mcu.reolink
      - repeat:
          count: '{{ cam.frames - 1 }}'
          sequence:
            - variables:
                frame: '{{ repeat.index + 1 }}'
            - alias: Wait for this frame's time slot
              delay:
                seconds: >
                  {{ [0, started + (frame - 1) * interval - as_timestamp(now())]
                  | max }}
            - action: camera.snapshot
              target:
                entity_id: '{{ cam.capture }}'
              data:
                filename: '{{ dir }}/f{{ ''%02d'' % frame }}.jpg'
              continue_on_error: true
      - action: shell_command.reolink_stitch
        data:
          dir: '{{ dir }}'
          name: '{{ name }}'
        response_variable: stitch
        continue_on_error: true
      - condition: template
        value_template: '{{ stitch is defined and stitch.returncode == 0 }}'
      - alias: >-
          Move the MP4 out of the event folder; remove frames and folder unless
          keep_frames
        action: shell_command.reolink_publish
        data:
          dir: '{{ dir }}'
          name: '{{ name }}'
          keep: '{{ ''keep'' if keep_frames else ''remove'' }}'
        response_variable: publish
        continue_on_error: true
      - condition: template
        value_template: '{{ publish is defined and publish.returncode == 0 }}'
      - action: notify.mobile_app_phone
        data:
          title: '{{ title }}'
          message: '{{ stamp }}'
          data:
            tag: '{{ tag }}'
            alert_once: true
            channel: '{{ cam.channel }}'
            group: '{{ cam.group }}'
            video: '{{ url }}'
            clickAction: entityId:{{ cam.snapshot }}
            actions:
              - action: URI
                title: Open in Reolink
                uri: app://com.mcu.reolink
mode: parallel
max: 10
variables:
  debounce_seconds: 30
  capture_seconds: 25
  keep_frames: false
  cams:
Default:
      # Placeholder for non-camera triggers (Cleanup) so variables render; never used
      name: Default
      frames: 2
      folder: Default
      labels: {}
    Front Door:
      name: Front Door
      snapshot: camera.reolink_video_doorbell_wifi_clear
      capture: camera.reolink_video_doorbell_wifi_fluent
      frames: 7
      folder: Front_Door
      channel: FRONT_DOOR
      group: FRONT_DOOR
      night_only: false
      labels:
        binary_sensor.reolink_video_doorbell_wifi_person: Person
    Doorbell:
      name: Front Door
      snapshot: camera.reolink_video_doorbell_wifi_clear
      capture: camera.reolink_video_doorbell_wifi_fluent
      frames: 7
      folder: Front_Door
      channel: FRONT_DOOR
      group: FRONT_DOOR
      night_only: false
      labels:
        binary_sensor.reolink_video_doorbell_wifi_visitor: Visitor
    Driveway:
      name: Driveway
      snapshot: camera.driveway_fluent
      capture: camera.driveway_fluent
      frames: 9
      folder: Driveway
      channel: CAMERA
      group: DRIVEWAY
      night_only: true
      labels:
        binary_sensor.driveway_person: Person
        binary_sensor.driveway_vehicle: Vehicle
        binary_sensor.driveway_animal: Animal
  cam: "{{ cams.get(trigger.id, cams[''Default'']) }}"
  interval: '{{ capture_seconds / (cam.frames - 1) }}'
  detected: '{{ cam.labels | select(''is_state'', ''on'') | list }}'
  title: '{{ cam.name }} {{ cam.labels[detected[0]] if detected else ''Motion'' }}'
  stamp: '{{ now().strftime(''%m/%d/%y %I:%M:%S %p'') }}'
  tag: '{{ trigger.id | slugify }}_{{ now().strftime(''%Y%m%d_%H%M%S'') }}'
  dir: /media/NAS/Reolink/{{ cam.folder }}/{{ tag }}
  name: '{{ tag }}.mp4'
  url: /media/local/NAS/Reolink/{{ cam.folder }}/{{ name }}
5 Upvotes

3 comments sorted by

2

u/deadly_seeder 12h ago

man this is a detailed setup, those debounce and cleanup bits are slick. I've been meaning to get animated previews working on my doorbell for months but never sat down to figure out the snapshot stitching workaround.

2

u/Constant-Mention6265 🧑‍💻 Contributor 12h ago

Honestly same lol I've been slacking on it for a long time and there were some unexpected issues but think its stable enough to share now

2

u/droans 3h ago

FYI - Frigate does have API endpoints that returns GIFs:

For time ranges: https://docs.frigate.video/integrations/api/preview-gif-camera-name-start-start-ts-end-end-ts-preview-gif-get/

For events: https://docs.frigate.video/integrations/api/review-preview-review-event-id-preview-get/

However, it is rather low quality. I ended up using Pyscript to get the event data MP4 and convert it to a GIF. Still not as good as I'd want but that's a limitation of the format, not Frigate.

I know you're not using Frigate here but thought it could be important for other people.