diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52a7df8..cb3adeb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: - name: Build run: | sudo apt-get update - DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC sudo apt-get -y install git make wget gcc libevent-dev libcsfml-dev file g++ cpio unzip rsync bc bzip2 libcairo-dev + DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC sudo apt-get -y install git make wget gcc libevent-dev libcsfml-dev file g++ cpio unzip rsync bc bzip2 libcairo-dev libsqlite3-dev ./build.sh goke ./build.sh hisi ./build.sh star6b0 diff --git a/.github/workflows/preflight-pack.yml b/.github/workflows/preflight-pack.yml new file mode 100644 index 0000000..3174399 --- /dev/null +++ b/.github/workflows/preflight-pack.yml @@ -0,0 +1,46 @@ +name: preflight-pack + +# Build the standalone preflight map app (gs/mapserver.py + web/) into a single +# self-contained binary for Linux, macOS and Windows. Run manually or by pushing +# a `preflight-v*` tag; grab the binaries from the run's Artifacts. + +on: + workflow_dispatch: + push: + tags: + - "preflight-v*" + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact: msposd-preflight-linux + - os: macos-latest + artifact: msposd-preflight-macos + - os: windows-latest + artifact: msposd-preflight-windows + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install PyInstaller + run: python -m pip install --upgrade pyinstaller + + - name: Build + run: python -m PyInstaller --clean --noconfirm gs/pack/mapserver.spec + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + dist/msposd-preflight + dist/msposd-preflight.exe + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 44fcdc4..6ef2317 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,15 @@ msposd_star6b0 msposd_star6e msposd_star6c msposd +msposd_x msposd_rockchip version.h serial_monitor.c *.ini +# PyInstaller output (gs/pack standalone build) +/build/ +/dist/ +*.spec.bak + +.claude/ diff --git a/.vscode/launch.json b/.vscode/launch.json index e5d2963..18ad557 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "program": "${workspaceFolder}/msposd", "args": [ "--master", - "127.0.0.1:14550", + "127.0.0.1:14560", "--baudrate", "115200", "--osd", @@ -54,7 +54,9 @@ "args": [ "-m", "/tmp/inav_pty", - "-d", + "-d", + "--out", + "127.0.0.1:14560", "-r", "1030", "--ahi", diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bdcdf3b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# Project Instructions + +This file is the single source of truth for all AI coding agents (Claude Code, +OpenAI Codex, or any tool that reads AGENTS.md / CLAUDE.md). + +## Project Overview + +A program that draws flight controller's OSD(On-Screen Display) information via MSP DisplayPort protocil directly onto the video stream coming from an OpenIPC camera. + +## Development Workflow: spec - draft - simplify - verify + +Every non-trivial task MUST follow this four-phase pipeline. +Each phase is a gate: do not advance until the current phase passes. + +### Phase 1: Spec (Plan) + +Before writing any code, produce a plan: + +1. Read relevant documentation in `documentation/`. +2. Read the source files you intend to modify. +3. Write a concise plan covering: what changes, which files, why. +4. Document key design decisions and their rationale in the plan. This + prevents oscillating between approaches mid-implementation. +5. Get human approval on the plan before proceeding. + +Do NOT skip planning. A good plan lets you one-shot the implementation. + +### Phase 2: Draft (Implement) + +Execute the plan: + +- Follow the coding conventions below. +- Make minimal, focused changes. Do not refactor unrelated code. +- Do not add features beyond what the spec calls for. + +### Phase 3: Simplify (Review) + +After implementation, review your own work: + +- Can any function be shorter or clearer? +- Are there unnecessary abstractions, error paths, or comments? +- Does the architecture stay clean? No dead code, no orphan headers. +- Remove anything that is not strictly needed. + +### Phase 4: Verify (Build + Test) + +Run verification before declaring done: + + + +## Documentation in code Rules: + +Every function/method MUST include a documentation comment. + +Requirements: +- Use standard language conventions. +- Include: + - Short function description (MAX 50 words) + - Description of every parameter + - Return value description if applicable +- Keep descriptions concise and technical. +- Do NOT exceed 50 words for the function summary. + + diff --git a/Makefile b/Makefile index 5ad3010..56828bb 100644 --- a/Makefile +++ b/Makefile @@ -60,13 +60,13 @@ star6e: version.h native: version.h $(eval SDK = ./sdk/gk7205v300) $(eval CFLAGS += -D_x86) - $(eval LIB = -lcsfml-graphics -lcsfml-window -lcsfml-system `pkg-config --libs cairo x11 xext` -lXext -lm) + $(eval LIB = -lcsfml-graphics -lcsfml-window -lcsfml-system `pkg-config --libs cairo x11 xext` -lXext -lm -lsqlite3) $(eval BUILD = $(CC) $(SRCS) -I $(SDK)/include -L $(DRV) $(CFLAGS) $(LIB) -levent_core -O0 -g -o $(OUTPUT)) $(BUILD) rockchip: version.h $(eval SDK = ./sdk/gk7205v300) $(eval CFLAGS += -D__ROCKCHIP__) - $(eval LIB = `pkg-config --libs cairo x11 xext` -lXext -lm -lrt) + $(eval LIB = `pkg-config --libs cairo x11 xext` -lXext -lm -lrt -lsqlite3) $(eval BUILD = $(CC) $(SRCS) -I $(SDK)/include -L $(DRV) $(CFLAGS) $(LIB) -levent_core -O0 -g -o $(OUTPUT)) $(BUILD) diff --git a/build_rockchip.sh b/build_rockchip.sh index 31a4234..e648aa3 100755 --- a/build_rockchip.sh +++ b/build_rockchip.sh @@ -36,7 +36,7 @@ if [ ! -f $output/tmp/prepare_chroot.done ]; then echo 'deb [signed-by=/usr/share/keyrings/radxa-archive-keyring.gpg] https://radxa-repo.github.io/bullseye rockchip-bullseye main' > /etc/apt/sources.list.d/80-rockchip.list apt-get update - apt-get install -y git gcc make pkg-config libspdlog-dev libevent-dev libcairo-dev + apt-get install -y git gcc make pkg-config libspdlog-dev libevent-dev libcairo-dev libsqlite3-dev apt clean touch /tmp/prepare_chroot.done EOL diff --git a/documentation/build-preflight-release.md b/documentation/build-preflight-release.md new file mode 100644 index 0000000..b55f33e --- /dev/null +++ b/documentation/build-preflight-release.md @@ -0,0 +1,49 @@ +# Build a release package — preflight map + +How to package the preflight map (`map.sh preflight` / `gs/mapserver.py`) into a **single +self-contained binary per OS** that end users run without installing Python. It starts a +local server and opens the map in the user's **default browser** (no Python/WebKit to +install). Writable data (`config.ini`, `maps/`, `state.ini`, `landmarks.db`) is created +next to the executable on first run — that folder is the pack you copy to the OSD station. + +## Prerequisites +- Python **3.9+** and `pip` on the build machine (`pyinstaller` is installed automatically). +- Build **on each target OS** — a Windows `.exe` needs Windows, a macOS build needs a Mac. + +## Build (current OS) +```bash +./gs/pack/build.sh # Linux / macOS +gs\pack\build.bat # Windows +``` +Output: **`dist/msposd-preflight`** (`.exe` on Windows) — one file. + +## Build all three OSes at once (CI) +The `.github/workflows/preflight-pack.yml` matrix builds Linux/macOS/Windows. Trigger it: +- **Manually:** GitHub → Actions → *preflight-pack* → *Run workflow*, or +- **By tag:** push a tag matching `preflight-v*`, e.g. + ```bash + git tag preflight-v1.0 && git push origin preflight-v1.0 + ``` +Download the three binaries from the run's **Artifacts**. + +## Run / verify +```bash +./dist/msposd-preflight # starts server, opens the browser +./dist/msposd-preflight --port 9000 # custom port +./dist/msposd-preflight --no-browser # server only +``` +Ctrl+C (or closing the console) stops it. + +## Ship it +Give users the single binary. On first run it creates its data folder alongside itself; +after they download an area, hand the resulting `maps/.mbtiles` + `landmarks.db` +to the OSD/flight station. + +## Notes +- **Unsigned binaries** trip macOS Gatekeeper / Windows SmartScreen — code-sign (and + notarize on macOS) for public release; fine as-is for internal use. +- Some Windows AV engines false-positive on PyInstaller one-file builds. +- The dev workflow is unaffected: `./map.sh preflight` still runs `mapserver.py` + the + WebKit `mapwin` window. + +See [`../gs/pack/README.md`](../gs/pack/README.md) for how the packaging works internally. diff --git a/documentation/map-modes-and-home-plan.md b/documentation/map-modes-and-home-plan.md new file mode 100644 index 0000000..12a7901 --- /dev/null +++ b/documentation/map-modes-and-home-plan.md @@ -0,0 +1,132 @@ +# Plan — Map Modes, WebKitGTK Renderer, Target & Home Persistence + +Status: **DRAFT — awaiting approval & decisions (§9)**. Phase 1 (Spec). No code yet. + +Builds on [offline-map-overlay-spec.md](offline-map-overlay-spec.md). Goal: turn the +single Firefox window into a lightweight, cross-platform (x86 + Radxa Zero 2W) viewer +with three run modes, click-to-set target, and automatic home capture on ARM. + +## 1. Renderer decision + +Move overlay rendering to **WebKitGTK** via a small `mapwin` host (Python 3 + `gi` + +`WebKit2 4.1`), which is already installed here and available as the same Debian packages +on the Radxa (`gir1.2-webkit2-4.1`, `python3-gi`). Lighter than Chromium, borderless + +always-on-top capable, transparent-background capable (for overlay over video). + +- **Preflight (mode 1)** uses `mapwin` as a decorated/maximized config window. +- **Preview / Full (modes 2/3)** use `mapwin` as borderless overlay windows. + +The same `viewer.html` is loaded in all cases; mode-specific behavior is driven by URL +query params, so there is one UI codebase. + +## 2. New / changed files + +``` +gs/ +├── mapwin # NEW: Python GTK3+WebKit2 host (executable, takes window args) +├── map.sh # NEW: mode launcher wrapper (preflight|preview|full [flags]) +├── mapserver.py # CHANGED: STATUS/armed parsing, home capture, state file, /target +├── web/viewer.html # CHANGED: modes via query params, click-to-target, house icon, fit mode +├── state.ini # NEW (generated): shared target+home (see §6) +└── run-map.sh # kept as a thin alias to `map.sh preflight` +``` + +## 3. `mapwin` (WebKitGTK host) + +A ~80-line GTK3 + WebKit2 window. Arguments: + +| Flag | Meaning | +| ---- | ------- | +| `--url URL` | page to load (required) | +| `--fullscreen` | fullscreen window (mode 3, preflight on Radxa) | +| `--geometry WxH+X+Y` | explicit size/pos (mode 2 default = ¼ screen, bottom-right) | +| `--borderless` | undecorated + keep-above + skip taskbar (modes 2/3 overlay) | +| `--transparent` | RGBA visual + transparent background (future: map over video) | + +mapwin only manages the *window*; all map logic stays in `viewer.html`. + +## 4. Modes & CLI (`map.sh`) + +| Command | Window | Toolbox | Telemetry behavior | +| ------- | ------ | ------- | ------------------ | +| `map.sh preflight` | fullscreen (any browser) | yes | free browse; download; click-to-target | +| `map.sh preview --follow plane` | ¼ screen, borderless, keep-above | no | plane centered, map moves, fixed zoom | +| `map.sh preview --follow fit` | ¼ screen, borderless, keep-above | no | fit home+plane in view (auto zoom/center) | +| `map.sh full --follow plane\|fit` | fullscreen, borderless | no | as above, fullscreen (OSD renders over it) | + +`map.sh` starts `mapserver.py` if not already running, then launches the browser/`mapwin` +with the right URL params and window flags. The "executable that takes arguments" is +`mapwin`; `map.sh` is the convenience wrapper. + +## 5. `viewer.html` behavior by query param + +- `?mode=preflight` → toolbox visible (basemap, download, target fields), **map click sets + target**, full pan/zoom. (current behavior + click-to-target) +- `?mode=preview|full` → toolbox hidden, user interaction locked; layers driven by SSE. + - `&follow=plane` → `map.panTo(plane)` each fix, fixed zoom (config `zoom`). + - `&follow=fit` → `map.fitBounds([home, plane])` each fix → scale & center from the two + coordinates. +- **House icon** for home (from `/status.home`) in every mode once home is known. +- Plane / heading vector / target vector as today. + +## 6. Shared state file — target + home + +Both `mapserver.py` (write) and `msposd.c` (read) need it. **Recommended format: INI**, +because msposd already ships an INI parser (`osd/util/ini_parser.c`, `ReadIniInt`/Float), +so the C side is a few lines and no JSON dependency. (Decision in §9.) + +`gs/state.ini` (path also readable by msposd via config): +```ini +[target] +set = 1 ; 0 = no target +lat = 43.2500 +lon = 27.8500 + +[home] +set = 1 ; 0 = home not yet captured +lat = 43.1430 +lon = 27.9340 +``` +- `mapserver.py` writes `[target]` on user set/clear, `[home]` on ARM capture. +- Written atomically (temp file + rename) so a concurrent C read never sees a half file. +- `msposd.c` integration (reading/drawing target & home) is **out of scope for this plan**; + we only guarantee a C-friendly format and stable path. A follow-up task wires msposd.c. + +## 7. Home capture on ARM (`mapserver.py`) + +- Parse `MSP_CMD_STATUS` (cmd 101; also 150 STATUS_EX): `armed = payload[6] & 0x01`. +- Track armed edge. On **disarmed→armed** with a valid GPS fix, set `home = current pos` + and persist to `[home]`. If armed fires before a fix, capture on the first valid fix + while still armed and home unset this session. +- **On startup, load `[home]` from `state.ini`.** Do not overwrite it except on a fresh + arm edge — so restarting the server mid-flight keeps the correct home (the plane is no + longer at home, but the persisted value is right). +- Expose `home` in `/status` for the house icon. + +## 8. `mapserver.py` endpoint changes + +- `GET /status` → add `home: {lat,lon}|null`, `target: {lat,lon}|null`, `armed: bool`. +- `POST /target {lat,lon|null}` → set/clear target, persist `[target]`. (viewer click + fields) +- Keep `/pos`, `/tiles`, `/download`, `/settings`, basemaps as-is. +- State-file IO helpers (load on boot, atomic write). + +## 9. Decisions (locked — approved 2026-06-22) + +1. **State file format: INI** — `gs/state.ini`, reusing msposd's `ini_parser.c` on the C side. +2. **Phasing: Phase A now** — modes 1+2, click-to-target, ARM home capture. **Mode 3 + (fullscreen + OSD-over-map) deferred to Phase B.** +3. **Renderer: Python GTK3 + WebKit2 `mapwin`.** + +Target storage moves out of `config.ini [map]` into `state.ini [target]`; `config.ini` +keeps zoom/basemap/center. The viewer reads target+home from `/status`, sets target via +`POST /target`. + +## 10. Verify plan (Phase 4) + +- `mapwin`: opens borderless ¼-screen keep-above window at the right geometry (x86 now). +- Home: simulate ARM via an injected MSP_STATUS frame while a fix is present → `state.ini` + `[home]` written; restart server → home reloaded → house icon shown at the same spot. +- Target: click map in preflight → `[target]` written and re-read on reload. +- Modes: `follow=plane` keeps plane centered; `follow=fit` keeps both home and plane in view. +- `sim_msp.py` extended to optionally send ARMED so the whole flow is testable on the desk. +``` diff --git a/documentation/map_control_via_RC.md b/documentation/map_control_via_RC.md new file mode 100644 index 0000000..a679ede --- /dev/null +++ b/documentation/map_control_via_RC.md @@ -0,0 +1,127 @@ +# Map control via RC/MSP channels (Radxa, no keyboard) + +Plan for controlling the native OSD moving-map (zoom, follow mode, visibility) on a +Radxa ground station that has no keyboard — only HW buttons — where `msposd` runs as +a module inside a larger multi-component system. + +**Decision inputs (confirmed):** +- HW buttons are already encoded onto **RC channels present in the MSP/MAVLink stream** + that `msposd` receives. +- Command interface must support **both absolute and relative** semantics. + +## Background: the integration surface today + +**Map control entry points** — currently reachable only via X11 keys in +[`osd/util/Render_gs.c:145-185`](../osd/util/Render_gs.c): +- `map_toggle_enabled()` → show/hide ([`map_render.c:217`](../osd/util/map_render.c)) +- `map_zoom_step(±1)` → zoom ([`map_render.c:230`](../osd/util/map_render.c)) +- `map_follow_cycle()` → mode plane→center→north→fit ([`map_render.c:246`](../osd/util/map_render.c)) + +On the Radxa GS an X server *does* run (`Init()` opens `:0`, +[`Render_gs.c:242`](../osd/util/Render_gs.c)) but there is no keyboard, so the key path +is unreachable. + +**Existing event-ingress mechanisms** (reused by this plan): +- **libevent** main loop over serial/UDP bufferevents ([`msposd.c`](../msposd.c)). +- **RC-channel command dispatch** — `-c/--channels` → `ProcessChannel()` runs + `/usr/bin/channels.sh ` when a channel holds a level + ([`msposd.c:613-663`](../msposd.c)); `handle_stickcommands()` turns stick gestures into + a menu ([`osd/msp/msp_displayport.c:156`](../osd/msp/msp_displayport.c)). This is the + precedent for "external event → action", including a debounce/persist pattern + (`ChannelPersistPeriodMS`, [`msposd.c:603,629-636`](../msposd.c)). + +## Options considered + +| Option | How buttons reach msposd | Pros | Cons | +|---|---|---|---| +| **A. Local control socket** (Unix-domain / UDP 127.0.0.1) | daemon sends `map zoom +` | clean decoupling; language-agnostic; fits libevent; testable with `nc` | new socket surface; ~120 lines | +| **B. Control FIFO / file + inotify** | `echo map.mode=fit > /run/msposd.ctl` | trivial from shell; inotify already in-tree | awkward for event streams; one-writer | +| **C. Reuse RC channels** (in-process) | buttons → RC channel values in MSP stream | **zero new transport** — values already parsed & debounced; no X needed | needs the RC stream (present here) | +| D. Synthesize X keypresses (XTEST) | fake keystrokes into map window | no msposd change | brittle (focus/XTEST), X-coupled, toggles only — rejected | +| E. Signals (SIGUSR1/2) | `kill -USR1` | trivial | far too few distinct actions — rejected | + +**Chosen: Option C** — buttons already ride the RC/MSP stream, so an in-process +RC→map binding layer needs no new transport, no X keyboard, and no socket to secure. + +## Architecture + +``` +MSP/MAVLink stream ──> channels[18] (already parsed in msposd.c) + │ + ▼ + map_rc_update(channels) ← NEW: evaluate bindings each refresh + │ (band change / rising edge, debounced) + ▼ + map_control("mode fit") ← NEW: single dispatcher + ▲ │ + X keys ───────┘ ▼ + (Render_gs.c) map_set_shown / set_follow / set_zoom + map_zoom_step / map_follow_cycle (relative) +``` + +### 1. Unified dispatcher `map_control(const char *cmd)` +In [`osd/util/map_render.c`](../osd/util/map_render.c) — single source of truth for +both absolute and relative commands: + +``` +show 0|1|toggle +mode plane|center|north|fit | next|prev +zoom | + | - +``` + +- Refactor the X handler at [`Render_gs.c:170-184`](../osd/util/Render_gs.c) to call it + (`map_control("zoom +")`, etc.) so keyboard and RC share identical logic. +- Add absolute setters `map_set_shown()`, `map_set_follow()`, `map_set_zoom_level()`. + (Note: the current `map_toggle_enabled()` is really a show/hide *toggle*.) + +### 2. RC binding layer `map_rc_update(uint16_t channels[18])` +Hooked where channels are refreshed (same site as `ProcessChannels()`, +[`msposd.c:665`](../msposd.c)) but **not** gated by `!armed` — the map must be +controllable in flight. Two binding kinds cover absolute + relative: + +- **Position bindings (absolute):** a switch channel's *value bands* map to states — + idempotent; a switch position always means the same thing. +- **Momentary bindings (relative):** a **rising edge** on a button channel issues a + step, debounced with the existing `ChannelPersistPeriodMS` pattern so one press = + one action. + +Recommended default: a **stability window** (~200 ms) on position bindings so a band +must hold before it applies, riding out transitional PWM — reuse `ChannelPersistPeriodMS`. + +### 3. Config — new `[map.rc]` block in [`msposd.ini`](../msposd.ini) +Fully data-driven, so channel numbers are not hard-coded. Wire only the channels you +actually have (e.g. one 3-position switch + one momentary button is a valid minimal +setup). Any key omitted / `0` = that binding is inactive. + +```ini +[map.rc] +; RC channels are 1-based, as in the MSP stream. Values ~1000..2000. +mode_ch = 6 ; switch position -> follow mode (absolute) +mode_bands = plane,center,fit ; N positions split evenly low..high +show_ch = 7 ; 2-pos: low = hide, high = show (absolute) +zoom_ch = 8 ; analog/rotary -> zoom level across MBTiles range (absolute) +zoom_in_ch = 9 ; momentary button, rising edge -> zoom + (relative) +zoom_out_ch = 10 ; momentary button -> zoom - (relative) +mode_next_ch= 11 ; momentary button -> mode next (relative) +``` + +## Why this fits the constraints +- **Reuses the existing event pipeline** (RC channels already parsed & debounced) — no + X keyboard, no extra daemon, no socket surface to secure. +- **Absolute + relative** both first-class: switches give idempotent state, buttons give + steps. +- **Decoupled**: the button/GPIO component only needs to place values on RC channels, + which it already does. +- **One dispatcher** keeps the x86 keyboard workflow and the Radxa RC workflow from + drifting apart. + +## Scope / effort +~150 lines: `map_control` + absolute setters + `map_rc_update` + config parse, plus the +small `Render_gs.c` refactor. No change to the render/draw path. Testable on x86 by +injecting channel values. + +## Open items before implementation +- Confirm the actual channel numbers (or ship with the bindings above as commented, + all-disabled defaults so nothing activates until set). +- Confirm the position-binding stability window default (~200 ms, reusing + `ChannelPersistPeriodMS`). diff --git a/documentation/offline-map-overlay-spec.md b/documentation/offline-map-overlay-spec.md new file mode 100644 index 0000000..06812d0 --- /dev/null +++ b/documentation/offline-map-overlay-spec.md @@ -0,0 +1,328 @@ +# Phase-1 Spec — Offline Moving-Map Overlay for the Ground Station (x86 PoC) + +Status: **IMPLEMENTED.** Phase 1 (this document) — the Python/WebKit ground-station +bridge — ships in `gs/` (`mapserver.py` + `web/` + `mapwin`). Phase 2 — a **native C +moving-map renderer inside `msposd`** that draws directly into the OSD with Cairo, no +browser — has since been built; see **§14**. This document is kept as the design record +for the Python bridge; §14 documents the native in-OSD path (which supersedes the +"native C rewrite deferred to Future" note below). + +Scope of the original Phase 1: x86 proof-of-concept, single Python script, `msposd` +unmodified. That gate has been passed on both x86 and the Radxa ground station. + +## 1. Objective & Scope + +A standalone, offline moving-map aid for the **ground station only**, proven first on +x86/XFCE. It shows aircraft position, course vector, an optional user-set target and +target vector, over offline OSM tiles, at three fixed zoom levels, in a window occupying +~25% of screen, toggled via xbindkeys. + +In scope (PoC): +- Aircraft marker (rotates with heading), auto-centered. +- Course/heading vector (projected ahead). +- User-settable target marker + target vector (aircraft → target). +- 3 fixed zooms (z11 / z13 / z15), app-controlled only (no wheel zoom). +- **Hybrid tiles**: served from the offline MBTiles when cached, else proxied live from + OSM when online — so the user can pan/zoom-browse the world to find their area. +- **Browse-and-download UX**: scroll/zoom to the area, click "Download visible area"; + the current viewport is fetched at z11/13/15 into the MBTiles. Resumable (skips cached + tiles); a live tile-count estimate guards against over-large jobs (`MAX_TILES`). +- Online/offline status indicator. +- Settings persisted to a config file. +- Runs standalone pre-flight (browse / download / review / planning), no video/`msposd`. + +Out of scope (PoC): breadcrumb trail, range rings, multiple waypoints, no-fly zones, +terrain shading, record/playback. North-up only (course-up deferred). MOBAC remains an +alternative way to pre-build the MBTiles, but is no longer required. + +Deferred to **Future** (post-PoC): ARM/Buildroot image work, optional rewrite of the +bridge in C/libevent for footprint. + +## 2. Architecture + +``` +upstream MSP (wfb-ng / SITL) ──> msposd --osd --out 127.0.0.1:14560 [UNCHANGED] + │ UDP MSP + ▼ + ┌──────────────────────── mapserver.py (single Python script) ──────────────────────┐ + │ • UDP listener : parse MSP_RAW_GPS → lat/lon/course; MSP_ATTITUDE → heading │ + │ • GET /pos : Server-Sent Events feed (server → page), 1–5 Hz │ + │ • GET /tiles/{z}/{x}/{y}.png : tile bytes from MBTiles (sqlite3) │ + │ • GET / /viewer.html /leaflet.js /leaflet.css /rotatedmarker.js /plane.svg │ + │ • GET /settings : current target + zoom as JSON │ + │ • POST /settings : set target lat/lon + zoom → persist to config.ini │ + └────────────────────────────────────┬───────────────────────────────────────────────┘ + │ http://127.0.0.1:8088 + ▼ + cog (WPE WebKit) → viewer.html + Leaflet +``` + +Two processes (`mapserver.py`, `cog`), one launch script. `mapserver.py` is a pure MSP +consumer, exactly like `msposd`. Feed it by running the existing ground `msposd` with +`--out 127.0.0.1:14560` (flag already exists — no `msposd` code change). + +Decision: the bridge parses MSP itself (~30 lines) rather than having `msposd` emit JSON. +Rationale — Leaflet (browser) cannot read raw UDP and must fetch tiles + HTML over HTTP +regardless, so a web-facing server process is unavoidable; putting it in `msposd` would +grow `msposd` *and* still require this process. One bridge owning all web-facing work +keeps `msposd` focused. + +## 3. File Structure + +``` +gs/ +├── mapserver.py # single script: UDP MSP parse + HTTP (SSE, tiles, static, settings) +├── web/ +│ ├── viewer.html +│ ├── leaflet.js leaflet.css # vendored (see fetch-leaflet.sh) +│ └── icons/plane.svg +├── maps/ +│ └── area.mbtiles # offline tiles (z11,z13,z15), produced by MOBAC +├── config.ini # runtime settings (created/updated by mapserver.py) +├── run-map.sh # launches mapserver.py + cog +└── README.md +``` + +## 4. mapserver.py — implementation & routes + +Runtime: Python 3 standard library where possible. Recommended deps: `aiohttp` (HTTP + +SSE + async UDP in one loop) **or** stdlib `http.server` + `socketserver` if avoiding +pip; `sqlite3` is stdlib. Pick at Draft; `aiohttp` is the simpler single-loop option. + +Port: `8088` (configurable). Bind `127.0.0.1` only. + +| Method | Route | Response | +| ------ | ----- | -------- | +| GET | `/` , `/viewer.html` | static `web/viewer.html` | +| GET | `/leaflet.js` etc. | static assets from `web/` (whitelist; no path traversal) | +| GET | `/pos` | `text/event-stream`, one SSE event per update | +| GET | `/tiles/{z}/{x}/{y}.png` | `image/png` tile blob, or 204 if absent | +| GET | `/status` | `{online, mbtiles, center, zoom, max_tiles, download}` | +| GET | `/settings` | `application/json` current target+zoom+last center | +| POST | `/settings` | accept JSON, persist, 200 | +| GET | `/download` | `application/json` download job status | +| POST | `/download` | start download `{north,south,east,west}` → `{started,total}` | + +The tile route is a hybrid: MBTiles first, else (zoom in browse range and `/status` +online) a live OSM proxy; otherwise 204. A background probe sets the online flag every +15 s so offline operation never stalls on proxy timeouts. + +SSE event payload (at GPS update rate, capped ~5 Hz): +```json +{"lat":43.2111,"lon":27.9111,"heading":217,"course":214,"fix":1,"sats":11} +``` +`heading` from MSP_ATTITUDE; `course` from MSP_RAW_GPS. Target is **not** in the feed — +the page owns it (set via /settings), so it survives GPS dropouts. + +Static-asset serving: explicit filename whitelist, reject anything containing `..`. + +Concurrency: one async event loop runs the UDP receiver and the HTTP server together; +latest fix is held in a shared variable and fanned out to connected `/pos` SSE clients. + +## 5. MSP parsing (in mapserver.py) + +Reuse the wire layout proven in `osd.c`. Parse only two commands; ignore the rest. + +`MSP_RAW_GPS` (cmd 106) payload: +- `[0]` fixType, `[1]` numSat +- `[2..5]` lat int32, degrees ×1e7 ← osd.c does NOT read this; we do +- `[6..9]` lon int32, degrees ×1e7 ← same +- `[10..11]` alt, `[12..13]` speed +- `[14..15]` groundCourse, decidegrees (÷10) (osd.c:532 reads alt/speed/course only) + +`MSP_ATTITUDE` (cmd 108) payload: `[4..5]` heading (degrees). Matches osd.c:483. + +Frame sync: MSP v1 — `$M` `len` `cmd` `payload` `crc(xor of len..payload)`. Read +full UDP datagrams from `msposd --out` (already aggregated MSP); scan for frames; +validate CRC; drop partials. ~30 lines. + +## 6. MBTiles access (sqlite3, stdlib) + +```sql +SELECT tile_data FROM tiles +WHERE zoom_level = ?z AND tile_column = ?x AND tile_row = ?ymbt; +``` +**Critical:** MBTiles stores rows in TMS (bottom-origin) order; Leaflet/XYZ requests +top-origin `{y}`. Convert before the query: +``` +ymbt = (1 << z) - 1 - y +``` +Read connections open read-only per request; if the MBTiles file does not exist yet +(before any download) tiles return HTTP 204. Missing tile → 204 (Leaflet shows blank, no +error spam). Send `Cache-Control: max-age=604800` so WebKit caches tiles. + +### 6.1 Tile download (pre-flight, online) + +`POST /download {north, south, east, west}` (the viewer sends the current map bounds) +starts a background worker; `GET /download` returns `{state, done, total, failed, msg}` +which the page polls. The worker: +- computes the tile x/y ranges of the bbox for z11/z13/z15 (`bbox_tile_ranges` / + `deg2num`, standard slippy-map); +- refuses jobs over `MAX_TILES` (8000) — guards against a huge area and is polite; +- fetches each missing tile from `tile_url` (default OSM, configurable) with a real + `User-Agent` and a `tile_delay_ms` gap, inserting into the MBTiles (TMS y-flip), + `INSERT OR REPLACE`, committing every 10 tiles — so it is resumable; +- records the area center in `[map] center_lat/lon` so the viewer can center on it for + pre-flight review even with no GPS fix. + +Note: bulk OSM tile fetching is subject to the OSM tile usage policy; keep radii modest +or point `tile_url` at your own/another source for large areas. + +## 7. viewer.html (Leaflet) + +Init: +- `L.map` with `zoomControl:false`, `attributionControl:false`, `scrollWheelZoom:false`, + `doubleClickZoom:false`, `keyboard:false`, fade/zoom animations off. +- `L.tileLayer('/tiles/{z}/{x}/{y}.png', {minZoom:11, maxZoom:15})`. +- Layers created once, then mutated: `plane` (`L.divIcon` holding plane.svg, rotated via + CSS `transform`), `headingLine` (polyline), `targetLine` (polyline), `targetMarker` + (CircleMarker). + +Telemetry: +- `new EventSource('/pos')`; on message → `plane.setLatLng`, `plane.setRotationAngle`, + recompute heading endpoint (~750 m ahead), `map.panTo([lat,lon])`. +- Heading endpoint (flat-earth, fine <1 km): + `lat2 = lat + d·cos(hdg)/111320`, `lon2 = lon + d·sin(hdg)/(111320·cos(lat))`. + +Controls (on-screen buttons; map UX otherwise locked): +- Zoom cycle: `Z1→Z2→Z3→Z1` = setZoom 11/13/15, then `panTo` aircraft. +- Target panel: lat/lon inputs + Set/Clear → `POST /settings`; updates `targetMarker` + and `targetLine`. Loaded from `GET /settings` on page load. + +Locked for PoC: north-up; only the plane icon rotates; target marker visible at all +zooms (CircleMarker, fixed pixel radius). + +## 8. cog / WPE launch (x86 / XFCE) + +Same `viewer.html` will later run on ARM unchanged; PoC targets x86 only. +- x86 XFCE (X11): run `cog` under Xwayland, or use WebKitGTK (`MiniBrowser` / a tiny GTK + host) for convenience. Window sized ~25% of screen. +``` +cog -O fullscreen=false --geometry=640x640 http://127.0.0.1:8088/viewer.html +``` +(Exact geometry / always-on-top flags finalized in Draft.) + +## 9. xbindkeys toggle (x86) + +Bind a key (e.g. `Alt+M`) in `~/.xbindkeysrc` to a helper that **show/hides** the cog +window (keep WebKit warm — instant, near-zero idle at 1–5 Hz) rather than start/stop. +On X11: `xdotool search --class cog windowunmap/windowmap` (or `wmctrl`). + +## 10. Config (config.ini) + +```ini +[server] +port = 8088 +udp_listen = 127.0.0.1:14560 +mbtiles = ./maps/area.mbtiles +web_root = ./web + +[map] +zoom = 13 ; last used (11/13/15) +target_lat = ; empty = no target +target_lon = +``` +`mapserver.py` reads at startup, rewrites `[map]` on `POST /settings`. + +## 11. Run / setup (no build step) + +- `mapserver.py` is interpreted — no compile. `python3 gs/mapserver.py`. +- Deps: Python 3, `sqlite3` (stdlib), optionally `aiohttp` (`pip install aiohttp`). +- Runtime deps: `cog`/WPE (or WebKitGTK) and `xbindkeys`/`xdotool` on the x86 box. +- `run-map.sh` starts `mapserver.py` then `cog`. + +## 12. Verify (Phase 4 plan) + +1. **mapserver.py:** feed a recorded MSP UDP capture (SITL path in + `HowToSimulateInFlightMSPData.txt`) → confirm `/pos` SSE emits correct moving + lat/lon/heading. +2. **Tiles:** `curl /tiles/13/x/y.png` returns bytes; verify a known tile renders + right-side-up (TMS flip correct) in the browser. +3. **End-to-end x86:** run `msposd ... --out 127.0.0.1:14560`, `mapserver.py`, `cog`; + drive SITL; confirm marker moves, heading vector follows course, target vector + tracks a set target, zoom cycles, toggle hides/shows. +4. **Standalone pre-flight:** run `mapserver.py`+`cog` with no `msposd`; confirm map + browses and target can be set. + +## 13. Decisions (locked — approved 2026-06-22) + +1. Ports: HTTP `8088`, UDP listen `127.0.0.1:14560`. +2. Tile format: **MBTiles** (single SQLite file). +3. **Python stdlib only** — no `aiohttp`. `ThreadingHTTPServer` + a UDP listener + thread + per-request SQLite. Runs with zero `pip` install. +4. Toggle: `Alt+M` via xbindkeys, **show/hide** the cog window (keep WebKit warm). +5. Heading vector: **fixed 750 m**. +6. Plane rotation: **CSS-rotated `L.divIcon`**, not the Leaflet.RotatedMarker plugin + (one fewer offline asset to vendor). + +## 14. Phase 2 — Native OSD moving-map renderer (`osd/util/map_render.c`) + +Status: **implemented** for ground-station builds (`_x86` and `__ROCKCHIP__`). Unlike the +Phase-1 Python/WebKit bridge, this renderer runs **inside `msposd`** and draws the map +directly into the OSD canvas with Cairo — no browser, no second process. It reads the +**same MBTiles** the preflight tool downloads (read-only), plus `gs/state.ini` (home) and +the landmarks DB (target). It is `#include`d into `osd.c` and composited each frame from +the GS render path. Master switch `[map] enabled=1` in `msposd.ini`; fully inert when `0`. + +### 14.1 Config — `[map]` in `msposd.ini` + +| Key | Meaning | +| --- | ------- | +| `enabled` | 0/1 master switch (also gates the hotkeys). | +| `mbtiles` | path to the offline MBTiles (resolved next to the binary if relative). | +| `zoom` | requested zoom, snapped to a level stored in the MBTiles. | +| `follow` | `plane` / `north` / `center` / `fit` (see §14.2). | +| `lead`, `plane_y` | plane lead offset (plane mode) / plane vertical position (center mode). | +| `layout` | `corner` (a window) or `full` (whole OSD). | +| `geometry` | `WxH+X+Y` absolute px — overrides the anchor scheme. | +| `corner`,`width`,`height`,`margin` | anchor scheme: named corner + size/margin, each **px or %** of the overlay. | +| `opacity` | 0..100 %. | +| `scalebar` | distance scale bar on/off. | +| `frame` | rounded window frame on/off (§14.4). | +| `on_top` | map over the OSD glyphs (1) or beneath them (0). | +| `recenter_px`,`heading_gate_deg` | re-render thresholds (plane drift / course turn) to keep CPU low. | + +### 14.2 Follow modes +- **plane** — north-up; plane offset opposite its course so more map shows ahead. +- **north** — north-up, plane centred. +- **center** — track-up (map rotates on course), plane pinned near the bottom edge. +- **fit** — north-up; zoom+centre to hold **all of plane, home and target** in view + (bounding box of whatever ≥2 of those points exist; falls back to fixed zoom for one). + +### 14.3 Markers & scale +Plane triangle (points along heading, or up in track-up mode), **home** green house, +**target** two concentric rings — **light-red outer + red inner** — and an unrotating +distance scale bar. Tiles are held static and only the plane marker moves between +re-renders. + +### 14.4 Window frame +In `corner`/`geometry` layout the tiles are clipped to a rounded rectangle and framed +with a soft outer halo, a 2 px semi-transparent border and a 1 px inner bevel, so the map +reads as a floating window. Screen-space (never rotates in track-up). Skipped for `full`. + +### 14.5 Status / problem messages +When the map can't render, the window still draws with a dark panel + framed border and a +centred message so the cause is visible instead of a blank/absent overlay: +- **No offline map file** / `` — MBTiles missing (open is retried on a 3 s backoff, + so a file that appears after a download recovers on its own — no longer disables the map). +- **Map file has no tiles** / `` — empty MBTiles. +- **Cannot open map** / `` — corrupt/locked/permissions. +- **No offline map here** / `GPS , ` — position outside the downloaded coverage. +- **Waiting for GPS fix…** — no plane position or home yet. + +### 14.6 Home & target sources +**Home** is read from `gs/state.ini`, resolved **next to the `msposd` binary** (not the +CWD) so it matches mapserver's `APP_DIR/state.ini` regardless of launch directory. +**Target** comes from the landmarks DB via `poi_get_target()`. Both refresh at ~1 Hz and +trigger a recompose when they change. + +### 14.7 Controls +- **x86 keyboard** (active only while `enabled=1`): `g` show/hide, `+`/`-` zoom, + `f` cycle follow mode. +- **Radxa (HW buttons, no keyboard):** planned via the RC/MSP channel stream feeding a + `map_control()` dispatcher — see [`map_control_via_RC.md`](map_control_via_RC.md). + +### 14.8 Constraint — PNG tiles only +The native path decodes tiles with Cairo's PNG reader, so the OSD overlay needs **PNG** +MBTiles. JPEG basemaps (e.g. Esri imagery) preview in the WebKit map but do not render +natively until a JPEG decoder is added. See `gs/README.md` for the per-basemap format. diff --git a/documentation/poi-osd-direction-plan.md b/documentation/poi-osd-direction-plan.md new file mode 100644 index 0000000..efd89c4 --- /dev/null +++ b/documentation/poi-osd-direction-plan.md @@ -0,0 +1,173 @@ +# Plan — POI Direction Markers on the OSD + +Status: **IMPLEMENTED** (2026-06-25). Native (x86) build verified. One simplification +vs the spec: the planned `poi_osd.h` was folded into `poi_osd.c` (single `#include`d +TU, matching `Render_gs.c`), so no separate header exists. + +Goal: draw nearby map landmarks (POIs) as small circles + name labels on the OSD, +projected to their real on-screen direction from the aircraft, so the pilot sees +"that city is over there." First iteration: POIs of `kind='place'` only. + +## 1. Where it runs + +Ground Side (GS) rendering only: `#if defined(_x86) || defined(__ROCKCHIP__)`. +That build links Cairo and runs on the same machine as `gs/`, so it has the +landmarks DB and the `drawCircleGS` / `drawText` primitives in +[osd/util/Render_gs.c](../osd/util/Render_gs.c). On the camera builds +(Sigmastar/Goke, busybox OpenIPC) the feature compiles out entirely. + +**No SQLite dependency on the camera/OpenIPC firmware.** Two independent guards: +- The `#include "osd/util/poi_osd.c"` in `osd.c` is itself inside the GS `#if`, + so `sqlite3.h` is never parsed on a camera build (no header, no symbols). +- `-lsqlite3` is added only to the `native` and `rockchip` link lines; the + `goke`/`hisi`/`star6*` targets never link it. +`rockchip` is the Radxa ground-side board (per the map plan), not a camera — so +both SQLite-linked targets are genuinely ground side. + +## 2. Files + +``` +osd/util/poi_osd.c # NEW: DB load + per-frame projection + draw (all logic here) +osd/util/poi_osd.h # NEW: DrawPOIs() declaration + POI struct +osd.c # CHANGED: extract plane lat/lon; #include poi_osd.c; one DrawPOIs() call +Makefile # CHANGED: add -lsqlite3 to `native` and `rockchip` targets +gs/.../config # CHANGED: enable flag + db path (reuse existing ini parser) +``` + +`poi_osd.c` is `#include`d from `osd.c` under the GS guard, matching the repo's +existing single-translation-unit pattern (`msposd.c` includes `osd.c` includes +`Render_gs.c`). No SRCS/object reshuffle. + +## 3. Data source + +`gs/maps/landmarks.db`, table `landmarks`. **Bounding-box query around the plane, +refreshed on movement** (not a full-table load): re-query only when the plane has +moved more than ~¼ of the range since the last load. + +```sql +SELECT name, name_en, lat, lon FROM landmarks +WHERE kind='place' + AND lat BETWEEN :s AND :n -- lat/lon stored as degrees + AND lon BETWEEN :w AND :e; -- bbox = plane ± range, in degrees +``` +where `dLat = range_m/111320`, `dLon = range_m/(111320*cos(lat))`. + +The returned candidates (a small set) are cached in a static array and scanned +each frame for the exact ±45° / ≤range filter — so there is **no per-frame SQL**, +the render loop stays allocation-free at 5–10 Hz, and DB hits happen only on +meaningful movement. If the file is missing/locked, log once and disable for the +session. + +**Indexing:** a normal index on `lat,lon` (or `kind,lat,lon`) makes the bbox query +cheap and needs no schema change. For a future all-kinds expansion (tens of +thousands of rows) the same WHERE clause can be swapped for an R-Tree virtual +table populated by the Python writer (`store_landmarks`); R-Tree filters by bbox, +so C would still refine to the exact sector. Deferred — `place` is too small to +need it. See §7.5. + +## 4. Telemetry needed (and the one gap) + +Already in [osd.c](../osd.c#L133): `last_heading`, `last_altitude`, `pitch_degree`, +`OVERLAY_WIDTH/HEIGHT`, and the AHI focal length `f` / `pos_y` (boresight screen-y). + +**Missing:** plane lat/lon. `MSP_RAW_GPS` ([osd.c:532](../osd.c#L532)) currently +reads only course/alt/speed. Add (3 lines + 2 statics): + +```c +last_lat = *(int32_t *)&msp_message->payload[2]; // deg * 1e7 +last_lon = *(int32_t *)&msp_message->payload[6]; // deg * 1e7 +``` + +## 5. Per-frame algorithm (`DrawPOIs`) + +For each cached POI: + +1. **Distance + bearing** from plane (lat/lon ÷ 1e7) via haversine + initial bearing. +2. **Filters:** keep if `distance ≤ RANGE_M` (default 10 000 m) **and** + `|normalize(bearing − last_heading)| ≤ 45°`. +3. **Horizontal projection** (full-screen width, anamorphic-aware): + - `HFOV_deg = vFOV_deg * 4/3` (existing convention for 4:3 sensor → 16:9 video). + - `f_h = (OVERLAY_WIDTH/2) / tan(HFOV_deg/2)`. + - `az = normalize(bearing − last_heading)`. + - `x = OVERLAY_WIDTH/2 + f_h * tan(az)`, clamp to `[0, OVERLAY_WIDTH]`. +4. **Vertical projection** (tied to the AHI horizon model, decision §7): + - Elevation vs horizontal: `el = atan2(ref_alt − plane_alt, distance)`. + - Approximation: POI at home altitude, `last_altitude` = height above home ⇒ + `ref_alt − plane_alt = −last_altitude`. + - Screen angle from boresight: `ang = el − pitch_degree`. + - `y = pos_y − f * tan(ang)` — identical mapping to the pitch-ladder lines at + [osd.c:1030](../osd.c#L1030), so markers sit consistently with the drawn horizon. + - Skip if `x`/`y` fall outside the overlay. +5. **Draw:** `drawCircleGS(x, y, 6, COLOR_WHITE, 2, false)` then + `drawText(label, x + 9, y − 6, COLOR_WHITE, font, false, 1, 0)`. + `label = name_en` if non-empty else `name` (decision §7). + +`Transpose=false`: POIs are projected in absolute screen coords already; we do not +want the additional pitch/roll transform that `drawCircleGS(Transpose=true)` applies. + +## 6. Call site & signature + +One call in the GS-guarded AHI block, near the course-vector draw +([osd.c:1277](../osd.c#L1277)), where `f`, `pos_y`, `pitch_degree`, `vFOV_deg`, +`last_heading` are in scope: + +```c +#if defined(_x86) || defined(__ROCKCHIP__) + if (POI_Enabled) + DrawPOIs(last_lat, last_lon, last_altitude, last_heading, + pitch_degree, pos_y, f, vFOV_deg); +#endif +``` + +## 7. Decisions (locked with user, 2026-06-25) + +1. **Vertical placement: elevation vs AHI horizon** — accurate; reuses `f`, + `pitch_degree`, `pos_y`. (Rejected: fixed band near horizon — simpler but drifts.) +2. **Label: `name_en`, fall back to `name`** — prefer Latin/English, local name if empty. +3. **Bbox query refreshed on movement, candidates cached, no per-frame SQL** — + re-query only after the plane moves ~¼ range; per-frame work is an in-memory + scan of the small candidate set. Scales to all-POI later by swapping the WHERE. +4. **New `.c` `#include`d, not a separate object** — matches existing single-TU pattern. +5. **Altitude approximation** — all `place` POIs at home altitude; `last_altitude` + treated as height-above-home (relative-alt FCs). User-approved simplification. +6. **SQLite is x86/rockchip-only** — guarded include + per-target link; busybox + camera/OpenIPC builds have no SQLite dependency. (§1) + +### 7.5 Deferred: R-Tree +R-Tree is the right index once we process all POI kinds (large N). It requires the +Python writer to create/populate an `rtree` virtual table alongside `landmarks`. +The C side already queries by bbox, so adopting it later is a WHERE-clause swap. +Not done now: `place` is a few hundred rows, where a plain `lat,lon` index wins. + +## 8. Config (reuse existing ini parser: `osd/util/ini_parser.c` / `simple_ini.c`) + +| Key | Default | Meaning | +| --- | --- | --- | +| `poi.enabled` | auto | optional master override; when absent, POIs start on only if `db_path` exists and contains landmarks | +| `poi.db_path` | `gs/maps/landmarks.db` | landmarks DB location | +| `poi.range_m` | `10000` | max distance (may later scale with altitude) | +| `poi.fov_deg` | `45` | half-angle filter around heading | + +## 9. Build / dependencies + +- Add `-lsqlite3` to the `native` ([Makefile:63](../Makefile#L63)) and `rockchip` + ([Makefile:70](../Makefile#L70)) link lines. Requires `libsqlite3-dev` at build time + and `libsqlite3` at runtime (already present transitively via system SQLite). +- No change to camera targets (feature compiled out). + +## 10. Verify plan (Phase 4) + +- Build `native`; run with `sim_msp.py` feeding a known lat/lon/heading/altitude. +- Place a synthetic `place` row at a known bearing/distance in the DB; confirm the + marker lands at the predicted screen x (and y for a chosen altitude/pitch). +- Heading sweep: marker enters at ±45°, exits beyond it; range cut at 10 km. +- Altitude/pitch sanity: increasing `last_altitude` raises markers toward horizon; + pitching the nose down lowers them, consistent with the AHI ladder. +- Missing DB: feature disables cleanly, OSD otherwise unaffected. + +## 11. Out of scope (later) + +- Per-POI elevation from the DB `ele` column (currently approximated to home alt). +- Non-`place` kinds (peaks, water, etc.) and clutter management / nearest-N capping. +- Range auto-scaling with altitude. +``` diff --git a/documentation/poi-type-selection-plan.md b/documentation/poi-type-selection-plan.md new file mode 100644 index 0000000..ee6dd74 --- /dev/null +++ b/documentation/poi-type-selection-plan.md @@ -0,0 +1,147 @@ +# Plan — POI Type/Subtype Selection (preflight tree → OSD filter) + +Status: **IMPLEMENTED** (2026-06-26). Server + viewer + OSD done; native build and +end-to-end DB flow (seed → /poi-types → /poi-selection → OSD JOIN) verified. The +landmarks `kind` column is noisy (88 kinds incl. `bus`/`bench`/`addr:city`); the +tree renders the raw GROUP BY faithfully, collapsed-by-default and scrollable. + +Goal: in `./map.sh preflight`, show a checkbox tree of the landmark `kind`/`subtype` +groups (with counts) at the **bottom** of the map. The user checks which types to +draw on the OSD. The selection is **persisted in landmarks.db** and reloaded into +the tree on the next map load, and the OSD ([poi_osd.c](../osd/util/poi_osd.c)) +draws only the selected `kind`/`subtype` pairs. + +Builds on [poi-osd-direction-plan.md](poi-osd-direction-plan.md). + +## 1. Data model — new table `poi_selection` + +In `gs/maps/landmarks.db`, created by `open_landmarks_db()` alongside `landmarks`: + +```sql +CREATE TABLE IF NOT EXISTS poi_selection( + kind TEXT NOT NULL, + subtype TEXT NOT NULL, -- '' when the landmark has no subtype + enabled INTEGER NOT NULL, -- 0/1 + PRIMARY KEY(kind, subtype) +); +``` + +`subtype` is normalized to `''` (never NULL) so the primary key and the OSD join +are simple. A row exists per (kind, subtype) the user has touched; absence = unset. + +### Default (decision: place-only) +On first use (table empty), the server **seeds** `enabled=1` for every (kind, +subtype) where `kind='place'` and `enabled=0` for the rest — preserving today's OSD +behavior until the user changes it. Seeding happens lazily when `/poi-types` is +first served and the table has no rows. + +## 2. Server — `gs/mapserver.py` + +### Schema +- `open_landmarks_db()`: add the `CREATE TABLE IF NOT EXISTS poi_selection(...)`. + +### `GET /poi-types` +Returns the grouped tree joined with the saved selection: + +```sql +SELECT l.kind, + COALESCE(NULLIF(l.subtype,''),'') AS subtype, + COUNT(*) AS cnt, + COALESCE(s.enabled,0) AS enabled +FROM landmarks l +LEFT JOIN poi_selection s + ON s.kind = l.kind AND s.subtype = COALESCE(NULLIF(l.subtype,''),'') +GROUP BY l.kind, subtype +ORDER BY l.kind, subtype; +``` +(Count = total rows, decision.) If the table is empty, seed place-only first (§1), +then run the query. Response: `[{kind, subtype, count, enabled}]`. + +### `POST /poi-selection` +Body: `{"selection":[{"kind","subtype","enabled"}...]}`. Upsert each row +(`INSERT ... ON CONFLICT(kind,subtype) DO UPDATE SET enabled=...`). Guarded by +`landmarks_db_lock`. Returns `{}`. + +### Routes +- `do_GET`: add `/poi-types`. +- `do_POST`: add `/poi-selection`. +- Reuse `_json_body()`, `_send()`, `open_landmarks_db()`, `landmarks_db_lock`. + +## 3. UI — `gs/web/viewer.html` (preflight only) + +New **bottom panel** `#poipanel` (hidden in overlay modes, like `#panel`), a +scrollable checkbox tree: + +``` +[POI types to show on OSD] +☑ place (97) ← kind header: tri-state, toggles all children + ☑ city (3) + ☑ town (12) + ☑ village (70) + ... +☐ natural (40) + ☐ peak (8) + ☐ water (32) +☐ ... +``` + +- Dependency-free: build nested `
`/`