Cameras / Home Assistant and Frigate

Connect Home Assistant or Frigate.

Whatever camera your setup can see, it can send Tendline a still frame when something moves, or on a schedule. Tendline reads the frame and checks it against your routines.

Early access

Push cameras are part of early access. They aren’t open to the public yet. This guide shows exactly what the setup involves; the steps work once your account has early access. Join the early list.

One private push URL per camera.

You choose which frames Tendline sees. Nothing is sent unless your automation or the bridge sends it.

In the Tendline app, open Settings, go to Cameras, then Add any camera. Give it a name and a few location words, like “front door, porch”; the location words decide which routines the camera serves. Select Get push URL.

The URL is shown once. Treat it like a password: anyone who has it can send pictures to that camera. If it leaks, remove the camera in Settings > Cameras and add it again for a new one.

Each camera can send up to 120 frames an hour, each under 1.5 MB. Tendline uses the time the frame was taken, as long as it’s within the last 15 minutes.

Home Assistant in five steps.

Works with any camera entity and any sensor that turns on: motion, occupancy, or a person sensor from your camera or from Frigate.

Paste the push command into secrets.yaml

Open /config/secrets.yaml with the File editor or Studio Code Server add-on and paste this entry. Replace PASTE-PUSH-URL-HERE with your push URL and leave the rest exactly as it is. From integrations/home-assistant/secrets-snippet.yaml:

# Tendline push commands for secrets.yaml: one entry per camera.
# Copy an entry into /config/secrets.yaml and replace PASTE-PUSH-URL-HERE with
# that camera's push URL from the Tendline app. Keep everything else as it is.
# Each name must match a line in configuration.yaml (configuration-snippet.yaml).

tendline_front_door: >-
  curl -sS -m 30 -w '\n%{http_code}'
  -H 'Content-Type: image/jpeg'
  -H 'X-Tendline-Label:{{ label | default("") }}'
  -H 'X-Tendline-Event-Time:{{ event_time | default("") }}'
  --data-binary '@{{ file }}'
  PASTE-PUSH-URL-HERE

# A second camera looks the same, with its own name and push URL:
# tendline_driveway: >-
#   curl -sS -m 30 -w '\n%{http_code}'
#   -H 'Content-Type: image/jpeg'
#   -H 'X-Tendline-Label:{{ label | default("") }}'
#   -H 'X-Tendline-Event-Time:{{ event_time | default("") }}'
#   --data-binary '@{{ file }}'
#   PASTE-PUSH-URL-HERE

Add one line to configuration.yaml

If you already have a shell_command: section, add the line under it. Then restart Home Assistant; after later edits, reloading shell commands from Settings > Tools > YAML is enough. From integrations/home-assistant/configuration-snippet.yaml:

# Tendline: the one-time addition to configuration.yaml.
# Add one line per camera under shell_command (merge it with an existing
# shell_command: section if you have one). Each name points at the entry with
# the same name in secrets.yaml, which holds that camera's push URL.
# Restart Home Assistant the first time; after that, reload shell commands.

shell_command:
  tendline_front_door: !secret tendline_front_door
  # tendline_driveway: !secret tendline_driveway

# Not needed with the blueprint's default snapshot folder, /media/tendline,
# which Home Assistant allows out of the box. Only if you pick a folder outside
# /media and /config/www, list it here. It must already exist, or Home Assistant
# reports a configuration error.
# homeassistant:
#   allowlist_external_dirs:
#     - /config/tendline

The frame is saved to /media/tendline/ before it’s sent. Home Assistant OS and Container allow that folder out of the box, and only signed-in users can see it, so nothing else is needed.

Add the blueprint

In the blueprints/automation folder inside /config, make a folder called tendline and save the blueprint below in it as tendline_push.yaml. Then reload automations from Settings > Tools > YAML.

The blueprint: tendline_push.yaml
# Tendline: send a camera snapshot to a Tendline push URL when a sensor turns on.
# Install: save as /config/blueprints/automation/tendline/tendline_push.yaml, then reload automations.
# It needs a push command for each camera in secrets.yaml and configuration.yaml: see
# configuration-snippet.yaml, secrets-snippet.yaml and integrations/README.md.
blueprint:
  name: Send a camera snapshot to Tendline
  author: Tendline
  description: >-
    When a motion, occupancy or person sensor turns on, take a snapshot from a camera and send it to
    Tendline through that camera's push URL. Tendline only sees the frames this automation sends.


    Before you use it, add a Tendline push command for the camera to secrets.yaml and
    configuration.yaml, as described in the Tendline setup guide.
  domain: automation
  homeassistant:
    min_version: 2024.10.0
  input:
    trigger_sensor:
      name: Sensor
      description: The sensor that starts it, for example a motion, occupancy or person sensor.
      selector:
        entity:
          filter:
            - domain: binary_sensor
    camera_entity:
      name: Camera
      description: The camera to take the snapshot from.
      selector:
        entity:
          filter:
            - domain: camera
    push_command:
      name: Push command
      description: >-
        The name you gave this camera under shell_command in configuration.yaml, without
        "shell_command.", for example tendline_front_door.
      default: tendline_front_door
      selector:
        text:
    label:
      name: What the sensor detects
      description: Tells Tendline what the sensor reacted to. Pick Motion if it reacts to any movement.
      default: motion
      selector:
        select:
          options:
            - label: Motion
              value: motion
            - label: Person
              value: person
            - label: Vehicle
              value: car
            - label: Package
              value: package
            - label: Animal
              value: animal
    cooldown:
      name: Cooldown
      description: >-
        After sending a frame, ignore the sensor for this long. Tendline takes up to 120 frames an
        hour from each camera, so keep this at 30 seconds or more.
      default: 60
      selector:
        number:
          min: 30
          max: 3600
          step: 10
          unit_of_measurement: seconds
          mode: box
    snapshot_delay:
      name: Wait before the snapshot
      description: Some sensors fire before anyone is in view. A second or two can give a better frame.
      default: 0
      selector:
        number:
          min: 0
          max: 10
          step: 1
          unit_of_measurement: seconds
    snapshot_folder:
      name: Snapshot folder
      description: >-
        Where the frame is saved before it is sent. The default works on Home Assistant OS and
        Container with no extra setup, and only signed-in users can see it.
      default: /media/tendline
      selector:
        text:

mode: single
max_exceeded: silent

variables:
  camera_entity: !input camera_entity
  push_command: !input push_command
  label: !input label
  snapshot_folder: !input snapshot_folder

triggers:
  - trigger: state
    entity_id: !input trigger_sensor
    from: "off"
    to: "on"

conditions:
  - condition: not
    conditions:
      - condition: state
        entity_id: !input camera_entity
        state:
          - unavailable
          - unknown

actions:
  - delay:
      seconds: !input snapshot_delay
  - variables:
      snapshot_file: "{{ snapshot_folder.rstrip('/') }}/{{ camera_entity.split('.')[1] }}.jpg"
  - action: camera.snapshot
    target:
      entity_id: !input camera_entity
    data:
      filename: "{{ snapshot_file }}"
  - action: "shell_command.{{ push_command }}"
    data:
      file: "{{ snapshot_file }}"
      label: "{{ label }}"
      event_time: "{{ now().timestamp() | int }}"
    response_variable: tendline
  - variables:
      status: "{{ (tendline.stdout | default(''))[-3:] }}"
  - if:
      - condition: template
        value_template: "{{ status != '200' }}"
    then:
      - action: system_log.write
        data:
          level: warning
          logger: tendline
          message: >-
            Tendline: the frame from {{ camera_entity }} didn't go through
            {%- if status in ['', '000'] %} because Tendline couldn't be reached.
            {%- elif status == '404' %} because Tendline doesn't recognize the push URL in secrets.yaml, or push
            cameras aren't switched on for your account yet.
            {%- elif status == '413' %} because it is larger than 1.5 MB. Use a lower-resolution camera entity.
            {%- elif status == '429' %} because this camera sent more than 120 frames this hour. Raise the cooldown.
            {%- else %} (answer {{ status }}). It will work again on the next trigger if this was temporary.
            {%- endif %}
  - delay:
      seconds: !input cooldown

Create the automation

Go to Settings > Automations & scenes > Blueprints and choose Send a camera snapshot to Tendline. Pick the sensor and the camera, enter the push command name (tendline_front_door), and say what the sensor detects: motion, a person, a vehicle, a package or an animal. The cooldown, 60 seconds unless you change it, keeps each camera well under its hourly limit.

Try it

Open the automation, use its menu and choose Run actions. The frame should show up for that camera in Tendline within a few seconds. If it doesn’t, look in Settings > System > Logs for a line that starts with “Tendline:”. It says what went wrong.

The frame is sent with curl, which is already inside Home Assistant OS and Home Assistant Container. On Home Assistant Core in your own Python environment, make sure curl is installed on that machine.

Options

  • More cameras. Repeat the first two steps with a new name, like tendline_driveway, and that camera’s push URL. Then create one automation per camera.
  • Wait before the snapshot. Some sensors fire before anyone is in view. One or two seconds can give a better frame.
  • Frigate’s sensors. The blueprint works with the person sensors Frigate’s integration creates. The bridge below usually gives better frames, because it sends Frigate’s own snapshot of each object.
  • Look-checks on a schedule. “At 10 PM check the garage is closed” needs a fresh frame at 10 PM even if nothing moves. Send one with a plain time-triggered automation, with no label:
alias: Tendline garage look-check
triggers:
  - trigger: time
    at: "21:59:00"
actions:
  - action: camera.snapshot
    target:
      entity_id: camera.garage
    data:
      filename: /media/tendline/garage.jpg
  - action: shell_command.tendline_garage
    data:
      file: /media/tendline/garage.jpg

Frigate: one small bridge.

A single Python file, with nothing to install, that sends each new Frigate event’s snapshot to that camera’s push URL with what Frigate saw and when.

  • Each event is sent once, as soon as Frigate has a snapshot of it, even across restarts.
  • The first start begins from now. After a restart it catches up on the last 15 minutes only.
  • At most 100 frames per camera per hour. It slows down when Tendline asks and waits when it can’t be reached.
  • Clean frames, with no boxes or timestamps drawn on them, made smaller when they’re over 1.5 MB.
  • It never logs a full push URL, only the first four characters of the code.

Frigate’s objects arrive in Tendline as a person, a car (cars, trucks, motorcycles, buses, boats), a package, an animal, or plain motion for anything else.

By default it sends people, vehicles, bicycles and packages. Add dog,cat to TENDLINE_LABELS if a routine is about a pet.

Put the bridge next to Frigate

Next to Frigate’s docker-compose.yml, make a folder called tendline. Put tendline_frigate.py in it (the full script is below), and a cameras.json that maps each Frigate camera name, exactly as it appears in Frigate’s config, to its push URL:

{
  "front_door": "https://tendline-6iz.pages.dev/api/push/PASTE-THE-REST",
  "driveway": "https://tendline-6iz.pages.dev/api/push/PASTE-THE-REST"
}

Add it to docker-compose.yml

It runs on the official python:3.12-alpine image as it is, with the script mounted in. Nothing is built or installed.

  tendline-bridge:
    image: python:3.12-alpine
    container_name: tendline-bridge
    restart: unless-stopped
    depends_on:
      - frigate
    command: ["python", "/app/tendline_frigate.py"]
    environment:
      FRIGATE_URL: http://frigate:5000
      TENDLINE_CAMERAS_FILE: /app/cameras.json
      TENDLINE_STATE_FILE: /data/state.json
    volumes:
      - ./tendline/tendline_frigate.py:/app/tendline_frigate.py:ro
      - ./tendline/cameras.json:/app/cameras.json:ro
      - ./tendline/data:/data

frigate is the name of Frigate’s service in your compose file. Port 5000 is Frigate’s internal port: other containers in the same file can reach it without publishing it, and it’s best left unpublished.

Test it, then start it

The test sends one current frame from each camera and prints what Tendline saw.

docker compose run --rm tendline-bridge python /app/tendline_frigate.py --test
docker compose up -d tendline-bridge
docker compose logs -f tendline-bridge

Without Docker, any machine with Python 3.9 or newer that can reach Frigate works: set FRIGATE_URL and TENDLINE_CAMERAS_FILE and run python3 tendline_frigate.py --test, then without --test.

If Frigate asks for a login

Frigate’s port 8971 needs a user. Create one with the viewer role for the bridge, then add:

FRIGATE_URL: https://192.168.1.20:8971
FRIGATE_USER: tendline
FRIGATE_PASSWORD: the-password-you-chose
FRIGATE_INSECURE_TLS: "1"   # only if Frigate uses its own self-signed certificate

Bridge options

Frigate bridge settings
SettingDefaultWhat it does
FRIGATE_URL--frigate-urlhttp://127.0.0.1:5000Where Frigate is.
TENDLINE_CAMERAS_FILE--cameras-filenoneA JSON file of Frigate camera names and push URLs.
TENDLINE_CAMERAS--camerasnoneThe same on one line: front_door=https://…,driveway=https://…
TENDLINE_LABELS--labelsperson, car, truck, motorcycle, bicycle, bus, packageWhich Frigate objects to send.
TENDLINE_MIN_SCORE--min-score0.7Skip objects Frigate is less sure about than this.
TENDLINE_POLL_SECONDS--poll-seconds5How often to ask Frigate for new events.
TENDLINE_MAX_PER_HOUR--max-per-hour100Frames per camera per hour, at most 120.
TENDLINE_STATE_FILE--state-filetendline_frigate_state.jsonWhere it remembers what it sent. Keep it on a volume.
--test, --onceflagsoffSend one current frame per camera; or check once, send what’s new, and exit.
The full bridge script: tendline_frigate.py
#!/usr/bin/env python3
"""Tendline bridge for Frigate (Python 3.9+, standard library only).

Polls Frigate's HTTP API for new tracked-object events that have a snapshot, downloads each snapshot and posts it
to the matching camera's Tendline push URL, with the object label and the time of the frame. Each event is sent
once, even across restarts, and the first start begins from "now": old history is never replayed.

    FRIGATE_URL=http://frigate:5000 \
    TENDLINE_CAMERAS="front_door=https://tendline-6iz.pages.dev/api/push/<token>" \
    python3 tendline_frigate.py --test

Every option is a flag or an environment variable (run with --help); integrations/README.md has the full guide.
Push URLs are secrets: this script only ever logs the first four characters of a token.
"""
from __future__ import annotations

import argparse
import collections
import http.client
import json
import logging
import os
import re
import signal
import ssl
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.cookies import SimpleCookie
from typing import Callable, Dict, NamedTuple, Optional, Tuple

__version__ = "1.0.0"

MAX_FRAME_BYTES = 1_500_000
LOOKBACK_SECONDS = 15 * 60  # Tendline only accepts event times from the last 15 minutes
SERVER_LIMIT_PER_HOUR = 120
SNAPSHOT_HEIGHTS = (None, 1080, 720, 480)
BACKOFF_BASE, BACKOFF_CAP = 5.0, 900.0
STATE_MAX_IDS, STATE_MAX_AGE = 2000, 24 * 3600
DEFAULT_LABELS = "person,car,truck,motorcycle,bicycle,bus,package"
USER_AGENT = f"tendline-frigate/{__version__}"

VEHICLES = {"car", "truck", "motorcycle", "bus", "boat"}
ANIMALS = {"dog", "cat", "bird", "horse", "sheep", "cow", "bear", "deer", "raccoon", "fox", "squirrel", "rabbit",
           "goat", "skunk"}
TOKEN_RE = re.compile(r"(/api/push/)([A-Za-z0-9_-]+)")
PUSH_PATH_RE = re.compile(r"/api/push/[A-Za-z0-9_-]{20,80}")

log = logging.getLogger("tendline_frigate")


def mask(text: object) -> str:
    """Hides all but the first four characters of every push token in `text`."""
    return TOKEN_RE.sub(lambda m: m.group(1) + m.group(2)[:4] + "****", str(text))


class _MaskTokens(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.msg, record.args = mask(record.getMessage()), None
        return True


log.addFilter(_MaskTokens())


def tendline_label(frigate_label: str) -> str:
    if frigate_label in ("person", "package"):
        return frigate_label
    if frigate_label in VEHICLES:
        return "car"
    if frigate_label in ANIMALS:
        return "animal"
    return "motion"


def _float(value: object) -> Optional[float]:
    try:
        return float(value) if value is not None and value != "" else None
    except (TypeError, ValueError):
        return None


def event_score(event: dict) -> Optional[float]:
    data = event.get("data") if isinstance(event.get("data"), dict) else {}
    for value in (data.get("top_score"), event.get("top_score"), data.get("score")):
        if _float(value) is not None:
            return _float(value)
    return None


# ---------------------------------------------------------------- configuration

class ConfigError(Exception):
    pass


def parse_camera_map(text: str) -> Dict[str, str]:
    """'front_door=https://...,driveway=https://...' (commas, spaces or newlines between pairs)."""
    cameras = {}
    for number, part in enumerate(p for p in re.split(r"[,\s]+", text.strip()) if p):
        name, sep, url = part.partition("=")
        if not sep or not name.strip() or not url.strip():
            raise ConfigError(f"camera entry {number + 1} should look like name=https://...")
        cameras[name.strip()] = url.strip()
    return cameras


def load_camera_file(path: str) -> Dict[str, str]:
    try:
        with open(path, encoding="utf-8") as f:
            raw = json.load(f)
    except (OSError, ValueError) as e:
        raise ConfigError(f"can't read the cameras file {path}: {e}") from None
    if not isinstance(raw, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in raw.items()):
        raise ConfigError(f'{path} should be a JSON object like {{"front_door": "https://..."}}')
    return dict(raw)


def check_push_url(name: str, url: str) -> None:
    parts = urllib.parse.urlsplit(url)
    local = parts.hostname in ("localhost", "127.0.0.1", "::1")
    if parts.scheme != "https" and not (parts.scheme == "http" and local):
        raise ConfigError(f"{name}: the push URL must start with https://")
    if not PUSH_PATH_RE.fullmatch(parts.path) or parts.query:
        raise ConfigError(f"{name}: that doesn't look like a Tendline push URL "
                          "(it ends in /api/push/ followed by a long code)")


class Config(NamedTuple):
    frigate_url: str
    cameras: Dict[str, str]
    labels: frozenset
    min_score: float
    poll_seconds: float
    max_per_hour: int
    state_file: str
    frigate_user: str
    frigate_password: str
    frigate_insecure: bool
    once: bool
    test: bool
    verbose: bool


def parse_config(argv: Optional[list], env: Dict[str, str]) -> Config:
    def env_or(key: str, default: str) -> str:
        return env.get(key) or default

    p = argparse.ArgumentParser(
        prog="tendline_frigate.py",
        description="Send Frigate event snapshots to Tendline push URLs.",
        epilog="Push URLs come from the Tendline app (one per camera). Treat them like passwords.",
    )
    p.add_argument("--frigate-url", default=env_or("FRIGATE_URL", "http://127.0.0.1:5000"),
                   help="Frigate address, e.g. http://frigate:5000 (env FRIGATE_URL)")
    p.add_argument("--cameras", default=env.get("TENDLINE_CAMERAS", ""),
                   help="Frigate camera name = push URL pairs, comma separated (env TENDLINE_CAMERAS)")
    p.add_argument("--cameras-file", default=env.get("TENDLINE_CAMERAS_FILE", ""),
                   help='JSON file {"front_door": "https://..."} (env TENDLINE_CAMERAS_FILE)')
    p.add_argument("--labels", default=env_or("TENDLINE_LABELS", DEFAULT_LABELS),
                   help=f"Frigate labels to send (env TENDLINE_LABELS, default {DEFAULT_LABELS})")
    p.add_argument("--min-score", type=float, default=env_or("TENDLINE_MIN_SCORE", "0.7"),
                   help="skip events whose top score is lower (env TENDLINE_MIN_SCORE, default 0.7)")
    p.add_argument("--poll-seconds", type=float, default=env_or("TENDLINE_POLL_SECONDS", "5"),
                   help="how often to ask Frigate for new events (env TENDLINE_POLL_SECONDS, default 5)")
    p.add_argument("--max-per-hour", type=int, default=env_or("TENDLINE_MAX_PER_HOUR", "100"),
                   help="frames per camera per hour, at most 120 (env TENDLINE_MAX_PER_HOUR, default 100)")
    p.add_argument("--state-file", default=env_or("TENDLINE_STATE_FILE", "tendline_frigate_state.json"),
                   help="where to remember sent events (env TENDLINE_STATE_FILE)")
    p.add_argument("--frigate-user", default=env.get("FRIGATE_USER", ""),
                   help="only for Frigate's login port 8971; the password comes from env FRIGATE_PASSWORD")
    p.add_argument("--frigate-insecure", action="store_true",
                   default=env.get("FRIGATE_INSECURE_TLS", "").lower() in ("1", "true", "yes"),
                   help="accept Frigate's self-signed certificate (env FRIGATE_INSECURE_TLS=1)")
    p.add_argument("--once", action="store_true", help="check Frigate once, send what's new, and exit")
    p.add_argument("--test", action="store_true", help="send one current frame per camera to check the setup")
    p.add_argument("-v", "--verbose", action="store_true", help="log every poll")
    a = p.parse_args(argv)

    try:
        cameras = load_camera_file(a.cameras_file) if a.cameras_file else {}
        cameras.update(parse_camera_map(a.cameras))
        if not cameras:
            raise ConfigError("no cameras: set TENDLINE_CAMERAS or TENDLINE_CAMERAS_FILE")
        for name, url in cameras.items():
            check_push_url(name, url)
        labels = frozenset(x.strip() for x in a.labels.split(",") if x.strip())
        if not labels:
            raise ConfigError("the label list is empty")
        if not 0 <= a.min_score <= 1:
            raise ConfigError("--min-score must be between 0 and 1")
        if a.poll_seconds < 1:
            raise ConfigError("--poll-seconds must be at least 1")
        if not 1 <= a.max_per_hour <= SERVER_LIMIT_PER_HOUR:
            raise ConfigError(f"--max-per-hour must be between 1 and {SERVER_LIMIT_PER_HOUR}")
        password = env.get("FRIGATE_PASSWORD", "")
        if a.frigate_user and not password:
            raise ConfigError("FRIGATE_USER is set but FRIGATE_PASSWORD is empty")
    except ConfigError as e:
        p.error(str(e))
    return Config(a.frigate_url, cameras, labels, a.min_score, a.poll_seconds, a.max_per_hour, a.state_file,
                  a.frigate_user, password, a.frigate_insecure, a.once, a.test, a.verbose)


# ---------------------------------------------------------------- Frigate

class FrigateError(Exception):
    def __init__(self, message: str, status: Optional[int] = None):
        super().__init__(message)
        self.status = status


class Frigate:
    """The three Frigate endpoints this bridge uses: /api/events, an event's snapshot.jpg and a camera's latest.jpg."""

    def __init__(self, url: str, user: str = "", password: str = "", insecure: bool = False, timeout: float = 15.0):
        base = url.rstrip("/")
        self.api = (base[:-4] if base.endswith("/api") else base) + "/api"
        self.user, self.password, self.timeout = user, password, timeout
        self.token: Optional[str] = None
        self.context = None
        if insecure:
            self.context = ssl.create_default_context()
            self.context.check_hostname = False
            self.context.verify_mode = ssl.CERT_NONE

    def _get(self, path: str, params: Optional[dict] = None, retry_login: bool = True):
        url = self.api + path + ("?" + urllib.parse.urlencode(params) if params else "")
        headers = {"User-Agent": USER_AGENT}
        if self.token:
            headers["Authorization"] = "Bearer " + self.token
        try:
            with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=self.timeout,
                                        context=self.context) as r:
                return r.read(), r.headers
        except urllib.error.HTTPError as e:
            e.close()
            if e.code == 401 and self.user and retry_login:
                self.login()
                return self._get(path, params, retry_login=False)
            if e.code == 401:
                raise FrigateError("Frigate asked for a login: use port 5000 from inside Docker, "
                                   "or set FRIGATE_USER and FRIGATE_PASSWORD", 401) from None
            raise FrigateError(f"Frigate answered {e.code} for {path}", e.code) from None
        except (urllib.error.URLError, OSError, http.client.HTTPException) as e:
            raise FrigateError(f"can't reach Frigate at {self.api} ({getattr(e, 'reason', e)})") from None

    def login(self) -> None:
        body = json.dumps({"user": self.user, "password": self.password}).encode()
        req = urllib.request.Request(self.api + "/login", data=body,
                                     headers={"Content-Type": "application/json", "User-Agent": USER_AGENT})
        try:
            with urllib.request.urlopen(req, timeout=self.timeout, context=self.context) as r:
                cookies = r.headers.get_all("Set-Cookie") or []
        except urllib.error.HTTPError as e:
            e.close()
            raise FrigateError(f"Frigate login failed ({e.code}); check FRIGATE_USER and FRIGATE_PASSWORD",
                               e.code) from None
        except (urllib.error.URLError, OSError, http.client.HTTPException) as e:
            raise FrigateError(f"can't reach Frigate at {self.api} ({getattr(e, 'reason', e)})") from None
        for header in cookies:
            jar = SimpleCookie()
            jar.load(header)
            if "frigate_token" in jar:
                self.token = jar["frigate_token"].value
                return
        raise FrigateError("Frigate's login answer had no frigate_token cookie")

    def events(self, after: float, cameras, labels, limit: int = 100) -> list:
        params = {
            "cameras": ",".join(sorted(cameras)),
            "labels": ",".join(sorted(labels)),
            "after": f"{after:.3f}",
            "has_snapshot": 1,
            "include_thumbnails": 0,
            "sort": "date_desc",
            "limit": limit,
        }
        raw, _ = self._get("/events", params)
        try:
            events = json.loads(raw)
        except ValueError:
            raise FrigateError("Frigate's /events answer wasn't JSON") from None
        if not isinstance(events, list):
            raise FrigateError("Frigate's /events answer wasn't a list")
        return [e for e in events if isinstance(e, dict)]

    def snapshot(self, event_id: str, height: Optional[int] = None) -> Tuple[bytes, Optional[float]]:
        params = {"bbox": 0, "timestamp": 0, "crop": 0}
        if height:
            params["height"] = height
        raw, headers = self._get(f"/events/{urllib.parse.quote(event_id, safe='')}/snapshot.jpg", params)
        return raw, _float(headers.get("X-Frame-Time"))

    def latest(self, camera: str, height: Optional[int] = None) -> Tuple[bytes, bool]:
        raw, headers = self._get(f"/{urllib.parse.quote(camera, safe='')}/latest.jpg",
                                 {"height": height} if height else None)
        return raw, headers.get("X-Frigate-Offline", "").lower() == "true"


class FrameTooLarge(Exception):
    pass


def fetch_within_limit(fetch: Callable[[Optional[int]], tuple]) -> tuple:
    """Calls fetch(height) at full size, then smaller heights, until the JPEG fits Tendline's 1.5 MB limit."""
    for height in SNAPSHOT_HEIGHTS:
        image, extra = fetch(height)
        if len(image) <= MAX_FRAME_BYTES:
            return image, extra
    raise FrameTooLarge("the frame is over 1.5 MB even at 480 pixels high")


# ---------------------------------------------------------------- Tendline

class PushResult(NamedTuple):
    status: int  # 0 when Tendline couldn't be reached
    data: dict
    retry_after: Optional[float] = None
    error: str = ""


def _json_dict(raw: bytes) -> dict:
    try:
        value = json.loads(raw or b"{}")
    except ValueError:
        return {}
    return value if isinstance(value, dict) else {}


def push_frame(url: str, image: bytes, label: Optional[str] = None, event_time: Optional[float] = None,
               timeout: float = 30.0) -> PushResult:
    headers = {"Content-Type": "image/jpeg", "User-Agent": USER_AGENT}
    if label:
        headers["X-Tendline-Label"] = label
    if event_time:
        headers["X-Tendline-Event-Time"] = str(int(event_time))
    req = urllib.request.Request(url, data=image, headers=headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return PushResult(r.status, _json_dict(r.read()))
    except urllib.error.HTTPError as e:
        try:
            body = e.read()
        except (OSError, http.client.HTTPException):
            body = b""
        finally:
            e.close()
        return PushResult(e.code, _json_dict(body), _float(e.headers.get("Retry-After")))
    except (urllib.error.URLError, OSError, http.client.HTTPException) as e:
        return PushResult(0, {}, None, str(getattr(e, "reason", e)))


def explain(result: PushResult) -> str:
    server = result.data.get("message") or ""
    if result.status == 0:
        return f"couldn't reach Tendline ({result.error})"
    if result.status == 404:
        return "Tendline doesn't recognize this push URL, or push cameras aren't switched on for your account"
    if result.status == 413:
        return "the frame is larger than Tendline accepts (1.5 MB)"
    if result.status == 429:
        return "too many frames this hour for this camera"
    if result.status >= 500:
        return f"Tendline had a problem (HTTP {result.status}); it will be retried"
    return f"Tendline answered {result.status}" + (f": {server}" if server else "")


def _short(text: object, limit: int = 140) -> str:
    text = " ".join(str(text or "").split())
    return text if len(text) <= limit else text[: limit - 1] + "…"


# ---------------------------------------------------------------- bridge

class Lane:
    """One Frigate camera and its push URL, with its own hourly budget and backoff."""

    def __init__(self, name: str, url: str, max_per_hour: int):
        self.name, self.url, self.max_per_hour = name, url, max_per_hour
        self.sent: collections.deque = collections.deque()
        self.blocked_until = 0.0
        self.failures = 0

    def has_room(self, now: float) -> bool:
        while self.sent and self.sent[0] <= now - 3600:
            self.sent.popleft()
        return len(self.sent) < self.max_per_hour

    def back_off(self, now: float, at_least: float = 0.0) -> float:
        self.failures += 1
        delay = max(min(BACKOFF_CAP, BACKOFF_BASE * 2 ** (self.failures - 1)), at_least)
        self.blocked_until = now + delay
        return delay


class State:
    """Sent (or permanently skipped) Frigate event ids, and the moment of the first start."""

    def __init__(self, path: str):
        self.path = path
        self.since: Optional[float] = None
        self.done: Dict[str, float] = {}

    def load(self) -> bool:
        try:
            with open(self.path, encoding="utf-8") as f:
                raw = json.load(f)
            self.since = float(raw["since"])
            self.done = {str(k): float(v) for k, v in raw.get("done", {}).items()}
            return True
        except FileNotFoundError:
            return False
        except (OSError, ValueError, KeyError, TypeError, AttributeError) as e:
            log.warning("State file %s is unreadable (%s); starting again from now.", self.path, e)
            self.since, self.done = None, {}
            return False

    def mark(self, event_id: str, now: float) -> None:
        self.done[event_id] = now

    def save(self, now: float) -> None:
        cutoff = now - STATE_MAX_AGE
        newest = sorted(((t, k) for k, t in self.done.items() if t >= cutoff), reverse=True)[:STATE_MAX_IDS]
        self.done = {k: t for t, k in newest}
        folder = os.path.dirname(os.path.abspath(self.path))
        os.makedirs(folder, exist_ok=True)
        tmp = self.path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump({"version": 1, "since": self.since, "done": self.done}, f)
        os.replace(tmp, self.path)


class Bridge:
    def __init__(self, frigate: Frigate, lanes: Dict[str, Lane], labels, min_score: float, state: State,
                 clock: Callable[[], float] = time.time):
        self.frigate, self.lanes, self.labels, self.min_score = frigate, lanes, frozenset(labels), min_score
        self.state, self.clock = state, clock
        self.stop = threading.Event()
        self._noted: Dict[str, str] = {}

    def start(self) -> None:
        now = self.clock()
        if self.state.load():
            log.info("Resuming: %d events remembered; looking back at most %d minutes.",
                     len(self.state.done), LOOKBACK_SECONDS // 60)
        else:
            self.state.since = now
            self.state.save(now)
            log.info("First start: only events from now on will be sent.")

    def poll(self) -> int:
        """Asks Frigate for recent events and sends the ones that are due. Returns how many were sent."""
        now = self.clock()
        after = max(self.state.since or now, now - LOOKBACK_SECONDS)
        events = self.frigate.events(after, self.lanes.keys(), self.labels)
        log.debug("Frigate returned %d events after %.0f.", len(events), after)
        sent = 0
        for event in sorted(events, key=lambda e: _float(e.get("start_time")) or 0.0):
            if self.stop.is_set():
                break
            if self._due(event, after) and self._send(event):
                sent += 1
        return sent

    def _due(self, event: dict, after: float) -> bool:
        event_id = event.get("id")
        if not isinstance(event_id, str) or event_id in self.state.done:
            return False
        if event.get("camera") not in self.lanes or event.get("label") not in self.labels:
            return False
        if not event.get("has_snapshot") or event.get("false_positive"):
            return False
        if (_float(event.get("start_time")) or 0.0) <= after:
            return False
        score = event_score(event)
        return score is None or score >= self.min_score

    def _note(self, event_id: str, message: str) -> None:
        """Logs why an event is waiting, once per event and reason."""
        if self._noted.get(event_id) != message:
            if len(self._noted) > 500:
                self._noted.clear()
            self._noted[event_id] = message
            log.info(message)

    def _send(self, event: dict) -> bool:
        lane, event_id, now = self.lanes[event["camera"]], event["id"], self.clock()
        if now < lane.blocked_until:
            return False
        if not lane.has_room(now):
            self._note(event_id, f"{lane.name}: holding event {event_id}; this camera has sent its "
                                 f"{lane.max_per_hour} frames for the last hour.")
            return False
        try:
            image, frame_time = fetch_within_limit(lambda h: self.frigate.snapshot(event_id, h))
        except FrameTooLarge as e:
            log.warning("%s: event %s skipped: %s.", lane.name, event_id, e)
            self.state.mark(event_id, now)
            return False
        except FrigateError as e:
            self._note(event_id, f"{lane.name}: no snapshot yet for event {event_id} ({e}); will try again.")
            return False

        label = tendline_label(event["label"])
        data = event.get("data") if isinstance(event.get("data"), dict) else {}
        when = frame_time or _float(data.get("snapshot_frame_time")) or _float(event.get("start_time")) or now
        result = push_frame(lane.url, image, label, min(when, now))
        if result.status not in (0, 404):
            lane.sent.append(now)

        if 200 <= result.status < 300:
            lane.failures = 0
            self.state.mark(event_id, now)
            self.state.save(now)
            alerts = result.data.get("alerts")
            log.info("%s: sent %s (event %s). Tendline saw: %s%s", lane.name, label, event_id,
                     _short(result.data.get("description")) or "(no description)",
                     f" [{alerts} heads-up]" if alerts else "")
            return True
        if result.status == 429:
            delay = lane.back_off(now, at_least=max(60.0, min(result.retry_after or 0.0, 3600.0)))
            log.warning("%s: %s; pausing this camera for %d s.", lane.name, explain(result), delay)
        elif result.status == 404:
            lane.blocked_until = now + 600
            log.error("%s: %s (%s). Trying again in 10 minutes.", lane.name, explain(result), mask(lane.url))
        elif result.status == 0 or result.status >= 500:
            delay = lane.back_off(now)
            log.warning("%s: event %s not delivered: %s; trying again in %d s.", lane.name, event_id,
                        explain(result), delay)
        else:  # 400, 413 and other answers that won't change on a retry
            self.state.mark(event_id, now)
            log.warning("%s: event %s skipped: %s.", lane.name, event_id, explain(result))
        return False


def run(bridge: Bridge, poll_seconds: float, once: bool = False) -> int:
    bridge.start()
    failures = 0
    while not bridge.stop.is_set():
        try:
            bridge.poll()
            failures, wait = 0, poll_seconds
        except FrigateError as e:
            failures += 1
            wait = min(300.0, poll_seconds * 2 ** failures)
            log.warning("%s; trying again in %d s.", e, wait)
        if once:
            break
        bridge.stop.wait(wait)
    bridge.state.save(bridge.clock())
    log.info("Stopped.")
    return 1 if once and failures else 0


def run_test(frigate: Frigate, lanes: Dict[str, Lane], clock: Callable[[], float] = time.time) -> int:
    """Sends one current frame per camera (Frigate's latest.jpg) and reports what Tendline answered."""
    failed = 0
    for lane in lanes.values():
        try:
            image, offline = fetch_within_limit(lambda h: frigate.latest(lane.name, h))
        except (FrigateError, FrameTooLarge) as e:
            log.error("%s: couldn't get a frame from Frigate: %s", lane.name, e)
            failed += 1
            continue
        if offline:
            log.warning("%s: Frigate says this camera is offline, so this is its last saved frame.", lane.name)
        result = push_frame(lane.url, image, None, clock())
        if 200 <= result.status < 300:
            log.info("%s: OK. Tendline saw: %s", lane.name,
                     _short(result.data.get("description")) or "(no description)")
        else:
            failed += 1
            log.error("%s: %s.", lane.name, explain(result))
    log.info("Test finished: %d of %d cameras OK.", len(lanes) - failed, len(lanes))
    return 1 if failed else 0


def main(argv: Optional[list] = None, env: Optional[Dict[str, str]] = None) -> int:
    cfg = parse_config(argv, dict(os.environ) if env is None else env)
    if not log.handlers:
        handler = logging.StreamHandler()
        handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
        log.addHandler(handler)
        log.propagate = False
    log.setLevel(logging.DEBUG if cfg.verbose else logging.INFO)

    frigate = Frigate(cfg.frigate_url, cfg.frigate_user, cfg.frigate_password, cfg.frigate_insecure)
    lanes = {name: Lane(name, url, cfg.max_per_hour) for name, url in cfg.cameras.items()}
    log.info("Tendline bridge %s: Frigate %s; cameras %s; labels %s; min score %.2f.", __version__, frigate.api,
             ", ".join(sorted(lanes)), ",".join(sorted(cfg.labels)), cfg.min_score)
    if cfg.test:
        return run_test(frigate, lanes)

    bridge = Bridge(frigate, lanes, cfg.labels, cfg.min_score, State(cfg.state_file))
    if threading.current_thread() is threading.main_thread():
        for sig in (signal.SIGTERM, signal.SIGINT):
            signal.signal(sig, lambda *_: bridge.stop.set())
    return run(bridge, cfg.poll_seconds, cfg.once)


if __name__ == "__main__":
    sys.exit(main())

What Tendline receives.

Only what you send, and only what it needs to check a routine.

  • A still frame, a label and a time. What the camera’s own detector saw (person, car, package, animal or motion) and when. No video clips.
  • No audio. Nothing here sends sound, and Tendline never asks for it.
  • No identity. The bridge doesn’t send Frigate’s sub labels, recognized names or plate readings. Tendline never identifies people or reads plates.
  • Your camera password stays with you. Home Assistant and Frigate talk to your cameras; Tendline only gets the frames they send.
  • Heads-ups, not an alarm. Tendline tells you what did or didn’t happen. It isn’t an alarm or an emergency service.

If something isn’t working.

“Doesn’t recognize this push URL” (answer 404)

The URL is mistyped or cut short, the camera was removed in Tendline, or push cameras aren’t switched on for your account yet. Copy the URL again, or remove the camera and add it again for a new one. The bridge tries that camera again every 10 minutes.

“Too many frames this hour” (answer 429)

Each camera can send up to 120 frames an hour. In Home Assistant, raise the cooldown, or use a person sensor instead of a general motion sensor. The bridge holds frames back on its own and waits when Tendline asks it to; to send fewer, narrow TENDLINE_LABELS, raise TENDLINE_MIN_SCORE, or set required_zones under snapshots for that camera in Frigate.

The frame is too large (answer 413)

Frames must be under 1.5 MB. In Home Assistant, choose a lower-resolution camera entity; many cameras offer a “sub” or “fluent” stream. The bridge already asks Frigate for a smaller frame when it needs to.

Nothing happens in Home Assistant

Open the automation’s Traces. If it didn’t run, check that the sensor really goes from off to on. If it stopped early, the camera may have been unavailable; those frames are skipped.

“Action shell_command.tendline_… not found” means the push command name doesn’t match configuration.yaml, or Home Assistant hasn’t restarted since you added shell_command. An error about allowlist_external_dirs means the snapshot folder isn’t allowed: keep the default, /media/tendline.

Lines starting “Tendline:” in Settings > System > Logs say what Tendline answered.

Nothing happens with Frigate

Run the bridge with --test first. If that works, the setup is right.

Camera names in cameras.json must match Frigate’s exactly. Snapshots must be on in Frigate for those cameras, because events without a snapshot are skipped. The object type must be in TENDLINE_LABELS, and Frigate’s score must reach TENDLINE_MIN_SCORE. On the very first start, only new events are sent.

“Can’t reach Frigate” means FRIGATE_URL is wrong. Inside Docker Compose, use Frigate’s service name, http://frigate:5000, not localhost.

Before you share a log

The bridge only ever logs the first four characters of a push code. Home Assistant is different: if curl itself fails, for example while your internet connection is down, Home Assistant writes the whole command to its log, push URL included. Remove that line before you share a Home Assistant log.

Get early access

Push cameras come with early access. Leave your email, tick Other under cameras, and we’ll write when you can connect Home Assistant or Frigate. We won’t use your address for anything else.

The free plan will stay free. Joining the list costs nothing and commits you to nothing.

What would you use it for?
Which cameras?

If the button doesn’t respond, email admin1@sovnode.ai with “early list” in the subject and we’ll add you.