Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1693524
feat(plugin): Phase 1 backend engine — wazero runtime, broker, SDK, r…
beetlebugorg Jul 15, 2026
6e8c6f8
feat(plugin): server routes + CLI verbs for the plugin engine
beetlebugorg Jul 15, 2026
c70220f
feat(web): frontend plugin host — own-ship + AIS as builtin plugins
beetlebugorg Jul 15, 2026
d785fe4
fix(plugin): dev exits promptly on Ctrl-C
beetlebugorg Jul 15, 2026
2c5fc67
feat(web): plugin management UI (Plugins settings tab)
beetlebugorg Jul 15, 2026
52b8c71
feat(plugin): revoking a grant tears down what it authorized; config …
beetlebugorg Jul 15, 2026
a7c31a2
feat(web): per-plugin config editor + roomier settings dialog
beetlebugorg Jul 15, 2026
fc10051
docs: multi-page plugin documentation
beetlebugorg Jul 15, 2026
6c30aa4
refactor(plugin): name the reference plugin "NMEA 0183"; drop tier ta…
beetlebugorg Jul 15, 2026
d1830ec
feat(plugin): serve.set + net.http capabilities
beetlebugorg Jul 15, 2026
222254c
feat(plugin): GRIB weather plugin (wind streamlines)
beetlebugorg Jul 15, 2026
419efec
feat(web): dynamically load installed-plugin UI + ctx.map escape hatch
beetlebugorg Jul 15, 2026
3749747
feat(web): Layers control — show/hide core + plugin overlays
beetlebugorg Jul 15, 2026
eb1b7f8
feat(plugin): forecast time slider for the wind overlay
beetlebugorg Jul 15, 2026
b87f1e2
feat(plugin): decode complex-packed GRIB2 (real GFS)
beetlebugorg Jul 15, 2026
f1ef202
feat(plugin): fetch live GFS (byte-range subset) + fit the wire
beetlebugorg Jul 15, 2026
0d929a4
fix(web): plugin UI now actually loads + global-longitude wind sampling
beetlebugorg Jul 15, 2026
b1a38f8
feat(plugin): binary wind grid + readout, on-map toggle, contrast, no…
beetlebugorg Jul 15, 2026
14064bc
feat(plugin): GFS as the default source, auto-discovering the latest …
beetlebugorg Jul 15, 2026
931716e
feat(server): CHARTPLOTTER_NO_REBAKE dev flag
beetlebugorg Jul 15, 2026
1461352
fix(web): mount getElementById shim; lighter wind particles
beetlebugorg Jul 15, 2026
3298dfb
fix(plugin): fetch the CURRENT GFS cycle (clock-based, not a bucket l…
beetlebugorg Jul 15, 2026
13b9470
feat(sdk): ConfigWatcher — plugins react to hot config edits without …
beetlebugorg Jul 17, 2026
1df4462
feat(grib): Lambert-conformal grids (template 3.30) + resampling with…
beetlebugorg Jul 17, 2026
372541a
feat(weather): HRRR layers, forecast details, refresh, probe — and ho…
beetlebugorg Jul 17, 2026
d4f110a
chore(nmea0183): real plugin id org.beetlebug.nmea0183 (org.example w…
beetlebugorg Jul 17, 2026
5ae453b
fix(vessel): clear a disabled source's readings — no phantom own-ship
beetlebugorg Jul 17, 2026
2951812
feat(web): settings redesign, plugin-owned connections, tap claims, d…
beetlebugorg Jul 17, 2026
3399b68
feat(plugins): per-plugin log capture + full-pane Logs viewer
beetlebugorg Jul 17, 2026
d410795
feat(web): first-class layers popover; Developer-mode gate; 5-tab set…
beetlebugorg Jul 17, 2026
24ed837
feat(weather): viewport-fallback HRRR centring + lifecycle logging (v…
beetlebugorg Jul 17, 2026
a824824
docs(plugins): ConfigWatcher, ctx.taps, log capture, live nmea.source…
beetlebugorg Jul 17, 2026
ba07bb3
docs: user/developer split; plain-language plugin guides; weather.gri…
beetlebugorg Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,4 @@ web/sprite.png
/testdata/*.zip
/testdata/*.pdf
/.claude/
plugins/*/plugin.wasm
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/chartplotter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
168 changes: 168 additions & 0 deletions cmd/chartplotter/plugin.go
Original file line number Diff line number Diff line change
@@ -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 <dataDir>/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) }
37 changes: 37 additions & 0 deletions docs/docs/nmea0183.md
Original file line number Diff line number Diff line change
@@ -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.
101 changes: 101 additions & 0 deletions docs/docs/plugins/capabilities.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading