Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ jobs:
fetch-depth: 0 # semantic-release needs full history + tags
- uses: cycjimmy/semantic-release-action@v4
id: semantic
with:
# The app's pinned version has to be bumped in the release commit:
# the app store reads addon/config.yaml from main, and a version with
# no matching published image tag makes the app uninstallable.
# changelog writes addon/CHANGELOG.md, exec rewrites the two pinned
# versions, git commits them (with [skip ci], so the push back to main
# does not re-trigger this workflow).
extra_plugins: |
@semantic-release/changelog
@semantic-release/exec
@semantic-release/git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down Expand Up @@ -79,3 +90,41 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

publish-addon-image:
# The Home Assistant app is a thin wrapper around the image above, so it
# can only be built once that image exists at this exact version.
needs: [release, publish-image]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
# The triggering SHA, i.e. before semantic-release's version-bump commit.
# Fine: the wrapper's version comes from the job output below, and neither
# config.yaml nor build.yaml is baked into the image.
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: addon
platforms: linux/amd64,linux/arm64
push: true
# Pinned to the release just published, never `latest`: an app
# updated by the Supervisor must get the application code it claims to.
build-args: |
BUILD_FROM=ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}
tags: |
ghcr.io/${{ github.repository }}-addon:${{ needs.release.outputs.version }}
ghcr.io/${{ github.repository }}-addon:latest
cache-from: type=gha
cache-to: type=gha,mode=max
19 changes: 19 additions & 0 deletions .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{
"changelogFile": "addon/CHANGELOG.md"
}
],
[
"@semantic-release/exec",
{
"prepareCmd": "./addon/bump-version.sh ${nextRelease.version}"
}
],
[
"@semantic-release/git",
{
"assets": ["addon/config.yaml", "addon/build.yaml", "addon/CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}
49 changes: 32 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -62,25 +62,40 @@ RUN cd /opt/saezuri/server && node -e "require('@napi-rs/canvas')"
# Static bundle.
COPY --from=build /app/dist /usr/share/nginx/html

# Short, typeable mount paths for the two persistent stores. The real directories
# stay under the html root, where nginx already serves them and where the refresh
# service already writes, so a volume mounted the old way keeps working
# untouched; these symlinks only spare operators a 45-character -v target.
# Docker resolves symlinks in a mount destination, so a volume mounted on
# /data/illustrations lands on the real directory.
RUN mkdir -p /usr/share/nginx/html/assets/illustrations \
/usr/share/nginx/html/assets/calls \
/data \
&& ln -s /usr/share/nginx/html/assets/illustrations /data/illustrations \
&& ln -s /usr/share/nginx/html/assets/calls /data/calls
# /data is the real persistence root for the two stores; the html root reaches
# them through symlinks. This direction, not the reverse, because the Home
# Assistant Supervisor mounts its persistent volume at /data (alongside the
# options.json it writes there) — symlinks under /data would be mounted over, and
# downloaded art would silently land in the ephemeral container layer instead.
# Docker resolves symlinks in a mount destination, so a volume mounted the long
# way (/usr/share/nginx/html/assets/illustrations) still lands on /data: both
# spellings keep working, and an existing deployment keeps its data because that
# data lives in the volume, not at a path.
#
# The bundled art the build just shipped (the generic fallback silhouette) has to
# move out of the way first — `ln -s` onto an existing directory silently nests
# the link inside it. It cannot simply move *into* /data either: under the
# Supervisor /data is a bind mount, which masks image content rather than seeding
# it the way a named volume does. So it is parked here and re-seeded at start by
# nginx/entrypoint.sh. `rm -rf` for calls, which never has bundled content.
RUN mkdir -p /opt/saezuri/bundled \
&& mv /usr/share/nginx/html/assets/illustrations /opt/saezuri/bundled/illustrations \
&& rm -rf /usr/share/nginx/html/assets/calls \
&& mkdir -p /data/illustrations /data/calls \
&& ln -s /data/illustrations /usr/share/nginx/html/assets/illustrations \
&& ln -s /data/calls /usr/share/nginx/html/assets/calls

# Config template (installed at start) + entrypoint hooks. The template lives
# OUTSIDE /etc/nginx/templates so the image's built-in envsubst step doesn't
# clobber nginx's own $variables — our hook just copies it verbatim now that
# nothing is substituted. 40 installs the static-serving config; 50 launches the
# refresh service (fetches per-species art + dictionaries, publishes the
# snapshot). Both run before nginx, in that order.
# Config template (installed at start) + the location blocks it includes +
# entrypoint hooks. The template lives OUTSIDE /etc/nginx/templates so the
# image's built-in envsubst step doesn't clobber nginx's own $variables — our
# hook just copies it verbatim now that nothing is substituted. The locations
# file is included from a server block, so it ships unconditionally: the Home
# Assistant app wrapper (addon/) adds a second server block that includes the
# same file. 40 installs the static-serving config; 50 launches the refresh service
# (fetches per-species art + dictionaries, publishes the snapshot). Both run
# before nginx, in that order.
COPY nginx/default.conf.template /etc/nginx/saezuri.conf.template
COPY nginx/saezuri-locations.conf /etc/nginx/saezuri-locations.conf
COPY nginx/entrypoint.sh /docker-entrypoint.d/40-saezuri.sh
COPY nginx/generator.sh /docker-entrypoint.d/50-generator.sh
RUN chmod +x /docker-entrypoint.d/40-saezuri.sh \
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,10 @@ well as an x86 host. The two volumes keep the illustrations
and reference recordings it collects, so replacing the container doesn't start it over —
both sections below explain what lands in them.

`/data/illustrations` and `/data/calls` are symlinks to where the files actually live,
`/usr/share/nginx/html/assets/illustrations` and `.../assets/calls`. Docker resolves a symlinked
mount destination, so both spellings mount the same directory: the short one is just less to
type, and an existing deployment mounted on the long path keeps working unchanged.
`/data/illustrations` and `/data/calls` are where the files actually live;
`/usr/share/nginx/html/assets/illustrations` and `.../assets/calls` are symlinks to them. Docker
resolves a symlinked mount destination, so both spellings mount the same directory: the short one
is just less to type, and an existing deployment mounted on the long path keeps working unchanged.

## On-demand generation (optional)

Expand Down Expand Up @@ -280,6 +280,15 @@ cp .env.example .env # set BIRDNETGO_URL (and BIRDNETGO_TOKEN if
docker compose up --build
```

## Home Assistant

Saezuri also ships as a Home Assistant app, what Home Assistant called an add-on until
recently: add `https://github.com/vrwrts/saezuri` as a repository and it installs from
the app store, appears in the sidebar through ingress, and finds a BirdNET-Go app on the
same machine by itself. Everything Home Assistant specific lives in [`addon/`](addon/),
named for the Supervisor's own `/addons` layout; the page users read inside Home Assistant
is [`addon/DOCS.md`](addon/DOCS.md).

## Landing page

A static one-pager (Astro) lives in [`site/`](site/) and shares the app's design tokens
Expand Down
13 changes: 13 additions & 0 deletions addon/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Changelog

## 0.13.0

First release of the Home Assistant app. Wraps the existing Saezuri image with
ingress, so the collage appears in the sidebar, and with the Supervisor's
configuration and persistence.

- BirdNET-Go is detected automatically when it runs as an app on the same
machine, so **BirdNET-Go URL** can usually be left empty.
- Illustrations and cached reference recordings persist in `/data`, so they
survive an app update.
- Port 80 is available but off by default, for an e-ink panel fetching `/24h.png`.
155 changes: 155 additions & 0 deletions addon/DOCS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Saezuri

A live bird collage for [BirdNET-Go](https://github.com/tphakala/birdnet-go), in the
kachō-e woodblock style of
[AvianVisitors](https://github.com/Twarner491/AvianVisitors). BirdNET-Go does the
listening; Saezuri shows what it heard, drawing the birds you hear most the largest.

Saezuri is read-only. It never writes to BirdNET-Go, and the browser only ever
talks to Saezuri itself.

## Installation

1. In Home Assistant, go to **Settings** → **Apps** → **App store**, open the
three-dot menu and choose **Repositories**.
2. Add `https://github.com/vrwrts/saezuri`.
3. Find **Saezuri** in the store and click **Install**.
4. Start it. It appears in the sidebar as **Saezuri**.

If BirdNET-Go runs as an app on the same machine you can start Saezuri without
configuring anything. Otherwise set **BirdNET-Go URL** first.

## Finding BirdNET-Go

Leave **BirdNET-Go URL** empty and Saezuri looks for a BirdNET-Go app on the
Supervisor network at startup. It tries these hostnames on port 8080, in order, and
confirms each hit really is BirdNET-Go before using it:

| Hostname | Where that app came from |
| --- | --- |
| `db21ed7f-birdnet-go` | the [alexbelgium add-ons](https://github.com/alexbelgium/hassio-addons) repository |
| `local-birdnet-go` | a copy you built yourself under `/addons` |
| `a0d7b954-birdnet-go` | the [Home Assistant Community Add-ons](https://github.com/hassio-addons/repository) repository |

The app log says which one it picked. If several respond, the first in that order
wins and the others are logged so you can see what was skipped.

**If nothing is found**, the app stops with a message saying so. Set
**BirdNET-Go URL** to your instance, for example `http://192.168.1.10:8080`. That
also covers a BirdNET-Go that is not an app at all, running in Docker or on
another machine.

**If it picks the wrong instance**, set **BirdNET-Go URL** explicitly. A configured
URL always wins and is never second-guessed.

**If your BirdNET-Go app has an unusual slug**, put its hostname in **Extra hostnames to
probe** rather than waiting on a code change. Entries there are tried first.

**If the log says authentication is required**, your BirdNET-Go runs in PrivateMode.
Detection can find the instance but cannot discover a token, so set **BirdNET-Go
token** as well.

## Configuration

### Connection

| Option | What it does |
| --- | --- |
| **BirdNET-Go URL** | Base URL of your instance, for example `http://192.168.1.10:8080`. Leave empty for the detection above. |
| **BirdNET-Go token** | Bearer token. Only needed when BirdNET-Go runs in PrivateMode. |
| **Extra hostnames to probe** | Comma-separated hostnames tried before the built-in guesses during detection. |

### Illustrations

The moment BirdNET-Go reports a species, Saezuri downloads its ready-made cutout
from the [saezuri-illustrations](https://github.com/vrwrts/saezuri-illustrations)
repo. This is on by default and needs no key. Species with no contributed art get a
generic silhouette, still labelled and still sized by their real count.

| Option | Default | What it does |
| --- | --- | --- |
| **Illustrations repository** | `vrwrts/saezuri-illustrations` | Where cutouts are downloaded from. Empty turns downloading off. |
| **Illustrations branch** | `main` | Branch or tag to download from. |
| **Illustrations base URL** | derived | Overrides the two above with a direct URL. |
| **Gemini API key** | unset | Optional. Set it to *also* generate art, in the same style, for species nobody has contributed yet. |
| **Generated illustrations per cycle** | `4` | How many to generate at a time. |
| **Pause between generations** | `6` | Seconds between generated illustrations. |

Generation costs money at Google's rates and is entirely optional. Everything works
without a key.

### Reference recordings

When a species is heard, Saezuri looks up a freely-licensed recording of its call and
caches it, so selecting a bird offers a play button.

| Option | Default | What it does |
| --- | --- | --- |
| **Recording archives** | `commons` | Comma-separated archives to search. Empty turns recordings off. |
| **Recordings per cycle** | `4` | How many to look up at a time. |

### E-ink frames

Saezuri renders the same collage to a flat PNG per time window, for an e-ink panel.

| Option | Default | What it does |
| --- | --- | --- |
| **E-ink frame width** | `800` | Width in pixels. 700 or less switches to portrait packing. |
| **E-ink frame height** | `480` | Height in pixels. |
| **E-ink frame background** | `#fcfcfb` | Background, as a six-digit hex colour. |
| **E-ink frame shadows** | on | Soft shadows under the birds. |
| **E-ink frames to render** | all five | Comma-separated, from `1h,12h,24h,7d,all`. |

See *Using an e-ink panel* below for how to reach them.

### Display languages

| Option | Default | What it does |
| --- | --- | --- |
| **Display languages** | all 16 | Comma-separated languages to publish species names for. The browser picks the closest match to its own language. |

Available: `cs,da,de,en,es,fi,fr,hu,it,lv,nb,nl,pl,pt,sk,sv`.

### Refresh cadence

Rarely worth touching.

| Option | Default | What it does |
| --- | --- | --- |
| **Publish debounce** | `20000` ms | How long to wait after a detection before republishing, so a burst becomes one update. |
| **Ageing interval** | `120000` ms | How often detections are dropped out of their time window. |
| **Summary interval** | `1800000` ms | How often to recount everything from BirdNET-Go. |

## Using an e-ink panel

An e-ink panel fetches a rendered frame such as `/24h.png` directly. Ingress cannot
serve it, because ingress requires Home Assistant authentication and a panel has no
way to log in. So open the direct port instead:

1. Open the app's **Configuration** tab and switch to **Network**.
2. Give **80/tcp** a host port, for example `8090`.
3. Restart the app.

Your panel then fetches `http://<home-assistant-host>:8090/24h.png`. That port serves
the whole collage with no authentication, so only open it on a network you trust.

## Storage

Downloaded illustrations, cached recordings and the working cache live in the app's
`/data`, which the Supervisor keeps across restarts and updates. They are included in
a Home Assistant backup, so a large illustration set makes for larger backups.

## Licensing

The illustrations and the tooling that makes them inherit **CC-BY-NC-SA-4.0** from the
BirdNET-Pi lineage, so this app and the art it downloads are **for non-commercial
use only**. Personal use in your own home is fine. Publishing the images, or a
repository derived from them, carries obligations worth reading first: see
[Credits and licensing](https://github.com/vrwrts/saezuri#credits-and-licensing).

Cached reference recordings each carry their own CC licence and are always shown with
their recordist credited.

## Support

Issues and questions: <https://github.com/vrwrts/saezuri/issues>.
21 changes: 21 additions & 0 deletions addon/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# A thin wrapper, not a rebuild: the application image is already published
# multi-arch, so this layer only adds the Supervisor's calling convention
# (/data/options.json -> environment variables) on top of it.
ARG BUILD_FROM=ghcr.io/vrwrts/saezuri:latest
FROM ${BUILD_FROM}

# jq reads options.json. curl (not busybox wget) because the BirdNET-Go probe has
# to tell an authentication failure apart from an unreachable host, and busybox
# wget cannot report an HTTP status code.
RUN apk add --no-cache jq curl

COPY nginx/ingress.conf /opt/saezuri/addon/ingress.conf
COPY run.sh /run.sh
RUN chmod +x /run.sh

# The base image inherits nginx's ENTRYPOINT ["/docker-entrypoint.sh"]. Left in
# place, CMD below would become an *argument* to it: the /docker-entrypoint.d
# hooks would run first and 40-saezuri.sh would abort on the still-unset
# BIRDNETGO_URL. run.sh calls that entrypoint itself, once the options are read.
ENTRYPOINT []
CMD ["/run.sh"]
10 changes: 10 additions & 0 deletions addon/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Only used when the Supervisor builds the app locally (a copy under /addons);
# an install from the repository pulls `image:` from config.yaml instead. Both
# entries name the same tag on purpose: the application image is a multi-arch
# manifest, so the platform resolves per architecture. `aarch64` is Home
# Assistant's spelling; there is no `arm64` here.
# Kept in step with config.yaml's version by semantic-release, so a wrapper is
# never built against a different release than it claims to be.
build_from:
amd64: ghcr.io/vrwrts/saezuri:0.13.0
aarch64: ghcr.io/vrwrts/saezuri:0.13.0
18 changes: 18 additions & 0 deletions addon/bump-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
# Rewrites the app's pinned version in place. Run by semantic-release (see
# .releaserc.json) so the bump lands in the release commit: the app store reads
# config.yaml from the default branch, and a version with no matching published
# image tag makes the app uninstallable, so this can never be a manual step.
set -eu

VERSION="${1:?usage: bump-version.sh X.Y.Z}"
DIR="$(dirname "$0")"

sed -i.bak -E "s/^version: .*/version: ${VERSION}/" "$DIR/config.yaml"
# The wrapper must be built against the application image of the same release.
sed -i.bak -E "s|(ghcr\.io/vrwrts/saezuri):[^ ]*|\1:${VERSION}|" "$DIR/build.yaml"
rm -f "$DIR/config.yaml.bak" "$DIR/build.yaml.bak"

grep -q "^version: ${VERSION}$" "$DIR/config.yaml"
grep -c "saezuri:${VERSION}$" "$DIR/build.yaml" | grep -qx 2
echo "addon pinned to ${VERSION}"
Loading
Loading