Dynamic avatar rotation
JavaScript 77%
Python 21%
Shell 1%
Dockerfile <1%

README.md

bsky-avatar #

My Bluesky avatar updates itself. It changes with the weather where my phone is, the season, the date, and wherever I'm travelling, and I can pin a custom one by hand for any stretch of time (a holiday, a heads-down week).

It stacks hand-drawn transparent layers over a fixed portrait of me (the avatar-system design) and pushes the result to gui.do on Bluesky, and optionally mirrors it to Signal. It runs on a schedule a few times a day, and every run is idempotent, so a missed run or an extra one does no harm.

Design, internals, and the decisions behind it: ARCHITECTURE.md.

How it decides #

Each run builds a small state object, { bg, weather, season, holiday, ring, halo }, and renders it in a fixed z-order (ported straight from the design's studio.html). Each field resolves on its own, with a manual override winning first:

Field Source
bg manual override, else the country flag where my phone is, else a date background (e.g. Pride), else none
weather Open-Meteo current weather for the phone's coordinates, mapped to one of 6 layers
season the date — meteorological seasons by whole month (spring Mar–May, summer Jun–Aug, autumn Sep–Nov, winter Dec–Feb), dropped while a flag is up
holiday the date (config/holidays.yaml: Sinterklaas, King's Day, NYE, birthday, …)
ring daily colour rotation at home, neutral while abroad
halo on only when a flag background is showing

Where my phone is #

Both the flag and the weather follow one set of coordinates, and those resolve from the first source that answers:

  1. The phone pushes them. It POSTs its position to src/sink.js on the NAS over the always-on WireGuard tunnel. Nothing at home needs to be up, so this keeps working while I'm away.
  2. Home Assistant. A person entity read over the LAN, when HA is up.
  3. The last known position, cached for locationCacheMaxAgeHours.
  4. Home, as the final fallback.

Coordinates are rounded to about a kilometre before they're written to disk or leave the LAN, then reverse-geocoded to a country through OpenStreetMap Nominatim. If I'm not home, that country's flag fills the background.

Every external call fails soft: if a source is down it steps to the next one rather than crashing, and the source that won is printed in each run's [ctx] line. That last part matters: a dead HA box once failed quietly all the way back to home coordinates, so the avatar showed home weather and no flag for weeks while I was abroad, and nothing said so.

Logs only help if someone reads them, so after alertAfterBlindRuns consecutive runs with no position at all — phone silent, HA unreachable, cache expired — it sends itself a Signal message, and one more when location comes back. One message per outage, not one per run. A cached position doesn't trigger it: that is degraded but still true.

Feeding the sink #

OwnTracks in HTTP mode is the easy option: point it at http://<nas-ip>:8477/, put LOC_SINK_TOKEN in the password field, and it reports on its own. Anything that can POST JSON works too:

curl -X POST http://<nas-ip>:8477/ \
  -H "Authorization: Bearer $LOC_SINK_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"lat": 41.39, "lon": 2.16}'

Configure #

The repo ships example configs. Copy each one and edit your own. The real files are gitignored and live only on the NAS, because they hold personal things like travel dates that don't belong in a public repo.

  • config/settings.example.yaml → settings.yaml: timezone, home country/coords, HA entity, birthday, abroad ring, location freshness windows.
  • config/holidays.example.yaml → holidays.yaml: date → holiday layer, plus ranges (festive week, Pride month).
  • config/overrides.example.yaml → overrides.yaml: manual periods, either a partial state or a full pre-made image.
  • .env.example → .env: secrets (Bluesky app password, HA token, location-sink token).

Layer and flag names have to match the files in assets/layers/ (without .png).

Run #

npm install
cp .env.example .env                                   # fill in BSKY_APP_PASSWORD + HASS_TOKEN
cp config/settings.example.yaml config/settings.yaml   # same for holidays + overrides

npm run dry                 # compute + render to ./out.png, no upload
node src/index.js --state '{"bg":"flag-gr","weather":"weather-rain","ring":"none","halo":true}' --dry-run  # preview any combo
node src/index.js           # real run, uploads only if the state changed
node src/index.js --force   # upload even if nothing changed

Schedule #

It runs as a single detached, self-scheduling container (Docker): updates at 04:00, 09:00, 14:00, 18:00 and 22:00 (Amsterdam time), restarts on boot, and fails soft. Holidays show over a window from 22:00 the evening before to 04:00 the morning after (so they land across time zones); the 22:00 and 04:00 runs flip those edges. A sample container setup is in deploy/.

Why an app password #

This logs in with a Bluesky app password, a deliberate exception for a low-stakes personal toy (I use OAuth everywhere else). The secret stays in .env on the NAS and never gets committed.