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:
- The phone pushes them. It POSTs its position to
src/sink.json the NAS over the always-on WireGuard tunnel. Nothing at home needs to be up, so this keeps working while I'm away. - Home Assistant. A person entity read over the LAN, when HA is up.
- The last known position, cached for
locationCacheMaxAgeHours. - 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.