diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dfd04cc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +**/node_modules +**/.venv +**/__pycache__ +**/.mypy_cache +**/.ruff_cache +**/.pytest_cache +**/.hypothesis +web/dist +web/test-results +web/playwright-report +tools/fixtures/cache diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2e0d78c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..71e8a12 --- /dev/null +++ b/.env.example @@ -0,0 +1,67 @@ +# Copy to .env and adjust. No secrets are required for a default deployment. + +# Where the web container listens on the host. Keep 127.0.0.1 when a reverse +# proxy on the same host fronts it; use 0.0.0.0 only behind your own firewall. +GHOSTJS8_HTTP_BIND=127.0.0.1 +GHOSTJS8_HTTP_PORT=8080 + +# Origins allowed to open the browser WebSocket (comma-separated). Empty = only +# same-origin pages (the Origin must match the Host your proxy forwards). +# Setting your public URL is still recommended. +GHOSTJS8_ALLOWED_ORIGINS=https://ghost.example.net + +# Start listening at boot (persistent listening). Leave the host empty to pick a +# receiver from the UI instead. +GHOSTJS8_RECEIVER_HOST= +GHOSTJS8_RECEIVER_PORT=8073 +GHOSTJS8_RECEIVER_PASSWORD= +GHOSTJS8_DIAL_HZ=14078000 +GHOSTJS8_MODE=usb +GHOSTJS8_LOW_CUT_HZ=100 +GHOSTJS8_HIGH_CUT_HZ=3000 + +# Minutes to keep the receiver with no browser watching. 0 = keep listening forever. +GHOSTJS8_IDLE_DISCONNECT_MINUTES=0 + +# Decode/station retention in the SQLite store. +GHOSTJS8_RETENTION_DAYS=30 + +# Limits +GHOSTJS8_MAX_CLIENTS_PER_SESSION=16 +GHOSTJS8_WATERFALL_MAX_FPS=10 + +# Receivers chosen in the UI (or by the GhostNet autopilot) must resolve to public +# addresses. Set true to allow your own LAN KiwiSDR (private, CGNAT/tailnet +# ranges). Loopback, link-local and metadata addresses are always refused. A +# receiver you configure above (GHOSTJS8_RECEIVER_HOST) is trusted as-is. +GHOSTJS8_ALLOW_PRIVATE_RECEIVERS=false + +# Use published multi-arch images (amd64/arm64) instead of building locally: +# docker compose pull && docker compose up -d --no-build +# GHOSTJS8_IMAGE_PREFIX=ghcr.io/d3mocide/ghostjs8 +# GHOSTJS8_TAG=0.1.0 + +# Logs: json (default) or text; level DEBUG/INFO/WARNING/ERROR +GHOSTJS8_LOG_FORMAT=json +GHOSTJS8_LOG_LEVEL=INFO + +# GhostNet autopilot (docs/ghostnet.md): follow your region's GhostNet JS8 windows on +# the nearest public KiwiSDR and record them (traffic, waterfall, audio). Off by default. +GHOSTJS8_GHOSTNET=off +# Region: na | eu | aus. Home grid: your Maidenhead locator, e.g. EM73. +GHOSTJS8_GHOSTNET_REGION=na +GHOSTJS8_HOME_GRID= +# GHOSTJS8_GHOSTNET_PREROLL_MINUTES=10 +# FLAC audio, roughly 35-50 MB per recorded hour: +# GHOSTJS8_GHOSTNET_RECORD_AUDIO=on +# Watch 7.107 MHz between windows (not recorded): +# GHOSTJS8_GHOSTNET_PARK=on +# GHOSTJS8_NET_RETENTION_DAYS=90 + +# Decoder placeholder identity (never transmitted; JS8Call refuses to start without one). +# Set on the decoder service if you want different values. +# GHOSTJS8_JS8_CALL=N0CALL +# GHOSTJS8_JS8_GRID=AA00 + +# Building behind a TLS-intercepting proxy: path to a full CA bundle. +# EXTRA_CA=/path/to/ca-bundle.crt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..259210c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,172 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + bridge: + name: bridge (ruff, mypy, pytest) + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: bridge + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.11.32" + enable-cache: true + - run: uv sync --locked --all-extras + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy + - run: uv run pytest + + web: + name: web (eslint, svelte-check, vitest, build) + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run check + - run: npm test + - run: npm run build + + e2e: + name: web e2e (Playwright vs simulated stack) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.11.32" + enable-cache: true + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: web/package-lock.json + - run: uv --directory bridge sync --locked --all-extras + - run: npm --prefix web ci + - run: npx --prefix web playwright install --with-deps chromium + - run: make e2e + - if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-traces + path: web/test-results/ + retention-days: 7 + + images: + name: ${{ matrix.image }} image (build, ${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + image: [web, bridge] + arch: [amd64, arm64] + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: docker build -t ghostjs8-${{ matrix.image }}:ci ${{ matrix.image }}/ + - name: Bridge image can write FLAC (soundfile + bundled libsndfile) + if: matrix.image == 'bridge' + run: > + docker run --rm --read-only --entrypoint python ghostjs8-bridge:ci -c + "import io, numpy, soundfile; b = io.BytesIO(); + soundfile.write(b, numpy.zeros(1200, dtype='int16'), 12000, format='FLAC', subtype='PCM_16'); + assert b.getvalue().startswith(b'fLaC'); print('flac ok')" + + contract: + name: contract (generated schema + TS in sync) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.11.32" + enable-cache: true + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: web/package-lock.json + - run: uv --directory bridge sync --locked --all-extras + - run: npm --prefix web ci + - run: make contract-check + + decoder-image: + name: decoder image (build + boot + healthy, ${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build (verifies the per-arch JS8Call SHA-256) + run: docker build -f decoder/Dockerfile -t ghostjs8-decoder:ci . + - name: Boot and wait for healthy + run: | + docker run -d --name dec ghostjs8-decoder:ci + for i in $(seq 1 30); do + s=$(docker inspect dec --format '{{.State.Health.Status}}') + echo "health=$s"; [ "$s" = healthy ] && exit 0; sleep 5 + done + docker logs dec; exit 1 + + acceptance: + name: acceptance (real recording -> native JS8Call -> browser WebSocket, ${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + needs: [bridge, decoder-image] + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: make acceptance diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..74a112d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,123 @@ +name: release + +# Tag vX.Y.Z -> multi-arch (amd64 + arm64) images on GHCR: +# ghcr.io//ghostjs8-{web,bridge,decoder}:X.Y.Z and :latest +# Each architecture builds natively on its own runner (no emulation), is pushed +# by digest, then the digests are merged into one manifest list per image. + +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + +jobs: + build: + name: build ${{ matrix.image }} (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + strategy: + fail-fast: true + matrix: + image: [web, bridge, decoder] + arch: [amd64, arm64] + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + - image: web + context: web + file: web/Dockerfile + - image: bridge + context: bridge + file: bridge/Dockerfile + - image: decoder + context: . + file: decoder/Dockerfile + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Image name + run: echo "IMAGE=${REGISTRY}/${GITHUB_REPOSITORY_OWNER,,}/ghostjs8-${{ matrix.image }}" >> "$GITHUB_ENV" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE }} + labels: | + org.opencontainers.image.title=ghost.js8 ${{ matrix.image }} + org.opencontainers.image.licenses=GPL-3.0-or-later + - id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ${{ matrix.context }} + file: ${{ matrix.file }} + platforms: linux/${{ matrix.arch }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + provenance: mode=max + sbom: true + - name: Export digest + run: | + mkdir -p /tmp/digests + d='${{ steps.build.outputs.digest }}' + touch "/tmp/digests/${d#sha256:}" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: digests-${{ matrix.image }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: publish ${{ matrix.image }} (multi-arch) + runs-on: ubuntu-24.04 + needs: build + permissions: + contents: read + packages: write + strategy: + matrix: + image: [web, bridge, decoder] + steps: + - name: Image name and version + run: | + echo "IMAGE=${REGISTRY}/${GITHUB_REPOSITORY_OWNER,,}/ghostjs8-${{ matrix.image }}" >> "$GITHUB_ENV" + echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: digests-${{ matrix.image }}-* + path: /tmp/digests + merge-multiple: true + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Create manifest list + working-directory: /tmp/digests + run: | + test "$(ls | wc -l)" -eq 2 + docker buildx imagetools create \ + -t "${IMAGE}:${VERSION}" -t "${IMAGE}:latest" \ + $(printf "${IMAGE}@sha256:%s " *) + - name: Inspect + run: docker buildx imagetools inspect "${IMAGE}:${VERSION}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..149cab7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.hypothesis/ +*.egg-info/ +dist/ +build/ + +# Node +node_modules/ +web/dist/ +web/test-results/ +web/playwright-report/ + +# Fixtures are fetched + verified, never committed +tools/fixtures/cache/ + +# Local env +.env +*.sqlite3 +*.sqlite3-* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dade995 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,174 @@ +# AGENTS.md — ghost.js8 + +Handoff notes for coding agents and humans. Keep this file current: if you change +an invariant, a command, or discover a gotcha, update it in the same commit. + +## What this is + +Receive-only JS8Call decoding through public KiwiSDR web receivers. The **native +JS8Call application** decodes inside a Docker container; the browser is only the +interface. Full design: [`docs/architecture.md`](docs/architecture.md). + +``` +KiwiSDR ─ SND ─→ bridge ─→ decoder-agent ─→ PulseAudio null sink ─→ JS8Call (native) + └ W/F ─→ bridge ─→ browser canvas JS8Call ─ UDP API ─→ decoder-agent ─→ bridge ─→ browser +``` + +## Layout + +| Path | What | +|---|---| +| `bridge/` | Python package `ghostjs8` (uv project; extra `bridge` = FastAPI/uvicorn/pydantic). `python -m ghostjs8 {bridge,agent,fake-kiwi,acceptance}`. | +| `bridge/src/ghostjs8/sim/` | `fake_kiwi` (simulated KiwiSDR) and `acceptance` (browser-like test client) | +| `bridge/src/ghostjs8/receivers/` | Receiver adapter interface + KiwiSDR implementation | +| `bridge/src/ghostjs8/decoders/` | Decoder adapter interface + native JS8Call implementation (incl. the in-container decoder-agent) | +| `bridge/src/ghostjs8/session/` | Session orchestration, health state machine, staleness tracking | +| `bridge/src/ghostjs8/api/` | FastAPI app: `/ws`, `/api/*`, `/healthz`, `/readyz` | +| `bridge/src/ghostjs8/contract/` | Pydantic models — **the single source of truth** for the browser protocol | +| `contract/` | Generated JSON Schema (committed) | +| `web/` | Vite + Svelte 5 + TypeScript UI. `web/src/lib/` is framework-free (protocol, DSP, waterfall, audio). | +| `web/src/lib/protocol/generated.ts` | Generated from the JSON Schema. **Never edit by hand.** | +| `web/src/lib/` | Framework-free, unit-tested: `protocol/` (client, binary), `audio/` (ring, worklet, player), `waterfall/`, `state/` (reducer, situation, Svelte app store), `grid.ts`, `bands.ts` | +| `web/src/features/` | Svelte panels: receiver, tuning, audio, waterfall, timeline, stations, map, status | +| `web/src/lib/backdrop/` | ASCII backdrop engine (`ascii.ts`, pure helpers unit-tested) + per-viewer mode preference | +| `web/src/styles/tokens.css` | Design tokens: the single source for colour, glass, radii, spacing, type, motion | +| `web/deploy/` | nginx template for the `web` image | +| `tools/dev-stack.sh` | fake KiwiSDR + fake decoder-agent + real bridge, no Docker (`GHOSTNET=1 SEED_NET=1` for the autopilot + a seeded recording) | +| `bridge/src/ghostjs8/ghostnet/` | GhostNet autopilot: `schedule` (plan v1.5 windows), `picker` (nearest receiver), `recorder` (FLAC + waterfall PNG), `pilot` ([`docs/ghostnet.md`](docs/ghostnet.md)) | +| `decoder/` | Decoder container: Dockerfile, supervisor config, PulseAudio config, JS8Call ini template | +| `tools/fake-kiwi/` | Simulated KiwiSDR server using real SND/W/F framing | +| `tools/fixtures/` | Fetch + verify script for the GPL-3.0 upstream test recording (never vendored) | +| `tools/acceptance/` | Real-recording end-to-end acceptance runner | + +## Commands + +Run from the repo root. + +| Command | Does | +|---|---| +| `make setup` | `uv sync --locked --all-extras` (bridge) + `npm ci` (web) | +| `make lint` | ruff check + ruff format --check + eslint + prettier --check | +| `make typecheck` | mypy --strict + svelte-check + tsc | +| `make test` | pytest + vitest | +| `make check` | all of the above | +| `make contract` | regenerate JSON Schema + TS types from Pydantic (milestone 6) | +| `make contract-check` | fail if generated files are out of date | +| `make decoder-image` | build `ghostjs8-decoder:dev` (set `EXTRA_CA=/path/bundle.crt` behind a TLS proxy) | +| `make fixtures` | fetch + SHA-256-verify the upstream test recording into `tools/fixtures/cache/` | +| `make acceptance` | real-recording end-to-end test in Docker (`docker-compose.test.yml`, isolated network) | +| `make e2e` | Playwright (desktop + phone) against the simulated stack; set `PW_CHROMIUM_PATH` to use a preinstalled Chromium | +| `make images` / `make up` / `make down` | production compose build / start / stop | +| `git tag vX.Y.Z && git push --tags` | release: CI publishes multi-arch (amd64 + arm64) images to GHCR (`.github/workflows/release.yml`) | +| `tools/dev-stack.sh` + `npm --prefix web run dev` | UI development against the simulated stack | + +Toolchain: Python 3.12 via `uv`, Node 22 LTS. Everything is pinned (see +[`docs/SECURITY-UPDATES.md`](docs/SECURITY-UPDATES.md) once written). + +## Invariants — do not break + +1. **Receive only.** No transmit button, TX field, or anything that looks like it + could send. A Playwright test asserts this. A future radio interface must be a + separate, explicit extension point. Branding stays mode-neutral ("JS8 ops + console"); current capability is shown by the top bar's `RX` chip, which + tracks the contract's `hello.receive_only`. +2. **Never change shared receiver hardware settings.** No attenuation changes, + ever. Kiwi AGC is per-channel DSP; we send fixed per-channel defaults only. + ADC overload is shown and receiver switching is offered — nothing else. +3. **Decoding is proven by the real recording, not mocks.** The acceptance test + (`make acceptance`) must keep passing across decoder/runtime upgrades. +4. **Truthful readiness.** An open WebSocket proves nothing. Health reports + distinct, timestamped component states; "last decode" is never inferred. +5. **Kiwi audio normalization:** request uncompressed mono at 12 kHz. SND PCM is + big-endian s16 unless the frame's little-endian flag is set — honor it, never + double-swap. Compressed, stereo, IQ, or non-12 kHz frames are rejected with a + typed, logged error. +6. **SND and W/F connections share one session identifier.** Mismatch is an error. +7. **Bounded everything:** browser clients per session, audio buffer, waterfall + frame rate. Drop/resync instead of growing latency. +8. **Contract is generated.** Edit the Pydantic models, run `make contract`, + commit both. CI fails on drift. +9. **Pins:** base images by digest, Python deps by hash (`uv.lock`), Node deps by + lockfile with exact versions, GitHub Actions by commit SHA, JS8Call by version + + SHA-256. +10. **Be polite to public receivers:** identify as `ghost.js8`, back off on + reconnect (jittered exponential, min 5 s, max 5 min), honor limits and + rejections. + +## Gotchas (learned the hard way) + +- JS8Call 3.x AppImage fails on Ubuntu 22.04 — use **24.04**. Docker has no FUSE, + so the AppImage is **unpacked at build time with `unsquashfs`** at the + offset after its ELF runtime. It is never executed, which also lets an + amd64 host prepare the arm64 tree. Upstream ships x86_64 and aarch64 only, with + one SHA-256 pin per architecture. +- Receivers chosen by viewers or the autopilot go through + `receivers/address_policy.py`: resolve, check **every** address, dial the + checked IP, refuse redirects. Only the operator's boot target + (`ReceiverTarget(trusted=True)`) skips it. The simulated stack needs + `GHOSTJS8_ALLOW_LOOPBACK_RECEIVERS=true` (set by `tools/dev-stack.sh`). +- `StationConfig.min_connect_interval_s` (5 s) spaces all receiver connects. + Station unit tests that reconnect quickly pass `min_connect_interval_s=0`. +- Local arm64 checks: `docker run --privileged --rm tonistiigi/binfmt --install arm64` + (mount `binfmt_misc` first in a sandbox), then + `DOCKER_DEFAULT_PLATFORM=linux/arm64 make acceptance`. It is slow under + emulation but passes. +- Qt hides PulseAudio **monitor** sources from its input list. Expose the null + sink's monitor via **`module-remap-source`** as an ordinary capture source. +- JS8Call's own audio output goes to a separate **discard** sink. +- JS8Call's UDP API: the agent listens on localhost and **learns the reply port** + from incoming datagrams. +- JS8Call blocks on a settings dialog if `MyCall` or `MyGrid` is empty; the + template sets receive-only placeholders. `PSKReporter` and `SpotToAPRS` + **default to on** — they are forced off so we never publish spots. +- The decode filter (`[WideGraph] FilterCenter/FilterWidth`) **is** the decode + range. It must cover the receiver passband. +- `AppRun` is a symlink; we exec `/opt/js8call/usr/bin/JS8Call` so the process + is named `JS8Call` (healthcheck and `pkill -x JS8Call` rely on it). +- `RX.DIRECTED` `value` omits the sender (it is in `params.FROM`) and ends with + the EOT marker `♢`. `RX.ACTIVITY` `value` carries the full `FROM: ...` line. + `STATION.STATUS` arrives at ~2 Hz — ignore it. `PING` arrives every 15 s. +- JS8Call only emits `RX.SPOT` when spotting to PSKReporter is enabled (we keep + it off). Heard stations come from the agent polling `RX.GET_CALL_ACTIVITY` + (reply `RX.CALL_ACTIVITY`: callsign -> SNR/GRID/UTC). +- The decoder-agent may only send `ALLOWED_REQUESTS` to JS8Call (read-only); + a test asserts `TX.*` requests raise. +- nginx's `mime.types` may lack `.mjs`; the web template serves it as + `text/javascript`. With `nosniff`, MapLibre's module worker fails otherwise. +- MapLibre 6 loads its worker relative to its module URL: Vite must not + pre-bundle it (`optimizeDeps.exclude`) and the app calls `setWorkerUrl()` + with the `?url` import so production builds emit the worker. +- Compose's bake builder refuses build secrets outside the project; the + Makefile builds with `COMPOSE_BAKE=false` and the default `extra_ca` secret is + an empty in-repo file. +- `uv add/remove` re-syncs without extras; run `uv sync --all-extras` after. +- Building behind a TLS-intercepting proxy: pass the full CA bundle as build + secret `extra_ca`; never disable verification. +- JS8 decoding depends on UTC alignment. The host must be NTP-synced; keep + bridge-side audio buffering small. +- `uv` caches builds: if the editable install looks empty after creating new + package dirs, run `uv sync --reinstall-package ghostjs8`. +- GhostNet windows are UTC and live in `ghostnet/schedule.py` `PLAN`. A window in + progress beats the next window's pre-roll. Any viewer tune/select/disconnect + calls `station.operator_changed()`, which pauses the autopilot. Keep that hook + on new control paths. +- The GhostNet distribution's ALE codeplug and HFN CSV are ALE, not JS8; don't + import them. +- `soundfile` ships libsndfile in its manylinux wheel; the bridge image needs no + system package for FLAC. +- axe flags scrollable lists with no focusable children + (`scrollable-region-focusable`). It only shows up once a list overflows, as + in the full e2e run, so give such lists `tabindex="0"` and a label. +- The ASCII backdrop canvas sits at `z-index: -1`, so the page base gradient + lives on `` and `` must stay transparent, or the canvas is hidden. + Keep the glass blur high enough (~20 px) that backdrop labels don't compete + with panel text. +- TypeScript is pinned to 6.0.x because typescript-eslint does not yet support 7. + +## Conventions + +- Conventional commits (`feat:`, `fix:`, `test:`, `docs:`, `build:`, `ci:`, `chore:`). +- Python: fully typed, `mypy --strict` clean, ruff-formatted, asyncio throughout, + injected clocks for anything time-based (tests must be deterministic). +- TypeScript: strict, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + no `any` at module boundaries. +- Structured JSON logs carrying a session ID. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a54c430 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +All notable changes. Versions follow SemVer; the protocol version (`v`) is +tracked separately in `docs/protocol.md`. + +## [0.1.0] — unreleased + +First MVP. + +- Native JS8Call 3.0.3 decoder container (Ubuntu 24.04, Xvfb, PulseAudio null + sink + remap-source + discard sink, supervised, checksum-verified binary). +- decoder-agent: PCM into PulseAudio, JS8Call UDP API with reply-port learning, + call-activity polling, health facts. +- KiwiSDR adapter: paired SND/W/F sessions, big-endian PCM normalization with + the little-endian flag honoured, typed rejections and format errors. +- Bridge: v1 protocol (Pydantic → JSON Schema → TypeScript), evidence-based + health, binary audio/waterfall fan-out, receiver directory, SQLite history, + reconnect policy, JSON logs. +- Web: Svelte 5 listening-post UI with waterfall, AudioWorklet monitor, + timeline, stations, map and status; responsive and accessible. +- Real-recording acceptance test, Playwright smoke tests, CI. +- GhostNet autopilot (opt-in): follows the GhostNet v1.5 JS8 windows for a region + on the nearest suitable public KiwiSDR, parks on 7.107 MHz between windows, + and records traffic, a waterfall image and FLAC audio per window. It steps + aside when a viewer takes control. +- "Ghost signal" visual design: frosted-glass panels over a deep solid + gradient and a live ASCII backdrop. It shows drifting static and radio-wave + rings carrying the callsign of each new decode. It also pulses on every 15 s + JS8 slot, and an occasional ghost drifts past. A top-bar toggle switches to a + still gradient; reduced motion draws one frame. + The redesign also adds a sticky top bar and a hero strip showing the + frequency, the situation and decodes/stations/last decode. The waterfall + colormap was retuned to match. +- New mark and wordmark: a stencil line-art ghost with a radio-wave hem and + slit eyes, and `ghost.js8_` set in Share Tech Mono with a blinking cursor. + Panel titles and the frequency readout use the same terminal face. +- Mode-neutral branding: tagline "JS8 ops console". The "Receive only" + badge became an `RX` mode chip that shows capability, not brand. A future + transmit extension changes the chip, not the name. +- Net log viewer (`#/net/`) with click-to-seek audio, and a live + @GSTFLASH alert banner. Timeline tags for FLASH/GN/DIR traffic. +- **64-bit ARM support (linux/arm64)** alongside amd64. The decoder picks the + matching JS8Call build with one SHA-256 pin per architecture. The AppImage + is unpacked with `unsquashfs` and never executed during the build. CI builds + and boots the decoder and runs the real-recording acceptance test on native + ARM runners. +- Release workflow: a `vX.Y.Z` tag publishes multi-arch images to GHCR. + Compose can pull them (`GHOSTJS8_IMAGE_PREFIX`, `GHOSTJS8_TAG`). CI also + builds the bridge and web images on both architectures. +- Security hardening from a pre-release review: + - receiver addresses are resolved and every address checked at connect + time (closes `127.1`-style tricks, internal DNS names and rebinding); + - redirects are refused; + - private receivers are opt-in; + - viewers see only generic connection errors; + - receiver connects are spaced at least 5 s apart; + - the WebSocket is same-origin by default; + - Kiwi passwords cannot carry whitespace or control characters; + - nginx security headers apply on every path; + - containers drop all capabilities with `no-new-privileges`; + - IPv6 receiver literals are bracketed. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..34ccb95 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# CLAUDE.md + +@AGENTS.md + +## Notes for Claude + +- Milestone order is non-negotiable: decoder container → audio path → + real-recording acceptance test passes → bridge contract → UI. Do not start UI + work before `make acceptance` passes. +- When observed runtime behaviour conflicts with `docs/architecture.md` or the + gotchas above, stop and raise it with the user instead of working around it. +- Run `make check` before every commit. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..def67d2 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +# ghost.js8 developer entry points. See AGENTS.md. +SHELL := /bin/bash +.SHELLFLAGS := -euo pipefail -c +.DEFAULT_GOAL := check + +UV := uv --directory bridge +NPM := npm --prefix web + +.PHONY: setup lint typecheck test check contract contract-check acceptance decoder-image fixtures e2e images up down + +setup: + $(UV) sync --locked --all-extras + $(NPM) ci + +lint: + $(UV) run ruff check . + $(UV) run ruff format --check . + $(NPM) run lint + +typecheck: + $(UV) run mypy + $(NPM) run check + +test: + $(UV) run pytest + $(NPM) test + +check: lint typecheck test + +contract: + $(UV) run python -m ghostjs8.contract.generate ../contract/schema.json + cd web && node scripts/gen-protocol.mjs + +contract-check: contract + git diff --exit-code -- contract/schema.json web/src/lib/protocol/generated.ts + +fixtures: + python3 tools/fixtures/fetch_fixture.py + +COMPOSE_TEST := COMPOSE_BAKE=false EXTRA_CA=$(EXTRA_CA) docker compose -f docker-compose.test.yml + +acceptance: fixtures + $(COMPOSE_TEST) build + $(COMPOSE_TEST) up --abort-on-container-exit --exit-code-from acceptance; \ + rc=$$?; $(COMPOSE_TEST) down -v --remove-orphans >/dev/null 2>&1; exit $$rc + +comma := , +EXTRA_CA ?= +DOCKER_SECRET := $(if $(EXTRA_CA),--secret id=extra_ca$(comma)src=$(EXTRA_CA),) + +decoder-image: + docker build $(DOCKER_SECRET) -f decoder/Dockerfile -t ghostjs8-decoder:dev . + +e2e: + cd web && npx playwright test + +images: + COMPOSE_BAKE=false EXTRA_CA=$(EXTRA_CA) docker compose build + +up: + COMPOSE_BAKE=false docker compose up -d + +down: + docker compose down diff --git a/README.md b/README.md index f9a112a..7382fed 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,76 @@ -# ghostjs8 -A web based JS8call receiver for the GhostNet +

+ ghost.js8 — JS8 ops console for KiwiSDR and GhostNet +

+ +

+ A JS8 ops console in the browser.
+ The native JS8Call decoder, headless in Docker, wired to public KiwiSDR receivers and GhostNet. +

+ +

+ CI + Mode RX + amd64 and arm64 + License GPL-3.0 +

+ +

+ ghost.js8 dashboard: frequency, live waterfall, decoded traffic, GhostNet autopilot, heard stations +

+ +## What it does + +- **Real decoding.** KiwiSDR audio goes into native JS8Call. CI proves it end to end with a real recording. +- **Live console.** Waterfall, decoded traffic, heard stations, map and an audio monitor work on desktop and phone. +- **GhostNet autopilot (opt-in).** It follows your region's GhostNet JS8 nets on the nearest KiwiSDR. Each net is recorded (traffic, waterfall, audio) for later review. Live `@GSTFLASH` traffic raises an alert. +- **Truthful health.** Every component reports timestamped evidence. "Connected" is never mistaken for "decoding". +- **Polite on shared receivers.** It identifies itself, backs off on reconnect and never touches shared receiver settings. +- **RX today, built to grow.** Current builds have no transmit path (the UI shows an `RX` mode chip). Transmit is planned as a separate, explicitly enabled extension with proper radio tooling. + +``` +KiwiSDR ─ SND ─▶ bridge ─▶ decoder-agent ─▶ PulseAudio ─▶ JS8Call (native, headless) + └ W/F ─▶ bridge ─▶ browser JS8Call ─ UDP ─▶ agent ─▶ bridge ─▶ browser +``` + +## Quick start + +```sh +git clone https://github.com/d3mocide/ghost.js8 && cd ghost.js8 +cp .env.example .env # set GHOSTJS8_ALLOWED_ORIGINS +docker compose up -d --build # UI on http://127.0.0.1:8080 +``` + +To monitor GhostNet unattended, add three lines to `.env` ([details](docs/ghostnet.md)): + +```sh +GHOSTJS8_GHOSTNET=on +GHOSTJS8_GHOSTNET_REGION=na # na | eu | aus +GHOSTJS8_HOME_GRID=EM73 # your Maidenhead grid +``` + +Runs on **amd64 and 64-bit ARM** (Raspberry Pi 4/5 on a 64-bit OS, Apple Silicon, ARM servers). Releases publish multi-arch images, so you can pull instead of build: set `GHOSTJS8_IMAGE_PREFIX=ghcr.io/d3mocide/ghostjs8` and `GHOSTJS8_TAG` in `.env`, then run `docker compose pull && docker compose up -d --no-build`. + +Put a reverse proxy in front for TLS and authentication ([operations](docs/operations.md)). Keep the host NTP-synced, because JS8 decoding depends on accurate time. + +## Docs + +[Architecture](docs/architecture.md) · [GhostNet](docs/ghostnet.md) · [Operations](docs/operations.md) · [Protocol](docs/protocol.md) · [KiwiSDR notes](docs/kiwisdr-notes.md) · [Decoder container](docs/decoder-container.md) · [Security updates](docs/SECURITY-UPDATES.md) · [Contributing / agents](AGENTS.md) + +## Develop + +Requires `uv`, Node 22, GNU make and Docker. + +```sh +make setup # pinned deps +make check # lint, typecheck, unit tests (bridge + web) +make acceptance # real-recording decode test (Docker) +make e2e # Playwright against a simulated stack +``` + +For UI work without Docker, run `tools/dev-stack.sh` and `npm --prefix web run dev`. Add `GHOSTNET=1 SEED_NET=1` to the dev stack for a demo GhostNet recording. + +## License + +GPL-3.0-or-later. JS8Call (GPL-3.0) runs as a separate, unmodified process. The upstream test recording is fetched at test time, not redistributed. Map outlines are Natural Earth (public domain). + +A d3FRAG Networks project. In the spirit of S2 Underground's GhostNet; not affiliated. diff --git a/bridge/.dockerignore b/bridge/.dockerignore new file mode 100644 index 0000000..dfd04cc --- /dev/null +++ b/bridge/.dockerignore @@ -0,0 +1,12 @@ +.git +**/node_modules +**/.venv +**/__pycache__ +**/.mypy_cache +**/.ruff_cache +**/.pytest_cache +**/.hypothesis +web/dist +web/test-results +web/playwright-report +tools/fixtures/cache diff --git a/bridge/Dockerfile b/bridge/Dockerfile new file mode 100644 index 0000000..90155b0 --- /dev/null +++ b/bridge/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1.7 +# ghost.js8 bridge (also runs fake-kiwi and the acceptance client in tests). +ARG PYTHON_IMAGE=python:3.12-slim@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f +ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.32@sha256:df4cae8f3a96d175e2e5f992e597550000edbe78fdc2594d5cd8de1a217f504c + +FROM ${UV_IMAGE} AS uv + +FROM ${PYTHON_IMAGE} AS build +COPY --from=uv /uv /usr/local/bin/uv +ENV UV_PROJECT_ENVIRONMENT=/opt/venv UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +WORKDIR /src +COPY pyproject.toml uv.lock README.md ./ +COPY src ./src +# uv verifies every artifact against the hashes in uv.lock (--locked). +# Optional secret "extra_ca": CA bundle for builds behind a TLS-intercepting proxy. +RUN --mount=type=secret,id=extra_ca,required=false \ + if [ -s /run/secrets/extra_ca ]; then export SSL_CERT_FILE=/run/secrets/extra_ca UV_NATIVE_TLS=1; fi; \ + uv sync --locked --no-dev --extra bridge --no-editable + +FROM ${PYTHON_IMAGE} AS runtime +LABEL org.opencontainers.image.title="ghost.js8 bridge" \ + org.opencontainers.image.source="https://github.com/d3mocide/ghost.js8" \ + org.opencontainers.image.licenses="GPL-3.0-or-later" +RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin ghost \ + && install -d -o ghost -g ghost -m 0750 /data +COPY --from=build /opt/venv /opt/venv +ENV PATH=/opt/venv/bin:$PATH PYTHONUNBUFFERED=1 +USER ghost +VOLUME /data +EXPOSE 8073 +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8073/healthz', timeout=2).status == 200 else 1)"] +CMD ["python", "-m", "ghostjs8", "bridge"] diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 0000000..6421a55 --- /dev/null +++ b/bridge/README.md @@ -0,0 +1,8 @@ +# ghostjs8 (bridge package) + +Python package for ghost.js8: KiwiSDR receiver adapter, JS8Call decoder +adapter + in-container decoder-agent, session orchestration, the browser +WebSocket API, and test simulators (`ghostjs8.sim`). See the repository +`README.md` and `AGENTS.md`. + +Commands: `python -m ghostjs8 {bridge|agent|fake-kiwi|acceptance}`. diff --git a/bridge/pyproject.toml b/bridge/pyproject.toml new file mode 100644 index 0000000..4542c1e --- /dev/null +++ b/bridge/pyproject.toml @@ -0,0 +1,73 @@ +[project] +name = "ghostjs8" +version = "0.1.0" +description = "Receive-only JS8Call decoding through web-based KiwiSDR receivers" +readme = "README.md" +license = "GPL-3.0-or-later" +requires-python = ">=3.12,<3.13" +dependencies = [ + "websockets>=15", +] + +[project.optional-dependencies] +bridge = [ + "fastapi>=0.115", + "pydantic>=2.9", + "soundfile>=0.13", + "uvicorn>=0.32", +] + +[dependency-groups] +dev = [ + "httpx2>=2.13.1", + "hypothesis>=6.168.5", + "mypy>=2.4.0", + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", + "pytest-timeout>=2.4.0", + "ruff>=0.16.10", +] + +[build-system] +requires = ["hatchling==1.27.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ghostjs8"] + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF", "ASYNC", "PT", "PL", "TRY", "S", "ANN"] +ignore = [ + "PLR2004", # magic values are protocol constants here, named where it helps + "PLR0913", # keyword-only configuration arguments are fine + "PLR0911", # parsers return early by design + "E501", # ruff format owns line length; long literals/log strings are fine + "TRY003", +] + +[tool.ruff.lint.per-file-ignores] +"src/ghostjs8/__main__.py" = ["PLC0415"] # lazy imports keep each command light +"tests/**" = ["S101", "S105", "S106", "ANN", "PLR", "ASYNC109", "ASYNC110"] + +[tool.mypy] +strict = true +python_version = "3.12" +mypy_path = "src" +packages = ["ghostjs8"] +warn_unreachable = true + +[[tool.mypy.overrides]] +module = ["soundfile"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +addopts = "-ra --strict-markers --timeout=30" +markers = ["integration: needs sockets / subprocesses, no Docker"] diff --git a/bridge/src/ghostjs8/__init__.py b/bridge/src/ghostjs8/__init__.py new file mode 100644 index 0000000..d28ae5b --- /dev/null +++ b/bridge/src/ghostjs8/__init__.py @@ -0,0 +1,12 @@ +"""ghost.js8 — receive-only JS8Call decoding through KiwiSDR receivers.""" + +from importlib.metadata import PackageNotFoundError, version +from typing import Final + +try: + __version__: str = version("ghostjs8") +except PackageNotFoundError: # pragma: no cover - only when run from an unpacked tree + __version__ = "0.0.0+unknown" + +# Bridge <-> browser protocol version. Bump on any breaking contract change. +PROTOCOL_VERSION: Final = 1 diff --git a/bridge/src/ghostjs8/__main__.py b/bridge/src/ghostjs8/__main__.py new file mode 100644 index 0000000..106ee33 --- /dev/null +++ b/bridge/src/ghostjs8/__main__.py @@ -0,0 +1,141 @@ +"""``python -m ghostjs8 ``: bridge, agent, fake-kiwi, acceptance.""" + +from __future__ import annotations + +import logging +import sys +from dataclasses import replace + + +def _bridge() -> None: + import uvicorn + + from ghostjs8.api.app import create_app + from ghostjs8.api.hub import Hub + from ghostjs8.decoders.base import DecoderSink + from ghostjs8.decoders.js8call_native.adapter import Js8CallNativeDecoder + from ghostjs8.ghostnet.pilot import build_pilot, disabled_status + from ghostjs8.receivers.base import ReceiverSink, Tuning + from ghostjs8.receivers.directory import Directory + from ghostjs8.receivers.kiwisdr.client import KiwiEndpoint, KiwiReceiver + from ghostjs8.session.station import ( + DecoderHandle, + ReceiverHandle, + ReceiverTarget, + Station, + StationConfig, + ) + from ghostjs8.settings import Settings + from ghostjs8.store.sqlite import Store + from ghostjs8.util.clock import SystemClock + from ghostjs8.util.logs import configure + + settings = Settings.from_env() + configure("bridge", settings.log_level, settings.log_format) + log = logging.getLogger("ghostjs8") + clock = SystemClock() + + def make_receiver(target: ReceiverTarget, tuning: Tuning, sink: ReceiverSink) -> ReceiverHandle: + return KiwiReceiver( + KiwiEndpoint( + target.host, + target.port, + target.password, + tls=target.tls, + policy=None if target.trusted else settings.receiver_policy, + ), + tuning, + sink, + clock=clock, + ) + + def make_decoder(sink: DecoderSink) -> DecoderHandle: + return Js8CallNativeDecoder(settings.agent_url, sink, clock=clock) + + target = ( + ReceiverTarget( + settings.receiver_host, + settings.receiver_port, + settings.receiver_password, + trusted=True, # operator-configured + ) + if settings.receiver_host + else None + ) + if settings.allow_loopback_receivers: + log.warning("GHOSTJS8_ALLOW_LOOPBACK_RECEIVERS is on: for the simulated stack only") + station = Station( + Hub(max_clients=settings.max_clients), + make_decoder, + make_receiver, + tuning=settings.tuning, + target=target, + store=Store(settings.db_path, clock, retention_days=settings.retention_days) + if settings.db_path + else None, + clock=clock, + config=StationConfig( + waterfall_max_fps=settings.waterfall_max_fps, + idle_disconnect_s=settings.idle_disconnect_minutes * 60.0, + ), + ) + directory = Directory(source=settings.directory_url, clock=clock) + pilot, why_off = build_pilot(settings, station, directory, clock) + background = [] + if pilot is not None: + if settings.idle_disconnect_minutes: + log.warning("GhostNet autopilot keeps listening unattended: idle disconnect disabled") + station.config = replace(station.config, idle_disconnect_s=0.0) + background.append(pilot.run()) + log.info( + "GhostNet autopilot on: region %s, home grid %s", + pilot.config.region, + pilot.config.home_grid, + ) + else: + if settings.ghostnet: + log.error("GhostNet autopilot not started: %s", why_off) + station.join_messages.append(lambda: disabled_status(why_off)) + uvicorn.run( + create_app(station, settings, clock=clock, directory=directory, background=background), + host=settings.listen_host, + port=settings.listen_port, + log_config=None, + access_log=False, + ws_max_size=64 * 1024, + ) + + +def main() -> None: + cmd, rest = (sys.argv[1], sys.argv[2:]) if len(sys.argv) > 1 else ("", []) + if cmd == "bridge": + _bridge() + elif cmd == "agent": + from ghostjs8.decoders.js8call_native.agent import main as agent_main + + agent_main(rest) + elif cmd == "fake-kiwi": + from ghostjs8.sim.fake_kiwi import main as fake_main + + fake_main(rest) + elif cmd == "fake-agent": + from ghostjs8.sim.fake_agent import main as fake_agent_main + + fake_agent_main(rest) + elif cmd == "seed-net": + from ghostjs8.sim.seed_net import main as seed_main + + seed_main(rest) + elif cmd == "acceptance": + from ghostjs8.sim.acceptance import main as acceptance_main + + sys.exit(acceptance_main(rest)) + else: + sys.stderr.write( + "usage: python -m ghostjs8 {bridge|agent|fake-kiwi|fake-agent|seed-net|acceptance} [args]\n" + ) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/bridge/src/ghostjs8/api/__init__.py b/bridge/src/ghostjs8/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/api/app.py b/bridge/src/ghostjs8/api/app.py new file mode 100644 index 0000000..3c1cc85 --- /dev/null +++ b/bridge/src/ghostjs8/api/app.py @@ -0,0 +1,271 @@ +"""HTTP + WebSocket API for browsers. + +- ``GET /healthz`` liveness: the bridge process is serving +- ``GET /readyz`` readiness: full component health (200 when listening, 503 otherwise) +- ``GET /api/receivers`` cached public receiver directory +- ``GET /api/nets``, ``/api/nets/{id}``, ``/api/nets/{id}/waterfall.png``, + ``/api/nets/{id}/audio.flac`` recorded GhostNet windows +- ``WS /ws`` the browser protocol (see contract/messages.py, docs/protocol.md) +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from collections.abc import AsyncIterator, Awaitable, Sequence +from pathlib import Path +from urllib.parse import urlsplit + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse +from pydantic import ValidationError + +from ghostjs8.api.hub import Client, HubFull +from ghostjs8.api.nets import nets_router +from ghostjs8.contract.messages import ( + ClientMessage, + DisconnectReceiver, + Error, + ErrorCode, + Ping, + Pong, + SelectReceiver, + Subscribe, + Tune, + client_message_adapter, +) +from ghostjs8.receivers.address_policy import AddressPolicy, ReceiverAddressRefused +from ghostjs8.receivers.base import Tuning +from ghostjs8.receivers.directory import Directory +from ghostjs8.session.station import ReceiverTarget, Station +from ghostjs8.settings import Settings +from ghostjs8.util.clock import Clock, SystemClock + +log = logging.getLogger("ghostjs8.api") + +CONTROL_BURST = 10 # control messages per client ... +CONTROL_WINDOW_S = 5.0 # ... per this many seconds +MAX_CLIENT_MESSAGE_BYTES = 4096 + + +class InvalidReceiver(ValueError): + pass + + +def validate_receiver_host(host: str, *, allow_private: bool, allow_loopback: bool = False) -> str: + """Early, friendly check of a viewer-supplied host string. + + The binding check happens at connect time, on the resolved addresses + (receivers/address_policy.py); this only rejects what is wrong on its face. + """ + try: + return AddressPolicy(allow_private=allow_private, allow_loopback=allow_loopback).check_host( + host + ) + except ReceiverAddressRefused as exc: + raise InvalidReceiver(str(exc)) from None + + +def origin_allowed(origin: str | None, host: str | None, allowed: tuple[str, ...]) -> bool: + """Browser WebSocket origin check. + + With an explicit allow-list, the Origin must be on it. Without one, only + same-origin pages may connect (Origin host:port == Host). Requests with no + Origin header are not from a browser page and cannot be cross-site. + """ + if origin is None: + return True + if allowed: + return origin in allowed + if not host: + return False + o = urlsplit(origin) + default = {"http": 80, "https": 443}.get(o.scheme) + try: + o_port = o.port or default + h = urlsplit(f"//{host}") + h_port = h.port or default + except ValueError: + return False + return (o.hostname or "") == (h.hostname or "") and o_port == h_port + + +class RateLimiter: + def __init__( + self, clock: Clock, burst: int = CONTROL_BURST, window_s: float = CONTROL_WINDOW_S + ) -> None: + self._clock, self._burst, self._window = clock, burst, window_s + self._stamps: list[float] = [] + + def allow(self) -> bool: + now = self._clock.monotonic() + self._stamps = [t for t in self._stamps if now - t < self._window] + if len(self._stamps) >= self._burst: + return False + self._stamps.append(now) + return True + + +def create_app( + station: Station, + settings: Settings, + *, + directory: Directory | None = None, + clock: Clock | None = None, + background: Sequence[Awaitable[None]] = (), +) -> FastAPI: + clk = clock or SystemClock() + receivers = directory or Directory(source=settings.directory_url) + store = station.store + recordings = Path(settings.recordings_dir) + + @contextlib.asynccontextmanager + async def lifespan(_: FastAPI) -> AsyncIterator[None]: + tasks = [asyncio.create_task(station.run(), name="station")] + tasks += [asyncio.ensure_future(job) for job in background] + try: + yield + finally: + for task in tasks: + task.cancel() + for task in tasks: + with contextlib.suppress(asyncio.CancelledError): + await task + + app = FastAPI(title="ghost.js8 bridge", lifespan=lifespan, docs_url=None, redoc_url=None) + + @app.get("/healthz") + async def healthz() -> JSONResponse: + return JSONResponse({"status": "ok"}) + + @app.get("/readyz") + async def readyz() -> JSONResponse: + snap = station.health.snapshot() + return JSONResponse( + json.loads(snap.model_dump_json()), + status_code=200 if snap.overall == "listening" else 503, + ) + + @app.get("/api/receivers") + async def api_receivers() -> JSONResponse: + return JSONResponse(json.loads((await receivers.get()).model_dump_json())) + + app.include_router(nets_router(store, recordings)) + + @app.websocket("/ws") + async def ws_endpoint(ws: WebSocket) -> None: + origin = ws.headers.get("origin") + if not origin_allowed(origin, ws.headers.get("host"), settings.allowed_origins): + await ws.close(code=1008, reason="origin not allowed") + return + try: + client = station.hub.add() + except HubFull: + await ws.close(code=1013, reason="session full") + return + await ws.accept() + limiter = RateLimiter(clk) + try: + station.client_joined(client) + async with asyncio.TaskGroup() as tg: + tg.create_task(_pump_out(ws, client.queue)) + tg.create_task(_pump_in(ws, client, station, settings, limiter)) + except* (WebSocketDisconnect, RuntimeError): + pass + finally: + station.hub.remove(client) + station.client_left() + + return app + + +async def _pump_out(ws: WebSocket, queue: asyncio.Queue[str | bytes]) -> None: + while True: + item = await queue.get() + if isinstance(item, bytes): + await ws.send_bytes(item) + else: + await ws.send_text(item) + + +def _error(client: Client, code: ErrorCode, message: str) -> None: + client.send_model(Error(code=code, message=message)) + + +async def _pump_in( + ws: WebSocket, client: Client, station: Station, settings: Settings, limiter: RateLimiter +) -> None: + while True: + message = await ws.receive() + if message["type"] == "websocket.disconnect": + raise WebSocketDisconnect(code=message.get("code", 1000)) + text = message.get("text") + if text is None: + _error(client, "bad_message", "binary messages are not accepted") + continue + if len(text) > MAX_CLIENT_MESSAGE_BYTES: + _error(client, "bad_message", "message too large") + continue + try: + raw = json.loads(text) + except json.JSONDecodeError: + _error(client, "bad_message", "not JSON") + continue + if isinstance(raw, dict) and raw.get("v") not in (None, 1): + _error( + client, + "unsupported_version", + f"protocol v{raw.get('v')} not supported; this bridge speaks v1", + ) + continue + try: + msg: ClientMessage = client_message_adapter.validate_python(raw) + except ValidationError as exc: + _error(client, "bad_message", exc.errors(include_url=False)[0]["msg"]) + continue + await _handle(msg, client, station, settings, limiter) + + +async def _handle( + msg: ClientMessage, client: Client, station: Station, settings: Settings, limiter: RateLimiter +) -> None: + if isinstance(msg, Ping): + client.send_model(Pong(utc=station.clock.utc_now())) + return + if isinstance(msg, Subscribe): + client.audio, client.waterfall = msg.audio, msg.waterfall + return + if not isinstance(msg, (Tune, SelectReceiver, DisconnectReceiver)): + return # ClientHello: informational + if not limiter.allow(): + _error(client, "rate_limited", "too many control messages; slow down") + return + if isinstance(msg, Tune): + t = msg.tuning + if t.low_cut_hz >= t.high_cut_hz: + _error(client, "invalid_tuning", "low cut must be below high cut") + return + await station.tune( + Tuning( + dial_hz=t.dial_hz, mode=t.mode, low_cut_hz=t.low_cut_hz, high_cut_hz=t.high_cut_hz + ) + ) + elif isinstance(msg, SelectReceiver): + try: + host = validate_receiver_host( + msg.receiver.host, + allow_private=settings.allow_private_receivers, + allow_loopback=settings.allow_loopback_receivers, + ) + except InvalidReceiver as exc: + _error(client, "invalid_receiver", str(exc)) + return + log.info("receiver selected: %s:%d", host, msg.receiver.port) + station.select_receiver( + ReceiverTarget(host, msg.receiver.port, msg.password, msg.receiver.name) + ) + else: + station.disconnect_receiver() + station.operator_changed() diff --git a/bridge/src/ghostjs8/api/hub.py b/bridge/src/ghostjs8/api/hub.py new file mode 100644 index 0000000..d664dbd --- /dev/null +++ b/bridge/src/ghostjs8/api/hub.py @@ -0,0 +1,85 @@ +"""Fan-out from one station to many browsers with bounded per-client queues. + +JSON events go to every client. Binary audio / waterfall frames go only to +clients that subscribed to that channel. Queues drop the oldest item when full +so a slow viewer never adds latency for anyone else. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from dataclasses import dataclass, field +from typing import Literal + +from pydantic import BaseModel + +log = logging.getLogger("ghostjs8.api.hub") + +Outgoing = str | bytes +BinaryChannel = Literal["audio", "waterfall"] + + +class HubFull(Exception): + """Too many browser clients for this session.""" + + +@dataclass(eq=False) +class Client: + queue: asyncio.Queue[Outgoing] + audio: bool = False + waterfall: bool = False + dropped: int = 0 + closed: asyncio.Event = field(default_factory=asyncio.Event) + + def send(self, payload: Outgoing) -> None: + if self.queue.full(): + self.dropped += 1 + with contextlib.suppress(asyncio.QueueEmpty): + self.queue.get_nowait() # drop oldest, keep the stream live + self.queue.put_nowait(payload) + + def send_model(self, message: BaseModel) -> None: + self.send(message.model_dump_json()) + + +class Hub: + def __init__(self, *, max_clients: int = 16, queue_size: int = 256) -> None: + self._clients: set[Client] = set() + self._max_clients = max_clients + self._queue_size = queue_size + + @property + def client_count(self) -> int: + return len(self._clients) + + @property + def max_clients(self) -> int: + return self._max_clients + + def wants(self, channel: BinaryChannel) -> bool: + return any(getattr(c, channel) for c in self._clients) + + def add(self) -> Client: + if len(self._clients) >= self._max_clients: + raise HubFull(f"session already has {self._max_clients} clients") + client = Client(asyncio.Queue(maxsize=self._queue_size)) + self._clients.add(client) + return client + + def remove(self, client: Client) -> None: + self._clients.discard(client) + client.closed.set() + + def broadcast(self, message: BaseModel) -> None: + self.broadcast_raw(message.model_dump_json()) + + def broadcast_raw(self, payload: Outgoing) -> None: + for client in list(self._clients): + client.send(payload) + + def broadcast_binary(self, channel: BinaryChannel, frame: bytes) -> None: + for client in list(self._clients): + if getattr(client, channel): + client.send(frame) diff --git a/bridge/src/ghostjs8/api/nets.py b/bridge/src/ghostjs8/api/nets.py new file mode 100644 index 0000000..462465e --- /dev/null +++ b/bridge/src/ghostjs8/api/nets.py @@ -0,0 +1,76 @@ +"""HTTP routes for recorded GhostNet windows.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse, JSONResponse + +from ghostjs8.contract.messages import NetLog +from ghostjs8.ghostnet.recorder import OFFSET_HI_HZ, OFFSET_LO_HZ, net_stations +from ghostjs8.store.sqlite import Store + +# Net ids are generated as "-YYYYMMDDTHHMMZ". Anything else is refused +# before it can touch the filesystem. +NET_ID = re.compile(r"^[a-z0-9-]{1,40}-\d{8}T\d{4}Z$") +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def png_height(path: Path) -> int: + try: + with path.open("rb") as f: + header = f.read(24) + except OSError: + return 0 + return int.from_bytes(header[20:24], "big") if header[:8] == PNG_MAGIC else 0 + + +def nets_router(store: Store | None, recordings: Path) -> APIRouter: + router = APIRouter(prefix="/api/nets") + + def checked(net_id: str) -> Store: + if store is None or not NET_ID.match(net_id): + raise HTTPException(status_code=404, detail="no such net") + return store + + def file(path: Path, media_type: str) -> FileResponse: + if not path.is_file(): + raise HTTPException(status_code=404, detail="not recorded") + return FileResponse(path, media_type=media_type, filename=path.name) + + @router.get("") + async def list_nets() -> JSONResponse: + nets = store.nets() if store is not None else [] + return JSONResponse([json.loads(n.model_dump_json()) for n in nets]) + + @router.get("/{net_id}") + async def get_net(net_id: str) -> JSONResponse: + st = checked(net_id) + summary = st.net(net_id) + if summary is None: + raise HTTPException(status_code=404, detail="no such net") + decodes = st.net_decodes(net_id) + body = NetLog( + summary=summary, + decodes=decodes, + stations=net_stations(st, decodes), + waterfall_seconds=png_height(recordings / net_id / "waterfall.png"), + waterfall_offset_lo_hz=OFFSET_LO_HZ, + waterfall_offset_hi_hz=OFFSET_HI_HZ, + ) + return JSONResponse(json.loads(body.model_dump_json())) + + @router.get("/{net_id}/waterfall.png") + async def get_waterfall(net_id: str) -> FileResponse: + checked(net_id) + return file(recordings / net_id / "waterfall.png", "image/png") + + @router.get("/{net_id}/audio.flac") + async def get_audio(net_id: str) -> FileResponse: + checked(net_id) + return file(recordings / net_id / "audio.flac", "audio/flac") + + return router diff --git a/bridge/src/ghostjs8/contract/__init__.py b/bridge/src/ghostjs8/contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/contract/binary.py b/bridge/src/ghostjs8/contract/binary.py new file mode 100644 index 0000000..310f688 --- /dev/null +++ b/bridge/src/ghostjs8/contract/binary.py @@ -0,0 +1,33 @@ +"""Binary WebSocket frames, bridge -> browser. All integers little-endian. + + audio (0x01): u8 channel | u8 flags | u16 reserved | u32 seq | u32 sample_rate | s16le PCM... + waterfall (0x02): u8 channel | u8 flags | u16 bins | u32 seq | u32 start_hz | u32 span_hz | u8 bins... + +audio flags: bit0 = ADC overload reported by the receiver for this block +waterfall flags: reserved (0) + +Mirrored in web/src/lib/protocol/binary.ts; docs/protocol.md is the spec. +""" + +from __future__ import annotations + +import struct + +CHANNEL_AUDIO = 0x01 +CHANNEL_WATERFALL = 0x02 +AUDIO_FLAG_ADC_OVERLOAD = 0x01 + +AUDIO_HEADER = struct.Struct(" bytes: + flags = AUDIO_FLAG_ADC_OVERLOAD if adc_overload else 0 + return AUDIO_HEADER.pack(CHANNEL_AUDIO, flags, 0, seq & 0xFFFFFFFF, sample_rate) + pcm_s16le + + +def encode_waterfall(seq: int, start_hz: int, span_hz: int, bins: bytes) -> bytes: + return ( + WATERFALL_HEADER.pack(CHANNEL_WATERFALL, 0, len(bins), seq & 0xFFFFFFFF, start_hz, span_hz) + + bins + ) diff --git a/bridge/src/ghostjs8/contract/generate.py b/bridge/src/ghostjs8/contract/generate.py new file mode 100644 index 0000000..5b13c60 --- /dev/null +++ b/bridge/src/ghostjs8/contract/generate.py @@ -0,0 +1,99 @@ +"""Generate contract/schema.json from the Pydantic models. + +``python -m ghostjs8.contract.generate ``. TypeScript is produced from +that file by json-schema-to-typescript (see Makefile ``contract``). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, TypeAdapter +from pydantic.json_schema import models_json_schema + +from ghostjs8 import PROTOCOL_VERSION +from ghostjs8.contract import messages as m + +SERVER: list[type[BaseModel]] = [ + m.Hello, + m.Health, + m.Session, + m.Decode, + m.Station, + m.History, + m.ReceiverStatus, + m.GhostNet, + m.Error, + m.Pong, +] +CLIENT: list[type[BaseModel]] = [ + m.ClientHello, + m.Tune, + m.SelectReceiver, + m.DisconnectReceiver, + m.Subscribe, + m.Ping, +] +HTTP: list[type[BaseModel]] = [m.ReceiverDirectory, m.NetSummary, m.NetLog] + + +def _strip_titles(node: object) -> None: + """Property-level titles make json2ts emit one alias per field; drop them.""" + if isinstance(node, dict): + node.pop("title", None) + for value in node.values(): + _strip_titles(value) + elif isinstance(node, list): + for item in node: + _strip_titles(item) + + +def build_schema() -> dict[str, Any]: + _, defs = models_json_schema( + [(model, "serialization") for model in SERVER + CLIENT + HTTP], + ref_template="#/$defs/{model}", + ) + all_defs: dict[str, Any] = defs.get("$defs", {}) + # Pydantic marks fields with defaults optional, but serialization always + # emits them, and the web client always sends complete messages. Requiring + # every property keeps the generated TypeScript strict on both sides. + for node in all_defs.values(): + if node.get("type") == "object" and "properties" in node: + node["required"] = sorted(node["properties"].keys()) + for prop in node["properties"].values(): + _strip_titles(prop) + for name in ("ServerMessage", "ClientMessage"): + adapter: TypeAdapter[Any] = ( + m.server_message_adapter if name == "ServerMessage" else m.client_message_adapter + ) + union = adapter.json_schema(ref_template="#/$defs/{model}", mode="serialization") + union.pop("$defs", None) + union["title"] = name + all_defs[name] = union + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/d3mocide/ghost.js8/contract/schema.json", + "title": "GhostJs8Protocol", + "description": f"ghost.js8 bridge <-> browser protocol v{PROTOCOL_VERSION}. Generated; do not edit.", + "type": "object", + "properties": { + "server": {"$ref": "#/$defs/ServerMessage"}, + "client": {"$ref": "#/$defs/ClientMessage"}, + }, + "$defs": dict(sorted(all_defs.items())), + } + + +def main(argv: list[str] | None = None) -> None: + args = sys.argv[1:] if argv is None else argv + out = Path(args[0]) if args else Path("contract/schema.json") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(build_schema(), indent=2, sort_keys=False) + "\n") + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/bridge/src/ghostjs8/contract/messages.py b/bridge/src/ghostjs8/contract/messages.py new file mode 100644 index 0000000..92133a3 --- /dev/null +++ b/bridge/src/ghostjs8/contract/messages.py @@ -0,0 +1,324 @@ +"""Bridge <-> browser protocol: the single source of truth. + +JSON text frames are discriminated unions on ``type`` and carry the protocol +version ``v``. JSON Schema (``contract/schema.json``) and TypeScript +(``web/src/lib/protocol/generated.ts``) are generated from these models with +``make contract``; never edit the generated files by hand. CI fails on drift. + +Binary frames (audio, waterfall) are documented in docs/protocol.md and +implemented in ``ghostjs8.contract.binary``. + +This is a receive-only protocol: there is deliberately no message that could +transmit. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +from ghostjs8 import PROTOCOL_VERSION + +Mode = Literal["usb", "lsb"] +ComponentState = Literal["ok", "degraded", "down", "unknown"] +SessionState = Literal["idle", "connecting", "connected", "backoff", "rejected", "failed"] +RejectReasonName = Literal["full", "busy", "auth", "down", "redirect", "duplicate_ip"] +ErrorCode = Literal[ + "bad_message", + "unsupported_version", + "session_full", + "invalid_tuning", + "invalid_receiver", + "rate_limited", + "internal", +] + + +class _Model(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class _Msg(_Model): + v: Literal[1] = PROTOCOL_VERSION + + +# ----------------------------------------------------------------- shared + + +class TuningModel(_Model): + dial_hz: int = Field(gt=0, lt=100_000_000) + mode: Mode = "usb" + low_cut_hz: int = Field(default=100, ge=0, le=6000) + high_cut_hz: int = Field(default=3000, ge=0, le=6000) + + +class ReceiverRef(_Model): + host: str = Field(min_length=1, max_length=253) + port: int = Field(default=8073, ge=1, le=65535) + name: str | None = None + + +class Limits(_Model): + max_clients: int + audio_sample_rate: int + waterfall_max_fps: float + waterfall_bins: int + + +class Component(_Model): + state: ComponentState + since: datetime # when the state last changed + last_seen: datetime | None = None # last positive evidence, if any + detail: str = "" + + +class Components(_Model): + bridge: Component + decoder_process: Component # JS8Call process running (and agent reachable) + decoder_api: Component # JS8Call UDP API answering (PING / request round trip) + decoder_capture: Component # JS8Call holds a live capture stream on its input + receiver: Component # receiver session connected + audio: Component # fresh audio arriving from the receiver + waterfall: Component # fresh waterfall rows arriving + + +# ---------------------------------------------------------- server -> client + + +class Hello(_Msg): + """First message on every connection.""" + + type: Literal["hello"] = "hello" + server: str = "ghost.js8" + build: str + receive_only: Literal[True] = True + limits: Limits | None = None + + +class Health(_Msg): + """Truthful readiness: distinct components, each with evidence timestamps.""" + + type: Literal["health"] = "health" + utc: datetime + overall: Literal["listening", "degraded", "offline"] + components: Components + last_decode_utc: datetime | None = None + + +class Session(_Msg): + type: Literal["session"] = "session" + state: SessionState + receiver: ReceiverRef | None = None + tuning: TuningModel + subscribers: int + reject_reason: RejectReasonName | None = None + detail: str = "" + next_retry_utc: datetime | None = None + + +class Decode(_Msg): + """One decoded JS8 transmission. ``utc`` comes from the decoder, never inferred.""" + + type: Literal["decode"] = "decode" + kind: Literal["activity", "directed"] + utc: datetime + text: str + snr_db: int + offset_hz: int + dial_hz: int | None = None + freq_hz: int | None = None + speed: str + from_call: str | None = None + to_call: str | None = None + grid: str | None = None # valid Maidenhead only + + +class Station(_Msg): + """A heard station. ``grid`` is present only when it is a valid Maidenhead locator.""" + + type: Literal["station"] = "station" + callsign: str + last_heard_utc: datetime + snr_db: int | None = None + grid: str | None = None + offset_hz: int | None = None + dial_hz: int | None = None + heard_count: int = 1 + + +class History(_Msg): + """Sent once after hello: recent traffic from the store, oldest first.""" + + type: Literal["history"] = "history" + decodes: list[Decode] + stations: list[Station] + + +class ReceiverStatus(_Msg): + type: Literal["receiver_status"] = "receiver_status" + adc_overload: bool + rssi_dbm: float | None = None + info: dict[str, str] = Field(default_factory=dict) + + +class Error(_Msg): + type: Literal["error"] = "error" + code: ErrorCode + message: str + + +class GhostNetWindow(_Model): + id: str + label: str + kind: Literal["net", "bridge"] + band: str + dial_hz: int + start: datetime + end: datetime + + +class GhostNet(_Msg): + """GhostNet autopilot status (absent features report enabled = false).""" + + type: Literal["ghostnet"] = "ghostnet" + enabled: bool + region: Literal["na", "eu", "aus"] | None + home_grid: str | None + mode: Literal["off", "window", "parked", "paused"] + window: GhostNetWindow | None # the window being monitored (pre-roll included) + next_window: GhostNetWindow | None + recording_net_id: str | None + receiver_reason: str + paused_until: datetime | None + detail: str + + +class Pong(_Msg): + type: Literal["pong"] = "pong" + utc: datetime + + +ServerMessage = Annotated[ + Hello + | Health + | Session + | Decode + | Station + | History + | ReceiverStatus + | GhostNet + | Error + | Pong, + Field(discriminator="type"), +] +server_message_adapter: TypeAdapter[ServerMessage] = TypeAdapter(ServerMessage) + + +# ------------------------------------------------------------- HTTP API + + +class ReceiverListing(_Model): + """One public receiver from the directory (GET /api/receivers).""" + + id: str + name: str + host: str + port: int + tls: bool + location: str + grid: str | None # valid Maidenhead only + lat: float | None + lon: float | None + users: int + users_max: int + min_hz: int + max_hz: int + antenna: str + snr_db: int | None + + +class NetSummary(_Model): + """One recorded GhostNet window (GET /api/nets).""" + + id: str + window_id: str + label: str + kind: Literal["net", "bridge"] + band: str + dial_hz: int + scheduled_start: datetime + scheduled_end: datetime + started: datetime + ended: datetime | None # null while recording + receiver: str | None + receiver_reason: str + decode_count: int + station_count: int + has_audio: bool + has_waterfall: bool + flash_count: int # @GSTFLASH messages copied + operator_override: bool # a viewer retuned during the window + + +class NetLog(_Model): + """A recorded net with its traffic (GET /api/nets/{id}).""" + + summary: NetSummary + decodes: list[Decode] + stations: list[Station] + waterfall_seconds: int # rows in waterfall.png, one per second from `started` + waterfall_offset_lo_hz: int + waterfall_offset_hi_hz: int + + +class ReceiverDirectory(_Model): + fetched_utc: datetime | None + stale: bool # served from cache after a failed refresh + source: str + receivers: list[ReceiverListing] + + +# ---------------------------------------------------------- client -> server + + +class ClientHello(_Msg): + type: Literal["hello"] = "hello" + client: str = Field(default="web", max_length=64) + + +class Tune(_Msg): + type: Literal["tune"] = "tune" + tuning: TuningModel + + +class SelectReceiver(_Msg): + type: Literal["select_receiver"] = "select_receiver" + receiver: ReceiverRef + # Sent verbatim inside "SET auth t=kiwi p=...": no whitespace or control characters, + # which would let a value smuggle extra parameters into that command. + password: str = Field(default="", max_length=128, pattern=r"^[^\s\x00-\x1f\x7f]*$") + + +class DisconnectReceiver(_Msg): + type: Literal["disconnect_receiver"] = "disconnect_receiver" + + +class Subscribe(_Msg): + """Opt in to binary channels. Both default off: no bytes the viewer does not want.""" + + type: Literal["subscribe"] = "subscribe" + audio: bool = False + waterfall: bool = False + + +class Ping(_Msg): + type: Literal["ping"] = "ping" + + +ClientMessage = Annotated[ + ClientHello | Tune | SelectReceiver | DisconnectReceiver | Subscribe | Ping, + Field(discriminator="type"), +] +client_message_adapter: TypeAdapter[ClientMessage] = TypeAdapter(ClientMessage) diff --git a/bridge/src/ghostjs8/decoders/__init__.py b/bridge/src/ghostjs8/decoders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/decoders/base.py b/bridge/src/ghostjs8/decoders/base.py new file mode 100644 index 0000000..b9affcf --- /dev/null +++ b/bridge/src/ghostjs8/decoders/base.py @@ -0,0 +1,68 @@ +"""Decoder adapter interface. + +A decoder consumes normalized audio (mono s16le, 12 kHz) and produces decode +events plus health facts. Today: native JS8Call behind an in-container agent. +Tomorrow: a WASM build. The bridge only sees this interface. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, Protocol + +DecodeKind = Literal["activity", "directed"] + + +@dataclass(frozen=True, slots=True) +class DecodeEvent: + kind: DecodeKind + utc: datetime # time JS8Call attributes to the transmission + text: str # display text; directed traffic is "FROM: TO ..." like JS8Call's UI + snr_db: int + offset_hz: int # audio offset within the passband + speed: str # JS8 submode: normal, fast, turbo, slow, ultra + from_call: str | None = None + to_call: str | None = None + grid: str | None = None + tdrift_s: float | None = None + + +@dataclass(frozen=True, slots=True) +class SpotEvent: + """A station heard, from RX.SPOT / RX.CALL_ACTIVITY.""" + + callsign: str + utc: datetime + snr_db: int | None = None + offset_hz: int | None = None + grid: str | None = None + + +@dataclass(frozen=True, slots=True) +class DecoderHealth: + """Facts reported by the decoder, each with the time it was last true.""" + + process_up: bool + capturing: bool # decoder holds a live audio capture stream + last_ping_utc: datetime | None # decoder API heartbeat (JS8Call PING) + last_api_reply_utc: datetime | None # round trip: request -> reply + last_audio_write_utc: datetime | None # audio last delivered into the decoder input + dropped_audio_blocks: int = 0 + details: dict[str, str] = field(default_factory=dict) + + +class DecoderSink(Protocol): + def on_decode(self, event: DecodeEvent) -> None: ... + + def on_spot(self, event: SpotEvent) -> None: ... + + def on_health(self, health: DecoderHealth) -> None: ... + + +class DecoderError(Exception): + """Base for decoder adapter failures.""" + + +class DecoderUnavailable(DecoderError): + """The decoder cannot be reached (agent down, container restarting).""" diff --git a/bridge/src/ghostjs8/decoders/js8call_native/__init__.py b/bridge/src/ghostjs8/decoders/js8call_native/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/decoders/js8call_native/adapter.py b/bridge/src/ghostjs8/decoders/js8call_native/adapter.py new file mode 100644 index 0000000..f72afc7 --- /dev/null +++ b/bridge/src/ghostjs8/decoders/js8call_native/adapter.py @@ -0,0 +1,111 @@ +"""Bridge-side adapter for the native JS8Call decoder (talks to decoder-agent).""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from typing import Any + +from websockets.asyncio.client import ClientConnection, connect +from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidURI + +from ghostjs8 import __version__ +from ghostjs8.decoders.base import DecodeEvent, DecoderSink, DecoderUnavailable, SpotEvent +from ghostjs8.decoders.js8call_native import agent_protocol as proto +from ghostjs8.decoders.js8call_native.messages import parse_js8_message +from ghostjs8.util.clock import Clock, SystemClock + +log = logging.getLogger("ghostjs8.decoders.js8call_native") + + +class Js8CallNativeDecoder: + """One decoder slot. ``run()`` raises DecoderUnavailable when the link drops.""" + + def __init__( + self, + agent_url: str, + sink: DecoderSink, + *, + clock: Clock | None = None, + max_pending_blocks: int = 48, + connect_timeout_s: float = 5.0, + ) -> None: + self.agent_url = agent_url + self.sink = sink + self._clock = clock or SystemClock() + self._audio: asyncio.Queue[bytes] = asyncio.Queue(maxsize=max_pending_blocks) + self._connect_timeout_s = connect_timeout_s + self._ws: ClientConnection | None = None + self.dropped_audio_blocks = 0 + + @property + def connected(self) -> bool: + return self._ws is not None + + def submit_audio(self, pcm_s16le: bytes) -> None: + """Queue PCM for the decoder. Never blocks; drops oldest when full or offline.""" + if self._audio.full(): + with contextlib.suppress(asyncio.QueueEmpty): + self._audio.get_nowait() + self.dropped_audio_blocks += 1 + self._audio.put_nowait(pcm_s16le) + + async def run(self) -> None: + try: + async with asyncio.timeout(self._connect_timeout_s): + ws = await connect(self.agent_url, compression=None, max_size=2**20) + except (TimeoutError, OSError, InvalidHandshake, InvalidURI) as exc: + raise DecoderUnavailable(f"decoder agent {self.agent_url}: {exc}") from exc + self._ws = ws + # Audio queued while offline is stale; JS8 needs live, aligned audio. + while not self._audio.empty(): + self._audio.get_nowait() + log.info("connected to decoder agent %s", self.agent_url) + try: + await ws.send(json.dumps({"t": "hello", "bridge": __version__})) + async with asyncio.TaskGroup() as tg: + tg.create_task(self._send_audio(ws)) + tg.create_task(self._receive(ws)) + except* ConnectionClosed as eg: + raise DecoderUnavailable(f"decoder agent link closed: {eg.exceptions[0]}") from None + finally: + self._ws = None + await ws.close() + + async def close(self) -> None: + if self._ws is not None: + await self._ws.close() + + async def _send_audio(self, ws: ClientConnection) -> None: + while True: + await ws.send(await self._audio.get()) + + async def _receive(self, ws: ClientConnection) -> None: + async for raw in ws: + if isinstance(raw, bytes): + continue + try: + payload = json.loads(raw) + except json.JSONDecodeError: + log.warning("agent sent non-JSON text") + continue + if isinstance(payload, dict): + self._dispatch(payload) + raise ConnectionClosed(None, None) + + def _dispatch(self, payload: dict[str, Any]) -> None: + kind = payload.get("t") + if kind == "health": + self.sink.on_health(proto.decode_health(payload)) + elif kind == "js8": + msg = payload.get("msg") + received = proto.from_utc_ms(payload.get("rx_utc_ms")) or self._clock.utc_now() + if not isinstance(msg, dict): + return + for event in parse_js8_message(msg, received_utc=received): + if isinstance(event, DecodeEvent): + self.sink.on_decode(event) + elif isinstance(event, SpotEvent): + self.sink.on_spot(event) diff --git a/bridge/src/ghostjs8/decoders/js8call_native/agent.py b/bridge/src/ghostjs8/decoders/js8call_native/agent.py new file mode 100644 index 0000000..6d9cd66 --- /dev/null +++ b/bridge/src/ghostjs8/decoders/js8call_native/agent.py @@ -0,0 +1,342 @@ +"""decoder-agent: runs inside the decoder container next to JS8Call. + +- Accepts one bridge WebSocket (``/agent``); a newer connection replaces an older one. +- Writes incoming PCM into the PulseAudio null sink ``ghost_rx`` through ``pacat`` + with a bounded queue (drop, never grow latency). +- Owns JS8Call's UDP API on localhost: learns the reply port from incoming + datagrams, forwards decode/station messages, and polls call activity. +- Reports health facts: JS8Call process state, whether JS8Call holds an uncorked + capture stream on ``ghost_rx_in``, API heartbeat (PING) and request/reply + round trips, and when audio was last written. + +Run: ``python -m ghostjs8.decoders.js8call_native.agent`` +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import itertools +import json +import logging +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from websockets.asyncio.server import ServerConnection, serve +from websockets.exceptions import ConnectionClosed + +from ghostjs8.decoders.base import DecoderHealth +from ghostjs8.decoders.js8call_native import agent_protocol as proto +from ghostjs8.decoders.js8call_native.messages import FORWARDED_TYPES +from ghostjs8.util.clock import Clock, SystemClock +from ghostjs8.util.logs import configure + +log = logging.getLogger("ghostjs8.agent") + +# Read-only requests the agent may send to JS8Call. Nothing else, ever: in +# particular no TX.* type can be constructed by this module. +ALLOWED_REQUESTS = frozenset({"RX.GET_CALL_ACTIVITY", "STATION.GET_CALLSIGN"}) + +PACAT_CMD = ( + "pacat", + "--playback", + "--device=ghost_rx", + "--raw", + "--format=s16le", + "--rate=12000", + "--channels=1", + "--latency-msec=200", + "--client-name=ghost-agent", + "--stream-name=receiver", +) + + +# --------------------------------------------------------------------- UDP API + + +class Js8UdpApi(asyncio.DatagramProtocol): + """JS8Call's UDP API endpoint on localhost.""" + + def __init__(self, clock: Clock, forward: asyncio.Queue[str]) -> None: + self._clock = clock + self._forward = forward + self._transport: asyncio.DatagramTransport | None = None + self.reply_addr: tuple[str, int] | None = None + self.last_ping: datetime | None = None + self.last_reply: datetime | None = None + self._pending: set[int] = set() + self._ids = itertools.count(1) + self.dropped_forwards = 0 + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + if isinstance(transport, asyncio.DatagramTransport): + self._transport = transport + + def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None: + # Learn where JS8Call listens for replies: the source of its datagrams. + self.reply_addr = (str(addr[0]), int(addr[1])) + try: + message = json.loads(data) + except (json.JSONDecodeError, UnicodeDecodeError): + log.warning("ignoring non-JSON datagram from %s", addr) + return + if not isinstance(message, dict): + return + mtype = message.get("type") + now = self._clock.utc_now() + if mtype == "PING": + self.last_ping = now + params = message.get("params") + msg_id = params.get("_ID") if isinstance(params, dict) else None + if isinstance(msg_id, int) and msg_id in self._pending: + self._pending.discard(msg_id) + self.last_reply = now + if mtype in FORWARDED_TYPES: + try: + self._forward.put_nowait(proto.encode_js8(message, now)) + except asyncio.QueueFull: + self.dropped_forwards += 1 + + def request(self, mtype: str) -> bool: + """Send a read-only request to JS8Call. False if we do not know where yet.""" + if mtype not in ALLOWED_REQUESTS: + raise ValueError(f"request type not allowed: {mtype}") + if self._transport is None or self.reply_addr is None: + return False + msg_id = next(self._ids) + self._pending.add(msg_id) + if len(self._pending) > 32: # replies lost; do not grow without bound + self._pending = {msg_id} + payload = {"type": mtype, "value": "", "params": {"_ID": msg_id}} + self._transport.sendto(json.dumps(payload).encode(), self.reply_addr) + return True + + +# ------------------------------------------------------------------- PCM sink + + +@dataclass +class PcmSink: + """Bounded PCM pipe into PulseAudio via a long-running pacat process.""" + + clock: Clock + command: Sequence[str] = PACAT_CMD + max_blocks: int = 48 # ~2 s of 512-sample blocks + queue: asyncio.Queue[bytes] = field(init=False) + last_write: datetime | None = None + dropped: int = 0 + + def __post_init__(self) -> None: + self.queue = asyncio.Queue(maxsize=self.max_blocks) + + def submit(self, pcm: bytes) -> None: + if self.queue.full(): + with contextlib.suppress(asyncio.QueueEmpty): + self.queue.get_nowait() # drop oldest: keep latency bounded + self.dropped += 1 + self.queue.put_nowait(pcm) + + async def run(self) -> None: + while True: + proc = await asyncio.create_subprocess_exec( + *self.command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL + ) + log.info("pcm writer started (pid %s)", proc.pid) + try: + await self._pump(proc) + except (BrokenPipeError, ConnectionResetError) as exc: + log.warning("pcm writer pipe broke: %s", exc) + finally: + if proc.returncode is None: + proc.kill() + await proc.wait() + log.warning("pcm writer exited (%s); restarting", proc.returncode) + await asyncio.sleep(1.0) + + async def _pump(self, proc: asyncio.subprocess.Process) -> None: + if proc.stdin is None: # pragma: no cover - PIPE always set + return + while proc.returncode is None: + pcm = await self.queue.get() + proc.stdin.write(pcm) + await proc.stdin.drain() + self.last_write = self.clock.utc_now() + + +# ---------------------------------------------------------------- health probes + + +def parse_supervisor_status(output: str, program: str) -> bool: + for line in output.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[0] == program: + return parts[1] == "RUNNING" + return False + + +def js8call_capturing( + sources_json: str, outputs_json: str, source_name: str = "ghost_rx_in" +) -> bool: + """True if JS8Call holds an uncorked capture stream on the given source.""" + try: + sources = json.loads(sources_json) + outputs = json.loads(outputs_json) + except json.JSONDecodeError: + return False + indices = { + s.get("index") for s in sources if isinstance(s, dict) and s.get("name") == source_name + } + if not indices: + return False + for out in outputs: + if not isinstance(out, dict): + continue + props = out.get("properties") + binary = props.get("application.process.binary") if isinstance(props, dict) else None + if out.get("source") in indices and binary == "JS8Call" and out.get("corked") is False: + return True + return False + + +async def _run(*cmd: str) -> str: + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL + ) + try: + out, _ = await asyncio.wait_for(proc.communicate(), 4) + except TimeoutError: + proc.kill() + return "" + return out.decode(errors="replace") + + +async def probe_container() -> tuple[bool, bool]: + status = await _run( + "supervisorctl", "-c", "/etc/ghostjs8/supervisord.conf", "status", "js8call" + ) + sources = await _run("pactl", "-f", "json", "list", "short", "sources") + outputs = await _run("pactl", "-f", "json", "list", "source-outputs") + return parse_supervisor_status(status, "js8call"), js8call_capturing(sources, outputs) + + +# ---------------------------------------------------------------------- agent + +Probe = Callable[[], Awaitable[tuple[bool, bool]]] + + +class Agent: + def __init__( + self, + *, + clock: Clock | None = None, + udp_host: str = "127.0.0.1", + udp_port: int = 2242, + pcm: PcmSink | None = None, + probe: Probe = probe_container, + health_interval_s: float = 3.0, + call_activity_interval_s: float = 15.0, + ) -> None: + self.clock = clock or SystemClock() + self.forward: asyncio.Queue[str] = asyncio.Queue(maxsize=512) + self.udp = Js8UdpApi(self.clock, self.forward) + self.pcm = pcm or PcmSink(self.clock) + self._probe = probe + self._udp_addr = (udp_host, udp_port) + self._health_interval_s = health_interval_s + self._call_activity_interval_s = call_activity_interval_s + self._bridge: ServerConnection | None = None + self.health = DecoderHealth(False, False, None, None, None) + + async def serve(self, host: str, port: int) -> None: + loop = asyncio.get_running_loop() + transport, _ = await loop.create_datagram_endpoint( + lambda: self.udp, local_addr=self._udp_addr + ) + try: + async with ( + serve(self._handle, host, port, compression=None, max_size=2**20), + asyncio.TaskGroup() as tg, + ): + log.info( + "agent listening ws://%s:%d%s, udp %s:%d", + host, + port, + proto.AGENT_PATH, + *self._udp_addr, + ) + tg.create_task(self.pcm.run()) + tg.create_task(self._health_loop()) + tg.create_task(self._call_activity_loop()) + tg.create_task(self._forward_loop()) + finally: + transport.close() + + async def _handle(self, ws: ServerConnection) -> None: + if ws.request is None or ws.request.path != proto.AGENT_PATH: + await ws.close(1008, "unknown path") + return + if self._bridge is not None: + await self._bridge.close(1000, "replaced by a newer bridge connection") + self._bridge = ws + log.info("bridge connected from %s", ws.remote_address) + try: + await ws.send(proto.encode_health(self.health, self.clock.utc_now())) + async for message in ws: + if isinstance(message, bytes): + self.pcm.submit(message) + except ConnectionClosed: + pass + finally: + if self._bridge is ws: + self._bridge = None + log.info("bridge disconnected") + + async def _send(self, text: str) -> None: + ws = self._bridge + if ws is None: + return + with contextlib.suppress(ConnectionClosed): + await ws.send(text) + + async def _forward_loop(self) -> None: + while True: + await self._send(await self.forward.get()) + + async def _health_loop(self) -> None: + while True: + process_up, capturing = await self._probe() + self.health = DecoderHealth( + process_up=process_up, + capturing=capturing, + last_ping_utc=self.udp.last_ping, + last_api_reply_utc=self.udp.last_reply, + last_audio_write_utc=self.pcm.last_write, + dropped_audio_blocks=self.pcm.dropped, + details={ + "udp_reply_port": str(self.udp.reply_addr[1]) if self.udp.reply_addr else "" + }, + ) + await self._send(proto.encode_health(self.health, self.clock.utc_now())) + await asyncio.sleep(self._health_interval_s) + + async def _call_activity_loop(self) -> None: + while True: + await asyncio.sleep(self._call_activity_interval_s) + self.udp.request("RX.GET_CALL_ACTIVITY") + + +def main(argv: list[str] | None = None) -> None: + p = argparse.ArgumentParser(description="ghost.js8 decoder-agent") + p.add_argument("--host", default="0.0.0.0") # noqa: S104 - internal container network + p.add_argument("--port", type=int, default=proto.DEFAULT_AGENT_PORT) + p.add_argument("--udp-port", type=int, default=2242) + args = p.parse_args(argv) + configure("decoder-agent") + asyncio.run(Agent(udp_port=args.udp_port).serve(args.host, args.port)) + + +if __name__ == "__main__": + main() diff --git a/bridge/src/ghostjs8/decoders/js8call_native/agent_protocol.py b/bridge/src/ghostjs8/decoders/js8call_native/agent_protocol.py new file mode 100644 index 0000000..5b64746 --- /dev/null +++ b/bridge/src/ghostjs8/decoders/js8call_native/agent_protocol.py @@ -0,0 +1,73 @@ +"""Bridge <-> decoder-agent wire protocol (internal network only). + +One WebSocket at ``ws://:8074/agent``. + +bridge -> agent + binary mono s16le PCM at 12 kHz, any block size + text {"t": "hello", "bridge": ""} + +agent -> bridge (text, JSON) + {"t": "js8", "rx_utc_ms": int, "msg": {}} + {"t": "health", "utc_ms": int, "process_up": bool, "capturing": bool, + "last_ping_utc_ms": int|null, "last_api_reply_utc_ms": int|null, + "last_audio_write_utc_ms": int|null, "dropped_audio_blocks": int, + "details": {str: str}} + +The agent forwards JS8Call messages verbatim; normalization happens in the +bridge (``messages.py``) where it is unit-tested without a container. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +from ghostjs8.decoders.base import DecoderHealth + +AGENT_PATH = "/agent" +DEFAULT_AGENT_PORT = 8074 + + +def utc_ms(dt: datetime | None) -> int | None: + return None if dt is None else round(dt.timestamp() * 1000) + + +def from_utc_ms(ms: object) -> datetime | None: + if isinstance(ms, int) and not isinstance(ms, bool) and ms > 0: + return datetime.fromtimestamp(ms / 1000, tz=UTC) + return None + + +def encode_js8(message: dict[str, Any], received: datetime) -> str: + return json.dumps({"t": "js8", "rx_utc_ms": utc_ms(received), "msg": message}) + + +def encode_health(health: DecoderHealth, now: datetime) -> str: + return json.dumps( + { + "t": "health", + "utc_ms": utc_ms(now), + "process_up": health.process_up, + "capturing": health.capturing, + "last_ping_utc_ms": utc_ms(health.last_ping_utc), + "last_api_reply_utc_ms": utc_ms(health.last_api_reply_utc), + "last_audio_write_utc_ms": utc_ms(health.last_audio_write_utc), + "dropped_audio_blocks": health.dropped_audio_blocks, + "details": health.details, + } + ) + + +def decode_health(payload: dict[str, Any]) -> DecoderHealth: + details = payload.get("details") + dropped = payload.get("dropped_audio_blocks") + return DecoderHealth( + process_up=payload.get("process_up") is True, + capturing=payload.get("capturing") is True, + last_ping_utc=from_utc_ms(payload.get("last_ping_utc_ms")), + last_api_reply_utc=from_utc_ms(payload.get("last_api_reply_utc_ms")), + last_audio_write_utc=from_utc_ms(payload.get("last_audio_write_utc_ms")), + dropped_audio_blocks=dropped if isinstance(dropped, int) else 0, + details={str(k): str(v) for k, v in details.items()} if isinstance(details, dict) else {}, + ) diff --git a/bridge/src/ghostjs8/decoders/js8call_native/messages.py b/bridge/src/ghostjs8/decoders/js8call_native/messages.py new file mode 100644 index 0000000..f12e62b --- /dev/null +++ b/bridge/src/ghostjs8/decoders/js8call_native/messages.py @@ -0,0 +1,169 @@ +"""Normalize JS8Call UDP API messages into decoder events. + +Message shape: ``{"type": str, "value": str, "params": {...}}``. Observed on +JS8Call 3.0.3 (see docs/decoder-container.md): + +- ``RX.ACTIVITY``: ``value`` is the full line, e.g. ``"K0OG: KN4CRD SNR +02 "``. +- ``RX.DIRECTED``: ``value`` omits the sender (``params.FROM``) and ends with + the end-of-message marker ``♢``. We display ``FROM: value`` like JS8Call does. +- ``RX.CALL_ACTIVITY``: reply to ``RX.GET_CALL_ACTIVITY``; ``params`` maps each + heard callsign to ``{SNR, GRID, UTC}`` (plus ``_ID``). This is our station + source: JS8Call only emits ``RX.SPOT`` when spotting to PSKReporter is on, + which we keep off. +- ``RX.SPOT``: parsed if present (``params.CALL``), for completeness. + +Unknown types and malformed payloads return None; they never raise. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, datetime +from typing import Any + +from ghostjs8.decoders.base import DecodeEvent, SpotEvent + +EOM_MARKERS = ("♢",) # JS8 end-of-message glyph appended to the last frame + +SPEEDS = {0: "normal", 1: "fast", 2: "turbo", 4: "slow", 8: "ultra"} + +FORWARDED_TYPES = frozenset({"RX.ACTIVITY", "RX.DIRECTED", "RX.SPOT", "RX.CALL_ACTIVITY"}) + + +def clean_text(text: str) -> str: + out = text.strip() + for marker in EOM_MARKERS: + out = out.removesuffix(marker).rstrip() + return out + + +def _int(params: Mapping[str, Any], key: str) -> int | None: + value = params.get(key) + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return round(value) + return None + + +def _str(params: Mapping[str, Any], key: str) -> str | None: + value = params.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _utc(params: Mapping[str, Any], fallback: datetime) -> datetime: + ms = _int(params, "UTC") + if ms is None or ms <= 0: + return fallback + return datetime.fromtimestamp(ms / 1000, tz=UTC) + + +def _speed(params: Mapping[str, Any]) -> str: + sub = _int(params, "SPEED") + return SPEEDS.get(sub, "unknown") if sub is not None else "unknown" + + +def _float(params: Mapping[str, Any], key: str) -> float | None: + value = params.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + return None + + +def parse_js8_message( + message: Mapping[str, Any], *, received_utc: datetime +) -> list[DecodeEvent | SpotEvent]: + """Events carried by one JS8Call API message (often none).""" + event = _parse_single(message, received_utc=received_utc) + if event is not None: + return [event] + if message.get("type") == "RX.CALL_ACTIVITY": + return list(_parse_call_activity(message, received_utc=received_utc)) + return [] + + +def _parse_call_activity( + message: Mapping[str, Any], *, received_utc: datetime +) -> list[DecodeEvent | SpotEvent]: + params = message.get("params") + if not isinstance(params, Mapping): + return [] + out: list[DecodeEvent | SpotEvent] = [] + for call, detail in params.items(): + if not isinstance(call, str) or call.startswith("_") or not isinstance(detail, Mapping): + continue + callsign = call.strip() + if not callsign: + continue + out.append( + SpotEvent( + callsign=callsign, + utc=_utc(detail, received_utc), + snr_db=_int(detail, "SNR"), + grid=_str(detail, "GRID"), + ) + ) + return out + + +def _parse_single( + message: Mapping[str, Any], *, received_utc: datetime +) -> DecodeEvent | SpotEvent | None: + mtype = message.get("type") + value = message.get("value") + params = message.get("params") + if not isinstance(mtype, str) or not isinstance(params, Mapping): + return None + text = value if isinstance(value, str) else "" + utc = _utc(params, received_utc) + + if mtype in ("RX.ACTIVITY", "RX.DIRECTED"): + snr, offset = _int(params, "SNR"), _int(params, "OFFSET") + if snr is None or offset is None: + return None + body = clean_text(text) + if not body: + return None + if mtype == "RX.DIRECTED": + sender = _str(params, "FROM") + display = f"{sender}: {body}" if sender else body + return DecodeEvent( + kind="directed", + utc=utc, + text=display, + snr_db=snr, + offset_hz=offset, + speed=_speed(params), + from_call=sender, + to_call=_str(params, "TO"), + grid=_str(params, "GRID"), + tdrift_s=_float(params, "TDRIFT"), + ) + sender, sep, _ = body.partition(":") + return DecodeEvent( + kind="activity", + utc=utc, + text=body, + snr_db=snr, + offset_hz=offset, + speed=_speed(params), + from_call=sender.strip() if sep and " " not in sender.strip() else None, + tdrift_s=_float(params, "TDRIFT"), + ) + + if mtype == "RX.SPOT": + call = _str(params, "CALL") or _str(params, "CALLSIGN") + if call is None: + return None + return SpotEvent( + callsign=call, + utc=utc, + snr_db=_int(params, "SNR"), + offset_hz=_int(params, "OFFSET"), + grid=_str(params, "GRID"), + ) + return None diff --git a/bridge/src/ghostjs8/ghostnet/__init__.py b/bridge/src/ghostjs8/ghostnet/__init__.py new file mode 100644 index 0000000..dc0f1e1 --- /dev/null +++ b/bridge/src/ghostjs8/ghostnet/__init__.py @@ -0,0 +1 @@ +"""GhostNet monitoring: schedule, receiver picking, recording and auto-pilot.""" diff --git a/bridge/src/ghostjs8/ghostnet/picker.py b/bridge/src/ghostjs8/ghostnet/picker.py new file mode 100644 index 0000000..2d5f6f3 --- /dev/null +++ b/bridge/src/ghostjs8/ghostnet/picker.py @@ -0,0 +1,68 @@ +"""Pick a nearby public KiwiSDR for a given frequency. + +Ranking: great-circle distance from the operator's home grid, among receivers +that cover the dial, have a free slot, and (when they report it) at least a +minimal HF SNR. Receivers that recently rejected or failed us sit out a +cooldown so we move on instead of hammering them. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from ghostjs8.contract.messages import ReceiverListing + +EARTH_RADIUS_KM = 6371.0 +MIN_SNR_DB = 15 # directory "snr" (HF band average); below this a receiver is mostly noise +DEFAULT_COOLDOWN = timedelta(minutes=30) + + +def distance_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + p1, p2 = math.radians(lat1), math.radians(lat2) + dp, dl = p2 - p1, math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a))) + + +@dataclass(frozen=True, slots=True) +class Candidate: + listing: ReceiverListing + distance_km: float + + @property + def reason(self) -> str: + snr = f", SNR {self.listing.snr_db} dB" if self.listing.snr_db is not None else "" + return f"nearest free receiver: {self.distance_km:,.0f} km{snr}" + + +class ReceiverPicker: + def __init__( + self, home: tuple[float, float], *, cooldown: timedelta = DEFAULT_COOLDOWN + ) -> None: + self.home = home + self._cooldown = cooldown + self._benched: dict[str, datetime] = {} + + def bench(self, listing_id: str, now: datetime) -> None: + """Skip this receiver until the cooldown expires (it rejected or failed us).""" + self._benched[listing_id] = now + self._cooldown + + def rank( + self, receivers: Iterable[ReceiverListing], dial_hz: int, now: datetime + ) -> list[Candidate]: + self._benched = {k: v for k, v in self._benched.items() if v > now} + out: list[Candidate] = [] + for r in receivers: + if r.id in self._benched or r.lat is None or r.lon is None: + continue + if not (r.min_hz <= dial_hz and dial_hz + 3000 <= r.max_hz): + continue + if r.users_max > 0 and r.users >= r.users_max: + continue + if r.snr_db is not None and r.snr_db < MIN_SNR_DB: + continue + out.append(Candidate(r, distance_km(self.home[0], self.home[1], r.lat, r.lon))) + return sorted(out, key=lambda c: (c.distance_km, -(c.listing.snr_db or 0))) diff --git a/bridge/src/ghostjs8/ghostnet/pilot.py b/bridge/src/ghostjs8/ghostnet/pilot.py new file mode 100644 index 0000000..4d90764 --- /dev/null +++ b/bridge/src/ghostjs8/ghostnet/pilot.py @@ -0,0 +1,345 @@ +"""GhostNet autopilot: be on the net, on a nearby receiver, and record it. + +Every tick (5 s): + +- In a window (pre-roll included): make sure a net recording is running, the + station is tuned to the window's frequency, and it is on a nearby public + KiwiSDR. A receiver that rejects, fails, or does not connect within the grace + period is benched and the next-nearest is tried. +- Between windows: park on GhostNet's 40 m frequency and keep decoding + (persistent watch), unless parking is disabled. +- When a viewer takes manual control, the autopilot yields until the current + window ends (recording continues and is flagged), or, while parked, until + the next window. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Literal + +from ghostjs8.contract.messages import GhostNet, GhostNetWindow, NetSummary, ReceiverListing +from ghostjs8.ghostnet.picker import Candidate, ReceiverPicker +from ghostjs8.ghostnet.recorder import NetRecorder, prune_recordings +from ghostjs8.ghostnet.schedule import GHOSTNET_40M_HZ, Occurrence, Region, active, upcoming +from ghostjs8.receivers.base import Tuning +from ghostjs8.receivers.directory import Directory +from ghostjs8.session.station import ReceiverTarget, Station +from ghostjs8.settings import Settings +from ghostjs8.store.sqlite import Store +from ghostjs8.util.clock import Clock +from ghostjs8.util.maidenhead import grid_center, normalize_grid + +log = logging.getLogger("ghostjs8.ghostnet.pilot") + +Mode = Literal["off", "window", "parked", "paused"] +TICK_S = 5.0 +PRUNE_EVERY = timedelta(hours=1) + + +@dataclass(frozen=True, slots=True) +class PilotConfig: + region: Region + home_grid: str + home: tuple[float, float] + recordings: Path + preroll: timedelta = timedelta(minutes=10) + record_audio: bool = True + park: bool = True + park_dial_hz: int = GHOSTNET_40M_HZ + connect_grace: timedelta = timedelta(seconds=60) + net_retention: timedelta = timedelta(days=90) + + +def net_id(occ: Occurrence) -> str: + return f"{occ.window.id}-{occ.start.strftime('%Y%m%dT%H%MZ')}" + + +def _window_model(occ: Occurrence | None) -> GhostNetWindow | None: + if occ is None: + return None + w = occ.window + kind: Literal["net", "bridge"] = "net" if w.kind == "net" else "bridge" + return GhostNetWindow( + id=w.id, + label=w.label, + kind=kind, + band=w.band, + dial_hz=w.dial_hz, + start=occ.start, + end=occ.end, + ) + + +class GhostNetPilot: + def __init__( + self, + station: Station, + directory: Directory, + store: Store, + config: PilotConfig, + clock: Clock, + *, + picker: ReceiverPicker | None = None, + ) -> None: + self.station = station + self.directory = directory + self.store = store + self.config = config + self.clock = clock + self.picker = picker or ReceiverPicker(config.home) + self.recorder: NetRecorder | None = None + self.mode: Mode = "parked" if config.park else "off" + self.window: Occurrence | None = None + self.paused_until: datetime | None = None + self.listing: ReceiverListing | None = None + self.reason = "" + self.detail = "" + self._selected_at: datetime | None = None + self._last_status: str | None = None + self._last_prune: datetime | None = None + station.operator_action.append(self.operator_took_control) + station.join_messages.append(self.status) + + # ------------------------------------------------------------------ loop + + async def run(self) -> None: + try: + while True: + try: + await self.tick() + except Exception: + log.exception("ghostnet tick failed") + await asyncio.sleep(TICK_S) + finally: + self._stop_recording() + + async def tick(self) -> None: + now = self.clock.utc_now() + occ = active(self.config.region, now, self.config.preroll) + + if occ is not None and (self.window is None or net_id(occ) != net_id(self.window)): + self._stop_recording() + self.window = occ + self.paused_until = None # a new window always takes the station + self._start_recording(occ, now) + elif occ is None and self.window is not None: + self._stop_recording() + self.window = None + + if self.paused_until is not None and now >= self.paused_until: + self.paused_until = None + + if self.paused_until is not None: + self.mode = "paused" + elif occ is not None: + self.mode = "window" + await self._ensure_on(occ.window.dial_hz, now) + elif self.config.park: + self.mode = "parked" + await self._ensure_on(self.config.park_dial_hz, now) + else: + self.mode = "off" + + if self._last_prune is None or now - self._last_prune >= PRUNE_EVERY: + self._last_prune = now + removed = prune_recordings( + self.store, self.config.recordings, self.config.net_retention + ) + if removed: + log.info("pruned %d expired net recordings", removed) + + self._publish() + + def operator_took_control(self) -> None: + now = self.clock.utc_now() + if self.window is not None: + self.paused_until = self.window.end + if self.recorder is not None: + self.recorder.mark_override() + else: + nxt = upcoming(self.config.region, now, 1) + self.paused_until = (nxt[0].start - self.config.preroll) if nxt else None + self.mode = "paused" + self.detail = "a viewer took manual control" + self._publish() + + # ------------------------------------------------------------- receiver + + async def _ensure_on(self, dial_hz: int, now: datetime) -> None: + st = self.station + if st.tuning.dial_hz != dial_hz or st.tuning.mode != "usb": + await st.tune(Tuning(dial_hz=dial_hz, mode="usb", low_cut_hz=100, high_cut_hz=3000)) + if self._needs_new_receiver(dial_hz, now): + if self.listing is not None: + self.picker.bench(self.listing.id, now) + await self._pick(dial_hz, now) + + def _needs_new_receiver(self, dial_hz: int, now: datetime) -> bool: + st = self.station + ours = self.listing + if ( + st.target is None + or ours is None + or st.target.host != ours.host + or st.target.port != ours.port + ): + return True # not on a receiver we chose + if not (ours.min_hz <= dial_hz <= ours.max_hz): + return True + if st.state in ("rejected", "failed"): + return True + if st.state in ("connecting", "backoff") and self._selected_at is not None: + return now - self._selected_at > self.config.connect_grace + return False + + async def _pick(self, dial_hz: int, now: datetime) -> None: + listing = await self.directory.get() + candidates: list[Candidate] = self.picker.rank(listing.receivers, dial_hz, now) + if not candidates: + self.listing = None + self.reason = "" + self.detail = "no free public receiver covers this frequency right now; retrying" + return + best = candidates[0] + r = best.listing + self.listing = r + self.reason = best.reason + self.detail = "" + self._selected_at = now + log.info("ghostnet: selecting %s:%d (%s)", r.host, r.port, best.reason) + self.station.select_receiver(ReceiverTarget(r.host, r.port, name=r.name, tls=r.tls)) + if self.recorder is not None: + self.recorder.set_receiver(f"{r.name} ({r.host}:{r.port})", best.reason) + + # ------------------------------------------------------------ recording + + def _start_recording(self, occ: Occurrence, now: datetime) -> None: + w = occ.window + kind: Literal["net", "bridge"] = "net" if w.kind == "net" else "bridge" + summary = NetSummary( + id=net_id(occ), + window_id=w.id, + label=w.label, + kind=kind, + band=w.band, + dial_hz=w.dial_hz, + scheduled_start=occ.start, + scheduled_end=occ.end, + started=now, + ended=None, + receiver=None, + receiver_reason="", + decode_count=0, + station_count=0, + has_audio=False, + has_waterfall=False, + flash_count=0, + operator_override=False, + ) + existing = self.store.net(summary.id) + if existing is not None and existing.ended is not None: + return # already recorded (e.g. bridge restarted after the window) + self.recorder = NetRecorder( + self.store, + self.clock, + self.config.recordings, + summary, + record_audio=self.config.record_audio, + ) + self.station.observers.append(self.recorder) + if self.listing is not None: + self.recorder.set_receiver( + f"{self.listing.name} ({self.listing.host}:{self.listing.port})", self.reason + ) + + def _stop_recording(self) -> None: + rec = self.recorder + if rec is None: + return + self.recorder = None + if rec in self.station.observers: + self.station.observers.remove(rec) + rec.finish() + + # --------------------------------------------------------------- status + + def status(self) -> GhostNet: + now = self.clock.utc_now() + nxt = [ + o + for o in upcoming(self.config.region, now, 3) + if self.window is None or net_id(o) != net_id(self.window) + ] + return GhostNet( + enabled=True, + region=self.config.region.value, + home_grid=self.config.home_grid, + mode=self.mode, + window=_window_model(self.window), + next_window=_window_model(nxt[0] if nxt else None), + recording_net_id=self.recorder.summary.id if self.recorder else None, + receiver_reason=self.reason, + paused_until=self.paused_until, + detail=self.detail, + ) + + def _publish(self) -> None: + msg = self.status() + key = msg.model_dump_json(exclude={"v"}) + if key != self._last_status: + self._last_status = key + self.station.hub.broadcast(msg) + + +def disabled_status(detail: str) -> GhostNet: + return GhostNet( + enabled=False, + region=None, + home_grid=None, + mode="off", + window=None, + next_window=None, + recording_net_id=None, + receiver_reason="", + paused_until=None, + detail=detail, + ) + + +def build_pilot( + settings: Settings, station: Station, directory: Directory, clock: Clock +) -> tuple[GhostNetPilot | None, str]: + """Construct the autopilot from settings, or explain why it is off.""" + if not settings.ghostnet: + return None, "GhostNet autopilot is off (GHOSTJS8_GHOSTNET=on to enable)" + try: + region = Region(settings.ghostnet_region) + except ValueError: + return ( + None, + f"GHOSTJS8_GHOSTNET_REGION must be na, eu or aus (got {settings.ghostnet_region!r})", + ) + grid = normalize_grid(settings.home_grid) + if grid is None: + return ( + None, + f"GHOSTJS8_HOME_GRID must be a 4/6/8-character Maidenhead grid (got {settings.home_grid!r})", + ) + if station.store is None: + return None, "GhostNet recording needs persistence: set GHOSTJS8_DB_PATH" + config = PilotConfig( + region=region, + home_grid=grid, + home=grid_center(grid), + recordings=Path(settings.recordings_dir), + preroll=timedelta(minutes=max(0, settings.ghostnet_preroll_minutes)), + record_audio=settings.ghostnet_record_audio, + park=settings.ghostnet_park, + net_retention=timedelta(days=max(1, settings.net_retention_days)), + ) + return GhostNetPilot(station, directory, station.store, config, clock), "" diff --git a/bridge/src/ghostjs8/ghostnet/recorder.py b/bridge/src/ghostjs8/ghostnet/recorder.py new file mode 100644 index 0000000..566fe6d --- /dev/null +++ b/bridge/src/ghostjs8/ghostnet/recorder.py @@ -0,0 +1,283 @@ +"""Record one GhostNet window: traffic, a waterfall image, and receiver audio. + +Files live in ``//``: + +- ``audio.flac`` mono 16-bit 12 kHz receiver audio, written as it arrives +- ``waterfall.png`` one row per second (newest at the bottom), audio offsets + -500..+3500 Hz at 10 Hz per pixel, "spectre" palette + +Traffic is copied into the store per net (``net_decodes``), so a net log does +not depend on the shorter general decode retention. +""" + +from __future__ import annotations + +import logging +import re +import shutil +import struct +import zlib +from collections.abc import Callable +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from ghostjs8.contract.messages import Decode, NetSummary, Station +from ghostjs8.receivers.base import AUDIO_SAMPLE_RATE, AudioBlock, WaterfallRow +from ghostjs8.store.sqlite import Store +from ghostjs8.util.clock import Clock +from ghostjs8.util.maidenhead import normalize_grid + +log = logging.getLogger("ghostjs8.ghostnet.recorder") + +OFFSET_LO_HZ = -500 +OFFSET_HI_HZ = 3500 +WATERFALL_WIDTH = (OFFSET_HI_HZ - OFFSET_LO_HZ) // 10 # 10 Hz per pixel +LEVEL_FLOOR = 255 - 120 # raw u8 mapped to palette index 0 (matches the web default) +LEVEL_CEILING = 255 - 40 +MAX_WATERFALL_ROWS = 4 * 3600 # hard cap: four hours at one row per second +FLASH = re.compile(r"@GSTFLASH\b", re.IGNORECASE) + +# "spectre" colormap stops, identical to web/src/lib/waterfall/colormap.ts +_SPECTRE = ( # keep in step with web/src/lib/waterfall/colormap.ts SPECTRE + (0.0, 3, 4, 12), + (0.3, 22, 20, 68), + (0.5, 62, 44, 140), + (0.66, 46, 130, 206), + (0.8, 94, 234, 212), + (0.92, 204, 250, 240), + (1.0, 255, 255, 255), +) + + +def spectre_palette() -> bytes: + out = bytearray() + for i in range(256): + t = i / 255 + k = 0 + while k < len(_SPECTRE) - 2 and t > _SPECTRE[k + 1][0]: + k += 1 + a, b = _SPECTRE[k], _SPECTRE[k + 1] + f = 0.0 if b[0] == a[0] else (t - a[0]) / (b[0] - a[0]) + out += bytes(round(a[c] + (b[c] - a[c]) * f) for c in (1, 2, 3)) + return bytes(out) + + +def encode_png_palette(rows: list[bytes], width: int, palette: bytes) -> bytes: + """Minimal PNG (8-bit indexed colour). Stdlib only.""" + + def chunk(kind: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + kind + + data + + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + ) + + height = max(1, len(rows)) + raw = b"".join(b"\x00" + r for r in rows) if rows else b"\x00" + bytes(width) + ihdr = struct.pack(">IIBBBBB", width, height, 8, 3, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"PLTE", palette) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +class WaterfallAccumulator: + """Reduces incoming rows to one fixed-width row per second (peak hold).""" + + def __init__(self, started: datetime, dial_hz: int, *, lsb: bool = False) -> None: + self.started = started + self.dial_hz = dial_hz + self.lsb = lsb + self.rows: list[bytes] = [] + self._current: bytearray | None = None + self._second = -1 + + def add(self, row: WaterfallRow, now: datetime) -> None: + second = int((now - self.started).total_seconds()) + if second < 0 or second >= MAX_WATERFALL_ROWS: + return + if second != self._second: + self._flush_until(second) + self._second = second + self._current = bytearray(WATERFALL_WIDTH) + if self._current is None: # pragma: no cover - set above + return + mapped = self._map(row) + cur = self._current + for x in range(WATERFALL_WIDTH): + v = mapped[x] + cur[x] = max(cur[x], v) + + def finish(self) -> list[bytes]: + if self._current is not None: + self._flush_until(self._second + 1) + return self.rows + + def _flush_until(self, second: int) -> None: + if self._current is not None: + self.rows.append(bytes(self._current)) + self._current = None + while len(self.rows) < second: + self.rows.append(bytes(WATERFALL_WIDTH)) # gap: no rows arrived that second + + def _map(self, row: WaterfallRow) -> bytes: + n = len(row.bins) + out = bytearray(WATERFALL_WIDTH) + if n == 0 or row.span_hz <= 0: + return bytes(out) + bins_per_hz = n / row.span_hz + scale = 255 / (LEVEL_CEILING - LEVEL_FLOOR) + for x in range(WATERFALL_WIDTH): + offset = OFFSET_LO_HZ + x * 10 + lo_hz = self.dial_hz - offset - 10 if self.lsb else self.dial_hz + offset + b0 = int((lo_hz - row.start_hz) * bins_per_hz) + b1 = max(b0 + 1, int((lo_hz + 10 - row.start_hz) * bins_per_hz)) + if b1 <= 0 or b0 >= n: + continue + peak = max(row.bins[max(0, b0) : min(n, b1)]) + out[x] = min(255, max(0, round((peak - LEVEL_FLOOR) * scale))) + return bytes(out) + + +AudioWriterFactory = Callable[[Path], Any] + + +def _open_flac(path: Path) -> Any: # noqa: ANN401 - soundfile ships without type hints + import soundfile # noqa: PLC0415 - heavy import only when recording audio + + return soundfile.SoundFile( + str(path), "w", samplerate=AUDIO_SAMPLE_RATE, channels=1, format="FLAC", subtype="PCM_16" + ) + + +class NetRecorder: + """Observes a station for the length of one window.""" + + def __init__( + self, + store: Store, + clock: Clock, + root: Path, + summary: NetSummary, + *, + record_audio: bool = True, + lsb: bool = False, + open_audio: AudioWriterFactory = _open_flac, + ) -> None: + self.store = store + self.clock = clock + self.dir = root / summary.id + self.summary = summary + self._audio: Any = None + self._decodes = 0 + self._flash = 0 + self._closed = False + self.dir.mkdir(parents=True, exist_ok=True) + if record_audio: + try: + self._audio = open_audio(self.dir / "audio.flac") + except Exception: + log.exception("audio recording unavailable; continuing without it") + self._wf = WaterfallAccumulator(summary.started, summary.dial_hz, lsb=lsb) + self.store.save_net(summary.model_copy(update={"has_audio": self._audio is not None})) + log.info("recording %s (%s)", summary.label, summary.id, extra={"net": summary.id}) + + # station observer ------------------------------------------------- + + def on_audio(self, block: AudioBlock) -> None: + if self._audio is not None and not self._closed: + self._audio.buffer_write(block.pcm_s16le, dtype="int16") + + def on_waterfall(self, row: WaterfallRow) -> None: + if not self._closed: + self._wf.add(row, self.clock.utc_now()) + + def on_decode(self, decode: Decode) -> None: + if self._closed: + return + self.store.add_net_decode(self.summary.id, decode) + self._decodes += 1 + if FLASH.search(decode.text): + self._flash += 1 + + # lifecycle ---------------------------------------------------------- + + def mark_override(self) -> None: + if not self.summary.operator_override: + self.summary = self.summary.model_copy(update={"operator_override": True}) + self.store.save_net(self.summary) + + def set_receiver(self, receiver: str, reason: str) -> None: + self.summary = self.summary.model_copy( + update={"receiver": receiver, "receiver_reason": reason} + ) + self.store.save_net(self.summary) + + def finish(self) -> NetSummary: + if self._closed: + return self.summary + self._closed = True + has_audio = False + if self._audio is not None: + self._audio.close() + has_audio = (self.dir / "audio.flac").stat().st_size > 0 + rows = self._wf.finish() + if rows: + (self.dir / "waterfall.png").write_bytes( + encode_png_palette(rows, WATERFALL_WIDTH, spectre_palette()) + ) + decodes = self.store.net_decodes(self.summary.id) + self.summary = self.summary.model_copy( + update={ + "ended": self.clock.utc_now(), + "decode_count": len(decodes), + "station_count": len(net_stations(self.store, decodes)), + "flash_count": self._flash, + "has_audio": has_audio, + "has_waterfall": bool(rows), + } + ) + self.store.save_net(self.summary) + log.info( + "recorded %s: %d decodes", self.summary.id, len(decodes), extra={"net": self.summary.id} + ) + return self.summary + + +def net_stations(store: Store, decodes: list[Decode]) -> list[Station]: + """Stations heard in a net, from its own traffic plus known grids.""" + seen: dict[str, Station] = {} + for d in decodes: + call = (d.from_call or "").strip().upper() + if not call or call.startswith("@"): + continue + known = store.station(call) + prev = seen.get(call) + grid = ( + normalize_grid(d.grid) + or (prev.grid if prev else None) + or (known.grid if known else None) + ) + seen[call] = Station( + callsign=call, + last_heard_utc=d.utc, + snr_db=d.snr_db, + grid=grid, + offset_hz=d.offset_hz, + dial_hz=d.dial_hz, + heard_count=(prev.heard_count + (1 if d.kind == "activity" else 0)) if prev else 1, + ) + return sorted(seen.values(), key=lambda s: s.callsign) + + +def prune_recordings(store: Store, root: Path, older_than: timedelta) -> int: + expired = store.expired_nets(older_than) + for net_id in expired: + shutil.rmtree(root / net_id, ignore_errors=True) + store.delete_net(net_id) + return len(expired) diff --git a/bridge/src/ghostjs8/ghostnet/schedule.py b/bridge/src/ghostjs8/ghostnet/schedule.py new file mode 100644 index 0000000..268848a --- /dev/null +++ b/bridge/src/ghostjs8/ghostnet/schedule.py @@ -0,0 +1,212 @@ +"""The GhostNet v1.5 JS8Call comm windows, as data. + +Source: "GhostNet Version 1.5", S2 Underground (CC BY-NC-SA 4.0), pages 12-18. +Only the JS8Call windows are listed; Winlink, RTTY, ALE and voice windows are +other modes this project does not decode. + +All times are UTC. The North America "Thursday night" net is Friday 01:00 UTC. +GhostNet's 40 m JS8 frequency is 7.107 MHz, deliberately *not* JS8Call's +standard 7.078 MHz. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from datetime import datetime, time, timedelta + +GHOSTNET_40M_HZ = 7_107_000 +GHOSTNET_20M_HZ = 14_107_000 +GHOSTNET_80M_HZ = 3_575_000 + +MON, TUE, WED, THU, FRI, SAT, SUN = range(7) + + +class Region(enum.StrEnum): + NA = "na" + EU = "eu" + AUS = "aus" + + +@dataclass(frozen=True, slots=True) +class NetWindow: + id: str + label: str + kind: str # "net" | "bridge" + weekday: int # UTC weekday, Monday = 0 + start: time # UTC + minutes: int + dial_hz: int + band: str + regions: frozenset[Region] + + def occurrence_from(self, now: datetime) -> Occurrence: + """The occurrence that is in progress at ``now`` or starts after it.""" + day0 = now.replace(hour=0, minute=0, second=0, microsecond=0) + days = (self.weekday - now.weekday()) % 7 + for offset in (days - 7, days, days + 7): + start = day0 + timedelta(days=offset, hours=self.start.hour, minutes=self.start.minute) + end = start + timedelta(minutes=self.minutes) + if end > now: + return Occurrence(self, start, end) + raise AssertionError("unreachable: a weekly window always recurs within 14 days") + + +@dataclass(frozen=True, slots=True) +class Occurrence: + window: NetWindow + start: datetime + end: datetime + + @property + def key(self) -> str: + """Stable id for one occurrence, e.g. ``na-thu-net@2026-10-09T01:00Z``.""" + return f"{self.window.id}@{self.start.strftime('%Y-%m-%dT%H:%MZ')}" + + +def _w( # noqa: PLR0917 - compact table rows below + id: str, + label: str, + kind: str, + weekday: int, + hh: int, + mm: int, + minutes: int, + dial_hz: int, + band: str, + *regions: Region, +) -> NetWindow: + return NetWindow( + id, label, kind, weekday, time(hh, mm), minutes, dial_hz, band, frozenset(regions) + ) + + +NA, EU, AUS = Region.NA, Region.EU, Region.AUS + +PLAN: tuple[NetWindow, ...] = ( + # Weekly nets, Thursday night local (40 m, 7.107 MHz, NVIS) + _w("na-net", "GhostNet North America", "net", FRI, 1, 0, 30, GHOSTNET_40M_HZ, "40m", NA), + _w("eu-net", "GhostNet Europe", "net", THU, 18, 0, 30, GHOSTNET_40M_HZ, "40m", EU), + _w("aus-net", "GhostNet Australia", "net", THU, 7, 0, 30, GHOSTNET_40M_HZ, "40m", AUS), + # Saturday data bridges + _w( + "na-eu-20m", + "North America-Europe data bridge", + "bridge", + SAT, + 18, + 0, + 60, + GHOSTNET_20M_HZ, + "20m", + NA, + EU, + ), + _w( + "na-aus-20m", + "North America-Australia data bridge", + "bridge", + SAT, + 12, + 0, + 60, + GHOSTNET_20M_HZ, + "20m", + NA, + AUS, + ), + _w( + "na-aus-80m", + "North America-Australia data bridge (80 m)", + "bridge", + SAT, + 13, + 30, + 30, + GHOSTNET_80M_HZ, + "80m", + NA, + AUS, + ), + _w( + "eu-aus-20m", + "Europe-Australia data bridge", + "bridge", + SAT, + 20, + 0, + 60, + GHOSTNET_20M_HZ, + "20m", + EU, + AUS, + ), + _w( + "eu-aus-80m", + "Europe-Australia data bridge (80 m)", + "bridge", + SAT, + 21, + 30, + 30, + GHOSTNET_80M_HZ, + "80m", + EU, + AUS, + ), + _w( + "aus-sp-20m", + "Australia-South Pacific data bridge", + "bridge", + SAT, + 8, + 0, + 60, + GHOSTNET_20M_HZ, + "20m", + AUS, + ), + _w( + "aus-sp-40m", + "Australia-South Pacific data bridge (40 m)", + "bridge", + SAT, + 9, + 0, + 30, + GHOSTNET_40M_HZ, + "40m", + AUS, + ), +) + +# Groups worth calling out in traffic. +GROUP_GHOSTNET = "@GHOSTNET" +GROUP_FLASH = "@GSTFLASH" # emergency flash traffic: relay to highest level HQ within range + + +def windows_for(region: Region) -> list[NetWindow]: + return [w for w in PLAN if region in w.regions] + + +def upcoming(region: Region, now: datetime, count: int = 5) -> list[Occurrence]: + """Occurrences in progress or upcoming, soonest first.""" + occ = sorted((w.occurrence_from(now) for w in windows_for(region)), key=lambda o: o.start) + return occ[:count] + + +def active( + region: Region, now: datetime, preroll: timedelta = timedelta(minutes=10) +) -> Occurrence | None: + """The occurrence the station should be on now (pre-roll included), if any. + + A window in progress keeps the station until it ends, even when the next + window's pre-roll has begun. + """ + candidates = [ + o for o in upcoming(region, now, count=len(PLAN)) if o.start - preroll <= now < o.end + ] + if not candidates: + return None + in_progress = [o for o in candidates if o.start <= now] + return min(in_progress or candidates, key=lambda o: o.start) diff --git a/bridge/src/ghostjs8/py.typed b/bridge/src/ghostjs8/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/receivers/__init__.py b/bridge/src/ghostjs8/receivers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/receivers/address_policy.py b/bridge/src/ghostjs8/receivers/address_policy.py new file mode 100644 index 0000000..01f971a --- /dev/null +++ b/bridge/src/ghostjs8/receivers/address_policy.py @@ -0,0 +1,113 @@ +"""Which receiver addresses the bridge may connect to on someone else's behalf. + +Viewers (and the GhostNet autopilot, via the public directory) choose receiver +hosts. Without care that is a server-side request primitive: ``127.1``, +``0x7f000001``, a name that resolves to an internal address, or a DNS answer that +changes between check and connect would all reach things the bridge can see. + +So the policy is enforced where the connection is made: + +1. the host *string* is syntax-checked (a cheap, early, friendly error); +2. the name is resolved, **every** returned address is checked, and the + connection is then made to that checked address (the URL keeps the name, so + the HTTP Host header and TLS SNI are unchanged). Nothing re-resolves later, + which closes DNS rebinding. + +Receivers configured by the operator (``GHOSTJS8_RECEIVER_HOST``) are trusted +and bypass the policy. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import re +import socket +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from ghostjs8.receivers.base import ReceiverError + +IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address +Resolver = Callable[[str, int], Awaitable[list[str]]] + +_HOSTNAME = re.compile( + r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(? list[str]: + infos = await asyncio.get_running_loop().getaddrinfo( + host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP + ) + return [str(info[4][0]) for info in infos] + + +@dataclass(frozen=True, slots=True) +class AddressPolicy: + allow_private: bool = False + #: Simulated/dev stacks only: lets the fake KiwiSDR on 127.0.0.1 be chosen. + allow_loopback: bool = False + + def check_host(self, host: str) -> str: + """Normalize and syntax-check a host string; refuse obvious internal names.""" + h = host.strip().rstrip(".").lower() + literal = h[1:-1] if h.startswith("[") and h.endswith("]") else h + try: + ip = ipaddress.ip_address(literal) + except ValueError: + internal = h in _BLOCKED_NAMES or h.endswith(".localhost") + if not _HOSTNAME.match(h) or (internal and not self.allow_loopback): + raise ReceiverAddressRefused(f"not an allowed receiver host: {host!r}") from None + return h + self.check_ip(ip) + return str(ip) + + def check_ip(self, ip: IPAddress) -> None: + mapped = ip.ipv4_mapped if isinstance(ip, ipaddress.IPv6Address) else None + if mapped is not None: + ip = mapped + if ip.is_loopback and self.allow_loopback: + return + if ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified: + raise ReceiverAddressRefused("receiver address is not allowed") + if ip.is_global: + return + if not self.allow_private or ip.is_reserved: + raise ReceiverAddressRefused( + "receiver resolves to a private address " + "(set GHOSTJS8_ALLOW_PRIVATE_RECEIVERS=true for LAN receivers)" + ) + + async def resolve(self, host: str, port: int, resolver: Resolver = system_resolver) -> str: + """Resolve ``host`` and return one address that the policy allows. + + Every address in the answer must pass; a mixed answer (one public, one + internal) is refused rather than trusted. + """ + name = self.check_host(host) + try: + ip = ipaddress.ip_address(name) + except ValueError: + pass + else: + return str(ip) + try: + answers = await resolver(name, port) + except OSError as exc: + raise ReceiverAddressRefused(f"cannot resolve receiver host {host!r}") from exc + if not answers: + raise ReceiverAddressRefused(f"cannot resolve receiver host {host!r}") + addresses = [ipaddress.ip_address(a.split("%", 1)[0]) for a in answers] + for a in addresses: + self.check_ip(a) + return str(addresses[0]) + + +__all__ = ["AddressPolicy", "ReceiverAddressRefused", "Resolver", "system_resolver"] diff --git a/bridge/src/ghostjs8/receivers/base.py b/bridge/src/ghostjs8/receivers/base.py new file mode 100644 index 0000000..4210f52 --- /dev/null +++ b/bridge/src/ghostjs8/receivers/base.py @@ -0,0 +1,136 @@ +"""Receiver adapter interface: what the bridge needs from any SDR input. + +A receiver produces normalized audio (12 kHz, mono, signed 16-bit +little-endian PCM), waterfall rows, and status. Adapters raise the typed errors +below; they never retry on their own (reconnect policy lives in the session +layer so it can be shared and tested). +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Literal, Protocol + +AUDIO_SAMPLE_RATE = 12_000 +"""The only audio rate the decoder path supports.""" + +Mode = Literal["usb", "lsb"] + + +@dataclass(frozen=True, slots=True) +class Tuning: + """What to listen to. Frequencies in Hz; passband edges relative to the dial.""" + + dial_hz: int + mode: Mode = "usb" + low_cut_hz: int = 100 + high_cut_hz: int = 3000 + + def __post_init__(self) -> None: + if not 0 < self.dial_hz < 100_000_000: + raise ValueError(f"dial_hz out of range: {self.dial_hz}") + if not 0 <= self.low_cut_hz < self.high_cut_hz <= 6000: + raise ValueError(f"bad passband {self.low_cut_hz}..{self.high_cut_hz} Hz") + + +@dataclass(frozen=True, slots=True) +class AudioBlock: + """Normalized PCM: mono s16le at AUDIO_SAMPLE_RATE.""" + + seq: int + pcm_s16le: bytes + rssi_dbm: float + adc_overload: bool + + @property + def samples(self) -> int: + return len(self.pcm_s16le) // 2 + + +@dataclass(frozen=True, slots=True) +class WaterfallRow: + """One waterfall line. Bin values are u8 where dBm ~= value - 255 (before cal).""" + + seq: int + start_hz: int + span_hz: int + bins: bytes + + +class ReceiverSink(Protocol): + """Where an adapter delivers data. Implementations must not block.""" + + def on_audio(self, block: AudioBlock) -> None: ... + + def on_waterfall(self, row: WaterfallRow) -> None: ... + + def on_info(self, key: str, value: str) -> None: + """Informational receiver metadata (version, users, sample rate, ...).""" + ... + + +# --------------------------------------------------------------------------- +# Typed errors +# --------------------------------------------------------------------------- + + +class ReceiverError(Exception): + """Base for everything a receiver adapter raises.""" + + +class RejectReason(enum.StrEnum): + FULL = "full" # all client slots taken + BUSY = "busy" # temporarily unavailable (db update, starting up, ...) + AUTH = "auth" # password required / wrong + DOWN = "down" # receiver says it is down + REDIRECT = "redirect" # receiver wants us elsewhere + DUPLICATE_IP = "duplicate_ip" # no multiple connections from one address + + +class ReceiverRejected(ReceiverError): + def __init__(self, reason: RejectReason, detail: str = "") -> None: + super().__init__( + f"receiver rejected connection: {reason.value}{': ' + detail if detail else ''}" + ) + self.reason = reason + self.detail = detail + + +class ReceiverUnreachable(ReceiverError): + """DNS failure, refused connection, bad host, or a non-WebSocket answer.""" + + +class ReceiverTimeout(ReceiverError): + """Connect or handshake did not complete in time.""" + + +class ReceiverClosed(ReceiverError): + """The receiver ended an established session.""" + + +class SessionPairingError(ReceiverError): + """SND and W/F streams were not opened under the same session identifier.""" + + +class FrameError(ReceiverError): + """A frame could not be used. Logged and counted; does not end the session.""" + + +class MalformedFrame(FrameError): + """Truncated or structurally invalid frame.""" + + +AudioFormatKind = Literal["compressed", "stereo", "iq", "sample_rate"] + + +class UnsupportedAudioFormat(ReceiverError): + """The receiver is sending audio we deliberately do not handle. + + Fatal for the session: a decoder fed the wrong format fails silently. + """ + + def __init__(self, kind: AudioFormatKind, detail: str = "") -> None: + super().__init__(f"unsupported audio format: {kind}{': ' + detail if detail else ''}") + self.kind: AudioFormatKind = kind + self.detail = detail diff --git a/bridge/src/ghostjs8/receivers/directory.py b/bridge/src/ghostjs8/receivers/directory.py new file mode 100644 index 0000000..da15c6f --- /dev/null +++ b/bridge/src/ghostjs8/receivers/directory.py @@ -0,0 +1,157 @@ +"""Public KiwiSDR directory, fetched by the bridge and cached. + +Source: the KiwiSDR.com public list as republished by rx.linkfanel.net +(``kiwisdr_com.js``: a JavaScript array literal with trailing commas). The +browser never contacts the directory itself. On refresh failure the last good +list is served marked ``stale``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import urllib.request +from collections.abc import Awaitable, Callable, Mapping +from typing import Any +from urllib.parse import urlsplit + +from ghostjs8.contract.messages import ReceiverDirectory, ReceiverListing +from ghostjs8.util.clock import Clock, SystemClock +from ghostjs8.util.maidenhead import normalize_grid + +log = logging.getLogger("ghostjs8.receivers.directory") + +DEFAULT_SOURCE = "http://rx.linkfanel.net/kiwisdr_com.js" +_TRAILING_COMMA = re.compile(r",(\s*[\]}])") +_GPS = re.compile(r"\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)") + +Fetcher = Callable[[str], Awaitable[bytes]] + + +class DirectoryError(Exception): + pass + + +def parse_directory(text: str) -> list[ReceiverListing]: + start, end = text.find("["), text.rfind("]") + if start < 0 or end <= start: + raise DirectoryError("no receiver array found") + try: + raw = json.loads(_TRAILING_COMMA.sub(r"\1", text[start : end + 1])) + except json.JSONDecodeError as exc: + raise DirectoryError(f"unparseable directory: {exc}") from exc + if not isinstance(raw, list): + raise DirectoryError("directory is not a list") + out: list[ReceiverListing] = [] + for entry in raw: + if isinstance(entry, Mapping): + listing = _listing(entry) + if listing is not None: + out.append(listing) + return out + + +def _int(value: object, default: int = 0) -> int: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return default + + +def _listing(e: Mapping[str, Any]) -> ReceiverListing | None: + if str(e.get("offline", "no")).lower() == "yes" or e.get("status", "active") != "active": + return None + url = str(e.get("url", "")).strip() + parts = urlsplit(url) + if parts.scheme not in ("http", "https") or not parts.hostname: + return None + tls = parts.scheme == "https" + port = parts.port or (443 if tls else 80) + lo, _, hi = str(e.get("bands", "0-30000000")).partition("-") + gps = _GPS.search(str(e.get("gps", ""))) + lat, lon = (float(gps.group(1)), float(gps.group(2))) if gps else (None, None) + if lat is not None and lon is not None and not (-90 <= lat <= 90 and -180 <= lon <= 180): + lat = lon = None + snr = str(e.get("snr", "")).split(",")[0] + return ReceiverListing( + id=str(e.get("id") or f"{parts.hostname}:{port}"), + name=str(e.get("name", "")).strip()[:200] or parts.hostname, + host=parts.hostname, + port=port, + tls=tls, + location=str(e.get("loc", "")).strip()[:200], + grid=normalize_grid(str(e.get("grid", ""))), + lat=lat, + lon=lon, + users=_int(e.get("users")), + users_max=_int(e.get("users_max")), + min_hz=_int(lo), + max_hz=_int(hi, 30_000_000), + antenna=str(e.get("antenna", "")).strip()[:200], + snr_db=_int(snr) if snr.strip().lstrip("-").isdigit() else None, + ) + + +async def _http_fetch(url: str) -> bytes: + def get() -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "ghost.js8"}) # noqa: S310 + with urllib.request.urlopen(req, timeout=20) as resp: # noqa: S310 - configured URL + data: bytes = resp.read(16 * 2**20) + return data + + return await asyncio.to_thread(get) + + +class Directory: + def __init__( + self, + *, + source: str = DEFAULT_SOURCE, + ttl_s: float = 900.0, + retry_s: float = 60.0, + clock: Clock | None = None, + fetch: Fetcher = _http_fetch, + ) -> None: + self._source = source + self._ttl_s = ttl_s + self._retry_s = retry_s + self._clock = clock or SystemClock() + self._fetch = fetch + self._cached: ReceiverDirectory | None = None + self._fetched_mono: float | None = None + self._failed_mono: float | None = None + self._lock = asyncio.Lock() + + async def get(self) -> ReceiverDirectory: + async with self._lock: + now = self._clock.monotonic() + fresh = self._fetched_mono is not None and now - self._fetched_mono < self._ttl_s + recently_failed = ( + self._failed_mono is not None and now - self._failed_mono < self._retry_s + ) + if self._cached is not None and (fresh or recently_failed): + return self._cached + try: + receivers = parse_directory( + (await self._fetch(self._source)).decode("utf-8", "replace") + ) + except Exception as exc: + log.warning("receiver directory refresh failed: %s", exc) + self._failed_mono = now + if self._cached is None: + self._cached = ReceiverDirectory( + fetched_utc=None, stale=True, source=self._source, receivers=[] + ) + else: + self._cached = self._cached.model_copy(update={"stale": True}) + return self._cached + self._fetched_mono, self._failed_mono = now, None + self._cached = ReceiverDirectory( + fetched_utc=self._clock.utc_now(), + stale=False, + source=self._source, + receivers=receivers, + ) + return self._cached diff --git a/bridge/src/ghostjs8/receivers/kiwisdr/__init__.py b/bridge/src/ghostjs8/receivers/kiwisdr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/receivers/kiwisdr/client.py b/bridge/src/ghostjs8/receivers/kiwisdr/client.py new file mode 100644 index 0000000..d7a617b --- /dev/null +++ b/bridge/src/ghostjs8/receivers/kiwisdr/client.py @@ -0,0 +1,446 @@ +"""KiwiSDR receiver adapter: a paired SND + W/F WebSocket session. + +Both streams are opened under one session identifier (the path component in +``//SND`` and ``//W/F``). Audio is normalized to mono s16le at 12 kHz; +anything else is a typed, logged, fatal error. The adapter never retries; the +session layer owns reconnect policy. + +Etiquette: identifies as ``ghost.js8``, sends keepalives, only touches +per-channel settings (mode, passband, frequency, this channel's AGC, waterfall +zoom/speed). It never changes attenuation or any other shared hardware setting. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import socket +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +from websockets.asyncio.client import ClientConnection, connect +from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatus, InvalidURI + +from ghostjs8.receivers.address_policy import AddressPolicy +from ghostjs8.receivers.base import ( + AUDIO_SAMPLE_RATE, + AudioBlock, + FrameError, + MalformedFrame, + ReceiverClosed, + ReceiverError, + ReceiverSink, + ReceiverTimeout, + ReceiverUnreachable, + SessionPairingError, + Tuning, + UnsupportedAudioFormat, + WaterfallRow, +) +from ghostjs8.receivers.kiwisdr import framing +from ghostjs8.util.clock import Clock, SystemClock + +log = logging.getLogger("ghostjs8.receivers.kiwisdr") + +StreamKind = Literal["SND", "W/F"] + +IDENT = "ghost.js8" +DEFAULT_BANDWIDTH_HZ = 30_000_000 +# Allowed deviation of the receiver's reported true sample rate from nominal. +SAMPLE_RATE_TOLERANCE = 0.01 +# Compressed frames already in flight when compression=0 is sent are dropped +# for this long before compressed audio becomes a fatal error. +COMPRESSION_GRACE_S = 2.0 +# Consecutive malformed frames before the session is abandoned. +MAX_CONSECUTIVE_BAD_FRAMES = 50 + +# MSG keys forwarded to the sink as receiver info. +INFO_KEYS = frozenset( + { + "version_maj", + "version_min", + "bandwidth", + "sample_rate", + "audio_rate", + "freq_offset", + "rx_chans", + "chan_no_pwd", + "max_camp", + } +) + +# (url, address) -> connection. ``address`` is the policy-checked IP to dial, or +# None for trusted (operator-configured) endpoints, which resolve normally. +Connector = Callable[[str, str | None], "contextlib.AbstractAsyncContextManager[ClientConnection]"] + + +@dataclass(frozen=True, slots=True) +class KiwiEndpoint: + host: str + port: int = 8073 + password: str = "" + tls: bool = False + #: None = trusted (operator-configured). Otherwise resolved and checked + #: before connecting; see receivers/address_policy.py. + policy: AddressPolicy | None = None + + def stream_url(self, session_id: int, stream: StreamKind) -> str: + scheme = "wss" if self.tls else "ws" + host = f"[{self.host}]" if ":" in self.host and not self.host.startswith("[") else self.host + return f"{scheme}://{host}:{self.port}/{session_id}/{stream}" + + +def session_id_from_url(url: str) -> int: + parts = url.split("/") + try: + return int(parts[3]) + except (IndexError, ValueError) as exc: + raise SessionPairingError(f"no session identifier in {url!r}") from exc + + +def verify_pairing(snd_url: str, wf_url: str) -> int: + """Return the shared session id, or raise if the two streams differ.""" + snd_id, wf_id = session_id_from_url(snd_url), session_id_from_url(wf_url) + if snd_id != wf_id: + raise SessionPairingError(f"SND session {snd_id} != W/F session {wf_id}") + return snd_id + + +def passband_for(tuning: Tuning) -> tuple[int, int]: + """Kiwi passband edges: positive for USB, negative (mirrored) for LSB.""" + if tuning.mode == "usb": + return tuning.low_cut_hz, tuning.high_cut_hz + return -tuning.high_cut_hz, -tuning.low_cut_hz + + +def passband_center_hz(tuning: Tuning) -> int: + lo, hi = passband_for(tuning) + return tuning.dial_hz + (lo + hi) // 2 + + +class _connect_no_redirects(connect): + """websockets' connect, but an HTTP redirect is an error, never followed.""" + + def process_redirect(self, exc: Exception) -> Exception | str: + return exc + + +def _default_connector( + url: str, address: str | None +) -> contextlib.AbstractAsyncContextManager[ClientConnection]: + pinned: dict[str, Any] = {} + if address is not None: + # Dial the checked address directly; the URL keeps the name for the + # Host header and TLS SNI. No proxy: it would re-resolve the name. + pinned = {"host": address, "proxy": None} + return _connect_no_redirects( + url, + compression=None, + open_timeout=None, # the adapter applies its own timeout + ping_interval=None, # Kiwi uses application-level keepalives + max_size=2**20, + user_agent_header=IDENT, + **pinned, + ) + + +class KiwiReceiver: + """One paired session against one KiwiSDR.""" + + def __init__( + self, + endpoint: KiwiEndpoint, + tuning: Tuning, + sink: ReceiverSink, + *, + waterfall: bool = True, + wf_span_hz: int = 12_000, + wf_speed: int = 3, + session_id: int | None = None, + clock: Clock | None = None, + connect_timeout_s: float = 10.0, + handshake_timeout_s: float = 15.0, + keepalive_interval_s: float = 2.0, + compression_grace_s: float = COMPRESSION_GRACE_S, + connector: Connector = _default_connector, + ) -> None: + self.endpoint = endpoint + self.sink = sink + self._tuning = tuning + self._waterfall = waterfall + self._wf_span_hz = wf_span_hz + self._wf_speed = wf_speed + self._clock = clock or SystemClock() + self.session_id = ( + session_id if session_id is not None else int(self._clock.utc_now().timestamp()) + ) + self._connect_timeout_s = connect_timeout_s + self._handshake_timeout_s = handshake_timeout_s + self._keepalive_interval_s = keepalive_interval_s + self._compression_grace_s = compression_grace_s + self._closing = False + self._connector = connector + + self._snd: ClientConnection | None = None + self._wf: ClientConnection | None = None + self._audio_started = asyncio.Event() + self._compression_off_at: float | None = None + self._bandwidth_hz = DEFAULT_BANDWIDTH_HZ + self._freq_offset_hz = 0 + self._kiwi_version: tuple[int, int] | None = None + self._bad_frames = 0 + self.stats = {"snd_frames": 0, "wf_frames": 0, "dropped_frames": 0} + + @property + def tuning(self) -> Tuning: + return self._tuning + + # ------------------------------------------------------------------ run + + async def run(self) -> None: + """Connect both streams and pump frames until closed or failed. + + Raises a ReceiverError subclass on any failure; returns only after + ``close()``. + """ + snd_url = self.endpoint.stream_url(self.session_id, "SND") + wf_url = self.endpoint.stream_url(self.session_id, "W/F") + verify_pairing(snd_url, wf_url) + logx = {"session": self.session_id, "host": self.endpoint.host} + + address: str | None = None + if self.endpoint.policy is not None: + async with asyncio.timeout(self._connect_timeout_s): + address = await self.endpoint.policy.resolve(self.endpoint.host, self.endpoint.port) + + async with contextlib.AsyncExitStack() as stack: + self._snd = await self._open(stack, snd_url, address) + await self._send(self._snd, f"SET auth t=kiwi p={self.endpoint.password}") + if self._waterfall: + self._wf = await self._open(stack, wf_url, address) + await self._send(self._wf, f"SET auth t=kiwi p={self.endpoint.password}") + log.info("kiwi streams open", extra=logx) + failure: ReceiverError | None = None + try: + async with asyncio.TaskGroup() as tg: + tg.create_task(self._read_snd(self._snd)) + tg.create_task(self._keepalive(self._snd)) + tg.create_task(self._handshake_watchdog()) + if self._wf is not None: + tg.create_task(self._read_wf(self._wf)) + tg.create_task(self._keepalive(self._wf)) + except* ReceiverError as eg: + first = eg.exceptions[0] + failure = first if isinstance(first, ReceiverError) else None + finally: + self._snd = self._wf = None + if failure is not None and not self._closing: + raise failure + + async def close(self) -> None: + """End the session. ``run()`` then returns normally.""" + self._closing = True + for ws in (self._snd, self._wf): + if ws is not None: + with contextlib.suppress(Exception): + await ws.close() + + async def retune(self, tuning: Tuning) -> None: + self._tuning = tuning + if self._snd is not None: + await self._send_mod(self._snd) + if self._wf is not None: + await self._send_wf_view(self._wf) + + # ------------------------------------------------------------ plumbing + + async def _open( + self, stack: contextlib.AsyncExitStack, url: str, address: str | None + ) -> ClientConnection: + try: + async with asyncio.timeout(self._connect_timeout_s): + return await stack.enter_async_context(self._connector(url, address)) + except TimeoutError as exc: + raise ReceiverTimeout(f"connect to {url} timed out") from exc + except InvalidStatus as exc: + raise ReceiverUnreachable(f"{url}: HTTP {exc.response.status_code}") from exc + except (InvalidHandshake, InvalidURI) as exc: + raise ReceiverUnreachable(f"{url}: not a KiwiSDR WebSocket ({exc})") from exc + except (OSError, socket.gaierror) as exc: + raise ReceiverUnreachable(f"{url}: {exc}") from exc + + async def _send(self, ws: ClientConnection, text: str) -> None: + try: + await ws.send(text) + except ConnectionClosed as exc: + raise ReceiverClosed(f"connection closed while sending: {exc}") from exc + + async def _keepalive(self, ws: ClientConnection) -> None: + while True: + await asyncio.sleep(self._keepalive_interval_s) + await self._send(ws, "SET keepalive") + + async def _handshake_watchdog(self) -> None: + try: + async with asyncio.timeout(self._handshake_timeout_s): + await self._audio_started.wait() + except TimeoutError as exc: + raise ReceiverTimeout( + f"no audio within {self._handshake_timeout_s:.0f}s of connecting" + ) from exc + + # ---------------------------------------------------------------- SND + + async def _read_snd(self, ws: ClientConnection) -> None: + try: + async for raw in ws: + frame = self._parse(raw, "SND") + if isinstance(frame, framing.MsgFrame): + await self._on_snd_msg(ws, frame) + elif isinstance(frame, framing.SndFrame): + self._on_snd(frame) + except ConnectionClosed as exc: + raise ReceiverClosed(f"SND stream closed: {exc}") from exc + raise ReceiverClosed("SND stream ended") + + async def _on_snd_msg(self, ws: ClientConnection, frame: framing.MsgFrame) -> None: + for name, value in frame.params: + self._check_common(name, value) + if name == "audio_rate": + rate = int(value or 0) + if rate != AUDIO_SAMPLE_RATE: + raise UnsupportedAudioFormat( + "sample_rate", f"receiver audio_rate={rate}, need 12000" + ) + await self._send(ws, f"SET AR OK in={rate} out=44100") + elif name == "sample_rate": + true_rate = float(value or 0) + if abs(true_rate - AUDIO_SAMPLE_RATE) / AUDIO_SAMPLE_RATE > SAMPLE_RATE_TOLERANCE: + raise UnsupportedAudioFormat("sample_rate", f"receiver sample_rate={true_rate}") + await self._send(ws, "SET compression=0") + self._compression_off_at = self._clock.monotonic() + # This channel's own AGC (per-channel DSP, not shared hardware). + await self._send(ws, "SET agc=1 hang=0 thresh=-100 slope=6 decay=1000 manGain=50") + await self._send_mod(ws) + await self._send(ws, f"SET ident_user={IDENT}") + await self._send(ws, "SET keepalive") + + def _on_snd(self, frame: framing.SndFrame) -> None: + if frame.flags & framing.SND_FLAG_COMPRESSED and self._in_compression_grace(): + self.stats["dropped_frames"] += 1 + return + try: + pcm = framing.snd_to_s16le(frame) # UnsupportedAudioFormat propagates: fatal + except MalformedFrame as exc: + self._bad_frame(exc) + return + self._bad_frames = 0 + self.stats["snd_frames"] += 1 + self._audio_started.set() + self.sink.on_audio( + AudioBlock( + seq=frame.seq, + pcm_s16le=pcm, + rssi_dbm=frame.rssi_dbm, + adc_overload=frame.adc_overload, + ) + ) + + def _in_compression_grace(self) -> bool: + if self._compression_off_at is None: + return True # compression=0 not sent yet; nothing should be flowing + return self._clock.monotonic() - self._compression_off_at < self._compression_grace_s + + async def _send_mod(self, ws: ClientConnection) -> None: + lo, hi = passband_for(self._tuning) + freq_khz = (self._tuning.dial_hz - self._freq_offset_hz) / 1000 + await self._send( + ws, f"SET mod={self._tuning.mode} low_cut={lo} high_cut={hi} freq={freq_khz:.3f}" + ) + + # ---------------------------------------------------------------- W/F + + async def _read_wf(self, ws: ClientConnection) -> None: + try: + async for raw in ws: + frame = self._parse(raw, "W/F") + if isinstance(frame, framing.MsgFrame): + await self._on_wf_msg(ws, frame) + elif isinstance(frame, framing.WfFrame): + self._on_wf(frame) + except ConnectionClosed as exc: + raise ReceiverClosed(f"W/F stream closed: {exc}") from exc + raise ReceiverClosed("W/F stream ended") + + async def _on_wf_msg(self, ws: ClientConnection, frame: framing.MsgFrame) -> None: + for name, value in frame.params: + self._check_common(name, value) + if name == "wf_setup": + await self._send_wf_view(ws) + await self._send(ws, "SET maxdb=-10 mindb=-110") + await self._send(ws, f"SET wf_speed={self._wf_speed}") + await self._send(ws, "SET wf_comp=0") + await self._send(ws, "SET interp=13") + await self._send(ws, f"SET ident_user={IDENT}") + await self._send(ws, "SET keepalive") + + def _on_wf(self, frame: framing.WfFrame) -> None: + if len(frame.bins) != framing.WF_BINS: + self._bad_frame(MalformedFrame(f"W/F row has {len(frame.bins)} bins, expected 1024")) + return + self.stats["wf_frames"] += 1 + self.sink.on_waterfall( + WaterfallRow( + seq=frame.seq, + start_hz=framing.wf_start_hz(frame.x_bin, self._bandwidth_hz) + + self._freq_offset_hz, + span_hz=framing.wf_span_hz(frame.zoom, self._bandwidth_hz), + bins=frame.bins, + ) + ) + + async def _send_wf_view(self, ws: ClientConnection) -> None: + zoom = framing.wf_zoom_for_span(self._wf_span_hz, self._bandwidth_hz) + center_khz = (passband_center_hz(self._tuning) - self._freq_offset_hz) / 1000 + if self._kiwi_version is None or self._kiwi_version >= (1, 329): + await self._send(ws, f"SET zoom={zoom} cf={center_khz:.3f}") + else: # pre-1.329 servers only understand a start counter + span_khz = self._bandwidth_hz / 1000 / 2**zoom + start_khz = max(0.0, center_khz - span_khz / 2) + full = framing.WF_BINS * 2**framing.WF_MAX_ZOOM + counter = round(start_khz / (self._bandwidth_hz / 1000) * full) + await self._send(ws, f"SET zoom={zoom} start={counter}") + + # -------------------------------------------------------------- shared + + def _parse(self, raw: bytes | str, stream: StreamKind) -> framing.Frame | None: + try: + return framing.parse_frame(raw) + except MalformedFrame as exc: + self._bad_frame(exc) + return None + + def _bad_frame(self, exc: FrameError) -> None: + self._bad_frames += 1 + self.stats["dropped_frames"] += 1 + log.warning("dropped frame: %s", exc, extra={"session": self.session_id}) + if self._bad_frames >= MAX_CONSECUTIVE_BAD_FRAMES: + raise ReceiverClosed(f"{self._bad_frames} consecutive bad frames; last: {exc}") + + def _check_common(self, name: str, value: str | None) -> None: + rejection = framing.rejection_from_msg(name, value) + if rejection is not None: + log.warning("kiwi rejected session: %s", rejection, extra={"session": self.session_id}) + raise rejection + if name == "bandwidth" and value: + self._bandwidth_hz = int(float(value)) + elif name == "freq_offset" and value: + self._freq_offset_hz = round(float(value) * 1000) # kHz on the wire + elif name == "version_maj" and value: + self._kiwi_version = (int(value), self._kiwi_version[1] if self._kiwi_version else 0) + elif name == "version_min" and value and self._kiwi_version is not None: + self._kiwi_version = (self._kiwi_version[0], int(value)) + if name in INFO_KEYS and value is not None: + self.sink.on_info(name, value) diff --git a/bridge/src/ghostjs8/receivers/kiwisdr/framing.py b/bridge/src/ghostjs8/receivers/kiwisdr/framing.py new file mode 100644 index 0000000..d1e7b0d --- /dev/null +++ b/bridge/src/ghostjs8/receivers/kiwisdr/framing.py @@ -0,0 +1,213 @@ +"""KiwiSDR WebSocket framing. Pure functions; no I/O. + +Every server message starts with a 3-byte ASCII tag. Layouts (verified against +jks-prv/kiwiclient and documented in docs/kiwisdr-notes.md): + + SND tag(3) flags:u8 seq:u32le smeter:u16be pcm... + W/F tag(3) pad:u8 x_bin:u32le flags_zoom:u32le seq:u32le bins:u8... + MSG tag(3) pad:u8 "name=value name2=value2 ..." (values URL-encoded) + EXT tag(3) pad:u8 same as MSG + +SND PCM is signed 16-bit **big-endian** unless SND_FLAG_LITTLE_ENDIAN is set. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass +from urllib.parse import unquote + +from ghostjs8.receivers.base import ( + AudioFormatKind, + MalformedFrame, + ReceiverRejected, + RejectReason, + UnsupportedAudioFormat, +) + +SND_FLAG_ADC_OVFL = 0x02 +SND_FLAG_STEREO = 0x08 +SND_FLAG_COMPRESSED = 0x10 +SND_FLAG_LITTLE_ENDIAN = 0x80 + +SND_HEADER = struct.Struct("H") +SND_PREFIX_LEN = 3 + SND_HEADER.size + SND_SMETER.size # 10 +WF_HEADER = struct.Struct(" float: + return 0.1 * self.smeter - 127.0 + + @property + def adc_overload(self) -> bool: + return bool(self.flags & SND_FLAG_ADC_OVFL) + + +@dataclass(frozen=True, slots=True) +class WfFrame: + x_bin: int + zoom: int + flags: int + seq: int + bins: bytes + + +@dataclass(frozen=True, slots=True) +class MsgFrame: + """MSG or EXT key/value list. Bare keys map to None. Order preserved.""" + + tag: str + params: tuple[tuple[str, str | None], ...] + + +Frame = SndFrame | WfFrame | MsgFrame + + +def parse_frame(raw: bytes | str) -> Frame | None: + """Parse one server message. Unknown tags return None (ignored safely).""" + data = raw.encode() if isinstance(raw, str) else raw + if len(data) < 3: + raise MalformedFrame(f"frame shorter than tag: {len(data)} bytes") + tag = data[:3] + if tag == b"SND": + return _parse_snd(data) + if tag == b"W/F": + return _parse_wf(data) + if tag in (b"MSG", b"EXT"): + return MsgFrame( + tag=tag.decode(), params=parse_msg_text(data[4:].decode("utf-8", "replace")) + ) + return None + + +def _parse_snd(data: bytes) -> SndFrame: + if len(data) < SND_PREFIX_LEN: + raise MalformedFrame(f"SND frame truncated: {len(data)} bytes") + flags, seq = SND_HEADER.unpack_from(data, 3) + (smeter,) = SND_SMETER.unpack_from(data, 3 + SND_HEADER.size) + return SndFrame(flags=flags, seq=seq, smeter=smeter, data=bytes(data[SND_PREFIX_LEN:])) + + +def _parse_wf(data: bytes) -> WfFrame: + if len(data) < WF_PREFIX_LEN: + raise MalformedFrame(f"W/F frame truncated: {len(data)} bytes") + x_bin, flags_zoom, seq = WF_HEADER.unpack_from(data, 4) + return WfFrame( + x_bin=x_bin, + zoom=flags_zoom & 0xFFFF, + flags=(flags_zoom >> 16) & 0xFFFF, + seq=seq, + bins=bytes(data[WF_PREFIX_LEN:]), + ) + + +def parse_msg_text(text: str) -> tuple[tuple[str, str | None], ...]: + out: list[tuple[str, str | None]] = [] + for token in text.strip().split(" "): + if not token: + continue + name, sep, value = token.partition("=") + out.append((name, unquote(value) if sep else None)) + return tuple(out) + + +# --------------------------------------------------------------------------- +# Audio normalization +# --------------------------------------------------------------------------- + + +def snd_to_s16le(frame: SndFrame, *, iq_mode: bool = False) -> bytes: + """Return the frame's PCM as mono s16le, or raise a typed error. + + - compressed (ADPCM) frames are rejected: we request compression=0 + - stereo frames are rejected (IQ when the session requested IQ) + - big-endian is the default and is byte-swapped + - the little-endian flag is honored: no swap, never a double swap + """ + if frame.flags & SND_FLAG_COMPRESSED: + raise UnsupportedAudioFormat("compressed", "ADPCM audio; compression=0 was not honored") + if frame.flags & SND_FLAG_STEREO: + kind: AudioFormatKind = "iq" if iq_mode else "stereo" + raise UnsupportedAudioFormat(kind, "2-channel audio") + pcm = frame.data + if len(pcm) % 2: + raise MalformedFrame(f"odd PCM byte count {len(pcm)} in SND seq {frame.seq}") + if frame.flags & SND_FLAG_LITTLE_ENDIAN: + return pcm + return swap16(pcm) + + +def swap16(pcm: bytes) -> bytes: + """Swap byte order of each 16-bit word. Host-endianness independent.""" + out = bytearray(len(pcm)) + out[0::2] = pcm[1::2] + out[1::2] = pcm[0::2] + return bytes(out) + + +# --------------------------------------------------------------------------- +# Waterfall geometry +# --------------------------------------------------------------------------- + + +def wf_span_hz(zoom: int, bandwidth_hz: int) -> int: + if not 0 <= zoom <= WF_MAX_ZOOM: + raise ValueError(f"zoom out of range: {zoom}") + return round(bandwidth_hz / (1 << zoom)) + + +def wf_start_hz(x_bin: int, bandwidth_hz: int) -> int: + """Start frequency of a row from the server's x_bin (full-resolution bins).""" + return round(x_bin * bandwidth_hz / (WF_BINS << WF_MAX_ZOOM)) + + +def wf_zoom_for_span(span_hz: int, bandwidth_hz: int) -> int: + """Smallest zoom whose span still covers span_hz.""" + zoom = 0 + while zoom < WF_MAX_ZOOM and bandwidth_hz / (1 << (zoom + 1)) >= span_hz: + zoom += 1 + return zoom + + +# --------------------------------------------------------------------------- +# Rejections +# --------------------------------------------------------------------------- + +_BADP: dict[str, tuple[RejectReason, str]] = { + "1": (RejectReason.AUTH, "bad password, or all channels not needing one are busy"), + "2": (RejectReason.BUSY, "receiver still determining its local address"), + "3": (RejectReason.AUTH, "admin connection not allowed from this address"), + "4": (RejectReason.AUTH, "no admin password set"), + "5": (RejectReason.DUPLICATE_IP, "no multiple connections from the same address"), + "6": (RejectReason.BUSY, "database update in progress"), + "7": (RejectReason.BUSY, "another admin connection is open"), +} + + +def rejection_from_msg(name: str, value: str | None) -> ReceiverRejected | None: + """Map a MSG parameter to a typed rejection, or None if it is not one.""" + if name == "too_busy": + return ReceiverRejected(RejectReason.FULL, f"all {value} client slots taken") + if name == "badp": + if value in (None, "0"): + return None # badp=0 means the password was accepted + reason, detail = _BADP.get(value, (RejectReason.AUTH, f"badp={value}")) + return ReceiverRejected(reason, detail) + if name == "down": + return ReceiverRejected(RejectReason.DOWN, "receiver reports it is down") + if name == "redirect": + return ReceiverRejected(RejectReason.REDIRECT, value or "") + return None diff --git a/bridge/src/ghostjs8/session/__init__.py b/bridge/src/ghostjs8/session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/session/health.py b/bridge/src/ghostjs8/session/health.py new file mode 100644 index 0000000..cbf250e --- /dev/null +++ b/bridge/src/ghostjs8/session/health.py @@ -0,0 +1,224 @@ +"""Truthful readiness. + +Every component state is derived from evidence with a timestamp, evaluated +against an injected clock. Nothing is inferred from an open socket, and the +last-decode time is only ever set by an actual decode. + +Thresholds are deliberately generous enough for real receivers (network +jitter) and tight enough that a dead path shows within seconds. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Literal + +from ghostjs8.contract.messages import Component, Components, ComponentState, Health, SessionState +from ghostjs8.decoders.base import DecoderHealth +from ghostjs8.util.clock import Clock + +_Eval = tuple[ComponentState, str, datetime | None] + + +@dataclass(frozen=True, slots=True) +class Thresholds: + audio_stale_s: float = 3.0 + waterfall_stale_s: float = 5.0 + decoder_report_stale_s: float = 10.0 # agent sends health every 3 s + decoder_ping_stale_s: float = 40.0 # JS8Call PINGs every 15 s + + +@dataclass(slots=True) +class _Tracked: + state: ComponentState + since: datetime + + +class HealthTracker: + def __init__(self, clock: Clock, thresholds: Thresholds | None = None) -> None: + self._clock = clock + self._t = thresholds or Thresholds() + self._started = clock.utc_now() + self._tracked: dict[str, _Tracked] = {} + self._decoder_link = False + self._decoder_facts: DecoderHealth | None = None + self._decoder_facts_mono: float | None = None + self._decoder_facts_utc: datetime | None = None + self._receiver_state: SessionState = "idle" + self._receiver_detail = "" + self._receiver_since_utc: datetime | None = None + self._audio_mono: float | None = None + self._audio_utc: datetime | None = None + self._wf_mono: float | None = None + self._wf_utc: datetime | None = None + self._last_decode_utc: datetime | None = None + self._last_snapshot: Health | None = None + + # -------------------------------------------------------------- inputs + + def decoder_link(self, connected: bool) -> None: + self._decoder_link = connected + if not connected: + self._decoder_facts = None + + def decoder_facts(self, facts: DecoderHealth) -> None: + self._decoder_facts = facts + self._decoder_facts_mono = self._clock.monotonic() + self._decoder_facts_utc = self._clock.utc_now() + + def receiver(self, state: SessionState, detail: str = "") -> None: + if state != self._receiver_state: + self._receiver_since_utc = self._clock.utc_now() + self._receiver_state = state + self._receiver_detail = detail + if state != "connected": + self._audio_mono = self._wf_mono = None + + def audio(self) -> None: + self._audio_mono = self._clock.monotonic() + self._audio_utc = self._clock.utc_now() + + def waterfall(self) -> None: + self._wf_mono = self._clock.monotonic() + self._wf_utc = self._clock.utc_now() + + def decoded(self, utc: datetime) -> None: + if self._last_decode_utc is None or utc > self._last_decode_utc: + self._last_decode_utc = utc + + # ------------------------------------------------------------- outputs + + def snapshot(self) -> Health: + now = self._clock.utc_now() + proc = self._decoder_process() + decoder_up = proc[0] == "ok" + api = self._decoder_api(now) if decoder_up else ("down", "decoder process down", None) + cap = self._decoder_capture() if decoder_up else ("down", "decoder process down", None) + rx = self._receiver() + rx_up = self._receiver_state == "connected" + audio = self._stream( + self._audio_mono, self._audio_utc, self._t.audio_stale_s, "audio", rx_up + ) + wf = self._stream( + self._wf_mono, self._wf_utc, self._t.waterfall_stale_s, "waterfall", rx_up + ) + + components = Components( + bridge=self._component("bridge", ("ok", "", now), now), + decoder_process=self._component("decoder_process", proc, now), + decoder_api=self._component("decoder_api", api, now), + decoder_capture=self._component("decoder_capture", cap, now), + receiver=self._component("receiver", rx, now), + audio=self._component("audio", audio, now), + waterfall=self._component("waterfall", wf, now), + ) + decode_path = (proc, api, cap, rx, audio) + overall: Literal["listening", "degraded", "offline"] + if all(c[0] == "ok" for c in decode_path): + overall = "listening" + elif not decoder_up or not rx_up: + overall = "offline" + else: + overall = "degraded" + snap = Health( + utc=now, overall=overall, components=components, last_decode_utc=self._last_decode_utc + ) + self._last_snapshot = snap + return snap + + def poll(self) -> tuple[Health, bool]: + """Snapshot plus whether any state (or the last decode) changed since the previous one.""" + prev = self._last_snapshot + current = self.snapshot() + changed = ( + prev is None + or _states(prev) != _states(current) + or prev.last_decode_utc != current.last_decode_utc + ) + return current, changed + + # ---------------------------------------------------------- evaluation + + def _component(self, name: str, ev: _Eval, now: datetime) -> Component: + state, detail, last_seen = ev + tracked = self._tracked.get(name) + if tracked is None or tracked.state != state: + tracked = _Tracked(state, now if tracked is not None else self._started) + self._tracked[name] = tracked + return Component(state=state, since=tracked.since, last_seen=last_seen, detail=detail) + + def _decoder_process(self) -> _Eval: + if not self._decoder_link: + return ("down", "decoder agent unreachable", self._decoder_facts_utc) + facts = self._decoder_facts + if facts is None or self._decoder_facts_mono is None: + return ("unknown", "waiting for decoder report", None) + age = self._clock.monotonic() - self._decoder_facts_mono + if age > self._t.decoder_report_stale_s: + return ("down", f"no decoder report for {age:.0f}s", self._decoder_facts_utc) + if not facts.process_up: + return ("down", "JS8Call process not running", self._decoder_facts_utc) + return ("ok", "", self._decoder_facts_utc) + + def _decoder_api(self, now: datetime) -> _Eval: + facts = self._decoder_facts + if facts is None: + return ("unknown", "", None) + seen = _latest(facts.last_ping_utc, facts.last_api_reply_utc) + if seen is None: + return ("down", "no API heartbeat yet", None) + age = (now - seen).total_seconds() + if age > self._t.decoder_ping_stale_s: + return ("down", f"no API heartbeat for {age:.0f}s", seen) + return ("ok", "", seen) + + def _decoder_capture(self) -> _Eval: + facts = self._decoder_facts + if facts is None: + return ("unknown", "", None) + if not facts.capturing: + return ("down", "decoder is not capturing its audio input", facts.last_audio_write_utc) + return ("ok", "", facts.last_audio_write_utc) + + def _receiver(self) -> _Eval: + state, detail = self._receiver_state, self._receiver_detail + since = self._receiver_since_utc + if state == "connected": + return ("ok", detail, self._audio_utc or since) + if state in ("connecting", "backoff"): + return ("degraded", detail or state, since) + if state == "idle": + return ("unknown", detail or "no receiver selected", None) + return ("down", detail or state, since) + + def _stream( + self, mono: float | None, utc: datetime | None, stale_s: float, what: str, rx_up: bool + ) -> _Eval: + if not rx_up: + return ("unknown", "receiver not connected", utc) + if mono is None: + return ("down", f"no {what} yet", None) + age = self._clock.monotonic() - mono + if age > stale_s: + return ("down", f"{what} stale for {age:.0f}s", utc) + return ("ok", "", utc) + + +def _latest(*times: datetime | None) -> datetime | None: + present = [t for t in times if t is not None] + return max(present) if present else None + + +def _states(h: Health) -> tuple[str, ...]: + c = h.components + return ( + h.overall, + c.bridge.state, + c.decoder_process.state, + c.decoder_api.state, + c.decoder_capture.state, + c.receiver.state, + c.audio.state, + c.waterfall.state, + ) diff --git a/bridge/src/ghostjs8/session/station.py b/bridge/src/ghostjs8/session/station.py new file mode 100644 index 0000000..109fc93 --- /dev/null +++ b/bridge/src/ghostjs8/session/station.py @@ -0,0 +1,560 @@ +"""A station: one receiver session feeding one decoder slot, fanned out to browsers. + +Owns everything with policy in it, so adapters stay simple and testable: + +- receiver lifecycle and reconnect policy (jittered backoff; no automatic retry + after auth/redirect rejections or unsupported audio formats) +- control from browsers (tune, select / disconnect receiver) +- truthful health (``HealthTracker``) and session state broadcasts +- binary fan-out to subscribed browsers with a waterfall frame-rate cap +- persistence (decodes, heard stations) and history for new viewers +- optional idle disconnect when nobody is watching +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Protocol + +from pydantic import BaseModel + +from ghostjs8 import __version__ +from ghostjs8.api.hub import Client, Hub +from ghostjs8.contract import binary +from ghostjs8.contract.messages import ( + Decode, + Hello, + History, + Limits, + ReceiverRef, + ReceiverStatus, + RejectReasonName, + Session, + SessionState, + TuningModel, +) +from ghostjs8.contract.messages import Station as StationMsg +from ghostjs8.decoders.base import DecodeEvent, DecoderError, DecoderHealth, DecoderSink, SpotEvent +from ghostjs8.receivers.address_policy import ReceiverAddressRefused +from ghostjs8.receivers.base import ( + AUDIO_SAMPLE_RATE, + AudioBlock, + ReceiverError, + ReceiverRejected, + ReceiverSink, + ReceiverTimeout, + ReceiverUnreachable, + RejectReason, + Tuning, + UnsupportedAudioFormat, + WaterfallRow, +) +from ghostjs8.session.health import HealthTracker +from ghostjs8.store.sqlite import Store +from ghostjs8.util.backoff import Backoff +from ghostjs8.util.clock import Clock, SystemClock +from ghostjs8.util.maidenhead import normalize_grid + +log = logging.getLogger("ghostjs8.session.station") + +# Rejections that a retry cannot fix: wait for the operator. +TERMINAL_REJECTIONS = frozenset({RejectReason.AUTH, RejectReason.REDIRECT}) +# Rejections where the receiver is up but has no room: be patient. +PATIENT_REJECTIONS = frozenset({RejectReason.FULL, RejectReason.BUSY, RejectReason.DUPLICATE_IP}) +PATIENT_MIN_DELAY_S = 60.0 +ADC_OVERLOAD_HOLD_S = 2.0 +STATUS_INTERVAL_S = 2.0 +HEALTH_POLL_S = 1.0 +HEALTH_HEARTBEAT_S = 10.0 +PRUNE_INTERVAL_S = 3600.0 + + +class ReceiverHandle(Protocol): + @property + def tuning(self) -> Tuning: ... + + async def run(self) -> None: ... + + async def close(self) -> None: ... + + async def retune(self, tuning: Tuning) -> None: ... + + +class DecoderHandle(Protocol): + @property + def connected(self) -> bool: ... + + def submit_audio(self, pcm_s16le: bytes) -> None: ... + + async def run(self) -> None: ... + + async def close(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class ReceiverTarget: + host: str + port: int + password: str = "" + name: str | None = None + tls: bool = False + #: Operator-configured (GHOSTJS8_RECEIVER_HOST). Viewer- and autopilot-chosen + #: targets are untrusted: their addresses go through the AddressPolicy. + trusted: bool = False + + +class StationObserver(Protocol): + """Taps the station's data path (e.g. the GhostNet net recorder). Must not block.""" + + def on_audio(self, block: AudioBlock) -> None: ... + + def on_waterfall(self, row: WaterfallRow) -> None: ... + + def on_decode(self, decode: Decode) -> None: ... + + +ReceiverFactory = Callable[[ReceiverTarget, Tuning, ReceiverSink], ReceiverHandle] +DecoderFactory = Callable[[DecoderSink], DecoderHandle] + + +@dataclass(frozen=True, slots=True) +class StationConfig: + waterfall_max_fps: float = 10.0 + waterfall_bins: int = 1024 + idle_disconnect_s: float = 0.0 # 0 = keep listening with nobody watching + history_decodes: int = 200 + # Politeness floor between connection attempts, whoever triggers them (viewer + # re-selects, autopilot re-picks, reconnects): at most one per this interval. + min_connect_interval_s: float = 5.0 + + +class Station(ReceiverSink, DecoderSink): + def __init__( + self, + hub: Hub, + decoder_factory: DecoderFactory, + receiver_factory: ReceiverFactory | None = None, + *, + tuning: Tuning | None = None, + target: ReceiverTarget | None = None, + store: Store | None = None, + clock: Clock | None = None, + config: StationConfig | None = None, + receiver_backoff: Backoff | None = None, + decoder_backoff: Backoff | None = None, + ) -> None: + self.hub = hub + self.clock = clock or SystemClock() + self.config = config or StationConfig() + self.health = HealthTracker(self.clock) + self.store = store + self.decoder = decoder_factory(self) + self._receiver_factory = receiver_factory + self._receiver_backoff = receiver_backoff or Backoff() + self._last_connect_at: float | None = None + self._decoder_backoff = decoder_backoff or Backoff(minimum_s=1.0, maximum_s=15.0) + + self.tuning = tuning or Tuning(dial_hz=14_078_000) + self.target = target + self.receiver: ReceiverHandle | None = None + self.state: SessionState = "idle" + self.reject_reason: RejectReasonName | None = None + self.detail = "" + self.next_retry_utc: datetime | None = None + self._target_changed = asyncio.Event() + self._idle_paused = False + self._no_clients_since: float | None = self.clock.monotonic() + + self._adc_overload_until: float | None = None + self._adc_overload_reported = False + self._rssi: float | None = None + self._receiver_info: dict[str, str] = {} + self._last_wf_mono: float | None = None + self.observers: list[StationObserver] = [] + # Called when a viewer changes tuning or receiver (the GhostNet autopilot yields). + self.operator_action: list[Callable[[], None]] = [] + # Extra messages sent to each new viewer after the standard ones. + self.join_messages: list[Callable[[], BaseModel]] = [] + + # ================================================================ run + + async def run(self) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(self._decoder_loop()) + tg.create_task(self._receiver_loop()) + tg.create_task(self._health_loop()) + tg.create_task(self._status_loop()) + tg.create_task(self._idle_loop()) + if self.store is not None: + tg.create_task(self._prune_loop()) + + async def _decoder_loop(self) -> None: + while True: + self.health.decoder_link(False) + try: + await self.decoder.run() + self._decoder_backoff.reset() + except DecoderError as exc: + log.warning("decoder unavailable: %s", exc) + self.health.decoder_link(False) + await asyncio.sleep(self._decoder_backoff.next_delay()) + + async def _receiver_loop(self) -> None: + while True: + self._target_changed.clear() + target = self.target + if target is None or self._receiver_factory is None or self._idle_paused: + self._set_state("idle", detail="nobody watching" if self._idle_paused else "") + await self._target_changed.wait() + continue + if self._last_connect_at is not None: + wait = self.config.min_connect_interval_s - ( + self.clock.monotonic() - self._last_connect_at + ) + if wait > 0: + await asyncio.sleep(min(wait, self.config.min_connect_interval_s)) + if self.target is not target: + continue # superseded while waiting: only the latest choice connects + self._last_connect_at = self.clock.monotonic() + self._set_state("connecting", detail=f"{target.host}:{target.port}") + self.receiver = self._receiver_factory(target, self.tuning, self) + run = asyncio.create_task(self.receiver.run()) + changed = asyncio.create_task(self._target_changed.wait()) + await asyncio.wait({run, changed}, return_when=asyncio.FIRST_COMPLETED) + if not run.done(): # operator changed target (or idle pause): restart now + await self.receiver.close() + with contextlib.suppress(ReceiverError, asyncio.CancelledError): + await run + changed.cancel() + self._receiver_backoff.reset() + continue + changed.cancel() + delay = self._after_receiver_ended(run) + self.receiver = None + if delay is None: + await self._target_changed.wait() # terminal: wait for the operator + continue + self.next_retry_utc = self.clock.utc_now() + timedelta(seconds=delay) + self._broadcast_session() + log.info("reconnecting to receiver in %.0fs", delay) + with contextlib.suppress(TimeoutError): + async with asyncio.timeout(delay): + await self._target_changed.wait() + self.next_retry_utc = None + + def _after_receiver_ended(self, run: asyncio.Task[None]) -> float | None: + """Classify how a receiver session ended. Returns retry delay, or None to stop.""" + exc = run.exception() + if exc is None: + self._set_state("idle", detail="closed") + return None + if isinstance(exc, ReceiverRejected): + log.warning("receiver rejected: %s", exc) + if exc.reason in TERMINAL_REJECTIONS: + self._set_state("rejected", reason=exc.reason.value, detail=str(exc)) + return None + delay = self._receiver_backoff.next_delay() + if exc.reason in PATIENT_REJECTIONS: + delay = max(delay, PATIENT_MIN_DELAY_S) + self._set_state("rejected", reason=exc.reason.value, detail=str(exc)) + return delay + if isinstance(exc, UnsupportedAudioFormat): + log.error("receiver sends unsupported audio: %s", exc) + self._set_state("failed", detail=str(exc)) + return None + if isinstance(exc, ReceiverAddressRefused): + log.warning("receiver address refused: %s", exc) + self._set_state("failed", detail=str(exc)) + return None + if isinstance(exc, ReceiverError): + log.warning("receiver session ended: %s", exc) + # Viewers get a coarse reason; the exact error (refused vs. HTTP + # status vs. reset) would turn the bridge into a port scanner. + if isinstance(exc, ReceiverTimeout): + detail = "receiver did not answer in time" + elif isinstance(exc, ReceiverUnreachable): + detail = "receiver unreachable" + else: + detail = str(exc) + self._set_state("backoff", detail=detail) + return self._receiver_backoff.next_delay() + raise exc + + async def _health_loop(self) -> None: + last_sent = -1e9 + while True: + snap, changed = self.health.poll() + now = self.clock.monotonic() + if changed or now - last_sent >= HEALTH_HEARTBEAT_S: + self.hub.broadcast(snap) + last_sent = now + await asyncio.sleep(HEALTH_POLL_S) + + async def _status_loop(self) -> None: + while True: + if self.state == "connected": + self.hub.broadcast(self.receiver_status()) + await asyncio.sleep(STATUS_INTERVAL_S) + + async def _idle_loop(self) -> None: + limit = self.config.idle_disconnect_s + if limit <= 0: + return + while True: + await asyncio.sleep(1.0) + idle_since = self._no_clients_since + if ( + not self._idle_paused + and idle_since is not None + and self.clock.monotonic() - idle_since >= limit + and self.target is not None + ): + log.info("no viewers for %.0fs: releasing receiver", limit) + self._idle_paused = True + self._target_changed.set() + + async def _prune_loop(self) -> None: + while True: + if self.store is not None: + removed = self.store.prune() + if removed: + log.info("pruned %d expired records", removed) + await asyncio.sleep(PRUNE_INTERVAL_S) + + # ============================================================ control + + def select_receiver(self, target: ReceiverTarget) -> None: + self.target = target + self._receiver_backoff.reset() + self._target_changed.set() + + def disconnect_receiver(self) -> None: + self.target = None + self._target_changed.set() + + async def tune(self, tuning: Tuning) -> None: + self.tuning = tuning + if self.receiver is not None: + try: + await self.receiver.retune(tuning) + except ReceiverError as exc: + log.warning("retune failed: %s", exc) + self._broadcast_session() + + def operator_changed(self) -> None: + """A viewer took manual control (tune / select / release).""" + for callback in self.operator_action: + callback() + + def client_joined(self, client: Client) -> None: + self._no_clients_since = None + if self._idle_paused: + self._idle_paused = False + self._target_changed.set() + client.send_model( + Hello( + build=__version__, + limits=Limits( + max_clients=self.hub.max_clients, + audio_sample_rate=AUDIO_SAMPLE_RATE, + waterfall_max_fps=self.config.waterfall_max_fps, + waterfall_bins=self.config.waterfall_bins, + ), + ) + ) + if self.store is not None: + client.send_model( + History( + decodes=self.store.recent_decodes(self.config.history_decodes), + stations=list(reversed(self.store.stations())), + ) + ) + client.send_model(self.session_message()) + client.send_model(self.health.snapshot()) + client.send_model(self.receiver_status()) + for make in self.join_messages: + client.send_model(make()) + self._broadcast_session() # subscriber count changed + + def client_left(self) -> None: + if self.hub.client_count == 0: + self._no_clients_since = self.clock.monotonic() + self._broadcast_session() + + # ========================================================== messages + + def session_message(self) -> Session: + t = self.target + return Session( + state=self.state, + receiver=ReceiverRef(host=t.host, port=t.port, name=t.name) if t else None, + tuning=TuningModel( + dial_hz=self.tuning.dial_hz, + mode=self.tuning.mode, + low_cut_hz=self.tuning.low_cut_hz, + high_cut_hz=self.tuning.high_cut_hz, + ), + subscribers=self.hub.client_count, + reject_reason=self.reject_reason, + detail=self.detail, + next_retry_utc=self.next_retry_utc, + ) + + def receiver_status(self) -> ReceiverStatus: + return ReceiverStatus( + adc_overload=self._adc_overload_active(), + rssi_dbm=round(self._rssi, 1) if self._rssi is not None else None, + info=dict(self._receiver_info), + ) + + def _set_state( + self, state: SessionState, *, reason: RejectReasonName | None = None, detail: str = "" + ) -> None: + self.state, self.reject_reason, self.detail = state, reason, detail + self.health.receiver(state, detail) + if state != "connected": + self._adc_overload_until = None + self._rssi = None + self._broadcast_session() + + def _broadcast_session(self) -> None: + self.hub.broadcast(self.session_message()) + + def _adc_overload_active(self) -> bool: + until = self._adc_overload_until + return until is not None and self.clock.monotonic() < until + + # ======================================================= ReceiverSink + + def on_audio(self, block: AudioBlock) -> None: + if self.state != "connected": + self._set_state("connected", detail=self.target.host if self.target else "") + self._receiver_backoff.reset() + self.health.audio() + self._rssi = block.rssi_dbm + if block.adc_overload: + self._adc_overload_until = self.clock.monotonic() + ADC_OVERLOAD_HOLD_S + overload = self._adc_overload_active() + if overload != self._adc_overload_reported: + self._adc_overload_reported = overload + self.hub.broadcast(self.receiver_status()) + self.decoder.submit_audio(block.pcm_s16le) + for obs in self.observers: + obs.on_audio(block) + if self.hub.wants("audio"): + self.hub.broadcast_binary( + "audio", + binary.encode_audio( + block.seq, AUDIO_SAMPLE_RATE, block.pcm_s16le, adc_overload=block.adc_overload + ), + ) + + def on_waterfall(self, row: WaterfallRow) -> None: + self.health.waterfall() + for obs in self.observers: + obs.on_waterfall(row) + if not self.hub.wants("waterfall"): + return + now = self.clock.monotonic() + min_gap = 1.0 / self.config.waterfall_max_fps + if self._last_wf_mono is not None and now - self._last_wf_mono < min_gap * 0.95: + return + self._last_wf_mono = now + self.hub.broadcast_binary( + "waterfall", binary.encode_waterfall(row.seq, row.start_hz, row.span_hz, row.bins) + ) + + def on_info(self, key: str, value: str) -> None: + self._receiver_info[key] = value + + # ======================================================== DecoderSink + + def on_decode(self, event: DecodeEvent) -> None: + dial = self.tuning.dial_hz + grid = normalize_grid(event.grid) + msg = Decode( + kind=event.kind, + utc=event.utc, + text=event.text, + snr_db=event.snr_db, + offset_hz=event.offset_hz, + dial_hz=dial, + freq_hz=dial + event.offset_hz, + speed=event.speed, + from_call=event.from_call, + to_call=event.to_call, + grid=grid, + ) + self.health.decoded(event.utc) + log.info( + "decode %s snr=%d off=%d %s", event.kind, event.snr_db, event.offset_hz, event.text + ) + if self.store is not None: + self.store.add_decode(msg) + self.hub.broadcast(msg) + for obs in self.observers: + obs.on_decode(msg) + # JS8Call reports directed traffic twice (activity + directed): count the + # activity line; the directed copy only contributes a grid, if it has one. + if event.from_call: + if event.kind == "activity": + self._heard( + event.from_call, event.utc, event.snr_db, grid, event.offset_hz, count=True + ) + elif grid: + self._heard( + event.from_call, event.utc, event.snr_db, grid, event.offset_hz, count=False + ) + + def on_spot(self, event: SpotEvent) -> None: + self._heard( + event.callsign, + event.utc, + event.snr_db, + normalize_grid(event.grid), + event.offset_hz, + count=False, + ) + + def on_health(self, health: DecoderHealth) -> None: + self.health.decoder_link(self.decoder.connected) + self.health.decoder_facts(health) + + def _heard( + self, + callsign: str, + utc: datetime, + snr: int | None, + grid: str | None, + offset: int | None, + *, + count: bool, + ) -> None: + call = callsign.strip().upper() + if not call or call.startswith("@") or len(call) > 16: + return # groups (@ALLCALL, @HB) are not stations + if self.store is not None: + station = self.store.heard( + call, + utc, + snr_db=snr, + grid=grid, + offset_hz=offset, + dial_hz=self.tuning.dial_hz, + count=count, + ) + else: + station = StationMsg( + callsign=call, + last_heard_utc=utc, + snr_db=snr, + grid=grid, + offset_hz=offset, + dial_hz=self.tuning.dial_hz, + ) + self.hub.broadcast(station) diff --git a/bridge/src/ghostjs8/settings.py b/bridge/src/ghostjs8/settings.py new file mode 100644 index 0000000..e711514 --- /dev/null +++ b/bridge/src/ghostjs8/settings.py @@ -0,0 +1,108 @@ +"""Bridge configuration from environment variables (see .env.example).""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field + +from ghostjs8.receivers.address_policy import AddressPolicy +from ghostjs8.receivers.base import Mode, Tuning + + +def _int(env: Mapping[str, str], key: str, default: int) -> int: + raw = env.get(key, "").strip() + return int(raw) if raw else default + + +def _bool(env: Mapping[str, str], key: str, *, default: bool) -> bool: + raw = env.get(key, "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +@dataclass(frozen=True) +class Settings: + listen_host: str = "0.0.0.0" # noqa: S104 - container service on an internal network + listen_port: int = 8073 + agent_url: str = "ws://decoder:8074/agent" + allowed_origins: tuple[str, ...] = () # empty: same-origin only (Origin must match Host) + max_clients: int = 16 + # Optional receiver to start listening on at boot (persistent listening). + receiver_host: str | None = None + receiver_port: int = 8073 + receiver_password: str = "" + tuning: Tuning = field(default_factory=lambda: Tuning(dial_hz=14_078_000)) + log_level: str = "INFO" + log_format: str = "json" # json | text + db_path: str = "/data/ghostjs8.sqlite3" # empty: no persistence + retention_days: int = 30 + idle_disconnect_minutes: int = 0 # 0 = keep listening with nobody watching + waterfall_max_fps: float = 10.0 + # Viewer/autopilot-chosen receivers must resolve to public addresses unless + # this is on (LAN Kiwis). Loopback/link-local are always refused. + allow_private_receivers: bool = False + # Simulated stack only (tools/dev-stack.sh): the fake KiwiSDR on 127.0.0.1. + allow_loopback_receivers: bool = False + directory_url: str = "http://rx.linkfanel.net/kiwisdr_com.js" + # GhostNet autopilot (docs/ghostnet.md) + ghostnet: bool = False + ghostnet_region: str = "" # na | eu | aus + home_grid: str = "" # Maidenhead grid used to pick a nearby receiver + ghostnet_preroll_minutes: int = 10 + ghostnet_record_audio: bool = True + ghostnet_park: bool = True + recordings_dir: str = "/data/nets" + net_retention_days: int = 90 + + @property + def receiver_policy(self) -> AddressPolicy: + return AddressPolicy( + allow_private=self.allow_private_receivers, + allow_loopback=self.allow_loopback_receivers, + ) + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> Settings: + e = os.environ if env is None else env + mode = e.get("GHOSTJS8_MODE", "usb").lower() + if mode not in ("usb", "lsb"): + raise ValueError(f"GHOSTJS8_MODE must be usb or lsb, not {mode!r}") + typed_mode: Mode = "usb" if mode == "usb" else "lsb" + origins = tuple( + o.strip() for o in e.get("GHOSTJS8_ALLOWED_ORIGINS", "").split(",") if o.strip() + ) + return cls( + listen_host=e.get("GHOSTJS8_LISTEN_HOST", cls.listen_host), + listen_port=_int(e, "GHOSTJS8_LISTEN_PORT", cls.listen_port), + agent_url=e.get("GHOSTJS8_AGENT_URL", cls.agent_url), + allowed_origins=origins, + max_clients=_int(e, "GHOSTJS8_MAX_CLIENTS_PER_SESSION", cls.max_clients), + receiver_host=e.get("GHOSTJS8_RECEIVER_HOST") or None, + receiver_port=_int(e, "GHOSTJS8_RECEIVER_PORT", cls.receiver_port), + receiver_password=e.get("GHOSTJS8_RECEIVER_PASSWORD", ""), + tuning=Tuning( + dial_hz=_int(e, "GHOSTJS8_DIAL_HZ", 14_078_000), + mode=typed_mode, + low_cut_hz=_int(e, "GHOSTJS8_LOW_CUT_HZ", 100), + high_cut_hz=_int(e, "GHOSTJS8_HIGH_CUT_HZ", 3000), + ), + log_level=e.get("GHOSTJS8_LOG_LEVEL", "INFO").upper(), + log_format=e.get("GHOSTJS8_LOG_FORMAT", "json").lower(), + db_path=e.get("GHOSTJS8_DB_PATH", cls.db_path), + retention_days=_int(e, "GHOSTJS8_RETENTION_DAYS", 30), + idle_disconnect_minutes=_int(e, "GHOSTJS8_IDLE_DISCONNECT_MINUTES", 0), + waterfall_max_fps=float(e.get("GHOSTJS8_WATERFALL_MAX_FPS", "10") or 10), + allow_private_receivers=_bool(e, "GHOSTJS8_ALLOW_PRIVATE_RECEIVERS", default=False), + allow_loopback_receivers=_bool(e, "GHOSTJS8_ALLOW_LOOPBACK_RECEIVERS", default=False), + directory_url=e.get("GHOSTJS8_DIRECTORY_URL", cls.directory_url), + ghostnet=_bool(e, "GHOSTJS8_GHOSTNET", default=False), + ghostnet_region=e.get("GHOSTJS8_GHOSTNET_REGION", "").strip().lower(), + home_grid=e.get("GHOSTJS8_HOME_GRID", "").strip(), + ghostnet_preroll_minutes=_int(e, "GHOSTJS8_GHOSTNET_PREROLL_MINUTES", 10), + ghostnet_record_audio=_bool(e, "GHOSTJS8_GHOSTNET_RECORD_AUDIO", default=True), + ghostnet_park=_bool(e, "GHOSTJS8_GHOSTNET_PARK", default=True), + recordings_dir=e.get("GHOSTJS8_RECORDINGS_DIR", cls.recordings_dir), + net_retention_days=_int(e, "GHOSTJS8_NET_RETENTION_DAYS", 90), + ) diff --git a/bridge/src/ghostjs8/sim/__init__.py b/bridge/src/ghostjs8/sim/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/sim/acceptance.py b/bridge/src/ghostjs8/sim/acceptance.py new file mode 100644 index 0000000..ddebd03 --- /dev/null +++ b/bridge/src/ghostjs8/sim/acceptance.py @@ -0,0 +1,64 @@ +"""Acceptance client: connect to the browser-facing WebSocket like a browser and +wait for an expected decode. Exit 0 on success, 1 on timeout. + +Mocked decodes cannot satisfy this: the text must come from native JS8Call +decoding the real upstream recording replayed through fake-kiwi. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time + +from websockets.asyncio.client import connect + +from ghostjs8.contract.messages import Decode, Hello, server_message_adapter + +EXPECTED = "K0OG: KN4CRD SNR +02" + + +async def wait_for_decode(url: str, expected: str, timeout_s: float) -> bool: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + async with connect(url, open_timeout=5) as ws: + print(f"[acceptance] connected to {url}", flush=True) + async with asyncio.timeout(max(0.0, deadline - time.monotonic())): + async for raw in ws: + if isinstance(raw, bytes): + continue + msg = server_message_adapter.validate_python(json.loads(raw)) + if isinstance(msg, Hello): + print(f"[acceptance] hello build={msg.build} v={msg.v}", flush=True) + elif isinstance(msg, Decode): + print( + f"[acceptance] decode {msg.kind} {msg.utc:%H:%M:%S} snr={msg.snr_db} off={msg.offset_hz} {msg.text}", + flush=True, + ) + if expected in msg.text: + print(f"[acceptance] PASS: saw {expected!r}", flush=True) + return True + except TimeoutError: + break + except OSError as exc: + print(f"[acceptance] waiting for bridge: {exc}", flush=True) + await asyncio.sleep(2) + print(f"[acceptance] FAIL: {expected!r} not decoded within {timeout_s:.0f}s", flush=True) + return False + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="ghost.js8 real-recording acceptance test") + p.add_argument("--url", default="ws://bridge:8073/ws") + p.add_argument("--expect", default=EXPECTED) + p.add_argument("--timeout", type=float, default=150.0) + args = p.parse_args(argv) + ok = asyncio.run(wait_for_decode(args.url, args.expect, args.timeout)) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bridge/src/ghostjs8/sim/fake_agent.py b/bridge/src/ghostjs8/sim/fake_agent.py new file mode 100644 index 0000000..447f39a --- /dev/null +++ b/bridge/src/ghostjs8/sim/fake_agent.py @@ -0,0 +1,128 @@ +"""A stand-in decoder-agent for UI development and browser tests. + +Speaks the real agent protocol (``agent_protocol.py``): accepts PCM, reports +healthy, and emits *scripted* JS8Call messages on UTC 15 s boundaries. It +decodes nothing. Real decoding is proven only by ``make acceptance``. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging +from datetime import UTC, datetime +from typing import Any + +from websockets.asyncio.server import ServerConnection, serve +from websockets.exceptions import ConnectionClosed + +from ghostjs8.decoders.base import DecoderHealth +from ghostjs8.decoders.js8call_native import agent_protocol as proto + +log = logging.getLogger("ghostjs8.sim.fake_agent") + +# (from, to or None, text after "FROM: ", grid, snr, offset) +SCRIPT: list[tuple[str, str | None, str, str, int, int]] = [ + ("K0OG", "KN4CRD", "KN4CRD SNR +02", "EN34", -20, 702), + ("KG9B", "KN4CRD", "KN4CRD HEARTBEAT SNR -14", "EN52", -14, 521), + ("KN4ZXG", None, "@HB HEARTBEAT FM16", "FM16", -24, 1866), + ("W1GHZ", "@ALLCALL", "@ALLCALL CQ CQ CQ FN31", "FN31pr", -9, 1210), + ("W4GHT", "@GSTFLASH", "@GSTFLASH DRILL DRILL DRILL - EXERCISE ONLY", "EM73", -7, 1450), + ("KN4CRD", "@GHOSTNET", "@GHOSTNET CHECKING IN EM73", "EM73", -11, 980), +] + + +def _messages(i: int, now: datetime) -> list[dict[str, Any]]: + frm, to, text, grid, snr, off = SCRIPT[i % len(SCRIPT)] + utc = int(now.timestamp() * 1000) + common = { + "SNR": snr, + "OFFSET": off, + "SPEED": 0, + "UTC": utc, + "DIAL": 14_078_000, + "FREQ": 14_078_000 + off, + } + out: list[dict[str, Any]] = [ + {"type": "RX.ACTIVITY", "value": f"{frm}: {text} ", "params": common}, + ] + if to: + out.append( + { + "type": "RX.DIRECTED", + "value": f"{text} ♢ ", + "params": {**common, "FROM": frm, "TO": to, "GRID": ""}, + } + ) + out.append( + { + "type": "RX.CALL_ACTIVITY", + "value": "", + "params": {frm: {"SNR": snr, "GRID": grid, "UTC": utc}}, + } + ) + return out + + +class FakeAgent: + def __init__(self, period_s: float = 15.0) -> None: + self._period = period_s + self._last_pcm: datetime | None = None + + async def handle(self, ws: ServerConnection) -> None: + log.info("bridge connected") + sender = asyncio.create_task(self._send_loop(ws)) + try: + async for message in ws: + if isinstance(message, bytes): + self._last_pcm = datetime.now(UTC) + except ConnectionClosed: + pass + finally: + sender.cancel() + with contextlib.suppress(asyncio.CancelledError): + await sender + + async def _send_loop(self, ws: ServerConnection) -> None: + i = 0 + next_cycle = 0.0 + loop = asyncio.get_running_loop() + while True: + now = datetime.now(UTC) + health = DecoderHealth( + process_up=True, + capturing=True, + last_ping_utc=now, + last_api_reply_utc=now, + last_audio_write_utc=self._last_pcm, + details={"simulated": "true"}, + ) + await ws.send(proto.encode_health(health, now)) + if loop.time() >= next_cycle: + if next_cycle: # skip the very first tick so the UI starts empty + for msg in _messages(i, now): + await ws.send(proto.encode_js8(msg, now)) + i += 1 + next_cycle = loop.time() + self._period + await asyncio.sleep(1.0) + + +def main(argv: list[str] | None = None) -> None: + p = argparse.ArgumentParser(description="ghost.js8 simulated decoder-agent (UI tests only)") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=proto.DEFAULT_AGENT_PORT) + p.add_argument("--period", type=float, default=15.0) + args = p.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") + agent = FakeAgent(args.period) + + async def run() -> None: + async with serve(agent.handle, args.host, args.port, compression=None): + await asyncio.Event().wait() + + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/bridge/src/ghostjs8/sim/fake_kiwi.py b/bridge/src/ghostjs8/sim/fake_kiwi.py new file mode 100644 index 0000000..36fdce9 --- /dev/null +++ b/bridge/src/ghostjs8/sim/fake_kiwi.py @@ -0,0 +1,371 @@ +"""A simulated KiwiSDR speaking real SND / W/F / MSG framing. + +Used by unit/integration tests and by the acceptance stack (tools/fake-kiwi). +It is a test double, not an emulator: it implements the subset of the protocol +the adapter relies on, plus fault injection. + +Audio: plays a 12 kHz mono s16 WAV (or silence) as big-endian PCM frames, +paced in real time. Playback can be aligned to UTC 15 s boundaries and +repeated, which is what JS8 decoding needs. + +Pairing: a W/F connection is accepted only while an SND connection with the +same session identifier is open; otherwise it gets ``MSG pairing_error`` and is +closed. (Real Kiwis associate the two by this identifier.) +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging +import math +import struct +import time +import wave +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from websockets.asyncio.server import Server, ServerConnection, serve +from websockets.exceptions import ConnectionClosed +from websockets.http11 import Request, Response + +log = logging.getLogger("ghostjs8.sim.fake_kiwi") + +RATE = 12_000 +SAMPLES_PER_FRAME = 512 +WF_BINS = 1024 + +Behavior = Literal["ok", "too_busy", "bad_password", "down", "db_update", "silent", "hang_up"] + + +@dataclass +class FakeKiwiConfig: + host: str = "127.0.0.1" + port: int = 0 + behavior: Behavior = "ok" + password: str = "" # if set, wrong passwords get badp=1 + audio_rate: int = RATE + sample_rate: float = 12001.135 + little_endian_flag: bool = False # send LE PCM with the LE flag set + compressed_flag: bool = False # mark every frame compressed (fault) + stereo_flag: bool = False # mark every frame stereo (fault) + truncate_every: int = 0 # every Nth SND frame truncated (fault); 0 = never + adc_overload: bool = False + wav: Path | None = None + align_utc_s: int = 0 # align playback starts to this boundary (15 for JS8); 0 = immediate + repeats: int = 1 + repeat_gap_s: int = 15 # extra boundaries between repeats + wf_rows_per_s: float = 10.0 + bandwidth_hz: int = 30_000_000 + + +@dataclass +class _SessionLog: + commands: list[str] = field(default_factory=list) + + +class FakeKiwi: + def __init__(self, config: FakeKiwiConfig, *, now: Callable[[], float] = time.time) -> None: + self.config = config + self._now = now + self._snd_sessions: set[int] = set() + self.sessions: dict[tuple[int, str], _SessionLog] = {} + self._server: Server | None = None + self.port = config.port + self._pcm = _load_wav(config.wav) if config.wav else b"" + + # ------------------------------------------------------------ lifecycle + + async def start(self) -> None: + self._server = await serve( + self._handle, + self.config.host, + self.config.port, + process_request=self._process_request, + compression=None, + ping_interval=None, + ) + sock = next(iter(self._server.sockets)) + self.port = sock.getsockname()[1] + log.info( + "fake kiwi listening on %s:%d (%s)", self.config.host, self.port, self.config.behavior + ) + + async def stop(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + + async def __aenter__(self) -> FakeKiwi: + await self.start() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.stop() + + def commands(self, stream: str) -> list[str]: + """All SET commands received on a stream kind across sessions.""" + return [c for (sid, kind), s in self.sessions.items() if kind == stream for c in s.commands] + + # ------------------------------------------------------------- routing + + def _process_request(self, connection: ServerConnection, request: Request) -> Response | None: + parts = request.path.strip("/").split("/") + valid = ( + len(parts) in (2, 3) and parts[0].isdigit() and "/".join(parts[1:]) in ("SND", "W/F") + ) + if not valid: + return connection.respond(404, "not a kiwi stream\n") + return None + + async def _handle(self, ws: ServerConnection) -> None: + if ws.request is None: # pragma: no cover - set before the handler runs + return + parts = ws.request.path.strip("/").split("/") + sid, kind = int(parts[0]), "/".join(parts[1:]) + slog = self.sessions.setdefault((sid, kind), _SessionLog()) + try: + if kind == "SND": + self._snd_sessions.add(sid) + try: + await self._snd(ws, slog) + finally: + self._snd_sessions.discard(sid) + else: + await self._wf(ws, sid, slog) + except ConnectionClosed: + pass + + # ------------------------------------------------------------ handshake + + async def _auth(self, ws: ServerConnection, slog: _SessionLog) -> bool: + cmd = await self._recv_cmd(ws, slog) + if not cmd.startswith("SET auth"): + await ws.close(1008, "expected auth") + return False + b = self.config.behavior + if b == "hang_up": + await ws.close(1011, "bye") + return False + if b == "silent": + await ws.wait_closed() # never answers; client must time out + return False + if b == "too_busy": + await self._msg(ws, "too_busy=4") + await ws.close() + return False + if b == "down": + await self._msg(ws, "down") + await ws.close() + return False + if b == "db_update": + await self._msg(ws, "badp=6") + await ws.close() + return False + given = cmd.partition(" p=")[2] + if b == "bad_password" or (self.config.password and given != self.config.password): + await self._msg(ws, "badp=1") + await ws.close() + return False + await self._msg(ws, "badp=0") + await self._msg(ws, "version_maj=1 version_min=826") + await self._msg(ws, f"bandwidth={self.config.bandwidth_hz} rx_chans=8 chan_no_pwd=8") + return True + + async def _recv_cmd(self, ws: ServerConnection, slog: _SessionLog) -> str: + raw = await ws.recv() + text = raw if isinstance(raw, str) else raw.decode() + if text != "SET keepalive": + slog.commands.append(text) + return text + + async def _msg(self, ws: ServerConnection, body: str) -> None: + await ws.send(b"MSG " + body.encode()) + + # ----------------------------------------------------------------- SND + + async def _snd(self, ws: ServerConnection, slog: _SessionLog) -> None: + if not await self._auth(ws, slog): + return + await self._msg(ws, f"audio_rate={self.config.audio_rate}") + await self._msg(ws, f"sample_rate={self.config.sample_rate:.3f}") + mod_seen = asyncio.Event() + reader = asyncio.create_task(self._drain(ws, slog, on_mod=mod_seen.set)) + try: + waiter = asyncio.create_task(mod_seen.wait()) + await asyncio.wait({waiter, reader}, return_when=asyncio.FIRST_COMPLETED) + waiter.cancel() + if mod_seen.is_set(): + await self._stream_audio(ws) + finally: + reader.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reader + + async def _drain( + self, ws: ServerConnection, slog: _SessionLog, on_mod: Callable[[], None] | None = None + ) -> None: + async for raw in ws: + text = raw if isinstance(raw, str) else raw.decode() + if text == "SET keepalive": + continue + slog.commands.append(text) + if on_mod and text.startswith("SET mod="): + on_mod() + + def _playback_starts(self, t0: float) -> list[int]: + """Sample indices (relative to stream start t0) where the WAV starts.""" + if not self._pcm: + return [] + align = self.config.align_utc_s + first = math.ceil((t0 + 0.5) / align) * align if align else t0 + step = (len(self._pcm) // 2) / RATE + (self.config.repeat_gap_s if align else 0) + if align: + step = math.ceil(step / align) * align + return [round((first + i * step - t0) * RATE) for i in range(self.config.repeats)] + + async def _stream_audio(self, ws: ServerConnection) -> None: + cfg = self.config + t0 = self._now() + starts = self._playback_starts(t0) + wav_len = len(self._pcm) // 2 + flags = 0x02 if cfg.adc_overload else 0 + flags |= 0x80 if cfg.little_endian_flag else 0 + flags |= 0x10 if cfg.compressed_flag else 0 + flags |= 0x08 if cfg.stereo_flag else 0 + n = 0 + seq = 0 + loop = asyncio.get_running_loop() + mono0 = loop.time() + while True: + samples = bytearray(SAMPLES_PER_FRAME * 2) # native s16le assembly + for start in starts: + lo, hi = max(n, start), min(n + SAMPLES_PER_FRAME, start + wav_len) + if lo < hi: + samples[(lo - n) * 2 : (hi - n) * 2] = self._pcm[ + (lo - start) * 2 : (hi - start) * 2 + ] + pcm = bytes(samples) if cfg.little_endian_flag else _swap16(bytes(samples)) + header = b"SND" + struct.pack("H", 1270 - 600) + frame = header + pcm + seq += 1 + if cfg.truncate_every and seq % cfg.truncate_every == 0: + frame = frame[:-1] + await ws.send(frame) + n += SAMPLES_PER_FRAME + await asyncio.sleep(max(0.0, mono0 + n / RATE - loop.time())) + + # ----------------------------------------------------------------- W/F + + async def _wf(self, ws: ServerConnection, sid: int, slog: _SessionLog) -> None: + if sid not in self._snd_sessions: + await self._msg(ws, "pairing_error=no_snd_session") + await ws.close(1008, "unpaired W/F") + return + if not await self._auth(ws, slog): + return + await self._msg(ws, "wf_setup") + reader = asyncio.create_task(self._drain(ws, slog)) + try: + seq = 0 + period = 1.0 / self.config.wf_rows_per_s + while sid in self._snd_sessions: + zoom, x_bin = _view(slog, self.config.bandwidth_hz) + await ws.send( + _synthetic_wf_row( + seq, zoom=zoom, x_bin=x_bin, bandwidth_hz=self.config.bandwidth_hz + ) + ) + seq += 1 + await asyncio.sleep(period) + await ws.close() + finally: + reader.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reader + + +def _view(slog: _SessionLog, bandwidth_hz: int) -> tuple[int, int]: + """(zoom, x_bin) for the last ``SET zoom=Z cf=kHz`` the client sent, like a real Kiwi.""" + for cmd in reversed(slog.commands): + if cmd.startswith("SET zoom="): + fields = dict(part.split("=", 1) for part in cmd.split()[1:] if "=" in part) + zoom = int(fields.get("zoom", "0")) + if "cf" not in fields: + return zoom, int(float(fields.get("start", "0"))) + span = bandwidth_hz / (1 << zoom) + start_hz = max(0.0, float(fields["cf"]) * 1000 - span / 2) + return zoom, round(start_hz / bandwidth_hz * (WF_BINS << 14)) + return 0, 0 + + +def _synthetic_wf_row(seq: int, *, zoom: int, x_bin: int, bandwidth_hz: int = 30_000_000) -> bytes: + """Noise floor, a drifting carrier and a few JS8-like tones near the passband + centre (offsets 702/1210/1866 Hz from a dial 1550 Hz below centre). Synthetic: + tests transport and rendering only.""" + floor = 255 - 110 + bin_hz = bandwidth_hz / (1 << zoom) / WF_BINS + row = bytearray(floor + ((i * 7 + seq * 13) % 9) for i in range(WF_BINS)) + carrier = (seq * 3) % WF_BINS + for d in range(-2, 3): + row[(carrier + d) % WF_BINS] = 255 - 40 - abs(d) * 6 + for k, offset in enumerate((702, 1210, 1866)): + if (seq // 40 + k) % 3 == 0: + continue # tones come and go like 15 s JS8 transmissions + centre = WF_BINS // 2 + round((offset - 1550) / bin_hz) + for b in range(centre - max(1, round(25 / bin_hz)), centre + max(2, round(25 / bin_hz))): + if 0 <= b < WF_BINS: + row[b] = 255 - 55 - (seq + b) % 6 + return b"W/F" + b"\x00" + struct.pack(" bytes: + out = bytearray(len(pcm)) + out[0::2] = pcm[1::2] + out[1::2] = pcm[0::2] + return bytes(out) + + +def _load_wav(path: Path) -> bytes: + with wave.open(str(path), "rb") as w: + if (w.getnchannels(), w.getsampwidth(), w.getframerate()) != (1, 2, RATE): + raise ValueError(f"{path}: need mono s16 at {RATE} Hz") + return w.readframes(w.getnframes()) # little-endian s16 + + +# ----------------------------------------------------------------------- CLI + + +def main(argv: list[str] | None = None) -> None: + p = argparse.ArgumentParser(description="Simulated KiwiSDR for ghost.js8 tests") + p.add_argument("--host", default="0.0.0.0") # noqa: S104 - container service + p.add_argument("--port", type=int, default=8073) + p.add_argument("--wav", type=Path) + p.add_argument("--align", type=int, default=15, help="UTC alignment seconds (0 = none)") + p.add_argument("--repeats", type=int, default=2) + p.add_argument("--behavior", default="ok") + args = p.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") + cfg = FakeKiwiConfig( + host=args.host, + port=args.port, + wav=args.wav, + align_utc_s=args.align, + repeats=args.repeats, + behavior=args.behavior, + ) + + async def run() -> None: + async with FakeKiwi(cfg): + await asyncio.Event().wait() + + asyncio.run(run()) + + +__all__ = ["FakeKiwi", "FakeKiwiConfig", "main"] + +if __name__ == "__main__": + main() diff --git a/bridge/src/ghostjs8/sim/seed_net.py b/bridge/src/ghostjs8/sim/seed_net.py new file mode 100644 index 0000000..1cd6503 --- /dev/null +++ b/bridge/src/ghostjs8/sim/seed_net.py @@ -0,0 +1,145 @@ +"""Create a synthetic recorded GhostNet window, for UI development and browser tests. + +Writes a 5-minute recording of last week's net for the region: audio (quiet +hiss with JS8-like tone bursts), a waterfall image, and scripted traffic +including GhostNet check-ins and an @GSTFLASH drill. Nothing here is real +traffic; it exercises storage, the API and the net-log viewer. +""" + +from __future__ import annotations + +import argparse +import math +import random +import struct +from datetime import timedelta +from pathlib import Path + +from ghostjs8.contract.messages import Decode, NetSummary +from ghostjs8.ghostnet.recorder import NetRecorder +from ghostjs8.ghostnet.schedule import Region, upcoming +from ghostjs8.receivers.base import AUDIO_SAMPLE_RATE, AudioBlock, WaterfallRow +from ghostjs8.store.sqlite import Store +from ghostjs8.util.clock import ManualClock, SystemClock + +SECONDS = 300 +SPAN_HZ = 14_648 +TRAFFIC = [ + # (seconds after start, from, text after "FROM: ", grid, snr, offset) + (20, "K0OG", "@GHOSTNET CHECKING IN EN34", "EN34", -14, 820), + (35, "KN4CRD", "@GHOSTNET QRV EM73", "EM73", -9, 1310), + (65, "W1GHZ", "@GHOSTNET SNR? FN31", "FN31pr", -18, 640), + (95, "KG9B", "@GNUSAIN CHECKING IN EN52", "EN52", -12, 1720), + (140, "W4GHT", "@GSTFLASH DRILL DRILL DRILL - EXERCISE ONLY", "EM73", -7, 1450), + (200, "KD8SKZ", "K0OG SNR -14", None, -16, 1040), +] + + +def _audio_block(t: float, rng: random.Random) -> bytes: + samples = [] + tone_on = (int(t) % 15) < 12 # JS8 transmissions fill ~12.6 s of each 15 s slot + for i in range(512): + x = rng.gauss(0, 18) + if tone_on: + x += 900 * math.sin( + 2 * math.pi * (1000 + 6.25 * (int(t * 6.25) % 8)) * (t + i / AUDIO_SAMPLE_RATE) + ) + samples.append(max(-32768, min(32767, round(x)))) + return struct.pack(f"<{len(samples)}h", *samples) + + +def _wf_row(dial_hz: int, second: int) -> WaterfallRow: + start = dial_hz + 1550 - SPAN_HZ // 2 + bins = bytearray(140 + (i * 7 + second * 13) % 9 for i in range(1024)) + for _, _, _, _, snr, offset in TRAFFIC: + if (second % 15) < 12 and (second // 15 + offset) % 3: + b = round((dial_hz + offset - start) * 1024 / SPAN_HZ) + for d in range(-2, 3): + bins[b + d] = max(bins[b + d], 205 + snr) + return WaterfallRow(seq=second, start_hz=start, span_hz=SPAN_HZ, bins=bytes(bins)) + + +def seed(db: Path, recordings: Path, region: Region = Region.NA) -> str: + now = SystemClock().utc_now() + occ = next(o for o in upcoming(region, now, 10) if o.window.kind == "net") + occ_start, occ_end = occ.start - timedelta(days=7), occ.end - timedelta(days=7) + clock = ManualClock(occ_start) + store = Store(db, clock) + w = occ.window + nid = f"{w.id}-{occ_start.strftime('%Y%m%dT%H%MZ')}" + if store.net(nid) is not None: + return nid + summary = NetSummary( + id=nid, + window_id=w.id, + label=w.label, + kind="net", + band=w.band, + dial_hz=w.dial_hz, + scheduled_start=occ_start, + scheduled_end=occ_end, + started=occ_start, + ended=None, + receiver=None, + receiver_reason="", + decode_count=0, + station_count=0, + has_audio=False, + has_waterfall=False, + flash_count=0, + operator_override=False, + ) + rec = NetRecorder(store, clock, recordings, summary) + rec.set_receiver( + "Sample Loop Receiver (simulated)", "nearest free receiver: 0 km (seeded demo data)" + ) + rng = random.Random(8) # noqa: S311 - deterministic test data + traffic = list(TRAFFIC) + blocks_per_second = AUDIO_SAMPLE_RATE / 512 + for second in range(SECONDS): + rec.on_waterfall(_wf_row(w.dial_hz, second)) + for k in range(round(blocks_per_second)): + rec.on_audio( + AudioBlock( + seq=0, + pcm_s16le=_audio_block(second + k / blocks_per_second, rng), + rssi_dbm=-70, + adc_overload=False, + ) + ) + while traffic and traffic[0][0] <= second: + _, frm, text, grid, snr, offset = traffic.pop(0) + utc = clock.utc_now() + store.heard(frm, utc, snr_db=snr, grid=grid, offset_hz=offset, dial_hz=w.dial_hz) + rec.on_decode( + Decode( + kind="activity", + utc=utc, + text=f"{frm}: {text}", + snr_db=snr, + offset_hz=offset, + dial_hz=w.dial_hz, + freq_hz=w.dial_hz + offset, + speed="normal", + from_call=frm, + grid=grid, + ) + ) + clock.advance(1) + rec.finish() + return nid + + +def main(argv: list[str] | None = None) -> None: + p = argparse.ArgumentParser(description="seed a synthetic GhostNet recording (UI tests only)") + p.add_argument("--db", type=Path, required=True) + p.add_argument("--recordings", type=Path, required=True) + p.add_argument("--region", default="na") + args = p.parse_args(argv) + print(seed(args.db, args.recordings, Region(args.region))) + + +__all__ = ["main", "seed"] + +if __name__ == "__main__": + main() diff --git a/bridge/src/ghostjs8/store/__init__.py b/bridge/src/ghostjs8/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/store/sqlite.py b/bridge/src/ghostjs8/store/sqlite.py new file mode 100644 index 0000000..bcf4a8c --- /dev/null +++ b/bridge/src/ghostjs8/store/sqlite.py @@ -0,0 +1,190 @@ +"""SQLite (WAL) persistence for decodes and heard stations. + +Small and synchronous: a few writes per 15 s cycle. Retention is enforced by +``prune()``, which the station calls periodically. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path + +from ghostjs8.contract.messages import Decode, NetSummary, Station +from ghostjs8.util.clock import Clock + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS decodes ( + id INTEGER PRIMARY KEY, + utc TEXT NOT NULL, + json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS decodes_utc ON decodes(utc); +CREATE TABLE IF NOT EXISTS stations ( + callsign TEXT PRIMARY KEY, + last_heard_utc TEXT NOT NULL, + snr_db INTEGER, + grid TEXT, + offset_hz INTEGER, + dial_hz INTEGER, + heard_count INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS stations_heard ON stations(last_heard_utc); +CREATE TABLE IF NOT EXISTS nets ( + id TEXT PRIMARY KEY, + started TEXT NOT NULL, + json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS nets_started ON nets(started); +CREATE TABLE IF NOT EXISTS net_decodes ( + id INTEGER PRIMARY KEY, + net_id TEXT NOT NULL REFERENCES nets(id) ON DELETE CASCADE, + json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS net_decodes_net ON net_decodes(net_id); +""" + + +def _iso(dt: datetime) -> str: + return dt.isoformat() + + +class Store: + def __init__(self, path: str | Path, clock: Clock, *, retention_days: int = 30) -> None: + self._clock = clock + self._retention = timedelta(days=retention_days) + if str(path) != ":memory:": + Path(path).parent.mkdir(parents=True, exist_ok=True) + self._db = sqlite3.connect(str(path), isolation_level=None, check_same_thread=False) + self._db.execute("PRAGMA journal_mode=WAL") + self._db.execute("PRAGMA synchronous=NORMAL") + self._db.executescript(SCHEMA) + + def close(self) -> None: + self._db.close() + + def add_decode(self, decode: Decode) -> None: + self._db.execute( + "INSERT INTO decodes (utc, json) VALUES (?, ?)", + (_iso(decode.utc), decode.model_dump_json()), + ) + + def heard( + self, + callsign: str, + utc: datetime, + *, + snr_db: int | None, + grid: str | None, + offset_hz: int | None, + dial_hz: int | None, + count: bool = True, + ) -> Station: + """Record a station; keeps the latest valid grid. Returns the updated row.""" + self._db.execute( + """ + INSERT INTO stations (callsign, last_heard_utc, snr_db, grid, offset_hz, dial_hz, heard_count) + VALUES (?, ?, ?, ?, ?, ?, 1) + ON CONFLICT(callsign) DO UPDATE SET + last_heard_utc = max(last_heard_utc, excluded.last_heard_utc), + snr_db = coalesce(excluded.snr_db, snr_db), + grid = coalesce(excluded.grid, grid), + offset_hz = coalesce(excluded.offset_hz, offset_hz), + dial_hz = coalesce(excluded.dial_hz, dial_hz), + heard_count = heard_count + ? + """, + (callsign, _iso(utc), snr_db, grid, offset_hz, dial_hz, 1 if count else 0), + ) + station = self.station(callsign) + if station is None: # pragma: no cover - just inserted + raise RuntimeError("station vanished") + return station + + def station(self, callsign: str) -> Station | None: + row = self._db.execute( + "SELECT callsign, last_heard_utc, snr_db, grid, offset_hz, dial_hz, heard_count " + "FROM stations WHERE callsign = ?", + (callsign,), + ).fetchone() + return _station(row) if row else None + + def recent_decodes(self, limit: int = 200) -> list[Decode]: + rows = self._db.execute( + "SELECT json FROM (SELECT id, json FROM decodes ORDER BY id DESC LIMIT ?) ORDER BY id", + (limit,), + ).fetchall() + return [Decode.model_validate_json(r[0]) for r in rows] + + def stations( + self, *, within: timedelta = timedelta(hours=24), limit: int = 500 + ) -> list[Station]: + since = _iso(self._clock.utc_now() - within) + rows = self._db.execute( + "SELECT callsign, last_heard_utc, snr_db, grid, offset_hz, dial_hz, heard_count FROM stations " + "WHERE last_heard_utc >= ? ORDER BY last_heard_utc DESC LIMIT ?", + (since, limit), + ).fetchall() + return [_station(r) for r in rows] + + # ------------------------------------------------------------------ nets + + def save_net(self, net: NetSummary) -> None: + self._db.execute( + "INSERT INTO nets (id, started, json) VALUES (?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET json = excluded.json", + (net.id, _iso(net.started), net.model_dump_json()), + ) + + def net(self, net_id: str) -> NetSummary | None: + row = self._db.execute("SELECT json FROM nets WHERE id = ?", (net_id,)).fetchone() + return NetSummary.model_validate_json(row[0]) if row else None + + def nets(self, limit: int = 100) -> list[NetSummary]: + rows = self._db.execute( + "SELECT json FROM nets ORDER BY started DESC LIMIT ?", (limit,) + ).fetchall() + return [NetSummary.model_validate_json(r[0]) for r in rows] + + def add_net_decode(self, net_id: str, decode: Decode) -> None: + self._db.execute( + "INSERT INTO net_decodes (net_id, json) VALUES (?, ?)", + (net_id, decode.model_dump_json()), + ) + + def net_decodes(self, net_id: str) -> list[Decode]: + rows = self._db.execute( + "SELECT json FROM net_decodes WHERE net_id = ? ORDER BY id", (net_id,) + ).fetchall() + return [Decode.model_validate_json(r[0]) for r in rows] + + def expired_nets(self, older_than: timedelta) -> list[str]: + cutoff = _iso(self._clock.utc_now() - older_than) + return [ + r[0] + for r in self._db.execute("SELECT id FROM nets WHERE started < ?", (cutoff,)).fetchall() + ] + + def delete_net(self, net_id: str) -> None: + self._db.execute("DELETE FROM net_decodes WHERE net_id = ?", (net_id,)) + self._db.execute("DELETE FROM nets WHERE id = ?", (net_id,)) + + def prune(self) -> int: + cutoff = _iso(self._clock.utc_now() - self._retention) + removed = self._db.execute("DELETE FROM decodes WHERE utc < ?", (cutoff,)).rowcount + removed += self._db.execute( + "DELETE FROM stations WHERE last_heard_utc < ?", (cutoff,) + ).rowcount + return removed + + +def _station(row: tuple[object, ...]) -> Station: + callsign, heard, snr, grid, offset, dial, count = row + return Station( + callsign=str(callsign), + last_heard_utc=datetime.fromisoformat(str(heard)), + snr_db=snr if isinstance(snr, int) else None, + grid=grid if isinstance(grid, str) else None, + offset_hz=offset if isinstance(offset, int) else None, + dial_hz=dial if isinstance(dial, int) else None, + heard_count=count if isinstance(count, int) else 1, + ) diff --git a/bridge/src/ghostjs8/util/__init__.py b/bridge/src/ghostjs8/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/src/ghostjs8/util/backoff.py b/bridge/src/ghostjs8/util/backoff.py new file mode 100644 index 0000000..8ab12a3 --- /dev/null +++ b/bridge/src/ghostjs8/util/backoff.py @@ -0,0 +1,33 @@ +"""Jittered exponential backoff for reconnecting to public receivers politely.""" + +from __future__ import annotations + +import random +from collections.abc import Callable + + +class Backoff: + def __init__( + self, + *, + minimum_s: float = 5.0, + maximum_s: float = 300.0, + factor: float = 2.0, + jitter: float = 0.25, + rand: Callable[[], float] = random.random, + ) -> None: + if not 0 < minimum_s <= maximum_s: + raise ValueError("need 0 < minimum <= maximum") + self._min, self._max, self._factor, self._jitter = minimum_s, maximum_s, factor, jitter + self._rand = rand + self._attempt = 0 + + def next_delay(self) -> float: + """Delay before the next attempt; grows until reset().""" + base = min(self._max, self._min * self._factor**self._attempt) + self._attempt += 1 + spread = base * self._jitter + return max(self._min, min(self._max, base - spread + 2 * spread * self._rand())) + + def reset(self) -> None: + self._attempt = 0 diff --git a/bridge/src/ghostjs8/util/clock.py b/bridge/src/ghostjs8/util/clock.py new file mode 100644 index 0000000..5dcbaa3 --- /dev/null +++ b/bridge/src/ghostjs8/util/clock.py @@ -0,0 +1,45 @@ +"""Injectable clocks. Anything time-dependent takes a Clock so tests are deterministic.""" + +from __future__ import annotations + +import time +from datetime import UTC, datetime, timedelta +from typing import Protocol + + +class Clock(Protocol): + def monotonic(self) -> float: + """Seconds from an arbitrary origin; never goes backwards.""" + ... + + def utc_now(self) -> datetime: + """Wall-clock time, timezone-aware UTC.""" + ... + + +class SystemClock: + def monotonic(self) -> float: + return time.monotonic() + + def utc_now(self) -> datetime: + return datetime.now(UTC) + + +class ManualClock: + """A clock that only moves when told to. For tests and simulations.""" + + def __init__(self, start: datetime | None = None) -> None: + self._mono = 1000.0 + self._utc = start or datetime(2026, 1, 1, tzinfo=UTC) + + def monotonic(self) -> float: + return self._mono + + def utc_now(self) -> datetime: + return self._utc + + def advance(self, seconds: float) -> None: + if seconds < 0: + raise ValueError("ManualClock cannot go backwards") + self._mono += seconds + self._utc += timedelta(seconds=seconds) diff --git a/bridge/src/ghostjs8/util/logs.py b/bridge/src/ghostjs8/util/logs.py new file mode 100644 index 0000000..bc15fed --- /dev/null +++ b/bridge/src/ghostjs8/util/logs.py @@ -0,0 +1,50 @@ +"""Structured JSON logs (one object per line), with any ``extra=`` fields kept.""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime + +_STD = frozenset(vars(logging.LogRecord("", 0, "", 0, "", None, None))) | { + "message", + "asctime", + "taskName", +} + + +class JsonFormatter(logging.Formatter): + def __init__(self, service: str) -> None: + super().__init__() + self._service = service + + def format(self, record: logging.LogRecord) -> str: + entry: dict[str, object] = { + "ts": datetime.fromtimestamp(record.created, tz=UTC).isoformat(timespec="milliseconds"), + "level": record.levelname.lower(), + "service": self._service, + "logger": record.name, + "msg": record.getMessage(), + } + for key, value in vars(record).items(): + if key not in _STD and not key.startswith("_"): + entry[key] = ( + value + if isinstance(value, (str, int, float, bool)) or value is None + else str(value) + ) + if record.exc_info: + entry["exc"] = self.formatException(record.exc_info) + return json.dumps(entry, ensure_ascii=False) + + +def configure(service: str, level: str = "INFO", fmt: str = "json") -> None: + handler = logging.StreamHandler() + if fmt == "json": + handler.setFormatter(JsonFormatter(service)) + else: + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) + root = logging.getLogger() + root.handlers[:] = [handler] + root.setLevel(level) + logging.getLogger("websockets").setLevel(max(logging.WARNING, root.level)) diff --git a/bridge/src/ghostjs8/util/maidenhead.py b/bridge/src/ghostjs8/util/maidenhead.py new file mode 100644 index 0000000..5227b52 --- /dev/null +++ b/bridge/src/ghostjs8/util/maidenhead.py @@ -0,0 +1,43 @@ +"""Maidenhead grid locators. Only valid grids are ever mapped.""" + +from __future__ import annotations + +import re + +# Field (A-R), square (0-9), optional subsquare (a-x), optional extended square (0-9). +_GRID = re.compile(r"^[A-R]{2}(?:[0-9]{2}(?:[A-X]{2}(?:[0-9]{2})?)?)?$") + + +def normalize_grid(grid: str | None) -> str | None: + """Return the canonical form (AB12cd34) of a valid 2/4/6/8-character grid, else None. + + Two-character fields are rejected: too coarse to map meaningfully. + """ + if not grid: + return None + g = grid.strip().upper() + if len(g) not in (4, 6, 8) or not _GRID.match(g): + return None + return g[:4] + g[4:6].lower() + g[6:] + + +def grid_center(grid: str) -> tuple[float, float]: + """(lat, lon) of the centre of a valid grid square. Raises ValueError otherwise.""" + g = normalize_grid(grid) + if g is None: + raise ValueError(f"invalid Maidenhead grid: {grid!r}") + u = g.upper() + lon: float = (ord(u[0]) - 65) * 20 - 180 + lat: float = (ord(u[1]) - 65) * 10 - 90 + lon += int(u[2]) * 2 + lat += int(u[3]) + lon_size, lat_size = 2.0, 1.0 + if len(u) >= 6: + lon += (ord(u[4]) - 65) * (2 / 24) + lat += (ord(u[5]) - 65) * (1 / 24) + lon_size, lat_size = 2 / 24, 1 / 24 + if len(u) == 8: + lon += int(u[6]) * (2 / 240) + lat += int(u[7]) * (1 / 240) + lon_size, lat_size = 2 / 240, 1 / 240 + return lat + lat_size / 2, lon + lon_size / 2 diff --git a/bridge/tests/__init__.py b/bridge/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/tests/integration/__init__.py b/bridge/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/tests/integration/test_agent.py b/bridge/tests/integration/test_agent.py new file mode 100644 index 0000000..fe9cb83 --- /dev/null +++ b/bridge/tests/integration/test_agent.py @@ -0,0 +1,262 @@ +"""decoder-agent pieces over real localhost sockets (no container).""" + +from __future__ import annotations + +import asyncio +import json +import socket +import sys +from datetime import UTC, datetime + +import pytest + +from ghostjs8.decoders.base import DecodeEvent, DecoderHealth, DecoderUnavailable, SpotEvent +from ghostjs8.decoders.js8call_native import agent_protocol as proto +from ghostjs8.decoders.js8call_native.adapter import Js8CallNativeDecoder +from ghostjs8.decoders.js8call_native.agent import ( + Agent, + Js8UdpApi, + PcmSink, + js8call_capturing, + parse_supervisor_status, +) +from ghostjs8.util.clock import ManualClock + + +async def udp_api() -> tuple[Js8UdpApi, asyncio.Queue[str], int, asyncio.DatagramTransport]: + q: asyncio.Queue[str] = asyncio.Queue(maxsize=8) + api = Js8UdpApi(ManualClock(), q) + loop = asyncio.get_running_loop() + transport, _ = await loop.create_datagram_endpoint(lambda: api, local_addr=("127.0.0.1", 0)) + port = transport.get_extra_info("sockname")[1] + return api, q, port, transport + + +def js8_socket() -> socket.socket: + """Plays JS8Call: sends from an ephemeral port and listens there for replies.""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.bind(("127.0.0.1", 0)) + s.settimeout(2) + return s + + +async def test_reply_port_learned_and_requests_reach_it() -> None: + api, _, port, transport = await udp_api() + js8 = js8_socket() + try: + assert api.request("RX.GET_CALL_ACTIVITY") is False # nowhere to send yet + js8.sendto( + json.dumps({"type": "PING", "value": "", "params": {}}).encode(), ("127.0.0.1", port) + ) + await asyncio.sleep(0.05) + assert api.reply_addr == js8.getsockname() + assert api.last_ping is not None + assert api.request("RX.GET_CALL_ACTIVITY") is True + data = await asyncio.to_thread(js8.recv, 4096) + req = json.loads(data) + assert req["type"] == "RX.GET_CALL_ACTIVITY" + # the reply carries our _ID -> round trip recorded + assert api.last_reply is None + reply = {"type": "RX.CALL_ACTIVITY", "value": "", "params": {"_ID": req["params"]["_ID"]}} + js8.sendto(json.dumps(reply).encode(), ("127.0.0.1", port)) + await asyncio.sleep(0.05) + assert api.last_reply is not None + finally: + js8.close() + transport.close() + + +async def test_forwards_decodes_and_ignores_noise() -> None: + _api, q, port, transport = await udp_api() + js8 = js8_socket() + try: + for msg in ( + {"type": "STATION.STATUS", "value": "", "params": {}}, + {"type": "RX.ACTIVITY", "value": "A: B", "params": {"SNR": 1, "OFFSET": 2}}, + {"type": "RX.DIRECTED", "value": "B ♢", "params": {"FROM": "A", "SNR": 1, "OFFSET": 2}}, + {"type": "SOMETHING.NEW", "value": "", "params": {}}, + ): + js8.sendto(json.dumps(msg).encode(), ("127.0.0.1", port)) + js8.sendto(b"\xff not json", ("127.0.0.1", port)) + await asyncio.sleep(0.1) + types = [json.loads(q.get_nowait())["msg"]["type"] for _ in range(q.qsize())] + assert types == ["RX.ACTIVITY", "RX.DIRECTED"] + finally: + js8.close() + transport.close() + + +async def test_transmit_requests_are_impossible() -> None: + api, _, _, transport = await udp_api() + try: + for bad in ("TX.SEND_MESSAGE", "TX.SET_TEXT", "STATION.SET_CALLSIGN", "RIG.SET_FREQ"): + with pytest.raises(ValueError, match="not allowed"): + api.request(bad) + finally: + transport.close() + + +def test_supervisor_status_parsing() -> None: + out = "js8call RUNNING pid 11, uptime 0:00:24\n" + assert parse_supervisor_status(out, "js8call") + assert not parse_supervisor_status("js8call BACKOFF Exited too quickly\n", "js8call") + assert not parse_supervisor_status("", "js8call") + + +SOURCES = json.dumps( + [{"index": 0, "name": "ghost_rx.monitor"}, {"index": 1, "name": "ghost_rx_in"}] +) + + +def outputs(source: int, binary: str, corked: bool) -> str: + return json.dumps( + [ + { + "index": 1, + "source": source, + "corked": corked, + "properties": {"application.process.binary": binary}, + } + ] + ) + + +@pytest.mark.parametrize( + ("outs", "expected"), + [ + (outputs(1, "JS8Call", False), True), + (outputs(1, "JS8Call", True), False), # corked: not capturing + (outputs(0, "JS8Call", False), False), # wrong source + (outputs(1, "pacat", False), False), # someone else + ("not json", False), + ], +) +def test_capture_detection(outs: str, expected: bool) -> None: + assert js8call_capturing(SOURCES, outs) is expected + + +async def test_pcm_sink_is_bounded_and_drops_oldest() -> None: + sink = PcmSink(ManualClock(), command=("cat",), max_blocks=3) + for i in range(5): + sink.submit(bytes([i])) + assert sink.dropped == 2 + assert [sink.queue.get_nowait() for _ in range(3)] == [b"\x02", b"\x03", b"\x04"] + + +async def test_pcm_sink_writes_through_process() -> None: + sink = PcmSink( + ManualClock(), command=(sys.executable, "-c", "import sys; sys.stdin.buffer.read()") + ) + task = asyncio.create_task(sink.run()) + sink.submit(b"\x00\x01" * 256) + async with asyncio.timeout(5): + while sink.last_write is None: + await asyncio.sleep(0.02) + task.cancel() + # Let run() kill and reap the child inside this loop (no transport GC after close). + await asyncio.gather(task, return_exceptions=True) + + +# --------------------------------------------------- agent <-> bridge adapter + + +class Collect: + def __init__(self) -> None: + self.decodes: list[DecodeEvent] = [] + self.spots: list[SpotEvent] = [] + self.health: list[DecoderHealth] = [] + + def on_decode(self, event: DecodeEvent) -> None: + self.decodes.append(event) + + def on_spot(self, event: SpotEvent) -> None: + self.spots.append(event) + + def on_health(self, health: DecoderHealth) -> None: + self.health.append(health) + + +def free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +async def test_agent_end_to_end_with_bridge_adapter() -> None: + async def probe() -> tuple[bool, bool]: + return True, True + + written: list[bytes] = [] + pcm = PcmSink( + ManualClock(), command=(sys.executable, "-c", "import sys; sys.stdin.buffer.read()") + ) + agent = Agent( + udp_port=0, pcm=pcm, probe=probe, health_interval_s=0.05, call_activity_interval_s=60 + ) + ws_port = free_port() + agent_task = asyncio.create_task(agent.serve("127.0.0.1", ws_port)) + await asyncio.sleep(0.2) + udp_port = agent.udp._transport.get_extra_info("sockname")[1] # type: ignore[union-attr] + + sink = Collect() + dec = Js8CallNativeDecoder(f"ws://127.0.0.1:{ws_port}/agent", sink) + dec_task = asyncio.create_task(dec.run()) + try: + async with asyncio.timeout(5): + while not (dec.connected and sink.health): + await asyncio.sleep(0.02) + assert sink.health[-1].process_up + assert sink.health[-1].capturing + + dec.submit_audio(b"\x01\x00" * 512) + async with asyncio.timeout(5): + while pcm.last_write is None: + await asyncio.sleep(0.02) + written.append(b"ok") + + js8 = js8_socket() + js8.sendto(json.dumps(DIRECTED).encode(), ("127.0.0.1", udp_port)) + async with asyncio.timeout(5): + while not sink.decodes: + await asyncio.sleep(0.02) + js8.close() + assert sink.decodes[0].text == "K0OG: KN4CRD SNR +02" + finally: + dec_task.cancel() + agent_task.cancel() + await asyncio.gather(dec_task, agent_task, return_exceptions=True) + + +async def test_adapter_raises_unavailable_when_agent_down() -> None: + dec = Js8CallNativeDecoder( + f"ws://127.0.0.1:{free_port()}/agent", Collect(), connect_timeout_s=1 + ) + with pytest.raises(DecoderUnavailable): + await dec.run() + + +def test_adapter_drops_oldest_audio_when_offline() -> None: + dec = Js8CallNativeDecoder("ws://unused/agent", Collect(), max_pending_blocks=2) + for i in range(4): + dec.submit_audio(bytes([i])) + assert dec.dropped_audio_blocks == 2 + + +def test_health_roundtrip() -> None: + t = datetime(2026, 10, 8, tzinfo=UTC) + h = DecoderHealth(True, False, t, None, t, 3, {"udp_reply_port": "4242"}) + assert proto.decode_health(json.loads(proto.encode_health(h, t))) == h + + +DIRECTED = { + "type": "RX.DIRECTED", + "value": "KN4CRD SNR +02 ♢ ", + "params": { + "FROM": "K0OG", + "TO": "KN4CRD", + "SNR": -22, + "OFFSET": 705, + "SPEED": 0, + "UTC": 1791424811558, + }, +} diff --git a/bridge/tests/integration/test_kiwi_client.py b/bridge/tests/integration/test_kiwi_client.py new file mode 100644 index 0000000..5d51223 --- /dev/null +++ b/bridge/tests/integration/test_kiwi_client.py @@ -0,0 +1,275 @@ +"""KiwiReceiver against the fake KiwiSDR over real localhost sockets.""" + +from __future__ import annotations + +import asyncio +import contextlib +import socket +import struct +import wave +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from pathlib import Path + +import pytest +from websockets.asyncio.client import connect + +from ghostjs8.receivers.base import ( + AudioBlock, + ReceiverRejected, + ReceiverTimeout, + ReceiverUnreachable, + RejectReason, + Tuning, + UnsupportedAudioFormat, + WaterfallRow, +) +from ghostjs8.receivers.kiwisdr.client import KiwiEndpoint, KiwiReceiver +from ghostjs8.sim.fake_kiwi import FakeKiwi, FakeKiwiConfig + +pytestmark = pytest.mark.integration + +TUNING = Tuning(dial_hz=14_078_000, mode="usb", low_cut_hz=100, high_cut_hz=3000) + + +@dataclass +class Sink: + audio: list[AudioBlock] = field(default_factory=list) + rows: list[WaterfallRow] = field(default_factory=list) + info: dict[str, str] = field(default_factory=dict) + + def on_audio(self, block: AudioBlock) -> None: + self.audio.append(block) + + def on_waterfall(self, row: WaterfallRow) -> None: + self.rows.append(row) + + def on_info(self, key: str, value: str) -> None: + self.info[key] = value + + +@contextlib.asynccontextmanager +async def fake(**kw: object) -> AsyncIterator[FakeKiwi]: + async with FakeKiwi(FakeKiwiConfig(**kw)) as kiwi: # type: ignore[arg-type] + yield kiwi + + +def receiver(kiwi: FakeKiwi, sink: Sink, **kw: object) -> KiwiReceiver: + opts: dict[str, object] = {"handshake_timeout_s": 3.0, "connect_timeout_s": 3.0} + opts.update(kw) + return KiwiReceiver(KiwiEndpoint("127.0.0.1", kiwi.port), TUNING, sink, **opts) # type: ignore[arg-type] + + +async def run_until(rx: KiwiReceiver, cond: object, timeout: float = 5.0) -> None: + """Run the receiver until cond() is true, then close it cleanly.""" + task = asyncio.create_task(rx.run()) + try: + async with asyncio.timeout(timeout): + while not cond(): # type: ignore[operator] + if task.done(): + await task + return + await asyncio.sleep(0.02) + finally: + await rx.close() + await asyncio.wait_for(task, 3) + + +def tone_wav(path: Path, n: int = 12_000) -> list[int]: + samples = [((i * 37) % 2000) - 1000 for i in range(n)] + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(12_000) + w.writeframes(struct.pack(f"<{n}h", *samples)) + return samples + + +# ----------------------------------------------------------------- happy path + + +async def test_streams_audio_and_waterfall(tmp_path: Path) -> None: + samples = tone_wav(tmp_path / "t.wav") + sink = Sink() + async with fake(wav=tmp_path / "t.wav") as kiwi: + rx = receiver(kiwi, sink) + await run_until(rx, lambda: len(sink.audio) >= 30 and len(sink.rows) >= 2) + + pcm = b"".join(b.pcm_s16le for b in sink.audio) + got = list(struct.unpack(f"<{len(pcm) // 2}h", pcm)) + assert got[: len(samples)] == samples[: len(got)] # big-endian on the wire -> LE out + assert all(not b.adc_overload for b in sink.audio) + assert all(len(r.bins) == 1024 for r in sink.rows) + assert sink.info["audio_rate"] == "12000" + assert sink.info["version_maj"] == "1" + + +async def test_little_endian_flag_path(tmp_path: Path) -> None: + samples = tone_wav(tmp_path / "t.wav") + sink = Sink() + async with fake(wav=tmp_path / "t.wav", little_endian_flag=True) as kiwi: + await run_until(receiver(kiwi, sink, waterfall=False), lambda: len(sink.audio) >= 10) + pcm = b"".join(b.pcm_s16le for b in sink.audio) + assert list(struct.unpack(f"<{len(pcm) // 2}h", pcm)) == samples[: len(pcm) // 2] + + +async def test_sends_expected_commands_and_never_shared_hardware() -> None: + sink = Sink() + async with fake() as kiwi: + await run_until(receiver(kiwi, sink), lambda: len(sink.rows) >= 1 and len(sink.audio) >= 1) + snd, wf = kiwi.commands("SND"), kiwi.commands("W/F") + assert "SET compression=0" in snd + assert "SET mod=usb low_cut=100 high_cut=3000 freq=14078.000" in snd + assert "SET ident_user=ghost.js8" in snd + assert "SET wf_comp=0" in wf + assert any(c.startswith("SET zoom=11 cf=14079.550") for c in wf) + forbidden = ("attn", "rf_attn", "genattn", "SET gen=", "admin") + assert not [c for c in snd + wf if any(f in c for f in forbidden)] + + +async def test_both_streams_use_one_session_id() -> None: + sink = Sink() + async with fake() as kiwi: + rx = receiver(kiwi, sink, session_id=4242) + await run_until(rx, lambda: len(sink.rows) >= 1) + assert {sid for sid, _ in kiwi.sessions} == {4242} + assert {kind for _, kind in kiwi.sessions} == {"SND", "W/F"} + + +async def test_unpaired_waterfall_is_refused_by_server() -> None: + async with fake() as kiwi: + async with connect(f"ws://127.0.0.1:{kiwi.port}/999/W/F") as ws: + msg = await ws.recv() + assert msg == b"MSG pairing_error=no_snd_session" + + +async def test_retune_sends_new_mod_and_view() -> None: + sink = Sink() + async with fake() as kiwi: + rx = receiver(kiwi, sink) + task = asyncio.create_task(rx.run()) + async with asyncio.timeout(5): + while len(sink.rows) < 1: + await asyncio.sleep(0.02) + await rx.retune(Tuning(dial_hz=7_078_000, mode="lsb")) + await asyncio.sleep(0.2) + await rx.close() + await asyncio.wait_for(task, 3) + assert "SET mod=lsb low_cut=-3000 high_cut=-100 freq=7078.000" in kiwi.commands("SND") + assert any(c.startswith("SET zoom=11 cf=7076.450") for c in kiwi.commands("W/F")) + + +async def test_adc_overload_flag_surfaces() -> None: + sink = Sink() + async with fake(adc_overload=True) as kiwi: + await run_until(receiver(kiwi, sink, waterfall=False), lambda: len(sink.audio) >= 3) + assert all(b.adc_overload for b in sink.audio) + + +async def test_truncated_frames_dropped_session_continues() -> None: + sink = Sink() + async with fake(truncate_every=3) as kiwi: + rx = receiver(kiwi, sink, waterfall=False) + await run_until(rx, lambda: len(sink.audio) >= 20) + assert rx.stats["dropped_frames"] >= 5 + + +# ------------------------------------------------------------------ rejections + + +@pytest.mark.parametrize( + ("behavior", "reason"), + [ + ("too_busy", RejectReason.FULL), + ("bad_password", RejectReason.AUTH), + ("down", RejectReason.DOWN), + ("db_update", RejectReason.BUSY), + ], +) +async def test_receiver_rejections(behavior: str, reason: RejectReason) -> None: + async with fake(behavior=behavior) as kiwi: + with pytest.raises(ReceiverRejected) as exc: + await receiver(kiwi, Sink()).run() + assert exc.value.reason is reason + + +async def test_wrong_password_rejected() -> None: + async with fake(password="sekrit") as kiwi: + with pytest.raises(ReceiverRejected) as exc: + await receiver(kiwi, Sink()).run() + assert exc.value.reason is RejectReason.AUTH + + +async def test_handshake_timeout() -> None: + async with fake(behavior="silent") as kiwi: + with pytest.raises(ReceiverTimeout): + await receiver(kiwi, Sink(), handshake_timeout_s=0.5).run() + + +async def test_refused_port_is_unreachable() -> None: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] # bound but not listening -> refused + rx = KiwiReceiver(KiwiEndpoint("127.0.0.1", port), TUNING, Sink()) + with pytest.raises(ReceiverUnreachable): + await rx.run() + + +async def test_unresolvable_host_is_unreachable() -> None: + rx = KiwiReceiver(KiwiEndpoint("no-such-host.invalid", 8073), TUNING, Sink()) + with pytest.raises(ReceiverUnreachable): + await rx.run() + + +async def test_connect_timeout() -> None: + async def never(_: object) -> None: + await asyncio.Event().wait() + + @contextlib.asynccontextmanager + async def slow_connector(url: str, address: str | None = None) -> AsyncIterator[object]: + await never(url) + yield None # pragma: no cover + + rx = KiwiReceiver( + KiwiEndpoint("127.0.0.1", 1), + TUNING, + Sink(), + connect_timeout_s=0.2, + connector=slow_connector, # type: ignore[arg-type] + ) + with pytest.raises(ReceiverTimeout): + await rx.run() + + +# -------------------------------------------------------- unsupported formats + + +async def test_persistent_compressed_audio_is_fatal() -> None: + async with fake(compressed_flag=True) as kiwi: + with pytest.raises(UnsupportedAudioFormat) as exc: + await receiver(kiwi, Sink(), waterfall=False, compression_grace_s=0.0).run() + assert exc.value.kind == "compressed" + + +async def test_stereo_audio_is_fatal() -> None: + async with fake(stereo_flag=True) as kiwi: + with pytest.raises(UnsupportedAudioFormat) as exc: + await receiver(kiwi, Sink(), waterfall=False).run() + assert exc.value.kind == "stereo" + + +async def test_wrong_audio_rate_is_fatal() -> None: + async with fake(audio_rate=20250) as kiwi: + with pytest.raises(UnsupportedAudioFormat) as exc: + await receiver(kiwi, Sink(), waterfall=False).run() + assert exc.value.kind == "sample_rate" + + +async def test_close_returns_cleanly_and_releases_sockets() -> None: + sink = Sink() + async with fake() as kiwi: + rx = receiver(kiwi, sink) + await run_until(rx, lambda: len(sink.rows) >= 1) # raises if run() raised + await asyncio.sleep(0.2) + assert rx._snd is None + assert rx._wf is None diff --git a/bridge/tests/unit/__init__.py b/bridge/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge/tests/unit/test_bridge_core.py b/bridge/tests/unit/test_bridge_core.py new file mode 100644 index 0000000..b7e76c8 --- /dev/null +++ b/bridge/tests/unit/test_bridge_core.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +import asyncio +import json +import struct +from dataclasses import replace +from datetime import UTC, datetime +from typing import ClassVar + +import pytest +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from ghostjs8.api.app import InvalidReceiver, RateLimiter, create_app, validate_receiver_host +from ghostjs8.api.hub import Hub, HubFull +from ghostjs8.contract import binary +from ghostjs8.contract.messages import ( + Decode, + Error, + Health, + Hello, + History, + ReceiverDirectory, + ReceiverStatus, + Session, + server_message_adapter, +) +from ghostjs8.contract.messages import Station as StationMsg +from ghostjs8.decoders.base import DecodeEvent, DecoderSink, SpotEvent +from ghostjs8.receivers.base import ( + AudioBlock, + ReceiverRejected, + ReceiverSink, + ReceiverTimeout, + RejectReason, + Tuning, + UnsupportedAudioFormat, + WaterfallRow, +) +from ghostjs8.receivers.directory import Directory +from ghostjs8.session.station import ReceiverTarget, Station, StationConfig +from ghostjs8.settings import Settings +from ghostjs8.store.sqlite import Store +from ghostjs8.util.backoff import Backoff +from ghostjs8.util.clock import ManualClock + +# ------------------------------------------------------------------ fakes + + +class FakeDecoder: + def __init__(self, sink: DecoderSink) -> None: + self.sink = sink + self.audio: list[bytes] = [] + self.connected = True + + def submit_audio(self, pcm_s16le: bytes) -> None: + self.audio.append(pcm_s16le) + + async def run(self) -> None: + await asyncio.Event().wait() + + async def close(self) -> None: + pass + + +class FakeReceiver: + """Scripted receiver: raises `fail` immediately, or runs until closed.""" + + instances: ClassVar[list[FakeReceiver]] = [] + fail: ClassVar[Exception | None] = None + + def __init__(self, target: ReceiverTarget, tuning: Tuning, sink: ReceiverSink) -> None: + self.target, self.tuning, self.sink = target, tuning, sink + self.closed = asyncio.Event() + self.retunes: list[Tuning] = [] + FakeReceiver.instances.append(self) + + async def run(self) -> None: + if FakeReceiver.fail is not None: + raise FakeReceiver.fail + await self.closed.wait() + + async def close(self) -> None: + self.closed.set() + + async def retune(self, tuning: Tuning) -> None: + self.retunes.append(tuning) + self.tuning = tuning + + +@pytest.fixture(autouse=True) +def reset_fakes() -> None: + FakeReceiver.instances = [] + FakeReceiver.fail = None + + +def fast_backoff() -> Backoff: + return Backoff(minimum_s=0.01, maximum_s=0.02, jitter=0.0) + + +def station(clock: ManualClock | None = None, **kw: object) -> Station: + opts: dict[str, object] = { + "receiver_backoff": fast_backoff(), + "clock": clock or ManualClock(), + # The 5 s politeness floor between connects has its own tests below. + "config": StationConfig(min_connect_interval_s=0), + } + opts.update(kw) + return Station(Hub(), FakeDecoder, FakeReceiver, **opts) # type: ignore[arg-type] + + +EVENT = DecodeEvent( + kind="activity", + utc=datetime(2026, 10, 8, tzinfo=UTC), + text="K0OG: KN4CRD SNR +02", + snr_db=-22, + offset_hz=705, + speed="normal", + from_call="K0OG", +) + + +def drain(q: asyncio.Queue[str | bytes]) -> list[object]: + out: list[object] = [] + while not q.empty(): + item = q.get_nowait() + out.append(item if isinstance(item, bytes) else server_message_adapter.validate_json(item)) + return out + + +async def wait_for(cond: object, timeout: float = 2.0) -> None: + async with asyncio.timeout(timeout): + while not cond(): # type: ignore[operator] + await asyncio.sleep(0.005) + + +# ------------------------------------------------------------------- units + + +def test_backoff_grows_caps_and_resets() -> None: + b = Backoff(minimum_s=5, maximum_s=300, jitter=0.0) + delays = [b.next_delay() for _ in range(10)] + assert delays[:4] == [5, 10, 20, 40] + assert max(delays) == 300 + b.reset() + assert b.next_delay() == 5 + + +def test_backoff_jitter_stays_in_bounds() -> None: + for r in (0.0, 1.0): + b = Backoff(minimum_s=5, maximum_s=300, jitter=0.25, rand=lambda r=r: r) + assert all(5 <= b.next_delay() <= 300 for _ in range(12)) + + +async def test_hub_limits_subscriptions_and_drops_oldest() -> None: + hub = Hub(max_clients=2, queue_size=2) + a, b = hub.add(), hub.add() + with pytest.raises(HubFull): + hub.add() + a.audio = True + assert hub.wants("audio") + assert not hub.wants("waterfall") + hub.broadcast_binary("audio", b"x") + assert a.queue.qsize() == 1 + assert b.queue.qsize() == 0 + for i in range(3): + hub.broadcast_raw(f"m{i}") + assert a.dropped == 2 + hub.remove(a) + assert a.closed.is_set() + + +@pytest.mark.parametrize( + ("host", "private", "ok"), + [ + ("kiwi.example.org", False, True), + ("1.1.1.1", False, True), + ("192.168.1.20", True, True), + ("192.168.1.20", False, False), + ("127.0.0.1", True, False), + ("::1", True, False), + ("169.254.169.254", True, False), + ("0.0.0.0", True, False), # noqa: S104 + ("localhost", True, False), + ("decoder", True, False), + ("bad host!", True, False), + ("x.localhost", True, False), + ], +) +def test_receiver_host_validation(host: str, private: bool, ok: bool) -> None: + if ok: + assert validate_receiver_host(host, allow_private=private) + else: + with pytest.raises(InvalidReceiver): + validate_receiver_host(host, allow_private=private) + + +def test_rate_limiter() -> None: + clock = ManualClock() + rl = RateLimiter(clock, burst=2, window_s=5) + assert rl.allow() + assert rl.allow() + assert not rl.allow() + clock.advance(5) + assert rl.allow() + + +def test_settings_from_env() -> None: + s = Settings.from_env( + { + "GHOSTJS8_RECEIVER_HOST": "kiwi.example", + "GHOSTJS8_DIAL_HZ": "7078000", + "GHOSTJS8_MODE": "LSB", + "GHOSTJS8_ALLOWED_ORIGINS": "https://a, https://b", + "GHOSTJS8_ALLOW_PRIVATE_RECEIVERS": "false", + "GHOSTJS8_IDLE_DISCONNECT_MINUTES": "5", + } + ) + assert s.receiver_host == "kiwi.example" + assert s.tuning == Tuning(dial_hz=7_078_000, mode="lsb") + assert s.allowed_origins == ("https://a", "https://b") + assert not s.allow_private_receivers + assert s.idle_disconnect_minutes == 5 + with pytest.raises(ValueError, match="usb or lsb"): + Settings.from_env({"GHOSTJS8_MODE": "am"}) + + +def test_binary_frame_layouts() -> None: + a = binary.encode_audio(7, 12000, b"\x01\x00\x02\x00", adc_overload=True) + assert struct.unpack_from(" None: + st = station() + sub, other = st.hub.add(), st.hub.add() + sub.audio = True + st.on_audio(AudioBlock(seq=3, pcm_s16le=b"\x00\x01", rssi_dbm=-60, adc_overload=False)) + assert isinstance(st.decoder, FakeDecoder) + assert st.decoder.audio == [b"\x00\x01"] + assert st.state == "connected" + frames = [x for x in drain(sub.queue) if isinstance(x, bytes)] + assert frames == [binary.encode_audio(3, 12000, b"\x00\x01", adc_overload=False)] + assert not [x for x in drain(other.queue) if isinstance(x, bytes)] + + +async def test_waterfall_frame_rate_cap() -> None: + clock = ManualClock() + st = station(clock, config=StationConfig(waterfall_max_fps=10, min_connect_interval_s=0)) + c = st.hub.add() + c.waterfall = True + row = WaterfallRow(seq=1, start_hz=0, span_hz=12_000, bins=bytes(1024)) + for _ in range(5): # 5 rows within 50 ms: only the first passes + st.on_waterfall(row) + clock.advance(0.01) + clock.advance(0.1) + st.on_waterfall(row) + assert len([x for x in drain(c.queue) if isinstance(x, bytes)]) == 2 + + +async def test_adc_overload_status_debounced() -> None: + clock = ManualClock() + st = station(clock) + c = st.hub.add() + st.on_audio(AudioBlock(seq=1, pcm_s16le=b"", rssi_dbm=-30, adc_overload=True)) + msgs = [m for m in drain(c.queue) if isinstance(m, ReceiverStatus)] + assert msgs[-1].adc_overload + clock.advance(1.0) + st.on_audio(AudioBlock(seq=2, pcm_s16le=b"", rssi_dbm=-30, adc_overload=False)) + assert st.receiver_status().adc_overload # held for 2 s, no flapping + clock.advance(1.5) + st.on_audio(AudioBlock(seq=3, pcm_s16le=b"", rssi_dbm=-30, adc_overload=False)) + msgs = [m for m in drain(c.queue) if isinstance(m, ReceiverStatus)] + assert not msgs[-1].adc_overload + + +async def test_decode_is_stored_broadcast_and_station_counted_once() -> None: + clock = ManualClock() + store = Store(":memory:", clock) + st = station(clock, store=store) + c = st.hub.add() + st.on_decode(EVENT) + directed = replace(EVENT, kind="directed", grid="EN34") + st.on_decode(directed) + st.on_decode(replace(EVENT, from_call="@ALLCALL")) + msgs = drain(c.queue) + decodes = [m for m in msgs if isinstance(m, Decode)] + stations = [m for m in msgs if isinstance(m, StationMsg)] + assert [d.freq_hz for d in decodes] == [14_078_705] * 3 + assert [s.callsign for s in stations] == ["K0OG", "K0OG"] # groups are not stations + assert stations[-1].grid == "EN34" + assert stations[-1].heard_count == 1 + assert len(store.recent_decodes()) == 3 + assert st.health.snapshot().last_decode_utc == EVENT.utc + + +async def test_spots_never_map_invalid_grids() -> None: + st = station() + c = st.hub.add() + st.on_spot(SpotEvent("KG9B", EVENT.utc, -15, None, "XX99zz")) + st.on_spot(SpotEvent("K0OG", EVENT.utc, -22, None, "en34")) + stations = [m for m in drain(c.queue) if isinstance(m, StationMsg)] + assert [(s.callsign, s.grid) for s in stations] == [("KG9B", None), ("K0OG", "EN34")] + + +async def test_client_join_sends_hello_history_session_health_status() -> None: + clock = ManualClock() + store = Store(":memory:", clock) + st = station(clock, store=store) + st.on_decode(EVENT) + c = st.hub.add() + st.client_joined(c) + kinds = [type(m) for m in drain(c.queue)] + assert kinds[:5] == [Hello, History, Session, Health, ReceiverStatus] + + +# ------------------------------------------------------- station: lifecycle + + +async def run_station(st: Station) -> asyncio.Task[None]: + task = asyncio.create_task(st.run()) + await asyncio.sleep(0) + return task + + +async def test_select_tune_and_disconnect() -> None: + st = station() + task = await run_station(st) + try: + st.select_receiver(ReceiverTarget("kiwi.example", 8073)) + await wait_for(lambda: len(FakeReceiver.instances) == 1) + assert st.state == "connecting" + rx = FakeReceiver.instances[0] + await st.tune(Tuning(dial_hz=7_078_000, mode="lsb")) + assert rx.retunes == [Tuning(dial_hz=7_078_000, mode="lsb")] + st.select_receiver(ReceiverTarget("other.example", 8073)) + await wait_for(lambda: len(FakeReceiver.instances) == 2) + assert rx.closed.is_set() + assert FakeReceiver.instances[1].tuning.dial_hz == 7_078_000 + st.disconnect_receiver() + await wait_for(lambda: st.state == "idle") + finally: + task.cancel() + + +@pytest.mark.parametrize( + ("exc", "state", "retries"), + [ + (ReceiverRejected(RejectReason.AUTH), "rejected", False), + (ReceiverRejected(RejectReason.REDIRECT, "x"), "rejected", False), + (UnsupportedAudioFormat("compressed"), "failed", False), + (ReceiverTimeout("slow"), "backoff", True), + ], +) +async def test_failure_policy(exc: Exception, state: str, retries: bool) -> None: + FakeReceiver.fail = exc + st = station() + task = await run_station(st) + try: + st.select_receiver(ReceiverTarget("kiwi.example", 8073)) + await wait_for(lambda: st.state == state) + await asyncio.sleep(0.1) + assert (len(FakeReceiver.instances) > 1) is retries + finally: + task.cancel() + + +async def test_full_receiver_waits_patiently() -> None: + FakeReceiver.fail = ReceiverRejected(RejectReason.FULL, "all 4 slots taken") + st = station() + task = await run_station(st) + try: + st.select_receiver(ReceiverTarget("kiwi.example", 8073)) + await wait_for(lambda: st.next_retry_utc is not None) + assert st.reject_reason == "full" + assert st.next_retry_utc is not None + assert (st.next_retry_utc - st.clock.utc_now()).total_seconds() >= 60 + assert len(FakeReceiver.instances) == 1 + finally: + task.cancel() + + +async def test_idle_disconnect_and_resume() -> None: + clock = ManualClock() + st = station( + clock, + target=ReceiverTarget("kiwi.example", 8073), + config=StationConfig(idle_disconnect_s=60, min_connect_interval_s=0), + ) + task = await run_station(st) + try: + await wait_for(lambda: len(FakeReceiver.instances) == 1) + clock.advance(61) + await wait_for(lambda: st.state == "idle", timeout=3) + assert FakeReceiver.instances[0].closed.is_set() + st.client_joined(st.hub.add()) + await wait_for(lambda: len(FakeReceiver.instances) == 2) + finally: + task.cancel() + + +# -------------------------------------------------------------------- HTTP/WS + + +def app_with(st: Station, **kw: object) -> TestClient: + settings = Settings(db_path="", **kw) # type: ignore[arg-type] + + async def fetch(url: str) -> bytes: + return ( + b'var x = [{"id":"a","status":"active","offline":"no","url":"http://192.0.2.1:8073"},];' + ) + + return TestClient(create_app(st, settings, directory=Directory(fetch=fetch))) + + +def test_ws_handshake_messages_and_cleanup() -> None: + st = station() + with app_with(st) as http, http.websocket_connect("/ws") as ws: + first = server_message_adapter.validate_json(ws.receive_text()) + assert isinstance(first, Hello) + assert first.receive_only is True + assert st.hub.client_count == 1 + assert st.hub.client_count == 0 + + +def test_ws_control_messages() -> None: + st = station() + select_loopback = { + "v": 1, + "type": "select_receiver", + "receiver": {"host": "127.0.0.1", "port": 8073, "name": None}, + "password": "", + } + with app_with(st) as http, http.websocket_connect("/ws") as ws: + ws.send_text(json.dumps({"v": 1, "type": "ping"})) + ws.send_text("not json") + ws.send_text(json.dumps({"v": 2, "type": "ping"})) + ws.send_text(json.dumps({"v": 1, "type": "transmit", "text": "CQ"})) + ws.send_text(json.dumps(select_loopback)) + seen: list[object] = [] + while len([m for m in seen if isinstance(m, Error)]) < 4: + seen.append(server_message_adapter.validate_json(ws.receive_text())) + codes = [m.code for m in seen if isinstance(m, Error)] + assert codes == ["bad_message", "unsupported_version", "bad_message", "invalid_receiver"] + assert any(type(m).__name__ == "Pong" for m in seen) + + +def test_ws_origin_allowlist() -> None: + st = station() + with app_with(st, allowed_origins=("https://ghost.example",)) as http: + with ( + pytest.raises(WebSocketDisconnect), + http.websocket_connect("/ws", headers={"origin": "https://evil"}), + ): + pass + with http.websocket_connect("/ws", headers={"origin": "https://ghost.example"}) as ws: + assert json.loads(ws.receive_text())["type"] == "hello" + + +def test_ws_session_full() -> None: + st = Station(Hub(max_clients=1), FakeDecoder, FakeReceiver) # type: ignore[arg-type] + with app_with(st) as http, http.websocket_connect("/ws") as ws1: + ws1.receive_text() + with pytest.raises(WebSocketDisconnect), http.websocket_connect("/ws"): + pass + + +def test_http_endpoints() -> None: + st = station() + with app_with(st) as http: + assert http.get("/healthz").json() == {"status": "ok"} + ready = http.get("/readyz") + assert ready.status_code == 503 # nothing connected: not ready, and says why + assert Health.model_validate(ready.json()).overall == "offline" + directory = ReceiverDirectory.model_validate(http.get("/api/receivers").json()) + assert [r.host for r in directory.receivers] == ["192.0.2.1"] diff --git a/bridge/tests/unit/test_directory.py b/bridge/tests/unit/test_directory.py new file mode 100644 index 0000000..2974883 --- /dev/null +++ b/bridge/tests/unit/test_directory.py @@ -0,0 +1,83 @@ +import pytest + +from ghostjs8.receivers.directory import Directory, DirectoryError, parse_directory +from ghostjs8.util.clock import ManualClock + +# Shape of rx.linkfanel.net/kiwisdr_com.js (trimmed, values synthetic). +SAMPLE = """// KiwiSDR.com receiver list for dyatlov map maker +var kiwisdr_com = +[ + { + "id":"aaa", "status":"active", "offline":"no", "name":"Test RX | Somewhere", + "bands":"10000-32000000", "users":"3", "users_max":"4", "gps":"(46.427049, 0.888543)", + "grid":"JN06kj", "loc":"Somewhere FRANCE", "antenna":"Loop", "snr":"47,43", + "url":"http://192.0.2.10:8073" + }, + { + "id":"bbb", "status":"active", "offline":"no", "name":"Proxied", "bands":"0-30000000", + "users":"0", "users_max":"8", "gps":"", "grid":"N0CALL", "url":"http://22204.proxy.kiwisdr.com", + }, + {"id":"ccc", "status":"active", "offline":"yes", "url":"http://198.51.100.1:8073"}, + {"id":"ddd", "status":"active", "offline":"no", "url":"javascript:alert(1)"}, +] +; +""" + + +def test_parse_sample() -> None: + rx = parse_directory(SAMPLE) + assert [r.id for r in rx] == ["aaa", "bbb"] + a, b = rx + assert (a.host, a.port, a.tls) == ("192.0.2.10", 8073, False) + assert (a.users, a.users_max, a.min_hz, a.max_hz) == (3, 4, 10_000, 32_000_000) + assert (a.lat, a.lon, a.grid, a.snr_db) == (46.427049, 0.888543, "JN06kj", 47) + assert (b.host, b.port) == ("22204.proxy.kiwisdr.com", 80) + assert b.grid is None # invalid grids are never passed on + assert b.lat is None + + +def test_garbage_raises() -> None: + with pytest.raises(DirectoryError): + parse_directory("var x = 5;") + + +async def test_cache_ttl_and_stale_on_error() -> None: + clock = ManualClock() + calls: list[str] = [] + fail = False + + async def fetch(url: str) -> bytes: + calls.append(url) + if fail: + raise OSError("network down") + return SAMPLE.encode() + + d = Directory(clock=clock, fetch=fetch, ttl_s=900, retry_s=60) + first = await d.get() + assert not first.stale + assert len(first.receivers) == 2 + await d.get() + assert len(calls) == 1 # cached + + clock.advance(901) + fail = True + stale = await d.get() + assert stale.stale + assert len(stale.receivers) == 2 # last good list kept + await d.get() + assert len(calls) == 2 # no hammering within retry window + + clock.advance(61) + fail = False + again = await d.get() + assert not again.stale + assert len(calls) == 3 + + +async def test_first_fetch_failure_is_empty_and_stale() -> None: + async def fetch(url: str) -> bytes: + raise OSError("offline") + + result = await Directory(clock=ManualClock(), fetch=fetch).get() + assert result.stale + assert result.receivers == [] diff --git a/bridge/tests/unit/test_ghostnet_api.py b/bridge/tests/unit/test_ghostnet_api.py new file mode 100644 index 0000000..15d746a --- /dev/null +++ b/bridge/tests/unit/test_ghostnet_api.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import asyncio +from datetime import timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from ghostjs8.api.app import create_app +from ghostjs8.api.hub import Hub +from ghostjs8.contract.messages import Decode, NetLog, NetSummary +from ghostjs8.decoders.base import DecoderSink +from ghostjs8.ghostnet.pilot import build_pilot +from ghostjs8.ghostnet.recorder import NetRecorder +from ghostjs8.receivers.base import AudioBlock, WaterfallRow +from ghostjs8.receivers.directory import Directory +from ghostjs8.session.station import Station +from ghostjs8.settings import Settings +from ghostjs8.store.sqlite import Store +from ghostjs8.util.clock import ManualClock + +NET = "na-net-20261009T0100Z" + + +class FakeDecoder: + connected = True + + def __init__(self, sink: DecoderSink) -> None: + pass + + def submit_audio(self, pcm_s16le: bytes) -> None: + pass + + async def run(self) -> None: + await asyncio.Event().wait() + + async def close(self) -> None: + pass + + +def recorded(tmp_path: Path) -> tuple[Station, Settings]: + clock = ManualClock() + store = Store(":memory:", clock) + now = clock.utc_now() + summary = NetSummary( + id=NET, + window_id="na-net", + label="GhostNet North America", + kind="net", + band="40m", + dial_hz=7_107_000, + scheduled_start=now, + scheduled_end=now + timedelta(minutes=30), + started=now, + ended=None, + receiver=None, + receiver_reason="", + decode_count=0, + station_count=0, + has_audio=False, + has_waterfall=False, + flash_count=0, + operator_override=False, + ) + rec = NetRecorder(store, clock, tmp_path, summary) + rec.on_audio(AudioBlock(seq=0, pcm_s16le=b"\x01\x00" * 1200, rssi_dbm=-60, adc_overload=False)) + rec.on_waterfall( + WaterfallRow(seq=0, start_hz=7_100_000, span_hz=14_648, bins=bytes([200] * 1024)) + ) + rec.on_decode( + Decode( + kind="activity", + utc=now, + text="K0OG: @GHOSTNET QSL", + snr_db=-9, + offset_hz=850, + dial_hz=7_107_000, + freq_hz=7_107_850, + speed="normal", + from_call="K0OG", + ) + ) + clock.advance(2) + rec.finish() + station = Station(Hub(), FakeDecoder, clock=clock, store=store) + return station, Settings(db_path=":memory:", recordings_dir=str(tmp_path)) + + +def test_net_endpoints(tmp_path: Path) -> None: + station, settings = recorded(tmp_path) + with TestClient(create_app(station, settings, directory=Directory(fetch=_no_fetch))) as http: + nets = http.get("/api/nets").json() + assert [NetSummary.model_validate(n).id for n in nets] == [NET] + log = NetLog.model_validate(http.get(f"/api/nets/{NET}").json()) + assert log.summary.decode_count == 1 + assert [s.callsign for s in log.stations] == ["K0OG"] + assert log.waterfall_seconds == 1 + assert (log.waterfall_offset_lo_hz, log.waterfall_offset_hi_hz) == (-500, 3500) + png = http.get(f"/api/nets/{NET}/waterfall.png") + assert png.status_code == 200 + assert png.headers["content-type"] == "image/png" + assert png.content.startswith(b"\x89PNG") + flac = http.get(f"/api/nets/{NET}/audio.flac") + assert flac.status_code == 200 + assert flac.headers["content-type"] == "audio/flac" + assert flac.content.startswith(b"fLaC") + + +@pytest.mark.parametrize( + "path", + [ + "/api/nets/nope", + "/api/nets/na-net-20991231T0000Z", + "/api/nets/..%2F..%2Fetc-20261009T0100Z/audio.flac", + "/api/nets/../etc/passwd", + "/api/nets/NA-NET-20261009T0100Z/waterfall.png", + ], +) +def test_net_endpoints_refuse_unknown_and_traversal(tmp_path: Path, path: str) -> None: + station, settings = recorded(tmp_path) + with TestClient(create_app(station, settings, directory=Directory(fetch=_no_fetch))) as http: + assert http.get(path).status_code == 404 + + +async def _no_fetch(_: str) -> bytes: + raise OSError("offline") + + +@pytest.mark.parametrize( + ("env", "ok", "why"), + [ + ({}, False, "off"), + ( + { + "GHOSTJS8_GHOSTNET": "on", + "GHOSTJS8_GHOSTNET_REGION": "mars", + "GHOSTJS8_HOME_GRID": "EM73", + }, + False, + "REGION", + ), + ( + { + "GHOSTJS8_GHOSTNET": "on", + "GHOSTJS8_GHOSTNET_REGION": "na", + "GHOSTJS8_HOME_GRID": "ZZ99", + }, + False, + "HOME_GRID", + ), + ( + { + "GHOSTJS8_GHOSTNET": "on", + "GHOSTJS8_GHOSTNET_REGION": "na", + "GHOSTJS8_HOME_GRID": "em73", + }, + True, + "", + ), + ], +) +def test_build_pilot_validation(env: dict[str, str], ok: bool, why: str) -> None: + clock = ManualClock() + station = Station(Hub(), FakeDecoder, clock=clock, store=Store(":memory:", clock)) + settings = Settings.from_env(env) + pilot, reason = build_pilot(settings, station, Directory(fetch=_no_fetch), clock) + assert (pilot is not None) is ok + assert why in reason + if pilot is not None: + assert pilot.config.home_grid == "EM73" + + +def test_build_pilot_needs_store() -> None: + clock = ManualClock() + station = Station(Hub(), FakeDecoder, clock=clock) + settings = Settings.from_env( + {"GHOSTJS8_GHOSTNET": "on", "GHOSTJS8_GHOSTNET_REGION": "na", "GHOSTJS8_HOME_GRID": "EM73"} + ) + pilot, reason = build_pilot(settings, station, Directory(fetch=_no_fetch), clock) + assert pilot is None + assert "DB_PATH" in reason diff --git a/bridge/tests/unit/test_ghostnet_picker.py b/bridge/tests/unit/test_ghostnet_picker.py new file mode 100644 index 0000000..1763a42 --- /dev/null +++ b/bridge/tests/unit/test_ghostnet_picker.py @@ -0,0 +1,62 @@ +from datetime import UTC, datetime, timedelta + +from ghostjs8.contract.messages import ReceiverListing +from ghostjs8.ghostnet.picker import ReceiverPicker, distance_km +from ghostjs8.util.maidenhead import grid_center + +NOW = datetime(2026, 10, 9, tzinfo=UTC) + + +def rx(id: str, lat: float | None, lon: float | None, **kw: object) -> ReceiverListing: + base: dict[str, object] = dict( + id=id, + name=id, + host=f"{id}.example", + port=8073, + tls=False, + location="", + grid=None, + lat=lat, + lon=lon, + users=0, + users_max=4, + min_hz=0, + max_hz=30_000_000, + antenna="", + snr_db=30, + ) + base.update(kw) + return ReceiverListing(**base) # type: ignore[arg-type] + + +HOME = grid_center("EM73") # Atlanta area + + +def test_distance() -> None: + assert round(distance_km(0, 0, 0, 1)) == 111 + assert distance_km(10, 10, 10, 10) == 0 + + +def test_ranks_by_distance_and_filters() -> None: + picker = ReceiverPicker(HOME) + listings = [ + rx("far", 47.6, -122.3), # Seattle + rx("near", 34.0, -84.0), # Atlanta + rx("mid", 39.0, -77.0), # DC + rx("full", 33.9, -84.1, users=4), + rx("noisy", 33.8, -84.2, snr_db=8), + rx("vlf", 33.7, -84.3, max_hz=1_000_000), + rx("nogps", None, None), + ] + ranked = picker.rank(listings, 7_107_000, NOW) + assert [c.listing.id for c in ranked] == ["near", "mid", "far"] + assert ranked[0].reason.startswith("nearest free receiver:") + + +def test_bench_cooldown() -> None: + picker = ReceiverPicker(HOME, cooldown=timedelta(minutes=30)) + listings = [rx("near", 34.0, -84.0), rx("mid", 39.0, -77.0)] + picker.bench("near", NOW) + assert [c.listing.id for c in picker.rank(listings, 7_107_000, NOW)] == ["mid"] + later = NOW + timedelta(minutes=31) + assert [c.listing.id for c in picker.rank(listings, 7_107_000, later)] == ["near", "mid"] diff --git a/bridge/tests/unit/test_ghostnet_pilot.py b/bridge/tests/unit/test_ghostnet_pilot.py new file mode 100644 index 0000000..5ede6a1 --- /dev/null +++ b/bridge/tests/unit/test_ghostnet_pilot.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import asyncio +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from ghostjs8.api.hub import Hub +from ghostjs8.contract.messages import GhostNet, server_message_adapter +from ghostjs8.decoders.base import DecodeEvent, DecoderSink +from ghostjs8.ghostnet.pilot import GhostNetPilot, PilotConfig, net_id +from ghostjs8.ghostnet.schedule import Region +from ghostjs8.receivers.directory import Directory +from ghostjs8.session.station import Station +from ghostjs8.store.sqlite import Store +from ghostjs8.util.clock import ManualClock +from ghostjs8.util.maidenhead import grid_center + + +class FakeDecoder: + connected = True + + def __init__(self, sink: DecoderSink) -> None: + self.sink = sink + + def submit_audio(self, pcm_s16le: bytes) -> None: + pass + + async def run(self) -> None: + await asyncio.Event().wait() + + async def close(self) -> None: + pass + + +DIRECTORY = b"""var kiwisdr_com = [ + {"id":"near","status":"active","offline":"no","name":"Near RX","bands":"0-30000000","users":"0","users_max":"4", + "gps":"(33.9, -84.3)","snr":"30","url":"http://near.example:8073"}, + {"id":"mid","status":"active","offline":"no","name":"Mid RX","bands":"0-30000000","users":"1","users_max":"4", + "gps":"(36.1, -86.7)","snr":"30","url":"http://mid.example:8073"}, + {"id":"far","status":"active","offline":"no","name":"Far RX","bands":"0-30000000","users":"0","users_max":"4", + "gps":"(47.6, -122.3)","snr":"30","url":"https://far.example:443"}, +];""" + + +def at(s: str) -> datetime: + return datetime.fromisoformat(s).replace(tzinfo=UTC) + + +async def fetch(_: str) -> bytes: + return DIRECTORY + + +def make( + tmp_path: Path, start: str, *, park: bool = True +) -> tuple[GhostNetPilot, Station, ManualClock, Store]: + clock = ManualClock(at(start)) + store = Store(":memory:", clock) + station = Station(Hub(), FakeDecoder, clock=clock, store=store) + cfg = PilotConfig( + region=Region.NA, + home_grid="EM73", + home=grid_center("EM73"), + recordings=tmp_path, + park=park, + record_audio=False, + ) + pilot = GhostNetPilot(station, Directory(fetch=fetch, clock=clock), store, cfg, clock) + return pilot, station, clock, store + + +def event(clock: ManualClock, text: str, frm: str) -> DecodeEvent: + return DecodeEvent( + kind="activity", + utc=clock.utc_now(), + text=text, + snr_db=-10, + offset_hz=800, + speed="normal", + from_call=frm, + ) + + +async def test_parks_on_7107_with_nearest_receiver_between_windows(tmp_path: Path) -> None: + pilot, station, _, _ = make(tmp_path, "2026-10-08T20:00:00") # Thu evening UTC, no window + await pilot.tick() + assert pilot.mode == "parked" + assert station.tuning.dial_hz == 7_107_000 + assert station.target is not None + assert station.target.host == "near.example" + assert pilot.recorder is None + assert pilot.reason.startswith("nearest free receiver") + + +async def test_no_park_means_off(tmp_path: Path) -> None: + pilot, station, _, _ = make(tmp_path, "2026-10-08T20:00:00", park=False) + await pilot.tick() + assert pilot.mode == "off" + assert station.target is None + + +async def test_window_preroll_starts_recording_and_finishes_with_traffic(tmp_path: Path) -> None: + pilot, station, clock, store = make(tmp_path, "2026-10-09T00:51:00") # NA net pre-roll + await pilot.tick() + assert pilot.mode == "window" + assert pilot.recorder is not None + nid = pilot.recorder.summary.id + assert nid == "na-net-20261009T0100Z" + assert pilot.recorder in station.observers + clock.advance(600) + station.on_decode(event(clock, "K0OG: @GHOSTNET QSL", "K0OG")) + station.on_decode(event(clock, "W1GHZ: @GSTFLASH TEST", "W1GHZ")) + clock.advance(30 * 60) # 01:31, window over + await pilot.tick() + assert pilot.recorder is None + assert pilot.mode == "parked" + net = store.net(nid) + assert net is not None + assert net.ended is not None + assert (net.decode_count, net.station_count, net.flash_count) == (2, 2, 1) + assert net.receiver is not None + assert "near.example" in net.receiver + + +async def test_saturday_bridge_retunes_to_20m(tmp_path: Path) -> None: + pilot, station, _, _ = make(tmp_path, "2026-10-10T12:05:00") # NA-AUS 20 m bridge + await pilot.tick() + assert pilot.window is not None + assert pilot.window.window.id == "na-aus-20m" + assert station.tuning.dial_hz == 14_107_000 + + +async def test_rejected_receiver_is_benched_and_next_nearest_tried(tmp_path: Path) -> None: + pilot, station, _, _ = make(tmp_path, "2026-10-08T20:00:00") + await pilot.tick() + assert station.target is not None + assert station.target.host == "near.example" + station.state = "rejected" + await pilot.tick() + assert station.target.host == "mid.example" + + +async def test_slow_connect_falls_back_after_grace(tmp_path: Path) -> None: + pilot, station, clock, _ = make(tmp_path, "2026-10-08T20:00:00") + await pilot.tick() + station.state = "connecting" + clock.advance(30) + await pilot.tick() + assert station.target is not None + assert station.target.host == "near.example" # still within grace + clock.advance(31) + await pilot.tick() + assert station.target.host == "mid.example" + + +async def test_operator_override_pauses_until_window_end(tmp_path: Path) -> None: + pilot, station, clock, store = make(tmp_path, "2026-10-09T01:05:00") + await pilot.tick() + nid = net_id(pilot.window) # type: ignore[arg-type] + station.operator_changed() + assert pilot.mode == "paused" + assert pilot.paused_until == at("2026-10-09T01:30:00") + from ghostjs8.receivers.base import Tuning # noqa: PLC0415 + + await station.tune(Tuning(dial_hz=14_078_000)) + clock.advance(60) + await pilot.tick() + assert station.tuning.dial_hz == 14_078_000 # not fought over + assert pilot.recorder is not None # still recording + clock.advance(30 * 60) + await pilot.tick() + assert pilot.mode == "parked" + assert station.tuning.dial_hz == 7_107_000 + net = store.net(nid) + assert net is not None + assert net.operator_override + + +async def test_status_is_broadcast_and_sent_to_new_viewers(tmp_path: Path) -> None: + pilot, station, _, _ = make(tmp_path, "2026-10-08T20:00:00") + watcher = station.hub.add() + await pilot.tick() + msgs = [] + while not watcher.queue.empty(): + raw = watcher.queue.get_nowait() + assert isinstance(raw, str) + msgs.append(server_message_adapter.validate_python(json.loads(raw))) + status = [m for m in msgs if isinstance(m, GhostNet)] + assert status + assert status[-1].mode == "parked" + assert status[-1].next_window is not None + assert status[-1].next_window.id == "na-net" + joiner = station.hub.add() + station.client_joined(joiner) + kinds = [] + while not joiner.queue.empty(): + raw = joiner.queue.get_nowait() + assert isinstance(raw, str) + kinds.append(json.loads(raw)["type"]) + assert "ghostnet" in kinds + + +async def test_already_recorded_window_is_not_overwritten(tmp_path: Path) -> None: + pilot, _, clock, store = make(tmp_path, "2026-10-09T01:05:00") + await pilot.tick() + occ = pilot.window + assert occ is not None + clock.advance(30 * 60) + await pilot.tick() + finished = store.net(net_id(occ)) + assert finished is not None + assert finished.ended is not None + # e.g. a restarted bridge re-entering the same, already finished window + pilot._start_recording(occ, clock.utc_now()) + assert pilot.recorder is None + assert store.net(net_id(occ)) == finished + + +@pytest.mark.parametrize("start", ["2026-10-09T00:49:00"]) +async def test_before_preroll_is_parked(tmp_path: Path, start: str) -> None: + pilot, _, _, _ = make(tmp_path, start) + await pilot.tick() + assert pilot.mode == "parked" + assert pilot.recorder is None + assert pilot.status().next_window is not None + assert pilot.status().next_window.start == at("2026-10-09T01:00:00") # type: ignore[union-attr] + assert timedelta(minutes=10) == pilot.config.preroll diff --git a/bridge/tests/unit/test_ghostnet_recorder.py b/bridge/tests/unit/test_ghostnet_recorder.py new file mode 100644 index 0000000..286f8cf --- /dev/null +++ b/bridge/tests/unit/test_ghostnet_recorder.py @@ -0,0 +1,168 @@ +import struct +import zlib +from datetime import timedelta +from pathlib import Path + +import soundfile + +from ghostjs8.contract.messages import Decode, NetSummary +from ghostjs8.ghostnet.recorder import ( + WATERFALL_WIDTH, + NetRecorder, + WaterfallAccumulator, + encode_png_palette, + net_stations, + prune_recordings, + spectre_palette, +) +from ghostjs8.receivers.base import AudioBlock, WaterfallRow +from ghostjs8.store.sqlite import Store +from ghostjs8.util.clock import ManualClock + +DIAL = 7_107_000 + + +def summary(clock: ManualClock, net_id: str = "na-net-20260101T0100Z") -> NetSummary: + now = clock.utc_now() + return NetSummary( + id=net_id, + window_id="na-net", + label="GhostNet North America", + kind="net", + band="40m", + dial_hz=DIAL, + scheduled_start=now, + scheduled_end=now + timedelta(minutes=30), + started=now, + ended=None, + receiver=None, + receiver_reason="", + decode_count=0, + station_count=0, + has_audio=False, + has_waterfall=False, + flash_count=0, + operator_override=False, + ) + + +def decode( + clock: ManualClock, text: str, frm: str, kind: str = "activity", grid: str | None = None +) -> Decode: + return Decode( + kind=kind, # type: ignore[arg-type] + utc=clock.utc_now(), + text=text, + snr_db=-12, + offset_hz=900, + dial_hz=DIAL, + freq_hz=DIAL + 900, + speed="normal", + from_call=frm, + grid=grid, + ) + + +def png_dims(data: bytes) -> tuple[int, int, int]: + assert data[:8] == b"\x89PNG\r\n\x1a\n" + width, height, depth, color = struct.unpack(">IIBB", data[16:26]) + assert depth == 8 + return width, height, color + + +def wf_row_with_signal_at(offset_hz: int, value: int = 230) -> WaterfallRow: + # 1024 bins over 14.648 kHz centred on dial + 1550 Hz (what the Kiwi adapter requests) + span = 14_648 + start = DIAL + 1550 - span // 2 + bins = bytearray([140] * 1024) + b = round((DIAL + offset_hz - start) * 1024 / span) + bins[b] = value + return WaterfallRow(seq=0, start_hz=start, span_hz=span, bins=bytes(bins)) + + +def test_png_encoder_roundtrip() -> None: + rows = [bytes(range(10)), bytes(10)] + png = encode_png_palette(rows, 10, spectre_palette()) + assert png_dims(png) == (10, 2, 3) + idat = png[png.index(b"IDAT") + 4 : png.index(b"IEND") - 8] + assert zlib.decompress(idat) == b"\x00" + rows[0] + b"\x00" + rows[1] + + +def test_waterfall_accumulator_maps_offsets_and_fills_gaps() -> None: + clock = ManualClock() + acc = WaterfallAccumulator(clock.utc_now(), DIAL) + acc.add(wf_row_with_signal_at(1000), clock.utc_now()) + clock.advance(3) # seconds 1 and 2 have no rows + acc.add(wf_row_with_signal_at(2000), clock.utc_now()) + rows = acc.finish() + assert len(rows) == 4 + assert all(len(r) == WATERFALL_WIDTH for r in rows) + px_1000 = (1000 + 500) // 10 + assert max(range(WATERFALL_WIDTH), key=lambda x: rows[0][x]) in ( + px_1000 - 1, + px_1000, + px_1000 + 1, + ) + assert rows[1] == bytes(WATERFALL_WIDTH) # gap row + assert max(rows[0][px_1000 - 1 : px_1000 + 2]) > 200 + + +def test_recorder_writes_audio_waterfall_and_traffic(tmp_path: Path) -> None: + clock = ManualClock() + store = Store(":memory:", clock) + rec = NetRecorder(store, clock, tmp_path, summary(clock)) + for _ in range(24): # ~1 s of audio + rec.on_audio( + AudioBlock(seq=0, pcm_s16le=b"\x10\x00" * 512, rssi_dbm=-60, adc_overload=False) + ) + rec.on_waterfall(wf_row_with_signal_at(800)) + rec.on_decode(decode(clock, "K0OG: @GHOSTNET CHECKING IN", "K0OG")) + clock.advance(1) + rec.on_waterfall(wf_row_with_signal_at(1200)) + rec.on_decode(decode(clock, "W1GHZ: @GSTFLASH TEST", "W1GHZ", grid="FN31pr")) + rec.on_decode(decode(clock, "@ALLCALL: noise", "@ALLCALL")) + rec.set_receiver("kiwi.example:8073", "nearest free receiver: 312 km") + clock.advance(60) + done = rec.finish() + + assert done.ended == clock.utc_now() + assert (done.decode_count, done.station_count, done.flash_count) == (3, 2, 1) + assert done.has_audio + assert done.has_waterfall + assert done.receiver == "kiwi.example:8073" + info = soundfile.info(str(tmp_path / done.id / "audio.flac")) + assert (info.samplerate, info.channels, info.frames) == (12000, 1, 24 * 512) + assert png_dims((tmp_path / done.id / "waterfall.png").read_bytes())[:2] == (WATERFALL_WIDTH, 2) + assert store.net(done.id) == done + assert [n.id for n in store.nets()] == [done.id] + stations = net_stations(store, store.net_decodes(done.id)) + assert [(s.callsign, s.grid) for s in stations] == [("K0OG", None), ("W1GHZ", "FN31pr")] + rec.on_decode(decode(clock, "late", "X1X")) # ignored after finish + assert len(store.net_decodes(done.id)) == 3 + + +def test_recorder_survives_audio_failure(tmp_path: Path) -> None: + clock = ManualClock() + store = Store(":memory:", clock) + + def broken(_: Path) -> object: + raise OSError("disk full") + + rec = NetRecorder(store, clock, tmp_path, summary(clock), open_audio=broken) + rec.on_audio(AudioBlock(seq=0, pcm_s16le=b"\x00\x00", rssi_dbm=-60, adc_overload=False)) + assert not rec.finish().has_audio + + +def test_prune_recordings(tmp_path: Path) -> None: + clock = ManualClock() + store = Store(":memory:", clock) + NetRecorder( + store, clock, tmp_path, summary(clock, "old-20260101T0100Z"), record_audio=False + ).finish() + clock.advance(91 * 86400) + NetRecorder( + store, clock, tmp_path, summary(clock, "new-20260402T0100Z"), record_audio=False + ).finish() + assert prune_recordings(store, tmp_path, timedelta(days=90)) == 1 + assert [n.id for n in store.nets()] == ["new-20260402T0100Z"] + assert not (tmp_path / "old-20260101T0100Z").exists() diff --git a/bridge/tests/unit/test_ghostnet_schedule.py b/bridge/tests/unit/test_ghostnet_schedule.py new file mode 100644 index 0000000..243bef0 --- /dev/null +++ b/bridge/tests/unit/test_ghostnet_schedule.py @@ -0,0 +1,75 @@ +from datetime import UTC, datetime, timedelta + +from ghostjs8.ghostnet.schedule import ( + GHOSTNET_20M_HZ, + GHOSTNET_40M_HZ, + GHOSTNET_80M_HZ, + PLAN, + Region, + active, + upcoming, + windows_for, +) + + +def at(s: str) -> datetime: + return datetime.fromisoformat(s).replace(tzinfo=UTC) + + +def test_plan_matches_ghostnet_v15() -> None: + by_id = {w.id: w for w in PLAN} + na = by_id["na-net"] + assert (na.weekday, na.start.hour, na.minutes, na.dial_hz) == (4, 1, 30, 7_107_000) # Fri 0100Z + assert by_id["eu-net"].weekday == 3 + assert by_id["eu-net"].start.hour == 18 + assert by_id["aus-net"].start.hour == 7 + assert {w.dial_hz for w in PLAN} == {GHOSTNET_40M_HZ, GHOSTNET_20M_HZ, GHOSTNET_80M_HZ} + assert GHOSTNET_40M_HZ != 7_078_000 # GhostNet deliberately avoids the standard JS8 frequency + + +def test_region_membership() -> None: + assert [w.id for w in windows_for(Region.NA)] == [ + "na-net", + "na-eu-20m", + "na-aus-20m", + "na-aus-80m", + ] + assert "aus-sp-40m" in {w.id for w in windows_for(Region.AUS)} + assert "aus-sp-40m" not in {w.id for w in windows_for(Region.EU)} + + +def test_upcoming_from_thursday_evening_us_time() -> None: + # Thu 2026-10-08 23:00Z: the NA net is Fri 01:00Z + occ = upcoming(Region.NA, at("2026-10-08T23:00:00")) + assert occ[0].window.id == "na-net" + assert occ[0].start == at("2026-10-09T01:00:00") + assert occ[0].key == "na-net@2026-10-09T01:00Z" + assert [o.window.id for o in occ[1:3]] == ["na-aus-20m", "na-aus-80m"] # Sat 12:00, 13:30 + + +def test_in_progress_window_is_returned_and_rolls_to_next_week() -> None: + now = at("2026-10-09T01:20:00") + assert upcoming(Region.NA, now)[0].start == at("2026-10-09T01:00:00") + later = at("2026-10-09T01:31:00") + nxt = next(o for o in upcoming(Region.NA, later, 10) if o.window.id == "na-net") + assert nxt.start == at("2026-10-16T01:00:00") + + +def test_active_with_preroll() -> None: + assert active(Region.NA, at("2026-10-09T00:49:00")) is None + pre = active(Region.NA, at("2026-10-09T00:51:00")) + assert pre is not None + assert pre.window.id == "na-net" + assert active(Region.NA, at("2026-10-09T01:29:59")) is not None + assert active(Region.NA, at("2026-10-09T01:30:00")) is None + + +def test_in_progress_window_wins_over_next_preroll() -> None: + # AUS: 08:00-09:00 20 m bridge, then 09:00-09:30 40 m bridge + now = at("2026-10-10T08:55:00") + occ = active(Region.AUS, now) + assert occ is not None + assert occ.window.id == "aus-sp-20m" + nxt = active(Region.AUS, now + timedelta(minutes=5)) + assert nxt is not None + assert nxt.window.id == "aus-sp-40m" diff --git a/bridge/tests/unit/test_health.py b/bridge/tests/unit/test_health.py new file mode 100644 index 0000000..5eae255 --- /dev/null +++ b/bridge/tests/unit/test_health.py @@ -0,0 +1,150 @@ +from datetime import timedelta + +from ghostjs8.decoders.base import DecoderHealth +from ghostjs8.session.health import HealthTracker +from ghostjs8.util.clock import ManualClock + + +def facts( + clock: ManualClock, *, up: bool = True, capturing: bool = True, ping_age: float = 1.0 +) -> DecoderHealth: + now = clock.utc_now() + return DecoderHealth( + process_up=up, + capturing=capturing, + last_ping_utc=now - timedelta(seconds=ping_age), + last_api_reply_utc=None, + last_audio_write_utc=now, + ) + + +def healthy() -> tuple[HealthTracker, ManualClock]: + clock = ManualClock() + h = HealthTracker(clock) + h.decoder_link(True) + h.decoder_facts(facts(clock)) + h.receiver("connected") + h.audio() + h.waterfall() + return h, clock + + +def test_initial_state_is_offline_not_ok() -> None: + h = HealthTracker(ManualClock()) + snap = h.snapshot() + assert snap.overall == "offline" + assert snap.components.bridge.state == "ok" + assert snap.components.decoder_process.state == "down" + assert snap.components.receiver.state == "unknown" + assert snap.last_decode_utc is None + + +def test_all_evidence_fresh_is_listening() -> None: + h, _ = healthy() + snap = h.snapshot() + assert snap.overall == "listening" + assert {c["state"] for c in snap.components.model_dump().values()} == {"ok"} + + +def test_audio_and_waterfall_go_stale_deterministically() -> None: + h, clock = healthy() + clock.advance(2.9) + h.decoder_facts(facts(clock)) + assert h.snapshot().components.audio.state == "ok" + clock.advance(0.2) + h.decoder_facts(facts(clock)) + snap = h.snapshot() + assert snap.components.audio.state == "down" + assert "stale" in snap.components.audio.detail + assert snap.components.waterfall.state == "ok" # 5 s threshold + assert snap.overall == "degraded" + clock.advance(2.0) + assert h.snapshot().components.waterfall.state == "down" + + +def test_decoder_crash_while_bridge_stays_up() -> None: + h, clock = healthy() + h.decoder_facts(facts(clock, up=False, capturing=False)) + snap = h.snapshot() + assert snap.components.bridge.state == "ok" + assert snap.components.decoder_process.state == "down" + assert snap.components.decoder_api.state == "down" + assert snap.components.decoder_capture.state == "down" + assert snap.overall == "offline" + + +def test_agent_link_loss_is_decoder_down() -> None: + h, _ = healthy() + h.decoder_link(False) + snap = h.snapshot() + assert snap.components.decoder_process.state == "down" + assert snap.components.decoder_process.detail == "decoder agent unreachable" + + +def test_stale_decoder_reports_mean_down() -> None: + h, clock = healthy() + clock.advance(11) + snap = h.snapshot() + assert snap.components.decoder_process.state == "down" + assert "no decoder report" in snap.components.decoder_process.detail + + +def test_api_heartbeat_staleness() -> None: + h, clock = healthy() + h.decoder_facts(facts(clock, ping_age=41)) + assert h.snapshot().components.decoder_api.state == "down" + h.decoder_facts(facts(clock, ping_age=5)) + assert h.snapshot().components.decoder_api.state == "ok" + + +def test_not_capturing_is_reported() -> None: + h, clock = healthy() + h.decoder_facts(facts(clock, capturing=False)) + snap = h.snapshot() + assert snap.components.decoder_capture.state == "down" + assert snap.overall == "degraded" + + +def test_receiver_states() -> None: + h, _ = healthy() + h.receiver("backoff", "reconnecting in 10s") + snap = h.snapshot() + assert snap.components.receiver.state == "degraded" + assert snap.components.audio.state == "unknown" # not "stale": there is no receiver + assert snap.overall == "offline" + h.receiver("rejected", "all 4 client slots taken") + assert h.snapshot().components.receiver.detail == "all 4 client slots taken" + + +def test_since_only_moves_on_state_change() -> None: + h, clock = healthy() + first = h.snapshot().components.audio.since + clock.advance(1) + h.audio() + assert h.snapshot().components.audio.since == first + clock.advance(4) + h.decoder_facts(facts(clock)) + down = h.snapshot().components.audio + assert down.state == "down" + assert down.since == clock.utc_now() + + +def test_last_decode_is_only_set_by_decodes() -> None: + h, clock = healthy() + assert h.snapshot().last_decode_utc is None + t = clock.utc_now() + h.decoded(t) + h.decoded(t - timedelta(seconds=30)) # older decode never moves it backwards + assert h.snapshot().last_decode_utc == t + + +def test_poll_reports_changes_only() -> None: + h, clock = healthy() + _, changed = h.poll() + assert changed + _, changed = h.poll() + assert not changed + clock.advance(4) + h.decoder_facts(facts(clock)) + _, changed = h.poll() + assert changed diff --git a/bridge/tests/unit/test_js8_messages.py b/bridge/tests/unit/test_js8_messages.py new file mode 100644 index 0000000..9cfa612 --- /dev/null +++ b/bridge/tests/unit/test_js8_messages.py @@ -0,0 +1,135 @@ +from datetime import UTC, datetime + +import pytest + +from ghostjs8.decoders.base import DecodeEvent, SpotEvent +from ghostjs8.decoders.js8call_native.messages import clean_text, parse_js8_message + +NOW = datetime(2026, 10, 8, 2, 0, tzinfo=UTC) +UTC_MS = 1791424811558 # from a real JS8Call 3.0.3 datagram + +# Shapes captured from JS8Call 3.0.3 decoding A_1_4.wav (docs/decoder-container.md). +ACTIVITY = { + "type": "RX.ACTIVITY", + "value": "K0OG: KN4CRD SNR +02 ", + "params": { + "_ID": -1, + "DIAL": 14078000, + "FREQ": 14078705, + "OFFSET": 705, + "SNR": -22, + "SPEED": 0, + "TDRIFT": 0.3, + "UTC": UTC_MS, + "BITS": 3, + }, +} +DIRECTED = { + "type": "RX.DIRECTED", + "value": "KN4CRD SNR +02 ♢ ", + "params": { + "_ID": -1, + "FROM": "K0OG", + "TO": "KN4CRD", + "CMD": " SNR", + "GRID": "", + "EXTRA": "+02", + "TEXT": "KN4CRD SNR +02 ♢ ", + "DIAL": 14078000, + "FREQ": 14078705, + "OFFSET": 705, + "SNR": -22, + "SPEED": 0, + "TDRIFT": 0.3, + "UTC": UTC_MS, + }, +} + + +def one(msg: dict[str, object]) -> DecodeEvent | SpotEvent: + events = parse_js8_message(msg, received_utc=NOW) + assert len(events) == 1 + return events[0] + + +def test_activity() -> None: + ev = one(ACTIVITY) + assert isinstance(ev, DecodeEvent) + assert ev.kind == "activity" + assert ev.text == "K0OG: KN4CRD SNR +02" + assert (ev.snr_db, ev.offset_hz, ev.speed, ev.from_call) == (-22, 705, "normal", "K0OG") + assert ev.utc == datetime.fromtimestamp(UTC_MS / 1000, tz=UTC) + + +def test_directed_prefixes_sender_and_strips_eom() -> None: + ev = one(DIRECTED) + assert isinstance(ev, DecodeEvent) + assert ev.kind == "directed" + assert ev.text == "K0OG: KN4CRD SNR +02" + assert (ev.from_call, ev.to_call, ev.grid) == ("K0OG", "KN4CRD", None) + + +def test_call_activity_yields_spots_with_grids() -> None: + msg = { + "type": "RX.CALL_ACTIVITY", + "value": "", + "params": { + "_ID": 3, + "K0OG": {"SNR": -22, "GRID": "EN34", "UTC": UTC_MS}, + "KG9B": {"SNR": -15, "GRID": "", "UTC": UTC_MS}, + }, + } + spots = parse_js8_message(msg, received_utc=NOW) + assert spots == [ + SpotEvent("K0OG", datetime.fromtimestamp(UTC_MS / 1000, tz=UTC), -22, None, "EN34"), + SpotEvent("KG9B", datetime.fromtimestamp(UTC_MS / 1000, tz=UTC), -15, None, None), + ] + + +def test_rx_spot_if_enabled() -> None: + ev = one( + { + "type": "RX.SPOT", + "value": "", + "params": {"CALL": "K0OG", "SNR": -3, "GRID": "EN34", "OFFSET": 700}, + } + ) + assert isinstance(ev, SpotEvent) + assert (ev.callsign, ev.grid, ev.offset_hz, ev.utc) == ("K0OG", "EN34", 700, NOW) + + +@pytest.mark.parametrize( + "msg", + [ + {"type": "STATION.STATUS", "value": "", "params": {"OFFSET": 1500}}, + {"type": "PING", "value": "", "params": {"UTC": UTC_MS}}, + {"type": "TOTALLY.NEW", "value": "x", "params": {}}, + {"type": "RX.ACTIVITY", "value": "x", "params": {"SNR": "bad", "OFFSET": 1}}, + {"type": "RX.ACTIVITY", "value": " ", "params": {"SNR": 1, "OFFSET": 1}}, + {"type": "RX.ACTIVITY", "value": "x", "params": "not a dict"}, + {"type": 5, "value": "x", "params": {}}, + {"value": "no type"}, + {"type": "RX.CALL_ACTIVITY", "value": "", "params": {"_ID": 1, "X": "not a dict"}}, + ], +) +def test_unknown_or_malformed_messages_are_ignored(msg: dict[str, object]) -> None: + assert parse_js8_message(msg, received_utc=NOW) == [] + + +def test_missing_utc_falls_back_to_receive_time() -> None: + msg = {"type": "RX.ACTIVITY", "value": "A: B", "params": {"SNR": 1, "OFFSET": 2}} + ev = one(msg) + assert ev.utc == NOW + + +def test_speed_names() -> None: + for code, name in ((1, "fast"), (2, "turbo"), (4, "slow"), (8, "ultra"), (99, "unknown")): + params = {"SNR": 1, "OFFSET": 2, "SPEED": code} + ev = one({"type": "RX.ACTIVITY", "value": "A: B", "params": params}) + assert isinstance(ev, DecodeEvent) + assert ev.speed == name + + +def test_clean_text() -> None: + assert clean_text(" HELLO ♢ ") == "HELLO" + assert clean_text("HELLO") == "HELLO" diff --git a/bridge/tests/unit/test_kiwi_framing.py b/bridge/tests/unit/test_kiwi_framing.py new file mode 100644 index 0000000..cd46e19 --- /dev/null +++ b/bridge/tests/unit/test_kiwi_framing.py @@ -0,0 +1,178 @@ +import struct + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from ghostjs8.receivers.base import ( + MalformedFrame, + ReceiverRejected, + RejectReason, + UnsupportedAudioFormat, +) +from ghostjs8.receivers.kiwisdr import framing +from ghostjs8.receivers.kiwisdr.framing import ( + SND_FLAG_ADC_OVFL, + SND_FLAG_COMPRESSED, + SND_FLAG_LITTLE_ENDIAN, + SND_FLAG_STEREO, + MsgFrame, + SndFrame, + WfFrame, + parse_frame, + snd_to_s16le, +) + + +def snd(flags: int, pcm: bytes, seq: int = 7, smeter: int = 670) -> bytes: + return b"SND" + struct.pack("H", smeter) + pcm + + +def be(*samples: int) -> bytes: + return struct.pack(f">{len(samples)}h", *samples) + + +def le(*samples: int) -> bytes: + return struct.pack(f"<{len(samples)}h", *samples) + + +# ----------------------------------------------------------------- parsing + + +def test_parse_snd_header_fields() -> None: + frame = parse_frame(snd(SND_FLAG_ADC_OVFL, be(1, 2), seq=0x01020304, smeter=670)) + assert isinstance(frame, SndFrame) + assert frame.seq == 0x01020304 + assert frame.smeter == 670 + assert frame.rssi_dbm == pytest.approx(-60.0) + assert frame.adc_overload + assert frame.data == be(1, 2) + + +def test_parse_wf_strips_16_byte_header() -> None: + bins = bytes(range(256)) * 4 + raw = b"W/F\x00" + struct.pack(" None: + frame = parse_frame(b"MSG audio_rate=12000 wf_setup name=a%20b") + assert frame == MsgFrame( + tag="MSG", params=(("audio_rate", "12000"), ("wf_setup", None), ("name", "a b")) + ) + + +def test_parse_accepts_text_frames() -> None: + assert isinstance(parse_frame("MSG down"), MsgFrame) + + +def test_unknown_tag_is_ignored() -> None: + assert parse_frame(b"XYZ whatever") is None + + +@pytest.mark.parametrize( + "raw", + [b"", b"SN", b"SND\x00\x00", snd(0, b"")[:-1], b"W/F\x00" + b"\x00" * 11], +) +def test_truncated_frames_raise_malformed(raw: bytes) -> None: + with pytest.raises(MalformedFrame): + parse_frame(raw) + + +# ---------------------------------------------------------- normalization + + +def test_big_endian_is_default_and_swapped() -> None: + frame = parse_frame(snd(0, be(1, -2, 0x1234))) + assert isinstance(frame, SndFrame) + assert snd_to_s16le(frame) == le(1, -2, 0x1234) + + +def test_little_endian_flag_is_honored_without_swap() -> None: + frame = parse_frame(snd(SND_FLAG_LITTLE_ENDIAN, le(1, -2, 0x1234))) + assert isinstance(frame, SndFrame) + assert snd_to_s16le(frame) == le(1, -2, 0x1234) + + +@given(st.lists(st.integers(-32768, 32767), max_size=600)) +def test_no_double_swap_property(samples: list[int]) -> None: + from_be = snd_to_s16le(SndFrame(0, 0, 0, be(*samples))) + from_le = snd_to_s16le(SndFrame(SND_FLAG_LITTLE_ENDIAN, 0, 0, le(*samples))) + assert from_be == from_le == le(*samples) + + +def test_overload_flag_does_not_affect_byte_order() -> None: + frame = SndFrame(SND_FLAG_ADC_OVFL, 0, 0, be(300)) + assert snd_to_s16le(frame) == le(300) + + +def test_compressed_frames_rejected() -> None: + with pytest.raises(UnsupportedAudioFormat) as exc: + snd_to_s16le(SndFrame(SND_FLAG_COMPRESSED, 0, 0, b"\x00\x00")) + assert exc.value.kind == "compressed" + + +def test_stereo_frames_rejected() -> None: + with pytest.raises(UnsupportedAudioFormat) as exc: + snd_to_s16le(SndFrame(SND_FLAG_STEREO, 0, 0, b"\x00" * 14)) + assert exc.value.kind == "stereo" + + +def test_iq_frames_rejected_when_iq_mode() -> None: + with pytest.raises(UnsupportedAudioFormat) as exc: + snd_to_s16le(SndFrame(SND_FLAG_STEREO, 0, 0, b"\x00" * 14), iq_mode=True) + assert exc.value.kind == "iq" + + +def test_odd_pcm_length_is_malformed() -> None: + with pytest.raises(MalformedFrame): + snd_to_s16le(SndFrame(0, 3, 0, b"\x00\x01\x02")) + + +# ---------------------------------------------------------------- geometry + + +def test_wf_geometry_full_band() -> None: + assert framing.wf_span_hz(0, 30_000_000) == 30_000_000 + assert framing.wf_span_hz(14, 30_000_000) == 1831 + assert framing.wf_start_hz(0, 30_000_000) == 0 + full = framing.WF_BINS << framing.WF_MAX_ZOOM + assert framing.wf_start_hz(full // 2, 30_000_000) == 15_000_000 + + +@pytest.mark.parametrize(("span", "zoom"), [(30_000_000, 0), (12_000, 11), (3_000, 13), (1, 14)]) +def test_zoom_for_span(span: int, zoom: int) -> None: + z = framing.wf_zoom_for_span(span, 30_000_000) + assert z == zoom + assert framing.wf_span_hz(z, 30_000_000) >= min(span, framing.wf_span_hz(14, 30_000_000)) + + +# --------------------------------------------------------------- rejections + + +@pytest.mark.parametrize( + ("name", "value", "reason"), + [ + ("too_busy", "4", RejectReason.FULL), + ("badp", "1", RejectReason.AUTH), + ("badp", "5", RejectReason.DUPLICATE_IP), + ("badp", "6", RejectReason.BUSY), + ("badp", "99", RejectReason.AUTH), + ("down", None, RejectReason.DOWN), + ("redirect", "http%3A//x", RejectReason.REDIRECT), + ], +) +def test_rejections_are_typed(name: str, value: str | None, reason: RejectReason) -> None: + rej = framing.rejection_from_msg(name, value) + assert isinstance(rej, ReceiverRejected) + assert rej.reason is reason + + +@pytest.mark.parametrize( + ("name", "value"), [("badp", "0"), ("audio_rate", "12000"), ("wf_setup", None)] +) +def test_non_rejections(name: str, value: str | None) -> None: + assert framing.rejection_from_msg(name, value) is None diff --git a/bridge/tests/unit/test_kiwi_session_ids.py b/bridge/tests/unit/test_kiwi_session_ids.py new file mode 100644 index 0000000..8693001 --- /dev/null +++ b/bridge/tests/unit/test_kiwi_session_ids.py @@ -0,0 +1,53 @@ +import pytest + +from ghostjs8.receivers.base import SessionPairingError, Tuning +from ghostjs8.receivers.kiwisdr.client import ( + KiwiEndpoint, + passband_center_hz, + passband_for, + verify_pairing, +) + + +def test_stream_urls_share_session_id() -> None: + ep = KiwiEndpoint("kiwi.example", 8073) + snd, wf = ep.stream_url(1234, "SND"), ep.stream_url(1234, "W/F") + assert snd == "ws://kiwi.example:8073/1234/SND" + assert wf == "ws://kiwi.example:8073/1234/W/F" + assert verify_pairing(snd, wf) == 1234 + + +def test_mismatched_session_ids_fail() -> None: + ep = KiwiEndpoint("kiwi.example") + with pytest.raises(SessionPairingError): + verify_pairing(ep.stream_url(1, "SND"), ep.stream_url(2, "W/F")) + + +def test_missing_session_id_fails() -> None: + with pytest.raises(SessionPairingError): + verify_pairing("ws://h:1/SND", "ws://h:1/W/F") + + +def test_tls_scheme() -> None: + assert KiwiEndpoint("h", 443, tls=True).stream_url(5, "SND") == "wss://h:443/5/SND" + + +def test_usb_passband_positive() -> None: + t = Tuning(dial_hz=14_078_000, mode="usb", low_cut_hz=100, high_cut_hz=3000) + assert passband_for(t) == (100, 3000) + assert passband_center_hz(t) == 14_079_550 + + +def test_lsb_passband_mirrored_negative() -> None: + t = Tuning(dial_hz=7_078_000, mode="lsb", low_cut_hz=100, high_cut_hz=3000) + assert passband_for(t) == (-3000, -100) + assert passband_center_hz(t) == 7_076_450 + + +@pytest.mark.parametrize( + "kwargs", + [{"dial_hz": 0}, {"dial_hz": 14_078_000, "low_cut_hz": 3000, "high_cut_hz": 100}], +) +def test_bad_tuning_rejected(kwargs: dict[str, int]) -> None: + with pytest.raises(ValueError, match=r"\w"): + Tuning(**kwargs) # type: ignore[arg-type] diff --git a/bridge/tests/unit/test_maidenhead.py b/bridge/tests/unit/test_maidenhead.py new file mode 100644 index 0000000..db916ef --- /dev/null +++ b/bridge/tests/unit/test_maidenhead.py @@ -0,0 +1,51 @@ +import pytest + +from ghostjs8.util.maidenhead import grid_center, normalize_grid + + +@pytest.mark.parametrize( + ("raw", "norm"), + [ + ("EN34", "EN34"), + ("en34", "EN34"), + ("FN31pr", "FN31pr"), + ("fn31PR", "FN31pr"), + ("JO65ab12", "JO65ab12"), + (" EM73 ", "EM73"), + ("RR99xx", "RR99xx"), + ], +) +def test_valid(raw: str, norm: str) -> None: + assert normalize_grid(raw) == norm + + +@pytest.mark.parametrize( + "raw", + [ + None, + "", + "EN", + "SN34", + "EN3", + "EN345", + "EN34y", + "EN34yz", + "EN34ab1", + "1234", + "EN34ab1x", + "N0CALL", + "@ALLCALL", + "EN34-", + ], +) +def test_invalid(raw: str | None) -> None: + assert normalize_grid(raw) is None + + +def test_centres() -> None: + lat, lon = grid_center("FN31pr") + assert (round(lat, 3), round(lon, 3)) == (41.729, -72.708) + lat, lon = grid_center("EN34") + assert (lat, lon) == (44.5, -93.0) + with pytest.raises(ValueError, match="invalid"): + grid_center("ZZ99") diff --git a/bridge/tests/unit/test_package.py b/bridge/tests/unit/test_package.py new file mode 100644 index 0000000..db0261f --- /dev/null +++ b/bridge/tests/unit/test_package.py @@ -0,0 +1,10 @@ +import ghostjs8 + + +def test_protocol_version_is_positive_int() -> None: + assert isinstance(ghostjs8.PROTOCOL_VERSION, int) + assert ghostjs8.PROTOCOL_VERSION >= 1 + + +def test_version_is_resolvable() -> None: + assert ghostjs8.__version__ diff --git a/bridge/tests/unit/test_receiver_security.py b/bridge/tests/unit/test_receiver_security.py new file mode 100644 index 0000000..8dd5bb5 --- /dev/null +++ b/bridge/tests/unit/test_receiver_security.py @@ -0,0 +1,317 @@ +"""Receiver address policy, origin check and connection politeness (pre-0.1.0 review).""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator +from typing import ClassVar + +import pytest +from pydantic import ValidationError + +from ghostjs8.api.app import origin_allowed +from ghostjs8.api.hub import Hub +from ghostjs8.contract.messages import SelectReceiver +from ghostjs8.receivers.address_policy import AddressPolicy, ReceiverAddressRefused +from ghostjs8.receivers.base import ( + ReceiverSink, + ReceiverTimeout, + ReceiverUnreachable, + Tuning, +) +from ghostjs8.receivers.kiwisdr.client import KiwiEndpoint, KiwiReceiver +from ghostjs8.session.station import ReceiverTarget, Station, StationConfig +from ghostjs8.settings import Settings +from ghostjs8.util.backoff import Backoff +from ghostjs8.util.clock import ManualClock + +PUBLIC = AddressPolicy() +LAN = AddressPolicy(allow_private=True) +DEV = AddressPolicy(allow_loopback=True) + + +def answers(*ips: str): # type: ignore[no-untyped-def] + async def resolver(host: str, port: int) -> list[str]: + return list(ips) + + return resolver + + +# ------------------------------------------------------------ address policy + + +@pytest.mark.parametrize( + "host", + ["127.1", "2130706433", "0x7f000001", "0177.0.0.1", "localtest.me", "rebind.example"], +) +async def test_names_resolving_to_loopback_are_refused(host: str) -> None: + # The string may look harmless; the resolved address decides. + with pytest.raises(ReceiverAddressRefused): + await PUBLIC.resolve(host, 8073, answers("127.0.0.1")) + with pytest.raises(ReceiverAddressRefused): + await LAN.resolve(host, 8073, answers("127.0.0.1")) + + +@pytest.mark.parametrize( + ("ip", "public_ok", "lan_ok"), + [ + ("1.1.1.1", True, True), + ("2606:4700:4700::1111", True, True), + ("192.168.1.20", False, True), + ("10.0.0.5", False, True), + ("172.18.0.3", False, True), # a Docker network address + ("100.64.0.1", False, True), # CGNAT / tailnets: LAN-only + ("fd00::1", False, True), + ("127.0.0.1", False, False), + ("::1", False, False), + ("::ffff:127.0.0.1", False, False), # IPv4-mapped loopback + ("169.254.169.254", False, False), # cloud metadata + ("0.0.0.0", False, False), # noqa: S104 + ("224.0.0.1", False, False), + ("240.0.0.1", False, False), # reserved + ], +) +async def test_resolved_address_policy(ip: str, public_ok: bool, lan_ok: bool) -> None: + for policy, ok in ((PUBLIC, public_ok), (LAN, lan_ok)): + if ok: + assert await policy.resolve("kiwi.example", 8073, answers(ip)) == ip + else: + with pytest.raises(ReceiverAddressRefused): + await policy.resolve("kiwi.example", 8073, answers(ip)) + + +async def test_mixed_answer_is_refused() -> None: + with pytest.raises(ReceiverAddressRefused): + await PUBLIC.resolve("kiwi.example", 8073, answers("1.1.1.1", "10.0.0.1")) + + +async def test_resolution_failure_is_refused() -> None: + async def broken(host: str, port: int) -> list[str]: + raise OSError("NXDOMAIN") + + with pytest.raises(ReceiverAddressRefused, match="cannot resolve"): + await PUBLIC.resolve("nope.example", 8073, broken) + with pytest.raises(ReceiverAddressRefused): + await PUBLIC.resolve("empty.example", 8073, answers()) + + +async def test_loopback_only_for_the_simulated_stack() -> None: + assert await DEV.resolve("127.0.0.1", 8073) == "127.0.0.1" + assert await DEV.resolve("fake-kiwi", 8073, answers("127.0.0.1")) == "127.0.0.1" + with pytest.raises(ReceiverAddressRefused): + await DEV.resolve("kiwi.example", 8073, answers("10.0.0.1")) # still no LAN + + +def test_internal_names_refused_early() -> None: + for host in ("localhost", "decoder", "bridge", "x.localhost", "bad host!"): + with pytest.raises(ReceiverAddressRefused): + LAN.check_host(host) + + +def test_settings_default_to_public_receivers_only() -> None: + s = Settings.from_env({}) + assert s.receiver_policy == AddressPolicy(allow_private=False, allow_loopback=False) + on = Settings.from_env( + {"GHOSTJS8_ALLOW_PRIVATE_RECEIVERS": "true", "GHOSTJS8_ALLOW_LOOPBACK_RECEIVERS": "1"} + ) + assert on.receiver_policy == AddressPolicy(allow_private=True, allow_loopback=True) + + +# ------------------------------------------------------------ kiwi client + + +def test_ipv6_literal_is_bracketed() -> None: + url = KiwiEndpoint("2606:4700::1111", 8073).stream_url(1, "SND") + assert url == "ws://[2606:4700::1111]:8073/1/SND" + assert KiwiEndpoint("kiwi.example").stream_url(1, "SND") == "ws://kiwi.example:8073/1/SND" + + +class _Sink: + def on_audio(self, block: object) -> None: ... + def on_waterfall(self, row: object) -> None: ... + def on_receiver_info(self, info: object) -> None: ... + def on_smeter(self, rssi: object) -> None: ... + + +async def test_client_dials_the_checked_address_and_refuses_bad_ones() -> None: + dialed: list[tuple[str, str | None]] = [] + + @contextlib.asynccontextmanager + async def connector(url: str, address: str | None) -> AsyncIterator[object]: + dialed.append((url, address)) + raise OSError("stop here") + yield # pragma: no cover + + class Policy(AddressPolicy): + async def resolve(self, host: str, port: int, resolver: object = None) -> str: # type: ignore[override] + return await AddressPolicy.resolve(self, host, port, answers("93.184.216.34")) + + rx = KiwiReceiver( + KiwiEndpoint("kiwi.example", 8073, policy=Policy()), + Tuning(dial_hz=14_078_000), + _Sink(), # type: ignore[arg-type] + connector=connector, # type: ignore[arg-type] + session_id=7, + ) + with pytest.raises(ReceiverUnreachable): + await rx.run() + # URL keeps the name (Host header / SNI); TCP goes to the checked address. + assert dialed == [("ws://kiwi.example:8073/7/SND", "93.184.216.34")] + + class Internal(AddressPolicy): + async def resolve(self, host: str, port: int, resolver: object = None) -> str: # type: ignore[override] + return await AddressPolicy.resolve(self, host, port, answers("10.1.2.3")) + + dialed.clear() + rx = KiwiReceiver( + KiwiEndpoint("kiwi.example", 8073, policy=Internal()), + Tuning(dial_hz=14_078_000), + _Sink(), # type: ignore[arg-type] + connector=connector, # type: ignore[arg-type] + ) + with pytest.raises(ReceiverAddressRefused): + await rx.run() + assert dialed == [] # never dialed + + +# ------------------------------------------------------------ origin check + + +@pytest.mark.parametrize( + ("origin", "host", "allowed", "ok"), + [ + (None, "ghost.example", (), True), # not a browser page + ("https://ghost.example", "ghost.example", (), True), # same origin + ("http://127.0.0.1:4173", "127.0.0.1:4173", (), True), + ("https://evil.example", "ghost.example", (), False), # cross-site page + ("https://ghost.example", None, (), False), + ("http://192.168.1.5:8080", "192.168.1.5:8080", (), True), # LAN, explicit port + ("https://ghost.example", "ghost.example:443", (), True), # default port spelled out + ("http://ghost.example:8080", "ghost.example", (), False), # port differs + ("http://[fd00::5]:8080", "[fd00::5]:8080", (), True), + ("https://a.example", "ghost.example", ("https://a.example",), True), + ("https://ghost.example", "ghost.example", ("https://a.example",), False), + ], +) +def test_origin_allowed( + origin: str | None, host: str | None, allowed: tuple[str, ...], ok: bool +) -> None: + assert origin_allowed(origin, host, allowed) is ok + + +# ------------------------------------------------------------ contract + + +@pytest.mark.parametrize("password", ["a b", "x\nSET mod=am", "tab\there", "\x00"]) +def test_password_cannot_smuggle_parameters(password: str) -> None: + with pytest.raises(ValidationError): + SelectReceiver(receiver={"host": "kiwi.example", "port": 8073}, password=password) # type: ignore[arg-type] + SelectReceiver(receiver={"host": "kiwi.example", "port": 8073}, password="s3cr3t!#") # type: ignore[arg-type] + + +# ------------------------------------------------------------ station politeness + + +class _Decoder: + connected = True + + def __init__(self, sink: object) -> None: ... + def submit_audio(self, pcm: bytes) -> None: ... + + async def run(self) -> None: + await asyncio.Event().wait() + + async def close(self) -> None: ... + + +class _Rx: + instances: ClassVar[list[_Rx]] = [] + fail: ClassVar[Exception | None] = None + + def __init__(self, target: ReceiverTarget, tuning: Tuning, sink: ReceiverSink) -> None: + self.target = target + self.closed = asyncio.Event() + _Rx.instances.append(self) + + async def run(self) -> None: + if _Rx.fail is not None: + raise _Rx.fail + await self.closed.wait() + + async def close(self) -> None: + self.closed.set() + + async def retune(self, tuning: Tuning) -> None: ... + + +@pytest.fixture(autouse=True) +def _reset() -> None: + _Rx.instances = [] + _Rx.fail = None + + +def _station(floor: float) -> Station: + return Station( + Hub(), + _Decoder, # type: ignore[arg-type] + _Rx, # type: ignore[arg-type] + clock=ManualClock(), + receiver_backoff=Backoff(minimum_s=0.01, maximum_s=0.02, jitter=0.0), + config=StationConfig(min_connect_interval_s=floor), + ) + + +async def _until(cond: object, timeout: float = 2.0) -> None: + async with asyncio.timeout(timeout): + while not cond(): # type: ignore[operator] + await asyncio.sleep(0.005) + + +async def test_reselect_burst_connects_only_the_latest_after_the_floor() -> None: + st = _station(0.3) + task = asyncio.create_task(st.run()) + try: + st.select_receiver(ReceiverTarget("a.example", 8073)) + await _until(lambda: len(_Rx.instances) == 1) + for host in ("b.example", "c.example", "d.example"): # a viewer spamming selects + st.select_receiver(ReceiverTarget(host, 8073)) + await asyncio.sleep(0.02) + await asyncio.sleep(0.1) + assert len(_Rx.instances) == 1 # nothing new inside the floor + await _until(lambda: len(_Rx.instances) == 2) + assert _Rx.instances[1].target.host == "d.example" # only the latest choice + finally: + task.cancel() + + +async def test_address_refusal_is_terminal_and_errors_are_coarse() -> None: + _Rx.fail = ReceiverAddressRefused("receiver address is not allowed") + st = _station(0) + task = asyncio.create_task(st.run()) + try: + st.select_receiver(ReceiverTarget("x.example", 8073)) + await _until(lambda: st.state == "failed") + await asyncio.sleep(0.05) + assert len(_Rx.instances) == 1 # not retried + finally: + task.cancel() + + for exc, detail in ( + ( + ReceiverUnreachable("ws://10.0.0.1:22/1/SND: [Errno 111] Connection refused"), + "unreachable", + ), + (ReceiverTimeout("connect to ws://10.0.0.1:8080/1/SND timed out"), "in time"), + ): + _Rx.instances, _Rx.fail = [], exc + st = _station(0) + task = asyncio.create_task(st.run()) + try: + st.select_receiver(ReceiverTarget("x.example", 8073)) + await _until(lambda st=st: st.state == "backoff") # type: ignore[misc] + assert detail in st.detail + assert "10.0.0.1" not in st.detail + assert "Errno" not in st.detail + finally: + task.cancel() diff --git a/bridge/tests/unit/test_store.py b/bridge/tests/unit/test_store.py new file mode 100644 index 0000000..158e3fb --- /dev/null +++ b/bridge/tests/unit/test_store.py @@ -0,0 +1,63 @@ +from datetime import timedelta + +from ghostjs8.contract.messages import Decode +from ghostjs8.store.sqlite import Store +from ghostjs8.util.clock import ManualClock + + +def decode(clock: ManualClock, text: str) -> Decode: + return Decode( + kind="activity", utc=clock.utc_now(), text=text, snr_db=-10, offset_hz=700, speed="normal" + ) + + +def test_decodes_roundtrip_oldest_first_and_limited() -> None: + clock = ManualClock() + s = Store(":memory:", clock) + for i in range(5): + s.add_decode(decode(clock, f"m{i}")) + clock.advance(15) + assert [d.text for d in s.recent_decodes(limit=3)] == ["m2", "m3", "m4"] + + +def test_station_upsert_keeps_last_valid_grid_and_counts() -> None: + clock = ManualClock() + s = Store(":memory:", clock) + s.heard("K0OG", clock.utc_now(), snr_db=-20, grid="EN34", offset_hz=700, dial_hz=14_078_000) + clock.advance(30) + st = s.heard("K0OG", clock.utc_now(), snr_db=-12, grid=None, offset_hz=None, dial_hz=None) + assert (st.grid, st.snr_db, st.offset_hz, st.heard_count) == ("EN34", -12, 700, 2) + assert st.last_heard_utc == clock.utc_now() + st = s.heard( + "K0OG", + clock.utc_now() - timedelta(hours=1), + snr_db=None, + grid=None, + offset_hz=None, + dial_hz=None, + count=False, + ) + assert st.last_heard_utc == clock.utc_now() # never moves backwards + assert st.heard_count == 2 + + +def test_stations_window_and_prune() -> None: + clock = ManualClock() + s = Store(":memory:", clock, retention_days=1) + s.heard("OLD", clock.utc_now(), snr_db=None, grid=None, offset_hz=None, dial_hz=None) + s.add_decode(decode(clock, "old")) + clock.advance(2 * 86400) + s.heard("NEW", clock.utc_now(), snr_db=None, grid=None, offset_hz=None, dial_hz=None) + assert [x.callsign for x in s.stations()] == ["NEW"] + assert s.prune() == 2 + assert s.station("OLD") is None + assert s.recent_decodes() == [] + + +def test_file_backed_wal(tmp_path) -> None: # type: ignore[no-untyped-def] + clock = ManualClock() + path = tmp_path / "db" / "ghost.sqlite3" + s = Store(path, clock) + s.add_decode(decode(clock, "persist me")) + s.close() + assert [d.text for d in Store(path, clock).recent_decodes()] == ["persist me"] diff --git a/bridge/uv.lock b/bridge/uv.lock new file mode 100644 index 0000000..f8c44ec --- /dev/null +++ b/bridge/uv.lock @@ -0,0 +1,660 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.142.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/2c/d69ce63c27bf9c5e574ab425118390102924503b4b3cdf33c07a69744dd2/fastapi-0.142.4.tar.gz", hash = "sha256:7fe2e254a0a948b88f432b02b8f627463285248233bba0998ed9640191e2ff57", size = 467841, upload-time = "2026-10-07T21:22:13.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/6a/c3e85c05b2ec12e9d352b6f1700e29e0fb3f0fff2511b9c44cdf8837577d/fastapi-0.142.4-py3-none-any.whl", hash = "sha256:8a651aa071a9f92c8217cca1d0b044bf9c326f336578be0a728a1a1f22ec52d2", size = 144456, upload-time = "2026-10-07T21:22:11.386Z" }, +] + +[[package]] +name = "ghostjs8" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "websockets" }, +] + +[package.optional-dependencies] +bridge = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "soundfile" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx2" }, + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-timeout" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'bridge'", specifier = ">=0.115" }, + { name = "pydantic", marker = "extra == 'bridge'", specifier = ">=2.9" }, + { name = "soundfile", marker = "extra == 'bridge'", specifier = ">=0.13" }, + { name = "uvicorn", marker = "extra == 'bridge'", specifier = ">=0.32" }, + { name = "websockets", specifier = ">=15" }, +] +provides-extras = ["bridge"] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx2", specifier = ">=2.13.1" }, + { name = "hypothesis", specifier = ">=6.168.5" }, + { name = "mypy", specifier = ">=2.4.0" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-timeout", specifier = ">=2.4.0" }, + { name = "ruff", specifier = ">=0.16.10" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.168.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a8/bd70d7c2966e561228b9fdc075ee77c0ba577dcbbfbf921edf614db14f6a/hypothesis-6.168.5.tar.gz", hash = "sha256:76b9226962fe11d40858253a967eda95bb65811365286317e0118f4ec8f808c7", size = 511115, upload-time = "2026-10-05T23:26:35.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/0c/7f04c8d277dfc828ba584b7d9d10dbac5e91fce673fa5328f7bd5bf64609/hypothesis-6.168.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ca43a751410a9c6685f029fd5126cc5507664cafaa76017922aa8ae2e17b6620", size = 791539, upload-time = "2026-10-05T23:24:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/660906d83db74eb86feda715d0f2df14836205b14a183332116676733e6f/hypothesis-6.168.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b98707cbe9f430d100a945bbe17612fd3aa44eac1b0ac5299669fe3b8e4128", size = 787208, upload-time = "2026-10-05T23:25:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/36e19492bc4ff354c2be9c8fa7c6ace0c65f9d2c7116656b741680c6ca55/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dde52a0b696c642e7f988a03026c7c29f90daf21e74507b6f865c3ccc9d536e", size = 1115200, upload-time = "2026-10-05T23:25:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/d4/82/3273fb0a3567c09b767bb8fe2824d65e16ae2abb92cf1f43762df723df94/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42f02e4541fe0c17a1320617effc0ab8a8aca2a9af15e3358d4150acf3bbdc00", size = 1145113, upload-time = "2026-10-05T23:25:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/74/59/5c5904555a0bbd4b2898d73ea90c6d03f5be0d8ff0756ac1d519ace6ae66/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf6dd7e537a12763c9afa017f7a6159e5cda608e98670621fa44596a1e8e9288", size = 1139564, upload-time = "2026-10-05T23:25:56.681Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/55654ff9575587a401e304f08ad1d43b7e6318f81c66bd866fdc5ab4665b/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df2c04cd30abf42c52580184216162a75b5508b214a472b86670f6dd50659a3b", size = 1192142, upload-time = "2026-10-05T23:26:06.565Z" }, + { url = "https://files.pythonhosted.org/packages/48/91/4cc9d6e8a950473e07e3ebf00cbb8ee0d76b14d193f94c3de20f1c09e2b1/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278662eb21aaec9eaae71ea4dabd4fe390c2af11ec58a6a0606687cf6d7689b0", size = 1157262, upload-time = "2026-10-05T23:24:59.229Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/4b8aa3be788ea81b9a7bc6b673ed89edd72fd0645c6aa691d4c159ff971a/hypothesis-6.168.5-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:6bcedc4ab8ab92dd0f3af0cfe24dce184d225751d7bc870a9cddb9a557de847f", size = 1113779, upload-time = "2026-10-05T23:24:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/f9/98/2eb4c79d1851195e6a083568b065235680ab984e984bbd472f2a7d02ba33/hypothesis-6.168.5-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b58097cc3b98d8616f635ac73888fc9f859311875f2adc043f1544c40c3c466", size = 1152639, upload-time = "2026-10-05T23:25:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9c/68f7e99b43c6f37c077669a4d3bd88f48c042444ced9e7cff0eaf44bc70a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f8a387d9ee7f804e830b31f2e2e339ab5731665e922cfda4f6f6fbdb05e191b4", size = 1290068, upload-time = "2026-10-05T23:25:28.45Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/d4f87164a0d028ab102cea345b601d9dafb3196358df5448caa88ac3c1e2/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:326f6383fdf2e37ac69773589a8238a3bf396ca8ac8efacb0fb9ed42dd08e426", size = 1418551, upload-time = "2026-10-05T23:24:51.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/6b518a25514f0e643f95610c77e279bfbf0e0b3bd423aac0187d6f039b9a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d33fc74e43bbd7c3a8f6f7161a8b93b676924286e97e70e828c6e0dcee5c01f", size = 1372130, upload-time = "2026-10-05T23:25:32.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/c2/32538e14e63193ca894ba584696805d1eb45cfc27e15fccd47acfb87531c/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:1994923cf5e5220ae6bf19645302504b27c0289d83e5d8690df71dcae63d8416", size = 1268950, upload-time = "2026-10-05T23:25:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/e50e7e98af9489aa05203c2ab38c95d891dd8d1ed08fad972dcdb6955332/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:501038fd24d3bc95239cfd093a23cf1151f29dd82382a3554dac5dfdab9729ae", size = 1283907, upload-time = "2026-10-05T23:24:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/41c460a7d2148a04b212b2d594d39992fb52e0b844e13bf6784573fc8dea/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2292ddc24fe6d04b7d30fa6a7e2c9e280ad5078fe671d0bf4aa6df6e143b5ac", size = 1323762, upload-time = "2026-10-05T23:24:18.984Z" }, + { url = "https://files.pythonhosted.org/packages/68/4f/37a7fc1fe445e3589e0f56ff4573c28de1d6e6a03009cba2f99f04e46ffa/hypothesis-6.168.5-cp310-abi3-win32.whl", hash = "sha256:925d67c69b719d416334aa961c0cdfc4a58a471af1ebd2d7101bd515a70f4e5f", size = 677903, upload-time = "2026-10-05T23:25:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/7b25ca7845a60522ebc5f8054f6bba68d47126fb5d940c784fc528a4be4a/hypothesis-6.168.5-cp310-abi3-win_amd64.whl", hash = "sha256:2311590eccba452de863dfe3466daa86a05c25f072ab31ed8bb4d3313ee68439", size = 684616, upload-time = "2026-10-05T23:25:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/40e7c36b46c8788eddc7a322ad324e6db53c8ab9a8b9a95d6535ee7bdaaf/hypothesis-6.168.5-cp310-abi3-win_arm64.whl", hash = "sha256:222a6d23a2a824b0f9f73761c2fb9cd2aca96cf3e5b441617625bce4f7eb4fd4", size = 683061, upload-time = "2026-10-05T23:25:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/90/43/a04a727578cbef9f75c11fa6fbad66d13aaffc354f4f979506219814c7d4/hypothesis-6.168.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ddee1ef4bab47e315b705e42d2f4354e789973d11f9620d2df242aef4cfa42b2", size = 793156, upload-time = "2026-10-05T23:25:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/f4/91/55de4e2a12fe98ebd5bc8f35e59870c897ab360cbfe5aa63862cdbef56ad/hypothesis-6.168.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81ceb49b0dc3a4b6126cd0d3bf2b634af4e91513c8f1e2daee16041414ed8e3d", size = 784788, upload-time = "2026-10-05T23:26:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f4/61/230abc6320540bdf73baf9a1c025fb0aa27cfd5a3791a2e0c95114239a70/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a09caa95d2d7e6546f727f703de606145835d9ca215fb3134a21353c69afaac", size = 1113853, upload-time = "2026-10-05T23:25:30.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4d/3bf0a7806b3fa12ed076f2daeb3db0e6f9738994e879432ffd8dbcffd634/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97ac1d516a42a3b1f13b36a1aa6a5f842e43d67e69d4dc664a9645b28de411ef", size = 1156695, upload-time = "2026-10-05T23:24:28.607Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a0/603f918fcf8f74f81ea593b04e3a9a9fcd426bbf389ed52cb340249bdc14/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4819fba78c6cbaa6e2f9fd5a69a413817446943f286763819b5ac52391bff3e", size = 1288792, upload-time = "2026-10-05T23:25:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/711ef5be6e889dcd40d9b03cdd85cd42ae39af75835bced3c374730291a9/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87334b95dfbc101652fa48a427a742b0715b814506d9a10f621c29e476b4a2c1", size = 1323090, upload-time = "2026-10-05T23:25:58.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/0377d7d13ff3e2c16efd141942649edcdb568caec4576f86ac779545dd85/hypothesis-6.168.5-cp312-cp312-win_amd64.whl", hash = "sha256:2fcec23ff4eb526ee85d3510f564b938ca74f6011f1eec1050e4eb55280b0468", size = 681999, upload-time = "2026-10-05T23:25:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/92cb8092b680be2b6ec5ffe83b9f1a98dbf414117566f9e3ba4e8b569214/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:453ab7d0a1fadbaa54ae8722d22463cc2046fa8ef25b9b88715d28279bf79fc1", size = 790950, upload-time = "2026-10-05T23:24:45.852Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/78aea12694e3d1177e2980d44798b6d93e191faf59155b18bf5ae315f6a2/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:bbdbc43d1f9dad595b249b7bbe8ee5102bc94a4fcb0a79ff76d20e41fcfe342a", size = 782939, upload-time = "2026-10-05T23:25:39.961Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/5ef9947b2d149f773428e555bdf66688405aa5167510bdbe97c8ec5c6090/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bc36194d7b6083591060836c7872711a6820217b325bf432dd7e10b3d4af5cb", size = 1111380, upload-time = "2026-10-05T23:24:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/e1/65/7e668e203fb2659c6214dc0c24cc09b7dea8a02c7c8d0ad338f644a054c4/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22425e2b1543a43c157a81472c713ba8f291cbaf054c70ffe128e2cacc294f65", size = 1142644, upload-time = "2026-10-05T23:26:30.697Z" }, + { url = "https://files.pythonhosted.org/packages/c0/77/b112978676e795658d58c4294bf90cdbb8cb56cb8292c8c4874650468cf9/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eea0bc513d0e38d1d5ddfb581132928871cd02dc54dfe4511a5396727c48e9d0", size = 1135966, upload-time = "2026-10-05T23:24:49.806Z" }, + { url = "https://files.pythonhosted.org/packages/e6/27/cd3bf01e8246c4318ec3df15f5eeeee3214f444df0129a6c7f9a62859ee8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb142bc70bbf6645e15c7ca72de3f7c8dae198aa2743a609f4f3e3bb4f9c3a52", size = 1189646, upload-time = "2026-10-05T23:26:04.471Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a6/4d3e882f31c289e432dfec34dbb9029296038a8c69e8b28cebb0a5fb7ea8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a27b758707bd37f5a1759cca6eef83fe1a212c38dc4ca0a203434004c5647d15", size = 1154770, upload-time = "2026-10-05T23:25:10.814Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/96f5455e1b3d0409cbbb1434c98e792bcceefd614a4b11e600072520b487/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:77a111cb50c330fa7098f65852fa17a01ecd781a85be3cf5e5871bdeeeb0ecbc", size = 1109925, upload-time = "2026-10-05T23:25:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/3d/64/0758985d9d36f0c5ec981a1457ea1c8173f62d46a94531417aec117df4d7/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cdd0afc13e86ec76cae3d3659569c1f601f4e9ca52b5cf91c1685979eae64d7b", size = 1148476, upload-time = "2026-10-05T23:24:54.552Z" }, + { url = "https://files.pythonhosted.org/packages/43/5c/a9b8953e1d8aefcd3c22cf8d10dd8acf93e602b903278e2e51cf8544ccea/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:5fefb02035864c3d322e3b0969b296250923fdcfb574ea1ad4374f1a6333f663", size = 1285856, upload-time = "2026-10-05T23:25:38.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/66098444dc832523ddc4f2e05723662834e5f99bba3c759615d059f6420e/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:9db8aa1f5529e1b577ec18b775c2fb4225821712e946f7762b90c966604faf83", size = 1415500, upload-time = "2026-10-05T23:24:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/e971b6fe20ef8d7c2019cbf24b4f6149468efc88c42a744f5bc99e6ca0ed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:59e07d2f62b5ff573b0059959ae9cef9edfb0f5393fdb35ea81fce1ee77b27ac", size = 1368623, upload-time = "2026-10-05T23:26:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b279dfbd2c06cdb3030ba7eea042cb2cf0171d0013563d103d5207dde63b/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:8a03ca128bea29d6826fc545f1f6289fb1ea2e83a5bb811321761b2d515ca575", size = 1264457, upload-time = "2026-10-05T23:25:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/16ac9f8ddfada1cd278bd2185234d0d36ebd304926b69ad0497c210c6fed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:5c03f2d3f84f626f3fd07f54573ab40455e1a1996e98a4f4971caf8b7e796afe", size = 1281744, upload-time = "2026-10-05T23:24:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d1/99a44430b82998fdef0ffd7d353f64ee5f078c2805ff70f8677ee102cb6c/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2bdf8ce9b72a620cd5ec4dd6b1c1837ff6971489a863851d11d9b0f58dd4062a", size = 1321504, upload-time = "2026-10-05T23:26:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/58ef2564d1985a5c1a1dc57906b8363a767094abca180e80a0aca4cb635f/hypothesis-6.168.5-cp315-abi3.abi3t-win32.whl", hash = "sha256:5c3abbef7b17571fd713b0922407d9cd8cbc652254c0f462875f15199fcb29f7", size = 674959, upload-time = "2026-10-05T23:24:36.482Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/153414f55eb0c85bd9d891bd7811d746978c7ad3de81ea79eeb4e62e088b/hypothesis-6.168.5-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:38172199abab94a04bc017613e055faa796d7175fbc6221aac504d406c960b60", size = 681329, upload-time = "2026-10-05T23:26:08.897Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/117c82f08ab3ba1dcfbf6562ac43b8deb8efa8106646494fadd15122cc1b/hypothesis-6.168.5-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:0600ddc24c32dab5ca8e780630ab6e2561df6d7f594f781d0608b38e04c4da91", size = 679632, upload-time = "2026-10-05T23:24:33.753Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soundfile" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/d1/5e338af9ca6ed0786cd5bb03f6d60de1c325728c1189014f3b59aae7403c/soundfile-0.14.0-py2.py3-none-any.whl", hash = "sha256:8ba81ae3a89fd5ab3bef8a8eb481fbbe794e806309675a89b4df48b8d31908a8", size = 26799, upload-time = "2026-06-06T08:58:33.269Z" }, + { url = "https://files.pythonhosted.org/packages/7e/72/c6b21e58d3113596e7e8de0a08d6f1d95173492cfbca0a4db14148cbba2a/soundfile-0.14.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:19be05428da76ed61a4cad29b8e4bcf43a3e5c100089d2ec81dc961eed1b0dd4", size = 1144568, upload-time = "2026-06-06T08:58:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c", size = 1103726, upload-time = "2026-06-06T08:58:37.948Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f8/fc39fad6f879633461d27394cd1ddaf1f769ffa0597dca35872f51b16461/soundfile-0.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e85724a90bc99a6e8062c0b4ddf725f53b2a3b70afd4da875e9d2cfc4e92f377", size = 1238050, upload-time = "2026-06-06T08:58:39.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a2/70fd4432b924684c372df8b0a45708c36c057ef3596c9eb53e0a806b980b/soundfile-0.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1e38bac1853412871318e82a1ba69a8be677619b56025bbfcccdb41b6cafe82d", size = 1315963, upload-time = "2026-06-06T08:58:41.716Z" }, + { url = "https://files.pythonhosted.org/packages/d9/34/c9e80783d83eab739a9531fdee03675d53e0bf1b2ccb4bb3af5844675046/soundfile-0.14.0-py2.py3-none-win32.whl", hash = "sha256:0a6ae43c50c71b4e020cc55382925cb89451c1ed1a0c3d0f5d802da269226849", size = 902199, upload-time = "2026-06-06T08:58:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/ed/97/b39c18ac1df45e755ca22b8b00e872929da5d107998a207a5e4ac831bfda/soundfile-0.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:299491d3499460fb1b74bb4bd78b57ffc2d243a5fafa7b6ec1b264875c78453e", size = 1021480, upload-time = "2026-06-06T08:58:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/f4/83/55c65e61cf457805ce2ec157c1c6ae17715d0851aa2374422de0538838ca/soundfile-0.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:e090704718e124e7c844695236f1fce8d18a5e761eaf7c82dfcd124620805f98", size = 888858, upload-time = "2026-06-06T08:58:46.593Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[[package]] +name = "websockets" +version = "17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" }, + { url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" }, + { url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" }, + { url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" }, + { url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" }, +] diff --git a/contract/schema.json b/contract/schema.json new file mode 100644 index 0000000..8d2f163 --- /dev/null +++ b/contract/schema.json @@ -0,0 +1,1404 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/d3mocide/ghost.js8/contract/schema.json", + "title": "GhostJs8Protocol", + "description": "ghost.js8 bridge <-> browser protocol v1. Generated; do not edit.", + "type": "object", + "properties": { + "server": { + "$ref": "#/$defs/ServerMessage" + }, + "client": { + "$ref": "#/$defs/ClientMessage" + } + }, + "$defs": { + "ClientHello": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "hello", + "default": "hello", + "type": "string" + }, + "client": { + "default": "web", + "maxLength": 64, + "type": "string" + } + }, + "title": "ClientHello", + "type": "object", + "required": [ + "client", + "type", + "v" + ] + }, + "ClientMessage": { + "discriminator": { + "mapping": { + "disconnect_receiver": "#/$defs/DisconnectReceiver", + "hello": "#/$defs/ClientHello", + "ping": "#/$defs/Ping", + "select_receiver": "#/$defs/SelectReceiver", + "subscribe": "#/$defs/Subscribe", + "tune": "#/$defs/Tune" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/ClientHello" + }, + { + "$ref": "#/$defs/Tune" + }, + { + "$ref": "#/$defs/SelectReceiver" + }, + { + "$ref": "#/$defs/DisconnectReceiver" + }, + { + "$ref": "#/$defs/Subscribe" + }, + { + "$ref": "#/$defs/Ping" + } + ], + "title": "ClientMessage" + }, + "Component": { + "additionalProperties": false, + "properties": { + "state": { + "enum": [ + "ok", + "degraded", + "down", + "unknown" + ], + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "last_seen": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "detail": { + "default": "", + "type": "string" + } + }, + "required": [ + "detail", + "last_seen", + "since", + "state" + ], + "title": "Component", + "type": "object" + }, + "Components": { + "additionalProperties": false, + "properties": { + "bridge": { + "$ref": "#/$defs/Component" + }, + "decoder_process": { + "$ref": "#/$defs/Component" + }, + "decoder_api": { + "$ref": "#/$defs/Component" + }, + "decoder_capture": { + "$ref": "#/$defs/Component" + }, + "receiver": { + "$ref": "#/$defs/Component" + }, + "audio": { + "$ref": "#/$defs/Component" + }, + "waterfall": { + "$ref": "#/$defs/Component" + } + }, + "required": [ + "audio", + "bridge", + "decoder_api", + "decoder_capture", + "decoder_process", + "receiver", + "waterfall" + ], + "title": "Components", + "type": "object" + }, + "Decode": { + "additionalProperties": false, + "description": "One decoded JS8 transmission. ``utc`` comes from the decoder, never inferred.", + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "decode", + "default": "decode", + "type": "string" + }, + "kind": { + "enum": [ + "activity", + "directed" + ], + "type": "string" + }, + "utc": { + "format": "date-time", + "type": "string" + }, + "text": { + "type": "string" + }, + "snr_db": { + "type": "integer" + }, + "offset_hz": { + "type": "integer" + }, + "dial_hz": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null + }, + "freq_hz": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null + }, + "speed": { + "type": "string" + }, + "from_call": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "to_call": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "grid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "dial_hz", + "freq_hz", + "from_call", + "grid", + "kind", + "offset_hz", + "snr_db", + "speed", + "text", + "to_call", + "type", + "utc", + "v" + ], + "title": "Decode", + "type": "object" + }, + "DisconnectReceiver": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "disconnect_receiver", + "default": "disconnect_receiver", + "type": "string" + } + }, + "title": "DisconnectReceiver", + "type": "object", + "required": [ + "type", + "v" + ] + }, + "Error": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "error", + "default": "error", + "type": "string" + }, + "code": { + "enum": [ + "bad_message", + "unsupported_version", + "session_full", + "invalid_tuning", + "invalid_receiver", + "rate_limited", + "internal" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message", + "type", + "v" + ], + "title": "Error", + "type": "object" + }, + "GhostNet": { + "additionalProperties": false, + "description": "GhostNet autopilot status (absent features report enabled = false).", + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "ghostnet", + "default": "ghostnet", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "region": { + "anyOf": [ + { + "enum": [ + "na", + "eu", + "aus" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "home_grid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mode": { + "enum": [ + "off", + "window", + "parked", + "paused" + ], + "type": "string" + }, + "window": { + "anyOf": [ + { + "$ref": "#/$defs/GhostNetWindow" + }, + { + "type": "null" + } + ] + }, + "next_window": { + "anyOf": [ + { + "$ref": "#/$defs/GhostNetWindow" + }, + { + "type": "null" + } + ] + }, + "recording_net_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "receiver_reason": { + "type": "string" + }, + "paused_until": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "detail": { + "type": "string" + } + }, + "required": [ + "detail", + "enabled", + "home_grid", + "mode", + "next_window", + "paused_until", + "receiver_reason", + "recording_net_id", + "region", + "type", + "v", + "window" + ], + "title": "GhostNet", + "type": "object" + }, + "GhostNetWindow": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "kind": { + "enum": [ + "net", + "bridge" + ], + "type": "string" + }, + "band": { + "type": "string" + }, + "dial_hz": { + "type": "integer" + }, + "start": { + "format": "date-time", + "type": "string" + }, + "end": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "band", + "dial_hz", + "end", + "id", + "kind", + "label", + "start" + ], + "title": "GhostNetWindow", + "type": "object" + }, + "Health": { + "additionalProperties": false, + "description": "Truthful readiness: distinct components, each with evidence timestamps.", + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "health", + "default": "health", + "type": "string" + }, + "utc": { + "format": "date-time", + "type": "string" + }, + "overall": { + "enum": [ + "listening", + "degraded", + "offline" + ], + "type": "string" + }, + "components": { + "$ref": "#/$defs/Components" + }, + "last_decode_utc": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "components", + "last_decode_utc", + "overall", + "type", + "utc", + "v" + ], + "title": "Health", + "type": "object" + }, + "Hello": { + "additionalProperties": false, + "description": "First message on every connection.", + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "hello", + "default": "hello", + "type": "string" + }, + "server": { + "default": "ghost.js8", + "type": "string" + }, + "build": { + "type": "string" + }, + "receive_only": { + "const": true, + "default": true, + "type": "boolean" + }, + "limits": { + "anyOf": [ + { + "$ref": "#/$defs/Limits" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "build", + "limits", + "receive_only", + "server", + "type", + "v" + ], + "title": "Hello", + "type": "object" + }, + "History": { + "additionalProperties": false, + "description": "Sent once after hello: recent traffic from the store, oldest first.", + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "history", + "default": "history", + "type": "string" + }, + "decodes": { + "items": { + "$ref": "#/$defs/Decode" + }, + "type": "array" + }, + "stations": { + "items": { + "$ref": "#/$defs/Station" + }, + "type": "array" + } + }, + "required": [ + "decodes", + "stations", + "type", + "v" + ], + "title": "History", + "type": "object" + }, + "Limits": { + "additionalProperties": false, + "properties": { + "max_clients": { + "type": "integer" + }, + "audio_sample_rate": { + "type": "integer" + }, + "waterfall_max_fps": { + "type": "number" + }, + "waterfall_bins": { + "type": "integer" + } + }, + "required": [ + "audio_sample_rate", + "max_clients", + "waterfall_bins", + "waterfall_max_fps" + ], + "title": "Limits", + "type": "object" + }, + "NetLog": { + "additionalProperties": false, + "description": "A recorded net with its traffic (GET /api/nets/{id}).", + "properties": { + "summary": { + "$ref": "#/$defs/NetSummary" + }, + "decodes": { + "items": { + "$ref": "#/$defs/Decode" + }, + "type": "array" + }, + "stations": { + "items": { + "$ref": "#/$defs/Station" + }, + "type": "array" + }, + "waterfall_seconds": { + "type": "integer" + }, + "waterfall_offset_lo_hz": { + "type": "integer" + }, + "waterfall_offset_hi_hz": { + "type": "integer" + } + }, + "required": [ + "decodes", + "stations", + "summary", + "waterfall_offset_hi_hz", + "waterfall_offset_lo_hz", + "waterfall_seconds" + ], + "title": "NetLog", + "type": "object" + }, + "NetSummary": { + "additionalProperties": false, + "description": "One recorded GhostNet window (GET /api/nets).", + "properties": { + "id": { + "type": "string" + }, + "window_id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "kind": { + "enum": [ + "net", + "bridge" + ], + "type": "string" + }, + "band": { + "type": "string" + }, + "dial_hz": { + "type": "integer" + }, + "scheduled_start": { + "format": "date-time", + "type": "string" + }, + "scheduled_end": { + "format": "date-time", + "type": "string" + }, + "started": { + "format": "date-time", + "type": "string" + }, + "ended": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "receiver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "receiver_reason": { + "type": "string" + }, + "decode_count": { + "type": "integer" + }, + "station_count": { + "type": "integer" + }, + "has_audio": { + "type": "boolean" + }, + "has_waterfall": { + "type": "boolean" + }, + "flash_count": { + "type": "integer" + }, + "operator_override": { + "type": "boolean" + } + }, + "required": [ + "band", + "decode_count", + "dial_hz", + "ended", + "flash_count", + "has_audio", + "has_waterfall", + "id", + "kind", + "label", + "operator_override", + "receiver", + "receiver_reason", + "scheduled_end", + "scheduled_start", + "started", + "station_count", + "window_id" + ], + "title": "NetSummary", + "type": "object" + }, + "Ping": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "ping", + "default": "ping", + "type": "string" + } + }, + "title": "Ping", + "type": "object", + "required": [ + "type", + "v" + ] + }, + "Pong": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "pong", + "default": "pong", + "type": "string" + }, + "utc": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "type", + "utc", + "v" + ], + "title": "Pong", + "type": "object" + }, + "ReceiverDirectory": { + "additionalProperties": false, + "properties": { + "fetched_utc": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "stale": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "receivers": { + "items": { + "$ref": "#/$defs/ReceiverListing" + }, + "type": "array" + } + }, + "required": [ + "fetched_utc", + "receivers", + "source", + "stale" + ], + "title": "ReceiverDirectory", + "type": "object" + }, + "ReceiverListing": { + "additionalProperties": false, + "description": "One public receiver from the directory (GET /api/receivers).", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "tls": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "grid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lat": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "lon": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "users": { + "type": "integer" + }, + "users_max": { + "type": "integer" + }, + "min_hz": { + "type": "integer" + }, + "max_hz": { + "type": "integer" + }, + "antenna": { + "type": "string" + }, + "snr_db": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "antenna", + "grid", + "host", + "id", + "lat", + "location", + "lon", + "max_hz", + "min_hz", + "name", + "port", + "snr_db", + "tls", + "users", + "users_max" + ], + "title": "ReceiverListing", + "type": "object" + }, + "ReceiverRef": { + "additionalProperties": false, + "properties": { + "host": { + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "port": { + "default": 8073, + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "host", + "name", + "port" + ], + "title": "ReceiverRef", + "type": "object" + }, + "ReceiverStatus": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "receiver_status", + "default": "receiver_status", + "type": "string" + }, + "adc_overload": { + "type": "boolean" + }, + "rssi_dbm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null + }, + "info": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "required": [ + "adc_overload", + "info", + "rssi_dbm", + "type", + "v" + ], + "title": "ReceiverStatus", + "type": "object" + }, + "SelectReceiver": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "select_receiver", + "default": "select_receiver", + "type": "string" + }, + "receiver": { + "$ref": "#/$defs/ReceiverRef" + }, + "password": { + "default": "", + "maxLength": 128, + "pattern": "^[^\\s\\x00-\\x1f\\x7f]*$", + "type": "string" + } + }, + "required": [ + "password", + "receiver", + "type", + "v" + ], + "title": "SelectReceiver", + "type": "object" + }, + "ServerMessage": { + "discriminator": { + "mapping": { + "decode": "#/$defs/Decode", + "error": "#/$defs/Error", + "ghostnet": "#/$defs/GhostNet", + "health": "#/$defs/Health", + "hello": "#/$defs/Hello", + "history": "#/$defs/History", + "pong": "#/$defs/Pong", + "receiver_status": "#/$defs/ReceiverStatus", + "session": "#/$defs/Session", + "station": "#/$defs/Station" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/Hello" + }, + { + "$ref": "#/$defs/Health" + }, + { + "$ref": "#/$defs/Session" + }, + { + "$ref": "#/$defs/Decode" + }, + { + "$ref": "#/$defs/Station" + }, + { + "$ref": "#/$defs/History" + }, + { + "$ref": "#/$defs/ReceiverStatus" + }, + { + "$ref": "#/$defs/GhostNet" + }, + { + "$ref": "#/$defs/Error" + }, + { + "$ref": "#/$defs/Pong" + } + ], + "title": "ServerMessage" + }, + "Session": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "session", + "default": "session", + "type": "string" + }, + "state": { + "enum": [ + "idle", + "connecting", + "connected", + "backoff", + "rejected", + "failed" + ], + "type": "string" + }, + "receiver": { + "anyOf": [ + { + "$ref": "#/$defs/ReceiverRef" + }, + { + "type": "null" + } + ], + "default": null + }, + "tuning": { + "$ref": "#/$defs/TuningModel" + }, + "subscribers": { + "type": "integer" + }, + "reject_reason": { + "anyOf": [ + { + "enum": [ + "full", + "busy", + "auth", + "down", + "redirect", + "duplicate_ip" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "detail": { + "default": "", + "type": "string" + }, + "next_retry_utc": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "detail", + "next_retry_utc", + "receiver", + "reject_reason", + "state", + "subscribers", + "tuning", + "type", + "v" + ], + "title": "Session", + "type": "object" + }, + "Station": { + "additionalProperties": false, + "description": "A heard station. ``grid`` is present only when it is a valid Maidenhead locator.", + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "station", + "default": "station", + "type": "string" + }, + "callsign": { + "type": "string" + }, + "last_heard_utc": { + "format": "date-time", + "type": "string" + }, + "snr_db": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null + }, + "grid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "offset_hz": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null + }, + "dial_hz": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null + }, + "heard_count": { + "default": 1, + "type": "integer" + } + }, + "required": [ + "callsign", + "dial_hz", + "grid", + "heard_count", + "last_heard_utc", + "offset_hz", + "snr_db", + "type", + "v" + ], + "title": "Station", + "type": "object" + }, + "Subscribe": { + "additionalProperties": false, + "description": "Opt in to binary channels. Both default off: no bytes the viewer does not want.", + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "subscribe", + "default": "subscribe", + "type": "string" + }, + "audio": { + "default": false, + "type": "boolean" + }, + "waterfall": { + "default": false, + "type": "boolean" + } + }, + "title": "Subscribe", + "type": "object", + "required": [ + "audio", + "type", + "v", + "waterfall" + ] + }, + "Tune": { + "additionalProperties": false, + "properties": { + "v": { + "const": 1, + "default": 1, + "type": "integer" + }, + "type": { + "const": "tune", + "default": "tune", + "type": "string" + }, + "tuning": { + "$ref": "#/$defs/TuningModel" + } + }, + "required": [ + "tuning", + "type", + "v" + ], + "title": "Tune", + "type": "object" + }, + "TuningModel": { + "additionalProperties": false, + "properties": { + "dial_hz": { + "exclusiveMaximum": 100000000, + "exclusiveMinimum": 0, + "type": "integer" + }, + "mode": { + "default": "usb", + "enum": [ + "usb", + "lsb" + ], + "type": "string" + }, + "low_cut_hz": { + "default": 100, + "maximum": 6000, + "minimum": 0, + "type": "integer" + }, + "high_cut_hz": { + "default": 3000, + "maximum": 6000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "dial_hz", + "high_cut_hz", + "low_cut_hz", + "mode" + ], + "title": "TuningModel", + "type": "object" + } + } +} diff --git a/decoder/Dockerfile b/decoder/Dockerfile new file mode 100644 index 0000000..1b7b303 --- /dev/null +++ b/decoder/Dockerfile @@ -0,0 +1,118 @@ +# syntax=docker/dockerfile:1.7 +# ghost.js8 decoder: native JS8Call, headless, fed by a PulseAudio null sink. +# See docs/decoder-container.md. Build context: repository root. + +ARG UBUNTU_IMAGE=ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55 +ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.32@sha256:df4cae8f3a96d175e2e5f992e597550000edbe78fdc2594d5cd8de1a217f504c + +# --------------------------------------------------------------------------- +# fetch: download the pinned JS8Call AppImage, verify it, extract it. +# Nothing from this stage except the extracted tree reaches the final image. +# --------------------------------------------------------------------------- +FROM ${UBUNTU_IMAGE} AS fetch +# Set by BuildKit for the platform being built (amd64 | arm64). +ARG TARGETARCH +ARG JS8CALL_VERSION=3.0.3 +# One pinned artifact per architecture. Upstream publishes no checksums; these +# were recorded on first download (see docs/SECURITY-UPDATES.md). +ARG JS8CALL_SHA256_AMD64=3f89bd821f281c59a9384c08a3ad783ea3b9ac6abf319ce6c0d881c2ecc6e6cd +ARG JS8CALL_SHA256_ARM64=003b7e6f41abc702710051e648d3c673110fe31e8f275bbfa9508f560672b8a4 +ARG JS8CALL_BASE_URL=https://github.com/JS8Call-improved/js8call-improved/releases/download/v${JS8CALL_VERSION} +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates squashfs-tools \ + && rm -rf /var/lib/apt/lists/* +# Optional secret "extra_ca": a full CA bundle for builds behind a TLS-intercepting +# proxy. Used for this download only; never copied into the image. Integrity is +# guaranteed by the SHA-256 check, not by TLS. +# +# The AppImage is never executed at build time: its squashfs payload starts right +# after the ELF runtime (e_shoff + e_shentsize * e_shnum) and is unpacked with +# unsquashfs. That also lets an amd64 host prepare the arm64 tree. +RUN --mount=type=secret,id=extra_ca,required=false \ + set -eu; \ + case "${TARGETARCH:-amd64}" in \ + amd64) arch=x86_64; sha="${JS8CALL_SHA256_AMD64}" ;; \ + arm64) arch=aarch64; sha="${JS8CALL_SHA256_ARM64}" ;; \ + *) echo "unsupported architecture: ${TARGETARCH} (JS8Call ships x86_64 and aarch64 only)" >&2; exit 1 ;; \ + esac; \ + img=/tmp/js8call.AppImage; \ + cacert=""; [ -s /run/secrets/extra_ca ] && cacert="--cacert /run/secrets/extra_ca"; \ + curl -fsSL --retry 4 --retry-delay 2 $cacert -o "$img" \ + "${JS8CALL_BASE_URL}/JS8Call-v${JS8CALL_VERSION}-${arch}.AppImage"; \ + echo "${sha} $img" | sha256sum -c -; \ + shoff=$(od -An -t u8 -j 40 -N 8 "$img" | tr -d ' '); \ + shentsize=$(od -An -t u2 -j 58 -N 2 "$img" | tr -d ' '); \ + shnum=$(od -An -t u2 -j 60 -N 2 "$img" | tr -d ' '); \ + unsquashfs -q -no-progress -o $((shoff + shentsize * shnum)) -d /opt/js8call "$img" >/dev/null; \ + test -x /opt/js8call/usr/bin/JS8Call; \ + rm "$img" + +# --------------------------------------------------------------------------- +# agent: the decoder-agent's virtualenv (base deps only: websockets), built on +# the same base so the interpreter path matches the runtime stage. uv verifies +# every artifact against uv.lock hashes. +# --------------------------------------------------------------------------- +FROM ${UV_IMAGE} AS uv + +FROM ${UBUNTU_IMAGE} AS agent +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=uv /uv /usr/local/bin/uv +ENV UV_PROJECT_ENVIRONMENT=/opt/agent UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +WORKDIR /src +COPY bridge/pyproject.toml bridge/uv.lock bridge/README.md ./ +COPY bridge/src ./src +RUN --mount=type=secret,id=extra_ca,required=false \ + if [ -s /run/secrets/extra_ca ]; then export SSL_CERT_FILE=/run/secrets/extra_ca UV_NATIVE_TLS=1; fi; \ + uv sync --locked --no-dev --no-editable --python /usr/bin/python3 + +# --------------------------------------------------------------------------- +# runtime +# --------------------------------------------------------------------------- +FROM ${UBUNTU_IMAGE} AS runtime +ARG JS8CALL_VERSION=3.0.3 +LABEL org.opencontainers.image.title="ghost.js8 decoder" \ + org.opencontainers.image.source="https://github.com/d3mocide/ghost.js8" \ + org.opencontainers.image.licenses="GPL-3.0-or-later" \ + io.d3frag.ghostjs8.js8call.version="${JS8CALL_VERSION}" + +# Libraries the AppImage does not bundle (found with ldd against the extracted +# tree), plus Xvfb, PulseAudio, supervisord and Python for the decoder-agent. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + xvfb x11-utils xauth \ + pulseaudio pulseaudio-utils \ + supervisor python3 \ + libgl1 libegl1 libusb-1.0-0 libfontconfig1 libfreetype6 libharfbuzz0b \ + libx11-6 libx11-xcb1 libxcb1 libxkbcommon0 libxkbcommon-x11-0 \ + libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \ + libxcb-shape0 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxcb-shm0 \ + libxcb-sync1 libxcb-render0 libsm6 libice6 fonts-dejavu-core \ + procps tini \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=fetch /opt/js8call /opt/js8call +COPY --from=agent /opt/agent /opt/agent + +RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin ghost \ + && install -d -o ghost -g ghost -m 0700 /run/ghost + +COPY decoder/rootfs/ / +RUN chmod 0755 /usr/local/bin/ghost-* + +USER ghost +ENV HOME=/home/ghost \ + XDG_RUNTIME_DIR=/run/ghost \ + DISPLAY=:99 \ + PULSE_SERVER=unix:/run/ghost/pulse/native \ + QT_X11_NO_MITSHM=1 \ + QT_QPA_PLATFORM=xcb \ + GHOSTJS8_UDP_PORT=2242 + +EXPOSE 8074 +HEALTHCHECK --interval=15s --timeout=5s --start-period=40s --retries=3 \ + CMD ["/usr/local/bin/ghost-healthcheck"] + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/usr/local/bin/ghost-supervise"] diff --git a/decoder/rootfs/etc/ghostjs8/JS8Call.ini.tmpl b/decoder/rootfs/etc/ghostjs8/JS8Call.ini.tmpl new file mode 100644 index 0000000..8718452 --- /dev/null +++ b/decoder/rootfs/etc/ghostjs8/JS8Call.ini.tmpl @@ -0,0 +1,58 @@ +; Rendered by ghost-start-js8call on every start from environment variables. +; JS8Call rewrites this file on exit; we discard that and re-render, so the +; decoder is stateless and deterministic. Keys verified against JS8Call 3.0.3 +; source (JS8_UI/Configuration.cpp, JS8_UI/WideGraph.cpp, JS8_UI/mainwindow.cpp). + +[MultiSettings] +CurrentName=Default + +[Configuration] +; Receive-only placeholder identity. JS8Call opens a blocking settings dialog +; when either is empty. Never transmitted: TX, heartbeat, relay and autoreply off. +MyCall=${GHOSTJS8_JS8_CALL} +MyGrid=${GHOSTJS8_JS8_GRID} + +; Audio: stable PulseAudio device IDs (QAudioDevice::id() == Pulse name). +SoundInId=ghost_rx_in +SoundInName=ghost_rx_in +AudioInputChannel=Mono +SoundOutId=ghost_discard +SoundOutName=ghost_discard +AudioOutputChannel=Mono +NotificationSoundOutId=ghost_discard +NotificationSoundOutName=ghost_discard +AllowMonitorInputs=false +EnableNotifications=false + +; Start monitoring (receiving) immediately. +MonitorOFF=false +MonitorLastUsed=false + +; Never transmit, never spot, never phone home. +TransmitOFF=true +RelayOFF=true +AutoreplyOnAtStartup=false +TxBeacon=0 +PSKReporter=false +SpotToAPRS=false +APRSISRelay=false +CheckForUpdates=false +WriteLogs=false +Rig=None + +; JS8 UDP API -> decoder-agent on localhost. +UDPEnabled=true +UDPServer=127.0.0.1 +UDPServerPort=${GHOSTJS8_UDP_PORT} +AcceptUDPRequests=true +TCPEnabled=false +WSJTXProtocolEnabled=false + +[Common] +SubModeMultiDecode=true + +[WideGraph] +; Decode range = filter range (mainwindow.cpp: nfa/nfb). Cover the receiver passband. +FilterEnabled=true +FilterCenter=${GHOSTJS8_FILTER_CENTER} +FilterWidth=${GHOSTJS8_FILTER_WIDTH} diff --git a/decoder/rootfs/etc/ghostjs8/pulse.pa b/decoder/rootfs/etc/ghostjs8/pulse.pa new file mode 100644 index 0000000..97439e0 --- /dev/null +++ b/decoder/rootfs/etc/ghostjs8/pulse.pa @@ -0,0 +1,18 @@ +# PulseAudio graph for the decoder. Device names are stable IDs that JS8Call is +# configured with (SoundInId / SoundOutId). Do not rename without updating +# ghost-start-js8call. +# +# decoder-agent ──pcm──▶ ghost_rx (null sink) +# └─ ghost_rx.monitor ──▶ ghost_rx_in (remap source) ──▶ JS8Call input +# JS8Call output ─────▶ ghost_discard (null sink, never monitored) +# +# Qt hides monitor sources from its input list, hence module-remap-source. + +load-module module-native-protocol-unix auth-anonymous=1 socket=/run/ghost/pulse/native + +load-module module-null-sink sink_name=ghost_rx rate=48000 channels=1 channel_map=mono sink_properties=device.description=ghost_rx +load-module module-remap-source source_name=ghost_rx_in master=ghost_rx.monitor channels=1 channel_map=mono source_properties=device.description=ghost_rx_in +load-module module-null-sink sink_name=ghost_discard rate=48000 channels=1 channel_map=mono sink_properties=device.description=ghost_discard + +set-default-sink ghost_discard +set-default-source ghost_rx_in diff --git a/decoder/rootfs/etc/ghostjs8/supervisord.conf b/decoder/rootfs/etc/ghostjs8/supervisord.conf new file mode 100644 index 0000000..1ea7f0a --- /dev/null +++ b/decoder/rootfs/etc/ghostjs8/supervisord.conf @@ -0,0 +1,69 @@ +; Process supervision for the decoder container. +; Any critical program entering FATAL (exhausted restarts) takes the whole +; container down via ghost-fatal-listener, so Docker's restart policy recovers +; it and nothing can look healthy with a dead decoder. + +[supervisord] +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/run/ghost/supervisord.pid +nodaemon=true + +[unix_http_server] +file=/run/ghost/supervisor.sock +chmod=0700 + +[supervisorctl] +serverurl=unix:///run/ghost/supervisor.sock + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[program:xvfb] +command=/usr/bin/Xvfb :99 -screen 0 1280x800x24 -nolisten tcp +priority=10 +autorestart=true +startsecs=2 +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +redirect_stderr=true + +[program:pulseaudio] +command=/usr/bin/pulseaudio --daemonize=no --exit-idle-time=-1 --disallow-exit --disallow-module-loading=no -n -F /etc/ghostjs8/pulse.pa --log-target=stderr +priority=20 +autorestart=true +startsecs=2 +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +redirect_stderr=true + +[program:js8call] +command=/usr/local/bin/ghost-start-js8call +priority=30 +autorestart=true +startsecs=10 +startretries=3 +stopasgroup=true +killasgroup=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +redirect_stderr=true + +[program:agent] +command=/opt/agent/bin/python -m ghostjs8.decoders.js8call_native.agent --port 8074 --udp-port %(ENV_GHOSTJS8_UDP_PORT)s +priority=25 +autorestart=true +startsecs=3 +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +redirect_stderr=true + +[eventlistener:fatal] +command=/usr/local/bin/ghost-fatal-listener +events=PROCESS_STATE_FATAL +stdout_logfile=/dev/null +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/decoder/rootfs/usr/local/bin/ghost-fatal-listener b/decoder/rootfs/usr/local/bin/ghost-fatal-listener new file mode 100644 index 0000000..36fdc6b --- /dev/null +++ b/decoder/rootfs/usr/local/bin/ghost-fatal-listener @@ -0,0 +1,25 @@ +#!/usr/bin/python3 +"""supervisord event listener: exit the container when a program goes FATAL.""" + +import os +import signal +import sys + + +def main() -> None: + while True: + sys.stdout.write("READY\n") + sys.stdout.flush() + header = dict(tok.split(":", 1) for tok in sys.stdin.readline().split()) + payload = sys.stdin.read(int(header["len"])) + sys.stderr.write(f"ghost-fatal-listener: {header.get('eventname')} {payload}; stopping container\n") + sys.stderr.flush() + with open("/run/ghost/fatal", "w") as marker: + marker.write(payload) + sys.stdout.write("RESULT 2\nOK") + sys.stdout.flush() + os.kill(int(open("/run/ghost/supervisord.pid").read().strip()), signal.SIGTERM) + + +if __name__ == "__main__": + main() diff --git a/decoder/rootfs/usr/local/bin/ghost-healthcheck b/decoder/rootfs/usr/local/bin/ghost-healthcheck new file mode 100644 index 0000000..745c670 --- /dev/null +++ b/decoder/rootfs/usr/local/bin/ghost-healthcheck @@ -0,0 +1,59 @@ +#!/usr/bin/python3 +"""Container health: fail unless the decoder can really decode. + +Checks: every critical supervised program RUNNING; X answering; the PulseAudio +graph present; JS8Call running AND holding an uncorked capture stream on +ghost_rx_in. Exit 0 healthy, 1 unhealthy (reason on stdout). +""" + +import json +import subprocess +import sys + +CRITICAL = ("xvfb", "pulseaudio", "agent", "js8call") + + +def run(*cmd: str) -> str: + return subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=4).stdout + + +def fail(reason: str) -> None: + print(reason) + sys.exit(1) + + +def main() -> None: + try: + status = run("supervisorctl", "-c", "/etc/ghostjs8/supervisord.conf", "status", *CRITICAL) + except subprocess.CalledProcessError as exc: + fail(f"supervised program not running: {exc.stdout.strip()}") + for line in status.splitlines(): + name, state = line.split()[:2] + if state != "RUNNING": + fail(f"{name} is {state}") + try: + run("xdpyinfo") + except subprocess.CalledProcessError: + fail("X server not answering") + try: + sources = json.loads(run("pactl", "-f", "json", "list", "short", "sources")) + outputs = json.loads(run("pactl", "-f", "json", "list", "source-outputs")) + except (subprocess.CalledProcessError, json.JSONDecodeError) as exc: + fail(f"PulseAudio not answering: {exc}") + rx_in = [s["index"] for s in sources if s["name"] == "ghost_rx_in"] + if not rx_in: + fail("ghost_rx_in source missing") + capturing = [ + o + for o in outputs + if o["source"] == rx_in[0] + and o["properties"].get("application.process.binary") == "JS8Call" + and not o["corked"] + ] + if not capturing: + fail("JS8Call is not capturing from ghost_rx_in") + print("ok") + + +if __name__ == "__main__": + main() diff --git a/decoder/rootfs/usr/local/bin/ghost-start-js8call b/decoder/rootfs/usr/local/bin/ghost-start-js8call new file mode 100644 index 0000000..c4c49a8 --- /dev/null +++ b/decoder/rootfs/usr/local/bin/ghost-start-js8call @@ -0,0 +1,23 @@ +#!/bin/sh +# Render JS8Call.ini from the environment, wait for X and PulseAudio, then run +# JS8Call from the extracted AppImage. +set -eu + +: "${GHOSTJS8_JS8_CALL:=N0CALL}" +: "${GHOSTJS8_JS8_GRID:=AA00}" +: "${GHOSTJS8_UDP_PORT:=2242}" +: "${GHOSTJS8_FILTER_CENTER:=1550}" +: "${GHOSTJS8_FILTER_WIDTH:=2900}" +export GHOSTJS8_JS8_CALL GHOSTJS8_JS8_GRID GHOSTJS8_UDP_PORT GHOSTJS8_FILTER_CENTER GHOSTJS8_FILTER_WIDTH + +mkdir -p "$HOME/.config" +python3 -c 'import os, string, sys; sys.stdout.write(string.Template(open(sys.argv[1]).read()).substitute(os.environ))' \ + /etc/ghostjs8/JS8Call.ini.tmpl > "$HOME/.config/JS8Call.ini" + +i=0 +until xdpyinfo >/dev/null 2>&1 && pactl info >/dev/null 2>&1; do + i=$((i + 1)); [ "$i" -gt 50 ] && { echo "X/PulseAudio not ready" >&2; exit 1; } + sleep 0.2 +done +# Exec the real binary (AppRun is a symlink) so the process is named JS8Call. +exec /opt/js8call/usr/bin/JS8Call diff --git a/decoder/rootfs/usr/local/bin/ghost-supervise b/decoder/rootfs/usr/local/bin/ghost-supervise new file mode 100644 index 0000000..b9347a1 --- /dev/null +++ b/decoder/rootfs/usr/local/bin/ghost-supervise @@ -0,0 +1,11 @@ +#!/bin/sh +# Run supervisord; if ghost-fatal-listener stopped it because a critical program +# went FATAL, exit non-zero so any Docker restart policy treats it as a failure. +rm -f /run/ghost/fatal +/usr/bin/supervisord -n -c /etc/ghostjs8/supervisord.conf +rc=$? +if [ -e /run/ghost/fatal ]; then + echo "ghost-supervise: critical process FATAL: $(cat /run/ghost/fatal)" >&2 + exit 70 +fi +exit "$rc" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..7d8de67 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,66 @@ +# Real-recording acceptance test. Run with `make acceptance`. +# Isolated: the network has no egress. The fixture is fetched and verified on +# the host first (tools/fixtures/fetch_fixture.py) and mounted read-only. +name: ghostjs8-acceptance + +x-build-secrets: &build-secrets + secrets: [extra_ca] + +# Same hardening as production, so the acceptance test exercises it. +x-hardened: &hardened + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + +services: + fake-kiwi: + <<: *hardened + image: ghostjs8-bridge:test + build: + context: bridge + <<: *build-secrets + command: ["python", "-m", "ghostjs8", "fake-kiwi", "--port", "8073", + "--wav", "/fixtures/A_1_4.wav", "--align", "15", "--repeats", "2"] + volumes: + - ./tools/fixtures/cache:/fixtures:ro + healthcheck: { disable: true } # image's bridge healthcheck does not apply + networks: [isolated] + + decoder: + <<: *hardened + image: ghostjs8-decoder:test + build: + context: . + dockerfile: decoder/Dockerfile + <<: *build-secrets + networks: [isolated] + + bridge: + <<: *hardened + image: ghostjs8-bridge:test + environment: + GHOSTJS8_AGENT_URL: ws://decoder:8074/agent + GHOSTJS8_RECEIVER_HOST: fake-kiwi + GHOSTJS8_RECEIVER_PORT: "8073" + GHOSTJS8_DIAL_HZ: "14078000" + GHOSTJS8_LOG_LEVEL: INFO + depends_on: + decoder: { condition: service_healthy } + fake-kiwi: { condition: service_started } + networks: [isolated] + + acceptance: + <<: *hardened + image: ghostjs8-bridge:test + command: ["python", "-m", "ghostjs8", "acceptance", "--url", "ws://bridge:8073/ws", "--timeout", "150"] + healthcheck: { disable: true } + depends_on: + bridge: { condition: service_started } + networks: [isolated] + +networks: + isolated: + internal: true + +secrets: + extra_ca: + file: ${EXTRA_CA:-./tools/acceptance/no-extra-ca.pem} # empty unless building behind a TLS proxy diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a2821e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +# ghost.js8 — JS8 ops console (current builds are receive-only). +# cp .env.example .env && docker compose up -d --build +# Or use published multi-arch images (amd64/arm64) instead of building: +# GHOSTJS8_IMAGE_PREFIX=ghcr.io/d3mocide/ghostjs8 GHOSTJS8_TAG=0.1.0 \ +# docker compose pull && docker compose up -d --no-build +# Put your reverse proxy (NPM / Caddy) in front of the web port; it terminates TLS +# and should handle authentication (see docs/operations.md). +name: ghostjs8 + +x-build-secrets: &build-secrets + secrets: [extra_ca] + +# No container needs extra privileges: drop every capability and forbid gaining any. +x-hardened: &hardened + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + +services: + web: + <<: *hardened + image: ${GHOSTJS8_IMAGE_PREFIX:-ghostjs8}-web:${GHOSTJS8_TAG:-local} + build: + context: web + <<: *build-secrets + ports: + - "${GHOSTJS8_HTTP_BIND:-127.0.0.1}:${GHOSTJS8_HTTP_PORT:-8080}:8080" + depends_on: + bridge: { condition: service_started } + restart: unless-stopped + networks: [edge] + + bridge: + <<: *hardened + image: ${GHOSTJS8_IMAGE_PREFIX:-ghostjs8}-bridge:${GHOSTJS8_TAG:-local} + build: + context: bridge + <<: *build-secrets + env_file: + - path: .env + required: false + environment: + GHOSTJS8_AGENT_URL: ws://decoder:8074/agent + GHOSTJS8_DB_PATH: /data/ghostjs8.sqlite3 + volumes: + - bridge-data:/data + depends_on: + decoder: { condition: service_started } + restart: unless-stopped + read_only: true + tmpfs: [/tmp] + networks: [edge, decoder] + + decoder: + <<: *hardened + image: ${GHOSTJS8_IMAGE_PREFIX:-ghostjs8}-decoder:${GHOSTJS8_TAG:-local} + build: + context: . + dockerfile: decoder/Dockerfile + <<: *build-secrets + restart: unless-stopped # a FATAL critical process exits 70 and is restarted + networks: [decoder] # no egress: JS8Call never reaches the internet + +volumes: + bridge-data: + +networks: + edge: {} + decoder: + internal: true + +secrets: + extra_ca: + file: ${EXTRA_CA:-./tools/acceptance/no-extra-ca.pem} diff --git a/docs/SECURITY-UPDATES.md b/docs/SECURITY-UPDATES.md new file mode 100644 index 0000000..9215bdc --- /dev/null +++ b/docs/SECURITY-UPDATES.md @@ -0,0 +1,73 @@ +# Security updates and pinning + +Everything that enters an image or a build is pinned, so a build is +reproducible and an update is a reviewable diff. + +| What | Pinned how | Where | +|---|---|---| +| Base images | tag **and** sha256 digest | `decoder/Dockerfile`, `bridge/Dockerfile`, `web/Dockerfile` (`ARG …_IMAGE=`) | +| JS8Call | version + per-architecture SHA-256 of the AppImage; build fails on mismatch | `decoder/Dockerfile` (`JS8CALL_VERSION`, `JS8CALL_SHA256_AMD64`, `JS8CALL_SHA256_ARM64`) | +| Python deps | `uv.lock` with hashes; images install with `uv sync --locked` (hash-verified) | `bridge/uv.lock` | +| Node deps | exact versions + `package-lock.json` (integrity hashes); `npm ci` | `web/package*.json` | +| GitHub Actions | full commit SHA (with version comment) | `.github/workflows/*.yml` | +| Build tools | uv `0.11.32` (image digest), Node 22, Python 3.12 | Dockerfiles, `.nvmrc`, `.python-version` | +| Test fixture | upstream commit + SHA-256 | `tools/fixtures/fetch_fixture.py` | +| Basemap data | upstream commit + SHA-256 (provenance) | `web/public/map/README.md` | +| apt packages (decoder) | **not** version-pinned: installed from the GPG-signed Ubuntu archive at build time, so rebuilds pick up security fixes | `decoder/Dockerfile` | + +## Automation + +Renovate (`renovate.json`) opens PRs for npm, PyPI (via `uv.lock`), Docker +digests and GitHub Actions SHAs, groups action updates, and runs weekly lock +file maintenance. Security advisories get the `security` label. TypeScript is +held below 7 until typescript-eslint supports it. + +Renovate does **not** update JS8Call. That is a manual, deliberate change. + +## Updating JS8Call + +1. Read the upstream release notes (`JS8Call-improved/js8call-improved`). +2. Download **both** `JS8Call-vX.Y.Z-x86_64.AppImage` and + `JS8Call-vX.Y.Z-aarch64.AppImage`, compute their SHA-256s, and check the + GitHub build attestations if you can: + `gh attestation verify JS8Call-vX.Y.Z-.AppImage --repo JS8Call-improved/js8call-improved`. +3. Update `JS8CALL_VERSION`, `JS8CALL_SHA256_AMD64` and `JS8CALL_SHA256_ARM64` + in `decoder/Dockerfile`. +4. Diff `JS8_UI/Configuration.cpp`, `WideGraph.cpp` and the UDP message code + for renamed settings keys or message shapes; update + `decoder/rootfs/etc/ghostjs8/JS8Call.ini.tmpl` and + `bridge/src/ghostjs8/decoders/js8call_native/messages.py`. +5. `make decoder-image && make acceptance`. **Do not merge unless the + real-recording acceptance test passes**, on both architectures (CI runs it + on native amd64 and arm64 runners). + +## Rebuild cadence + +Rebuild and redeploy at least monthly, and promptly after any Ubuntu, Python, +nginx or Node security advisory, even without code changes, so the decoder's +apt packages and base layers stay current: + +```sh +git pull && make images && make up +``` + +## Reporting a vulnerability + +Open a private security advisory on the GitHub repository rather than a public +issue. + +## Hardening already in place + +- Containers run as non-root (`ghost` uid 10001 / nginx-unprivileged). The + bridge filesystem is read-only except `/data` and `/tmp`. +- The decoder sits on an internal Docker network with no egress. +- Only the web port is published, bound to `127.0.0.1` by default. +- Strict CSP (no inline scripts, same-origin connections only), `nosniff`, + `no-referrer`, COOP, restrictive Permissions-Policy. +- WebSocket Origin allowlist; per-connection rate limits on control messages; + 4 KiB message cap; receiver addresses validated (no loopback, link-local, + reserved, or internal service names). +- The decoder-agent can only send read-only requests to JS8Call; spotting, + update checks and transmit are disabled in its configuration. +- Build-time CA bundles for TLS-intercepting proxies are passed as BuildKit + secrets and never written into an image. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f25bd27 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,125 @@ +# Architecture + +ghost.js8 is a JS8 ops console. Current builds are receive-only (see the TX +extension point in `operations.md`). It pulls audio and waterfall data from +public KiwiSDR web receivers, decodes JS8 with the **native JS8Call +application** running headless in Docker, and presents traffic in a browser. + +## Components + +Four separable components with explicit interfaces: + +| Component | Today | Seam for later | +|---|---|---| +| **Receiver adapter** | KiwiSDR (`SND` + `W/F` WebSockets) | other SDR inputs | +| **Decoder adapter** | native JS8Call via PulseAudio + UDP API, behind an in-container *decoder-agent* | a WASM decoder | +| **Bridge** | session orchestration, health, fan-out to browsers | — | +| **Web UI** | Vite + Svelte 5 + TypeScript | — | + +``` + ┌──────────────────────── decoder container (×N slots) ─────────────────────┐ +KiwiSDR ─SND─┐ │ decoder-agent ─pcm→ pulse null sink "ghost_rx" │ + ├─bridge─┤ └ monitor → module-remap-source "ghost_rx_in" → JS8Call│ +KiwiSDR ─W/F─┘ │ │ JS8Call ─UDP (localhost)→ decoder-agent JS8Call out → "ghost_discard"│ + │ │ supervisor: Xvfb, pulseaudio, js8call, decoder-agent │ + │ └────────────────────────────────────────────────────────────────────────────┘ + └─ /ws (JSON + binary) ─→ nginx (web) ─→ your reverse proxy ─→ browser +``` + +## Container topology + +- **`decoder`** — one per concurrently decoded receiver (JS8Call is + one-audio-input-per-instance). Ubuntu 24.04 by digest; Xvfb; user-mode + PulseAudio; JS8Call AppImage extracted at build time and SHA-256 verified; the + decoder-agent (Python, from this repo). Supervised; a fatal exit of any + critical process exits the container so Docker restarts it. Internal network only. +- **`bridge`** — Python 3.12, FastAPI + uvicorn. Outbound Kiwi connections; + leases decoder slots; serves `/ws`, `/api/*`, `/healthz`, `/readyz`. + Internal network only. +- **`web`** — `nginxinc/nginx-unprivileged:alpine` by digest. Serves the static + build and proxies `/ws` + `/api` to the bridge. The only service your reverse + proxy talks to. TLS terminates at your proxy. +- **Test stack** (`docker-compose.test.yml`) — `fake-kiwi`, `bridge`, `decoder`, + `acceptance` on an `internal: true` network (no egress). + +## Why a decoder-agent + +The brief requires JS8Call's UDP API on localhost and PulseAudio fed directly, +but also separate `decoder` and `bridge` services. A small Python agent inside +the decoder container owns both localhost concerns and exposes one internal +WebSocket to the bridge (binary PCM in; JSON decodes + health facts out). This is +also the seam where a WASM decoder adapter would plug in. + +## Bridge ↔ browser contract (summary) + +Full byte layouts live in [`protocol.md`](protocol.md) (milestone 6). + +- JSON text frames: `{ "v": 1, "type": ..., ... }`, a discriminated union generated + from Pydantic models. + - server → client: `hello`, `health`, `session`, `decode` + (`kind: activity|directed`), `station`, `receiver_status`, `error` + - client → server: `hello`, `tune`, `select_receiver`, `subscribe`, `ping` +- Binary frames, little-endian, first byte = channel: + - `0x01` audio: `u8 ch | u8 flags | u16 rsvd | u32 seq | u32 rate` + s16le PCM + - `0x02` waterfall: `u8 ch | u8 flags | u16 bins | u32 seq | u32 start_hz | u32 span_hz` + u8 bins + +## Health model + +Distinct, timestamped component states — never inferred from an open socket: +bridge connected · decoder process up · decoder API responding (UDP heartbeat age) +· decoder capturing (JS8Call's Pulse capture stream present and running) · +receiver connected · audio fresh · waterfall fresh · last decode timestamp. + +## Decisions (agreed 2026-10-08) + +| # | Topic | Decision | +|---|---|---| +| D1 | UI framework | Svelte 5 + TS; hot paths framework-free in `web/src/lib/` | +| D2 | Map | MapLibre GL + self-hosted Protomaps PMTiles (low zoom, dark) | +| D3 | Contract | Pydantic → JSON Schema → TS (`json-schema-to-typescript`), CI drift check | +| D4 | Python deps | `uv` (`uv.lock` with hashes; `uv export --require-hashes` for images) | +| D5 | Scaling | one bridge, static pool of decoder slots (`DECODER_SLOTS`, default 1) | +| D6 | Shared sessions | one Kiwi connection per slot shared by all browsers; any client may retune (broadcast), optional hold lock | +| D7 | Directory | bridge fetches rx.linkfanel.net list, ~15 min cache, stale-on-error; manual host:port | +| D8 | Persistence | SQLite (WAL) on a volume; decodes + heard stations; 30-day default retention | +| D8b | Idle | keep decoding with no browsers attached; `IDLE_DISCONNECT_MINUTES` (0 = never) | +| D9 | Auth | reverse-proxy-fronted; bridge internal-only; WS Origin allowlist | +| D10 | License | GPL-3.0-or-later | +| D11 | Palette | "ghost signal": frosted glass over a deep solid gradient (ink → indigo → plum) with a live ASCII backdrop (drifting static, radio rings with the callsign on each decode, a pulse per 15 s JS8 slot, an occasional ghost); "still" mode and reduced motion/transparency honoured. Aqua→violet accent, mint for live signal. Type: Share Tech Mono for the wordmark, panel titles and big readouts; Space Grotesk for UI text; JetBrains Mono for data. Logo: line-art stencil ghost (bridges cut into the outline, radio-wave hem, slit eyes). Default waterfall colormap "spectre" (mirrored by recorded-net PNGs); viridis/inferno/grey selectable | +| D12 | Waterfall | zoomed around the dial by default, zoomable | +| — | Web server | nginx-unprivileged (lighter than Caddy; TLS is the outer proxy's job) | +| — | Defaults | Python 3.12, Node 22 LTS, supervisord + fatal-exit listener, passband 100–3000 Hz USB, Kiwi ident `ghost.js8`, reconnect backoff 5 s–5 min jittered | + +## Implementation notes and deviations from the plan + +| Topic | Planned | Shipped in 0.1.0 | Why / next | +|---|---|---|---| +| D2 map tiles | MapLibre + self-hosted Protomaps PMTiles | MapLibre + bundled Natural Earth 1:110m land/border GeoJSON (~160 KB) and a Maidenhead field grid | No tile server or extraction toolchain needed, zero third-party requests, fits the minimal look. PMTiles remains the upgrade path if detail is needed. | +| D5 decoder slots | one bridge leasing a pool of N decoder slots | one bridge drives one station (one receiver + one decoder) | Covers the MVP. Run several compose projects for several receivers. The in-bridge slot pool is a follow-up; the `Station`/`DecoderHandle` seams are where it goes. | +| D6 shared session | shared, any client retunes, optional hold lock | shared, any client retunes, changes broadcast to all viewers | The hold lock is a follow-up. | +| Station source | `RX.SPOT` | `RX.CALL_ACTIVITY` (agent polls `RX.GET_CALL_ACTIVITY`) | JS8Call emits `RX.SPOT` only when spotting to PSKReporter is on, which we keep off. | +| Directed text | — | `FROM: text`, EOM `♢` stripped | `RX.DIRECTED` omits the sender; activity and directed records of one transmission are collapsed in the UI. | +| UI dev/test | — | `ghostjs8.sim.fake_agent` + `tools/dev-stack.sh` | Browser tests and UI work without Docker. Scripted traffic, never a decoding claim. | + +## Milestones (status) + +All nine milestones are complete for 0.1.0. The original plan follows. + + +1. Repo skeleton, tooling, CLAUDE.md/AGENTS.md, CI scaffolding +2. Decoder image: pinned + checksum-verified JS8Call on Ubuntu 24.04, Xvfb, supervised +3. Pulse null sink + remap-source + discard sink; JS8Call configured by stable device names +4. KiwiSDR receiver adapter (paired SND/W/F, normalization, rejections) + contract v0 +5. fake-kiwi + fixture pipeline → **acceptance test passes** (gate) +6. Full contract + generated TS, health state machine, fan-out, limits, directory, persistence +7. Web app: tokens/branding, shell, waterfall, AudioWorklet, timeline, stations, map, status +8. Responsive, accessibility, Playwright smoke (incl. no-transmit assertion) +9. Docs, security-update process, release tagging + +## Known risks to verify early + +- Exact JS8Call 3.0.3 release asset and SHA-256 from `JS8Call-improved`. +- Whether JS8Call needs a callsign / first-run dialog dismissed to decode headless. +- Whether Qt Multimedia selects devices by Pulse description or name. +- Kiwi actual sample rate is ~12000.x Hz; nominal 12 kHz accepted, drift absorbed + by the bounded buffer; 20.25 kHz wideband mode rejected. diff --git a/docs/assets/banner.webp b/docs/assets/banner.webp new file mode 100644 index 0000000..6b74529 Binary files /dev/null and b/docs/assets/banner.webp differ diff --git a/docs/assets/dashboard.webp b/docs/assets/dashboard.webp new file mode 100644 index 0000000..d5fa76e Binary files /dev/null and b/docs/assets/dashboard.webp differ diff --git a/docs/assets/mark.svg b/docs/assets/mark.svg new file mode 100644 index 0000000..c14cc3a --- /dev/null +++ b/docs/assets/mark.svg @@ -0,0 +1,19 @@ + + ghost.js8 + + + + + + + + + + + + + + + + + diff --git a/docs/decoder-container.md b/docs/decoder-container.md new file mode 100644 index 0000000..1f4d38b --- /dev/null +++ b/docs/decoder-container.md @@ -0,0 +1,146 @@ +# Decoder container + +`decoder/Dockerfile` (build context: repository root) builds the image that runs the **native JS8Call** decoder headless. +One container = one decoder slot = one receiver being decoded. + +## Pins + +| What | Pin | Where | +|---|---|---| +| Base | `ubuntu:24.04@sha256:534baea6…eb55` | `decoder/Dockerfile` `UBUNTU_IMAGE` | +| JS8Call | `3.0.3`, `JS8Call-v3.0.3-{x86_64,aarch64}.AppImage` (chosen by `TARGETARCH`) | `JS8CALL_VERSION` | +| JS8Call SHA-256, amd64 | `3f89bd821f281c59a9384c08a3ad783ea3b9ac6abf319ce6c0d881c2ecc6e6cd` | `JS8CALL_SHA256_AMD64`; the build fails on mismatch | +| JS8Call SHA-256, arm64 | `003b7e6f41abc702710051e648d3c673110fe31e8f275bbfa9508f560672b8a4` | `JS8CALL_SHA256_ARM64`; the build fails on mismatch | +| apt packages | Ubuntu archive at build time (GPG-signed); not version-pinned | see `SECURITY-UPDATES.md` | + +The SHA-256s were recorded on first download (amd64 on 2026-10-07, arm64 on +2026-10-08). Upstream publishes no checksums or detached signatures for this +release. Their release workflow produces a GitHub build attestation, which can +be checked with +`gh attestation verify JS8Call-v3.0.3-.AppImage --repo JS8Call-improved/js8call-improved`. +The v3.0.3 tag is commit `4c592bd9…`, the same commit the acceptance-test +fixture is pinned to. + +Ubuntu 24.04 is required: the 3.x AppImage fails on 22.04. Docker has no FUSE, +so the AppImage is unpacked at build time in a throwaway stage. It is **never +executed**: the squashfs payload starts right after the ELF runtime +(`e_shoff + e_shentsize × e_shnum`) and is extracted with `unsquashfs`. That +also lets an amd64 host prepare the arm64 tree. + +**Architectures:** `linux/amd64` and `linux/arm64`. That covers a Raspberry +Pi 4/5 on a 64-bit OS, Apple Silicon and ARM cloud servers. Upstream ships no +32-bit ARM build. CI builds and boots the decoder on both, and runs the +real-recording acceptance test on native ARM runners. The extracted tree bundles Qt 6 and the PulseAudio client; the runtime +stage adds only what `ldd` showed missing (GL/EGL, X11/xcb, fontconfig, +freetype, harfbuzz, libusb). + +### Building behind a TLS-intercepting proxy + +```sh +docker build --secret id=extra_ca,src=/path/to/full-ca-bundle.crt -f decoder/Dockerfile -t ghostjs8-decoder . +``` + +The secret is used only by the download step in the `fetch` stage and never lands +in the image. Integrity comes from the SHA-256 check, not TLS. + +## Processes + +`tini` → `ghost-supervise` → `supervisord`: + +| Program | Role | +|---|---| +| `xvfb` | virtual display `:99` (Qt xcb platform) | +| `pulseaudio` | user-mode daemon, graph from `/etc/ghostjs8/pulse.pa` | +| `js8call` | `ghost-start-js8call`: renders `JS8Call.ini`, waits for X + Pulse, execs `/opt/js8call/usr/bin/JS8Call` (not `AppRun`, so the process is named `JS8Call`) | +| `agent` | decoder-agent (`/opt/agent`, a uv-locked venv with only `websockets`): bridge link on `:8074/agent`, PCM → `pacat` → `ghost_rx`, JS8 UDP API owner, health facts | +| `fatal` (event listener) | on any `PROCESS_STATE_FATAL`, writes `/run/ghost/fatal` and stops supervisord | + +A program that dies 4 times within its `startsecs` goes FATAL; the container +then exits with **code 70**, so a restart policy treats it as a failure. Nothing +can keep looking healthy with a dead decoder. + +## Audio graph + +``` +decoder-agent ──pcm──▶ ghost_rx (null sink, mono) + └─ ghost_rx.monitor ─▶ ghost_rx_in (module-remap-source) ─▶ JS8Call input +JS8Call output ──────▶ ghost_discard (null sink, never monitored) +``` + +Qt hides PulseAudio monitor sources from its device list, so the monitor is +re-exposed as an ordinary capture source. JS8Call selects devices by +`SoundInId`/`SoundOutId`, which are `QAudioDevice::id()` — the PulseAudio +device **name** — so the names in `pulse.pa` are stable IDs. Keep them in sync +with `JS8Call.ini.tmpl`. + +## JS8Call configuration + +`/etc/ghostjs8/JS8Call.ini.tmpl` is rendered on **every** start (JS8Call +rewrites the file on exit; we discard that). Environment: + +| Variable | Default | Meaning | +|---|---|---| +| `GHOSTJS8_JS8_CALL` | `N0CALL` | placeholder identity; JS8Call blocks on a settings dialog if empty | +| `GHOSTJS8_JS8_GRID` | `AA00` | placeholder grid; same reason | +| `GHOSTJS8_UDP_PORT` | `2242` | JS8Call UDP API target on `127.0.0.1` | +| `GHOSTJS8_FILTER_CENTER` | `1550` | decode filter center, Hz | +| `GHOSTJS8_FILTER_WIDTH` | `2900` | decode filter width, Hz (100–3000 Hz) | + +The filter **is** the decode range (`nfa`/`nfb` in `mainwindow.cpp`), so it must +cover the receiver passband. All submodes are decoded (`SubModeMultiDecode`). + +Settings forced off because their **defaults are on** and would transmit or +publish under the placeholder call: `PSKReporter`, `SpotToAPRS`, +`CheckForUpdates`, `WriteLogs`; plus `TxBeacon=0`, `TransmitOFF`, `RelayOFF`, +`AutoreplyOnAtStartup=false`, `Rig=None`. + +## UDP API (observed on 3.0.3) + +JSON datagrams `{"type", "value", "params"}` sent from an ephemeral port to +`127.0.0.1:2242`. Replies go to that source port (learn it from incoming +datagrams). + +| Type | Notes | +|---|---| +| `PING` | every 15 s — the decoder-API liveness signal | +| `RX.ACTIVITY` | `value` is the full line, e.g. `K0OG: KN4CRD SNR +02 ` | +| `RX.DIRECTED` | `value` **omits the sender**; `params.FROM` carries it. Text ends with JS8's EOT marker `♢`. Display as `FROM + ": " + value` minus the marker. | +| `RX.SPOT`, `RX.CALL_ACTIVITY` | station / spot info | +| `STATION.STATUS` | ~2 Hz; ignore | +| `CLOSE` | sent on shutdown | + +`AcceptUDPRequests=true` lets the agent query JS8Call; the agent sends only an +allowlist of read-only requests (`RX.GET_CALL_ACTIVITY`, `STATION.GET_CALLSIGN`) +and can never construct a `TX.*` request. + +**`RX.SPOT` is only emitted when spotting to reporting networks is on** +(`processSpots()` returns early otherwise). We keep spotting off, so the agent +polls `RX.GET_CALL_ACTIVITY` every 15 s; the `RX.CALL_ACTIVITY` reply maps each +heard callsign to `{SNR, GRID, UTC}`. A matching `_ID` in a reply also proves +an API round trip (health: "decoder API responding"). + +## decoder-agent link + +See `bridge/src/ghostjs8/decoders/js8call_native/agent_protocol.py`. The bridge +sends binary PCM (mono s16le 12 kHz); the agent sends JSON `js8` (verbatim +JS8Call messages) and `health` frames every 3 s. Audio queues are bounded at +both ends (~2 s) and drop oldest; queued audio is discarded on reconnect, +because JS8 needs live, time-aligned audio. + +## Health + +`ghost-healthcheck` (Docker `HEALTHCHECK`) fails unless all critical programs (xvfb, pulseaudio, agent, js8call) +are RUNNING, X answers, the Pulse graph exists, and JS8Call holds an +**uncorked capture stream on `ghost_rx_in`** (matched by +`application.process.binary=JS8Call`). + +## Debugging + +```sh +docker exec -u root sh -c 'apt-get update && apt-get install -y x11-apps netpbm' +docker exec sh -c 'xwd -root -silent | xwdtopnm | pnmtopng > /tmp/shot.png' +docker cp :/tmp/shot.png . +``` + +Manual decode smoke test: copy a 12 kHz WAV in, start a UDP listener on +`127.0.0.1:2242`, then `paplay --device=ghost_rx file.wav` on a 15 s UTC boundary. diff --git a/docs/ghostnet.md b/docs/ghostnet.md new file mode 100644 index 0000000..143ce8b --- /dev/null +++ b/docs/ghostnet.md @@ -0,0 +1,133 @@ +# GhostNet autopilot + +ghost.js8 can monitor the GhostNet JS8 nets for you, unattended. When enabled, +the bridge follows the GhostNet operating plan (v1.5) for your region. Ahead of +each window it connects to a public KiwiSDR near your home grid and tunes the +net frequency. It records the window (traffic, waterfall and audio) for viewing +later in the web UI. + +It is **off by default** and remains **receive only**: nothing in ghost.js8 can +transmit, and the autopilot never changes shared receiver hardware settings. + +## Enable + +Set these on the `bridge` service (for example in `.env`): + +| Variable | Default | Meaning | +|---|---|---| +| `GHOSTJS8_GHOSTNET` | `off` | `on` enables the autopilot | +| `GHOSTJS8_GHOSTNET_REGION` | — | `na`, `eu` or `aus`: which nets and data bridges to follow | +| `GHOSTJS8_HOME_GRID` | — | Your Maidenhead locator (4 or 6 characters); receivers are ranked by distance from it | +| `GHOSTJS8_GHOSTNET_PREROLL_MINUTES` | `10` | Connect and start recording this long before a window opens | +| `GHOSTJS8_GHOSTNET_RECORD_AUDIO` | `on` | Record FLAC audio (traffic and waterfall are always recorded) | +| `GHOSTJS8_GHOSTNET_PARK` | `on` | Between windows, park on 7.107 MHz and keep watching (not recorded) | +| `GHOSTJS8_RECORDINGS_DIR` | `/data/nets` | Where recordings are written (on the bridge data volume) | +| `GHOSTJS8_NET_RETENTION_DAYS` | `90` | Recordings older than this are pruned hourly | + +The autopilot also needs `GHOSTJS8_DB_PATH`. The production compose file sets it. + +If the configuration is invalid (unknown region, bad grid, no store), the bridge +still starts. The autopilot stays off, and the error is logged and shown in the +GhostNet panel. + +With the autopilot on, idle disconnect is disabled, because the point is to +listen with nobody watching. + +## Schedule followed + +Times are UTC (the plan's "Thursday night" nets in local time). The windows +come from `bridge/src/ghostjs8/ghostnet/schedule.py`. + +| Window | When (UTC) | Dial | Regions | +|---|---|---|---| +| GhostNet North America | Fri 01:00–01:30 | 7.107 MHz | na | +| GhostNet Europe | Thu 18:00–18:30 | 7.107 MHz | eu | +| GhostNet Australia | Thu 07:00–07:30 | 7.107 MHz | aus | +| NA–EU data bridge | Sat 18:00–19:00 | 14.107 MHz | na, eu | +| NA–AUS data bridge | Sat 12:00–13:00 | 14.107 MHz | na, aus | +| NA–AUS data bridge (80 m) | Sat 13:30–14:00 | 3.575 MHz | na, aus | +| EU–AUS data bridge | Sat 20:00–21:00 | 14.107 MHz | eu, aus | +| EU–AUS data bridge (80 m) | Sat 21:30–22:00 | 3.575 MHz | eu, aus | +| AUS–South Pacific data bridge | Sat 08:00–09:00 | 14.107 MHz | aus | +| AUS–South Pacific data bridge (40 m) | Sat 09:00–09:30 | 7.107 MHz | aus | + +All windows use USB with a 100–3000 Hz passband, which matches the JS8Call +decode filter. If the plan changes, edit `PLAN` and its tests. + +The ALE codeplug and the HFN ALE net list distributed with the plan are for +ALE, a different mode. ghost.js8 does not use them. + +## Receiver choice + +Receivers come from the public directory, which is fetched by the bridge. The +picker: + +1. keeps receivers that report a location, cover the dial frequency plus 3 kHz, + have a free channel and, when they report SNR, have at least 15 dB; +2. sorts them by great-circle distance from the home grid; +3. connects to the nearest one. + +The panel shows the reason, for example "nearest free receiver: 412 km, SNR 22 dB". + +A receiver that rejects the session, fails, or stays connecting/backing off for +longer than 60 s is benched for 30 minutes, and the next candidate is tried. The +normal reconnect etiquette applies throughout: jittered backoff, identifying as +`ghost.js8`, and honouring `too_busy` and `down`. + +## Manual override + +A viewer can always take control by tuning, selecting another receiver or +disconnecting. The autopilot then steps back: + +- **during a window**: it stays paused until the window ends, and the recording + is marked *operator override* (it continues, but may not be on the net); +- **between windows**: it stays paused until the next window's pre-roll. + +A new window always takes the station back. + +## Recordings + +Each window is stored under its net ID, `-Z` (for example +`na-net-20261009T0100Z`): + +``` +/data/nets//audio.flac 12 kHz mono 16-bit FLAC (≈35–50 MB per hour) +/data/nets//waterfall.png one row per second, -500…+3500 Hz around the dial +``` + +Decodes and the net summary live in the SQLite store (tables `nets` and +`net_decodes`). The HTTP API serves them read-only: + +| Endpoint | Returns | +|---|---| +| `GET /api/nets` | `NetSummary[]`, newest first | +| `GET /api/nets/{id}` | `NetLog` (summary, stations, traffic, waterfall geometry) | +| `GET /api/nets/{id}/waterfall.png` | waterfall image | +| `GET /api/nets/{id}/audio.flac` | audio | + +Net IDs are validated against a strict pattern, so unknown IDs and path tricks +return 404. + +## In the UI + +- **GhostNet panel** shows: + - the mode: *ON NET*, *WATCHING 7.107*, *PAUSED — MANUAL* or *AUTOPILOT OFF*; + - the current or next window, with a countdown in UTC and local time; + - a REC indicator and the receiver reason; + - the recorded nets. +- **Net log** (`#/net/`) shows: + - the waterfall, with time and offset axes; click it to seek the audio; + - the audio player and download links; + - heard stations; + - the traffic list, where each timestamp seeks the audio. +- **Tags**: traffic is tagged `FLASH` (@GSTFLASH), `GN` (@GHOSTNET and the + `@GN` groups) and `DIR` (directed). +- **@GSTFLASH alert**: live flash traffic raises a banner (`role="alert"`) until + it is acknowledged. History replayed on connect does not raise it. + +## Development + +`GHOSTNET=1 SEED_NET=1 tools/dev-stack.sh` runs the simulated stack with the +autopilot on (`HOME_GRID` defaults to `EN34`, `GHOSTNET_REGION` to `na`). It +seeds a synthetic recording of last week's net: `python -m ghostjs8 seed-net +--db … --recordings …`. The Playwright suite uses the same setup. diff --git a/docs/kiwisdr-notes.md b/docs/kiwisdr-notes.md new file mode 100644 index 0000000..bbf3d07 --- /dev/null +++ b/docs/kiwisdr-notes.md @@ -0,0 +1,102 @@ +# KiwiSDR protocol notes + +What `bridge/src/ghostjs8/receivers/kiwisdr/` relies on. Verified against +[jks-prv/kiwiclient](https://github.com/jks-prv/kiwiclient) (`kiwi/client.py`, +`microkiwi_waterfall.py`) and exercised by `ghostjs8.sim.fake_kiwi`. + +## Session pairing + +A session is two WebSockets on the receiver's HTTP port (usually 8073): + +``` +ws://host:port//SND +ws://host:port//W/F +``` + +`` is a client-chosen integer (browsers use a Unix timestamp). Both +streams **must** use the same identifier; `verify_pairing()` enforces it +before connecting, and the fake Kiwi refuses a W/F without a live SND under the +same id (`MSG pairing_error=...`). + +## Wire format + +Every server message starts with a 3-byte ASCII tag. Integers are +little-endian unless noted. + +``` +SND "SND" flags:u8 seq:u32 smeter:u16be pcm... +W/F "W/F" pad:u8 x_bin:u32 flags_zoom:u32 seq:u32 bins:u8[1024] +MSG "MSG" pad:u8 "name=value name=value ..." (values URL-encoded; bare names allowed) +EXT same as MSG +``` + +- `rssi_dBm = 0.1 * smeter - 127`. +- SND flags: `0x02` ADC overload · `0x08` stereo (IQ) · `0x10` compressed + (IMA ADPCM) · `0x80` PCM is little-endian. +- **SND PCM is signed 16-bit big-endian** by default. We byte-swap to + little-endian. If `0x80` is set the data is already LE and is passed through + (never double-swapped). +- Compressed, stereo/IQ and non-12 kHz audio are **fatal, typed errors** + (`UnsupportedAudioFormat`). Compressed frames already in flight when we send + `compression=0` are dropped for a 2 s grace window first. +- W/F `flags_zoom`: low 16 bits zoom (0–14), high 16 bits flags. Rows are 1024 + `u8` bins; `dBm ≈ value − 255` (minus the receiver's waterfall calibration, + typically ~13 dB). With `wf_comp=0` rows are uncompressed. +- Row start: `x_bin * bandwidth / (1024 << 14)`; span: `bandwidth >> zoom`. + `bandwidth` comes from `MSG bandwidth=` (default 30 MHz). + +## Handshake + +Client → server is plain-text `SET ...` messages. + +SND: + +1. `SET auth t=kiwi p=` +2. ← `MSG badp=0` (accepted) or a rejection (below), `version_maj/min`, `bandwidth`, … +3. ← `MSG audio_rate=12000` → `SET AR OK in=12000 out=44100` +4. ← `MSG sample_rate=12001.135` (true rate; must be within 1 % of 12 kHz) → + `SET compression=0`, `SET agc=1 hang=0 thresh=-100 slope=6 decay=1000 manGain=50`, + `SET mod=usb low_cut=100 high_cut=3000 freq=14078.000`, `SET ident_user=ghost.js8` +5. SND frames flow. `SET keepalive` every 2 s on each stream. + +W/F: + +1. `SET auth t=kiwi p=` +2. ← `MSG wf_setup` → `SET zoom= cf=` (≥ v1.329; older servers get + `SET zoom= start=`), `SET maxdb=-10 mindb=-110`, + `SET wf_speed=3`, `SET wf_comp=0`, `SET interp=13`, `SET ident_user=ghost.js8` + +Frequencies on the wire are kHz at baseband: subtract `MSG freq_offset=` (kHz) +for downconverter-fronted receivers. **LSB passbands are negative**: +`low_cut=-3000 high_cut=-100`. + +## What we send, and what we never send + +Only per-channel settings: mode, passband, frequency, **this channel's** AGC +(DSP, not hardware), waterfall zoom/speed/compression, identity, keepalives. +Never: attenuation, `genattn`/`gen`, admin commands, or anything that changes +the receiver for other users. An integration test asserts this. + +## Rejections → `ReceiverRejected(reason)` + +| MSG | Reason | +|---|---| +| `too_busy=` | `FULL` — all slots taken | +| `badp=1`, `3`, `4` | `AUTH` — password needed/wrong, or admin restrictions | +| `badp=2`, `6`, `7` | `BUSY` — starting up, database update, admin connected | +| `badp=5` | `DUPLICATE_IP` — one connection per address | +| `down` | `DOWN` | +| `redirect=` | `REDIRECT` (not followed automatically) | + +Transport failures: DNS/refused/non-WebSocket → `ReceiverUnreachable`; slow +connect or no audio within the handshake timeout → `ReceiverTimeout`; an +established stream closing → `ReceiverClosed`. + +## Etiquette + +- Identify as `ghost.js8` (`SET ident_user`), and in the HTTP User-Agent. +- One paired session per decoder slot, shared by every browser. +- The adapter never retries; the session layer reconnects with jittered + exponential backoff (min 5 s, max 5 min) and does not retry `AUTH`/`FULL` + rejections aggressively. +- Respect the receiver's own time limits; disconnect when told. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..28c2199 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,190 @@ +# Operations + +## Deploy + +```sh +cp .env.example .env # set GHOSTJS8_ALLOWED_ORIGINS, optionally a boot receiver +make images # or: docker compose build (EXTRA_CA=... behind a TLS proxy) +make up # docker compose up -d +``` + +Only the `web` container publishes a port (default `127.0.0.1:8080`). Point +your reverse proxy at it; it terminates TLS and handles authentication. + +### Published images and ARM + +Tagged releases publish multi-arch images (`linux/amd64` and `linux/arm64`) to +GHCR. ARM means a Raspberry Pi 4/5 on a **64-bit** OS, Apple Silicon or ARM +cloud servers; there is no 32-bit ARM build of JS8Call. To run them instead +of building: + +```sh +# in .env +GHOSTJS8_IMAGE_PREFIX=ghcr.io/d3mocide/ghostjs8 +GHOSTJS8_TAG=0.1.0 +``` + +```sh +docker compose pull && docker compose up -d --no-build +``` + +Building locally on an ARM host works too (`make images`); the decoder picks +the matching JS8Call build automatically. + +### Receivers on your LAN + +By default, a receiver chosen in the UI (or picked by the GhostNet autopilot) +must resolve to a **public** address. The bridge resolves the name, checks +every address, and connects to the checked address, so tricks like `127.1`, +names that point inside your network, or DNS rebinding are refused. To use +your own LAN KiwiSDR, either: + +- set it as the boot receiver (`GHOSTJS8_RECEIVER_HOST`), which is trusted as + configured, or +- set `GHOSTJS8_ALLOW_PRIVATE_RECEIVERS=true` to allow private and CGNAT + (e.g. tailnet) addresses from the UI. + +Loopback, link-local and cloud-metadata addresses are always refused. + +### Hardening + +All containers run as non-root, with `cap_drop: [ALL]` and +`no-new-privileges`. The decoder sits on an internal network with no egress. +The browser WebSocket accepts same-origin pages only unless +`GHOSTJS8_ALLOWED_ORIGINS` lists others. Connection attempts to receivers are +spaced at least 5 s apart, whoever triggers them. + +### Reverse proxy + +The bridge WebSocket lives at `/ws` on the same origin. Your proxy must pass +WebSocket upgrades and allow long-lived connections. + +**Caddy** + +```caddy +ghost.example.net { + basic_auth { + operator $2a$14$... # caddy hash-password + } + reverse_proxy 127.0.0.1:8080 +} +``` + +Caddy proxies WebSockets automatically. + +**Nginx Proxy Manager:** add a proxy host to `http://:8080`, enable +*Websockets Support*, add an Access List for authentication, and in *Advanced* +set `proxy_read_timeout 3600s;`. + +Set `GHOSTJS8_ALLOWED_ORIGINS=https://ghost.example.net` so other sites cannot +open the WebSocket from a visitor's browser. + +### Multiple receivers + +JS8Call takes one audio input per instance, so one decoder container decodes +one receiver at a time. In this release a bridge drives exactly one station +(one receiver + one decoder). To decode several receivers at once, run several +compose projects (`docker compose -p ghostjs8-40m ...`, each with its own +`.env` and port). An in-bridge pool of decoder slots is a planned follow-up +(see `architecture.md`). + +### GhostNet autopilot + +To monitor and record the GhostNet JS8 windows unattended, set +`GHOSTJS8_GHOSTNET=on`, `GHOSTJS8_GHOSTNET_REGION` and `GHOSTJS8_HOME_GRID`. See +[`ghostnet.md`](ghostnet.md). Recordings go to the `bridge-data` volume +(`/data/nets`) and are pruned after `GHOSTJS8_NET_RETENTION_DAYS`. Budget about +50 MB per recorded hour with audio on. A viewer who tunes or switches receivers +pauses the autopilot until the window ends. + +## Health + +`GET /readyz` returns the same health document the UI shows (200 only when +`overall = listening`, otherwise 503 with the reason). Every component carries +`state`, `since` (last state change), `last_seen` (last positive evidence) and +`detail`. + +| Component | OK means | Goes DOWN when | +|---|---|---| +| bridge | the bridge is serving | (it is the thing answering) | +| decoder_process | agent reachable and JS8Call RUNNING under supervision | agent link lost, no agent report for 10 s, or JS8Call not running | +| decoder_api | JS8Call's UDP API heard within 40 s (PING every 15 s, or a request round trip) | no heartbeat for 40 s | +| decoder_capture | JS8Call holds an uncorked capture stream on `ghost_rx_in` | JS8Call stopped capturing its input | +| receiver | KiwiSDR session connected and audio flowing | rejected, failed, backing off, or idle | +| audio | receiver audio arrived within 3 s | stale > 3 s while connected | +| waterfall | waterfall rows arrived within 5 s | stale > 5 s while connected | +| last decode | time of the last real decode | never inferred; `null` until something is decoded | + +Container level: the decoder's Docker `HEALTHCHECK` fails unless Xvfb, +PulseAudio, the agent and JS8Call are RUNNING **and** JS8Call is capturing. A +critical process that crash-loops goes FATAL and the container exits with code +70; `restart: unless-stopped` brings it back. + +## What the headline means + +| Headline | Meaning | What to do | +|---|---|---| +| LISTENING — TRAFFIC COPIED | everything healthy, decodes in the last 5 min | nothing | +| NO TRAFFIC — BAND QUIET | healthy, nothing decoded recently | normal. Propagation, time of day, or no activity. Try another band or receiver if you expected traffic | +| RECEIVER FULL / RECEIVER BUSY | the KiwiSDR refused: all slots taken, or temporarily unavailable | wait (retried no sooner than 60 s) or switch receiver | +| PASSWORD REQUIRED / RECEIVER REFUSED | auth or redirect | not retried; pick again with a password or another receiver | +| UNSUPPORTED RECEIVER AUDIO | receiver sends compressed, stereo/IQ or non-12 kHz audio | switch receiver (e.g. wideband 20.25 kHz Kiwis are not supported) | +| ADC OVERLOAD AT RECEIVER | the remote receiver's input is overloaded | switch receiver. Volume does not help, and ghost.js8 never changes shared hardware settings (attenuation) on someone else's Kiwi | +| AUDIO STALE | connected but no audio | usually network trouble; it will reconnect | +| DECODER OFFLINE / DEGRADED | JS8Call not running or not capturing | wait for the supervisor restart; check `docker compose logs decoder` | + +## Troubleshooting + +- **Decodes never appear, health all OK:** check the host clock. JS8 decoding + needs UTC within about a second; run NTP/chrony on the Docker host. Also + confirm the passband covers the signals (default 100–3000 Hz USB) and the + dial is on a JS8 frequency. +- **`decoder_capture` DOWN:** `docker compose exec decoder pactl list short source-outputs` + should show JS8Call on `ghost_rx_in`. See `decoder-container.md`. +- **Receiver keeps rejecting:** public receivers fill up, especially in the + evening. The directory panel hides full receivers by default. +- **Logs:** structured JSON, one object per line, with `session` ids: + `docker compose logs -f bridge | jq -r '[.ts,.level,.logger,.msg]|@tsv'`. +- **Behind a TLS-intercepting proxy at build time:** `EXTRA_CA=/path/bundle.crt make images`. + +## Etiquette + +Public KiwiSDRs are shared by their owners. ghost.js8 identifies itself as +`ghost.js8`, uses one connection per station no matter how many browsers +watch, backs off politely (5 s → 5 min, jittered; ≥ 60 s when full), never +retries auth failures automatically, and only changes per-channel settings. +Set `GHOSTJS8_IDLE_DISCONNECT_MINUTES` if you do not need persistent listening. + +## Manual validation checklist (real receiver) + +Synthetic waterfall rows and the fake agent only test transport and +rendering. Before a release, check against a real public KiwiSDR: + +1. Pick a receiver covering 40 m or 20 m during an active period; tune 7.078 or 14.078 MHz USB. +2. Waterfall shows the 0–3 kHz JS8 segment with recognizable 50 Hz-wide JS8 + signals stacking on 15 s boundaries; the passband markers sit at 100/3000 Hz. +3. Audio monitor plays clean SSB audio with no growing delay. Buffer stays + around 250 ms and "dropped" stays low. +4. Within a few cycles, decodes appear with plausible SNR (−24…+10) and offsets + matching the visible signals. +5. Directed messages are marked DIR; heard stations fill in; stations that sent + grids appear on the map at plausible locations. +6. Health shows every component ONLINE with fresh evidence ages. +7. Retune to another band: the waterfall and passband follow, and other open + browsers see the change. +8. Switch receivers: old session closes, new one connects, no duplicate + connections remain on the old receiver (check its user list). +9. `docker compose restart decoder`: health shows DECODER OFFLINE within + about 10 s, then recovers, and decoding resumes without touching the bridge. +10. Leave the page: the "N watching" count in the Receiver panel of another + browser drops, and audio stops. +11. If you find a receiver that reports ADC overload, the UI shows the overload + banner and offers switching, and nothing on the receiver changes. + +## Extension point: transmit + +ghost.js8 is receive-only by design and a KiwiSDR cannot transmit. A future +radio interface must be a separate component with its own adapter, its own +authorization model, and explicit operator licensing checks. It must not be +added to the bridge ↔ browser protocol of this project: there is deliberately +no transmit message, and the browser tests assert no transmit control exists. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..cb6be2e --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,122 @@ +# Bridge ↔ browser protocol (v1) + +One WebSocket at `/ws` (proxied by the `web` container). Text frames are JSON; +binary frames carry audio and waterfall data. The protocol is **receive-only**: +no message exists that could transmit. + +**Source of truth:** `bridge/src/ghostjs8/contract/messages.py` (Pydantic). +Generated artifacts, committed and drift-checked in CI (`make contract-check`): + +- `contract/schema.json` — JSON Schema (draft 2020-12) +- `web/src/lib/protocol/generated.ts` — TypeScript types + +Every JSON message has `"v": 1` and a discriminating `"type"`. Every property +is always present (nullable fields are sent as `null`). + +## Connection sequence + +``` +browser bridge + │ ── WebSocket upgrade (Origin checked) ──▶ │ 1008 if Origin not allowed, 1013 if session full + │ ◀── hello (build, limits, receive_only: true) + │ ◀── history (recent decodes oldest-first, stations heard in the last 24 h) + │ ◀── session (receiver, tuning, state, subscribers) + │ ◀── health (component states) + │ ◀── receiver_status (ADC overload, RSSI, receiver info) + │ ── subscribe {audio, waterfall} ──▶ binary channels are opt-in (default off) + │ ◀── decode / station / session / health / receiver_status … (as they happen) + │ ◀── binary audio / waterfall frames (only if subscribed) +``` + +## Server → browser (JSON) + +| `type` | When | Key fields | +|---|---|---| +| `hello` | first | `build`, `receive_only: true`, `limits {max_clients, audio_sample_rate, waterfall_max_fps, waterfall_bins}` | +| `history` | after hello | `decodes[]`, `stations[]` | +| `session` | on any change | `state` (`idle` · `connecting` · `connected` · `backoff` · `rejected` · `failed`), `receiver {host, port, name}`, `tuning`, `subscribers`, `reject_reason`, `detail`, `next_retry_utc` | +| `health` | on change, and every 10 s | `overall` (`listening` · `degraded` · `offline`), `components {bridge, decoder_process, decoder_api, decoder_capture, receiver, audio, waterfall}` each `{state, since, last_seen, detail}`, `last_decode_utc` | +| `decode` | each decode | `kind` (`activity` · `directed`), `utc` (from the decoder), `text`, `snr_db`, `offset_hz`, `dial_hz`, `freq_hz`, `speed`, `from_call`, `to_call`, `grid` | +| `station` | station heard / updated | `callsign`, `last_heard_utc`, `snr_db`, `grid` (**valid Maidenhead only**), `offset_hz`, `dial_hz`, `heard_count` | +| `receiver_status` | every 2 s while connected, and immediately on ADC-overload change | `adc_overload`, `rssi_dbm`, `info` | +| `error` | bad client message | `code` (`bad_message` · `unsupported_version` · `session_full` · `invalid_tuning` · `invalid_receiver` · `rate_limited` · `internal`), `message` | +| `pong` | reply to `ping` | `utc` | + +### Semantics worth knowing + +- **Health is evidence, not inference.** `since` is when the state last + changed; `last_seen` is the last positive evidence. `last_decode_utc` only + moves when a decode arrives. Thresholds: audio stale > 3 s, waterfall > 5 s, + decoder report > 10 s, decoder API heartbeat > 40 s. +- **JS8Call reports directed traffic twice** (an `activity` line and a + `directed` record). Both are forwarded; UIs may collapse pairs with equal + `utc` and `offset_hz`. Station `heard_count` counts each transmission once. +- `session.state = rejected` with `reject_reason`: + `full` / `busy` / `duplicate_ip` → retried no sooner than 60 s; + `auth` / `redirect` → not retried until the operator acts. `failed` means the + receiver sends audio we deliberately do not support (compressed, stereo/IQ, + non-12 kHz); not retried. +- `grid` is `null` unless it is a valid 4/6/8-character Maidenhead locator. + +## Browser → server (JSON) + +| `type` | Fields | Notes | +|---|---|---| +| `hello` | `client` | informational | +| `subscribe` | `audio`, `waterfall` | per-connection opt-in to binary channels | +| `tune` | `tuning {dial_hz, mode, low_cut_hz, high_cut_hz}` | affects everyone watching this station | +| `select_receiver` | `receiver {host, port, name}`, `password` | loopback / link-local / reserved addresses refused; private ranges unless disabled | +| `disconnect_receiver` | — | release the receiver | +| `ping` | — | answered with `pong` | + +Control messages (`tune`, `select_receiver`, `disconnect_receiver`) are rate +limited to 10 per 5 s per connection. Messages over 4 KiB are rejected. + +## Binary frames (bridge → browser) + +All integers **little-endian**. First byte is the channel. + +### Audio — channel `0x01` + +``` +offset size field +0 1 channel = 0x01 +1 1 flags: bit0 = ADC overload reported for this block +2 2 reserved (0) +4 4 seq (u32, wraps) +8 4 sample_rate (u32) = 12000 +12 n PCM: mono signed 16-bit little-endian +``` + +Blocks are ~512 samples (~43 ms). Gaps in `seq` mean dropped blocks; the +browser player resynchronizes rather than buffering further. + +### Waterfall — channel `0x02` + +``` +offset size field +0 1 channel = 0x02 +1 1 flags (reserved, 0) +2 2 bins (u16) = 1024 +4 4 seq (u32) +8 4 start_hz (u32): frequency of bin 0 +12 4 span_hz (u32): total span; bin width = span_hz / bins +16 bins u8 per bin: dBm ≈ value − 255 (receiver waterfall calibration not applied) +``` + +Rows are capped at `limits.waterfall_max_fps` (default 10) per station. +Synthetic rows from `fake-kiwi` exercise transport and rendering only. + +## HTTP + +| Path | Response | +|---|---| +| `GET /healthz` | `{"status":"ok"}` — liveness | +| `GET /readyz` | a `health` message body; **200** when `overall = listening`, else **503** | +| `GET /api/receivers` | `ReceiverDirectory` (`fetched_utc`, `stale`, `source`, `receivers[]`) — cached 15 min, stale on error | + +## Versioning + +`v` is bumped on any breaking change. A bridge rejects messages with an +unknown `v` (`error: unsupported_version`); browsers should do the same and +reload. diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..4cf876a --- /dev/null +++ b/renovate.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "helpers:pinGitHubActionDigests", + "docker:pinDigests", + ":pinDevDependencies", + ":semanticCommits" + ], + "rangeStrategy": "pin", + "lockFileMaintenance": { "enabled": true, "schedule": ["before 6am on monday"] }, + "vulnerabilityAlerts": { "labels": ["security"] }, + "packageRules": [ + { + "matchManagers": ["npm"], + "matchPackageNames": ["typescript"], + "allowedVersions": "<7", + "description": "typescript-eslint does not support TS 7 yet (see AGENTS.md)" + }, + { + "matchUpdateTypes": ["minor", "patch", "digest"], + "matchManagers": ["github-actions"], + "groupName": "github actions" + } + ] +} diff --git a/tools/acceptance/README.md b/tools/acceptance/README.md new file mode 100644 index 0000000..21b5a7e --- /dev/null +++ b/tools/acceptance/README.md @@ -0,0 +1,16 @@ +# Acceptance test + +`make acceptance` proves the whole decode path with a real recording: + +``` +A_1_4.wav ─▶ fake-kiwi (KiwiSDR SND framing, big-endian, UTC-aligned, ×2) + ─▶ bridge (KiwiReceiver: framing + byte-order normalization) + ─▶ decoder-agent ─▶ PulseAudio ghost_rx ─▶ remap-source ─▶ native JS8Call + ─▶ UDP API ─▶ decoder-agent ─▶ bridge ─▶ browser-facing /ws + ─▶ acceptance client asserts "K0OG: KN4CRD SNR +02" within 150 s +``` + +It runs in `docker-compose.test.yml` on an isolated network (no egress). The +client is `ghostjs8.sim.acceptance`; it validates every message against the +generated contract. Keep this test passing across every decoder or runtime +upgrade. diff --git a/tools/acceptance/no-extra-ca.pem b/tools/acceptance/no-extra-ca.pem new file mode 100644 index 0000000..e69de29 diff --git a/tools/dev-stack.sh b/tools/dev-stack.sh new file mode 100755 index 0000000..a17f089 --- /dev/null +++ b/tools/dev-stack.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Local simulated stack for UI work and browser tests: fake KiwiSDR + fake +# decoder-agent + real bridge. No Docker, no real decoding (see make acceptance). +# tools/dev-stack.sh # bridge on :8073 +# BRIDGE_PORT=18073 tools/dev-stack.sh +# GHOSTNET=1 SEED_NET=1 tools/dev-stack.sh # GhostNet autopilot + a seeded recording +set -euo pipefail +cd "$(dirname "$0")/../bridge" +KIWI_PORT=${KIWI_PORT:-18070} +AGENT_PORT=${AGENT_PORT:-18074} +BRIDGE_PORT=${BRIDGE_PORT:-8073} +PERIOD=${PERIOD:-15} +DATA_DIR=${DATA_DIR:-$(mktemp -d)} +GHOSTNET=${GHOSTNET:-0} +DB_PATH="" +if [ "$GHOSTNET" = 1 ]; then + DB_PATH="$DATA_DIR/ghostjs8.sqlite3" + if [ "${SEED_NET:-0}" = 1 ]; then + uv run --all-extras python -m ghostjs8 seed-net --db "$DB_PATH" --recordings "$DATA_DIR/nets" \ + --region "${GHOSTNET_REGION:-na}" + fi +fi +trap 'kill 0' EXIT +uv run --all-extras python -m ghostjs8 fake-kiwi --host 127.0.0.1 --port "$KIWI_PORT" --align 0 & +uv run --all-extras python -m ghostjs8 fake-agent --port "$AGENT_PORT" --period "$PERIOD" & +GHOSTJS8_LISTEN_HOST=127.0.0.1 GHOSTJS8_LISTEN_PORT=$BRIDGE_PORT \ +GHOSTJS8_AGENT_URL=ws://127.0.0.1:$AGENT_PORT/agent \ +GHOSTJS8_RECEIVER_HOST=${RECEIVER_HOST-127.0.0.1} GHOSTJS8_RECEIVER_PORT=$KIWI_PORT \ +GHOSTJS8_DB_PATH=$DB_PATH GHOSTJS8_LOG_FORMAT=text GHOSTJS8_ALLOW_PRIVATE_RECEIVERS=true GHOSTJS8_ALLOW_LOOPBACK_RECEIVERS=true \ +GHOSTJS8_DIRECTORY_URL=${DIRECTORY_URL:-http://rx.linkfanel.net/kiwisdr_com.js} \ +GHOSTJS8_GHOSTNET=$([ "$GHOSTNET" = 1 ] && echo on || echo off) \ +GHOSTJS8_GHOSTNET_REGION=${GHOSTNET_REGION:-na} GHOSTJS8_HOME_GRID=${HOME_GRID:-EN34} \ +GHOSTJS8_RECORDINGS_DIR=$DATA_DIR/nets \ + uv run --all-extras python -m ghostjs8 bridge & +wait diff --git a/tools/fake-kiwi/README.md b/tools/fake-kiwi/README.md new file mode 100644 index 0000000..c1c3322 --- /dev/null +++ b/tools/fake-kiwi/README.md @@ -0,0 +1,18 @@ +# fake-kiwi + +A simulated KiwiSDR for tests and the acceptance stack. The implementation lives +in the bridge package as `ghostjs8.sim.fake_kiwi` so tests import it directly +and it stays typed and linted; this directory documents how to run it. + +```sh +# from bridge/ +uv run python -m ghostjs8 fake-kiwi --wav ../tools/fixtures/cache/A_1_4.wav --align 15 --repeats 2 --port 8073 +``` + +It speaks real KiwiSDR framing (big-endian SND PCM with correct headers, W/F +rows with the 16-byte header, MSG handshakes), enforces SND/W/F session +pairing, aligns playback to UTC 15-second boundaries, and can inject faults +(`too_busy`, `bad_password`, `down`, `db_update`, `silent`, compressed/stereo +flags, truncated frames, ADC overload). Waterfall rows are **synthetic**: they +test transport and rendering only. See `docs/operations.md` for the manual +real-receiver checklist. diff --git a/tools/fixtures/README.md b/tools/fixtures/README.md new file mode 100644 index 0000000..693007e --- /dev/null +++ b/tools/fixtures/README.md @@ -0,0 +1,26 @@ +# Test fixtures + +## `A_1_4.wav` — real-recording acceptance fixture + +| | | +|---|---| +| Upstream | [`JS8Call-improved/js8call-improved`](https://github.com/JS8Call-improved/js8call-improved) | +| Commit | `4c592bd9a034f18178a3e7179db92acb14939668` (tag `v3.0.3`) | +| Path | `media/tests/A_1_4.wav` | +| Format | RIFF WAVE, mono, signed 16-bit PCM, 12 000 Hz, 15 s | +| SHA-256 | `60b650c2090dff5e2144f164ebe692cde5f048c769518e1b1b9e67223f3da138` | +| License | **GPL-3.0** (as part of the JS8Call-improved source tree) | +| Expected decode includes | `K0OG: KN4CRD SNR +02` | + +`fetch_fixture.py` downloads the file from that exact commit, verifies the +SHA-256 and fails hard on mismatch. It is cached in `cache/` (git-ignored) and +**not redistributed** by this repository. Anyone running the acceptance test +fetches it from upstream under upstream's license. + +ghost.js8 itself is GPL-3.0-or-later, so using the fixture in tests raises no +license conflict; we still keep it out of the tree to keep provenance explicit +and the repository free of third-party binaries. + +```sh +python3 tools/fixtures/fetch_fixture.py # or: make fixtures +``` diff --git a/tools/fixtures/directory-sample.js b/tools/fixtures/directory-sample.js new file mode 100644 index 0000000..7390277 --- /dev/null +++ b/tools/fixtures/directory-sample.js @@ -0,0 +1,16 @@ +// Synthetic KiwiSDR directory in the rx.linkfanel.net kiwisdr_com.js shape. +// Used by browser tests so they never depend on the live public list. +var kiwisdr_com = +[ + { + "id":"sample-a", "status":"active", "offline":"no", "name":"Sample Loop Receiver | Testville", + "bands":"0-30000000", "users":"1", "users_max":"4", "gps":"(44.5, -93.0)", "grid":"EN34", + "loc":"Testville", "antenna":"Loop", "snr":"35,30", "url":"http://127.0.0.1:28070" + }, + { + "id":"sample-b", "status":"active", "offline":"no", "name":"Busy Dipole | Fullton", + "bands":"0-30000000", "users":"4", "users_max":"4", "gps":"(41.7, -72.7)", "grid":"FN31pr", + "loc":"Fullton", "antenna":"Dipole", "snr":"28,22", "url":"http://192.0.2.44:8073" + }, +] +; diff --git a/tools/fixtures/fetch_fixture.py b/tools/fixtures/fetch_fixture.py new file mode 100755 index 0000000..6a14a46 --- /dev/null +++ b/tools/fixtures/fetch_fixture.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Fetch the upstream JS8Call test recording at a pinned commit and verify it. + +Stdlib only. Fails hard (exit 1) on any SHA-256 mismatch. The file is GPL-3.0 +licensed upstream content and is cached locally, never committed (see README). +""" + +from __future__ import annotations + +import hashlib +import sys +import urllib.request +from pathlib import Path + +REPO = "JS8Call-improved/js8call-improved" +COMMIT = "4c592bd9a034f18178a3e7179db92acb14939668" # tag v3.0.3 +PATH = "media/tests/A_1_4.wav" +SHA256 = "60b650c2090dff5e2144f164ebe692cde5f048c769518e1b1b9e67223f3da138" +URL = f"https://raw.githubusercontent.com/{REPO}/{COMMIT}/{PATH}" + +CACHE = Path(__file__).resolve().parent / "cache" +TARGET = CACHE / "A_1_4.wav" + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def main() -> int: + if TARGET.exists() and sha256(TARGET.read_bytes()) == SHA256: + print(f"fixture ok (cached): {TARGET}") + return 0 + print(f"fetching {URL}") + with urllib.request.urlopen(URL, timeout=60) as resp: # noqa: S310 - fixed https URL + data = resp.read() + digest = sha256(data) + if digest != SHA256: + print(f"SHA-256 MISMATCH for {PATH}\n expected {SHA256}\n got {digest}", file=sys.stderr) + return 1 + CACHE.mkdir(exist_ok=True) + tmp = TARGET.with_suffix(".tmp") + tmp.write_bytes(data) + tmp.replace(TARGET) + print(f"fixture ok: {TARGET} ({len(data)} bytes, sha256 {digest})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..5bb1c76 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +test-results +playwright-report diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..caa6ce3 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +package-lock.json +src/lib/protocol/generated.ts +test-results/ +playwright-report/ diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..f50db70 --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..25b7a01 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7 +# ghost.js8 web UI: static Vite build served by unprivileged nginx (port 8080). +ARG NODE_IMAGE=node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 +ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083 + +FROM ${NODE_IMAGE} AS build +WORKDIR /src +COPY package.json package-lock.json ./ +RUN --mount=type=secret,id=extra_ca,required=false \ + if [ -s /run/secrets/extra_ca ]; then export NODE_EXTRA_CA_CERTS=/run/secrets/extra_ca; fi; \ + npm ci --ignore-scripts --no-audit --no-fund +COPY . . +RUN npm run build + +FROM ${NGINX_IMAGE} AS runtime +LABEL org.opencontainers.image.title="ghost.js8 web" \ + org.opencontainers.image.source="https://github.com/d3mocide/ghost.js8" \ + org.opencontainers.image.licenses="GPL-3.0-or-later" +ENV GHOSTJS8_BRIDGE_UPSTREAM=bridge:8073 \ + NGINX_ENVSUBST_FILTER=GHOSTJS8_ +COPY --from=build /src/dist /usr/share/nginx/html +COPY deploy/default.conf.template /etc/nginx/templates/default.conf.template +COPY deploy/security-headers.conf /etc/nginx/snippets/security-headers.conf +EXPOSE 8080 +HEALTHCHECK --interval=15s --timeout=3s --retries=3 CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"] diff --git a/web/deploy/default.conf.template b/web/deploy/default.conf.template new file mode 100644 index 0000000..6ea5009 --- /dev/null +++ b/web/deploy/default.conf.template @@ -0,0 +1,77 @@ +# ghost.js8 web: static UI + same-origin proxy to the bridge. TLS terminates at +# your reverse proxy (NPM / Caddy); point it at this container's port 8080. +# Rendered by the nginx image entrypoint (envsubst) from GHOSTJS8_BRIDGE_UPSTREAM. + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 8080; + server_name _; + server_tokens off; + root /usr/share/nginx/html; + + # Resolve the bridge lazily so the UI still serves while it restarts. + resolver 127.0.0.11 valid=10s ipv6=off; + set $bridge http://${GHOSTJS8_BRIDGE_UPSTREAM}; + + include /etc/nginx/snippets/security-headers.conf; + # The UI only issues GETs and WebSocket upgrades; nothing large comes in. + client_max_body_size 16k; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location = /ws { + proxy_pass $bridge; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; # keep the port: the bridge compares it with Origin + proxy_set_header Origin $http_origin; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location /api/ { + proxy_pass $bridge; + proxy_set_header Host $http_host; # keep the port: the bridge compares it with Origin + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /readyz { + proxy_pass $bridge; + } + + # Many nginx mime.types lack .mjs; module workers (MapLibre) refuse + # application/octet-stream under nosniff. + location ~* \.mjs$ { + types { } + default_type text/javascript; + expires 1y; + add_header Cache-Control "public, immutable" always; + include /etc/nginx/snippets/security-headers.conf; + try_files $uri =404; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable" always; + include /etc/nginx/snippets/security-headers.conf; + try_files $uri =404; + } + + location / { + add_header Cache-Control "no-cache" always; + include /etc/nginx/snippets/security-headers.conf; + try_files $uri $uri/ /index.html; + } +} diff --git a/web/deploy/security-headers.conf b/web/deploy/security-headers.conf new file mode 100644 index 0000000..c09b6c9 --- /dev/null +++ b/web/deploy/security-headers.conf @@ -0,0 +1,8 @@ +# Shared security headers. nginx drops server-level add_header directives in +# any location that has its own add_header, so every such location includes +# this file instead of relying on inheritance. +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" always; +add_header X-Content-Type-Options nosniff always; +add_header Referrer-Policy no-referrer always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; +add_header Cross-Origin-Opener-Policy same-origin always; diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..b4f5227 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,45 @@ +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +export default ts.config( + { + ignores: [ + 'dist/', + 'node_modules/', + 'test-results/', + 'playwright-report/', + 'src/lib/protocol/generated.ts', + ], + }, + js.configs.recommended, + ...ts.configs.strictTypeChecked, + ...svelte.configs.recommended, + { + languageOptions: { + globals: { ...globals.browser }, + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ['**/*.svelte', '**/*.svelte.ts'], + languageOptions: { parserOptions: { parser: ts.parser, svelteConfig } }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'error', + }, + }, + { files: ['*.config.js', '*.config.ts', 'scripts/**/*.mjs'], ...ts.configs.disableTypeChecked }, + { + files: ['*.config.js', '*.config.ts', 'scripts/**/*.mjs'], + languageOptions: { globals: { ...globals.node } }, + }, +); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..56193c8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + ghost.js8 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..5e4d135 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3460 @@ +{ + "name": "ghostjs8-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ghostjs8-web", + "version": "0.1.0", + "license": "GPL-3.0-or-later", + "dependencies": { + "@fontsource/jetbrains-mono": "5.3.0", + "@fontsource/share-tech-mono": "5.3.0", + "@fontsource/space-grotesk": "5.3.0", + "maplibre-gl": "6.13.0" + }, + "devDependencies": { + "@axe-core/playwright": "4.13.0", + "@eslint/js": "10.0.1", + "@playwright/test": "1.64.0", + "@sveltejs/vite-plugin-svelte": "7.3.1", + "@tsconfig/svelte": "5.0.8", + "@types/node": "22.20.5", + "eslint": "10.12.0", + "eslint-plugin-svelte": "3.23.0", + "globals": "17.13.0", + "json-schema-to-typescript": "16.0.0", + "prettier": "3.9.9", + "prettier-plugin-svelte": "4.1.1", + "svelte": "5.57.2", + "svelte-check": "4.7.6", + "typescript": "6.0.3", + "typescript-eslint": "8.71.1", + "vite": "8.3.3", + "vitest": "5.0.3" + }, + "engines": { + "node": ">=22 <23" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/share-tech-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/share-tech-mono/-/share-tech-mono-5.3.0.tgz", + "integrity": "sha512-hVEzVCzEid/fBfhggMyvkfDzn9ODrMihxC0EiOeQK3BuQNvdjmith2ERnoDNUkbW8zhufbKNuCgaSoT8LUNpuQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/space-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.3.0.tgz", + "integrity": "sha512-ksnGizDPXIDuvqcTYTSrmZ+evx9sDlS8rp7+42BQ7wU+spt3twEoXfbJz672C+5CLg6VeUQwRy5RXshWb67LcQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.2.tgz", + "integrity": "sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.4.4", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz", + "integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz", + "integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/earcut": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz", + "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==", + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.23.0.tgz", + "integrity": "sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.7.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.3.tgz", + "integrity": "sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/json-schema-to-typescript": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-16.0.0.tgz", + "integrity": "sha512-Ah6kK4q6SjlMzWBH1eNOQkdRh5Qhr5T/RCFwfjQqWZA7UDW+N47A8wfyoiN0L884s4L4bAEI54IjIuN3/u0B/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.9.3", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.25", + "js-yaml": "^5.2.3", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "prettier": "^3.9.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/maplibre-gl": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.13.0.tgz", + "integrity": "sha512-ELXk4h+xl0URFQEnVbAN6MGn61P6jq4amUWHiskLVsvHCaKdf1Zq6G8UNOD0AQ3UaXoL5ECc8ovz8yqYEBGPKw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.2", + "@maplibre/maplibre-gl-style-spec": "^26.4.4", + "@maplibre/mlt": "^1.3.0", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "bidi-js": "^1.1.0", + "earcut": "^3.2.4", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.1.tgz", + "integrity": "sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.34.5" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..5bd58b1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,46 @@ +{ + "name": "ghostjs8-web", + "private": true, + "version": "0.1.0", + "license": "GPL-3.0-or-later", + "type": "module", + "engines": { + "node": ">=22 <23" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings && tsc -p tsconfig.node.json", + "lint": "eslint . && prettier --check .", + "format": "prettier --write .", + "test": "vitest run", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@axe-core/playwright": "4.13.0", + "@eslint/js": "10.0.1", + "@playwright/test": "1.64.0", + "@sveltejs/vite-plugin-svelte": "7.3.1", + "@tsconfig/svelte": "5.0.8", + "@types/node": "22.20.5", + "eslint": "10.12.0", + "eslint-plugin-svelte": "3.23.0", + "globals": "17.13.0", + "json-schema-to-typescript": "16.0.0", + "prettier": "3.9.9", + "prettier-plugin-svelte": "4.1.1", + "svelte": "5.57.2", + "svelte-check": "4.7.6", + "typescript": "6.0.3", + "typescript-eslint": "8.71.1", + "vite": "8.3.3", + "vitest": "5.0.3" + }, + "dependencies": { + "@fontsource/jetbrains-mono": "5.3.0", + "@fontsource/share-tech-mono": "5.3.0", + "@fontsource/space-grotesk": "5.3.0", + "maplibre-gl": "6.13.0" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..3e799ab --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Browser smoke tests against the simulated stack (fake KiwiSDR + fake +// decoder-agent + real bridge) and a production build served by vite preview. +// Real decoding is covered by `make acceptance`, not here. +const BRIDGE_PORT = 28073; +const executablePath = process.env['PW_CHROMIUM_PATH']; + +export default defineConfig({ + testDir: 'tests/e2e', + timeout: 60_000, + retries: 0, + reporter: [['list']], + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + ...(executablePath ? { launchOptions: { executablePath } } : {}), + }, + projects: [ + { + name: 'desktop', + use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 960 } }, + }, + { name: 'phone', use: { ...devices['Pixel 7'] } }, + ], + webServer: [ + { + command: `GHOSTNET=1 SEED_NET=1 HOME_GRID=EN34 DIRECTORY_URL=file://${process.cwd()}/../tools/fixtures/directory-sample.js KIWI_PORT=28070 AGENT_PORT=28074 BRIDGE_PORT=${String(BRIDGE_PORT)} PERIOD=3 ../tools/dev-stack.sh`, + url: `http://127.0.0.1:${String(BRIDGE_PORT)}/healthz`, + reuseExistingServer: false, + timeout: 120_000, + }, + { + command: `npm run build && GHOSTJS8_BRIDGE_URL=http://127.0.0.1:${String(BRIDGE_PORT)} npx vite preview --host 127.0.0.1 --port 4173 --strictPort`, + url: 'http://127.0.0.1:4173', + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}); diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..c14cc3a --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,19 @@ + + ghost.js8 + + + + + + + + + + + + + + + + + diff --git a/web/public/map/README.md b/web/public/map/README.md new file mode 100644 index 0000000..78b4c07 --- /dev/null +++ b/web/public/map/README.md @@ -0,0 +1,16 @@ +# Basemap data + +| File | Source | License | +| ----------------- | ------------------------------------------------------------------ | ------------- | +| `land.geojson` | Natural Earth 1:110m `ne_110m_land.geojson` | Public domain | +| `borders.geojson` | Natural Earth 1:110m `ne_110m_admin_0_boundary_lines_land.geojson` | Public domain | + +Fetched from [`nvkelso/natural-earth-vector`](https://github.com/nvkelso/natural-earth-vector) +at commit `ca96624a56bd078437bca8184e78163e5039ad19` (2026-10-08). Upstream SHA-256: +land `9e0729ee253ca7d7a5c4ae9395fb1902264c5377c52e224d13dd85010e2835d9`, +borders `d42479fd79552cca4eec7f85fcdca717a790d29ff06be7676f1af0568c6d3f7c`. + +Minified for ghost.js8: feature properties dropped, coordinates rounded to +0.01°. Served from the `web` container, so map views make no third-party +requests. A self-hosted Protomaps PMTiles basemap is the documented upgrade +path if more detail is ever needed. diff --git a/web/public/map/borders.geojson b/web/public/map/borders.geojson new file mode 100644 index 0000000..a693cc9 --- /dev/null +++ b/web/public/map/borders.geojson @@ -0,0 +1,6100 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-130.54, 54.8], + [-129.98, 55.28], + [-130.01, 55.92], + [-131.71, 56.55], + [-132.73, 57.69], + [-133.36, 58.41], + [-134.27, 58.86], + [-134.94, 59.27], + [-135.48, 59.79], + [-136.48, 59.46], + [-137.45, 58.91], + [-138.34, 59.56], + [-139.04, 60], + [-140.01, 60.28], + [-141.0, 60.31], + [-140.99, 66.0], + [-140.99, 69.71] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-117.13, 32.54], + [-115.99, 32.61], + [-114.72, 32.72], + [-114.81, 32.53], + [-113.3, 32.04], + [-111.02, 31.33], + [-109.04, 31.34], + [-108.24, 31.34], + [-108.24, 31.75], + [-106.51, 31.75], + [-106.14, 31.4], + [-105.63, 31.08], + [-105.04, 30.64], + [-104.71, 30.12], + [-104.46, 29.57], + [-103.94, 29.27], + [-103.11, 28.97], + [-102.48, 29.76], + [-101.66, 29.78], + [-100.96, 29.38], + [-100.46, 28.7], + [-100.11, 28.11], + [-99.52, 27.54], + [-99.3, 26.84], + [-99.02, 26.37], + [-98.24, 26.06], + [-97.53, 25.84], + [-97.14, 25.87] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-90.1, 13.74], + [-90.06, 13.88], + [-89.72, 14.13], + [-89.53, 14.24], + [-89.59, 14.36], + [-89.35, 14.42] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-92.23, 14.54], + [-92.2, 14.83], + [-92.09, 15.06], + [-92.23, 15.25], + [-91.75, 16.07], + [-90.46, 16.07], + [-90.44, 16.41], + [-90.6, 16.47], + [-90.71, 16.69], + [-91.08, 16.92], + [-91.45, 17.25], + [-91.0, 17.25], + [-91.0, 17.82], + [-90.07, 17.82], + [-89.14, 17.81] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-88.93, 15.89], + [-89.23, 15.89], + [-89.15, 17.02], + [-89.14, 17.81] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-89.35, 14.42], + [-89.06, 14.34], + [-88.84, 14.14], + [-88.54, 13.98], + [-88.5, 13.85], + [-88.07, 13.96], + [-87.86, 13.89], + [-87.72, 13.79], + [-87.79, 13.38] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-87.32, 12.98], + [-87.01, 13.03], + [-86.88, 13.25], + [-86.73, 13.26], + [-86.76, 13.75], + [-86.52, 13.78], + [-86.31, 13.77], + [-86.1, 14.04], + [-85.8, 13.84], + [-85.7, 13.96], + [-85.51, 14.08], + [-85.17, 14.35], + [-85.15, 14.56], + [-85.05, 14.55], + [-84.92, 14.79], + [-84.82, 14.82], + [-84.65, 14.67], + [-84.45, 14.62], + [-84.23, 14.75], + [-83.98, 14.75], + [-83.63, 14.88], + [-83.49, 15.02], + [-83.15, 15.0] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-82.97, 8.23], + [-82.91, 8.42], + [-82.83, 8.63], + [-82.87, 8.81], + [-82.72, 8.93], + [-82.93, 9.07], + [-82.93, 9.48], + [-82.55, 9.57] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-83.66, 10.94], + [-83.9, 10.73], + [-84.19, 10.79], + [-84.36, 11.0], + [-84.67, 11.08], + [-84.9, 10.95], + [-85.56, 11.22], + [-85.71, 11.09] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-77.35, 8.67], + [-77.47, 8.52], + [-77.24, 7.94], + [-77.43, 7.64], + [-77.75, 7.71], + [-77.88, 7.22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-71.33, 11.78], + [-71.97, 11.61], + [-72.23, 11.11], + [-72.61, 10.82], + [-72.91, 10.45], + [-73.03, 9.74], + [-73.3, 9.15], + [-72.79, 9.09], + [-72.66, 8.63], + [-72.44, 8.41], + [-72.36, 8.0], + [-72.48, 7.63], + [-72.44, 7.42], + [-72.2, 7.34], + [-71.96, 6.99], + [-70.67, 7.09], + [-70.09, 6.96], + [-69.39, 6.1], + [-68.99, 6.21], + [-68.27, 6.15], + [-67.7, 6.27], + [-67.34, 6.1], + [-67.52, 5.56], + [-67.74, 5.22], + [-67.82, 4.5], + [-67.62, 3.84], + [-67.34, 3.54], + [-67.3, 3.32], + [-67.81, 2.82], + [-67.45, 2.6], + [-67.18, 2.25], + [-66.88, 1.25], + [-66.88, 1.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-75.37, -0.15], + [-75.23, -0.91], + [-75.55, -1.56], + [-76.64, -2.61], + [-77.84, -3.0], + [-78.45, -3.87], + [-78.64, -4.55], + [-79.21, -4.96], + [-79.62, -4.45], + [-80.03, -4.35], + [-80.44, -4.43], + [-80.47, -4.06], + [-80.18, -3.82], + [-80.3, -3.4] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-69.53, -10.95], + [-70.09, -11.12], + [-70.55, -11.01], + [-70.48, -9.49], + [-71.3, -10.08], + [-72.18, -10.05], + [-72.56, -9.52], + [-73.23, -9.46], + [-73.02, -9.03], + [-73.57, -8.42], + [-73.99, -7.52], + [-73.72, -7.34], + [-73.72, -6.92], + [-73.12, -6.63], + [-73.22, -6.09], + [-72.96, -5.74], + [-72.89, -5.27], + [-71.75, -4.59], + [-70.93, -4.4], + [-70.79, -4.25], + [-69.89, -4.3] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-66.96, -54.9], + [-67.56, -54.87], + [-68.63, -54.87], + [-68.63, -52.64] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-68.63, -52.3], + [-69.5, -52.14], + [-71.91, -52.01], + [-72.33, -51.43], + [-72.31, -50.68], + [-72.98, -50.74], + [-73.33, -50.38], + [-73.42, -49.32], + [-72.65, -48.88], + [-72.33, -48.24], + [-72.45, -47.74], + [-71.92, -46.88], + [-71.55, -45.56], + [-71.66, -44.97], + [-71.22, -44.78], + [-71.33, -44.41], + [-71.79, -44.21], + [-71.46, -43.79], + [-71.92, -43.41], + [-72.15, -42.25], + [-71.75, -42.05], + [-71.92, -40.83], + [-71.68, -39.81], + [-71.41, -38.92], + [-70.81, -38.55], + [-71.12, -37.58], + [-71.12, -36.66], + [-70.36, -36.01], + [-70.39, -35.17], + [-69.82, -34.19], + [-69.81, -33.27], + [-70.07, -33.09], + [-70.54, -31.36], + [-69.92, -30.34], + [-70.01, -29.37], + [-69.66, -28.46], + [-69.0, -27.52], + [-68.3, -26.9], + [-68.59, -26.51], + [-68.39, -26.18], + [-68.42, -24.52], + [-67.33, -24.03], + [-66.99, -22.99], + [-67.11, -22.74] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-58.43, -33.91], + [-58.35, -33.26], + [-58.13, -33.04], + [-58.14, -32.04], + [-57.87, -31.02], + [-57.63, -30.22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-54.63, -25.74], + [-54.79, -26.62], + [-55.7, -27.39], + [-56.49, -27.55], + [-57.61, -27.4], + [-58.62, -27.12], + [-57.63, -25.6], + [-57.78, -25.16], + [-58.81, -24.77], + [-60.03, -24.03], + [-60.85, -23.88], + [-62.69, -22.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-58.17, -20.18], + [-58.17, -20.18], + [-57.87, -20.73], + [-57.94, -22.09], + [-56.88, -22.28], + [-56.47, -22.09], + [-55.8, -22.36], + [-55.61, -22.66], + [-55.52, -23.57], + [-55.4, -23.96], + [-55.03, -24.0], + [-54.65, -23.84], + [-54.29, -24.02], + [-54.29, -24.57], + [-54.43, -25.16], + [-54.63, -25.74] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-59.76, 8.37], + [-60.55, 7.78], + [-60.64, 7.42], + [-60.3, 7.04], + [-60.54, 6.86], + [-61.16, 6.7], + [-61.14, 6.23], + [-61.41, 5.96], + [-60.73, 5.2] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-56.54, 1.9], + [-56.78, 1.86], + [-57.34, 1.95], + [-57.66, 1.68], + [-58.11, 1.51], + [-58.43, 1.46], + [-58.54, 1.27], + [-59.03, 1.32], + [-59.65, 1.79], + [-59.72, 2.25], + [-59.97, 2.76], + [-59.82, 3.61], + [-59.54, 3.96], + [-59.77, 4.42], + [-60.11, 4.57], + [-59.98, 5.01], + [-60.21, 5.24], + [-60.73, 5.2] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-54.52, 2.31], + [-55.1, 2.52], + [-55.57, 2.42], + [-55.97, 2.51], + [-56.07, 2.22], + [-55.91, 2.02], + [-56.0, 1.82], + [-56.54, 1.9] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-51.66, 4.16], + [-52.25, 3.24], + [-52.56, 2.5], + [-52.94, 2.12], + [-53.42, 2.05], + [-53.55, 2.33], + [-53.78, 2.38], + [-54.09, 2.11], + [-54.52, 2.31] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [115.45, 5.45], + [115.41, 4.96], + [115.35, 4.32], + [114.87, 4.35], + [114.66, 4.01], + [114.2, 4.53] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-6.2, 53.87], + [-6.95, 54.07], + [-7.57, 54.06], + [-7.37, 54.6], + [-7.57, 55.13] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-16.71, 13.59], + [-15.62, 13.62], + [-15.4, 13.86], + [-15.08, 13.88], + [-14.69, 13.63], + [-14.38, 13.63], + [-14.05, 13.79], + [-13.84, 13.51], + [-14.28, 13.28], + [-14.71, 13.3], + [-15.14, 13.51], + [-15.51, 13.28], + [-15.69, 13.27], + [-15.93, 13.13], + [-16.84, 13.15] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.55, 32.39], + [35.55, 31.78], + [35.4, 31.49] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [28.98, -28.96], + [29.33, -29.26], + [29.02, -29.74], + [28.85, -30.07], + [28.29, -30.23], + [28.11, -30.55], + [27.75, -30.65], + [27.0, -29.88], + [27.53, -29.24], + [28.07, -28.85], + [28.54, -28.65], + [28.98, -28.96] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-8.67, 27.66], + [-8.67, 27.59], + [-8.68, 27.4], + [-8.69, 25.88], + [-11.97, 25.93], + [-11.94, 23.37], + [-12.87, 23.28], + [-13.12, 22.77], + [-12.93, 21.33], + [-16.85, 21.33], + [-17.06, 21.0] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-8.68, 27.4], + [-4.92, 24.97], + [-4.92, 24.97] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-12.17, 14.62], + [-12.12, 13.99], + [-11.93, 13.42], + [-11.55, 13.14], + [-11.47, 12.75], + [-11.51, 12.44] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-13.7, 12.59], + [-13.72, 12.25], + [-13.83, 12.14], + [-13.74, 11.81], + [-13.9, 11.68], + [-14.12, 11.68], + [-14.38, 11.51], + [-14.69, 11.53], + [-15.13, 11.04] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-11.51, 12.44], + [-11.46, 12.08], + [-11.3, 12.08], + [-11.04, 12.21], + [-10.87, 12.18], + [-10.59, 11.92], + [-10.17, 11.84], + [-9.89, 12.06], + [-9.57, 12.19], + [-9.33, 12.33], + [-9.13, 12.31], + [-8.91, 12.09], + [-8.79, 11.81], + [-8.38, 11.39], + [-8.58, 11.14], + [-8.62, 10.81], + [-8.41, 10.91], + [-8.28, 10.79], + [-8.34, 10.49], + [-8.03, 10.21], + [-8.03, 10.21] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-11.44, 6.79], + [-11.2, 7.11], + [-11.15, 7.4], + [-10.7, 7.94], + [-10.23, 8.41] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-8.44, 7.69], + [-8.49, 7.4], + [-8.39, 6.91], + [-8.6, 6.47], + [-8.31, 6.19], + [-7.99, 6.13], + [-7.57, 5.71], + [-7.54, 5.31], + [-7.64, 5.19], + [-7.71, 4.36] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [8.42, 36.95], + [8.22, 36.43], + [8.38, 35.48], + [8.14, 34.66], + [7.52, 34.1], + [7.61, 33.34], + [8.43, 32.75], + [8.44, 32.51], + [9.06, 32.1], + [9.48, 30.31], + [9.48, 30.31] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [11.49, 33.14], + [11.43, 32.37], + [10.94, 32.08], + [10.64, 31.76], + [9.95, 31.38], + [10.06, 30.96], + [9.97, 30.54], + [9.48, 30.31] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [25.16, 31.57], + [24.8, 31.09], + [24.96, 30.66], + [24.7, 30.04], + [25, 29.24], + [25, 25.68], + [25, 22], + [25, 22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [4.27, 19.16], + [4.27, 16.85], + [3.72, 16.18], + [3.64, 15.57], + [2.75, 15.41], + [1.39, 15.32], + [1.02, 14.97], + [0.37, 14.93], + [0.37, 14.93] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [0.37, 14.93], + [0.3, 14.44], + [0.43, 13.99], + [0.99, 13.34], + [1.02, 12.85], + [2.18, 12.63], + [2.15, 11.94], + [2.15, 11.94] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-2.86, 4.99], + [-2.81, 5.39], + [-3.24, 6.25], + [-2.98, 7.38], + [-2.56, 8.22], + [-2.83, 9.64] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [1.06, 5.93], + [0.84, 6.28], + [0.57, 6.91], + [0.49, 7.41], + [0.71, 8.31], + [0.46, 8.68], + [0.37, 9.46], + [0.37, 10.19], + [-0.05, 10.71], + [0.02, 11.02] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [0.9, 11.0], + [0.77, 10.47], + [1.08, 10.18], + [1.43, 9.83], + [1.46, 9.33], + [1.66, 9.13], + [1.62, 6.83], + [1.87, 6.14] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [2.15, 11.94], + [2.49, 12.23], + [2.85, 12.24], + [3.61, 11.66], + [3.61, 11.66] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [3.61, 11.66], + [3.68, 12.55], + [3.97, 12.96], + [4.11, 13.53], + [4.37, 13.75], + [5.44, 13.87], + [6.45, 13.49], + [6.82, 13.12], + [7.33, 13.1], + [7.8, 13.34], + [9.01, 12.83], + [9.52, 12.85], + [10.11, 13.28], + [10.7, 13.25], + [10.99, 13.39], + [11.53, 13.33], + [12.3, 13.04], + [13.08, 13.6], + [13.32, 13.56] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [36.87, 22], + [32.9, 22], + [29.02, 22], + [25, 22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [14.85, 22.86], + [15.1, 21.31], + [15.47, 21.05], + [15.49, 20.73], + [15.9, 20.39], + [15.69, 19.96], + [15.3, 17.93], + [15.25, 16.63], + [13.97, 15.68], + [13.54, 14.37], + [13.96, 14.0], + [13.95, 13.35], + [14.6, 13.33], + [14.5, 12.86], + [14.5, 12.86] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [23.84, 19.58], + [23.89, 15.61], + [23.02, 15.68], + [22.57, 14.94], + [22.3, 14.33], + [22.51, 14.09], + [22.18, 13.79], + [22.3, 13.37], + [22.04, 12.96], + [21.94, 12.59], + [22.29, 12.65], + [22.5, 12.26], + [22.51, 11.68], + [22.88, 11.38], + [22.86, 11.14] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [15.28, 7.42], + [14.78, 6.41], + [14.54, 6.23], + [14.46, 5.45], + [14.56, 5.03], + [14.48, 4.73], + [14.95, 4.21], + [15.04, 3.85], + [15.41, 3.34], + [15.86, 3.01], + [15.91, 2.56], + [16.01, 2.27], + [16.01, 2.27] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [11.28, 2.26], + [11.29, 1.06], + [9.83, 1.07], + [9.49, 1.01] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [13.08, 2.27], + [13.0, 1.83], + [13.28, 1.31], + [14.03, 1.4], + [14.28, 1.2], + [13.84, 0.04], + [14.32, -0.55], + [14.43, -1.33], + [14.3, -2.0], + [13.99, -2.47], + [13.11, -2.43], + [12.58, -1.95], + [12.5, -2.39], + [11.82, -2.51], + [11.48, -2.77], + [11.86, -3.43], + [11.09, -3.98] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [16.01, 2.27], + [16.54, 3.2], + [17.13, 3.73], + [17.81, 3.56], + [18.45, 3.5] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.86, 11.14], + [22.98, 10.71], + [23.55, 10.09], + [23.56, 9.68], + [23.39, 9.27], + [23.46, 8.95], + [23.81, 8.67], + [23.81, 8.67] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [36.43, 14.42], + [37.59, 14.21], + [37.91, 14.96], + [38.51, 14.51], + [39.1, 14.74], + [39.34, 14.53], + [40.03, 14.52], + [40.9, 14.12], + [41.16, 13.77], + [41.6, 13.45], + [42.01, 12.87], + [42.35, 12.54], + [42.35, 12.54] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [43.15, 11.46], + [42.78, 10.93], + [42.78, 10.93] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [29.58, -1.34], + [29.82, -1.44], + [30.42, -1.13], + [30.42, -1.13] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.42, -1.13], + [30.82, -1.7], + [30.76, -2.29], + [30.47, -2.41] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [23.91, -10.93], + [24.02, -11.24], + [23.9, -11.72], + [24.08, -12.19], + [23.93, -12.57], + [24.02, -12.91], + [21.93, -12.9], + [21.89, -16.08], + [22.56, -16.9], + [23.22, -17.52] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [23.22, -17.52], + [24.03, -17.3], + [24.68, -17.35], + [25.08, -17.58], + [25.08, -17.66], + [24.93, -17.72] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [32.07, -26.73], + [31.87, -27.18], + [31.28, -27.29], + [30.69, -26.74], + [30.68, -26.4], + [30.95, -26.02], + [31.04, -25.73], + [31.33, -25.66], + [31.84, -25.84] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [25.26, -17.74], + [25.65, -18.54], + [25.85, -18.71], + [26.16, -19.29], + [27.3, -20.39], + [27.72, -20.5], + [27.73, -20.85], + [28.02, -21.49], + [28.79, -21.64], + [29.43, -22.09], + [29.43, -22.09] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.27, -15.51], + [30.34, -15.88], + [31.17, -15.86], + [31.64, -16.07], + [31.85, -16.32], + [32.33, -16.39], + [32.85, -16.71], + [32.85, -17.98], + [32.65, -18.67], + [32.61, -19.42], + [32.77, -19.72], + [32.66, -20.3], + [32.51, -20.4], + [32.25, -21.12], + [31.19, -22.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [32.83, -26.74], + [32.07, -26.73] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-9.03, 41.88], + [-8.67, 42.13], + [-8.26, 42.28], + [-8.01, 41.79], + [-7.42, 41.79], + [-7.25, 41.92], + [-6.67, 41.88], + [-6.39, 41.38], + [-6.85, 41.11], + [-6.86, 40.33], + [-7.03, 40.18], + [-7.07, 39.71], + [-7.5, 39.63], + [-7.1, 39.03], + [-7.37, 38.37], + [-7.03, 38.08], + [-7.17, 37.8], + [-7.54, 37.43], + [-7.45, 37.1] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [2.99, 42.47], + [1.83, 42.34], + [0.7, 42.8], + [0.34, 42.58], + [-1.5, 43.03], + [-1.9, 43.42] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [9.92, 54.98], + [9.28, 54.83], + [8.53, 54.96] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [6.19, 49.46], + [6.66, 49.2], + [8.1, 49.02], + [7.59, 48.33], + [7.47, 47.62], + [7.47, 47.62] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [14.12, 53.76], + [14.35, 53.25], + [14.07, 52.98], + [14.44, 52.62], + [14.69, 52.09], + [14.61, 51.75], + [15.02, 51.11], + [15.02, 51.11] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [9.59, 47.53], + [9.63, 47.35], + [9.48, 47.1], + [9.93, 46.92], + [10.44, 46.89] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [10.44, 46.89], + [11.05, 46.75], + [11.16, 46.94], + [12.15, 47.12], + [12.38, 46.77], + [13.81, 46.51] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [19.66, 54.43], + [20.89, 54.31], + [22.73, 54.33], + [23.24, 54.22], + [23.48, 53.91], + [23.53, 53.47], + [23.8, 53.09], + [23.8, 52.69], + [23.2, 52.49], + [23.51, 52.02], + [23.53, 51.58], + [24.03, 50.71], + [23.92, 50.42], + [23.43, 50.31], + [22.52, 49.48], + [22.78, 49.03], + [22.56, 49.09], + [21.61, 49.47], + [20.89, 49.33], + [20.42, 49.43], + [19.83, 49.22], + [19.32, 49.57], + [18.91, 49.44], + [18.85, 49.5], + [18.39, 49.99], + [17.65, 50.05], + [17.55, 50.36], + [16.87, 50.47], + [16.72, 50.22], + [16.18, 50.42], + [16.24, 50.7], + [15.49, 50.78], + [15.02, 51.11] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [24.31, 57.79], + [25.16, 57.97], + [25.6, 57.85], + [26.46, 57.48], + [27.29, 57.47], + [27.29, 57.47] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [27.98, 59.48], + [28.13, 59.3], + [27.42, 58.72], + [27.72, 57.79], + [27.29, 57.47] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [28.18, 56.17], + [29.23, 55.92], + [29.37, 55.67], + [29.9, 55.79], + [30.87, 55.55], + [30.97, 55.08], + [30.76, 54.81], + [31.38, 54.16], + [31.79, 53.97], + [31.73, 53.79], + [32.41, 53.62], + [32.69, 53.35], + [32.3, 53.13], + [31.5, 53.17], + [31.31, 53.07], + [31.54, 52.74], + [31.79, 52.1], + [31.79, 52.1] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [6.91, 53.48], + [7.09, 53.14], + [6.84, 52.23], + [6.59, 51.85], + [5.99, 51.85], + [6.16, 50.8] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [6.16, 50.8], + [6.04, 50.13] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [6.04, 50.13], + [6.24, 49.9], + [6.19, 49.46], + [6.19, 49.46] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [21.27, 55.19], + [22.32, 55.02], + [22.76, 54.86], + [22.65, 54.58], + [22.73, 54.33] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [21.06, 56.03], + [22.2, 56.34], + [23.88, 56.27], + [24.86, 56.37], + [25.0, 56.16], + [25.53, 56.1], + [26.49, 55.62] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [18.85, 49.5], + [18.55, 49.49], + [18.4, 49.31], + [18.17, 49.27], + [18.11, 49.04], + [17.91, 49.0], + [17.89, 48.9], + [17.55, 48.8], + [17.1, 48.82], + [16.96, 48.6] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [16.96, 48.6], + [16.88, 48.47], + [16.98, 48.12] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.09, 48.42], + [22.64, 48.15], + [22.71, 47.88], + [22.1, 47.67], + [21.63, 46.99], + [21.02, 46.32], + [20.22, 46.13], + [19.6, 46.17], + [18.83, 45.91], + [18.46, 45.76], + [17.63, 45.95], + [16.88, 46.38], + [16.56, 46.5], + [16.37, 46.84], + [16.2, 46.85] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [18.45, 42.48], + [18.56, 42.65], + [17.67, 43.03], + [17.3, 43.45], + [16.92, 43.67], + [16.46, 44.04], + [16.24, 44.35], + [15.75, 44.82], + [15.96, 45.23], + [16.32, 45.0], + [16.53, 45.21], + [17.0, 45.23], + [17.86, 45.07], + [18.55, 45.08], + [19.01, 44.86] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.56, 49.09], + [22.28, 48.83], + [22.09, 48.42], + [21.87, 48.32], + [20.8, 48.62], + [20.47, 48.56], + [20.24, 48.33], + [19.77, 48.2], + [19.66, 48.27], + [19.17, 48.11], + [18.78, 48.08], + [18.7, 47.88], + [17.86, 47.76], + [17.49, 47.87], + [16.98, 48.12] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [16.56, 46.5], + [15.77, 46.24], + [15.67, 45.83], + [15.32, 45.73], + [15.33, 45.45], + [14.94, 45.47], + [14.6, 45.63], + [14.41, 45.47], + [13.72, 45.5] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [26.62, 48.22], + [26.92, 48.12], + [27.23, 47.83], + [27.55, 47.41], + [28.13, 46.81], + [28.16, 46.37], + [28.05, 45.94], + [28.23, 45.49] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [28.56, 43.71], + [27.97, 43.81], + [27.24, 44.18], + [26.07, 43.94], + [25.57, 43.69], + [24.1, 43.74], + [23.33, 43.9], + [22.94, 43.82], + [22.66, 44.23], + [22.66, 44.23] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.66, 44.23], + [22.41, 44.01], + [22.5, 43.64], + [22.99, 43.21], + [22.6, 42.9], + [22.44, 42.58], + [22.54, 42.46], + [22.38, 42.32], + [22.36, 42.32] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.71, 47.88], + [23.14, 48.1], + [23.76, 47.99], + [24.4, 47.98], + [24.87, 47.74], + [25.21, 47.89], + [25.95, 47.99], + [26.2, 48.22], + [26.62, 48.22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [31.79, 52.1], + [32.16, 52.06], + [32.41, 52.29], + [32.72, 52.24], + [33.75, 52.34], + [34.39, 51.77], + [34.14, 51.57], + [34.22, 51.26], + [35.02, 51.21], + [35.38, 50.77], + [35.36, 50.58], + [36.63, 50.23], + [37.39, 50.38], + [38.01, 49.92], + [38.6, 49.93], + [40.07, 49.6], + [40.08, 49.31], + [39.67, 48.78], + [39.9, 48.23], + [39.74, 47.9], + [38.77, 47.83], + [38.26, 47.55], + [38.22, 47.1] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [28.0, 42.01], + [27.14, 42.14], + [26.12, 41.83], + [26.11, 41.33], + [25.2, 41.23], + [24.49, 41.58], + [23.69, 41.31], + [22.95, 41.34], + [22.88, 42.0], + [22.38, 42.32] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.95, 41.34], + [22.76, 41.3], + [22.6, 41.13], + [22.06, 41.15], + [21.67, 40.93], + [21.02, 40.84] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [26.12, 41.83], + [26.6, 41.56], + [26.29, 40.94], + [26.06, 40.82] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [21.02, 40.84], + [21, 40.58], + [20.68, 40.44], + [20.61, 40.11], + [20.15, 39.62] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [41.55, 41.54], + [42.62, 41.58], + [43.58, 41.09] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [39.95, 43.44], + [40.08, 43.55], + [40.92, 43.38], + [42.39, 43.22], + [43.76, 42.74], + [43.93, 42.55], + [44.54, 42.71], + [45.47, 42.5], + [45.78, 42.09], + [46.4, 41.86], + [46.15, 41.72], + [46.64, 41.18], + [46.5, 41.06], + [45.96, 41.12], + [45.22, 41.41], + [44.97, 41.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [28.59, 69.06], + [28.45, 68.36], + [29.98, 67.7], + [29.05, 66.94], + [30.22, 65.81], + [29.54, 64.95], + [30.44, 64.2], + [30.04, 63.55], + [31.52, 62.87], + [31.14, 62.36], + [30.21, 61.78], + [28.07, 60.5] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.4, 31.48], + [35.42, 31.1], + [34.92, 29.5] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [38.79, 33.38], + [39.2, 32.16], + [39.2, 32.16] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [47.97, 29.98], + [47.3, 30.06], + [46.57, 29.1], + [46.57, 29.1] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [48.57, 29.93], + [48.01, 30.45], + [48.0, 30.99], + [47.69, 30.98], + [47.85, 31.71], + [47.33, 32.47], + [46.11, 33.02], + [45.42, 33.97], + [45.65, 34.75], + [46.15, 35.09], + [46.08, 35.68], + [45.42, 35.98], + [44.77, 37.17] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [53.92, 37.2], + [54.8, 37.39], + [55.51, 37.96], + [56.18, 37.94], + [56.62, 38.12], + [57.33, 38.03], + [58.44, 37.52], + [59.23, 37.41], + [60.38, 36.53], + [61.12, 36.49], + [61.21, 35.65] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [53.11, 16.65], + [52.78, 17.35], + [52, 19], + [52, 19] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [42.78, 16.35], + [43.22, 16.67], + [43.12, 17.09], + [43.38, 17.58], + [43.79, 17.32], + [44.06, 17.41], + [45.22, 17.43], + [45.4, 17.33], + [46.37, 17.23], + [46.75, 17.28], + [47, 16.95], + [47.47, 17.12], + [48.18, 18.17], + [49.12, 18.62], + [52, 19] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [20.65, 69.11], + [21.98, 68.62], + [23.54, 67.94], + [23.57, 66.4], + [23.9, 66.01] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [48.42, 28.55], + [47.71, 28.53], + [47.46, 29.0], + [46.57, 29.1] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [51.39, 24.63], + [51.11, 24.56], + [50.81, 24.75] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [56.07, 26.06], + [56.26, 25.71] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [56.4, 24.92], + [55.89, 24.92], + [55.8, 24.27], + [55.98, 24.13], + [55.53, 23.93], + [55.53, 23.52], + [55.23, 23.11], + [55.21, 22.71], + [55.21, 22.71] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [49.1, 46.4], + [48.59, 46.56], + [48.69, 47.08], + [48.06, 47.74], + [47.32, 47.72], + [46.47, 48.39], + [47.04, 49.15], + [46.75, 49.36], + [47.55, 50.45], + [48.58, 49.87], + [48.7, 50.61], + [50.77, 51.69], + [52.33, 51.72], + [54.53, 51.03], + [55.72, 50.62], + [56.78, 51.04], + [58.36, 51.06], + [59.64, 50.55], + [59.93, 50.84], + [61.34, 50.8], + [61.59, 51.27], + [59.97, 51.96], + [60.93, 52.45], + [60.74, 52.72], + [61.7, 52.98], + [60.98, 53.67], + [61.44, 54.01], + [65.18, 54.35], + [65.67, 54.6], + [68.17, 54.97], + [69.07, 55.39], + [70.87, 55.17], + [71.18, 54.13], + [72.22, 54.38], + [73.51, 54.04], + [73.43, 53.49], + [74.38, 53.55], + [76.89, 54.49], + [76.53, 54.18], + [77.8, 53.4], + [80.04, 50.86], + [80.57, 51.39], + [81.95, 50.81], + [83.38, 51.07], + [83.94, 50.89], + [84.42, 50.31], + [85.12, 50.12], + [85.54, 49.69], + [86.83, 49.83], + [87.36, 49.22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [70.96, 42.27], + [71.26, 42.17], + [70.42, 41.52], + [71.16, 41.14], + [71.87, 41.39], + [73.06, 40.87], + [71.77, 40.15], + [71.01, 40.24], + [70.6, 40.22], + [70.46, 40.5], + [70.67, 40.96], + [69.33, 40.73], + [69.01, 40.09], + [68.54, 39.53], + [67.7, 39.58], + [67.44, 39.14], + [68.18, 38.9], + [68.39, 38.16], + [67.83, 37.15], + [67.83, 37.15] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [66.52, 37.36], + [66.22, 37.39], + [65.75, 37.66], + [65.59, 37.31], + [64.75, 37.11], + [64.55, 36.31], + [63.98, 36.01], + [63.19, 35.86], + [62.98, 35.4], + [62.23, 35.27], + [61.21, 35.65] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [67.83, 37.15], + [68.14, 37.02], + [68.86, 37.34], + [69.2, 37.15], + [69.52, 37.61], + [70.12, 37.59], + [70.27, 37.74], + [70.38, 38.14], + [70.81, 38.49], + [71.35, 38.26], + [71.24, 37.95], + [71.54, 37.91], + [71.45, 37.07], + [71.84, 36.74], + [72.19, 36.95], + [72.64, 37.05], + [73.26, 37.5], + [73.95, 37.42], + [74.98, 37.42] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [80.26, 42.35], + [80.12, 42.12], + [78.54, 41.58], + [78.19, 41.19], + [76.9, 41.07], + [76.53, 40.43], + [75.47, 40.56], + [74.78, 40.37], + [73.82, 39.89], + [73.96, 39.66], + [73.68, 39.43] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [73.68, 39.43], + [73.93, 38.51], + [74.26, 38.61], + [74.86, 38.38], + [74.83, 37.99], + [74.98, 37.42] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [74.45, 32.76], + [75.26, 32.27], + [75.26, 32.27] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [97.33, 28.26], + [97.4, 27.88], + [97.05, 27.7], + [97.13, 27.08], + [96.42, 27.26], + [95.12, 26.57], + [95.16, 26.0], + [94.6, 25.16], + [94.55, 24.68], + [94.11, 23.85], + [93.33, 24.08], + [93.29, 23.04], + [93.06, 22.7], + [93.17, 22.28], + [92.67, 22.04] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [88.12, 27.88], + [86.95, 27.97], + [85.82, 28.2], + [85.01, 28.64], + [84.23, 28.84], + [83.9, 29.32], + [83.34, 29.46], + [82.33, 30.12], + [81.53, 30.42], + [81.11, 30.18] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [88.81, 27.3], + [89.48, 28.04], + [90.02, 28.3], + [90.73, 28.06], + [91.26, 28.04], + [91.7, 27.77] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [92.37, 20.67], + [92.3, 21.48], + [92.65, 21.32], + [92.67, 22.04] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [97.33, 28.26], + [97.91, 28.34], + [98.25, 27.75], + [98.68, 27.51], + [98.71, 26.74], + [98.67, 25.92], + [97.72, 25.08], + [97.6, 23.9], + [98.66, 24.06], + [98.9, 23.14], + [99.53, 22.95], + [99.24, 22.12], + [99.98, 21.74], + [100.42, 21.56], + [101.15, 21.85], + [101.18, 21.44], + [101.18, 21.44] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [100.12, 20.42], + [100.55, 20.11], + [100.61, 19.51], + [101.28, 19.46], + [101.04, 18.41], + [101.06, 17.51], + [102.11, 18.11], + [102.41, 17.93], + [103.0, 17.96], + [103.2, 18.31], + [103.96, 18.24], + [104.72, 17.43], + [104.78, 16.44], + [105.59, 15.57], + [105.54, 14.72], + [105.22, 14.27], + [105.22, 14.27], + [104.28, 14.42], + [102.99, 14.23], + [102.35, 13.39], + [102.58, 12.19] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [101.18, 21.44], + [101.27, 21.2], + [101.8, 21.17], + [101.65, 22.32], + [102.17, 22.46] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [107.38, 14.2], + [107.61, 13.54], + [107.49, 12.34], + [105.81, 11.57], + [106.25, 10.96], + [105.2, 10.89], + [104.33, 10.49] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [102.17, 22.46], + [102.71, 22.71], + [103.5, 22.7], + [104.48, 22.82], + [105.33, 23.35], + [105.81, 22.98], + [106.73, 22.79], + [106.57, 22.22], + [107.04, 21.81], + [108.05, 21.55] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [102.14, 6.22], + [101.81, 5.81], + [101.15, 5.69], + [101.08, 6.2], + [100.26, 6.64], + [100.09, 6.46] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [117.88, 4.14], + [117.02, 4.31], + [115.87, 4.31], + [115.52, 3.17], + [115.13, 2.82], + [114.62, 1.43], + [113.81, 1.22], + [112.86, 1.5], + [112.38, 1.41], + [111.8, 0.9], + [111.16, 0.98], + [110.51, 0.77], + [109.83, 1.34], + [109.66, 2.01] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [87.75, 49.3], + [88.81, 49.47], + [90.71, 50.33], + [92.23, 50.8], + [93.1, 50.5], + [94.15, 50.48], + [94.82, 50.01], + [95.81, 49.98], + [97.26, 49.73], + [98.23, 50.42], + [97.83, 51.01], + [98.86, 52.05], + [99.98, 51.63], + [100.89, 51.52], + [102.07, 51.26], + [102.26, 50.51], + [103.68, 50.09], + [104.62, 50.28], + [105.89, 50.41], + [106.89, 50.27], + [107.87, 49.79], + [108.48, 49.28], + [109.4, 49.29], + [110.66, 49.13], + [111.58, 49.38], + [112.9, 49.54], + [114.36, 50.25], + [114.96, 50.14], + [115.49, 49.81], + [116.68, 49.89] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [124.27, 39.93], + [125.08, 40.57], + [126.18, 41.11], + [126.87, 41.82], + [127.34, 41.5], + [128.21, 41.47], + [128.05, 41.99], + [129.6, 42.42], + [129.99, 42.99], + [130.64, 42.4] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [128.35, 38.61], + [128.21, 38.37], + [127.78, 38.3], + [127.07, 38.26], + [126.68, 37.8], + [126.24, 37.84], + [126.17, 37.75] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [124.97, -8.89], + [125.07, -9.09], + [125.09, -9.39] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [141.0, -2.6], + [141.02, -5.86], + [141.03, -9.12] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [11.91, -5.04], + [12.32, -4.61], + [12.62, -4.44], + [13.0, -4.78] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [19.01, 44.86], + [19.37, 44.86], + [19.12, 44.42], + [19.6, 44.04], + [19.45, 43.57], + [19.22, 43.52] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [48.58, 41.81], + [47.99, 41.41], + [47.82, 41.15], + [47.37, 41.22], + [46.69, 41.83], + [46.4, 41.86] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [42.35, 37.23], + [41.84, 36.61], + [41.29, 36.36], + [41.38, 35.63], + [41.01, 34.42], + [38.79, 33.38], + [38.79, 33.38] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.82, 33.28], + [35.55, 33.26], + [35.55, 33.26] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [32.73, 35.14], + [32.92, 35.09], + [33.19, 35.17], + [33.38, 35.16], + [33.46, 35.1], + [33.48, 35.0], + [33.53, 35.04], + [33.68, 35.02], + [33.87, 35.09], + [33.97, 35.06] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [19.22, 43.52], + [19.48, 43.35], + [19.63, 43.21], + [19.96, 43.11], + [20.34, 42.9], + [20.26, 42.81] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [44.97, 41.25], + [45.18, 40.99], + [45.56, 40.81], + [45.36, 40.56], + [45.89, 40.22], + [45.61, 39.9], + [46.03, 39.63], + [46.48, 39.46], + [46.51, 38.77], + [46.51, 38.77] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-122.84, 49], + [-120, 49], + [-117.03, 49], + [-116.05, 49], + [-113, 49], + [-110.05, 49], + [-107.05, 49], + [-104.05, 49.0], + [-100.65, 49], + [-97.23, 49.0], + [-95.16, 49], + [-95.16, 49.38], + [-94.82, 49.39], + [-94.64, 48.84], + [-94.33, 48.67], + [-93.63, 48.61], + [-92.61, 48.45], + [-91.64, 48.14], + [-90.83, 48.27], + [-89.6, 48.01], + [-89.27, 48.02], + [-88.38, 48.3], + [-87.44, 47.94], + [-86.46, 47.55], + [-85.65, 47.22], + [-84.88, 46.9], + [-84.78, 46.64], + [-84.54, 46.54], + [-84.6, 46.44], + [-84.34, 46.41], + [-84.14, 46.51], + [-84.09, 46.28], + [-83.89, 46.12], + [-83.62, 46.12], + [-83.47, 45.99], + [-83.59, 45.82], + [-82.55, 45.35], + [-82.34, 44.44], + [-82.14, 43.57], + [-82.43, 42.98], + [-82.9, 42.43], + [-83.12, 42.08], + [-83.14, 41.98], + [-83.03, 41.83], + [-82.69, 41.68], + [-82.44, 41.68], + [-81.28, 42.21], + [-80.25, 42.37], + [-78.94, 42.86], + [-78.92, 42.97], + [-79.01, 43.27], + [-79.17, 43.47], + [-78.72, 43.63], + [-77.74, 43.63], + [-76.82, 43.63], + [-76.5, 44.02], + [-76.38, 44.1], + [-75.32, 44.82], + [-74.87, 45.0], + [-73.35, 45.01], + [-71.51, 45.01], + [-71.41, 45.26], + [-71.08, 45.31], + [-70.66, 45.46], + [-70.31, 45.91], + [-70.0, 46.69], + [-69.24, 47.45], + [-68.91, 47.19], + [-68.23, 47.35], + [-67.79, 47.07], + [-67.79, 45.7], + [-67.14, 45.14] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [38.41, 18.0], + [37.9, 17.43], + [37.17, 17.26], + [36.85, 16.96], + [36.75, 16.29], + [36.32, 14.82], + [36.43, 14.42], + [36.43, 14.42] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [47.79, 8.0], + [44.96, 5.0], + [43.66, 4.96], + [42.77, 4.25], + [42.13, 4.23], + [41.86, 3.92] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [41.86, 3.92], + [41.17, 3.92], + [40.77, 4.26], + [39.85, 3.84], + [39.56, 3.42], + [38.89, 3.5], + [38.67, 3.62], + [38.44, 3.59], + [38.12, 3.6], + [36.86, 4.45], + [36.16, 4.45], + [35.82, 4.78] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [39.2, -4.68], + [37.77, -3.68], + [37.7, -3.1], + [34.07, -1.06], + [33.9, -0.95] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-8.67, 27.66], + [-8.82, 27.66], + [-8.79, 27.12], + [-9.41, 27.09], + [-9.74, 26.86], + [-10.19, 26.86], + [-10.55, 26.99], + [-11.39, 26.88], + [-11.72, 26.1], + [-12.03, 26.03], + [-12.5, 24.77], + [-13.89, 23.69], + [-14.22, 22.31], + [-14.63, 21.86], + [-14.75, 21.5], + [-17.0, 21.42] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [11.03, 58.86], + [11.47, 59.43], + [12.3, 60.12], + [12.63, 61.29], + [11.99, 61.8], + [11.93, 63.13], + [12.58, 64.07], + [13.57, 64.05], + [13.92, 64.45], + [13.56, 64.79], + [15.11, 66.19], + [16.11, 67.3], + [16.77, 68.01], + [17.73, 68.01], + [17.99, 68.57], + [19.88, 68.41], + [20.03, 69.07], + [20.65, 69.11], + [20.65, 69.11] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [40.32, -10.32], + [39.52, -10.9], + [38.43, -11.29], + [37.83, -11.27], + [37.47, -11.57], + [36.78, -11.59], + [36.51, -11.72], + [35.31, -11.44], + [34.56, -11.52] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [32.76, -9.23], + [33.23, -9.68], + [33.49, -10.53], + [33.32, -10.8], + [33.11, -11.61], + [33.31, -12.44], + [32.99, -12.78], + [32.69, -13.71], + [33.21, -13.97], + [33.21, -13.97] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [34.56, -11.52], + [34.28, -12.28], + [34.56, -13.58], + [34.91, -13.57], + [35.27, -13.89], + [35.69, -14.61], + [35.77, -15.9], + [35.34, -16.11], + [35.03, -16.8], + [34.38, -16.18], + [34.31, -15.48], + [34.52, -15.01], + [34.46, -14.61], + [34.06, -14.36], + [33.79, -14.45], + [33.21, -13.97] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.83, 3.51], + [30.77, 2.34], + [31.17, 2.2], + [30.85, 1.85], + [30.47, 1.58], + [30.09, 1.06], + [29.88, 0.6], + [29.82, -0.21], + [29.59, -0.59], + [29.58, -1.34] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.47, -2.41], + [30.53, -2.81], + [30.74, -3.03], + [30.75, -3.36], + [30.51, -3.57], + [30.12, -4.09], + [29.75, -4.45], + [29.34, -4.5] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [20.26, 42.81], + [20.5, 42.88], + [20.64, 43.22], + [20.81, 43.27], + [20.96, 43.13], + [21.14, 43.07], + [21.27, 42.91], + [21.44, 42.86], + [21.63, 42.68], + [21.78, 42.68], + [21.66, 42.44], + [21.54, 42.32], + [21.58, 42.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-71.71, 18.05], + [-71.69, 18.32], + [-71.95, 18.62], + [-71.7, 18.79], + [-71.62, 19.17], + [-71.71, 19.71] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [48.95, 11.41], + [48.94, 11.39], + [48.94, 10.98], + [48.94, 9.97], + [48.94, 9.45], + [48.49, 8.84], + [47.79, 8.0] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-69.59, -17.58], + [-69.86, -18.09], + [-70.37, -18.35] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [34.92, 29.5], + [34.27, 31.22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.72, 32.71], + [35.55, 32.4] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.82, 33.28], + [36.07, 33.82], + [36.61, 34.2], + [36.45, 34.59], + [36.0, 34.64] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.55, 32.4], + [35.55, 32.39], + [35.18, 32.53], + [34.97, 31.87], + [35.23, 31.75], + [34.97, 31.62], + [34.93, 31.35], + [35.4, 31.49], + [35.4, 31.48] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.7, 32.72], + [35.84, 32.87], + [35.82, 33.28], + [35.82, 33.28] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [29.0, 9.6], + [29.52, 9.79], + [29.62, 10.08], + [30.0, 10.29], + [30.84, 9.71], + [31.35, 9.81], + [31.85, 10.53], + [32.4, 11.08], + [32.31, 11.68], + [32.07, 11.97], + [32.67, 12.02], + [32.74, 12.25], + [33.21, 12.18], + [33.09, 11.44], + [33.21, 10.72], + [33.72, 10.33], + [33.84, 9.98], + [33.82, 9.48], + [33.96, 9.46] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [23.89, 8.62], + [24.54, 8.92], + [24.79, 9.81], + [25.07, 10.27], + [25.79, 10.41], + [25.96, 10.14], + [26.48, 9.55], + [26.75, 9.47], + [27.11, 9.64], + [27.83, 9.6] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [34.01, 4.25], + [33.39, 3.79], + [32.69, 3.79], + [31.88, 3.56], + [31.25, 3.78], + [30.83, 3.51], + [30.83, 3.51] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [27.83, 9.6], + [27.97, 9.4], + [28.97, 9.4], + [29.0, 9.6] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.9, 4.61], + [34.41, 4.63], + [34.01, 4.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.82, 4.78], + [35.82, 5.34], + [35.3, 5.51] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [33.94, -9.69], + [33.74, -9.42], + [32.76, -9.23] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [34.56, -11.52], + [34.28, -10.16], + [33.94, -9.69] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-2.17, 35.17], + [-1.79, 34.53], + [-1.73, 33.92], + [-1.39, 32.86], + [-1.12, 32.65], + [-1.31, 32.26] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-1.31, 32.26], + [-2.62, 32.09], + [-3.07, 31.72], + [-3.65, 31.64], + [-3.69, 30.9], + [-4.86, 30.5], + [-5.24, 30.0], + [-6.06, 29.73], + [-7.06, 29.58], + [-8.67, 28.84], + [-8.67, 27.66] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [75.16, 37.13], + [75.9, 36.67], + [75.9, 36.67] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [78.81, 33.51], + [79.21, 32.99] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [77.84, 35.49], + [78.91, 34.32], + [78.81, 33.51] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [91.7, 27.77], + [92.5, 27.9], + [93.41, 28.64], + [94.57, 29.28], + [95.4, 29.03], + [96.12, 29.45], + [96.59, 28.83], + [96.25, 28.41], + [97.33, 28.26] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-57.15, 5.97], + [-57.31, 5.07], + [-57.91, 4.81], + [-57.86, 4.58], + [-58.04, 4.06], + [-57.6, 3.33] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-53.96, 5.76], + [-54.48, 4.9], + [-54.4, 4.21], + [-54.01, 3.62], + [-54.18, 3.19] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-57.6, 3.33], + [-57.28, 3.33], + [-57.15, 2.77], + [-56.54, 1.9] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-54.18, 3.19], + [-54.27, 2.73], + [-54.52, 2.31] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [42.78, 10.93], + [42.56, 10.57], + [42.93, 10.02], + [43.3, 9.54], + [43.68, 9.18], + [46.95, 8.0], + [47.79, 8.0] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [41.86, 3.92], + [40.98, 2.78], + [40.99, -0.86], + [41.59, -1.68] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [77.84, 35.49], + [77.29, 35.05], + [77.29, 35.05] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [33.44, 45.97], + [33.7, 46.22], + [34.41, 46.01], + [34.73, 45.97], + [34.86, 45.77], + [35.01, 45.74] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [75.26, 32.27], + [74.41, 31.69], + [74.42, 30.98], + [73.45, 29.98], + [72.82, 28.96], + [71.78, 27.91], + [70.62, 27.99], + [69.51, 26.94], + [70.17, 26.49], + [70.28, 25.72], + [70.84, 25.22], + [71.04, 24.36], + [68.84, 24.36], + [68.18, 23.69] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-60.73, 5.2], + [-60.73, 5.2], + [-60.6, 4.92], + [-60.97, 4.54], + [-62.09, 4.16], + [-62.8, 4.01], + [-63.09, 3.77], + [-63.89, 4.02], + [-64.63, 4.15], + [-64.82, 4.06], + [-64.37, 3.8], + [-64.41, 3.13], + [-64.27, 2.5], + [-63.42, 2.41], + [-63.37, 2.2], + [-64.08, 1.92], + [-64.2, 1.49], + [-64.61, 1.33], + [-65.35, 1.1], + [-65.55, 0.79], + [-66.33, 0.72], + [-66.88, 1.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [44.79, 39.71], + [44.79, 39.71], + [45.0, 39.74], + [45.3, 39.47], + [45.74, 39.47], + [45.74, 39.32], + [46.14, 38.74] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [46.51, 38.77], + [46.14, 38.74] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [44.97, 41.25], + [43.58, 41.09] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [43.58, 41.09], + [43.75, 40.74], + [43.66, 40.25], + [44.4, 40.01], + [44.79, 39.71], + [44.79, 39.71] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [44.79, 39.71], + [44.11, 39.43], + [44.42, 38.28], + [44.23, 37.97], + [44.77, 37.17] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [44.77, 37.17], + [44.77, 37.17], + [44.29, 37.0], + [43.94, 37.26], + [42.78, 37.39], + [42.35, 37.23], + [42.35, 37.23] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [42.35, 37.23], + [41.21, 37.07], + [40.67, 37.09], + [39.52, 36.72], + [38.7, 36.71], + [38.17, 36.9], + [37.07, 36.62], + [36.74, 36.82], + [36.69, 36.26], + [36.42, 36.04], + [36.15, 35.82] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [38.79, 33.38], + [36.83, 32.31], + [35.72, 32.71], + [35.7, 32.72] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [39.2, 32.16], + [39.0, 32.01], + [37.0, 31.51], + [38.0, 30.51], + [37.67, 30.34], + [37.5, 30.0], + [36.74, 29.87], + [36.5, 29.51], + [36.07, 29.2], + [34.96, 29.36] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [46.57, 29.1], + [44.71, 29.18], + [41.89, 31.19], + [40.4, 31.89], + [39.2, 32.16] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [52, 19], + [55, 20], + [55.67, 22], + [55.21, 22.71] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [55.21, 22.71], + [55.01, 22.5], + [52.0, 23.0], + [51.62, 24.01], + [51.58, 24.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [61.21, 35.65], + [60.8, 34.4], + [60.53, 33.68], + [60.96, 33.53], + [60.54, 32.98], + [60.86, 32.18], + [60.94, 31.55], + [61.7, 31.38], + [61.78, 30.74], + [60.87, 29.83] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [60.87, 29.83], + [60.87, 29.83], + [61.37, 29.3], + [61.77, 28.7], + [62.73, 28.26], + [62.76, 27.38], + [63.23, 27.22], + [63.32, 26.76], + [61.87, 26.24], + [61.5, 25.08] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [67.83, 37.15], + [67.08, 37.36], + [66.52, 37.36], + [66.52, 37.36] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [66.52, 37.36], + [66.55, 37.97], + [65.22, 38.4], + [64.17, 38.89], + [63.52, 39.36], + [62.37, 40.05], + [61.88, 41.08], + [61.55, 41.27], + [60.47, 41.22], + [60.08, 41.43], + [59.98, 42.22], + [58.63, 42.75], + [57.79, 42.17], + [56.93, 41.83], + [57.1, 41.32], + [55.97, 41.31] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [74.98, 37.42], + [74.98, 37.42], + [75.16, 37.13], + [75.16, 37.13] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [75.16, 37.13], + [74.58, 37.02], + [74.07, 36.84], + [72.92, 36.72], + [71.85, 36.51], + [71.26, 36.07], + [71.5, 35.65], + [71.61, 35.15], + [71.12, 34.73], + [71.16, 34.35], + [70.88, 33.99], + [69.93, 34.02], + [70.32, 33.36], + [69.69, 33.11], + [69.26, 32.5], + [69.32, 31.9], + [68.93, 31.62], + [68.56, 31.71], + [67.79, 31.58], + [67.68, 31.3], + [66.94, 31.3], + [66.38, 30.74], + [66.35, 29.89], + [65.05, 29.47], + [64.35, 29.56], + [64.15, 29.34], + [63.55, 29.47], + [62.55, 29.32], + [60.87, 29.83] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [73.68, 39.43], + [71.78, 39.28], + [70.55, 39.6], + [69.46, 39.53], + [69.56, 40.1], + [70.65, 39.94], + [71.01, 40.24] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [87.36, 49.22], + [86.6, 48.55], + [85.77, 48.46], + [85.72, 47.45], + [85.16, 47.0], + [83.18, 47.33], + [82.46, 45.54], + [81.95, 45.32], + [79.97, 44.92], + [80.87, 43.18], + [80.18, 42.92], + [80.26, 42.35], + [80.26, 42.35] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [80.26, 42.35], + [79.64, 42.5], + [79.14, 42.86], + [77.66, 42.96], + [76.0, 42.99], + [75.64, 42.88], + [74.21, 43.3], + [73.65, 43.09], + [73.49, 42.5], + [71.84, 42.85], + [71.19, 42.7], + [70.96, 42.27] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [70.96, 42.27], + [70.96, 42.27], + [70.39, 42.08], + [69.07, 41.38], + [68.63, 40.67], + [68.26, 40.66], + [67.99, 41.14], + [66.71, 41.17], + [66.51, 41.99], + [66.02, 41.99], + [66.1, 43.0], + [64.9, 43.73], + [63.19, 43.65], + [62.01, 43.5], + [61.06, 44.41], + [60.24, 44.78], + [58.69, 45.5], + [58.5, 45.59], + [55.93, 45.0], + [55.97, 41.31], + [55.97, 41.31] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [55.97, 41.31], + [55.46, 41.26], + [54.76, 42.04], + [54.08, 42.32], + [52.94, 42.12], + [52.5, 41.78] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [76.65, 35.79], + [77.84, 35.49] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [81.11, 30.18], + [80.48, 29.73], + [80.09, 28.79], + [81.06, 28.42], + [82, 27.93], + [83.3, 27.36], + [84.68, 27.23], + [85.25, 26.73], + [86.02, 26.63], + [87.23, 26.4], + [88.06, 26.41], + [88.17, 26.81], + [88.04, 27.45], + [88.12, 27.88] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [88.81, 27.3], + [88.84, 27.1], + [89.74, 26.72], + [90.37, 26.88], + [91.22, 26.81], + [92.03, 26.84], + [92.1, 27.45], + [91.7, 27.77] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [88.12, 27.88], + [88.12, 27.88], + [88.73, 28.09], + [88.73, 28.09], + [88.81, 27.3], + [88.81, 27.3] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [92.67, 22.04], + [92.15, 23.63], + [91.87, 23.62], + [91.71, 22.99], + [91.16, 23.5], + [91.47, 24.07], + [91.92, 24.13], + [92.38, 24.98], + [91.8, 25.15], + [90.87, 25.13], + [89.92, 25.27], + [89.83, 25.97], + [89.36, 26.01], + [88.56, 26.45], + [88.21, 25.77], + [88.93, 25.24], + [88.31, 24.87], + [88.08, 24.5], + [88.7, 24.23], + [88.53, 23.63], + [88.88, 22.88], + [89.03, 22.06] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [101.18, 21.44], + [100.33, 20.79], + [100.12, 20.42], + [100.12, 20.42] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [100.12, 20.42], + [99.54, 20.19], + [98.96, 19.75], + [98.25, 19.71], + [97.8, 18.63], + [97.38, 18.45], + [97.86, 17.57], + [98.49, 16.84], + [98.9, 16.18], + [98.54, 15.31], + [98.19, 15.12], + [98.43, 14.62], + [99.1, 13.83], + [99.21, 13.27], + [99.2, 12.8], + [99.59, 11.89], + [99.04, 10.96], + [98.55, 9.93] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [102.17, 22.46], + [102.17, 22.46], + [102.75, 21.68], + [103.2, 20.77], + [104.44, 20.76], + [104.82, 19.89], + [104.18, 19.62], + [103.9, 19.27], + [105.09, 18.67], + [105.93, 17.49], + [106.56, 16.6], + [107.31, 15.91], + [107.56, 15.2], + [107.38, 14.2], + [107.38, 14.2] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [107.38, 14.2], + [106.5, 14.57], + [106.04, 13.88], + [105.22, 14.27] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [130.64, 42.4], + [130.78, 42.22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [27.29, 57.47], + [27.77, 57.24], + [27.86, 56.76], + [28.18, 56.17], + [28.18, 56.17] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [28.18, 56.17], + [27.1, 55.78], + [26.49, 55.62] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [31.79, 52.1], + [30.93, 52.04], + [30.62, 51.82], + [30.56, 51.32], + [30.16, 51.42], + [29.25, 51.37], + [28.99, 51.6], + [28.62, 51.43], + [28.24, 51.57], + [27.45, 51.59], + [26.34, 51.83], + [25.33, 51.91], + [24.55, 51.89], + [24.01, 51.62], + [23.53, 51.58] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [26.49, 55.62], + [26.49, 55.62], + [26.59, 55.17], + [25.77, 54.85], + [25.54, 54.28], + [24.45, 53.91], + [23.48, 53.91] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [20.65, 69.11], + [21.24, 69.37], + [22.36, 68.84], + [23.66, 68.89], + [24.74, 68.65], + [25.69, 69.09], + [26.18, 69.83], + [27.73, 70.16], + [29.02, 69.77], + [28.59, 69.06], + [28.59, 69.06] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [28.59, 69.06], + [29.4, 69.16], + [31.1, 69.56] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [26.62, 48.22], + [26.62, 48.22], + [26.86, 48.37], + [27.52, 48.47], + [28.26, 48.16], + [28.67, 48.12], + [29.12, 47.85], + [29.05, 47.51], + [29.42, 47.35], + [29.56, 46.93], + [29.91, 46.67], + [29.84, 46.53], + [30.02, 46.42], + [29.76, 46.35], + [29.17, 46.38], + [29.07, 46.52], + [28.86, 46.44], + [28.93, 46.26], + [28.66, 45.94], + [28.49, 45.6], + [28.23, 45.49] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [28.23, 45.49], + [28.23, 45.49], + [28.68, 45.3], + [29.15, 45.46], + [29.6, 45.29] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.66, 44.23], + [22.47, 44.41], + [22.71, 44.58], + [22.46, 44.7], + [22.15, 44.48], + [21.56, 44.77], + [21.48, 45.18], + [20.87, 45.42], + [20.76, 45.73], + [20.22, 46.13] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [21.02, 40.84], + [21.02, 40.84], + [20.61, 41.09], + [20.46, 41.52], + [20.59, 41.86] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [21.58, 42.25], + [21.58, 42.25], + [21.35, 42.21], + [20.76, 42.05], + [20.72, 41.85], + [20.59, 41.86] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [20.59, 41.86], + [20.59, 41.86], + [20.52, 42.22], + [20.28, 42.32], + [20.07, 42.59] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [20.07, 42.59], + [20.07, 42.59], + [19.8, 42.5], + [19.74, 42.69], + [19.3, 42.2], + [19.37, 41.88] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.36, 42.32], + [21.92, 42.3], + [21.58, 42.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [20.26, 42.81], + [20.26, 42.81], + [20.07, 42.59] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [19.22, 43.52], + [19.03, 43.43], + [18.71, 43.2], + [18.56, 42.65] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [19.01, 44.86], + [19.39, 45.24], + [19.07, 45.52], + [18.83, 45.91] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [15.02, 51.11], + [14.57, 51.0], + [14.31, 51.12], + [14.06, 50.93], + [13.34, 50.73], + [12.97, 50.48], + [12.24, 50.27], + [12.42, 49.97], + [12.52, 49.55], + [13.03, 49.31], + [13.6, 48.88], + [13.6, 48.88] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [13.6, 48.88], + [13.24, 48.42], + [12.88, 48.29], + [13.03, 47.64], + [12.93, 47.47], + [12.62, 47.67], + [12.14, 47.7], + [11.43, 47.52], + [10.54, 47.57], + [10.4, 47.3], + [9.9, 47.58], + [9.59, 47.53], + [9.59, 47.53] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [9.59, 47.53], + [8.52, 47.83], + [8.32, 47.61], + [7.47, 47.62] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [16.96, 48.6], + [16.5, 48.79], + [16.03, 48.73], + [15.25, 49.04], + [14.9, 48.96], + [14.34, 48.56], + [13.6, 48.88] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [16.98, 48.12], + [16.9, 47.71], + [16.34, 47.71], + [16.53, 47.5], + [16.2, 46.85], + [16.2, 46.85] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [16.2, 46.85], + [16.01, 46.68], + [15.14, 46.66], + [14.63, 46.43], + [13.81, 46.51] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [13.81, 46.51], + [13.7, 46.02], + [13.94, 45.59] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [10.44, 46.89], + [10.36, 46.48], + [9.92, 46.31], + [9.18, 46.44], + [8.97, 46.04], + [8.49, 46.01], + [8.32, 46.16], + [7.76, 45.82], + [7.27, 45.78], + [6.84, 45.99] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [7.47, 47.62], + [7.19, 47.45], + [6.74, 47.54], + [6.77, 47.29], + [6.04, 46.73], + [6.02, 46.27], + [6.5, 46.43], + [6.84, 45.99] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [6.84, 45.99], + [6.84, 45.99], + [6.8, 45.71], + [7.1, 45.33], + [6.75, 45.03], + [7.01, 44.25], + [7.55, 44.13], + [7.44, 43.69] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [6.04, 50.13], + [6.04, 50.13], + [5.78, 50.09], + [5.67, 49.53], + [5.67, 49.53] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [5.67, 49.53], + [4.8, 49.99], + [4.29, 49.91], + [3.59, 50.38], + [3.12, 50.78], + [2.66, 50.8], + [2.51, 51.15] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [6.16, 50.8], + [6.16, 50.8], + [5.61, 51.04], + [4.97, 51.48], + [4.05, 51.27], + [3.31, 51.35] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-4.92, 24.97], + [-6.45, 24.96], + [-5.97, 20.64], + [-5.49, 16.33], + [-5.32, 16.2], + [-5.54, 15.5], + [-9.55, 15.49], + [-9.7, 15.26], + [-10.09, 15.33], + [-10.65, 15.13], + [-11.35, 15.41], + [-11.67, 15.39], + [-11.83, 14.8], + [-12.17, 14.62], + [-12.17, 14.62] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-12.17, 14.62], + [-12.83, 15.3], + [-13.44, 16.04], + [-14.1, 16.3], + [-14.58, 16.6], + [-15.14, 16.59], + [-15.62, 16.37], + [-16.12, 16.46], + [-16.46, 16.14] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [9.48, 30.31], + [9.81, 29.42], + [9.86, 28.96], + [9.68, 28.14], + [9.76, 27.69], + [9.63, 27.14], + [9.72, 26.51], + [9.32, 26.09], + [9.91, 25.37], + [9.95, 24.94], + [10.3, 24.38], + [10.77, 24.56], + [11.56, 24.1], + [12.0, 23.47], + [12.0, 23.47] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [12.0, 23.47], + [8.57, 21.57], + [5.68, 19.6], + [4.27, 19.16] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [4.27, 19.16], + [4.27, 19.16], + [3.16, 19.06], + [3.15, 19.69], + [2.68, 19.86], + [2.06, 20.14], + [1.82, 20.61], + [-1.55, 22.79], + [-4.92, 24.97] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [14.85, 22.86], + [14.14, 22.49], + [13.58, 23.04], + [12.0, 23.47] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [25, 22], + [25, 20.0], + [23.85, 20], + [23.84, 19.58] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [23.84, 19.58], + [19.85, 21.5], + [15.86, 23.41], + [14.85, 22.86] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.86, 11.14], + [22.23, 10.97], + [21.72, 10.57], + [21.0, 9.48], + [20.06, 9.01], + [19.09, 9.07], + [18.81, 8.98], + [18.91, 8.63], + [18.39, 8.28], + [17.96, 7.89], + [16.71, 7.51], + [16.46, 7.73], + [16.29, 7.75], + [16.11, 7.5], + [15.28, 7.42] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [15.28, 7.42], + [15.28, 7.42], + [15.44, 7.69], + [15.12, 8.38], + [14.98, 8.8], + [14.54, 8.97], + [13.95, 9.55], + [14.17, 10.02], + [14.63, 9.92], + [14.91, 9.99], + [15.47, 9.98], + [14.92, 10.89], + [14.96, 11.56], + [14.89, 12.22], + [14.5, 12.86] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [14.5, 12.86], + [14.21, 12.8], + [14.18, 12.48] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [14.18, 12.48], + [14.58, 12.09], + [14.47, 11.9], + [14.42, 11.57], + [13.57, 10.8], + [13.31, 10.16], + [13.17, 9.64], + [12.96, 9.42], + [12.75, 8.72], + [12.22, 8.31], + [12.06, 7.8], + [11.84, 7.4], + [11.75, 6.98], + [11.06, 6.64], + [10.5, 7.06], + [10.12, 7.04], + [9.52, 6.45], + [9.23, 6.44], + [8.76, 5.48], + [8.5, 4.77] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-11.51, 12.44], + [-11.51, 12.44], + [-11.66, 12.39], + [-12.2, 12.47], + [-12.28, 12.35], + [-12.5, 12.33], + [-13.22, 12.58], + [-13.7, 12.59] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-8.03, 10.21], + [-8.23, 10.13], + [-8.31, 9.79], + [-8.08, 9.38], + [-7.83, 8.58], + [-8.2, 8.46], + [-8.3, 8.32], + [-8.22, 8.12], + [-8.28, 7.69], + [-8.44, 7.69] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-8.44, 7.69], + [-8.44, 7.69], + [-8.72, 7.71], + [-8.93, 7.31], + [-9.21, 7.31], + [-9.4, 7.53], + [-9.34, 7.93], + [-9.76, 8.54], + [-10.02, 8.43], + [-10.23, 8.41] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-10.23, 8.41], + [-10.23, 8.41], + [-10.51, 8.35], + [-10.49, 8.72], + [-10.65, 8.98], + [-10.62, 9.27], + [-10.84, 9.69], + [-11.12, 10.05], + [-11.92, 10.05], + [-12.15, 9.86], + [-12.43, 9.84], + [-12.6, 9.62], + [-12.71, 9.34], + [-13.25, 8.9] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [0.37, 14.93], + [-0.27, 14.92], + [-0.52, 15.12], + [-1.07, 14.97], + [-2.0, 14.56], + [-2.19, 14.25], + [-2.97, 13.8], + [-3.1, 13.54], + [-3.52, 13.34], + [-4.01, 13.47], + [-4.28, 13.23], + [-4.43, 12.54], + [-5.22, 11.71], + [-5.2, 11.38], + [-5.47, 10.95], + [-5.4, 10.37], + [-5.4, 10.37] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-5.4, 10.37], + [-5.82, 10.22], + [-6.05, 10.1], + [-6.21, 10.52], + [-6.49, 10.41], + [-6.67, 10.43], + [-6.85, 10.14], + [-7.62, 10.15], + [-7.9, 10.3], + [-8.03, 10.21] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [2.15, 11.94], + [1.94, 11.64], + [1.45, 11.55], + [1.24, 11.11], + [0.9, 11.0], + [0.9, 11.0] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [0.9, 11.0], + [0.02, 11.02] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-2.83, 9.64], + [-3.51, 9.9], + [-3.98, 9.86], + [-4.33, 9.61], + [-4.78, 9.82], + [-4.95, 10.15], + [-5.4, 10.37] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [0.02, 11.02], + [0.02, 11.02], + [-0.44, 11.1], + [-0.76, 10.94], + [-1.2, 11.01], + [-2.94, 10.96], + [-2.96, 10.4], + [-2.83, 9.64], + [-2.83, 9.64] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [3.61, 11.66], + [3.57, 11.33], + [3.8, 10.73], + [3.6, 10.33], + [3.71, 10.06], + [3.22, 9.44], + [2.91, 9.14], + [2.72, 8.51], + [2.75, 7.87], + [2.69, 6.26] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [16.01, 2.27], + [15.94, 1.73], + [15.15, 1.96], + [14.34, 2.23], + [13.08, 2.27], + [13.08, 2.27] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [13.08, 2.27], + [12.95, 2.32], + [12.36, 2.19], + [11.75, 2.33], + [11.28, 2.26], + [11.28, 2.26] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [11.28, 2.26], + [9.65, 2.28] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [23.81, 8.67], + [24.57, 8.23], + [25.11, 7.83], + [25.12, 7.5], + [25.8, 6.98], + [26.21, 6.55], + [26.47, 5.95], + [27.21, 5.55], + [27.37, 5.23], + [27.37, 5.23] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [27.37, 5.23], + [27.04, 5.13], + [26.4, 5.15], + [25.65, 5.26], + [25.28, 5.17], + [25.13, 4.93], + [24.81, 4.9], + [24.41, 5.11], + [23.3, 4.61], + [22.84, 4.71], + [22.7, 4.63], + [22.41, 4.03], + [21.66, 4.22], + [20.93, 4.32], + [20.29, 4.69], + [19.47, 5.03], + [18.93, 4.71], + [18.54, 4.2], + [18.45, 3.5] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [36.43, 14.42], + [36.27, 13.56], + [35.86, 12.58], + [35.26, 12.08], + [34.83, 11.32], + [34.73, 10.91], + [34.26, 10.63], + [33.96, 9.58], + [33.96, 9.58] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [33.96, 9.58], + [33.97, 8.68], + [33.83, 8.38], + [33.29, 8.35], + [32.95, 7.78], + [33.57, 7.71], + [34.08, 7.23], + [34.25, 6.83], + [34.71, 6.59], + [35.3, 5.51] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [42.35, 12.54], + [42.78, 12.46], + [43.08, 12.7] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [42.78, 10.93], + [42.55, 11.11], + [42.31, 11.03], + [41.76, 11.05], + [41.74, 11.36], + [41.66, 11.63], + [42, 12.1], + [42.35, 12.54] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [33.9, -0.95], + [33.9, -0.95], + [33.89, 0.11], + [34.18, 0.52], + [34.67, 1.18], + [35.04, 1.91], + [34.6, 3.05], + [34.48, 3.56], + [34.01, 4.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.83, 3.51], + [29.95, 4.17], + [29.72, 4.6], + [29.16, 4.39], + [28.7, 4.46], + [28.43, 4.29], + [27.98, 4.41], + [27.37, 5.23] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [18.45, 3.5], + [18.45, 3.5], + [18.39, 2.9], + [18.09, 2.37], + [17.9, 1.74], + [17.77, 0.86], + [17.83, 0.29], + [17.66, -0.06], + [17.64, -0.42], + [17.52, -0.74], + [16.87, -1.23], + [16.41, -1.74], + [15.97, -2.71], + [16.01, -3.54], + [15.75, -3.86], + [15.17, -4.34], + [14.58, -4.97], + [14.21, -4.79], + [14.14, -4.51], + [13.6, -4.5], + [13.26, -4.88], + [13.0, -4.78] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [13.0, -4.78], + [13.0, -4.78], + [12.63, -4.99], + [12.47, -5.25], + [12.44, -5.68], + [12.18, -5.79] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [29.58, -1.34], + [29.29, -1.62], + [29.25, -2.22], + [29.12, -2.29], + [29.02, -2.84] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [29.02, -2.84], + [29.28, -3.29], + [29.34, -4.5] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [29.34, -4.5], + [29.52, -5.42], + [29.42, -5.94], + [29.62, -6.52], + [30.2, -7.08], + [30.74, -8.34] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.74, -8.34], + [30.35, -8.24], + [29.0, -8.41], + [28.73, -8.53], + [28.45, -9.16], + [28.67, -9.61], + [28.5, -10.79], + [28.37, -11.79], + [28.64, -11.97], + [29.34, -12.36], + [29.62, -12.18], + [29.7, -13.26], + [28.93, -13.25], + [28.52, -12.7], + [28.16, -12.27], + [27.39, -12.13], + [27.16, -11.61], + [26.55, -11.92], + [25.75, -11.78], + [25.42, -11.33], + [24.78, -11.24], + [24.31, -11.26], + [24.26, -10.95], + [23.91, -10.93] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [23.91, -10.93], + [23.46, -10.87], + [22.84, -11.02], + [22.4, -10.99], + [22.16, -11.08], + [22.21, -9.89], + [21.88, -9.52], + [21.8, -8.91], + [21.95, -8.31], + [21.75, -7.92], + [21.73, -7.29], + [20.51, -7.3], + [20.6, -6.94], + [20.09, -6.94], + [20.04, -7.12], + [19.42, -7.16], + [19.17, -7.74], + [19.02, -7.99], + [18.46, -7.85], + [18.13, -7.99], + [17.47, -8.07], + [17.09, -7.55], + [16.86, -7.22], + [16.57, -6.62], + [16.33, -5.88], + [13.38, -5.86], + [13.02, -5.98], + [12.74, -5.97], + [12.32, -6.1] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.47, -2.41], + [30.47, -2.41], + [29.94, -2.35], + [29.63, -2.92], + [29.02, -2.84] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.42, -1.13], + [30.77, -1.01], + [31.87, -1.03], + [33.9, -0.95] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [32.76, -9.23], + [32.19, -8.93], + [31.56, -8.76], + [31.16, -8.59], + [30.74, -8.34] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [33.21, -13.97], + [30.18, -14.8], + [30.27, -15.51] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [30.27, -15.51], + [30.27, -15.51], + [29.52, -15.64], + [28.95, -16.04], + [28.83, -16.39], + [28.47, -16.47], + [27.6, -17.29], + [27.04, -17.94], + [26.71, -17.96], + [26.38, -17.85], + [25.26, -17.74], + [25.08, -17.66] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [23.22, -17.52], + [21.38, -17.93], + [18.96, -17.79], + [18.26, -17.31], + [14.21, -17.35], + [14.06, -17.42], + [13.46, -16.97], + [12.81, -16.94], + [12.22, -17.11], + [11.73, -17.3] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [24.93, -17.72], + [24.52, -17.89], + [24.22, -17.89], + [23.58, -18.28], + [23.2, -17.87], + [21.66, -18.22], + [20.91, -18.25], + [20.88, -21.81], + [19.9, -21.85], + [19.9, -24.77], + [19.9, -24.77] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [29.43, -22.09], + [28.02, -22.83], + [27.12, -23.57], + [26.79, -24.24], + [26.49, -24.62], + [25.94, -24.7], + [25.77, -25.17], + [25.66, -25.49], + [25.03, -25.72], + [24.21, -25.67], + [23.73, -25.39], + [23.31, -25.27], + [22.82, -25.5], + [22.58, -25.98], + [22.11, -26.28], + [21.61, -26.73], + [20.89, -26.83], + [20.67, -26.48], + [20.76, -25.87], + [20.17, -24.92], + [19.9, -24.77] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [31.19, -22.25], + [31.19, -22.25], + [30.66, -22.15], + [30.32, -22.27], + [29.84, -22.1], + [29.43, -22.09] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [32.07, -26.73], + [32.07, -26.73], + [31.99, -26.29], + [31.84, -25.84] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [31.84, -25.84], + [31.84, -25.84], + [31.75, -25.48], + [31.93, -24.37], + [31.67, -23.66], + [31.19, -22.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-89.14, 17.81], + [-89.14, 17.81], + [-89.15, 17.96], + [-89.03, 18.0], + [-88.85, 17.88], + [-88.49, 18.49], + [-88.3, 18.5] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-89.35, 14.42], + [-89.15, 14.68], + [-89.23, 14.87], + [-89.15, 15.07], + [-88.68, 15.35], + [-88.23, 15.73] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-66.88, 1.25], + [-67.07, 1.13], + [-67.26, 1.72], + [-67.54, 2.04], + [-67.87, 1.69], + [-69.82, 1.71], + [-69.8, 1.09], + [-69.22, 0.99], + [-69.25, 0.6], + [-69.45, 0.71], + [-70.02, 0.54], + [-70.02, -0.19], + [-69.58, -0.55], + [-69.42, -1.12], + [-69.44, -1.56], + [-69.89, -4.3], + [-69.89, -4.3] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-69.89, -4.3], + [-70.39, -3.77], + [-70.69, -3.74], + [-70.05, -2.73], + [-70.81, -2.26], + [-71.41, -2.34], + [-71.77, -2.17], + [-72.33, -2.43], + [-73.07, -2.31], + [-73.66, -1.26], + [-74.12, -1.0], + [-74.44, -0.53], + [-75.11, -0.06], + [-75.37, -0.15], + [-75.37, -0.15] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-75.37, -0.15], + [-75.8, 0.08], + [-76.29, 0.42], + [-76.58, 0.26], + [-77.42, 0.4], + [-77.67, 0.83], + [-77.86, 0.81], + [-78.86, 1.38] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-67.11, -22.74], + [-67.11, -22.74], + [-67.83, -22.87], + [-68.22, -21.49], + [-68.76, -20.37], + [-68.44, -19.41], + [-68.97, -18.98], + [-69.1, -18.26], + [-69.59, -17.58] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-58.17, -20.18], + [-58.18, -19.87], + [-59.12, -19.36], + [-60.04, -19.34], + [-61.79, -19.63], + [-62.27, -20.51], + [-62.29, -21.05], + [-62.69, -22.25] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-54.63, -25.74], + [-54.63, -25.74], + [-54.13, -25.55], + [-53.63, -26.12], + [-53.65, -26.92], + [-54.49, -27.47], + [-55.16, -27.88], + [-56.29, -28.85], + [-57.63, -30.22] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-57.63, -30.22], + [-57.63, -30.22], + [-56.98, -30.11], + [-55.97, -30.88], + [-55.6, -30.85], + [-54.57, -31.49], + [-53.79, -32.05], + [-53.21, -32.73], + [-53.65, -33.2], + [-53.37, -33.77] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-69.59, -17.58], + [-68.96, -16.5], + [-69.39, -15.66], + [-69.16, -15.32], + [-69.34, -14.95], + [-68.95, -14.45], + [-68.93, -13.6], + [-68.88, -12.9], + [-68.67, -12.56], + [-69.53, -10.95] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [13.32, 13.56], + [13.32, 13.56], + [14.0, 12.46], + [14.18, 12.48], + [14.18, 12.48] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [6.19, 49.46], + [5.9, 49.44], + [5.67, 49.53] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [46.14, 38.74], + [45.46, 38.87], + [44.95, 39.34], + [44.79, 39.71] + ], + [ + [46.51, 38.77], + [47.69, 39.51], + [48.06, 39.58], + [48.36, 39.29], + [48.01, 38.79], + [48.63, 38.27], + [48.88, 38.32] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-13.7, 12.59], + [-13.7, 12.59], + [-15.55, 12.63], + [-15.82, 12.52], + [-16.15, 12.55], + [-16.68, 12.38] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [19.9, -24.77], + [19.89, -28.46], + [19.0, -28.97], + [18.46, -29.05], + [17.84, -28.86], + [17.39, -28.78], + [17.22, -28.36], + [16.82, -28.08], + [16.34, -28.58] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-62.69, -22.25], + [-62.69, -22.25], + [-62.85, -22.03], + [-63.99, -21.99], + [-64.38, -22.8], + [-64.96, -22.08], + [-66.27, -21.83], + [-67.11, -22.74] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [-69.53, -10.95], + [-69.53, -10.95], + [-68.79, -11.04], + [-68.27, -11.01], + [-68.05, -10.71], + [-67.17, -10.31], + [-66.65, -9.93], + [-65.34, -9.76], + [-65.44, -10.51], + [-65.32, -10.9], + [-65.4, -11.57], + [-64.32, -12.46], + [-63.2, -12.63], + [-62.8, -13.0], + [-62.13, -13.2], + [-61.71, -13.49], + [-61.08, -13.48], + [-60.5, -13.78], + [-60.46, -14.35], + [-60.26, -14.65], + [-60.25, -15.08], + [-60.54, -15.09], + [-60.16, -16.26], + [-58.24, -16.3], + [-58.39, -16.88], + [-58.28, -17.27], + [-57.73, -17.55], + [-57.5, -18.17], + [-57.68, -18.96], + [-57.95, -19.4], + [-57.85, -19.97], + [-57.85, -19.97], + [-58.17, -20.18] + ], + [ + [-57.85, -19.97], + [-58.17, -20.18] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [75.9, 36.67], + [76.19, 35.9], + [76.65, 35.79] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [79.21, 32.99], + [79.21, 32.99], + [79.18, 32.48], + [79.18, 32.48] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [79.18, 32.48], + [78.46, 32.62], + [78.55, 32.27] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [78.55, 32.27], + [78.65, 31.85] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [78.65, 31.85], + [78.74, 31.52], + [78.88, 31.43] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [78.88, 31.43], + [79.37, 31.11] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [79.37, 31.11], + [79.72, 30.88], + [79.81, 30.84] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [79.81, 30.84], + [80.26, 30.61] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [80.26, 30.61], + [81.11, 30.18], + [81.11, 30.18] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [36.7, 45.61], + [36.58, 45.2], + [36.64, 44.95] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [87.36, 49.22], + [87.75, 49.3], + [88.01, 48.6], + [88.85, 48.07], + [90.28, 47.69], + [90.97, 46.89], + [90.59, 45.72], + [90.95, 45.29], + [92.13, 45.12], + [93.48, 44.98], + [94.69, 44.35], + [95.31, 44.24], + [95.76, 43.32], + [96.35, 42.73], + [97.45, 42.75], + [99.52, 42.52], + [100.85, 42.66], + [101.83, 42.51], + [103.31, 41.91], + [104.52, 41.91], + [104.97, 41.6], + [106.13, 42.13], + [107.74, 42.48], + [109.24, 42.52], + [110.41, 42.87], + [111.13, 43.41], + [111.83, 43.74], + [111.67, 44.07], + [111.35, 44.46], + [111.87, 45.1], + [112.44, 45.01], + [113.46, 44.81], + [114.46, 45.34], + [115.99, 45.73], + [116.72, 46.39], + [117.42, 46.67], + [118.87, 46.81], + [119.66, 46.69], + [119.77, 47.05], + [118.87, 47.75], + [118.06, 48.07], + [117.3, 47.7], + [116.31, 47.85], + [115.74, 47.73], + [115.49, 48.14], + [116.19, 49.13], + [116.68, 49.89], + [116.68, 49.89] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [116.68, 49.89], + [116.68, 49.89], + [117.88, 49.51], + [119.29, 50.14], + [119.28, 50.58], + [120.18, 51.64], + [120.74, 51.96], + [120.73, 52.52], + [120.18, 52.75], + [121.0, 53.25], + [122.25, 53.43], + [123.57, 53.46], + [125.07, 53.16], + [125.95, 52.79], + [126.56, 51.78], + [126.94, 51.35], + [127.29, 50.74], + [127.66, 49.76], + [129.4, 49.44], + [130.58, 48.73], + [130.99, 47.79], + [132.51, 47.79], + [133.37, 48.18], + [135.03, 48.48], + [134.5, 47.58], + [134.11, 47.21], + [133.77, 46.12], + [133.1, 45.14], + [131.88, 45.32], + [131.03, 44.97], + [131.29, 44.11], + [131.14, 42.93], + [130.63, 42.9], + [130.64, 42.4] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [35.55, 33.26], + [35.46, 33.09], + [35.13, 33.09] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [75.14, 34.6], + [74.24, 34.75], + [73.75, 34.32], + [74.1, 33.44], + [74.45, 32.76] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [77.29, 35.05], + [76.87, 34.65], + [75.76, 34.5], + [75.14, 34.6] + ] + } + } + ] +} diff --git a/web/public/map/land.geojson b/web/public/map/land.geojson new file mode 100644 index 0000000..842c574 --- /dev/null +++ b/web/public/map/land.geojson @@ -0,0 +1,6547 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-59.57, -80.04], + [-59.87, -80.55], + [-60.16, -81.0], + [-62.26, -80.86], + [-64.49, -80.92], + [-65.74, -80.59], + [-65.74, -80.55], + [-66.29, -80.26], + [-64.04, -80.29], + [-61.88, -80.39], + [-61.14, -79.98], + [-60.61, -79.63], + [-59.57, -80.04] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-159.21, -79.5], + [-161.13, -79.63], + [-162.44, -79.28], + [-163.03, -78.93], + [-163.07, -78.87], + [-163.71, -78.6], + [-163.71, -78.6], + [-163.11, -78.22], + [-161.25, -78.38], + [-160.25, -78.69], + [-159.48, -79.05], + [-159.21, -79.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-45.15, -78.05], + [-43.92, -78.48], + [-43.49, -79.09], + [-43.37, -79.52], + [-43.33, -80.03], + [-44.88, -80.34], + [-46.51, -80.59], + [-48.39, -80.83], + [-50.48, -81.03], + [-52.85, -80.97], + [-54.16, -80.63], + [-53.99, -80.22], + [-51.85, -79.95], + [-50.99, -79.61], + [-50.36, -79.18], + [-49.91, -78.81], + [-49.31, -78.46], + [-48.66, -78.05], + [-48.66, -78.05], + [-48.15, -78.05], + [-46.66, -77.83], + [-45.15, -78.05] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-121.21, -73.5], + [-119.92, -73.66], + [-118.72, -73.48], + [-119.29, -73.83], + [-120.23, -74.09], + [-121.62, -74.01], + [-122.62, -73.66], + [-122.62, -73.66], + [-122.41, -73.32], + [-121.21, -73.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-125.56, -73.48], + [-124.03, -73.87], + [-124.62, -73.83], + [-125.91, -73.74], + [-127.28, -73.46], + [-127.28, -73.46], + [-126.56, -73.25], + [-125.56, -73.48] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-98.98, -71.93], + [-97.88, -72.07], + [-96.79, -71.95], + [-96.2, -72.52], + [-96.98, -72.44], + [-98.2, -72.48], + [-99.43, -72.44], + [-100.78, -72.5], + [-101.8, -72.31], + [-102.33, -71.89], + [-102.33, -71.89], + [-101.7, -71.72], + [-100.43, -71.85], + [-98.98, -71.93] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-68.45, -70.96], + [-68.33, -71.41], + [-68.51, -71.8], + [-68.78, -72.17], + [-69.96, -72.31], + [-71.08, -72.5], + [-72.39, -72.48], + [-71.9, -72.09], + [-73.07, -72.23], + [-74.19, -72.37], + [-74.95, -72.07], + [-75.01, -71.66], + [-73.92, -71.27], + [-73.92, -71.27], + [-73.23, -71.15], + [-72.07, -71.19], + [-71.78, -70.68], + [-71.72, -70.31], + [-71.74, -69.51], + [-71.17, -69.04], + [-70.25, -68.88], + [-69.72, -69.25], + [-69.49, -69.62], + [-69.06, -70.07], + [-68.73, -70.51], + [-68.45, -70.96] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-58.61, -64.15], + [-59.05, -64.37], + [-59.79, -64.21], + [-60.61, -64.31], + [-61.3, -64.54], + [-62.02, -64.8], + [-62.51, -65.09], + [-62.65, -65.48], + [-62.59, -65.86], + [-62.12, -66.19], + [-62.81, -66.43], + [-63.75, -66.5], + [-64.29, -66.84], + [-64.88, -67.15], + [-65.51, -67.58], + [-65.67, -67.95], + [-65.31, -68.37], + [-64.78, -68.68], + [-63.96, -68.91], + [-63.2, -69.23], + [-62.79, -69.62], + [-62.57, -69.99], + [-62.28, -70.38], + [-61.81, -70.72], + [-61.51, -71.09], + [-61.38, -72.01], + [-61.08, -72.38], + [-61.0, -72.77], + [-60.69, -73.17], + [-60.83, -73.7], + [-61.38, -74.11], + [-61.96, -74.44], + [-63.3, -74.58], + [-63.75, -74.93], + [-64.35, -75.26], + [-65.86, -75.64], + [-67.19, -75.79], + [-68.45, -76.01], + [-69.8, -76.22], + [-70.6, -76.63], + [-72.21, -76.67], + [-73.97, -76.63], + [-75.56, -76.71], + [-77.24, -76.71], + [-76.93, -77.1], + [-75.4, -77.28], + [-74.28, -77.56], + [-73.66, -77.91], + [-74.77, -78.22], + [-76.5, -78.12], + [-77.93, -78.38], + [-77.98, -78.79], + [-78.02, -79.18], + [-76.85, -79.51], + [-76.63, -79.89], + [-75.36, -80.26], + [-73.24, -80.42], + [-71.44, -80.69], + [-70.01, -81.0], + [-68.19, -81.32], + [-65.7, -81.47], + [-63.26, -81.75], + [-61.55, -82.04], + [-59.69, -82.38], + [-58.71, -82.85], + [-58.22, -83.22], + [-57.01, -82.87], + [-55.36, -82.57], + [-53.62, -82.26], + [-51.54, -82.0], + [-49.76, -81.73], + [-47.27, -81.71], + [-44.83, -81.85], + [-42.81, -82.08], + [-42.16, -81.65], + [-40.77, -81.36], + [-38.24, -81.34], + [-36.27, -81.12], + [-34.39, -80.91], + [-32.31, -80.77], + [-30.1, -80.59], + [-28.55, -80.34], + [-29.25, -79.99], + [-29.69, -79.63], + [-29.69, -79.26], + [-31.62, -79.3], + [-33.68, -79.46], + [-35.64, -79.46], + [-35.91, -79.08], + [-35.78, -78.34], + [-35.33, -78.12], + [-33.9, -77.89], + [-32.21, -77.65], + [-31.0, -77.36], + [-29.78, -77.07], + [-28.88, -76.67], + [-27.51, -76.5], + [-26.16, -76.36], + [-25.47, -76.28], + [-23.93, -76.24], + [-22.46, -76.11], + [-21.22, -75.91], + [-20.01, -75.67], + [-18.91, -75.44], + [-17.52, -75.13], + [-16.64, -74.79], + [-15.7, -74.5], + [-15.41, -74.11], + [-16.47, -73.87], + [-16.11, -73.46], + [-15.45, -73.15], + [-14.41, -72.95], + [-13.31, -72.72], + [-12.29, -72.4], + [-11.51, -72.01], + [-11.02, -71.54], + [-10.3, -71.27], + [-9.1, -71.32], + [-8.61, -71.66], + [-7.42, -71.7], + [-7.38, -71.32], + [-6.87, -70.93], + [-5.79, -71.03], + [-5.54, -71.4], + [-4.34, -71.46], + [-3.05, -71.29], + [-1.8, -71.17], + [-0.66, -71.23], + [-0.23, -71.64], + [0.87, -71.3], + [1.89, -71.13], + [3.02, -70.99], + [4.14, -70.85], + [5.16, -70.62], + [6.27, -70.46], + [7.14, -70.25], + [7.74, -69.89], + [8.49, -70.15], + [9.53, -70.01], + [10.25, -70.48], + [10.82, -70.83], + [11.95, -70.64], + [12.4, -70.25], + [13.42, -69.97], + [14.73, -70.03], + [15.13, -70.4], + [15.95, -70.03], + [17.03, -69.91], + [18.2, -69.87], + [19.26, -69.89], + [20.38, -70.01], + [21.45, -70.07], + [21.92, -70.4], + [22.57, -70.7], + [23.67, -70.52], + [24.84, -70.48], + [25.98, -70.48], + [27.09, -70.46], + [28.09, -70.32], + [29.15, -70.21], + [30.03, -69.93], + [30.97, -69.76], + [31.99, -69.66], + [32.75, -69.38], + [33.3, -68.84], + [33.87, -68.5], + [34.91, -68.66], + [35.3, -69.01], + [36.16, -69.25], + [37.2, -69.17], + [37.91, -69.52], + [38.65, -69.78], + [39.67, -69.54], + [40.02, -69.11], + [40.92, -68.93], + [41.96, -68.6], + [42.94, -68.46], + [44.11, -68.27], + [44.9, -68.05], + [45.72, -67.82], + [46.5, -67.6], + [47.44, -67.72], + [48.34, -67.37], + [48.99, -67.09], + [49.93, -67.11], + [50.75, -66.88], + [50.95, -66.52], + [51.79, -66.25], + [52.61, -66.05], + [53.61, -65.9], + [54.53, -65.82], + [55.41, -65.88], + [56.36, -65.97], + [57.16, -66.25], + [57.26, -66.68], + [58.14, -67.01], + [58.74, -67.29], + [59.94, -67.41], + [60.61, -67.68], + [61.43, -67.95], + [62.39, -68.01], + [63.19, -67.82], + [64.05, -67.41], + [64.99, -67.62], + [65.97, -67.74], + [66.91, -67.86], + [67.89, -67.93], + [68.89, -67.93], + [69.71, -68.97], + [69.67, -69.23], + [69.56, -69.68], + [68.6, -69.93], + [67.81, -70.31], + [67.95, -70.7], + [69.07, -70.68], + [68.93, -71.07], + [68.42, -71.44], + [67.95, -71.85], + [68.71, -72.17], + [69.87, -72.26], + [71.02, -72.09], + [71.57, -71.7], + [71.91, -71.32], + [72.45, -71.01], + [73.08, -70.72], + [73.34, -70.36], + [73.86, -69.87], + [74.49, -69.78], + [75.63, -69.74], + [76.63, -69.62], + [77.64, -69.46], + [78.13, -69.07], + [78.43, -68.7], + [79.11, -68.33], + [80.09, -68.07], + [80.94, -67.88], + [81.48, -67.54], + [82.05, -67.37], + [82.78, -67.21], + [83.78, -67.31], + [84.68, -67.21], + [85.66, -67.09], + [86.75, -67.15], + [87.48, -66.88], + [87.99, -66.21], + [88.36, -66.48], + [88.83, -66.95], + [89.67, -67.15], + [90.63, -67.23], + [91.59, -67.11], + [92.61, -67.19], + [93.55, -67.21], + [94.18, -67.11], + [95.02, -67.17], + [95.78, -67.39], + [96.68, -67.25], + [97.76, -67.25], + [98.68, -67.11], + [99.72, -67.25], + [100.38, -66.92], + [100.89, -66.58], + [101.58, -66.31], + [102.83, -65.56], + [103.48, -65.7], + [104.24, -65.97], + [104.91, -66.33], + [106.18, -66.93], + [107.16, -66.95], + [108.08, -66.95], + [109.16, -66.84], + [110.24, -66.7], + [111.06, -66.43], + [111.74, -66.13], + [112.86, -66.09], + [113.6, -65.88], + [114.39, -66.07], + [114.9, -66.39], + [115.6, -66.7], + [116.7, -66.66], + [117.38, -66.92], + [118.58, -67.17], + [119.83, -67.27], + [120.87, -67.19], + [121.65, -66.88], + [122.32, -66.56], + [123.22, -66.48], + [124.12, -66.62], + [125.16, -66.72], + [126.1, -66.56], + [127.0, -66.56], + [127.88, -66.66], + [128.8, -66.76], + [129.7, -66.58], + [130.78, -66.43], + [131.8, -66.39], + [132.94, -66.39], + [133.86, -66.29], + [134.76, -66.21], + [135.03, -65.72], + [135.07, -65.31], + [135.7, -65.58], + [135.87, -66.03], + [136.21, -66.45], + [136.62, -66.78], + [137.46, -66.95], + [138.6, -66.9], + [139.91, -66.88], + [140.81, -66.82], + [142.12, -66.82], + [143.06, -66.8], + [144.37, -66.84], + [145.49, -66.92], + [146.2, -67.23], + [146.0, -67.6], + [146.65, -67.9], + [147.72, -68.13], + [148.84, -68.39], + [150.13, -68.56], + [151.48, -68.72], + [152.5, -68.87], + [153.64, -68.89], + [154.28, -68.56], + [155.17, -68.84], + [155.93, -69.15], + [156.81, -69.38], + [158.03, -69.48], + [159.18, -69.6], + [159.67, -69.99], + [160.81, -70.23], + [161.57, -70.58], + [162.69, -70.74], + [163.84, -70.72], + [164.92, -70.78], + [166.11, -70.76], + [167.31, -70.83], + [168.43, -70.97], + [169.46, -71.21], + [170.5, -71.4], + [171.21, -71.7], + [171.09, -72.09], + [170.56, -72.44], + [170.11, -72.89], + [169.76, -73.24], + [169.29, -73.66], + [167.98, -73.81], + [167.39, -74.17], + [166.09, -74.38], + [165.64, -74.77], + [164.96, -75.15], + [164.23, -75.46], + [163.82, -75.87], + [163.57, -76.24], + [163.47, -76.69], + [163.49, -77.07], + [164.06, -77.46], + [164.27, -77.83], + [164.74, -78.18], + [166.6, -78.32], + [167.0, -78.75], + [165.19, -78.91], + [163.67, -79.12], + [161.77, -79.16], + [160.92, -79.73], + [160.75, -80.2], + [160.32, -80.57], + [159.79, -80.95], + [161.12, -81.28], + [161.63, -81.69], + [162.49, -82.06], + [163.71, -82.4], + [165.1, -82.71], + [166.6, -83.02], + [168.9, -83.34], + [169.4, -83.83], + [172.28, -84.04], + [172.48, -84.12], + [173.22, -84.41], + [175.99, -84.16], + [178.28, -84.47], + [180, -84.71], + [180, -90], + [-180, -90], + [-180, -84.71], + [-179.94, -84.72], + [-179.06, -84.14], + [-177.26, -84.45], + [-177.14, -84.42], + [-176.86, -84.33], + [-176.52, -84.23], + [-176.23, -84.14], + [-176.08, -84.1], + [-175.93, -84.1], + [-175.83, -84.12], + [-174.38, -84.53], + [-173.12, -84.12], + [-172.89, -84.06], + [-169.95, -83.88], + [-169.0, -84.12], + [-168.53, -84.24], + [-167.02, -84.57], + [-164.18, -84.83], + [-161.93, -85.14], + [-158.07, -85.37], + [-155.19, -85.1], + [-150.94, -85.3], + [-148.53, -85.61], + [-145.89, -85.32], + [-143.11, -85.04], + [-142.89, -84.57], + [-146.83, -84.53], + [-150.06, -84.3], + [-150.9, -83.9], + [-153.59, -83.69], + [-153.41, -83.24], + [-153.04, -82.83], + [-152.67, -82.45], + [-152.86, -82.04], + [-154.53, -81.77], + [-155.29, -81.42], + [-156.84, -81.1], + [-154.41, -81.16], + [-152.1, -81.0], + [-150.65, -81.34], + [-148.87, -81.04], + [-147.22, -80.67], + [-146.42, -80.34], + [-146.77, -79.93], + [-148.06, -79.65], + [-149.53, -79.36], + [-151.59, -79.3], + [-153.39, -79.16], + [-155.33, -79.06], + [-155.98, -78.69], + [-157.27, -78.38], + [-158.05, -78.03], + [-158.37, -76.89], + [-157.88, -76.99], + [-156.97, -77.3], + [-155.33, -77.2], + [-153.74, -77.07], + [-152.92, -77.5], + [-151.33, -77.4], + [-150.0, -77.18], + [-148.75, -76.91], + [-147.61, -76.58], + [-146.1, -76.48], + [-146.14, -76.11], + [-146.5, -75.73], + [-146.2, -75.38], + [-144.91, -75.2], + [-144.32, -75.54], + [-142.79, -75.34], + [-141.64, -75.09], + [-140.21, -75.07], + [-138.86, -74.97], + [-137.51, -74.73], + [-136.43, -74.52], + [-135.21, -74.3], + [-134.43, -74.36], + [-133.75, -74.44], + [-132.26, -74.3], + [-130.93, -74.48], + [-129.55, -74.46], + [-128.24, -74.32], + [-126.89, -74.42], + [-125.4, -74.52], + [-124.01, -74.48], + [-122.56, -74.5], + [-121.07, -74.52], + [-119.7, -74.48], + [-118.68, -74.19], + [-117.47, -74.03], + [-116.22, -74.24], + [-115.02, -74.07], + [-113.94, -73.71], + [-113.3, -74.03], + [-112.95, -74.38], + [-112.3, -74.71], + [-111.26, -74.42], + [-110.07, -74.79], + [-108.71, -74.91], + [-107.56, -75.18], + [-106.15, -75.13], + [-104.88, -74.95], + [-103.37, -74.99], + [-102.02, -75.13], + [-100.65, -75.3], + [-100.12, -74.87], + [-100.76, -74.54], + [-101.25, -74.19], + [-102.55, -74.11], + [-103.11, -73.73], + [-103.33, -73.36], + [-103.68, -72.62], + [-102.92, -72.75], + [-101.61, -72.81], + [-100.31, -72.75], + [-99.14, -72.91], + [-98.12, -73.21], + [-97.69, -73.56], + [-96.34, -73.62], + [-95.04, -73.48], + [-93.67, -73.28], + [-92.44, -73.17], + [-91.42, -73.4], + [-90.09, -73.32], + [-89.23, -72.56], + [-88.42, -73.01], + [-87.27, -73.19], + [-86.01, -73.09], + [-85.19, -73.48], + [-83.88, -73.52], + [-82.67, -73.64], + [-81.47, -73.85], + [-80.69, -73.48], + [-80.3, -73.13], + [-79.3, -73.52], + [-77.93, -73.42], + [-76.91, -73.64], + [-76.22, -73.97], + [-74.89, -73.87], + [-73.85, -73.66], + [-72.83, -73.4], + [-71.62, -73.26], + [-70.21, -73.15], + [-68.94, -73.01], + [-67.96, -72.79], + [-67.37, -72.48], + [-67.13, -72.05], + [-67.25, -71.64], + [-67.56, -71.25], + [-67.92, -70.85], + [-68.23, -70.46], + [-68.49, -70.11], + [-68.54, -69.72], + [-68.45, -69.33], + [-67.98, -68.95], + [-67.58, -68.54], + [-67.43, -68.15], + [-67.62, -67.72], + [-67.74, -67.33], + [-67.25, -66.88], + [-66.7, -66.58], + [-66.06, -66.21], + [-65.37, -65.9], + [-64.57, -65.6], + [-64.18, -65.17], + [-63.63, -64.9], + [-63.0, -64.64], + [-62.04, -64.58], + [-61.41, -64.27], + [-60.71, -64.07], + [-59.89, -63.96], + [-59.16, -63.7], + [-58.59, -63.39], + [-57.81, -63.27], + [-57.22, -63.53], + [-57.6, -63.86], + [-58.61, -64.15] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-67.75, -53.85], + [-66.45, -54.45], + [-65.05, -54.7], + [-65.5, -55.2], + [-66.45, -55.25], + [-66.96, -54.9], + [-67.29, -55.3], + [-68.15, -55.61], + [-69.23, -55.5], + [-69.96, -55.2], + [-71.01, -55.05], + [-72.26, -54.5], + [-73.29, -53.96], + [-74.66, -52.84], + [-73.84, -53.05], + [-72.43, -53.72], + [-71.11, -54.07], + [-70.59, -53.62], + [-70.27, -52.93], + [-69.35, -52.52], + [-68.63, -52.64], + [-68.63, -52.64], + [-68.25, -53.1], + [-67.75, -53.85] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-58.55, -51.1], + [-57.75, -51.55], + [-58.05, -51.9], + [-59.4, -52.2], + [-59.85, -51.85], + [-60.7, -52.3], + [-61.2, -51.85], + [-60, -51.25], + [-59.15, -51.5], + [-58.55, -51.1] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [70.28, -49.71], + [68.75, -49.77], + [68.72, -49.24], + [68.87, -48.83], + [68.94, -48.62], + [69.58, -48.94], + [70.53, -49.06], + [70.56, -49.26], + [70.28, -49.71] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [145.4, -40.79], + [146.36, -41.14], + [146.91, -41.0], + [147.69, -40.81], + [148.29, -40.88], + [148.36, -42.06], + [148.02, -42.41], + [147.91, -43.21], + [147.56, -42.94], + [146.87, -43.63], + [146.66, -43.58], + [146.05, -43.55], + [145.43, -42.69], + [145.3, -42.03], + [144.72, -41.16], + [144.74, -40.7], + [145.4, -40.79] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [173.02, -40.92], + [173.25, -41.33], + [173.96, -40.93], + [174.25, -41.35], + [174.25, -41.77], + [173.88, -42.23], + [173.22, -42.97], + [172.71, -43.37], + [173.08, -43.85], + [172.31, -43.87], + [171.45, -44.24], + [171.19, -44.9], + [170.62, -45.91], + [169.83, -46.36], + [169.33, -46.64], + [168.41, -46.62], + [167.76, -46.29], + [166.68, -46.22], + [166.51, -45.85], + [167.05, -45.11], + [168.3, -44.12], + [168.95, -43.94], + [169.67, -43.56], + [170.52, -43.03], + [171.13, -42.51], + [171.57, -41.77], + [171.95, -41.51], + [172.1, -40.96], + [172.8, -40.49], + [173.02, -40.92] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [174.61, -36.16], + [175.34, -37.21], + [175.36, -36.53], + [175.81, -36.8], + [175.96, -37.56], + [176.76, -37.88], + [177.44, -37.96], + [178.01, -37.58], + [178.52, -37.7], + [178.27, -38.58], + [177.97, -39.17], + [177.21, -39.15], + [176.94, -39.45], + [177.03, -39.88], + [176.89, -40.07], + [176.51, -40.6], + [176.01, -41.29], + [175.24, -41.69], + [175.07, -41.43], + [174.65, -41.28], + [175.23, -40.46], + [174.9, -39.91], + [173.82, -39.51], + [173.85, -39.15], + [174.57, -38.8], + [174.74, -38.03], + [174.7, -37.38], + [174.29, -36.71], + [174.32, -36.53], + [173.84, -36.12], + [173.05, -35.24], + [172.64, -34.53], + [173.01, -34.45], + [173.55, -35.01], + [174.33, -35.27], + [174.61, -36.16] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [167.12, -22.16], + [166.74, -22.4], + [166.19, -22.13], + [165.47, -21.68], + [164.83, -21.15], + [164.17, -20.44], + [164.03, -20.11], + [164.46, -20.12], + [165.02, -20.46], + [165.46, -20.8], + [165.78, -21.08], + [166.6, -21.7], + [167.12, -22.16] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [178.37, -17.34], + [178.72, -17.63], + [178.55, -18.15], + [177.93, -18.29], + [177.38, -18.16], + [177.29, -17.72], + [177.67, -17.38], + [178.13, -17.5], + [178.37, -17.34] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [179.36, -16.8], + [178.73, -17.01], + [178.6, -16.64], + [179.1, -16.43], + [179.41, -16.38], + [180, -16.07], + [180, -16.56], + [179.36, -16.8] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-179.92, -16.5], + [-180, -16.56], + [-180, -16.07], + [-179.79, -16.02], + [-179.92, -16.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [167.84, -16.47], + [167.52, -16.6], + [167.18, -16.16], + [167.22, -15.89], + [167.84, -16.47] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [167.11, -14.93], + [167.27, -15.74], + [167.0, -15.61], + [166.79, -15.67], + [166.65, -15.39], + [166.63, -14.63], + [167.11, -14.93] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [50.06, -13.56], + [50.22, -14.76], + [50.48, -15.23], + [50.38, -15.71], + [50.2, -16.0], + [49.86, -15.41], + [49.67, -15.71], + [49.86, -16.45], + [49.77, -16.88], + [49.5, -17.11], + [49.44, -17.95], + [49.04, -19.12], + [48.55, -20.5], + [47.93, -22.39], + [47.55, -23.78], + [47.1, -24.94], + [46.28, -25.18], + [45.41, -25.6], + [44.83, -25.35], + [44.04, -24.99], + [43.76, -24.46], + [43.7, -23.57], + [43.35, -22.78], + [43.25, -22.06], + [43.43, -21.34], + [43.89, -21.16], + [43.9, -20.83], + [44.37, -20.07], + [44.46, -19.44], + [44.23, -18.96], + [44.04, -18.33], + [43.96, -17.41], + [44.31, -16.85], + [44.45, -16.22], + [44.94, -16.18], + [45.5, -15.97], + [45.87, -15.79], + [46.31, -15.78], + [46.88, -15.21], + [47.71, -14.59], + [48.01, -14.09], + [47.87, -13.66], + [48.29, -13.78], + [48.85, -13.09], + [48.86, -12.49], + [49.19, -12.04], + [49.54, -12.47], + [49.81, -12.9], + [50.06, -13.56] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [143.56, -13.76], + [143.92, -14.55], + [144.56, -14.17], + [144.89, -14.59], + [145.37, -14.98], + [145.27, -15.43], + [145.49, -16.29], + [145.64, -16.78], + [145.89, -16.91], + [146.16, -17.76], + [146.06, -18.28], + [146.39, -18.96], + [147.47, -19.48], + [148.18, -19.96], + [148.85, -20.39], + [148.72, -20.63], + [149.29, -21.26], + [149.68, -22.34], + [150.08, -22.12], + [150.48, -22.56], + [150.73, -22.4], + [150.9, -23.46], + [151.61, -24.08], + [152.07, -24.46], + [152.86, -25.27], + [153.14, -26.07], + [153.16, -26.64], + [153.09, -27.26], + [153.57, -28.11], + [153.51, -29.0], + [153.34, -29.46], + [153.07, -30.35], + [153.09, -30.92], + [152.89, -31.64], + [152.45, -32.55], + [151.71, -33.04], + [151.34, -33.82], + [151.01, -34.31], + [150.71, -35.17], + [150.33, -35.67], + [150.08, -36.42], + [149.95, -37.11], + [150.0, -37.43], + [149.42, -37.77], + [148.3, -37.81], + [147.38, -38.22], + [146.92, -38.61], + [146.32, -39.04], + [145.49, -38.59], + [144.88, -38.42], + [145.03, -37.9], + [144.49, -38.09], + [143.61, -38.81], + [142.75, -38.54], + [142.18, -38.38], + [141.61, -38.31], + [140.64, -38.02], + [139.99, -37.4], + [139.81, -36.64], + [139.57, -36.14], + [139.08, -35.73], + [138.12, -35.61], + [138.45, -35.13], + [138.21, -34.38], + [137.72, -35.08], + [136.83, -35.26], + [137.35, -34.71], + [137.5, -34.13], + [137.89, -33.64], + [137.81, -32.9], + [137.0, -33.75], + [136.37, -34.09], + [135.99, -34.89], + [135.21, -34.48], + [135.24, -33.95], + [134.61, -33.22], + [134.09, -32.85], + [134.27, -32.62], + [132.99, -32.01], + [132.29, -31.98], + [131.33, -31.5], + [129.54, -31.59], + [128.24, -31.95], + [127.1, -32.28], + [126.15, -32.22], + [125.09, -32.73], + [124.22, -32.96], + [124.03, -33.48], + [123.66, -33.89], + [122.81, -33.91], + [122.18, -34.0], + [121.3, -33.82], + [120.58, -33.93], + [119.89, -33.98], + [119.3, -34.51], + [119.01, -34.46], + [118.51, -34.75], + [118.02, -35.06], + [117.3, -35.03], + [116.63, -35.03], + [115.56, -34.39], + [115.03, -34.2], + [115.05, -33.62], + [115.55, -33.49], + [115.71, -33.26], + [115.68, -32.9], + [115.8, -32.21], + [115.69, -31.61], + [115.16, -30.6], + [115.0, -30.03], + [115.04, -29.46], + [114.64, -28.81], + [114.62, -28.52], + [114.17, -28.12], + [114.05, -27.33], + [113.48, -26.54], + [113.34, -26.12], + [113.78, -26.55], + [113.44, -25.62], + [113.94, -25.91], + [114.23, -26.3], + [114.22, -25.79], + [113.72, -25.0], + [113.63, -24.68], + [113.39, -24.38], + [113.5, -23.81], + [113.71, -23.56], + [113.84, -23.06], + [113.74, -22.48], + [114.15, -21.76], + [114.23, -22.52], + [114.65, -21.83], + [115.46, -21.5], + [115.95, -21.07], + [116.71, -20.7], + [117.17, -20.62], + [117.44, -20.75], + [118.23, -20.37], + [118.84, -20.26], + [118.99, -20.04], + [119.25, -19.95], + [119.81, -19.98], + [120.86, -19.68], + [121.4, -19.24], + [121.66, -18.71], + [122.24, -18.2], + [122.29, -17.8], + [122.31, -17.25], + [123.01, -16.41], + [123.43, -17.27], + [123.86, -17.07], + [123.5, -16.6], + [123.82, -16.11], + [124.26, -16.33], + [124.38, -15.57], + [124.93, -15.08], + [125.17, -14.68], + [125.67, -14.51], + [125.69, -14.23], + [126.13, -14.35], + [126.14, -14.1], + [126.58, -13.95], + [127.07, -13.82], + [127.8, -14.28], + [128.36, -14.87], + [128.99, -14.88], + [129.62, -14.97], + [129.41, -14.42], + [129.89, -13.62], + [130.34, -13.36], + [130.18, -13.11], + [130.62, -12.54], + [131.22, -12.18], + [131.74, -12.3], + [132.58, -12.11], + [132.56, -11.6], + [131.82, -11.27], + [132.36, -11.13], + [133.02, -11.38], + [133.55, -11.79], + [134.39, -12.04], + [134.68, -11.94], + [135.3, -12.25], + [135.88, -11.96], + [136.26, -12.05], + [136.49, -11.86], + [136.95, -12.35], + [136.69, -12.89], + [136.31, -13.29], + [135.96, -13.32], + [136.08, -13.72], + [135.78, -14.22], + [135.43, -14.72], + [135.5, -15.0], + [136.3, -15.55], + [137.07, -15.87], + [137.58, -16.22], + [138.3, -16.81], + [138.59, -16.81], + [139.11, -17.06], + [139.26, -17.37], + [140.22, -17.71], + [140.88, -17.37], + [141.07, -16.83], + [141.27, -16.39], + [141.4, -15.84], + [141.7, -15.04], + [141.56, -14.56], + [141.64, -14.27], + [141.52, -13.7], + [141.65, -12.94], + [141.84, -12.74], + [141.69, -12.41], + [141.93, -11.88], + [142.12, -11.33], + [142.14, -11.04], + [142.52, -10.67], + [142.8, -11.16], + [142.87, -11.78], + [143.12, -11.91], + [143.16, -12.33], + [143.52, -12.83], + [143.6, -13.4], + [143.56, -13.76] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [162.12, -10.48], + [162.4, -10.83], + [161.7, -10.82], + [161.32, -10.2], + [161.92, -10.45], + [162.12, -10.48] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [120.72, -10.24], + [120.3, -10.26], + [118.97, -9.56], + [119.9, -9.36], + [120.43, -9.67], + [120.78, -9.97], + [120.72, -10.24] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [160.85, -9.87], + [160.46, -9.9], + [159.85, -9.79], + [159.64, -9.64], + [159.7, -9.24], + [160.36, -9.4], + [160.69, -9.61], + [160.85, -9.87] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [161.68, -9.6], + [161.53, -9.78], + [160.79, -8.92], + [160.58, -8.32], + [160.92, -8.32], + [161.28, -9.12], + [161.68, -9.6] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [124.44, -10.14], + [123.58, -10.36], + [123.46, -10.24], + [123.55, -9.9], + [123.98, -9.29], + [124.97, -8.89], + [125.09, -8.66], + [125.95, -8.43], + [126.64, -8.4], + [126.96, -8.27], + [127.34, -8.4], + [126.97, -8.67], + [125.93, -9.11], + [125.09, -9.39], + [124.44, -10.14] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [117.9, -8.1], + [118.26, -8.36], + [118.88, -8.28], + [119.13, -8.71], + [117.97, -8.91], + [117.28, -9.04], + [116.74, -9.03], + [117.08, -8.46], + [117.63, -8.45], + [117.9, -8.1] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [122.9, -8.09], + [122.76, -8.65], + [121.25, -8.93], + [119.92, -8.81], + [119.92, -8.44], + [120.72, -8.24], + [121.34, -8.54], + [122.01, -8.46], + [122.9, -8.09] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [159.88, -8.34], + [159.92, -8.54], + [159.13, -8.11], + [158.59, -7.75], + [158.21, -7.42], + [158.36, -7.32], + [158.82, -7.56], + [159.64, -8.02], + [159.88, -8.34] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [157.54, -7.35], + [157.34, -7.4], + [156.9, -7.18], + [156.49, -6.77], + [156.54, -6.6], + [157.14, -7.02], + [157.54, -7.35] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [108.62, -6.78], + [110.54, -6.88], + [110.76, -6.47], + [112.61, -6.95], + [112.98, -7.59], + [114.48, -7.78], + [115.71, -8.37], + [114.56, -8.75], + [113.46, -8.35], + [112.56, -8.38], + [111.52, -8.3], + [110.59, -8.12], + [109.43, -7.74], + [108.69, -7.64], + [108.28, -7.77], + [106.45, -7.35], + [106.28, -6.92], + [105.37, -6.85], + [106.05, -5.9], + [107.27, -5.95], + [108.07, -6.35], + [108.49, -6.42], + [108.62, -6.78] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [134.72, -6.21], + [134.21, -6.9], + [134.11, -6.14], + [134.29, -5.78], + [134.5, -5.45], + [134.73, -5.74], + [134.72, -6.21] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [155.88, -6.82], + [155.6, -6.92], + [155.17, -6.54], + [154.73, -5.9], + [154.51, -5.14], + [154.65, -5.04], + [154.76, -5.34], + [155.06, -5.57], + [155.55, -6.2], + [156.02, -6.54], + [155.88, -6.82] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [151.98, -5.48], + [151.46, -5.56], + [151.3, -5.84], + [150.75, -6.08], + [150.24, -6.32], + [149.71, -6.32], + [148.89, -6.03], + [148.32, -5.75], + [148.4, -5.44], + [149.3, -5.58], + [149.85, -5.51], + [150.0, -5.03], + [150.14, -5.0], + [150.24, -5.53], + [150.81, -5.46], + [151.09, -5.11], + [151.65, -4.76], + [151.54, -4.17], + [152.14, -4.15], + [152.34, -4.31], + [152.32, -4.87], + [151.98, -5.48] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [127.25, -3.46], + [126.87, -3.79], + [126.18, -3.61], + [125.99, -3.18], + [127.0, -3.13], + [127.25, -3.46] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [130.47, -3.09], + [130.83, -3.86], + [129.99, -3.45], + [129.16, -3.36], + [128.59, -3.43], + [127.9, -3.39], + [128.14, -2.84], + [129.37, -2.8], + [130.47, -3.09] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [153.14, -4.5], + [152.83, -4.77], + [152.64, -4.18], + [152.41, -3.79], + [151.95, -3.46], + [151.38, -3.04], + [150.66, -2.74], + [150.94, -2.5], + [151.48, -2.78], + [151.82, -3.0], + [152.24, -3.24], + [152.64, -3.66], + [153.02, -3.98], + [153.14, -4.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [134.14, -1.15], + [134.42, -2.77], + [135.46, -3.37], + [136.29, -2.31], + [137.44, -1.7], + [138.33, -1.7], + [139.18, -2.05], + [139.93, -2.41], + [141.0, -2.6], + [142.74, -3.29], + [144.58, -3.86], + [145.27, -4.37], + [145.83, -4.88], + [145.98, -5.47], + [147.65, -6.08], + [147.89, -6.61], + [146.97, -6.72], + [147.19, -7.39], + [148.08, -8.04], + [148.73, -9.1], + [149.31, -9.07], + [149.27, -9.51], + [150.04, -9.68], + [149.74, -9.87], + [150.8, -10.29], + [150.69, -10.58], + [150.03, -10.65], + [149.78, -10.39], + [148.92, -10.28], + [147.91, -10.13], + [147.14, -9.49], + [146.57, -8.94], + [146.05, -8.07], + [144.74, -7.63], + [143.9, -7.92], + [143.29, -8.25], + [143.41, -8.98], + [142.63, -9.33], + [142.07, -9.16], + [141.03, -9.12], + [140.14, -8.3], + [139.13, -8.1], + [138.88, -8.38], + [137.61, -8.41], + [138.04, -7.6], + [138.67, -7.32], + [138.41, -6.23], + [137.93, -5.39], + [135.99, -4.55], + [135.16, -4.46], + [133.66, -3.54], + [133.37, -4.02], + [132.98, -4.11], + [132.76, -3.75], + [132.75, -3.31], + [131.99, -2.82], + [133.07, -2.46], + [133.78, -2.48], + [133.7, -2.21], + [132.23, -2.21], + [131.84, -1.62], + [130.94, -1.43], + [130.52, -0.94], + [131.87, -0.7], + [132.38, -0.37], + [133.99, -0.78], + [134.14, -1.15] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [125.24, 1.42], + [124.44, 0.43], + [123.69, 0.24], + [122.72, 0.43], + [121.06, 0.38], + [120.18, 0.24], + [120.04, -0.52], + [120.94, -1.41], + [121.48, -0.96], + [123.34, -0.62], + [123.26, -1.08], + [122.82, -0.93], + [122.39, -1.52], + [121.51, -1.9], + [122.45, -3.19], + [122.27, -3.53], + [123.17, -4.68], + [123.16, -5.34], + [122.63, -5.63], + [122.24, -5.28], + [122.72, -4.46], + [121.74, -4.85], + [121.49, -4.57], + [121.62, -4.19], + [120.9, -3.6], + [120.97, -2.63], + [120.31, -2.93], + [120.39, -4.1], + [120.43, -5.53], + [119.8, -5.67], + [119.37, -5.38], + [119.65, -4.46], + [119.5, -3.49], + [119.08, -3.49], + [118.77, -2.8], + [119.18, -2.15], + [119.32, -1.35], + [119.83, 0.15], + [120.04, 0.57], + [120.89, 1.31], + [121.67, 1.01], + [122.93, 0.88], + [124.08, 0.92], + [125.07, 1.64], + [125.24, 1.42] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [128.69, 1.13], + [128.64, 0.26], + [128.12, 0.36], + [127.97, -0.25], + [128.38, -0.78], + [128.1, -0.9], + [127.7, -0.27], + [127.4, 1.01], + [127.6, 1.81], + [127.93, 2.17], + [128.0, 1.63], + [128.59, 1.54], + [128.69, 1.13] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [105.82, -5.85], + [104.71, -5.87], + [103.87, -5.04], + [102.58, -4.22], + [102.16, -3.61], + [101.4, -2.8], + [100.9, -2.05], + [100.14, -0.65], + [99.26, 0.18], + [98.97, 1.04], + [98.6, 1.82], + [97.7, 2.45], + [97.18, 3.31], + [96.42, 3.87], + [95.38, 4.97], + [95.29, 5.48], + [95.94, 5.44], + [97.48, 5.25], + [98.37, 4.27], + [99.14, 3.59], + [99.69, 3.17], + [100.64, 2.1], + [101.66, 2.08], + [102.5, 1.4], + [103.08, 0.56], + [103.84, 0.1], + [103.44, -0.71], + [104.01, -1.06], + [104.37, -1.08], + [104.54, -1.78], + [104.89, -2.34], + [105.62, -2.43], + [106.11, -3.06], + [105.86, -4.31], + [105.82, -5.85] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [117.88, 1.83], + [119.0, 0.9], + [117.81, 0.78], + [117.48, 0.1], + [117.52, -0.8], + [116.56, -1.49], + [116.53, -2.48], + [116.15, -4.01], + [116.0, -3.66], + [114.86, -4.11], + [114.47, -3.5], + [113.76, -3.44], + [113.26, -3.12], + [112.07, -3.48], + [111.7, -2.99], + [111.05, -3.05], + [110.22, -2.93], + [110.07, -1.59], + [109.57, -1.31], + [109.09, -0.46], + [108.95, 0.42], + [109.07, 1.34], + [109.66, 2.01], + [110.4, 1.66], + [111.17, 1.85], + [111.37, 2.7], + [111.8, 2.89], + [113.0, 3.1], + [113.71, 3.89], + [114.2, 4.53], + [114.6, 4.9], + [115.45, 5.45], + [116.22, 6.14], + [116.73, 6.92], + [117.13, 6.93], + [117.64, 6.42], + [117.69, 5.99], + [118.35, 5.71], + [119.18, 5.41], + [119.11, 5.02], + [118.44, 4.97], + [118.62, 4.48], + [117.88, 4.14], + [117.31, 3.23], + [118.05, 2.29], + [117.88, 1.83] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [126.38, 8.41], + [126.48, 7.75], + [126.54, 7.19], + [126.2, 6.27], + [125.83, 7.29], + [125.36, 6.79], + [125.68, 6.05], + [125.4, 5.58], + [124.22, 6.16], + [123.94, 6.89], + [124.24, 7.36], + [123.61, 7.83], + [123.3, 7.42], + [122.83, 7.46], + [122.09, 6.9], + [121.92, 7.19], + [122.31, 8.03], + [122.94, 8.32], + [123.49, 8.69], + [123.84, 8.24], + [124.6, 8.51], + [124.76, 8.96], + [125.47, 8.99], + [125.41, 9.76], + [126.22, 9.29], + [126.31, 8.78], + [126.38, 8.41] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [81.22, 6.2], + [80.35, 5.97], + [79.87, 6.76], + [79.7, 8.2], + [80.15, 9.82], + [80.84, 9.27], + [81.3, 8.56], + [81.79, 7.52], + [81.64, 6.48], + [81.22, 6.2] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-60.94, 10.11], + [-61.77, 10], + [-61.95, 10.09], + [-61.66, 10.37], + [-61.68, 10.76], + [-61.1, 10.89], + [-60.9, 10.86], + [-60.94, 10.11] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [123.98, 10.28], + [123.62, 9.95], + [123.31, 9.32], + [123.0, 9.02], + [122.38, 9.71], + [122.59, 9.98], + [122.84, 10.26], + [122.95, 10.88], + [123.5, 10.94], + [123.34, 10.27], + [124.08, 11.23], + [123.98, 10.28] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [118.5, 9.32], + [117.17, 8.37], + [117.66, 9.07], + [118.39, 9.68], + [118.99, 10.38], + [119.51, 11.37], + [119.69, 10.55], + [119.03, 10.0], + [118.5, 9.32] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [121.88, 11.89], + [122.48, 11.58], + [123.12, 11.58], + [123.1, 11.17], + [122.64, 10.74], + [122.0, 10.44], + [121.97, 10.91], + [122.04, 11.42], + [121.88, 11.89] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [125.5, 12.16], + [125.78, 11.05], + [125.01, 11.31], + [125.03, 10.98], + [125.28, 10.36], + [124.8, 10.13], + [124.76, 10.84], + [124.46, 10.89], + [124.3, 11.5], + [124.89, 11.42], + [124.88, 11.79], + [124.27, 12.56], + [125.23, 12.54], + [125.5, 12.16] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [121.53, 13.07], + [121.26, 12.21], + [120.83, 12.7], + [120.32, 13.47], + [121.18, 13.43], + [121.53, 13.07] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [121.32, 18.5], + [121.94, 18.22], + [122.25, 18.48], + [122.34, 18.22], + [122.17, 17.81], + [122.52, 17.09], + [122.25, 16.26], + [121.66, 15.93], + [121.51, 15.12], + [121.73, 14.33], + [122.26, 14.22], + [122.7, 14.34], + [123.95, 13.78], + [123.86, 13.24], + [124.18, 13.0], + [124.08, 12.54], + [123.3, 13.03], + [122.93, 13.55], + [122.67, 13.19], + [122.03, 13.78], + [121.13, 13.64], + [120.63, 13.86], + [120.68, 14.27], + [120.99, 14.53], + [120.69, 14.76], + [120.56, 14.4], + [120.07, 14.97], + [119.92, 15.41], + [119.88, 16.36], + [120.29, 16.03], + [120.39, 17.6], + [120.72, 18.51], + [121.32, 18.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-65.59, 18.23], + [-65.85, 17.98], + [-66.6, 17.98], + [-67.18, 17.95], + [-67.24, 18.37], + [-67.1, 18.52], + [-66.28, 18.51], + [-65.77, 18.43], + [-65.59, 18.23] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-76.9, 17.87], + [-77.21, 17.7], + [-77.77, 17.86], + [-78.34, 18.23], + [-78.22, 18.45], + [-77.8, 18.52], + [-77.57, 18.49], + [-76.9, 18.4], + [-76.37, 18.16], + [-76.2, 17.89], + [-76.9, 17.87] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-72.58, 19.87], + [-71.71, 19.71], + [-71.59, 19.88], + [-70.81, 19.88], + [-70.21, 19.62], + [-69.95, 19.65], + [-69.77, 19.29], + [-69.22, 19.31], + [-69.25, 19.02], + [-68.81, 18.98], + [-68.32, 18.61], + [-68.69, 18.21], + [-69.16, 18.42], + [-69.62, 18.38], + [-69.95, 18.43], + [-70.13, 18.25], + [-70.52, 18.18], + [-70.67, 18.43], + [-71.0, 18.28], + [-71.4, 17.6], + [-71.66, 17.76], + [-71.71, 18.04], + [-72.37, 18.21], + [-72.84, 18.15], + [-73.45, 18.22], + [-73.92, 18.03], + [-74.46, 18.34], + [-74.37, 18.66], + [-73.45, 18.53], + [-72.69, 18.45], + [-72.33, 18.67], + [-72.79, 19.1], + [-72.78, 19.48], + [-73.42, 19.64], + [-73.19, 19.92], + [-72.58, 19.87] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [110.34, 18.68], + [109.48, 18.2], + [108.66, 18.51], + [108.63, 19.37], + [109.12, 19.82], + [110.21, 20.1], + [110.79, 20.08], + [111.01, 19.7], + [110.57, 19.26], + [110.34, 18.68] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-155.54, 19.08], + [-155.69, 18.92], + [-155.94, 19.06], + [-155.91, 19.34], + [-156.07, 19.7], + [-156.02, 19.81], + [-155.85, 19.98], + [-155.92, 20.17], + [-155.86, 20.27], + [-155.79, 20.25], + [-155.4, 20.08], + [-155.22, 19.99], + [-155.06, 19.86], + [-154.81, 19.51], + [-154.83, 19.45], + [-155.22, 19.24], + [-155.54, 19.08] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-156.08, 20.64], + [-156.41, 20.57], + [-156.59, 20.78], + [-156.7, 20.86], + [-156.71, 20.93], + [-156.61, 21.01], + [-156.26, 20.92], + [-156.0, 20.76], + [-156.08, 20.64] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-156.76, 21.18], + [-156.79, 21.07], + [-157.33, 21.1], + [-157.25, 21.22], + [-156.76, 21.18] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-157.65, 21.32], + [-157.71, 21.26], + [-157.78, 21.28], + [-158.13, 21.31], + [-158.25, 21.54], + [-158.29, 21.58], + [-158.03, 21.72], + [-157.94, 21.65], + [-157.65, 21.32] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-159.35, 21.98], + [-159.46, 21.88], + [-159.8, 22.07], + [-159.75, 22.14], + [-159.6, 22.24], + [-159.37, 22.21], + [-159.35, 21.98] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-79.68, 22.77], + [-79.28, 22.4], + [-78.35, 22.51], + [-77.99, 22.28], + [-77.15, 21.66], + [-76.52, 21.21], + [-76.19, 21.22], + [-75.6, 21.02], + [-75.67, 20.74], + [-74.93, 20.69], + [-74.18, 20.28], + [-74.3, 20.05], + [-74.96, 19.92], + [-75.63, 19.87], + [-76.32, 19.95], + [-77.76, 19.86], + [-77.09, 20.41], + [-77.49, 20.67], + [-78.14, 20.74], + [-78.48, 21.03], + [-78.72, 21.6], + [-79.28, 21.56], + [-80.22, 21.83], + [-80.52, 22.04], + [-81.82, 22.19], + [-82.17, 22.39], + [-81.8, 22.64], + [-82.78, 22.69], + [-83.49, 22.17], + [-83.91, 22.15], + [-84.05, 21.91], + [-84.55, 21.8], + [-84.97, 21.9], + [-84.45, 22.2], + [-84.23, 22.57], + [-83.78, 22.79], + [-83.27, 22.98], + [-82.51, 23.08], + [-82.27, 23.19], + [-81.4, 23.12], + [-80.62, 23.11], + [-79.68, 22.77] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.53, 23.76], + [-77.78, 23.71], + [-78.03, 24.29], + [-78.41, 24.58], + [-78.19, 25.21], + [-77.89, 25.17], + [-77.54, 24.34], + [-77.53, 23.76] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [121.18, 22.79], + [120.75, 21.97], + [120.22, 22.81], + [120.11, 23.56], + [120.69, 24.54], + [121.5, 25.3], + [121.95, 25.0], + [121.78, 24.39], + [121.18, 22.79] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.82, 26.58], + [-78.91, 26.42], + [-78.98, 26.79], + [-78.51, 26.87], + [-77.85, 26.84], + [-77.82, 26.58] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77, 26.59], + [-77.17, 25.88], + [-77.36, 26.01], + [-77.34, 26.53], + [-77.79, 26.93], + [-77.79, 27.04], + [-77, 26.59] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [134.64, 34.15], + [134.77, 33.81], + [134.2, 33.2], + [133.79, 33.52], + [133.28, 33.29], + [133.01, 32.7], + [132.36, 32.99], + [132.37, 33.46], + [132.92, 34.06], + [133.49, 33.94], + [133.9, 34.36], + [134.64, 34.15] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [34.58, 35.67], + [33.9, 35.25], + [33.97, 35.06], + [34.0, 34.98], + [32.98, 34.57], + [32.49, 34.7], + [32.26, 35.1], + [32.73, 35.14], + [32.8, 35.15], + [32.95, 35.39], + [33.67, 35.37], + [34.58, 35.67] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [23.7, 35.71], + [24.25, 35.37], + [25.03, 35.42], + [25.77, 35.35], + [25.75, 35.18], + [26.29, 35.3], + [26.16, 35.0], + [24.72, 34.92], + [24.74, 35.08], + [23.51, 35.28], + [23.7, 35.71] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [15.52, 38.23], + [15.16, 37.44], + [15.31, 37.13], + [15.1, 36.62], + [14.34, 37.0], + [13.83, 37.1], + [12.43, 37.61], + [12.57, 38.13], + [13.74, 38.03], + [14.76, 38.14], + [15.52, 38.23] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [9.21, 41.21], + [9.81, 40.5], + [9.67, 39.18], + [9.21, 39.24], + [8.81, 38.91], + [8.43, 39.17], + [8.39, 40.38], + [8.16, 40.95], + [8.71, 40.9], + [9.21, 41.21] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [140.98, 37.14], + [140.6, 36.34], + [140.77, 35.84], + [140.25, 35.14], + [138.98, 34.67], + [137.22, 34.61], + [135.79, 33.46], + [135.12, 33.85], + [135.08, 34.6], + [133.34, 34.38], + [132.16, 33.9], + [130.99, 33.89], + [132.0, 33.15], + [131.33, 31.45], + [130.69, 31.03], + [130.2, 31.42], + [130.45, 32.32], + [129.81, 32.61], + [129.41, 33.3], + [130.35, 33.6], + [130.88, 34.23], + [131.88, 34.75], + [132.62, 35.43], + [134.61, 35.73], + [135.68, 35.53], + [136.72, 37.3], + [137.39, 36.83], + [138.86, 37.83], + [139.43, 38.22], + [140.05, 39.44], + [139.88, 40.56], + [140.31, 41.2], + [141.37, 41.38], + [141.91, 39.99], + [141.88, 39.18], + [140.96, 38.17], + [140.98, 37.14] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [9.56, 42.15], + [9.23, 41.38], + [8.78, 41.58], + [8.54, 42.26], + [8.75, 42.63], + [9.39, 43.01], + [9.56, 42.15] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [143.91, 44.17], + [144.61, 43.96], + [145.32, 44.38], + [145.54, 43.26], + [144.06, 42.99], + [143.18, 42.0], + [141.61, 42.68], + [141.07, 41.58], + [139.96, 41.57], + [139.82, 42.56], + [140.31, 43.33], + [141.38, 43.39], + [141.67, 44.77], + [141.97, 45.55], + [143.14, 44.51], + [143.91, 44.17] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-63.66, 46.55], + [-62.94, 46.42], + [-62.01, 46.44], + [-62.5, 46.03], + [-62.87, 45.97], + [-64.14, 46.39], + [-64.39, 46.73], + [-64.01, 47.04], + [-63.66, 46.55] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-61.81, 49.11], + [-62.29, 49.09], + [-63.59, 49.4], + [-64.52, 49.87], + [-64.17, 49.96], + [-62.86, 49.71], + [-61.84, 49.29], + [-61.81, 49.11] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-123.51, 48.51], + [-124.01, 48.37], + [-125.66, 48.83], + [-125.95, 49.18], + [-126.85, 49.53], + [-127.03, 49.81], + [-128.06, 49.99], + [-128.44, 50.54], + [-128.36, 50.77], + [-127.31, 50.55], + [-126.7, 50.4], + [-125.76, 50.3], + [-125.42, 49.95], + [-124.92, 49.48], + [-123.92, 49.06], + [-123.51, 48.51] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-56.13, 50.69], + [-56.8, 49.81], + [-56.14, 50.15], + [-55.47, 49.94], + [-55.82, 49.59], + [-54.94, 49.31], + [-54.47, 49.56], + [-53.48, 49.25], + [-53.79, 48.52], + [-53.09, 48.69], + [-52.96, 48.16], + [-52.65, 47.54], + [-53.07, 46.66], + [-53.52, 46.62], + [-54.18, 46.81], + [-53.96, 47.63], + [-54.24, 47.75], + [-55.4, 46.88], + [-56.0, 46.92], + [-55.29, 47.39], + [-56.25, 47.63], + [-57.33, 47.57], + [-59.27, 47.6], + [-59.42, 47.9], + [-58.8, 48.25], + [-59.23, 48.52], + [-58.39, 49.13], + [-57.36, 50.72], + [-56.74, 51.29], + [-55.87, 51.63], + [-55.41, 51.59], + [-55.6, 51.32], + [-56.13, 50.69] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-132.71, 54.04], + [-132.71, 54.04], + [-132.71, 54.04], + [-132.71, 54.04], + [-131.75, 54.12], + [-132.05, 52.98], + [-131.18, 52.18], + [-131.58, 52.18], + [-132.18, 52.64], + [-132.55, 53.1], + [-133.05, 53.41], + [-133.24, 53.85], + [-133.18, 54.17], + [-132.71, 54.04] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [143.65, 50.75], + [144.65, 48.98], + [143.17, 49.31], + [142.56, 47.86], + [143.53, 46.84], + [143.51, 46.14], + [142.75, 46.74], + [142.09, 45.97], + [141.91, 46.81], + [142.02, 47.78], + [141.9, 48.86], + [142.14, 49.62], + [142.18, 50.95], + [141.59, 51.94], + [141.68, 53.3], + [142.61, 53.76], + [142.21, 54.23], + [142.65, 54.37], + [142.91, 53.7], + [143.26, 52.74], + [143.24, 51.76], + [143.65, 50.75] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-6.79, 52.26], + [-8.56, 51.67], + [-9.98, 51.82], + [-9.17, 52.86], + [-9.69, 53.88], + [-8.33, 54.66], + [-7.57, 55.13], + [-6.73, 55.17], + [-5.66, 54.55], + [-6.2, 53.87], + [-6.03, 53.15], + [-6.79, 52.26] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [12.69, 55.61], + [12.09, 54.8], + [11.04, 55.36], + [10.9, 55.78], + [12.37, 56.11], + [12.69, 55.61] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-153.01, 57.12], + [-154.01, 56.73], + [-154.52, 56.99], + [-154.67, 57.46], + [-153.76, 57.82], + [-153.23, 57.97], + [-152.56, 57.9], + [-152.14, 57.59], + [-153.01, 57.12] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-3.01, 58.63], + [-4.07, 57.55], + [-3.06, 57.69], + [-1.96, 57.68], + [-2.22, 56.87], + [-3.12, 55.97], + [-2.09, 55.91], + [-1.11, 54.62], + [-0.43, 54.46], + [0.18, 53.33], + [0.47, 52.93], + [1.68, 52.74], + [1.56, 52.1], + [1.05, 51.81], + [1.45, 51.29], + [0.55, 50.77], + [-0.79, 50.77], + [-2.49, 50.5], + [-2.96, 50.7], + [-3.62, 50.23], + [-4.54, 50.34], + [-5.25, 49.96], + [-5.78, 50.16], + [-4.31, 51.21], + [-3.41, 51.43], + [-4.98, 51.59], + [-5.27, 51.99], + [-4.22, 52.3], + [-4.77, 52.84], + [-4.58, 53.5], + [-3.09, 53.4], + [-2.95, 53.98], + [-3.63, 54.62], + [-4.84, 54.79], + [-5.08, 55.06], + [-4.72, 55.51], + [-5.05, 55.78], + [-5.59, 55.31], + [-5.64, 56.28], + [-6.15, 56.79], + [-5.79, 57.82], + [-5.01, 58.63], + [-4.21, 58.55], + [-3.01, 58.63] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-165.58, 59.91], + [-166.19, 59.75], + [-166.85, 59.94], + [-167.46, 60.21], + [-166.47, 60.38], + [-165.67, 60.29], + [-165.58, 59.91] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-79.27, 62.16], + [-79.66, 61.63], + [-80.1, 61.72], + [-80.36, 62.02], + [-80.32, 62.09], + [-79.93, 62.39], + [-79.52, 62.36], + [-79.27, 62.16] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-81.9, 62.71], + [-83.07, 62.16], + [-83.77, 62.18], + [-83.99, 62.45], + [-83.25, 62.91], + [-81.88, 62.9], + [-81.9, 62.71] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-171.73, 63.78], + [-171.11, 63.59], + [-170.49, 63.69], + [-169.68, 63.43], + [-168.69, 63.3], + [-168.77, 63.19], + [-169.53, 62.98], + [-170.29, 63.19], + [-170.67, 63.38], + [-171.55, 63.32], + [-171.79, 63.41], + [-171.73, 63.78] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-85.16, 65.66], + [-84.98, 65.22], + [-84.46, 65.37], + [-83.88, 65.11], + [-82.79, 64.77], + [-81.64, 64.46], + [-81.55, 63.98], + [-80.82, 64.06], + [-80.1, 63.73], + [-80.99, 63.41], + [-82.55, 63.65], + [-83.11, 64.1], + [-84.1, 63.57], + [-85.52, 63.05], + [-85.87, 63.64], + [-87.22, 63.54], + [-86.35, 64.04], + [-86.22, 64.82], + [-85.88, 65.74], + [-85.16, 65.66] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-14.51, 66.46], + [-14.74, 65.81], + [-13.61, 65.13], + [-14.91, 64.36], + [-17.79, 63.68], + [-18.66, 63.5], + [-19.97, 63.64], + [-22.76, 63.96], + [-21.78, 64.4], + [-23.96, 64.89], + [-22.18, 65.08], + [-22.23, 65.38], + [-24.33, 65.61], + [-23.65, 66.26], + [-22.13, 66.41], + [-20.58, 65.73], + [-19.06, 66.28], + [-17.8, 65.99], + [-16.17, 66.53], + [-14.51, 66.46] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-75.87, 67.15], + [-76.99, 67.1], + [-77.24, 67.59], + [-76.81, 68.15], + [-75.9, 68.29], + [-75.11, 68.01], + [-75.1, 67.58], + [-75.22, 67.44], + [-75.87, 67.15] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-175.01, 66.58], + [-174.34, 66.34], + [-174.57, 67.06], + [-171.86, 66.91], + [-169.9, 65.98], + [-170.89, 65.54], + [-172.53, 65.44], + [-172.56, 64.46], + [-172.96, 64.25], + [-173.89, 64.28], + [-174.65, 64.63], + [-175.98, 64.92], + [-176.21, 65.36], + [-177.22, 65.52], + [-178.36, 65.39], + [-178.9, 65.74], + [-178.69, 66.11], + [-179.88, 65.87], + [-179.43, 65.4], + [-180, 64.98], + [-180, 68.96], + [-177.55, 68.2], + [-174.93, 67.21], + [-175.01, 66.58] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-95.65, 69.11], + [-96.27, 68.76], + [-97.62, 69.06], + [-98.43, 68.95], + [-99.8, 69.4], + [-98.92, 69.71], + [-98.22, 70.14], + [-97.16, 69.86], + [-96.56, 69.68], + [-96.26, 69.49], + [-95.65, 69.11] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [180, 70.83], + [178.9, 70.78], + [178.73, 71.1], + [180, 71.52], + [180, 70.83] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-178.69, 70.89], + [-180, 70.83], + [-180, 71.52], + [-179.87, 71.56], + [-179.02, 71.56], + [-177.58, 71.27], + [-177.66, 71.13], + [-178.69, 70.89] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-90.55, 69.5], + [-90.55, 68.48], + [-89.22, 69.26], + [-88.02, 68.62], + [-88.32, 67.87], + [-87.35, 67.2], + [-86.31, 67.92], + [-85.58, 68.78], + [-85.52, 69.88], + [-84.1, 69.81], + [-82.62, 69.66], + [-81.28, 69.16], + [-81.22, 68.67], + [-81.96, 68.13], + [-81.26, 67.6], + [-81.39, 67.11], + [-83.34, 66.41], + [-84.74, 66.26], + [-85.77, 66.56], + [-86.07, 66.06], + [-87.03, 65.21], + [-87.32, 64.78], + [-88.48, 64.1], + [-89.91, 64.03], + [-90.7, 63.61], + [-90.77, 62.96], + [-91.93, 62.84], + [-93.16, 62.02], + [-94.24, 60.9], + [-94.63, 60.11], + [-94.68, 58.95], + [-93.22, 58.78], + [-92.76, 57.85], + [-92.3, 57.09], + [-90.9, 57.28], + [-89.04, 56.85], + [-88.04, 56.47], + [-87.32, 56.0], + [-86.07, 55.72], + [-85.01, 55.3], + [-83.36, 55.24], + [-82.27, 55.15], + [-82.44, 54.28], + [-82.12, 53.28], + [-81.4, 52.16], + [-79.91, 51.21], + [-79.14, 51.53], + [-78.6, 52.56], + [-79.12, 54.14], + [-79.83, 54.67], + [-78.23, 55.14], + [-77.1, 55.84], + [-76.54, 56.53], + [-76.62, 57.2], + [-77.3, 58.05], + [-78.52, 58.8], + [-77.34, 59.85], + [-77.77, 60.76], + [-78.11, 62.32], + [-77.41, 62.55], + [-75.7, 62.28], + [-74.67, 62.18], + [-73.84, 62.44], + [-72.91, 62.11], + [-71.68, 61.53], + [-71.37, 61.14], + [-69.59, 61.06], + [-69.62, 60.22], + [-69.29, 58.96], + [-68.37, 58.8], + [-67.65, 58.21], + [-66.2, 58.77], + [-65.25, 59.87], + [-64.58, 60.34], + [-63.8, 59.44], + [-62.5, 58.17], + [-61.4, 56.97], + [-61.8, 56.34], + [-60.47, 55.78], + [-59.57, 55.2], + [-57.98, 54.95], + [-57.33, 54.63], + [-56.94, 53.78], + [-56.16, 53.65], + [-55.76, 53.27], + [-55.68, 52.15], + [-56.41, 51.77], + [-57.13, 51.42], + [-58.77, 51.06], + [-60.03, 50.24], + [-61.72, 50.08], + [-63.86, 50.29], + [-65.36, 50.3], + [-66.4, 50.23], + [-67.24, 49.51], + [-68.51, 49.07], + [-69.95, 47.74], + [-71.1, 46.82], + [-70.26, 46.99], + [-68.65, 48.3], + [-66.55, 49.13], + [-65.06, 49.23], + [-64.17, 48.74], + [-65.12, 48.07], + [-64.8, 46.99], + [-64.47, 46.24], + [-63.17, 45.74], + [-61.52, 45.88], + [-60.52, 47.01], + [-60.45, 46.28], + [-59.8, 45.92], + [-61.04, 45.27], + [-63.25, 44.67], + [-64.25, 44.27], + [-65.36, 43.55], + [-66.12, 43.62], + [-66.16, 44.47], + [-64.43, 45.29], + [-66.03, 45.26], + [-67.14, 45.14], + [-66.96, 44.81], + [-68.03, 44.33], + [-69.06, 43.98], + [-70.12, 43.68], + [-70.69, 43.03], + [-70.81, 42.87], + [-70.83, 42.34], + [-70.49, 41.81], + [-70.08, 41.78], + [-70.18, 42.15], + [-69.88, 41.92], + [-69.97, 41.64], + [-70.64, 41.48], + [-71.12, 41.49], + [-71.86, 41.32], + [-72.29, 41.27], + [-72.88, 41.22], + [-73.71, 40.93], + [-72.24, 41.12], + [-71.95, 40.93], + [-73.34, 40.63], + [-73.98, 40.63], + [-73.95, 40.75], + [-74.26, 40.47], + [-73.96, 40.43], + [-74.18, 39.71], + [-74.91, 38.94], + [-74.98, 39.2], + [-75.2, 39.25], + [-75.53, 39.5], + [-75.32, 38.96], + [-75.08, 38.78], + [-75.06, 38.4], + [-75.38, 38.02], + [-75.94, 37.22], + [-76.03, 37.26], + [-75.72, 37.94], + [-76.23, 38.32], + [-76.35, 39.15], + [-76.54, 38.72], + [-76.33, 38.08], + [-76.96, 38.23], + [-76.3, 37.92], + [-76.26, 36.97], + [-75.97, 36.9], + [-75.87, 36.55], + [-75.73, 35.55], + [-76.36, 34.81], + [-77.4, 34.51], + [-78.05, 33.93], + [-78.55, 33.86], + [-79.06, 33.49], + [-79.2, 33.16], + [-80.3, 32.51], + [-80.86, 32.03], + [-81.34, 31.44], + [-81.49, 30.73], + [-81.31, 30.04], + [-80.98, 29.18], + [-80.54, 28.47], + [-80.53, 28.04], + [-80.06, 26.88], + [-80.09, 26.21], + [-80.13, 25.82], + [-80.38, 25.21], + [-80.68, 25.08], + [-81.17, 25.2], + [-81.33, 25.64], + [-81.71, 25.87], + [-82.24, 26.73], + [-82.71, 27.5], + [-82.86, 27.89], + [-82.65, 28.55], + [-82.93, 29.1], + [-83.71, 29.94], + [-84.1, 30.09], + [-85.11, 29.64], + [-85.29, 29.69], + [-85.77, 30.15], + [-86.4, 30.4], + [-87.53, 30.27], + [-88.42, 30.38], + [-89.18, 30.32], + [-89.6, 30.18], + [-89.41, 29.89], + [-89.43, 29.49], + [-89.22, 29.29], + [-89.41, 29.16], + [-89.78, 29.31], + [-90.15, 29.12], + [-90.88, 29.15], + [-91.63, 29.68], + [-92.5, 29.55], + [-93.23, 29.78], + [-93.85, 29.71], + [-94.69, 29.48], + [-95.6, 28.74], + [-96.59, 28.31], + [-97.14, 27.83], + [-97.37, 27.38], + [-97.38, 26.69], + [-97.33, 26.21], + [-97.14, 25.87], + [-97.14, 25.87], + [-97.14, 25.87], + [-97.53, 24.99], + [-97.7, 24.27], + [-97.78, 22.93], + [-97.87, 22.44], + [-97.7, 21.9], + [-97.39, 21.41], + [-97.19, 20.64], + [-96.53, 19.89], + [-96.29, 19.32], + [-95.9, 18.83], + [-94.84, 18.56], + [-94.43, 18.14], + [-93.55, 18.42], + [-92.79, 18.52], + [-92.04, 18.7], + [-91.41, 18.88], + [-90.77, 19.28], + [-90.53, 19.87], + [-90.45, 20.71], + [-90.28, 21.0], + [-89.6, 21.26], + [-88.54, 21.49], + [-87.66, 21.46], + [-87.05, 21.54], + [-86.81, 21.33], + [-86.85, 20.85], + [-87.38, 20.26], + [-87.62, 19.65], + [-87.44, 19.47], + [-87.59, 19.04], + [-87.84, 18.26], + [-88.09, 18.52], + [-88.3, 18.5], + [-88.3, 18.35], + [-88.11, 18.35], + [-88.12, 18.08], + [-88.29, 17.64], + [-88.2, 17.49], + [-88.3, 17.13], + [-88.24, 17.04], + [-88.36, 16.53], + [-88.55, 16.27], + [-88.73, 16.23], + [-88.93, 15.89], + [-88.6, 15.71], + [-88.52, 15.86], + [-88.22, 15.73], + [-88.12, 15.69], + [-87.9, 15.86], + [-87.62, 15.88], + [-87.52, 15.8], + [-87.37, 15.85], + [-86.9, 15.76], + [-86.44, 15.78], + [-86.12, 15.89], + [-86.0, 16.01], + [-85.68, 15.95], + [-85.44, 15.89], + [-85.18, 15.91], + [-84.98, 16.0], + [-84.53, 15.86], + [-84.37, 15.84], + [-84.06, 15.65], + [-83.77, 15.42], + [-83.41, 15.27], + [-83.15, 15.0], + [-83.23, 14.9], + [-83.28, 14.68], + [-83.18, 14.31], + [-83.41, 13.97], + [-83.52, 13.57], + [-83.55, 13.13], + [-83.5, 12.87], + [-83.47, 12.42], + [-83.63, 12.32], + [-83.72, 11.89], + [-83.65, 11.63], + [-83.86, 11.37], + [-83.81, 11.1], + [-83.66, 10.94], + [-83.4, 10.4], + [-83.02, 9.99], + [-82.55, 9.57], + [-82.19, 9.21], + [-82.21, 9.0], + [-81.81, 8.95], + [-81.71, 9.03], + [-81.44, 8.79], + [-80.95, 8.86], + [-80.52, 9.11], + [-79.91, 9.31], + [-79.57, 9.61], + [-79.02, 9.55], + [-79.06, 9.45], + [-78.5, 9.42], + [-78.06, 9.25], + [-77.73, 8.95], + [-77.35, 8.67], + [-76.84, 8.64], + [-76.09, 9.34], + [-75.67, 9.44], + [-75.66, 9.77], + [-75.48, 10.62], + [-74.91, 11.08], + [-74.28, 11.1], + [-74.2, 11.31], + [-73.41, 11.23], + [-72.63, 11.73], + [-72.24, 11.96], + [-71.75, 12.44], + [-71.4, 12.38], + [-71.14, 12.11], + [-71.33, 11.78], + [-71.36, 11.54], + [-71.95, 11.42], + [-71.62, 10.97], + [-71.63, 10.45], + [-72.07, 9.87], + [-71.7, 9.07], + [-71.26, 9.14], + [-71.04, 9.86], + [-71.35, 10.21], + [-71.4, 10.97], + [-70.16, 11.38], + [-70.29, 11.85], + [-69.94, 12.16], + [-69.58, 11.46], + [-68.88, 11.44], + [-68.23, 10.89], + [-68.19, 10.55], + [-67.3, 10.55], + [-66.23, 10.65], + [-65.66, 10.2], + [-64.89, 10.08], + [-64.33, 10.39], + [-64.32, 10.64], + [-63.08, 10.7], + [-61.88, 10.72], + [-62.73, 10.42], + [-62.39, 9.95], + [-61.59, 9.87], + [-60.83, 9.38], + [-60.67, 8.58], + [-60.15, 8.6], + [-59.76, 8.37], + [-59.1, 8.0], + [-58.48, 7.35], + [-58.45, 6.83], + [-58.08, 6.81], + [-57.54, 6.32], + [-57.15, 5.97], + [-55.95, 5.77], + [-55.84, 5.95], + [-55.03, 6.03], + [-53.96, 5.76], + [-53.62, 5.65], + [-52.88, 5.41], + [-51.82, 4.57], + [-51.66, 4.16], + [-51.32, 4.2], + [-51.07, 3.65], + [-50.51, 1.9], + [-49.97, 1.74], + [-49.95, 1.05], + [-50.7, 0.22], + [-50.39, -0.08], + [-48.62, -0.24], + [-48.58, -1.24], + [-47.82, -0.58], + [-46.57, -0.94], + [-44.91, -1.55], + [-44.42, -2.14], + [-44.58, -2.69], + [-43.42, -2.38], + [-41.47, -2.91], + [-39.98, -2.87], + [-38.5, -3.7], + [-37.22, -4.82], + [-36.45, -5.11], + [-35.6, -5.15], + [-35.24, -5.46], + [-34.9, -6.74], + [-34.73, -7.34], + [-35.13, -9.0], + [-35.64, -9.65], + [-37.05, -11.04], + [-37.68, -12.17], + [-38.42, -13.04], + [-38.67, -13.06], + [-38.95, -13.79], + [-38.88, -15.67], + [-39.16, -17.21], + [-39.27, -17.87], + [-39.58, -18.26], + [-39.76, -19.6], + [-40.77, -20.9], + [-40.94, -21.94], + [-41.75, -22.37], + [-41.99, -22.97], + [-43.07, -22.97], + [-44.65, -23.35], + [-45.35, -23.8], + [-46.47, -24.09], + [-47.65, -24.89], + [-48.5, -25.88], + [-48.64, -26.62], + [-48.47, -27.18], + [-48.66, -28.19], + [-48.89, -28.67], + [-49.59, -29.22], + [-50.7, -30.98], + [-51.58, -31.78], + [-52.26, -32.25], + [-52.71, -33.2], + [-53.37, -33.77], + [-53.81, -34.4], + [-54.94, -34.95], + [-55.67, -34.75], + [-56.22, -34.86], + [-57.14, -34.43], + [-57.82, -34.46], + [-58.43, -33.91], + [-58.5, -34.43], + [-57.23, -35.29], + [-57.36, -35.98], + [-56.74, -36.41], + [-56.79, -36.9], + [-57.75, -38.18], + [-59.23, -38.72], + [-61.24, -38.93], + [-62.34, -38.83], + [-62.13, -39.42], + [-62.33, -40.17], + [-62.15, -40.68], + [-62.75, -41.03], + [-63.77, -41.17], + [-64.73, -40.8], + [-65.12, -41.06], + [-64.98, -42.06], + [-64.3, -42.36], + [-63.76, -42.04], + [-63.46, -42.56], + [-64.38, -42.87], + [-65.18, -43.5], + [-65.33, -44.5], + [-65.57, -45.04], + [-66.51, -45.04], + [-67.29, -45.55], + [-67.58, -46.3], + [-66.6, -47.03], + [-65.64, -47.24], + [-65.99, -48.13], + [-67.17, -48.7], + [-67.82, -49.87], + [-68.73, -50.26], + [-69.14, -50.73], + [-68.82, -51.77], + [-68.15, -52.35], + [-68.57, -52.3], + [-69.46, -52.29], + [-69.94, -52.54], + [-70.85, -52.9], + [-71.01, -53.83], + [-71.43, -53.86], + [-72.56, -53.53], + [-73.7, -52.84], + [-74.95, -52.26], + [-75.26, -51.63], + [-74.98, -51.04], + [-75.48, -50.38], + [-75.61, -48.67], + [-75.18, -47.71], + [-74.13, -46.94], + [-75.64, -46.65], + [-74.69, -45.76], + [-74.35, -44.1], + [-73.24, -44.45], + [-72.72, -42.38], + [-73.39, -42.12], + [-73.7, -43.37], + [-74.33, -43.22], + [-74.02, -41.79], + [-73.68, -39.94], + [-73.22, -39.26], + [-73.51, -38.28], + [-73.59, -37.16], + [-73.17, -37.12], + [-72.55, -35.51], + [-71.86, -33.91], + [-71.44, -32.42], + [-71.67, -30.92], + [-71.37, -30.1], + [-71.49, -28.86], + [-70.91, -27.64], + [-70.72, -25.71], + [-70.4, -23.63], + [-70.09, -21.39], + [-70.16, -19.76], + [-70.37, -18.35], + [-71.38, -17.77], + [-71.46, -17.36], + [-73.44, -16.36], + [-75.24, -15.27], + [-76.01, -14.65], + [-76.42, -13.82], + [-76.26, -13.53], + [-77.11, -12.22], + [-78.09, -10.38], + [-79.04, -8.39], + [-79.45, -7.93], + [-79.76, -7.19], + [-80.54, -6.54], + [-81.25, -6.14], + [-80.93, -5.69], + [-81.41, -4.74], + [-81.1, -4.04], + [-80.3, -3.4], + [-79.77, -2.66], + [-79.99, -2.22], + [-80.37, -2.69], + [-80.97, -2.25], + [-80.76, -1.97], + [-80.93, -1.06], + [-80.58, -0.91], + [-80.4, -0.28], + [-80.02, 0.36], + [-80.09, 0.77], + [-79.54, 0.98], + [-78.86, 1.38], + [-78.99, 1.69], + [-78.62, 1.77], + [-78.66, 2.27], + [-78.43, 2.63], + [-77.93, 2.7], + [-77.51, 3.33], + [-77.13, 3.85], + [-77.5, 4.09], + [-77.31, 4.67], + [-77.53, 5.58], + [-77.32, 5.85], + [-77.48, 6.69], + [-77.88, 7.22], + [-78.21, 7.51], + [-78.43, 8.05], + [-78.18, 8.32], + [-78.44, 8.39], + [-78.62, 8.72], + [-79.12, 9.0], + [-79.56, 8.93], + [-79.76, 8.58], + [-80.16, 8.33], + [-80.38, 8.3], + [-80.48, 8.09], + [-80.0, 7.55], + [-80.28, 7.42], + [-80.42, 7.27], + [-80.89, 7.22], + [-81.06, 7.82], + [-81.19, 7.65], + [-81.52, 7.71], + [-81.72, 8.11], + [-82.13, 8.18], + [-82.39, 8.29], + [-82.82, 8.29], + [-82.85, 8.07], + [-82.97, 8.23], + [-83.51, 8.45], + [-83.71, 8.66], + [-83.6, 8.83], + [-83.63, 9.05], + [-83.91, 9.29], + [-84.3, 9.49], + [-84.65, 9.62], + [-84.71, 9.91], + [-84.98, 10.09], + [-84.91, 9.8], + [-85.11, 9.56], + [-85.34, 9.83], + [-85.66, 9.93], + [-85.8, 10.13], + [-85.79, 10.44], + [-85.66, 10.75], + [-85.94, 10.9], + [-85.71, 11.09], + [-86.06, 11.4], + [-86.53, 11.81], + [-86.75, 12.14], + [-87.17, 12.46], + [-87.67, 12.91], + [-87.56, 13.06], + [-87.39, 12.91], + [-87.32, 12.98], + [-87.49, 13.3], + [-87.79, 13.38], + [-87.9, 13.15], + [-88.48, 13.16], + [-88.84, 13.26], + [-89.26, 13.46], + [-89.81, 13.52], + [-90.1, 13.74], + [-90.61, 13.91], + [-91.23, 13.93], + [-91.69, 14.13], + [-92.23, 14.54], + [-93.36, 15.62], + [-93.88, 15.94], + [-94.69, 16.2], + [-95.25, 16.13], + [-96.05, 15.75], + [-96.56, 15.65], + [-97.26, 15.92], + [-98.01, 16.11], + [-98.95, 16.57], + [-99.7, 16.71], + [-100.83, 17.17], + [-101.67, 17.65], + [-101.92, 17.92], + [-102.48, 17.98], + [-103.5, 18.29], + [-103.92, 18.75], + [-104.99, 19.32], + [-105.49, 19.95], + [-105.73, 20.43], + [-105.4, 20.53], + [-105.5, 20.82], + [-105.27, 21.08], + [-105.27, 21.42], + [-105.6, 21.87], + [-105.69, 22.27], + [-106.03, 22.77], + [-106.91, 23.77], + [-107.92, 24.55], + [-108.4, 25.17], + [-109.26, 25.58], + [-109.44, 25.82], + [-109.29, 26.44], + [-109.8, 26.68], + [-110.39, 27.16], + [-110.64, 27.86], + [-111.18, 27.94], + [-111.76, 28.47], + [-112.23, 28.95], + [-112.27, 29.27], + [-112.81, 30.02], + [-113.16, 30.79], + [-113.15, 31.17], + [-113.87, 31.57], + [-114.21, 31.52], + [-114.78, 31.8], + [-114.94, 31.39], + [-114.77, 30.91], + [-114.67, 30.16], + [-114.33, 29.75], + [-113.59, 29.06], + [-113.42, 28.83], + [-113.27, 28.75], + [-113.14, 28.41], + [-112.96, 28.43], + [-112.76, 27.78], + [-112.46, 27.53], + [-112.24, 27.17], + [-111.62, 26.66], + [-111.28, 25.73], + [-110.99, 25.29], + [-110.71, 24.83], + [-110.66, 24.3], + [-110.17, 24.27], + [-109.77, 23.81], + [-109.41, 23.36], + [-109.43, 23.19], + [-109.85, 22.82], + [-110.03, 22.82], + [-110.29, 23.43], + [-110.95, 24.0], + [-111.67, 24.48], + [-112.18, 24.74], + [-112.15, 25.47], + [-112.3, 26.01], + [-112.78, 26.32], + [-113.46, 26.77], + [-113.6, 26.64], + [-113.85, 26.9], + [-114.47, 27.14], + [-115.06, 27.72], + [-114.98, 27.8], + [-114.57, 27.74], + [-114.2, 28.12], + [-114.16, 28.57], + [-114.93, 29.28], + [-115.52, 29.56], + [-115.89, 30.18], + [-116.26, 30.84], + [-116.72, 31.64], + [-117.13, 32.54], + [-117.3, 33.05], + [-117.94, 33.62], + [-118.41, 33.74], + [-118.52, 34.03], + [-119.08, 34.08], + [-119.44, 34.35], + [-120.37, 34.45], + [-120.62, 34.61], + [-120.74, 35.16], + [-121.71, 36.16], + [-122.55, 37.55], + [-122.51, 37.78], + [-122.95, 38.11], + [-123.73, 38.95], + [-123.87, 39.77], + [-124.4, 40.31], + [-124.18, 41.14], + [-124.21, 42.0], + [-124.53, 42.77], + [-124.14, 43.71], + [-123.9, 45.52], + [-124.08, 46.86], + [-124.4, 47.72], + [-124.69, 48.18], + [-124.57, 48.38], + [-123.12, 48.04], + [-122.59, 47.1], + [-122.34, 47.36], + [-122.5, 48.18], + [-122.84, 49.0], + [-122.97, 49.0], + [-124.91, 49.98], + [-125.62, 50.42], + [-127.44, 50.83], + [-127.99, 51.72], + [-127.85, 52.33], + [-129.13, 52.76], + [-129.31, 53.56], + [-130.51, 54.29], + [-130.54, 54.8], + [-131.09, 55.18], + [-131.97, 55.5], + [-132.25, 56.37], + [-133.54, 57.18], + [-134.08, 58.12], + [-135.04, 58.19], + [-136.63, 58.21], + [-137.8, 58.5], + [-139.87, 59.54], + [-140.83, 59.73], + [-142.57, 60.08], + [-143.96, 60.0], + [-145.93, 60.46], + [-147.11, 60.88], + [-148.22, 60.67], + [-148.02, 59.98], + [-148.57, 59.91], + [-149.73, 59.71], + [-150.61, 59.37], + [-151.72, 59.16], + [-151.86, 59.75], + [-151.41, 60.73], + [-150.35, 61.03], + [-150.62, 61.28], + [-151.9, 60.73], + [-152.58, 60.06], + [-154.02, 59.35], + [-153.29, 58.86], + [-154.23, 58.15], + [-155.31, 57.73], + [-156.31, 57.42], + [-156.56, 56.98], + [-158.12, 56.46], + [-158.43, 55.99], + [-159.6, 55.57], + [-160.29, 55.64], + [-161.22, 55.36], + [-162.24, 55.02], + [-163.07, 54.69], + [-164.79, 54.4], + [-164.94, 54.57], + [-163.85, 55.04], + [-162.87, 55.35], + [-161.8, 55.9], + [-160.56, 56.01], + [-160.07, 56.42], + [-158.68, 57.02], + [-158.46, 57.22], + [-157.72, 57.57], + [-157.55, 58.33], + [-157.04, 58.92], + [-158.19, 58.62], + [-158.52, 58.79], + [-159.06, 58.42], + [-159.71, 58.93], + [-159.98, 58.57], + [-160.36, 59.07], + [-161.35, 58.67], + [-161.97, 58.67], + [-162.05, 59.27], + [-161.87, 59.63], + [-162.52, 59.99], + [-163.82, 59.8], + [-164.66, 60.27], + [-165.35, 60.51], + [-165.35, 61.07], + [-166.12, 61.5], + [-165.73, 62.08], + [-164.92, 62.63], + [-164.56, 63.15], + [-163.75, 63.22], + [-163.07, 63.06], + [-162.26, 63.54], + [-161.53, 63.46], + [-160.77, 63.77], + [-160.96, 64.22], + [-161.52, 64.4], + [-160.78, 64.79], + [-161.39, 64.78], + [-162.45, 64.56], + [-162.76, 64.34], + [-163.55, 64.56], + [-164.96, 64.45], + [-166.43, 64.69], + [-166.84, 65.09], + [-168.11, 65.67], + [-166.71, 66.09], + [-164.47, 66.58], + [-163.65, 66.58], + [-163.79, 66.08], + [-161.68, 66.12], + [-162.49, 66.74], + [-163.72, 67.12], + [-164.43, 67.62], + [-165.39, 68.04], + [-166.76, 68.36], + [-166.2, 68.88], + [-164.43, 68.92], + [-163.17, 69.37], + [-162.93, 69.86], + [-161.91, 70.33], + [-160.93, 70.45], + [-159.04, 70.89], + [-158.12, 70.82], + [-156.58, 71.36], + [-155.07, 71.15], + [-154.34, 70.7], + [-153.9, 70.89], + [-152.21, 70.83], + [-152.27, 70.6], + [-150.74, 70.43], + [-149.72, 70.53], + [-147.61, 70.21], + [-145.69, 70.12], + [-144.92, 69.99], + [-143.59, 70.15], + [-142.07, 69.85], + [-140.99, 69.71], + [-139.12, 69.47], + [-137.55, 68.99], + [-136.5, 68.9], + [-135.63, 69.32], + [-134.41, 69.63], + [-132.93, 69.51], + [-131.43, 69.94], + [-129.79, 70.19], + [-129.11, 69.78], + [-128.36, 70.01], + [-128.14, 70.48], + [-127.45, 70.38], + [-125.76, 69.48], + [-124.42, 70.16], + [-124.29, 69.4], + [-123.06, 69.56], + [-122.68, 69.86], + [-121.47, 69.8], + [-119.94, 69.38], + [-117.6, 69.01], + [-116.23, 68.84], + [-115.25, 68.91], + [-113.9, 68.4], + [-115.3, 67.9], + [-113.5, 67.69], + [-110.8, 67.81], + [-109.95, 67.98], + [-108.88, 67.38], + [-107.79, 67.89], + [-108.81, 68.31], + [-108.17, 68.65], + [-106.95, 68.7], + [-106.15, 68.8], + [-105.34, 68.56], + [-104.34, 68.02], + [-103.22, 68.1], + [-101.45, 67.65], + [-99.9, 67.81], + [-98.44, 67.78], + [-98.56, 68.4], + [-97.67, 68.58], + [-96.12, 68.24], + [-96.13, 67.29], + [-95.49, 68.09], + [-94.68, 68.06], + [-94.23, 69.07], + [-95.3, 69.69], + [-96.47, 70.09], + [-96.39, 71.19], + [-95.21, 71.92], + [-93.89, 71.76], + [-92.88, 71.32], + [-91.52, 70.19], + [-92.41, 69.7], + [-90.55, 69.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-114.17, 73.12], + [-114.67, 72.65], + [-112.44, 72.96], + [-111.05, 72.45], + [-109.92, 72.96], + [-109.01, 72.63], + [-108.19, 71.65], + [-107.69, 72.07], + [-108.4, 73.09], + [-107.52, 73.24], + [-106.52, 73.08], + [-105.4, 72.67], + [-104.77, 71.7], + [-104.46, 70.99], + [-102.79, 70.5], + [-100.98, 70.02], + [-101.09, 69.58], + [-102.73, 69.5], + [-102.09, 69.12], + [-102.43, 68.75], + [-104.24, 68.91], + [-105.96, 69.18], + [-107.12, 69.12], + [-109, 68.78], + [-111.97, 68.6], + [-113.31, 68.54], + [-113.85, 69.01], + [-115.22, 69.28], + [-116.11, 69.17], + [-117.34, 69.96], + [-116.67, 70.07], + [-115.13, 70.24], + [-113.72, 70.19], + [-112.42, 70.37], + [-114.35, 70.6], + [-116.49, 70.52], + [-117.9, 70.54], + [-118.43, 70.91], + [-116.11, 71.31], + [-117.66, 71.3], + [-119.4, 71.56], + [-118.56, 72.31], + [-117.87, 72.71], + [-115.19, 73.31], + [-114.17, 73.12] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-104.5, 73.42], + [-105.38, 72.76], + [-106.94, 73.46], + [-106.6, 73.6], + [-105.26, 73.64], + [-104.5, 73.42] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-76.34, 73.1], + [-76.25, 72.83], + [-77.31, 72.86], + [-78.39, 72.88], + [-79.49, 72.74], + [-79.78, 72.8], + [-80.88, 73.33], + [-80.83, 73.69], + [-80.35, 73.76], + [-78.06, 73.65], + [-76.34, 73.1] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-86.56, 73.16], + [-85.77, 72.53], + [-84.85, 73.34], + [-82.32, 73.75], + [-80.6, 72.72], + [-80.75, 72.06], + [-78.77, 72.35], + [-77.82, 72.75], + [-75.61, 72.24], + [-74.23, 71.77], + [-74.1, 71.33], + [-72.24, 71.56], + [-71.2, 70.92], + [-68.79, 70.53], + [-67.91, 70.12], + [-66.97, 69.19], + [-68.81, 68.72], + [-66.45, 68.07], + [-64.86, 67.85], + [-63.42, 66.93], + [-61.85, 66.86], + [-62.16, 66.16], + [-63.92, 65.0], + [-65.15, 65.43], + [-66.72, 66.39], + [-68.02, 66.26], + [-68.14, 65.69], + [-67.09, 65.11], + [-65.73, 64.65], + [-65.32, 64.38], + [-64.67, 63.39], + [-65.01, 62.67], + [-66.28, 62.95], + [-68.78, 63.75], + [-67.37, 62.88], + [-66.33, 62.28], + [-66.17, 61.93], + [-68.88, 62.33], + [-71.02, 62.91], + [-72.24, 63.4], + [-71.89, 63.68], + [-73.38, 64.19], + [-74.83, 64.68], + [-74.82, 64.39], + [-77.71, 64.23], + [-78.56, 64.57], + [-77.9, 65.31], + [-76.02, 65.33], + [-73.96, 65.45], + [-74.29, 65.81], + [-73.94, 66.31], + [-72.65, 67.28], + [-72.93, 67.73], + [-73.31, 68.07], + [-74.84, 68.55], + [-76.87, 68.89], + [-76.23, 69.15], + [-77.29, 69.77], + [-78.17, 69.83], + [-78.96, 70.17], + [-79.49, 69.87], + [-81.31, 69.74], + [-84.94, 69.97], + [-87.06, 70.26], + [-88.68, 70.41], + [-89.51, 70.76], + [-88.47, 71.22], + [-89.89, 71.22], + [-90.21, 72.24], + [-89.44, 73.13], + [-88.41, 73.54], + [-85.83, 73.8], + [-86.56, 73.16] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-100.36, 73.84], + [-99.16, 73.63], + [-97.38, 73.76], + [-97.12, 73.47], + [-98.05, 72.99], + [-96.54, 72.56], + [-96.72, 71.66], + [-98.36, 71.27], + [-99.32, 71.36], + [-100.01, 71.74], + [-102.5, 72.51], + [-102.48, 72.83], + [-100.44, 72.71], + [-101.54, 73.36], + [-100.36, 73.84] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [143.6, 73.21], + [142.09, 73.21], + [140.04, 73.32], + [139.86, 73.37], + [140.81, 73.77], + [142.06, 73.86], + [143.48, 73.48], + [143.6, 73.21] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-93.2, 72.77], + [-94.27, 72.02], + [-95.41, 72.06], + [-96.03, 72.94], + [-96.02, 73.44], + [-95.5, 73.86], + [-94.5, 74.13], + [-92.42, 74.1], + [-90.51, 73.86], + [-92.0, 72.97], + [-93.2, 72.77] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-120.46, 71.4], + [-123.09, 70.9], + [-123.62, 71.34], + [-125.93, 71.87], + [-125.59, 72.19], + [-124.81, 73.02], + [-123.94, 73.68], + [-124.92, 74.29], + [-121.54, 74.45], + [-120.11, 74.24], + [-117.56, 74.19], + [-116.58, 73.9], + [-115.51, 73.48], + [-116.77, 73.22], + [-119.22, 72.52], + [-120.46, 71.82], + [-120.46, 71.4] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [150.73, 75.08], + [149.58, 74.69], + [147.98, 74.78], + [146.12, 75.17], + [146.36, 75.5], + [148.22, 75.35], + [150.73, 75.08] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-93.61, 74.98], + [-94.16, 74.59], + [-95.61, 74.67], + [-96.82, 74.93], + [-96.29, 75.38], + [-94.85, 75.65], + [-93.98, 75.3], + [-93.61, 74.98] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [145.09, 75.56], + [144.3, 74.82], + [140.61, 74.85], + [138.96, 74.61], + [136.97, 75.26], + [137.51, 75.95], + [138.83, 76.14], + [141.47, 76.09], + [145.09, 75.56] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-98.5, 76.72], + [-97.74, 76.26], + [-97.7, 75.74], + [-98.16, 75], + [-99.81, 74.9], + [-100.88, 75.06], + [-100.86, 75.64], + [-102.5, 75.56], + [-102.57, 76.34], + [-101.49, 76.31], + [-99.98, 76.65], + [-98.58, 76.59], + [-98.5, 76.72] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-108.21, 76.2], + [-107.82, 75.85], + [-106.93, 76.01], + [-105.88, 75.97], + [-105.7, 75.48], + [-106.31, 75.01], + [-109.7, 74.85], + [-112.22, 74.42], + [-113.74, 74.39], + [-113.87, 74.72], + [-111.79, 75.16], + [-116.31, 75.04], + [-117.71, 75.22], + [-116.35, 76.2], + [-115.4, 76.48], + [-112.59, 76.14], + [-110.81, 75.55], + [-109.07, 75.47], + [-110.5, 76.43], + [-109.58, 76.79], + [-108.55, 76.68], + [-108.21, 76.2] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [57.54, 70.72], + [56.94, 70.63], + [53.68, 70.76], + [53.41, 71.21], + [51.6, 71.47], + [51.46, 72.01], + [52.48, 72.23], + [52.44, 72.77], + [54.43, 73.63], + [53.51, 73.75], + [55.9, 74.63], + [55.63, 75.08], + [57.87, 75.61], + [61.17, 76.25], + [64.5, 76.44], + [66.21, 76.81], + [68.16, 76.94], + [68.85, 76.54], + [68.18, 76.23], + [64.64, 75.74], + [61.58, 75.26], + [58.48, 74.31], + [56.99, 73.33], + [55.42, 72.37], + [55.62, 71.54], + [57.54, 70.72] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-94.68, 77.1], + [-93.57, 76.78], + [-91.61, 76.78], + [-90.74, 76.45], + [-90.97, 76.07], + [-89.82, 75.85], + [-89.19, 75.61], + [-87.84, 75.57], + [-86.38, 75.48], + [-84.79, 75.7], + [-82.75, 75.78], + [-81.13, 75.71], + [-80.06, 75.34], + [-79.83, 74.92], + [-80.46, 74.66], + [-81.95, 74.44], + [-83.23, 74.56], + [-86.1, 74.41], + [-88.15, 74.39], + [-89.76, 74.52], + [-92.42, 74.84], + [-92.77, 75.39], + [-92.89, 75.88], + [-93.89, 76.32], + [-95.96, 76.44], + [-97.12, 76.75], + [-96.75, 77.16], + [-94.68, 77.1] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-116.2, 77.65], + [-116.34, 76.88], + [-117.11, 76.53], + [-118.04, 76.48], + [-119.9, 76.05], + [-121.5, 75.9], + [-122.85, 76.12], + [-122.85, 76.12], + [-121.16, 76.86], + [-119.1, 77.51], + [-117.57, 77.5], + [-116.2, 77.65] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [106.97, 76.97], + [107.24, 76.48], + [108.15, 76.72], + [111.08, 76.71], + [113.33, 76.22], + [114.13, 75.85], + [113.89, 75.33], + [112.78, 75.03], + [110.15, 74.48], + [109.4, 74.18], + [110.64, 74.04], + [112.12, 73.79], + [113.02, 73.98], + [113.53, 73.34], + [113.97, 73.59], + [115.57, 73.75], + [118.78, 73.59], + [119.02, 73.12], + [123.2, 72.97], + [123.26, 73.74], + [125.38, 73.56], + [126.98, 73.57], + [128.59, 73.04], + [129.05, 72.4], + [128.46, 71.98], + [129.72, 71.19], + [131.29, 70.79], + [132.25, 71.84], + [133.86, 71.39], + [135.56, 71.66], + [137.5, 71.35], + [138.23, 71.63], + [139.87, 71.49], + [139.15, 72.42], + [140.47, 72.85], + [149.5, 72.2], + [150.35, 71.61], + [152.97, 70.84], + [157.01, 71.03], + [159.0, 70.87], + [159.83, 70.45], + [159.71, 69.72], + [160.94, 69.44], + [162.28, 69.64], + [164.05, 69.67], + [165.94, 69.47], + [167.84, 69.58], + [169.58, 68.69], + [170.82, 69.01], + [170.01, 69.65], + [170.45, 70.1], + [173.64, 69.82], + [175.72, 69.88], + [178.6, 69.4], + [180, 68.96], + [180, 64.98], + [179.99, 64.97], + [178.71, 64.53], + [177.41, 64.61], + [178.31, 64.08], + [178.91, 63.25], + [179.37, 62.98], + [179.49, 62.57], + [179.23, 62.3], + [177.36, 62.52], + [174.57, 61.77], + [173.68, 61.65], + [172.15, 60.95], + [170.7, 60.34], + [170.33, 59.88], + [168.9, 60.57], + [166.3, 59.79], + [165.84, 60.16], + [164.88, 59.73], + [163.54, 59.87], + [163.22, 59.21], + [162.02, 58.24], + [162.05, 57.84], + [163.19, 57.62], + [163.06, 56.16], + [162.13, 56.12], + [161.7, 55.29], + [162.12, 54.86], + [160.37, 54.34], + [160.02, 53.2], + [158.53, 52.96], + [158.23, 51.94], + [156.79, 51.01], + [156.42, 51.7], + [155.99, 53.16], + [155.43, 55.38], + [155.91, 56.77], + [156.76, 57.36], + [156.81, 57.83], + [158.36, 58.06], + [160.15, 59.31], + [161.87, 60.34], + [163.67, 61.14], + [164.47, 62.55], + [163.26, 62.47], + [162.66, 61.64], + [160.12, 60.54], + [159.3, 61.77], + [156.72, 61.43], + [154.22, 59.76], + [155.04, 59.15], + [152.81, 58.88], + [151.27, 58.78], + [151.34, 59.5], + [149.78, 59.66], + [148.54, 59.16], + [145.49, 59.34], + [142.2, 59.04], + [138.96, 57.09], + [135.13, 54.73], + [136.7, 54.6], + [137.19, 53.98], + [138.16, 53.76], + [138.8, 54.25], + [139.9, 54.19], + [141.35, 53.09], + [141.38, 52.24], + [140.6, 51.24], + [140.51, 50.05], + [140.06, 48.45], + [138.55, 47.0], + [138.22, 46.31], + [136.86, 45.14], + [135.52, 43.99], + [134.87, 43.4], + [133.54, 42.81], + [132.91, 42.8], + [132.28, 43.28], + [130.94, 42.55], + [130.78, 42.22], + [130.4, 42.28], + [129.97, 41.94], + [129.67, 41.6], + [129.71, 40.88], + [129.19, 40.66], + [129.01, 40.49], + [128.63, 40.19], + [127.97, 40.03], + [127.53, 39.76], + [127.5, 39.32], + [127.39, 39.21], + [127.78, 39.05], + [128.35, 38.61], + [129.21, 37.43], + [129.46, 36.78], + [129.47, 35.63], + [129.09, 35.08], + [128.19, 34.89], + [127.39, 34.48], + [126.49, 34.39], + [126.37, 34.93], + [126.56, 35.68], + [126.12, 36.73], + [126.86, 36.89], + [126.17, 37.75], + [125.69, 37.94], + [125.57, 37.75], + [125.28, 37.67], + [125.24, 37.86], + [124.98, 37.95], + [124.71, 38.11], + [124.99, 38.55], + [125.22, 38.67], + [125.13, 38.85], + [125.39, 39.39], + [125.32, 39.55], + [124.74, 39.66], + [124.27, 39.93], + [122.87, 39.64], + [122.13, 39.17], + [121.05, 38.9], + [121.59, 39.36], + [121.38, 39.75], + [122.17, 40.42], + [121.64, 40.95], + [120.77, 40.59], + [119.64, 39.9], + [119.02, 39.25], + [118.04, 39.2], + [117.53, 38.74], + [118.06, 38.06], + [118.88, 37.9], + [118.91, 37.45], + [119.7, 37.16], + [120.82, 37.87], + [121.71, 37.48], + [122.36, 37.45], + [122.52, 36.93], + [121.1, 36.65], + [120.64, 36.11], + [119.66, 35.61], + [119.15, 34.91], + [120.23, 34.36], + [120.62, 33.38], + [121.23, 32.46], + [121.91, 31.69], + [121.89, 30.95], + [121.26, 30.68], + [121.5, 30.14], + [122.09, 29.83], + [121.94, 29.02], + [121.68, 28.23], + [121.13, 28.14], + [120.4, 27.05], + [119.59, 25.74], + [118.66, 24.55], + [117.28, 23.62], + [115.89, 22.78], + [114.76, 22.67], + [114.15, 22.22], + [113.81, 22.55], + [113.24, 22.05], + [111.84, 21.55], + [110.79, 21.4], + [110.44, 20.34], + [109.89, 20.28], + [109.63, 21.01], + [109.86, 21.4], + [108.52, 21.72], + [108.05, 21.55], + [106.72, 20.7], + [105.88, 19.75], + [105.66, 19.06], + [106.43, 18.0], + [107.36, 16.7], + [108.27, 16.08], + [108.88, 15.28], + [109.34, 13.43], + [109.2, 11.67], + [108.37, 11.01], + [107.22, 10.36], + [106.41, 9.53], + [105.16, 8.6], + [104.8, 9.24], + [105.08, 9.92], + [104.33, 10.49], + [103.5, 10.63], + [103.09, 11.15], + [102.59, 12.19], + [101.69, 12.65], + [100.83, 12.63], + [100.98, 13.41], + [100.1, 13.41], + [100.02, 12.31], + [99.48, 10.85], + [99.15, 9.96], + [99.22, 9.24], + [99.87, 9.21], + [100.28, 8.3], + [100.46, 7.43], + [101.02, 6.86], + [101.62, 6.74], + [102.14, 6.22], + [102.37, 6.13], + [102.96, 5.52], + [103.38, 4.86], + [103.44, 4.18], + [103.33, 3.73], + [103.43, 3.38], + [103.5, 2.79], + [103.85, 2.52], + [104.25, 1.63], + [104.23, 1.29], + [103.52, 1.23], + [102.57, 1.97], + [101.39, 2.76], + [101.27, 3.27], + [100.7, 3.94], + [100.56, 4.77], + [100.2, 5.31], + [100.31, 6.04], + [100.09, 6.46], + [99.69, 6.85], + [99.52, 7.34], + [98.99, 7.91], + [98.5, 8.38], + [98.34, 7.79], + [98.15, 8.35], + [98.26, 8.97], + [98.55, 9.93], + [98.46, 10.68], + [98.76, 11.44], + [98.43, 12.03], + [98.51, 13.12], + [98.1, 13.64], + [97.78, 14.84], + [97.6, 16.1], + [97.16, 16.93], + [96.51, 16.43], + [95.37, 15.71], + [94.81, 15.8], + [94.19, 16.04], + [94.53, 17.28], + [94.32, 18.21], + [93.54, 19.37], + [93.66, 19.73], + [93.08, 19.86], + [92.37, 20.67], + [92.08, 21.19], + [92.03, 21.7], + [91.83, 22.18], + [91.42, 22.77], + [90.5, 22.81], + [90.59, 22.39], + [90.27, 21.84], + [89.85, 22.04], + [89.7, 21.86], + [89.42, 21.97], + [89.03, 22.06], + [88.89, 21.69], + [88.21, 21.7], + [86.98, 21.5], + [87.03, 20.74], + [86.5, 20.15], + [85.06, 19.48], + [83.94, 18.3], + [83.19, 17.67], + [82.19, 17.02], + [82.19, 16.56], + [81.69, 16.31], + [80.79, 15.95], + [80.32, 15.9], + [80.03, 15.14], + [80.23, 13.84], + [80.29, 13.01], + [79.86, 12.06], + [79.86, 10.36], + [79.34, 10.31], + [78.89, 9.55], + [79.19, 9.22], + [78.28, 8.93], + [77.94, 8.25], + [77.54, 7.97], + [76.59, 8.9], + [76.13, 10.3], + [75.75, 11.31], + [75.4, 11.78], + [74.86, 12.74], + [74.62, 13.99], + [74.44, 14.62], + [73.53, 15.99], + [73.12, 17.93], + [72.82, 19.21], + [72.82, 20.42], + [72.63, 21.36], + [71.18, 20.76], + [70.47, 20.88], + [69.16, 22.09], + [69.64, 22.45], + [69.35, 22.84], + [68.18, 23.69], + [67.44, 23.94], + [67.15, 24.66], + [66.37, 25.43], + [64.53, 25.24], + [62.91, 25.22], + [61.5, 25.08], + [59.62, 25.38], + [58.53, 25.61], + [57.4, 25.74], + [56.97, 26.97], + [56.49, 27.14], + [55.72, 26.96], + [54.72, 26.48], + [53.49, 26.81], + [52.48, 27.58], + [51.52, 27.87], + [50.85, 28.81], + [50.12, 30.15], + [49.58, 29.99], + [48.94, 30.32], + [48.57, 29.93], + [47.97, 29.98], + [48.18, 29.53], + [48.09, 29.31], + [48.42, 28.55], + [48.81, 27.69], + [49.3, 27.46], + [49.47, 27.11], + [50.15, 26.69], + [50.21, 26.28], + [50.11, 25.94], + [50.24, 25.61], + [50.53, 25.33], + [50.66, 25.0], + [50.81, 24.75], + [50.74, 25.48], + [51.01, 26.01], + [51.29, 26.11], + [51.59, 25.8], + [51.61, 25.22], + [51.39, 24.63], + [51.58, 24.25], + [51.76, 24.29], + [51.79, 24.02], + [52.58, 24.18], + [53.4, 24.15], + [54.01, 24.12], + [54.69, 24.8], + [55.44, 25.44], + [56.07, 26.06], + [56.36, 26.4], + [56.49, 26.31], + [56.39, 25.9], + [56.26, 25.71], + [56.4, 24.92], + [56.85, 24.24], + [57.4, 23.88], + [58.14, 23.75], + [58.73, 23.57], + [59.18, 22.99], + [59.45, 22.66], + [59.81, 22.53], + [59.81, 22.31], + [59.44, 21.71], + [59.28, 21.43], + [58.86, 21.11], + [58.49, 20.43], + [58.03, 20.48], + [57.83, 20.24], + [57.67, 19.74], + [57.79, 19.07], + [57.69, 18.94], + [57.23, 18.95], + [56.61, 18.57], + [56.51, 18.09], + [56.28, 17.88], + [55.66, 17.88], + [55.27, 17.63], + [55.27, 17.23], + [54.79, 16.95], + [54.24, 17.05], + [53.57, 16.71], + [53.11, 16.65], + [52.39, 16.38], + [52.19, 15.94], + [52.17, 15.6], + [51.17, 15.18], + [49.57, 14.71], + [48.68, 14.0], + [48.24, 13.95], + [47.94, 14.01], + [47.35, 13.59], + [46.72, 13.4], + [45.88, 13.35], + [45.63, 13.29], + [45.41, 13.03], + [45.14, 12.95], + [44.99, 12.7], + [44.49, 12.72], + [44.18, 12.59], + [43.48, 12.64], + [43.22, 13.22], + [43.25, 13.77], + [43.09, 14.06], + [42.89, 14.8], + [42.6, 15.21], + [42.81, 15.26], + [42.7, 15.72], + [42.82, 15.91], + [42.78, 16.35], + [42.65, 16.77], + [42.35, 17.08], + [42.27, 17.47], + [41.75, 17.83], + [41.22, 18.67], + [40.94, 19.49], + [40.25, 20.17], + [39.8, 20.34], + [39.14, 21.29], + [39.02, 21.99], + [39.07, 22.58], + [38.49, 23.69], + [38.02, 24.08], + [37.48, 24.29], + [37.15, 24.86], + [37.21, 25.08], + [36.93, 25.6], + [36.64, 25.83], + [36.25, 26.57], + [35.64, 27.38], + [35.13, 28.06], + [34.63, 28.06], + [34.79, 28.61], + [34.83, 28.96], + [34.96, 29.36], + [34.92, 29.5], + [34.64, 29.1], + [34.43, 28.34], + [34.15, 27.82], + [33.92, 27.65], + [33.59, 27.97], + [33.14, 28.42], + [32.42, 29.85], + [32.32, 29.76], + [32.73, 28.71], + [33.35, 27.7], + [34.1, 26.14], + [34.47, 25.6], + [34.8, 25.03], + [35.69, 23.93], + [35.49, 23.75], + [35.53, 23.1], + [36.69, 22.2], + [36.87, 22.0], + [37.19, 21.02], + [36.97, 20.84], + [37.11, 19.81], + [37.48, 18.61], + [37.86, 18.37], + [38.41, 18.0], + [38.99, 16.84], + [39.27, 15.92], + [39.81, 15.44], + [41.18, 14.49], + [41.73, 13.92], + [42.28, 13.34], + [42.59, 13.0], + [43.08, 12.7], + [43.32, 12.39], + [43.29, 11.97], + [42.72, 11.74], + [43.15, 11.46], + [43.47, 11.28], + [43.67, 10.86], + [44.12, 10.45], + [44.61, 10.44], + [45.56, 10.7], + [46.65, 10.82], + [47.53, 11.13], + [48.02, 11.19], + [48.38, 11.38], + [48.95, 11.41], + [49.27, 11.43], + [49.73, 11.58], + [50.26, 11.68], + [50.73, 12.02], + [51.11, 12.02], + [51.13, 11.75], + [51.04, 11.17], + [51.05, 10.64], + [50.83, 10.28], + [50.55, 9.2], + [50.07, 8.08], + [49.45, 6.8], + [48.59, 5.34], + [47.74, 4.22], + [46.56, 2.86], + [45.56, 2.05], + [44.07, 1.05], + [43.14, 0.29], + [42.04, -0.92], + [41.81, -1.45], + [41.59, -1.68], + [40.88, -2.08], + [40.64, -2.5], + [40.26, -2.57], + [40.12, -3.28], + [39.8, -3.68], + [39.6, -4.35], + [39.2, -4.68], + [38.74, -5.91], + [38.8, -6.48], + [39.44, -6.84], + [39.47, -7.1], + [39.19, -7.7], + [39.25, -8.01], + [39.19, -8.49], + [39.54, -9.11], + [39.95, -10.1], + [40.32, -10.32], + [40.48, -10.77], + [40.44, -11.76], + [40.56, -12.64], + [40.6, -14.2], + [40.78, -14.69], + [40.48, -15.41], + [40.09, -16.1], + [39.45, -16.72], + [38.54, -17.1], + [37.41, -17.59], + [36.28, -18.66], + [35.9, -18.84], + [35.2, -19.55], + [34.79, -19.78], + [34.7, -20.5], + [35.18, -21.25], + [35.37, -21.84], + [35.39, -22.14], + [35.56, -22.09], + [35.53, -23.07], + [35.37, -23.54], + [35.61, -23.71], + [35.46, -24.12], + [35.04, -24.48], + [34.22, -24.82], + [33.01, -25.36], + [32.57, -25.73], + [32.66, -26.15], + [32.92, -26.22], + [32.83, -26.74], + [32.58, -27.47], + [32.46, -28.3], + [32.2, -28.75], + [31.52, -29.26], + [31.33, -29.4], + [30.9, -29.91], + [30.62, -30.42], + [30.06, -31.14], + [28.93, -32.17], + [28.22, -32.77], + [27.46, -33.23], + [26.42, -33.61], + [25.91, -33.67], + [25.78, -33.94], + [25.17, -33.8], + [24.68, -33.99], + [23.59, -33.79], + [22.99, -33.92], + [22.57, -33.86], + [21.54, -34.26], + [20.69, -34.42], + [20.07, -34.8], + [19.62, -34.82], + [19.19, -34.46], + [18.86, -34.44], + [18.42, -34.0], + [18.38, -34.14], + [18.24, -33.87], + [18.25, -33.28], + [17.93, -32.61], + [18.25, -32.43], + [18.22, -31.66], + [17.57, -30.73], + [17.06, -29.88], + [17.06, -29.88], + [16.35, -28.58], + [15.6, -27.82], + [15.21, -27.09], + [14.99, -26.12], + [14.74, -25.39], + [14.41, -23.85], + [14.39, -22.66], + [14.26, -22.11], + [13.87, -21.7], + [13.35, -20.87], + [12.83, -19.67], + [12.61, -19.05], + [11.79, -18.07], + [11.73, -17.3], + [11.64, -16.67], + [11.78, -15.79], + [12.12, -14.88], + [12.18, -14.45], + [12.5, -13.55], + [12.74, -13.14], + [13.31, -12.48], + [13.63, -12.04], + [13.74, -11.3], + [13.69, -10.73], + [13.39, -10.37], + [13.12, -9.77], + [12.88, -9.17], + [12.93, -8.96], + [13.24, -8.56], + [12.93, -7.6], + [12.73, -6.93], + [12.23, -6.29], + [12.32, -6.1], + [12.18, -5.79], + [11.92, -5.04], + [11.09, -3.98], + [10.07, -2.97], + [9.41, -2.14], + [8.8, -1.11], + [8.83, -0.78], + [9.05, -0.46], + [9.29, 0.27], + [9.49, 1.01], + [9.31, 1.16], + [9.65, 2.28], + [9.8, 3.07], + [9.4, 3.73], + [8.95, 3.9], + [8.74, 4.35], + [8.49, 4.5], + [8.5, 4.77], + [7.46, 4.41], + [7.08, 4.46], + [6.7, 4.24], + [5.9, 4.26], + [5.36, 4.89], + [5.03, 5.61], + [4.33, 6.27], + [3.57, 6.26], + [2.69, 6.26], + [1.87, 6.14], + [1.06, 5.93], + [-0.51, 5.34], + [-1.06, 5.0], + [-1.96, 4.71], + [-2.86, 4.99], + [-3.31, 4.98], + [-4.01, 5.18], + [-4.65, 5.17], + [-5.83, 4.99], + [-6.53, 4.71], + [-7.52, 4.34], + [-7.71, 4.36], + [-7.97, 4.36], + [-9.0, 4.83], + [-9.91, 5.59], + [-10.77, 6.14], + [-11.44, 6.79], + [-11.71, 6.86], + [-12.43, 7.26], + [-12.95, 7.8], + [-13.12, 8.16], + [-13.25, 8.9], + [-13.69, 9.49], + [-14.07, 9.89], + [-14.33, 10.02], + [-14.58, 10.21], + [-14.69, 10.66], + [-14.84, 10.88], + [-15.13, 11.04], + [-15.66, 11.46], + [-16.09, 11.52], + [-16.31, 11.81], + [-16.31, 11.96], + [-16.61, 12.17], + [-16.68, 12.38], + [-16.84, 13.15], + [-16.71, 13.6], + [-17.13, 14.37], + [-17.62, 14.73], + [-17.19, 14.92], + [-16.7, 15.62], + [-16.46, 16.14], + [-16.55, 16.67], + [-16.27, 17.17], + [-16.15, 18.11], + [-16.26, 19.1], + [-16.38, 19.59], + [-16.28, 20.09], + [-16.54, 20.57], + [-17.06, 21.0], + [-17.02, 21.42], + [-16.97, 21.89], + [-16.59, 22.16], + [-16.26, 22.68], + [-16.33, 23.02], + [-15.98, 23.72], + [-15.43, 24.36], + [-15.09, 24.52], + [-14.82, 25.1], + [-14.8, 25.64], + [-14.44, 26.25], + [-13.77, 26.62], + [-13.14, 27.64], + [-12.62, 28.04], + [-11.69, 28.15], + [-10.9, 28.83], + [-10.4, 29.1], + [-9.56, 29.93], + [-9.81, 31.18], + [-9.43, 32.04], + [-9.3, 32.56], + [-8.66, 33.24], + [-7.65, 33.7], + [-6.91, 34.11], + [-6.24, 35.15], + [-5.93, 35.76], + [-5.19, 35.76], + [-4.59, 35.33], + [-3.64, 35.4], + [-2.6, 35.18], + [-2.17, 35.17], + [-1.21, 35.71], + [-0.13, 35.89], + [0.5, 36.3], + [1.47, 36.61], + [3.16, 36.78], + [4.82, 36.87], + [5.32, 36.72], + [6.26, 37.11], + [7.33, 37.12], + [7.74, 36.89], + [8.42, 36.95], + [9.51, 37.35], + [10.21, 37.23], + [10.18, 36.72], + [11.03, 37.09], + [11.1, 36.9], + [10.6, 36.41], + [10.59, 35.95], + [10.94, 35.7], + [10.81, 34.83], + [10.15, 34.33], + [10.34, 33.79], + [10.86, 33.77], + [11.11, 33.29], + [11.49, 33.14], + [12.66, 32.79], + [13.08, 32.88], + [13.92, 32.71], + [15.25, 32.27], + [15.71, 31.38], + [16.61, 31.18], + [18.02, 30.76], + [19.09, 30.27], + [19.57, 30.53], + [20.05, 30.99], + [19.82, 31.75], + [20.13, 32.24], + [20.85, 32.71], + [21.54, 32.84], + [22.9, 32.64], + [23.24, 32.19], + [23.61, 32.19], + [23.93, 32.02], + [24.92, 31.9], + [25.16, 31.57], + [26.5, 31.59], + [27.46, 31.32], + [28.45, 31.03], + [28.91, 30.87], + [29.68, 31.19], + [30.1, 31.47], + [30.98, 31.56], + [31.69, 31.43], + [31.96, 30.93], + [32.19, 31.26], + [32.99, 31.02], + [33.77, 30.97], + [34.27, 31.22], + [34.56, 31.55], + [34.49, 31.61], + [34.75, 32.07], + [34.96, 32.83], + [35.1, 33.08], + [35.13, 33.09], + [35.48, 33.91], + [35.98, 34.61], + [36.0, 34.64], + [35.91, 35.41], + [36.15, 35.82], + [35.78, 36.28], + [36.16, 36.65], + [35.55, 36.57], + [34.71, 36.8], + [34.03, 36.22], + [32.51, 36.11], + [31.7, 36.64], + [30.62, 36.68], + [30.39, 36.26], + [29.7, 36.14], + [28.73, 36.68], + [27.64, 36.66], + [27.05, 37.65], + [26.32, 38.21], + [26.8, 38.99], + [26.17, 39.46], + [27.28, 40.42], + [28.82, 40.46], + [29.24, 41.22], + [31.15, 41.09], + [32.35, 41.74], + [33.51, 42.02], + [35.17, 42.04], + [36.91, 41.34], + [38.35, 40.95], + [39.51, 41.1], + [40.37, 41.01], + [41.55, 41.54], + [41.7, 41.96], + [41.45, 42.65], + [40.88, 43.01], + [40.32, 43.13], + [39.96, 43.44], + [38.68, 44.28], + [37.54, 44.66], + [36.68, 45.24], + [37.4, 45.4], + [38.23, 46.24], + [37.67, 46.64], + [39.15, 47.04], + [39.12, 47.26], + [38.22, 47.1], + [37.43, 47.02], + [36.76, 46.7], + [35.82, 46.65], + [34.96, 46.27], + [35.02, 45.65], + [35.51, 45.41], + [36.53, 45.47], + [36.33, 45.11], + [35.24, 44.94], + [33.88, 44.36], + [33.33, 44.56], + [33.55, 45.03], + [32.45, 45.33], + [32.63, 45.52], + [33.59, 45.85], + [33.3, 46.08], + [31.74, 46.33], + [31.68, 46.71], + [30.75, 46.58], + [30.38, 46.03], + [29.6, 45.29], + [29.63, 45.04], + [29.14, 44.82], + [28.84, 44.91], + [28.56, 43.71], + [28.04, 43.29], + [27.67, 42.58], + [28.0, 42.01], + [28.12, 41.62], + [28.99, 41.3], + [28.81, 41.05], + [27.62, 41.0], + [27.19, 40.69], + [26.36, 40.15], + [26.04, 40.62], + [26.06, 40.82], + [25.45, 40.85], + [24.93, 40.95], + [23.71, 40.69], + [24.41, 40.13], + [23.9, 39.96], + [23.34, 39.96], + [22.81, 40.48], + [22.63, 40.26], + [22.85, 39.66], + [23.35, 39.19], + [22.97, 38.97], + [23.53, 38.51], + [24.03, 38.22], + [24.04, 37.66], + [23.12, 37.92], + [23.41, 37.41], + [22.78, 37.31], + [23.15, 36.42], + [22.49, 36.41], + [21.67, 36.85], + [21.3, 37.65], + [21.12, 38.31], + [20.73, 38.77], + [20.22, 39.34], + [20.15, 39.63], + [19.98, 39.7], + [19.96, 39.92], + [19.41, 40.25], + [19.32, 40.73], + [19.4, 41.41], + [19.54, 41.72], + [19.37, 41.88], + [19.16, 41.96], + [18.88, 42.28], + [18.45, 42.48], + [17.51, 42.85], + [16.93, 43.21], + [16.02, 43.51], + [15.17, 44.24], + [15.38, 44.32], + [14.92, 44.74], + [14.9, 45.08], + [14.26, 45.23], + [13.95, 44.8], + [13.66, 45.14], + [13.68, 45.48], + [13.72, 45.5], + [13.94, 45.59], + [13.14, 45.74], + [12.33, 45.38], + [12.38, 44.89], + [12.26, 44.6], + [12.59, 44.09], + [13.53, 43.59], + [14.03, 42.76], + [15.14, 41.96], + [15.93, 41.96], + [16.17, 41.74], + [15.89, 41.54], + [16.79, 41.18], + [17.52, 40.88], + [18.38, 40.36], + [18.48, 40.17], + [18.29, 39.81], + [17.74, 40.28], + [16.87, 40.44], + [16.45, 39.8], + [17.17, 39.42], + [17.05, 38.9], + [16.64, 38.84], + [16.1, 37.99], + [15.68, 37.91], + [15.69, 38.21], + [15.89, 38.75], + [16.11, 38.96], + [15.72, 39.54], + [15.41, 40.05], + [15.0, 40.17], + [14.7, 40.6], + [14.06, 40.79], + [13.63, 41.19], + [12.89, 41.25], + [12.11, 41.7], + [11.19, 42.36], + [10.51, 42.93], + [10.2, 43.92], + [9.7, 44.04], + [8.89, 44.37], + [8.43, 44.23], + [7.85, 43.77], + [7.44, 43.69], + [6.53, 43.13], + [4.56, 43.4], + [3.1, 43.08], + [2.99, 42.47], + [3.04, 41.89], + [2.09, 41.23], + [0.81, 41.01], + [0.72, 40.68], + [0.11, 40.12], + [-0.28, 39.31], + [0.11, 38.74], + [-0.47, 38.29], + [-0.68, 37.64], + [-1.44, 37.44], + [-2.15, 36.67], + [-3.42, 36.66], + [-4.37, 36.68], + [-5.0, 36.32], + [-5.38, 35.95], + [-5.87, 36.03], + [-6.24, 36.37], + [-6.52, 36.94], + [-7.45, 37.1], + [-7.86, 36.84], + [-8.38, 36.98], + [-8.9, 36.87], + [-8.75, 37.65], + [-8.84, 38.27], + [-9.29, 38.36], + [-9.53, 38.74], + [-9.45, 39.39], + [-9.05, 39.76], + [-8.98, 40.16], + [-8.77, 40.76], + [-8.79, 41.18], + [-8.99, 41.54], + [-9.03, 41.88], + [-8.98, 42.59], + [-9.39, 43.03], + [-7.98, 43.75], + [-6.75, 43.57], + [-5.41, 43.57], + [-4.35, 43.4], + [-3.52, 43.46], + [-1.9, 43.42], + [-1.38, 44.02], + [-1.19, 46.01], + [-2.23, 47.06], + [-2.96, 47.57], + [-4.49, 47.96], + [-4.59, 48.68], + [-3.3, 48.9], + [-1.62, 48.64], + [-1.93, 49.78], + [-0.99, 49.35], + [1.34, 50.13], + [1.64, 50.95], + [2.51, 51.15], + [3.32, 51.35], + [3.83, 51.62], + [4.71, 53.09], + [6.07, 53.51], + [6.91, 53.48], + [7.1, 53.69], + [7.94, 53.75], + [8.12, 53.53], + [8.8, 54.02], + [8.57, 54.4], + [8.53, 54.96], + [8.12, 55.52], + [8.09, 56.54], + [8.26, 56.81], + [8.54, 57.11], + [9.42, 57.17], + [9.78, 57.45], + [10.58, 57.73], + [10.55, 57.22], + [10.25, 56.89], + [10.37, 56.61], + [10.91, 56.46], + [10.67, 56.08], + [10.37, 56.19], + [9.65, 55.47], + [9.92, 54.98], + [9.94, 54.6], + [10.95, 54.36], + [10.94, 54.01], + [11.96, 54.2], + [12.52, 54.47], + [13.65, 54.08], + [14.12, 53.76], + [14.8, 54.05], + [16.36, 54.51], + [17.62, 54.85], + [18.62, 54.68], + [18.7, 54.44], + [19.66, 54.43], + [19.89, 54.87], + [21.27, 55.19], + [21.06, 56.03], + [21.09, 56.78], + [21.58, 57.41], + [22.52, 57.75], + [23.32, 57.01], + [24.12, 57.03], + [24.31, 57.79], + [24.43, 58.38], + [24.06, 58.26], + [23.43, 58.61], + [23.34, 59.19], + [24.6, 59.47], + [25.86, 59.61], + [26.95, 59.45], + [27.98, 59.48], + [29.12, 60.03], + [28.07, 60.5], + [26.26, 60.42], + [24.5, 60.06], + [22.87, 59.85], + [22.29, 60.39], + [21.32, 60.72], + [21.54, 61.71], + [21.06, 62.61], + [21.54, 63.19], + [22.44, 63.82], + [24.73, 64.9], + [25.4, 65.11], + [25.29, 65.53], + [23.9, 66.01], + [22.18, 65.72], + [21.21, 65.03], + [21.37, 64.41], + [19.78, 63.61], + [17.85, 62.75], + [17.12, 61.34], + [17.83, 60.64], + [18.79, 60.08], + [17.87, 58.95], + [16.83, 58.72], + [16.45, 57.04], + [15.88, 56.1], + [14.67, 56.2], + [14.1, 55.41], + [12.94, 55.36], + [12.63, 56.31], + [11.79, 57.44], + [11.03, 58.86], + [10.36, 59.47], + [8.38, 58.31], + [7.05, 58.08], + [5.67, 58.59], + [5.31, 59.66], + [4.99, 61.97], + [5.91, 62.61], + [8.55, 63.45], + [10.53, 64.49], + [12.36, 65.88], + [14.76, 67.81], + [16.44, 68.56], + [19.18, 69.82], + [21.38, 70.26], + [23.02, 70.2], + [24.55, 71.03], + [26.37, 70.99], + [28.17, 71.19], + [31.29, 70.45], + [30.01, 70.19], + [31.1, 69.56], + [32.13, 69.91], + [33.78, 69.3], + [36.51, 69.06], + [40.29, 67.93], + [41.06, 67.46], + [41.13, 66.79], + [40.02, 66.27], + [38.38, 66.0], + [33.92, 66.76], + [33.18, 66.63], + [34.81, 65.9], + [34.94, 64.41], + [36.23, 64.11], + [37.01, 63.85], + [37.14, 64.33], + [36.52, 64.78], + [37.18, 65.14], + [39.59, 64.52], + [40.44, 64.76], + [39.76, 65.5], + [42.09, 66.48], + [43.02, 66.42], + [43.95, 66.07], + [44.53, 66.76], + [43.7, 67.35], + [44.19, 67.95], + [43.45, 68.57], + [46.25, 68.25], + [46.82, 67.69], + [45.56, 67.57], + [45.56, 67.01], + [46.35, 66.67], + [47.89, 66.88], + [48.14, 67.52], + [50.23, 68.0], + [53.72, 68.86], + [54.47, 68.81], + [53.49, 68.2], + [54.73, 68.1], + [55.44, 68.44], + [57.32, 68.47], + [58.8, 68.88], + [59.94, 68.28], + [61.08, 68.94], + [60.03, 69.52], + [60.55, 69.85], + [63.5, 69.55], + [64.89, 69.23], + [68.51, 68.09], + [69.18, 68.62], + [68.16, 69.14], + [68.14, 69.36], + [66.93, 69.45], + [67.26, 69.93], + [66.72, 70.71], + [66.69, 71.03], + [68.54, 71.93], + [69.2, 72.84], + [69.94, 73.04], + [72.59, 72.78], + [72.8, 72.22], + [71.85, 71.41], + [72.47, 71.09], + [72.79, 70.39], + [72.56, 69.02], + [73.67, 68.41], + [73.24, 67.74], + [71.28, 66.32], + [72.42, 66.17], + [72.82, 66.53], + [73.92, 66.79], + [74.19, 67.28], + [75.05, 67.76], + [74.47, 68.33], + [74.94, 68.99], + [73.84, 69.07], + [73.6, 69.63], + [74.4, 70.63], + [73.1, 71.45], + [74.89, 72.12], + [74.66, 72.83], + [75.16, 72.86], + [75.68, 72.3], + [75.29, 71.34], + [76.36, 71.15], + [75.9, 71.87], + [77.58, 72.27], + [79.65, 72.32], + [81.5, 71.75], + [80.61, 72.58], + [80.51, 73.65], + [82.25, 73.85], + [84.66, 73.81], + [86.82, 73.94], + [86.01, 74.46], + [87.17, 75.12], + [88.32, 75.14], + [90.26, 75.64], + [92.9, 75.77], + [93.23, 76.05], + [95.86, 76.14], + [96.68, 75.92], + [98.92, 76.45], + [100.76, 76.43], + [101.04, 76.86], + [101.99, 77.29], + [104.35, 77.7], + [106.07, 77.37], + [104.71, 77.13], + [106.97, 76.97] + ], + [ + [49.11, 41.28], + [49.62, 40.57], + [50.08, 40.53], + [50.39, 40.26], + [49.57, 40.18], + [49.4, 39.4], + [49.22, 39.05], + [48.86, 38.82], + [48.88, 38.32], + [49.2, 37.58], + [50.15, 37.37], + [50.84, 36.87], + [52.26, 36.7], + [53.83, 36.97], + [53.92, 37.2], + [53.74, 37.91], + [53.88, 38.95], + [53.1, 39.29], + [53.36, 39.98], + [52.69, 40.03], + [52.92, 40.88], + [53.86, 40.63], + [54.74, 40.95], + [54.01, 41.55], + [53.72, 42.12], + [52.92, 41.87], + [52.81, 41.14], + [52.5, 41.78], + [52.45, 42.03], + [52.69, 42.44], + [52.5, 42.79], + [51.34, 43.13], + [50.89, 44.03], + [50.34, 44.28], + [50.31, 44.61], + [51.28, 44.51], + [51.32, 45.25], + [52.17, 45.41], + [53.04, 45.26], + [53.22, 46.23], + [53.04, 46.85], + [52.04, 46.8], + [51.19, 47.05], + [50.03, 46.61], + [49.1, 46.4], + [48.65, 45.81], + [47.68, 45.64], + [46.68, 44.61], + [47.59, 43.66], + [47.49, 42.99], + [48.58, 41.81], + [49.11, 41.28] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-93.84, 77.52], + [-94.3, 77.49], + [-96.17, 77.56], + [-96.44, 77.83], + [-94.42, 77.82], + [-93.72, 77.63], + [-93.84, 77.52] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-110.19, 77.7], + [-112.05, 77.41], + [-113.53, 77.73], + [-112.72, 78.05], + [-111.26, 78.15], + [-109.85, 78.0], + [-110.19, 77.7] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [24.72, 77.85], + [22.49, 77.44], + [20.73, 77.68], + [21.42, 77.94], + [20.81, 78.25], + [22.88, 78.45], + [23.28, 78.08], + [24.72, 77.85] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-109.66, 78.6], + [-110.88, 78.41], + [-112.54, 78.41], + [-112.53, 78.55], + [-111.5, 78.85], + [-110.96, 78.8], + [-109.66, 78.6] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-95.83, 78.06], + [-97.31, 77.85], + [-98.12, 78.08], + [-98.55, 78.46], + [-98.63, 78.87], + [-97.34, 78.83], + [-96.75, 78.77], + [-95.56, 78.42], + [-95.83, 78.06] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-100.06, 78.32], + [-99.67, 77.91], + [-101.3, 78.02], + [-102.95, 78.34], + [-105.18, 78.38], + [-104.21, 78.68], + [-105.42, 78.92], + [-105.49, 79.3], + [-103.53, 79.17], + [-100.83, 78.8], + [-100.06, 78.32] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [105.08, 78.31], + [99.44, 77.92], + [101.26, 79.23], + [102.09, 79.35], + [102.84, 79.28], + [105.37, 78.71], + [105.08, 78.31] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [18.25, 79.7], + [21.54, 78.96], + [19.03, 78.56], + [18.47, 77.83], + [17.59, 77.64], + [17.12, 76.81], + [15.91, 76.77], + [13.76, 77.38], + [14.67, 77.74], + [13.17, 78.02], + [11.22, 78.87], + [10.44, 79.65], + [13.17, 80.01], + [13.72, 79.66], + [15.14, 79.67], + [15.52, 80.02], + [16.99, 80.05], + [18.25, 79.7] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [25.45, 80.41], + [27.41, 80.06], + [25.92, 79.52], + [23.02, 79.4], + [20.08, 79.57], + [19.9, 79.84], + [18.46, 79.86], + [17.37, 80.32], + [20.46, 80.6], + [21.91, 80.36], + [22.92, 80.66], + [25.45, 80.41] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.14, 80.55], + [49.79, 80.42], + [48.89, 80.34], + [48.75, 80.18], + [47.59, 80.01], + [46.5, 80.25], + [47.07, 80.56], + [44.85, 80.59], + [46.8, 80.77], + [48.32, 80.78], + [48.52, 80.51], + [49.1, 80.75], + [50.04, 80.92], + [51.52, 80.7], + [51.14, 80.55] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [99.94, 78.88], + [97.76, 78.76], + [94.97, 79.04], + [93.31, 79.43], + [92.55, 80.14], + [91.18, 80.34], + [93.78, 81.02], + [95.94, 81.25], + [97.88, 80.75], + [100.19, 79.78], + [99.94, 78.88] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-87.02, 79.66], + [-85.81, 79.34], + [-87.19, 79.04], + [-89.04, 78.29], + [-90.8, 78.22], + [-92.88, 78.34], + [-93.95, 78.75], + [-93.94, 79.11], + [-93.15, 79.38], + [-94.97, 79.37], + [-96.08, 79.71], + [-96.71, 80.16], + [-96.02, 80.6], + [-95.32, 80.91], + [-94.3, 80.98], + [-94.74, 81.21], + [-92.41, 81.26], + [-91.13, 80.72], + [-89.45, 80.51], + [-87.81, 80.32], + [-87.02, 79.66] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-68.5, 83.11], + [-65.83, 83.03], + [-63.68, 82.9], + [-61.85, 82.63], + [-61.89, 82.36], + [-64.33, 81.93], + [-66.75, 81.73], + [-67.66, 81.5], + [-65.48, 81.51], + [-67.84, 80.9], + [-69.47, 80.62], + [-71.18, 79.8], + [-73.24, 79.63], + [-73.88, 79.43], + [-76.91, 79.32], + [-75.53, 79.2], + [-76.22, 79.02], + [-75.39, 78.53], + [-76.34, 78.18], + [-77.89, 77.9], + [-78.36, 77.51], + [-79.76, 77.21], + [-79.62, 76.98], + [-77.91, 77.02], + [-77.89, 76.78], + [-80.56, 76.18], + [-83.17, 76.45], + [-86.11, 76.3], + [-87.6, 76.42], + [-89.49, 76.47], + [-89.62, 76.95], + [-87.77, 77.18], + [-88.26, 77.9], + [-87.65, 77.97], + [-84.98, 77.54], + [-86.34, 78.18], + [-87.96, 78.37], + [-87.15, 78.76], + [-85.38, 79.0], + [-85.09, 79.35], + [-86.51, 79.74], + [-86.93, 80.25], + [-84.2, 80.21], + [-83.41, 80.1], + [-81.85, 80.46], + [-84.1, 80.58], + [-87.6, 80.52], + [-89.37, 80.86], + [-90.2, 81.26], + [-91.37, 81.55], + [-91.59, 81.89], + [-90.1, 82.08], + [-88.93, 82.12], + [-86.97, 82.28], + [-85.5, 82.65], + [-84.26, 82.6], + [-83.18, 82.32], + [-82.42, 82.86], + [-81.1, 83.02], + [-79.31, 83.13], + [-76.25, 83.17], + [-75.72, 83.06], + [-72.83, 83.23], + [-70.67, 83.17], + [-68.5, 83.11] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-27.1, 83.52], + [-20.85, 82.73], + [-22.69, 82.34], + [-26.52, 82.3], + [-31.9, 82.2], + [-31.4, 82.02], + [-27.86, 82.13], + [-24.84, 81.79], + [-22.9, 82.09], + [-22.07, 81.73], + [-23.17, 81.15], + [-20.62, 81.52], + [-15.77, 81.91], + [-12.77, 81.72], + [-12.21, 81.29], + [-16.29, 80.58], + [-16.85, 80.35], + [-20.05, 80.18], + [-17.73, 80.13], + [-18.9, 79.4], + [-19.7, 78.75], + [-19.67, 77.64], + [-18.47, 76.99], + [-20.04, 76.94], + [-21.68, 76.63], + [-19.83, 76.1], + [-19.6, 75.25], + [-20.67, 75.16], + [-19.37, 74.3], + [-21.59, 74.22], + [-20.43, 73.82], + [-20.76, 73.46], + [-22.17, 73.31], + [-23.57, 73.31], + [-22.31, 72.63], + [-22.3, 72.18], + [-24.28, 72.6], + [-24.79, 72.33], + [-23.44, 72.08], + [-22.13, 71.47], + [-21.75, 70.66], + [-23.54, 70.47], + [-24.31, 70.86], + [-25.54, 71.43], + [-25.2, 70.75], + [-26.36, 70.23], + [-23.73, 70.18], + [-22.35, 70.13], + [-25.03, 69.26], + [-27.75, 68.47], + [-30.67, 68.13], + [-31.78, 68.12], + [-32.81, 67.74], + [-34.2, 66.68], + [-36.35, 65.98], + [-37.04, 65.94], + [-38.38, 65.69], + [-39.81, 65.46], + [-40.67, 64.84], + [-40.68, 64.14], + [-41.19, 63.48], + [-42.82, 62.68], + [-42.42, 61.9], + [-42.87, 61.07], + [-43.38, 60.1], + [-44.79, 60.04], + [-46.26, 60.85], + [-48.26, 60.86], + [-49.23, 61.41], + [-49.9, 62.38], + [-51.63, 63.63], + [-52.14, 64.28], + [-52.28, 65.18], + [-53.66, 66.1], + [-53.3, 66.84], + [-53.97, 67.19], + [-52.98, 68.36], + [-51.48, 68.73], + [-51.08, 69.15], + [-50.87, 69.93], + [-52.01, 69.57], + [-52.56, 69.43], + [-53.46, 69.28], + [-54.68, 69.61], + [-54.75, 70.29], + [-54.36, 70.82], + [-53.43, 70.84], + [-51.39, 70.57], + [-53.11, 71.2], + [-54.0, 71.55], + [-55, 71.41], + [-55.83, 71.65], + [-54.72, 72.59], + [-55.33, 72.96], + [-56.12, 73.65], + [-57.32, 74.71], + [-58.6, 75.1], + [-58.59, 75.52], + [-61.27, 76.1], + [-63.39, 76.18], + [-66.06, 76.13], + [-68.5, 76.06], + [-69.66, 76.38], + [-71.4, 77.01], + [-68.78, 77.32], + [-66.76, 77.38], + [-71.04, 77.64], + [-73.3, 78.04], + [-73.16, 78.43], + [-69.37, 78.91], + [-65.71, 79.39], + [-65.32, 79.76], + [-68.02, 80.12], + [-67.15, 80.52], + [-63.69, 81.21], + [-62.23, 81.32], + [-62.65, 81.77], + [-60.28, 82.03], + [-57.21, 82.19], + [-54.13, 82.2], + [-53.04, 81.89], + [-50.39, 82.44], + [-48.0, 82.06], + [-46.6, 81.99], + [-44.52, 81.66], + [-46.9, 82.2], + [-46.76, 82.63], + [-43.41, 83.23], + [-39.9, 83.18], + [-38.62, 83.55], + [-35.09, 83.65], + [-27.1, 83.52] + ] + ] + } + } + ] +} diff --git a/web/scripts/gen-protocol.mjs b/web/scripts/gen-protocol.mjs new file mode 100644 index 0000000..4663533 --- /dev/null +++ b/web/scripts/gen-protocol.mjs @@ -0,0 +1,20 @@ +// Generate src/lib/protocol/generated.ts from ../contract/schema.json. +// Run via `make contract`; never edit the output by hand. +import { readFile, writeFile } from 'node:fs/promises'; +import { compile } from 'json-schema-to-typescript'; + +const schemaPath = new URL('../../contract/schema.json', import.meta.url); +const outPath = new URL('../src/lib/protocol/generated.ts', import.meta.url); + +const schema = JSON.parse(await readFile(schemaPath, 'utf8')); +const ts = await compile(schema, 'GhostJs8Protocol', { + bannerComment: + '/* eslint-disable */\n/**\n * GENERATED from contract/schema.json by web/scripts/gen-protocol.mjs.\n * Source of truth: bridge/src/ghostjs8/contract/messages.py. Do not edit.\n */', + additionalProperties: false, + unreachableDefinitions: true, + strictIndexSignatures: true, + format: true, + style: { singleQuote: true, printWidth: 100 }, +}); +await writeFile(outPath, ts); +console.log(`wrote ${outPath.pathname}`); diff --git a/web/src/app/App.svelte b/web/src/app/App.svelte new file mode 100644 index 0000000..2008220 --- /dev/null +++ b/web/src/app/App.svelte @@ -0,0 +1,204 @@ + + + + + +
+ + + + {#if netId} + + {:else} + {#if phone} + + {/if} + +
+
+ {#if show('controls')} + + + + {/if} +
+
+ {#if show('waterfall')}{/if} + {#if show('traffic')}{/if} +
+
+ {#if show('ghostnet')}{/if} + {#if show('stations')}{/if} + {#if show('map')}{/if} + {#if show('status')}{/if} +
+
+ {/if} + +
+ ghost.js8 · JS8 ops console · decodes by native JS8Call · build {app.state.hello?.build ?? '—'} +
+
+ + diff --git a/web/src/components/Backdrop.svelte b/web/src/components/Backdrop.svelte new file mode 100644 index 0000000..951bbbd --- /dev/null +++ b/web/src/components/Backdrop.svelte @@ -0,0 +1,81 @@ + + +{#if backdrop.mode === 'signal'} + +{/if} + + diff --git a/web/src/components/Logo.svelte b/web/src/components/Logo.svelte new file mode 100644 index 0000000..a7ef44f --- /dev/null +++ b/web/src/components/Logo.svelte @@ -0,0 +1,80 @@ + + + + + diff --git a/web/src/components/Panel.svelte b/web/src/components/Panel.svelte new file mode 100644 index 0000000..365802b --- /dev/null +++ b/web/src/components/Panel.svelte @@ -0,0 +1,121 @@ + + +
+
+ +

{title}

+ + {#if actions}
{@render actions()}
{/if} +
+
+ {@render children()} +
+
+ + diff --git a/web/src/components/Pill.svelte b/web/src/components/Pill.svelte new file mode 100644 index 0000000..6c00dd5 --- /dev/null +++ b/web/src/components/Pill.svelte @@ -0,0 +1,57 @@ + + + + {label} + + + diff --git a/web/src/components/pill.ts b/web/src/components/pill.ts new file mode 100644 index 0000000..38ddf51 --- /dev/null +++ b/web/src/components/pill.ts @@ -0,0 +1 @@ +export type PillTone = 'ok' | 'info' | 'warn' | 'alert' | 'unknown'; diff --git a/web/src/features/audio/AudioPanel.svelte b/web/src/features/audio/AudioPanel.svelte new file mode 100644 index 0000000..b9acacb --- /dev/null +++ b/web/src/features/audio/AudioPanel.svelte @@ -0,0 +1,91 @@ + + + + {#snippet actions()} + {#if app.audioEnabled} + + {/if} + {/snippet} +
+ {#if app.audioEnabled} + + + {:else} + + {/if} +
+ + {#if app.audioError}{/if} + {#if app.audioEnabled && app.audioStats} +

+ buffer {bufferedMs} ms · dropped {app.audioStats.dropped} · gaps {app.audioStats.underruns} +

+ {/if} +

Monitoring only. Volume changes what you hear, not what the decoder receives.

+
+ + diff --git a/web/src/features/ghostnet/FlashAlert.svelte b/web/src/features/ghostnet/FlashAlert.svelte new file mode 100644 index 0000000..ef1e87b --- /dev/null +++ b/web/src/features/ghostnet/FlashAlert.svelte @@ -0,0 +1,76 @@ + + +{#if current} + +{/if} + + diff --git a/web/src/features/ghostnet/GhostNetPanel.svelte b/web/src/features/ghostnet/GhostNetPanel.svelte new file mode 100644 index 0000000..0f24c64 --- /dev/null +++ b/web/src/features/ghostnet/GhostNetPanel.svelte @@ -0,0 +1,248 @@ + + + + {#snippet actions()} + + {/snippet} + + {#if !g || !g.enabled} +

{g?.detail ?? 'Waiting for the bridge…'}

+

+ Set GHOSTJS8_GHOSTNET=on, GHOSTJS8_GHOSTNET_REGION and + GHOSTJS8_HOME_GRID to monitor and record the nets automatically. +

+ {:else} +
+ {REGION_LABEL[g.region ?? 'na']} · home {g.home_grid} +
+ + {#if target} +
+
+ {#if g.recording_net_id} REC{/if} + {target.label} +
+
{target.band} · {formatMHz(target.dial_hz)} MHz USB
+
+ {#if inWindow && started} + ends in {countdown(app.now, Date.parse(target.end))} + {:else} + {inWindow ? 'pre-roll · starts in' : 'next in'} + {countdown(app.now, Date.parse(target.start))} + {/if} +
+
+ {utcDateTime(target.start)} · local {localWhen(target.start)} +
+
+ {/if} + + {#if g.receiver_reason}

Receiver: {g.receiver_reason}

{/if} + {#if g.mode === 'paused' && g.paused_until} +

+ A viewer took manual control; autopilot resumes {utcDateTime(g.paused_until)}. +

+ {/if} + {#if g.detail}

{g.detail}

{/if} + {/if} + +

Recorded nets

+ {#if loadError} +

Could not load recordings ({loadError}).

+ {:else if nets.length === 0} +

No nets recorded yet.

+ {:else} + + {/if} +
+ + diff --git a/web/src/features/ghostnet/NetLogView.svelte b/web/src/features/ghostnet/NetLogView.svelte new file mode 100644 index 0000000..4bdd9ee --- /dev/null +++ b/web/src/features/ghostnet/NetLogView.svelte @@ -0,0 +1,438 @@ + + +
+ + + {#if error} + + {:else if !log || !s} +

Loading recording…

+ {:else} +
+

{s.label}

+
+ {utcDateTime(s.scheduled_start)} – {utcTime(s.scheduled_end)}Z · {s.band} · {formatMHz( + s.dial_hz, + )} MHz +
+
+ {s.receiver ?? 'no receiver recorded'}{s.receiver_reason ? ` — ${s.receiver_reason}` : ''} +
+
+ + + {#if s.flash_count > 0}{/if} + {#if s.operator_override}{/if} + {#if s.ended === null}{/if} +
+
+ +
+ + {#if s.has_waterfall && seconds > 0} + +
+ + +
+

+ Newest at the bottom. Synthetic receivers produce synthetic waterfalls. +

+ {:else} +

No waterfall recorded.

+ {/if} +
+ +
+ + {#if s.has_audio} + + + {:else} +

No audio recorded for this net.

+ {/if} +
+ + + {#if log.stations.length === 0} +

No stations.

+ {:else} + + + + {#each log.stations as st (st.callsign)} + + + + + + + {/each} + +
CallsignSNRGridCount
{st.callsign}{formatSnr(st.snr_db)}{st.grid ?? '—'}{st.heard_count}
+ {/if} +
+
+
+ + + {#if rows.length === 0} +

No traffic copied during this window.

+ {:else} +
    + {#each rows as d, i (i)} +
  1. + + {formatSnr(d.snr_db)} + {d.offset_hz} + + {#if d.tags.flash}FLASH{/if} + {#if d.tags.ghostnet}{d.tags.regional ?? 'GN'}{/if} + {#if d.kind === 'directed'}DIR{/if} + + {d.text} +
  2. + {/each} +
+ {/if} +
+ {/if} +
+ + diff --git a/web/src/features/map/MapPanel.svelte b/web/src/features/map/MapPanel.svelte new file mode 100644 index 0000000..3397538 --- /dev/null +++ b/web/src/features/map/MapPanel.svelte @@ -0,0 +1,215 @@ + + + + {#snippet actions()} + {stations.length} with grid + {/snippet} +
+ {#if failed}

Map unavailable: {failed}

{/if} +

Only stations that sent a valid Maidenhead grid are plotted.

+
+ + diff --git a/web/src/features/receiver/ReceiverPanel.svelte b/web/src/features/receiver/ReceiverPanel.svelte new file mode 100644 index 0000000..178514f --- /dev/null +++ b/web/src/features/receiver/ReceiverPanel.svelte @@ -0,0 +1,297 @@ + + + + {#snippet actions()} + {#if current} + + {/if} + {/snippet} + +
+ {#if current} + {current.name ?? current.host} + {current.host}:{current.port} + {app.state.session?.subscribers ?? 0} watching + {:else} + No receiver selected. + {/if} +
+ +
+ Manual host:port +
+ + + + +
+
+ + + +
    + {#each results as r (r.id)} + {@const full = r.users >= r.users_max} +
  • + + {#if full}{/if} +
  • + {:else} +
  • {directory ? 'No receivers match.' : 'Loading directory…'}
  • + {/each} +
+
+ + diff --git a/web/src/features/stations/StationsPanel.svelte b/web/src/features/stations/StationsPanel.svelte new file mode 100644 index 0000000..66fcbff --- /dev/null +++ b/web/src/features/stations/StationsPanel.svelte @@ -0,0 +1,96 @@ + + + + {#snippet actions()} + {stations.length} + {/snippet} + {#if stations.length === 0} +

No stations heard yet.

+ {:else} +
+ + + + + + {#each stations as s (s.callsign)} + + + + + + + + {/each} + +
CallsignHeardSNRGridCount
{s.callsign}{ago(s.last_heard_utc, app.now)}{formatSnr(s.snr_db)}{s.grid ?? '—'}{s.heard_count}
+
+ {/if} +
+ + diff --git a/web/src/features/status/SituationBar.svelte b/web/src/features/status/SituationBar.svelte new file mode 100644 index 0000000..3595a01 --- /dev/null +++ b/web/src/features/status/SituationBar.svelte @@ -0,0 +1,212 @@ + + +
+
+ Listening on +
+ {dial === null ? '— — —' : formatMHz(dial)} + + MHz + {#if session}{session.tuning.mode.toUpperCase()}{/if} + {#if band}{band}{/if} + +
+ + {session?.receiver + ? (session.receiver.name ?? session.receiver.host) + : 'No receiver selected'} + +
+ +
+ +

{s.detail}

+ {#if s.suggestSwitch} + + {/if} +
+ +
+
+
Decodes · 1 h
+
{lastHour}
+
+
+
Stations
+
{heard}
+
+
+
Last decode
+
{lastDecode ? ago(lastDecode, app.now) : '—'}
+
+
+
+ + diff --git a/web/src/features/status/StatusPanel.svelte b/web/src/features/status/StatusPanel.svelte new file mode 100644 index 0000000..72ac9bc --- /dev/null +++ b/web/src/features/status/StatusPanel.svelte @@ -0,0 +1,112 @@ + + + + {#snippet actions()} + + {/snippet} + {#if !h} +

Awaiting first health report from the bridge.

+ {:else} +
    + {#each ROWS as [key, label] (key)} + {@const c = h.components[key]} +
  • + {label} + + + {#if c.last_seen}seen {ago(c.last_seen, app.now)} ago{:else}—{/if} + + {#if c.detail && c.state !== 'ok'}{c.detail}{/if} +
  • + {/each} +
  • + Last decode + + {h.last_decode_utc + ? `${utcTime(h.last_decode_utc)}Z (${ago(h.last_decode_utc, app.now)} ago)` + : 'none yet'} + +
  • +
+ {/if} +
+ + diff --git a/web/src/features/status/TopBar.svelte b/web/src/features/status/TopBar.svelte new file mode 100644 index 0000000..2c79e3d --- /dev/null +++ b/web/src/features/status/TopBar.svelte @@ -0,0 +1,192 @@ + + + + + diff --git a/web/src/features/timeline/TimelinePanel.svelte b/web/src/features/timeline/TimelinePanel.svelte new file mode 100644 index 0000000..a7ba40f --- /dev/null +++ b/web/src/features/timeline/TimelinePanel.svelte @@ -0,0 +1,174 @@ + + + + {#snippet actions()} + + {/snippet} +
{announcement}
+ {#if rows.length === 0} +

+ No traffic copied yet. Decodes appear here every 15-second cycle when JS8 is heard. +

+ {:else} + +
    + {#each rows as d (d.key)} + {@const tags = classify(d.text)} +
  1. + + {formatSnr(d.snr_db)} + {d.offset_hz} + + {#if d.kind === 'directed'}DIR{:else}ACT{/if} + + {#if tags.flash}FLASH{/if}{#if tags.ghostnet}{tags.regional ?? 'GN'}{/if}{#if d.from_call && d.text.startsWith(`${d.from_call}:`)}{d.from_call}{d.text.slice(d.from_call.length)}{:else}{d.text}{/if} +
  2. + {/each} +
+ {/if} +
+ + diff --git a/web/src/features/tuning/TuningPanel.svelte b/web/src/features/tuning/TuningPanel.svelte new file mode 100644 index 0000000..e932c16 --- /dev/null +++ b/web/src/features/tuning/TuningPanel.svelte @@ -0,0 +1,177 @@ + + + + {#snippet actions()} + {#if bandFor(tuning.dial_hz)}{bandFor(tuning.dial_hz)}{/if} + {/snippet} +
+ {#each JS8_BANDS as b (b.band)} + + {/each} +
+ +
+ +
+ Mode + + +
+ + + +
+

+ Tuning is shared by everyone watching this station. JS8 lives at the dial + 0–3 kHz (USB). +

+ {#if error}{/if} +
+ + diff --git a/web/src/features/waterfall/WaterfallPanel.svelte b/web/src/features/waterfall/WaterfallPanel.svelte new file mode 100644 index 0000000..fcfdf8a --- /dev/null +++ b/web/src/features/waterfall/WaterfallPanel.svelte @@ -0,0 +1,183 @@ + + + + {#snippet actions()} + + + + {/snippet} + +
+ + + {#if !fresh} +
+ {app.state.session?.state === 'connected' + ? 'WATERFALL STALE — NO ROWS ARRIVING' + : 'NO WATERFALL — RECEIVER NOT CONNECTED'} +
+ {/if} +
+

+ Axis shows audio offset in hertz from the dial, {width > 0 ? 'zero to three thousand' : ''}. +

+
+ + diff --git a/web/src/lib/audio/pcm-worklet.ts b/web/src/lib/audio/pcm-worklet.ts new file mode 100644 index 0000000..ba41f6a --- /dev/null +++ b/web/src/lib/audio/pcm-worklet.ts @@ -0,0 +1,50 @@ +/** + * AudioWorklet: plays PCM posted from the main thread through a bounded ring. + * Runs in AudioWorkletGlobalScope (not covered by the DOM lib), so the few + * globals used are declared here. + */ +import { PcmRing } from './ring'; + +declare const sampleRate: number; +declare function registerProcessor(name: string, ctor: new () => AudioWorkletProcessorLike): void; +declare const AudioWorkletProcessor: new () => { readonly port: MessagePort }; +interface AudioWorkletProcessorLike { + process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean; +} + +export const PROCESSOR_NAME = 'ghost-pcm'; + +class GhostPcmProcessor extends AudioWorkletProcessor implements AudioWorkletProcessorLike { + // ~1 s ceiling, ~250 ms prebuffer: enough for network jitter, never a growing backlog. + private readonly ring = new PcmRing(Math.round(sampleRate), Math.round(sampleRate / 4)); + private frames = 0; + + constructor() { + super(); + this.port.onmessage = (ev: MessageEvent) => { + const data = ev.data; + if (data instanceof Float32Array) this.ring.push(data); + else if (data === 'clear') this.ring.clear(); + }; + } + + process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean { + const out = outputs[0]; + const first = out?.[0]; + if (!out || !first) return true; + this.ring.pull(first); + for (let c = 1; c < out.length; c++) out[c]?.set(first); + this.frames += first.length; + if (this.frames >= sampleRate) { + this.frames = 0; + this.port.postMessage({ + buffered: this.ring.available, + dropped: this.ring.dropped, + underruns: this.ring.underruns, + }); + } + return true; + } +} + +registerProcessor(PROCESSOR_NAME, GhostPcmProcessor); diff --git a/web/src/lib/audio/player.ts b/web/src/lib/audio/player.ts new file mode 100644 index 0000000..76326ca --- /dev/null +++ b/web/src/lib/audio/player.ts @@ -0,0 +1,78 @@ +/** + * WebAudio playback of the receiver's 12 kHz PCM. The AudioContext runs at + * 12 kHz so the browser does the resampling. Must be started from a user + * gesture (autoplay policy). stop() releases every audio resource. + */ +import workletUrl from './pcm-worklet.ts?worker&url'; + +export interface PlayerStats { + buffered: number; + dropped: number; + underruns: number; +} + +export class AudioPlayer { + private ctx: AudioContext | null = null; + private node: AudioWorkletNode | null = null; + private gain: GainNode | null = null; + private volume = 0.8; + private muted = false; + onStats: ((stats: PlayerStats) => void) | null = null; + + get running(): boolean { + return this.ctx !== null; + } + + async start(sampleRate = 12_000): Promise { + if (this.ctx) return; + const ctx = new AudioContext({ sampleRate, latencyHint: 'interactive' }); + try { + await ctx.audioWorklet.addModule(workletUrl); + const node = new AudioWorkletNode(ctx, 'ghost-pcm', { + numberOfInputs: 0, + outputChannelCount: [1], + }); + const gain = ctx.createGain(); + node.connect(gain).connect(ctx.destination); + node.port.onmessage = (ev: MessageEvent) => this.onStats?.(ev.data); + this.ctx = ctx; + this.node = node; + this.gain = gain; + this.applyGain(); + await ctx.resume(); + } catch (err) { + await ctx.close(); + throw err; + } + } + + push(samples: Float32Array): void { + // Transfer, don't copy: the buffer belongs to the worklet from here on. + this.node?.port.postMessage(samples, [samples.buffer]); + } + + setVolume(volume: number): void { + this.volume = Math.min(1, Math.max(0, volume)); + this.applyGain(); + } + + setMuted(muted: boolean): void { + this.muted = muted; + this.applyGain(); + } + + async stop(): Promise { + const ctx = this.ctx; + this.node?.port.close(); + this.node?.disconnect(); + this.gain?.disconnect(); + this.ctx = this.node = this.gain = null; + if (ctx) await ctx.close(); + } + + private applyGain(): void { + if (this.gain && this.ctx) { + this.gain.gain.setTargetAtTime(this.muted ? 0 : this.volume, this.ctx.currentTime, 0.02); + } + } +} diff --git a/web/src/lib/audio/ring.ts b/web/src/lib/audio/ring.ts new file mode 100644 index 0000000..65eb493 --- /dev/null +++ b/web/src/lib/audio/ring.ts @@ -0,0 +1,75 @@ +/** + * Bounded PCM ring buffer with drop/resync semantics, used inside the + * AudioWorklet. Latency never grows: overflow drops the oldest samples, and + * an underrun outputs silence until `prebuffer` samples have accumulated. + * Framework-free and allocation-free on the hot path. + */ +export class PcmRing { + private readonly buf: Float32Array; + private read = 0; + private write = 0; + private count = 0; + private primed = false; + /** Samples discarded to keep latency bounded. */ + dropped = 0; + /** Output samples filled with silence because no audio was available. */ + underruns = 0; + + constructor( + readonly capacity: number, + readonly prebuffer: number, + ) { + if (prebuffer > capacity) throw new RangeError('prebuffer exceeds capacity'); + this.buf = new Float32Array(capacity); + } + + get available(): number { + return this.count; + } + + push(samples: Float32Array): void { + let src = samples; + if (src.length > this.capacity) { + this.dropped += src.length - this.capacity; + src = src.subarray(src.length - this.capacity); + } + const overflow = this.count + src.length - this.capacity; + if (overflow > 0) { + this.read = (this.read + overflow) % this.capacity; + this.count -= overflow; + this.dropped += overflow; + } + for (let i = 0; i < src.length; i++) { + this.buf[this.write] = src[i] ?? 0; + this.write = (this.write + 1) % this.capacity; + } + this.count += src.length; + } + + /** Fill `out` completely; returns the number of real (non-silence) samples. */ + pull(out: Float32Array): number { + if (!this.primed && this.count >= this.prebuffer) this.primed = true; + if (!this.primed) { + out.fill(0); + this.underruns += out.length; + return 0; + } + const n = Math.min(out.length, this.count); + for (let i = 0; i < n; i++) { + out[i] = this.buf[this.read] ?? 0; + this.read = (this.read + 1) % this.capacity; + } + this.count -= n; + if (n < out.length) { + out.fill(0, n); + this.underruns += out.length - n; + this.primed = false; // resync: wait for a fresh prebuffer instead of stuttering + } + return n; + } + + clear(): void { + this.read = this.write = this.count = 0; + this.primed = false; + } +} diff --git a/web/src/lib/backdrop/ascii.ts b/web/src/lib/backdrop/ascii.ts new file mode 100644 index 0000000..670b63a --- /dev/null +++ b/web/src/lib/backdrop/ascii.ts @@ -0,0 +1,344 @@ +/** + * ASCII "signal" backdrop: drifting ionospheric static, radio-wave rings when a + * station is heard, a soft pulse on every 15-second JS8 slot, and the odd ghost. + * + * Purely decorative and framework-free. The pure helpers below are unit-tested; + * `AsciiBackdrop` owns the canvas and the animation loop. + */ + +/** Static density ramp, sparse → dense. Index 0 is "nothing drawn". */ +export const RAMP = ' .·:-~=+*'; +export const SLOT_MS = 15_000; +const CELL_W = 11; +const CELL_H = 18; +const FPS = 16; +const RING_SPEED = 14; // cells per second +const RING_LIFE_S = 5.5; +const LABEL_LIFE_S = 4.5; +const MAX_RINGS = 8; +const STILL_T = 37; // the fixed moment drawn for reduced motion +const HUE_BUCKETS = 10; +const ALPHA_BUCKETS = 8; + +/** A small ghost, drawn in violet, drifting across now and then. */ +export const GHOST = [ + ' .-""""-. ', + ' / o o \\ ', + '| __ |', + '| |', + '| |', + ' \\/\\/\\/\\/\\/ ', +]; + +// ---------------------------------------------------------------- pure helpers + +function hash(ix: number, iy: number): number { + let h = (ix * 374_761_393 + iy * 668_265_263) | 0; + h = Math.imul(h ^ (h >>> 13), 1_274_126_177); + return ((h ^ (h >>> 16)) >>> 0) / 4_294_967_295; +} + +const fade = (t: number): number => t * t * (3 - 2 * t); + +/** Smooth 2D value noise in [0, 1]. */ +export function valueNoise(x: number, y: number): number { + const ix = Math.floor(x); + const iy = Math.floor(y); + const fx = fade(x - ix); + const fy = fade(y - iy); + const a = hash(ix, iy); + const b = hash(ix + 1, iy); + const c = hash(ix, iy + 1); + const d = hash(ix + 1, iy + 1); + return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy; +} + +/** Two drifting noise layers: slow bands of static that roll like the ionosphere. */ +export function staticField(col: number, row: number, t: number): number { + const n1 = valueNoise(col * 0.055 + t * 0.06, row * 0.11 - t * 0.025); + const n2 = valueNoise(col * 0.13 - t * 0.09 + 40, row * 0.07 + t * 0.04 + 17); + const n = n1 * 0.65 + n2 * 0.35; + const v = (n - 0.5) / 0.32; // most of the field stays empty + return v <= 0 ? 0 : v >= 1 ? 1 : v * v; +} + +/** + * Ramp character for an intensity in [0, 1]; ' ' means skip. `jitter` in [0, 1) + * nudges the glyph up or down one step so the static crackles instead of tiling. + */ +export function rampChar(v: number, jitter = 0.5): string { + if (v <= 0) return ' '; + const base = Math.floor(v * RAMP.length) + (jitter < 0.2 ? -1 : jitter > 0.8 ? 1 : 0); + const i = Math.max(0, Math.min(RAMP.length - 1, base)); + return RAMP[i] ?? ' '; +} + +/** Per-cell flicker in [0, 1), changing a few times a second. */ +export function flicker(col: number, row: number, t: number): number { + return hash(col * 7 + Math.floor(t * 6), row * 13 - Math.floor(t * 4)); +} + +/** Gaussian ring profile: 1 on the wavefront, falling off either side, fading with age. */ +export function ringIntensity(dist: number, ageS: number): number { + if (ageS < 0 || ageS > RING_LIFE_S) return 0; + const r = ageS * RING_SPEED; + const d = dist - r; + const life = 1 - ageS / RING_LIFE_S; + return Math.exp(-(d * d) / 2.2) * life * life; +} + +/** Wave glyph by angle so rings read as ((( · ))). */ +export function ringChar(dx: number, dy: number): string { + const ax = Math.abs(dx); + const ay = Math.abs(dy) * (CELL_H / CELL_W); + if (ay > ax * 2.2) return '~'; + return dx < 0 ? '(' : ')'; +} + +/** Milliseconds until the next 15-second JS8 slot boundary (UTC-aligned). */ +export function msToNextSlot(nowMs: number): number { + const r = nowMs % SLOT_MS; + return r === 0 ? 0 : SLOT_MS - r; +} + +// ---------------------------------------------------------------- renderer + +interface Ring { + col: number; + row: number; + born: number; // seconds (renderer clock) + label: string | null; + strength: number; +} + +interface Ghost { + born: number; + row: number; + dir: 1 | -1; +} + +export interface BackdropOptions { + /** Draw one frame and stop (reduced motion). */ + still?: boolean; + /** Override for tests. */ + random?: () => number; +} + +const AQUA: readonly [number, number, number] = [103, 232, 249]; +const VIOLET: readonly [number, number, number] = [167, 139, 250]; +const MINT: readonly [number, number, number] = [94, 234, 212]; + +function rgba(c: readonly [number, number, number], a: number): string { + return `rgba(${String(c[0])},${String(c[1])},${String(c[2])},${a.toFixed(3)})`; +} + +/** Precomputed fill styles: [hue bucket][alpha bucket], aqua → violet across the screen. */ +function buildStyles(maxAlpha: number): string[][] { + const out: string[][] = []; + for (let h = 0; h < HUE_BUCKETS; h++) { + const f = h / (HUE_BUCKETS - 1); + const c: [number, number, number] = [ + Math.round(AQUA[0] + (VIOLET[0] - AQUA[0]) * f), + Math.round(AQUA[1] + (VIOLET[1] - AQUA[1]) * f), + Math.round(AQUA[2] + (VIOLET[2] - AQUA[2]) * f), + ]; + const row: string[] = []; + for (let a = 0; a < ALPHA_BUCKETS; a++) row.push(rgba(c, ((a + 1) / ALPHA_BUCKETS) * maxAlpha)); + out.push(row); + } + return out; +} + +export class AsciiBackdrop { + private readonly ctx: CanvasRenderingContext2D; + private readonly random: () => number; + private readonly styles = buildStyles(0.32); + private readonly ringStyles = [0.15, 0.3, 0.45, 0.6].map((a) => rgba(MINT, a)); + private cols = 0; + private rows = 0; + private dpr = 1; + private rings: Ring[] = []; + private ghost: Ghost | null = null; + private nextGhostAt = 20; + private raf = 0; + private lastFrame = 0; + private t0 = performance.now(); + private slotTimer: ReturnType | undefined; + private running = false; + private still: boolean; + + constructor( + private readonly canvas: HTMLCanvasElement, + opts: BackdropOptions = {}, + ) { + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) throw new Error('2D canvas unavailable'); + this.ctx = ctx; + this.random = opts.random ?? Math.random; + this.still = opts.still ?? false; + } + + resize(width: number, height: number, dpr: number): void { + this.dpr = Math.min(2, Math.max(1, dpr)); + this.canvas.width = Math.round(width * this.dpr); + this.canvas.height = Math.round(height * this.dpr); + this.cols = Math.ceil(width / CELL_W) + 1; + this.rows = Math.ceil(height / CELL_H) + 1; + if (!this.running || this.still) this.draw(this.still ? STILL_T : this.clock()); + } + + start(): void { + if (this.running) return; + this.running = true; + if (this.still) { + // A pleasant fixed moment, with the ghost part-way across. + this.ghost = { born: STILL_T - 14, row: Math.max(4, this.rows * 0.62), dir: 1 }; + this.draw(STILL_T); + return; + } + this.scheduleSlot(); + const loop = (ms: number): void => { + if (!this.running) return; + this.raf = requestAnimationFrame(loop); + if (ms - this.lastFrame < 1000 / FPS) return; + this.lastFrame = ms; + this.draw(this.clock()); + }; + this.raf = requestAnimationFrame(loop); + } + + stop(): void { + this.running = false; + cancelAnimationFrame(this.raf); + clearTimeout(this.slotTimer); + } + + setStill(still: boolean): void { + if (still === this.still) return; + this.stop(); + this.still = still; + this.start(); + } + + /** A station was heard: ripple out from somewhere, with its callsign at the centre. */ + ping(label: string | null, strength = 1): void { + if (this.still || this.cols === 0) return; + const margin = 6; + this.rings.push({ + col: margin + this.random() * Math.max(1, this.cols - 2 * margin), + row: margin / 2 + this.random() * Math.max(1, this.rows - margin), + born: this.clock(), + label, + strength, + }); + if (this.rings.length > MAX_RINGS) this.rings.shift(); + } + + private clock(): number { + return (performance.now() - this.t0) / 1000; + } + + private scheduleSlot(): void { + this.slotTimer = setTimeout( + () => { + this.ping(null, 0.45); + this.scheduleSlot(); + }, + msToNextSlot(Date.now()) + 5, + ); + } + + private draw(t: number): void { + const { ctx, cols, rows, dpr } = this; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + ctx.font = `13px 'JetBrains Mono', ui-monospace, monospace`; + ctx.textBaseline = 'top'; + + this.rings = this.rings.filter((r) => t - r.born <= Math.max(RING_LIFE_S, LABEL_LIFE_S)); + const ghost = this.updateGhost(t); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + let ring = 0; + let rdx = 0; + let rdy = 0; + for (const r of this.rings) { + const dx = col - r.col; + const dy = (row - r.row) * (CELL_H / CELL_W); + const v = ringIntensity(Math.hypot(dx, dy), t - r.born) * r.strength; + if (v > ring) { + ring = v; + rdx = dx; + rdy = dy; + } + } + const x = col * CELL_W; + const y = row * CELL_H; + if (ring > 0.12) { + const i = Math.min(this.ringStyles.length - 1, Math.floor(ring * this.ringStyles.length)); + ctx.fillStyle = this.ringStyles[i] ?? ''; + ctx.fillText(ringChar(rdx, rdy), x, y); + continue; + } + const v = staticField(col, row, t); + const ch = rampChar(v, flicker(col, row, t)); + if (ch === ' ') continue; + const h = Math.min(HUE_BUCKETS - 1, Math.floor((col / cols) * HUE_BUCKETS)); + const a = Math.min(ALPHA_BUCKETS - 1, Math.floor(v * ALPHA_BUCKETS)); + ctx.fillStyle = this.styles[h]?.[a] ?? ''; + ctx.fillText(ch, x, y); + } + } + + this.drawLabels(t); + if (ghost) this.drawGhost(ghost.col, ghost.row); + } + + private drawLabels(t: number): void { + const { ctx } = this; + ctx.font = `600 14px 'JetBrains Mono', ui-monospace, monospace`; + for (const r of this.rings) { + if (!r.label) continue; + const age = t - r.born; + if (age > LABEL_LIFE_S) continue; + const a = Math.min(1, age * 3) * (1 - age / LABEL_LIFE_S); + const text = `· ${r.label} ·`; + const w = ctx.measureText(text).width; + ctx.fillStyle = rgba(MINT, 0.55 * a); + ctx.fillText(text, r.col * CELL_W - w / 2, r.row * CELL_H); + } + } + + private updateGhost(t: number): { col: number; row: number } | null { + if (!this.ghost && t >= this.nextGhostAt && this.rows > 12) { + this.ghost = { + born: t, + row: 4 + this.random() * (this.rows - 12), + dir: this.random() < 0.5 ? 1 : -1, + }; + } + const g = this.ghost; + if (!g) return null; + const width = GHOST[0]?.length ?? 12; + const span = this.cols + width * 2; + const travelS = Math.max(30, span / 2.2); + const p = (t - g.born) / travelS; + if (p >= 1) { + this.ghost = null; + this.nextGhostAt = t + 45 + this.random() * 60; + return null; + } + const col = g.dir === 1 ? -width + p * span : this.cols + width - p * span - width; + return { col, row: g.row + Math.sin((t - g.born) * 1.3) * 0.8 }; + } + + private drawGhost(col: number, row: number): void { + const { ctx } = this; + ctx.font = `13px 'JetBrains Mono', ui-monospace, monospace`; + ctx.fillStyle = rgba(VIOLET, 0.42); + GHOST.forEach((line, i) => { + ctx.fillText(line, col * CELL_W, (row + i) * CELL_H); + }); + } +} diff --git a/web/src/lib/backdrop/mode.svelte.ts b/web/src/lib/backdrop/mode.svelte.ts new file mode 100644 index 0000000..47ba6a7 --- /dev/null +++ b/web/src/lib/backdrop/mode.svelte.ts @@ -0,0 +1,28 @@ +// Backdrop preference: 'signal' (animated ASCII) or 'still' (solid gradient only). +// Per-viewer convenience; storage may be unavailable, so every access is guarded. + +export type BackdropMode = 'signal' | 'still'; +const KEY = 'ghostjs8.backdrop'; + +function load(): BackdropMode { + try { + return localStorage.getItem(KEY) === 'still' ? 'still' : 'signal'; + } catch { + return 'signal'; + } +} + +class BackdropPref { + mode = $state(typeof window === 'undefined' ? 'signal' : load()); + + toggle(): void { + this.mode = this.mode === 'signal' ? 'still' : 'signal'; + try { + localStorage.setItem(KEY, this.mode); + } catch { + /* storage unavailable: preference lasts for this page only */ + } + } +} + +export const backdrop = new BackdropPref(); diff --git a/web/src/lib/bands.ts b/web/src/lib/bands.ts new file mode 100644 index 0000000..09f5335 --- /dev/null +++ b/web/src/lib/bands.ts @@ -0,0 +1,23 @@ +/** JS8 calling frequencies: JS8Call 3.0.3 defaults (JS8_Main/FrequencyList.cpp). Dial, USB. */ +export interface BandPreset { + readonly band: string; + readonly dialHz: number; +} + +export const JS8_BANDS: readonly BandPreset[] = [ + { band: '160m', dialHz: 1_843_500 }, + { band: '80m', dialHz: 3_578_000 }, + { band: '60m', dialHz: 5_363_000 }, + { band: '40m', dialHz: 7_078_000 }, + { band: '30m', dialHz: 10_130_000 }, + { band: '20m', dialHz: 14_078_000 }, + { band: '17m', dialHz: 18_104_000 }, + { band: '15m', dialHz: 21_078_000 }, + { band: '12m', dialHz: 24_922_000 }, + { band: '10m', dialHz: 28_078_000 }, + { band: '6m', dialHz: 50_318_000 }, +]; + +export function bandFor(dialHz: number): string | null { + return JS8_BANDS.find((b) => Math.abs(b.dialHz - dialHz) < 50_000)?.band ?? null; +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..c21d3b6 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,40 @@ +/** Display formatting. All UI times are UTC. */ + +/** 14078000 -> "14.078 000" (MHz with a thin grouping, like JS8Call). */ +export function formatMHz(hz: number): string { + const khz = Math.floor(hz / 1000); + const mhz = Math.floor(khz / 1000); + const rest = String(khz % 1000).padStart(3, '0'); + const sub = String(Math.round(hz % 1000)).padStart(3, '0'); + return `${String(mhz)}.${rest} ${sub}`; +} + +export function formatKHz(hz: number): string { + return (hz / 1000).toFixed(3); +} + +export function formatSnr(db: number | null | undefined): string { + if (db === null || db === undefined) return '—'; + return `${db >= 0 ? '+' : '−'}${String(Math.abs(db)).padStart(2, '0')}`; +} + +export function utcTime(iso: string | Date, seconds = true): string { + const d = typeof iso === 'string' ? new Date(iso) : iso; + const s = d.toISOString(); + return seconds ? s.slice(11, 19) : s.slice(11, 16); +} + +export function utcDateTime(iso: string): string { + const s = new Date(iso).toISOString(); + return `${s.slice(0, 10)} ${s.slice(11, 19)}Z`; +} + +/** "12s", "4m", "3h", "2d" ago. */ +export function ago(iso: string | null | undefined, now: number): string { + if (!iso) return 'never'; + const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000)); + if (s < 60) return `${String(s)}s`; + if (s < 3600) return `${String(Math.floor(s / 60))}m`; + if (s < 86400) return `${String(Math.floor(s / 3600))}h`; + return `${String(Math.floor(s / 86400))}d`; +} diff --git a/web/src/lib/ghostnet.ts b/web/src/lib/ghostnet.ts new file mode 100644 index 0000000..c2b6b39 --- /dev/null +++ b/web/src/lib/ghostnet.ts @@ -0,0 +1,56 @@ +/** GhostNet helpers: traffic classification and countdowns. Pure, unit-tested. */ + +export interface TrafficTags { + /** @GSTFLASH: emergency flash traffic, relay to highest level HQ within range. */ + readonly flash: boolean; + /** @GHOSTNET or a regional @GN group. */ + readonly ghostnet: boolean; + /** The regional group, e.g. "@GNUSASC", if present. */ + readonly regional: string | null; +} + +const FLASH = /(^|[\s:])@GSTFLASH\b/i; +const GHOSTNET = /(^|[\s:])@GHOSTNET\b/i; +const REGIONAL = /(^|[\s:])(@GN[A-Z]{3}[A-Z]{0,2})\b/i; + +export function classify(text: string): TrafficTags { + const regional = REGIONAL.exec(text)?.[2]?.toUpperCase() ?? null; + return { flash: FLASH.test(text), ghostnet: GHOSTNET.test(text) || regional !== null, regional }; +} + +/** "3d 04h", "2h 05m", "12m 05s", "45s". */ +export function countdown(fromMs: number, toMs: number): string { + let s = Math.max(0, Math.round((toMs - fromMs) / 1000)); + const d = Math.floor(s / 86400); + s -= d * 86400; + const h = Math.floor(s / 3600); + s -= h * 3600; + const m = Math.floor(s / 60); + s -= m * 60; + const p = (n: number): string => String(n).padStart(2, '0'); + if (d > 0) return `${String(d)}d ${p(h)}h`; + if (h > 0) return `${String(h)}h ${p(m)}m`; + if (m > 0) return `${String(m)}m ${p(s)}s`; + return `${String(s)}s`; +} + +/** Local wall-clock rendering of a UTC instant, for "Thursday night" sanity. */ +export function localWhen(iso: string, locale?: string): string { + return new Date(iso).toLocaleString(locale, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + }); +} + +export const REGION_LABEL: Record<'na' | 'eu' | 'aus', string> = { + na: 'North America', + eu: 'Europe', + aus: 'Australia', +}; + +/** Parse "#/nets/" routes. Only ids the bridge generates are accepted. */ +export function netIdFromHash(hash: string): string | null { + const m = /^#\/nets\/([a-z0-9-]{1,40}-\d{8}T\d{4}Z)$/.exec(hash); + return m?.[1] ?? null; +} diff --git a/web/src/lib/grid.ts b/web/src/lib/grid.ts new file mode 100644 index 0000000..e04b164 --- /dev/null +++ b/web/src/lib/grid.ts @@ -0,0 +1,35 @@ +/** Maidenhead locators. Mirrors bridge/src/ghostjs8/util/maidenhead.py. Only valid grids are mapped. */ + +const GRID = /^[A-R]{2}(?:[0-9]{2}(?:[A-X]{2}(?:[0-9]{2})?)?)?$/; + +export function normalizeGrid(grid: string | null | undefined): string | null { + if (!grid) return null; + const g = grid.trim().toUpperCase(); + if (![4, 6, 8].includes(g.length) || !GRID.test(g)) return null; + return g.slice(0, 4) + g.slice(4, 6).toLowerCase() + g.slice(6); +} + +/** [lon, lat] of the square's centre, or null for an invalid grid. */ +export function gridCenter(grid: string | null | undefined): [number, number] | null { + const g = normalizeGrid(grid)?.toUpperCase(); + if (!g) return null; + const c = (i: number): number => g.charCodeAt(i) - 65; + const d = (i: number): number => Number(g[i]); + let lon = c(0) * 20 - 180 + d(2) * 2; + let lat = c(1) * 10 - 90 + d(3); + let w = 2; + let h = 1; + if (g.length >= 6) { + lon += c(4) * (2 / 24); + lat += c(5) * (1 / 24); + w = 2 / 24; + h = 1 / 24; + } + if (g.length === 8) { + lon += d(6) * (2 / 240); + lat += d(7) * (1 / 240); + w = 2 / 240; + h = 1 / 240; + } + return [lon + w / 2, lat + h / 2]; +} diff --git a/web/src/lib/protocol/binary.ts b/web/src/lib/protocol/binary.ts new file mode 100644 index 0000000..84137cf --- /dev/null +++ b/web/src/lib/protocol/binary.ts @@ -0,0 +1,76 @@ +/** + * Binary WebSocket frames from the bridge. Spec: docs/protocol.md. + * All integers little-endian. Mirrors bridge/src/ghostjs8/contract/binary.py. + */ + +export const CHANNEL_AUDIO = 0x01; +export const CHANNEL_WATERFALL = 0x02; +export const AUDIO_HEADER_BYTES = 12; +export const WATERFALL_HEADER_BYTES = 16; + +export interface AudioFrame { + readonly kind: 'audio'; + readonly seq: number; + readonly sampleRate: number; + readonly adcOverload: boolean; + /** Mono samples scaled to [-1, 1). */ + readonly samples: Float32Array; +} + +export interface WaterfallFrame { + readonly kind: 'waterfall'; + readonly seq: number; + readonly startHz: number; + readonly spanHz: number; + /** u8 per bin; dBm ~= value - 255. */ + readonly bins: Uint8Array; +} + +export type BinaryFrame = AudioFrame | WaterfallFrame; + +export class FrameError extends Error {} + +export function decodeFrame(buffer: ArrayBuffer): BinaryFrame { + const view = new DataView(buffer); + if (buffer.byteLength < 1) throw new FrameError('empty frame'); + const channel = view.getUint8(0); + if (channel === CHANNEL_AUDIO) return decodeAudio(buffer, view); + if (channel === CHANNEL_WATERFALL) return decodeWaterfall(buffer, view); + throw new FrameError(`unknown channel 0x${channel.toString(16)}`); +} + +function decodeAudio(buffer: ArrayBuffer, view: DataView): AudioFrame { + if (buffer.byteLength < AUDIO_HEADER_BYTES) throw new FrameError('truncated audio header'); + const pcmBytes = buffer.byteLength - AUDIO_HEADER_BYTES; + if (pcmBytes % 2 !== 0) throw new FrameError('odd PCM byte count'); + const n = pcmBytes / 2; + const samples = new Float32Array(n); + for (let i = 0; i < n; i++) { + samples[i] = view.getInt16(AUDIO_HEADER_BYTES + i * 2, true) / 32768; + } + return { + kind: 'audio', + seq: view.getUint32(4, true), + sampleRate: view.getUint32(8, true), + adcOverload: (view.getUint8(1) & 0x01) !== 0, + samples, + }; +} + +function decodeWaterfall(buffer: ArrayBuffer, view: DataView): WaterfallFrame { + if (buffer.byteLength < WATERFALL_HEADER_BYTES) + throw new FrameError('truncated waterfall header'); + const bins = view.getUint16(2, true); + if (buffer.byteLength !== WATERFALL_HEADER_BYTES + bins) { + throw new FrameError( + `waterfall row has ${String(buffer.byteLength - 16)} bytes, header says ${String(bins)}`, + ); + } + return { + kind: 'waterfall', + seq: view.getUint32(4, true), + startHz: view.getUint32(8, true), + spanHz: view.getUint32(12, true), + bins: new Uint8Array(buffer, WATERFALL_HEADER_BYTES, bins), + }; +} diff --git a/web/src/lib/protocol/client.ts b/web/src/lib/protocol/client.ts new file mode 100644 index 0000000..4177d75 --- /dev/null +++ b/web/src/lib/protocol/client.ts @@ -0,0 +1,152 @@ +/** + * Browser side of the bridge WebSocket: reconnect with backoff, minimal + * validation of server JSON, binary frame dispatch, and clean shutdown. + */ +import type { ClientMessage, ServerMessage } from './generated'; +import { decodeFrame, FrameError, type BinaryFrame } from './binary'; + +export const PROTOCOL_VERSION = 1; + +const SERVER_TYPES = new Set([ + 'hello', + 'history', + 'session', + 'health', + 'decode', + 'station', + 'receiver_status', + 'ghostnet', + 'error', + 'pong', +]); + +export function parseServerMessage(text: string): ServerMessage | null { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return null; + } + if (typeof raw !== 'object' || raw === null) return null; + const rec = raw as Record; + if ( + rec['v'] !== PROTOCOL_VERSION || + typeof rec['type'] !== 'string' || + !SERVER_TYPES.has(rec['type']) + ) { + return null; + } + return raw as ServerMessage; +} + +export interface ClientHandlers { + onMessage(message: ServerMessage): void; + onFrame(frame: BinaryFrame): void; + onLink(state: 'connecting' | 'open' | 'reconnecting' | 'closed'): void; +} + +export interface SocketLike { + binaryType: string; + readyState: number; + onopen: ((ev: Event) => void) | null; + onclose: ((ev: CloseEvent) => void) | null; + onmessage: ((ev: MessageEvent) => void) | null; + onerror: ((ev: Event) => void) | null; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +export type SocketFactory = (url: string) => SocketLike; + +export class BridgeClient { + private socket: SocketLike | null = null; + private stopped = true; + private attempt = 0; + private timer: ReturnType | null = null; + private subscription = { audio: false, waterfall: false }; + + constructor( + private readonly url: string, + private readonly handlers: ClientHandlers, + private readonly makeSocket: SocketFactory = (u) => new WebSocket(u), + ) {} + + start(): void { + if (!this.stopped) return; + this.stopped = false; + this.connect(); + } + + /** Close the socket and stop reconnecting (call when leaving the view). */ + stop(): void { + this.stopped = true; + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + const s = this.socket; + this.socket = null; + if (s) { + s.onclose = s.onmessage = s.onerror = s.onopen = null; + s.close(1000, 'view closed'); + } + this.handlers.onLink('closed'); + } + + send(message: ClientMessage): boolean { + const s = this.socket; + if (!s || s.readyState !== 1) return false; + s.send(JSON.stringify(message)); + return true; + } + + subscribe(audio: boolean, waterfall: boolean): void { + this.subscription = { audio, waterfall }; + this.send({ v: 1, type: 'subscribe', audio, waterfall }); + } + + private connect(): void { + this.handlers.onLink(this.attempt === 0 ? 'connecting' : 'reconnecting'); + const s = this.makeSocket(this.url); + this.socket = s; + s.binaryType = 'arraybuffer'; + s.onopen = () => { + this.attempt = 0; + this.handlers.onLink('open'); + s.send(JSON.stringify({ v: 1, type: 'hello', client: 'ghost.js8-web' })); + const { audio, waterfall } = this.subscription; + s.send(JSON.stringify({ v: 1, type: 'subscribe', audio, waterfall })); + }; + s.onmessage = (ev: MessageEvent) => { + const data: unknown = ev.data; + if (typeof data === 'string') { + const msg = parseServerMessage(data); + if (msg) this.handlers.onMessage(msg); + } else if (data instanceof ArrayBuffer) { + try { + this.handlers.onFrame(decodeFrame(data)); + } catch (err) { + if (!(err instanceof FrameError)) throw err; + } + } + }; + s.onclose = () => { + this.socket = null; + if (this.stopped) return; + this.attempt += 1; + this.handlers.onLink('reconnecting'); + const delay = + Math.min(15_000, 1000 * 2 ** Math.min(this.attempt - 1, 4)) * (0.75 + Math.random() * 0.5); + this.timer = setTimeout(() => { + this.timer = null; + if (!this.stopped) this.connect(); + }, delay); + }; + s.onerror = () => { + /* onclose follows and handles reconnect */ + }; + } +} + +export function defaultBridgeUrl(loc: Location = window.location): string { + const scheme = loc.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${scheme}//${loc.host}/ws`; +} diff --git a/web/src/lib/protocol/generated.ts b/web/src/lib/protocol/generated.ts new file mode 100644 index 0000000..c2c96f4 --- /dev/null +++ b/web/src/lib/protocol/generated.ts @@ -0,0 +1,372 @@ +/* eslint-disable */ +/** + * GENERATED from contract/schema.json by web/scripts/gen-protocol.mjs. + * Source of truth: bridge/src/ghostjs8/contract/messages.py. Do not edit. + */ + +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "ServerMessage". + */ +export type ServerMessage = + Hello | Health | Session | Decode | Station | History | ReceiverStatus | GhostNet | Error | Pong; +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "ClientMessage". + */ +export type ClientMessage = + ClientHello | Tune | SelectReceiver | DisconnectReceiver | Subscribe | Ping; + +/** + * ghost.js8 bridge <-> browser protocol v1. Generated; do not edit. + */ +export interface GhostJs8Protocol { + server?: ServerMessage; + client?: ClientMessage; +} +/** + * First message on every connection. + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Hello". + */ +export interface Hello { + v: 1; + type: 'hello'; + server: string; + build: string; + receive_only: true; + limits: Limits | null; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Limits". + */ +export interface Limits { + max_clients: number; + audio_sample_rate: number; + waterfall_max_fps: number; + waterfall_bins: number; +} +/** + * Truthful readiness: distinct components, each with evidence timestamps. + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Health". + */ +export interface Health { + v: 1; + type: 'health'; + utc: string; + overall: 'listening' | 'degraded' | 'offline'; + components: Components; + last_decode_utc: string | null; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Components". + */ +export interface Components { + bridge: Component; + decoder_process: Component; + decoder_api: Component; + decoder_capture: Component; + receiver: Component; + audio: Component; + waterfall: Component; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Component". + */ +export interface Component { + state: 'ok' | 'degraded' | 'down' | 'unknown'; + since: string; + last_seen: string | null; + detail: string; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Session". + */ +export interface Session { + v: 1; + type: 'session'; + state: 'idle' | 'connecting' | 'connected' | 'backoff' | 'rejected' | 'failed'; + receiver: ReceiverRef | null; + tuning: TuningModel; + subscribers: number; + reject_reason: ('full' | 'busy' | 'auth' | 'down' | 'redirect' | 'duplicate_ip') | null; + detail: string; + next_retry_utc: string | null; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "ReceiverRef". + */ +export interface ReceiverRef { + host: string; + port: number; + name: string | null; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "TuningModel". + */ +export interface TuningModel { + dial_hz: number; + mode: 'usb' | 'lsb'; + low_cut_hz: number; + high_cut_hz: number; +} +/** + * One decoded JS8 transmission. ``utc`` comes from the decoder, never inferred. + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Decode". + */ +export interface Decode { + v: 1; + type: 'decode'; + kind: 'activity' | 'directed'; + utc: string; + text: string; + snr_db: number; + offset_hz: number; + dial_hz: number | null; + freq_hz: number | null; + speed: string; + from_call: string | null; + to_call: string | null; + grid: string | null; +} +/** + * A heard station. ``grid`` is present only when it is a valid Maidenhead locator. + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Station". + */ +export interface Station { + v: 1; + type: 'station'; + callsign: string; + last_heard_utc: string; + snr_db: number | null; + grid: string | null; + offset_hz: number | null; + dial_hz: number | null; + heard_count: number; +} +/** + * Sent once after hello: recent traffic from the store, oldest first. + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "History". + */ +export interface History { + v: 1; + type: 'history'; + decodes: Decode[]; + stations: Station[]; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "ReceiverStatus". + */ +export interface ReceiverStatus { + v: 1; + type: 'receiver_status'; + adc_overload: boolean; + rssi_dbm: number | null; + info: { + [k: string]: string | undefined; + }; +} +/** + * GhostNet autopilot status (absent features report enabled = false). + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "GhostNet". + */ +export interface GhostNet { + v: 1; + type: 'ghostnet'; + enabled: boolean; + region: ('na' | 'eu' | 'aus') | null; + home_grid: string | null; + mode: 'off' | 'window' | 'parked' | 'paused'; + window: GhostNetWindow | null; + next_window: GhostNetWindow | null; + recording_net_id: string | null; + receiver_reason: string; + paused_until: string | null; + detail: string; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "GhostNetWindow". + */ +export interface GhostNetWindow { + id: string; + label: string; + kind: 'net' | 'bridge'; + band: string; + dial_hz: number; + start: string; + end: string; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Error". + */ +export interface Error { + v: 1; + type: 'error'; + code: + | 'bad_message' + | 'unsupported_version' + | 'session_full' + | 'invalid_tuning' + | 'invalid_receiver' + | 'rate_limited' + | 'internal'; + message: string; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Pong". + */ +export interface Pong { + v: 1; + type: 'pong'; + utc: string; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "ClientHello". + */ +export interface ClientHello { + v: 1; + type: 'hello'; + client: string; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Tune". + */ +export interface Tune { + v: 1; + type: 'tune'; + tuning: TuningModel; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "SelectReceiver". + */ +export interface SelectReceiver { + v: 1; + type: 'select_receiver'; + receiver: ReceiverRef; + password: string; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "DisconnectReceiver". + */ +export interface DisconnectReceiver { + v: 1; + type: 'disconnect_receiver'; +} +/** + * Opt in to binary channels. Both default off: no bytes the viewer does not want. + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Subscribe". + */ +export interface Subscribe { + v: 1; + type: 'subscribe'; + audio: boolean; + waterfall: boolean; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "Ping". + */ +export interface Ping { + v: 1; + type: 'ping'; +} +/** + * A recorded net with its traffic (GET /api/nets/{id}). + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "NetLog". + */ +export interface NetLog { + summary: NetSummary; + decodes: Decode[]; + stations: Station[]; + waterfall_seconds: number; + waterfall_offset_lo_hz: number; + waterfall_offset_hi_hz: number; +} +/** + * One recorded GhostNet window (GET /api/nets). + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "NetSummary". + */ +export interface NetSummary { + id: string; + window_id: string; + label: string; + kind: 'net' | 'bridge'; + band: string; + dial_hz: number; + scheduled_start: string; + scheduled_end: string; + started: string; + ended: string | null; + receiver: string | null; + receiver_reason: string; + decode_count: number; + station_count: number; + has_audio: boolean; + has_waterfall: boolean; + flash_count: number; + operator_override: boolean; +} +/** + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "ReceiverDirectory". + */ +export interface ReceiverDirectory { + fetched_utc: string | null; + stale: boolean; + source: string; + receivers: ReceiverListing[]; +} +/** + * One public receiver from the directory (GET /api/receivers). + * + * This interface was referenced by `GhostJs8Protocol`'s JSON-Schema + * via the `definition` "ReceiverListing". + */ +export interface ReceiverListing { + id: string; + name: string; + host: string; + port: number; + tls: boolean; + location: string; + grid: string | null; + lat: number | null; + lon: number | null; + users: number; + users_max: number; + min_hz: number; + max_hz: number; + antenna: string; + snr_db: number | null; +} diff --git a/web/src/lib/state/app.svelte.ts b/web/src/lib/state/app.svelte.ts new file mode 100644 index 0000000..f04fdd2 --- /dev/null +++ b/web/src/lib/state/app.svelte.ts @@ -0,0 +1,130 @@ +/** + * The running application: reactive state (Svelte runes) wired to the bridge + * client and the audio player. Waterfall frames bypass reactivity and go + * straight to registered renderers. + */ +import { AudioPlayer, type PlayerStats } from '../audio/player'; +import { BridgeClient, defaultBridgeUrl } from '../protocol/client'; +import type { WaterfallFrame } from '../protocol/binary'; +import type { ClientMessage, ServerMessage } from '../protocol/generated'; +import { initialState, reduce, type Action, type AppState } from './state'; + +type WaterfallListener = (frame: WaterfallFrame) => void; + +export class GhostApp { + state: AppState = $state(initialState); + audioEnabled = $state(false); + audioError = $state(null); + muted = $state(false); + volume = $state(0.8); + audioStats = $state(null); + now = $state(Date.now()); + /** When this view started; older traffic (history) never raises live alerts. */ + readonly startedAt = Date.now(); + + private readonly client: BridgeClient; + private readonly player = new AudioPlayer(); + // Deliberately non-reactive: frames go straight to canvases, not through Svelte. + // eslint-disable-next-line svelte/prefer-svelte-reactivity + private readonly waterfallListeners = new Set(); + private clock: ReturnType | null = null; + private lastBinaryTick = { audio: 0, waterfall: 0 }; + + constructor(url: string = defaultBridgeUrl()) { + this.client = new BridgeClient(url, { + onMessage: (message: ServerMessage) => { + this.dispatch({ type: 'server', message, now: Date.now() }); + }, + onLink: (link) => { + this.dispatch({ type: 'link', link }); + }, + onFrame: (frame) => { + const t = Date.now(); + if (frame.kind === 'audio') { + if (this.audioEnabled) this.player.push(frame.samples); + this.markBinary('audio', t); + } else { + for (const l of this.waterfallListeners) l(frame); + this.markBinary('waterfall', t); + } + }, + }); + this.player.onStats = (s) => { + this.audioStats = s; + }; + } + + start(): void { + this.client.start(); + this.clock = setInterval(() => { + this.now = Date.now(); + }, 1000); + } + + /** Release sockets, timers and audio (view leave). */ + async stop(): Promise { + this.client.stop(); + if (this.clock !== null) clearInterval(this.clock); + this.clock = null; + this.waterfallListeners.clear(); + await this.disableAudio(); + } + + dispatch(action: Action): void { + this.state = reduce(this.state, action); + } + + send(message: ClientMessage): boolean { + return this.client.send(message); + } + + onWaterfall(listener: WaterfallListener): () => void { + this.waterfallListeners.add(listener); + this.resubscribe(); + return () => { + this.waterfallListeners.delete(listener); + this.resubscribe(); + }; + } + + /** Must be called from a user gesture. */ + async enableAudio(): Promise { + this.audioError = null; + try { + await this.player.start(this.state.hello?.limits?.audio_sample_rate ?? 12_000); + this.player.setVolume(this.volume); + this.player.setMuted(this.muted); + this.audioEnabled = true; + this.resubscribe(); + } catch (err) { + this.audioError = err instanceof Error ? err.message : String(err); + } + } + + async disableAudio(): Promise { + this.audioEnabled = false; + this.resubscribe(); + await this.player.stop(); + } + + setVolume(v: number): void { + this.volume = v; + this.player.setVolume(v); + } + + setMuted(m: boolean): void { + this.muted = m; + this.player.setMuted(m); + } + + private resubscribe(): void { + this.client.subscribe(this.audioEnabled, this.waterfallListeners.size > 0); + } + + private markBinary(channel: 'audio' | 'waterfall', t: number): void { + // Throttle reactive updates: freshness only needs ~2 Hz resolution. + if (t - this.lastBinaryTick[channel] < 500) return; + this.lastBinaryTick[channel] = t; + this.dispatch({ type: 'binary', channel, now: t }); + } +} diff --git a/web/src/lib/state/context.ts b/web/src/lib/state/context.ts new file mode 100644 index 0000000..a5aabf6 --- /dev/null +++ b/web/src/lib/state/context.ts @@ -0,0 +1,14 @@ +import { getContext, setContext } from 'svelte'; +import type { GhostApp } from './app.svelte'; + +const KEY = Symbol('ghost.js8 app'); + +export function provideApp(app: GhostApp): void { + setContext(KEY, app); +} + +export function useApp(): GhostApp { + const app = getContext(KEY); + if (!app) throw new Error('GhostApp context missing'); + return app; +} diff --git a/web/src/lib/state/situation.ts b/web/src/lib/state/situation.ts new file mode 100644 index 0000000..a1758b4 --- /dev/null +++ b/web/src/lib/state/situation.ts @@ -0,0 +1,149 @@ +/** + * One headline that tells the operator what is going on, in plain terms. + * Distinguishes "the band is quiet" from "something is broken": propagation and + * public receiver availability are not decoder success or failure. + */ +import type { AppState } from './state'; + +export type Tone = 'ok' | 'info' | 'warn' | 'alert'; + +export interface Situation { + readonly code: string; + readonly tone: Tone; + readonly headline: string; // terse, operator-style + readonly detail: string; // plain-language explanation + readonly suggestSwitch: boolean; // offer receiver switching +} + +const QUIET_AFTER_MS = 5 * 60_000; + +export function situation(s: AppState, now: number): Situation { + if (s.link !== 'open') { + return { + code: 'link', + tone: s.link === 'closed' ? 'alert' : 'warn', + headline: s.link === 'closed' ? 'LINK CLOSED' : 'ACQUIRING LINK', + detail: 'No connection to the ghost.js8 bridge. Retrying automatically.', + suggestSwitch: false, + }; + } + const h = s.health; + const session = s.session; + if (h && h.components.decoder_process.state !== 'ok') { + return { + code: 'decoder-down', + tone: 'alert', + headline: 'DECODER OFFLINE', + detail: `The JS8Call decoder is not running (${h.components.decoder_process.detail || 'no report'}). It restarts automatically; nothing you do in the browser fixes this.`, + suggestSwitch: false, + }; + } + if (!session || session.state === 'idle' || !session.receiver) { + return { + code: 'no-receiver', + tone: 'info', + headline: 'NO RECEIVER SELECTED', + detail: 'Pick a public KiwiSDR or enter host:port to start listening.', + suggestSwitch: false, + }; + } + if (session.state === 'rejected') { + const r = session.reject_reason; + if (r === 'full') { + return { + code: 'receiver-full', + tone: 'warn', + headline: 'RECEIVER FULL', + detail: + 'Every listening slot on this receiver is taken. Retrying politely; another receiver may be free now.', + suggestSwitch: true, + }; + } + if (r === 'busy' || r === 'duplicate_ip') { + return { + code: 'receiver-busy', + tone: 'warn', + headline: 'RECEIVER BUSY', + detail: `The receiver refused for now (${session.detail}). Retrying politely.`, + suggestSwitch: true, + }; + } + return { + code: 'receiver-refused', + tone: 'alert', + headline: r === 'auth' ? 'PASSWORD REQUIRED' : 'RECEIVER REFUSED', + detail: `${session.detail} Not retrying until you choose again.`, + suggestSwitch: true, + }; + } + if (session.state === 'failed') { + return { + code: 'receiver-unsupported', + tone: 'alert', + headline: 'UNSUPPORTED RECEIVER AUDIO', + detail: `${session.detail} Choose a different receiver.`, + suggestSwitch: true, + }; + } + if (session.state === 'connecting' || session.state === 'backoff') { + return { + code: 'connecting', + tone: 'warn', + headline: session.state === 'backoff' ? 'RECEIVER LOST — RETRYING' : 'CONNECTING TO RECEIVER', + detail: session.detail || 'Opening the receiver session.', + suggestSwitch: session.state === 'backoff', + }; + } + if (s.receiverStatus?.adc_overload) { + return { + code: 'adc-overload', + tone: 'alert', + headline: 'ADC OVERLOAD AT RECEIVER', + detail: + 'The remote receiver’s input is overloaded by strong signals. Browser volume cannot fix this, and ghost.js8 will not change someone else’s hardware settings. Decoding may suffer; consider another receiver.', + suggestSwitch: true, + }; + } + if (h && h.components.audio.state === 'down') { + return { + code: 'audio-stale', + tone: 'warn', + headline: 'AUDIO STALE', + detail: `Connected, but no fresh audio is arriving from the receiver (${h.components.audio.detail}).`, + suggestSwitch: true, + }; + } + if ( + h && + (h.components.decoder_capture.state !== 'ok' || h.components.decoder_api.state !== 'ok') + ) { + return { + code: 'decoder-degraded', + tone: 'warn', + headline: 'DECODER DEGRADED', + detail: + h.components.decoder_capture.detail || + h.components.decoder_api.detail || + 'Decoder not fully ready.', + suggestSwitch: false, + }; + } + const last = h?.last_decode_utc ? Date.parse(h.last_decode_utc) : null; + if (last === null || now - last > QUIET_AFTER_MS) { + return { + code: 'band-quiet', + tone: 'info', + headline: 'NO TRAFFIC — BAND QUIET', + detail: + 'Receiver and decoder are healthy. No JS8 heard recently: propagation, time of day or simply no activity. This is not a fault.', + suggestSwitch: false, + }; + } + return { + code: 'listening', + tone: 'ok', + headline: 'LISTENING — TRAFFIC COPIED', + detail: 'Receiver connected, decoder online, decodes arriving.', + suggestSwitch: false, + }; +} diff --git a/web/src/lib/state/state.ts b/web/src/lib/state/state.ts new file mode 100644 index 0000000..78ecb81 --- /dev/null +++ b/web/src/lib/state/state.ts @@ -0,0 +1,161 @@ +/** + * Pure application state + reducer. Components render from this; the + * WebSocket client feeds it. Unit-tested without a browser. + */ +import type { + Decode, + Error as ServerError, + GhostNet, + Health, + Hello, + ReceiverStatus, + ServerMessage, + Session, + Station, +} from '../protocol/generated'; +import { normalizeGrid } from '../grid'; + +export type LinkState = 'connecting' | 'open' | 'reconnecting' | 'closed'; + +export interface DecodeRow extends Decode { + /** Stable key: one JS8 transmission (activity + directed records collapse). */ + readonly key: string; + readonly receivedAt: number; +} + +export interface AppState { + readonly link: LinkState; + readonly hello: Hello | null; + readonly session: Session | null; + readonly health: Health | null; + readonly receiverStatus: ReceiverStatus | null; + readonly ghostnet: GhostNet | null; + readonly decodes: readonly DecodeRow[]; // oldest first + readonly stations: Readonly>; + readonly lastError: ServerError | null; + readonly lastAudioAt: number | null; + readonly lastWaterfallAt: number | null; +} + +export const MAX_DECODES = 500; + +export const initialState: AppState = { + link: 'connecting', + hello: null, + session: null, + health: null, + receiverStatus: null, + ghostnet: null, + decodes: [], + stations: {}, + lastError: null, + lastAudioAt: null, + lastWaterfallAt: null, +}; + +export type Action = + | { readonly type: 'server'; readonly message: ServerMessage; readonly now: number } + | { readonly type: 'link'; readonly link: LinkState } + | { readonly type: 'binary'; readonly channel: 'audio' | 'waterfall'; readonly now: number } + | { readonly type: 'clear-error' }; + +export function decodeKey(d: Pick): string { + return `${d.utc}|${String(d.offset_hz)}`; +} + +function addDecodes( + rows: readonly DecodeRow[], + incoming: readonly Decode[], + now: number, +): DecodeRow[] { + const out = [...rows]; + const index = new Map(out.map((r, i) => [r.key, i])); + for (const d of incoming) { + const key = decodeKey(d); + const at = index.get(key); + const existing = at === undefined ? undefined : out[at]; + if (existing && at !== undefined) { + // Same transmission reported again: keep one row, prefer the directed record's fields. + if (d.kind === 'directed' || existing.kind !== 'directed') { + out[at] = { + ...existing, + ...d, + kind: existing.kind === 'directed' ? 'directed' : d.kind, + key, + receivedAt: existing.receivedAt, + }; + } + continue; + } + index.set(key, out.length); + out.push({ ...d, grid: normalizeGrid(d.grid), key, receivedAt: now }); + } + return out.length > MAX_DECODES ? out.slice(out.length - MAX_DECODES) : out; +} + +function addStations( + stations: Readonly>, + incoming: readonly Station[], +): Record { + const out = { ...stations }; + for (const s of incoming) { + out[s.callsign] = { ...s, grid: normalizeGrid(s.grid) }; + } + return out; +} + +export function reduce(state: AppState, action: Action): AppState { + switch (action.type) { + case 'link': + return { ...state, link: action.link }; + case 'binary': + return action.channel === 'audio' + ? { ...state, lastAudioAt: action.now } + : { ...state, lastWaterfallAt: action.now }; + case 'clear-error': + return { ...state, lastError: null }; + case 'server': + return reduceServer(state, action.message, action.now); + } +} + +function reduceServer(state: AppState, m: ServerMessage, now: number): AppState { + switch (m.type) { + case 'hello': + return { ...state, hello: m }; + case 'history': + return { + ...state, + decodes: addDecodes([], m.decodes, now), + stations: addStations({}, m.stations), + }; + case 'session': + return { ...state, session: m }; + case 'health': + return { ...state, health: m }; + case 'receiver_status': + return { ...state, receiverStatus: m }; + case 'ghostnet': + return { ...state, ghostnet: m }; + case 'decode': + return { ...state, decodes: addDecodes(state.decodes, [m], now) }; + case 'station': + return { ...state, stations: addStations(state.stations, [m]) }; + case 'error': + return { ...state, lastError: m }; + case 'pong': + return state; + } +} + +/** Stations sorted by most recently heard. */ +export function stationList(state: AppState): Station[] { + return Object.values(state.stations).sort((a, b) => + b.last_heard_utc.localeCompare(a.last_heard_utc), + ); +} + +/** Stations with a valid grid, for the map. */ +export function mappableStations(state: AppState): Station[] { + return stationList(state).filter((s) => normalizeGrid(s.grid) !== null); +} diff --git a/web/src/lib/waterfall/blit.ts b/web/src/lib/waterfall/blit.ts new file mode 100644 index 0000000..1a08a7d --- /dev/null +++ b/web/src/lib/waterfall/blit.ts @@ -0,0 +1,55 @@ +/** + * Map one waterfall row (u8 dB bins over [startHz, startHz+spanHz)) onto a + * pixel row covering the visible range [viewLoHz, viewHiHz). Pure function so + * it can be unit-tested; the renderer only handles canvas plumbing. + */ +export interface RowGeometry { + readonly startHz: number; + readonly spanHz: number; + readonly viewLoHz: number; + readonly viewHiHz: number; +} + +/** dB window applied to raw u8 values before the colormap. */ +export interface Levels { + readonly floor: number; // raw u8 value mapped to colour 0 + readonly ceiling: number; // raw u8 value mapped to colour 255 +} + +export const DEFAULT_LEVELS: Levels = { floor: 255 - 120, ceiling: 255 - 40 }; + +export function blitRow( + bins: Uint8Array, + geo: RowGeometry, + out: Uint32Array, + lut: Uint32Array, + levels: Levels = DEFAULT_LEVELS, +): void { + const width = out.length; + const nBins = bins.length; + const background = lut[0] ?? 0; + if (nBins === 0 || width === 0 || geo.spanHz <= 0) { + out.fill(background); + return; + } + const range = Math.max(1, levels.ceiling - levels.floor); + const hzPerPx = (geo.viewHiHz - geo.viewLoHz) / width; + const binsPerHz = nBins / geo.spanHz; + for (let x = 0; x < width; x++) { + // Max over the bins this pixel covers, so narrow signals never vanish when zoomed out. + const loHz = geo.viewLoHz + x * hzPerPx; + const b0 = Math.floor((loHz - geo.startHz) * binsPerHz); + const b1 = Math.max(b0 + 1, Math.floor((loHz + hzPerPx - geo.startHz) * binsPerHz)); + if (b1 <= 0 || b0 >= nBins) { + out[x] = background; + continue; + } + let peak = 0; + for (let b = Math.max(0, b0); b < Math.min(nBins, b1); b++) { + const v = bins[b] ?? 0; + if (v > peak) peak = v; + } + const level = Math.min(255, Math.max(0, Math.round(((peak - levels.floor) / range) * 255))); + out[x] = lut[level] ?? background; + } +} diff --git a/web/src/lib/waterfall/colormap.ts b/web/src/lib/waterfall/colormap.ts new file mode 100644 index 0000000..043e651 --- /dev/null +++ b/web/src/lib/waterfall/colormap.ts @@ -0,0 +1,68 @@ +/** Waterfall colormaps as 256-entry RGBA lookup tables. */ + +export type ColormapName = 'spectre' | 'viridis' | 'inferno' | 'grey'; +export const COLORMAPS: readonly ColormapName[] = ['spectre', 'viridis', 'inferno', 'grey']; + +type Stop = readonly [number, number, number, number]; // position 0..1, r, g, b + +/** + * Default "aurora": ink -> indigo -> violet -> azure -> aqua-mint -> white. + * Monotonic in lightness. Mirrored by the bridge's recorded-net PNG palette. + */ +const SPECTRE: readonly Stop[] = [ + [0.0, 3, 4, 12], + [0.3, 22, 20, 68], + [0.5, 62, 44, 140], + [0.66, 46, 130, 206], + [0.8, 94, 234, 212], + [0.92, 204, 250, 240], + [1.0, 255, 255, 255], +]; +const VIRIDIS: readonly Stop[] = [ + [0.0, 68, 1, 84], + [0.25, 59, 82, 139], + [0.5, 33, 145, 140], + [0.75, 94, 201, 98], + [1.0, 253, 231, 37], +]; +const INFERNO: readonly Stop[] = [ + [0.0, 0, 0, 4], + [0.25, 87, 16, 110], + [0.5, 188, 55, 84], + [0.75, 249, 142, 9], + [1.0, 252, 255, 164], +]; +const GREY: readonly Stop[] = [ + [0.0, 0, 0, 0], + [1.0, 255, 255, 255], +]; +const STOPS: Record = { + spectre: SPECTRE, + viridis: VIRIDIS, + inferno: INFERNO, + grey: GREY, +}; + +const cache = new Map(); + +/** Packed little-endian RGBA (as written by Uint32Array over ImageData). */ +export function colormapLut(name: ColormapName): Uint32Array { + const hit = cache.get(name); + if (hit) return hit; + const stops = STOPS[name]; + const lut = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + const t = i / 255; + let k = 0; + while (k < stops.length - 2 && t > (stops[k + 1]?.[0] ?? 1)) k++; + const a: Stop = stops[k] ?? [0, 0, 0, 0]; + const b = stops[k + 1] ?? a; + const f = b[0] === a[0] ? 0 : (t - a[0]) / (b[0] - a[0]); + const r = Math.round(a[1] + (b[1] - a[1]) * f); + const g = Math.round(a[2] + (b[2] - a[2]) * f); + const bl = Math.round(a[3] + (b[3] - a[3]) * f); + lut[i] = (255 << 24) | (bl << 16) | (g << 8) | r; + } + cache.set(name, lut); + return lut; +} diff --git a/web/src/lib/waterfall/renderer.ts b/web/src/lib/waterfall/renderer.ts new file mode 100644 index 0000000..4cca6fc --- /dev/null +++ b/web/src/lib/waterfall/renderer.ts @@ -0,0 +1,79 @@ +/** + * Canvas waterfall: newest row on top, older rows scroll down. The backing + * store tracks panel size x devicePixelRatio. Pixel mapping lives in blit.ts. + */ +import { blitRow, DEFAULT_LEVELS, type Levels } from './blit'; +import { colormapLut, type ColormapName } from './colormap'; +import type { WaterfallFrame } from '../protocol/binary'; + +export class WaterfallRenderer { + private readonly ctx: CanvasRenderingContext2D; + private row: ImageData | null = null; + private lut: Uint32Array; + private viewLo = 0; + private viewHi = 1; + private levels: Levels = DEFAULT_LEVELS; + private rowHeight = 1; + + constructor( + private readonly canvas: HTMLCanvasElement, + colormap: ColormapName = 'spectre', + ) { + const ctx = canvas.getContext('2d', { alpha: false }); + if (!ctx) throw new Error('2D canvas unavailable'); + this.ctx = ctx; + this.lut = colormapLut(colormap); + } + + resize(cssWidth: number, cssHeight: number, dpr: number): void { + const w = Math.max(1, Math.round(cssWidth * dpr)); + const h = Math.max(1, Math.round(cssHeight * dpr)); + if (w === this.canvas.width && h === this.canvas.height) return; + this.canvas.width = w; + this.canvas.height = h; + this.rowHeight = Math.max(1, Math.round(dpr)); + this.row = this.ctx.createImageData(w, 1); + this.clear(); + } + + setView(loHz: number, hiHz: number): void { + this.viewLo = loHz; + this.viewHi = Math.max(loHz + 1, hiHz); + } + + setColormap(name: ColormapName): void { + this.lut = colormapLut(name); + } + + setLevels(levels: Levels): void { + this.levels = levels; + } + + clear(): void { + const v = this.lut[0] ?? 0; // little-endian RGBA packed as ABGR + this.ctx.fillStyle = `rgb(${String(v & 255)} ${String((v >>> 8) & 255)} ${String((v >>> 16) & 255)})`; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + push(frame: WaterfallFrame): void { + const row = this.row; + if (!row) return; + const { width, height } = this.canvas; + const rh = this.rowHeight; + // Scroll everything down by one row, then paint the newest row at the top. + this.ctx.drawImage(this.canvas, 0, 0, width, height - rh, 0, rh, width, height - rh); + blitRow( + frame.bins, + { + startHz: frame.startHz, + spanHz: frame.spanHz, + viewLoHz: this.viewLo, + viewHiHz: this.viewHi, + }, + new Uint32Array(row.data.buffer), + this.lut, + this.levels, + ); + for (let y = 0; y < rh; y++) this.ctx.putImageData(row, 0, y); + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..b9bb3bf --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte'; +import App from './app/App.svelte'; +import './styles/tokens.css'; +import './styles/base.css'; + +const target = document.getElementById('app'); +if (!target) throw new Error('#app mount point missing'); + +export default mount(App, { target }); diff --git a/web/src/styles/base.css b/web/src/styles/base.css new file mode 100644 index 0000000..5ce318f --- /dev/null +++ b/web/src/styles/base.css @@ -0,0 +1,238 @@ +@import '@fontsource/jetbrains-mono/400.css'; +@import '@fontsource/jetbrains-mono/600.css'; +@import '@fontsource/share-tech-mono/400.css'; +@import '@fontsource/space-grotesk/400.css'; +@import '@fontsource/space-grotesk/600.css'; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + color: var(--g-text); + font-family: var(--g-font-ui); + font-size: 16px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +/* + * Base: a deep solid gradient on (ink → midnight indigo → plum). + * stays transparent so the ASCII backdrop canvas (z-index -1) shows. + */ +html { + background: + radial-gradient(90% 60% at 85% 0%, var(--g-bg-glow-a), transparent 70%), + radial-gradient(80% 70% at 0% 100%, var(--g-bg-glow-b), transparent 70%), + linear-gradient(160deg, var(--g-bg-0) 0%, var(--g-bg-1) 45%, var(--g-bg-2) 100%); + background-color: var(--g-bg-0); + background-attachment: fixed; +} + +/* fine film grain: texture only, pointer-transparent */ +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 100; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.045'/%3E%3C/svg%3E"); + mix-blend-mode: overlay; +} + +@media (prefers-reduced-transparency: reduce) { + body::after { + display: none; + } +} + +::selection { + background: rgb(167 139 250 / 0.35); +} + +* { + scrollbar-width: thin; + scrollbar-color: rgb(255 255 255 / 0.14) transparent; +} + +.mono, +code, +kbd, +samp { + font-family: var(--g-font-mono); + font-variant-numeric: tabular-nums slashed-zero; +} + +.num { + font-variant-numeric: tabular-nums slashed-zero; +} + +.caps { + text-transform: uppercase; + letter-spacing: var(--g-tracking-caps); +} + +:focus-visible { + outline: 2px solid var(--g-chrome); + outline-offset: 2px; + border-radius: var(--g-radius-s); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +button, +input, +select { + font: inherit; + color: inherit; +} + +/* shared controls */ +.btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--g-space-2); + min-height: 36px; + padding: 0 var(--g-space-4); + border: 1px solid var(--g-border); + border-radius: var(--g-radius-pill); + background: var(--g-glass-raised); + box-shadow: var(--g-inner-glow); + color: var(--g-text); + font-family: var(--g-font-ui); + font-size: var(--g-text-s); + font-weight: 600; + letter-spacing: 0.01em; + cursor: pointer; + transition: + background var(--g-dur-fast) var(--g-ease), + border-color var(--g-dur-fast) var(--g-ease), + box-shadow var(--g-dur-fast) var(--g-ease), + transform var(--g-dur-fast) var(--g-ease); +} +.btn:hover { + background: rgb(255 255 255 / 0.1); + border-color: var(--g-border-strong); +} +.btn:active { + transform: translateY(1px); +} +.btn[aria-pressed='true'], +.btn.active { + border-color: transparent; + /* gradient hairline: tinted fill over an opaque base, accent on the border box */ + background: + var(--g-accent-soft) padding-box, + linear-gradient(rgb(14 16 32), rgb(14 16 32)) padding-box, + var(--g-accent) border-box; + color: #fff; + box-shadow: + 0 0 0 1px rgb(167 139 250 / 0.15), + 0 6px 20px -8px rgb(103 232 249 / 0.6); +} +.btn.primary { + border-color: transparent; + background: var(--g-accent); + color: #0a0c18; + box-shadow: 0 8px 24px -10px rgb(125 211 252 / 0.8); +} +.btn.primary:hover { + filter: brightness(1.08); +} +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.btn.ghost { + background: transparent; + box-shadow: none; +} + +.field { + display: grid; + gap: 6px; + font-size: var(--g-text-xs); + color: var(--g-text-muted); + font-weight: 600; + letter-spacing: 0.04em; +} +.input { + min-height: 38px; + width: 100%; + padding: 0 var(--g-space-3); + border: 1px solid var(--g-border); + border-radius: var(--g-radius-s); + background: var(--g-glass-sunken); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.35); + color: var(--g-text); + font-family: var(--g-font-mono); + font-size: var(--g-text-m); + font-weight: 400; + font-variant-numeric: tabular-nums; + letter-spacing: 0; + transition: + border-color var(--g-dur-fast) var(--g-ease), + box-shadow var(--g-dur-fast) var(--g-ease); +} +.input:focus { + outline: none; + border-color: rgb(125 211 252 / 0.6); + box-shadow: + inset 0 1px 2px rgb(0 0 0 / 0.35), + 0 0 0 3px rgb(125 211 252 / 0.15); +} +select.input { + cursor: pointer; +} +input[type='radio'], +input[type='checkbox'], +input[type='range'] { + accent-color: var(--g-accent-a); +} + +/* small status/label chip used inside rows */ +.chip { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: var(--g-radius-pill); + font-family: var(--g-font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + line-height: 1.6; + white-space: nowrap; +} +.chip.alert { + color: var(--g-alert); + background: var(--g-alert-soft); +} +.chip.gn { + color: var(--g-violet); + background: var(--g-violet-soft); +} +.chip.dir { + color: var(--g-signal); + background: var(--g-signal-soft); +} +.chip.act { + color: var(--g-text-muted); + background: rgb(255 255 255 / 0.05); +} diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..924b9e4 --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,108 @@ +/* + * ghost.js8 design tokens — the single source for colour, glass, radii, blur, + * spacing, type and motion. Palette "ghost signal": a deep solid gradient with + * a live ASCII field behind frosted glass, aqua→violet accent, mint for live signal. + * Contrast: --g-text and --g-text-muted are >= 7:1 and >= 4.5:1 against the + * most opaque glass surface over --g-bg-0 (checked against #141826). + */ +:root { + color-scheme: dark; + + /* base: solid gradient, ink → midnight indigo → plum */ + --g-bg-0: #04050b; + --g-bg-1: #0a0c1f; + --g-bg-2: #1a1033; + --g-bg-glow-a: rgb(56 189 248 / 0.1); /* top-right, cool */ + --g-bg-glow-b: rgb(139 92 246 / 0.14); /* bottom-left, warm violet */ + + /* glass */ + --g-glass: rgb(12 14 30 / 0.6); + --g-glass-strong: rgb(18 22 40 / 0.86); + --g-glass-raised: rgb(255 255 255 / 0.06); /* controls, rows on hover */ + --g-glass-sunken: rgb(3 4 10 / 0.45); /* inputs, canvases */ + --g-glass-solid: #141826; /* fallback when blur is unavailable or unwanted */ + --g-glass-blur: 20px; + --g-glass-saturate: 170%; + --g-border: rgb(255 255 255 / 0.08); + --g-border-strong: rgb(255 255 255 / 0.16); + --g-edge-hi: rgb(255 255 255 / 0.22); /* lit top edge of glass */ + --g-hairline: rgb(255 255 255 / 0.05); + --g-shadow: 0 24px 48px -24px rgb(0 0 0 / 0.7), 0 2px 6px -2px rgb(0 0 0 / 0.4); + --g-inner-glow: inset 0 1px 0 rgb(255 255 255 / 0.07); + + /* text */ + --g-text: #eef1fb; + --g-text-muted: #a3adc6; + --g-text-dim: #6c7690; /* decorative only, never sole carrier of meaning */ + + /* accent: the brand gradient, aqua → violet */ + --g-accent-a: #67e8f9; + --g-accent-b: #a78bfa; + --g-accent: linear-gradient(120deg, var(--g-accent-a), var(--g-accent-b)); + --g-accent-soft: linear-gradient(120deg, rgb(103 232 249 / 0.16), rgb(167 139 250 / 0.16)); + + /* semantic accents: each also has an icon/label, colour is never alone */ + --g-signal: #5eead4; /* live signal, ok */ + --g-signal-soft: rgb(94 234 212 / 0.12); + --g-chrome: #7dd3fc; /* UI chrome, focus */ + --g-chrome-soft: rgb(125 211 252 / 0.12); + --g-violet: #c4b5fd; /* GhostNet */ + --g-violet-soft: rgb(167 139 250 / 0.14); + --g-warn: #fcd34d; /* degraded, stale */ + --g-warn-soft: rgb(252 211 77 / 0.12); + --g-alert: #fb7185; /* overload, fault, flash */ + --g-alert-soft: rgb(251 113 133 / 0.14); + + /* type */ + --g-font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + /* display: wordmark, panel titles, big readouts. Terminal bite, used large. */ + --g-font-display: 'Share Tech Mono', var(--g-font-mono); + --g-font-ui: 'Space Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + --g-text-xs: 0.6875rem; + --g-text-s: 0.8125rem; + --g-text-m: 0.9375rem; + --g-text-l: 1.125rem; + --g-text-xl: 1.5rem; + --g-text-xxl: 2.25rem; + --g-tracking-caps: 0.12em; + + /* space (4 px grid) */ + --g-space-1: 0.25rem; + --g-space-2: 0.5rem; + --g-space-3: 0.75rem; + --g-space-4: 1rem; + --g-space-5: 1.5rem; + --g-space-6: 2rem; + + /* radii */ + --g-radius-s: 8px; + --g-radius-m: 12px; + --g-radius-l: 20px; + --g-radius-pill: 999px; + + /* motion */ + --g-ease: cubic-bezier(0.2, 0.7, 0.2, 1); + --g-dur-fast: 140ms; + --g-dur: 260ms; + --g-dur-slow: 900ms; + + /* layout */ + --g-gutter: var(--g-space-4); + --g-gap: 14px; +} + +@media (prefers-reduced-transparency: reduce) { + :root { + --g-glass: var(--g-glass-solid); + --g-glass-strong: var(--g-glass-solid); + --g-glass-blur: 0px; + } +} + +@media (prefers-reduced-motion: reduce) { + :root { + --g-dur-fast: 0ms; + --g-dur: 0ms; + --g-dur-slow: 0ms; + } +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..17256e8 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,6 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), + compilerOptions: { runes: true }, +}; diff --git a/web/tests/e2e/ghostnet.spec.ts b/web/tests/e2e/ghostnet.spec.ts new file mode 100644 index 0000000..3d7528f --- /dev/null +++ b/web/tests/e2e/ghostnet.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; + +// The e2e stack runs the GhostNet autopilot (region na, home grid EN34) against +// the simulated receiver, with one seeded recording of last week's net. + +test.describe('GhostNet', () => { + test('autopilot status and recorded nets are shown', async ({ page, isMobile }) => { + await page.goto('/'); + if (isMobile) await page.getByRole('button', { name: 'GhostNet', exact: true }).click(); + const panel = page.getByTestId('panel-ghostnet'); + await expect(panel).toContainText(/WATCHING 7\.107|ON NET/, { timeout: 20_000 }); + await expect(panel).toContainText('North America'); + await expect(panel).toContainText(/next in|ends in|starts in/); + await expect(page.getByTestId('net-link').first()).toContainText('GhostNet North America'); + }); + + test('a recorded net replays waterfall, audio and traffic', async ({ page, isMobile }) => { + await page.goto('/'); + if (isMobile) await page.getByRole('button', { name: 'GhostNet', exact: true }).click(); + await page.getByTestId('net-link').first().click(); + const view = page.getByTestId('net-log'); + await expect(view).toBeVisible(); + await expect(view.getByRole('heading', { name: 'GhostNet North America' })).toBeVisible(); + const img = page.getByTestId('net-waterfall'); + await expect.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth)).toBe(400); + const audio = page.getByTestId('net-audio'); + await expect + .poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration), { timeout: 15_000 }) + .toBeGreaterThan(200); + const traffic = page.getByTestId('net-traffic'); + await expect(traffic).toContainText('@GSTFLASH DRILL'); + await expect(traffic.getByText('FLASH', { exact: true })).toBeVisible(); + await expect(traffic.getByText('@GNUSAIN', { exact: true })).toBeVisible(); + await page.getByRole('link', { name: '← LIVE' }).click(); + await expect(view).toBeHidden(); + }); + + test('@GSTFLASH traffic raises an alert that can be acknowledged', async ({ page }) => { + await page.goto('/'); + const alert = page.getByTestId('flash-alert'); + await expect(alert).toBeVisible({ timeout: 30_000 }); + await expect(alert).toContainText('@GSTFLASH'); + await alert.getByRole('button', { name: 'Acknowledge' }).click(); + await expect(alert).toBeHidden(); + }); +}); diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..d772fd0 --- /dev/null +++ b/web/tests/e2e/smoke.spec.ts @@ -0,0 +1,120 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +test.describe('ghost.js8 smoke', () => { + test('establishes the link and shows live traffic', async ({ page, isMobile }) => { + await page.goto('/'); + await expect(page.getByTestId('link-state')).toContainText('LINK ESTABLISHED'); + await expect(page.getByTestId('mode-chip')).toHaveText(/\bRX\b/); + await expect(page.getByTestId('decode-row').first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('situation')).toHaveAttribute( + 'data-code', + /listening|band-quiet/, + ); + if (isMobile) { + await page.getByRole('button', { name: 'Stations' }).click(); + } + await expect(page.getByTestId('station-row').first()).toBeVisible({ timeout: 20_000 }); + }); + + test('has no transmit control anywhere', async ({ page, isMobile }) => { + await page.goto('/'); + await expect(page.getByTestId('link-state')).toContainText('LINK ESTABLISHED'); + const views = isMobile + ? ['Traffic', 'Waterfall', 'Stations', 'Map', 'Controls', 'Status'] + : ['']; + for (const view of views) { + if (view) await page.getByRole('button', { name: view, exact: true }).click(); + const controls = page.locator('button, input, select, textarea, [role="button"], a'); + const texts = await controls.evaluateAll((els) => + els.map((e) => + [ + e.textContent, + e.getAttribute('aria-label'), + e.getAttribute('title'), + e.getAttribute('name'), + e.getAttribute('placeholder'), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(), + ), + ); + const forbidden = /\b(transmit|tx|send|key ?up|ptt|beacon|heartbeat on|cq)\b/; + expect(texts.filter((t) => forbidden.test(t))).toEqual([]); + } + }); + + test('waterfall renders rows', async ({ page, isMobile }) => { + await page.goto('/'); + if (isMobile) await page.getByRole('button', { name: 'Waterfall', exact: true }).click(); + const canvas = page.getByTestId('waterfall-canvas'); + await expect(canvas).toBeVisible(); + await expect + .poll( + () => + canvas.evaluate((c: HTMLCanvasElement) => { + const ctx = c.getContext('2d'); + if (!ctx) return 0; + const d = ctx.getImageData(0, 0, c.width, Math.min(c.height, 20)).data; + let lit = 0; + for (let i = 0; i < d.length; i += 4) + if ((d[i] ?? 0) + (d[i + 1] ?? 0) + (d[i + 2] ?? 0) > 120) lit++; + return lit; + }), + { timeout: 15_000 }, + ) + .toBeGreaterThan(0); + }); + + test('maps only stations with valid grids', async ({ page, isMobile }) => { + await page.goto('/'); + if (isMobile) await page.getByRole('button', { name: 'Map', exact: true }).click(); + await expect(page.getByTestId('panel-map')).toContainText(/[1-9] with grid/, { + timeout: 30_000, + }); + }); + + test('audio monitor starts and stops cleanly', async ({ page, isMobile }) => { + await page.goto('/'); + if (isMobile) await page.getByRole('button', { name: 'Controls', exact: true }).click(); + const toggle = page.getByTestId('audio-toggle'); + await expect(toggle).toHaveText(/enable audio/i); + await toggle.click(); + await expect(page.getByTestId('panel-audio')).toContainText(/buffer \d+ ms/, { + timeout: 10_000, + }); + await expect(page.getByTestId('panel-audio')).not.toContainText('could not start'); + await toggle.click(); + await expect(toggle).toHaveText(/enable audio/i); + }); + + test('passes an automated accessibility scan', async ({ page }) => { + // Scan the settled UI: entry animations briefly tint rows. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/'); + await expect(page.getByTestId('decode-row').first()).toBeVisible({ timeout: 20_000 }); + const results = await new AxeBuilder({ page }).exclude('.maplibregl-canvas').analyze(); + const serious = results.violations.filter( + (v) => v.impact === 'serious' || v.impact === 'critical', + ); + expect( + serious.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(' ')).join(', ')}`), + ).toEqual([]); + }); + + test('backdrop is decorative and can be switched to a still gradient', async ({ page }) => { + await page.goto('/'); + const fx = page.getByRole('button', { name: 'Animated backdrop' }); + await expect(fx).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByTestId('backdrop')).toHaveAttribute('aria-hidden', 'true'); + await fx.click(); + await expect(fx).toHaveAttribute('aria-pressed', 'false'); + await expect(page.getByTestId('backdrop')).toHaveCount(0); + await page.reload(); + await expect(page.getByRole('button', { name: 'Animated backdrop' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + }); +}); diff --git a/web/tests/unit/backdrop.test.ts b/web/tests/unit/backdrop.test.ts new file mode 100644 index 0000000..cc30eb7 --- /dev/null +++ b/web/tests/unit/backdrop.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + GHOST, + RAMP, + flicker, + msToNextSlot, + rampChar, + ringChar, + ringIntensity, + staticField, + valueNoise, +} from '../../src/lib/backdrop/ascii'; + +describe('ascii backdrop helpers', () => { + it('value noise and the static field stay in [0, 1]', () => { + for (let i = 0; i < 2000; i++) { + const x = (i * 0.37) % 97; + const y = (i * 0.61) % 53; + const n = valueNoise(x, y); + expect(n).toBeGreaterThanOrEqual(0); + expect(n).toBeLessThanOrEqual(1); + const f = staticField(x, y, i * 0.05); + expect(f).toBeGreaterThanOrEqual(0); + expect(f).toBeLessThanOrEqual(1); + const k = flicker(i, i * 2, i * 0.1); + expect(k).toBeGreaterThanOrEqual(0); + expect(k).toBeLessThan(1.0000001); + } + }); + + it('leaves most of the field empty so panels stay readable', () => { + let empty = 0; + const n = 4000; + for (let i = 0; i < n; i++) if (staticField(i % 120, Math.floor(i / 120), 3) === 0) empty++; + expect(empty / n).toBeGreaterThan(0.4); + }); + + it('maps intensity to the ramp, with jitter at most one step', () => { + expect(rampChar(0)).toBe(' '); + expect(rampChar(1)).toBe(RAMP.at(-1)); + const mid = RAMP.indexOf(rampChar(0.5)); + expect(Math.abs(RAMP.indexOf(rampChar(0.5, 0.05)) - mid)).toBeLessThanOrEqual(1); + expect(Math.abs(RAMP.indexOf(rampChar(0.5, 0.95)) - mid)).toBeLessThanOrEqual(1); + }); + + it('rings peak on the wavefront and fade out with age', () => { + const age = 1; + const front = ringIntensity(14 * age, age); + expect(front).toBeGreaterThan(ringIntensity(14 * age + 4, age)); + expect(front).toBeGreaterThan(ringIntensity(14 * age - 4, age)); + expect(ringIntensity(14 * 3, 3)).toBeLessThan(front); + expect(ringIntensity(0, 10)).toBe(0); + expect(ringIntensity(0, -1)).toBe(0); + }); + + it('draws rings as ((( ~ )))', () => { + expect(ringChar(-5, 0)).toBe('('); + expect(ringChar(5, 0)).toBe(')'); + expect(ringChar(0, 5)).toBe('~'); + }); + + it('aligns slot pulses to UTC 15-second boundaries', () => { + expect(msToNextSlot(Date.UTC(2026, 9, 9, 1, 0, 0))).toBe(0); + expect(msToNextSlot(Date.UTC(2026, 9, 9, 1, 0, 1))).toBe(14_000); + expect(msToNextSlot(Date.UTC(2026, 9, 9, 1, 0, 14, 900))).toBe(100); + }); + + it('keeps the ghost sprite rectangular', () => { + const w = GHOST[0]?.length; + for (const line of GHOST) expect(line.length).toBe(w); + }); +}); diff --git a/web/tests/unit/binary.test.ts b/web/tests/unit/binary.test.ts new file mode 100644 index 0000000..e25a125 --- /dev/null +++ b/web/tests/unit/binary.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { decodeFrame, FrameError } from '../../src/lib/protocol/binary'; + +function audioFrame(seq: number, flags: number, samples: number[]): ArrayBuffer { + const buf = new ArrayBuffer(12 + samples.length * 2); + const v = new DataView(buf); + v.setUint8(0, 1); + v.setUint8(1, flags); + v.setUint32(4, seq, true); + v.setUint32(8, 12000, true); + samples.forEach((s, i) => { + v.setInt16(12 + i * 2, s, true); + }); + return buf; +} + +function wfFrame(bins: number[], declared = bins.length): ArrayBuffer { + const buf = new ArrayBuffer(16 + bins.length); + const v = new DataView(buf); + v.setUint8(0, 2); + v.setUint16(2, declared, true); + v.setUint32(4, 9, true); + v.setUint32(8, 14_000_000, true); + v.setUint32(12, 12_000, true); + bins.forEach((b, i) => { + v.setUint8(16 + i, b); + }); + return buf; +} + +describe('decodeFrame', () => { + it('decodes little-endian audio to float samples', () => { + const f = decodeFrame(audioFrame(7, 1, [16384, -32768, 0])); + expect(f.kind).toBe('audio'); + if (f.kind !== 'audio') return; + expect(f.seq).toBe(7); + expect(f.sampleRate).toBe(12000); + expect(f.adcOverload).toBe(true); + expect(Array.from(f.samples)).toEqual([0.5, -1, 0]); + }); + + it('decodes waterfall rows', () => { + const f = decodeFrame(wfFrame([1, 2, 3])); + expect(f.kind).toBe('waterfall'); + if (f.kind !== 'waterfall') return; + expect([f.seq, f.startHz, f.spanHz]).toEqual([9, 14_000_000, 12_000]); + expect(Array.from(f.bins)).toEqual([1, 2, 3]); + }); + + it('rejects malformed frames', () => { + expect(() => decodeFrame(new ArrayBuffer(0))).toThrow(FrameError); + expect(() => decodeFrame(new Uint8Array([9]).buffer)).toThrow(FrameError); + expect(() => decodeFrame(audioFrame(1, 0, [1]).slice(0, 13))).toThrow(FrameError); + expect(() => decodeFrame(wfFrame([1, 2, 3], 4))).toThrow(FrameError); + }); +}); diff --git a/web/tests/unit/client.test.ts b/web/tests/unit/client.test.ts new file mode 100644 index 0000000..b92d8d8 --- /dev/null +++ b/web/tests/unit/client.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BridgeClient, parseServerMessage, type SocketLike } from '../../src/lib/protocol/client'; + +class FakeSocket implements SocketLike { + binaryType = 'blob'; + readyState = 0; + sent: string[] = []; + closed: number | null = null; + onopen: ((ev: Event) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + send(data: string): void { + this.sent.push(data); + } + close(code?: number): void { + this.closed = code ?? 1000; + } + open(): void { + this.readyState = 1; + this.onopen?.(new Event('open')); + } + message(data: unknown): void { + this.onmessage?.({ data } as MessageEvent); + } + drop(): void { + this.readyState = 3; + this.onclose?.({} as CloseEvent); + } +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('parseServerMessage', () => { + it('accepts known v1 messages only', () => { + expect(parseServerMessage('{"v":1,"type":"pong","utc":"x"}')?.type).toBe('pong'); + expect(parseServerMessage('{"v":2,"type":"pong"}')).toBeNull(); + expect(parseServerMessage('{"v":1,"type":"transmit"}')).toBeNull(); + expect(parseServerMessage('nope')).toBeNull(); + expect(parseServerMessage('[]')).toBeNull(); + }); +}); + +describe('BridgeClient', () => { + it('connects, says hello, re-subscribes, dispatches, reconnects and cleans up', () => { + vi.useFakeTimers(); + const sockets: FakeSocket[] = []; + const links: string[] = []; + const messages: string[] = []; + const frames: string[] = []; + const client = new BridgeClient( + 'ws://x/ws', + { + onMessage: (m) => messages.push(m.type), + onFrame: (f) => frames.push(f.kind), + onLink: (l) => links.push(l), + }, + () => { + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + ); + client.start(); + client.subscribe(true, false); + const s0 = sockets[0]; + if (!s0) throw new Error('no socket'); + s0.open(); + expect(s0.binaryType).toBe('arraybuffer'); + expect(s0.sent.map((x) => JSON.parse(x) as { type: string }).map((x) => x.type)).toEqual([ + 'hello', + 'subscribe', + ]); + expect(JSON.parse(s0.sent[1] ?? '{}')).toMatchObject({ audio: true, waterfall: false }); + + s0.message('{"v":1,"type":"pong","utc":"x"}'); + s0.message('garbage'); + const wf = new ArrayBuffer(17); + new DataView(wf).setUint8(0, 2); + new DataView(wf).setUint16(2, 1, true); + s0.message(wf); + s0.message(new ArrayBuffer(3)); // malformed binary: ignored, no throw + expect(messages).toEqual(['pong']); + expect(frames).toEqual(['waterfall']); + + s0.drop(); + expect(links.at(-1)).toBe('reconnecting'); + vi.advanceTimersByTime(20_000); + expect(sockets).toHaveLength(2); + + client.stop(); + expect(sockets[1]?.closed).toBe(1000); + expect(links.at(-1)).toBe('closed'); + vi.advanceTimersByTime(60_000); + expect(sockets).toHaveLength(2); // no reconnect after stop + }); +}); diff --git a/web/tests/unit/format.test.ts b/web/tests/unit/format.test.ts new file mode 100644 index 0000000..498040e --- /dev/null +++ b/web/tests/unit/format.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { bandFor, JS8_BANDS } from '../../src/lib/bands'; +import { ago, formatMHz, formatSnr, utcTime } from '../../src/lib/format'; + +describe('format', () => { + it('formats frequencies and SNR', () => { + expect(formatMHz(14_078_000)).toBe('14.078 000'); + expect(formatMHz(7_078_705)).toBe('7.078 705'); + expect(formatSnr(2)).toBe('+02'); + expect(formatSnr(-22)).toBe('−22'); + expect(formatSnr(null)).toBe('—'); + }); + + it('formats UTC and ages', () => { + expect(utcTime('2026-10-08T02:26:58.000Z')).toBe('02:26:58'); + const now = Date.parse('2026-10-08T03:00:00Z'); + expect(ago('2026-10-08T02:59:30Z', now)).toBe('30s'); + expect(ago('2026-10-08T01:00:00Z', now)).toBe('2h'); + expect(ago(null, now)).toBe('never'); + }); + + it('has JS8Call default band presets', () => { + expect(JS8_BANDS.find((b) => b.band === '20m')?.dialHz).toBe(14_078_000); + expect(bandFor(7_078_000)).toBe('40m'); + expect(bandFor(9_000_000)).toBeNull(); + }); +}); diff --git a/web/tests/unit/ghostnet.test.ts b/web/tests/unit/ghostnet.test.ts new file mode 100644 index 0000000..88972ba --- /dev/null +++ b/web/tests/unit/ghostnet.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { classify, countdown, netIdFromHash } from '../../src/lib/ghostnet'; + +describe('classify', () => { + it('flags flash traffic', () => { + expect(classify('W1GHZ: @GSTFLASH BRIDGE OUT ON I-95').flash).toBe(true); + expect(classify('W1GHZ: @gstflash test').flash).toBe(true); + expect(classify('W1GHZ: NOT@GSTFLASHY').flash).toBe(false); + }); + + it('recognises GhostNet and regional groups', () => { + expect(classify('K0OG: @GHOSTNET QSL').ghostnet).toBe(true); + const sc = classify('KN4CRD: @GNUSASC CHECKING IN'); + expect([sc.ghostnet, sc.regional]).toEqual([true, '@GNUSASC']); + expect(classify('K0OG: KN4CRD SNR +02')).toEqual({ + flash: false, + ghostnet: false, + regional: null, + }); + }); +}); + +describe('countdown', () => { + it('formats durations', () => { + expect(countdown(0, 45_000)).toBe('45s'); + expect(countdown(0, 725_000)).toBe('12m 05s'); + expect(countdown(0, 7_500_000)).toBe('2h 05m'); + expect(countdown(0, 3 * 86400_000 + 4 * 3600_000)).toBe('3d 04h'); + expect(countdown(10, 0)).toBe('0s'); + }); +}); + +describe('netIdFromHash', () => { + it('accepts bridge-generated ids only', () => { + expect(netIdFromHash('#/nets/na-net-20261009T0100Z')).toBe('na-net-20261009T0100Z'); + expect(netIdFromHash('#/nets/../../etc')).toBeNull(); + expect(netIdFromHash('#/nets/')).toBeNull(); + expect(netIdFromHash('#/other')).toBeNull(); + }); +}); diff --git a/web/tests/unit/grid.test.ts b/web/tests/unit/grid.test.ts new file mode 100644 index 0000000..9245691 --- /dev/null +++ b/web/tests/unit/grid.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { gridCenter, normalizeGrid } from '../../src/lib/grid'; + +describe('Maidenhead', () => { + it.each([ + ['EN34', 'EN34'], + ['fn31PR', 'FN31pr'], + ['JO65ab12', 'JO65ab12'], + ])('normalizes %s', (raw, norm) => { + expect(normalizeGrid(raw)).toBe(norm); + }); + + it.each(['', 'EN', 'SN34', 'EN3', 'EN34yz', 'N0CALL', '@HB', 'EN34ab1'])('rejects %s', (raw) => { + expect(normalizeGrid(raw)).toBeNull(); + expect(gridCenter(raw)).toBeNull(); + }); + + it('matches the bridge implementation', () => { + expect(gridCenter('EN34')).toEqual([-93, 44.5]); + const c = gridCenter('FN31pr'); + expect(c?.map((x) => Math.round(x * 1000) / 1000)).toEqual([-72.708, 41.729]); + }); +}); diff --git a/web/tests/unit/ring.test.ts b/web/tests/unit/ring.test.ts new file mode 100644 index 0000000..bfde36f --- /dev/null +++ b/web/tests/unit/ring.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { PcmRing } from '../../src/lib/audio/ring'; + +const seq = (from: number, n: number): Float32Array => + Float32Array.from({ length: n }, (_, i) => from + i); + +describe('PcmRing', () => { + it('outputs silence until prebuffered, then plays in order', () => { + const r = new PcmRing(16, 4); + const out = new Float32Array(3); + r.push(seq(1, 3)); + expect(r.pull(out)).toBe(0); + expect(Array.from(out)).toEqual([0, 0, 0]); + r.push(seq(4, 3)); + expect(r.pull(out)).toBe(3); + expect(Array.from(out)).toEqual([1, 2, 3]); + }); + + it('is bounded: overflow drops the oldest samples', () => { + const r = new PcmRing(4, 1); + r.push(seq(1, 6)); + expect(r.available).toBe(4); + expect(r.dropped).toBe(2); + const out = new Float32Array(4); + r.pull(out); + expect(Array.from(out)).toEqual([3, 4, 5, 6]); + }); + + it('a single oversized push keeps only the newest capacity samples', () => { + const r = new PcmRing(3, 1); + r.push(seq(1, 10)); + const out = new Float32Array(3); + r.pull(out); + expect(Array.from(out)).toEqual([8, 9, 10]); + expect(r.dropped).toBe(7); + }); + + it('resyncs after an underrun instead of stuttering', () => { + const r = new PcmRing(16, 4); + r.push(seq(1, 5)); + const out = new Float32Array(4); + r.pull(out); // 1..4 + expect(r.pull(out)).toBe(1); // 5 then silence -> underrun + expect(r.underruns).toBe(3); + r.push(seq(6, 2)); + expect(r.pull(out)).toBe(0); // waits for a fresh prebuffer + r.push(seq(8, 2)); + expect(r.pull(out)).toBe(4); + expect(Array.from(out)).toEqual([6, 7, 8, 9]); + }); + + it('rejects impossible configuration and clears', () => { + expect(() => new PcmRing(2, 3)).toThrow(RangeError); + const r = new PcmRing(4, 1); + r.push(seq(1, 2)); + r.clear(); + expect(r.available).toBe(0); + }); +}); diff --git a/web/tests/unit/situation.test.ts b/web/tests/unit/situation.test.ts new file mode 100644 index 0000000..d4082f5 --- /dev/null +++ b/web/tests/unit/situation.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import type { Component, Health, Session } from '../../src/lib/protocol/generated'; +import { situation } from '../../src/lib/state/situation'; +import { initialState, type AppState } from '../../src/lib/state/state'; + +const NOW = Date.parse('2026-10-08T03:00:00Z'); +const ok: Component = { state: 'ok', since: '2026-10-08T02:00:00Z', last_seen: null, detail: '' }; +const down = (detail: string): Component => ({ ...ok, state: 'down', detail }); + +function health( + over: Partial = {}, + lastDecode: string | null = null, +): Health { + return { + v: 1, + type: 'health', + utc: '2026-10-08T03:00:00Z', + overall: 'listening', + last_decode_utc: lastDecode, + components: { + bridge: ok, + decoder_process: ok, + decoder_api: ok, + decoder_capture: ok, + receiver: ok, + audio: ok, + waterfall: ok, + ...over, + }, + }; +} + +function session(over: Partial = {}): Session { + return { + v: 1, + type: 'session', + state: 'connected', + receiver: { host: 'kiwi.example', port: 8073, name: null }, + tuning: { dial_hz: 14_078_000, mode: 'usb', low_cut_hz: 100, high_cut_hz: 3000 }, + subscribers: 1, + reject_reason: null, + detail: '', + next_retry_utc: null, + ...over, + }; +} + +const state = (over: Partial): AppState => ({ ...initialState, link: 'open', ...over }); +const code = (s: AppState): string => situation(s, NOW).code; + +describe('situation', () => { + it('link first', () => { + expect(code({ ...initialState, link: 'reconnecting' })).toBe('link'); + }); + + it('decoder offline is an alert, independent of the band', () => { + expect( + code( + state({ + health: health({ decoder_process: down('JS8Call process not running') }), + session: session(), + }), + ), + ).toBe('decoder-down'); + }); + + it('receiver full and busy are explained and offer switching', () => { + const full = situation( + state({ health: health(), session: session({ state: 'rejected', reject_reason: 'full' }) }), + NOW, + ); + expect([full.code, full.suggestSwitch]).toEqual(['receiver-full', true]); + expect( + code( + state({ health: health(), session: session({ state: 'rejected', reject_reason: 'busy' }) }), + ), + ).toBe('receiver-busy'); + }); + + it('ADC overload says volume will not fix it', () => { + const s = situation( + state({ + health: health(), + session: session(), + receiverStatus: { + v: 1, + type: 'receiver_status', + adc_overload: true, + rssi_dbm: -20, + info: {}, + }, + }), + NOW, + ); + expect(s.code).toBe('adc-overload'); + expect(s.detail).toMatch(/volume cannot fix/i); + expect(s.suggestSwitch).toBe(true); + }); + + it('band quiet is not a fault', () => { + const s = situation( + state({ health: health({}, '2026-10-08T02:40:00Z'), session: session() }), + NOW, + ); + expect([s.code, s.tone]).toEqual(['band-quiet', 'info']); + }); + + it('recent decodes mean listening', () => { + expect(code(state({ health: health({}, '2026-10-08T02:59:00Z'), session: session() }))).toBe( + 'listening', + ); + }); + + it('no receiver selected', () => { + expect( + code(state({ health: health(), session: session({ state: 'idle', receiver: null }) })), + ).toBe('no-receiver'); + }); +}); diff --git a/web/tests/unit/state.test.ts b/web/tests/unit/state.test.ts new file mode 100644 index 0000000..ee65ad7 --- /dev/null +++ b/web/tests/unit/state.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { Decode, ServerMessage, Station } from '../../src/lib/protocol/generated'; +import { + initialState, + mappableStations, + MAX_DECODES, + reduce, + stationList, + type AppState, +} from '../../src/lib/state/state'; + +const base: Decode = { + v: 1, + type: 'decode', + kind: 'activity', + utc: '2026-10-08T02:26:58.000Z', + text: 'K0OG: KN4CRD SNR +02', + snr_db: -20, + offset_hz: 702, + dial_hz: 14_078_000, + freq_hz: 14_078_702, + speed: 'normal', + from_call: 'K0OG', + to_call: null, + grid: null, +}; + +const station = (callsign: string, heard: string, grid: string | null): Station => ({ + v: 1, + type: 'station', + callsign, + last_heard_utc: heard, + snr_db: -10, + grid, + offset_hz: 700, + dial_hz: 14_078_000, + heard_count: 1, +}); + +const apply = (state: AppState, ...msgs: ServerMessage[]): AppState => + msgs.reduce((s, message) => reduce(s, { type: 'server', message, now: 0 }), state); + +describe('reducer', () => { + it('collapses activity + directed records of one transmission into one directed row', () => { + const s = apply(initialState, base, { ...base, kind: 'directed', to_call: 'KN4CRD' }); + expect(s.decodes).toHaveLength(1); + expect(s.decodes[0]?.kind).toBe('directed'); + expect(s.decodes[0]?.to_call).toBe('KN4CRD'); + // order independence + const t = apply(initialState, { ...base, kind: 'directed', to_call: 'KN4CRD' }, base); + expect(t.decodes[0]?.kind).toBe('directed'); + }); + + it('keeps distinct transmissions and bounds the timeline', () => { + const many: Decode[] = Array.from({ length: MAX_DECODES + 10 }, (_, i) => ({ + ...base, + offset_hz: i, + })); + const s = apply(initialState, ...many); + expect(s.decodes).toHaveLength(MAX_DECODES); + expect(s.decodes[0]?.offset_hz).toBe(10); + }); + + it('history replaces, live messages append', () => { + const s = apply(initialState, base, { + v: 1, + type: 'history', + decodes: [{ ...base, offset_hz: 1 }], + stations: [station('A1A', '2026-10-08T00:00:00Z', 'EN34')], + }); + expect(s.decodes.map((d) => d.offset_hz)).toEqual([1]); + expect(Object.keys(s.stations)).toEqual(['A1A']); + }); + + it('only stations with valid grids are mappable; list is newest first', () => { + const s = apply( + initialState, + station('OLD', '2026-10-08T00:00:00Z', 'EN34'), + station('NEW', '2026-10-08T01:00:00Z', null), + station('BAD', '2026-10-08T02:00:00Z', 'ZZ99'), + ); + expect(stationList(s).map((x) => x.callsign)).toEqual(['BAD', 'NEW', 'OLD']); + expect(mappableStations(s).map((x) => x.callsign)).toEqual(['OLD']); + expect(s.stations['BAD']?.grid).toBeNull(); + }); + + it('tracks link, errors and binary freshness', () => { + let s = reduce(initialState, { type: 'link', link: 'open' }); + s = reduce(s, { type: 'binary', channel: 'waterfall', now: 42 }); + s = apply(s, { v: 1, type: 'error', code: 'rate_limited', message: 'slow down' }); + expect([s.link, s.lastWaterfallAt, s.lastError?.code]).toEqual(['open', 42, 'rate_limited']); + expect(reduce(s, { type: 'clear-error' }).lastError).toBeNull(); + }); +}); diff --git a/web/tests/unit/waterfall.test.ts b/web/tests/unit/waterfall.test.ts new file mode 100644 index 0000000..ee944f1 --- /dev/null +++ b/web/tests/unit/waterfall.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { blitRow } from '../../src/lib/waterfall/blit'; +import { colormapLut, COLORMAPS } from '../../src/lib/waterfall/colormap'; + +const lut = Uint32Array.from({ length: 256 }, (_, i) => i); // identity: colour index == level +const levels = { floor: 0, ceiling: 255 }; + +describe('colormaps', () => { + it('builds opaque 256-entry LUTs, dark to light', () => { + for (const name of COLORMAPS) { + const l = colormapLut(name); + expect(l.length).toBe(256); + expect(l.every((v) => v >>> 24 === 255)).toBe(true); + const lum = (v: number): number => (v & 255) + ((v >>> 8) & 255) + ((v >>> 16) & 255); + expect(lum(l[255] ?? 0)).toBeGreaterThan(lum(l[0] ?? 0)); + } + }); +}); + +describe('blitRow', () => { + it('maps bins 1:1 when the view equals the row', () => { + const out = new Uint32Array(4); + blitRow( + new Uint8Array([0, 85, 170, 255]), + { startHz: 0, spanHz: 4, viewLoHz: 0, viewHiHz: 4 }, + out, + lut, + levels, + ); + expect(Array.from(out)).toEqual([0, 85, 170, 255]); + }); + + it('keeps narrow peaks when zoomed out (max per pixel)', () => { + const bins = new Uint8Array(8); + bins[5] = 200; + const out = new Uint32Array(2); + blitRow(bins, { startHz: 0, spanHz: 8, viewLoHz: 0, viewHiHz: 8 }, out, lut, levels); + expect(Array.from(out)).toEqual([0, 200]); + }); + + it('paints background outside the row coverage', () => { + const out = new Uint32Array(4); + blitRow( + new Uint8Array([255, 255]), + { startHz: 100, spanHz: 2, viewLoHz: 98, viewHiHz: 102 }, + out, + lut, + levels, + ); + expect(Array.from(out)).toEqual([0, 0, 255, 255]); + }); + + it('applies levels and clamps', () => { + const out = new Uint32Array(3); + blitRow( + new Uint8Array([10, 60, 250]), + { startHz: 0, spanHz: 3, viewLoHz: 0, viewHiHz: 3 }, + out, + lut, + { + floor: 50, + ceiling: 100, + }, + ); + expect(Array.from(out)).toEqual([0, 51, 255]); + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..9bd79c7 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vite/client"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte", "tests/**/*.ts"] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..897eae0 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2023", "DOM"] + }, + "include": ["vite.config.ts", "playwright.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..cc6c447 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,24 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vitest/config'; + +// The bridge is reached through the same origin in production (nginx proxies +// /ws and /api). In dev, Vite proxies to a locally running bridge. +const BRIDGE = process.env['GHOSTJS8_BRIDGE_URL'] ?? 'http://127.0.0.1:8073'; + +const proxy = { + '/api': BRIDGE, + '/ws': { target: BRIDGE.replace(/^http/, 'ws'), ws: true }, +}; + +export default defineConfig({ + plugins: [svelte()], + server: { proxy }, + preview: { proxy }, + // MapLibre loads its worker relative to its own module URL; pre-bundling breaks that. + optimizeDeps: { exclude: ['maplibre-gl'] }, + build: { target: 'es2023', sourcemap: true }, + test: { + include: ['tests/unit/**/*.test.ts'], + environment: 'node', + }, +});