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..4b70539 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ CFLAGS ?= CFLAGS += -Wno-address-of-packed-member -DVERSION_STRING="\"$(VERSION_STRING)\"" SRCS := compat.c msposd.c bmp/bitmap.c bmp/region.c bmp/lib/schrift.c bmp/text.c osd/net/network.c osd/msp/msp.c osd/msp/msp_displayport.c libpng/lodepng.c osd/util/interface.c osd/util/settings.c osd/util/ini_parser.c osd/msp/vtxmenu.c osd/util/subtitle.c osd/util/simple_ini.c +GS_SRCS := osd/util/terrain_elevation.c osd/util/terrain_agl.c OUTPUT ?= $(PWD) BUILD = $(CC) $(SRCS) -I $(SDK)/include -I$(TOOLCHAIN)/usr/include -I$(PWD) -L$(DRV) $(CFLAGS) $(LIB) -levent_core -Os -s $(CFLAGS) -o $(OUTPUT) @@ -60,13 +61,31 @@ 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 BUILD = $(CC) $(SRCS) -I $(SDK)/include -L $(DRV) $(CFLAGS) $(LIB) -levent_core -O0 -g -o $(OUTPUT)) + $(eval LIB = -lcsfml-graphics -lcsfml-window -lcsfml-system `pkg-config --libs cairo x11 xext` -lXext -lm -lsqlite3) + $(eval BUILD = $(CC) $(SRCS) $(GS_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 BUILD = $(CC) $(SRCS) -I $(SDK)/include -L $(DRV) $(CFLAGS) $(LIB) -levent_core -O0 -g -o $(OUTPUT)) + $(eval LIB = `pkg-config --libs cairo x11 xext` -lXext -lm -lrt -lsqlite3) + $(eval BUILD = $(CC) $(SRCS) $(GS_SRCS) -I $(SDK)/include -L $(DRV) $(CFLAGS) $(LIB) -levent_core -O0 -g -o $(OUTPUT)) $(BUILD) + +test-terrain-elevation: + $(CC) tests/terrain_elevation_test.c osd/util/terrain_elevation.c -I$(PWD) \ + -DTERRAIN_ELEVATION_DB_PATH='"/tmp/msposd-terrain-elevation-test.db"' \ + -Wall -Wextra -Werror -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer -no-pie \ + -o /tmp/msposd-terrain-elevation-test -lsqlite3 -lm + ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:handle_segv=0 \ + /tmp/msposd-terrain-elevation-test + +test-terrain-agl: + $(CC) tests/terrain_agl_test.c osd/util/terrain_agl.c osd/util/terrain_elevation.c \ + -I$(PWD) -DTERRAIN_ELEVATION_DB_PATH='"/tmp/msposd-terrain-agl-test.db"' \ + -Wall -Wextra -Werror -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer -no-pie \ + -o /tmp/msposd-terrain-agl-test -lsqlite3 -lm + ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:handle_segv=0 \ + /tmp/msposd-terrain-agl-test + +test-terrain: test-terrain-elevation test-terrain-agl diff --git a/README.md b/README.md index f67f487..97076d8 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ This keywords can be added using the PilotName/CraftName fields(INAV/BF/Ardu), o - ```!TMP!``` Camera board temp - ```!TMW!``` WiFi module temperature (only 8812EU2/8733BU chipsets supported) - ```!RC!``` Sticks Position widget (Ground Side only) + - ```!AGL!``` GPS-calibrated height above terrain in metres (Ground Side only); + displays ```----m``` while unavailable ### Usage Example: 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..676c1b8 --- /dev/null +++ b/documentation/build-preflight-release.md @@ -0,0 +1,84 @@ +# 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 `config.ini`, `state.ini` and `maps/` are created next to the +executable on first run. Map tiles, shared landmarks and shared elevation data live +inside `maps/`. + +## 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. + +PyInstaller bundles a bootloader for the host OS; it does not cross-compile. Running +`build.sh` on Linux or WSL produces a Linux ELF executable. Copying or renaming that +file to `.exe` will produce Windows' “not a valid app” error. Use native Windows with +`build.bat`, or use the Windows artifact from CI. + +## Build (current OS) +```bash +./gs/pack/build.sh # Linux / macOS +gs\pack\build.bat # Windows +``` +Output: **`dist/msposd-preflight`** (`.exe` on Windows) — one file. + +On Linux, verify the artifact type before publishing it: + +```bash +file dist/msposd-preflight +# ... ELF 64-bit ... +``` + +## 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. + +## Install elsewhere on Linux + +No installation step is required. The binary can be copied to another writable folder +on the same Linux system: + +```bash +mkdir -p ~/Apps/msposd-preflight +cp dist/msposd-preflight ~/Apps/msposd-preflight/ +chmod +x ~/Apps/msposd-preflight/msposd-preflight +~/Apps/msposd-preflight/msposd-preflight +``` + +Its new containing directory becomes the writable application directory. Copy the old +`config.ini`, `state.ini` and `maps/` into that directory to preserve settings, map +packs, landmarks and elevation data. Otherwise it starts with defaults and creates data +as needed. Only one server can own the default HTTP port; stop the old copy or start the +new one with `--port 9000`. + +## Ship it +Give users the single binary. On first run it creates its data folder alongside itself; +each area download creates a new `maps/.mbtiles` without modifying older packs. +Select the desired entry under **Downloaded maps**, then use **Export selected map…** to +create a zip containing that pack plus `landmarks.db` and `elevation.db` for 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. +- For distribution across different Linux releases, build on an appropriately old + compatible Linux baseline; copying within the build machine itself is safe. +- The dev launcher opens preflight in the system browser by default. Use + `./map.sh preflight --GTK` for the compatibility WebKit `mapwin` window. + +See [`../gs/pack/README.md`](../gs/pack/README.md) for how the packaging works internally. diff --git a/documentation/gs-map-internals.md b/documentation/gs-map-internals.md new file mode 100644 index 0000000..5b6282d --- /dev/null +++ b/documentation/gs-map-internals.md @@ -0,0 +1,496 @@ +# GS offline map — technical reference + +Implementation detail for the ground-station map: storage formats, HTTP surface, +configuration keys and the C-side integration. For what the tool *does* and how to +use it, see [`../gs/README.md`](../gs/README.md). For the original design record see +[`offline-map-overlay-spec.md`](offline-map-overlay-spec.md). + +Two programs share this data: + +| | preflight (`gs/`) | in flight (`msposd`) | +| --- | --- | --- | +| language | Python 3, stdlib only | C | +| writes | `maps/*.mbtiles`, `maps/landmarks.db`, `maps/elevation.db`, `config.ini` | `state.ini [home]` | +| reads | all of the above | the packs + `landmarks.db` + `elevation.db`, read-only | + +--- + +## 1. Storage + +### 1.1 Tile packs — `maps/.mbtiles` + +Standard MBTiles: `tiles(zoom_level, tile_column, tile_row, tile_data)` with a unique +index on the triple, plus a `metadata(name, value)` table. Rows are TMS (`tile_row` +counts from the south), so both readers convert: `tms_row = (2^z - 1) - y`. + +**Stored zoom levels** are three, derived from the user's *detail zoom* `d`: + +``` +zooms = [d - 4, d - 2, d] d ∈ [12, 18], default 15 +``` + +Positional, not fixed numbers — changing `d` moves all three. The C renderer never +assumes these values; it discovers what a pack holds with +`SELECT DISTINCT zoom_level FROM tiles ORDER BY zoom_level`. + +**Pack identity** is allocated once for every download. The optional **Map name** is +normalised to a portable ASCII filename stem; when it is empty, the distinct per-zoom +sources are joined coarse→detail to form the stem. `mbtiles_for()` maps that id to +`maps/.mbtiles`. + +The server reserves the file with exclusive creation before starting its worker. If the +plain filename exists, it tries `_YYYYMMDD_HHMMSS.mbtiles` (UTC), followed by +`_` only if another request already claimed that timestamp. Consequently a download +never opens an existing pack for writing. Windows device names such as `CON` are also +prefixed so exported packs remain portable. + +A blended download records what it built in the metadata table: + +``` +sources = 13:OpenTopoMap,15:OpenTopoMap,17:Satellite +``` + +### 1.2 Points — `maps/landmarks.db` + +| table | contents | written by | read by | +| --- | --- | --- | --- | +| `landmarks` | Overpass-fetched named features | preflight download | `poi_osd.c` | +| `poi_selection` | which POI kinds/subtypes are enabled | preflight panel | `poi_osd.c` | +| `waypoints` | user-authored named points | preflight panel | `poi_osd.c` | + +```sql +CREATE TABLE waypoints(kind TEXT PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, name TEXT) +``` + +Waypoint slots use `kind` values `target`, `target2` … `target5`. **Slot 1 keeps the +historical `kind='target'`**, so a pack authored by an older preflight still shows its +point, and a ground station that only knows about one target still finds it. Clearing +a slot deletes its row rather than blanking it. + +`name` is capped at **31 bytes** (not characters) and trimmed on a UTF-8 character +boundary by `clip_name()`, because the C side reads it into `char[32]` where +`snprintf` truncates by byte — a Cyrillic name is ~2 bytes per character and a naive +character-slice would hand the renderer invalid UTF-8. + +### 1.3 Terrain elevation — `maps/elevation.db` + +Height above sea level for the downloaded area, as decoded metre grids rather than +images. Its own file because terrain does not depend on the basemap, so one DEM serves +every pack. Written when **Download elevation data** is ticked (`[map] elevation`, +default on). Ground builds of `msposd` can query it through +`terrain_elevation_at(lat, lon, &elevation_m)`. `terrain_agl.c` consumes that lookup and +`MSP_RAW_GPS` to calculate numeric AGL for the placeholder and moving-map displays. + +```sql +CREATE TABLE meta(name TEXT PRIMARY KEY, value TEXT); +CREATE TABLE elevation( + zoom INTEGER, tile_x INTEGER, tile_y INTEGER, -- XYZ, NOT TMS + width INTEGER, height INTEGER, + min_m INTEGER, max_m INTEGER, -- range without decoding the blob + data BLOB, -- width*height int16 LE, row 0 = north + PRIMARY KEY(zoom, tile_x, tile_y)); +``` + +**`tile_x`/`tile_y` are XYZ, not the TMS row order the sibling `.mbtiles` files use.** +Two files in the same folder therefore index rows oppositely; reading this table with +MBTiles' flipped `y` mirrors the terrain north–south, which looks plausible and is +wrong. The convention is also recorded in `meta` (`tile_scheme`, `row_order`). + +`meta` is written on every open and makes the file self-describing without this +document: `zoom`, `encoding` (`int16_le`), `compression` (`none`), `nodata` (`-32768`), +`tile_scheme`, `row_order`, `units`, `vertical_datum`, `source`. + +Stored uncompressed so any reader can `memcpy`/`frombuffer` it directly. zlib would save +only 1.5–4× on real terrain (measured: 4.1× coastal, 1.8× Rila, 1.5× Mont Blanc) and +`compression` in `meta` leaves room to change that without breaking readers. + +**Source and resolution.** AWS Open Data "terrarium" XYZ PNG tiles, +`elev = (R*256 + G + B/256) - 32768`. Fixed at **z12** — ~28 m/pixel at 43°N, matching +the ~30 m native posting of the SRTM-derived data. Higher zooms only interpolate: +measured on three summits, z10 through z15 return the same metre values, so storing +above z12 costs 4× per level for no information. + +**Size and the cap.** Every z12 tile is a fixed 256×256 int16 = **128 KiB**, independent +of terrain. A typical 27×14 km area is 8 tiles ≈ 1 MB, but the same bbox at detail z12 +would be ~11250 tiles ≈ 1.5 GB — so `MAX_DEM_TILES` (1000, ≈228×228 km / 125 MB) skips the +DEM with a message rather than silently producing that. The DEM covers exactly the tile +download's bbox and does not count against `MAX_TILES`. + +**Lookup.** `elevation_at(lat, lon)` bilinearly interpolates the 4 nearest samples +(nearest-sample gives ~28 m stair-steps). Returns `None` — never 0 — outside stored +coverage, so "no data" stays distinguishable from a real sea-level reading. Also exposed +as `GET /elevation?lat=&lon=` and `tiles_info.py --lat --lon`. + +The C equivalent is `terrain_elevation_at(double lat, double lon, double *elevation_m)` +in `osd/util/terrain_elevation.c`. It implements the same half-pixel alignment, +bilinear interpolation and edge fallback as Python. It opens the database read-only for +each call and returns `false` for missing coverage, unsupported metadata, malformed tile +data, invalid coordinates or SQLite errors. Failed calls leave the output value unchanged. +Tile dimensions, blob type and exact blob length are validated before sample access, and +global pixel arithmetic is 64-bit to remain defined even with hostile metadata. The source +is linked only into native and Rockchip builds, so Air Unit builds do not gain an SQLite +dependency. + +**AGL calculation.** `terrain_agl.c` stores one offset at a witnessed +disarmed→armed transition: + +``` +altitude_offset = gps_altitude_at_home - terrain_at_home +raw_AGL = current_gps_altitude - terrain_at_current_position +calibrated_AGL = raw_AGL - altitude_offset +``` + +It uses the latest single `MSP_RAW_GPS` sample without averaging. Calibration requires a +fresh valid fix and terrain coverage at home; a status-before-GPS ordering difference is +tolerated for two seconds. Current terrain is refreshed with each valid GPS message, and +the result becomes unavailable after three seconds without a valid update. The state is +process-local and is not written to `state.ini`. Disarming preserves a successfully +calibrated offset, and subsequent GPS updates continue calculating AGL regardless of the +current armed state. Only a witnessed disarmed→armed transition can replace the offset; +a failed recalibration does not erase an existing offset, and a disarmed GPS sample alone +never calibrates it. Before calibration—including startup while already airborne—the +displayed value is the direct GPS-altitude-minus-terrain difference; after calibration the +offset is applied automatically. At ground-side startup, +`terrain_elevation_available()` enables the logic only when `elevation.db` has the +supported schema and at least one terrain tile. This is independent of both the map +configuration and `!AGL!`, ensuring the arming calibration is captured before either +display is used. The five-character placeholder is replaced in place with rounded metres +(for example, ` 42m` or ` -15m`), or `----m` while unavailable. A visible moving map +also draws `AGL m` at its bottom centre from the same cached result. + +**Read paths take no `elevation_db_lock`.** That lock serialises the writer, and +`download_elevation()` holds it across its whole tile loop — so a reader that took it +would stall for the entire download. The preflight panel's pointer readout polls +`/elevation` continuously, straight through downloads, so `elevation_at()` and +`elevation_summary()` open `mode=ro` connections and rely on SQLite's own locking plus +`busy_timeout`; both already degrade to `None`/empty on `sqlite3.Error`. The lock remains +for the writer and for `serve_export()`, which needs a consistent snapshot. Measured: 55 +pointer lookups served during an 8.6 s download, worst case 80 ms. + +The readout polls at 150 ms and skips movements under ~11 m — finer than the 28 m grid, +so the interpolated value cannot have changed meaningfully. Typical lookup is 1.6 ms. + +> **Datum.** These are metres above the **geoid** (EGM96/MSL). Raw GNSS height is above +> the WGS84 **ellipsoid**, ~35 m higher in Bulgaria. Directly subtracting terrain MSL +> from ellipsoidal GPS altitude would therefore be wrong by tens of metres. The home +> `altitude_offset` calibration above cancels that initial constant datum difference; +> subsequent GPS vertical drift remains. + +What the data is worth, how it was verified, and the home-calibrated AGL implementation are in +[`terrain-elevation.md`](terrain-elevation.md). + +### 1.4 Runtime state — `state.ini` + +Holds `[home]` only: captured live on the station at the disarmed→armed transition, so +it is station-local rather than part of the pack. Written atomically (temp file + +`os.replace`) so a concurrent C-side read never sees a half-written file. A legacy +`[target]` section is honoured once and migrated into `waypoints`. + +**Consequence:** the preflight→flight handoff is DB-only. Tiles and points travel; +only `[home]` stays behind. + +--- + +## 2. HTTP surface (`mapserver.py`) + +Served on `127.0.0.1:` (default 8088, `[server] port`). Single-instance by port: +a second launch that finds a *responding* server just reopens the browser and exits. + +| method | path | purpose | +| --- | --- | --- | +| GET | `/` `/viewer.html` | the app shell (`Cache-Control: no-store`) | +| GET | `/pos` | SSE stream of parsed MSP position/heading/armed | +| GET | `/status` | everything the panel needs (see below) | +| GET | `/settings` · POST | zoom, detail zoom, basemap, per-zoom sources | +| GET | `/tiles/{z}/{x}/{y}?src=&offline=` | cache first, then live proxy | +| GET | `/cache?src=` | per-zoom counts, covered box, size, format, elevation summary | +| GET | `/packs` | every downloaded pack with sources, zooms, counts, size and bounds | +| DELETE | `/packs?id=` | delete one existing regular pack file; shared databases remain | +| GET | `/coverage?src=&z=&n=&s=&e=&w=` | which tiles are present in a bbox | +| GET/POST | `/download` | progress / start a bbox download (see phases below) | +| GET | `/export?src=` | zip of the pack + `landmarks.db` + `elevation.db` | +| GET | `/landmarks` `/poi-types` · POST `/poi-selection` | POI data and selection | +| GET | `/elevation?lat=&lon=` | terrain height in m (`null` outside coverage) | +| GET | `/viewshed?lat=&lon=&ant=&alt=&max_km=&az=` | terrain line-of-sight horizon ring | +| POST | `/target` | waypoint slots | + +`/status` carries `zooms`, `detail_zoom`, `detail_min`/`detail_max`, `sources`, `pack`, +`basemaps`, `basemaps_disabled` (name → reason), `targets`, `target_slots`, `home`, +`max_tiles` and download progress. `target` (slot 1 alone) is repeated under the old +key for overlay clients that predate multiple waypoints. + +`/target` accepts `{"targets":[{name,lat,lon}|null, …]}`; a shorter list leaves later +slots untouched, so the panel can post only what it edited. The legacy `{lat,lon}` +body still works and writes slot 1. + +`?src=` names a **pack**. Configured packs remain one or more known basemap names joined +by `+`. `/packs` also exposes filename-stem ids for regular `.mbtiles` files discovered +directly inside the configured maps directory, allowing preflight to inspect imported or +previously configured packs. `resolve_pack()` accepts only those two forms; stems are +restricted to safe characters, must already exist and cannot be symlinks, so an arbitrary +query string cannot escape `maps/`. + +The preflight viewer keeps its configured online-preview `PACK` separate from +`VIEW_PACK`. Basemap/per-zoom controls define the sources for `/download`, but the server +allocates a fresh pack id for every request. The selected view pack is used only while +**Show downloaded** or **Offline mode** is active; tile and coverage requests then use +that pack id and its actual stored zoom list with network fallback disabled. Selection +is browser-local and does not rewrite either map configuration file. Cache-only zoom +navigation treats that stored list as a discrete sequence: a one-level Leaflet request +is redirected in the requested direction to the next available level. Enabling either +mode while centred outside the selected pack fits its bounds before snapping to a stored +zoom, avoiding an unexplained blank view. + +`DELETE /packs?id=` applies the same safe-id and regular-file checks as pack selection, +rejects the pack currently being downloaded, and unlinks only its `.mbtiles` file. The +confirmation is performed in the viewer; `landmarks.db` and `elevation.db` are shared by +all packs and are deliberately untouched. + +--- + +## 3. Tile formats + +| basemap | format | bytes/tile @z15 | +| --- | --- | --- | +| Satellite / Streets / Topo (Esri) | JPEG (baseline, SOF0) | 22–30 KB | +| OpenTopoMap, Thunderforest | PNG | ~37 KB | + +Both render in both places. Cairo reads PNG natively; JPEG is decoded by a vendored +single-file decoder, `osd/util/stb_image.h` (stb_image v2.30, MIT/public domain), built +`STBI_ONLY_JPEG` + `STBI_NO_STDIO` and used only from `map_render.c` — so camera builds +never see it and no new system dependency is introduced. + +`map_decode_tile()` sniffs the SOI marker (`FF D8`) per *tile*, not per file, which is +what lets a blended pack hold PNG at the coarse levels and JPEG at the detail level. +Dimensions are rejected above 2048×2048 before decoding, and a failed decode is treated +as a missing tile. + +Which formats the OSD accepts is declared by `OSD_TILE_FORMATS` in `mapserver.py`; a +basemap serving anything else is greyed out in the panel with its reason. Widening that +set is the only change needed if another format gains a decoder. + +Aerial imagery is essentially always JPEG — the same Esri tile served as PNG is ~62 KB +against ~22 KB — so JPEG support is what makes imagery packs affordable to store. + +--- + +## 4. Download budget + +Tiles quadruple per zoom level, so the area that fits in `MAX_TILES` (12000) shrinks by +4× per step. Measured against a real cache (model predicted 7267 tiles for a 57×102 km +area at z15; the pack actually held 7233): + +| detail | m/pixel @43°N | tile covers | max square @12000 tiles | +| --- | --- | --- | --- | +| z12 | 29 m | 14.3 km | ~1500 km | +| z14 | 7.2 m | 1789 m | 190 km | +| z15 | 3.6 m | 894 m | 95 km | +| z16 | 1.8 m | 447 m | 47 km | +| z17 | 0.9 m | 224 m | 24 km | +| z18 | 0.45 m | 112 m | 12 km | + +Ground resolution is `156543.03 × cos(lat) / 2^z`; the panel quotes it at ~43° as a +fixed guide. Note the map's resolution outruns GPS accuracy past roughly z16 — beyond +that you are buying visual context, not better positioning. + +Individual tile failures retry 3×. Every download gets a newly allocated pack, even +when the source and bbox match an earlier request, so an existing working map is never +modified. The shared elevation database remains reusable: terrain tiles already present +there do not need to be downloaded again. + +**Progress is reported per phase.** `dl_status` carries `phase` — `tiles` → `poi` → +`elevation` → `done` — alongside `done`/`total`, and the UI's progress bar restarts for +each. Without this the tile phase reached 100% and then sat there through the POI and +elevation work, which reads as a hang. `download_elevation()` takes a `progress(done, +total)` callback and counts every planned tile including already-cached terrain, so the +bar advances rather than jumping. The POI phase is a single Overpass request with no +granularity, so it shows a full bar and its own label instead of a fake percentage. + +### 4.1 Transport + +`_tile_get()` fetches over a **kept-alive connection**, pooled per `(scheme, host)` in a +`threading.local()`. A fresh TLS handshake per tile used to cost ~125 ms — more than the +~40 ms transfer itself — so reuse is the dominant download speedup, and it lowers load on +the tile server rather than raising it. + +Measured on 104 Esri tiles (z11/13/15), same bbox each run: + +| transport | tile_delay_ms | tiles/s | ms/tile | +| --- | --- | --- | --- | +| one `urlopen` per tile (pre-change) | 60 | 4.6–7.9 | 127–217 | +| kept-alive | 60 | 12.3 | 81 | +| kept-alive | 30 | 20.7 | 48 | +| kept-alive | 10 | 39.6 | 25 | +| kept-alive | 0 | 72.0 | 14 | + +With the handshake gone, `tile_delay_ms` (default 60) dominates: it is now ~4× the actual +per-tile work, where before it was a ~35% surcharge. Lowering it is the next lever, but it +is a politeness setting — commercial CDNs (Esri) do not need it; the volunteer-run +OpenTopoMap asks not to be strained by mass downloads. + +Two behaviours the pool must preserve, both covered by the retry in `_tile_get()`: +a server that closes an idle keep-alive socket makes the *next* request fail, which must +be retried transparently on a fresh connection rather than counted as a tile failure; and +the response body must always be drained (even on 404) or the socket cannot be reused. +Redirects are followed manually — `http.client` does not, whereas `urllib` did — which +matters for custom `[server] tile_url` providers. + +### 4.2 Why throughput is bursty + +Downloads run in bursts — long fast stretches broken by stalls. Ranked by contribution: + +**Sporadic backend renders.** OpenTopoMap serves most of the world pre-rendered, but that +coverage thins out at z16–z18 over unremarkable terrain. A tile already on the backend's +disk costs ~40 ms; one that must be rendered costs **1.2–2.3 s**. mod_tile renders a whole +8×8 metatile at a time, so a single stall makes its ~63 neighbours fast — hence a stall +followed by a burst. + +Do **not** read `X-Cache-Status: MISS` as "this tile was rendered". It only reports the +nginx edge cache. Measured at z16 over rural terrain, twelve consecutive tiles across a +metatile boundary all returned MISS at ~42 ms each, boundary tiles included — the backend +already had them. Stalls are sporadic and clustered where pre-rendered coverage runs out, +**not** periodic, and there is no fixed render-per-N-tiles ratio to plan around. + +**Unequal zoom levels.** The worker walks the stored zooms in order, coarse first. The two +coarse levels are a few dozen tiles and effectively always pre-rendered; the detail level +is ~94% of the tiles and by far the most likely to be cold. A fast opening followed by a +long slower tail is structural, not a fault. + +**Fresh-pack writes.** Every download starts with an exclusively reserved empty file, so +the tile lookup normally finds no rows and the worker fetches the complete requested +area. This intentionally gives stronger protection than resume-in-place: a completed +working pack is never opened for writing or partly changed by a later download. + +**Mixed sources.** A topo-coarse/imagery-detail pack changes character at the level +boundary: Esri is a CDN with no render queue and is uniformly fast. + +**Reporting granularity.** `set_dl()` and the SQLite commit fire only on `done % 10 == 0`, +so progress advances in steps of ten tiles — chunkier-looking than the underlying flow. + +The variance predates the kept-alive change; that change only unmasked it. A constant +~125 ms handshake plus the 60 ms sleep once made a 1.5 s render ~7× a normal tile; against +a ~100 ms tile it is ~18×. The one client-side remedy is concurrency, which overlaps the +render waits instead of serialising them — the case where parallel workers buy more than +raw throughput. + +The `for x: for y:` scan order walks metatiles coherently (a column of 8 y-values stays +within one metatile row, and the next 7 x-columns reuse whatever it rendered), so it +should not be "optimised" into something that scatters across them. + +--- + +## 5. Configuration keys + +### `gs/config.ini` (preflight; written by the panel) + +| section | key | meaning | +| --- | --- | --- | +| server | `port` | HTTP port (default 8088) | +| server | `udp_listen` | MSP input, default `127.0.0.1:14560` | +| server | `mbtiles` | path whose *directory* becomes `maps/` | +| server | `tile_url` | custom source when `basemap` is not built in; fields `{z} {x} {y} {s} {key}` | +| server | `tile_key` | API key for keyed providers (gitignored) | +| server | `tile_delay_ms` | per-tile download throttle | +| map | `zoom` | last viewer zoom | +| map | `detail_zoom` | highest stored level, 12–18 | +| map | `sources` | per-zoom basemaps, coarse→detail; empty = use `basemap` for all | +| map | `basemap` | the single/uniform source | +| map | `elevation` | download terrain elevation with the tiles (default 1) | +| map | `center_lat` / `center_lon` | last downloaded centre | + +### `msposd.ini` `[map]` (in-flight renderer) + +`enabled` (default 0 — absent means off, i.e. previous behaviour), `mbtiles`, +`zoom`, `follow` (plane/north/fit/center), `lead`, `plane_y`, `layout`, `geometry`, +`corner`/`width`/`height`/`margin`, `opacity`, `scalebar`, `frame`, `on_top`, +`recenter_px`, `heading_gate_deg`, `track_vector`, `track_vector_len`, +`track_vector_min_speed`, `track_vector_alpha`, `help_font_size`. + +`recenter_px` (default 48) is the drift threshold in *world pixels* that triggers a tile +recompose. World pixels per metre double with each zoom level, so at z18 the aircraft +crosses it 8× as often as at z15 — raise it if a high detail zoom costs too much CPU on +a small board. + +If `mbtiles` names a file that does not exist, the first `*.mbtiles` in that folder is +loaded instead, so a renamed or rebuilt pack still shows a map. + +--- + +## 6. C-side integration + +Both files are `#include`d into `osd.c` inside `#if defined(_x86) || defined(__ROCKCHIP__)`, +so camera builds never compile them, never link sqlite3 and never see the JPEG decoder. + +### `osd/util/poi_osd.c` — points + +Reads waypoint slots with + +```sql +SELECT lat, lon, name FROM waypoints WHERE kind='target' OR kind LIKE 'target_' ORDER BY kind +``` + +into `poi_targets[POI_TARGETS]`, refreshed at most once a second. `ORDER BY kind` puts +`target` first, then `target2`…`target5`. Exposed to `map_render.c` via +`poi_target_count()` and `poi_get_target_at()`. A missing file, missing table, missing +column or corrupt database all degrade to zero waypoints — verified against each case. + +`DrawTarget()` runs *before* the `poi_enabled` check, so waypoints render even with the +POI feature switched off. Each draws a two-ring crosshair plus `" "` in red. + +### `osd/util/map_render.c` — the map + +- Opens the pack read-only, discovers its zoom levels, snaps the requested zoom to the + nearest available (`map_snap_zoom`). +- Composites tiles into a cached surface, redrawn only when the view really changes; + the plane marker is stamped per frame over a copy, so the map holds still between + recomposes. +- Keeps 128 decoded tile surfaces in an LRU keyed by `(z, x, y)`. +- `map_close_db()` finalises the prepared statement *before* closing the handle (sqlite + refuses otherwise), resets the zoom list, and **flushes the tile LRU** — those entries + carry no pack identity, so a switch without the flush would draw the previous pack's + tiles. +- Pack cycling (`h`) lists `*.mbtiles` in the pack's folder alphabetically, matching an + exact `.mbtiles` ending so sqlite's `-journal` side files are skipped. A pack that + fails to open is skipped; if every candidate fails the previous pack is reopened. + Runtime only — `msposd.ini` is not rewritten. +- Showing the map starts independent three-second filename and keyboard-guide timers. + The filename is centred at the top; `G Hide`, `F Mode`, `H Type`, `P POIs` and + `−/+ Zoom` are drawn down the left edge. Pack cycling restarts only the filename timer. +- Fit mode frames plane and home only. Waypoints are deliberately excluded: one distant + point would zoom the map out far enough to lose all detail around the aircraft. + +`POI_TARGETS` comes from `poi_osd.c`, included one line earlier in `osd.c`; `map_render.c` +carries an `#ifndef` fallback so it still compiles if that order changes. + +### `osd/util/Render_gs.c` — input + +Map keys are grabbed on the X root window only when `[map] enabled=1`; with the feature +off nothing new is grabbed and the keys pass through to the desktop. Each grab is +registered for the four NumLock/CapsLock combinations so an active lock key does not +silently disable them. Single-action keys drop X autorepeat. + +**x86 only.** The Radxa ground station runs an X server but has no keyboard; control +there is planned over the RC/MSP channel stream — see +[`map_control_via_RC.md`](map_control_via_RC.md). + +--- + +## 7. Packaged build + +`gs/pack/mapserver.spec` freezes `mapserver.py` + `web/` into one binary with +PyInstaller. When frozen, `APP_DIR` is the executable's directory (so writable data sits +next to it) while `RES_DIR` is `sys._MEIPASS` (the bundled read-only assets), and +`--open-browser` defaults on. Editing files under `gs/` has **no effect** on an existing +binary until it is rebuilt — a frozen build carries its own snapshot, and its `maps/` +folder is separate from `gs/maps/`. + +Moving the one-file executable changes `APP_DIR`. The new directory therefore gets its +own `config.ini`, `state.ini` and `maps/`; these must be copied beside the executable when +migrating an existing setup. A second process using the default HTTP port does not open +that second data directory: single-instance handling reuses the server already bound to +the port. Use a different `--port` or stop the first process when testing two locations. + +The PyInstaller bootloader is host-platform-specific. `build.sh` on Linux or WSL creates +an ELF executable, while `build.bat` under native Windows creates the PE `.exe`. There is +no supported suffix change or direct Linux-to-Windows conversion; build on the target OS +or download the matching CI artifact. 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..ac026a3 --- /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.example) +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..ec55cb4 --- /dev/null +++ b/documentation/offline-map-overlay-spec.md @@ -0,0 +1,332 @@ +# 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 Tile formats — PNG and JPEG +The native path decodes **PNG** with Cairo's reader and **JPEG** with a vendored +single-file decoder (`osd/util/stb_image.h`, built JPEG-only from `map_render.c`), so +imagery basemaps such as Esri World Imagery render natively as well as in the WebKit map. +JPEG matters for imagery specifically: an aerial tile is ~22 KB as JPEG against ~62 KB for +the same tile as PNG. Accepted formats are declared by `OSD_TILE_FORMATS` in +`gs/mapserver.py`, which greys out any basemap serving something the OSD cannot decode. +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 `
`/`