diff --git a/.env.example b/.env.example index 4ac6f07..0641147 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,11 @@ BSKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx HASS_HOST=http://homeassistant.local:8123 HASS_TOKEN= +# Location sink (src/sink.js) — the shared secret the phone authenticates with. +# Required to start the sink; generate one with: openssl rand -hex 32 +LOC_SINK_TOKEN= +# LOC_SINK_PORT=8477 + # Optional: override the atproto service endpoint (defaults to https://bsky.social) # BSKY_SERVICE=https://bsky.social diff --git a/.gitignore b/.gitignore index 0968216..23f2cb9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ node_modules/ config/settings.yaml config/holidays.yaml config/overrides.yaml -state/last.json +state/*.json out.png previews/ *.log diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ef6448f..1629bfe 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -99,25 +99,53 @@ warning so the per-year dates (Diwali) get extended. ## Location and weather (`src/location.js`, `src/weather.js`) -Coordinates come from a Home Assistant person entity (set in `settings.yaml`), -**rounded** to ~1 km before they leave the LAN. They are reverse-geocoded to a +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 coordinates. +EU flag). Weather comes from Open-Meteo for those same coordinates. Every external call **fails soft**: Home Assistant, Nominatim, or Open-Meteo -being unreachable falls back to home coordinates or the last sensible value -instead of crashing. The phone tracker must be GPS-based (a Companion app) for -coordinates to be real when away from home. +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`. + +### 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 tuning | No (personal) | +| `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 | No (secret) | +| `.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 diff --git a/README.md b/README.md index 33d2a33..b924d4a 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,38 @@ first: ## Where my phone is -Coordinates come from a Home Assistant person entity, rounded to about a -kilometre before they 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 HA, Nominatim, or Open-Meteo is -down, it falls back to home coordinates or the last sensible value instead of -crashing. +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. + +### Feeding the sink + +[OwnTracks](https://owntracks.org/) in HTTP mode is the easy option: point it at +`http://:8477/`, put `LOC_SINK_TOKEN` in the password field, and it +reports on its own. Anything that can POST JSON works too: + +```bash +curl -X POST http://:8477/ \ + -H "Authorization: Bearer $LOC_SINK_TOKEN" \ + -H 'content-type: application/json' \ + -d '{"lat": 41.39, "lon": 2.16}' +``` ## Configure @@ -42,10 +68,10 @@ 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. +- `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). +- `.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`). diff --git a/config/settings.example.yaml b/config/settings.example.yaml index 86d6576..5c7dfd4 100644 --- a/config/settings.example.yaml +++ b/config/settings.example.yaml @@ -14,9 +14,21 @@ homeCountry: nl homeLat: 52.37 homeLon: 4.90 -# Home Assistant entity whose lat/lon follow your phone. +# Home Assistant entity whose lat/lon follow your phone. Used as the second +# location source, after any position the phone has pushed to src/sink.js. locationEntity: person.me +# How long a phone-pushed position (state/location.json) stays authoritative. +# Older than this and the resolver moves on to Home Assistant. +pushMaxAgeHours: 12 + +# How long to keep using the last known position when every live source fails. +# Without this a dead source silently teleports you home mid-trip. +locationCacheMaxAgeHours: 36 + +# Port the location sink (src/sink.js) listens on. LOC_SINK_PORT overrides it. +sinkPort: 8477 + # Round coordinates to this many decimals before sending them off-LAN # (to Open-Meteo / Nominatim). 2 ≈ ~1 km — enough for weather, not your address. coordPrecision: 2 diff --git a/deploy/README.md b/deploy/README.md index 70cdeca..e9146da 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,7 +1,7 @@ # Deploy (Synology NAS) The avatar updater runs as a single **detached, self-scheduling container** on -the NAS: it updates the avatar at 06:30 / 14:00 / 20:00 (Europe/Amsterdam) via +the NAS: it updates the avatar at 04:00 / 09:00 / 14:00 / 18:00 / 22:00 via `schedule.sh`, restarts on boot, and fails soft so a bad run never matters. Files live at `/volume1/docker/bsky-avatar/` on the NAS. @@ -28,6 +28,41 @@ sudo docker run -d --name bsky-avatar --restart unless-stopped \ (`deploy/` is also baked into the image via the Dockerfile; the bind-mount lets you tweak `schedule.sh` without a rebuild and keeps older images working.) +## The location sink + +A second container from the same image, so the phone can push its position over +WireGuard even when Home Assistant (or the whole home LAN) is down. + +```bash +# token the phone authenticates with — put the same value in .env +openssl rand -hex 32 + +sudo docker run -d --name bsky-avatar-sink --restart unless-stopped \ + --env-file /volume1/docker/bsky-avatar/.env \ + -p 8477:8477 \ + -v /volume1/docker/bsky-avatar/config:/app/config \ + -v /volume1/docker/bsky-avatar/state:/app/state \ + --entrypoint node bsky-avatar /app/src/sink.js +``` + +It shares the `state/` volume with the updater: the sink writes +`state/location.json`, the updater reads it on its next run. + +Check it, from the NAS and then from the phone over WireGuard: + +```bash +curl -s http://localhost:8477/healthz # -> ok +curl -s "http://:8477/location?token=$LOC_SINK_TOKEN" # last position +``` + +**Keep it on the LAN.** WireGuard already carries the phone onto the home subnet, +so the sink needs no port forward, no tunnel, and no public DNS. Do not expose it. + +On the phone, [OwnTracks](https://owntracks.org/) in HTTP mode is the least +work: set the URL to `http://:8477/`, leave the username as anything, +and put the token in the password field (the sink accepts it as HTTP Basic). +Anything that can POST `{"lat": .., "lon": ..}` with a bearer token works too. + ## After editing config `config/*.yaml` (holidays, overrides, settings) is bind-mounted and re-read on @@ -46,18 +81,21 @@ sudo docker rm -f bsky-avatar ## Updating the code -Re-sync the repo to `/volume1/docker/bsky-avatar/`, then: +Re-sync the repo to `/volume1/docker/bsky-avatar/`, then rebuild and recreate +**both** containers (the code lives inside the image; only `config/` and +`state/` are bind-mounted): ```bash sudo docker build -t bsky-avatar /volume1/docker/bsky-avatar -sudo docker rm -f bsky-avatar -# re-run the `docker run -d ...` line above +sudo docker rm -f bsky-avatar bsky-avatar-sink +# re-run the two `docker run -d ...` lines above ``` ## Useful ```bash sudo docker logs --tail 50 bsky-avatar # see recent runs + next-run countdown +sudo docker logs --tail 20 bsky-avatar-sink # see positions the phone has pushed sudo docker exec bsky-avatar node src/index.js --dry-run # render without uploading sudo docker exec bsky-avatar node src/index.js --force # force an immediate upload ``` diff --git a/src/config.js b/src/config.js index d142e0c..f51ae4f 100644 --- a/src/config.js +++ b/src/config.js @@ -29,6 +29,9 @@ export function loadConfig() { birthday: '', holidayLeadHour: 22, // holidays switch on this hour the evening before (Amsterdam time) holidayTrailHour: 4, // and off this hour the morning after the last day + pushMaxAgeHours: 12, // how long a phone-pushed position stays authoritative + locationCacheMaxAgeHours: 36, // how long to keep using the last known position + sinkPort: 8477, // port src/sink.js listens on (LOC_SINK_PORT overrides) ...settings, }, holidays: { diff --git a/src/location.js b/src/location.js index 101f057..05fb06f 100644 --- a/src/location.js +++ b/src/location.js @@ -1,15 +1,52 @@ -// Where is the phone? Read coords from Home Assistant, reverse-geocode to a -// country. Every step fails soft — a null country means "treat as home". +// Where is the phone? Resolved from the first source that answers, most direct +// first, then a cached last-known position before finally assuming home. Every +// step fails soft — a null country means "treat as home", so no flag appears. +// +// 1. phone push — coords the phone POSTs to src/sink.js over WireGuard, +// read from state/location.json. Works from anywhere and +// needs nothing on the home LAN to be up. +// 2. home assistant — the person entity, read over the LAN. Only works while +// HA itself is up. +// 3. cache — the last position either source produced, within a TTL, +// so a dead source doesn't silently teleport you home. +// 4. home — the configured home coords/country. +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { ROOT } from './config.js'; + +export const SINK_FILE = join(ROOT, 'state', 'location.json'); +export const CACHE_FILE = join(ROOT, 'state', 'location-cache.json'); const round = (n, dp) => Number(n.toFixed(dp)); +const hoursSince = (iso) => (Date.now() - new Date(iso).getTime()) / 3_600_000; + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +// Coords last POSTed by the phone, if they're recent enough to trust. +function pushedCoords(maxAgeHours, log) { + const data = readJson(SINK_FILE); + if (!data || typeof data.lat !== 'number' || typeof data.lon !== 'number') return null; + const age = hoursSince(data.ts); + if (!Number.isFinite(age)) return null; + if (age > maxAgeHours) { + log(`[location] phone push is ${age.toFixed(1)}h old (max ${maxAgeHours}h) — ignoring`); + return null; + } + return { lat: data.lat, lon: data.lon, ageHours: age }; +} // Read lat/lon from a HA entity (person.* or device_tracker.*). async function haCoords({ hassHost, hassToken, entity }, timeoutMs = 8000) { if (!hassHost || !hassToken) return null; - const ctrl = AbortSignal.timeout(timeoutMs); const res = await fetch(`${hassHost}/api/states/${entity}`, { headers: { Authorization: `Bearer ${hassToken}` }, - signal: ctrl, + signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) throw new Error(`HA ${res.status}`); const { attributes } = await res.json(); @@ -34,25 +71,84 @@ async function reverseCountry({ lat, lon }, timeoutMs = 8000) { return cc ? cc.toLowerCase() : null; } -// Returns { lat, lon, country, source }. Falls back to home coords/country. +export function writeCache(entry) { + try { + mkdirSync(dirname(CACHE_FILE), { recursive: true }); + writeFileSync(CACHE_FILE, JSON.stringify(entry, null, 2)); + } catch { + // The cache is an optimisation; failing to write it is never fatal. + } +} + +function readCache(maxAgeHours, log) { + const c = readJson(CACHE_FILE); + if (!c || typeof c.lat !== 'number' || typeof c.lon !== 'number') return null; + const age = hoursSince(c.ts); + if (!Number.isFinite(age)) return null; + if (age > maxAgeHours) { + log(`[location] cached position is ${age.toFixed(1)}h old (max ${maxAgeHours}h) — falling back home`); + return null; + } + return { ...c, ageHours: age }; +} + +// Returns { lat, lon, country, source }. Never throws. export async function resolveLocation(cfg, log = console.error) { const { settings, env } = cfg; const dp = settings.coordPrecision; + let coords = null; - let source = 'home-fallback'; - try { - coords = await haCoords({ - hassHost: env.hassHost, - hassToken: env.hassToken, - entity: settings.locationEntity, - }); - if (coords) source = 'home-assistant'; - } catch (e) { - log(`[location] HA unreachable: ${e.message}`); + let source = null; + + // 1. Phone push (WireGuard -> sink on the NAS). Independent of the home LAN. + const pushed = pushedCoords(settings.pushMaxAgeHours, log); + if (pushed) { + coords = { lat: pushed.lat, lon: pushed.lon }; + source = `phone-push(${pushed.ageHours.toFixed(1)}h)`; + } + + // 2. Home Assistant. + if (!coords) { + try { + const ha = await haCoords({ + hassHost: env.hassHost, + hassToken: env.hassToken, + entity: settings.locationEntity, + }); + if (ha) { + coords = ha; + source = 'home-assistant'; + } + } catch (e) { + log(`[location] HA unreachable: ${e.message}`); + } } + + // 3. Cached last-known position — already geocoded, so reuse it as-is. if (!coords) { - coords = { lat: settings.homeLat, lon: settings.homeLon }; + const cached = readCache(settings.locationCacheMaxAgeHours, log); + if (cached) { + log(`[location] using cached position from ${cached.source} (${cached.ageHours.toFixed(1)}h old)`); + return { + lat: cached.lat, + lon: cached.lon, + country: cached.country || settings.homeCountry, + source: `cache:${cached.source}`, + }; + } } + + // 4. Home. + if (!coords) { + log('[location] no live or cached position — assuming home'); + return { + lat: round(settings.homeLat, dp), + lon: round(settings.homeLon, dp), + country: settings.homeCountry, + source: 'home-fallback', + }; + } + const safe = { lat: round(coords.lat, dp), lon: round(coords.lon, dp) }; let country = settings.homeCountry; @@ -60,7 +156,18 @@ export async function resolveLocation(cfg, log = console.error) { const cc = await reverseCountry(safe); if (cc) country = cc; } catch (e) { - log(`[location] reverse-geocode failed: ${e.message} — assuming home`); + const cached = readCache(settings.locationCacheMaxAgeHours, () => {}); + if (cached?.country) { + country = cached.country; + log(`[location] reverse-geocode failed: ${e.message} — reusing cached country ${country}`); + } else { + log(`[location] reverse-geocode failed: ${e.message} — assuming home country`); + } } - return { ...safe, country, source }; + + const resolved = { ...safe, country, source }; + // Cache under the bare source name — the "(3.2h)" freshness marker in `source` + // describes this run, and would be misleading once read back later. + writeCache({ ...resolved, source: source.replace(/\(.*\)$/, ''), ts: new Date().toISOString() }); + return resolved; } diff --git a/src/sink.js b/src/sink.js new file mode 100644 index 0000000..99940c5 --- /dev/null +++ b/src/sink.js @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Location sink: a tiny HTTP endpoint the phone POSTs its coordinates to. +// +// Why this exists: reading the phone's position from Home Assistant only works +// while HA is up and on the LAN. When HA is down (or the LAN is), the avatar +// silently falls back to home coords, so travelling shows neither the right +// flag nor the right weather. The phone can always reach the NAS over the +// always-on WireGuard tunnel, so let it push instead. +// +// Speaks OwnTracks' HTTP mode (the payload it posts is `{_type: "location", +// lat, lon, tst}`) and also accepts a plain `{lat, lon}` body, so a Shortcut, +// Tasker task, or curl works just as well. +// +// Coordinates are rounded to `coordPrecision` decimals (~1 km at 2) before +// they're written to disk: enough for weather and country, not your address. +// +// POST / -> record a position (auth required) +// GET /healthz -> "ok" (no auth), for uptime checks +// GET /location -> the currently stored position (auth required) +import { createServer } from 'node:http'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { loadConfig, ROOT } from './config.js'; +import { SINK_FILE } from './location.js'; + +loadDotEnv(); +const cfg = loadConfig(); +const token = process.env.LOC_SINK_TOKEN || ''; +const port = Number(process.env.LOC_SINK_PORT || cfg.settings.sinkPort || 8477); +const dp = cfg.settings.coordPrecision; + +if (!token) { + console.error('[sink] refusing to start: LOC_SINK_TOKEN is not set'); + process.exit(1); +} + +const round = (n) => Number(n.toFixed(dp)); + +// Bearer token, `?token=`, or HTTP Basic (OwnTracks only offers user/password). +// Returns { ok, scheme, presented } so a rejection can say what was tried — +// a silent 401 is very hard to debug from a phone. +function authorised(req, url) { + const header = req.headers.authorization || ''; + if (header.startsWith('Bearer ')) { + const presented = header.slice(7); + return { ok: safeEqual(presented, token), scheme: 'bearer', presented }; + } + if (header.startsWith('Basic ')) { + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); + const presented = decoded.slice(decoded.indexOf(':') + 1); + return { ok: safeEqual(presented, token), scheme: 'basic', presented }; + } + const q = url.searchParams.get('token'); + if (q) return { ok: safeEqual(q, token), scheme: 'query', presented: q }; + return { ok: false, scheme: 'none', presented: '' }; +} + +// Describe a rejected credential without printing it: length plus a short +// prefix is enough to spot a truncated paste, a stray space, or a blank field. +function describe({ scheme, presented }) { + if (scheme === 'none') return 'no credential sent (no Authorization header, no ?token=)'; + const shape = presented ? `${presented.length} chars, starts "${presented.slice(0, 4)}"` : 'empty'; + return `${scheme} credential did not match (${shape}; expected ${token.length} chars)`; +} + +// Length-independent comparison that doesn't leak the token by timing. +function safeEqual(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +// Accept OwnTracks (`lat`/`lon`/`tst`) and plain `{latitude, longitude}` bodies. +function parsePosition(body) { + if (!body || typeof body !== 'object') return null; + if (body._type && body._type !== 'location') return null; // ignore transitions, waypoints, … + const lat = num(body.lat ?? body.latitude); + const lon = num(body.lon ?? body.lng ?? body.longitude); + if (lat === null || lon === null) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; + const ts = typeof body.tst === 'number' ? new Date(body.tst * 1000).toISOString() : new Date().toISOString(); + return { + lat: round(lat), + lon: round(lon), + ts, + device: typeof body.tid === 'string' ? body.tid : 'unknown', + via: body._type === 'location' ? 'owntracks' : 'http', + }; +} + +const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null); + +function readBody(req, limitBytes = 64 * 1024) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on('data', (c) => { + size += c.length; + if (size > limitBytes) { + reject(new Error('body too large')); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +const json = (res, code, payload) => { + const body = JSON.stringify(payload); + res.writeHead(code, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }); + res.end(body); +}; + +const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'GET' && url.pathname === '/healthz') { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ok\n'); + return; + } + + const auth = authorised(req, url); + if (!auth.ok) { + console.error(`[sink] 401 ${req.method} ${url.pathname} — ${describe(auth)}`); + json(res, 401, { error: 'unauthorised' }); + return; + } + + if (req.method === 'GET' && url.pathname === '/location') { + if (!existsSync(SINK_FILE)) return json(res, 404, { error: 'no position recorded yet' }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(readFileSync(SINK_FILE, 'utf8')); + return; + } + + if (req.method !== 'POST') { + json(res, 405, { error: 'method not allowed' }); + return; + } + + let body; + try { + body = JSON.parse(await readBody(req)); + } catch (e) { + json(res, 400, { error: `bad body: ${e.message}` }); + return; + } + + const position = parsePosition(body); + if (!position) { + // OwnTracks also posts non-location messages; acknowledge and drop them. + json(res, 200, []); + return; + } + + mkdirSync(dirname(SINK_FILE), { recursive: true }); + writeFileSync(SINK_FILE, JSON.stringify(position, null, 2)); + console.error(`[sink] ${position.via} ${position.device} -> ${position.lat},${position.lon} @ ${position.ts}`); + + // OwnTracks expects a JSON array back (it treats objects in it as commands). + json(res, 200, []); +}); + +// OkHttp (which OwnTracks uses) pools connections and reuses them well after +// Node's 5s default idle timeout, which surfaces on the phone as "unexpected end +// of stream". Outlive the client's idle window instead. +server.keepAliveTimeout = 65_000; +server.headersTimeout = 70_000; + +server.listen(port, '0.0.0.0', () => { + console.error(`[sink] listening on 0.0.0.0:${port}, writing ${SINK_FILE} (coords rounded to ${dp} dp)`); +}); + +// Minimal .env loader, mirroring index.js (no dependency, never overrides real env). +function loadDotEnv() { + const path = join(ROOT, '.env'); + if (!existsSync(path)) return; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i); + if (!m) continue; + const key = m[1]; + const val = m[2].trim().replace(/^["']|["']$/g, ''); + if (!(key in process.env)) process.env[key] = val; + } +}