# Architecture How `bsky-avatar` works, the decisions behind it, and how to change it. For setup and commands see [README](README.md); for deployment see [`deploy/`](deploy/README.md). ## Overview One Node script runs on a schedule. Each run computes a small **state object**, renders it by stacking transparent PNG layers over a fixed portrait, and pushes the result to the Bluesky profile. Runs are idempotent: the state is hashed and the upload is skipped when nothing changed, so a missed or extra run does no harm. ``` date + location + weather -> state -> composite layers -> upload to Bluesky (resolve) (render) (push) ``` ## State model The avatar is fully described by one object: ``` { bg, weather, season, holiday, ring, halo } ``` Fields resolve **independently** (they are not one mutually-exclusive choice). A flag background and a holiday prop can both be active at once, for example. | Field | Meaning | Values | |-------|---------|--------| | `bg` | background fill | `''` (cream/home), `flag-`, `flag-pride`, `flag-eu` | | `weather` | weather overlay | `weather-clear/cloudy/rain/snow/fog/thunderstorm` (or `''`) | | `season` | seasonal accent | `season-spring/summer/autumn/winter` (home only) | | `holiday` | holiday prop | `holiday-*` (or `''`) | | `ring` | recolorable frame | a hex, or `none` | | `halo` | white silhouette under the base | bool (abroad only) | | `palette` | full-avatar skin | `''` (default), `green`, or `rainbow` | A `palette` skin (e.g. `green`) renders the normal composite, then re-screens the whole canvas as a two-tone **halftone** print: each opaque pixel becomes one of two colours by its luminance, via ImageMagick's `h8x8a` clustered-dot map (baked into `compositor.js` so it needs no ImageMagick at runtime). Weather, season, holiday, and ring all fold into the screen. It is set only via an override. `rainbow` is the same screen with the duotone pair swapped per vertical band: six equal columns on the pride hues, each pair derived from green's own saturation and lightness (and green's 52° highlight-to-shadow hue offset), so tone is identical across the bands and only the colour travels. ## Decision engine (`src/state.js`, `src/dates.js`) Each field is resolved with a **manual override winning first**, then computed: | Field | Source (high to low priority) | |-------|-------------------------------| | `bg` | override -> country flag from phone location -> date background (Pride/EU) -> none | | `weather` | override -> Open-Meteo for the phone's coordinates | | `season` | override -> from the date (dropped while a `bg` flag is up) | | `holiday` | override -> the date (see Date logic) | | `ring` | override -> daily colour rotation at home, neutral while abroad | | `halo` | on only when a `bg` flag is showing | Setting a country `bg` (travel) automatically drops the season, neutralises the ring, turns on the halo, and switches weather to its light variant. Those rules fall out of `bg` being set; they are not configured per holiday. ## Layers and compositor (`src/compositor.js`) All layers are authored on a shared **1000x1000** grid with identical registration, so compositing is `drawImage(layer, 0, 0)` in a fixed z-order with no transforms. The routine is ported from the design's `studio.html` (the source of truth for layout). Bluesky masks the avatar to a circle. Z-order, bottom to top: background (flag or cream) -> background weather (clear/cloudy/storm/rain) -> season -> behind-base holidays -> halo -> base portrait -> foreground weather (snow/fog) -> ring -> on-top holidays. Special cases: - **Behind-base holidays** (`BEHIND_BASE_HOLIDAY`): `holiday-newyear`, `holiday-wintermode`, `holiday-earthday` render *behind* the portrait so the face stays clear. Everything else renders on top. - **Ring** is a white mask tinted to any hex via `source-in`. - **April Fools** has no layer; it rotates the whole composite 180 degrees. - Output is PNG, capped under Bluesky's 1 MB blob limit (JPEG fallback if needed). Layers are resolved **by filename**: any `state` field is turned straight into `assets/layers/.png`, and a missing file is skipped rather than failing the run (mirroring studio's `onerror`). There is no registry — dropping a PNG in and naming it from config is all it takes to add a layer. The only places a layer name is hard-coded are the special cases above. ### Layer generators (`tools/`) Some layers are drawn programmatically rather than by hand, one Python/Pillow script per layer, each writing its own PNG: | Script | Layer | |--------|-------| | `gen-flag-es-ct.py` | `flag-es-ct` | | `gen-holiday-summermode.py` | `holiday-summermode` | | `gen-holiday-eclipse.py` | `holiday-eclipse` | Run as `python3 tools/gen-.py [dst.png]`; with no argument each writes to its own path under `assets/layers/`. They are deterministic — re-running reproduces the committed PNG byte for byte — so the script, not the PNG, is the source of truth, and the geometry reasoning lives in its docstring. The generated PNG is committed as well so a normal run needs no Python. ### One-off event layers A layer tied to a single event (an eclipse, a specific trip) is driven from `config/overrides.yaml`, not `config/holidays.yaml` — `holiday-eclipse` and `holiday-summermode` both work this way. The dates are the only thing that would have to change per occurrence, and putting them in the gitignored overrides file keeps *when and where* out of a public repo while the artwork stays in it. See `config/overrides.example.yaml` for the shape. ## Date logic (`src/dates.js`) **Season bands.** `seasonFor(month)` uses **meteorological** seasons (Northern Hemisphere) — whole calendar months, no astronomical solstice/equinox dates: | Layer | Months | Inclusive dates | |-------|--------|-----------------| | `season-spring` | 3–5 | Mar 1 – May 31 | | `season-summer` | 6–8 | Jun 1 – Aug 31 | | `season-autumn` | 9–11 | Sep 1 – Nov 30 | | `season-winter` | 12, 1–2 | Dec 1 – Feb 28/29 | The month comes from `todayParts(settings.timezone)`, so a season flips at local midnight in the configured timezone, **not** UTC and not the traveller's local time. Unlike holidays there is no lead/trail window — the change is a hard cut at that midnight. A `season:` override wins over the band, and any country `bg` drops the season entirely (`src/state.js`). Holidays come from four mechanisms in `config/holidays.yaml`: | Mechanism | For | Example | |-----------|-----|---------| | `fixed` (MM-DD) | dates that never move | Sinterklaas `12-05`, Valentine's `02-14`, Europe Day `05-09` | | `dated` (YYYY-MM-DD) | dates with no formula; verified and extended by hand | Diwali (lunar) | | `computed` (rule) | dates with a formula | Easter (computus), King's Day (Apr 27 / 26-if-Sunday), Amsterdam Pride (1st Saturday of August) | | `ranges` (MM-DD..MM-DD) | multi-day windows | December festive week | A `fixed`/`dated` value is either a layer name (head prop) or `{ bg: flag-x }` (background) or `{ holiday: ..., bg: ... }`. **Holiday window.** Holidays are not bound to a calendar day (midnight to midnight). Each shows from `holidayLeadHour` the evening before its first day to `holidayTrailHour` the morning after its last day (defaults 22:00 and 04:00, ~30h for a single-day holiday), so an internationally spread audience catches it. On/off is bound to the configured timezone (`settings.timezone`), **not** the traveller's location. Adjacent years are checked so New Year's pre-midnight window resolves. When the `dated` table runs out (no entry for the current year), the app logs a warning so the per-year dates (Diwali) get extended. ## Location and weather (`src/location.js`, `src/weather.js`) Coordinates resolve from the first source that answers, and everything downstream — flag *and* weather — follows whatever they say: | # | Source | Where from | Fails when | |---|--------|-----------|------------| | 1 | phone push | `state/location.json`, written by `src/sink.js` when the phone POSTs to it over the always-on WireGuard tunnel | the phone hasn't reported within `pushMaxAgeHours` | | 2 | Home Assistant | the person entity in `settings.yaml`, read over the LAN | HA or the home LAN is down | | 3 | cache | `state/location-cache.json`, the last position either live source produced | older than `locationCacheMaxAgeHours` | | 4 | home | `homeLat` / `homeLon` / `homeCountry` | never | Source 1 exists because source 2 has a single point of failure that sits *at home*: when the HA box went down, the avatar spent weeks quietly reporting home weather with no flag, from home coordinates, while its owner was not there. The phone can always reach the NAS over WireGuard, so it pushes rather than being polled. Source 3 exists so a dead source degrades slowly instead of teleporting you home mid-trip. Coordinates are **rounded** to `coordPrecision` decimals (~1 km at 2) before they are written to disk or leave the LAN. They are reverse-geocoded to a country via OpenStreetMap Nominatim; if it isn't the home country, that country's flag fills the background (EU members without a dedicated flag fall back to the EU flag). Weather comes from Open-Meteo for those same coordinates. Every external call **fails soft**: Home Assistant, Nominatim, or Open-Meteo being unreachable steps down the chain instead of crashing. Whichever source won is printed in the `[ctx]` log line of every run, so a silent degradation is visible in `docker logs`. ### Degradation alerts (`src/health.js`) Falling back to home coordinates is indistinguishable, in the rendered avatar, from actually being home — which is why the original outage went unnoticed for weeks. So the resolver's verdict is monitored: `alertAfterBlindRuns` consecutive runs resolving to `home-fallback` sends one Signal message, and the recovery sends one more. State lives in `state/location-health.json`; `cache:*` sources neither alert nor reset the counter, since a cached position is stale but not a lie. A failing alert is logged and swallowed — it must never break a run. ### The sink (`src/sink.js`) A dependency-free HTTP endpoint, run as a second container from the same image. It speaks OwnTracks' HTTP mode (`{_type: "location", lat, lon, tst}`) and also accepts a plain `{lat, lon}` body, so a Shortcut, a Tasker task, or `curl` work too. Auth is a single shared secret (`LOC_SINK_TOKEN`) accepted as a bearer token, a `?token=` query parameter, or the password half of HTTP Basic — the last because OwnTracks only offers a username/password field. It is bound to the LAN and reachable from the phone only through WireGuard; it is never exposed publicly. ## Configuration | File | Holds | In the repo? | |------|-------|--------------| | `config/settings.yaml` | timezone, home country/coords, HA entity, birthday, ring/window/location tuning | No (personal) | | `config/holidays.yaml` | the holiday calendar | No (personal) | | `config/overrides.yaml` | manual periods ("on holiday") | No (personal) | | `.env` | Bluesky app password, HA token, location-sink token | No (secret) | | `config/*.example.yaml`, `.env.example` | templates for the above | Yes | Personal config and secrets live only on the host and are gitignored; the repo ships example templates. Travel dates in particular must never reach a public repo. ## Output targets After rendering, the image is pushed to one or more targets, each independently and fail-soft, so one being down never blocks the other: - **Bluesky** (always): `uploadBlob` then `putRecord` on `app.bsky.actor.profile`, preserving every other profile field. Tracks the full state hash. - **Signal** (optional): `PUT /v1/profiles/{number}` (raw-base64 avatar) on a `signal-cli-rest-api` sidecar running in json-rpc mode (Signal has no official API; the sidecar must be linked to the account once via QR). In `events` mode Signal tracks only `bg`+`holiday`, so the daily weather/ring churn doesn't push there; in `all` mode it mirrors every change. **Per-target idempotency:** `state/last.json` stores the last applied key per target. A target pushes only when its own key changed, so a target that failed or was offline last run retries on its own without re-pushing the others. ## Scheduling and deployment Runs as a single detached, self-scheduling container (`deploy/schedule.sh`): 04:00, 09:00, 14:00, 18:00, 22:00 (configured timezone). The 22:00 and 04:00 runs exist to flip the holiday-window edges; the middle three keep weather fresh. The container bind-mounts `src/`, `assets/`, `config/`, `state/`, `deploy/`, so **code, layer art, and config changes go live by syncing the file and restarting (or the next run) with no image rebuild**. A rebuild is only needed when dependencies in `package.json` change. `.env` changes need a container recreate (rm + run), because `--env-file` is read only at create time. **Update workflow.** Code changes (`src/`, `assets/`, `Dockerfile`, `deploy/`, `*.example.yaml`) go to both the repo and the host. Personal config (`settings/holidays/overrides.yaml`, `.env`) stays host-only. ## Key decisions | Decision | Rationale | |----------|-----------| | **App password, not OAuth** | A scoped, deliberate exception for one low-stakes personal tool. The secret stays in `.env`, never committed. OAuth is used everywhere else. | | **`@napi-rs/canvas`, not `node-canvas`** | Prebuilt N-API binaries: works on current Node with no cairo/build toolchain, and a far smaller container. | | **Compositor ported from `studio.html`** | The design ships a working reference renderer; porting it verbatim keeps z-order bugs out. | | **Holiday window bound to a fixed timezone** | Predictable on/off for an international audience, and it doesn't jump around as the phone travels. | | **Pride as single days, not all of June** | A `bg` flag suppresses ambient (season/ring/weather) for its whole window. A month of that kills the daily variation; single symbolic days don't. | | **`fixed` vs `dated` vs `computed`** | Compute what has a formula (set-and-forget), table what doesn't (Diwali), with a warning when the table expires. | | **Personal config out of the repo** | Privacy: travel dates and home coordinates don't belong in a public repo. | | **Idempotent, fail-soft** | Extra runs are free, missed runs self-heal, and no upstream outage can break the profile. | ## Stack Node 20+, `@napi-rs/canvas` (compositing), `@atproto/api` (Bluesky), `js-yaml` (config), Docker. No database; state is a single `state/last.json` hash. ## Adding things - **A new layer:** drop the 1000x1000 transparent PNG into `assets/layers/`. If it's a full-canvas backdrop that would cover the face, add it to `BEHIND_BASE_HOLIDAY` in `src/compositor.js`; otherwise it renders on top. - **A new holiday:** add it to `config/holidays.yaml` under `fixed`, `dated`, `computed`, or `ranges` depending on how its date behaves. - **A new flag:** add `flag-.png` and, if needed, the country code to the flag set in `src/state.js`.