diff --git a/.gitignore b/.gitignore index 4e51a1b..179f391 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ web/sprite.png /testdata/*.zip /testdata/*.pdf /.claude/ +plugins/*/plugin.wasm diff --git a/Makefile b/Makefile index 9411d08..2f175cd 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CACHE ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)/chartplotter S101_PC ?= $(HOME)/Projects/s101-portrayal-catalogue/PortrayalCatalog S101_FC ?= $(HOME)/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml -.PHONY: build build-tile57 tile57-lib vendor-style-engine xbuild xbuild-tile57 test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages +.PHONY: build build-tile57 build-plugins tile57-lib vendor-style-engine xbuild xbuild-tile57 test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages # Prebaked prod test set (US Inland ENC bundle + the NOAA world archive). # NB: keep these as bare values with NO inline `#` comments — Make folds any @@ -122,6 +122,16 @@ build: $(TILE57_LIB) ## Build bin/chartplotter (CGO + native libtile57; fetches # Back-compat alias — libtile57 is now the default engine, so this is just `build`. build-tile57: build ## Alias for `build` (libtile57 is the sole engine now) +# In-tree reference plugins compiled to Tier-A WASM (wasip1). Pure Go, CGO off, no +# tile57 — builds standalone. Output stays beside each plugin's manifest so the +# directory is directly runnable with `chartplotter plugin dev`. +CORE_PLUGINS := core.nmea0183 core.weather +build-plugins: ## Build the in-tree reference plugins to plugin.wasm (wasip1) + @for p in $(CORE_PLUGINS); do \ + echo "→ plugins/$$p/plugin.wasm (wasip1)"; \ + GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o plugins/$$p/plugin.wasm ./plugins/$$p || exit 1; \ + done + # Quick cross-platform test builds. CGO is off, so this is pure `go build` per # target — fast cold, near-instant on re-runs thanks to the build cache. Stamps # the same version as `build`; strips symbols (-s -w) and paths (-trimpath) like a diff --git a/cmd/chartplotter/main.go b/cmd/chartplotter/main.go index 4b07038..4d85707 100644 --- a/cmd/chartplotter/main.go +++ b/cmd/chartplotter/main.go @@ -32,6 +32,7 @@ type cli struct { Bake bakeCmd `cmd:"" name:"bake" help:"Bake S-57 ENC cells (.zip/.000/dir) into a PMTiles archive for a prebaked deployment."` Serve serveCmd `cmd:"" name:"serve" help:"Serve the web frontend (embedded static) + the NOAA cell proxy."` Simulate simulateCmd `cmd:"" name:"simulate" help:"Run a NMEA0183 traffic generator over TCP (own-ship + AIS targets) for testing."` + Plugin pluginCmd `cmd:"" name:"plugin" help:"Manage plugins: install, list, enable/disable, remove, dev."` } type emitAssetsCmd struct { diff --git a/cmd/chartplotter/plugin.go b/cmd/chartplotter/plugin.go new file mode 100644 index 0000000..b03e70b --- /dev/null +++ b/cmd/chartplotter/plugin.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os/signal" + "syscall" + + "github.com/beetlebugorg/chartplotter/internal/engine/nmea" + "github.com/beetlebugorg/chartplotter/internal/engine/plugin" + "github.com/beetlebugorg/chartplotter/internal/engine/server" +) + +// plugin.go adds the `chartplotter plugin …` verb group (spec §2 CLI): install, +// list, enable, disable, remove operate on /plugins.json + the unpacked +// archives; dev runs an unpacked directory under the broker for iteration. + +type pluginCmd struct { + Install pluginInstallCmd `cmd:"" help:"Install a plugin from a .zip (verify + unpack)."` + List pluginListCmd `cmd:"" help:"List installed plugins."` + Enable pluginEnableCmd `cmd:"" help:"Enable a plugin."` + Disable pluginDisableCmd `cmd:"" help:"Disable a plugin."` + Remove pluginRemoveCmd `cmd:"" help:"Uninstall a plugin."` + Dev pluginDevCmd `cmd:"" help:"Run an unpacked plugin directory under the broker (auto-restart)."` +} + +// pluginManager builds a state-only Manager (no runners) for offline CLI management. +func pluginManager(dataDir string) *plugin.Manager { + if dataDir == "" { + dataDir = server.DefaultDataDir() + } + return plugin.NewManager(context.Background(), plugin.ManagerOpts{DataDir: dataDir, NoStart: true, Logf: log.Printf}) +} + +type pluginInstallCmd struct { + Archive string `arg:"" type:"existingfile" help:"Plugin archive (.zip)."` + Data string `help:"Data dir override (default: XDG data)."` + GrantAll bool `name:"grant-all" help:"Grant every capability the manifest requests and enable the plugin."` +} + +func (c pluginInstallCmd) Run() error { + m := pluginManager(c.Data) + man, err := m.Install(c.Archive, plugin.InstallOptions{}) + if err != nil { + return err + } + fmt.Printf("installed %s@%s (%s)\n", man.ID, man.Version, man.Name) + if len(man.Capabilities) > 0 { + fmt.Println("requested capabilities:") + for _, cp := range man.Capabilities { + fmt.Printf(" - %s\n", cp.Cap) + } + } + if c.GrantAll { + if err := m.SetGrants(man.ID, man.Capabilities, nil); err != nil { + return err + } + if err := m.Enable(man.ID); err != nil { + return err + } + fmt.Println("granted all capabilities and enabled") + } else { + fmt.Printf("review capabilities, then: chartplotter plugin enable %s\n", man.ID) + } + return nil +} + +type pluginListCmd struct { + Data string `help:"Data dir override."` +} + +func (c pluginListCmd) Run() error { + for _, info := range pluginManager(c.Data).List() { + state := "disabled" + if info.Record.Enabled { + state = info.Status.State + if state == "" { + state = "enabled" + } + } + name := info.Record.ID + if info.Manifest != nil { + name = info.Manifest.Name + } + fmt.Printf("%-32s v%-8s %-10s %s\n", info.Record.ID, info.Record.Version, state, name) + } + return nil +} + +type pluginEnableCmd struct { + ID string `arg:"" help:"Plugin id."` + Data string `help:"Data dir override."` +} + +func (c pluginEnableCmd) Run() error { + if err := pluginManager(c.Data).Enable(c.ID); err != nil { + return err + } + fmt.Printf("enabled %s (restart the server to apply)\n", c.ID) + return nil +} + +type pluginDisableCmd struct { + ID string `arg:"" help:"Plugin id."` + Data string `help:"Data dir override."` +} + +func (c pluginDisableCmd) Run() error { + if err := pluginManager(c.Data).Disable(c.ID); err != nil { + return err + } + fmt.Printf("disabled %s (restart the server to apply)\n", c.ID) + return nil +} + +type pluginRemoveCmd struct { + ID string `arg:"" help:"Plugin id."` + Data string `help:"Data dir override."` + PurgeData bool `name:"purge-data" help:"Also delete the plugin's stored data."` +} + +func (c pluginRemoveCmd) Run() error { + if err := pluginManager(c.Data).Remove(c.ID, c.PurgeData); err != nil { + return err + } + fmt.Printf("removed %s\n", c.ID) + return nil +} + +type pluginDevCmd struct { + Dir string `arg:"" type:"existingdir" help:"Unpacked plugin directory (containing plugin.json)."` + Config string `help:"Plugin config as JSON, e.g. '{\"host\":\"127.0.0.1\",\"port\":10110}'."` +} + +func (c pluginDevCmd) Run() error { + var config map[string]any + if c.Config != "" { + if err := json.Unmarshal([]byte(c.Config), &config); err != nil { + return fmt.Errorf("bad --config JSON: %w", err) + } + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + fmt.Printf("dev: running %s (Ctrl-C to stop)\n", c.Dir) + return plugin.DevRun(ctx, c.Dir, config, nil, consoleHost{}, log.Printf) +} + +// consoleHost prints capability effects to stdout for `plugin dev`. +type consoleHost struct{} + +func (consoleHost) PublishVessel(src string, d []nmea.Delta) { + fmt.Printf("vessel[%s]: %d delta(s)\n", src, len(d)) +} +func (consoleHost) PublishAIS(src string, t []nmea.AISTarget) { + fmt.Printf("ais[%s]: %d target(s)\n", src, len(t)) +} +func (consoleHost) PublishRaw(src string, lines []string) { + for _, l := range lines { + fmt.Printf("raw[%s]: %s\n", src, l) + } +} +func (consoleHost) EvictAIS(source string) { fmt.Printf("ais[%s]: evicted (grant revoked)\n", source) } +func (consoleHost) UpdateStatus(id string, st plugin.PluginStatus) { + fmt.Printf("status[%s]: %s %s\n", id, st.State, st.Detail) +} +func (consoleHost) Log(id, level, msg string) { fmt.Printf("log[%s] %s: %s\n", id, level, msg) } diff --git a/docs/docs/nmea0183.md b/docs/docs/nmea0183.md new file mode 100644 index 0000000..bc79bba --- /dev/null +++ b/docs/docs/nmea0183.md @@ -0,0 +1,37 @@ +--- +id: nmea0183 +title: Connecting your instruments +--- + +# Connecting your instruments + +chartplotter reads your boat's instruments — GPS, compass, speed, depth, wind, +and AIS — over the network using NMEA 0183, the standard most marine +electronics speak. If your boat has a WiFi gateway or multiplexer (many modern +chartplotters, AIS units, and hubs have one built in), you can connect in a +minute. + +## Set it up + +1. Open **Settings → Plugins** and find **NMEA 0183**. +2. Tap **Connections**, then **+ Add connection**. +3. Enter the gateway's address and port. The standard port is **10110**; your + gateway's manual or app will show its address (something like `10.0.0.20`). +4. Save. The dot next to the connection turns **green** when data is flowing. + +Your boat appears on the chart as soon as a GPS position comes through, and +AIS targets show up around you. + +## When something's wrong + +- **Red dot** — chartplotter can't reach that address. Double-check the + address and port, and that you're on the boat's WiFi. +- **Green but no boat** — connected, but no GPS position yet. Tap the **≋** + button to watch the raw data coming in; if lines are scrolling, the fix + should follow shortly. +- **Pause (❚❚)** stops the connection without deleting it — handy at home when + there's nothing to connect to. + +Turning the plugin off removes your boat from the chart right away (no stale +position is left behind). AIS targets fade out on their own after a few +minutes. diff --git a/docs/docs/plugins/capabilities.md b/docs/docs/plugins/capabilities.md new file mode 100644 index 0000000..96ca43f --- /dev/null +++ b/docs/docs/plugins/capabilities.md @@ -0,0 +1,101 @@ +--- +id: plugins-capabilities +title: Capabilities +sidebar_position: 4 +--- + +# Capability reference + +Everything a host-side plugin can do is a **capability**: declared in the +[manifest](./manifest.md), granted (as a subset) by the user, and mediated by +the host **broker** at runtime. A plugin with no grants can do nothing. + +This page lists every capability the host knows about, how it's mediated, its +manifest parameters, and — honestly — whether it is **implemented** or +**Roadmap** in this build. + +## How mediation works + +For a plugin→host **request** to a capability the plugin wasn't granted, the +broker replies with error code `-32000` (`CapabilityDenied`). For a **notification** +(fire-and-forget, e.g. `vessel.publish`) there is no reply channel, so an +ungranted call is **silently dropped**. Grants can change at runtime without a +restart — the host pushes the new set via a `host.grantsChanged` notification. + +## Implemented capabilities + +| Capability | Grants | Mediation | Manifest params | +| --- | --- | --- | --- | +| `vessel.write` | Publish SignalK-style vessel deltas into the shared vessel state, attributed to the plugin id. | `vessel.publish` notifications are applied only if granted; unknown paths in a batch are rejected, valid siblings still apply. | — | +| `ais.write` | Publish AIS target updates (upserted into the shared AIS store, attributed to the plugin). | `ais.publish` notifications applied only if granted. | — | +| `net.tcp-client` | Ask the host to **dial** an allow-listed `host:port`. The host owns the socket and streams inbound chunks back; the plugin never opens a socket itself. | `tcp.connect` checks the grant, then the `hosts` allowlist; a non-matching target is denied `-32000`. | `hosts` (required to be useful) | +| `storage` | A per-plugin key/value store (`storage.json` in the plugin's data dir), with a byte quota. | `storage.*` requests require the grant; writes past the quota fail. | `quota` (default 5 MiB) | + +Two more effects are always available to a **running** plugin and need no explicit grant: + +| Effect | Notes | +| --- | --- | +| **config get/set** | `config.get` returns the plugin's own settings; `config.set` stores plugin-learned values. Not capability-gated. | +| **status.update** | Report health (`running` / `degraded` / `error` + detail) to the connections/plugins UI. Not capability-gated. | + +### `raw.publish` (derived gate) + +`raw.publish` feeds the **raw-sentence sniffer**. It is not a standalone +capability you request; the broker accepts it only if the plugin holds +`vessel.write` **or** `ais.write` — the rationale being that a plugin that is +neither a vessel nor an AIS source has no business injecting raw sentences. + +### `net.tcp-client` host allowlist + +The `hosts` allowlist patterns support: + +- An exact host: `nmea.example.com`. +- A `*.` wildcard prefix: `*.example.com` matches `example.com` and any subdomain. +- An optional `:port` suffix: `nmea.example.com:2000` restricts to that port. +- `${config:key}` substitution: `${config:host}` is filled from the plugin's + config at grant time, so the user's chosen server is the allowed one. (This is + how the reference NMEA 0183 plugin scopes its dial to the configured host.) + +An **empty** `hosts` list denies every connection. + +### `storage` quota + +The `quota` string parses as bytes: `"10MB"`, `"512KB"`, `"1024"` (bare = bytes). +A `storage` grant with no `quota` gets a conservative **5 MiB** default. A +`storage.set` that would push the KV file past the quota fails with an internal +error; the key is not written. + +## Roadmap capabilities + +These capability **names are defined** and appear in the manifest schema, but the +host does **not** enforce or wire them in this build. Do not write how-to guides +against them yet. + +| Capability | Intended grant | Current status | +| --- | --- | --- | +| `vessel.read` | Read the shared vessel state host-side. | **Declared, no host RPC.** There is no read method in the protocol; host-side plugins are producers. (The frontend `ctx.vessel` is how UI plugins read vessel state — see [UI plugins](./ui.md).) | +| `ais.read` | Read the shared AIS targets host-side. | **Declared, no host RPC.** Same as above; UI plugins read AIS via `ctx.ais`. | +| `serial` | Open an allow-listed serial device. | **Gated but not wired.** `serial.list` returns an empty port list; `serial.open` returns `-32601` "serial transport not available in this build". | +| `net.udp` | UDP send/receive. | **Not implemented** — no host handler. | +| `net.http` | Host-mediated HTTP fetches. | **Not implemented** — no host handler. | +| `http.register` | Register a plugin HTTP endpoint under the server. | **Not implemented** — no host handler. | +| `notify` | Post host notifications from a host-side plugin. | **Declared only.** (UI plugins post notifications via `ctx.notify` — see [UI plugins](./ui.md).) | +| `ui.settings` | Contribute a settings form. | **Not gated.** UI contributions run trusted in the frontend; see below. | +| `ui.panel` | Contribute a panel slot. | **Not gated.** | +| `ui.map-layer` | Contribute a map layer. | **Not gated.** | +| `ui.hud` | Contribute a HUD widget. | **Not gated.** | + +### A note on the `ui.*` capabilities + +UI plugins run **trusted in the main document** — the security gate is at install +time, not a browser sandbox. The `ui.*` capability names exist so a manifest can +declare its UI surface, but the frontend plugin host does not check them before +loading a controller. What keeps a UI plugin contained is the **declarative +`ctx`**: it can only touch the map through host-owned handles and can't paint over +safety-critical S-52 layers. See [UI plugins](./ui.md). + +## Related + +- [Manifest reference](./manifest.md) — how capabilities are declared. +- [SDK](./sdk.md) — the Go methods that exercise each capability. +- [Protocol](./protocol.md) — the wire methods behind each capability. diff --git a/docs/docs/plugins/examples.md b/docs/docs/plugins/examples.md new file mode 100644 index 0000000..1b15abe --- /dev/null +++ b/docs/docs/plugins/examples.md @@ -0,0 +1,259 @@ +--- +id: plugins-examples +title: Examples +sidebar_position: 9 +--- + +# Worked examples + +Two complete, real examples: a **backend Tier-A** data source (the reference +plugin that ships in-tree), and a **UI-only** overlay in the built-in style. +Both are runnable and live in the repo. + +## Backend (Tier A): the NMEA 0183 source + +`plugins/core.nmea0183` is the reference plugin — the built-in NMEA 0183 (tcp-client) +source, reimplemented as a WASM plugin. It owns **no I/O of its own**: the host +dials the socket (`net.tcp-client`) and streams bytes; the plugin frames lines, +parses them with the same `nmea` package the built-in runner uses, and publishes +vessel/AIS/raw deltas back. Parity between the two is the acceptance test. + +### The manifest + +```jsonc +{ + "manifestVersion": 1, + "id": "core.nmea0183", + "name": "TCP Client (NMEA0183)", + "version": "1.0.0", + "description": "Reads NMEA0183 from a TCP server and publishes vessel/AIS state.", + "publisher": "beetlebug.org", + "license": "MIT", + "apiVersion": 1, + "entry": { "wasm": "plugin.wasm" }, + "capabilities": [ + { "cap": "vessel.write" }, + { "cap": "ais.write" }, + { "cap": "net.tcp-client", "hosts": ["${config:host}"] } + ], + "provides": [ + { "service": "nmea.source", "apiVersion": 1 } + ] +} +``` + +Notes on what's real here: + +- The `net.tcp-client` allowlist uses **`${config:host}`** — the dial is scoped to + whatever host the user configured, resolved at grant time. +- `provides: [{ "service": "nmea.source" }]` is **forward-looking metadata**. The + `services.*` cross-plugin surface is [Roadmap](./capabilities.md#roadmap-capabilities); + declaring the service has no runtime effect today. +- It carries no `files` map because it's built and run in-tree (via + `chartplotter plugin dev` / `make build-plugins`); a distributed archive would + add the `plugin.wasm` sha256. + +### The logic, walked through + +**Lifecycle & connect.** `Start` reads the target from config, then asks the host +to dial. All callbacks fire on the read loop — no goroutines. + +```go +func (p *tcpClient) Start(h *sdk.Host) { + p.h = h + p.store = &nmea.Store{} + p.parser = &nmea.Parser{} + p.ais = nmea.NewAISStore(0) + p.aisFeed = p.ais.Feeder() + p.prev = map[string]any{} + + host, port := target(h.Config()) // "host"+"port", or "server" = "host:port" + if host == "" || port == 0 { + h.Status("degraded", "no server configured") + return + } + h.TCPConnect(host, port, sdk.TCPHandlers{ + OnConnect: func(handle int) { p.handle = handle; h.Status("running", "connected to "+host) }, + OnData: func(_ int, data []byte) { p.onData(data) }, + OnError: func(_ int, err error) { h.Status("degraded", "connection closed") }, + }) +} + +func (p *tcpClient) Stop() { + if p.handle != 0 { p.h.CloseHandle(p.handle) } +} +``` + +**Framing.** The host delivers **chunks**, not lines, so the plugin buffers and +splits on `\n` itself: + +```go +func (p *tcpClient) onData(chunk []byte) { + p.buf += string(chunk) + for { + i := strings.IndexByte(p.buf, '\n') + if i < 0 { break } + line := strings.TrimSpace(p.buf[:i]) + p.buf = p.buf[i+1:] + if line != "" { p.handleLine(line) } + } +} +``` + +**Parse & route.** Each line goes raw → sniffer, VDM/VDO → AIS decode, everything +else → the vessel store — mirroring the built-in runner: + +```go +func (p *tcpClient) handleLine(line string) { + p.h.PublishRaw(line) // raw.publish (gated behind vessel/ais write) + s, err := nmea.ParseSentence(line) + if err != nil { return } + if s.Type == "VDM" || s.Type == "VDO" { + p.aisFeed(line) + p.publishAIS() + return + } + p.store.Apply(p.parser, s) + p.publishVessel() +} +``` + +**Publish (diffed & batched).** `publishVessel` diffs the current snapshot against +the last published set and emits only changed paths; the SDK batches the queued +deltas and flushes at the end of the read-loop iteration: + +```go +func (p *tcpClient) publishVessel() { + cur := vesselPaths(p.store.Snapshot()) // flatten to writable dotted paths + var deltas []sdk.Delta + for path, v := range cur { + if prev, ok := p.prev[path]; !ok || !reflect.DeepEqual(prev, v) { + deltas = append(deltas, sdk.DeltaOf(path, v)) + } + } + if len(deltas) > 0 { + p.h.PublishVessel(deltas...) + p.prev = cur + } +} +``` + +`vesselPaths` only ever emits the [writable vessel paths](./sdk.md#vessel-paths) — +unknown paths would be dropped by the host anyway. + +### Build & run + +```bash +# Build to WASM (this is what `make build-plugins` runs) +GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o plugins/core.nmea0183/plugin.wasm ./plugins/core.nmea0183 + +# Run live against a local NMEA source; dev grants all declared caps +chartplotter plugin dev plugins/core.nmea0183 --config '{"host":"127.0.0.1","port":10110}' +``` + +`dev` prints the effects as they happen: + +``` +status[core.nmea0183]: running connected to 127.0.0.1:10110 +raw[core.nmea0183]: $GPGGA,123519,4807.038,N,01131.000,E,… +vessel[core.nmea0183]: 3 delta(s) +ais[core.nmea0183]: 1 target(s) +``` + +## UI-only plugin: track line + SOG HUD + +The built-in **own-ship** (`web/src/plugins/own-ship.mjs`) and **AIS overlay** +(`web/src/plugins/ais-overlay.mjs`) are real UI plugins — read them as the +canonical examples. They're pure `ctx` consumers: no raw `map`/`plotter`. Here's +the shape, distilled. + +### own-ship, in essence + +own-ship renders a heading-rotated glyph marker, a dashed COG/SOG predictor + a +solid heading line, follows the vessel with break-out, and shows a GPS-freshness +pill. The mechanics that matter for authors: + +```js +export default class OwnShip { + constructor(ctx) { this.ctx = ctx; /* … */ } + + start() { + const ctx = this.ctx; + + // A rotated DOM marker (author owns its teardown). + this._marker = ctx.markers.add("own-ship", { rotationAlignment: "map", anchor: "center" }); + this._marker.setStyle("pointer-events:auto;cursor:pointer").setHTML(OWN_SHIP_MARKER); + this._marker.onClick((e) => { e.stopPropagation(); this._select(e); }); + + // Vectors in the safe overlay band (host self-heals across style rebuilds). + this._predLayer = ctx.layers.add("predictor", { band: "overlay", layers: [ /* casing + dashed line */ ] }); + this._headLayer = ctx.layers.add("heading", { band: "overlay", layers: [ /* casing + solid line */ ] }); + + // Floating chrome (theme vars inherit): a re-centre chip + a GPS pill. + const mount = ctx.hud.mount("own-ship"); + // …append chip + pill… + + // Follow break-out on real gestures; keep the vessel fixed under wheel-zoom. + ctx.camera.onGesture(() => this._setFollow(false)); + ctx.camera.registerFollowAnchor(() => (this._follow && this._fix) ? [this._fix.lng, this._fix.lat] : null); + + // Live data. + ctx.vessel.subscribe((s) => this._update(s)); + this._update(ctx.vessel.get()); + } + + destroy() { + // Host tears down layers, gesture/anchor listeners, the mount, and the + // subscription (all ctx-tracked). We own the marker + timers/RAF. + if (this._gpsTimer) clearInterval(this._gpsTimer); + if (this._marker) this._marker.remove(); + } +} +``` + +The key patterns to copy: markers for glyphs (**you** remove them), `ctx.layers` +for vectors in the `overlay` band, `ctx.hud.mount` for floating chrome that +inherits the theme, `ctx.camera` for follow, and `ctx.vessel.subscribe` + +priming with `ctx.vessel.get()`. + +### AIS overlay, in essence + +The AIS overlay subscribes to `ctx.ais`, keeps a `mmsi → marker` map, upserts a +glyph per target (rotated by COG/heading, coloured via `--ais-*` CSS vars), and +removes markers for targets that drop out of the feed: + +```js +export default class AISOverlay { + constructor(ctx) { this.ctx = ctx; this._markers = new Map(); } + + start() { + this._off = this.ctx.ais.subscribe((targets) => this._apply(targets || [])); + } + + _apply(targets) { + const seen = new Set(); + for (const t of targets) { + if (typeof t.lat !== "number" || (!t.lat && !t.lon)) continue; // skip position-less + seen.add(t.mmsi); + this._upsert(t); + } + for (const [mmsi, rec] of this._markers) { + if (!seen.has(mmsi)) { rec.marker.remove(); this._markers.delete(mmsi); } + } + } + + destroy() { + if (this._off) this._off(); + for (const rec of this._markers.values()) rec.marker.remove(); + this._markers.clear(); + } +} +``` + +See [UI plugins](./ui.md) for the full `ctx` reference behind these. + +## See also + +- [SDK](./sdk.md) — the backend API. +- [UI plugins](./ui.md) — the `ctx` surface. +- [Getting started](./getting-started.md) — from zero to running. diff --git a/docs/docs/plugins/getting-started.md b/docs/docs/plugins/getting-started.md new file mode 100644 index 0000000..63106a8 --- /dev/null +++ b/docs/docs/plugins/getting-started.md @@ -0,0 +1,174 @@ +--- +id: plugins-getting-started +title: Getting Started +sidebar_position: 2 +--- + +# Getting started + +This page walks a minimal host-side plugin from source to running, end to end. +We'll build a Tier-A (WASM) plugin that publishes a fixed vessel position, run +it with the live dev harness, then install and enable it. + +For a full, realistic plugin see the [NMEA 0183 example](./examples.md); this +page keeps the logic trivial so the mechanics are clear. + +## Prerequisites + +- Go (the same toolchain the repo pins). The SDK builds unchanged for + `GOOS=wasip1 GOARCH=wasm`. +- A built `chartplotter` binary (`make build`) for the `chartplotter plugin` verbs. + +## 1. Write the plugin + +A host-side plugin is a Go `main` package that implements +[`sdk.Plugin`](./sdk.md) and calls `sdk.Run`. Create a directory for it: + +```bash +mkdir -p plugins/example.heartbeat +``` + +`plugins/example.heartbeat/main.go`: + +```go +// example.heartbeat — a minimal plugin: on start it publishes one fixed vessel +// position, then reports healthy. Demonstrates the SDK lifecycle and the vessel +// data plane; it does no I/O of its own. +package main + +import "github.com/beetlebugorg/chartplotter/sdk" + +type heartbeat struct{ h *sdk.Host } + +// Start runs once after the handshake, on the read-loop goroutine. It must not +// block — register handlers / kick off work and return. +func (p *heartbeat) Start(h *sdk.Host) { + p.h = h + if !h.HasGrant("vessel.write") { + h.Status("degraded", "vessel.write not granted") + return + } + // Publish a position. Queued now, flushed at the end of this iteration. + h.PublishVessel( + sdk.DeltaOf("navigation.position", map[string]float64{"lat": 38.9784, "lon": -76.4922}), + sdk.DeltaOf("navigation.sog", 0.0), + ) + h.Status("running", "published home position") +} + +func (p *heartbeat) Stop() {} + +func main() { + if err := sdk.Run(&heartbeat{}); err != nil { + panic(err) + } +} +``` + +Key points, expanded in the [SDK reference](./sdk.md): + +- The model is **single-threaded and event-driven**. Do not start background + goroutines or timers — a Tier-A wasip1 module is one cooperatively-scheduled + thread. Everything happens on the read loop. +- Publishes are **batched**: `PublishVessel`/`PublishAIS`/`PublishRaw` queue, + and the SDK flushes once per handled host message. +- Only the [writable vessel paths](./sdk.md#vessel-paths) are accepted; unknown + paths are dropped by the host. + +## 2. Write the manifest + +`plugins/example.heartbeat/plugin.json` — see the full [manifest reference](./manifest.md): + +```jsonc +{ + "manifestVersion": 1, + "id": "example.heartbeat", + "name": "Heartbeat", + "version": "1.0.0", + "apiVersion": 1, + "entry": { "wasm": "plugin.wasm" }, + "capabilities": [ + { "cap": "vessel.write" } + ] +} +``` + +The `id` must be reverse-DNS (must contain a dot) and must **not** start with +`core.` (reserved for built-ins). `apiVersion` must match the host's — `1` today. + +## 3. Build to WASM + +Compile the plugin to a `wasip1` module beside its manifest: + +```bash +GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o plugins/example.heartbeat/plugin.wasm ./plugins/example.heartbeat +``` + +The in-tree reference plugins use exactly this, wrapped in a Makefile target: + +```bash +make build-plugins # builds the CORE_PLUGINS list to plugin.wasm +``` + +To add your plugin to that target, append its id to `CORE_PLUGINS` in the +`Makefile`. (`build-plugins` is pure Go, CGO off — it doesn't need the tile57 +engine.) + +## 4. Run it live with `plugin dev` + +`chartplotter plugin dev` runs an **unpacked** directory under the real broker +with auto-restart — no packaging needed. It grants every capability the manifest +declares, so it's the fast iteration loop: + +```bash +chartplotter plugin dev plugins/example.heartbeat +``` + +`dev` prints capability effects to the console. You'll see the vessel delta and +the status update: + +``` +dev: running plugins/example.heartbeat (Ctrl-C to stop) +plugin example.heartbeat: starting (dir plugins/example.heartbeat) +status[example.heartbeat]: running published home position +vessel[example.heartbeat]: 2 delta(s) +``` + +Pass runtime config as JSON with `--config`: + +```bash +chartplotter plugin dev plugins/example.heartbeat --config '{"greeting":"hi"}' +``` + +The config map is delivered to your plugin via `h.Config()`. + +## 5. Package, install, grant, enable + +Once it works under `dev`, package it as a zip and install it into the real +server. Packaging is covered in full on the [packaging page](./packaging.md); +the short version: + +1. Compute the sha256 of `plugin.wasm` and list it in the manifest's `files` map. +2. Zip `plugin.json` + `plugin.wasm` (paths relative to the archive root). +3. Install, grant, and enable: + +```bash +# Install (verifies the content hashes, unpacks — but leaves it disabled/ungranted) +chartplotter plugin install example.heartbeat-1.0.0.zip + +# Grant everything the manifest asks for and enable in one step: +chartplotter plugin install example.heartbeat-1.0.0.zip --grant-all + +# Or, after a plain install, enable it (grants are set via the HTTP API / UI): +chartplotter plugin enable example.heartbeat +``` + +Enable/disable take effect for the **running server** on the next start (the CLI +edits `plugins.json`); when the server itself manages the plugin, enable starts +the runner immediately. See [packaging & CLI](./packaging.md). + +## Where to go next + +- [SDK reference](./sdk.md) — every `Host` method with examples. +- [Capabilities](./capabilities.md) — what each grant lets you do. +- [Examples](./examples.md) — the full NMEA 0183 source, walked through. diff --git a/docs/docs/plugins/manifest.md b/docs/docs/plugins/manifest.md new file mode 100644 index 0000000..f51c7c9 --- /dev/null +++ b/docs/docs/plugins/manifest.md @@ -0,0 +1,185 @@ +--- +id: plugins-manifest +title: Manifest (plugin.json) +sidebar_position: 3 +--- + +# Manifest reference + +Every plugin has a `plugin.json` at its archive root. It is the +**content-addressed root** of the package: its `files` map lists every other +archive file with its sha256, so verifying the manifest verifies the whole zip. + +The manifest is parsed with **unknown fields rejected** — a typo'd key is an +install error, not a silent no-op. The schema below is exactly the Go +`Manifest` struct (`internal/engine/plugin/manifest.go`). + +## Full annotated example + +```jsonc +{ + // Schema version of this file. Must be 1. + "manifestVersion": 1, + + // Reverse-DNS id: [a-z0-9.-], must contain a dot, no leading/trailing dot. + // Must NOT start with "core." (reserved for built-ins). + "id": "com.example.windbridge", + + // Human-facing name shown in the plugins UI / CLI list. + "name": "Wind Bridge", + + // Semver MAJOR.MINOR.PATCH, optional -pre / +build. + "version": "1.2.0", + + // Optional metadata. + "description": "Publishes true/apparent wind from a serial anemometer.", + "publisher": "example.com", + "license": "MIT", + "homepage": "https://example.com/windbridge", + + // Plugin protocol + capability-schema major. Must equal the host's (1 today); + // a mismatch is rejected at install/parse time. + "apiVersion": 1, + + // Runtime entry points. At least one of wasm / native is required for a + // host-side plugin; a pure-UI plugin may carry only `ui` instead. + "entry": { + // Tier A: the wasip1 module path within the archive (preferred). + "wasm": "plugin.wasm", + // Tier B: per-platform native binaries, keyed "-". + "native": { + "linux-amd64": "bin/windbridge-linux-amd64", + "darwin-arm64": "bin/windbridge-darwin-arm64" + } + }, + + // Capabilities the plugin REQUESTS. The user grants a subset at install time. + // See the capability reference for each cap's parameters. + "capabilities": [ + { "cap": "vessel.write" }, + { "cap": "net.tcp-client", "hosts": ["*.example.com:2000", "${config:host}"] }, + { "cap": "storage", "quota": "2MB" } + ], + + // Optional UI contribution (see the UI plugins page). + "ui": { + "entry": "ui/index.mjs", + "settings": { "type": "object", "properties": { "host": { "type": "string" } } }, + "panels": [{ "id": "wind", "title": "Wind", "icon": "wind" }], + "mapLayers": [{ "id": "wind-arrow", "title": "Wind arrow" }], + "hud": [{ "id": "wind-hud", "title": "Wind HUD" }] + }, + + // Service declarations (Roadmap — see notes below). + "provides": [{ "service": "nmea.source", "apiVersion": 1 }], + "consumes": [], + + // Content-hash map: path -> sha256 for every OTHER file in the archive. + // Verifying the manifest against these verifies the whole zip. + "files": { + "plugin.wasm": "sha256:9f2b…", + "ui/index.mjs": "3ab1…" + } +} +``` + +## Field reference + +### Top level + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `manifestVersion` | int | yes | Must be `1`. | +| `id` | string | yes | Reverse-DNS, `[a-z0-9.-]`, must contain a dot, no leading/trailing dot. `core.*` is rejected for third-party archives. | +| `name` | string | — | Display name. | +| `version` | string | yes | Semver `MAJOR.MINOR.PATCH` with optional `-pre`/`+build`. | +| `description` | string | — | Free text. | +| `publisher` | string | — | Informational only today (no signing/identity check). | +| `license` | string | — | SPDX id or free text. | +| `homepage` | string | — | URL. | +| `apiVersion` | int | yes | Must equal the host's `APIVersion` (**1**). Mismatch → install error. | +| `entry` | object | yes* | Runtime entry points. Required unless the manifest is UI-only. | +| `capabilities` | array | — | Requested capabilities (see below). | +| `ui` | object | — | UI contribution (see [UI plugins](./ui.md)). | +| `provides` | array | — | Service declarations. `nmea.source` is live (see below); others are roadmap. | +| `consumes` | array | — | Service dependencies. **Roadmap**. | +| `files` | object | — | `path → sha256` for every other archive file. Verified on install. | + +\* Validation requires at least one of `entry.wasm`, a non-empty `entry.native`, +or a `ui` block — a manifest with none is rejected ("declares no entry point"). + +### `entry` + +| Field | Type | Notes | +| --- | --- | --- | +| `wasm` | string | Tier A. Path to the `wasip1` module in the archive. Preferred; portable. | +| `native` | object | Tier B. `"-" → path`, e.g. `"linux-amd64"`. The host picks the entry matching its platform. | + +The host selects **WASM by default**, falling back to (or forced onto) native +only when there is no `wasm` entry or `forceNative` is set in the plugin's state +record. Files under `bin/` in the archive get the executable bit on unpack. + +### `capabilities[]` + +Each entry is a `Capability` object. `cap` is required; the other fields are +parameters whose applicability depends on `cap`: + +| Field | Type | Applies to | Notes | +| --- | --- | --- | --- | +| `cap` | string | all | The capability name. Empty `cap` is a validation error. | +| `hosts` | string[] | `net.*` | Allowlist of `host` or `host:port` patterns. Supports a `*.` wildcard prefix and `${config:key}` substitution. | +| `devices` | string[] | `serial` | Device allowlist (resolved at grant). **Serial is Roadmap.** | +| `quota` | string | `storage` | Byte quota, e.g. `"10MB"`, `"512KB"`, `"1024"`. Default 5 MiB if omitted. | + +See the [capability reference](./capabilities.md) for what each cap grants and +which are enforced today. + +### `ui` + +| Field | Type | Notes | +| --- | --- | --- | +| `entry` | string | Path to the UI controller module (served at `/plugins//ui/…`). | +| `settings` | JSON | Settings schema/descriptor for the plugin's config form. | +| `panels` | UISlot[] | Declared panel slots. | +| `mapLayers` | UISlot[] | Declared map-layer slots. | +| `hud` | UISlot[] | Declared HUD slots. | + +A `UISlot` is `{ "id": string, "title"?: string, "icon"?: string }`. See +[UI plugins](./ui.md). + +## Roadmap fields + +These fields parse and validate today but are **not acted on** by the current +host: + +### `provides: nmea.source` — data-source plugins own a connection type + +A plugin declaring `provides: [{ "service": "nmea.source" }]` is treated as a +**connection type**, and the app routes its configuration through the +Connections UI rather than a generic settings form: + +- its row in Settings → Plugins shows a **Connections** button that drills into + the connections view (status badge, pause/resume, raw-sentence sniffer, edit); +- its `ui.settings.items` schema becomes the connection form (text/number + fields), offered in "+ Add connection" alongside the built-in TCP client; +- pausing the connection disables the plugin, and the host clears every vessel + reading the plugin wrote (no phantom own-ship); +- the raw sentences it publishes (`PublishRaw`) feed the same sniffer stream as + built-in connections. + +Declare it plus `vessel.write` / `ais.write` capabilities and publish deltas — +the shell does the rest. + +- **`provides` / `consumes`** — service declarations for cross-plugin + `services.*` calls. The `services.*` call surface is not implemented; declaring + a service has no runtime effect yet. (The reference plugin declares + `provides: [{ "service": "nmea.source" }]` as forward-looking metadata only.) +- **Signing** — there is no `signature`/key field consumed today. ed25519 + signing + TOFU key pinning are Roadmap; `plugins.json` reserves a `pinnedKey` + slot but nothing populates it. + +## See also + +- [Capabilities](./capabilities.md) +- [Packaging & CLI](./packaging.md) — how `files` hashes are verified on install. +- [UI plugins](./ui.md) diff --git a/docs/docs/plugins/overview.md b/docs/docs/plugins/overview.md new file mode 100644 index 0000000..b47c98b --- /dev/null +++ b/docs/docs/plugins/overview.md @@ -0,0 +1,115 @@ +--- +id: plugins-overview +title: Plugins Overview +sidebar_position: 1 +--- + +# Plugins + +Plugins extend chartplotter with new **data sources** (a NMEA feed, a sensor +bridge) and new **UI** (a map overlay, a HUD widget) without forking the app. +A plugin is a small, self-contained package the host installs, verifies, and +runs under an explicit set of **capabilities** the user grants. + +This section documents what the plugin system does **today**. The design spec +(`specs/plugin-system.md`) describes a larger surface; features that are not yet +built are called out as **Roadmap** on each page so you never write against an +API that isn't there. + +## Two tiers + +A host-side plugin ships its logic as one of two runtimes. Both speak the same +[wire protocol](./protocol.md) over stdio, so the code and the [Go SDK](./sdk.md) +are identical between them — only the packaging differs. + +| Tier | Runtime | Sandbox | When | +| --- | --- | --- | --- | +| **A — WASM** (default) | `wasip1` module run in-process by [wazero](https://wazero.io) | Strong: the module's only syscall surface is stdio + a coarse clock. No filesystem, no network, no random source. | The default and recommended tier. Portable — one `plugin.wasm` runs on every host OS/arch. | +| **B — Native** (escape hatch) | A native executable the host spawns as a child process | Weak in this build: no OS-level sandbox yet. Trusted like any local binary. | Only when a plugin needs a native library WASM can't reach. Per-platform binaries. | + +Tier A is preferred because the module is contained: it cannot open a file or a +socket on its own. Every effect — publishing vessel data, dialing a TCP server, +reading storage — flows through a host-mediated capability, so the host is always +in the loop. See the [capability model](./capabilities.md). + +There is also a third, purely front-end kind of plugin — a **UI plugin** — that +ships no host-side code at all, only a browser controller. The built-in own-ship +and AIS overlays are UI plugins. See [UI plugins](./ui.md). + +## The capability model + +Everything a plugin can do is **opt-in**. A manifest *declares* the capabilities +it wants; the user *grants* a subset at install time; and the host **mediates** +every use of a granted capability at runtime. A plugin with no grants can do +nothing but exist. + +Capabilities that are enforced today: + +- `vessel.write` — publish SignalK-style vessel deltas into the shared state. +- `ais.write` — publish AIS target updates. +- `net.tcp-client` — ask the host to dial an allow-listed TCP `host:port`. The + host owns the socket; the plugin only ever sees bytes. +- `storage` — a per-plugin key/value store with a byte quota. + +`raw.publish` (feeding the raw-sentence sniffer) is gated behind holding +`vessel.write` or `ais.write`. Config read/write and status reporting are always +available to a running plugin. + +Declared but **not yet enforced or wired**: `serial` (device I/O returns "not +available in this build"), `net.udp`, `net.http`, `http.register`, and the +`ui.*` capabilities. See the [capability reference](./capabilities.md) for the +full table and status of each. + +## The trust & security model + +- **Content-hash packaging.** A plugin archive's `plugin.json` carries a `files` + map of `path → sha256` covering every other file. Verifying the manifest + verifies the whole zip. This is implemented today. +- **Signing is Roadmap.** ed25519 signatures + trust-on-first-use (TOFU) key + pinning are **not implemented**. v1 does content-hash verification only; there + is no publisher-identity check. +- **WASM containment (Tier A).** A wasip1 module has no ambient authority beyond + stdio and a clock. This is the real sandbox in this build. +- **Native plugins are not sandboxed.** A Tier-B binary runs with your user's + privileges. OS-level native sandboxing is Roadmap. Install native plugins only + from sources you trust. +- **UI plugins run trusted in the main document.** There is no iframe/browser + sandbox around a UI controller; the security gate is at install time, not in + the browser. UI plugins can only touch the map through the declarative `ctx`, + which keeps them off safety-critical S-52 layers, but they share the page. +- **`core.*` is reserved.** Ids under the `core.` prefix are the host's built-in + plugins; third-party archives claiming that prefix are rejected by the installer. +- **Circuit breaker.** A host-side plugin that crashes 5 times within 2 minutes + is auto-disabled. + +## Where plugins live on disk + +Installed plugins live under the server's data directory (``, the XDG +data dir by default): + +``` +/ + plugins.json # install + grant state (enabled, grants, config) + plugins/ + / + / # the unpacked archive (plugin.json, plugin.wasm, ui/…) + data/ # per-plugin persistent storage (survives upgrades) + storage.json # the `storage` capability's KV file +``` + +- `plugins.json` is the state of record: which plugins are installed, enabled, + and what each was granted. See [packaging](./packaging.md). +- Each `//` directory is a verified, unpacked archive. +- `/data/` persists across version upgrades and is only deleted with + `--purge-data`. + +## Read next + +- [Getting started](./getting-started.md) — write, build, and run your first plugin. +- [Manifest reference](./manifest.md) — every field of `plugin.json`. +- [Capabilities](./capabilities.md) — the full capability table. +- [Go SDK](./sdk.md) — the `Plugin` interface and every `Host` method. +- [Wire protocol](./protocol.md) — for authors not using the Go SDK. +- [UI plugins](./ui.md) — the `ctx` surface for browser controllers. +- [Packaging & CLI](./packaging.md) — archives, install, and the CLI verbs. +- [Examples](./examples.md) — complete, runnable worked examples. diff --git a/docs/docs/plugins/packaging.md b/docs/docs/plugins/packaging.md new file mode 100644 index 0000000..0610fe3 --- /dev/null +++ b/docs/docs/plugins/packaging.md @@ -0,0 +1,202 @@ +--- +id: plugins-packaging +title: Packaging & CLI +sidebar_position: 8 +--- + +# Packaging, installing & the CLI + +This page covers turning a plugin into an installable archive, the install/verify +flow, and the full `chartplotter plugin` CLI and HTTP surface. + +## The archive + +A plugin ships as a **zip** whose root contains `plugin.json` plus every file it +references. The manifest's `files` map (`path → sha256`) covers every **other** +file in the archive, so verifying the manifest verifies the whole zip. + +``` +myplugin-1.0.0.zip +├── plugin.json # the manifest, with a `files` sha256 map +├── plugin.wasm # Tier-A entry (entry.wasm) +├── bin/ # Tier-B entries (entry.native), if any — get the exec bit +│ └── myplugin-linux-amd64 +└── ui/ # UI controller + assets (ui.entry), if any + └── index.mjs +``` + +- Files listed in `files` **must** be present with a matching sha256, or install + fails. Files present but **not** listed are allowed (e.g. a future `plugin.sig`). +- Paths are archive-relative; traversal (`..`) is rejected on unpack. +- Files under `bin/` are unpacked executable (`0755`); everything else `0644`. + +### Computing the hashes + +The `files` values are lowercase hex sha256 (an optional `sha256:` prefix is +accepted). For example: + +```bash +sha256sum plugin.wasm ui/index.mjs +``` + +Put each digest in the manifest: + +```jsonc +"files": { + "plugin.wasm": "9f2b…", + "ui/index.mjs": "sha256:3ab1…" +} +``` + +Then zip the tree (manifest at the root): + +```bash +zip -r myplugin-1.0.0.zip plugin.json plugin.wasm ui +``` + +### The `core.*` reservation + +Ids under the `core.` prefix are reserved for the host's built-in plugins. A +third-party archive whose `id` starts with `core.` is **rejected by the +installer**. (In-tree tooling can opt in via an internal `AllowCore` flag; the +public install paths never do.) + +### Signing — Roadmap + +There is **no signing** in this build. Install-time verification is +**content-hash only**. ed25519 signatures + trust-on-first-use (TOFU) key pinning +are planned; `plugins.json` reserves a `pinnedKey` field, but nothing populates +or checks it yet. Treat a plugin archive with the same trust you'd give any +downloaded binary. + +## Installing + +Two front doors, same engine underneath: + +### Via the CLI + +```bash +chartplotter plugin install myplugin-1.0.0.zip +``` + +This **verifies** (hash check), **unpacks** to +`/plugins///`, and records the plugin in `plugins.json` as +**disabled and ungranted** — so you can review its requested capabilities before +it runs. It prints the requested capabilities and the next step. + +Add `--grant-all` to grant every capability the manifest requests and enable in +one shot (handy for dev / trusted plugins): + +```bash +chartplotter plugin install myplugin-1.0.0.zip --grant-all +``` + +### Via the web UI + +Upload the zip through the app's plugins UI, which POSTs it to +`/api/plugins/install`. Same verify + unpack; grants are then set through the UI. + +## Enabling & granting + +- **Enable/disable** flips the `enabled` flag and (when the server owns the + plugin) starts/stops its runner. Via the CLI the change lands in `plugins.json` + and applies on the next server start. +- **Grants** are the user-approved subset of the manifest's capabilities. They're + set through the HTTP API / UI (`PUT /api/plugins//grants`), or all at once + with `install --grant-all`. Grants can change at runtime; a running host-side + plugin is updated live via `host.grantsChanged` (no restart). + +## Removing + +```bash +chartplotter plugin remove # stop, disable, delete the unpacked versions +chartplotter plugin remove --purge-data # also delete the plugin's stored data +``` + +Without `--purge-data`, the plugin's `data/` directory (its `storage` KV, +learned config) is kept so a reinstall resumes where it left off. + +## CLI reference + +The `chartplotter plugin` verb group operates on `/plugins.json` and the +unpacked archives. The CLI is a **state-only** manager (it never spawns runners); +the running server is what actually runs plugins. `enable`/`disable`/`remove` +therefore say "restart the server to apply". + +| Verb | Args | Flags | Does | +| --- | --- | --- | --- | +| `install` | `` | `--data `, `--grant-all` | Verify + unpack + record (disabled). `--grant-all` grants all requested caps and enables. | +| `list` | — | `--data ` | List installed plugins: id, version, state, name. | +| `enable` | `` | `--data ` | Mark enabled. | +| `disable` | `` | `--data ` | Mark disabled. | +| `remove` | `` | `--data `, `--purge-data` | Uninstall; `--purge-data` also deletes stored data. | +| `dev` | `` | `--config ` | Run an **unpacked** directory under the real broker with auto-restart. Grants everything the manifest declares. Prints capability effects. | + +`--data` overrides the data directory (default: the XDG data dir). `dev`'s +`--config` takes the plugin's runtime config as JSON, e.g. +`--config '{"host":"127.0.0.1","port":10110}'`. + +```bash +chartplotter plugin list +# example.heartbeat v1.0.0 enabled Heartbeat +# com.example.windbridge v1.2.0 disabled Wind Bridge +``` + +## HTTP API reference + +The server exposes the management surface under `/api/plugins`, plus per-plugin +static serving. (These back the web UI.) + +| Method & path | Does | +| --- | --- | +| `GET /api/plugins` | List installed plugins with manifest + grant state + live status. | +| `POST /api/plugins/install` | Multipart upload (form field `plugin`) → verify → unpack. | +| `GET /api/plugins/stream` | SSE: pushes the plugin status map when it changes. | +| `POST /api/plugins//enable` | Enable + start the runner. | +| `POST /api/plugins//disable` | Disable + stop the runner. | +| `PUT`/`POST` `/api/plugins//grants` | Body `{ grants, config }` — set the grant set (and optionally config); hot-applied to a running plugin. | +| `PUT`/`POST` `/api/plugins//config` | Body: a config map — update config only, keeping grants. | +| `DELETE /api/plugins/` | Uninstall; `?purgeData=1` also deletes stored data. | +| `GET /plugins//ui/*` | Serve the plugin's unpacked `ui/` directory (UI controller + assets). | +| `GET /plugins//serve/*` | Serve runtime-published artifacts from the plugin's `data/serve/` directory. | + +## State: `plugins.json` + +Install/grant state lives in `/plugins.json` as a sorted JSON array of +records: + +```jsonc +[ + { + "id": "com.example.windbridge", + "version": "1.2.0", // the active unpacked version + "enabled": true, + "grants": [ // the user-approved subset of the manifest caps + { "cap": "vessel.write" }, + { "cap": "net.tcp-client", "hosts": ["10.0.0.5:2000"] } + ], + "config": { "server": "10.0.0.5:2000" }, + "forceNative": false, // prefer the native entry over wasm + "pinnedKey": "" // Roadmap: TOFU publisher key (unused today) + } +] +``` + +The manifest itself is **not** stored here — it lives with the unpacked archive +on disk and is re-read as needed. + +## Lifecycle & resilience + +- A host-side plugin runs under a supervised runner: it's (re)started with a + 1 s → 30 s backoff if it exits, pinged every 15 s (3 missed pings → restart), + and asked to shut down gracefully (5 s grace) on stop. +- **Circuit breaker:** 5 crashes within 2 minutes auto-disables the plugin and + surfaces an error status. +- **UI-only plugins** (no `wasm`/`native` entry) have no host runner — enabling + them is purely a state flag; the frontend loads the controller. + +## See also + +- [Manifest](./manifest.md) — the `files` map and `id` rules. +- [Capabilities](./capabilities.md) — what you're granting. +- [Getting started](./getting-started.md) — the `dev` loop. diff --git a/docs/docs/plugins/protocol.md b/docs/docs/plugins/protocol.md new file mode 100644 index 0000000..802a47f --- /dev/null +++ b/docs/docs/plugins/protocol.md @@ -0,0 +1,182 @@ +--- +id: plugins-protocol +title: Wire Protocol +sidebar_position: 6 +--- + +# Wire protocol + +The [Go SDK](./sdk.md) is the easy path, but the protocol is a plain, +language-agnostic contract: **newline-delimited JSON-RPC 2.0 over stdio**. Any +language that can read stdin, write stdout, and emit JSON can implement a plugin +directly. This page documents the wire surface **as implemented today** — only +methods the host actually handles are listed. + +## Transport & framing + +- The host launches the plugin (a WASM module via wazero, or a native child + process) and connects to its **stdio**: the plugin reads requests on **stdin** + and writes replies/notifications on **stdout**. +- **Framing is NDJSON**: one JSON-RPC 2.0 object per line, `\n`-terminated. This + is the only framing negotiated in this build. (`lpbin`, a length-prefixed + binary framing, is mentioned in the design but **not implemented** — do not + offer it.) +- **stderr is a log stream**: one record per line. A line that parses as + `{"level":"…","msg":"…"}` keeps its structure; anything else is logged at + `info`. The host tags each with your plugin id. +- Binary payloads (TCP/serial bytes) are carried as JSON base64 strings in the + `data` field. + +## Message shapes + +Every line is one JSON-RPC 2.0 object. Which of three shapes it is follows from +the fields present: + +| Shape | Has `method` | Has `id` | Meaning | +| --- | --- | --- | --- | +| request | yes | yes | expects a matching response | +| notification | yes | no | fire-and-forget | +| response | no | yes | reply to a prior request (`result` xor `error`) | + +```jsonc +// request +{ "jsonrpc": "2.0", "id": 1, "method": "host.hello", "params": { … } } +// notification +{ "jsonrpc": "2.0", "method": "vessel.publish", "params": { … } } +// success response +{ "jsonrpc": "2.0", "id": 1, "result": { … } } +// error response +{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32000, "message": "…" } } +``` + +`id` may be a string or number and round-trips untouched. + +### Error codes + +| Code | Name | Meaning | +| --- | --- | --- | +| `-32700` | ParseError | malformed JSON | +| `-32600` | InvalidRequest | not a valid request | +| `-32601` | MethodNotFound | unknown method — SDKs surface this as "capability not available" | +| `-32602` | InvalidParams | bad params | +| `-32603` | InternalError | host-side failure (e.g. dial error) | +| `-32000` | CapabilityDenied | a granted-capability check failed | +| `-32001` | ProviderStopped | service call to a stopped provider (Roadmap) | +| `-32002` | HandleUnknown | `io.close` / `*.send` for an unknown handle | + +**Forward-compat rule:** ignore unknown *notifications* and unknown *fields*; +answer unknown *methods* with `MethodNotFound`. Additive changes (new methods, +fields, capabilities) do **not** bump the API version. + +## The handshake + +The **host speaks first**. It sends `host.hello` (a request) offering the API +majors and framings it supports, plus this plugin's current grants and config: + +```jsonc +// host → plugin +{ "jsonrpc": "2.0", "id": 1, "method": "host.hello", "params": { + "apiVersions": [1], + "pluginId": "com.example.windbridge", + "grants": [ { "cap": "vessel.write" }, { "cap": "net.tcp-client", "hosts": ["10.0.0.5:2000"] } ], + "config": { "server": "10.0.0.5:2000" }, + "framing": ["ndjson"] +} } +``` + +The plugin replies with the major it picked and the framing it will use — it +**must** answer `"ndjson"`: + +```jsonc +// plugin → host +{ "jsonrpc": "2.0", "id": 1, "result": { "apiVersion": 1, "framing": "ndjson" } } +``` + +After the reply, the plugin is live. The host may then send `plugin.ping` +periodically and `plugin.shutdown` to stop it. Grants/config changes arrive at +runtime as a `host.grantsChanged` notification. + +## Method tables + +Direction: **→** host-to-plugin, **←** plugin-to-host. + +### Lifecycle & meta + +| Method | Dir | Kind | Params → Result | +| --- | --- | --- | --- | +| `host.hello` | → | req | `HostHello` → `{ apiVersion, framing }` | +| `plugin.ping` | → | req | `{}` → `{ ok: true }` (liveness) | +| `plugin.shutdown` | → | req | `{}` → `{ ok: true }`, then exit | +| `host.grantsChanged` | → | notif | `{ grants, config }` — new grant set + config | +| `config.get` | ← | req | `{}` → the plugin's config object | +| `config.set` | ← | req | `{ key, value }` → `{ ok: true }` (store a plugin-learned value) | +| `status.update` | ← | notif | `{ state, detail?, metrics? }` | + +### Vessel / AIS / raw (data plane) + +| Method | Dir | Kind | Params | Requires | +| --- | --- | --- | --- | --- | +| `vessel.publish` | ← | notif | `{ deltas: [ { path, value, ts? } ] }` | `vessel.write` | +| `ais.publish` | ← | notif | `{ targets: [ AISTarget ] }` | `ais.write` | +| `raw.publish` | ← | notif | `{ lines: [ string ] }` | `vessel.write` or `ais.write` | + +Deltas use the [writable vessel paths](./sdk.md#vessel-paths). An ungranted +notification is silently dropped (no reply channel). + +### Transports (host-mediated) + +| Method | Dir | Kind | Params → Result | Requires | +| --- | --- | --- | --- | --- | +| `tcp.connect` | ← | req | `{ host, port }` → `{ handle }` | `net.tcp-client` (+ allowlist) | +| `tcp.send` | ← | notif | `{ handle, data(base64), n }` | (open handle) | +| `tcp.data` | → | notif | `{ handle, data(base64), n }` — inbound chunk | | +| `io.close` | ← | req | `{ handle }` → `{ ok: true }` | | +| `io.closed` | → | notif | `{ handle, reason }` — peer/device closed or errored | | + +The host reads **chunks** from the socket, not lines — line framing is the +plugin's job. + +### Storage + +Requires the `storage` capability. + +| Method | Dir | Kind | Params → Result | +| --- | --- | --- | --- | +| `storage.get` | ← | req | `{ key }` → `{ value, found }` | +| `storage.set` | ← | req | `{ key, value }` → `{ ok: true }` (or error past quota) | +| `storage.delete` | ← | req | `{ key }` → `{ ok: true }` | +| `storage.list` | ← | req | `{}` → `{ keys: [ string ] }` | + +## Roadmap methods + +Defined in the protocol constants but **not handled** by this build — do not rely +on them: + +- `config.changed` (→ notif) — settings-edited signal; today config changes are + delivered via `host.grantsChanged` instead. +- `serial.list` (←) — returns an **empty** port list (serial not wired). +- `serial.open` (←) — returns `-32601` "serial transport not available in this build". +- `serial.data` / any serial I/O — not delivered. +- Anything under `net.udp`, `net.http`, `http.register`, or `services.*` — no + handler exists. + +## Minimal example exchange + +```jsonc +// ← the plugin answers the handshake +{ "jsonrpc": "2.0", "id": 1, "result": { "apiVersion": 1, "framing": "ndjson" } } +// ← ask the host to dial +{ "jsonrpc": "2.0", "id": 100, "method": "tcp.connect", "params": { "host": "10.0.0.5", "port": 2000 } } +// → host replies with a handle +{ "jsonrpc": "2.0", "id": 100, "result": { "handle": 1 } } +// → inbound bytes arrive +{ "jsonrpc": "2.0", "method": "tcp.data", "params": { "handle": 1, "data": "JEdQR0dBLC4uLg0K", "n": 12 } } +// ← publish parsed vessel state (batched) +{ "jsonrpc": "2.0", "method": "vessel.publish", "params": { "deltas": [ + { "path": "navigation.position", "value": { "lat": 38.97, "lon": -76.49 } }, + { "path": "navigation.sog", "value": 5.4 } +] } } +``` + +The protocol is fully implementable from this page and the spec's Appendix A.1 — +you do not need the Go SDK. But if you're writing in Go, [use it](./sdk.md). diff --git a/docs/docs/plugins/sdk.md b/docs/docs/plugins/sdk.md new file mode 100644 index 0000000..b2bcc9a --- /dev/null +++ b/docs/docs/plugins/sdk.md @@ -0,0 +1,281 @@ +--- +id: plugins-sdk +title: Go SDK +sidebar_position: 5 +--- + +# Go SDK + +The `sdk` package (`github.com/beetlebugorg/chartplotter/sdk`) is the thin Go +client for writing host-side plugins. You implement the `Plugin` interface and +call `sdk.Run`; the SDK owns the [handshake](./protocol.md), ping/shutdown, +data-plane batching, and request/response plumbing, so you write only your logic. + +The same code builds for **Tier A** (`GOOS=wasip1 GOARCH=wasm`) and **Tier B** +(a normal native build). + +## The event-driven, single-threaded model + +This is the most important thing to understand about the SDK, and it is a **hard +requirement**, not a style preference: + +- A Tier-A wasip1 module is **one cooperatively-scheduled thread**. A blocking + stdin read halts the whole module. +- Therefore the SDK **never** depends on a background goroutine or timer running + concurrently with the read loop. Everything happens **on the read loop**. +- Incoming host messages drive your callbacks. Plugin→host **requests resolve + asynchronously** — you pass a callback that runs later, when the reply arrives. +- Buffered publishes **flush after each handled message**: one chunk of inbound + bytes → one batched publish. That is exactly the batching the protocol wants. + +Practical rules: + +- **Do not** start goroutines, `time.Sleep`, `time.Ticker`, or block in `Start` + or any callback. `Start` must register handlers / kick off async work and + return. +- Producer plugins are always driven by host-delivered I/O (e.g. TCP data on + stdin), so per-message flushing covers you. +- Host calls that return data (`TCPConnect`, `StorageGet`, …) are **async**: + they take a callback that fires on the read loop when the response lands. + +## The `Plugin` interface + +```go +type Plugin interface { + // Start runs once after the handshake, on the read-loop goroutine. + // It must NOT block — register handlers / kick off async connects and return. + Start(h *Host) + // Stop is called on graceful shutdown. + Stop() +} +``` + +Your `main` hands an instance to `sdk.Run`, which drives the loop until the host +closes stdin or sends `plugin.shutdown`: + +```go +func main() { + if err := sdk.Run(&myPlugin{}); err != nil { + panic(err) + } +} +``` + +## Reacting to live config edits (`ConfigWatcher`) + +Config is **hot-applied**: when the user (or your UI half) writes settings, the +host pushes the new values to your running instance — there is no restart. If +your plugin should react (reconnect, refetch, re-centre), implement the optional +`ConfigWatcher` interface next to `Plugin`: + +```go +// Called on the read-loop goroutine after Host.Config() reflects the new values. +func (p *myPlugin) ConfigChanged() { + if addr := p.h.ConfigString("server"); addr != p.current { + p.reconnect(addr) + } +} +``` + +Patterns that build on this: + +- **UI-driven state** — your UI half writes config keys (via + `POST /api/plugins//config`) that the WASM half watches. The weather + plugin's sailing-area centre (`hiLat`/`hiLon`, written from the GPS fix) works + this way. +- **Refresh nonce** — a UI "refresh" button writes a changing `refresh` value; + `ConfigChanged` sees it differ from the last seen value and re-pulls sources. + Pair in-flight async chains with a generation counter so a newer trigger + cleanly supersedes an older one. + +## Re-exported types + +The SDK re-exports the wire types so you import only `sdk`: + +```go +type ( + Capability = plugin.Capability // { Cap, Hosts, Devices, Quota } + Delta = plugin.Delta // { Path, Value(json.RawMessage), Ts } + AISTarget = plugin.AISTargetDTO // MMSI, Lat, Lon, COG, SOG, … +) +``` + +## `Host` methods + +`Host` is your handle to the broker. All methods run on the read loop. + +### Config & grants + +| Method | Signature | Notes | +| --- | --- | --- | +| `Config` | `Config() map[string]any` | The plugin's current settings. Treat as read-only. | +| `ConfigString` | `ConfigString(key string) string` | A string setting, or `""`. | +| `HasGrant` | `HasGrant(cap string) bool` | Whether the plugin holds a capability. Check before using it. | + +```go +func (p *myPlugin) Start(h *sdk.Host) { + server := h.ConfigString("server") // e.g. "10.0.0.5:2000" + if !h.HasGrant("net.tcp-client") { + h.Status("degraded", "net.tcp-client not granted") + return + } + _ = server +} +``` + +Config and grants are kept live: when the user edits settings or changes grants, +the SDK updates them from the `host.grantsChanged` notification automatically. + +### Data plane (batched) + +| Method | Signature | Capability | +| --- | --- | --- | +| `PublishVessel` | `PublishVessel(deltas ...Delta)` | `vessel.write` | +| `PublishAIS` | `PublishAIS(targets ...AISTarget)` | `ais.write` | +| `PublishRaw` | `PublishRaw(lines ...string)` | gated behind `vessel.write`/`ais.write` | +| `DeltaOf` | `DeltaOf(path string, value any) Delta` (package func) | build a delta from any JSON-serialisable value | + +These **queue**; the SDK flushes the buffers once per handled host message. You +don't call flush yourself. + +```go +h.PublishVessel( + sdk.DeltaOf("navigation.position", map[string]float64{"lat": 38.97, "lon": -76.49}), + sdk.DeltaOf("navigation.sog", 5.4), + sdk.DeltaOf("navigation.cogTrue", 271.0), +) +``` + +### Status & logging + +| Method | Signature | Notes | +| --- | --- | --- | +| `Status` | `Status(state, detail string)` | Report health. `state` is `running` / `degraded` / `error`; surfaces in the plugins/connections UI. | +| `Log` | `Log(level, msg string)` | Structured log line. The host tags it with your plugin id, keeps the last 400 lines in a ring served at `GET /api/plugins//logs`, and shows them in the plugin's **Logs** view (Settings → Plugins → Logs) with level/text filtering. Log the events a user debugging your plugin would need: source cycles found, artifacts published, fetch failures. | + +```go +h.Status("running", "connected to "+server) +h.Log("warn", "checksum mismatch, dropping sentence") +``` + +### Transports (async, host-mediated) + +The host owns the socket; you only see bytes. `net.tcp-client` is the only +transport wired today. + +```go +type TCPHandlers struct { + OnConnect func(handle int) + OnData func(handle int, data []byte) + OnError func(handle int, err error) +} +``` + +| Method | Signature | Notes | +| --- | --- | --- | +| `TCPConnect` | `TCPConnect(host string, port int, hnd TCPHandlers)` | Asks the host to dial (subject to the `hosts` allowlist). The result → `OnConnect`/`OnError`; inbound chunks → `OnData`; peer close/error → `OnError`. | +| `TCPSend` | `TCPSend(handle int, data []byte)` | Write outbound bytes to a handle. | +| `CloseHandle` | `CloseHandle(handle int)` | Release a transport handle. | + +The host delivers **chunks, not lines** — line framing is your job. Buffer across +`OnData` calls: + +```go +func (p *client) Start(h *sdk.Host) { + p.h = h + host, port := "10.0.0.5", 2000 + h.TCPConnect(host, port, sdk.TCPHandlers{ + OnConnect: func(handle int) { p.handle = handle; h.Status("running", "connected") }, + OnData: func(_ int, data []byte) { p.onChunk(data) }, + OnError: func(_ int, err error) { h.Status("degraded", "closed") }, + }) +} + +func (p *client) onChunk(chunk []byte) { + p.buf += string(chunk) + for { + i := strings.IndexByte(p.buf, '\n') + if i < 0 { break } + line := strings.TrimSpace(p.buf[:i]) + p.buf = p.buf[i+1:] + if line != "" { p.handleLine(line) } + } +} +``` + +### Storage (async) + +Requires the `storage` capability. Values are raw JSON. + +| Method | Signature | +| --- | --- | +| `StorageGet` | `StorageGet(key string, cb func(value json.RawMessage, found bool, err error))` | +| `StorageSet` | `StorageSet(key string, value json.RawMessage, cb func(error))` | + +```go +h.StorageGet("lastMMSI", func(v json.RawMessage, found bool, err error) { + if err != nil || !found { return } + var mmsi uint32 + _ = json.Unmarshal(v, &mmsi) + // … use mmsi on the read loop … +}) + +b, _ := json.Marshal(uint32(366123456)) +h.StorageSet("lastMMSI", b, func(err error) { + if err != nil { h.Log("warn", "storage quota exceeded") } +}) +``` + +> **Roadmap:** `StorageDelete` and `StorageList` exist as [protocol methods](./protocol.md) +> but the SDK does not expose helper wrappers yet — use them only if you drop to +> the raw protocol. + +## Vessel paths + +`PublishVessel` accepts only the **writable** dotted paths below (unknown paths +are dropped by the host). This is the `vessel.write` schema +(`internal/engine/nmea/publish.go`). + +| Path | Value type | +| --- | --- | +| `navigation.position` | object `{ "lat": number, "lon": number }` | +| `navigation.cogTrue` | number (degrees) | +| `navigation.sog` | number | +| `navigation.headingTrue` | number | +| `navigation.headingMagnetic` | number | +| `navigation.magneticVariation` | number | +| `navigation.rateOfTurn` | number | +| `navigation.speedThroughWater` | number | +| `navigation.datetime` | RFC 3339 timestamp string | +| `environment.depth.belowTransducer` | number | +| `environment.depth.belowKeel` | number | +| `environment.depth.belowSurface` | number | +| `environment.water.temperature` | number | +| `environment.wind.angleApparent` | number | +| `environment.wind.speedApparent` | number | +| `environment.wind.angleTrue` | number | +| `environment.wind.speedTrue` | number | +| `environment.wind.directionTrue` | number | +| `route.xte` | number | +| `route.bearingToWaypoint` | number | +| `route.distanceToWaypoint` | number | +| `route.activeWaypoint` | string | + +Writes are **attributed** to your plugin id, so the app can show provenance and +arbitrate against built-in NMEA sources (latest-wins per path today). + +## Building + +```bash +# Tier A (WASM, preferred) +GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o plugin.wasm ./plugins/ + +# Tier B (native) — a normal build for the target platform +go build -o bin/-linux-amd64 ./plugins/ +``` + +## See also + +- [Getting started](./getting-started.md) — end-to-end build & run. +- [Protocol](./protocol.md) — the wire methods the SDK wraps. +- [Examples](./examples.md) — the NMEA 0183 plugin, walked through. diff --git a/docs/docs/plugins/ui.md b/docs/docs/plugins/ui.md new file mode 100644 index 0000000..8583379 --- /dev/null +++ b/docs/docs/plugins/ui.md @@ -0,0 +1,322 @@ +--- +id: plugins-ui +title: UI Plugins +sidebar_position: 7 +--- + +# UI plugins + +A **UI plugin** is a browser-side controller that draws on the chart — a map +overlay, a HUD widget, a settings form. It ships as an ES module and runs in the +frontend, driven only by a small **declarative `ctx`**. It can carry host-side +code too (a WASM/native entry), but the UI half is independent: the built-in +own-ship and AIS overlays are pure UI plugins with no host-side runtime. + +> **Trust model.** UI plugins run **trusted in the main document** — there is no +> iframe or browser sandbox. The security gate is at install time. What keeps a UI +> plugin contained is the `ctx`: it never gets the raw MapLibre `map` or the +> plotter, so it can't paint over safety-critical S-52 layers or reach app +> internals. See [capabilities](./capabilities.md#a-note-on-the-ui-capabilities). + +## The controller convention + +A UI plugin's module **default-exports a class** following the same convention +the built-ins use: + +```js +export default class MyOverlay { + constructor(ctx) { + this.ctx = ctx; // your only handle to the app + } + start() { // called once after construction; set up here + // subscribe to data, add layers/markers, mount HUD… + } + destroy() { // called on unload; tear down what start() created + } +} +``` + +- `constructor(ctx)` — stash `ctx`; do not do heavy work yet. +- `start()` — subscribe, add layers/markers, mount UI. May be async. +- `destroy()` — release anything the host doesn't auto-track (see below). + +The host loads the module, builds the `ctx`, `new`s the class, and calls +`start()`. On unload it calls `destroy()`, then runs every `ctx`-tracked cleanup +and removes all your layers. + +### What the host cleans up vs. what you own + +The host **auto-tracks and tears down**: layers you `add`, gesture/anchor +listeners, mounted HUD/panel elements, vessel subscriptions, and settings +registrations. **You own** markers and any timers / `requestAnimationFrame` you +start — a busy AIS feed creates and drops many markers, so the host doesn't track +them. Remove your markers and clear your timers in `destroy()`. + +## Loading & serving + +- **Built-ins** are statically imported and registered by the shell (own-ship as + `core.own-ship`, AIS as `core.ais`). +- **Installed UI plugins** declare `ui.entry` in the [manifest](./manifest.md); + the frontend dynamically `import()`s it from `/plugins//ui/…`. That path + serves the plugin's unpacked `ui/` directory (static files, with the right + `.mjs`/`.wasm` MIME types and range support). A separate `/plugins//serve/…` + path serves runtime-published artifacts from the plugin's data dir. + +```jsonc +"ui": { + "entry": "ui/index.mjs", + "mapLayers": [{ "id": "my-overlay", "title": "My overlay" }] +} +``` + +The default export of `ui/index.mjs` is the controller class. + +## The `ctx` reference + +`ctx` is the entire surface a controller gets. Every handle below is real and +implemented in `web/src/core/plugin-host.mjs`. + +### `ctx.plugin` + +`ctx.plugin.log(level, ...args)` writes to the console **and** a per-plugin ring +the Plugins panel's Logs viewer merges (tagged `[ui]`) with your WASM half's +server-captured `Log` lines — one timeline for both halves of the plugin. + + +| Member | Signature | Notes | +| --- | --- | --- | +| `id` | string | the plugin id | +| `version` | string | | +| `log` | `log(level, ...args)` | console log tagged `[plugin ]`; `level` `"error"`/`"warn"`/other | + +### `ctx.vessel` — live own-ship state + +| Member | Signature | Notes | +| --- | --- | --- | +| `get` | `get() → state` | current vessel state (`{ navigation, environment, route }`) or undefined | +| `subscribe` | `subscribe(fn) → off` | `fn(state)` on change (≤ 4 Hz coalesced). Auto-tracked. | + +```js +this.ctx.vessel.subscribe((s) => { + const pos = s?.navigation?.position; + if (pos) this.marker.setLngLat([pos.lon, pos.lat]); +}); +this._update(this.ctx.vessel.get()); // prime with the current snapshot +``` + +### `ctx.ais` — live AIS targets + +| Member | Signature | Notes | +| --- | --- | --- | +| `subscribe` | `subscribe(fn) → off` | `fn(targets[])` from the coalesced server feed (EventSource + poll fallback). Auto-tracked. | + +Targets carry `mmsi`, `lat`, `lon`, `cog`, `sog`, `heading`, `name`, `shipType`, +`typeName`, `destination`, `length`, `beam`, `draught`, `status`, `class`, and +(when computed) `danger`/`cpaNm`/`tcpaMin`. Skip targets with no position. + +### `ctx.layers` — declarative GeoJSON layers + +| Member | Signature | Returns | +| --- | --- | --- | +| `add` | `add(layerId, spec) → { setData, remove }` | a layer handle | + +You describe layers; you never touch MapLibre. The host creates the GeoJSON +source + style layers, inserts them in the chosen **z-band**, and re-adds + +re-seeds them after a style rebuild — so you never hand-roll style-reload +self-healing. + +`spec`: + +```js +{ + band: "overlay" | "top", // z-band; default "overlay" + // single-layer shorthand: + type, paint, layout, + // …or several layers sharing one source: + layers: [ { type, paint, layout }, … ], +} +``` + +The handle: + +- `setData(featureCollection)` — swap the GeoJSON. +- `remove()` — drop it (also auto-removed on unload). + +```js +this.line = this.ctx.layers.add("track", { + band: "overlay", + layers: [ + { type: "line", paint: { "line-color": "#fff", "line-width": 4 } }, + { type: "line", paint: { "line-color": "#16324f", "line-width": 1.8, "line-dasharray": [2, 1.8] } }, + ], +}); +this.line.setData({ type: "FeatureCollection", features: [ /* … */ ] }); +``` + +**Z-bands** (fixed — you select, never extend): + +| Band | Sits | +| --- | --- | +| `overlay` | beneath chart text/symbol labels — the **safe default** (own-ship's predictor lives here) | +| `top` | above everything | + +Plugin layers can't be placed above the S-52 labels; that's deliberate. + +### `ctx.markers` — DOM glyph markers + +| Member | Signature | Returns | +| --- | --- | --- | +| `add` | `add(markerId, opts) → handle` | a chainable marker handle | + +`opts`: `{ rotationAlignment?: "map"|"viewport", anchor?: "center"|… }`. + +Handle methods (chainable): `element` (the DOM node), `setHTML(html)`, +`setStyle(css)`, `setLngLat([lng,lat])`, `setRotation(deg)`, `onClick(fn)`, +`show()`, `hide()`, `remove()`. + +> **You own marker teardown** — call `remove()` in `destroy()` (or when a target +> drops out). + +```js +this.marker = this.ctx.markers.add("me", { rotationAlignment: "map", anchor: "center" }); +this.marker.setHTML(OWN_SHIP_MARKER).setStyle("pointer-events:auto;cursor:pointer"); +this.marker.onClick((e) => { e.stopPropagation(); this._select(e); }); +this.marker.setLngLat([lon, lat]).setRotation(headingDeg); +``` + +### `ctx.camera` — camera & follow + +| Member | Signature | Notes | +| --- | --- | --- | +| `follow` | `follow(fix)` | keep the camera on `fix` (`{ lng, lat, … }`) | +| `easeTo` | `easeTo(opts)` | animate the camera (`{ center, zoom, duration }`) | +| `getZoom` | `getZoom() → number` | | +| `project` | `project([lng,lat]) → {x,y}` | geo → screen pixels | +| `containerHeight` | `containerHeight() → px` | map container height | +| `onGesture` | `onGesture(fn) → off` | fires on **real** user pan/rotate (not programmatic eases) — for follow break-out. Auto-tracked. | +| `registerFollowAnchor` | `registerFollowAnchor(fn) → off` | contribute the point wheel-zoom should keep fixed; `fn()` returns `[lng,lat]` or null. Auto-tracked. | + +### `ctx.hud` / `ctx.panels` — floating overlay UI + +| Member | Signature | Returns | +| --- | --- | --- | +| `hud.mount` | `mount(slotId) → element` | a fresh element in the shell chrome | +| `panels.mount` | `mount(slotId) → element` | same | + +The returned element lives in the shell's shadow DOM, so **theme CSS variables +inherit** (style with `var(--ui-accent, …)`, `var(--topbar-h)`, etc.). The mount +is auto-removed on unload. + +```js +const mount = this.ctx.hud.mount("wind"); +mount.innerHTML = `
`; +``` + +### `ctx.settings` — settings contributions + +| Member | Signature | Notes | +| --- | --- | --- | +| `register` | `register(descriptor) → off` | contribute a settings entry; the id is auto-scoped to your plugin. Auto-tracked. | + +### `ctx.notify` — notification center + +`notify.info(msg)`, `notify.warn(msg)`, `notify.error(msg)`. + +### `ctx.callout` — info picker / pick report + +| Member | Signature | Notes | +| --- | --- | --- | +| `show` | `show(info)` | pop the target-info callout: `{ title, subtitle?, rows: [[label, value], …], x, y }` | + +```js +this.ctx.callout.show({ + title: "Own ship", + rows: [["Position", fmtLatLon(lat, lng)], ["SOG", this.ctx.units.format("speed", sog)]], + x: e.clientX, y: e.clientY, +}); +``` + +### `ctx.taps` — chart tap arbitration + +| Member | Signature | Notes | +| --- | --- | --- | +| `claim` | `claim(fn) → unregister` | `fn(e)` is offered every chart tap (`e` is the MapLibre click event: `lngLat`, `point`, `originalEvent`). Return `true` to consume it — the ECDIS pick report and later claimants don't fire. Return anything else to pass. | + +Claim taps only while your overlay is **active**; pass when it isn't, so the +chart behaves as if your plugin weren't installed. The wind probe is the model: + +```js +ctx.taps.claim((e) => { + if (!this._on) return false; // layer hidden → not our tap + this._setProbe(e.lngLat); + return true; // consumed: no pick report +}); +``` + +The unregister is tracked by the host and runs on plugin unload. + +### `ctx.units` — unit-aware formatting + +| Member | Signature | Notes | +| --- | --- | --- | +| `format` | `format(kind, value) → string` | formats honoring the live mariner prefs; `kind` e.g. `"speed"`, `"depth"`, `"distance"` | + +## Theming + +Style with the app's CSS custom properties so your UI tracks day/dusk/night and +the shell layout. Commonly used: `--ui-accent` / `--ui-accent-text`, `--topbar-h` +/ `--botbar-h`, and (for AIS-style glyphs) `--ais-fill` / `--ais-halo` / +`--ais-danger`. HUD/panel mounts and markers inherit these. + +## Worked example: a HUD + map-layer overlay + +A compact controller that draws a track line and a SOG HUD chip. It mirrors the +shape of the built-in [own-ship](./examples.md#ui-only-plugin-track-line--sog-hud) +module — read that for the full, production version. + +```js +export default class TrackHud { + constructor(ctx) { this.ctx = ctx; this._pts = []; } + + start() { + // Map layer in the safe overlay band. + this._line = this.ctx.layers.add("track", { + band: "overlay", + layers: [{ type: "line", paint: { "line-color": "var(--ui-accent,#2f81f7)", "line-width": 2 } }], + }); + + // HUD chip in the shell chrome (theme vars inherit). + const mount = this.ctx.hud.mount("track"); + this._chip = document.createElement("div"); + this._chip.style.cssText = + "position:absolute;right:12px;bottom:calc(var(--botbar-h,0px)+12px);" + + "padding:6px 12px;border-radius:16px;background:var(--ui-accent,#2f81f7);" + + "color:var(--ui-accent-text,#fff);font:600 12px system-ui"; + mount.appendChild(this._chip); + + // Live data. + this.ctx.vessel.subscribe((s) => this._update(s)); + this._update(this.ctx.vessel.get()); + } + + _update(s) { + const nav = s?.navigation, pos = nav?.position; + if (!pos) return; + this._pts.push([pos.lon, pos.lat]); + if (this._pts.length > 500) this._pts.shift(); + this._line.setData({ + type: "FeatureCollection", + features: [{ type: "Feature", geometry: { type: "LineString", coordinates: this._pts } }], + }); + this._chip.textContent = "SOG " + this.ctx.units.format("speed", nav.sog ?? 0); + } + + destroy() { /* host removes the layer + HUD mount + subscription; nothing else to own */ } +} +``` + +## See also + +- [Examples](./examples.md) — own-ship and AIS, the real built-ins. +- [Manifest](./manifest.md) — the `ui` block. +- [Packaging](./packaging.md) — how the `ui/` directory ships and is served. diff --git a/docs/docs/plugins/weather-grid.md b/docs/docs/plugins/weather-grid.md new file mode 100644 index 0000000..ac8a8f8 --- /dev/null +++ b/docs/docs/plugins/weather-grid.md @@ -0,0 +1,136 @@ +--- +id: weather-grid +title: "weather.grid: consuming weather data" +--- + +# The `weather.grid` service + +The weather plugin doesn't keep its forecasts to itself: every grid it decodes is +published as a **served artifact** any other plugin (or external client) can +fetch and sample. This page is the contract. If you want forecast wind, gusts, +temperature, cloud cover, or barometric pressure in your own plugin — routing, +anchor-watch wind alarms, polar performance overlays — consume this; don't +re-download GRIB. + +## How the provider works + +The bundled provider (`org.beetlebug.weather`) publishes three layers, +best-first: an HRRR 3 km window (~5°×6°) centred on the vessel — or the viewed +area when there's no fix — with wind + gust to +18 h; an HRRR ~15 km +CONUS-wide resample of the same records; and a GFS 0.25° base over North +America with wind, gust, 2 m temperature, cloud, and MSL pressure to +48 h. + +Both models come from NOAA's open-data S3 archives by **byte-ranging** exactly +the needed GRIB records (offsets from the `.idx` files) — never whole +multi-hundred-MB products. HRRR's Lambert-conformal grid (template 3.30) is +decoded natively, resampled to regular lat/lon, and its grid-relative winds are +rotated to earth-relative (skipping that skews directions by >10° across the +domain; see `plugins/core.weather/grib/lambert.go`). Cycles are auto-discovered +by walking back from the wall clock; a `refresh` config nonce (written by the +UI's ↻ button) re-resolves them via `ConfigWatcher`, with generation counters +cancelling superseded fetch chains. The Go-encode → JS-parse byte layout is +locked by `plugins/core.weather/pipeline_test.go`. + +## Discovery + +Find a provider through the plugin registry: `GET /api/plugins`, look for a +plugin whose manifest declares + +```json +"provides": [{ "service": "weather.grid", "apiVersion": 1 }] +``` + +then fetch its index at `GET /plugins//serve/weather.json`: + +```json +{ + "service": "weather.grid", + "apiVersion": 1, + "format": "WGRD/4", + "layers": [ + { "name": "window", "url": "wind-hi.bin", "model": "HRRR 3 km", + "refTime": "2026-07-17T08:00:00Z", "hours": [0,1,2,3,4,6,9,12,15,18], + "fields": ["wind10m","gust"], + "grid": { "nx": 201, "ny": 168, "lo1": -79.5, "la1": 41.45, + "lo2": -73.5, "la2": 36.44, "dx": 0.03, "dy": 0.03 } }, + { "name": "conus", "url": "wind-mid.bin", "model": "HRRR 15 km", ... }, + { "name": "base", "url": "wind.bin", "model": "GFS 0.25°", ... } + ], + "units": { "wind10m": "m/s (u east, v north)", "gust": "m/s", + "temp2m": "°C", "cloud": "%", "pressure": "hPa" } +} +``` + +Layers are listed **best-first**: sample the first layer that covers your point +and valid time, fall through to the next. `refTime` is the model cycle; a step's +valid time is `refTime + hours[i]`. Layers can disappear (the high-res toggle, +no GPS fix) — always handle a missing `url` with a fallthrough. Re-fetch the +index when you see new data (the plugin refreshes cycles automatically; poll the +index every few minutes, or on your own refresh cadence). + +## The WGRD/4 binary format + +Little-endian throughout. All Float32 arrays are 4-byte aligned, so a browser +can view them zero-copy (`new Float32Array(buf, offset, n)`). + +``` +offset size field +0 4 magic "WGRD" +4 4 u32 version (4) +8 4 u32 nx +12 4 u32 ny +16 4 u32 nSteps +20 16 f32 lo1, la1, dx, dy (grid origin = NW corner; rows run south) +36 8 f64 refUnix (cycle time, seconds since epoch; 0 = unknown) +44 … nSteps × step +``` + +Each step: + +``` +i32 hour forecast lead, hours from refTime +u32 mask which planes follow (only present planes are stored) +[nx*ny] f32 per present plane, in mask-bit order +``` + +Mask bits: `1` u (wind east, m/s) · `2` v (wind north, m/s) · `4` gust (m/s) · +`8` temp (°C) · `16` cloud (%) · `32` pressure (hPa). Values are row-major from +(`la1`,`lo1`), row stride `nx`, rows going **south** (`la1` is the north edge). +`NaN` cells mean "no coverage here" (e.g. a high-res window's corners outside +the model domain) — treat NaN as a miss and fall through to the next layer. + +Versions 2 and 3 exist historically (fixed plane sets, no mask); a consumer only +needs v4 but should check the version field and reject what it doesn't know. + +## Sampling reference + +Bilinear interpolation in grid space, then linear in time: + +```js +function sample(layer, plane, lng, lat) { // → value or NaN + const g = layer.grid; + const fx = (lng - g.lo1) / g.dx, fy = (g.la1 - lat) / g.dy; + if (fx < 0 || fy < 0 || fx > g.nx - 1 || fy > g.ny - 1) return NaN; + const x0 = Math.floor(fx), y0 = Math.floor(fy); + const x1 = Math.min(x0 + 1, g.nx - 1), y1 = Math.min(y0 + 1, g.ny - 1); + const tx = fx - x0, ty = fy - y0, at = (x, y) => plane[y * g.nx + x]; + return at(x0, y0) * (1 - tx) * (1 - ty) + at(x1, y0) * tx * (1 - ty) + + at(x0, y1) * (1 - tx) * ty + at(x1, y1) * tx * ty; +} +``` + +For a valid time between two steps, sample both and blend linearly. Wind +direction (meteorological "from"): +`(atan2(-u, -v) · 180/π + 360) % 360`; speed = `hypot(u, v)`. If you draw wind +**on the chart**, rotate screen vectors by the map bearing — the chart may be +course-up (see the wind overlay in `plugins/core.weather/ui/plugin.mjs` for the +worked pattern). + +## Rules for consumers + +- **Never** hardcode blob names, grids, hours, or field sets — read the index. +- Respect layer order (`window` → `conus` → `base`); NaN or out-of-coverage + means try the next layer, not "no data". +- Don't re-download source GRIB yourself for fields the service already carries; + if you need a field it lacks, ask for it to be added to the provider (the + format grows by mask bits without breaking existing consumers). diff --git a/docs/docs/style-guide.md b/docs/docs/style-guide.md new file mode 100644 index 0000000..14369c8 --- /dev/null +++ b/docs/docs/style-guide.md @@ -0,0 +1,151 @@ +--- +id: style-guide +title: UI style guide +--- + +# UI style guide + +The rules every panel, dialog, and plugin UI in chartplotter follows. This is a +working document: when a PR and this guide disagree, fix one of them — never +ship a third convention. + +The design north star is [OpenBridge](https://www.openbridge.no/) — the open +maritime UI standard: glanceable at arm's length, dark-first (a bridge at night), +finger-operable on a wet touchscreen, and calm. Chrome should recede; the chart +is the product. + +## Principles + +1. **The chart is the hero.** UI floats over it, takes the minimum footprint, + and gets out of the way. Prefer collapsing/folding chrome (the weather card's + slim scrubber) over permanently large panels. +2. **Stable geometry.** Nothing the user is about to tap may move. Dialogs and + panels have *fixed* sizes; content changes scroll, they do not resize. +3. **One scroll container.** Per view, exactly one element scrolls. Nested + scrollbars are a bug. (Documented exception: capped log viewers such as the + NMEA sniffer `
`, max-height 160px.)
+4. **Spacing separates; borders delineate.** Prefer whitespace over boxes. Never
+   nest a bordered container directly inside another bordered container with no
+   job for the inner border ("double frames").
+5. **Three themes, one variable set.** Day / dusk / night are all served by the
+   same `--ui-*` tokens. Components never hardcode colours (status hues aside)
+   and never assume light or dark.
+
+## Design tokens
+
+Tokens are defined once in `chartplotter.view.mjs` and inherited everywhere
+(including plugin UI and shadow roots). Always write `var(--ui-x)`; a fallback
+literal (`var(--ui-x, #161b22)`) is allowed only in plugin UI that may render
+outside the shell.
+
+| Token | Role |
+| --- | --- |
+| `--ui-bg` | App/drawer background — the deepest layer |
+| `--ui-surface` | Cards, rows, inputs — one step up |
+| `--ui-surface-2` | Buttons, rails — two steps up |
+| `--ui-hover` | Hover fill for buttons/rows |
+| `--ui-text` / `--ui-text-dim` / `--ui-text-faint` | Primary / secondary / tertiary text |
+| `--ui-border` / `--ui-border-2` / `--ui-border-strong` | Standard / hairline / control borders |
+| `--ui-accent` / `--ui-accent-hover` / `--ui-accent-text` | Selection, primary buttons, active states |
+| `--ui-shadow` | Elevation shadows |
+| `--tap-min` | Minimum touch target (44px) |
+
+Status colours (the only sanctioned hardcoded hues, shared app-wide):
+`#3fb950` live/ok · `#d29922` stale/warn · `#f85149` error/danger ·
+`#58a6ff` connecting/info · `#6e7681` off/unknown.
+
+## Scales
+
+**Spacing** — 4px grid. Use these steps, nothing in between:
+`4, 8, 12, 16, 20, 24`. Row padding 12px; card padding 12–16px; gaps 8–12px.
+
+**Radius** — three sizes only:
+
+| Radius | Use |
+| --- | --- |
+| 7–8px | Controls: buttons, inputs, selects, segmented strips, rail tabs |
+| 10px | Cards: list rows, forms, editors, empty states |
+| 14px | Surfaces: popovers, drawers, floating HUD cards |
+
+**Type** — system-ui stack, sizes:
+
+| Size | Use |
+| --- | --- |
+| 11px, 600–700, uppercase, letter-spacing .03–.06em | Section headers, badges, tiny labels |
+| 12px | Secondary text: descriptions, meta lines, hints |
+| 13px | Body/default: buttons, fields, rail tabs |
+| 13.5px, 600 | Row titles |
+| 14px, 600+ | Panel/dialog titles |
+
+Numeric readouts use `font-variant-numeric: tabular-nums`. Inputs are ≥16px on
+touch (iOS zooms otherwise) — this is the one sanctioned deviation from the type
+scale.
+
+## Layout rules
+
+- **Fixed dialog geometry.** The settings shell is `height:min(62dvh,620px)`
+  on desktop, `min(72dvh,620px)` stacked on mobile — identical on every tab.
+  If a new pane needs more room, it scrolls; it never grows the dialog.
+- **One scroll container** per view (`overflow-y:auto` +
+  `overscroll-behavior:contain`). The settings *pane* scrolls; the drawer body
+  around it is `overflow:hidden` while settings is open. If you add an inner
+  region that must scroll (log viewer), cap its height and document it.
+- **Sticky section headers** inside a scrolling pane: full-bleed, opaque, in
+  the *pane's actual background* (`--ui-bg` in the drawer) so content slides
+  under without seams, with a hairline `--ui-border-2` bottom edge.
+- **No double frames.** The drawer/popover provides the outer frame. Inner
+  structure is expressed with a single divider (`border-right` between rail and
+  pane) or spacing, not another full border+radius box.
+- **Touch targets** ≥ `--tap-min` (44px) under `@media (pointer:coarse)`;
+  visual size may stay smaller (switch tracks use an invisible padded overlay).
+
+## Components
+
+**Buttons** — `--ui-surface-2` fill, `--ui-border`, radius 8, padding 6×12,
+13px. `.primary` = accent fill, transparent border. `.danger` = `#f85149` text,
+red-tinted hover. Icon buttons: padding 6×8, min-width 34px.
+
+**List rows** (connections, plugins) — `--ui-surface` card, radius 10, padding
+12×14, 12px gap, 10px between rows: status dot (10px circle) · info block
+(13.5px/600 name + uppercase badge, 12px dim meta with ellipsis) · right-aligned
+actions. Live updates *patch* badge/meta in place — never re-render a list the
+user may be interacting with.
+
+**Settings rows** — label left, control right, full-width 12px dim description
+underneath (max-width 56ch), separated by `--ui-border-2` hairlines, 12px
+vertical padding. No card boxes inside the pane.
+
+**Forms/editors** — `--ui-surface` card, radius 10, padding 14×16, uppercase
+12px heading; fields as label (92–110px, dim) + flexed input; actions row
+right-aligned (Cancel, then primary). Inline errors in `#f85149` 12px, never
+alerts.
+
+**Badges** — 11px/500 uppercase dim text next to the name; the coloured dot
+carries the state, the badge names it.
+
+**Drill-down panes** (plugin → connections) — replace the pane content, top bar
+with `← Back` on the left and a dim context label on the right. Keep the child
+panel element persistent across re-renders so its state survives.
+
+**Empty states** — dashed `--ui-border` box, radius 10, centered dim text,
+padding 26px. Say what to do next, not just "nothing here".
+
+**Floating HUD cards** (weather scrubber, pill controls) — radius 14, surface
+background + border + shadow, minimum footprint, fold-out details. Anything
+drawn *on the chart* (arrows, streamlines) must respect the map's rotation.
+
+## Writing
+
+Sentence case everywhere ("Add connection", not "Add Connection"). Descriptions
+say what the thing does in the user's terms, ≤ one sentence, no jargon the
+pick-report wouldn't use. Errors state what failed and what to try.
+
+## Review checklist
+
+- [ ] Same size before/after every interaction (no layout shift)?
+- [ ] Exactly one scrollbar in the view?
+- [ ] All spacing on the 4px grid; radii from {8, 10, 14}?
+- [ ] Only `--ui-*` tokens (plus sanctioned status hues)?
+- [ ] Legible in all three themes (day/dusk/night)?
+- [ ] Touch targets ≥44px on coarse pointers; inputs ≥16px?
+- [ ] Live data patches in place instead of re-rendering under the user?
diff --git a/docs/docs/weather.md b/docs/docs/weather.md
new file mode 100644
index 0000000..49deb26
--- /dev/null
+++ b/docs/docs/weather.md
@@ -0,0 +1,46 @@
+---
+id: weather
+title: Weather on your chart
+---
+
+# Weather on your chart
+
+The weather overlay shows live forecast wind right on the chart — moving
+streaks that flow the way the wind blows. Longer, faster streaks mean more
+wind. Colours go from calm blue to stormy magenta.
+
+It uses real forecasts from NOAA, updated automatically. Near the US coast it
+switches to a high-detail model that can see the wind inside bays and sounds.
+
+## The weather card
+
+The small card at the top of the screen is your forecast:
+
+- **Drag the slider** to look ahead in time — the wind on the chart, and all
+  the numbers, follow along. Times are shown in your local time.
+- The line under the slider always shows conditions at your boat: wind speed
+  and direction, gusts, temperature, cloud, and barometric pressure.
+- **Tap ▼** to open the day-by-day strip. Tap any hour to jump there.
+- **Tap ↻** to fetch the newest forecast right now. (It also refreshes itself
+  every few hours.)
+
+## Check the wind anywhere
+
+With the wind layer on, **tap any spot on the chart** and a small tag pops up
+with the wind at that exact place. Tap somewhere else to move it, or tap the
+tag itself to make it go away.
+
+## Turning it on and off
+
+Use the layers button (bottom right) to show or hide the wind streaks. The
+forecast data stays loaded — this just clears the picture.
+
+## One setting
+
+**High-res coastal wind** (in Settings → Plugins → Weather) is on by default.
+It gives much better wind detail near the US coast but downloads about 60 MB
+each refresh. If you're on a slow or expensive connection, turn it off — you
+still get the standard forecast at about half the data.
+
+Something look wrong? Settings → Plugins → Weather → **Logs** shows what the
+plugin has been doing, which helps when asking for help.
diff --git a/docs/sidebars.js b/docs/sidebars.js
index a4dbf73..aae3d87 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -4,14 +4,47 @@
 const sidebars = {
   docs: [
     'intro',
-    'installation',
-    'getting-started',
-    'chart1',
-    'widget',
-    'cli',
-    'architecture',
-    'tile-schema',
-    'limitations',
+    {
+      // Plain-language pages for people USING chartplotter on a boat.
+      type: 'category',
+      label: 'Using chartplotter',
+      items: [
+        'installation',
+        'getting-started',
+        'chart1',
+        'nmea0183',
+        'weather',
+        'widget',
+        'cli',
+        'limitations',
+      ],
+    },
+    {
+      // How chartplotter works inside, and how to extend it.
+      type: 'category',
+      label: 'Developing',
+      items: [
+        'architecture',
+        'style-guide',
+        'tile-schema',
+        {
+          type: 'category',
+          label: 'Plugins',
+          items: [
+            'plugins/plugins-overview',
+            'plugins/plugins-getting-started',
+            'plugins/plugins-manifest',
+            'plugins/plugins-capabilities',
+            'plugins/plugins-sdk',
+            'plugins/plugins-protocol',
+            'plugins/plugins-ui',
+            'plugins/weather-grid',
+            'plugins/plugins-packaging',
+            'plugins/plugins-examples',
+          ],
+        },
+      ],
+    },
   ],
 };
 
diff --git a/go.mod b/go.mod
index 0756f46..bc4a629 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
 	github.com/BertoldVdb/go-ais v0.4.0
 	github.com/alecthomas/kong v1.15.0
 	github.com/stretchr/testify v1.11.1
+	github.com/tetratelabs/wazero v1.12.0
 	golang.org/x/image v0.44.0
 	modernc.org/sqlite v1.53.0
 )
diff --git a/go.sum b/go.sum
index 69d9380..780436b 100644
--- a/go.sum
+++ b/go.sum
@@ -33,6 +33,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
+github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
 golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
 golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
 golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
diff --git a/internal/engine/nmea/ais.go b/internal/engine/nmea/ais.go
index 453d49f..c4703c0 100644
--- a/internal/engine/nmea/ais.go
+++ b/internal/engine/nmea/ais.go
@@ -42,6 +42,7 @@ type AISTarget struct {
 type AISStore struct {
 	mu      sync.Mutex
 	targets map[uint32]*AISTarget
+	sources map[uint32]string // per-MMSI provenance (source/plugin id); not serialized
 	ttl     time.Duration
 	ver     uint64
 	now     func() time.Time
@@ -75,6 +76,11 @@ func (s *AISStore) feeder() func(line string) {
 	}
 }
 
+// Feeder exposes feeder for external decoders that own the transport — e.g. the
+// reference tcp-client plugin, which parses AIS itself and mirrors this store before
+// publishing targets back to the host (spec §12 milestone).
+func (s *AISStore) Feeder() func(line string) { return s.feeder() }
+
 func (s *AISStore) apply(p *aisnmea.VdmPacket) {
 	if p == nil || p.Packet == nil || p.MessageType == "VDO" { // VDO is own-ship, not a target
 		return
@@ -172,6 +178,100 @@ func setShipType(t *AISTarget, code uint8) {
 	}
 }
 
+// Upsert merges an externally-decoded target (e.g. from a plugin's ais.publish,
+// spec §4) into the store, attributed to source, and bumps the version. Incoming
+// non-zero/non-nil fields win; unset fields preserve the existing value, so a
+// position-only and a static-only update for the same MMSI compose exactly as the
+// built-in VDM decode path does. A zero MMSI is ignored.
+func (s *AISStore) Upsert(t AISTarget, source string) {
+	if t.MMSI == 0 {
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.sources == nil {
+		s.sources = map[uint32]string{}
+	}
+	now := s.clock()
+	if existing := s.targets[t.MMSI]; existing != nil {
+		mergeTarget(existing, &t, now)
+	} else {
+		cp := t
+		if cp.LastSeen.IsZero() {
+			cp.LastSeen = now
+		}
+		s.targets[t.MMSI] = &cp
+	}
+	s.sources[t.MMSI] = source
+	s.ver++
+}
+
+// EvictSource removes every target last written by source (e.g. when a plugin's
+// ais.write grant is revoked) and returns how many were removed.
+func (s *AISStore) EvictSource(source string) int {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	n := 0
+	for mmsi, src := range s.sources {
+		if src == source {
+			delete(s.targets, mmsi)
+			delete(s.sources, mmsi)
+			n++
+		}
+	}
+	if n > 0 {
+		s.ver++
+	}
+	return n
+}
+
+// mergeTarget overlays src's set fields onto dst and refreshes LastSeen.
+func mergeTarget(dst, src *AISTarget, now time.Time) {
+	dst.LastSeen = now
+	if src.Lat != 0 || src.Lon != 0 {
+		dst.Lat, dst.Lon = src.Lat, src.Lon
+	}
+	if src.COG != nil {
+		dst.COG = src.COG
+	}
+	if src.SOG != nil {
+		dst.SOG = src.SOG
+	}
+	if src.Heading != nil {
+		dst.Heading = src.Heading
+	}
+	if src.Name != "" {
+		dst.Name = src.Name
+	}
+	if src.CallSign != "" {
+		dst.CallSign = src.CallSign
+	}
+	if src.ShipType != 0 {
+		dst.ShipType = src.ShipType
+	}
+	if src.TypeName != "" {
+		dst.TypeName = src.TypeName
+	}
+	if src.Destination != "" {
+		dst.Destination = src.Destination
+	}
+	if src.Length != 0 {
+		dst.Length = src.Length
+	}
+	if src.Beam != 0 {
+		dst.Beam = src.Beam
+	}
+	if src.Draught != nil {
+		dst.Draught = src.Draught
+	}
+	if src.Status != "" {
+		dst.Status = src.Status
+	}
+	if src.Class != "" {
+		dst.Class = src.Class
+	}
+}
+
 // Snapshot returns the live (non-stale) targets, sorted by MMSI, pruning expired
 // ones as a side effect.
 func (s *AISStore) Snapshot() []AISTarget {
@@ -182,6 +282,7 @@ func (s *AISStore) Snapshot() []AISTarget {
 	for mmsi, t := range s.targets {
 		if t.LastSeen.Before(cutoff) {
 			delete(s.targets, mmsi)
+			delete(s.sources, mmsi)
 			continue
 		}
 		out = append(out, *t)
diff --git a/internal/engine/nmea/evict_test.go b/internal/engine/nmea/evict_test.go
new file mode 100644
index 0000000..8be2a4b
--- /dev/null
+++ b/internal/engine/nmea/evict_test.go
@@ -0,0 +1,30 @@
+package nmea
+
+import "testing"
+
+func TestAISStoreUpsertAndEvictSource(t *testing.T) {
+	s := NewAISStore(0)
+	s.Upsert(AISTarget{MMSI: 1, Lat: 10, Lon: 20}, "plugin-a")
+	s.Upsert(AISTarget{MMSI: 2, Lat: 11, Lon: 21}, "plugin-a")
+	s.Upsert(AISTarget{MMSI: 3, Lat: 12, Lon: 22}, "plugin-b")
+	if got := len(s.Snapshot()); got != 3 {
+		t.Fatalf("want 3 targets, got %d", got)
+	}
+	ver := s.Version()
+
+	// Revoking plugin-a's grant evicts only its targets and bumps the version.
+	if n := s.EvictSource("plugin-a"); n != 2 {
+		t.Fatalf("EvictSource removed %d, want 2", n)
+	}
+	if s.Version() == ver {
+		t.Fatalf("version did not change after eviction")
+	}
+	snap := s.Snapshot()
+	if len(snap) != 1 || snap[0].MMSI != 3 {
+		t.Fatalf("want only plugin-b's target 3 left, got %+v", snap)
+	}
+	// Evicting an unknown source removes nothing.
+	if n := s.EvictSource("nobody"); n != 0 {
+		t.Fatalf("EvictSource(nobody) removed %d, want 0", n)
+	}
+}
diff --git a/internal/engine/nmea/publish.go b/internal/engine/nmea/publish.go
new file mode 100644
index 0000000..34ce4b9
--- /dev/null
+++ b/internal/engine/nmea/publish.go
@@ -0,0 +1,174 @@
+package nmea
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+)
+
+// publish.go is the attributed path/value write path into VesselState, used by the
+// plugin broker's vessel.write capability (spec §6). Unlike Apply (which takes a raw
+// NMEA Sentence), PublishDeltas takes SignalK-style dotted paths and validates them
+// against the schema this package defines — the authoritative allowed set is
+// vesselSetters below, keyed by the same dotted JSON paths as state.go's structs.
+// Every write is attributed to a source id so conflicting providers can be arbitrated
+// (per-path latest-wins with provenance; automatic quality arbitration is an open
+// question, spec §13).
+
+// Delta is one path/value update. Value is the raw JSON scalar/object for the path.
+type Delta struct {
+	Path  string
+	Value json.RawMessage
+}
+
+// PublishDeltas applies a batch of deltas under the write lock, attributing each to
+// source. It returns the number applied and the first error encountered (unknown path
+// or bad value); valid deltas in a batch still apply even if a sibling is rejected.
+func (s *Store) PublishDeltas(source string, deltas []Delta) (int, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.prov == nil {
+		s.prov = map[string]string{}
+	}
+	applied := 0
+	var firstErr error
+	for _, d := range deltas {
+		ops, ok := vesselPaths[d.Path]
+		if !ok {
+			if firstErr == nil {
+				firstErr = fmt.Errorf("unknown vessel path %q", d.Path)
+			}
+			continue
+		}
+		if err := ops.set(&s.state, d.Value); err != nil {
+			if firstErr == nil {
+				firstErr = fmt.Errorf("%s: %w", d.Path, err)
+			}
+			continue
+		}
+		s.prov[d.Path] = source
+		applied++
+	}
+	if applied > 0 {
+		s.state.Updated = time.Now().UTC()
+	}
+	return applied, firstErr
+}
+
+// ClearSource removes every reading last written by source — a plugin or connection
+// the user deliberately disabled/removed. Signal LOSS keeps the last-known state (an
+// ECDIS shows a greyed last fix); source REMOVAL must not leave a phantom vessel on
+// screen. Returns how many paths were cleared; bumps Updated so streams re-emit.
+func (s *Store) ClearSource(source string) int {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	cleared := 0
+	for path, src := range s.prov {
+		if src != source {
+			continue
+		}
+		if ops, ok := vesselPaths[path]; ok && ops.clear != nil {
+			ops.clear(&s.state)
+		}
+		delete(s.prov, path)
+		cleared++
+	}
+	if cleared > 0 {
+		s.state.Updated = time.Now().UTC()
+	}
+	return cleared
+}
+
+// Provenance returns a copy of the path→source map (which plugin/source last wrote
+// each path), for the connections/plugins UI to show where a reading came from.
+func (s *Store) Provenance() map[string]string {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	out := make(map[string]string, len(s.prov))
+	for k, v := range s.prov {
+		out[k] = v
+	}
+	return out
+}
+
+// ValidVesselPath reports whether path is a writable vessel path.
+func ValidVesselPath(path string) bool { _, ok := vesselPaths[path]; return ok }
+
+// pathOps is one writable vessel path: set unmarshals a delta value into the state;
+// clear nils the field back out (for ClearSource).
+type pathOps struct {
+	set   func(*VesselState, json.RawMessage) error
+	clear func(*VesselState)
+}
+
+// vesselPaths maps a dotted path to its set/clear pair. This IS the vessel.write
+// schema (spec §6).
+var vesselPaths = map[string]pathOps{
+	"navigation.position": {
+		set: func(vs *VesselState, raw json.RawMessage) error {
+			var p Position
+			if err := json.Unmarshal(raw, &p); err != nil {
+				return err
+			}
+			vs.Navigation.Position = &p
+			return nil
+		},
+		clear: func(vs *VesselState) { vs.Navigation.Position = nil },
+	},
+	"navigation.cogTrue":           floatPath(func(vs *VesselState) **float64 { return &vs.Navigation.COGTrue }),
+	"navigation.sog":               floatPath(func(vs *VesselState) **float64 { return &vs.Navigation.SOG }),
+	"navigation.headingTrue":       floatPath(func(vs *VesselState) **float64 { return &vs.Navigation.HeadingTrue }),
+	"navigation.headingMagnetic":   floatPath(func(vs *VesselState) **float64 { return &vs.Navigation.HeadingMagnetic }),
+	"navigation.magneticVariation": floatPath(func(vs *VesselState) **float64 { return &vs.Navigation.MagneticVariation }),
+	"navigation.rateOfTurn":        floatPath(func(vs *VesselState) **float64 { return &vs.Navigation.RateOfTurn }),
+	"navigation.speedThroughWater": floatPath(func(vs *VesselState) **float64 { return &vs.Navigation.SpeedThroughWater }),
+	"navigation.datetime": {
+		set: func(vs *VesselState, raw json.RawMessage) error {
+			var t time.Time
+			if err := json.Unmarshal(raw, &t); err != nil {
+				return err
+			}
+			vs.Navigation.Datetime = &t
+			return nil
+		},
+		clear: func(vs *VesselState) { vs.Navigation.Datetime = nil },
+	},
+	"environment.depth.belowTransducer": floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowTransducer }),
+	"environment.depth.belowKeel":       floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowKeel }),
+	"environment.depth.belowSurface":    floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowSurface }),
+	"environment.water.temperature":     floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Water.Temperature }),
+	"environment.wind.angleApparent":    floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Wind.AngleApparent }),
+	"environment.wind.speedApparent":    floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Wind.SpeedApparent }),
+	"environment.wind.angleTrue":        floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Wind.AngleTrue }),
+	"environment.wind.speedTrue":        floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Wind.SpeedTrue }),
+	"environment.wind.directionTrue":    floatPath(func(vs *VesselState) **float64 { return &vs.Environment.Wind.DirectionTrue }),
+	"route.xte":                         floatPath(func(vs *VesselState) **float64 { return &vs.Route.XTE }),
+	"route.bearingToWaypoint":           floatPath(func(vs *VesselState) **float64 { return &vs.Route.BearingToWaypoint }),
+	"route.distanceToWaypoint":          floatPath(func(vs *VesselState) **float64 { return &vs.Route.DistanceToWaypoint }),
+	"route.activeWaypoint": {
+		set: func(vs *VesselState, raw json.RawMessage) error {
+			var w string
+			if err := json.Unmarshal(raw, &w); err != nil {
+				return err
+			}
+			vs.Route.ActiveWaypoint = w
+			return nil
+		},
+		clear: func(vs *VesselState) { vs.Route.ActiveWaypoint = "" },
+	},
+}
+
+// floatPath builds the set/clear pair for a *float64 field addressed by get.
+func floatPath(get func(*VesselState) **float64) pathOps {
+	return pathOps{
+		set: func(vs *VesselState, raw json.RawMessage) error {
+			var v float64
+			if err := json.Unmarshal(raw, &v); err != nil {
+				return err
+			}
+			*get(vs) = &v
+			return nil
+		},
+		clear: func(vs *VesselState) { *get(vs) = nil },
+	}
+}
diff --git a/internal/engine/nmea/state.go b/internal/engine/nmea/state.go
index e766ac8..b15a49f 100644
--- a/internal/engine/nmea/state.go
+++ b/internal/engine/nmea/state.go
@@ -89,6 +89,7 @@ type VesselState struct {
 type Store struct {
 	mu    sync.RWMutex
 	state VesselState
+	prov  map[string]string // path → provenance (source/plugin id); set by PublishDeltas
 }
 
 // Snapshot returns a copy of the current state, safe to read without the lock.
diff --git a/internal/engine/plugin/broker.go b/internal/engine/plugin/broker.go
new file mode 100644
index 0000000..526cd20
--- /dev/null
+++ b/internal/engine/plugin/broker.go
@@ -0,0 +1,287 @@
+package plugin
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"net"
+	"sync"
+	"time"
+
+	"github.com/beetlebugorg/chartplotter/internal/engine/nmea"
+)
+
+// broker.go is the host end of the JSON-RPC session with one plugin. It performs the
+// handshake, runs the read loop, correlates host→plugin requests with their replies,
+// dispatches plugin→host requests/notifications to the capability handlers
+// (capabilities.go), and enforces the granted capability set on every inbound call
+// (spec §6). One brokerSession per active plugin runner; the Host it calls into is
+// provided by the server (the shared vessel/AIS/raw stores).
+
+// PluginStatus mirrors the plugin's status.update (spec §4). The server maps State
+// (running|degraded|error) onto the connections UI's SourceStatus enum.
+type PluginStatus struct {
+	State   string         `json:"state"`
+	Detail  string         `json:"detail,omitempty"`
+	Metrics map[string]any `json:"metrics,omitempty"`
+}
+
+// Host is the capability backend the broker calls into for effects that touch the
+// app's shared state. Keeping it an interface lets the plugin package stay decoupled
+// from the server; the shared nmea stores are passed through as concrete types since
+// nmea is a low-level dependency of both. Host-mediated I/O (TCP dial, storage) is
+// owned by the broker itself and is not part of this interface.
+type Host interface {
+	PublishVessel(source string, deltas []nmea.Delta)
+	PublishAIS(source string, targets []nmea.AISTarget)
+	PublishRaw(source string, lines []string)
+	UpdateStatus(source string, st PluginStatus)
+	// EvictAIS drops the targets a source published (its ais.write grant was revoked).
+	EvictAIS(source string)
+	Log(pluginID, level, msg string)
+}
+
+// ioHandle is one open host-mediated transport (a dialed TCP conn), addressed by an
+// opaque per-plugin integer handle.
+type ioHandle struct {
+	conn   net.Conn
+	cancel context.CancelFunc
+}
+
+// brokerSession drives the protocol over one Session.
+type brokerSession struct {
+	id       string
+	sess     Session
+	host     Host
+	storeDir string // /plugins//data — storage KV root
+	dialer   net.Dialer
+	statusFn func(PluginStatus) // optional: the runner records the plugin's reported status
+
+	mu      sync.Mutex
+	grants  []Capability
+	config  map[string]any
+	quota   int64
+	nextID  int64
+	pending map[int64]chan *Message
+	nextH   int
+	handles map[int]*ioHandle
+	closed  bool
+}
+
+func newBrokerSession(id string, sess Session, host Host, storeDir string, grants []Capability, config map[string]any) *brokerSession {
+	return &brokerSession{
+		id:       id,
+		sess:     sess,
+		host:     host,
+		storeDir: storeDir,
+		dialer:   net.Dialer{Timeout: 10 * time.Second},
+		grants:   grants,
+		config:   config,
+		quota:    storageQuota(grants),
+		pending:  map[int64]chan *Message{},
+		handles:  map[int]*ioHandle{},
+	}
+}
+
+// handshake sends host.hello and awaits the plugin's HelloResult (spec §4).
+func (b *brokerSession) handshake(ctx context.Context) (HelloResult, error) {
+	raw, err := b.request(ctx, MethodHostHello, HostHello{
+		APIVersions: []int{APIVersion},
+		PluginID:    b.id,
+		Grants:      b.grantsCopy(),
+		Config:      b.configCopy(),
+		Framing:     []string{"ndjson"},
+	})
+	if err != nil {
+		return HelloResult{}, err
+	}
+	var res HelloResult
+	if err := json.Unmarshal(raw, &res); err != nil {
+		return HelloResult{}, err
+	}
+	return res, nil
+}
+
+// serve runs the read loop until the session ends (plugin exit → io.EOF) or ctx is
+// cancelled. Responses are correlated to pending host requests; plugin→host requests
+// are handled in goroutines (they may block on I/O) and notifications inline (they are
+// fast store writes whose order should be preserved).
+func (b *brokerSession) serve(ctx context.Context) error {
+	for {
+		m, err := b.sess.Recv()
+		if err != nil {
+			return err
+		}
+		switch {
+		case m.isResponse():
+			b.deliverResponse(m)
+		case m.isRequest():
+			go b.handleRequest(ctx, m)
+		case m.isNotification():
+			b.handleNotification(m)
+		}
+	}
+}
+
+// --- host→plugin request/response plumbing ---------------------------------
+
+// request sends a host→plugin request and blocks for its response.
+func (b *brokerSession) request(ctx context.Context, method string, params any) (json.RawMessage, error) {
+	b.mu.Lock()
+	if b.closed {
+		b.mu.Unlock()
+		return nil, errors.New("session closed")
+	}
+	b.nextID++
+	id := b.nextID
+	ch := make(chan *Message, 1)
+	b.pending[id] = ch
+	b.mu.Unlock()
+
+	defer func() {
+		b.mu.Lock()
+		delete(b.pending, id)
+		b.mu.Unlock()
+	}()
+
+	msg, err := newRequest(id, method, params)
+	if err != nil {
+		return nil, err
+	}
+	if err := b.sess.Send(msg); err != nil {
+		return nil, err
+	}
+	select {
+	case <-ctx.Done():
+		return nil, ctx.Err()
+	case resp := <-ch:
+		if resp.Error != nil {
+			return nil, resp.Error
+		}
+		return resp.Result, nil
+	}
+}
+
+// notify sends a host→plugin notification (no reply).
+func (b *brokerSession) notify(method string, params any) error {
+	msg, err := newNotification(method, params)
+	if err != nil {
+		return err
+	}
+	return b.sess.Send(msg)
+}
+
+func (b *brokerSession) deliverResponse(m *Message) {
+	var id int64
+	if json.Unmarshal(m.ID, &id) != nil {
+		return
+	}
+	b.mu.Lock()
+	ch := b.pending[id]
+	b.mu.Unlock()
+	if ch != nil {
+		ch <- m
+	}
+}
+
+// reply sends a success response to a plugin→host request.
+func (b *brokerSession) reply(id json.RawMessage, result any) {
+	msg, err := newResult(id, result)
+	if err != nil {
+		b.replyErr(id, CodeInternalError, err.Error())
+		return
+	}
+	_ = b.sess.Send(msg)
+}
+
+// replyErr sends an error response.
+func (b *brokerSession) replyErr(id json.RawMessage, code int, msg string) {
+	_ = b.sess.Send(newErrorResponse(id, code, msg))
+}
+
+// --- grant / config helpers ------------------------------------------------
+
+func (b *brokerSession) hasCap(cap string) bool {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	_, ok := HasCap(b.grants, cap)
+	return ok
+}
+
+func (b *brokerSession) grantFor(cap string) (Capability, bool) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return HasCap(b.grants, cap)
+}
+
+func (b *brokerSession) grantsCopy() []Capability {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return append([]Capability(nil), b.grants...)
+}
+
+func (b *brokerSession) configCopy() map[string]any {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	out := make(map[string]any, len(b.config))
+	for k, v := range b.config {
+		out[k] = v
+	}
+	return out
+}
+
+// setGrants swaps the grant set + config at runtime and notifies the plugin
+// (host.grantsChanged, spec §4). Revoking a transport capability tears down the
+// host-mediated connections it authorized — otherwise a plugin keeps pumping data
+// over a socket the host dialed under a grant that no longer exists.
+func (b *brokerSession) setGrants(ctx context.Context, grants []Capability, config map[string]any) {
+	b.mu.Lock()
+	_, hadTCP := HasCap(b.grants, CapTCPClient)
+	_, hadSerial := HasCap(b.grants, CapSerial)
+	_, hadAIS := HasCap(b.grants, CapAISWrite)
+	b.grants = grants
+	b.config = config
+	b.quota = storageQuota(grants)
+	_, nowTCP := HasCap(grants, CapTCPClient)
+	_, nowSerial := HasCap(grants, CapSerial)
+	_, nowAIS := HasCap(grants, CapAISWrite)
+	b.mu.Unlock()
+	// All host-mediated handles in this build are transport sockets; if any transport
+	// grant was revoked, close them so the plugin stops receiving (and thus stops
+	// publishing). The plugin sees io.closed and degrades.
+	if (hadTCP && !nowTCP) || (hadSerial && !nowSerial) {
+		b.closeHandles()
+	}
+	// Revoking ais.write also removes the targets this plugin already contributed, so
+	// stale AIS doesn't linger on the chart until TTL eviction.
+	if hadAIS && !nowAIS {
+		b.host.EvictAIS(b.id)
+	}
+	_ = b.notify(MethodGrantsChanged, GrantsChanged{Grants: grants, Config: config})
+}
+
+// shutdown asks the plugin to stop gracefully, then closes the transport. The runner
+// force-kills after a timeout if the plugin doesn't exit (spec §4: 5 s).
+func (b *brokerSession) shutdown(ctx context.Context) {
+	_, _ = b.request(ctx, MethodPluginShutdown, nil)
+	b.closeHandles()
+	b.mu.Lock()
+	b.closed = true
+	b.mu.Unlock()
+	_ = b.sess.Close()
+}
+
+func (b *brokerSession) closeHandles() {
+	b.mu.Lock()
+	handles := b.handles
+	b.handles = map[int]*ioHandle{}
+	b.mu.Unlock()
+	for _, h := range handles {
+		if h.cancel != nil {
+			h.cancel()
+		}
+		if h.conn != nil {
+			_ = h.conn.Close()
+		}
+	}
+}
diff --git a/internal/engine/plugin/capabilities.go b/internal/engine/plugin/capabilities.go
new file mode 100644
index 0000000..516e42f
--- /dev/null
+++ b/internal/engine/plugin/capabilities.go
@@ -0,0 +1,516 @@
+package plugin
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/beetlebugorg/chartplotter/internal/engine/nmea"
+)
+
+// maxHTTPBody caps an http.fetch response body (spec §6 "response size caps").
+const maxHTTPBody = 32 << 20
+
+// capabilities.go implements the plugin→host surface: it dispatches inbound requests
+// and notifications to the granted capability, enforcing the grant set first (spec
+// §6). Host-mediated I/O (the host dials the socket; the plugin only sees bytes) and
+// the storage KV live here.
+
+// handleRequest handles a plugin→host request and always sends exactly one reply.
+func (b *brokerSession) handleRequest(ctx context.Context, m *Message) {
+	switch m.Method {
+	case MethodConfigGet:
+		b.reply(m.ID, b.configCopy())
+	case MethodConfigSet:
+		b.handleConfigSet(m)
+	case MethodTCPConnect:
+		b.handleTCPConnect(ctx, m)
+	case MethodIOClose:
+		b.handleIOClose(m)
+	case MethodSerialList:
+		b.serialList(m)
+	case MethodSerialOpen:
+		b.serialOpen(m)
+	case MethodStorageGet, MethodStorageSet, MethodStorageDelete, MethodStorageList:
+		b.handleStorage(m)
+	case MethodServeSet, MethodServeClear:
+		b.handleServe(m)
+	case MethodHTTPFetch:
+		b.handleHTTPFetch(m)
+	default:
+		// Unknown methods answer MethodNotFound; SDKs surface this as "capability
+		// not available" (spec §5).
+		b.replyErr(m.ID, CodeMethodNotFound, "unknown method: "+m.Method)
+	}
+}
+
+// handleNotification handles a plugin→host notification (no reply). Unknown
+// notifications are ignored (spec §5: plugins/host ignore unknown notifications).
+func (b *brokerSession) handleNotification(m *Message) {
+	switch m.Method {
+	case MethodVesselPublish:
+		b.handleVesselPublish(m)
+	case MethodAISPublish:
+		b.handleAISPublish(m)
+	case MethodRawPublish:
+		b.handleRawPublish(m)
+	case MethodStatusUpdate:
+		b.handleStatusUpdate(m)
+	case MethodTCPSend:
+		b.handleTCPSend(m)
+	}
+}
+
+// --- vessel / ais / raw / status -------------------------------------------
+
+func (b *brokerSession) handleVesselPublish(m *Message) {
+	if !b.hasCap(CapVesselWrite) {
+		return // no grant → silently drop (notification has no reply channel)
+	}
+	var p VesselPublish
+	if json.Unmarshal(m.Params, &p) != nil {
+		return
+	}
+	deltas := make([]nmea.Delta, 0, len(p.Deltas))
+	for _, d := range p.Deltas {
+		deltas = append(deltas, nmea.Delta{Path: d.Path, Value: d.Value})
+	}
+	b.host.PublishVessel(b.id, deltas)
+}
+
+func (b *brokerSession) handleAISPublish(m *Message) {
+	if !b.hasCap(CapAISWrite) {
+		return
+	}
+	var p AISPublish
+	if json.Unmarshal(m.Params, &p) != nil {
+		return
+	}
+	targets := make([]nmea.AISTarget, 0, len(p.Targets))
+	for _, t := range p.Targets {
+		targets = append(targets, aisFromDTO(t))
+	}
+	b.host.PublishAIS(b.id, targets)
+}
+
+func (b *brokerSession) handleRawPublish(m *Message) {
+	// raw.publish feeds the sniffer; gate behind being a data source (vessel or AIS
+	// write), since a plugin with neither has no business injecting sentences.
+	if !b.hasCap(CapVesselWrite) && !b.hasCap(CapAISWrite) {
+		return
+	}
+	var p RawPublish
+	if json.Unmarshal(m.Params, &p) != nil {
+		return
+	}
+	b.host.PublishRaw(b.id, p.Lines)
+}
+
+func (b *brokerSession) handleStatusUpdate(m *Message) {
+	var s StatusUpdate
+	if json.Unmarshal(m.Params, &s) != nil {
+		return
+	}
+	ps := PluginStatus(s)
+	b.host.UpdateStatus(b.id, ps)
+	if b.statusFn != nil {
+		b.statusFn(ps) // let the runner reflect it in the plugins UI
+	}
+}
+
+func aisFromDTO(t AISTargetDTO) nmea.AISTarget {
+	return nmea.AISTarget{
+		MMSI: t.MMSI, Lat: t.Lat, Lon: t.Lon,
+		COG: t.COG, SOG: t.SOG, Heading: t.Heading,
+		Name: t.Name, CallSign: t.CallSign, ShipType: t.ShipType, TypeName: t.TypeName,
+		Destination: t.Destination, Length: t.Length, Beam: t.Beam, Draught: t.Draught,
+		Status: t.Status, Class: t.Class,
+	}
+}
+
+// --- config ----------------------------------------------------------------
+
+func (b *brokerSession) handleConfigSet(m *Message) {
+	var kv StorageSet
+	if err := json.Unmarshal(m.Params, &kv); err != nil {
+		b.replyErr(m.ID, CodeInvalidParams, err.Error())
+		return
+	}
+	var v any
+	_ = json.Unmarshal(kv.Value, &v)
+	b.mu.Lock()
+	if b.config == nil {
+		b.config = map[string]any{}
+	}
+	b.config[kv.Key] = v
+	b.mu.Unlock()
+	b.reply(m.ID, map[string]any{"ok": true})
+}
+
+// --- host-mediated TCP -----------------------------------------------------
+
+func (b *brokerSession) handleTCPConnect(ctx context.Context, m *Message) {
+	grant, ok := b.grantFor(CapTCPClient)
+	if !ok {
+		b.replyErr(m.ID, CodeCapabilityDenied, "net.tcp-client not granted")
+		return
+	}
+	var p TCPConnect
+	if err := json.Unmarshal(m.Params, &p); err != nil {
+		b.replyErr(m.ID, CodeInvalidParams, err.Error())
+		return
+	}
+	if !matchHostAllow(grant.Hosts, p.Host, p.Port) {
+		b.replyErr(m.ID, CodeCapabilityDenied, fmt.Sprintf("%s:%d not in the granted allowlist", p.Host, p.Port))
+		return
+	}
+	dialCtx, cancel := context.WithCancel(ctx)
+	conn, err := b.dialer.DialContext(dialCtx, "tcp", net.JoinHostPort(p.Host, strconv.Itoa(p.Port)))
+	if err != nil {
+		cancel()
+		b.replyErr(m.ID, CodeInternalError, "dial: "+err.Error())
+		return
+	}
+	handle := b.addHandle(&ioHandle{conn: conn, cancel: cancel})
+	b.reply(m.ID, HandleResult{Handle: handle})
+	go b.pumpConn(handle, conn)
+}
+
+// pumpConn reads chunks (not lines — line framing is the plugin's job, spec §10) from
+// a dialed conn and forwards them as tcp.data notifications, ending with io.closed.
+func (b *brokerSession) pumpConn(handle int, conn net.Conn) {
+	buf := make([]byte, 4096)
+	for {
+		n, err := conn.Read(buf)
+		if n > 0 {
+			chunk := make([]byte, n)
+			copy(chunk, buf[:n])
+			_ = b.notify(MethodTCPData, IOData{Handle: handle, Data: chunk, N: n})
+		}
+		if err != nil {
+			_ = b.notify(MethodIOClosed, IOHandle{Handle: handle, Reason: err.Error()})
+			b.dropHandle(handle)
+			return
+		}
+	}
+}
+
+func (b *brokerSession) handleTCPSend(m *Message) {
+	var d IOData
+	if json.Unmarshal(m.Params, &d) != nil {
+		return
+	}
+	if h := b.getHandle(d.Handle); h != nil && h.conn != nil {
+		_, _ = h.conn.Write(d.Data)
+	}
+}
+
+func (b *brokerSession) handleIOClose(m *Message) {
+	var h IOHandle
+	if err := json.Unmarshal(m.Params, &h); err != nil {
+		b.replyErr(m.ID, CodeInvalidParams, err.Error())
+		return
+	}
+	b.dropHandle(h.Handle)
+	b.reply(m.ID, map[string]any{"ok": true})
+}
+
+// --- serial (not wired in this build) --------------------------------------
+
+func (b *brokerSession) serialList(m *Message) {
+	if !b.hasCap(CapSerial) {
+		b.replyErr(m.ID, CodeCapabilityDenied, "serial not granted")
+		return
+	}
+	// Device enumeration quality across platforms is an open question (spec §13); the
+	// serial transport is not wired in this build. Report an empty list rather than
+	// failing so settings dropdowns render.
+	b.reply(m.ID, map[string][]string{"ports": {}})
+}
+
+func (b *brokerSession) serialOpen(m *Message) {
+	b.replyErr(m.ID, CodeMethodNotFound, "serial transport not available in this build")
+}
+
+// --- handle table ----------------------------------------------------------
+
+func (b *brokerSession) addHandle(h *ioHandle) int {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	b.nextH++
+	b.handles[b.nextH] = h
+	return b.nextH
+}
+
+func (b *brokerSession) getHandle(id int) *ioHandle {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return b.handles[id]
+}
+
+func (b *brokerSession) dropHandle(id int) {
+	b.mu.Lock()
+	h := b.handles[id]
+	delete(b.handles, id)
+	b.mu.Unlock()
+	if h != nil {
+		if h.cancel != nil {
+			h.cancel()
+		}
+		if h.conn != nil {
+			_ = h.conn.Close()
+		}
+	}
+}
+
+// --- storage KV ------------------------------------------------------------
+
+func (b *brokerSession) handleStorage(m *Message) {
+	if !b.hasCap(CapStorage) {
+		b.replyErr(m.ID, CodeCapabilityDenied, "storage not granted")
+		return
+	}
+	switch m.Method {
+	case MethodStorageGet:
+		var k StorageKey
+		if json.Unmarshal(m.Params, &k) != nil {
+			b.replyErr(m.ID, CodeInvalidParams, "bad params")
+			return
+		}
+		kv := b.loadKV()
+		v, ok := kv[k.Key]
+		b.reply(m.ID, StorageValue{Value: v, Found: ok})
+	case MethodStorageSet:
+		var s StorageSet
+		if json.Unmarshal(m.Params, &s) != nil {
+			b.replyErr(m.ID, CodeInvalidParams, "bad params")
+			return
+		}
+		kv := b.loadKV()
+		kv[s.Key] = s.Value
+		if err := b.saveKV(kv); err != nil {
+			b.replyErr(m.ID, CodeInternalError, err.Error())
+			return
+		}
+		b.reply(m.ID, map[string]any{"ok": true})
+	case MethodStorageDelete:
+		var k StorageKey
+		if json.Unmarshal(m.Params, &k) != nil {
+			b.replyErr(m.ID, CodeInvalidParams, "bad params")
+			return
+		}
+		kv := b.loadKV()
+		delete(kv, k.Key)
+		_ = b.saveKV(kv)
+		b.reply(m.ID, map[string]any{"ok": true})
+	case MethodStorageList:
+		kv := b.loadKV()
+		keys := make([]string, 0, len(kv))
+		for k := range kv {
+			keys = append(keys, k)
+		}
+		b.reply(m.ID, StorageList{Keys: keys})
+	}
+}
+
+// --- served artifacts: serve.set / serve.clear -----------------------------
+
+func (b *brokerSession) handleServe(m *Message) {
+	if !b.hasCap(CapStorage) {
+		b.replyErr(m.ID, CodeCapabilityDenied, "storage not granted (required to publish served artifacts)")
+		return
+	}
+	serveDir := filepath.Join(b.storeDir, "serve")
+	switch m.Method {
+	case MethodServeSet:
+		var s ServeSet
+		if json.Unmarshal(m.Params, &s) != nil {
+			b.replyErr(m.ID, CodeInvalidParams, "bad params")
+			return
+		}
+		full, ok := safeJoin(serveDir, s.Name)
+		if !ok || full == serveDir {
+			b.replyErr(m.ID, CodeInvalidParams, "invalid serve name")
+			return
+		}
+		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+			b.replyErr(m.ID, CodeInternalError, err.Error())
+			return
+		}
+		if err := os.WriteFile(full, s.Data, 0o644); err != nil {
+			b.replyErr(m.ID, CodeInternalError, err.Error())
+			return
+		}
+		b.reply(m.ID, map[string]any{"ok": true, "url": "/plugins/" + b.id + "/serve/" + strings.TrimPrefix(cleanServeName(s.Name), "/")})
+	case MethodServeClear:
+		var s ServeClear
+		if json.Unmarshal(m.Params, &s) == nil {
+			if full, ok := safeJoin(serveDir, s.Name); ok {
+				_ = os.Remove(full)
+			}
+		}
+		b.reply(m.ID, map[string]any{"ok": true})
+	}
+}
+
+func cleanServeName(name string) string { return filepathToSlash(name) }
+
+// --- host-mediated HTTP: http.fetch ----------------------------------------
+
+func (b *brokerSession) handleHTTPFetch(m *Message) {
+	grant, ok := b.grantFor(CapHTTP)
+	if !ok {
+		b.replyErr(m.ID, CodeCapabilityDenied, "net.http not granted")
+		return
+	}
+	var f HTTPFetch
+	if json.Unmarshal(m.Params, &f) != nil {
+		b.replyErr(m.ID, CodeInvalidParams, "bad params")
+		return
+	}
+	u, err := url.Parse(f.URL)
+	if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
+		b.replyErr(m.ID, CodeInvalidParams, "url must be http(s)")
+		return
+	}
+	if !matchHostAllow(grant.Hosts, u.Hostname(), schemePort(u)) {
+		b.replyErr(m.ID, CodeCapabilityDenied, u.Hostname()+" not in the net.http allowlist")
+		return
+	}
+	method := f.Method
+	if method == "" {
+		method = http.MethodGet
+	}
+	req, err := http.NewRequest(method, f.URL, bytes.NewReader(f.Body))
+	if err != nil {
+		b.replyErr(m.ID, CodeInvalidParams, err.Error())
+		return
+	}
+	for k, v := range f.Headers {
+		req.Header.Set(k, v)
+	}
+	resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
+	if err != nil {
+		b.replyErr(m.ID, CodeInternalError, "fetch: "+err.Error())
+		return
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(io.LimitReader(resp.Body, maxHTTPBody))
+	hdr := map[string]string{}
+	for _, h := range []string{"Content-Type", "ETag", "Last-Modified", "Cache-Control", "Content-Length"} {
+		if v := resp.Header.Get(h); v != "" {
+			hdr[h] = v
+		}
+	}
+	b.reply(m.ID, HTTPResponse{Status: resp.StatusCode, Headers: hdr, Body: body})
+}
+
+// schemePort returns the explicit port or the scheme default.
+func schemePort(u *url.URL) int {
+	if p := u.Port(); p != "" {
+		n, _ := strconv.Atoi(p)
+		return n
+	}
+	if u.Scheme == "https" {
+		return 443
+	}
+	return 80
+}
+
+func filepathToSlash(p string) string { return filepath.ToSlash(filepath.Clean("/" + p)) }
+
+func (b *brokerSession) kvPath() string { return filepath.Join(b.storeDir, "storage.json") }
+
+func (b *brokerSession) loadKV() map[string]json.RawMessage {
+	kv := map[string]json.RawMessage{}
+	data, err := os.ReadFile(b.kvPath())
+	if err == nil {
+		_ = json.Unmarshal(data, &kv)
+	}
+	return kv
+}
+
+func (b *brokerSession) saveKV(kv map[string]json.RawMessage) error {
+	data, err := json.Marshal(kv)
+	if err != nil {
+		return err
+	}
+	if b.quota > 0 && int64(len(data)) > b.quota {
+		return fmt.Errorf("storage quota exceeded (%d > %d bytes)", len(data), b.quota)
+	}
+	if err := os.MkdirAll(b.storeDir, 0o755); err != nil {
+		return err
+	}
+	return os.WriteFile(b.kvPath(), data, 0o644)
+}
+
+// --- allowlist + quota parsing ---------------------------------------------
+
+// matchHostAllow reports whether host:port matches any granted pattern. Patterns
+// support a "*." wildcard prefix and an optional ":port" (spec §3). An empty grant
+// list denies everything.
+func matchHostAllow(patterns []string, host string, port int) bool {
+	for _, p := range patterns {
+		ph, pp, hasPort := strings.Cut(p, ":")
+		if hasPort {
+			if n, err := strconv.Atoi(pp); err != nil || n != port {
+				continue
+			}
+		}
+		if matchHostPattern(ph, host) {
+			return true
+		}
+	}
+	return false
+}
+
+func matchHostPattern(pattern, host string) bool {
+	if pattern == host {
+		return true
+	}
+	if suffix, ok := strings.CutPrefix(pattern, "*."); ok {
+		return host == suffix || strings.HasSuffix(host, "."+suffix)
+	}
+	return false
+}
+
+// storageQuota parses the storage grant's Quota ("10MB", "512KB", "1024") into bytes.
+// A storage grant without a quota gets a conservative 5 MiB default.
+func storageQuota(grants []Capability) int64 {
+	g, ok := HasCap(grants, CapStorage)
+	if !ok {
+		return 0
+	}
+	if g.Quota == "" {
+		return 5 << 20
+	}
+	return parseBytes(g.Quota)
+}
+
+func parseBytes(s string) int64 {
+	s = strings.TrimSpace(strings.ToUpper(s))
+	mult := int64(1)
+	switch {
+	case strings.HasSuffix(s, "MB"):
+		mult, s = 1<<20, strings.TrimSuffix(s, "MB")
+	case strings.HasSuffix(s, "KB"):
+		mult, s = 1<<10, strings.TrimSuffix(s, "KB")
+	case strings.HasSuffix(s, "B"):
+		s = strings.TrimSuffix(s, "B")
+	}
+	n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
+	if err != nil {
+		return 5 << 20
+	}
+	return n * mult
+}
diff --git a/internal/engine/plugin/dev.go b/internal/engine/plugin/dev.go
new file mode 100644
index 0000000..3b93188
--- /dev/null
+++ b/internal/engine/plugin/dev.go
@@ -0,0 +1,115 @@
+package plugin
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// dev.go implements `chartplotter plugin dev `: run an unpacked plugin directory
+// under the real broker with auto-restart, for iteration without packaging (spec §11).
+// It reuses the same runtime + broker + capability path as the server, so a plugin
+// that works under dev works installed.
+
+// DevRun runs the plugin unpacked at dir against host, restarting it (with backoff)
+// whenever it exits, until ctx is cancelled. config/grants are the settings and
+// capability grants the plugin should see — pass the manifest's declared capabilities
+// to grant everything during development.
+func DevRun(ctx context.Context, dir string, config map[string]any, grants []Capability, host Host, logf func(string, ...any)) error {
+	if logf == nil {
+		logf = func(string, ...any) {}
+	}
+	b, err := os.ReadFile(filepath.Join(dir, "plugin.json"))
+	if err != nil {
+		return fmt.Errorf("read plugin.json: %w", err)
+	}
+	man, err := ParseManifest(b)
+	if err != nil {
+		return err
+	}
+	if grants == nil {
+		grants = man.Capabilities // dev default: grant everything the manifest asks for
+	}
+	storeDir := filepath.Join(dir, "data")
+
+	backoff := defaultBackoff
+	for ctx.Err() == nil {
+		logf("plugin %s: starting (dir %s)", man.ID, dir)
+		err := devRunOnce(ctx, dir, man, storeDir, config, grants, host)
+		if ctx.Err() != nil {
+			return nil
+		}
+		logf("plugin %s exited: %v; restarting in %s", man.ID, err, backoff)
+		if !sleepCtx(ctx, backoff) {
+			return nil
+		}
+		backoff = minDur(backoff*2, defaultMaxBackoff)
+	}
+	return nil
+}
+
+func devRunOnce(parent context.Context, dir string, man *Manifest, storeDir string, config map[string]any, grants []Capability, host Host) error {
+	logw := &lineLogger{logf: func(level, msg string) { host.Log(man.ID, level, msg) }}
+	inst, err := startInstance(parent, dir, man, false, storeDir, logw)
+	if err != nil {
+		return err
+	}
+	sess := newStdioSession(inst.Stdout(), inst.Stdin(), inst.Kill)
+	b := newBrokerSession(man.ID, sess, host, storeDir, grants, config)
+
+	ctx, cancel := context.WithCancel(parent)
+	defer cancel()
+	// serve() blocks reading the plugin's stdout; a context cancel (Ctrl-C →
+	// signal.NotifyContext) won't unblock it on its own, so kill the session on
+	// cancellation to make the read return EOF and DevRun exit promptly.
+	go func() {
+		<-ctx.Done()
+		sess.Kill()
+	}()
+	serveDone := make(chan error, 1)
+	go func() { serveDone <- b.serve(ctx) }()
+
+	hctx, hcancel := context.WithTimeout(ctx, handshakeTimeout)
+	_, err = b.handshake(hctx)
+	hcancel()
+	if err != nil {
+		sess.Kill()
+		<-serveDone
+		_ = inst.Wait()
+		return fmt.Errorf("handshake: %w", err)
+	}
+	host.UpdateStatus(man.ID, PluginStatus{State: "running"})
+
+	// Ping liveness (dev doesn't restart on missed pings — the outer loop handles
+	// exits — but keeps the same cadence so a wedged plugin still gets killed).
+	go func() {
+		t := time.NewTicker(pingInterval)
+		defer t.Stop()
+		misses := 0
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-t.C:
+				pctx, c := context.WithTimeout(ctx, pingTimeout)
+				_, err := b.request(pctx, MethodPluginPing, nil)
+				c()
+				if err != nil {
+					if misses++; misses >= maxPingMisses {
+						sess.Kill()
+						return
+					}
+				} else {
+					misses = 0
+				}
+			}
+		}
+	}()
+
+	serveErr := <-serveDone
+	sess.Kill()
+	_ = inst.Wait()
+	return serveErr
+}
diff --git a/internal/engine/plugin/install.go b/internal/engine/plugin/install.go
new file mode 100644
index 0000000..965e0d7
--- /dev/null
+++ b/internal/engine/plugin/install.go
@@ -0,0 +1,191 @@
+package plugin
+
+import (
+	"archive/zip"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// install.go verifies and unpacks a plugin archive. A plugin is a zip whose
+// plugin.json carries a `files` map of path→sha256 covering every other file, so
+// verifying the manifest verifies the whole archive (spec §2). Signing + TOFU key
+// pinning are Phase 3; v1 does content-hash verification only.
+
+// InstallOptions tunes installer policy.
+type InstallOptions struct {
+	// AllowCore permits ids under the reserved core.* prefix. False for third-party
+	// archives (the CLI/UI install path); true only for in-tree tooling.
+	AllowCore bool
+}
+
+// VerifyArchive opens the zip at path, parses+validates plugin.json, and checks that
+// every file listed in the manifest's `files` map exists with a matching sha256.
+// Files present in the archive but absent from `files` are allowed (e.g. plugin.sig);
+// files listed but missing, or with a hash mismatch, are an error.
+func VerifyArchive(path string) (*Manifest, error) {
+	zr, err := zip.OpenReader(path)
+	if err != nil {
+		return nil, fmt.Errorf("open archive: %w", err)
+	}
+	defer zr.Close()
+	return verify(&zr.Reader)
+}
+
+func verify(zr *zip.Reader) (*Manifest, error) {
+	mf := findFile(zr, "plugin.json")
+	if mf == nil {
+		return nil, fmt.Errorf("archive has no plugin.json")
+	}
+	mb, err := readZipFile(mf)
+	if err != nil {
+		return nil, fmt.Errorf("read plugin.json: %w", err)
+	}
+	m, err := ParseManifest(mb)
+	if err != nil {
+		return nil, err
+	}
+	for name, want := range m.Files {
+		f := findFile(zr, name)
+		if f == nil {
+			return nil, fmt.Errorf("manifest lists %q but it is missing from the archive", name)
+		}
+		sum, err := hashZipFile(f)
+		if err != nil {
+			return nil, fmt.Errorf("hash %q: %w", name, err)
+		}
+		if !hashEqual(sum, want) {
+			return nil, fmt.Errorf("hash mismatch for %q: archive has %s, manifest wants %s", name, sum, want)
+		}
+	}
+	return m, nil
+}
+
+// Install verifies the archive and unpacks it to ///,
+// returning the manifest. It refuses to overwrite an already-unpacked version.
+func Install(archivePath, pluginsDir string, opts InstallOptions) (*Manifest, error) {
+	m, err := VerifyArchive(archivePath)
+	if err != nil {
+		return nil, err
+	}
+	if !opts.AllowCore && strings.HasPrefix(m.ID, CorePrefix) {
+		return nil, fmt.Errorf("id %q uses the reserved %q prefix", m.ID, CorePrefix)
+	}
+	dest := filepath.Join(pluginsDir, m.ID, m.Version)
+	if _, err := os.Stat(dest); err == nil {
+		return nil, fmt.Errorf("%s@%s is already installed", m.ID, m.Version)
+	}
+	zr, err := zip.OpenReader(archivePath)
+	if err != nil {
+		return nil, err
+	}
+	defer zr.Close()
+	// Unpack into a temp dir then rename, so a failed unpack leaves nothing partial.
+	tmp := dest + ".tmp"
+	_ = os.RemoveAll(tmp)
+	if err := unpack(&zr.Reader, tmp); err != nil {
+		_ = os.RemoveAll(tmp)
+		return nil, err
+	}
+	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+		_ = os.RemoveAll(tmp)
+		return nil, err
+	}
+	if err := os.Rename(tmp, dest); err != nil {
+		_ = os.RemoveAll(tmp)
+		return nil, err
+	}
+	return m, nil
+}
+
+// unpack writes every archive file under dest, rejecting path traversal.
+func unpack(zr *zip.Reader, dest string) error {
+	for _, f := range zr.File {
+		if f.FileInfo().IsDir() {
+			continue
+		}
+		clean, ok := safeJoin(dest, f.Name)
+		if !ok {
+			return fmt.Errorf("unsafe archive path %q", f.Name)
+		}
+		if err := os.MkdirAll(filepath.Dir(clean), 0o755); err != nil {
+			return err
+		}
+		if err := extractOne(f, clean); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func extractOne(f *zip.File, dest string) error {
+	rc, err := f.Open()
+	if err != nil {
+		return err
+	}
+	defer rc.Close()
+	// Native entry points need the executable bit; everything else is 0644.
+	mode := os.FileMode(0o644)
+	if strings.HasPrefix(f.Name, "bin/") {
+		mode = 0o755
+	}
+	out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	_, err = io.Copy(out, io.LimitReader(rc, maxLine*4)) // generous per-file cap
+	return err
+}
+
+// safeJoin joins base and a possibly-hostile archive path, returning ok=false if the
+// result escapes base.
+func safeJoin(base, name string) (string, bool) {
+	clean := filepath.Join(base, filepath.Clean("/"+name))
+	if clean != base && !strings.HasPrefix(clean, base+string(os.PathSeparator)) {
+		return "", false
+	}
+	return clean, true
+}
+
+func findFile(zr *zip.Reader, name string) *zip.File {
+	for _, f := range zr.File {
+		if f.Name == name {
+			return f
+		}
+	}
+	return nil
+}
+
+func readZipFile(f *zip.File) ([]byte, error) {
+	rc, err := f.Open()
+	if err != nil {
+		return nil, err
+	}
+	defer rc.Close()
+	return io.ReadAll(io.LimitReader(rc, maxLine))
+}
+
+func hashZipFile(f *zip.File) (string, error) {
+	rc, err := f.Open()
+	if err != nil {
+		return "", err
+	}
+	defer rc.Close()
+	h := sha256.New()
+	if _, err := io.Copy(h, rc); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// hashEqual compares a computed hex digest against a manifest value, tolerating an
+// optional "sha256:" prefix and case differences.
+func hashEqual(got, want string) bool {
+	want = strings.TrimPrefix(want, "sha256:")
+	return strings.EqualFold(got, want)
+}
diff --git a/internal/engine/plugin/manager.go b/internal/engine/plugin/manager.go
new file mode 100644
index 0000000..8a9a9da
--- /dev/null
+++ b/internal/engine/plugin/manager.go
@@ -0,0 +1,607 @@
+package plugin
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/beetlebugorg/chartplotter/internal/engine/plugin/runtime/native"
+	"github.com/beetlebugorg/chartplotter/internal/engine/plugin/runtime/wasm"
+)
+
+// manager.go owns install/verify/unpack, the plugins.json state, and the lifecycle of
+// one runner per active plugin — mirroring nmea.Manager's config-vs-runner shape. It
+// reuses the 1 s→30 s reconnect backoff (nmea/manager.go) and adds a circuit breaker
+// (N crashes in M minutes → disabled + notification, spec §9).
+
+// Lifecycle timings.
+const (
+	defaultBackoff    = 1 * time.Second
+	defaultMaxBackoff = 30 * time.Second
+	handshakeTimeout  = 10 * time.Second
+	shutdownGrace     = 5 * time.Second
+	pingInterval      = 15 * time.Second
+	pingTimeout       = 5 * time.Second
+	maxPingMisses     = 3
+	// Circuit breaker: this many crashes within breakerWindow disables the plugin.
+	breakerCrashes = 5
+	breakerWindow  = 2 * time.Minute
+)
+
+// ManagerOpts configures a Manager.
+type ManagerOpts struct {
+	DataDir string // ; plugins unpack under /plugins, state at /plugins.json
+	Host    Host   // capability backend (the server's shared stores); required unless NoStart
+	Logf    func(format string, args ...any)
+	// NoStart makes the Manager a pure state manipulator (CLI one-shots): it never
+	// spawns runners, so install/list/enable/disable/remove just edit plugins.json.
+	NoStart bool
+}
+
+// Manager is the host-side plugin engine.
+type Manager struct {
+	ctx        context.Context
+	opts       ManagerOpts
+	pluginsDir string
+	state      *stateStore
+
+	mu      sync.Mutex
+	runners map[string]*pluginRunner
+
+	logMu sync.Mutex
+	logs  map[string][]LogEntry // plugin id → capped ring of captured log lines
+}
+
+// LogEntry is one captured plugin log line, served by /api/plugins//logs so a
+// plugin's behaviour is inspectable from its settings without shell access.
+type LogEntry struct {
+	Time  time.Time `json:"time"`
+	Level string    `json:"level"`
+	Msg   string    `json:"msg"`
+}
+
+// maxLogEntries caps the per-plugin ring (oldest lines drop off).
+const maxLogEntries = 400
+
+func (m *Manager) appendLog(id, level, msg string) {
+	m.logMu.Lock()
+	defer m.logMu.Unlock()
+	if m.logs == nil {
+		m.logs = map[string][]LogEntry{}
+	}
+	l := append(m.logs[id], LogEntry{Time: time.Now().UTC(), Level: level, Msg: msg})
+	if n := len(l) - maxLogEntries; n > 0 {
+		l = append(l[:0], l[n:]...)
+	}
+	m.logs[id] = l
+}
+
+// Logs returns a snapshot of a plugin's captured log ring (newest last).
+func (m *Manager) Logs(id string) []LogEntry {
+	m.logMu.Lock()
+	defer m.logMu.Unlock()
+	out := make([]LogEntry, len(m.logs[id]))
+	copy(out, m.logs[id])
+	return out
+}
+
+// PluginInfo is a plugin's install/grant state plus its manifest summary and live
+// status, the unit the /api/plugins UI lists.
+type PluginInfo struct {
+	Record   PluginRecord `json:"record"`
+	Manifest *Manifest    `json:"manifest,omitempty"`
+	Status   PluginStatus `json:"status"`
+	Running  bool         `json:"running"`
+}
+
+// NewManager builds the engine rooted at opts.DataDir and starts a runner for every
+// enabled, installed plugin. ctx bounds every runner.
+func NewManager(ctx context.Context, opts ManagerOpts) *Manager {
+	if opts.Logf == nil {
+		opts.Logf = func(string, ...any) {}
+	}
+	pluginsDir := filepath.Join(opts.DataDir, "plugins")
+	m := &Manager{
+		ctx:        ctx,
+		opts:       opts,
+		pluginsDir: pluginsDir,
+		state:      newStateStore(filepath.Join(opts.DataDir, "plugins.json")),
+		runners:    map[string]*pluginRunner{},
+	}
+	if !opts.NoStart {
+		for _, rec := range m.state.list() {
+			if rec.Enabled {
+				m.startRunner(rec)
+			}
+		}
+	}
+	return m
+}
+
+// PluginsDir is /plugins, where archives unpack.
+func (m *Manager) PluginsDir() string { return m.pluginsDir }
+
+// Install verifies + unpacks an archive and records it (disabled, ungranted) so the
+// user can review its capabilities before enabling. opts.AllowCore gates core.* ids.
+func (m *Manager) Install(archivePath string, opts InstallOptions) (*Manifest, error) {
+	man, err := Install(archivePath, m.pluginsDir, opts)
+	if err != nil {
+		return nil, err
+	}
+	m.state.put(PluginRecord{ID: man.ID, Version: man.Version, Enabled: false})
+	return man, nil
+}
+
+// List returns every installed plugin with its manifest + live status.
+func (m *Manager) List() []PluginInfo {
+	recs := m.state.list()
+	out := make([]PluginInfo, 0, len(recs))
+	for _, rec := range recs {
+		man, _ := m.loadManifest(rec.ID, rec.Version)
+		info := PluginInfo{Record: rec, Manifest: man}
+		m.mu.Lock()
+		r := m.runners[rec.ID]
+		m.mu.Unlock()
+		switch {
+		case r != nil:
+			info.Running = true
+			info.Status = r.currentStatus()
+		case m.opts.NoStart && rec.Enabled:
+			// A state-only (CLI) manager doesn't run plugins; enabled means "will run".
+			info.Status = PluginStatus{State: "enabled"}
+		case rec.Enabled:
+			info.Status = PluginStatus{State: "error", Detail: "not running"}
+		default:
+			info.Status = PluginStatus{State: "disabled"}
+		}
+		out = append(out, info)
+	}
+	return out
+}
+
+// Enable marks a plugin enabled and starts its runner (if it has a host-side entry).
+func (m *Manager) Enable(id string) error {
+	rec, ok := m.state.mutate(id, func(r *PluginRecord) { r.Enabled = true })
+	if !ok {
+		return fmt.Errorf("no such plugin %q", id)
+	}
+	m.startRunner(rec)
+	return nil
+}
+
+// Disable stops the runner and marks the plugin disabled.
+func (m *Manager) Disable(id string) error {
+	if _, ok := m.state.mutate(id, func(r *PluginRecord) { r.Enabled = false }); !ok {
+		return fmt.Errorf("no such plugin %q", id)
+	}
+	m.stopRunner(id)
+	return nil
+}
+
+// SetGrants swaps a plugin's grant set (and optionally config), hot-applying it to a
+// running plugin via host.grantsChanged (spec §4). A nil config leaves config
+// untouched (grants-only edit).
+func (m *Manager) SetGrants(id string, grants []Capability, config map[string]any) error {
+	return m.hotApply(id, func(r *PluginRecord) {
+		r.Grants = grants
+		if config != nil {
+			r.Config = config
+		}
+	})
+}
+
+// SetConfig replaces a plugin's settings, leaving its grants intact, and hot-applies
+// the change to a running plugin (host.grantsChanged carries the new config).
+func (m *Manager) SetConfig(id string, config map[string]any) error {
+	return m.hotApply(id, func(r *PluginRecord) { r.Config = config })
+}
+
+// hotApply mutates a plugin's record and re-pushes grants+config to its live runner.
+func (m *Manager) hotApply(id string, fn func(*PluginRecord)) error {
+	rec, ok := m.state.mutate(id, fn)
+	if !ok {
+		return fmt.Errorf("no such plugin %q", id)
+	}
+	m.mu.Lock()
+	r := m.runners[id]
+	m.mu.Unlock()
+	if r != nil {
+		r.applyGrants(rec.Grants, rec.Config)
+	}
+	return nil
+}
+
+// Remove stops, disables, and deletes a plugin. purgeData additionally removes its
+// per-plugin storage (spec §2).
+func (m *Manager) Remove(id string, purgeData bool) error {
+	m.stopRunner(id)
+	if !m.state.remove(id) {
+		return fmt.Errorf("no such plugin %q", id)
+	}
+	dir := filepath.Join(m.pluginsDir, id)
+	if purgeData {
+		return os.RemoveAll(dir)
+	}
+	// Keep /data; remove only the unpacked version dirs. Simplest: remove all but
+	// the data subdir.
+	entries, _ := os.ReadDir(dir)
+	for _, e := range entries {
+		if e.Name() != "data" {
+			_ = os.RemoveAll(filepath.Join(dir, e.Name()))
+		}
+	}
+	return nil
+}
+
+// Statuses returns live status keyed by plugin id (for the SSE stream).
+func (m *Manager) Statuses() map[string]PluginStatus {
+	out := map[string]PluginStatus{}
+	for _, info := range m.List() {
+		out[info.Record.ID] = info.Status
+	}
+	return out
+}
+
+// Close stops every runner.
+func (m *Manager) Close() {
+	m.mu.Lock()
+	runners := m.runners
+	m.runners = map[string]*pluginRunner{}
+	m.mu.Unlock()
+	for _, r := range runners {
+		r.stop()
+	}
+}
+
+// VersionDir returns the unpacked directory of a plugin's active version.
+func (m *Manager) VersionDir(id string) (string, bool) {
+	rec, ok := m.state.get(id)
+	if !ok {
+		return "", false
+	}
+	return filepath.Join(m.pluginsDir, id, rec.Version), true
+}
+
+// DataDir returns a plugin's persistent per-plugin storage root (survives upgrades).
+func (m *Manager) DataDir(id string) string { return filepath.Join(m.pluginsDir, id, "data") }
+
+func (m *Manager) loadManifest(id, version string) (*Manifest, error) {
+	b, err := os.ReadFile(filepath.Join(m.pluginsDir, id, version, "plugin.json"))
+	if err != nil {
+		return nil, err
+	}
+	return ParseManifest(b)
+}
+
+// startRunner starts (or restarts) the runner for rec, if it has a host-side entry
+// point. UI-only plugins (no wasm/native) have no host runner — the frontend loads
+// them; enabling them is purely a state flag.
+func (m *Manager) startRunner(rec PluginRecord) {
+	if m.opts.NoStart {
+		return // pure state manipulator (CLI); the server's Manager runs plugins
+	}
+	man, err := m.loadManifest(rec.ID, rec.Version)
+	if err != nil {
+		m.opts.Logf("plugin %s: load manifest: %v", rec.ID, err)
+		return
+	}
+	if man.Entry.WASM == "" && len(man.Entry.Native) == 0 {
+		return // UI-only; no host runner
+	}
+	m.stopRunner(rec.ID)
+	r := &pluginRunner{
+		mgr:      m,
+		id:       rec.ID,
+		record:   rec,
+		manifest: man,
+		dir:      filepath.Join(m.pluginsDir, rec.ID, rec.Version),
+		done:     make(chan struct{}),
+	}
+	ctx, cancel := context.WithCancel(m.ctx)
+	r.cancel = cancel
+	m.mu.Lock()
+	m.runners[rec.ID] = r
+	m.mu.Unlock()
+	go r.run(ctx)
+}
+
+func (m *Manager) stopRunner(id string) {
+	m.mu.Lock()
+	r := m.runners[id]
+	delete(m.runners, id)
+	m.mu.Unlock()
+	if r != nil {
+		r.stop()
+	}
+}
+
+// --- runner ----------------------------------------------------------------
+
+// rtInstance is the common shape of a wasm/native runtime instance (satisfied
+// structurally by both, so the manager needs no cross-package interface import).
+type rtInstance interface {
+	Stdin() io.WriteCloser
+	Stdout() io.Reader
+	Kill()
+	Wait() error
+}
+
+type pluginRunner struct {
+	mgr      *Manager
+	id       string
+	record   PluginRecord
+	manifest *Manifest
+	dir      string
+
+	cancel context.CancelFunc
+	done   chan struct{}
+
+	mu      sync.Mutex
+	broker  *brokerSession
+	status  PluginStatus
+	crashes []time.Time
+}
+
+func (r *pluginRunner) storeDir() string { return filepath.Join(r.mgr.pluginsDir, r.id, "data") }
+
+func (r *pluginRunner) run(ctx context.Context) {
+	defer close(r.done)
+	backoff := defaultBackoff
+	for ctx.Err() == nil {
+		err := r.runOnce(ctx)
+		if ctx.Err() != nil {
+			return
+		}
+		if err != nil {
+			r.setStatus(PluginStatus{State: "error", Detail: err.Error()})
+			r.mgr.opts.Logf("plugin %s exited: %v", r.id, err)
+		}
+		if r.tripBreaker() {
+			r.setStatus(PluginStatus{State: "error", Detail: "disabled: crash loop"})
+			r.mgr.opts.Logf("plugin %s: too many crashes, disabling", r.id)
+			_, _ = r.mgr.state.mutate(r.id, func(rec *PluginRecord) { rec.Enabled = false })
+			r.mgr.stopRunner(r.id)
+			return
+		}
+		if !sleepCtx(ctx, backoff) {
+			return
+		}
+		backoff = minDur(backoff*2, defaultMaxBackoff)
+	}
+}
+
+// runOnce starts one instance, handshakes, pings, and serves until it exits.
+func (r *pluginRunner) runOnce(parent context.Context) error {
+	inst, err := r.startInstance(parent)
+	if err != nil {
+		return err
+	}
+	sess := newStdioSession(inst.Stdout(), inst.Stdin(), inst.Kill)
+	b := newBrokerSession(r.id, sess, r.mgr.opts.Host, r.storeDir(), r.record.Grants, r.record.Config)
+	b.statusFn = func(ps PluginStatus) { // reflect the plugin's own status.update in the UI
+		r.mu.Lock()
+		r.status = ps
+		r.mu.Unlock()
+	}
+	r.setBroker(b)
+	defer r.setBroker(nil)
+
+	// The read loop must be running for handshake's reply (and every later response)
+	// to be delivered, so start serve first, then handshake over it.
+	runCtx, cancel := context.WithCancel(parent)
+	defer cancel()
+	serveDone := make(chan error, 1)
+	go func() { serveDone <- b.serve(runCtx) }()
+
+	hctx, hcancel := context.WithTimeout(runCtx, handshakeTimeout)
+	_, err = b.handshake(hctx)
+	hcancel()
+	if err != nil {
+		sess.Kill()
+		<-serveDone
+		_ = inst.Wait()
+		return fmt.Errorf("handshake: %w", err)
+	}
+	r.setStatus(PluginStatus{State: "running"})
+	go r.pingLoop(runCtx, b, sess)
+
+	serveErr := <-serveDone
+	sess.Kill()
+	_ = inst.Wait()
+	return serveErr
+}
+
+// startInstance selects the runtime for this runner.
+func (r *pluginRunner) startInstance(ctx context.Context) (rtInstance, error) {
+	logw := &lineLogger{logf: func(level, msg string) {
+		r.mgr.appendLog(r.id, level, msg)
+		r.mgr.opts.Host.Log(r.id, level, msg)
+	}}
+	return startInstance(ctx, r.dir, r.manifest, r.record.ForceNative, r.storeDir(), logw)
+}
+
+// startInstance selects the runtime — WASM (Tier A, preferred) unless the plugin has
+// no wasm entry or native is forced (spec §2) — and starts an instance from an
+// unpacked plugin dir. Shared by the Manager's runners and DevRun.
+func startInstance(ctx context.Context, dir string, man *Manifest, forceNative bool, storeDir string, stderr io.Writer) (rtInstance, error) {
+	useNative := forceNative || man.Entry.WASM == ""
+	if !useNative {
+		b, err := os.ReadFile(filepath.Join(dir, man.Entry.WASM))
+		if err != nil {
+			return nil, fmt.Errorf("read wasm: %w", err)
+		}
+		return wasm.Start(ctx, b, wasm.Config{Name: man.ID, Stderr: stderr})
+	}
+	rel, ok := man.Entry.Native[platformKey()]
+	if !ok {
+		return nil, fmt.Errorf("no native entry for %s", platformKey())
+	}
+	return native.Start(ctx, native.Config{
+		Path:   filepath.Join(dir, rel),
+		Dir:    storeDir,
+		Env:    []string{}, // minimal env (spec §9)
+		Stderr: stderr,
+	})
+}
+
+func (r *pluginRunner) pingLoop(ctx context.Context, b *brokerSession, sess Session) {
+	t := time.NewTicker(pingInterval)
+	defer t.Stop()
+	misses := 0
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-t.C:
+			pctx, cancel := context.WithTimeout(ctx, pingTimeout)
+			_, err := b.request(pctx, MethodPluginPing, nil)
+			cancel()
+			if err != nil {
+				if misses++; misses >= maxPingMisses {
+					r.mgr.opts.Logf("plugin %s: %d missed pings, restarting", r.id, misses)
+					sess.Kill() // unblocks serve → restart
+					return
+				}
+			} else {
+				misses = 0
+			}
+		}
+	}
+}
+
+// stop cancels the runner, asking the plugin to shut down gracefully first.
+func (r *pluginRunner) stop() {
+	if b := r.getBroker(); b != nil {
+		sctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
+		b.shutdown(sctx)
+		cancel()
+	}
+	if r.cancel != nil {
+		r.cancel()
+	}
+	<-r.done
+}
+
+func (r *pluginRunner) applyGrants(grants []Capability, config map[string]any) {
+	r.mu.Lock()
+	r.record.Grants = grants
+	r.record.Config = config
+	b := r.broker
+	r.mu.Unlock()
+	if b != nil {
+		b.setGrants(context.Background(), grants, config)
+	}
+}
+
+func (r *pluginRunner) setBroker(b *brokerSession) { r.mu.Lock(); r.broker = b; r.mu.Unlock() }
+func (r *pluginRunner) getBroker() *brokerSession  { r.mu.Lock(); defer r.mu.Unlock(); return r.broker }
+
+func (r *pluginRunner) setStatus(s PluginStatus) {
+	r.mu.Lock()
+	r.status = s
+	r.mu.Unlock()
+	r.mgr.opts.Host.UpdateStatus(r.id, s)
+}
+
+func (r *pluginRunner) currentStatus() PluginStatus {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return r.status
+}
+
+// tripBreaker records a crash and reports whether the breaker should open.
+func (r *pluginRunner) tripBreaker() bool {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	now := time.Now()
+	kept := r.crashes[:0]
+	for _, t := range r.crashes {
+		if now.Sub(t) < breakerWindow {
+			kept = append(kept, t)
+		}
+	}
+	kept = append(kept, now)
+	r.crashes = kept
+	return len(kept) >= breakerCrashes
+}
+
+// --- helpers ---------------------------------------------------------------
+
+// platformKey is the "-" native-entry key, e.g. "linux-amd64".
+func platformKey() string { return runtime.GOOS + "-" + runtime.GOARCH }
+
+// lineLogger splits a plugin's stderr into log records tagged with the plugin id. A
+// line that parses as {"level":…,"msg":…} keeps its structure; anything else logs at
+// info verbatim (spec §4).
+type lineLogger struct {
+	logf func(level, msg string)
+	buf  []byte
+}
+
+func (l *lineLogger) Write(p []byte) (int, error) {
+	l.buf = append(l.buf, p...)
+	for {
+		i := indexByte(l.buf, '\n')
+		if i < 0 {
+			break
+		}
+		line := strings.TrimRight(string(l.buf[:i]), "\r")
+		l.buf = l.buf[i+1:]
+		l.emit(line)
+	}
+	return len(p), nil
+}
+
+func (l *lineLogger) emit(line string) {
+	if line == "" {
+		return
+	}
+	var rec struct {
+		Level string `json:"level"`
+		Msg   string `json:"msg"`
+	}
+	if json.Unmarshal([]byte(line), &rec) == nil && rec.Msg != "" {
+		level := rec.Level
+		if level == "" {
+			level = "info"
+		}
+		l.logf(level, rec.Msg)
+		return
+	}
+	l.logf("info", line)
+}
+
+func indexByte(b []byte, c byte) int {
+	for i, x := range b {
+		if x == c {
+			return i
+		}
+	}
+	return -1
+}
+
+// sleepCtx / minDur mirror nmea's cancellable-sleep helpers.
+func sleepCtx(ctx context.Context, d time.Duration) bool {
+	t := time.NewTimer(d)
+	defer t.Stop()
+	select {
+	case <-ctx.Done():
+		return false
+	case <-t.C:
+		return true
+	}
+}
+
+func minDur(a, b time.Duration) time.Duration {
+	if a < b {
+		return a
+	}
+	return b
+}
diff --git a/internal/engine/plugin/manifest.go b/internal/engine/plugin/manifest.go
new file mode 100644
index 0000000..294e544
--- /dev/null
+++ b/internal/engine/plugin/manifest.go
@@ -0,0 +1,153 @@
+package plugin
+
+import (
+	"encoding/json"
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+// manifest.go defines plugin.json (spec §3) and its validation. The manifest is the
+// content-addressed root of a plugin archive: its `files` map lists every archive
+// file with its sha256, so verifying the manifest (and, in a later phase, its
+// signature) verifies the whole zip (§2).
+
+// CorePrefix marks in-tree ("built-in") plugin ids. Third-party archives claiming it
+// are rejected by the installer (spec §7 "Reserved for the host").
+const CorePrefix = "core."
+
+// Capability names enforced by the broker (spec §6). Everything is opt-in.
+const (
+	CapVesselRead   = "vessel.read"
+	CapVesselWrite  = "vessel.write"
+	CapAISRead      = "ais.read"
+	CapAISWrite     = "ais.write"
+	CapSerial       = "serial"
+	CapTCPClient    = "net.tcp-client"
+	CapUDP          = "net.udp"
+	CapHTTP         = "net.http"
+	CapStorage      = "storage"
+	CapNotify       = "notify"
+	CapHTTPRegister = "http.register"
+	CapUISettings   = "ui.settings"
+	CapUIPanel      = "ui.panel"
+	CapUIMapLayer   = "ui.map-layer"
+	CapUIHUD        = "ui.hud"
+)
+
+// Capability is one entry in the manifest `capabilities` list, or one element of the
+// grant set. Hosts/Devices/Quota carry the capability's parameters (allowlist,
+// device set, storage quota); which apply depends on Cap.
+type Capability struct {
+	Cap     string   `json:"cap"`
+	Hosts   []string `json:"hosts,omitempty"`   // net.* allowlist patterns
+	Devices []string `json:"devices,omitempty"` // serial device allowlist (resolved at grant)
+	Quota   string   `json:"quota,omitempty"`   // storage quota, e.g. "10MB"
+}
+
+// Entry names a plugin's runtime entry points. At least one of WASM / Native is
+// required for a plugin that runs host-side code; a pure-UI built-in (registered in
+// the frontend) may instead carry only UI.
+type Entry struct {
+	WASM   string            `json:"wasm,omitempty"`   // Tier A: wasip1 module path in the archive
+	Native map[string]string `json:"native,omitempty"` // Tier B: "linux-amd64" → path
+}
+
+// UISlot is a declared UI contribution point (panel/mapLayer/hud entry).
+type UISlot struct {
+	ID    string `json:"id"`
+	Title string `json:"title,omitempty"`
+	Icon  string `json:"icon,omitempty"`
+}
+
+// UI is the manifest `ui` block (spec §8): the entry module plus declared slots.
+type UI struct {
+	Entry     string          `json:"entry,omitempty"`
+	Settings  json.RawMessage `json:"settings,omitempty"`
+	Panels    []UISlot        `json:"panels,omitempty"`
+	MapLayers []UISlot        `json:"mapLayers,omitempty"`
+	HUD       []UISlot        `json:"hud,omitempty"`
+}
+
+// Service is a provides/consumes declaration (spec §7).
+type Service struct {
+	Service    string `json:"service"`
+	APIVersion int    `json:"apiVersion,omitempty"`
+	Optional   bool   `json:"optional,omitempty"`
+}
+
+// Manifest is the parsed plugin.json.
+type Manifest struct {
+	ManifestVersion int               `json:"manifestVersion"`
+	ID              string            `json:"id"`
+	Name            string            `json:"name"`
+	Version         string            `json:"version"`
+	Description     string            `json:"description,omitempty"`
+	Publisher       string            `json:"publisher,omitempty"`
+	License         string            `json:"license,omitempty"`
+	Homepage        string            `json:"homepage,omitempty"`
+	APIVersion      int               `json:"apiVersion"`
+	Entry           Entry             `json:"entry"`
+	Capabilities    []Capability      `json:"capabilities,omitempty"`
+	UI              *UI               `json:"ui,omitempty"`
+	Provides        []Service         `json:"provides,omitempty"`
+	Consumes        []Service         `json:"consumes,omitempty"`
+	Files           map[string]string `json:"files,omitempty"`
+}
+
+var (
+	// idPattern: reverse-DNS, [a-z0-9.-], must contain a dot, no leading/trailing dot.
+	idPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
+	// semverPattern: a pragmatic MAJOR.MINOR.PATCH with optional -pre/+build.
+	semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$`)
+)
+
+// ParseManifest decodes and validates plugin.json bytes.
+func ParseManifest(b []byte) (*Manifest, error) {
+	var m Manifest
+	dec := json.NewDecoder(strings.NewReader(string(b)))
+	dec.DisallowUnknownFields() // catch typo'd manifest keys early
+	if err := dec.Decode(&m); err != nil {
+		return nil, fmt.Errorf("parse plugin.json: %w", err)
+	}
+	if err := m.Validate(); err != nil {
+		return nil, err
+	}
+	return &m, nil
+}
+
+// Validate checks structural validity. It does NOT enforce the core.* installer
+// policy (that is caller policy — see Install) so in-tree built-ins can validate.
+func (m *Manifest) Validate() error {
+	if m.ManifestVersion != 1 {
+		return fmt.Errorf("unsupported manifestVersion %d (want 1)", m.ManifestVersion)
+	}
+	if !idPattern.MatchString(m.ID) {
+		return fmt.Errorf("invalid id %q (want reverse-DNS [a-z0-9.-])", m.ID)
+	}
+	if !semverPattern.MatchString(m.Version) {
+		return fmt.Errorf("invalid version %q (want semver)", m.Version)
+	}
+	if m.APIVersion != APIVersion {
+		return fmt.Errorf("plugin apiVersion %d not supported (host: %d)", m.APIVersion, APIVersion)
+	}
+	if m.Entry.WASM == "" && len(m.Entry.Native) == 0 && m.UI == nil {
+		return fmt.Errorf("manifest declares no entry point (wasm, native, or ui)")
+	}
+	for _, c := range m.Capabilities {
+		if c.Cap == "" {
+			return fmt.Errorf("capability with empty cap")
+		}
+	}
+	return nil
+}
+
+// HasCap reports whether the given capability list grants cap (by name).
+func HasCap(caps []Capability, cap string) (Capability, bool) {
+	for _, c := range caps {
+		if c.Cap == cap {
+			return c, true
+		}
+	}
+	return Capability{}, false
+}
diff --git a/internal/engine/plugin/parity_test.go b/internal/engine/plugin/parity_test.go
new file mode 100644
index 0000000..8f58bf1
--- /dev/null
+++ b/internal/engine/plugin/parity_test.go
@@ -0,0 +1,226 @@
+package plugin
+
+import (
+	"context"
+	"encoding/json"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/beetlebugorg/chartplotter/internal/engine/nmea"
+	"github.com/stretchr/testify/require"
+)
+
+// parity_test.go is the Phase-1 acceptance test (spec §12): the reference
+// core.nmea0183 WASM plugin, driven end-to-end through the real Manager → broker →
+// wazero → tcp.connect pipeline, must reproduce the exact vessel/AIS state the
+// built-in nmea parse path produces from the same sentences.
+
+// sampleSentences: a representative instrument mix plus two AIS reports.
+var sampleSentences = []string{
+	"$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A",
+	"$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47",
+	"$GPVTG,084.4,T,081.6,M,022.4,N,041.5,K*43",
+	"$HEHDT,274.07,T*03",
+	"$IIDPT,4.1,0.5,*7F",
+	"$IIMTW,17.5,C*1A",
+	"!AIVDM,1,1,,B,23aDqDOP0S0:mk2Kv3Ip=wvpR>`<,0*3D",
+	"!AIVDM,1,1,,A,H42O55i18tMET00000000000000,2*6D",
+}
+
+// builtinResult computes the vessel + AIS state the built-in runner's readLoop
+// branch (nmea/source.go) would produce from lines.
+func builtinResult(lines []string) (nmea.VesselState, []nmea.AISTarget) {
+	store := &nmea.Store{}
+	parser := &nmea.Parser{}
+	ais := nmea.NewAISStore(0)
+	feed := ais.Feeder()
+	for _, line := range lines {
+		s, err := nmea.ParseSentence(line)
+		if err != nil {
+			continue
+		}
+		if s.Type == "VDM" || s.Type == "VDO" {
+			feed(line)
+			continue
+		}
+		store.Apply(parser, s)
+	}
+	return store.Snapshot(), ais.Snapshot()
+}
+
+// testHost adapts the plugin broker's Host onto real nmea stores.
+type testHost struct {
+	vessel *nmea.Store
+	ais    *nmea.AISStore
+	mu     sync.Mutex
+	raw    []string
+	logs   []string
+}
+
+func (h *testHost) PublishVessel(source string, deltas []nmea.Delta) {
+	h.vessel.PublishDeltas(source, deltas)
+}
+func (h *testHost) PublishAIS(source string, targets []nmea.AISTarget) {
+	for _, t := range targets {
+		h.ais.Upsert(t, source)
+	}
+}
+func (h *testHost) PublishRaw(source string, lines []string) {
+	h.mu.Lock()
+	h.raw = append(h.raw, lines...)
+	h.mu.Unlock()
+}
+func (h *testHost) EvictAIS(source string) { h.ais.EvictSource(source) }
+func (h *testHost) UpdateStatus(id string, st PluginStatus) {
+	h.mu.Lock()
+	h.logs = append(h.logs, "status "+st.State+": "+st.Detail)
+	h.mu.Unlock()
+}
+func (h *testHost) Log(id, level, msg string) {
+	h.mu.Lock()
+	h.logs = append(h.logs, "log ["+level+"] "+msg)
+	h.mu.Unlock()
+}
+
+func TestTCPClientPluginParity(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping wasm parity test in -short mode")
+	}
+	wasmPath := buildPluginWasm(t)
+
+	// Fake NMEA server: accept one connection, write every sentence, keep it open.
+	addr, port := startNMEAServer(t, sampleSentences)
+
+	// Lay out an installed, enabled plugin under a temp data dir.
+	dataDir := t.TempDir()
+	verDir := filepath.Join(dataDir, "plugins", "core.nmea0183", "1.0.0")
+	require.NoError(t, os.MkdirAll(verDir, 0o755))
+	wasmBytes, err := os.ReadFile(wasmPath)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(verDir, "plugin.wasm"), wasmBytes, 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(verDir, "plugin.json"), []byte(testManifest), 0o644))
+
+	stateJSON := `[{"id":"core.nmea0183","version":"1.0.0","enabled":true,` +
+		`"grants":[{"cap":"vessel.write"},{"cap":"ais.write"},{"cap":"net.tcp-client","hosts":["127.0.0.1"]}],` +
+		`"config":{"host":"127.0.0.1","port":` + strconv.Itoa(port) + `}}]`
+	require.NoError(t, os.WriteFile(filepath.Join(dataDir, "plugins.json"), []byte(stateJSON), 0o644))
+
+	host := &testHost{vessel: &nmea.Store{}, ais: nmea.NewAISStore(0)}
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	mgr := NewManager(ctx, ManagerOpts{DataDir: dataDir, Host: host})
+	defer mgr.Close()
+
+	wantVessel, wantAIS := builtinResult(sampleSentences)
+
+	// Poll until the host store converges (the plugin batches at ~75 ms).
+	ok := false
+	for i := 0; i < 150; i++ { // up to ~15s; generous so a loaded machine doesn't flake
+		if vesselJSON(host.vessel.Snapshot()) == vesselJSON(wantVessel) &&
+			aisJSON(host.ais.Snapshot()) == aisJSON(wantAIS) {
+			ok = true
+			break
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+	if !ok {
+		host.mu.Lock()
+		t.Logf("plugin logs:\n%s", strings.Join(host.logs, "\n"))
+		host.mu.Unlock()
+		t.Logf("want vessel: %s", vesselJSON(wantVessel))
+		t.Logf("got  vessel: %s", vesselJSON(host.vessel.Snapshot()))
+		t.Logf("want ais: %s", aisJSON(wantAIS))
+		t.Logf("got  ais: %s", aisJSON(host.ais.Snapshot()))
+		t.Fatal("plugin state did not converge")
+	}
+
+	require.JSONEq(t, vesselJSON(wantVessel), vesselJSON(host.vessel.Snapshot()))
+	require.JSONEq(t, aisJSON(wantAIS), aisJSON(host.ais.Snapshot()))
+
+	// Raw sentences reached the sniffer path.
+	host.mu.Lock()
+	require.NotEmpty(t, host.raw, "expected raw sentences published to the sniffer")
+	host.mu.Unlock()
+	_ = addr
+}
+
+const testManifest = `{
+  "manifestVersion": 1,
+  "id": "core.nmea0183",
+  "name": "NMEA 0183",
+  "version": "1.0.0",
+  "apiVersion": 1,
+  "entry": { "wasm": "plugin.wasm" },
+  "capabilities": [
+    { "cap": "vessel.write" },
+    { "cap": "ais.write" },
+    { "cap": "net.tcp-client", "hosts": ["${config:host}"] }
+  ]
+}`
+
+// buildPluginWasm compiles the reference plugin to wasip1 and returns the path.
+func buildPluginWasm(t *testing.T) string {
+	t.Helper()
+	root, err := filepath.Abs(filepath.Join("..", "..", ".."))
+	require.NoError(t, err)
+	out := filepath.Join(t.TempDir(), "plugin.wasm")
+	cmd := exec.Command("go", "build", "-o", out, "./plugins/core.nmea0183")
+	cmd.Dir = root
+	cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0")
+	if b, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("build plugin wasm: %v\n%s", err, b)
+	}
+	return out
+}
+
+// startNMEAServer listens on a loopback port, writes every line to the first client,
+// and holds the connection open until the test ends.
+func startNMEAServer(t *testing.T, lines []string) (string, int) {
+	t.Helper()
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = ln.Close() })
+	go func() {
+		conn, err := ln.Accept()
+		if err != nil {
+			return
+		}
+		_, _ = conn.Write([]byte(strings.Join(lines, "\r\n") + "\r\n"))
+		// Hold open so onClose doesn't fire mid-test; closed by ln.Close via cleanup.
+		buf := make([]byte, 256)
+		for {
+			if _, err := conn.Read(buf); err != nil {
+				return
+			}
+		}
+	}()
+	_, portStr, _ := net.SplitHostPort(ln.Addr().String())
+	port, _ := strconv.Atoi(portStr)
+	return ln.Addr().String(), port
+}
+
+// vesselJSON marshals a VesselState for comparison, zeroing the non-deterministic
+// Updated timestamp.
+func vesselJSON(vs nmea.VesselState) string {
+	vs.Updated = time.Time{}
+	b, _ := json.Marshal(vs)
+	return string(b)
+}
+
+// aisJSON marshals AIS targets for comparison, zeroing per-target LastSeen.
+func aisJSON(targets []nmea.AISTarget) string {
+	cp := make([]nmea.AISTarget, len(targets))
+	copy(cp, targets)
+	for i := range cp {
+		cp[i].LastSeen = time.Time{}
+	}
+	b, _ := json.Marshal(cp)
+	return string(b)
+}
diff --git a/internal/engine/plugin/plugin_test.go b/internal/engine/plugin/plugin_test.go
new file mode 100644
index 0000000..cadbeb5
--- /dev/null
+++ b/internal/engine/plugin/plugin_test.go
@@ -0,0 +1,134 @@
+package plugin
+
+import (
+	"archive/zip"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/beetlebugorg/chartplotter/internal/engine/nmea"
+	"github.com/stretchr/testify/require"
+)
+
+func TestManifestValidate(t *testing.T) {
+	valid := `{"manifestVersion":1,"id":"org.example.foo","name":"Foo","version":"1.2.0",` +
+		`"apiVersion":1,"entry":{"wasm":"plugin.wasm"}}`
+	m, err := ParseManifest([]byte(valid))
+	require.NoError(t, err)
+	require.Equal(t, "org.example.foo", m.ID)
+
+	bad := []string{
+		`{"manifestVersion":2,"id":"org.example.foo","version":"1.0.0","apiVersion":1,"entry":{"wasm":"p"}}`,
+		`{"manifestVersion":1,"id":"BadID","version":"1.0.0","apiVersion":1,"entry":{"wasm":"p"}}`,
+		`{"manifestVersion":1,"id":"org.example.foo","version":"nope","apiVersion":1,"entry":{"wasm":"p"}}`,
+		`{"manifestVersion":1,"id":"org.example.foo","version":"1.0.0","apiVersion":9,"entry":{"wasm":"p"}}`,
+		`{"manifestVersion":1,"id":"org.example.foo","version":"1.0.0","apiVersion":1,"entry":{}}`, // no entry point
+	}
+	for _, b := range bad {
+		_, err := ParseManifest([]byte(b))
+		require.Error(t, err, b)
+	}
+}
+
+func TestInstallVerifiesHashesAndRejectsCore(t *testing.T) {
+	dir := t.TempDir()
+	wasm := []byte("\x00asm fake module bytes")
+	sum := sha256.Sum256(wasm)
+	man := map[string]any{
+		"manifestVersion": 1, "id": "org.example.foo", "name": "Foo", "version": "1.0.0",
+		"apiVersion": 1, "entry": map[string]any{"wasm": "plugin.wasm"},
+		"files": map[string]string{"plugin.wasm": "sha256:" + hex.EncodeToString(sum[:])},
+	}
+	manBytes, _ := json.Marshal(man)
+	good := writeZip(t, dir, "good.zip", map[string][]byte{"plugin.json": manBytes, "plugin.wasm": wasm})
+
+	pluginsDir := filepath.Join(dir, "plugins")
+	m, err := Install(good, pluginsDir, InstallOptions{})
+	require.NoError(t, err)
+	require.Equal(t, "org.example.foo", m.ID)
+	_, err = os.Stat(filepath.Join(pluginsDir, "org.example.foo", "1.0.0", "plugin.wasm"))
+	require.NoError(t, err)
+
+	// Tamper: manifest hash no longer matches → verify fails.
+	tampered := writeZip(t, dir, "bad.zip", map[string][]byte{"plugin.json": manBytes, "plugin.wasm": []byte("different")})
+	_, err = Install(tampered, pluginsDir, InstallOptions{})
+	require.ErrorContains(t, err, "hash mismatch")
+
+	// core.* rejected unless AllowCore.
+	coreMan := map[string]any{"manifestVersion": 1, "id": "core.foo", "name": "C", "version": "1.0.0",
+		"apiVersion": 1, "entry": map[string]any{"wasm": "plugin.wasm"},
+		"files": map[string]string{"plugin.wasm": "sha256:" + hex.EncodeToString(sum[:])}}
+	coreBytes, _ := json.Marshal(coreMan)
+	coreZip := writeZip(t, dir, "core.zip", map[string][]byte{"plugin.json": coreBytes, "plugin.wasm": wasm})
+	_, err = Install(coreZip, pluginsDir, InstallOptions{})
+	require.ErrorContains(t, err, "reserved")
+	_, err = Install(coreZip, pluginsDir, InstallOptions{AllowCore: true})
+	require.NoError(t, err)
+}
+
+func TestMatchHostAllow(t *testing.T) {
+	cases := []struct {
+		patterns  []string
+		host      string
+		port      int
+		wantAllow bool
+	}{
+		{[]string{"sk.local:3000"}, "sk.local", 3000, true},
+		{[]string{"sk.local:3000"}, "sk.local", 3001, false},
+		{[]string{"sk.local"}, "sk.local", 9999, true},
+		{[]string{"*.tile.example.com"}, "a.tile.example.com", 443, true},
+		{[]string{"*.tile.example.com"}, "tile.example.com", 443, true},
+		{[]string{"*.tile.example.com"}, "evil.com", 443, false},
+		{[]string{"192.168.1.10:2000"}, "192.168.1.10", 2000, true},
+		{nil, "anything", 80, false},
+	}
+	for _, c := range cases {
+		require.Equal(t, c.wantAllow, matchHostAllow(c.patterns, c.host, c.port), c)
+	}
+}
+
+func TestVesselPublishDeltasMapping(t *testing.T) {
+	store := &nmea.Store{}
+	n, err := store.PublishDeltas("pluginX", []nmea.Delta{
+		{Path: "navigation.sog", Value: json.RawMessage(`6.2`)},
+		{Path: "navigation.position", Value: json.RawMessage(`{"lat":48.1,"lon":11.5}`)},
+		{Path: "not.a.real.path", Value: json.RawMessage(`1`)},
+	})
+	require.Equal(t, 2, n)
+	require.ErrorContains(t, err, "unknown vessel path")
+
+	snap := store.Snapshot()
+	require.NotNil(t, snap.Navigation.SOG)
+	require.InDelta(t, 6.2, *snap.Navigation.SOG, 1e-9)
+	require.NotNil(t, snap.Navigation.Position)
+	require.InDelta(t, 48.1, snap.Navigation.Position.Lat, 1e-9)
+	require.Equal(t, "pluginX", store.Provenance()["navigation.sog"])
+}
+
+func TestParseBytes(t *testing.T) {
+	require.Equal(t, int64(10<<20), parseBytes("10MB"))
+	require.Equal(t, int64(512<<10), parseBytes("512KB"))
+	require.Equal(t, int64(1024), parseBytes("1024"))
+	require.Equal(t, int64(2048), parseBytes("2048B"))
+}
+
+// writeZip builds a zip archive at dir/name from files and returns its path.
+func writeZip(t *testing.T, dir, name string, files map[string][]byte) string {
+	t.Helper()
+	path := filepath.Join(dir, name)
+	f, err := os.Create(path)
+	require.NoError(t, err)
+	defer f.Close()
+	zw := zip.NewWriter(f)
+	for n, b := range files {
+		w, err := zw.Create(n)
+		require.NoError(t, err)
+		_, err = w.Write(b)
+		require.NoError(t, err)
+	}
+	require.NoError(t, zw.Close())
+	return path
+}
diff --git a/internal/engine/plugin/protocol.go b/internal/engine/plugin/protocol.go
new file mode 100644
index 0000000..7f85a65
--- /dev/null
+++ b/internal/engine/plugin/protocol.go
@@ -0,0 +1,339 @@
+// Package plugin implements the chartplotter plugin engine: the host side of the
+// plugin protocol (specs/plugin-system.md). A plugin is an out-of-process (native)
+// or in-process-sandboxed (WASM/wazero) program that speaks newline-delimited
+// JSON-RPC 2.0 over stdio against the host Broker. Everything a plugin can do is a
+// capability granted at install time and mediated here; a sandboxed WASM plugin has
+// no syscall surface beyond stdio + a coarse clock, so the host opens the socket and
+// the plugin only ever sees bytes.
+//
+// This file defines the wire protocol: the JSON-RPC message envelope, the method
+// name constants for every RPC in Appendix A.1 of the spec, the handshake payloads,
+// and the typed params for the Phase-1 surface (vessel/ais/raw publish, status,
+// host-mediated TCP/serial/storage, config). Framing + the read/write loop live in
+// session.go; capability enforcement in broker.go/capabilities.go.
+package plugin
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// APIVersion is the plugin protocol + capability-schema major this host speaks.
+// Additive changes (new methods/fields/capabilities) do not bump it; plugins must
+// ignore unknown notifications/fields and the host answers unknown methods with
+// MethodNotFound. See spec §5.
+const APIVersion = 1
+
+// jsonrpcVersion is the required "jsonrpc" field value on every message.
+const jsonrpcVersion = "2.0"
+
+// Message is a single JSON-RPC 2.0 object — one per NDJSON line. It is a union of
+// the three shapes the transport carries; which one it is follows from the fields
+// present:
+//
+//   - request:      Method set, ID set        (expects a matching response)
+//   - notification: Method set, ID absent      (fire-and-forget)
+//   - response:     ID set, Result xor Error   (reply to a request)
+//
+// ID is kept as raw bytes so a peer's string-or-number id round-trips untouched;
+// Params/Result stay raw so routing can decode into the concrete type lazily.
+type Message struct {
+	JSONRPC string          `json:"jsonrpc"`
+	ID      json.RawMessage `json:"id,omitempty"`
+	Method  string          `json:"method,omitempty"`
+	Params  json.RawMessage `json:"params,omitempty"`
+	Result  json.RawMessage `json:"result,omitempty"`
+	Error   *RPCError       `json:"error,omitempty"`
+}
+
+// isRequest reports whether m carries a method call awaiting a reply.
+func (m *Message) isRequest() bool { return m.Method != "" && len(m.ID) > 0 }
+
+// isNotification reports whether m is a fire-and-forget method call.
+func (m *Message) isNotification() bool { return m.Method != "" && len(m.ID) == 0 }
+
+// isResponse reports whether m is a reply to a prior request.
+func (m *Message) isResponse() bool { return m.Method == "" && len(m.ID) > 0 }
+
+// RPCError is the JSON-RPC 2.0 error object.
+type RPCError struct {
+	Code    int             `json:"code"`
+	Message string          `json:"message"`
+	Data    json.RawMessage `json:"data,omitempty"`
+}
+
+func (e *RPCError) Error() string { return fmt.Sprintf("rpc %d: %s", e.Code, e.Message) }
+
+// JSON-RPC error codes: the standard set plus chartplotter capability codes. The
+// SDK surfaces MethodNotFound as "capability not available" (spec §5); CapabilityDenied
+// is returned when a plugin invokes a host method it was not granted (spec §6).
+const (
+	CodeParseError       = -32700
+	CodeInvalidRequest   = -32600
+	CodeMethodNotFound   = -32601
+	CodeInvalidParams    = -32602
+	CodeInternalError    = -32603
+	CodeCapabilityDenied = -32000 // granted-capability check failed
+	CodeProviderStopped  = -32001 // service call to a stopped/disabled provider (§7)
+	CodeHandleUnknown    = -32002 // io.close / *.send referencing an unknown handle
+)
+
+// Method names. Direction in the comments is host→plugin (→) or plugin→host (←),
+// matching Appendix A.1. Domain prefixes (host/plugin/vessel/ais/serial/tcp/storage/
+// config/…) are reserved for the host (§7 "Reserved for the host").
+const (
+	// Lifecycle & meta.
+	MethodHostHello      = "host.hello"         // → req: handshake
+	MethodPluginPing     = "plugin.ping"        // → req: liveness
+	MethodPluginShutdown = "plugin.shutdown"    // → req: graceful stop
+	MethodGrantsChanged  = "host.grantsChanged" // → notif: new grant set + config
+	MethodConfigChanged  = "config.changed"     // → notif: settings edited
+	MethodConfigGet      = "config.get"         // ← req: own settings
+	MethodConfigSet      = "config.set"         // ← req: plugin-learned values
+	MethodStatusUpdate   = "status.update"      // ← notif: {state, detail, metrics}
+
+	// Vessel / AIS / raw.
+	MethodVesselPublish = "vessel.publish" // ← notif: SignalK-style deltas
+	MethodAISPublish    = "ais.publish"    // ← notif: AIS target updates
+	MethodRawPublish    = "raw.publish"    // ← notif: raw sentences → sniffer
+
+	// Transports (host-mediated).
+	MethodTCPConnect = "tcp.connect" // ← req:  → {handle}
+	MethodTCPSend    = "tcp.send"    // ← notif: outbound bytes
+	MethodTCPData    = "tcp.data"    // → notif: inbound chunk {handle,data,n}
+	MethodSerialList = "serial.list" // ← req: enumerate granted ports
+	MethodSerialOpen = "serial.open" // ← req:  → {handle}
+	MethodSerialData = "serial.data" // → notif: inbound chunk
+	MethodIOClose    = "io.close"    // ← req: release a handle
+	MethodIOClosed   = "io.closed"   // → notif: peer/device closed or errored
+
+	// Storage.
+	MethodStorageGet    = "storage.get"    // ← req
+	MethodStorageSet    = "storage.set"    // ← req
+	MethodStorageDelete = "storage.delete" // ← req
+	MethodStorageList   = "storage.list"   // ← req
+
+	// Served artifacts: publish a blob at GET /plugins//serve/ (§4).
+	MethodServeSet   = "serve.set"   // ← req: {name, data} → served file
+	MethodServeClear = "serve.clear" // ← req: {name}
+
+	// Outbound HTTP (host-mediated, allowlisted).
+	MethodHTTPFetch = "http.fetch" // ← req: request/response
+)
+
+// --- handshake (spec §4) ---------------------------------------------------
+
+// HostHello is the host.hello params: the host speaks first, offering the API
+// majors and framings it supports plus the plugin's current grants and config.
+type HostHello struct {
+	APIVersions []int             `json:"apiVersions"`
+	PluginID    string            `json:"pluginId"`
+	Grants      []Capability      `json:"grants"`
+	Config      map[string]any    `json:"config"`
+	Framing     []string          `json:"framing"` // e.g. ["ndjson","lpbin"]
+	Env         map[string]string `json:"env,omitempty"`
+}
+
+// HelloResult is the plugin's reply: the major it picked and the framing it will use.
+type HelloResult struct {
+	APIVersion int    `json:"apiVersion"`
+	Framing    string `json:"framing"`
+}
+
+// GrantsChanged is the host.grantsChanged notification: grants can change at runtime
+// without a restart (spec §4).
+type GrantsChanged struct {
+	Grants []Capability   `json:"grants"`
+	Config map[string]any `json:"config"`
+}
+
+// --- data plane ------------------------------------------------------------
+
+// Delta is a SignalK-style path/value update applied to the shared VesselState.
+// Paths are the dotted form the host validates against nmea/state.go (see
+// vesselPaths in capabilities.go); Value is the raw JSON scalar/object.
+type Delta struct {
+	Path  string          `json:"path"`
+	Value json.RawMessage `json:"value"`
+	Ts    json.RawMessage `json:"ts,omitempty"`
+}
+
+// VesselPublish is the vessel.publish params: a batch of deltas (spec §4 batching).
+type VesselPublish struct {
+	Deltas []Delta `json:"deltas"`
+}
+
+// AISPublish is the ais.publish params: a batch of decoded target updates.
+type AISPublish struct {
+	Targets []AISTargetDTO `json:"targets"`
+}
+
+// AISTargetDTO is the wire shape of an AIS target update; it maps onto nmea.AISTarget
+// in the broker (capabilities.go). Optional numeric fields are pointers so "unknown"
+// is distinct from zero.
+type AISTargetDTO struct {
+	MMSI        uint32   `json:"mmsi"`
+	Lat         float64  `json:"lat"`
+	Lon         float64  `json:"lon"`
+	COG         *float64 `json:"cog,omitempty"`
+	SOG         *float64 `json:"sog,omitempty"`
+	Heading     *float64 `json:"heading,omitempty"`
+	Name        string   `json:"name,omitempty"`
+	CallSign    string   `json:"callSign,omitempty"`
+	ShipType    int      `json:"shipType,omitempty"`
+	TypeName    string   `json:"typeName,omitempty"`
+	Destination string   `json:"destination,omitempty"`
+	Length      int      `json:"length,omitempty"`
+	Beam        int      `json:"beam,omitempty"`
+	Draught     *float64 `json:"draught,omitempty"`
+	Status      string   `json:"status,omitempty"`
+	Class       string   `json:"class,omitempty"`
+}
+
+// RawPublish is the raw.publish params: raw sentence lines for the sniffer.
+type RawPublish struct {
+	Lines []string `json:"lines"`
+}
+
+// StatusUpdate is the status.update params, mirroring nmea.SourceStatus (spec §4);
+// the broker maps State (running|degraded|error) onto the connections UI enum.
+type StatusUpdate struct {
+	State   string         `json:"state"`
+	Detail  string         `json:"detail,omitempty"`
+	Metrics map[string]any `json:"metrics,omitempty"`
+}
+
+// --- transports ------------------------------------------------------------
+
+// TCPConnect is the tcp.connect params: the host dials, subject to the net.tcp-client
+// allowlist (spec §6). Returns HandleResult.
+type TCPConnect struct {
+	Host string `json:"host"`
+	Port int    `json:"port"`
+}
+
+// SerialOpen is the serial.open params: the host owns the fd; the device must be in
+// the granted set (spec §6).
+type SerialOpen struct {
+	Device string `json:"device"`
+	Baud   int    `json:"baud"`
+}
+
+// HandleResult is the reply to tcp.connect / serial.open: an opaque per-plugin handle.
+type HandleResult struct {
+	Handle int `json:"handle"`
+}
+
+// IOData is a serial.data / tcp.data notification: inbound bytes for a handle, base64
+// in ndjson framing (spec §4). Also the tcp.send / serial.write outbound shape.
+type IOData struct {
+	Handle int    `json:"handle"`
+	Data   []byte `json:"data"` // encoding/json base64-encodes []byte automatically
+	N      int    `json:"n,omitempty"`
+}
+
+// IOClose / IOClosed reference a handle by number.
+type IOHandle struct {
+	Handle int    `json:"handle"`
+	Reason string `json:"reason,omitempty"` // set on io.closed
+}
+
+// --- storage ---------------------------------------------------------------
+
+// StorageKey is the storage.get / storage.delete params.
+type StorageKey struct {
+	Key string `json:"key"`
+}
+
+// StorageSet is the storage.set params.
+type StorageSet struct {
+	Key   string          `json:"key"`
+	Value json.RawMessage `json:"value"`
+}
+
+// StorageValue is the storage.get reply.
+type StorageValue struct {
+	Value json.RawMessage `json:"value"`
+	Found bool            `json:"found"`
+}
+
+// StorageList is the storage.list reply.
+type StorageList struct {
+	Keys []string `json:"keys"`
+}
+
+// ServeSet publishes a blob at GET /plugins//serve/ (spec §4 "Served
+// artifacts"): the zero-RPC path for tile archives, weather grids, and other static
+// products. Data is base64 in ndjson framing.
+type ServeSet struct {
+	Name string `json:"name"`
+	Data []byte `json:"data"`
+}
+
+// ServeClear removes a published artifact.
+type ServeClear struct {
+	Name string `json:"name"`
+}
+
+// HTTPFetch is the http.fetch params (spec §4): a host-mediated outbound request,
+// allowlisted by the net.http grant and size-capped.
+type HTTPFetch struct {
+	URL     string            `json:"url"`
+	Method  string            `json:"method,omitempty"` // default GET
+	Headers map[string]string `json:"headers,omitempty"`
+	Body    []byte            `json:"body,omitempty"`
+}
+
+// HTTPResponse is the http.fetch reply.
+type HTTPResponse struct {
+	Status  int               `json:"status"`
+	Headers map[string]string `json:"headers,omitempty"`
+	Body    []byte            `json:"body"`
+}
+
+// --- message constructors --------------------------------------------------
+
+// newRequest builds a request message with id and marshaled params.
+func newRequest(id int64, method string, params any) (*Message, error) {
+	p, err := marshalParams(params)
+	if err != nil {
+		return nil, err
+	}
+	return &Message{JSONRPC: jsonrpcVersion, ID: marshalID(id), Method: method, Params: p}, nil
+}
+
+// newNotification builds a fire-and-forget message (no id).
+func newNotification(method string, params any) (*Message, error) {
+	p, err := marshalParams(params)
+	if err != nil {
+		return nil, err
+	}
+	return &Message{JSONRPC: jsonrpcVersion, Method: method, Params: p}, nil
+}
+
+// newResult builds a success response for the request id.
+func newResult(id json.RawMessage, result any) (*Message, error) {
+	r, err := json.Marshal(result)
+	if err != nil {
+		return nil, err
+	}
+	return &Message{JSONRPC: jsonrpcVersion, ID: id, Result: r}, nil
+}
+
+// newErrorResponse builds an error response for the request id.
+func newErrorResponse(id json.RawMessage, code int, msg string) *Message {
+	return &Message{JSONRPC: jsonrpcVersion, ID: id, Error: &RPCError{Code: code, Message: msg}}
+}
+
+func marshalID(id int64) json.RawMessage {
+	b, _ := json.Marshal(id)
+	return b
+}
+
+// marshalParams marshals params, treating nil as absent.
+func marshalParams(params any) (json.RawMessage, error) {
+	if params == nil {
+		return nil, nil
+	}
+	return json.Marshal(params)
+}
diff --git a/internal/engine/plugin/runtime/native/native.go b/internal/engine/plugin/runtime/native/native.go
new file mode 100644
index 0000000..4e3d1e3
--- /dev/null
+++ b/internal/engine/plugin/runtime/native/native.go
@@ -0,0 +1,79 @@
+// Package native runs a Tier-B plugin: a per-platform binary launched as a child
+// process (spec §1). Unlike the WASM tier this is NOT a sandbox — the OS boundary is
+// the user's account; the broker's capability checks keep honest plugins honest and
+// keep the programming model identical, but a native plugin can open any file/socket
+// the user can (spec §9 "Honest limits"). The child is started with a minimal env,
+// closed extra fds, and cwd set to its data dir.
+//
+// Like the wasm package, this returns a concrete *Instance the plugin.Manager wraps
+// into a plugin.Session, so there is no import cycle.
+package native
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os/exec"
+)
+
+// Config parameterises a child-process launch.
+type Config struct {
+	Path   string    // absolute path to the plugin binary
+	Args   []string  // args after arg0
+	Env    []string  // minimal environment (spec §9)
+	Dir    string    // cwd — the plugin's data dir
+	Stderr io.Writer // fd2 log stream; optional
+}
+
+// Instance is a running native plugin child process.
+type Instance struct {
+	cmd    *exec.Cmd
+	stdin  io.WriteCloser
+	stdout io.Reader
+	done   chan struct{}
+	err    error
+}
+
+// Start launches the child and wires its stdio. It returns once the process is
+// spawned; Wait blocks for exit.
+func Start(ctx context.Context, cfg Config) (*Instance, error) {
+	cmd := exec.CommandContext(ctx, cfg.Path, cfg.Args...)
+	cmd.Dir = cfg.Dir
+	cmd.Env = cfg.Env
+	cmd.Stderr = cfg.Stderr
+
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		return nil, fmt.Errorf("stdin pipe: %w", err)
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return nil, fmt.Errorf("stdout pipe: %w", err)
+	}
+	if err := cmd.Start(); err != nil {
+		return nil, fmt.Errorf("start plugin process: %w", err)
+	}
+
+	inst := &Instance{cmd: cmd, stdin: stdin, stdout: stdout, done: make(chan struct{})}
+	go func() {
+		defer close(inst.done)
+		inst.err = cmd.Wait()
+	}()
+	return inst, nil
+}
+
+func (i *Instance) Stdin() io.WriteCloser { return i.stdin }
+func (i *Instance) Stdout() io.Reader     { return i.stdout }
+
+// Kill terminates the child process immediately.
+func (i *Instance) Kill() {
+	if i.cmd.Process != nil {
+		_ = i.cmd.Process.Kill()
+	}
+}
+
+// Wait blocks until the process exits and returns its exit error.
+func (i *Instance) Wait() error {
+	<-i.done
+	return i.err
+}
diff --git a/internal/engine/plugin/runtime/wasm/wasm.go b/internal/engine/plugin/runtime/wasm/wasm.go
new file mode 100644
index 0000000..413ecb5
--- /dev/null
+++ b/internal/engine/plugin/runtime/wasm/wasm.go
@@ -0,0 +1,140 @@
+// Package wasm runs a Tier-A plugin: a wasip1 module executed in-process by wazero
+// (pure Go, no CGO — matches the repo's pure-Go-deps convention). The module is
+// instantiated with NO WASI preopens, no sockets, and no environment beyond what the
+// caller passes — its only syscall surface is stdio + a clock, so every effect flows
+// through the JSON-RPC stdio channel to the host broker for a capability check
+// (spec §1, §9). Memory is capped; the host holds a cancel handle to pause/kill it.
+//
+// This package deliberately does NOT import the parent plugin package (that would be
+// an import cycle): it returns a concrete *Instance exposing the module's stdio and a
+// kill/wait pair, which the plugin.Manager wraps into a plugin.Session.
+package wasm
+
+import (
+	"context"
+	"fmt"
+	"io"
+
+	"github.com/tetratelabs/wazero"
+	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+)
+
+// wasmPageSize is the WebAssembly page size (64 KiB); memory limits are expressed in
+// pages.
+const wasmPageSize = 64 << 10
+
+// DefaultMemoryBytes is the per-module memory cap (spec §9: 256 MiB default). A
+// manifest may request more, surfaced in the grant dialog.
+const DefaultMemoryBytes = 256 << 20
+
+// Config parameterises a module instantiation.
+type Config struct {
+	// Name is the module/arg0 name (shows in traces and the module's os.Args[0]).
+	Name string
+	// MemoryBytes caps linear memory; 0 → DefaultMemoryBytes. Rounded down to a page.
+	MemoryBytes uint64
+	// Stderr receives the module's fd2 (a log stream, one record per line). Optional.
+	Stderr io.Writer
+}
+
+// Instance is a running wasip1 module. Its stdio is piped to/from the host: the host
+// writes Stdin (the module's fd0) and reads Stdout (the module's fd1).
+type Instance struct {
+	stdinW  *io.PipeWriter
+	stdinR  *io.PipeReader
+	stdoutR *io.PipeReader
+	stdoutW *io.PipeWriter
+	cancel  context.CancelFunc
+	done    chan struct{}
+	err     error
+}
+
+// Start compiles and instantiates the module, running its `_start` in a goroutine.
+// It returns as soon as the instance is wired; the module runs until it returns from
+// `_start`, is killed, or its stdin closes.
+func Start(ctx context.Context, module []byte, cfg Config) (*Instance, error) {
+	memBytes := cfg.MemoryBytes
+	if memBytes == 0 {
+		memBytes = DefaultMemoryBytes
+	}
+	pages := uint32(memBytes / wasmPageSize)
+	if pages == 0 {
+		pages = 1
+	}
+
+	rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithMemoryLimitPages(pages))
+	wasi_snapshot_preview1.MustInstantiate(ctx, rt)
+
+	compiled, err := rt.CompileModule(ctx, module)
+	if err != nil {
+		_ = rt.Close(ctx)
+		return nil, fmt.Errorf("compile wasm: %w", err)
+	}
+
+	stdinR, stdinW := io.Pipe()
+	stdoutR, stdoutW := io.Pipe()
+
+	name := cfg.Name
+	if name == "" {
+		name = "plugin"
+	}
+	modCfg := wazero.NewModuleConfig().
+		WithName(name).
+		WithArgs(name).
+		WithStdin(stdinR).
+		WithStdout(stdoutW).
+		WithStderr(stderrOr(cfg.Stderr)).
+		// A clock is the one ambient effect Tier A keeps: Go/TinyGo runtimes need it,
+		// and timing side-channels are an accepted risk (spec §9 "Honest limits").
+		// No WithFSConfig / preopens / sockets: the module cannot touch fs or network.
+		WithSysNanotime().
+		WithSysWalltime()
+
+	runCtx, cancel := context.WithCancel(ctx)
+	inst := &Instance{
+		stdinW: stdinW, stdinR: stdinR,
+		stdoutR: stdoutR, stdoutW: stdoutW,
+		cancel: cancel,
+		done:   make(chan struct{}),
+	}
+
+	go func() {
+		defer close(inst.done)
+		// InstantiateModule runs `_start` and blocks until it returns (or the context
+		// is cancelled — how Kill aborts a wedged module). When it returns, close the
+		// pipes so the host's Recv sees EOF and its next Send sees a broken pipe.
+		_, err := rt.InstantiateModule(runCtx, compiled, modCfg)
+		inst.err = err
+		_ = stdoutW.Close()
+		_ = stdinR.Close()
+		_ = rt.Close(context.Background())
+	}()
+
+	return inst, nil
+}
+
+// Stdin is the host's write side (the module reads it as fd0).
+func (i *Instance) Stdin() io.WriteCloser { return i.stdinW }
+
+// Stdout is the host's read side (the module writes it as fd1).
+func (i *Instance) Stdout() io.Reader { return i.stdoutR }
+
+// Kill aborts the module immediately (cancels its run context) and unblocks IO.
+func (i *Instance) Kill() {
+	i.cancel()
+	_ = i.stdinW.Close()
+	_ = i.stdoutR.Close()
+}
+
+// Wait blocks until the module exits and returns its exit error (nil on clean exit).
+func (i *Instance) Wait() error {
+	<-i.done
+	return i.err
+}
+
+func stderrOr(w io.Writer) io.Writer {
+	if w == nil {
+		return io.Discard
+	}
+	return w
+}
diff --git a/internal/engine/plugin/session.go b/internal/engine/plugin/session.go
new file mode 100644
index 0000000..e6ea1c5
--- /dev/null
+++ b/internal/engine/plugin/session.go
@@ -0,0 +1,98 @@
+package plugin
+
+import (
+	"bufio"
+	"encoding/json"
+	"io"
+	"sync"
+)
+
+// maxLine is the NDJSON line cap from spec §4 (16 MiB). A plugin that needs to move
+// more than this per message must negotiate lpbin framing (reserved, not in v1).
+const maxLine = 16 << 20
+
+// Session is the host's transport to one plugin instance. Both runtimes — wazero
+// (WASM stdio) and os/exec (native pipes) — present the identical interface: the
+// runtime owns process/module liveness (Kill), the Session owns framing. A session
+// is a session regardless of attachment (spec §11).
+type Session interface {
+	// Send writes one JSON-RPC message as an NDJSON line. Safe for concurrent use.
+	Send(*Message) error
+	// Recv reads the next message, blocking until one arrives. Returns io.EOF when
+	// the plugin's stdout closes (it exited).
+	Recv() (*Message, error)
+	// Close closes the host's write side (the plugin sees stdin EOF and should exit).
+	Close() error
+	// Kill force-terminates the underlying module/process immediately.
+	Kill()
+}
+
+// stdioSession frames JSON-RPC over a plugin's stdio: it reads the plugin's stdout
+// (r) and writes the plugin's stdin (w). kill terminates the runtime that owns them.
+type stdioSession struct {
+	r    io.Reader // plugin stdout
+	sc   *bufio.Scanner
+	wmu  sync.Mutex
+	w    io.WriteCloser // plugin stdin
+	kill func()
+	once sync.Once
+}
+
+// newStdioSession wraps a plugin's stdout reader and stdin writer. kill is the
+// runtime's force-terminate hook (module close / process Kill).
+func newStdioSession(stdout io.Reader, stdin io.WriteCloser, kill func()) *stdioSession {
+	sc := bufio.NewScanner(stdout)
+	sc.Buffer(make([]byte, 0, 64<<10), maxLine)
+	return &stdioSession{r: stdout, sc: sc, w: stdin, kill: kill}
+}
+
+func (s *stdioSession) Send(m *Message) error {
+	b, err := json.Marshal(m)
+	if err != nil {
+		return err
+	}
+	b = append(b, '\n')
+	s.wmu.Lock()
+	defer s.wmu.Unlock()
+	_, err = s.w.Write(b)
+	return err
+}
+
+func (s *stdioSession) Recv() (*Message, error) {
+	if !s.sc.Scan() {
+		if err := s.sc.Err(); err != nil {
+			return nil, err
+		}
+		return nil, io.EOF
+	}
+	// Copy the token: bufio.Scanner reuses its buffer on the next Scan.
+	line := s.sc.Bytes()
+	var m Message
+	if err := json.Unmarshal(line, &m); err != nil {
+		// A malformed line is a protocol violation; surface it so the runner can
+		// decide (log + continue, or restart). We return the error rather than
+		// skipping so a wedged encoder is visible.
+		return nil, &protocolError{err: err}
+	}
+	return &m, nil
+}
+
+func (s *stdioSession) Close() error {
+	return s.w.Close()
+}
+
+func (s *stdioSession) Kill() {
+	s.once.Do(func() {
+		if s.kill != nil {
+			s.kill()
+		}
+		_ = s.w.Close()
+	})
+}
+
+// protocolError marks an unparseable line so callers can distinguish it from a
+// transport EOF/IO error.
+type protocolError struct{ err error }
+
+func (e *protocolError) Error() string { return "plugin protocol: " + e.err.Error() }
+func (e *protocolError) Unwrap() error { return e.err }
diff --git a/internal/engine/plugin/state.go b/internal/engine/plugin/state.go
new file mode 100644
index 0000000..574f5dc
--- /dev/null
+++ b/internal/engine/plugin/state.go
@@ -0,0 +1,139 @@
+package plugin
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"sort"
+	"sync"
+)
+
+// state.go persists per-plugin state to /plugins.json: enabled flag,
+// granted capabilities, pinned publisher key, and per-plugin settings (spec §2). It
+// mirrors server.connectionsStore — an in-memory map mirrored to a JSON file behind a
+// mutex, best-effort writes — but the per-entry struct is richer than nmea.Source.
+
+// PluginRecord is one plugins.json entry. It records install/grant state, not the
+// manifest (the manifest lives with the unpacked archive on disk).
+type PluginRecord struct {
+	ID          string         `json:"id"`
+	Version     string         `json:"version"` // the active unpacked version
+	Enabled     bool           `json:"enabled"`
+	Grants      []Capability   `json:"grants,omitempty"`      // user-granted subset of the manifest's caps
+	Config      map[string]any `json:"config,omitempty"`      // per-plugin settings round-tripped as `config`
+	ForceNative bool           `json:"forceNative,omitempty"` // prefer the native entry over wasm (§2)
+	PinnedKey   string         `json:"pinnedKey,omitempty"`   // TOFU publisher key fingerprint (Phase 3)
+}
+
+// clone returns a deep-enough copy for handing out without exposing the stored map.
+func (r *PluginRecord) clone() PluginRecord {
+	cp := *r
+	cp.Grants = append([]Capability(nil), r.Grants...)
+	if r.Config != nil {
+		cp.Config = make(map[string]any, len(r.Config))
+		for k, v := range r.Config {
+			cp.Config[k] = v
+		}
+	}
+	return cp
+}
+
+// stateStore is the plugins.json-backed set of PluginRecords.
+type stateStore struct {
+	mu      sync.Mutex
+	path    string
+	records map[string]*PluginRecord
+}
+
+func newStateStore(path string) *stateStore {
+	s := &stateStore{path: path, records: map[string]*PluginRecord{}}
+	s.load()
+	return s
+}
+
+func (s *stateStore) load() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	b, err := os.ReadFile(s.path)
+	if err != nil {
+		return
+	}
+	var list []*PluginRecord
+	if json.Unmarshal(b, &list) != nil {
+		return
+	}
+	for _, r := range list {
+		if r != nil && r.ID != "" {
+			s.records[r.ID] = r
+		}
+	}
+}
+
+// save writes the current records as a sorted JSON array; caller holds the lock.
+func (s *stateStore) save() {
+	list := make([]*PluginRecord, 0, len(s.records))
+	for _, r := range s.records {
+		list = append(list, r)
+	}
+	sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
+	b, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return
+	}
+	_ = os.MkdirAll(filepath.Dir(s.path), 0o755)
+	_ = os.WriteFile(s.path, b, 0o644) // best-effort, matching connectionsStore
+}
+
+func (s *stateStore) list() []PluginRecord {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	out := make([]PluginRecord, 0, len(s.records))
+	for _, r := range s.records {
+		out = append(out, r.clone())
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+	return out
+}
+
+func (s *stateStore) get(id string) (PluginRecord, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if r, ok := s.records[id]; ok {
+		return r.clone(), true
+	}
+	return PluginRecord{}, false
+}
+
+// put upserts a record and persists.
+func (s *stateStore) put(r PluginRecord) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	cp := r.clone()
+	s.records[r.ID] = &cp
+	s.save()
+}
+
+// mutate applies fn to the stored record for id (if present) under the lock, then
+// persists. Returns the updated copy.
+func (s *stateStore) mutate(id string, fn func(*PluginRecord)) (PluginRecord, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	r, ok := s.records[id]
+	if !ok {
+		return PluginRecord{}, false
+	}
+	fn(r)
+	s.save()
+	return r.clone(), true
+}
+
+func (s *stateStore) remove(id string) bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if _, ok := s.records[id]; !ok {
+		return false
+	}
+	delete(s.records, id)
+	s.save()
+	return true
+}
diff --git a/internal/engine/server/http.go b/internal/engine/server/http.go
index 013f06f..0269b7d 100644
--- a/internal/engine/server/http.go
+++ b/internal/engine/server/http.go
@@ -20,6 +20,7 @@ import (
 	"time"
 
 	"github.com/beetlebugorg/chartplotter/internal/engine/nmea"
+	"github.com/beetlebugorg/chartplotter/internal/engine/plugin"
 	"github.com/beetlebugorg/chartplotter/internal/engine/tilesource"
 	"github.com/beetlebugorg/chartplotter/web"
 )
@@ -50,10 +51,11 @@ type Server struct {
 	auxIdx  *auxIndex         // index of companion aux.zips for /api/aux (TXTDSC/PICREP)
 	cellIdx *cellIndex        // persistent name→bbox index over cached cells (/api/cells, search fly-to)
 
-	vessel  *nmea.Store       // latest NMEA0183 vessel state (fed by nmeaMgr)
-	nmeaMgr *nmea.Manager     // live NMEA0183 connections (writes into vessel)
-	conns   *connectionsStore // persisted connection configs (/connections.json)
-	rawHub  *rawHub           // raw-sentence fan-out for the per-connection sniffer
+	vessel    *nmea.Store       // latest NMEA0183 vessel state (fed by nmeaMgr)
+	nmeaMgr   *nmea.Manager     // live NMEA0183 connections (writes into vessel)
+	conns     *connectionsStore // persisted connection configs (/connections.json)
+	rawHub    *rawHub           // raw-sentence fan-out for the per-connection sniffer
+	pluginMgr *plugin.Manager   // installed plugins: lifecycle + broker (/plugins.json)
 }
 
 // New returns a Server. Pass an empty assetsDir to serve the embedded asset
@@ -81,7 +83,8 @@ func New(assetsDir, cacheDir, dataDir string, allowRemote bool, engineCommit str
 	// lives in /prefs.json so it survives restarts and is shared across clients.
 	s.packs = scanPacks(cacheDir)
 	s.prefs = loadPrefs(dataDir)
-	s.initNMEA() // load connections.json + start their live runners
+	s.initNMEA()    // load connections.json + start their live runners
+	s.initPlugins() // load plugins.json + start enabled plugins' host-side runners
 	n := 0
 	for _, name := range sortedKeys(s.packs) {
 		if s.prefs.isDisabled(name) {
@@ -148,6 +151,9 @@ func (s *Server) Close() error {
 	if s.nmeaMgr != nil {
 		s.nmeaMgr.Close()
 	}
+	if s.pluginMgr != nil {
+		s.pluginMgr.Close()
+	}
 	s.sets.closeAll()
 	return nil
 }
@@ -168,6 +174,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		s.handleAPI(lw, r)
 	case strings.HasPrefix(r.URL.Path, "/tiles/"):
 		s.serveTileSet(lw, r)
+	case strings.HasPrefix(r.URL.Path, "/plugins/"):
+		// Per-plugin, plugin-owned static: /plugins//ui/* (ES modules/assets from
+		// the unpacked archive) and /plugins//serve/* (published artifacts).
+		s.servePluginStatic(lw, r)
 	case r.URL.Path == "/aux/index.json" || strings.HasPrefix(r.URL.Path, "/aux/"):
 		// Feature attachments (TXTDSC/PICREP) as loose static files: GET /aux/index.json
 		// (the manifest) + GET /aux/ (one file). The SAME path the offline bundle
@@ -235,7 +245,11 @@ func setSecurityHeaders(w http.ResponseWriter) {
 	h := w.Header()
 	h.Set("X-Content-Type-Options", "nosniff")
 	h.Set("X-Frame-Options", "DENY")
-	h.Set("Content-Security-Policy", "frame-ancestors 'none'")
+	// connect-src 'self' keeps installed plugin UI (trusted, runs in the main
+	// document) from phoning home — all fetch/XHR/EventSource/WebSocket must be
+	// same-origin (spec §9). Everything the app itself fetches is same-origin
+	// (OSM + NOAA go through server-side proxies), so this is transparent to it.
+	h.Set("Content-Security-Policy", "frame-ancestors 'none'; connect-src 'self'")
 	h.Set("Referrer-Policy", "no-referrer")
 }
 
@@ -327,6 +341,14 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
 		s.serveConnectionsStream(w, r) // SSE: live connection-status badges
 	case strings.HasPrefix(r.URL.Path, "/api/connections/"):
 		s.serveConnection(w, r) // GET/PUT/DELETE /, or SSE //raw (sniffer)
+	case r.URL.Path == "/api/plugins":
+		s.servePlugins(w, r) // GET list (manifest + grants + status)
+	case r.URL.Path == "/api/plugins/install":
+		s.servePluginInstall(w, r) // POST multipart zip → verify → unpack
+	case r.URL.Path == "/api/plugins/stream":
+		s.servePluginsStream(w, r) // SSE: plugin status/state changes
+	case strings.HasPrefix(r.URL.Path, "/api/plugins/"):
+		s.servePluginItem(w, r) // enable/disable/grants/config/remove for one plugin
 	case strings.HasPrefix(r.URL.Path, "/api/import"):
 		s.handleImport(w, r) // POST: server-side native bake → register a tile set; status polling
 	case r.URL.Path == "/api/packs":
diff --git a/internal/engine/server/live_provider.go b/internal/engine/server/live_provider.go
index aa0ac7a..33feb4f 100644
--- a/internal/engine/server/live_provider.go
+++ b/internal/engine/server/live_provider.go
@@ -240,14 +240,25 @@ func (s *Server) prepareLiveProvider(jobID, encRoot, provider string) (int, tile
 // RUNNING engine (the .enginever stamp matches the linked tile57 commit). An unstamped
 // tree counts as stale — prepareLiveProvider re-bakes it to staging and swaps. With no
 // engine commit linked in, everything counts as current (nothing to compare against).
+//
+// Dev escape hatch: set CHARTPLOTTER_NO_REBAKE=1 to treat all baked charts as current,
+// so rebuilding the app (which bumps the version/engine stamp) doesn't kick off a slow
+// re-bake on every restart. Genuinely-missing charts still bake — this only suppresses
+// the "engine changed" staleness re-bake. Tiles may then be from an older engine build.
 func (s *Server) liveEngineCurrent(cellsDir string) bool {
-	if s.EngineCommit == "" {
+	if s.EngineCommit == "" || noRebake() {
 		return true
 	}
 	b, err := os.ReadFile(filepath.Join(cellsDir, ".enginever"))
 	return err == nil && string(b) == s.EngineCommit
 }
 
+// noRebake reports whether the dev "don't re-bake on engine change" override is set.
+func noRebake() bool {
+	v := os.Getenv("CHARTPLOTTER_NO_REBAKE")
+	return v != "" && v != "0" && v != "false"
+}
+
 // registerLiveProviders re-registers, at boot, a runtime compositor for every installed provider
 // that has kept per-cell archives (a live provider from a previous run) and isn't disabled — so
 // live layers survive a restart without re-baking. A batch pack registered for the same provider is
diff --git a/internal/engine/server/plugins.go b/internal/engine/server/plugins.go
new file mode 100644
index 0000000..114a127
--- /dev/null
+++ b/internal/engine/server/plugins.go
@@ -0,0 +1,251 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/beetlebugorg/chartplotter/internal/engine/nmea"
+	"github.com/beetlebugorg/chartplotter/internal/engine/plugin"
+)
+
+// plugins.go wires the plugin engine (internal/engine/plugin) into the HTTP server:
+// it constructs the Manager, provides the capability Host backed by the shared
+// vessel/AIS/raw stores, and serves the /api/plugins management routes plus the
+// per-plugin /plugins//{ui,serve}/* static surface (spec §11, Appendix A.2).
+
+// maxPluginUpload caps an uploaded plugin archive.
+const maxPluginUpload = 64 << 20
+
+// initPlugins builds the Manager rooted at the data dir and starts enabled plugins.
+func (s *Server) initPlugins() {
+	s.pluginMgr = plugin.NewManager(context.Background(), plugin.ManagerOpts{
+		DataDir: s.dataDir,
+		Host:    &pluginHost{s: s},
+		Logf:    log.Printf,
+	})
+}
+
+// pluginHost implements plugin.Host over the server's shared stores. Plugin writes
+// are attributed to the plugin id so provenance/priority can arbitrate against the
+// built-in NMEA sources (spec §6, §9).
+type pluginHost struct{ s *Server }
+
+func (h *pluginHost) PublishVessel(source string, deltas []nmea.Delta) {
+	h.s.vessel.PublishDeltas(source, deltas)
+}
+func (h *pluginHost) PublishAIS(source string, targets []nmea.AISTarget) {
+	ais := h.s.nmeaMgr.AIS()
+	for _, t := range targets {
+		ais.Upsert(t, source)
+	}
+}
+func (h *pluginHost) PublishRaw(source string, lines []string) {
+	for _, line := range lines {
+		h.s.rawHub.publish(source, line)
+	}
+}
+func (h *pluginHost) EvictAIS(source string)                   { h.s.nmeaMgr.AIS().EvictSource(source) }
+func (h *pluginHost) UpdateStatus(string, plugin.PluginStatus) {} // surfaced via the SSE poll
+func (h *pluginHost) Log(id, level, msg string)                { log.Printf("[plugin %s] %s: %s", id, level, msg) }
+
+// --- management API --------------------------------------------------------
+
+// servePlugins handles GET /api/plugins — the installed list with manifest + status.
+func (s *Server) servePlugins(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		apiErr(w, http.StatusMethodNotAllowed, "GET only")
+		return
+	}
+	writeJSON(w, map[string]any{"ok": true, "plugins": s.pluginMgr.List()})
+}
+
+// servePluginInstall handles POST /api/plugins/install — a multipart zip upload.
+func (s *Server) servePluginInstall(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		apiErr(w, http.StatusMethodNotAllowed, "POST only")
+		return
+	}
+	if err := r.ParseMultipartForm(maxPluginUpload); err != nil {
+		apiErr(w, http.StatusBadRequest, "bad upload: "+err.Error())
+		return
+	}
+	file, _, err := r.FormFile("plugin")
+	if err != nil {
+		apiErr(w, http.StatusBadRequest, "missing 'plugin' file")
+		return
+	}
+	defer file.Close()
+	tmp, err := os.CreateTemp("", "cp-plugin-*.zip")
+	if err != nil {
+		apiErr(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	defer os.Remove(tmp.Name())
+	if _, err := io.Copy(tmp, io.LimitReader(file, maxPluginUpload)); err != nil {
+		tmp.Close()
+		apiErr(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	tmp.Close()
+	man, err := s.pluginMgr.Install(tmp.Name(), plugin.InstallOptions{})
+	if err != nil {
+		apiErr(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, map[string]any{"ok": true, "manifest": man})
+}
+
+// servePluginItem routes /api/plugins/[/].
+func (s *Server) servePluginItem(w http.ResponseWriter, r *http.Request) {
+	rest := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
+	id, action, _ := strings.Cut(rest, "/")
+	if !validPluginID(id) {
+		apiErr(w, http.StatusBadRequest, "bad plugin id")
+		return
+	}
+	switch {
+	case action == "logs" && r.Method == http.MethodGet:
+		writeJSON(w, map[string]any{"ok": true, "logs": s.pluginMgr.Logs(id)})
+	case action == "enable" && r.Method == http.MethodPost:
+		s.pluginErr(w, s.pluginMgr.Enable(id))
+	case action == "disable" && r.Method == http.MethodPost:
+		err := s.pluginMgr.Disable(id)
+		if err == nil {
+			// Deliberately turning a data source OFF is not signal loss: drop every
+			// reading it wrote (position, wind, …) so no phantom own-ship lingers.
+			// (AIS targets it fed age out via the store TTL.)
+			s.vessel.ClearSource(id)
+		}
+		s.pluginErr(w, err)
+	case action == "grants" && (r.Method == http.MethodPut || r.Method == http.MethodPost):
+		var body struct {
+			Grants []plugin.Capability `json:"grants"`
+			Config map[string]any      `json:"config"`
+		}
+		if err := decodeJSON(r, &body); err != nil {
+			apiErr(w, http.StatusBadRequest, err.Error())
+			return
+		}
+		s.pluginErr(w, s.pluginMgr.SetGrants(id, body.Grants, body.Config))
+	case action == "config" && (r.Method == http.MethodPut || r.Method == http.MethodPost):
+		var cfg map[string]any
+		if err := decodeJSON(r, &cfg); err != nil {
+			apiErr(w, http.StatusBadRequest, err.Error())
+			return
+		}
+		s.pluginErr(w, s.pluginMgr.SetConfig(id, cfg)) // config-only update keeps grants
+	case action == "" && r.Method == http.MethodDelete:
+		purge := r.URL.Query().Get("purgeData") != ""
+		err := s.pluginMgr.Remove(id, purge)
+		if err == nil {
+			s.vessel.ClearSource(id) // as with disable: no phantom readings
+		}
+		s.pluginErr(w, err)
+	default:
+		apiErr(w, http.StatusNotFound, "unknown plugin endpoint")
+	}
+}
+
+// servePluginsStream pushes the plugin status map whenever it changes (mirrors the
+// connections-stream pattern).
+func (s *Server) servePluginsStream(w http.ResponseWriter, r *http.Request) {
+	flusher, ok := sseStart(w)
+	if !ok {
+		return
+	}
+	ticker := time.NewTicker(500 * time.Millisecond)
+	defer ticker.Stop()
+	last := ""
+	for {
+		b, _ := json.Marshal(map[string]any{"plugins": s.pluginMgr.List()})
+		if line := string(b); line != last {
+			last = line
+			fmt.Fprintf(w, "data: %s\n\n", line)
+			flusher.Flush()
+		}
+		select {
+		case <-r.Context().Done():
+			return
+		case <-ticker.C:
+		}
+	}
+}
+
+// --- per-plugin static: /plugins//{ui,serve}/* -------------------------
+
+// servePluginStatic serves a plugin's UI bundle and published artifacts from the
+// unpacked archive / its data dir, with Range + the shared asset headers (.wasm/.mjs
+// mime, caching). Everything is namespaced under the plugin id.
+func (s *Server) servePluginStatic(w http.ResponseWriter, r *http.Request) {
+	rest := strings.TrimPrefix(r.URL.Path, "/plugins/")
+	id, tail, _ := strings.Cut(rest, "/")
+	if !validPluginID(id) {
+		http.NotFound(w, r)
+		return
+	}
+	kind, rel, _ := strings.Cut(tail, "/")
+	rel = path.Clean("/" + rel)[1:] // strip traversal
+	if rel == "" || strings.Contains(rel, "..") {
+		http.NotFound(w, r)
+		return
+	}
+	var base string
+	switch kind {
+	case "ui":
+		dir, ok := s.pluginMgr.VersionDir(id)
+		if !ok {
+			http.NotFound(w, r)
+			return
+		}
+		base = filepath.Join(dir, "ui")
+	case "serve":
+		base = filepath.Join(s.pluginMgr.DataDir(id), "serve")
+	default:
+		http.NotFound(w, r)
+		return
+	}
+	full := filepath.Join(base, filepath.FromSlash(rel))
+	if !strings.HasPrefix(full, base+string(os.PathSeparator)) {
+		http.NotFound(w, r)
+		return
+	}
+	s.serveFile(w, r, full, rel)
+}
+
+// --- helpers ---------------------------------------------------------------
+
+// pluginErr writes an ok/err JSON response from a Manager operation error.
+func (s *Server) pluginErr(w http.ResponseWriter, err error) {
+	if err != nil {
+		apiErr(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, map[string]any{"ok": true})
+}
+
+func decodeJSON(r *http.Request, v any) error {
+	return json.NewDecoder(io.LimitReader(r.Body, maxConnBody)).Decode(v)
+}
+
+// validPluginID accepts the reverse-DNS ids the manifest allows — a safe path
+// component (no slashes, dots/hyphens only).
+func validPluginID(s string) bool {
+	if s == "" || len(s) > 128 {
+		return false
+	}
+	for _, c := range s {
+		if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-') {
+			return false
+		}
+	}
+	return !strings.Contains(s, "..")
+}
diff --git a/plugins/core.nmea0183/main.go b/plugins/core.nmea0183/main.go
new file mode 100644
index 0000000..33d0947
--- /dev/null
+++ b/plugins/core.nmea0183/main.go
@@ -0,0 +1,213 @@
+// Command core.nmea0183 is the reference chartplotter plugin (spec §12, Phase 1
+// milestone): the built-in NMEA 0183 (tcp-client) source reimplemented as a Tier-A
+// WASM plugin. It owns no I/O of its own — the host dials the socket (net.tcp-client
+// capability) and streams bytes; this plugin only frames lines, parses them with the
+// same nmea package the built-in runner uses, and publishes vessel/AIS/raw deltas
+// back to the host. The built-in stays; parity between the two is the acceptance test.
+//
+// Build (Tier A): GOOS=wasip1 GOARCH=wasm go build -o plugin.wasm ./plugins/core.nmea0183
+package main
+
+import (
+	"reflect"
+	"strconv"
+	"strings"
+
+	"github.com/beetlebugorg/chartplotter/internal/engine/nmea"
+	"github.com/beetlebugorg/chartplotter/sdk"
+)
+
+type tcpClient struct {
+	h       *sdk.Host
+	store   *nmea.Store
+	parser  *nmea.Parser
+	ais     *nmea.AISStore
+	aisFeed func(line string)
+
+	prev   map[string]any // last-published vessel paths, for diffing
+	aisVer uint64
+	buf    string // partial-line accumulator across chunks
+	handle int
+}
+
+func (p *tcpClient) Start(h *sdk.Host) {
+	p.h = h
+	p.store = &nmea.Store{}
+	p.parser = &nmea.Parser{}
+	p.ais = nmea.NewAISStore(0)
+	p.aisFeed = p.ais.Feeder()
+	p.prev = map[string]any{}
+
+	host, port := target(h.Config())
+	if host == "" || port == 0 {
+		h.Status("degraded", "no server configured")
+		return
+	}
+	// The host dials; we only ever see bytes. All callbacks fire on the read loop.
+	h.TCPConnect(host, port, sdk.TCPHandlers{
+		OnConnect: func(handle int) {
+			p.handle = handle
+			h.Status("running", "connected to "+host+":"+strconv.Itoa(port))
+		},
+		OnData:  func(_ int, data []byte) { p.onData(data) },
+		OnError: func(_ int, err error) { h.Status("degraded", "connection closed") },
+	})
+}
+
+func (p *tcpClient) Stop() {
+	if p.handle != 0 {
+		p.h.CloseHandle(p.handle)
+	}
+}
+
+// onData buffers inbound chunks and frames complete newline-terminated sentences —
+// line framing is the plugin's job, the host delivers chunks (spec §10).
+func (p *tcpClient) onData(chunk []byte) {
+	p.buf += string(chunk)
+	for {
+		i := strings.IndexByte(p.buf, '\n')
+		if i < 0 {
+			break
+		}
+		line := strings.TrimSpace(p.buf[:i])
+		p.buf = p.buf[i+1:]
+		if line != "" {
+			p.handleLine(line)
+		}
+	}
+}
+
+// handleLine mirrors the built-in runner's readLoop branch (nmea/source.go): raw →
+// sniffer, VDM/VDO → AIS decode, everything else → the vessel store.
+func (p *tcpClient) handleLine(line string) {
+	p.h.PublishRaw(line)
+	s, err := nmea.ParseSentence(line)
+	if err != nil {
+		return
+	}
+	if s.Type == "VDM" || s.Type == "VDO" {
+		p.aisFeed(line)
+		p.publishAIS()
+		return
+	}
+	p.store.Apply(p.parser, s)
+	p.publishVessel()
+}
+
+// publishVessel diffs the current vessel snapshot against the last published set and
+// emits only the changed paths (attributed to this plugin host-side).
+func (p *tcpClient) publishVessel() {
+	cur := vesselPaths(p.store.Snapshot())
+	var deltas []sdk.Delta
+	for path, v := range cur {
+		if prev, ok := p.prev[path]; !ok || !reflect.DeepEqual(prev, v) {
+			deltas = append(deltas, sdk.DeltaOf(path, v))
+		}
+	}
+	if len(deltas) > 0 {
+		p.h.PublishVessel(deltas...)
+		p.prev = cur
+	}
+}
+
+// publishAIS republishes the current target set on any change (Upsert merges, so
+// republishing is idempotent and converges the host store to this plugin's).
+func (p *tcpClient) publishAIS() {
+	if v := p.ais.Version(); v != p.aisVer {
+		p.aisVer = v
+		targets := p.ais.Snapshot()
+		out := make([]sdk.AISTarget, 0, len(targets))
+		for _, t := range targets {
+			out = append(out, aisToDTO(t))
+		}
+		p.h.PublishAIS(out...)
+	}
+}
+
+func main() {
+	if err := sdk.Run(&tcpClient{}); err != nil {
+		panic(err)
+	}
+}
+
+// --- config + mapping helpers ----------------------------------------------
+
+// target resolves host+port from config: either "host"+"port", or a combined
+// "server" ("host:port").
+func target(cfg map[string]any) (string, int) {
+	if h, ok := cfg["host"].(string); ok && h != "" {
+		return h, cfgInt(cfg, "port")
+	}
+	if s, ok := cfg["server"].(string); ok && s != "" {
+		host, portStr, _ := strings.Cut(s, ":")
+		port, _ := strconv.Atoi(portStr)
+		return host, port
+	}
+	return "", 0
+}
+
+func cfgInt(cfg map[string]any, key string) int {
+	switch v := cfg[key].(type) {
+	case float64:
+		return int(v)
+	case int:
+		return v
+	case string:
+		n, _ := strconv.Atoi(v)
+		return n
+	}
+	return 0
+}
+
+// vesselPaths flattens the set (non-nil) fields of a VesselState into the dotted
+// paths the host's vessel.write schema accepts (nmea/publish.go).
+func vesselPaths(vs nmea.VesselState) map[string]any {
+	m := map[string]any{}
+	nav := vs.Navigation
+	if nav.Position != nil {
+		m["navigation.position"] = *nav.Position
+	}
+	putF(m, "navigation.cogTrue", nav.COGTrue)
+	putF(m, "navigation.sog", nav.SOG)
+	putF(m, "navigation.headingTrue", nav.HeadingTrue)
+	putF(m, "navigation.headingMagnetic", nav.HeadingMagnetic)
+	putF(m, "navigation.magneticVariation", nav.MagneticVariation)
+	putF(m, "navigation.rateOfTurn", nav.RateOfTurn)
+	putF(m, "navigation.speedThroughWater", nav.SpeedThroughWater)
+	if nav.Datetime != nil {
+		m["navigation.datetime"] = *nav.Datetime
+	}
+	env := vs.Environment
+	putF(m, "environment.depth.belowTransducer", env.Depth.BelowTransducer)
+	putF(m, "environment.depth.belowKeel", env.Depth.BelowKeel)
+	putF(m, "environment.depth.belowSurface", env.Depth.BelowSurface)
+	putF(m, "environment.water.temperature", env.Water.Temperature)
+	putF(m, "environment.wind.angleApparent", env.Wind.AngleApparent)
+	putF(m, "environment.wind.speedApparent", env.Wind.SpeedApparent)
+	putF(m, "environment.wind.angleTrue", env.Wind.AngleTrue)
+	putF(m, "environment.wind.speedTrue", env.Wind.SpeedTrue)
+	putF(m, "environment.wind.directionTrue", env.Wind.DirectionTrue)
+	putF(m, "route.xte", vs.Route.XTE)
+	putF(m, "route.bearingToWaypoint", vs.Route.BearingToWaypoint)
+	putF(m, "route.distanceToWaypoint", vs.Route.DistanceToWaypoint)
+	if vs.Route.ActiveWaypoint != "" {
+		m["route.activeWaypoint"] = vs.Route.ActiveWaypoint
+	}
+	return m
+}
+
+func putF(m map[string]any, path string, v *float64) {
+	if v != nil {
+		m[path] = *v
+	}
+}
+
+func aisToDTO(t nmea.AISTarget) sdk.AISTarget {
+	return sdk.AISTarget{
+		MMSI: t.MMSI, Lat: t.Lat, Lon: t.Lon,
+		COG: t.COG, SOG: t.SOG, Heading: t.Heading,
+		Name: t.Name, CallSign: t.CallSign, ShipType: t.ShipType, TypeName: t.TypeName,
+		Destination: t.Destination, Length: t.Length, Beam: t.Beam, Draught: t.Draught,
+		Status: t.Status, Class: t.Class,
+	}
+}
diff --git a/plugins/core.nmea0183/plugin.json b/plugins/core.nmea0183/plugin.json
new file mode 100644
index 0000000..6775de0
--- /dev/null
+++ b/plugins/core.nmea0183/plugin.json
@@ -0,0 +1,51 @@
+{
+  "manifestVersion": 1,
+  "id": "org.beetlebug.nmea0183",
+  "name": "NMEA 0183",
+  "version": "1.0.0",
+  "description": "Connects to a NMEA 0183 server over TCP and reads instrument sentences \u2014 GPS position, heading, speed, depth, wind \u2014 plus AIS targets, feeding them to the plotter as a data source.",
+  "publisher": "beetlebug.org",
+  "license": "MIT",
+  "apiVersion": 1,
+  "entry": {
+    "wasm": "plugin.wasm"
+  },
+  "capabilities": [
+    {
+      "cap": "vessel.write"
+    },
+    {
+      "cap": "ais.write"
+    },
+    {
+      "cap": "net.tcp-client",
+      "hosts": [
+        "${config:host}"
+      ]
+    }
+  ],
+  "ui": {
+    "settings": {
+      "items": [
+        {
+          "key": "host",
+          "type": "text",
+          "label": "Host",
+          "placeholder": "127.0.0.1"
+        },
+        {
+          "key": "port",
+          "type": "number",
+          "label": "Port",
+          "default": 10110
+        }
+      ]
+    }
+  },
+  "provides": [
+    {
+      "service": "nmea.source",
+      "apiVersion": 1
+    }
+  ]
+}
diff --git a/plugins/core.weather/gen/main.go b/plugins/core.weather/gen/main.go
new file mode 100644
index 0000000..c270554
--- /dev/null
+++ b/plugins/core.weather/gen/main.go
@@ -0,0 +1,72 @@
+// Command gen synthesizes a plausible surface-wind field and writes it as a real
+// two-message (UGRD + VGRD) GRIB2 file, used as the weather plugin's embedded offline
+// sample. Run from the plugin dir: `go run ./gen > sample.grib2` (or with -o).
+//
+// The field is a background westerly plus a cyclonic (counter-clockwise) vortex off
+// the mid-Atlantic coast, so the animated streamlines curve around a "low" — a
+// recognisable weather picture over the demo area (Chesapeake).
+package main
+
+import (
+	"flag"
+	"math"
+	"os"
+	"time"
+
+	"github.com/beetlebugorg/chartplotter/plugins/core.weather/grib"
+)
+
+func main() {
+	out := flag.String("o", "sample.grib2", "output GRIB2 path")
+	flag.Parse()
+
+	// Region: mid-Atlantic / US East Coast, 0.5°.
+	la1, lo1 := 44.0, -84.0 // NW corner (first point)
+	la2, lo2 := 30.0, -64.0 // SE corner
+	dx, dy := 0.5, 0.5
+	nx := int(math.Round((lo2-lo1)/dx)) + 1
+	ny := int(math.Round((la1-la2)/dy)) + 1
+
+	ref := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC)
+	base := grib.Grid{Nx: nx, Ny: ny, La1: la1, Lo1: lo1, La2: la2, Lo2: lo2, Dx: dx, Dy: dy, RefTime: ref}
+
+	// A forecast time series: the cyclonic low drifts north-east and intensifies,
+	// so scrubbing the time slider shows the storm track evolve.
+	hours := []int{0, 6, 12, 18, 24, 36, 48}
+	var data []byte
+	for _, hr := range hours {
+		f := float64(hr) / 48.0
+		clat := 37.5 + 3.0*f  // drifts north
+		clon := -73.0 + 4.0*f // drifts east
+		peak := 18.0 + 12.0*f // intensifies
+		u := make([]float64, nx*ny)
+		v := make([]float64, nx*ny)
+		for y := 0; y < ny; y++ {
+			lat := la1 - float64(y)*dy
+			for x := 0; x < nx; x++ {
+				lon := lo1 + float64(x)*dx
+				i := y*nx + x
+				bu, bv := 8.0, 1.5 // background westerly
+				ddx := (lon - clon) * math.Cos(lat*math.Pi/180)
+				ddy := lat - clat
+				r := math.Hypot(ddx, ddy)
+				strength := peak * math.Exp(-r*r/18) // Gaussian falloff, m/s
+				if r > 1e-6 {
+					u[i] = bu + strength*(-ddy/r) // counter-clockwise tangential
+					v[i] = bv + strength*(ddx/r)
+				} else {
+					u[i], v[i] = bu, bv
+				}
+			}
+		}
+		ug := base
+		ug.Category, ug.Number, ug.ForecastHour, ug.Values = 2, 2, hr, u // UGRD
+		vg := base
+		vg.Category, vg.Number, vg.ForecastHour, vg.Values = 2, 3, hr, v // VGRD
+		data = append(data, grib.Encode(ug)...)
+		data = append(data, grib.Encode(vg)...)
+	}
+	if err := os.WriteFile(*out, data, 0o644); err != nil {
+		panic(err)
+	}
+}
diff --git a/plugins/core.weather/grib/complex.go b/plugins/core.weather/grib/complex.go
new file mode 100644
index 0000000..48dda1e
--- /dev/null
+++ b/plugins/core.weather/grib/complex.go
@@ -0,0 +1,160 @@
+package grib
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math"
+)
+
+// unpackComplex decodes GRIB2 complex packing (data-representation template 5.2) and
+// complex packing with spatial differencing (5.3) — the packing GFS uses. It follows
+// the NCEP g2clib comunpack algorithm: read the spatial-difference extras, then the
+// per-group reference values / bit-widths / lengths, then the packed group data, then
+// undo the differencing and apply the reference/scale.
+func unpackComplex(s5, s7 []byte, numPoints int) ([]float64, error) {
+	if len(s5) < 47 {
+		return nil, fmt.Errorf("complex: section 5 too short (%d)", len(s5))
+	}
+	tmpl := binary.BigEndian.Uint16(s5[9:11])
+	ref := float64(math.Float32frombits(binary.BigEndian.Uint32(s5[11:15])))
+	binScale := signed16(s5[15:17])
+	decScale := signed16(s5[17:19])
+	refBits := int(s5[19])
+	ng := int(binary.BigEndian.Uint32(s5[31:35]))
+	groupWidthRef := int(s5[35])
+	groupWidthBits := int(s5[36])
+	groupLenRef := int(binary.BigEndian.Uint32(s5[37:41]))
+	groupLenInc := int(s5[41])
+	lastGroupLen := int(binary.BigEndian.Uint32(s5[42:46]))
+	groupLenBits := int(s5[46])
+
+	spatialOrder, extraOctets := 0, 0
+	if tmpl == 3 {
+		if len(s5) < 49 {
+			return nil, fmt.Errorf("complex: 5.3 section 5 too short (%d)", len(s5))
+		}
+		spatialOrder = int(s5[47])
+		extraOctets = int(s5[48])
+	}
+	if ng <= 0 || ng > numPoints+1 {
+		return nil, fmt.Errorf("complex: implausible group count %d", ng)
+	}
+
+	pos := 0
+	// The NCEP encoder byte-aligns each subsection of the data section; without this
+	// the group data desyncs and the second-order integration runs away to garbage.
+	align := func() {
+		if r := pos % 8; r != 0 {
+			pos += 8 - r
+		}
+	}
+
+	// 1. Spatial-difference extras: the first value(s) (unsigned) and the overall
+	//    minimum of the differences (sign-magnitude), each `extraOctets` octets.
+	var ival1, ival2, minsd int64
+	if spatialOrder > 0 {
+		nb := extraOctets * 8
+		ival1 = int64(readBits(s7, pos, nb))
+		pos += nb
+		if spatialOrder == 2 {
+			ival2 = int64(readBits(s7, pos, nb))
+			pos += nb
+		}
+		minsd = readSignedBits(s7, pos, nb)
+		pos += nb
+	}
+	align()
+
+	// 2. Group reference values.
+	refs := make([]int64, ng)
+	for i := 0; i < ng; i++ {
+		refs[i] = int64(readBits(s7, pos, refBits))
+		pos += refBits
+	}
+	align()
+	// 3. Group widths.
+	widths := make([]int, ng)
+	for i := 0; i < ng; i++ {
+		widths[i] = groupWidthRef + int(readBits(s7, pos, groupWidthBits))
+		pos += groupWidthBits
+	}
+	align()
+	// 4. Group lengths (the last group's length is given explicitly).
+	lengths := make([]int, ng)
+	for i := 0; i < ng; i++ {
+		lengths[i] = groupLenRef + groupLenInc*int(readBits(s7, pos, groupLenBits))
+		pos += groupLenBits
+	}
+	lengths[ng-1] = lastGroupLen
+	align()
+
+	// 5. Packed group data → integer field.
+	ifld := make([]int64, numPoints)
+	n := 0
+	for gi := 0; gi < ng; gi++ {
+		w, l := widths[gi], lengths[gi]
+		if n+l > numPoints {
+			l = numPoints - n
+		}
+		if w == 0 {
+			for j := 0; j < l; j++ {
+				ifld[n] = refs[gi]
+				n++
+			}
+		} else {
+			for j := 0; j < l; j++ {
+				ifld[n] = refs[gi] + int64(readBits(s7, pos, w))
+				pos += w
+				n++
+			}
+		}
+	}
+	if n != numPoints {
+		return nil, fmt.Errorf("complex: unpacked %d of %d points", n, numPoints)
+	}
+
+	// 6. Undo spatial differencing.
+	switch spatialOrder {
+	case 1:
+		ifld[0] = ival1
+		for i := 1; i < numPoints; i++ {
+			ifld[i] += minsd
+			ifld[i] += ifld[i-1]
+		}
+	case 2:
+		if numPoints > 0 {
+			ifld[0] = ival1
+		}
+		if numPoints > 1 {
+			ifld[1] = ival2
+		}
+		for i := 2; i < numPoints; i++ {
+			ifld[i] += minsd
+			ifld[i] += 2*ifld[i-1] - ifld[i-2]
+		}
+	}
+
+	// 7. Apply reference + scale: Y = (R + X·2^E) / 10^D.
+	bs := math.Pow(2, float64(binScale))
+	ds := math.Pow(10, float64(decScale))
+	out := make([]float64, numPoints)
+	for i := 0; i < numPoints; i++ {
+		out[i] = (ref + float64(ifld[i])*bs) / ds
+	}
+	return out, nil
+}
+
+// readSignedBits reads a GRIB2 sign-magnitude integer of nb bits (the high bit is the
+// sign) starting at bit offset pos.
+func readSignedBits(data []byte, pos, nb int) int64 {
+	if nb == 0 {
+		return 0
+	}
+	raw := readBits(data, pos, nb)
+	sign := raw >> uint(nb-1)
+	mag := int64(raw & ((1 << uint(nb-1)) - 1))
+	if sign != 0 {
+		return -mag
+	}
+	return mag
+}
diff --git a/plugins/core.weather/grib/encode.go b/plugins/core.weather/grib/encode.go
new file mode 100644
index 0000000..793d00d
--- /dev/null
+++ b/plugins/core.weather/grib/encode.go
@@ -0,0 +1,141 @@
+package grib
+
+import (
+	"encoding/binary"
+	"math"
+)
+
+// Encode writes one GRIB2 message for g using grid-point simple packing (the inverse
+// of Decode). Used to build the plugin's embedded sample fixture and for round-trip
+// tests; it is not a general-purpose encoder. Values are packed at 0.01 precision in
+// 16 bits (ample for wind in m/s).
+func Encode(g Grid) []byte {
+	const decScale = 2 // 0.01 precision
+	const binScale = 0
+	const bits = 16
+
+	// Simple-packing quantisation: X = round((Y·10^D − R) / 2^E), R = min·10^D.
+	ds := math.Pow(10, float64(decScale))
+	ref := math.Inf(1)
+	for _, v := range g.Values {
+		if v*ds < ref {
+			ref = v * ds
+		}
+	}
+	if math.IsInf(ref, 1) {
+		ref = 0
+	}
+	packed := make([]uint16, len(g.Values))
+	for i, v := range g.Values {
+		packed[i] = uint16(math.Round((v*ds - ref)))
+	}
+
+	// Section 1 — Identification (21 octets).
+	s1 := make([]byte, 21)
+	putSecHeader(s1, 1)
+	binary.BigEndian.PutUint16(s1[12:14], uint16(g.RefTime.Year()))
+	s1[14] = byte(g.RefTime.Month())
+	s1[15] = byte(g.RefTime.Day())
+	s1[16] = byte(g.RefTime.Hour())
+	s1[17] = byte(g.RefTime.Minute())
+	s1[18] = byte(g.RefTime.Second())
+
+	// Section 3 — Grid definition, template 3.0 (72 octets).
+	s3 := make([]byte, 72)
+	putSecHeader(s3, 3)
+	binary.BigEndian.PutUint32(s3[6:10], uint32(g.Nx*g.Ny))
+	s3[14] = 6 // shape of earth: spherical, radius 6371229 m (default)
+	binary.BigEndian.PutUint32(s3[30:34], uint32(g.Nx))
+	binary.BigEndian.PutUint32(s3[34:38], uint32(g.Ny))
+	putMicro(s3[46:50], g.La1)
+	putMicro(s3[50:54], g.Lo1)
+	s3[54] = 0x30 // resolution/component flags: i,j increments given
+	putMicro(s3[55:59], g.La2)
+	putMicro(s3[59:63], g.Lo2)
+	putMicro(s3[63:67], g.Dx)
+	putMicro(s3[67:71], g.Dy)
+	s3[71] = 0x00 // scan mode: +i (W→E), -j (N→S), row-major from (La1,Lo1)
+
+	// Section 4 — Product definition, template 4.0 (34 octets).
+	s4 := make([]byte, 34)
+	putSecHeader(s4, 4)
+	s4[9] = byte(g.Category)
+	s4[10] = byte(g.Number)
+	s4[17] = 1 // unit of time range: hour
+	binary.BigEndian.PutUint32(s4[18:22], uint32(g.ForecastHour))
+	s4[22] = 103                                      // first fixed surface: specified height above ground
+	binary.BigEndian.PutUint32(s4[24:28], uint32(10)) // 10 m
+	s4[28] = 255                                      // no second fixed surface
+
+	// Section 5 — Data representation, template 5.0 (21 octets).
+	s5 := make([]byte, 21)
+	putSecHeader(s5, 5)
+	binary.BigEndian.PutUint32(s5[5:9], uint32(len(g.Values)))
+	binary.BigEndian.PutUint32(s5[11:15], math.Float32bits(float32(ref)))
+	putSigned16(s5[15:17], binScale)
+	putSigned16(s5[17:19], decScale)
+	s5[19] = bits
+	s5[20] = 0 // original values: floating point
+
+	// Section 6 — Bitmap (none).
+	s6 := make([]byte, 6)
+	putSecHeader(s6, 6)
+	s6[5] = 255
+
+	// Section 7 — Data.
+	dataBytes := make([]byte, len(packed)*2)
+	for i, x := range packed {
+		binary.BigEndian.PutUint16(dataBytes[i*2:], x)
+	}
+	s7 := make([]byte, 5+len(dataBytes))
+	putSecHeader(s7, 7)
+	copy(s7[5:], dataBytes)
+
+	body := concat(s1, s3, s4, s5, s6, s7)
+	total := 16 + len(body) + 4 // Section 0 + body + Section 8
+
+	out := make([]byte, 0, total)
+	s0 := make([]byte, 16)
+	copy(s0, "GRIB")
+	s0[6] = 2 // discipline: meteorological
+	s0[7] = 2 // edition
+	binary.BigEndian.PutUint64(s0[8:16], uint64(total))
+	out = append(out, s0...)
+	out = append(out, body...)
+	out = append(out, "7777"...)
+	return out
+}
+
+// putSecHeader writes a section's length (filled by the caller's fixed size) and
+// number into the first 5 octets.
+func putSecHeader(s []byte, num byte) {
+	binary.BigEndian.PutUint32(s[0:4], uint32(len(s)))
+	s[4] = num
+}
+
+func putMicro(b []byte, deg float64) {
+	neg := deg < 0
+	u := uint32(math.Round(math.Abs(deg) * 1e6))
+	if neg {
+		u |= 0x80000000
+	}
+	binary.BigEndian.PutUint32(b, u)
+}
+
+func putSigned16(b []byte, v int) {
+	var u uint16
+	if v < 0 {
+		u = uint16(-v) | 0x8000
+	} else {
+		u = uint16(v)
+	}
+	binary.BigEndian.PutUint16(b, u)
+}
+
+func concat(parts ...[]byte) []byte {
+	var out []byte
+	for _, p := range parts {
+		out = append(out, p...)
+	}
+	return out
+}
diff --git a/plugins/core.weather/grib/gfs_test.go b/plugins/core.weather/grib/gfs_test.go
new file mode 100644
index 0000000..239fae5
--- /dev/null
+++ b/plugins/core.weather/grib/gfs_test.go
@@ -0,0 +1,50 @@
+package grib
+
+import (
+	"math"
+	"os"
+	"testing"
+)
+
+// TestDecodeRealGFSComplex validates the complex-packing + spatial-differencing path
+// (template 5.3) against a real GFS wind field: the 1° UGRD/VGRD at 10 m above
+// ground, byte-range-fetched from the NOAA GFS open-data archive
+// (gfs.20230927/00). If the decoder desyncs (e.g. missing the byte-alignment between
+// group subsections), the second-order integration runs away and the value range
+// explodes — so the plausibility bounds below are a real correctness check.
+func TestDecodeRealGFSComplex(t *testing.T) {
+	b, err := os.ReadFile("testdata/gfs_1deg_uv.grib2")
+	if err != nil {
+		t.Skipf("fixture missing: %v", err)
+	}
+	grids, err := Decode(b)
+	if err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if len(grids) != 2 {
+		t.Fatalf("want 2 messages (UGRD+VGRD), got %d", len(grids))
+	}
+	for _, g := range grids {
+		if g.Nx != 360 || g.Ny != 181 {
+			t.Fatalf("grid %dx%d, want 360x181 (1° global)", g.Nx, g.Ny)
+		}
+		if g.Category != 2 || (g.Number != 2 && g.Number != 3) {
+			t.Fatalf("unexpected param %d/%d", g.Category, g.Number)
+		}
+		mn, mx := math.Inf(1), math.Inf(-1)
+		for _, v := range g.Values {
+			if math.IsNaN(v) || math.IsInf(v, 0) {
+				t.Fatalf("param %d/%d produced NaN/Inf", g.Category, g.Number)
+			}
+			mn, mx = math.Min(mn, v), math.Max(mx, v)
+		}
+		// Surface wind: realistic magnitudes are well under 100 m/s. A desync blows
+		// this to billions.
+		if mn < -100 || mx > 100 {
+			t.Fatalf("param %d/%d range [%.1f, %.1f] m/s is implausible (decode desync?)", g.Category, g.Number, mn, mx)
+		}
+		if mx-mn < 1 {
+			t.Fatalf("param %d/%d looks constant [%.2f, %.2f] — decode likely wrong", g.Category, g.Number, mn, mx)
+		}
+	}
+}
diff --git a/plugins/core.weather/grib/grib.go b/plugins/core.weather/grib/grib.go
new file mode 100644
index 0000000..e266e90
--- /dev/null
+++ b/plugins/core.weather/grib/grib.go
@@ -0,0 +1,219 @@
+// Package grib is a minimal GRIB2 codec for gridded wind fields: it decodes (and, for
+// building fixtures, encodes) regular lat/lon grids with grid-point simple packing —
+// GRIB2 grid-definition template 3.0, product template 4.0, data-representation
+// template 5.0, no bitmap. That subset is spec-compliant and covers many
+// simple-packed products; GFS's complex packing + spatial differencing (template 5.3)
+// is a documented follow-up. Big-endian throughout (GRIB2 is network byte order).
+package grib
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math"
+	"time"
+)
+
+// Grid is one decoded GRIB2 message: a field over a regular lat/lon grid
+// (template 3.0) or a Lambert-conformal grid (template 3.30, e.g. HRRR).
+type Grid struct {
+	Template           int       // grid-definition template: 0 = lat/lon, 30 = Lambert
+	Nx, Ny             int       // columns, rows
+	La1, Lo1, La2, Lo2 float64   // grid corners, degrees (La1/Lo1 = first point)
+	Dx, Dy             float64   // increments: degrees (3.0) or metres (3.30)
+	RefTime            time.Time // reference (analysis) time, UTC
+	Category, Number   int       // product discipline-2 category/number (2/2=UGRD, 2/3=VGRD)
+	ForecastHour       int       // forecast offset, hours
+	Values             []float64 // row-major from (La1,Lo1), len Nx*Ny
+
+	// Lambert-conformal parameters (template 3.30 only).
+	LoV, Latin1, Latin2 float64 // orientation longitude + standard parallels, degrees
+	ScanYUp             bool    // +j scanning: rows run south→north (HRRR does)
+	WindsGridRelative   bool    // u/v are along grid axes, not earth east/north
+}
+
+// Decode parses every GRIB2 message in b.
+func Decode(b []byte) ([]Grid, error) {
+	var out []Grid
+	for len(b) >= 16 {
+		if string(b[0:4]) != "GRIB" {
+			// Skip to the next "GRIB" (products can be concatenated with padding).
+			i := indexGRIB(b[1:])
+			if i < 0 {
+				break
+			}
+			b = b[1+i:]
+			continue
+		}
+		if b[7] != 2 {
+			return nil, fmt.Errorf("unsupported GRIB edition %d", b[7])
+		}
+		total := int(binary.BigEndian.Uint64(b[8:16]))
+		if total <= 0 || total > len(b) {
+			return nil, fmt.Errorf("bad message length %d (have %d)", total, len(b))
+		}
+		g, err := decodeMessage(b[:total])
+		if err != nil {
+			return nil, err
+		}
+		out = append(out, g)
+		b = b[total:]
+	}
+	if len(out) == 0 {
+		return nil, fmt.Errorf("no GRIB2 messages found")
+	}
+	return out, nil
+}
+
+func decodeMessage(b []byte) (Grid, error) {
+	var g Grid
+	var s5, s7 []byte // data-representation + data sections, decoded after the walk
+	numPoints := 0
+	p := 16 // past Section 0
+
+	for p+5 <= len(b) {
+		if string(b[p:p+4]) == "7777" { // Section 8: end
+			break
+		}
+		secLen := int(binary.BigEndian.Uint32(b[p : p+4]))
+		secNum := b[p+4]
+		if secLen < 5 || p+secLen > len(b) {
+			return g, fmt.Errorf("bad section %d length %d", secNum, secLen)
+		}
+		s := b[p : p+secLen]
+		switch secNum {
+		case 1: // Identification: reference time
+			year := int(binary.BigEndian.Uint16(s[12:14]))
+			g.RefTime = time.Date(year, time.Month(s[14]), int(s[15]), int(s[16]), int(s[17]), int(s[18]), 0, time.UTC)
+		case 3: // Grid definition (template 3.0 regular lat/lon, or 3.30 Lambert)
+			tmpl := binary.BigEndian.Uint16(s[12:14])
+			g.Template = int(tmpl)
+			switch tmpl {
+			case 0:
+				g.Nx = int(binary.BigEndian.Uint32(s[30:34]))
+				g.Ny = int(binary.BigEndian.Uint32(s[34:38]))
+				g.La1 = micro(s[46:50])
+				g.Lo1 = micro(s[50:54])
+				g.La2 = micro(s[55:59])
+				g.Lo2 = micro(s[59:63])
+				g.Dx = micro(s[63:67])
+				g.Dy = micro(s[67:71])
+			case 30: // Lambert conformal (HRRR/NAM); Dx/Dy are metres
+				g.Nx = int(binary.BigEndian.Uint32(s[30:34]))
+				g.Ny = int(binary.BigEndian.Uint32(s[34:38]))
+				g.La1 = micro(s[38:42])
+				g.Lo1 = micro(s[42:46])
+				g.WindsGridRelative = s[46]&0x08 != 0 // resolution/component flag bit 5
+				g.LoV = micro(s[51:55])
+				g.Dx = float64(binary.BigEndian.Uint32(s[55:59])) / 1e3
+				g.Dy = float64(binary.BigEndian.Uint32(s[59:63])) / 1e3
+				g.ScanYUp = s[64]&0x40 != 0
+				g.Latin1 = micro(s[65:69])
+				g.Latin2 = micro(s[69:73])
+			default:
+				return g, fmt.Errorf("unsupported grid template %d (want 3.0/3.30)", tmpl)
+			}
+		case 4: // Product definition (template 4.0)
+			tmpl := binary.BigEndian.Uint16(s[7:9])
+			if tmpl != 0 {
+				return g, fmt.Errorf("unsupported product template %d (want 4.0)", tmpl)
+			}
+			g.Category = int(s[9])
+			g.Number = int(s[10])
+			g.ForecastHour = int(binary.BigEndian.Uint32(s[18:22]))
+		case 5:
+			numPoints = int(binary.BigEndian.Uint32(s[5:9]))
+			s5 = s
+		case 7:
+			s7 = s[5:]
+		}
+		p += secLen
+	}
+	if s5 == nil || s7 == nil {
+		return g, fmt.Errorf("message missing data-representation or data section")
+	}
+
+	tmpl := binary.BigEndian.Uint16(s5[9:11])
+	switch tmpl {
+	case 0: // simple packing
+		refValue := math.Float32frombits(binary.BigEndian.Uint32(s5[11:15]))
+		g.Values = unpackSimple(s7, numPoints, float64(refValue), signed16(s5[15:17]), signed16(s5[17:19]), int(s5[19]))
+	case 2, 3: // complex packing (2), with spatial differencing (3)
+		vals, err := unpackComplex(s5, s7, numPoints)
+		if err != nil {
+			return g, err
+		}
+		g.Values = vals
+	default:
+		return g, fmt.Errorf("unsupported data-rep template %d (want 5.0/5.2/5.3)", tmpl)
+	}
+	if g.Values == nil {
+		return g, fmt.Errorf("message has no data section")
+	}
+	return g, nil
+}
+
+// unpackSimple applies the simple-packing formula:
+//
+//	Y = (R + X·2^E) / 10^D
+//
+// where X is the bitsPerValue-bit unsigned integer read big-endian from the stream.
+func unpackSimple(data []byte, n int, ref float64, binScale, decScale, bits int) []float64 {
+	out := make([]float64, n)
+	bs := math.Pow(2, float64(binScale))
+	ds := math.Pow(10, float64(decScale))
+	if bits == 0 { // constant field: every value == ref/10^D
+		for i := range out {
+			out[i] = ref / ds
+		}
+		return out
+	}
+	var bitPos int
+	for i := 0; i < n; i++ {
+		x := readBits(data, bitPos, bits)
+		out[i] = (ref + float64(x)*bs) / ds
+		bitPos += bits
+	}
+	return out
+}
+
+// readBits reads `bits` bits starting at bit offset `pos` (MSB-first).
+func readBits(data []byte, pos, bits int) uint64 {
+	var v uint64
+	for i := 0; i < bits; i++ {
+		bit := pos + i
+		byteIdx := bit >> 3
+		if byteIdx >= len(data) {
+			break
+		}
+		b := (data[byteIdx] >> (7 - uint(bit&7))) & 1
+		v = (v << 1) | uint64(b)
+	}
+	return v
+}
+
+// micro reads a GRIB2 lat/lon/increment (unsigned 1e-6 degrees, high bit = sign for
+// lat/lon but increments are unsigned; we treat as signed magnitude for corners).
+func micro(b []byte) float64 {
+	u := binary.BigEndian.Uint32(b)
+	if u&0x80000000 != 0 { // sign bit (GRIB2 uses sign-magnitude for lat/lon)
+		return -float64(u&0x7fffffff) / 1e6
+	}
+	return float64(u) / 1e6
+}
+
+func signed16(b []byte) int {
+	u := binary.BigEndian.Uint16(b)
+	if u&0x8000 != 0 {
+		return -int(u & 0x7fff)
+	}
+	return int(u)
+}
+
+func indexGRIB(b []byte) int {
+	for i := 0; i+4 <= len(b); i++ {
+		if string(b[i:i+4]) == "GRIB" {
+			return i
+		}
+	}
+	return -1
+}
diff --git a/plugins/core.weather/grib/grib_test.go b/plugins/core.weather/grib/grib_test.go
new file mode 100644
index 0000000..1538e56
--- /dev/null
+++ b/plugins/core.weather/grib/grib_test.go
@@ -0,0 +1,49 @@
+package grib
+
+import (
+	"math"
+	"testing"
+	"time"
+)
+
+func TestEncodeDecodeRoundTrip(t *testing.T) {
+	nx, ny := 8, 5
+	in := Grid{
+		Nx: nx, Ny: ny,
+		La1: 40, Lo1: -80, La2: 36, Lo2: -73,
+		Dx: 1, Dy: 1,
+		RefTime:  time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC),
+		Category: 2, Number: 2, // UGRD
+		ForecastHour: 0,
+		Values:       make([]float64, nx*ny),
+	}
+	for i := range in.Values {
+		in.Values[i] = -12.5 + 0.25*float64(i) // spans negative → positive, non-integer
+	}
+
+	msgs, err := Decode(Encode(in))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(msgs) != 1 {
+		t.Fatalf("want 1 message, got %d", len(msgs))
+	}
+	g := msgs[0]
+	if g.Nx != nx || g.Ny != ny {
+		t.Fatalf("grid size %dx%d, want %dx%d", g.Nx, g.Ny, nx, ny)
+	}
+	if g.Category != 2 || g.Number != 2 {
+		t.Fatalf("param %d/%d, want 2/2", g.Category, g.Number)
+	}
+	if math.Abs(g.La1-40) > 1e-4 || math.Abs(g.Lo1-(-80)) > 1e-4 {
+		t.Fatalf("corner (%.4f,%.4f), want (40,-80)", g.La1, g.Lo1)
+	}
+	if !g.RefTime.Equal(in.RefTime) {
+		t.Fatalf("refTime %v, want %v", g.RefTime, in.RefTime)
+	}
+	for i := range in.Values {
+		if math.Abs(g.Values[i]-in.Values[i]) > 0.01 { // 0.01 packing precision
+			t.Fatalf("value[%d]=%.4f, want %.4f", i, g.Values[i], in.Values[i])
+		}
+	}
+}
diff --git a/plugins/core.weather/grib/lambert.go b/plugins/core.weather/grib/lambert.go
new file mode 100644
index 0000000..91138a4
--- /dev/null
+++ b/plugins/core.weather/grib/lambert.go
@@ -0,0 +1,118 @@
+package grib
+
+import "math"
+
+// earthRadius is the NCEP spherical earth (GRIB2 shape-of-earth 6) used by the
+// Lambert-conformal products this package targets (HRRR, NAM).
+const earthRadius = 6371229.0
+
+// lambertProj returns a forward projection lat/lon (degrees) → fractional grid
+// index (i, j) for a template-3.30 grid, honouring the grid's scan direction.
+func lambertProj(g *Grid) func(lat, lon float64) (float64, float64) {
+	rad := math.Pi / 180
+	phi1, phi2 := g.Latin1*rad, g.Latin2*rad
+	var n float64
+	if math.Abs(phi1-phi2) < 1e-9 {
+		n = math.Sin(phi1)
+	} else {
+		n = math.Log(math.Cos(phi1)/math.Cos(phi2)) /
+			math.Log(math.Tan(math.Pi/4+phi2/2)/math.Tan(math.Pi/4+phi1/2))
+	}
+	f := math.Cos(phi1) * math.Pow(math.Tan(math.Pi/4+phi1/2), n) / n
+	rho := func(latDeg float64) float64 {
+		return earthRadius * f / math.Pow(math.Tan(math.Pi/4+latDeg*rad/2), n)
+	}
+	theta := func(lonDeg float64) float64 {
+		d := math.Mod(lonDeg-g.LoV, 360)
+		if d > 180 {
+			d -= 360
+		}
+		if d < -180 {
+			d += 360
+		}
+		return n * d * rad
+	}
+	xy := func(lat, lon float64) (float64, float64) {
+		r, t := rho(lat), theta(lon)
+		return r * math.Sin(t), -r * math.Cos(t)
+	}
+	x1, y1 := xy(g.La1, g.Lo1) // the grid's first point anchors the index origin
+	return func(lat, lon float64) (float64, float64) {
+		x, y := xy(lat, lon)
+		i := (x - x1) / g.Dx
+		j := (y - y1) / g.Dy // +y is north; ScanYUp grids scan the same way
+		if !g.ScanYUp {
+			j = -j
+		}
+		return i, j
+	}
+}
+
+// windAngle is the local rotation from grid axes to earth east/north at lon:
+// earthU = u·cos(a) + v·sin(a); earthV = −u·sin(a) + v·cos(a).
+func windAngle(g *Grid, lon float64) float64 {
+	rad := math.Pi / 180
+	phi1, phi2 := g.Latin1*rad, g.Latin2*rad
+	var n float64
+	if math.Abs(phi1-phi2) < 1e-9 {
+		n = math.Sin(phi1)
+	} else {
+		n = math.Log(math.Cos(phi1)/math.Cos(phi2)) /
+			math.Log(math.Tan(math.Pi/4+phi2/2)/math.Tan(math.Pi/4+phi1/2))
+	}
+	d := math.Mod(lon-g.LoV, 360)
+	if d > 180 {
+		d -= 360
+	}
+	if d < -180 {
+		d += 360
+	}
+	return n * d * rad
+}
+
+// ResampleLambert resamples fields sharing one Lambert grid onto a regular
+// lat/lon window (rows north→south from latMax, step degrees), bilinear in grid
+// space; points outside the grid come back NaN. When uIdx/vIdx are both ≥ 0 those
+// planes are a wind pair and — if the grid flags winds as grid-relative — are
+// rotated to earth east/north during resampling (skipping this skews directions by
+// >10° at the edges of a CONUS Lambert domain).
+func ResampleLambert(gs []*Grid, latMax, lonMin float64, nx, ny int, step float64, uIdx, vIdx int) [][]float64 {
+	if len(gs) == 0 {
+		return nil
+	}
+	g0 := gs[0]
+	proj := lambertProj(g0)
+	out := make([][]float64, len(gs))
+	for k := range out {
+		out[k] = make([]float64, nx*ny)
+	}
+	rotate := uIdx >= 0 && vIdx >= 0 && g0.WindsGridRelative
+	bil := func(vals []float64, i, j float64) float64 {
+		x0, y0 := int(math.Floor(i)), int(math.Floor(j))
+		if x0 < 0 || y0 < 0 || x0 >= g0.Nx-1 || y0 >= g0.Ny-1 {
+			return math.NaN()
+		}
+		tx, ty := i-float64(x0), j-float64(y0)
+		at := func(x, y int) float64 { return vals[y*g0.Nx+x] }
+		return at(x0, y0)*(1-tx)*(1-ty) + at(x0+1, y0)*tx*(1-ty) +
+			at(x0, y0+1)*(1-tx)*ty + at(x0+1, y0+1)*tx*ty
+	}
+	for r := 0; r < ny; r++ {
+		lat := latMax - float64(r)*step
+		for c := 0; c < nx; c++ {
+			lon := lonMin + float64(c)*step
+			i, j := proj(lat, lon)
+			p := r*nx + c
+			for k, g := range gs {
+				out[k][p] = bil(g.Values, i, j)
+			}
+			if rotate && !math.IsNaN(out[uIdx][p]) && !math.IsNaN(out[vIdx][p]) {
+				a := windAngle(g0, lon)
+				u, v := out[uIdx][p], out[vIdx][p]
+				out[uIdx][p] = u*math.Cos(a) + v*math.Sin(a)
+				out[vIdx][p] = -u*math.Sin(a) + v*math.Cos(a)
+			}
+		}
+	}
+	return out
+}
diff --git a/plugins/core.weather/grib/testdata/gfs_1deg_uv.grib2 b/plugins/core.weather/grib/testdata/gfs_1deg_uv.grib2
new file mode 100644
index 0000000..a9daa48
Binary files /dev/null and b/plugins/core.weather/grib/testdata/gfs_1deg_uv.grib2 differ
diff --git a/plugins/core.weather/main.go b/plugins/core.weather/main.go
new file mode 100644
index 0000000..982044a
--- /dev/null
+++ b/plugins/core.weather/main.go
@@ -0,0 +1,948 @@
+// Command core.weather is a GRIB weather plugin (Tier A, WASM). It decodes GRIB2
+// surface fields — 10 m wind plus gust, 2 m temperature, and total cloud cover —
+// into a compact binary grid and publishes it as a served artifact at
+// GET /plugins/core.weather/serve/wind.bin — the "grid, not tiles" model: the
+// plugin is never in the render path, and the frontend animates the grid as wind
+// particles entirely client-side.
+//
+// Data source (config "source"):
+//   - "gfs" (default): the latest real GFS forecast, auto-discovered from the NOAA
+//     open-data archive and byte-range-fetched (10 m wind only) via net.http.
+//   - "sample": an embedded offline GRIB2 sample (simple packing), for demos/offline.
+//   - a GFS product URL or any GRIB2 URL: fetched and decoded.
+//
+// Build (Tier A): GOOS=wasip1 GOARCH=wasm go build -o plugin.wasm ./plugins/core.weather
+package main
+
+import (
+	_ "embed"
+	"fmt"
+	"math"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/beetlebugorg/chartplotter/plugins/core.weather/grib"
+	"github.com/beetlebugorg/chartplotter/sdk"
+)
+
+//go:embed sample.grib2
+var sampleGRIB []byte
+
+type weather struct {
+	h      *sdk.Host
+	doc    windDoc // accumulates the GFS forecast series as hours are fetched
+	hiDoc  windDoc // accumulates the HRRR high-res series (sailing-area window)
+	midDoc windDoc // the same HRRR hours over all of CONUS at ~15 km (look-around)
+
+	gfsLabel, hiLabel string  // published-series summaries for the status line
+	gfsGen, hiGen     int     // invalidate an in-flight chain on refresh/config change
+	hiLat, hiLon      float64 // window centre of the last HRRR fetch (NaN = none)
+	hrrrStarted       bool    // the CONUS look-around layer has been kicked off
+	lastRefresh       string  // last seen "refresh" config nonce (UI refresh button)
+}
+
+// gfsBucket is the NOAA GFS open-data archive (S3).
+const gfsBucket = "https://noaa-gfs-bdp-pds.s3.amazonaws.com"
+
+// hrrrBucket is the NOAA HRRR open-data archive (S3): the 3 km, hourly-cycled CONUS
+// model. GFS answers "what's the weather offshore and next week"; HRRR answers it at
+// bay scale where the vessel actually sails, so the frontend samples it first.
+const hrrrBucket = "https://noaa-hrrr-bdp-pds.s3.amazonaws.com"
+
+// hrrrHours are the HRRR forecast leads fetched (hourly near now, opening out to the
+// +18 h horizon every cycle carries). Each hour is wind + gust only (~6 MB); the GFS
+// series supplies temperature/cloud and the longer horizon.
+var hrrrHours = []int{0, 1, 2, 3, 4, 6, 9, 12, 15, 18}
+
+// The HRRR sailing-area window: resampled from the Lambert CONUS grid to a regular
+// lat/lon box around the configured centre (hiLat/hiLon, set automatically by the UI
+// from the GPS fix) at ~3 km fidelity.
+const (
+	hiWindowLat = 5.0  // window height, degrees
+	hiWindowLon = 6.0  // window width, degrees
+	hiStep      = 0.03 // resample step, degrees (~3 km N-S)
+)
+
+// The HRRR "look-around" layer: the SAME downloaded records resampled over the whole
+// CONUS domain at ~15 km — panning the chart anywhere in US waters stays on HRRR
+// instead of dropping to 0.25° GFS. Costs no extra download (a GRIB byte-range always
+// delivers the full field); the coarser step keeps the published blob under the
+// serve-size limit.
+const (
+	midLatMin, midLatMax = 21.5, 48.0
+	midLonMin, midLonMax = -122.5, -61.0
+	midStep              = 0.15
+)
+
+// gfsHours are the forecast lead times fetched for the time slider (each is a
+// separate GFS product file). Dense 3-hourly steps through the first half day —
+// where linear interpolation between sparse anchors visibly misplaces fronts —
+// then widening out to two days.
+var gfsHours = []int{0, 3, 6, 9, 12, 18, 24, 36, 48}
+
+// extraFields are the per-hour scalar fields byte-range-fetched alongside 10 m wind
+// for the forecast readout. Each is one GRIB record, located by its .idx line and
+// verified by GRIB category/number after decode. Missing records are skipped (the
+// field is published as NaN for that step).
+var extraFields = []struct {
+	name         string // step slot
+	field, level string // .idx record match
+	cat, num     int    // GRIB2 category/number sanity check
+}{
+	{"gust", "GUST", "surface", 2, 22},
+	{"temp", "TMP", "2 m above ground", 0, 0},
+	{"cloud", "TCDC", "entire atmosphere", 6, 1},
+}
+
+func (p *weather) Start(h *sdk.Host) {
+	p.h = h
+	p.hiLat, p.hiLon = math.NaN(), math.NaN()
+	p.lastRefresh = h.ConfigString("refresh") // don't re-trigger on the persisted nonce
+	src := h.ConfigString("source")
+	if src == "" {
+		src = "gfs" // default: the latest real GFS forecast
+	}
+	switch {
+	case src == "sample":
+		p.publish(sampleGRIB, "embedded sample")
+	case src == "gfs":
+		p.gfsGen++
+		p.discoverGFS(p.gfsGen) // auto-resolve the latest cycle → fetch
+		p.maybeStartHRRR()
+	case strings.Contains(src, "pgrb2"): // an explicit GFS product URL
+		h.Status("running", "fetching GFS…")
+		p.fetchGFS(src, "GFS", func() { h.Status("degraded", "GFS product not available") })
+	default: // any other GRIB2 URL
+		h.Status("running", "fetching "+src)
+		h.Fetch(src, func(resp *sdk.HTTPResponse, err error) { p.onFetch(resp, err, src) })
+	}
+}
+
+// ConfigChanged reacts to hot config edits: a bumped "refresh" nonce (the panel's ↻
+// button / auto-refresh) re-pulls the newest cycles; otherwise the sailing-area
+// centre (hiLat/hiLon, written by the UI from the GPS fix) may have moved and the
+// HRRR window follows it — either way without a plugin restart.
+func (p *weather) ConfigChanged() {
+	if r := p.h.ConfigString("refresh"); r != p.lastRefresh {
+		p.lastRefresh = r
+		p.h.Log("info", "refresh requested — re-resolving newest cycles")
+		src := p.h.ConfigString("source")
+		if src == "" || src == "gfs" {
+			p.gfsGen++
+			p.discoverGFS(p.gfsGen)
+		}
+		p.hrrrStarted = false // force a fresh HRRR cycle walk (same centre or not)
+		p.hiLat, p.hiLon = math.NaN(), math.NaN()
+	}
+	p.maybeStartHRRR()
+}
+
+// configBool reads a boolean config value with a default (the settings toggle
+// stores true/false; tolerate strings for hand-edited config).
+func (p *weather) configBool(key string, def bool) bool {
+	switch v := p.h.Config()[key].(type) {
+	case bool:
+		return v
+	case string:
+		return v != "false" && v != "off" && v != "0"
+	case float64:
+		return v != 0
+	}
+	return def
+}
+
+// configFloat reads a numeric config value (numbers arrive as float64; the settings
+// UI may store strings).
+func (p *weather) configFloat(key string) (float64, bool) {
+	switch v := p.h.Config()[key].(type) {
+	case float64:
+		return v, true
+	case string:
+		f, err := strconv.ParseFloat(v, 64)
+		return f, err == nil
+	}
+	return 0, false
+}
+
+// maybeStartHRRR kicks off (or re-kicks) the HRRR series. The CONUS look-around
+// layer always fetches; the 3 km sailing window additionally needs a centre
+// (hiLat/hiLon config, written by the UI from the GPS fix). An unchanged centre
+// keeps the already-published series.
+func (p *weather) maybeStartHRRR() {
+	// The one user-facing knob: HRRR costs ~60 MB per refresh, which matters on
+	// metered marine data. Off → GFS-only; withdraw any published layers.
+	if !p.configBool("hires", true) {
+		if p.hrrrStarted {
+			p.hiGen++ // cancels any in-flight chain
+			p.hrrrStarted = false
+			p.hiLat, p.hiLon = math.NaN(), math.NaN()
+			p.h.ServeClear("wind-hi.bin")
+			p.h.ServeClear("wind-mid.bin")
+			p.hiLabel = ""
+			p.status()
+		}
+		return
+	}
+	lat, okLat := p.configFloat("hiLat")
+	lon, okLon := p.configFloat("hiLon")
+	hasCtr := okLat && okLon && lat >= 20 && lat <= 50 && lon >= -130 && lon <= -60
+	if hasCtr {
+		if math.Abs(lat-p.hiLat) < 0.25 && math.Abs(lon-p.hiLon) < 0.25 {
+			return // same sailing area — the published window still covers it
+		}
+	} else {
+		if p.hrrrStarted {
+			return // CONUS layer already fetched/fetching; nothing new to do
+		}
+		lat, lon = math.NaN(), math.NaN() // no centre: mid layer only
+	}
+	p.hrrrStarted = true
+	p.hiLat, p.hiLon = lat, lon
+	p.hiGen++
+	gen := p.hiGen
+	c := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Hour)
+	p.tryHRRRCycle(gen, c, 0)
+}
+
+// hrrrHourURL is the HRRR CONUS surface-product URL for a cycle + forecast hour.
+func hrrrHourURL(date, hh string, fff int) string {
+	return fmt.Sprintf("%s/hrrr.%s/conus/hrrr.t%sz.wrfsfcf%02d.grib2", hrrrBucket, date, hh, fff)
+}
+
+// tryHRRRCycle walks back hourly (HRRR cycles hourly, publishing ~1 h behind) until a
+// cycle's f00 index answers, then fetches the series.
+func (p *weather) tryHRRRCycle(gen int, c time.Time, back int) {
+	if gen != p.hiGen {
+		return // superseded by a newer window
+	}
+	if back > 6 {
+		p.h.Log("warn", "no recent HRRR cycle available (walked 6 h back)")
+		p.h.Status("degraded", "no recent HRRR cycle available")
+		return
+	}
+	t := c.Add(time.Duration(-back) * time.Hour)
+	date, hh := t.Format("20060102"), t.Format("15")
+	p.h.Fetch(hrrrHourURL(date, hh, 0)+".idx", func(resp *sdk.HTTPResponse, err error) {
+		if err != nil || resp == nil || resp.Status != 200 {
+			p.tryHRRRCycle(gen, c, back+1)
+			return
+		}
+		p.h.Log("info", "HRRR cycle "+date+" "+hh+"z")
+		p.hiDoc, p.midDoc = windDoc{}, windDoc{}
+		p.fetchHRRRHour(gen, date, hh, 0)
+	})
+}
+
+// fetchHRRRHour fetches hrrrHours[i]'s 10 m wind + surface gust, resamples the
+// Lambert field to the sailing-area lat/lon window, and chains to the next hour;
+// after the last it publishes wind-hi.bin.
+func (p *weather) fetchHRRRHour(gen int, date, hh string, i int) {
+	if gen != p.hiGen {
+		return
+	}
+	if i >= len(hrrrHours) {
+		if len(p.midDoc.Steps) == 0 {
+			p.hiLabel = ""
+			return
+		}
+		done := func() {
+			p.hiLabel = fmt.Sprintf("HRRR %sz: %d step(s)", hh, len(p.midDoc.Steps))
+			p.h.Log("info", fmt.Sprintf("published HRRR layers: %d step(s), window %v", len(p.midDoc.Steps), len(p.hiDoc.Steps) > 0))
+			p.status()
+		}
+		p.h.ServeSet("wind-mid.bin", encodeWindBin(&p.midDoc), func(_ string, err error) {
+			if err != nil {
+				p.h.Status("error", "publish mid: "+err.Error())
+				return
+			}
+			if len(p.hiDoc.Steps) == 0 { // no sailing-area centre yet
+				done()
+				return
+			}
+			p.h.ServeSet("wind-hi.bin", encodeWindBin(&p.hiDoc), func(_ string, err error) {
+				if err != nil {
+					p.h.Status("error", "publish hi: "+err.Error())
+					return
+				}
+				done()
+			})
+		})
+		return
+	}
+	fff := hrrrHours[i]
+	url := hrrrHourURL(date, hh, fff)
+	p.h.Status("running", fmt.Sprintf("fetching HRRR %sz +%dh (%d/%d)…", hh, fff, i+1, len(hrrrHours)))
+	next := func() { p.fetchHRRRHour(gen, date, hh, i+1) }
+	p.h.Fetch(url+".idx", func(resp *sdk.HTTPResponse, err error) {
+		if gen != p.hiGen {
+			return
+		}
+		if err != nil || resp == nil || resp.Status != 200 {
+			next()
+			return
+		}
+		recs := parseIdx(string(resp.Body))
+		start, end, ok := windRangeRecs(recs)
+		if !ok {
+			next()
+			return
+		}
+		p.h.FetchOpts(url, map[string]string{"Range": fmt.Sprintf("bytes=%d-%d", start, end)}, func(r *sdk.HTTPResponse, e error) {
+			if gen != p.hiGen {
+				return
+			}
+			if e != nil || r == nil || (r.Status != 200 && r.Status != 206) {
+				next()
+				return
+			}
+			wind := r.Body
+			gs, ge, gok := fieldRange(recs, "GUST", "surface")
+			if !gok {
+				p.addHRRRStep(fff, wind, nil)
+				next()
+				return
+			}
+			p.h.FetchOpts(url, map[string]string{"Range": fmt.Sprintf("bytes=%d-%d", gs, ge)}, func(gr *sdk.HTTPResponse, ge2 error) {
+				if gen != p.hiGen {
+					return
+				}
+				var gust []byte
+				if ge2 == nil && gr != nil && (gr.Status == 200 || gr.Status == 206) {
+					gust = gr.Body
+				}
+				p.addHRRRStep(fff, wind, gust)
+				next()
+			})
+		})
+	})
+}
+
+// addHRRRStep decodes an hour's Lambert wind (+ optional gust), resamples it to the
+// window around the configured centre — rotating grid-relative winds to earth east/
+// north — and appends the step.
+func (p *weather) addHRRRStep(fff int, windBlob, gustBlob []byte) {
+	grids, err := grib.Decode(windBlob)
+	if err != nil {
+		return
+	}
+	var u, v *grib.Grid
+	for i := range grids {
+		g := &grids[i]
+		if g.Template != 30 {
+			return
+		}
+		if g.Category == 2 && g.Number == 2 {
+			u = g
+		}
+		if g.Category == 2 && g.Number == 3 {
+			v = g
+		}
+	}
+	if u == nil || v == nil {
+		return
+	}
+	fields := []*grib.Grid{u, v}
+	if gustBlob != nil {
+		if gg, err := grib.Decode(gustBlob); err == nil && len(gg) > 0 &&
+			gg[0].Template == 30 && gg[0].Category == 2 && gg[0].Number == 22 {
+			fields = append(fields, &gg[0])
+		}
+	}
+	// The same decoded CONUS field feeds both layers: full 3 km fidelity in the
+	// sailing window, ~15 km across the whole domain for panning around.
+	addTo := func(doc *windDoc, latMax, lonMin, stepDeg float64, nx, ny int) {
+		planes := grib.ResampleLambert(fields, latMax, lonMin, nx, ny, stepDeg, 0, 1)
+		if doc.Header == (gridHeader{}) {
+			doc.RefTime = u.RefTime.Format(time.RFC3339)
+			doc.Header = gridHeader{
+				Nx: nx, Ny: ny, Dx: stepDeg, Dy: stepDeg,
+				Lo1: lonMin, La1: latMax,
+				Lo2: lonMin + float64(nx-1)*stepDeg, La2: latMax - float64(ny-1)*stepDeg,
+			}
+		}
+		st := step{Hour: fff, U: planes[0], V: planes[1]}
+		if len(planes) > 2 {
+			st.Gust = planes[2]
+		}
+		doc.Steps = append(doc.Steps, st)
+	}
+	if !math.IsNaN(p.hiLat) { // the 3 km window needs a sailing-area centre
+		addTo(&p.hiDoc, p.hiLat+hiWindowLat/2, p.hiLon-hiWindowLon/2, hiStep,
+			int(math.Round(hiWindowLon/hiStep))+1, int(math.Round(hiWindowLat/hiStep))+1)
+	}
+	addTo(&p.midDoc, midLatMax, midLonMin, midStep,
+		int(math.Round((midLonMax-midLonMin)/midStep))+1, int(math.Round((midLatMax-midLatMin)/midStep))+1)
+}
+
+// status writes the combined published-series summary.
+func (p *weather) status() {
+	s := p.gfsLabel
+	if p.hiLabel != "" {
+		if s != "" {
+			s += " · "
+		}
+		s += p.hiLabel
+	}
+	if s != "" {
+		p.h.Status("running", s)
+	}
+}
+
+// discoverGFS fetches the latest available GFS cycle. GFS runs at 00/06/12/18Z and a
+// cycle is published a few hours after its nominal time, so we start ~5h back, floor
+// to a 6h boundary, and walk older until a cycle's .idx is available. Uses the wall
+// clock (available to the module) — no bucket listing, which is paginated oldest-first.
+func (p *weather) discoverGFS(gen int) {
+	p.h.Status("running", "finding latest GFS cycle…")
+	c := time.Now().UTC().Add(-5 * time.Hour).Truncate(6 * time.Hour) // newest likely-published cycle
+	p.tryCycle(gen, c, 0)
+}
+
+// tryCycle attempts the cycle `back` steps before c, falling to the previous one if it
+// isn't up yet, up to ~2 days back. Once a cycle is confirmed (its f000 index exists),
+// it fetches the whole forecast series for the time slider.
+func (p *weather) tryCycle(gen int, c time.Time, back int) {
+	if gen != p.gfsGen {
+		return // superseded by a refresh
+	}
+	if back > 8 {
+		p.h.Log("warn", "no recent GFS cycle available (walked 2 days back)")
+		p.h.Status("degraded", "no recent GFS cycle available")
+		return
+	}
+	t := c.Add(time.Duration(-back*6) * time.Hour)
+	date, hh := t.Format("20060102"), t.Format("15")
+	p.h.Fetch(gfsHourURL(date, hh, 0)+".idx", func(resp *sdk.HTTPResponse, err error) {
+		if gen != p.gfsGen {
+			return
+		}
+		if err != nil || resp == nil || resp.Status != 200 {
+			p.tryCycle(gen, c, back+1) // this cycle isn't up yet
+			return
+		}
+		p.h.Log("info", "GFS cycle "+date+" "+hh+"z")
+		p.doc = windDoc{}
+		p.fetchHour(gen, date, hh, 0)
+	})
+}
+
+// gfsHourURL is the 0.25° GFS product URL for a cycle + forecast hour. 0.25° resolves
+// coastal/inland waters (bays, sounds) that the coarse 0.5° field smears into land —
+// the global field is cropped to the chart region (see cropGrid) so the wire stays
+// small despite the finer grid.
+func gfsHourURL(date, hh string, fff int) string {
+	return fmt.Sprintf("%s/gfs.%s/%s/atmos/gfs.t%sz.pgrb2.0p25.f%03d", gfsBucket, date, hh, hh, fff)
+}
+
+// fetchHour fetches gfsHours[i]'s 10 m wind (plus the extra readout fields), appends
+// it as a forecast step, and chains to the next; after the last it publishes the
+// multi-step series. Missing hours are skipped (a fresh cycle may not have the longer
+// leads yet).
+func (p *weather) fetchHour(gen int, date, hh string, i int) {
+	if gen != p.gfsGen {
+		return // superseded by a refresh
+	}
+	if i >= len(gfsHours) {
+		if len(p.doc.Steps) == 0 {
+			p.h.Status("degraded", "no GFS wind fetched")
+			return
+		}
+		p.h.ServeSet("wind.bin", encodeWindBin(&p.doc), func(_ string, err error) {
+			if err != nil {
+				p.h.Status("error", "publish: "+err.Error())
+				return
+			}
+			p.gfsLabel = fmt.Sprintf("GFS %s %sz: %d step(s)", date, hh, len(p.doc.Steps))
+			p.h.Log("info", fmt.Sprintf("published wind.bin: %s, %d step(s)", isize(p.doc.Header.Nx, p.doc.Header.Ny), len(p.doc.Steps)))
+			p.status()
+		})
+		return
+	}
+	fff := gfsHours[i]
+	url := gfsHourURL(date, hh, fff)
+	p.h.Status("running", fmt.Sprintf("fetching GFS %s %sz +%dh (%d/%d)…", date, hh, fff, i+1, len(gfsHours)))
+	p.h.Fetch(url+".idx", func(resp *sdk.HTTPResponse, err error) {
+		if gen != p.gfsGen {
+			return
+		}
+		if err != nil || resp == nil || resp.Status != 200 {
+			p.fetchHour(gen, date, hh, i+1)
+			return
+		}
+		recs := parseIdx(string(resp.Body))
+		start, end, ok := windRangeRecs(recs)
+		if !ok {
+			p.fetchHour(gen, date, hh, i+1)
+			return
+		}
+		p.h.FetchOpts(url, map[string]string{"Range": fmt.Sprintf("bytes=%d-%d", start, end)}, func(r *sdk.HTTPResponse, e error) {
+			if gen != p.gfsGen {
+				return
+			}
+			if e != nil || r == nil || (r.Status != 200 && r.Status != 206) {
+				p.fetchHour(gen, date, hh, i+1)
+				return
+			}
+			st, ok := p.newStep(fff, r.Body)
+			if !ok {
+				p.fetchHour(gen, date, hh, i+1)
+				return
+			}
+			p.fetchExtras(url, recs, st, 0, func() {
+				p.doc.Steps = append(p.doc.Steps, *st)
+				p.fetchHour(gen, date, hh, i+1)
+			})
+		})
+	})
+}
+
+// newStep decodes one forecast file's UGRD/VGRD into a cropped step, adopting the
+// grid header/reference time from the first successful hour.
+func (p *weather) newStep(fff int, blob []byte) (*step, bool) {
+	grids, err := grib.Decode(blob)
+	if err != nil {
+		return nil, false
+	}
+	var u, v *grib.Grid
+	for i := range grids {
+		g := &grids[i]
+		if g.Category == 2 && g.Number == 2 {
+			u = g
+		}
+		if g.Category == 2 && g.Number == 3 {
+			v = g
+		}
+	}
+	if u == nil || v == nil {
+		return nil, false
+	}
+	h, uu, vv := cropGrid(u, v)
+	if p.doc.Header == (gridHeader{}) {
+		p.doc.RefTime = u.RefTime.Format(time.RFC3339)
+		p.doc.Header = h
+	}
+	return &step{Hour: fff, U: uu, V: vv}, true
+}
+
+// fetchExtras chains the optional per-hour readout fields (gust/temp/cloud) onto st,
+// then calls done. A field whose record is missing or fails to decode is simply left
+// nil (published as NaN) — the wind step still ships.
+func (p *weather) fetchExtras(url string, recs []idxRec, st *step, j int, done func()) {
+	if j >= len(extraFields) {
+		done()
+		return
+	}
+	f := extraFields[j]
+	next := func() { p.fetchExtras(url, recs, st, j+1, done) }
+	start, end, ok := fieldRange(recs, f.field, f.level)
+	if !ok {
+		next()
+		return
+	}
+	p.h.FetchOpts(url, map[string]string{"Range": fmt.Sprintf("bytes=%d-%d", start, end)}, func(r *sdk.HTTPResponse, e error) {
+		if e == nil && r != nil && (r.Status == 200 || r.Status == 206) {
+			p.addExtra(st, j, r.Body)
+		}
+		next()
+	})
+}
+
+// addExtra decodes a single-record blob and stores its cropped values in the step
+// slot for extraFields[j], converting to display-friendly units (temp K → °C).
+func (p *weather) addExtra(st *step, j int, blob []byte) {
+	f := extraFields[j]
+	grids, err := grib.Decode(blob)
+	if err != nil || len(grids) == 0 {
+		return
+	}
+	g := &grids[0]
+	if g.Category != f.cat || g.Number != f.num { // idx line didn't match the record
+		return
+	}
+	_, vals, _ := cropGrid(g, g) // single field: crop once, ignore the duplicate
+	switch f.name {
+	case "gust":
+		st.Gust = vals
+	case "temp":
+		for i := range vals {
+			vals[i] -= 273.15
+		}
+		st.Temp = vals
+	case "cloud":
+		st.Cloud = vals
+	}
+}
+
+// fetchGFS byte-ranges only the 10 m UGRD/VGRD messages out of a GFS product, using
+// its wgrib2 .idx to find their offsets — so a plugin never downloads the whole
+// multi-hundred-MB file. onMiss is called if this product isn't available (the caller
+// may try an older cycle); on success it publishes with label.
+func (p *weather) fetchGFS(url, label string, onMiss func()) {
+	p.h.Fetch(url+".idx", func(resp *sdk.HTTPResponse, err error) {
+		if err != nil || resp == nil || resp.Status != 200 {
+			onMiss()
+			return
+		}
+		start, end, ok := windRange(string(resp.Body))
+		if !ok {
+			onMiss()
+			return
+		}
+		rng := fmt.Sprintf("bytes=%d-%d", start, end)
+		p.h.Status("running", "fetching "+label+"…")
+		p.h.FetchOpts(url, map[string]string{"Range": rng}, func(r *sdk.HTTPResponse, e error) {
+			if e != nil || r == nil || (r.Status != 200 && r.Status != 206) {
+				onMiss()
+				return
+			}
+			p.publish(r.Body, label)
+		})
+	})
+}
+
+func (p *weather) onFetch(resp *sdk.HTTPResponse, err error, label string) {
+	if err != nil || resp == nil || (resp.Status != 200 && resp.Status != 206) {
+		d := "fetch failed"
+		if err != nil {
+			d += ": " + err.Error()
+		} else if resp != nil {
+			d += " (HTTP " + itoa(resp.Status) + ")"
+		}
+		p.h.Status("degraded", d)
+		return
+	}
+	p.publish(resp.Body, label)
+}
+
+// idxRec is one parsed wgrib2 .idx line: byte offset, field name (UGRD, TMP, …),
+// level ("10 m above ground", "surface", …) and the forecast-time marker
+// ("anl", "24 hour fcst", "18-24 hour ave fcst", …).
+type idxRec struct {
+	off                 int
+	field, level, ftime string
+}
+
+// parseIdx parses a wgrib2 .idx listing into records (malformed lines are skipped).
+func parseIdx(idx string) []idxRec {
+	var recs []idxRec
+	for _, ln := range strings.Split(idx, "\n") {
+		f := strings.Split(ln, ":")
+		if len(f) < 6 {
+			continue
+		}
+		off, e := strconv.Atoi(f[1])
+		if e != nil {
+			continue
+		}
+		recs = append(recs, idxRec{off, f[3], f[4], f[5]})
+	}
+	return recs
+}
+
+// fieldRange returns the byte range [start,end] of the first instantaneous record
+// matching field+level (its end is the next record's offset). Time-averaged /
+// accumulated variants ("18-24 hour ave fcst") are skipped — GFS publishes both for
+// TCDC and the averaged one is a different quantity.
+func fieldRange(recs []idxRec, field, level string) (start, end int, ok bool) {
+	for i, r := range recs {
+		if r.field != field || r.level != level || strings.Contains(r.ftime, "ave") || strings.Contains(r.ftime, "acc") {
+			continue
+		}
+		end = r.off + (1 << 24) // last record: read a generous window
+		if i+1 < len(recs) {
+			end = recs[i+1].off - 1
+		}
+		return r.off, end, true
+	}
+	return 0, 0, false
+}
+
+// windRange parses a wgrib2 .idx and returns the byte range [start,end] spanning the
+// "10 m above ground" UGRD and VGRD records (their end is the next record's offset).
+func windRange(idx string) (start, end int, ok bool) {
+	return windRangeRecs(parseIdx(idx))
+}
+
+// windRangeRecs is windRange over pre-parsed records; UGRD and VGRD are adjacent in
+// GFS products, so one span covers both.
+func windRangeRecs(recs []idxRec) (start, end int, ok bool) {
+	uStart, uEnd, uOK := fieldRange(recs, "UGRD", "10 m above ground")
+	vStart, vEnd, vOK := fieldRange(recs, "VGRD", "10 m above ground")
+	if !uOK || !vOK {
+		return 0, 0, false
+	}
+	start, end = uStart, vEnd
+	if vStart < start {
+		start = vStart
+	}
+	if uEnd > end {
+		end = uEnd
+	}
+	return start, end, true
+}
+
+func (p *weather) Stop() {}
+
+// publish decodes GRIB2 wind (UGRD/VGRD, possibly several forecast hours) and serves
+// it as a multi-step wind document the frontend layer scrubs through.
+func (p *weather) publish(gribBytes []byte, srcLabel string) {
+	grids, err := grib.Decode(gribBytes)
+	if err != nil {
+		p.h.Status("error", "GRIB decode: "+err.Error())
+		return
+	}
+	// Group U/V by forecast hour, preserving hour order.
+	type uv struct{ u, v *grib.Grid }
+	byHour := map[int]*uv{}
+	var order []int
+	for i := range grids {
+		g := &grids[i]
+		if g.Category != 2 || (g.Number != 2 && g.Number != 3) {
+			continue
+		}
+		e := byHour[g.ForecastHour]
+		if e == nil {
+			e = &uv{}
+			byHour[g.ForecastHour] = e
+			order = append(order, g.ForecastHour)
+		}
+		if g.Number == 2 {
+			e.u = g
+		} else {
+			e.v = g
+		}
+	}
+	sort.Ints(order)
+
+	var doc windDoc
+	for _, hr := range order {
+		e := byHour[hr]
+		if e.u == nil || e.v == nil {
+			continue
+		}
+		// Crop the global field to the chart region — a full 0.25° global grid is far too
+		// big for the wire, but the region window keeps full fidelity where it's viewed.
+		h, u, v := cropGrid(e.u, e.v)
+		if doc.Header == (gridHeader{}) {
+			doc.RefTime = e.u.RefTime.Format(time.RFC3339)
+			doc.Header = h
+		}
+		doc.Steps = append(doc.Steps, step{Hour: hr, U: u, V: v})
+	}
+	if len(doc.Steps) == 0 {
+		p.h.Status("degraded", "no UGRD/VGRD in GRIB")
+		return
+	}
+	// Publish as a compact binary blob (Float32), not JSON: ~8× smaller, so a full
+	// 0.25° field fits the wire without downsampling. The frontend zero-copies the
+	// Float32 arrays out of the buffer.
+	body := encodeWindBin(&doc)
+	p.h.ServeSet("wind.bin", body, func(url string, err error) {
+		if err != nil {
+			p.h.Status("error", "publish: "+err.Error())
+			return
+		}
+		p.h.Status("running", "wind published ("+srcLabel+"): "+isize(doc.Header.Nx, doc.Header.Ny)+", "+itoa(len(doc.Steps))+" step(s)")
+	})
+}
+
+// encodeWindBin serialises the multi-step wind field as little-endian binary. Every
+// section is 4-byte aligned so the browser can view the u/v arrays as Float32Array
+// with zero copying:
+//
+//	"WGRD" | version u32 | nx u32 | ny u32 | nSteps u32 | lo1,la1,dx,dy f32 | refUnix f64
+//	per step: hour i32 | mask u32 | the present planes, each [nx*ny] f32
+//
+// refUnix (the cycle reference time, seconds since epoch) sits at offset 36 as a
+// float64, keeping the per-step arrays 4-byte aligned for a zero-copy view. The mask
+// (v4) says which planes follow — bit 0 u, 1 v, 2 gust, 3 temp, 4 cloud — so a
+// wind-only doc (the HRRR layers) doesn't ship NaN ballast for absent fields.
+func encodeWindBin(d *windDoc) []byte {
+	h := d.Header
+	np := h.Nx * h.Ny
+	out := make([]byte, 0, 44+len(d.Steps)*(8+np*20))
+	put32 := func(v uint32) { out = append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)) }
+	put64 := func(v uint64) {
+		out = append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24), byte(v>>32), byte(v>>40), byte(v>>48), byte(v>>56))
+	}
+	putF := func(f float64) { put32(math.Float32bits(float32(f))) }
+	var refUnix float64
+	if t, err := time.Parse(time.RFC3339, d.RefTime); err == nil {
+		refUnix = float64(t.Unix())
+	}
+	out = append(out, 'W', 'G', 'R', 'D')
+	put32(4)
+	put32(uint32(h.Nx))
+	put32(uint32(h.Ny))
+	put32(uint32(len(d.Steps)))
+	putF(h.Lo1)
+	putF(h.La1)
+	putF(h.Dx)
+	putF(h.Dy)
+	put64(math.Float64bits(refUnix))
+	for _, s := range d.Steps {
+		put32(uint32(int32(s.Hour)))
+		planes := [][]float64{s.U, s.V, s.Gust, s.Temp, s.Cloud}
+		mask := uint32(0)
+		for i, pl := range planes {
+			if pl != nil {
+				mask |= 1 << i
+			}
+		}
+		put32(mask)
+		for _, pl := range planes {
+			for _, x := range pl { // nil planes contribute nothing
+				putF(x)
+			}
+		}
+	}
+	return out
+}
+
+// windDoc is the published multi-step wind field (one grid header, N forecast steps).
+type windDoc struct {
+	RefTime string     `json:"refTime"`
+	Header  gridHeader `json:"header"`
+	Steps   []step     `json:"steps"`
+}
+
+type gridHeader struct {
+	Nx  int     `json:"nx"`
+	Ny  int     `json:"ny"`
+	Lo1 float64 `json:"lo1"`
+	La1 float64 `json:"la1"`
+	Lo2 float64 `json:"lo2"`
+	La2 float64 `json:"la2"`
+	Dx  float64 `json:"dx"`
+	Dy  float64 `json:"dy"`
+}
+
+type step struct {
+	Hour int       `json:"hour"`
+	U    []float64 `json:"u"` // 10 m wind, m/s eastward
+	V    []float64 `json:"v"` // 10 m wind, m/s northward
+	// Optional readout fields — nil when the source didn't provide them (published
+	// as NaN so the wire layout stays fixed).
+	Gust  []float64 `json:"gust,omitempty"`  // surface gust, m/s
+	Temp  []float64 `json:"temp,omitempty"`  // 2 m temperature, °C
+	Cloud []float64 `json:"cloud,omitempty"` // total cloud cover, %
+}
+
+// maxGridPoints caps a published field so the binary blob (2 × Float32 per point,
+// base64 on the wire) stays under the 16 MiB line limit. As binary, a full 0.25°
+// global field (~1.04M points ≈ 8.3 MB → ~11 MB base64) fits without downsampling;
+// finer/multi-step grids are still thinned.
+const maxGridPoints = 1_100_000
+
+// The published region: North America and adjacent waters. GFS global fields are
+// cropped to this window so a 0.25° grid (~2° of it) stays small on the wire while
+// keeping full fidelity over the charted area. Longitudes are −180..180.
+const (
+	regionLatMin, regionLatMax = 20.0, 55.0
+	regionLonMin, regionLonMax = -130.0, -60.0
+)
+
+// cropGrid extracts the region sub-rectangle from a (typically global) grid at full
+// resolution, re-basing the header to −180..180 longitude so the frontend samples it
+// directly. Falls back to a strided cap if the region isn't within the grid.
+func cropGrid(ug, vg *grib.Grid) (gridHeader, []float64, []float64) {
+	nx, ny := ug.Nx, ug.Ny
+	// Region longitudes into the grid's convention (GFS starts at Lo1=0, runs 0..360).
+	toGrid := func(lon float64) float64 {
+		for lon < ug.Lo1 {
+			lon += 360
+		}
+		for lon > ug.Lo1+360 {
+			lon -= 360
+		}
+		return lon
+	}
+	loA, loB := toGrid(regionLonMin), toGrid(regionLonMax)
+	x0 := int(math.Ceil((loA - ug.Lo1) / ug.Dx))
+	x1 := int(math.Floor((loB - ug.Lo1) / ug.Dx))
+	y0 := int(math.Ceil((ug.La1 - regionLatMax) / ug.Dy)) // rows run north→south
+	y1 := int(math.Floor((ug.La1 - regionLatMin) / ug.Dy))
+	if x0 < 0 {
+		x0 = 0
+	}
+	if x1 > nx-1 {
+		x1 = nx - 1
+	}
+	if y0 < 0 {
+		y0 = 0
+	}
+	if y1 > ny-1 {
+		y1 = ny - 1
+	}
+	if x1 < x0 || y1 < y0 { // region not covered — keep the whole grid, thinned
+		return capGrid(ug, vg, maxGridPoints)
+	}
+	nnx, nny := x1-x0+1, y1-y0+1
+	u := make([]float64, 0, nnx*nny)
+	v := make([]float64, 0, nnx*nny)
+	for y := y0; y <= y1; y++ {
+		for x := x0; x <= x1; x++ {
+			u = append(u, ug.Values[y*nx+x])
+			v = append(v, vg.Values[y*nx+x])
+		}
+	}
+	lo1 := ug.Lo1 + float64(x0)*ug.Dx
+	if lo1 > 180 {
+		lo1 -= 360
+	}
+	h := gridHeader{
+		Nx: nnx, Ny: nny, Dx: ug.Dx, Dy: ug.Dy,
+		Lo1: lo1, La1: ug.La1 - float64(y0)*ug.Dy,
+	}
+	h.Lo2 = h.Lo1 + float64(nnx-1)*h.Dx
+	h.La2 = h.La1 - float64(nny-1)*h.Dy
+	return h, u, v
+}
+
+// capGrid downsamples u/v (row-major from the grid's first point) by an integer
+// stride if the grid exceeds max points, adjusting the header increments. Streamlines
+// don't need 0.25° fidelity, and a global field must be thinned to fit the wire.
+func capGrid(ug, vg *grib.Grid, max int) (gridHeader, []float64, []float64) {
+	nx, ny := ug.Nx, ug.Ny
+	h := gridHeader{Nx: nx, Ny: ny, Lo1: ug.Lo1, La1: ug.La1, Lo2: ug.Lo2, La2: ug.La2, Dx: ug.Dx, Dy: ug.Dy}
+	if nx*ny <= max {
+		return h, ug.Values, vg.Values
+	}
+	stride := int(math.Ceil(math.Sqrt(float64(nx*ny) / float64(max))))
+	nnx, nny := (nx+stride-1)/stride, (ny+stride-1)/stride
+	u := make([]float64, 0, nnx*nny)
+	v := make([]float64, 0, nnx*nny)
+	for y := 0; y < ny; y += stride {
+		for x := 0; x < nx; x += stride {
+			u = append(u, ug.Values[y*nx+x])
+			v = append(v, vg.Values[y*nx+x])
+		}
+	}
+	h.Nx, h.Ny = nnx, nny
+	h.Dx, h.Dy = ug.Dx*float64(stride), ug.Dy*float64(stride)
+	return h, u, v
+}
+
+func isize(nx, ny int) string { return itoa(nx) + "×" + itoa(ny) }
+
+func itoa(n int) string {
+	if n == 0 {
+		return "0"
+	}
+	var b [12]byte
+	i := len(b)
+	for n > 0 {
+		i--
+		b[i] = byte('0' + n%10)
+		n /= 10
+	}
+	return string(b[i:])
+}
+
+func main() {
+	if err := sdk.Run(&weather{}); err != nil {
+		panic(err)
+	}
+}
diff --git a/plugins/core.weather/pipeline_test.go b/plugins/core.weather/pipeline_test.go
new file mode 100644
index 0000000..c7019b5
--- /dev/null
+++ b/plugins/core.weather/pipeline_test.go
@@ -0,0 +1,210 @@
+package main
+
+import (
+	"encoding/binary"
+	"math"
+	"os"
+	"testing"
+
+	"github.com/beetlebugorg/chartplotter/plugins/core.weather/grib"
+)
+
+// jsDoc mirrors what the frontend's parseWindBin builds from wind.bin.
+type jsDoc struct {
+	nx, ny            int
+	lo1, la1, dx, dy  float64
+	refUnix           float64
+	hours             []int
+	u, v              [][]float64
+	gust, temp, cloud [][]float64 // nil per step when the plane is absent (all-NaN)
+}
+
+// parseWindBinJS re-implements ui/plugin.mjs parseWindBin byte-for-byte (little
+// endian, Float32 header fields, f64 refUnix at 36, steps at 44; v3 carries five
+// planes per step with all-NaN reading as absent; v4 has a per-step plane mask and
+// ships only present planes).
+func parseWindBinJS(b []byte) *jsDoc {
+	if string(b[0:4]) != "WGRD" {
+		return nil
+	}
+	le := binary.LittleEndian
+	ver := int(le.Uint32(b[4:8]))
+	if ver < 2 || ver > 4 {
+		return nil
+	}
+	d := &jsDoc{
+		nx:  int(le.Uint32(b[8:12])),
+		ny:  int(le.Uint32(b[12:16])),
+		lo1: float64(math.Float32frombits(le.Uint32(b[20:24]))),
+		la1: float64(math.Float32frombits(le.Uint32(b[24:28]))),
+		dx:  float64(math.Float32frombits(le.Uint32(b[28:32]))),
+		dy:  float64(math.Float32frombits(le.Uint32(b[32:36]))),
+	}
+	nSteps := int(le.Uint32(b[16:20]))
+	d.refUnix = math.Float64frombits(le.Uint64(b[36:44]))
+	np := d.nx * d.ny
+	o := 44
+	plane := func() []float64 {
+		a := make([]float64, np)
+		for i := 0; i < np; i++ {
+			a[i] = float64(math.Float32frombits(le.Uint32(b[o : o+4])))
+			o += 4
+		}
+		if math.IsNaN(a[0]) && math.IsNaN(a[np-1]) { // all-NaN = absent
+			return nil
+		}
+		return a
+	}
+	for s := 0; s < nSteps; s++ {
+		d.hours = append(d.hours, int(int32(le.Uint32(b[o:o+4]))))
+		o += 4
+		mask := 0b11
+		if ver >= 4 {
+			mask = int(le.Uint32(b[o : o+4]))
+			o += 4
+		} else if ver == 3 {
+			mask = 0b11111
+		}
+		rd := func(bit int) []float64 {
+			if mask&bit == 0 {
+				return nil
+			}
+			return plane()
+		}
+		d.u = append(d.u, rd(1))
+		d.v = append(d.v, rd(2))
+		d.gust = append(d.gust, rd(4))
+		d.temp = append(d.temp, rd(8))
+		d.cloud = append(d.cloud, rd(16))
+	}
+	return d
+}
+
+// sampleJS re-implements ui/plugin.mjs _sample (bilinear, la1 = north edge).
+func (d *jsDoc) sampleJS(step int, lng, lat float64) (float64, float64, bool) {
+	global := float64(d.nx)*d.dx >= 359
+	fx := (lng - d.lo1) / d.dx
+	if global {
+		fx = math.Mod(math.Mod(fx, float64(d.nx))+float64(d.nx), float64(d.nx))
+	}
+	fy := (d.la1 - lat) / d.dy
+	if fy < 0 || fy > float64(d.ny-1) {
+		return 0, 0, false
+	}
+	if !global && (fx < 0 || fx > float64(d.nx-1)) {
+		return 0, 0, false
+	}
+	x0, y0 := int(math.Floor(fx)), int(math.Floor(fy))
+	x1 := x0 + 1
+	if global {
+		x1 = (x0 + 1) % d.nx
+	} else if x1 > d.nx-1 {
+		x1 = d.nx - 1
+	}
+	y1 := y0 + 1
+	if y1 > d.ny-1 {
+		y1 = d.ny - 1
+	}
+	tx, ty := fx-float64(x0), fy-float64(y0)
+	at := func(a []float64, x, y int) float64 { return a[y*d.nx+x] }
+	bil := func(a []float64) float64 {
+		return at(a, x0, y0)*(1-tx)*(1-ty) + at(a, x1, y0)*tx*(1-ty) +
+			at(a, x0, y1)*(1-tx)*ty + at(a, x1, y1)*tx*ty
+	}
+	return bil(d.u[step]), bil(d.v[step]), true
+}
+
+// sampleRaw bilinearly samples a decoded global grid directly (ground truth).
+func sampleRaw(g *grib.Grid, lng, lat float64) float64 {
+	lon := lng
+	for lon < g.Lo1 {
+		lon += 360
+	}
+	fx := (lon - g.Lo1) / g.Dx
+	fy := (g.La1 - lat) / g.Dy
+	x0, y0 := int(math.Floor(fx)), int(math.Floor(fy))
+	x1, y1 := (x0+1)%g.Nx, y0+1
+	if y1 > g.Ny-1 {
+		y1 = g.Ny - 1
+	}
+	tx, ty := fx-float64(x0), fy-float64(y0)
+	at := func(x, y int) float64 { return g.Values[y*g.Nx+x] }
+	return at(x0, y0)*(1-tx)*(1-ty) + at(x1, y0)*tx*(1-ty) +
+		at(x0, y1)*(1-tx)*ty + at(x1, y1)*tx*ty
+}
+
+// TestPipelineCropEncodeSample runs the real publish pipeline (decode → cropGrid →
+// encodeWindBin) on a real GFS field, then reads the blob back exactly the way the
+// frontend does and checks the sampled wind matches the raw decoded grid. Catches
+// crop offsets, header/base mismatches, and byte-layout drift between Go and JS.
+func TestPipelineCropEncodeSample(t *testing.T) {
+	b, err := os.ReadFile("grib/testdata/gfs_1deg_uv.grib2")
+	if err != nil {
+		t.Skipf("fixture missing: %v", err)
+	}
+	grids, err := grib.Decode(b)
+	if err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	var ug, vg *grib.Grid
+	for i := range grids {
+		g := &grids[i]
+		if g.Category == 2 && g.Number == 2 {
+			ug = g
+		}
+		if g.Category == 2 && g.Number == 3 {
+			vg = g
+		}
+	}
+	if ug == nil || vg == nil {
+		t.Fatal("fixture missing UGRD/VGRD")
+	}
+
+	h, u, v := cropGrid(ug, vg)
+	// A fake temp plane derived from U exercises the optional-field path; gust and
+	// cloud stay nil and must read back as absent.
+	temp := make([]float64, len(u))
+	for i := range u {
+		temp[i] = 15 + u[i]
+	}
+	doc := windDoc{RefTime: ug.RefTime.Format("2006-01-02T15:04:05Z"), Header: h,
+		Steps: []step{{Hour: 0, U: u, V: v, Temp: temp}}}
+	blob := encodeWindBin(&doc)
+
+	d := parseWindBinJS(blob)
+	if d == nil {
+		t.Fatal("parseWindBinJS: bad magic/version")
+	}
+	if d.nx != h.Nx || d.ny != h.Ny {
+		t.Fatalf("header mismatch: js %dx%d vs go %dx%d", d.nx, d.ny, h.Nx, h.Ny)
+	}
+	if d.gust[0] != nil || d.cloud[0] != nil {
+		t.Fatal("absent gust/cloud planes must parse as nil")
+	}
+	if d.temp[0] == nil {
+		t.Fatal("temp plane lost in round-trip")
+	}
+	if math.Abs(d.temp[0][100]-(15+u[100])) > 0.01 {
+		t.Fatalf("temp round-trip: got %v want %v", d.temp[0][100], 15+u[100])
+	}
+
+	pts := []struct{ lng, lat float64 }{
+		{-76.0, 37.0}, {-123.0, 37.5}, {-79.5, 25.5},
+		{-69.0, 43.0}, {-87.0, 43.5}, {-60.0, 30.0}, {-129.9, 20.1}, {-60.1, 54.9},
+	}
+	for _, p := range pts {
+		su, sv, ok := d.sampleJS(0, p.lng, p.lat)
+		if !ok {
+			t.Fatalf("sampleJS off-grid at %v,%v (grid lo1=%v la1=%v nx=%d ny=%d dx=%v dy=%v)",
+				p.lng, p.lat, d.lo1, d.la1, d.nx, d.ny, d.dx, d.dy)
+		}
+		ru := sampleRaw(ug, p.lng, p.lat)
+		rv := sampleRaw(vg, p.lng, p.lat)
+		// Float32 quantisation + a Float32 grid origin can shift sampling slightly;
+		// tolerate small differences, fail on anything structural.
+		if math.Abs(su-ru) > 0.35 || math.Abs(sv-rv) > 0.35 {
+			t.Errorf("wind mismatch at %v,%v: pipeline (%.2f,%.2f) vs raw (%.2f,%.2f)",
+				p.lng, p.lat, su, sv, ru, rv)
+		}
+	}
+}
diff --git a/plugins/core.weather/plugin.json b/plugins/core.weather/plugin.json
new file mode 100644
index 0000000..efc96b6
--- /dev/null
+++ b/plugins/core.weather/plugin.json
@@ -0,0 +1,48 @@
+{
+  "manifestVersion": 1,
+  "id": "org.beetlebug.weather",
+  "name": "Wind (GRIB)",
+  "version": "1.2.6",
+  "description": "Overlays forecast surface wind as animated streamlines. Decodes a GRIB2 wind field and draws it as moving particles on the chart, so you can see where the wind is going.",
+  "publisher": "beetlebug.org",
+  "license": "MIT",
+  "apiVersion": 1,
+  "entry": {
+    "wasm": "plugin.wasm"
+  },
+  "capabilities": [
+    {
+      "cap": "storage",
+      "quota": "16MB"
+    },
+    {
+      "cap": "net.http",
+      "hosts": [
+        "nomads.ncep.noaa.gov",
+        "*.amazonaws.com"
+      ]
+    },
+    {
+      "cap": "ui.map-layer"
+    }
+  ],
+  "ui": {
+    "entry": "ui/plugin.mjs",
+    "mapLayers": [
+      {
+        "id": "wind",
+        "title": "Wind"
+      }
+    ],
+    "settings": {
+      "items": [
+        {
+          "key": "hires",
+          "type": "toggle",
+          "label": "High-res coastal wind (HRRR, ~60 MB/refresh)",
+          "default": true
+        }
+      ]
+    }
+  }
+}
diff --git a/plugins/core.weather/sample.grib2 b/plugins/core.weather/sample.grib2
new file mode 100644
index 0000000..0b23475
Binary files /dev/null and b/plugins/core.weather/sample.grib2 differ
diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs
new file mode 100644
index 0000000..92ed179
--- /dev/null
+++ b/plugins/core.weather/ui/plugin.mjs
@@ -0,0 +1,1066 @@
+// Wind (GRIB) — UI half. Self-contained: it loads dynamically from the plugin
+// archive (/plugins/core.weather/ui/plugin.mjs) and uses only the ctx handles, no app
+// imports. It fetches the wind grid the host-side plugin published
+// (/plugins//serve/wind.json) and animates it as moving streamlines on a canvas
+// over the chart — the "grid, not tiles" model: the render is entirely client-side.
+//
+// This is the use-at-your-own-risk tier: a particle field needs raw projection, so it
+// uses ctx.map (accepting the same contract the built-ins live with) rather than the
+// declarative layers API.
+
+// Density is scaled to the viewport in _seed (not a fixed count) so a zoomed-in view
+// isn't a solid mat of lines. Kept deliberately sparse — wind is a background hint,
+// not a wall of streamlines.
+const DENSITY = 170; // particles per megapixel of map
+const MAX_PARTICLES = 420; // hard cap on big screens
+const MAX_AGE = 110; // frames before a particle respawns
+const STEP = 0.1; // screen px per (m/s) per frame on top of MIN_ADV
+// MIN_ADV is a floor on the per-frame advance (px) once there is any real wind: a
+// pure speed-proportional advance makes a 6 kt streak ~2 px — invisible. With the
+// floor, light air draws short-but-visible drifting streaks; the speed term still
+// makes strong wind race.
+const MIN_ADV = 0.6;
+// Streamline length tracks the wind: the trail keeps between TRAIL_MIN points (calm —
+// a short stub) and TRAIL_MAX (TRAIL_REF m/s and above — a long comet), on a square-
+// root curve so the low end doesn't collapse: ~16 px at 6 kt, ~45 px at 20 kt,
+// ~80 px in gale. Length AND motion speed both read as wind strength.
+const TRAIL_MIN = 6;
+const TRAIL_MAX = 34;
+const TRAIL_REF = 16; // m/s (~31 kt) at which a streamline reaches full length
+const LINE_WIDTH = 2.2;
+
+// Wind-speed colour ramp (m/s → colour), interpolated. A cool→warm scale that reads
+// on day/dusk/night charts: calm blues, moderate greens/gold, strong oranges/reds,
+// gale magenta.
+const RAMP = [
+  [0, "#5b9bd5"], [4, "#4fb0c6"], [8, "#5ec4a0"],
+  [12, "#8ecf6a"], [16, "#e6c84b"], [21, "#ef9a3d"],
+  [27, "#e5623c"], [34, "#c23b6b"], [45, "#8e3ca0"],
+];
+
+export default class WindOverlay {
+  constructor(ctx) {
+    this.ctx = ctx;
+    this._grid = null;
+    this._doc = null;
+    this._particles = [];
+    this._raf = 0;
+    this._on = true;
+  }
+
+  async start() {
+    const ctx = this.ctx;
+    // The canvas overlay, above the map GL canvas but click-through.
+    const map = ctx.map;
+    const container = map.getContainer();
+    const canvas = document.createElement("canvas");
+    canvas.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:3;";
+    container.appendChild(canvas);
+    this._canvas = canvas;
+    this._c2d = canvas.getContext("2d");
+    this._resize();
+    this._onResize = () => this._resize();
+    map.on("resize", this._onResize);
+    // Pause the particle field entirely while ZOOMING — every skipped frame is a few
+    // hundred projections the map renderer gets back, so the zoom stays fluid; the
+    // field pops back the moment the gesture ends. (The clear also matters: the
+    // projection scale changes, so anything left would stretch.) Do NOT pause on pan:
+    // the follow camera eases the map on every fix, and re-projecting per frame
+    // already tracks a pan without artifacts.
+    this._onZoom = () => {
+      this._zooming = true;
+      if (this._c2d) this._c2d.clearRect(0, 0, this._cw, this._ch);
+    };
+    this._onZoomEnd = () => {
+      this._zooming = false;
+    };
+    map.on("zoomstart", this._onZoom);
+    map.on("zoomend", this._onZoomEnd);
+    // The pill's arrow is drawn in the chart's frame — retarget it as the map turns
+    // (course-/head-up follow rotates continuously).
+    this._onRotate = () => {
+      this._updateReadout();
+      this._updateProbe(); // its arrow is drawn in the chart's frame too
+    };
+    map.on("rotate", this._onRotate);
+    // Tap-to-probe: while the wind layer is on, a chart tap drops a marker reading
+    // out the wind at that spot (tap the marker to dismiss). Registered through the
+    // shell's tap-claim chain: returning true consumes the tap, so the ECDIS pick
+    // report doesn't also fire; while the layer is off we pass, and taps behave as
+    // if the plugin weren't there. (Fallback for older shells: plain map click,
+    // unclaimed.)
+    const onTap = (e) => {
+      if (!this._on || !this._doc || !e.lngLat) return false;
+      this._setProbe({ lng: e.lngLat.lng, lat: e.lngLat.lat });
+      return true;
+    };
+    if (ctx.taps && ctx.taps.claim) {
+      ctx.taps.claim(onTap);
+    } else {
+      this._onClick = onTap;
+      map.on("click", this._onClick);
+    }
+
+    // Show/hide from the Layers control AND the on-map panel — both drive the same
+    // registry entry, so they stay in sync. Hiding stops the animation but keeps the
+    // wind data loaded (visual-only, distinct from disabling the plugin).
+    this._layer = ctx.overlays.register({
+      id: "wind",
+      title: "Wind streamlines",
+      group: "Wind",
+      onVisible: (v) => this._setOn(v),
+    });
+
+    this._mountPanel();
+    await this._loadDoc();
+    this._seed();
+    // Keep the forecast current: check every 10 min whether a newer model cycle
+    // must exist and re-pull if so (the ↻ button does the same on demand).
+    this._autoTimer = setInterval(() => this._autoRefresh(), 600000);
+    // The animation is driven by _setOn (from the overlay's persisted state,
+    // registered above). If it started before the grid loaded, it now has data.
+  }
+
+  async _loadDoc() {
+    try {
+      // The GFS base doc, the CONUS-wide HRRR look-around layer, and the optional
+      // 3 km HRRR window (published only when the plugin has a sailing-area centre —
+      // see _maybeSetCenter).
+      const [base, hi, mid] = await Promise.all([
+        this._fetchDoc("wind.bin"), this._fetchDoc("wind-hi.bin"), this._fetchDoc("wind-mid.bin"),
+      ]);
+      if (!base) return;
+      this._doc = base;
+      this._hi = hi;
+      this._mid = mid;
+      this._setStep(0); // build the initial grid from the first forecast step
+      this._buildSlider();
+      this._maybeSetCenter();
+    } catch (e) {
+      this.ctx.plugin.log("warn", "wind grid load failed", e);
+    }
+  }
+
+  async _fetchDoc(name) {
+    try {
+      const r = await fetch(`${this.ctx.assets}plugins/${this.ctx.plugin.id}/serve/${name}`, { cache: "no-store" });
+      if (!r.ok) return null;
+      return parseWindBin(await r.arrayBuffer());
+    } catch {
+      return null;
+    }
+  }
+
+  // _setStep sets the active wind grid from a fractional step position, linearly
+  // interpolating u/v between the two bracketing forecast steps.
+  _setStep(frac) {
+    const d = this._doc;
+    if (!d) return;
+    const n = d.steps.length;
+    frac = Math.max(0, Math.min(n - 1, frac));
+    const i0 = Math.floor(frac), i1 = Math.min(i0 + 1, n - 1), t = frac - i0;
+    const s0 = d.steps[i0], s1 = d.steps[i1];
+    const len = s0.u.length;
+    const u = new Array(len), v = new Array(len);
+    for (let k = 0; k < len; k++) {
+      u[k] = s0.u[k] * (1 - t) + s1.u[k] * t;
+      v[k] = s0.v[k] * (1 - t) + s1.v[k] * t;
+    }
+    const h = d.header;
+    this._grid = { nx: h.nx, ny: h.ny, lo1: h.lo1, la1: h.la1, lo2: h.lo2, la2: h.la2, dx: h.dx, dy: h.dy, u, v };
+    this._frac = frac;
+    this._hour = s0.hour * (1 - t) + s1.hour * t;
+    // The HRRR layers interpolated to the same valid time (3 km window + CONUS).
+    this._hiGrid = this._buildDocGrid(this._hi);
+    this._midGrid = this._buildDocGrid(this._mid);
+    this._updateTimeLabel();
+    this._updateReadout(); // conditions at the vessel change with the forecast step
+    this._updateDetails();
+  }
+
+  // _buildDocGrid interpolates one HRRR doc's u/v at the base doc's current valid
+  // time. Null when there is no doc or the valid time is outside its horizon —
+  // sampling then falls through to the next layer.
+  _buildDocGrid(hi) {
+    const base = this._doc;
+    if (!hi || !base || !Number.isFinite(hi.refMs) || !Number.isFinite(base.refMs)) return null;
+    const lead = (base.refMs + (this._hour || 0) * 3600000 - hi.refMs) / 3600000;
+    const s = hi.steps;
+    if (!s.length || lead < s[0].hour - 0.75 || lead > s[s.length - 1].hour + 0.75) return null;
+    let i0 = 0;
+    while (i0 < s.length - 2 && lead > s[i0 + 1].hour) i0++;
+    const i1 = Math.min(i0 + 1, s.length - 1);
+    const span = s[i1].hour - s[i0].hour;
+    const t = span > 0 ? Math.max(0, Math.min(1, (lead - s[i0].hour) / span)) : 0;
+    const len = s[i0].u.length;
+    const u = new Array(len), v = new Array(len);
+    for (let k = 0; k < len; k++) {
+      u[k] = s[i0].u[k] * (1 - t) + s[i1].u[k] * t;
+      v[k] = s[i0].v[k] * (1 - t) + s[i1].v[k] * t;
+    }
+    const h = hi.header;
+    return { nx: h.nx, ny: h.ny, lo1: h.lo1, la1: h.la1, lo2: h.lo2, la2: h.la2, dx: h.dx, dy: h.dy, u, v };
+  }
+
+  // _updateTimeLabel writes the active forecast's valid time (UTC + local) and lead
+  // hours into the scrubber's readout.
+  _updateTimeLabel() {
+    const d = this._doc;
+    if (!d || !this._label) return;
+    const leadH = this._hour || 0;
+    if (Number.isFinite(d.refMs)) {
+      const valid = new Date(d.refMs + leadH * 3600000);
+      this._label.textContent = fmtShortLocal(valid); // slim row: "Thu 14:00" (ship's clock)
+      if (this._sublabel) this._sublabel.textContent = `${fmtLocalFull(valid)} · ${fmtZ(valid)}`;
+    } else {
+      this._label.textContent = `+${Math.round(leadH)}h`;
+      if (this._sublabel) this._sublabel.textContent = "";
+    }
+    if (this._lead) {
+      const near = Math.abs(leadH - this._nowLeadH()) < 1.5;
+      this._lead.textContent = near ? "NOW" : `+${Math.round(leadH)}h`;
+      this._lead.classList.toggle("now", near);
+    }
+  }
+
+  // _nowLeadH is the lead time (hours from the cycle) that corresponds to the wall
+  // clock right now — used to mark the "now" position on the scrubber.
+  _nowLeadH() {
+    const d = this._doc;
+    if (!d || !Number.isFinite(d.refMs)) return 0;
+    return (Date.now() - d.refMs) / 3600000;
+  }
+
+  // Bilinear-sample the wind at (lng,lat), best source first: the 3 km HRRR window
+  // where the vessel sails, the ~15 km HRRR CONUS layer while looking around, the
+  // GFS base offshore / beyond the HRRR horizon. Returns [u,v] or null off-grid.
+  _sample(lng, lat) {
+    for (const g of [this._hiGrid, this._midGrid, this._grid]) {
+      if (!g) continue;
+      const w = this._sampleGrid(g, lng, lat);
+      if (w) return w;
+    }
+    return null;
+  }
+
+  // _sampleGrid bilinear-samples one interpolated u/v grid. Handles global grids
+  // expressed in 0–360° longitude (GFS) as well as −180..180; NaN cells (the HRRR
+  // window outside the model domain) count as off-grid.
+  _sampleGrid(g, lng, lat) {
+    const global = g.nx * g.dx >= 359; // spans the whole planet → longitude wraps
+    let fx = (lng - g.lo1) / g.dx;
+    if (global) fx = ((fx % g.nx) + g.nx) % g.nx; // wrap (e.g. lon -76 → 284 on a 0–360 grid)
+    const fy = (g.la1 - lat) / g.dy; // la1 is the north edge; rows go south
+    if (fy < 0 || fy > g.ny - 1) return null;
+    if (!global && (fx < 0 || fx > g.nx - 1)) return null;
+    const x0 = Math.floor(fx), y0 = Math.floor(fy);
+    const x1 = global ? (x0 + 1) % g.nx : Math.min(x0 + 1, g.nx - 1);
+    const y1 = Math.min(y0 + 1, g.ny - 1);
+    const tx = fx - x0, ty = fy - y0;
+    const at = (arr, x, y) => arr[y * g.nx + x];
+    const bil = (arr) =>
+      at(arr, x0, y0) * (1 - tx) * (1 - ty) + at(arr, x1, y0) * tx * (1 - ty) +
+      at(arr, x0, y1) * (1 - tx) * ty + at(arr, x1, y1) * tx * ty;
+    const u = bil(g.u), v = bil(g.v);
+    if (!Number.isFinite(u) || !Number.isFinite(v)) return null;
+    return [u, v];
+  }
+
+  _seed() {
+    const megapixels = (this._cw * this._ch) / 1e6 || 1;
+    const n = Math.min(MAX_PARTICLES, Math.max(250, Math.round(DENSITY * megapixels)));
+    this._particles = [];
+    for (let i = 0; i < n; i++) this._particles.push(this._spawn());
+  }
+
+  _spawn() {
+    if (!this._grid) return { lng: 0, lat: 0, age: 9999 };
+    // Spawn within the current viewport so particles are visible whatever the grid's
+    // extent (a global GFS field would otherwise scatter them across the planet).
+    const b = this.ctx.map.getBounds();
+    return {
+      lng: b.getWest() + Math.random() * (b.getEast() - b.getWest()),
+      lat: b.getSouth() + Math.random() * (b.getNorth() - b.getSouth()),
+      age: Math.floor(Math.random() * MAX_AGE),
+      trail: [], // geographic [lng,lat] history, re-projected each frame (map-locked)
+    };
+  }
+
+  // _mountPanel builds the two HUD pieces: the top-centre weather card (forecast
+  // time scrubber + detail tiles + per-step strip, hidden until data loads / the
+  // overlay is shown) and the on-map toggle/readout pill. Mounted in the shell
+  // chrome; theme vars inherit.
+  _mountPanel() {
+    const hud = this.ctx.hud.mount("wind-time");
+    // The weather card docks TOP-CENTRE, below the topbar — clear of the crowded
+    // bottom-centre chrome (data card, toasts, scale). It reads out the valid
+    // date/time (UTC primary, local secondary) of the step under the scrubber
+    // handle, the conditions at the vessel for that time (wind, gust, temperature,
+    // cloud cover), and a per-step strip for the whole forecast series.
+    hud.innerHTML = `
+
+ + NOW + + + + +
+
+ + · G + · + · +
+
+
+
· · at vessel
+
+
`; + this._sliderWrap = hud.querySelector(".wt"); + this._slider = hud.querySelector("input"); + this._label = hud.querySelector(".lbl"); + this._sublabel = hud.querySelector(".sub"); + this._lead = hud.querySelector(".lead"); + this._atLabel = hud.querySelector("#at"); + this._tiles = { + windArrow: hud.querySelector("#dwa"), wind: hud.querySelector("#dw"), windDir: hud.querySelector("#dwd"), + gust: hud.querySelector("#dg"), temp: hud.querySelector("#dt"), + cloud: hud.querySelector("#dc"), cloudWord: hud.querySelector("#dcw"), + }; + this._strip = hud.querySelector("#strip"); + this._slider.addEventListener("input", () => this._setStep(Number(this._slider.value) / 10)); + // Manual forecast refresh — bumps a config nonce the plugin watches, then polls + // for the re-published series. + this._rfBtn = hud.querySelector("#rf"); + this._rfBtn.addEventListener("click", () => this._refresh()); + + // Details fold out on demand and the choice sticks — collapsed, the card is a + // slim one-row scrubber that hides almost none of the chart. + const tgl = hud.querySelector("#fold"); + const setOpen = (open) => { + this._sliderWrap.classList.toggle("open", open); + tgl.textContent = open ? "▲" : "▼"; + try { localStorage.setItem("core.weather.panelOpen", open ? "1" : ""); } catch { /* private mode */ } + }; + let saved = ""; + try { saved = localStorage.getItem("core.weather.panelOpen") || ""; } catch { /* private mode */ } + setOpen(saved === "1"); + tgl.addEventListener("click", () => setOpen(!this._sliderWrap.classList.contains("open"))); + + // The on-map pill: enable/disable toggle AND live wind readout (speed in the + // mariner's units + compass direction it blows FROM, with an arrow pointing + // where it blows, in the chart's frame). Kept in sync with the Layers control + // via the shared registry. + const pill = this.ctx.hud.mount("wind-control"); + pill.innerHTML = `
🌬Wind
`; + this._ctl = pill.querySelector("#c"); + this._ctlVal = pill.querySelector("#v"); + this._ctlArrow = pill.querySelector("#ar"); + this._ctl.addEventListener("click", () => this._layer.toggle()); + this.ctx.vessel.subscribe(() => { + this._updateReadout(); + this._updateDetails(); + this._maybeSetCenter(); + }); + } + + // _maybeSetCenter keeps the plugin's HRRR window centred where the vessel actually + // sails — or, without a GPS fix, where the user is LOOKING (map centre, once + // zoomed in enough to mean a place rather than the whole coast). When that spot + // has no high-res coverage, write the new centre into the plugin config — the + // plugin hot-reloads it and refetches HRRR around it. Rate-limited; the window is + // ~5°×6°, so in practice this fires once per cruising ground / area of interest. + _maybeSetCenter() { + if (this._centerWriting) return; + if (!this._hasFix() && this.ctx.map.getZoom() < 8) return; // viewport too vague to anchor a window + const pos = this._readoutPos(); + if (pos.lat < 21 || pos.lat > 49 || pos.lng < -128 || pos.lng > -62) return; // outside HRRR CONUS + const h = this._hi && this._hi.header; + if (h) { + const cLat = h.la1 - ((h.ny - 1) * h.dy) / 2; + const cLon = h.lo1 + ((h.nx - 1) * h.dx) / 2; + if (Math.abs(pos.lat - cLat) < 1.5 && Math.abs(pos.lng - cLon) < 2) return; // window still fits + } + if (this._centerSetAt && Date.now() - this._centerSetAt < 600000) return; + this._centerSetAt = Date.now(); + this._centerWriting = true; + this._writeCenter(pos).finally(() => { + this._centerWriting = false; + }); + } + + async _writeCenter(pos) { + try { + const base = this.ctx.assets || "/"; + const id = this.ctx.plugin.id; + const list = await (await fetch(`${base}api/plugins`)).json(); + const me = (list.plugins || []).find((p) => p.record && p.record.id === id); + const config = { + ...((me && me.record.config) || {}), + hiLat: Math.round(pos.lat * 20) / 20, + hiLon: Math.round(pos.lng * 20) / 20, + }; + await fetch(`${base}api/plugins/${encodeURIComponent(id)}/config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + this.ctx.plugin.log("log", `HRRR window → ${config.hiLat},${config.hiLon}`); + this._pollHi(20); // the plugin refetches HRRR (~a minute); pick it up when it lands + } catch (e) { + this.ctx.plugin.log("warn", "HRRR window update failed", e); + } + } + + // _refresh asks the plugin to re-pull the newest forecast cycles (GFS + HRRR) by + // bumping a "refresh" nonce in its config — the plugin hot-reloads config, so no + // restart — then polls for the re-published series. + async _refresh() { + if (this._refreshing) return; + this._refreshing = true; + if (this._rfBtn) this._rfBtn.classList.add("spin"); + try { + const base = this.ctx.assets || "/"; + const id = this.ctx.plugin.id; + const list = await (await fetch(`${base}api/plugins`)).json(); + const me = (list.plugins || []).find((p) => p.record && p.record.id === id); + const config = { ...((me && me.record.config) || {}), refresh: String(Date.now()) }; + await fetch(`${base}api/plugins/${encodeURIComponent(id)}/config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + this._pollRefresh(20); + } catch (e) { + this.ctx.plugin.log("warn", "refresh failed", e); + this._refreshDone(); + } + } + + _refreshDone() { + this._refreshing = false; + if (this._rfBtn) this._rfBtn.classList.remove("spin"); + } + + // _pollRefresh waits for the plugin to publish a newer base doc (the last fetch of + // its chain), then reloads all layers and re-centres the scrubber on "now". If no + // newer cycle exists upstream the docs come back unchanged and polling just ends. + _pollRefresh(remaining) { + clearTimeout(this._rfTimer); + if (remaining <= 0) { + this._refreshDone(); + return; + } + this._rfTimer = setTimeout(async () => { + const doc = await this._fetchDoc("wind.bin"); + if (doc && (!this._doc || doc.refMs !== this._doc.refMs)) { + this._doc = doc; + this._hi = await this._fetchDoc("wind-hi.bin"); + this._mid = await this._fetchDoc("wind-mid.bin"); + this._buildSlider(); // re-anchor on the new cycle's "now" + this._refreshDone(); + return; + } + this._pollRefresh(remaining - 1); + }, 15000); + } + + // _autoRefresh (10-minutely) re-pulls when a materially newer cycle must exist: + // GFS cycles 6-hourly (+~4 h publish lag), HRRR hourly. Rate-limited so a dry + // upstream doesn't cause a fetch storm. + _autoRefresh() { + const now = Date.now(); + const age = (d) => (d && Number.isFinite(d.refMs) ? now - d.refMs : 0); + const stale = age(this._doc) > 10.5 * 3600000 || (this._hi && age(this._hi) > 3 * 3600000); + if (!stale) return; + if (this._lastAuto && now - this._lastAuto < 1800000) return; + this._lastAuto = now; + this._refresh(); + } + + // _pollHi watches for a fresh wind-hi.bin after a window/config change (and picks + // up the matching look-around layer alongside it). + _pollHi(remaining) { + clearTimeout(this._hiTimer); + if (remaining <= 0) return; + this._hiTimer = setTimeout(async () => { + const doc = await this._fetchDoc("wind-hi.bin"); + const fresh = doc && (!this._hi || + doc.refMs !== this._hi.refMs || + doc.header.lo1 !== this._hi.header.lo1 || doc.header.la1 !== this._hi.header.la1); + if (fresh) { + this._hi = doc; + this._mid = (await this._fetchDoc("wind-mid.bin")) || this._mid; + this._setStep(this._frac || 0); // rebuild grids at the current time + return; + } + this._pollHi(remaining - 1); + }, 30000); + } + + _buildSlider() { + if (!this._slider || !this._doc) return; + const steps = this._doc.steps; + this._slider.max = String((steps.length - 1) * 10); // ×10 for smooth interpolation + this._buildStrip(); + // Default to the CURRENT time (the forecast valid now), interpolated between steps. + const nowH = Number.isFinite(this._doc.refMs) ? (Date.now() - this._doc.refMs) / 3600000 : 0; + const frac = this._fracForHour(nowH); + this._slider.value = String(Math.round(frac * 10)); + this._setStep(frac); + this._syncSlider(); + } + + // _buildStrip creates one cell per forecast step (time, wind arrow, speed, cloud); + // the live values are filled by _updateDetails. Clicking a cell jumps the scrubber. + _buildStrip() { + if (!this._strip) return; + this._strip.innerHTML = ""; + this._cells = this._doc.steps.map((s, i) => { + const cell = document.createElement("button"); + cell.className = "cell"; + cell.innerHTML = `
${this._stepLabel(s.hour)}
+
·
`; + cell.addEventListener("click", () => { + this._slider.value = String(i * 10); + this._setStep(i); + }); + this._strip.appendChild(cell); + return cell; + }); + } + + // _stepLabel is a step's short strip caption: LOCAL weekday + hour ("Thu 14"), or + // the lead time when the cycle time is unknown. + _stepLabel(hour) { + const d = this._doc; + if (!Number.isFinite(d.refMs)) return `+${hour}h`; + const t = new Date(d.refMs + hour * 3600000); + return `${WD[t.getDay()]} ${String(t.getHours()).padStart(2, "0")}`; + } + + // _fracForHour maps a forecast lead (hours) to a fractional step index. + _fracForHour(h) { + const s = this._doc.steps; + if (h <= s[0].hour) return 0; + for (let i = 0; i < s.length - 1; i++) { + if (h <= s[i + 1].hour) return i + (h - s[i].hour) / (s[i + 1].hour - s[i].hour); + } + return s.length - 1; + } + + _syncSlider() { + if (this._sliderWrap) { + this._sliderWrap.style.display = this._on && this._doc && this._doc.steps.length > 0 ? "flex" : "none"; + } + } + + _updateReadout() { + if (!this._ctl) return; + this._ctl.classList.toggle("off", !this._on); + const w = this._on ? this._sample(this._readoutPos().lng, this._readoutPos().lat) : null; + if (!w) { + this._ctlVal.textContent = "Wind"; + this._ctlArrow.hidden = true; + return; + } + const spdKn = Math.hypot(w[0], w[1]) * 1.94384; // m/s → knots + const from = (Math.atan2(-w[0], -w[1]) * 180 / Math.PI + 360) % 360; // meteorological "from" + this._ctlArrow.hidden = false; + // ↓ (south) rotated to blow-toward, in the CHART's frame: when the map runs + // course-/head-up its bearing rotates the world, so subtract it or the arrow + // (like the streamlines before the same fix) points wrong on a rotated chart. + this._ctlArrow.style.transform = `rotate(${from - this.ctx.map.getBearing()}deg)`; + this._ctlVal.textContent = `${this.ctx.units.format("wind", spdKn)} ${Math.round(from)}° ${compass(from)}`; + } + + // _updateDetails fills the card's tiles (conditions at the vessel for the scrubbed + // time) and the per-step strip. Arrows here are data readouts, drawn north-up like + // any forecast table — only on-chart glyphs follow the map's rotation. + _updateDetails() { + const t = this._tiles, d = this._doc; + if (!t || !d) return; + const pos = this._readoutPos(); + if (this._atLabel) { + const src = this._hiGrid && this._sampleGrid(this._hiGrid, pos.lng, pos.lat) ? "HRRR 3 km" + : this._midGrid && this._sampleGrid(this._midGrid, pos.lng, pos.lat) ? "HRRR 15 km" + : "GFS 0.25°"; + this._atLabel.textContent = `${this._hasFix() ? "vessel" : "map centre"} (${src})`; + } + + const w = this._sample(pos.lng, pos.lat); + if (w) { + const spdKn = Math.hypot(w[0], w[1]) * 1.94384; + const from = (Math.atan2(-w[0], -w[1]) * 180 / Math.PI + 360) % 360; + t.windArrow.style.transform = `rotate(${from}deg)`; + t.wind.textContent = this.ctx.units.format("wind", spdKn); + t.windDir.textContent = `${Math.round(from)}° ${compass(from)}`; + } else { + t.windArrow.style.transform = ""; + t.wind.textContent = "—"; + t.windDir.textContent = "—"; + } + const frac = this._frac || 0; + const gust = this._sampleAt("gust", frac, pos); + t.gust.textContent = Number.isFinite(gust) ? this.ctx.units.format("wind", gust * 1.94384) : "—"; + const temp = this._sampleAt("temp", frac, pos); + t.temp.textContent = Number.isFinite(temp) ? this.ctx.units.format("temp", temp) : "—"; + const cloud = this._sampleAt("cloud", frac, pos); + t.cloud.textContent = Number.isFinite(cloud) ? `${cloudIcon(cloud)} ${Math.round(cloud)}%` : "—"; + t.cloudWord.textContent = Number.isFinite(cloud) ? cloudWord(cloud) : " "; + + if (this._cells) { + const active = Math.round(frac); + d.steps.forEach((s, i) => { + const cell = this._cells[i]; + if (!cell) return; + cell.classList.toggle("on", i === active); + const validMs = Number.isFinite(d.refMs) ? d.refMs + s.hour * 3600000 : NaN; + // Per-field: the HRRR layers first (matched by valid time), then this step. + const val = (name) => { + if (Number.isFinite(validMs)) { + for (const doc of [this._hi, this._mid]) { + const hv = this._docSample(doc, name, validMs, pos); + if (Number.isFinite(hv)) return hv; + } + } + return this._docArr(d, s[name], pos.lng, pos.lat); + }; + const u = val("u"), v = val("v"); + const ar = cell.querySelector(".ca"), sp = cell.querySelector(".cs"), cl = cell.querySelector(".cc"); + if (Number.isFinite(u) && Number.isFinite(v)) { + const from = (Math.atan2(-u, -v) * 180 / Math.PI + 360) % 360; + const spdKn = Math.hypot(u, v) * 1.94384; + ar.textContent = "↓"; + ar.style.transform = `rotate(${from}deg)`; + sp.textContent = this.ctx.units.format("wind", spdKn).replace(/[^\d.\-]+$/, "").trim(); + } else { + ar.textContent = "·"; + sp.textContent = "—"; + } + const cc = val("cloud"); + cl.textContent = Number.isFinite(cc) ? cloudIcon(cc) : ""; + const g = val("gust"); + const tc = val("temp"); + cell.title = [ + Number.isFinite(u) && Number.isFinite(v) + ? `Wind ${this.ctx.units.format("wind", Math.hypot(u, v) * 1.94384)} from ${Math.round((Math.atan2(-u, -v) * 180 / Math.PI + 360) % 360)}°` + : null, + Number.isFinite(g) ? `Gust ${this.ctx.units.format("wind", g * 1.94384)}` : null, + Number.isFinite(tc) ? `Temp ${this.ctx.units.format("temp", tc)}` : null, + Number.isFinite(cc) ? `Cloud ${Math.round(cc)}% (${cloudWord(cc)})` : null, + ].filter(Boolean).join("\n"); + }); + } + this._updateProbe(); // the probe reads the same scrubbed time + } + + // _docArr bilinearly samples one raw step plane of a doc at (lng,lat); NaN when + // the plane is absent or the point is off-grid. + _docArr(doc, arr, lng, lat) { + if (!arr || !doc) return NaN; + const h = doc.header; + const fx = (lng - h.lo1) / h.dx; + const fy = (h.la1 - lat) / h.dy; + if (fx < 0 || fx > h.nx - 1 || fy < 0 || fy > h.ny - 1) return NaN; + const x0 = Math.floor(fx), y0 = Math.floor(fy); + const x1 = Math.min(x0 + 1, h.nx - 1), y1 = Math.min(y0 + 1, h.ny - 1); + const tx = fx - x0, ty = fy - y0; + return arr[y0 * h.nx + x0] * (1 - tx) * (1 - ty) + arr[y0 * h.nx + x1] * tx * (1 - ty) + + arr[y1 * h.nx + x0] * (1 - tx) * ty + arr[y1 * h.nx + x1] * tx * ty; + } + + // _docSample samples a doc's named plane at an absolute valid time, blending the + // two bracketing forecast steps; NaN when the doc lacks the field, the time is + // outside its horizon, or the point is outside its coverage. + _docSample(doc, name, validMs, pos) { + if (!doc || !Number.isFinite(doc.refMs)) return NaN; + const lead = (validMs - doc.refMs) / 3600000; + const s = doc.steps; + if (!s.length || lead < s[0].hour - 0.75 || lead > s[s.length - 1].hour + 0.75) return NaN; + let i0 = 0; + while (i0 < s.length - 2 && lead > s[i0 + 1].hour) i0++; + const i1 = Math.min(i0 + 1, s.length - 1); + const span = s[i1].hour - s[i0].hour; + const t = span > 0 ? Math.max(0, Math.min(1, (lead - s[i0].hour) / span)) : 0; + const a = this._docArr(doc, s[i0][name], pos.lng, pos.lat); + const b = this._docArr(doc, s[i1][name], pos.lng, pos.lat); + if (!Number.isFinite(a)) return b; + if (!Number.isFinite(b)) return a; + return a * (1 - t) + b * t; + } + + // _sampleAt samples a named scalar plane at a fractional base-step position — + // HRRR high-res first (by valid time), then the GFS base steps. + _sampleAt(name, frac, pos) { + const base = this._doc; + const s = base && base.steps; + if (!s || !s.length) return NaN; + const i0 = Math.max(0, Math.min(s.length - 1, Math.floor(frac))); + const i1 = Math.min(i0 + 1, s.length - 1); + const t = frac - i0; + if (Number.isFinite(base.refMs)) { + const hour = s[i0].hour * (1 - t) + s[i1].hour * t; + const validMs = base.refMs + hour * 3600000; + for (const doc of [this._hi, this._mid]) { + const hv = this._docSample(doc, name, validMs, pos); + if (Number.isFinite(hv)) return hv; + } + } + const a = this._docArr(base, s[i0][name], pos.lng, pos.lat); + const b = this._docArr(base, s[i1][name], pos.lng, pos.lat); + if (!Number.isFinite(a)) return b; + if (!Number.isFinite(b)) return a; + return a * (1 - t) + b * t; + } + + _hasFix() { + const v = this.ctx.vessel.get(); + const p = v && v.navigation && v.navigation.position; + return !!(p && typeof p.lat === "number"); + } + + // _setProbe drops (or moves) the tap-probe marker and fills its readout. + _setProbe(pos) { + this._probePos = pos; + if (!this._probeMarker) { + this._probeMarker = this.ctx.markers.add("wind-probe", { rotationAlignment: "viewport", anchor: "bottom" }); + this._probeMarker.element.style.cursor = "pointer"; + this._probeMarker.onClick((e) => { + e.stopPropagation(); // don't let the map click re-drop the probe + this._clearProbe(); + }); + } + this._probeMarker.setLngLat([pos.lng, pos.lat]); + this._updateProbe(); + } + + _clearProbe() { + this._probePos = null; + if (this._probeMarker) this._probeMarker.hide(); + } + + // _updateProbe re-samples the probe point for the currently scrubbed time; called + // on tap, step change, map rotation, and data reloads so the chip stays live. + _updateProbe() { + if (!this._probePos || !this._probeMarker || !this._on) return; + const pos = this._probePos; + const w = this._sample(pos.lng, pos.lat); + const chip = (main, sub) => ` +
+
${main}
+ ${sub} +
+
`; + if (!w) { + this._probeMarker.setHTML(chip(`no wind data here`, "")); + this._probeMarker.show(); + return; + } + const spdKn = Math.hypot(w[0], w[1]) * 1.94384; + const from = (Math.atan2(-w[0], -w[1]) * 180 / Math.PI + 360) % 360; + const gust = this._sampleAt("gust", this._frac || 0, pos); + const src = this._hiGrid && this._sampleGrid(this._hiGrid, pos.lng, pos.lat) ? "HRRR 3 km" + : this._midGrid && this._sampleGrid(this._midGrid, pos.lng, pos.lat) ? "HRRR 15 km" + : "GFS 0.25°"; + const sub = [Number.isFinite(gust) ? `G ${this.ctx.units.format("wind", gust * 1.94384)}` : null, src] + .filter(Boolean).join(" · "); + this._probeMarker.setHTML(chip( + ` + ${this.ctx.units.format("wind", spdKn)} · ${Math.round(from)}° ${compass(from)}
+ ${sub}
`, + "")); + this._probeMarker.show(); + } + + _readoutPos() { + const v = this.ctx.vessel.get(); + const p = v && v.navigation && v.navigation.position; + if (p && typeof p.lat === "number") return { lng: p.lon, lat: p.lat }; + const c = this.ctx.map.getCenter(); // no fix → wind at the map centre + return { lng: c.lng, lat: c.lat }; + } + + _setOn(on) { + this._on = on; + if (this._canvas) this._canvas.style.display = on ? "" : "none"; + this._syncSlider(); + this._updateReadout(); + if (this._probeMarker) { + if (on && this._probePos) this._updateProbe(); + else this._probeMarker.hide(); + } + if (on) this._start(); + else this._stop(); + } + + _start() { + if (this._raf) return; // already animating + const step = () => { + this._raf = requestAnimationFrame(step); + this._frame(); + }; + this._raf = requestAnimationFrame(step); + } + + _stop() { + if (this._raf) cancelAnimationFrame(this._raf); + this._raf = 0; + if (this._c2d) this._c2d.clearRect(0, 0, this._cw, this._ch); + } + + _frame() { + const c = this._c2d; + if (!c || !this._grid || this._zooming) return; + // Full clear every frame — the streamlines are redrawn from each particle's + // GEOGRAPHIC path, so they stay locked to the map (no smear/artifacts when the + // follow camera pans) and nothing stale is ever left behind. + c.clearRect(0, 0, this._cw, this._ch); + c.lineCap = "round"; + c.lineJoin = "round"; + c.lineWidth = LINE_WIDTH; + c.shadowColor = "rgba(0,0,0,0.5)"; // soft halo for contrast on light charts + c.shadowBlur = 1.5; + + const map = this.ctx.map; + // Screen axes only align with east/north when the chart is north-up. In + // course-/head-up follow (or free rotation) the map bearing rotates the world + // under the canvas, so rotate the wind vector into the screen frame — without + // this every streamline is skewed by the map bearing (the classic "direction + // doesn't match the forecast" bug on a rotated chart). + const brg = (map.getBearing() * Math.PI) / 180; + const cb = Math.cos(brg), sb = Math.sin(brg); + for (const p of this._particles) { + const wind = this._sample(p.lng, p.lat); + if (!wind || p.age > MAX_AGE) { + Object.assign(p, this._spawn()); + continue; + } + // Advance the head in screen space (zoom-independent speed; v northward → up + // at bearing 0, rotated with the chart otherwise). The advance is floored at + // MIN_ADV px so light air still draws a visible streak, with the speed term on + // top; true calm (<1 kt) barely creeps. + const spd = Math.hypot(wind[0], wind[1]); + const adv = spd * STEP + (spd > 0.5 ? MIN_ADV : 0); + const k = adv / (spd || 1); // scales the wind vector to `adv` px + const head = map.project([p.lng, p.lat]); + const b = map.unproject([ + head.x + (wind[0] * cb - wind[1] * sb) * k, + head.y - (wind[1] * cb + wind[0] * sb) * k, + ]); + // Trail length follows the local wind speed on a sqrt curve (a while, not an + // if: a particle drifting into calmer air sheds its excess tail over frames). + const maxTrail = Math.round(TRAIL_MIN + (TRAIL_MAX - TRAIL_MIN) * Math.sqrt(Math.min(spd / TRAIL_REF, 1))); + p.trail.push([p.lng, p.lat]); + while (p.trail.length > maxTrail) p.trail.shift(); + p.lng = b.lng; + p.lat = b.lat; + p.age++; + + // Draw the trail as a fading streak: faint at BOTH ends (a soft comet), scaled + // by a birth/death life fade so streamlines appear and vanish gently. + const n = p.trail.length; + if (n < 2) continue; + const life = Math.sin((Math.PI * p.age) / MAX_AGE); + c.strokeStyle = rampColor(spd); + let prev = map.project(p.trail[0]); + for (let i = 1; i < n; i++) { + const pt = map.project(p.trail[i]); + c.globalAlpha = Math.sin((Math.PI * i) / (n - 1)) * life; // 0 at both ends of the streak + c.beginPath(); + c.moveTo(prev.x, prev.y); + c.lineTo(pt.x, pt.y); + c.stroke(); + prev = pt; + } + } + c.globalAlpha = 1; + c.shadowBlur = 0; + } + + _resize() { + const map = this.ctx.map; + const el = map.getContainer(); + const dpr = window.devicePixelRatio || 1; + this._cw = el.clientWidth; + this._ch = el.clientHeight; + this._canvas.width = this._cw * dpr; + this._canvas.height = this._ch * dpr; + this._canvas.style.width = this._cw + "px"; + this._canvas.style.height = this._ch + "px"; + this._c2d.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + destroy() { + if (this._raf) cancelAnimationFrame(this._raf); + clearInterval(this._autoTimer); + clearTimeout(this._hiTimer); + clearTimeout(this._rfTimer); + const map = this.ctx.map; + if (this._onResize) map.off("resize", this._onResize); + if (this._onZoom) map.off("zoomstart", this._onZoom); + if (this._onZoomEnd) map.off("zoomend", this._onZoomEnd); + if (this._onRotate) map.off("rotate", this._onRotate); + if (this._onClick) map.off("click", this._onClick); // legacy-shell fallback only + if (this._probeMarker) this._probeMarker.remove(); + if (this._canvas) this._canvas.remove(); + } +} + +// rampColor maps a wind speed (m/s) to a colour, linearly interpolated between the two +// bracketing RAMP stops for smooth transitions. Interp results are memoised per +// integer m/s so it stays cheap across thousands of particles per frame. +const rampCache = new Map(); +function rampColor(spd) { + const key = Math.round(spd); + let c = rampCache.get(key); + if (c) return c; + let lo = RAMP[0], hi = RAMP[RAMP.length - 1]; + for (let i = 0; i < RAMP.length - 1; i++) { + if (spd >= RAMP[i][0] && spd <= RAMP[i + 1][0]) { + lo = RAMP[i]; + hi = RAMP[i + 1]; + break; + } + } + const t = hi[0] === lo[0] ? 0 : Math.max(0, Math.min(1, (spd - lo[0]) / (hi[0] - lo[0]))); + c = lerpHex(lo[1], hi[1], t); + rampCache.set(key, c); + return c; +} + +// parseWindBin decodes the plugin's binary wind blob (see encodeWindBin, Go side): +// a small aligned header then, per forecast step, an hour and zero-copy Float32 +// planes. v2 carries u/v; v3 always ships five planes (all-NaN = absent); v4 adds a +// per-step mask (bit 0 u, 1 v, 2 gust, 3 temp, 4 cloud) and ships only present planes. +function parseWindBin(buf) { + const dv = new DataView(buf); + if (dv.getUint8(0) !== 0x57 || dv.getUint8(1) !== 0x47 || dv.getUint8(2) !== 0x52 || dv.getUint8(3) !== 0x44) { + return null; // not "WGRD" + } + const ver = dv.getUint32(4, true); + if (ver < 2 || ver > 4) return null; + const nx = dv.getUint32(8, true), ny = dv.getUint32(12, true), nSteps = dv.getUint32(16, true); + const lo1 = dv.getFloat32(20, true), la1 = dv.getFloat32(24, true); + const dx = dv.getFloat32(28, true), dy = dv.getFloat32(32, true); + const refUnix = dv.getFloat64(36, true); // cycle reference time (s since epoch) + const refMs = refUnix > 0 ? refUnix * 1000 : NaN; + const np = nx * ny; + let o = 44; + const plane = () => { + const a = new Float32Array(buf, o, np); + o += np * 4; + return Number.isNaN(a[0]) && Number.isNaN(a[np - 1]) ? null : a; // all-NaN = absent + }; + const steps = []; + for (let s = 0; s < nSteps; s++) { + const hour = dv.getInt32(o, true); + o += 4; + let mask = ver >= 4 ? dv.getUint32(o, true) : ver >= 3 ? 0b11111 : 0b11; + if (ver >= 4) o += 4; + const rd = (bit) => (mask & bit ? plane() : null); + const u = rd(1), v = rd(2), gust = rd(4), temp = rd(8), cloud = rd(16); + steps.push({ hour, u, v, gust, temp, cloud }); + } + const lo2 = lo1 + (nx - 1) * dx, la2 = la1 - (ny - 1) * dy; + return { header: { nx, ny, lo1, la1, lo2, la2, dx, dy }, refMs, steps }; +} + +// cloudIcon / cloudWord bucket a total-cloud-cover % into the marine-familiar +// METAR-ish scale (clear / few / scattered / broken / overcast). +function cloudIcon(pct) { + return pct < 10 ? "☀️" : pct < 25 ? "🌤" : pct < 50 ? "⛅" : pct < 85 ? "🌥" : "☁️"; +} +function cloudWord(pct) { + return pct < 10 ? "clear" : pct < 25 ? "few" : pct < 50 ? "scattered" : pct < 85 ? "broken" : "overcast"; +} + +// compass maps a bearing (deg) to an 8-point label. +function compass(deg) { + return ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.round(deg / 45) % 8]; +} + +const WD = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MO = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +// Times read in the ship's LOCAL clock first (what the crew plans by); the UTC +// valid time stays available as the compact secondary reference in the fold-out. + +// fmtShortLocal → "Thu 14:00" (fits the slim scrubber row). +function fmtShortLocal(d) { + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${WD[d.getDay()]} ${hh}:${mm}`; +} + +// fmtLocalFull → "Thu 16 Jul · 14:00" (the fold-out's primary valid time). +function fmtLocalFull(d) { + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${WD[d.getDay()]} ${d.getDate()} ${MO[d.getMonth()]} · ${hh}:${mm}`; +} + +// fmtZ → "18:00Z" (the marine-standard UTC reference). +function fmtZ(d) { + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + return `${hh}:${mm}Z`; +} + +// lerpHex blends two #rrggbb colours. +function lerpHex(a, b, t) { + const pa = parseInt(a.slice(1), 16), pb = parseInt(b.slice(1), 16); + const r = Math.round(((pa >> 16) & 255) * (1 - t) + ((pb >> 16) & 255) * t); + const g = Math.round(((pa >> 8) & 255) * (1 - t) + ((pb >> 8) & 255) * t); + const bl = Math.round((pa & 255) * (1 - t) + (pb & 255) * t); + return `rgb(${r},${g},${bl})`; +} diff --git a/sdk/sdk.go b/sdk/sdk.go new file mode 100644 index 0000000..102fd39 --- /dev/null +++ b/sdk/sdk.go @@ -0,0 +1,411 @@ +// Package sdk is the thin Go client for writing chartplotter plugins (spec §11). A +// plugin implements Plugin and calls Run; the SDK owns the handshake, ping/shutdown, +// data-plane batching (spec §10), and request/response plumbing, so an author writes +// only their logic. It builds unchanged for `GOOS=wasip1 GOARCH=wasm` (Tier A) and a +// normal native build (Tier B). +// +// The model is single-threaded and event-driven — a hard requirement for Tier A: a +// wasip1 module is one cooperatively-scheduled thread, and a blocking stdin read +// halts it, so the SDK must never depend on a background goroutine or timer running +// concurrently with the read loop. Everything happens on the read loop: incoming host +// messages drive callbacks, plugin→host requests resolve asynchronously when their +// reply arrives, and buffered publishes flush after each message is handled (a chunk +// of inbound bytes → one batched publish, which is exactly the batching the spec +// asks for). Producer plugins are always driven by host-delivered I/O (tcp/serial +// data on stdin), so per-message flushing is sufficient. +// +// For the in-tree reference plugin this SDK reuses the host's wire types from +// internal/engine/plugin (DRY); extracting a standalone module for third-party +// authors is Phase-3 polish behind the same JSON contract. +package sdk + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "sync/atomic" + + proto "github.com/beetlebugorg/chartplotter/internal/engine/plugin" +) + +// Re-exported wire types so plugin authors import only the SDK. +type ( + Capability = proto.Capability + Delta = proto.Delta + AISTarget = proto.AISTargetDTO + HTTPResponse = proto.HTTPResponse +) + +// Plugin is the interface an author implements. Start runs once after the handshake, +// on the read-loop goroutine — it must NOT block (register handlers / kick off async +// connects and return). Stop is called on graceful shutdown. +type Plugin interface { + Start(h *Host) + Stop() +} + +// ConfigWatcher is optionally implemented by plugins that react to live settings +// edits (the host hot-applies config without a restart). Called on the read-loop +// goroutine after Host.Config() reflects the new values. +type ConfigWatcher interface { + ConfigChanged() +} + +// TCPHandlers are the callbacks for a host-mediated TCP connection. All fire on the +// read-loop goroutine. +type TCPHandlers struct { + OnConnect func(handle int) + OnData func(handle int, data []byte) + OnError func(handle int, err error) +} + +// Host is the plugin's handle to the broker. +type Host struct { + out *bufio.Writer + nextID int64 + + grants []Capability + config map[string]any + + resolvers map[int64]func(json.RawMessage, *proto.RPCError) + handlers map[int]TCPHandlers + + vBuf []Delta + aBuf []AISTarget + rBuf []string +} + +func newHost() *Host { + return &Host{ + out: bufio.NewWriter(os.Stdout), + resolvers: map[int64]func(json.RawMessage, *proto.RPCError){}, + handlers: map[int]TCPHandlers{}, + } +} + +// Run is the plugin main loop. It returns when the host closes stdin or sends +// plugin.shutdown. Everything below runs on this single goroutine. +func Run(p Plugin) error { + h := newHost() + sc := bufio.NewScanner(os.Stdin) + sc.Buffer(make([]byte, 0, 64<<10), 16<<20) + + for sc.Scan() { + var m proto.Message + if json.Unmarshal(sc.Bytes(), &m) != nil { + continue + } + stop := h.dispatch(p, &m) + h.flush() // batch: one flush per handled host message + if stop { + return nil + } + } + return sc.Err() +} + +// dispatch handles one message; it returns true when the plugin should stop. +func (h *Host) dispatch(p Plugin, m *proto.Message) bool { + switch { + case m.Method == "" && len(m.ID) > 0: // response to a plugin→host request + h.resolve(m) + case m.Method != "" && len(m.ID) > 0: // host→plugin request + switch m.Method { + case proto.MethodHostHello: + h.onHello(m) + p.Start(h) + case proto.MethodPluginPing: + h.replyOK(m.ID) + case proto.MethodPluginShutdown: + p.Stop() + h.replyOK(m.ID) + return true + default: + h.replyErr(m.ID, proto.CodeMethodNotFound, "unknown method: "+m.Method) + } + default: // host→plugin notification + h.onNotify(p, m) + } + return false +} + +// --- lifecycle ------------------------------------------------------------- + +func (h *Host) onHello(m *proto.Message) { + var hello proto.HostHello + _ = json.Unmarshal(m.Params, &hello) + h.grants = hello.Grants + h.config = hello.Config + h.reply(m.ID, proto.HelloResult{APIVersion: proto.APIVersion, Framing: "ndjson"}) +} + +func (h *Host) onNotify(p Plugin, m *proto.Message) { + switch m.Method { + case proto.MethodGrantsChanged: + var g proto.GrantsChanged + if json.Unmarshal(m.Params, &g) == nil { + h.grants = g.Grants + h.config = g.Config + if w, ok := p.(ConfigWatcher); ok { + w.ConfigChanged() + } + } + case proto.MethodTCPData, proto.MethodSerialData: + var d proto.IOData + if json.Unmarshal(m.Params, &d) == nil { + if hnd, ok := h.handlers[d.Handle]; ok && hnd.OnData != nil { + hnd.OnData(d.Handle, d.Data) + } + } + case proto.MethodIOClosed: + var ho proto.IOHandle + if json.Unmarshal(m.Params, &ho) == nil { + if hnd, ok := h.handlers[ho.Handle]; ok { + delete(h.handlers, ho.Handle) + if hnd.OnError != nil { + hnd.OnError(ho.Handle, fmt.Errorf("%s", ho.Reason)) + } + } + } + } +} + +// --- config / grants ------------------------------------------------------- + +// Config returns the plugin's current settings (a copy is unnecessary — single- +// threaded — but callers should treat it as read-only). +func (h *Host) Config() map[string]any { return h.config } + +// ConfigString returns a string setting, or "" if unset. +func (h *Host) ConfigString(key string) string { + if v, ok := h.config[key].(string); ok { + return v + } + return "" +} + +// HasGrant reports whether the plugin was granted cap. +func (h *Host) HasGrant(cap string) bool { + for _, g := range h.grants { + if g.Cap == cap { + return true + } + } + return false +} + +// --- data plane (batched) -------------------------------------------------- + +// PublishVessel queues vessel deltas; they flush at the end of the current read-loop +// iteration (spec §10). +func (h *Host) PublishVessel(deltas ...Delta) { h.vBuf = append(h.vBuf, deltas...) } + +// PublishAIS queues AIS target updates. +func (h *Host) PublishAIS(targets ...AISTarget) { h.aBuf = append(h.aBuf, targets...) } + +// PublishRaw queues raw sentence lines for the sniffer. +func (h *Host) PublishRaw(lines ...string) { h.rBuf = append(h.rBuf, lines...) } + +// flush sends whatever is buffered as batched notifications. +func (h *Host) flush() { + if len(h.vBuf) > 0 { + h.notify(proto.MethodVesselPublish, proto.VesselPublish{Deltas: h.vBuf}) + h.vBuf = nil + } + if len(h.aBuf) > 0 { + h.notify(proto.MethodAISPublish, proto.AISPublish{Targets: h.aBuf}) + h.aBuf = nil + } + if len(h.rBuf) > 0 { + h.notify(proto.MethodRawPublish, proto.RawPublish{Lines: h.rBuf}) + h.rBuf = nil + } +} + +// Status reports plugin health (spec §4 status.update). +func (h *Host) Status(state, detail string) { + h.notify(proto.MethodStatusUpdate, proto.StatusUpdate{State: state, Detail: detail}) +} + +// Log writes a structured log record to stderr (the host tags it with the plugin id). +func (h *Host) Log(level, msg string) { + b, _ := json.Marshal(map[string]string{"level": level, "msg": msg}) + fmt.Fprintln(os.Stderr, string(b)) +} + +// --- transports (async) ---------------------------------------------------- + +// TCPConnect asks the host to dial host:port (subject to the net.tcp-client +// allowlist). The result is delivered to hnd.OnConnect (or OnError); inbound chunks +// to OnData; peer close/error to OnError. +func (h *Host) TCPConnect(host string, port int, hnd TCPHandlers) { + h.request(proto.MethodTCPConnect, proto.TCPConnect{Host: host, Port: port}, func(res json.RawMessage, rerr *proto.RPCError) { + if rerr != nil { + if hnd.OnError != nil { + hnd.OnError(0, rerr) + } + return + } + var r proto.HandleResult + if json.Unmarshal(res, &r) != nil { + return + } + h.handlers[r.Handle] = hnd + if hnd.OnConnect != nil { + hnd.OnConnect(r.Handle) + } + }) +} + +// TCPSend writes outbound bytes to a handle. +func (h *Host) TCPSend(handle int, data []byte) { + h.notify(proto.MethodTCPSend, proto.IOData{Handle: handle, Data: data, N: len(data)}) +} + +// CloseHandle releases a transport handle. +func (h *Host) CloseHandle(handle int) { + delete(h.handlers, handle) + h.request(proto.MethodIOClose, proto.IOHandle{Handle: handle}, nil) +} + +// --- storage (async) ------------------------------------------------------- + +// StorageGet reads a key; cb receives the raw JSON value + found flag (or an error). +func (h *Host) StorageGet(key string, cb func(value json.RawMessage, found bool, err error)) { + h.request(proto.MethodStorageGet, proto.StorageKey{Key: key}, func(res json.RawMessage, rerr *proto.RPCError) { + if cb == nil { + return + } + if rerr != nil { + cb(nil, false, rerr) + return + } + var v proto.StorageValue + if err := json.Unmarshal(res, &v); err != nil { + cb(nil, false, err) + return + } + cb(v.Value, v.Found, nil) + }) +} + +// StorageSet writes a key; cb (optional) receives any error. +func (h *Host) StorageSet(key string, value json.RawMessage, cb func(error)) { + h.request(proto.MethodStorageSet, proto.StorageSet{Key: key, Value: value}, func(_ json.RawMessage, rerr *proto.RPCError) { + if cb != nil { + if rerr != nil { + cb(rerr) + } else { + cb(nil) + } + } + }) +} + +// --- served artifacts + http ------------------------------------------------ + +// ServeSet publishes data at GET /plugins//serve/, host-served with Range + +// caching. The zero-RPC path for tile archives, weather grids, and other static +// products (spec §4). cb receives the served URL (or an error). +func (h *Host) ServeSet(name string, data []byte, cb func(url string, err error)) { + h.request(proto.MethodServeSet, proto.ServeSet{Name: name, Data: data}, func(res json.RawMessage, rerr *proto.RPCError) { + if cb == nil { + return + } + if rerr != nil { + cb("", rerr) + return + } + var r struct { + URL string `json:"url"` + } + _ = json.Unmarshal(res, &r) + cb(r.URL, nil) + }) +} + +// ServeClear removes a published artifact. +func (h *Host) ServeClear(name string) { + h.request(proto.MethodServeClear, proto.ServeClear{Name: name}, nil) +} + +// Fetch makes a host-mediated outbound GET (subject to the net.http allowlist). cb +// receives the response (or an error). +func (h *Host) Fetch(url string, cb func(*HTTPResponse, error)) { h.FetchOpts(url, nil, cb) } + +// FetchOpts is Fetch with request headers — e.g. a Range header to byte-range a large +// file without downloading all of it. +func (h *Host) FetchOpts(url string, headers map[string]string, cb func(*HTTPResponse, error)) { + h.request(proto.MethodHTTPFetch, proto.HTTPFetch{URL: url, Headers: headers}, func(res json.RawMessage, rerr *proto.RPCError) { + if cb == nil { + return + } + if rerr != nil { + cb(nil, rerr) + return + } + var r HTTPResponse + if err := json.Unmarshal(res, &r); err != nil { + cb(nil, err) + return + } + cb(&r, nil) + }) +} + +// --- transport plumbing ---------------------------------------------------- + +// request sends a plugin→host request and registers a resolver for its reply. resolver +// may be nil (fire-and-forget request). It runs later, on the read loop, when the +// response arrives. +func (h *Host) request(method string, params any, resolver func(json.RawMessage, *proto.RPCError)) { + id := atomic.AddInt64(&h.nextID, 1) + if resolver != nil { + h.resolvers[id] = resolver + } + idb, _ := json.Marshal(id) + pb, _ := json.Marshal(params) + h.send(&proto.Message{JSONRPC: "2.0", ID: idb, Method: method, Params: pb}) +} + +func (h *Host) resolve(m *proto.Message) { + var id int64 + if json.Unmarshal(m.ID, &id) != nil { + return + } + fn := h.resolvers[id] + delete(h.resolvers, id) + if fn != nil { + fn(m.Result, m.Error) + } +} + +func (h *Host) notify(method string, params any) { + pb, _ := json.Marshal(params) + h.send(&proto.Message{JSONRPC: "2.0", Method: method, Params: pb}) +} + +func (h *Host) reply(id json.RawMessage, result any) { + rb, _ := json.Marshal(result) + h.send(&proto.Message{JSONRPC: "2.0", ID: id, Result: rb}) +} + +func (h *Host) replyOK(id json.RawMessage) { h.reply(id, map[string]any{"ok": true}) } + +func (h *Host) replyErr(id json.RawMessage, code int, msg string) { + h.send(&proto.Message{JSONRPC: "2.0", ID: id, Error: &proto.RPCError{Code: code, Message: msg}}) +} + +func (h *Host) send(m *proto.Message) { + b, _ := json.Marshal(m) + h.out.Write(b) + h.out.WriteByte('\n') + h.out.Flush() +} + +// DeltaOf builds a vessel delta from a path and a JSON-serialisable value. +func DeltaOf(path string, value any) Delta { + b, _ := json.Marshal(value) + return Delta{Path: path, Value: b} +} diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 8779424..49ad39d 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -27,10 +27,13 @@ import { coreSettingsContributions, vgGroupOn, vgSetGroupOn } from "./core/core- import { VgRail } from "./plugins/vg-rail.mjs"; // mid-left viewing-group quick-toggle pill rail import { calibrationContribution } from "./plugins/calibration.mjs"; // "Calibration" tab — ruler-measure the 5 mm box → true physical scale import { DevTools } from "./plugins/dev-tools.mjs"; // the slim contributed Advanced-tab dev tools (rebake + feature inspector) -import { ConnectionsController } from "./plugins/connections.mjs"; // NMEA0183 data-source manager (Connections tab) +import { PluginsController } from "./plugins/plugins-manager.mjs"; // install + manage plugins (Plugins tab) import { VesselStateStore } from "./data/vessel-state-store.mjs"; // live NMEA0183 vessel state (own-ship/AIS/HUD feed) -import { OwnShip } from "./plugins/own-ship.mjs"; // own-ship marker + course predictor + follow camera -import { AISOverlay } from "./plugins/ais-overlay.mjs"; // AIS targets (other vessels) from the live feed +import { PluginHost } from "./core/plugin-host.mjs"; // loads builtin/plugin UI controllers with a declarative ctx +import { LayerRegistry } from "./core/layer-registry.mjs"; // map-overlay show/hide registry (Layers tab) +import { LayersController } from "./plugins/layers-panel.mjs"; // Layers settings tab +import OwnShip from "./plugins/own-ship.mjs"; // builtin core.own-ship: marker + course predictor + follow camera +import AISOverlay from "./plugins/ais-overlay.mjs"; // builtin core.ais: AIS targets (other vessels) from the live feed import { InfoCallouts } from "./plugins/info-callouts.mjs"; // precise DOM tap pads on INFORM01 + CHDATD01 callout boxes import "./plugins/target-info.mjs"; // defines (own-ship / AIS tap-info picker) import { PALETTE_DAY_ICON, PALETTE_DUSK_ICON, PALETTE_NIGHT_ICON } from "./lib/openbridge-icons.mjs"; // OpenBridge scheme glyphs @@ -405,6 +408,16 @@ export class ChartPlotter extends HTMLElement { } // Display calibration (ruler-measure the 5 mm check box → true physical scale). this._settingsRegistry.register(calibrationContribution(this)); + // Map-overlay show/hide registry + its Layers settings tab. Core overlays and + // plugins register their layers here (via ctx.overlays); the tab lists them. + this._layerRegistry = new LayerRegistry(); + if (!this._widget) { + this._layersCtl = new LayersController({ + layers: this._layerRegistry, + button: this.shadowRoot.getElementById("layers-btn"), + pop: this.shadowRoot.getElementById("layers-pop"), + }); + } this._settingsDlg = this.shadowRoot.getElementById("settings-dlg"); if (this._settingsDlg) this._settingsDlg.configure({ registry: this._settingsRegistry }); @@ -676,6 +689,7 @@ export class ChartPlotter extends HTMLElement { if (!this._widget) { this._devTools = new DevTools({ registry: this._settingsRegistry, + devMode: () => this._mariner.developerMode !== false, // default on until production flips it map, plotter: this._plotter, api: this._api, @@ -698,14 +712,18 @@ export class ChartPlotter extends HTMLElement { // NMEA0183 live data (server mode only — the feed comes from the server's // connection manager). VesselStateStore streams /api/vessel for the render - // plugins (own-ship/AIS/HUD); ConnectionsController contributes the - // Connections settings tab for managing data sources. + // plugins (own-ship/AIS/HUD). There is no standalone Connections tab: data- + // source plugins DEFINE the connection types, so connections are managed by + // drilling into the plugin's row on the Plugins tab (plugins-panel pushes a + // view for plugins that provide nmea.source). if (!this._widget) { this._vessel = new VesselStateStore({ assets: this._assets, widget: this._widget }); this._vessel.start(); - this._connections = new ConnectionsController({ + // Plugins settings tab: install/enable/disable/grant/remove plugins. + this._pluginsCtl = new PluginsController({ registry: this._settingsRegistry, assets: this._assets, + uiLogs: (id) => (this._pluginHost ? this._pluginHost.uiLogs(id) : []), notify: this._notify, }); // Tap-info picker for own-ship / AIS targets; dismissed on a map grab or tap. @@ -714,11 +732,36 @@ export class ChartPlotter extends HTMLElement { const showInfo = (info) => tinfo.show(info); map.on("dragstart", () => tinfo.hide()); map.on("click", () => tinfo.hide()); - // Own-ship marker + course predictor; follows the vessel (break-out + a - // re-centre chip mounted in the shell chrome) and streams fixes to the camera. - this._ownShip = new OwnShip({ map, plotter: this._plotter, vessel: this._vessel, host: this.shadowRoot, onSelect: showInfo, units: () => this._mariner }); - // AIS targets (other vessels) from the live feed. - this._ais = new AISOverlay({ map, assets: this._assets, widget: this._widget, onSelect: showInfo, units: () => this._mariner }); + // own-ship + AIS now run as builtin plugins (core.own-ship / core.ais) through + // the PluginHost, driven only by the declarative ctx — no direct map/plotter. + // Behaviour is unchanged; this is the reference for how third-party UI plugins + // load. registerZoomAnchor lets a plugin (own-ship) contribute the wheel-zoom + // anchor without exposing WheelZoom; the shell aggregates the registered fns. + this._zoomAnchors = new Set(); + // Map tap arbitration: plugins claim chart taps in registration order — a + // handler returning true consumes the tap (the ECDIS pick report and later + // claims don't fire); anything else passes it down the chain. + this._tapClaims ||= new Set(); + this._pluginHost = new PluginHost({ + map, + plotter: this._plotter, + vessel: this._vessel, + assets: this._assets, + aisStreamURL: this._assets + "api/ais/stream", + aisPollURL: this._assets + "api/ais", + chrome: this.shadowRoot, + showInfo, + getUnits: () => this._mariner, + registerZoomAnchor: (fn) => { this._zoomAnchors.add(fn); return () => this._zoomAnchors.delete(fn); }, + registerTapClaim: (fn) => { this._tapClaims.add(fn); return () => this._tapClaims.delete(fn); }, + settings: this._settingsRegistry, + notify: this._notify, + overlays: this._layerRegistry, + }); + this._pluginHost.register({ id: "core.own-ship", version: "1.0.0", ControllerClass: OwnShip }); + this._pluginHost.register({ id: "core.ais", version: "1.0.0", ControllerClass: AISOverlay }); + // Discover + load installed plugins that ship UI (e.g. the weather overlay). + this._pluginHost.start(); // Precise DOM tap pads on the INFORM01 "additional information" and CHDATD01 // "date-dependent" callout boxes (each floats offset from the feature, so the // fuzzy symbol pick can't own it). Sparse by nature — only info-bearing / @@ -1252,6 +1295,16 @@ export class ChartPlotter extends HTMLElement { // The dev feature inspector (DevTools) owns clicks while it's armed — defer to // it so a pick/coverage tap doesn't fire under an active inspect lock. if (this._devTools && this._devTools.inspecting) return; + // Offer the tap to registered claimants (plugins — e.g. the wind probe while + // its layer is on). A claim consumes the tap; a pass falls through to the + // default ECDIS cursor pick below. + for (const fn of this._tapClaims ||= new Set()) { + try { + if (fn(e) === true) return; + } catch (err) { + console.warn("[taps] claim handler failed", err); + } + } // The coverage/cell-boundary overlay is a passive debug layer — a tap always // runs the default ECDIS cursor pick (S-52 PresLib §10.8), never flies. this._pickReportAt(e.point, e.originalEvent); @@ -1549,7 +1602,12 @@ export class ChartPlotter extends HTMLElement { // wins; null → cursor-anchored). Today only own-ship anchors on the vessel while // following; future camera plugins slot in here without WheelZoom changing. _zoomAnchor() { - return (this._ownShip && this._ownShip.zoomAnchor()) || null; + if (!this._zoomAnchors) return null; + for (const fn of this._zoomAnchors) { + const a = fn(); + if (a) return a; + } + return null; } // List the catalog cells intersecting the current viewport in the coverage diff --git a/web/src/chartplotter.view.mjs b/web/src/chartplotter.view.mjs index e11458d..a34a458 100644 --- a/web/src/chartplotter.view.mjs +++ b/web/src/chartplotter.view.mjs @@ -10,7 +10,7 @@ // Convention reference: chart-library.mjs / chart-library.view.mjs. import { NOAA_ENC_URL } from "./plugins/chart-library.mjs"; // NOAA ENC page (static attribution link) -import { SEARCH_ICON, CHART_ICON, SETTINGS_ICON } from "./lib/openbridge-icons.mjs"; // vendored OpenBridge glyphs +import { SEARCH_ICON, CHART_ICON, SETTINGS_ICON, LAYERS_ICON } from "./lib/openbridge-icons.mjs"; // vendored OpenBridge glyphs export const STYLE = ` :host { display:block; position:relative; width:100%; height:100%; font:13px/1.4 system-ui,sans-serif; @@ -414,7 +414,12 @@ export const STYLE = ` transition:opacity .15s ease, transform .15s ease, visibility 0s linear .15s; } #drawer.open { opacity:1; transform:none; visibility:visible; transition:opacity .15s ease, transform .15s ease; } #drawer.wide { width:min(86vw, 940px); } /* charts: two-pane list + map */ - #drawer.set-wide { width:min(520px, calc(100vw - 24px)); } /* settings: rail + content */ + #drawer.set-wide { width:min(760px, calc(100vw - 24px)); } /* settings: rail + content */ + /* Settings owns its scrolling: has a FIXED-height shell whose + pane is the single scroll container, so the drawer body must never become a + second one around it (scrollbars-inside-scrollbars). Slimmer padding too — + the dialog's rail/pane provide their own gutters. */ + #drawer.set-wide .body { overflow:hidden; padding:10px 16px 14px; } #drawer.wide .miller { height:calc(100dvh - var(--botbar-h) - 208px); max-height:none; } #drawer .body { border-radius:0 0 13px 13px; } /* caret on the TOP edge, pointing up at the button above */ @@ -431,6 +436,17 @@ export const STYLE = ` Advanced-tab dev tools (rebake + feature inspector) carry their own CSS in dev-tools.view.mjs and render into the dialog's shadow. Nothing dev-side remains in the shell sheet. */ + /* Layers popover — layers are FIRST-CLASS (their own button), not a settings + page. A slim anchored surface above the bottom-right cluster; the panel + inside is its single scroll container. */ + #layers-pop { position:absolute; right:calc(12px + env(safe-area-inset-right,0px)); + bottom:calc(var(--botbar-h) + 66px); z-index:9; width:min(320px, calc(100vw - 24px)); + max-height:min(60dvh, 520px); overflow-y:auto; overscroll-behavior:contain; + background:var(--ui-bg); border:1px solid var(--ui-border); border-radius:14px; + box-shadow:0 12px 38px rgba(0,0,0,.30); padding:6px 12px 10px; + transform-origin:bottom right; transform:translateY(6px) scale(.97); opacity:0; visibility:hidden; + transition:opacity .15s ease, transform .15s ease, visibility 0s linear .15s; } + #layers-pop.open { opacity:1; transform:none; visibility:visible; transition:opacity .15s ease, transform .15s ease; } .dhead { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--ui-border); } .dhead strong { flex:1; font-size:14px; } .body { overflow:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; padding:14px 16px; flex:1; } @@ -598,12 +614,14 @@ export const CHROME = ` + +