summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agent/code-map.md399
-rw-r--r--.agent/config.md1
-rw-r--r--.agent/worklog.md4
-rw-r--r--docs/pmim-1-assembly.md276
4 files changed, 680 insertions, 0 deletions
diff --git a/.agent/code-map.md b/.agent/code-map.md
new file mode 100644
index 0000000..eb2f564
--- /dev/null
+++ b/.agent/code-map.md
@@ -0,0 +1,399 @@
+# Code Map & Context Tips
+
+**Purpose:** Bootstrap context for any new agent session. Read this before touching code.
+**Last updated:** 2026-06-03
+
+---
+
+## 1. Session Start Checklist
+
+Run these four commands before anything else:
+
+```bash
+git branch --show-current # must be main (or a feature branch off main)
+git checkout main # if not already there
+git config core.hooksPath scripts/git-hooks # activate pre-commit hook
+git pull origin main # sync latest
+```
+
+Then read `.agent/worklog.md` for current focus and recently completed work.
+
+**Merge policy (non-negotiable):** merge to `main` after every complete unit of work, not at session end.
+
+---
+
+## 2. Module Topology
+
+```
+nav/
+├── android-app/ Android application (API 36, Kotlin)
+│ └── app/src/main/kotlin/org/terst/nav/ ← all app source
+├── test-runner/ JVM-only test module (no Android SDK)
+│ └── src/main/kotlin/org/terst/nav/ ← shared portable code
+├── .agent/ Agent config, worklog, design, standards
+├── scripts/
+│ ├── git-hooks/pre-commit blocks pushes to master
+│ └── .claude/ change templates (ct-*.yaml)
+└── .github/workflows/android.yml CI; fires on main pushes only
+```
+
+**Build note:** `./gradlew assembleDebug` requires network access to Google plugin repo — unavailable in the cloud sandbox. Use `test-runner` for logic tests:
+
+```bash
+cd test-runner && ./gradlew test
+```
+
+---
+
+## 3. Source Package Map
+
+All source lives under `org.terst.nav`.
+
+### Root-level (android-app only)
+
+| File | Role |
+|------|------|
+| `MainActivity.kt` | Single-activity host; nav graph, SAF picker launcher |
+| `NavApplication.kt` | Application class; DI/singleton initialization |
+| `MainViewModel.kt` → `ui/MainViewModel.kt` | Top-level state (GPS, NMEA, mode) |
+| `LocationService.kt` | Foreground service; provides GPS to background |
+| `AnchorAlarmManager.kt` | Anchor drag detection; survives screen lock |
+| `PerformanceViewModel.kt` | Real-time sailing metrics (VMG, polar %, wind triangle) |
+| `PerformanceViewModelFactory.kt` | Factory for PerformanceViewModel |
+| `UnitPrefs.kt` | Unit system prefs (knots/m/s, m/ft, °C/°F) |
+| `NmeaConnectionPrefs.kt` | TCP/UDP NMEA connection settings |
+| `PolarData.kt` | Polar table data class + interpolation |
+| `PolarDiagramView.kt` | Custom View: polar diagram canvas renderer |
+| `BarometerSensorManager.kt` | Device barometer reader |
+| `BarometerTrendView.kt` | Pressure trend sparkline view |
+| `BarometerData.kt` | Barometer data class |
+| `MobData.kt` | MOB event data (position, timestamp) |
+| `TidalCurrentData.kt` | Tidal current data model |
+| `MockTidalCurrentGenerator.kt` | Test-only tidal current stub |
+| `PowerMode.kt` | Enum: FULL / ECONOMY / ANCHOR_WATCH |
+| `FeatureFlags.kt` → `settings/FeatureFlags.kt` | Runtime feature toggles |
+
+### `ais/`
+
+| File | Role |
+|------|------|
+| `AisRepository.kt` | In-memory AIS target store (MMSI → AisVessel) |
+| `AisVessel.kt` | AIS target data class |
+| `AisVdmParser.kt` → `nmea/` | Parses !AIVDM sentences |
+| `CpaCalculator.kt` | CPA / TCPA computation |
+| `CpaAlert.kt` | Alert data class for CPA threshold breach |
+| `AisHubApiService.kt` | AISHub internet feed Retrofit service |
+| `AisHubSource.kt` | Polls AISHub; feeds AisRepository |
+
+### `data/`
+
+| Sub-package | Files | Role |
+|-------------|-------|------|
+| `api/` | `ApiClient.kt`, `WeatherApiService.kt`, `MarineApiService.kt` | Retrofit/OkHttp setup; weather + marine endpoints |
+| `model/` | `BoatPolars`, `GribFile`, `GribRegion`, `GribParameter`, `WeatherResponse`, `MarineResponse`, `MarineConditions`, `WindArrow`, `WindForecast`, `ForecastItem`, `TideStation`, `TideConstituent`, `TidePrediction`, `LogbookEntry`, `SensorData`, `SatelliteDownloadRequest` | Pure data classes, no Android deps |
+| `repository/` | `WeatherRepository.kt` | Weather fetch + cache coordination |
+| `storage/` | `GribFileManager.kt` | GRIB2 file lifecycle (download, store, delete) |
+| `weather/` | `GribStalenessChecker.kt`, `SatelliteGribDownloader.kt` | GRIB freshness logic; satellite GRIB fetch |
+
+### `db/`
+
+| File | Role |
+|------|------|
+| `NavDatabase.kt` | Room database; holds `TrackDao` |
+
+### `gps/`
+
+| File | Role |
+|------|------|
+| `GpsProvider.kt` | Interface; abstracts device GPS vs NMEA GPS |
+| `GpsPosition.kt` | Data class: lat, lon, sog, cog, accuracy, timestamp |
+| `DeviceGpsProvider.kt` | FusedLocationProvider implementation |
+
+### `logbook/`
+
+| File | Role |
+|------|------|
+| `LogbookRepository.kt` | Interface |
+| `InMemoryLogbookRepository.kt` | Thread-safe impl (`@Synchronized` on save/getAll/reload) |
+| `LogbookStorage.kt` | JSON persistence to SAF directory |
+| `LogEntry.kt` | Log entry data class (type enum, text, photo, position) |
+| `LogbookFormatter.kt` | Formats log for display |
+| `LogbookPdfExporter.kt` | PDF export |
+| `VoiceLogViewModel.kt` | ViewModel for logbook + trip report tab |
+| `VoiceLogState.kt` | Sealed state for voice log UI |
+
+### `nmea/`
+
+| File | Role |
+|------|------|
+| `NmeaParser.kt` | Parses all NMEA 0183 sentences (GGA, RMC, MWV, HDG, DBT, VHW, XTE…) |
+| `AisVdmParser.kt` | Parses !AIVDM / !AIVDO AIS sentences |
+| `NmeaStreamManager.kt` | TCP/UDP connection manager; streams sentences to NmeaParser |
+
+### `routing/`
+
+| File | Role |
+|------|------|
+| `IsochroneRouter.kt` | Isochrone weather routing engine |
+| `IsochroneResult.kt` | Result data class with optimal route + isochrones |
+| `RoutePoint.kt` | Waypoint data class for routing |
+
+### `safety/`
+
+| File | Role |
+|------|------|
+| `AnchorWatchState.kt` | State sealed class for anchor alarm UI |
+
+### `sensors/`
+
+Data-only classes (no logic): `WindData`, `HeadingData`, `BoatSpeedData`, `DepthData`, `AttitudeData`, `NmeaBaroData`
+
+### `settings/`
+
+| File | Role |
+|------|------|
+| `FeatureFlags.kt` | Runtime boolean flags (e.g. night mode, race mode) |
+
+### `thermal/`
+
+| File | Role |
+|------|------|
+| `ThermalMonitor.kt` | Monitors battery temp via `PowerManager`; fires alert if overheating |
+
+### `tide/`
+
+| File | Role |
+|------|------|
+| `HarmonicTideCalculator.kt` | Offline tidal harmonic prediction; no network required |
+
+### `track/`
+
+| File | Role |
+|------|------|
+| `TrackRepository.kt` | Coordinates GPX + Room storage; source of truth = GPX/SAF |
+| `TrackStorage.kt` | SAF primary + MediaStore fallback for GPX files |
+| `TrackEntity.kt` | Room entity; `toShallowSavedTrack()` for fast list load |
+| `TrackDao.kt` | Room DAO |
+| `TackDetector.kt` | Time-based tack/jibe detection (CRUISING + RACE modes) |
+| `TackEvent.kt` | Detected tack data class |
+| `GpxParser.kt` | GPX file → list of TrackPoints |
+| `GpxSerializer.kt` | TrackPoints → GPX file |
+| `SavedTrack.kt` | In-memory track with points + metadata |
+| `TrackSummary.kt` | Lightweight summary (no points) for list display |
+| `TrackStats.kt` | Computed stats: distance, max speed, duration, tacks |
+| `TrackColors.kt` | Speed-to-color mapping for track rendering |
+| `SavedTracksFragment.kt` | List of saved tracks; SAF setup button |
+| `TrackDetailSheet.kt` | Bottom sheet: track stats, add note/photo |
+| `TrackSummarySheet.kt` | Mini summary bottom sheet |
+
+### `tripreport/`
+
+| File | Role |
+|------|------|
+| `TripReportFragment.kt` | Full trip report view (map + stats + log entries) |
+| `TripReportGenerator.kt` | Generates report from track + log data |
+| `TripReportViewModel.kt` | ViewModel for trip report |
+| `PreTripReportFragment.kt` | Pre-departure checklist / briefing |
+| `PreTripReportGenerator.kt` | Generates pre-trip summary (weather, similar trips, watch items) |
+| `PreTripReportViewModel.kt` | ViewModel for pre-trip report |
+| `PreTripModels.kt` | Data classes for pre-trip report |
+| `BoatProfileRepository.kt` | Stores boat profile (name, dimensions, polars) |
+
+### `vessel/`
+
+| File | Role |
+|------|------|
+| `VesselRepository.kt` | Crew + vessel data; in-memory + persistence |
+
+### `ui/` (top level)
+
+| File | Role |
+|------|------|
+| `MainViewModel.kt` | Top-level app state |
+| `MobHandler.kt` | MOB activation logic; always-on-top button handler |
+| `InstrumentHandler.kt` | Routes NMEA/GPS data to instrument display widgets |
+| `MapHandler.kt` | MapLibre map setup, overlay rendering, track display |
+| `MapLayerManager.kt` | Layer visibility management |
+| `LayerPickerSheet.kt` | Bottom sheet: layer toggle UI |
+| `NavLogger.kt` | In-app debug log (primary debugging tool — prefer over ADB) |
+| `LocationPermissionHandler.kt` | Runtime location permission request flow |
+| `DevLogSheet.kt` | Bottom sheet: shows NavLogger output |
+| `DirectionArrowView.kt` | Custom View: compass/bearing arrow |
+| `InclinometerView.kt` | Custom View: heel angle indicator |
+| `WaveView.kt` | Custom View: wave height visualization |
+| `WeatherActivity.kt` | Standalone weather details screen |
+
+### `ui/map/`
+
+| File | Role |
+|------|------|
+| `MapFragment.kt` | Main chart fragment; hosts MapLibre GL map |
+| `ParticleWindView.kt` | Animated wind particle overlay on chart |
+
+### `ui/safety/`
+
+| File | Role |
+|------|------|
+| `SafetyFragment.kt` | Safety tab: MOB, anchor alarm (depth + rode inputs, radius display) |
+
+### `ui/voicelog/`
+
+| File | Role |
+|------|------|
+| `VoiceLogFragment.kt` | Log tab: logbook entries + trip report; passes both `onSelect` and `onReport` to `SavedTrackAdapter` |
+
+### `ui/forecast/`
+
+| File | Role |
+|------|------|
+| `ForecastFragment.kt` | Weather forecast tab |
+| `ForecastAdapter.kt` | RecyclerView adapter for forecast items |
+
+### `ui/vessel/`
+
+| File | Role |
+|------|------|
+| `VesselRegistryFragment.kt` | Crew registry UI |
+
+### `ui/doc/`
+
+| File | Role |
+|------|------|
+| `DocFragment.kt` | In-app documentation viewer |
+
+### `ui/learn/`
+
+| File | Role |
+|------|------|
+| `LearnFragment.kt` | Sailing knowledge / learning content |
+
+---
+
+## 4. test-runner Module
+
+JVM-only; mirrors a subset of android-app source with no Android SDK dependencies. Run with `cd test-runner && ./gradlew test`.
+
+**Shared sources** (canonical copy in android-app, symlinked or mirrored in test-runner):
+
+| Package | Files |
+|---------|-------|
+| `ais/` | `AisVessel`, `CpaCalculator`, `CpaAlert` |
+| `data/model/` | `GribFile`, `GribRegion` |
+| `data/storage/` | `GribFileManager` |
+| `gps/` | `GpsPosition`, `GpsProvider` |
+| `nmea/` | `NmeaParser`, `AisVdmParser` |
+| `settings/` | `HardwareSource` |
+| `track/` | `TackDetector` ← **symlink** to android-app canonical source |
+| `vessel/` | `Vessel`, `VesselRepository`, `CrewMember` |
+
+**Important:** `TackDetector.kt` in test-runner is a filesystem symlink (`../../../../../../../../android-app/.../TackDetector.kt`). Edit the android-app copy only — test-runner picks it up automatically.
+
+---
+
+## 5. Key Architectural Invariants
+
+1. **Track storage source-of-truth = GPX files in `Documents/Nav/` via SAF.** Room (`NavDatabase`) is a read-cache for fast list loading. GPX/SAF is always authoritative.
+2. **Offline-first.** Every core feature (navigation, tides, safety) works without connectivity. Never gate core functionality on network.
+3. **`getExternalFilesDir()` is never acceptable** for user data. User data must survive uninstall. Use SAF (`Documents/Nav/`) or MediaStore.
+4. **`NavLogger` is the primary debug tool.** Log to `NavLogger`, not just Android `Log`, so in-app debug sheet shows it.
+5. **MOB button is always visible and non-dismissible.** Never place anything above it in the Z-order.
+6. **Logbook `InMemoryLogbookRepository`** has `@Synchronized` on `save()`, `getAll()`, `reload()`. Don't remove synchronization.
+7. **`VoiceLogFragment`** must pass both `onSelect` and `onReport` to `SavedTrackAdapter` — omitting either causes a CI failure.
+8. **MapLibre Android 11.x**: `Expression.get("color")` silently fails for `lineColor` on `LineLayer`. Use `Expression.step(Expression.get("speed"), ...)` to map speed → color literal.
+9. **Speed/temp/depth in UI** must use `UnitPrefs` conversions — never raw SI values.
+10. **NMEA sentences from instruments are magnetic.** Convert to true using WMM variation from `HarmonicTideCalculator`'s companion magnetic model — not a hardcoded offset.
+
+---
+
+## 6. Testing Guide
+
+```bash
+# JVM unit tests (no Android SDK required, works in cloud sandbox)
+cd test-runner && ./gradlew test
+
+# Android unit tests (requires local dev env with Android SDK)
+cd android-app && ./gradlew test
+
+# Android instrumented tests (requires device/emulator)
+cd android-app && ./gradlew connectedAndroidTest
+```
+
+Key test files by domain:
+
+| Domain | Test file |
+|--------|-----------|
+| Tack detection | `test-runner/.../track/TackDetectorTest.kt` (16 tests CRUISING + 2 RACE) |
+| Pre-trip report | `android-app/.../tripreport/PreTripReportGeneratorTest.kt` (18 JVM tests) |
+| Tide calculator | `android-app/.../tide/HarmonicTideCalculatorTest.kt` |
+| AIS / CPA | `test-runner/.../ais/CpaCalculatorTest.kt` |
+| NMEA parsing | `test-runner/.../nmea/NmeaParserTest.kt` |
+| GRIB storage | `test-runner/.../data/storage/GribFileManagerTest.kt` |
+
+---
+
+## 7. CI / Merge Workflow
+
+```bash
+# 1. Work on feature branch
+git checkout -b feature/my-thing
+
+# 2. Commit incrementally
+git add <files> && git commit -m "..."
+
+# 3. Push branch
+git push -u origin feature/my-thing
+
+# 4. Merge to main IMMEDIATELY when done (not at session end)
+git checkout main
+git merge --no-ff feature/my-thing
+git push -u origin main
+
+# 5. Delete feature branch (optional cleanup)
+git branch -d feature/my-thing
+```
+
+CI (`android.yml`) triggers only on pushes to `main`. Firebase App Distribution fires on `main` pushes only.
+
+---
+
+## 8. Context Tips & Gotchas
+
+### Navigation / GPS
+- `DeviceGpsProvider` uses `FusedLocationProviderClient`. External GPS comes via NMEA TCP/UDP through `NmeaStreamManager` → `NmeaParser` → `GpsProvider` interface.
+- GPS fix is stale after 10 s — `MainViewModel` shows red "No GPS" banner.
+
+### NMEA
+- `NmeaStreamManager` handles connection lifecycle (reconnect on failure). Don't manage socket lifecycle elsewhere.
+- All wind data from NMEA is apparent (`AWS`, `AWA`). True wind is computed in `PerformanceViewModel` using the vector triangle.
+
+### Tack Detection
+- `TackDetector` uses time-based windows (not point-count). `T_SETTLE=30s` before/after apex.
+- `circularMAD` stability check handles anchor-swing noise without an SOG gate.
+- RACE mode: `T_SETTLE=12s`, `MIN_GAP=20s`, `STAB_MAX=30°`.
+
+### Anchor Alarm
+- Alarm must survive: app background, screen lock, low battery, OS memory pressure.
+- `AnchorAlarmManager` runs in `LocationService` (foreground service), not in an Activity.
+- UI inputs (depth, rode) embedded in `card_anchor` in `fragment_safety.xml` — there is no separate anchor config screen.
+
+### Trip Report
+- Report auto-generates on tab open; RETRY button appears only on error.
+- Track map uses a full-bleed 260dp MapLibre instance embedded in the report fragment.
+- Pirate mode Easter egg: long-press title in `TripReportFragment`.
+
+### GRIB / Weather
+- GRIB files stored via `GribFileManager`; staleness checked by `GribStalenessChecker`.
+- `addGpsPoint()` matches current UTC hour prefix against `timeIso` for forecast slot lookup.
+- Particle wind animation: `ParticleWindView` is a GL overlay on the map.
+
+### Room / Track Storage
+- Room is a read-cache only. On cold start, if Room is empty, GPX files are backfilled.
+- `TrackEntity.toShallowSavedTrack()` serves the list view without loading track points.
+- `TrackStorage` has SAF primary path with MediaStore fallback; SAF state is a `Flow` observed by `SavedTracksFragment`.
+
+### Logbook
+- `onSafDirectorySelected` must call `logbookRepository.reload()` so entries from the newly-granted directory appear immediately.
+
+### Units
+- `UnitPrefs` is the single source of truth for display units. Always convert through it.
+- Distance: nm / km. Speed: knots / m/s. Depth: m / ft. Temperature: °C / °F.
diff --git a/.agent/config.md b/.agent/config.md
index 52d135d..56d9df5 100644
--- a/.agent/config.md
+++ b/.agent/config.md
@@ -7,6 +7,7 @@ This is the primary source of truth for all AI agents working on **Nav**. These
| File | Purpose |
|------|---------|
| `config.md` | **Main Entry Point** — Rules, workflows, and core mandates. |
+| `code-map.md` | **Code Map** — Package topology, key classes, gotchas, testing guide. Read this to bootstrap context fast. |
| `worklog.md` | **Session State** — Current focus, recently completed, and next steps. |
| `design.md` | **Architecture** — Component design, UI mockups, and roadmap. |
| `coding_standards.md` | **Technical Standards** — Android/Kotlin best practices, offline-first. |
diff --git a/.agent/worklog.md b/.agent/worklog.md
index c023a13..e55bf9f 100644
--- a/.agent/worklog.md
+++ b/.agent/worklog.md
@@ -8,6 +8,10 @@
## Recently Completed
+### Code Map & Context Tips — 2026-06-03
+
+Created `.agent/code-map.md`: full package topology, key class table per domain, architectural invariants, testing guide, CI/merge workflow, and a "gotchas" section covering known tricky spots (MapLibre lineColor expression, TackDetector symlink, Room as read-cache, VoiceLogFragment adapter signature, etc.). Updated `config.md` directory table to reference the new file.
+
### Tech Debt + Trip Report Redesign — 2026-06-03
Six tech debt items closed and trip report rebuilt:
diff --git a/docs/pmim-1-assembly.md b/docs/pmim-1-assembly.md
new file mode 100644
index 0000000..95449ee
--- /dev/null
+++ b/docs/pmim-1-assembly.md
@@ -0,0 +1,276 @@
+# PMIM-1 Assembly Guide
+## Portable Marine Instrument Multiplexer
+
+The PMIM-1 is a self-contained sensor box that attaches to a stanchion or companionway and streams NMEA 0183 sentences to the Nav app over Wi-Fi UDP broadcast. It provides GPS, AIS, heading, attitude (heel/pitch), and barometric pressure/temperature.
+
+---
+
+## Parts list
+
+| Part | Notes | Est. Cost |
+|---|---|---|
+| ESP32 DevKit V1 (30-pin) | Must be 30-pin, not 38-pin | $9 |
+| dAISy Mini (UART variant) | TTL UART version, not USB | $85 |
+| BN-880 module | Circular PCB ~28mm, integrated ceramic patch antenna | $24 |
+| GY-521 (MPU-6050) | Small blue board ~21×16mm | $8 |
+| BMP280 breakout | Verify BMP280, not BMP180 | $3 |
+| IP67 enclosure, white body / clear lid | ~120×80×40mm interior | $15 |
+| Anker PowerCore 10000mAh | No auto-shutoff at light load | $20 |
+| IP67 SMA female bulkhead | Panel-mount style with O-ring | $5 |
+| BNC-F to PL-259 adapter | For VHF antenna connection | $7 |
+| IPEX (U.FL) to SMA pigtail, ~100mm | Connects dAISy to bulkhead | incl. |
+| GoPro adhesive base + handlebar clamp | Standard Hero mount ecosystem | $15 |
+| 28 AWG silicone wire | ~1m each: red, black, yellow, green, blue, white, orange | $5 |
+| M2×6 standoffs + screws | 8× for ESP32 and dAISy | $2 |
+| Perfboard, ~50×70mm | Single-sided, wiring substrate | $1 |
+| PG7 cable gland | Sized for USB-C cable OD (~4–5mm) | $2 |
+| 4.7kΩ resistors | 2× for I2C pull-ups if BMP280 board lacks them | — |
+| 47Ω 0.5W resistor | Power bank keepalive load (optional, see notes) | — |
+| Gore-Tex speaker mesh tape | Breathable waterproof vent cover | $3 |
+| Silicone O-ring grease | Thin smear on lid O-ring at close-up | — |
+| **Total** | | **~$204** |
+
+---
+
+## Tools
+
+- Soldering iron, fine tip, 320–350°C
+- Solder, 0.5mm rosin-core, and flux pen
+- Multimeter (continuity + DC voltage)
+- Step drill or 15.5mm bit (SMA bulkhead hole)
+- 4mm drill bit (BMP280 vent)
+- PG7 cable gland hole size per gland datasheet (~12mm)
+- Rotary tool or hand drill
+- Hot glue gun (strain relief)
+- Tweezers and helping hands
+
+---
+
+## Enclosure orientation
+
+Orient the enclosure so the **clear lid faces up toward the sky**. This gives the BN-880's ceramic patch antenna a direct sky view. The GoPro mount adheres to the solid bottom face.
+
+```
+ Clear lid (faces sky)
+ ┌─────────────────────────┐
+ │ GPS antenna view │
+ └─────────────────────────┘
+ Front face (toward bow preferred)
+ ┌──────────┐
+ │ [SMA] │ ← AIS antenna exits here
+ └──────────┘
+ Back face
+ ┌──────────────────────────┐
+ │ [4mm vent, Gore-Tex] │ ← BMP280 pressure equalization
+ └──────────────────────────┘
+ Bottom face (GoPro mount)
+ ┌──────────────────────────┐
+ │ [PG7 cable gland] │ ← USB-C power from power bank
+ └──────────────────────────┘
+```
+
+---
+
+## Step 1 — Drill the enclosure
+
+1. **SMA bulkhead hole:** Center on the front face, ~15mm from one edge. Step-drill to 15.5mm. Install the SMA bulkhead connector with O-ring and nut — finger-tight plus ¼ turn.
+
+2. **BMP280 vent:** 4mm hole on the back face, as high as possible to avoid splash. Leave uncovered until components are mounted (Step 5).
+
+3. **Power cable gland:** Bottom face. Drill to PG7 spec (~12mm). Thread the USB-C cable through before soldering its end — you can't pull a USB-C plug through a gland.
+
+---
+
+## Step 2 — Perfboard layout
+
+Cut perfboard to fit the enclosure floor with ~5mm clearance each side. Lay out components before soldering anything:
+
+```
+┌────────────────────────────────────────────┐
+│ │
+│ [BN-880] [ESP32 DevKit V1] │
+│ compass far corner ┌────────────────┐ │
+│ ≥35mm from ESP32 │ │ │
+│ ○ (28mm circle) │ 30-pin │ │
+│ │ DevKit │ │
+│ [BMP280] │ │ │
+│ near back vent └────────────────┘ │
+│ │
+│ [GY-521] [dAISy Mini] │
+│ center right side, near SMA wall │
+│ │
+└────────────────────────────────────────────┘
+```
+
+> **Critical:** Keep BN-880 (HMC5883L compass) at least 35mm from the ESP32. The 2.4 GHz Wi-Fi radio causes magnetic interference at close range.
+
+Mount ESP32 and dAISy on M2×6 standoffs. Mount BN-880, GY-521, and BMP280 with double-sided foam tape, BN-880 ceramic antenna facing up toward the clear lid.
+
+---
+
+## Step 3 — Power wiring
+
+```
+Power bank → USB-C cable → PG7 cable gland
+ → ESP32 VIN (5V) [red wire]
+ → ESP32 GND [black wire]
+
+ESP32 3.3V pin → 3.3V bus rail along perfboard edge
+ESP32 GND → GND bus rail
+
+3.3V rail → dAISy VCC, BN-880 VCC, GY-521 VCC, BMP280 VCC
+GND rail → all module GNDs
+```
+
+> **Before powering:** Measure 3.3V rail to GND resistance cold (power off). Should be several kΩ. A short here will kill the ESP32's onboard regulator.
+
+**Power bank keepalive:** If the power bank cuts out at light load, solder a 47Ω 0.5W resistor between the 5V and GND rails on the perfboard. This draws ~100mA continuously and keeps the bank alive. Try without it first — Anker PowerCore units usually don't need it.
+
+---
+
+## Step 4 — UART wiring
+
+| Signal | ESP32 pin | Module pin | Wire color |
+|---|---|---|---|
+| UART1 RX | GPIO16 | dAISy TX | Yellow |
+| UART1 TX | GPIO17 | dAISy RX | Orange |
+| UART2 RX | GPIO4 | BN-880 TX | Green |
+| UART2 TX | GPIO2 | BN-880 RX | White |
+
+> dAISy RX and BN-880 RX are only needed for sending config commands. Leave them unconnected for basic operation if you want to keep it simple.
+
+**Baud rates:**
+- dAISy Mini UART: 38400 8N1
+- BN-880 GPS: 9600 8N1 (default)
+
+---
+
+## Step 5 — I2C bus wiring
+
+Single shared bus to all three sensors:
+
+| Signal | ESP32 pin | Wire color |
+|---|---|---|
+| SDA | GPIO21 | Blue |
+| SCL | GPIO22 | Purple |
+
+Daisy-chain both SDA and SCL to each sensor: GY-521 → BN-880 → BMP280.
+
+**Pull-up resistors:** GY-521 and BN-880 breakouts have 4.7kΩ pull-ups onboard. Check your BMP280 board — if it lacks them, add 4.7kΩ from SDA→3.3V and SCL→3.3V on the perfboard.
+
+**I2C addresses:**
+
+| Device | Address | How to set |
+|---|---|---|
+| MPU-6050 (GY-521) | 0x68 | Leave AD0 floating or tie to GND |
+| HMC5883L (BN-880) | 0x1E | Fixed |
+| BMP280 | 0x76 | SDO pulled LOW (0x77 if HIGH) |
+
+---
+
+## Step 6 — RF chain
+
+```
+dAISy Mini IPEX/U.FL port
+ → IPEX-to-SMA pigtail (route flat inside enclosure)
+ → SMA bulkhead (in enclosure wall, O-ring sealed)
+ → [outside] BNC-F to PL-259 adapter
+ → VHF antenna coax
+```
+
+Keep the IPEX pigtail as short as possible and route it away from I2C and UART wires. Seat the IPEX connector firmly — it should click.
+
+---
+
+## Step 7 — Calibration button
+
+Add a normally-open tactile button for IMU zero-offset calibration:
+
+- One terminal → GPIO0
+- Other terminal → GND
+- 10kΩ pull-up from GPIO0 → 3.3V (or use ESP32 internal pull-up in firmware)
+
+For field access without opening the box, thread an IP67 panel-mount push-button through the enclosure wall. Otherwise, accept that calibration requires opening the lid — acceptable for initial dock setup.
+
+---
+
+## Step 8 — BMP280 pressure vent
+
+Cut a 15mm circle of Gore-Tex speaker mesh tape. Apply it over the 4mm vent hole from the **inside** of the enclosure, pressing firmly around all edges. This lets atmospheric pressure equalize for accurate barometer readings while blocking water.
+
+Position the BMP280 within 20mm of this hole with its sensor port facing toward it.
+
+---
+
+## Step 9 — Pre-close checklist
+
+Run through this before sealing the enclosure:
+
+- [ ] Continuity: all GND connections ring out to common ground
+- [ ] No shorts: 3.3V rail to GND ≥ 5kΩ cold
+- [ ] IPEX connector fully seated (it clicks)
+- [ ] SMA bulkhead O-ring seated, nut snug
+- [ ] PG7 cable gland compression ring tight around USB-C cable
+- [ ] BN-880 ceramic antenna facing up toward clear lid
+- [ ] All wires lie flat — none above the tallest component
+- [ ] I2C scan (with firmware running) shows 0x1E, 0x68, 0x76
+- [ ] NMEA sentences confirmed arriving on phone before sealing
+
+---
+
+## Step 10 — Seal and mount
+
+1. Confirm lid O-ring is clean and seated in its groove. Apply a thin smear of silicone grease.
+2. Close lid. Tighten all screws evenly in a cross-pattern.
+3. **IP test:** Hold under running tap water for 10 seconds. Dry, open, check for moisture. Do this before attaching the GoPro mount.
+4. Attach GoPro adhesive base to the bottom face (or use the handlebar clamp on a stanchion).
+
+**Companionway:** Peel adhesive, press to clean dry surface, weight for 24h cure.
+**Stanchion:** Handlebar clamp around 25mm tube. Use a rubber shim if your stanchion diameter differs. Orient clear lid skyward and inboard for best GPS view.
+
+---
+
+## First power-on
+
+1. Leave box open for initial test.
+2. Connect power bank. ESP32 LED should illuminate.
+3. On your phone, join the PMIM-1 Wi-Fi AP (SSID set in firmware).
+4. Open Nav app → enable NMEA Instruments in hardware settings.
+5. Confirm data: GPS fix within ~60s cold start; AIS targets if vessels nearby; attitude immediately; baro after a few seconds.
+6. Tilt the box ~15° and confirm the inclinometer in the app responds.
+7. Close box, re-test over Wi-Fi.
+
+---
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| Power bank cuts out after ~30s | Auto-shutoff at light load | Add 47Ω keepalive resistor |
+| Compass heading drifts or spins | BN-880 too close to ESP32 | Increase separation, add mu-metal shim |
+| No AIS targets (even near traffic) | IPEX not seated / bad antenna connection | Re-seat IPEX, check SMA continuity |
+| dAISy takes ~30s to start | Normal warm-up | Wait — not a fault |
+| GPS cold start >5 min | Poor sky view | Orient clear lid more skyward |
+| 0x76 missing from I2C scan | BMP280 SDO high (→ 0x77) or bad connection | Check SDO pin, rescan at 0x77 |
+| Baro reads but lags weather changes | Vent hole blocked or Gore-Tex not breathable | Check mesh tape seal |
+
+---
+
+## Pin summary
+
+```
+ESP32 DevKit V1 — complete pin assignment
+
+GPIO1 → UART0 TX (debug/flash — do not use for peripherals)
+GPIO3 → UART0 RX (debug/flash — do not use for peripherals)
+GPIO2 → UART2 TX → BN-880 RX
+GPIO4 → UART2 RX → BN-880 TX
+GPIO16 → UART1 RX → dAISy TX
+GPIO17 → UART1 TX → dAISy RX
+GPIO21 → I2C SDA → MPU-6050, HMC5883L, BMP280
+GPIO22 → I2C SCL → MPU-6050, HMC5883L, BMP280
+GPIO0 → Calibration button (pull-up, active LOW)
+VIN → 5V from power bank
+3.3V → 3.3V supply rail
+GND → Common ground
+```