diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 419ed8c..8e3a58a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} @@ -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 diff --git a/.releaserc.json b/.releaserc.json index fea7fae..9d4cce1 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -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" ] } diff --git a/Dockerfile b/Dockerfile index e806a33..616c17c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 3ea0167..d529011 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/addon/CHANGELOG.md b/addon/CHANGELOG.md new file mode 100644 index 0000000..1195f58 --- /dev/null +++ b/addon/CHANGELOG.md @@ -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`. diff --git a/addon/DOCS.md b/addon/DOCS.md new file mode 100644 index 0000000..de609c6 --- /dev/null +++ b/addon/DOCS.md @@ -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://: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: . diff --git a/addon/Dockerfile b/addon/Dockerfile new file mode 100644 index 0000000..740683f --- /dev/null +++ b/addon/Dockerfile @@ -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"] diff --git a/addon/build.yaml b/addon/build.yaml new file mode 100644 index 0000000..cbb6878 --- /dev/null +++ b/addon/build.yaml @@ -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 diff --git a/addon/bump-version.sh b/addon/bump-version.sh new file mode 100755 index 0000000..4bda500 --- /dev/null +++ b/addon/bump-version.sh @@ -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}" diff --git a/addon/config.yaml b/addon/config.yaml new file mode 100644 index 0000000..04ce19c --- /dev/null +++ b/addon/config.yaml @@ -0,0 +1,55 @@ +name: Saezuri +slug: saezuri +version: 0.13.0 +description: Live bird collage for BirdNET-Go, in the AvianVisitors kachō-e style +url: https://github.com/vrwrts/saezuri +image: ghcr.io/vrwrts/saezuri-addon +arch: + - amd64 + - aarch64 +startup: services +boot: auto +ingress: true +ingress_port: 8099 +panel_icon: mdi:bird +panel_title: Saezuri +# Off by default; ingress covers the collage itself. An e-ink panel fetching +# /24h.png cannot go through ingress, which requires Home Assistant +# authentication, so that one case needs the port opened by hand. +ports: + "80/tcp": null +ports_description: + "80/tcp": Direct web access, only needed for an e-ink panel fetching /24h.png +options: + generate_max_per_cycle: 4 + calls_max_per_cycle: 4 + frame_width: 800 + frame_height: 480 + frame_bg: "#fcfcfb" + frame_shadow: true + publish_debounce_ms: 20000 + aging_interval_ms: 120000 + summary_interval_ms: 1800000 +schema: + # Optional: left empty, Saezuri probes the Supervisor network for a + # BirdNET-Go app at startup. See DOCS.md. + birdnetgo_url: url? + birdnetgo_token: password? + birdnetgo_extra_hosts: str? + illustrations_repo: str? + illustrations_ref: str? + illustrations_base_url: str? + gemini_api_key: password? + generate_max_per_cycle: int(1,64) + generate_sleep: int(0,600) + call_providers: str? + calls_max_per_cycle: int(1,64) + frame_width: int(100,4000) + frame_height: int(100,4000) + frame_bg: match(^#[0-9a-fA-F]{6}$) + frame_shadow: bool + frame_windows: str? + species_dict_locales: str? + publish_debounce_ms: int(1000,600000) + aging_interval_ms: int(10000,3600000) + summary_interval_ms: int(60000,86400000) diff --git a/addon/icon.png b/addon/icon.png new file mode 100644 index 0000000..4bbd7f7 Binary files /dev/null and b/addon/icon.png differ diff --git a/addon/logo.png b/addon/logo.png new file mode 100644 index 0000000..4620299 Binary files /dev/null and b/addon/logo.png differ diff --git a/addon/nginx/ingress.conf b/addon/nginx/ingress.conf new file mode 100644 index 0000000..5e89725 --- /dev/null +++ b/addon/nginx/ingress.conf @@ -0,0 +1,32 @@ +# Home Assistant ingress listener, installed into /etc/nginx/conf.d/ by run.sh. +# The Supervisor strips its /api/hassio_ingress/ prefix before proxying, +# so requests arrive root-anchored and this block can include the very same +# locations as the standalone port-80 listener. The prefix still matters to the +# browser, which has to build its own URLs, so it is injected into the document. +server { + listen 8099; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Ingress is the Supervisor's own reverse proxy and the only thing that may + # reach this port. The app has no authentication of its own by design. + allow 172.30.32.0/23; + deny all; + + # X-Ingress-Path is set by Home Assistant Core (the Supervisor forwards it + # through) and carries the per-session prefix with no trailing slash. Empty + # for a request that did not come through ingress, which leaves the app + # root-served — the same behaviour as port 80. + sub_filter_once on; + sub_filter '' + ''; + + # sub_filter cannot rewrite a compressed body, so no gzip here. And the + # injected token is per-session: a cached index.html would hand a later + # session a dead prefix, hence no-store on the document. + gzip off; + add_header Cache-Control "no-store" always; + + include /etc/nginx/saezuri-locations.conf; +} diff --git a/addon/run.sh b/addon/run.sh new file mode 100755 index 0000000..c1bd535 --- /dev/null +++ b/addon/run.sh @@ -0,0 +1,191 @@ +#!/bin/sh +# Home Assistant app entrypoint. Translates the Supervisor's options.json into +# the environment variables the application already understands, then hands off to +# the image's own entrypoint. Nothing downstream of here knows it is running under +# Home Assistant. +set -eu + +OPTIONS=/data/options.json + +# Hostnames probed for a BirdNET-Go app when birdnetgo_url is left empty, in +# the order they are tried. The Supervisor names an app -, +# where the prefix is `local` for a locally built app and the first 8 hex chars +# of sha1(repository-url) for one from a store. db21ed7f is +# github.com/alexbelgium/hassio-addons, which is where the BirdNET-Go app +# actually lives; a0d7b954 is the Home Assistant Community Add-ons repository, +# should it land there too. No `core-` entry: BirdNET-Go is not a built-in app. +# Users with an unusual slug extend this through the birdnetgo_extra_hosts option +# rather than needing a code change. +BIRDNETGO_HOSTS="db21ed7f-birdnet-go local-birdnet-go a0d7b954-birdnet-go" +BIRDNETGO_PROBE_PORT=8080 +# Short enough that a missing neighbour costs no noticeable startup time. +BIRDNETGO_PROBE_TIMEOUT=2 + +log() { echo "saezuri-addon: $*"; } + +# Absent or null means "not configured", and the application's own default must +# apply — so those are never exported. An empty string is a different answer: +# ILLUSTRATIONS_REPO and CALL_PROVIDERS read it as "off". +opt_present() { + jq -e --arg k "$1" 'has($k) and .[$k] != null' "$OPTIONS" >/dev/null 2>&1 +} + +opt_value() { + jq -r --arg k "$1" '.[$k] | tostring' "$OPTIONS" +} + +export_opt() { + if opt_present "$2"; then + export "$1=$(opt_value "$2")" + fi +} + +# FRAME_SHADOW is read as "anything but 0", so a YAML bool has to become 1 or 0; +# exporting the string "false" would silently enable the shadow. +export_bool_opt() { + if opt_present "$2"; then + if [ "$(opt_value "$2")" = "true" ]; then + export "$1=1" + else + export "$1=0" + fi + fi +} + +# --- Configuration ----------------------------------------------------------- + +export_opt BIRDNETGO_URL birdnetgo_url +export_opt BIRDNETGO_TOKEN birdnetgo_token +export_opt ILLUSTRATIONS_REPO illustrations_repo +export_opt ILLUSTRATIONS_REF illustrations_ref +export_opt ILLUSTRATIONS_BASE_URL illustrations_base_url +export_opt GEMINI_API_KEY gemini_api_key +export_opt GENERATE_MAX_PER_CYCLE generate_max_per_cycle +export_opt GENERATE_SLEEP generate_sleep +export_opt CALL_PROVIDERS call_providers +export_opt CALLS_MAX_PER_CYCLE calls_max_per_cycle +export_opt FRAME_WIDTH frame_width +export_opt FRAME_HEIGHT frame_height +export_opt FRAME_BG frame_bg +export_bool_opt FRAME_SHADOW frame_shadow +export_opt FRAME_WINDOWS frame_windows +export_opt SPECIES_DICT_LOCALES species_dict_locales +export_opt PUBLISH_DEBOUNCE_MS publish_debounce_ms +export_opt AGING_INTERVAL_MS aging_interval_ms +export_opt SUMMARY_INTERVAL_MS summary_interval_ms + +# Not options: the standalone defaults are wrong here. /data is the Supervisor's +# persistent volume, so the cache belongs there rather than in the container layer +# that an app update throws away. The html root is where nginx serves from. +export FRAME_HTML_DIR=/usr/share/nginx/html +export CACHE_DIR=/data/cache + +# The image already points the html root's asset directories at /data, so mounting +# the persistent volume is the whole of it: no symlink surgery, no moving files. +mkdir -p /data/illustrations /data/calls /data/cache + +# --- BirdNET-Go detection ---------------------------------------------------- + +# Confirms a candidate is really BirdNET-Go rather than just something with an +# open socket. /api/v2/app/config stays public even when BirdNET-Go runs in +# PrivateMode, and reports whether a token will be needed; /api/v2/health is the +# fallback for builds predating it, where a 401 is itself proof of an instance. +# Echoes "ok" or "auth" on a hit, nothing at all otherwise. +probe_birdnetgo() { + _base="http://$1:${BIRDNETGO_PROBE_PORT}" + _body=$(mktemp) + + _code=$(curl -s -o "$_body" -w '%{http_code}' \ + --max-time "$BIRDNETGO_PROBE_TIMEOUT" \ + -H 'Accept: application/json' \ + "$_base/api/v2/app/config" 2>/dev/null || echo 000) + if [ "$_code" = "200" ] && \ + jq -e 'has("csrfToken") and has("projectLinks")' "$_body" >/dev/null 2>&1; then + if jq -e '.security.privateMode == true' "$_body" >/dev/null 2>&1; then + echo auth + else + echo ok + fi + rm -f "$_body" + return 0 + fi + + _code=$(curl -s -o "$_body" -w '%{http_code}' \ + --max-time "$BIRDNETGO_PROBE_TIMEOUT" \ + -H 'Accept: application/json' \ + "$_base/api/v2/health" 2>/dev/null || echo 000) + if [ "$_code" = "200" ] && \ + jq -e 'has("status") and has("database_status")' "$_body" >/dev/null 2>&1; then + echo ok + elif [ "$_code" = "401" ]; then + echo auth + fi + rm -f "$_body" +} + +detect_birdnetgo() { + _candidates="$BIRDNETGO_HOSTS" + if opt_present birdnetgo_extra_hosts; then + # Tried first, so a hand-configured hostname beats the built-in guesses. + _candidates="$(opt_value birdnetgo_extra_hosts | tr ',' ' ') $_candidates" + fi + + _chosen="" + for _host in $_candidates; do + [ -n "$_host" ] || continue + _result=$(probe_birdnetgo "$_host") + [ -n "$_result" ] || continue + + if [ -z "$_chosen" ]; then + _chosen="$_host" + log "detected BirdNET-Go at $_host:${BIRDNETGO_PROBE_PORT}" + if [ "$_result" = "auth" ]; then + log "that instance requires authentication; set the birdnetgo_token option" + fi + else + # Logged rather than silently discarded, so a user with two instances + # can see which one was picked and override it. + log "also responding: $_host:${BIRDNETGO_PROBE_PORT} (not used)" + fi + done + + [ -n "$_chosen" ] || return 1 + export "BIRDNETGO_URL=http://$_chosen:${BIRDNETGO_PROBE_PORT}" +} + +# An explicitly configured URL always wins and is never second-guessed. +if [ -z "${BIRDNETGO_URL:-}" ]; then + log "no birdnetgo_url configured; looking for a BirdNET-Go app" + if ! detect_birdnetgo; then + log "no BirdNET-Go app found on the Supervisor network." + log "Set the birdnetgo_url option to your instance, for example" + log " http://192.168.1.10:8080" + log "If your BirdNET-Go app has an unusual slug, add its hostname to" + log "birdnetgo_extra_hosts instead." + exit 1 + fi +fi + +# --- Startup ----------------------------------------------------------------- + +redacted() { [ -n "${1:-}" ] && echo '' || echo ''; } + +log "BIRDNETGO_URL=${BIRDNETGO_URL}" +log "BIRDNETGO_TOKEN=$(redacted "${BIRDNETGO_TOKEN:-}")" +log "GEMINI_API_KEY=$(redacted "${GEMINI_API_KEY:-}")" +for _name in ILLUSTRATIONS_REPO ILLUSTRATIONS_REF ILLUSTRATIONS_BASE_URL \ + GENERATE_MAX_PER_CYCLE GENERATE_SLEEP CALL_PROVIDERS CALLS_MAX_PER_CYCLE \ + FRAME_WIDTH FRAME_HEIGHT FRAME_BG FRAME_SHADOW FRAME_WINDOWS \ + SPECIES_DICT_LOCALES PUBLISH_DEBOUNCE_MS AGING_INTERVAL_MS \ + SUMMARY_INTERVAL_MS FRAME_HTML_DIR CACHE_DIR; do + eval "_set=\${$_name+set}" + [ "${_set:-}" = set ] || continue + eval "_value=\$$_name" + log "$_name=$_value" +done + +cp /opt/saezuri/addon/ingress.conf /etc/nginx/conf.d/ingress.conf + +# The image's own entrypoint runs its /docker-entrypoint.d hooks (install the +# port-80 config, launch the refresh service) with the environment now populated. +exec /docker-entrypoint.sh nginx -g "daemon off;" diff --git a/addon/translations/en.yaml b/addon/translations/en.yaml new file mode 100644 index 0000000..3658a75 --- /dev/null +++ b/addon/translations/en.yaml @@ -0,0 +1,84 @@ +configuration: + birdnetgo_url: + name: BirdNET-Go URL + description: >- + Base URL of your BirdNET-Go instance, for example http://192.168.1.10:8080. + Leave empty to detect a BirdNET-Go app running on this machine. + birdnetgo_token: + name: BirdNET-Go token + description: >- + Bearer token, only needed when BirdNET-Go runs in PrivateMode. + birdnetgo_extra_hosts: + name: Extra hostnames to probe + description: >- + Comma-separated hostnames to try before the built-in guesses when + detecting BirdNET-Go. Only needed for a BirdNET-Go app with an unusual slug. + illustrations_repo: + name: Illustrations repository + description: >- + GitHub repository the ready-made cutouts are downloaded from. Empty + disables downloading. + illustrations_ref: + name: Illustrations branch + description: Branch or tag to download illustrations from. + illustrations_base_url: + name: Illustrations base URL + description: >- + Overrides the repository and branch above with a URL to download cutouts + from directly. + gemini_api_key: + name: Gemini API key + description: >- + Optional. Set it to also generate art for species nobody has contributed + an illustration for yet. + generate_max_per_cycle: + name: Generated illustrations per cycle + description: How many illustrations to generate at a time. + generate_sleep: + name: Pause between generations + description: Seconds to wait between generated illustrations. + call_providers: + name: Recording archives + description: >- + Comma-separated archives to look up reference recordings in. Empty + disables reference recordings. + calls_max_per_cycle: + name: Recordings per cycle + description: How many reference recordings to look up at a time. + frame_width: + name: E-ink frame width + description: Width in pixels of the rendered e-ink frames. + frame_height: + name: E-ink frame height + description: Height in pixels of the rendered e-ink frames. + frame_bg: + name: E-ink frame background + description: Background colour of the rendered frames, as a hex colour. + frame_shadow: + name: E-ink frame shadows + description: Draw soft shadows under the birds in the rendered frames. + frame_windows: + name: E-ink frames to render + description: >- + Comma-separated time windows to render frames for, from 1h, 12h, 24h, 7d + and all. + species_dict_locales: + name: Display languages + description: >- + Comma-separated languages to publish species-name dictionaries for. The + browser picks the closest match to its own language. + publish_debounce_ms: + name: Publish debounce + description: >- + Milliseconds to wait after a detection before republishing, so a burst + becomes one update. + aging_interval_ms: + name: Ageing interval + description: >- + Milliseconds between recomputes that drop detections out of their time + window. + summary_interval_ms: + name: Summary interval + description: Milliseconds between full recounts from BirdNET-Go. +network: + "80/tcp": Direct web access, only needed for an e-ink panel fetching /24h.png diff --git a/index.html b/index.html index 51ccfc8..4a3b50d 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + Saezuri