From 1693524deb6f9c17dcf4d939505fa585dfd88a0a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 15:37:37 +0000 Subject: [PATCH 01/33] =?UTF-8?q?feat(plugin):=20Phase=201=20backend=20eng?= =?UTF-8?q?ine=20=E2=80=94=20wazero=20runtime,=20broker,=20SDK,=20referenc?= =?UTF-8?q?e=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the host side of the plugin system (specs/plugin-system.md Phase 1): - internal/engine/plugin: NDJSON JSON-RPC 2.0 protocol + session, manifest + content-hash packaging, plugins.json state store, wazero (Tier A) and os/exec (Tier B) runtimes, the Broker with capability enforcement (vessel/ais/raw publish, status, host-mediated tcp.connect, storage, config), and the Manager (lifecycle, 1s→30s backoff, circuit breaker, ping liveness) mirroring nmea.Manager's shape. - nmea store extensions: Store.PublishDeltas (attributed SignalK path→field writer with provenance), AISStore.Upsert/Feeder, per-source provenance maps. - sdk/: event-driven Go plugin SDK (single-threaded, wasip1-safe — no background goroutines; async host calls resolved by the read loop; per-message batching). - plugins/core.tcp-client: the built-in tcp-client NMEA source reimplemented as the reference Tier-A WASM plugin. The built-in stays; parity is the acceptance test (parity_test.go drives the real wasm module through the broker end-to-end and asserts the host store matches the built-in nmea parse path). --- go.mod | 1 + go.sum | 2 + internal/engine/nmea/ais.go | 82 +++ internal/engine/nmea/publish.go | 132 +++++ internal/engine/nmea/state.go | 1 + internal/engine/plugin/broker.go | 265 +++++++++ internal/engine/plugin/capabilities.go | 394 +++++++++++++ internal/engine/plugin/install.go | 191 +++++++ internal/engine/plugin/manager.go | 522 ++++++++++++++++++ internal/engine/plugin/manifest.go | 153 +++++ internal/engine/plugin/parity_test.go | 225 ++++++++ internal/engine/plugin/plugin_test.go | 134 +++++ internal/engine/plugin/protocol.go | 303 ++++++++++ .../engine/plugin/runtime/native/native.go | 79 +++ internal/engine/plugin/runtime/wasm/wasm.go | 140 +++++ internal/engine/plugin/session.go | 98 ++++ internal/engine/plugin/state.go | 139 +++++ plugins/core.tcp-client/main.go | 213 +++++++ sdk/sdk.go | 349 ++++++++++++ 19 files changed, 3423 insertions(+) create mode 100644 internal/engine/nmea/publish.go create mode 100644 internal/engine/plugin/broker.go create mode 100644 internal/engine/plugin/capabilities.go create mode 100644 internal/engine/plugin/install.go create mode 100644 internal/engine/plugin/manager.go create mode 100644 internal/engine/plugin/manifest.go create mode 100644 internal/engine/plugin/parity_test.go create mode 100644 internal/engine/plugin/plugin_test.go create mode 100644 internal/engine/plugin/protocol.go create mode 100644 internal/engine/plugin/runtime/native/native.go create mode 100644 internal/engine/plugin/runtime/wasm/wasm.go create mode 100644 internal/engine/plugin/session.go create mode 100644 internal/engine/plugin/state.go create mode 100644 plugins/core.tcp-client/main.go create mode 100644 sdk/sdk.go 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..0f7b3b3 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,81 @@ 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++ +} + +// 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 +263,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/publish.go b/internal/engine/nmea/publish.go new file mode 100644 index 0000000..fbcd013 --- /dev/null +++ b/internal/engine/nmea/publish.go @@ -0,0 +1,132 @@ +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 { + set := vesselSetters[d.Path] + if set == nil { + if firstErr == nil { + firstErr = fmt.Errorf("unknown vessel path %q", d.Path) + } + continue + } + if err := 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 +} + +// 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 := vesselSetters[path]; return ok } + +// vesselSetters maps a dotted path to a function that unmarshals a value into the +// corresponding VesselState field. This IS the vessel.write schema (spec §6). +var vesselSetters = map[string]func(*VesselState, json.RawMessage) error{ + "navigation.position": 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 + }, + "navigation.cogTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.COGTrue }), + "navigation.sog": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.SOG }), + "navigation.headingTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.HeadingTrue }), + "navigation.headingMagnetic": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.HeadingMagnetic }), + "navigation.magneticVariation": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.MagneticVariation }), + "navigation.rateOfTurn": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.RateOfTurn }), + "navigation.speedThroughWater": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.SpeedThroughWater }), + "navigation.datetime": 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 + }, + "environment.depth.belowTransducer": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowTransducer }), + "environment.depth.belowKeel": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowKeel }), + "environment.depth.belowSurface": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowSurface }), + "environment.water.temperature": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Water.Temperature }), + "environment.wind.angleApparent": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.AngleApparent }), + "environment.wind.speedApparent": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.SpeedApparent }), + "environment.wind.angleTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.AngleTrue }), + "environment.wind.speedTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.SpeedTrue }), + "environment.wind.directionTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.DirectionTrue }), + "route.xte": floatSetter(func(vs *VesselState) **float64 { return &vs.Route.XTE }), + "route.bearingToWaypoint": floatSetter(func(vs *VesselState) **float64 { return &vs.Route.BearingToWaypoint }), + "route.distanceToWaypoint": floatSetter(func(vs *VesselState) **float64 { return &vs.Route.DistanceToWaypoint }), + "route.activeWaypoint": 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 + }, +} + +// floatSetter builds a setter that unmarshals a JSON number into a *float64 field +// addressed by get. +func floatSetter(get func(*VesselState) **float64) func(*VesselState, json.RawMessage) error { + return 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 + } +} 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..d5e57d5 --- /dev/null +++ b/internal/engine/plugin/broker.go @@ -0,0 +1,265 @@ +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) + 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 + + 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). +func (b *brokerSession) setGrants(ctx context.Context, grants []Capability, config map[string]any) { + b.mu.Lock() + b.grants = grants + b.config = config + b.quota = storageQuota(grants) + b.mu.Unlock() + _ = 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..d42e058 --- /dev/null +++ b/internal/engine/plugin/capabilities.go @@ -0,0 +1,394 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/beetlebugorg/chartplotter/internal/engine/nmea" +) + +// 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) + 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 + } + b.host.UpdateStatus(b.id, PluginStatus(s)) +} + +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}) + } +} + +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/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..046e9e6 --- /dev/null +++ b/internal/engine/plugin/manager.go @@ -0,0 +1,522 @@ +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 + Logf func(format string, args ...any) +} + +// 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 +} + +// 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{}, + } + 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() + if r != nil { + info.Running = true + info.Status = r.currentStatus() + } else if rec.Enabled { + info.Status = PluginStatus{State: "error", Detail: "not running"} + } else { + 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). +func (m *Manager) SetGrants(id string, grants []Capability, config map[string]any) error { + rec, ok := m.state.mutate(id, func(r *PluginRecord) { + r.Grants = grants + if config != nil { + r.Config = config + } + }) + 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() + } +} + +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) { + 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) + 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: WASM (Tier A, preferred) unless the plugin has +// no wasm entry or the user forced native (spec §2). +func (r *pluginRunner) startInstance(ctx context.Context) (rtInstance, error) { + logw := &lineLogger{logf: func(level, msg string) { r.mgr.opts.Host.Log(r.id, level, msg) }} + useNative := r.record.ForceNative || r.manifest.Entry.WASM == "" + if !useNative { + wasmPath := filepath.Join(r.dir, r.manifest.Entry.WASM) + b, err := os.ReadFile(wasmPath) + if err != nil { + return nil, fmt.Errorf("read wasm: %w", err) + } + return wasm.Start(ctx, b, wasm.Config{Name: r.id, Stderr: logw}) + } + rel, ok := r.manifest.Entry.Native[platformKey()] + if !ok { + return nil, fmt.Errorf("no native entry for %s", platformKey()) + } + return native.Start(ctx, native.Config{ + Path: filepath.Join(r.dir, rel), + Dir: r.storeDir(), + Env: []string{}, // minimal env (spec §9) + Stderr: logw, + }) +} + +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..63da640 --- /dev/null +++ b/internal/engine/plugin/parity_test.go @@ -0,0 +1,225 @@ +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.tcp-client 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) 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.tcp-client", "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.tcp-client","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 < 60; i++ { + 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.tcp-client", + "name": "TCP Client (NMEA0183)", + "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.tcp-client") + 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..c432fff --- /dev/null +++ b/internal/engine/plugin/protocol.go @@ -0,0 +1,303 @@ +// 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 +) + +// --- 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"` +} + +// --- 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/plugins/core.tcp-client/main.go b/plugins/core.tcp-client/main.go new file mode 100644 index 0000000..8e0edd9 --- /dev/null +++ b/plugins/core.tcp-client/main.go @@ -0,0 +1,213 @@ +// Command core.tcp-client is the reference chartplotter plugin (spec §12, Phase 1 +// milestone): the built-in tcp-client NMEA 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.tcp-client +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/sdk/sdk.go b/sdk/sdk.go new file mode 100644 index 0000000..e3d69cf --- /dev/null +++ b/sdk/sdk.go @@ -0,0 +1,349 @@ +// 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 +) + +// 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() +} + +// 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(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(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 + } + 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) + } + } + }) +} + +// --- 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} +} From 6e8c6f8766fe92bf1fd426d7b9427eaf3adbd5f3 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 15:50:05 +0000 Subject: [PATCH 02/33] feat(plugin): server routes + CLI verbs for the plugin engine - server: initPlugins wires plugin.Manager with a Host backed by the shared vessel/AIS/raw stores; /api/plugins (list/install/enable/disable/grants/ config/remove) + /api/plugins/stream SSE; /plugins//{ui,serve}/* static serving via serveFile (Range, .wasm/.mjs mime); CSP tightened to connect-src 'self' so trusted plugin UI can't phone home. - plugin: ManagerOpts.NoStart (state-only manager for CLI one-shots), DevRun (run an unpacked dir under the broker with restart), factored startInstance shared by runners and dev. - cli: `chartplotter plugin install|list|enable|disable|remove|dev`. - Makefile build-plugins target + core.tcp-client manifest. --- .gitignore | 1 + Makefile | 12 +- cmd/chartplotter/main.go | 1 + cmd/chartplotter/plugin.go | 167 ++++++++++++++++++++ internal/engine/plugin/dev.go | 108 +++++++++++++ internal/engine/plugin/manager.go | 63 ++++++-- internal/engine/server/http.go | 34 +++- internal/engine/server/plugins.go | 237 ++++++++++++++++++++++++++++ plugins/core.tcp-client/plugin.json | 19 +++ 9 files changed, 618 insertions(+), 24 deletions(-) create mode 100644 cmd/chartplotter/plugin.go create mode 100644 internal/engine/plugin/dev.go create mode 100644 internal/engine/server/plugins.go create mode 100644 plugins/core.tcp-client/plugin.json 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..ac46042 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.tcp-client +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..9d82bdc --- /dev/null +++ b/cmd/chartplotter/plugin.go @@ -0,0 +1,167 @@ +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) 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/internal/engine/plugin/dev.go b/internal/engine/plugin/dev.go new file mode 100644 index 0000000..b30aacc --- /dev/null +++ b/internal/engine/plugin/dev.go @@ -0,0 +1,108 @@ +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() + 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/manager.go b/internal/engine/plugin/manager.go index 046e9e6..a60c49a 100644 --- a/internal/engine/plugin/manager.go +++ b/internal/engine/plugin/manager.go @@ -38,8 +38,11 @@ const ( // 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 + 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. @@ -76,9 +79,11 @@ func NewManager(ctx context.Context, opts ManagerOpts) *Manager { state: newStateStore(filepath.Join(opts.DataDir, "plugins.json")), runners: map[string]*pluginRunner{}, } - for _, rec := range m.state.list() { - if rec.Enabled { - m.startRunner(rec) + if !opts.NoStart { + for _, rec := range m.state.list() { + if rec.Enabled { + m.startRunner(rec) + } } } return m @@ -108,12 +113,16 @@ func (m *Manager) List() []PluginInfo { m.mu.Lock() r := m.runners[rec.ID] m.mu.Unlock() - if r != nil { + switch { + case r != nil: info.Running = true info.Status = r.currentStatus() - } else if rec.Enabled { + 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"} - } else { + default: info.Status = PluginStatus{State: "disabled"} } out = append(out, info) @@ -203,6 +212,18 @@ func (m *Manager) Close() { } } +// 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 { @@ -215,6 +236,9 @@ func (m *Manager) loadManifest(id, version string) (*Manifest, error) { // 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) @@ -341,28 +365,33 @@ func (r *pluginRunner) runOnce(parent context.Context) error { return serveErr } -// startInstance selects the runtime: WASM (Tier A, preferred) unless the plugin has -// no wasm entry or the user forced native (spec §2). +// 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.opts.Host.Log(r.id, level, msg) }} - useNative := r.record.ForceNative || r.manifest.Entry.WASM == "" + 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 { - wasmPath := filepath.Join(r.dir, r.manifest.Entry.WASM) - b, err := os.ReadFile(wasmPath) + 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: r.id, Stderr: logw}) + return wasm.Start(ctx, b, wasm.Config{Name: man.ID, Stderr: stderr}) } - rel, ok := r.manifest.Entry.Native[platformKey()] + 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(r.dir, rel), - Dir: r.storeDir(), + Path: filepath.Join(dir, rel), + Dir: storeDir, Env: []string{}, // minimal env (spec §9) - Stderr: logw, + Stderr: stderr, }) } 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/plugins.go b/internal/engine/server/plugins.go new file mode 100644 index 0000000..2dabd0f --- /dev/null +++ b/internal/engine/server/plugins.go @@ -0,0 +1,237 @@ +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) 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 == "enable" && r.Method == http.MethodPost: + s.pluginErr(w, s.pluginMgr.Enable(id)) + case action == "disable" && r.Method == http.MethodPost: + s.pluginErr(w, s.pluginMgr.Disable(id)) + 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.SetGrants(id, nil, cfg)) // config-only update keeps grants + case action == "" && r.Method == http.MethodDelete: + purge := r.URL.Query().Get("purgeData") != "" + s.pluginErr(w, s.pluginMgr.Remove(id, purge)) + 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.tcp-client/plugin.json b/plugins/core.tcp-client/plugin.json new file mode 100644 index 0000000..f79176c --- /dev/null +++ b/plugins/core.tcp-client/plugin.json @@ -0,0 +1,19 @@ +{ + "manifestVersion": 1, + "id": "core.tcp-client", + "name": "TCP Client (NMEA0183)", + "version": "1.0.0", + "description": "Reads NMEA0183 sentences from a TCP server and publishes vessel/AIS state. The reference Tier-A (WASM) plugin; a drop-in for the built-in tcp-client 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}"] } + ], + "provides": [ + { "service": "nmea.source", "apiVersion": 1 } + ] +} From c70220f92a74412f4da9b2db220936afa9d72bc4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 16:03:45 +0000 Subject: [PATCH 03/33] =?UTF-8?q?feat(web):=20frontend=20plugin=20host=20?= =?UTF-8?q?=E2=80=94=20own-ship=20+=20AIS=20as=20builtin=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the frontend plugin host (spec §8, §11, Appendix A.3) and refactors the two built-in overlays to run through it, driven ONLY by a pure declarative ctx (no raw map or plotter object): - core/plugin-host.mjs: PluginHost loads controllers (constructor(ctx)/start()/ destroy()) and assembles ctx — vessel, ais (wrapped server feed), layers, markers, camera (follow/anchor/gesture), hud/panels mounts, settings, notify, callout, units. It is the single place that touches map/plotter internals. - plugins/plugin-layers.mjs: declarative GeoJSON layer host with named z-bands, style-reload self-healing and retained data (replaces own-ship's hand-rolled _ensureLayers). - plugins/own-ship.mjs, plugins/ais-overlay.mjs: converted to ctx controllers (default exports). Behaviour preserved — pose tween, GPS watchdog, follow break-out, wheel-zoom anchor, AIS glyphs/danger. own-ship no longer exposes zoomAnchor(); it registers the anchor via ctx.camera.registerFollowAnchor. - chartplotter.mjs: constructs PluginHost and registers core.own-ship / core.ais instead of newing the overlays; _zoomAnchor aggregates registered anchors. Verified: ESNext syntax + import-graph resolution (esbuild), modules serve with js mime, backend feeds vessel + AIS. Live browser screenshot not run (no browser in this environment). --- web/src/chartplotter.mjs | 38 +++- web/src/core/plugin-host.mjs | 312 ++++++++++++++++++++++++++++ web/src/plugins/ais-overlay.mjs | 103 +++------- web/src/plugins/own-ship.mjs | 329 ++++++++++-------------------- web/src/plugins/plugin-layers.mjs | 102 +++++++++ 5 files changed, 583 insertions(+), 301 deletions(-) create mode 100644 web/src/core/plugin-host.mjs create mode 100644 web/src/plugins/plugin-layers.mjs diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 8779424..3f35dae 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -29,8 +29,9 @@ import { calibrationContribution } from "./plugins/calibration.mjs"; // "Calibra 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 { 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 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 @@ -714,11 +715,27 @@ 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(); + this._pluginHost = new PluginHost({ + map, + plotter: this._plotter, + vessel: this._vessel, + 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); }, + settings: this._settingsRegistry, + notify: this._notify, + }); + 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 }); // 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 / @@ -1549,7 +1566,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/core/plugin-host.mjs b/web/src/core/plugin-host.mjs new file mode 100644 index 0000000..3f7871a --- /dev/null +++ b/web/src/core/plugin-host.mjs @@ -0,0 +1,312 @@ +// plugin-host.mjs — the frontend plugin host (spec §8, §11, Appendix A.3). +// +// It loads plugin UI controllers and gives each one a `ctx`: the same handles the +// built-in modules use, but as a small, declarative surface with NO raw `map` or +// `plotter` object. A controller follows the established convention — +// `constructor(ctx)`, `start()`, `destroy()` — exactly like the built-ins, so an +// author reads own-ship.mjs and writes the same kind of module. +// +// In this build the registry is the in-tree `core.*` controllers (own-ship, AIS), +// statically imported and registered by the shell; dynamic `import()` of +// `/plugins//ui/…` for installed plugins is the extension path that reuses this +// exact ctx. The host is the single place that touches the plotter/map internals — +// via ctx.layers / ctx.markers / ctx.camera — so plugin code stays renderer-agnostic +// and can't paint over safety-critical layers. + +import { PluginLayers } from "../plugins/plugin-layers.mjs"; +import { format } from "../lib/units.mjs"; + +export class PluginHost { + // services are the shell handles the ctx wraps: + // map — the MapLibre instance (wrapped, never exposed raw) + // plotter — (its overlay/camera API, wrapped) + // vessel — VesselStateStore + // aisStreamURL / aisPollURL — the AIS feed endpoints (or null in widget mode) + // chrome — an element in the shell shadow DOM for floating overlay UI + // showInfo — (info) => the target-info picker + // getUnits — () => live mariner prefs + // registerZoomAnchor — (fn) => unregister; the shell aggregates for wheel-zoom + // settings — SettingsRegistry + // notify — NotificationCenter + constructor(services) { + this._svc = services; + this._layers = new PluginLayers({ map: services.map, plotter: services.plotter }); + this._ais = new AISFeed(services.aisStreamURL, services.aisPollURL); + this._loaded = new Map(); // id -> { controller, cleanups } + } + + // register loads a controller for a plugin: builds its ctx, instantiates the + // controller, and calls start(). ControllerClass is the module's default export. + async register({ id, version, ControllerClass }) { + if (this._loaded.has(id)) return; + const cleanups = []; + const ctx = this._buildCtx({ id, version, cleanups }); + const controller = new ControllerClass(ctx); + this._loaded.set(id, { controller, cleanups }); + try { + await controller.start?.(); + } catch (e) { + console.warn(`[plugin ${id}] start failed`, e); + } + } + + unregister(id) { + const rec = this._loaded.get(id); + if (!rec) return; + this._loaded.delete(id); + try { + rec.controller.destroy?.(); + } catch (e) { + console.warn(`[plugin ${id}] destroy failed`, e); + } + for (const fn of rec.cleanups) { + try { + fn(); + } catch { + /* best-effort teardown */ + } + } + this._layers.removeAll(id); + } + + destroy() { + for (const id of [...this._loaded.keys()]) this.unregister(id); + this._ais.destroy(); + this._layers.destroy(); + } + + // _buildCtx assembles the declarative context for one plugin. + _buildCtx({ id, version, cleanups }) { + const svc = this._svc; + const map = svc.map; + + const track = (fn) => { + cleanups.push(fn); + return fn; + }; + + return { + plugin: { + id, + version, + log: (level, ...args) => console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](`[plugin ${id}]`, ...args), + }, + + // Live vessel state (≤4 Hz coalesced) — same store the built-ins read. + vessel: { + get: () => svc.vessel && svc.vessel.state, + subscribe: (fn) => { + if (!svc.vessel) return () => {}; + const off = svc.vessel.onChange(fn); + return track(off); + }, + }, + + // Live AIS targets, wrapped over the server feed (EventSource + poll fallback). + ais: { + subscribe: (fn) => track(this._ais.subscribe(fn)), + }, + + // Declarative GeoJSON layers with z-bands + style-reload self-healing. + layers: { + add: (layerId, spec) => this._layers.add(id, layerId, spec), + }, + + // DOM markers (rotated glyphs) without handing the plugin the raw map. The + // controller owns marker teardown (own-ship removes its one marker; AIS its + // per-target set) — the host does not auto-track them, since a busy AIS feed + // creates and drops many over a session. + markers: { + add: (markerId, opts) => this._makeMarker(map, opts), + }, + + // Camera/follow contract (wraps the plotter + map camera; no raw handle out). + camera: { + follow: (fix) => svc.plotter.updateFollow(fix), + easeTo: (opts) => map.easeTo(opts), + getZoom: () => map.getZoom(), + project: (lnglat) => map.project(lnglat), + containerHeight: () => (map.getContainer() && map.getContainer().clientHeight) || 0, + // User pan/rotate gestures (real ones only — programmatic eases have no + // originalEvent), for follow break-out. + onGesture: (fn) => { + const h = (e) => { + if (!e || e.originalEvent) fn(e); + }; + map.on("dragstart", h); + map.on("rotatestart", h); + return track(() => { + map.off("dragstart", h); + map.off("rotatestart", h); + }); + }, + // Register the point wheel-zoom should keep fixed (own-ship's anchor). The + // shell aggregates these for WheelZoom.getAnchor. + registerFollowAnchor: (fn) => track(svc.registerZoomAnchor ? svc.registerZoomAnchor(fn) : () => {}), + }, + + // Floating overlay-UI mount in the shell chrome (theme CSS vars inherit). + hud: { mount: (slotId) => this._mount(id, slotId, track) }, + panels: { mount: (slotId) => this._mount(id, slotId, track) }, + + // Settings contribution registry, pre-scoped to the plugin id. + settings: svc.settings + ? { + register: (desc) => { + const scoped = { ...desc, id: desc.id ? `${id}.${desc.id}` : id }; + const off = svc.settings.register(scoped); + return track(off); + }, + } + : { register: () => () => {} }, + + // Notification center. + notify: svc.notify + ? { + info: (m) => svc.notify.info(m), + warn: (m) => svc.notify.warn(m), + error: (m) => svc.notify.error(m), + } + : { info() {}, warn() {}, error() {} }, + + // Info callout / pick report. + callout: { + show: (info) => svc.showInfo && svc.showInfo(info), + }, + + // Unit formatting honoring the live mariner prefs (closed over, not exposed). + units: { + format: (kind, value) => format(kind, value, svc.getUnits ? svc.getUnits() : null), + }, + }; + } + + // _makeMarker wraps a MapLibre Marker in a small chainable handle. + _makeMarker(map, opts = {}) { + const el = document.createElement("div"); + const marker = new window.maplibregl.Marker({ + element: el, + rotationAlignment: opts.rotationAlignment || "map", + anchor: opts.anchor || "center", + }); + let added = false; + const handle = { + element: el, + setHTML: (html) => ((el.innerHTML = html), handle), + setStyle: (css) => ((el.style.cssText = css), handle), + setLngLat: (ll) => { + marker.setLngLat(ll); + if (!added) { + marker.addTo(map); + added = true; + } + return handle; + }, + setRotation: (deg) => (marker.setRotation(deg), handle), + onClick: (fn) => (el.addEventListener("click", fn), handle), + show: () => { + if (!added) { + marker.addTo(map); + added = true; + } + return handle; + }, + hide: () => { + if (added) { + marker.remove(); + added = false; + } + return handle; + }, + remove: () => { + if (added) { + marker.remove(); + added = false; + } + }, + }; + return handle; + } + + // _mount returns a fresh element in the shell chrome for a plugin's overlay UI. + _mount(pluginId, slotId, track) { + const el = document.createElement("div"); + el.dataset.plugin = pluginId; + el.dataset.slot = slotId || ""; + if (this._svc.chrome) this._svc.chrome.appendChild(el); + track(() => el.remove()); + return el; + } +} + +// AISFeed subscribes to the server's AIS stream once and fans targets out to every +// ctx.ais subscriber (the same EventSource + poll-fallback logic the AIS overlay used +// to own privately). Lazily started on the first subscription. +class AISFeed { + constructor(streamURL, pollURL) { + this._streamURL = streamURL; + this._pollURL = pollURL; + this._subs = new Set(); + this._es = null; + this._polling = false; + this._last = []; + } + + subscribe(fn) { + this._subs.add(fn); + if (this._last.length) fn(this._last); // prime with the latest snapshot + this._start(); + return () => this._subs.delete(fn); + } + + _start() { + if (!this._streamURL || this._es || this._polling) return; + if (typeof EventSource === "undefined") { + this._poll(); + return; + } + const es = new EventSource(this._streamURL); + es.onmessage = (ev) => { + let d; + try { + d = JSON.parse(ev.data); + } catch { + return; + } + this._emit(d.targets || []); + }; + es.onerror = () => {}; // EventSource auto-reconnects + this._es = es; + } + + async _poll() { + if (!this._pollURL) return; + this._polling = true; + while (this._polling) { + try { + const r = await fetch(this._pollURL, { cache: "no-store" }); + if (r.ok) this._emit((await r.json()).targets || []); + } catch { + /* ignore; retry */ + } + await new Promise((res) => setTimeout(res, 2000)); + } + } + + _emit(targets) { + this._last = targets; + for (const fn of this._subs) { + try { + fn(targets); + } catch (e) { + console.warn("[ais feed] subscriber", e); + } + } + } + + destroy() { + if (this._es) this._es.close(); + this._polling = false; + this._subs.clear(); + } +} diff --git a/web/src/plugins/ais-overlay.mjs b/web/src/plugins/ais-overlay.mjs index a7871da..87517f8 100644 --- a/web/src/plugins/ais-overlay.mjs +++ b/web/src/plugins/ais-overlay.mjs @@ -1,19 +1,15 @@ -// AISOverlay renders other vessels from the live AIS feed (/api/ais/stream). Like -// own-ship it is a pure consumer of server-decoded data — the server turns VDM/VDO -// (or, in future, NMEA2000 PGNs) into AIS targets; this just draws them. -// -// Each target is a heading-rotated OpenBridge AIS glyph: the directional triangle -// when COG/heading is known, the no-heading variant otherwise. Colour + halo come -// from --ais-* CSS vars (set per scheme on the app host), so targets honour -// day/dusk/night. The server prunes stale targets, so a target dropping out of the -// list removes its marker. +// ais-overlay — other vessels from the live AIS feed, a builtin plugin (core.ais). +// Like own-ship it is a pure ctx consumer: targets come from ctx.ais (the coalesced +// server feed), each drawn as a heading-rotated OpenBridge AIS glyph via ctx.markers. +// Colour + halo come from --ais-* CSS vars (set per scheme on the app host), so +// targets honour day/dusk/night. The server prunes stale targets, so a target +// dropping out of the feed removes its marker. import { AIS_TARGET_ICON, AIS_TARGET_NODIR_ICON, AIS_TARGET_DANGER_ICON, AIS_TARGET_DANGER_NODIR_ICON, } from "../lib/openbridge-icons.mjs"; import { fmtLatLon } from "./target-info.mjs"; -import { format } from "../lib/units.mjs"; // Pick the AIS glyph for a target's directionality + collision danger. function glyphFor(hasDir, danger) { @@ -26,44 +22,21 @@ const GLYPH_STYLE = "color:var(--ais-fill,#0a7d55);" + "filter:drop-shadow(0 0 1px var(--ais-halo,#fff)) drop-shadow(0 0 1px var(--ais-halo,#fff));"; -export class AISOverlay { - constructor({ map, assets = "/", widget = false, onSelect, units } = {}) { - this._map = map; - this._assets = assets; - this._widget = widget; - this._units = units; // () => mariner prefs, for SOG/CPA/draught units (live) - this._onSelect = onSelect; // tap → info picker - this._markers = new Map(); // mmsi -> {marker, el, hasDir} - this._es = null; - this._polling = false; - this.start(); +export default class AISOverlay { + constructor(ctx) { + this.ctx = ctx; + this._markers = new Map(); // mmsi -> {marker, hasDir, danger, t} } start() { - if (this._widget || this._es || this._polling) return; // AIS feed needs the server - if (!window.EventSource) { - this._poll(); - return; - } - const es = new EventSource(this._assets + "api/ais/stream"); - es.onmessage = (ev) => { - let d; - try { - d = JSON.parse(ev.data); - } catch { - return; - } - this._apply(d.targets || []); - }; - es.onerror = () => {}; // EventSource auto-reconnects - this._es = es; + this._off = this.ctx.ais.subscribe((targets) => this._apply(targets || [])); } _apply(targets) { const seen = new Set(); for (const t of targets) { - // Skip targets we can't place: no position yet (e.g. static-only msg 24/5 - // before a position report), which would otherwise render at 0,0. + // Skip targets we can't place: no position yet (static-only msg 24/5 before a + // position report), which would otherwise render at 0,0. if (typeof t.lat !== "number" || (!t.lat && !t.lon)) continue; seen.add(t.mmsi); this._upsert(t); @@ -79,36 +52,31 @@ export class AISOverlay { _upsert(t) { let rec = this._markers.get(t.mmsi); if (!rec) { - const el = document.createElement("div"); - el.style.cssText = GLYPH_STYLE; - el.title = t.name || String(t.mmsi); - const marker = new window.maplibregl.Marker({ element: el, rotationAlignment: "map", anchor: "center" }); - marker.setLngLat([t.lon, t.lat]); // must set a location before addTo (maplibre reads it) - rec = { marker, el, hasDir: undefined, t }; - el.addEventListener("click", (e) => { - e.stopPropagation(); // don't let the map's click handler dismiss the picker - this._select(rec, e); - }); + const marker = this.ctx.markers.add(`ais-${t.mmsi}`, { rotationAlignment: "map", anchor: "center" }); + marker.setStyle(GLYPH_STYLE); + marker.element.title = t.name || String(t.mmsi); + marker.setLngLat([t.lon, t.lat]); + rec = { marker, hasDir: undefined, danger: undefined, t }; + marker.onClick((e) => { e.stopPropagation(); this._select(rec, e); }); this._markers.set(t.mmsi, rec); - marker.addTo(this._map); } rec.t = t; // latest data for the picker const dir = num(t.cog) ?? num(t.heading); const hasDir = dir != null; const danger = !!t.danger; if (rec.hasDir !== hasDir || rec.danger !== danger) { - rec.el.innerHTML = glyphFor(hasDir, danger); - rec.el.style.color = danger ? "var(--ais-danger, #e23b2e)" : "var(--ais-fill, #0a7d55)"; + rec.marker.setHTML(glyphFor(hasDir, danger)); + rec.marker.element.style.color = danger ? "var(--ais-danger, #e23b2e)" : "var(--ais-fill, #0a7d55)"; rec.hasDir = hasDir; rec.danger = danger; } - if (t.name) rec.el.title = t.name; + if (t.name) rec.marker.element.title = t.name; rec.marker.setLngLat([t.lon, t.lat]).setRotation(hasDir ? dir : 0); } // Tap → info picker for this target. _select(rec, e) { - if (!this._onSelect || !rec.t) return; + if (!rec.t) return; const t = rec.t; const rows = [["MMSI", String(t.mmsi)]]; if (t.callSign) rows.push(["Call sign", t.callSign]); @@ -116,16 +84,15 @@ export class AISOverlay { else if (t.shipType) rows.push(["Type", String(t.shipType)]); if (t.status) rows.push(["Status", t.status]); if (t.destination) rows.push(["Destination", t.destination]); - const u = (this._units && this._units()) || null; if (t.length && t.beam) rows.push(["Size", `${t.length} × ${t.beam} m`]); - if (num(t.draught) != null) rows.push(["Draught", format("depth", t.draught, u)]); + if (num(t.draught) != null) rows.push(["Draught", this.ctx.units.format("depth", t.draught)]); if (typeof t.lat === "number") rows.push(["Position", fmtLatLon(t.lat, t.lon)]); if (num(t.cog) != null) rows.push(["COG", Math.round(t.cog) + "°T"]); - if (num(t.sog) != null) rows.push(["SOG", format("speed", t.sog, u)]); + if (num(t.sog) != null) rows.push(["SOG", this.ctx.units.format("speed", t.sog)]); if (num(t.heading) != null) rows.push(["Heading", Math.round(t.heading) + "°T"]); - if (num(t.cpaNm) != null) rows.push(["CPA", format("distance", t.cpaNm, u)]); + if (num(t.cpaNm) != null) rows.push(["CPA", this.ctx.units.format("distance", t.cpaNm)]); if (num(t.tcpaMin) != null) rows.push(["TCPA", t.tcpaMin < 0 ? "passed" : t.tcpaMin.toFixed(1) + " min"]); - this._onSelect({ + this.ctx.callout.show({ title: (t.danger ? "⚠ " : "") + (t.name || `MMSI ${t.mmsi}`), subtitle: t.danger ? "COLLISION RISK" : "AIS" + (t.class ? " " + t.class : ""), rows, @@ -134,22 +101,8 @@ export class AISOverlay { }); } - async _poll() { - this._polling = true; - while (this._polling) { - try { - const r = await fetch(this._assets + "api/ais", { cache: "no-store" }); - if (r.ok) this._apply((await r.json()).targets || []); - } catch { - // ignore; retry - } - await new Promise((res) => setTimeout(res, 2000)); - } - } - destroy() { - if (this._es) this._es.close(); - this._polling = false; + if (this._off) this._off(); for (const rec of this._markers.values()) rec.marker.remove(); this._markers.clear(); } diff --git a/web/src/plugins/own-ship.mjs b/web/src/plugins/own-ship.mjs index b2f5fbd..0a43e50 100644 --- a/web/src/plugins/own-ship.mjs +++ b/web/src/plugins/own-ship.mjs @@ -1,34 +1,26 @@ -// OwnShip — renders the vessel's own position from the live NMEA0183 feed, -// follows it (with break-out + a re-centre chip), and drives the camera. It is a -// pure consumer of VesselStateStore and renders nothing the chart owns. +// own-ship — the vessel's own position, a builtin plugin (core.own-ship). It renders +// the OpenBridge own-ship glyph (a DOM marker), a dashed COG/SOG predictor and a +// solid heading line, follows the vessel (break-out + a re-centre chip), and shows a +// GPS-freshness pill. It is a pure consumer of the plugin ctx: no raw map/plotter — +// the glyph is a ctx.markers handle, the vectors are ctx.layers (self-healing across +// style rebuilds), and the camera/follow + wheel-zoom anchor go through ctx.camera. // -// • a heading-rotated SVG Marker (screen-fixed, survives style reloads) — the -// OpenBridge own-ship glyph (haloed). Rotates by true heading (HDT), or COG -// when there's no heading sensor. -// • a geographic course/speed predictor: a dashed line from the vessel along -// COG, length = SOG × predictMin. A GeoJSON line layer, re-added after style -// rebuilds. +// This module reads exactly like a plugin an author would write against ctx; it is +// registered as a builtin by the shell (web/src/chartplotter.mjs) through PluginHost. // -// Follow: by default the camera keeps the vessel centred (the renderer boots in -// north-up, so updateFollow recentres each fix). Panning the map breaks follow -// and shows a "Re-centre" chip; tapping it resumes following. Adding a layer -// before the style is loaded throws, so layer creation is deferred to style.load. +// • a heading-rotated glyph marker (survives style reloads). Rotates by true +// heading (HDT), or COG when there's no heading sensor. +// • a geographic course/speed predictor (dashed) + a solid heading line. +// +// Follow: the camera keeps the vessel centred until the user pans/rotates, which +// breaks follow and shows a "Re-centre" chip; tapping it resumes following. import { OWN_SHIP_MARKER, CENTER_ICON } from "../lib/openbridge-icons.mjs"; import { fmtLatLon } from "./target-info.mjs"; -import { format } from "../lib/units.mjs"; - -const SRC = "ownship-predictor"; // COG/SOG vector (dashed) -const CASING = "ownship-predictor-casing"; -const LINE = "ownship-predictor-line"; -const HSRC = "ownship-heading"; // heading line (solid, the vessel's nose) -const HCASING = "ownship-heading-casing"; -const HLINE = "ownship-heading-line"; // GPS freshness thresholds. A position that hasn't advanced for STALE_MS is shown // greyed (sensor hiccup); past LOST_MS it's "GPS lost" and the vectors drop. The -// boat is never removed on a dropout — it freezes at the last known fix, which is -// the safe behaviour underway (a vanishing boat reads as "no hazard here"). +// boat is never removed on a dropout — it freezes at the last known fix. const STALE_MS = 6000; const LOST_MS = 20000; @@ -66,97 +58,85 @@ const CHIP_STYLE = ` const EMPTY = { type: "FeatureCollection", features: [] }; -export class OwnShip { - constructor({ map, plotter, vessel, host, onSelect, units, predictMin = 6 } = {}) { - this._map = map; - this._plotter = plotter; - this._vessel = vessel; - this._onSelect = onSelect; // tap → info picker - this._units = units; // () => mariner prefs, for the SOG/STW unit (live) +export default class OwnShip { + constructor(ctx, { predictMin = 6 } = {}) { + this.ctx = ctx; this._predictMin = predictMin; - this._marker = null; this._added = false; this._centered = false; // recenter once on the first fix so the boat is on-screen this._follow = true; // keep the vessel centred until the user pans away this._fix = null; // latest {lng, lat, courseDeg} - this._last = null; // last predictor GeoJSON (to restore after style reload) - // Smooth display: the rendered pose is interpolated fix→fix (in step with the - // camera ease in ) so the boat glides instead of hopping each fix. + this._last = null; // last predictor GeoJSON this._renderLng = null; this._renderLat = null; this._renderRot = 0; // current drawn pose this._predict = null; // {course, sog} for the predictor, from the latest fix - this._poseRAF = 0; // in-flight pose tween - this._lastFixTs = 0; // ms of the previous fix (to size the tween to the gap) - // GPS freshness: _freshWall is the wall-clock of the last position that actually - // advanced (moved, or its fix clock ticked). The watchdog ages it into - // live/stale/lost so a frozen feed is caught even when other sentences (depth, - // wind) keep the snapshot — and thus onChange — flowing. + this._poseRAF = 0; + this._lastFixTs = 0; this._freshWall = 0; - this._posKey = null; // last "lat,lon" seen, to spot a moving fix - this._fixClock = 0; // last navigation.datetime (ms), to spot a stationary-but-live fix + this._posKey = null; + this._fixClock = 0; this._gps = "none"; // none | live | stale | lost + this._cleanup = []; + } - this._el = document.createElement("div"); - this._el.style.cssText = "pointer-events:auto;cursor:pointer;will-change:transform"; - this._el.innerHTML = OWN_SHIP_MARKER; - this._el.addEventListener("click", (e) => { - e.stopPropagation(); // don't let the map's click handler dismiss the picker we're opening - this._select(e); + start() { + const ctx = this.ctx; + + // The own-ship glyph: a rotated DOM marker (survives style reloads). + this._marker = ctx.markers.add("own-ship", { rotationAlignment: "map", anchor: "center" }); + this._marker.setStyle("pointer-events:auto;cursor:pointer;will-change:transform"); + this._marker.setHTML(OWN_SHIP_MARKER); + this._marker.onClick((e) => { e.stopPropagation(); this._select(e); }); + + // Vectors: COG/SOG predictor (dashed) + heading line (solid), each a casing + + // line pair sharing one source, in the overlay z-band (beneath S-52 labels). + // The layer host re-adds + reseeds these across style rebuilds. + this._predLayer = ctx.layers.add("predictor", { + band: "overlay", + layers: [ + { type: "line", layout: { "line-cap": "round" }, paint: { "line-color": "#fff", "line-width": 4, "line-opacity": 0.9 } }, + { type: "line", layout: { "line-cap": "round" }, paint: { "line-color": "#16324f", "line-width": 1.8, "line-dasharray": [2, 1.8] } }, + ], + }); + this._headLayer = ctx.layers.add("heading", { + band: "overlay", + layers: [ + { type: "line", layout: { "line-cap": "round" }, paint: { "line-color": "#fff", "line-width": 4, "line-opacity": 0.9 } }, + { type: "line", layout: { "line-cap": "round" }, paint: { "line-color": "#16324f", "line-width": 1.8 } }, + ], }); - this._chip = this._makeChip(host); - this._gpsChip = this._makeGpsChip(host); - - // Pan and rotate both mean "I want to set my own view", so both break follow; - // pinch/wheel-zoom keeps it (vessel-anchored). We guard on originalEvent so only - // real user gestures count — our own programmatic eases (recenter, and the - // per-fix bearing hold in course-/head-up) fire rotate events WITHOUT one. - this._onGesture = (e) => { if (!e || e.originalEvent) this._setFollow(false); }; - map.on("dragstart", this._onGesture); - map.on("rotatestart", this._onGesture); - - // GPS-freshness watchdog: ages the last fix into live/stale/lost independent of - // the feed cadence (a frozen GPS still pushes depth/wind deltas, so we can't - // wait on onChange to notice the position stopped). - this._gpsTimer = setInterval(() => this._tickGps(), 1000); - - // Defer layer creation until the style is ready (see _ensureLayers); subscribe - // first so a not-yet-loaded style can't abort the constructor before we do. - this._onStyle = () => this._ensureLayers(); - map.on("style.load", this._onStyle); - this._ensureLayers(); - - this._off = vessel.onChange((s) => this._update(s)); - this._update(vessel.state); - } - - _makeChip(host) { - if (!host) return null; + // Floating chrome: the re-centre chip + the GPS status pill (theme vars inherit). + const mount = ctx.hud.mount("own-ship"); const style = document.createElement("style"); style.textContent = CHIP_STYLE; - host.appendChild(style); + mount.appendChild(style); const btn = document.createElement("button"); btn.id = "ownship-recenter"; btn.type = "button"; btn.title = "Centre on vessel"; btn.hidden = true; btn.innerHTML = `${CENTER_ICON}Re-centre`; - btn.onclick = () => { - this._setFollow(true); - this._recenter(); - }; - host.appendChild(btn); - return btn; - } + btn.onclick = () => { this._setFollow(true); this._recenter(); }; + mount.appendChild(btn); + this._chip = btn; + const pill = document.createElement("div"); + pill.id = "ownship-gps"; + pill.hidden = true; + mount.appendChild(pill); + this._gpsChip = pill; + + // Pan/rotate (real gestures only — the ctx guards on originalEvent) break follow. + ctx.camera.onGesture(() => this._setFollow(false)); + + // Wheel-zoom anchor: keep the vessel fixed while zooming when we're following. + ctx.camera.registerFollowAnchor(() => (this._follow && this._fix) ? [this._fix.lng, this._fix.lat] : null); + + // GPS-freshness watchdog (ages the fix independent of the feed cadence). + this._gpsTimer = setInterval(() => this._tickGps(), 1000); - // A non-interactive status pill (top-centre) shown only when the fix goes - // stale/lost. Mirrors the recenter chip's host-mounted pattern. - _makeGpsChip(host) { - if (!host) return null; - const el = document.createElement("div"); - el.id = "ownship-gps"; - el.hidden = true; - host.appendChild(el); - return el; + // Live vessel data (≤4 Hz coalesced). + ctx.vessel.subscribe((s) => this._update(s)); + this._update(ctx.vessel.get()); } _setFollow(on) { @@ -164,7 +144,6 @@ export class OwnShip { this._syncChip(); } - // Watchdog tick: age the last fresh fix into live/stale/lost and reflect it. _tickGps() { if (!this._freshWall || !this._fix) return; const age = Date.now() - this._freshWall; @@ -172,15 +151,15 @@ export class OwnShip { if (next !== this._gps) this._applyGps(next); } - // Reflect GPS freshness: grey/fade the glyph, drop the vectors once not live, and - // show the status pill. Never removes the boat — it stays frozen at the last fix. + // Reflect GPS freshness: grey/fade the glyph, drop the vectors once not live, show + // the status pill. Never removes the boat — it stays frozen at the last fix. _applyGps(status) { this._gps = status; - this._el.style.filter = + this._marker.element.style.filter = status === "lost" ? "grayscale(1) opacity(.4)" : status === "stale" ? "grayscale(1) opacity(.6)" : ""; - if (status !== "live") this._clearVectors(); // a frozen heading/COG vector would mislead + if (status !== "live") this._clearVectors(); if (this._gpsChip) { const lost = status === "lost"; this._gpsChip.hidden = status === "live" || status === "none"; @@ -189,13 +168,6 @@ export class OwnShip { } } - // Plugin contract (consumed by WheelZoom via the shell): the geographic point - // wheel-zoom should keep fixed while zooming — the vessel while we're following - // a fix, else null so it falls back to cursor-anchored zoom. - zoomAnchor() { - return (this._follow && this._fix) ? [this._fix.lng, this._fix.lat] : null; - } - _syncChip() { if (this._chip) this._chip.hidden = this._follow || !this._fix; } @@ -203,60 +175,18 @@ export class OwnShip { // Animate the camera back to the vessel and resume following. _recenter() { if (!this._fix) return; - this._map.easeTo({ center: [this._fix.lng, this._fix.lat], duration: 500 }); - this._plotter.updateFollow(this._fix); - } - - // (Re)create the predictor source + layers — also after a style rebuild, which - // drops plugin sources/layers (the DOM Marker survives). Adding a source/layer - // before the style loads throws, so defer until it's ready; style.load calls back. - _ensureLayers() { - const map = this._map; - if (!map.isStyleLoaded()) return; // style.load will call this again - if (!map.getSource(SRC)) { - map.addSource(SRC, { type: "geojson", data: this._last || EMPTY }); - } - this._plotter.addOverlayLayer( - { id: CASING, type: "line", source: SRC, layout: { "line-cap": "round" }, - paint: { "line-color": "#fff", "line-width": 4, "line-opacity": 0.9 } }, - { belowLabels: true }, - ); - this._plotter.addOverlayLayer( - { id: LINE, type: "line", source: SRC, layout: { "line-cap": "round" }, - paint: { "line-color": "#16324f", "line-width": 1.8, "line-dasharray": [2, 1.8] } }, - { belowLabels: true }, - ); - // Heading line (HDT, the vessel's nose): SOLID, distinct from the dashed COG - // vector. The gap between the two is what reveals being set by current/wind. - if (!map.getSource(HSRC)) { - map.addSource(HSRC, { type: "geojson", data: this._lastH || EMPTY }); - } - this._plotter.addOverlayLayer( - { id: HCASING, type: "line", source: HSRC, layout: { "line-cap": "round" }, - paint: { "line-color": "#fff", "line-width": 4, "line-opacity": 0.9 } }, - { belowLabels: true }, - ); - this._plotter.addOverlayLayer( - { id: HLINE, type: "line", source: HSRC, layout: { "line-cap": "round" }, - paint: { "line-color": "#16324f", "line-width": 1.8 } }, - { belowLabels: true }, - ); + this.ctx.camera.easeTo({ center: [this._fix.lng, this._fix.lat], duration: 500 }); + this.ctx.camera.follow(this._fix); } _update(s) { const nav = s && s.navigation; const pos = nav && nav.position; if (!pos || typeof pos.lat !== "number") { - // No position in the feed. If we've never had one, there's nothing to show. - // If we had a fix, keep it frozen and let the watchdog age it to lost — don't - // make the boat disappear. if (!this._fix) this._hide(); return; } - // Freshness: the fix is "fresh" if it moved, or its own clock (RMC/ZDA datetime) - // advanced — the latter catches a healthy GPS at anchor. Either way, stamp the - // wall clock the watchdog ages. const key = pos.lat.toFixed(6) + "," + pos.lon.toFixed(6); const clock = nav.datetime ? Date.parse(nav.datetime) : 0; if (key !== this._posKey || (clock && clock !== this._fixClock) || !this._freshWall) { @@ -266,18 +196,9 @@ export class OwnShip { this._posKey = key; if (clock) this._fixClock = clock; - // Self-heal the vector sources/layers each fix (~1 Hz, idempotent). The plugin - // is built on the canvas `ready` event — after the initial `style.load` has - // already fired — and the chart rebuilds its style with setStyle({diff:false}), - // which drops plugin layers. Relying on the style.load listener alone left the - // sources never (re)added, so the predictor + heading line never drew. - this._ensureLayers(); - const lng = pos.lon; const lat = pos.lat; - // Heading priority: true heading (HDT) → magnetic heading + variation (most - // boats send HDG, not HDT) → COG. COG is last because it's noisy at low speed, - // which made the marker spin far more than the vessel actually turns. + // Heading priority: true (HDT) → magnetic + variation → COG (noisy at low speed). const magTrue = num(nav.headingMagnetic) != null ? num(nav.headingMagnetic) + (num(nav.magneticVariation) ?? 0) : null; @@ -286,95 +207,75 @@ export class OwnShip { const sog = num(nav.sog) ?? 0; this._fix = { lng, lat, courseDeg: typeof course === "number" ? course : heading, headingDeg: heading }; - // Predictor params for this fix; the dashed line geometry is rebuilt from the - // *interpolated* position each tween frame (in _renderPose) so it tracks too. this._predict = (sog > 0.2 && typeof course === "number") ? { course, sog } : null; - // Tween the drawn pose (position + heading + predictor) from where it is now to - // this fix, sized to the gap since the last fix (linear) so it finishes about - // when the next fix arrives — gliding in step with the camera ease in - // rather than hopping. First fix / teleport / reduced-motion → snap. + // Tween the drawn pose from where it is now to this fix, sized to the inter-fix + // gap so it finishes about when the next fix arrives. First fix / teleport / + // reduced-motion → snap. const now = Date.now(); const gap = this._lastFixTs ? now - this._lastFixTs : 0; this._lastFixTs = now; let dur = 0; if (this._renderLng != null && gap > 0 && gap < 2500 && !reduceMotion()) { dur = clamp(gap, 200, 1200); - const a = this._map.project([this._renderLng, this._renderLat]); - const b = this._map.project([lng, lat]); - if (Math.hypot(b.x - a.x, b.y - a.y) > 400) dur = 0; // big jump → snap, don't crawl + const a = this.ctx.camera.project([this._renderLng, this._renderLat]); + const b = this.ctx.camera.project([lng, lat]); + if (Math.hypot(b.x - a.x, b.y - a.y) > 400) dur = 0; // big jump → snap } this._animatePose(lng, lat, heading, dur); // Bring the boat on-screen on the first fix regardless of camera mode. if (!this._centered) { this._centered = true; - const z = this._map.getZoom(); - this._map.easeTo({ center: [lng, lat], zoom: z < 10 ? 13 : z, duration: 700 }); + const z = this.ctx.camera.getZoom(); + this.ctx.camera.easeTo({ center: [lng, lat], zoom: z < 10 ? 13 : z, duration: 700 }); } else if (this._follow) { - // Keep centred (a no-op in the renderer's "free" mode). Throttle so bursts - // of fixes don't stack camera eases (which makes MapLibre throw "already - // running"); the recentre ease is short and 1 Hz fixes pass straight through. + // Keep centred; throttle so bursts of fixes don't stack camera eases. const t = Date.now(); if (t - (this._lastFollow || 0) > 250) { this._lastFollow = t; - this._plotter.updateFollow(this._fix); + this.ctx.camera.follow(this._fix); } } this._syncChip(); } - // Draw the vessel at an exact pose: the heading-rotated Marker plus the - // course/speed predictor rebuilt from THIS (possibly interpolated) position so - // the dashed line's origin tracks the boat. Records the drawn pose so the next - // fix can tween from it. + // Draw the vessel at an exact pose: the rotated marker plus the predictor + heading + // line rebuilt from THIS (possibly interpolated) position. Records the pose so the + // next fix can tween from it. _renderPose(lng, lat, rot) { - if (!this._marker) { - this._marker = new window.maplibregl.Marker({ element: this._el, rotationAlignment: "map", anchor: "center" }); - } this._marker.setLngLat([lng, lat]).setRotation(rot); - if (!this._added) { this._marker.addTo(this._map); this._added = true; } - // Vectors are dropped while the fix is stale/lost (a frozen vector misleads). + this._added = true; const live = this._gps === "live" || this._gps === "none"; - // COG/SOG vector (dashed): along course, length = SOG × predictMin. let data = EMPTY; if (live && this._predict) { const end = destination(lat, lng, this._predict.course, this._predict.sog * (this._predictMin / 60)); data = seg([lng, lat], end); } - // Heading line (solid): along the rendered heading (`rot`). Matches the COG - // vector's length under way so the crab angle is visible; a short fixed line at - // rest so the bow is still shown at anchor. let hdata = EMPTY; if (live) { const hlen = this._predict ? this._predict.sog * (this._predictMin / 60) : 0.3; hdata = seg([lng, lat], destination(lat, lng, rot, hlen)); } this._last = data; this._lastH = hdata; - const src = this._map.getSource(SRC); - if (src) src.setData(data); - const hsrc = this._map.getSource(HSRC); - if (hsrc) hsrc.setData(hdata); + this._predLayer.setData(data); + this._headLayer.setData(hdata); this._renderLng = lng; this._renderLat = lat; this._renderRot = rot; } - // Clear both vector sources (used when the fix goes stale/lost). _clearVectors() { this._last = EMPTY; this._lastH = EMPTY; - const src = this._map && this._map.getSource(SRC); - if (src) src.setData(EMPTY); - const hsrc = this._map && this._map.getSource(HSRC); - if (hsrc) hsrc.setData(EMPTY); + if (this._predLayer) this._predLayer.setData(EMPTY); + if (this._headLayer) this._headLayer.setData(EMPTY); } // Tween the drawn pose to the new fix over `dur` ms (linear). Bearing takes the - // shortest angular path. dur<=0 (or no prior pose) snaps. A new fix mid-tween - // cancels and re-tweens from the current interpolated pose. + // shortest angular path. dur<=0 (or no prior pose) snaps. _animatePose(toLng, toLat, toRot, dur) { if (this._poseRAF) { cancelAnimationFrame(this._poseRAF); this._poseRAF = 0; } const fromLng = this._renderLng, fromLat = this._renderLat, fromRot = this._renderRot; if (dur <= 0 || fromLng == null) { this._renderPose(toLng, toLat, toRot); return; } - const dRot = ((toRot - fromRot + 540) % 360) - 180; // shortest path, no 360° backspin + const dRot = ((toRot - fromRot + 540) % 360) - 180; const start = Date.now(); const step = () => { const t = Math.min(1, (Date.now() - start) / dur); @@ -386,46 +287,38 @@ export class OwnShip { // Tap → info picker with the vessel's live nav data. _select(e) { - if (!this._onSelect || !this._fix) return; - const nav = (this._vessel.state || {}).navigation || {}; + if (!this._fix) return; + const nav = (this.ctx.vessel.get() || {}).navigation || {}; const rows = [["Position", fmtLatLon(this._fix.lat, this._fix.lng)]]; const hdg = num(nav.headingTrue) ?? (num(nav.headingMagnetic) != null ? num(nav.headingMagnetic) + (num(nav.magneticVariation) ?? 0) : null); if (hdg != null) rows.push(["Heading", Math.round(hdg) + "°T"]); if (num(nav.cogTrue) != null) rows.push(["COG", Math.round(nav.cogTrue) + "°T"]); - const u = (this._units && this._units()) || null; - if (num(nav.sog) != null) rows.push(["SOG", format("speed", nav.sog, u)]); - if (num(nav.speedThroughWater) != null) rows.push(["STW", format("speed", nav.speedThroughWater, u)]); - this._onSelect({ title: "Own ship", rows, x: e.clientX, y: e.clientY }); + if (num(nav.sog) != null) rows.push(["SOG", this.ctx.units.format("speed", nav.sog)]); + if (num(nav.speedThroughWater) != null) rows.push(["STW", this.ctx.units.format("speed", nav.speedThroughWater)]); + this.ctx.callout.show({ title: "Own ship", rows, x: e.clientX, y: e.clientY }); } _hide() { if (this._poseRAF) { cancelAnimationFrame(this._poseRAF); this._poseRAF = 0; } - this._renderLng = null; this._renderLat = null; // next fix snaps, not crawls from a stale pose - if (this._marker && this._added) { - this._marker.remove(); - this._added = false; - } + this._renderLng = null; this._renderLat = null; + if (this._marker) this._marker.hide(); + this._added = false; this._fix = null; this._freshWall = 0; this._posKey = null; this._fixClock = 0; this._gps = "none"; - this._el.style.filter = ""; + if (this._marker) this._marker.element.style.filter = ""; if (this._gpsChip) this._gpsChip.hidden = true; this._clearVectors(); this._syncChip(); } + // The host tears down layers, gesture/anchor listeners, the mount, and the vessel + // subscription (all ctx-tracked); we own the marker + timers/RAF. destroy() { if (this._poseRAF) cancelAnimationFrame(this._poseRAF); if (this._gpsTimer) clearInterval(this._gpsTimer); - if (this._off) this._off(); - if (this._onStyle) this._map.off("style.load", this._onStyle); - if (this._onGesture) { this._map.off("dragstart", this._onGesture); this._map.off("rotatestart", this._onGesture); } if (this._marker) this._marker.remove(); - if (this._chip) this._chip.remove(); - if (this._gpsChip) this._gpsChip.remove(); - this._plotter.removeOverlay([CASING, LINE], SRC); - this._plotter.removeOverlay([HCASING, HLINE], HSRC); } } diff --git a/web/src/plugins/plugin-layers.mjs b/web/src/plugins/plugin-layers.mjs new file mode 100644 index 0000000..43ac64b --- /dev/null +++ b/web/src/plugins/plugin-layers.mjs @@ -0,0 +1,102 @@ +// plugin-layers.mjs — the declarative map-layer host for plugins (spec §8, §11). +// +// Plugins draw by describing GeoJSON layers, not by touching MapLibre directly: +// `layers.add(id, spec)` returns `{ setData, remove }`. This host owns the parts +// every overlay used to hand-roll (see the old own-ship `_ensureLayers`): +// +// • it creates the GeoJSON source + style layers, inserting them in the chosen +// z-band via the plotter's overlay API (so plugin layers can't paint over the +// safety-critical S-52 labels), and +// • it re-adds them after a style rebuild (`setStyle({diff:false})` drops every +// plugin source/layer) and re-seeds the last `setData` payload — so a plugin +// never reimplements style-reload self-healing. +// +// It is modeled on the AIS overlay's source-`setData` path and is the single place +// that reaches the plotter's overlay API, keeping the plugin `ctx` free of it. + +const EMPTY = { type: "FeatureCollection", features: [] }; + +// Named z-bands plugins select from (never extend, spec §7). Each maps to the +// plotter's belowLabels flag: "overlay" sits beneath chart text/symbol labels (the +// safe default — own-ship's predictor/heading lines live here); "top" floats above +// everything. +const BANDS = { overlay: { belowLabels: true }, top: { belowLabels: false } }; + +export class PluginLayers { + constructor({ map, plotter }) { + this._map = map; + this._plotter = plotter; + this._layers = new Map(); // key -> record + // A style rebuild drops every plugin source/layer; re-add them all. Registered + // once at construction so it catches rebuilds that fire after a plugin loads. + this._onStyle = () => { + for (const rec of this._layers.values()) this._ensure(rec); + }; + map.on("style.load", this._onStyle); + } + + // add registers a declarative layer for a plugin. `id` is namespaced by the host + // with the plugin id (identity-scoped, spec §7). spec: + // { band?: "overlay"|"top", + // type, paint, layout, // single-layer shorthand + // layers: [{ type, paint, layout }, …] } // or several sharing one source + // Returns { setData(featureCollection), remove() }. + add(pluginId, id, spec) { + const key = `${pluginId}:${id}`; + const rec = { key, srcId: `${key}-src`, spec, data: EMPTY, layerIds: [] }; + this._layers.set(key, rec); + this._ensure(rec); + return { + setData: (fc) => { + rec.data = fc || EMPTY; + // Self-heal: a style rebuild may have dropped the source between the + // style.load listener and this call (own-ship hit exactly this), so + // re-ensure before writing. + this._ensure(rec); + const src = this._map.getSource(rec.srcId); + if (src) src.setData(rec.data); + }, + remove: () => this._remove(key), + }; + } + + // _ensure (re)creates the source + layers idempotently, seeding the last data. + _ensure(rec) { + const map = this._map; + if (!map.isStyleLoaded()) return; // style.load will call back + if (!map.getSource(rec.srcId)) { + map.addSource(rec.srcId, { type: "geojson", data: rec.data }); + } + const band = BANDS[rec.spec.band] || BANDS.overlay; + const specs = rec.spec.layers || [rec.spec]; + rec.layerIds = []; + specs.forEach((ls, i) => { + const lid = `${rec.key}-l${i}`; + rec.layerIds.push(lid); + this._plotter.addOverlayLayer( + { id: lid, type: ls.type, source: rec.srcId, layout: ls.layout || {}, paint: ls.paint || {} }, + { belowLabels: band.belowLabels }, + ); + }); + } + + _remove(key) { + const rec = this._layers.get(key); + if (!rec) return; + this._layers.delete(key); + this._plotter.removeOverlay(rec.layerIds, rec.srcId); + } + + // Tear down every layer for one plugin (called when the plugin unloads). + removeAll(pluginId) { + const prefix = pluginId + ":"; + for (const key of [...this._layers.keys()]) { + if (key.startsWith(prefix)) this._remove(key); + } + } + + destroy() { + if (this._onStyle) this._map.off("style.load", this._onStyle); + for (const key of [...this._layers.keys()]) this._remove(key); + } +} From d785fe444b2db391297a94e829de4f74b513fd78 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 16:26:01 +0000 Subject: [PATCH 04/33] fix(plugin): dev exits promptly on Ctrl-C serve() blocks reading the plugin's stdout; a context cancel didn't unblock it, so plugin dev ignored SIGINT/SIGTERM. Kill the session on cancellation so the read returns EOF and DevRun exits. --- internal/engine/plugin/dev.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/engine/plugin/dev.go b/internal/engine/plugin/dev.go index b30aacc..3b93188 100644 --- a/internal/engine/plugin/dev.go +++ b/internal/engine/plugin/dev.go @@ -61,6 +61,13 @@ func devRunOnce(parent context.Context, dir string, man *Manifest, storeDir stri 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) }() From 2c5fc67475f9514d1ff059a9335dc65d6a0e62f9 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 16:31:48 +0000 Subject: [PATCH 05/33] feat(web): plugin management UI (Plugins settings tab) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Plugins tab to the settings dialog to install and manage plugins over the /api/plugins* endpoints, modeled on the Connections tab: - data/plugins-service.mjs: client (list, install multipart upload, enable, disable, setGrants, remove, status SSE stream). - plugins/plugins-panel.mjs: element — installed list with live status badges + tier tag, install-by-upload, enable/disable, a per-plugin capability grant editor (plain-language descriptions), and remove. Status ticks patch badges in place; structural changes re-render. - plugins/plugins-manager.mjs: PluginsController registers the tab via the settings-registry render(host) escape hatch. - chartplotter.mjs: construct PluginsController alongside ConnectionsController. --- web/src/chartplotter.mjs | 7 + web/src/data/plugins-service.mjs | 80 ++++++++ web/src/plugins/plugins-manager.mjs | 35 ++++ web/src/plugins/plugins-panel.mjs | 277 ++++++++++++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 web/src/data/plugins-service.mjs create mode 100644 web/src/plugins/plugins-manager.mjs create mode 100644 web/src/plugins/plugins-panel.mjs diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 3f35dae..9edbf75 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -28,6 +28,7 @@ import { VgRail } from "./plugins/vg-rail.mjs"; // mid-left viewing-group quick- 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 { PluginHost } from "./core/plugin-host.mjs"; // loads builtin/plugin UI controllers with a declarative ctx import OwnShip from "./plugins/own-ship.mjs"; // builtin core.own-ship: marker + course predictor + follow camera @@ -709,6 +710,12 @@ export class ChartPlotter extends HTMLElement { assets: this._assets, notify: this._notify, }); + // Plugins settings tab: install/enable/disable/grant/remove plugins. + this._pluginsCtl = new PluginsController({ + registry: this._settingsRegistry, + assets: this._assets, + notify: this._notify, + }); // Tap-info picker for own-ship / AIS targets; dismissed on a map grab or tap. const tinfo = document.createElement("target-info"); this.shadowRoot.appendChild(tinfo); diff --git a/web/src/data/plugins-service.mjs b/web/src/data/plugins-service.mjs new file mode 100644 index 0000000..279fdc1 --- /dev/null +++ b/web/src/data/plugins-service.mjs @@ -0,0 +1,80 @@ +// PluginsService is the client for the server's plugin-management API: list +// installed plugins, install from an uploaded .zip, enable/disable, edit grants, +// remove, and subscribe to the live status stream. It holds no UI state — the +// component drives it. See internal/engine/server/plugins.go. + +export class PluginsService { + constructor({ assets = "/" } = {}) { + this._assets = assets; + } + + /** List installed plugins, each as {record, manifest, status, running}. */ + async list() { + const r = await fetch(this._assets + "api/plugins", { cache: "no-store" }); + const j = await r.json(); + return j.plugins || []; + } + + /** Install a plugin from a File (a .zip). Returns the parsed manifest. */ + async install(file) { + const form = new FormData(); + form.append("plugin", file, file.name); + const r = await fetch(this._assets + "api/plugins/install", { method: "POST", body: form }); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || "install failed"); + return j.manifest; + } + + enable(id) { + return this._post("api/plugins/" + encodeURIComponent(id) + "/enable"); + } + + disable(id) { + return this._post("api/plugins/" + encodeURIComponent(id) + "/disable"); + } + + /** Replace a plugin's granted capabilities (and optionally its config). */ + setGrants(id, grants, config) { + return this._send("PUT", "api/plugins/" + encodeURIComponent(id) + "/grants", { grants, config }); + } + + async remove(id, purgeData) { + const q = purgeData ? "?purgeData=1" : ""; + const r = await fetch(this._assets + "api/plugins/" + encodeURIComponent(id) + q, { method: "DELETE" }); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || "remove failed"); + } + + async _post(path) { + const r = await fetch(this._assets + path, { method: "POST" }); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || "request failed"); + } + + async _send(method, path, body) { + const r = await fetch(this._assets + path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || "request failed"); + } + + /** Subscribe to the live status stream (the full plugin list on any change). + * Returns a stop function. */ + stream(onPlugins) { + if (!window.EventSource) return () => {}; + const es = new EventSource(this._assets + "api/plugins/stream"); + es.onmessage = (ev) => { + let s; + try { + s = JSON.parse(ev.data); + } catch { + return; + } + onPlugins(s.plugins || []); + }; + return () => es.close(); + } +} diff --git a/web/src/plugins/plugins-manager.mjs b/web/src/plugins/plugins-manager.mjs new file mode 100644 index 0000000..373060f --- /dev/null +++ b/web/src/plugins/plugins-manager.mjs @@ -0,0 +1,35 @@ +// PluginsController contributes the "Plugins" settings tab — install and manage +// plugins. Like ConnectionsController it is a plain class built once the app is +// ready; it registers a settings contribution whose render(host) mounts a persistent +// into the tab. The panel keeps its own state (open grant editor) +// across re-renders because the same element instance is moved into each fresh host. + +import "./plugins-panel.mjs"; +import { PluginsService } from "../data/plugins-service.mjs"; + +export class PluginsController { + constructor(deps = {}) { + this._service = new PluginsService({ assets: deps.assets || "/" }); + this._notify = deps.notify; + this._panel = null; + this._unregister = deps.registry.register({ + id: "plugins", + tab: { id: "plugins", label: "Plugins" }, + order: 5, + render: (host) => this._render(host), + }); + } + + _render(host) { + if (!this._panel) { + this._panel = document.createElement("plugins-panel"); + this._panel.configure({ service: this._service, notify: this._notify }); + } + host.appendChild(this._panel); // moving preserves the panel's state + } + + destroy() { + if (this._unregister) this._unregister(); + this._unregister = null; + } +} diff --git a/web/src/plugins/plugins-panel.mjs b/web/src/plugins/plugins-panel.mjs new file mode 100644 index 0000000..94fd53a --- /dev/null +++ b/web/src/plugins/plugins-panel.mjs @@ -0,0 +1,277 @@ +// — the Plugins settings UI. Lists installed plugins with live +// status badges (fed by the /api/plugins/stream SSE), an "Install plugin" upload, +// enable/disable, a per-plugin capability-grant editor, and remove. Mounted into the +// "Plugins" settings tab by PluginsController via the settings-dialog render(host) +// escape hatch. +// +// Status ticks patch each row's badge in place; structural changes (install / enable +// / grant edits / remove) re-render the list. + +const STYLE = ` + :host { display: block; color: var(--ui-text, #e6edf3); font-size: 13px; } + .bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; gap: 10px; } + .bar .hint { color: var(--ui-text-dim, #8b949e); font-size: 12px; } + button { + background: var(--ui-surface-2, #21262d); color: var(--ui-text, #e6edf3); + border: 1px solid var(--ui-border, #30363d); border-radius: 6px; + padding: 5px 10px; font: inherit; cursor: pointer; + touch-action: manipulation; -webkit-user-select: none; user-select: none; + } + button:hover { background: var(--ui-hover, #30363d); } + button.primary { background: var(--ui-accent, #2f81f7); color: var(--ui-accent-text, #fff); border-color: transparent; } + button.danger { color: #f85149; } + .empty { color: var(--ui-text-dim, #8b949e); padding: 18px 4px; text-align: center; } + + .row { + display: flex; align-items: center; gap: 10px; + padding: 9px 10px; border: 1px solid var(--ui-border, #30363d); + border-radius: 8px; margin-bottom: 8px; background: var(--ui-surface, #161b22); + } + .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: #6e7681; } + .info { flex: 1; min-width: 0; } + .name { font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .badge { font-size: 11px; font-weight: 500; color: var(--ui-text-dim, #8b949e); text-transform: uppercase; letter-spacing: .03em; } + .tag { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 10px; background: var(--ui-surface-2,#21262d); color: var(--ui-text-dim,#8b949e); text-transform: uppercase; letter-spacing:.03em; } + .meta { color: var(--ui-text-dim, #8b949e); font-size: 12px; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .actions { display: flex; align-items: center; gap: 8px; flex: none; } + + .grants { border: 1px solid var(--ui-border, #30363d); border-radius: 8px; padding: 10px 12px; margin: -4px 0 10px; background: var(--ui-bg, #0d1117); } + .grants h5 { margin: 0 0 8px; font-size: 12px; color: var(--ui-text-dim,#8b949e); text-transform: uppercase; letter-spacing:.03em; } + .cap { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; } + .cap input { margin-top: 3px; } + .cap .desc { color: var(--ui-text-dim, #8b949e); font-size: 12px; } + .cap code { color: var(--ui-text, #e6edf3); } + .grants-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } + .err { color: #f85149; font-size: 12px; margin-top: 6px; } + input[type=file] { display: none; } +`; + +// plugin status.State → [dot colour, badge label] +const BADGE = { + running: ["#3fb950", "running"], + enabled: ["#3fb950", "enabled"], + degraded: ["#d29922", "degraded"], + error: ["#f85149", "error"], + disabled: ["#6e7681", "disabled"], +}; + +// Plain-language descriptions of what each capability grants (for the grant editor). +const CAP_DESC = { + "vessel.read": "Read live vessel state (position, heading, speed…).", + "vessel.write": "Publish vessel data (position/heading/…) into the shared state.", + "ais.read": "Read AIS targets.", + "ais.write": "Publish AIS target updates.", + serial: "Open serial devices (host-owned).", + "net.tcp-client": "Make outbound TCP connections (host-dialed, allowlisted).", + "net.udp": "Send/receive UDP (host-mediated).", + "net.http": "Make outbound HTTP requests to allowlisted origins.", + storage: "A private key/value + blob store.", + notify: "Post to the notification centre.", + "http.register": "Serve routes under /plugins//api/.", + "ui.settings": "Add a settings panel.", + "ui.panel": "Add a side panel.", + "ui.map-layer": "Draw a map layer.", + "ui.hud": "Add a HUD widget.", +}; + +export class PluginsPanel extends HTMLElement { + constructor() { + super(); + if (!this.shadowRoot) this.attachShadow({ mode: "open" }); + this._service = null; + this._notify = null; + this._plugins = []; // [{record, manifest, status, running}] + this._grantsOpen = null; // id whose grant editor is open + this._stop = null; + this._err = ""; + } + + configure({ service, notify }) { + this._service = service; + this._notify = notify; + } + + connectedCallback() { + this.shadowRoot.innerHTML = `
`; + this._root = this.shadowRoot.getElementById("root"); + this._load(); + // Live updates: patch badges when only status changed, else re-render. + this._stop = this._service.stream((plugins) => this._onStream(plugins)); + } + + disconnectedCallback() { + if (this._stop) this._stop(); + this._stop = null; + } + + async _load() { + try { + this._plugins = await this._service.list(); + this._err = ""; + } catch (e) { + this._err = e.message || String(e); + } + this._render(); + } + + _onStream(plugins) { + const sameStructure = structureSig(plugins) === structureSig(this._plugins); + this._plugins = plugins; + if (sameStructure && !this._grantsOpen) { + this._patchBadges(); + } else { + this._render(); + } + } + + _render() { + const rows = this._plugins.map((p) => this._rowHTML(p)).join(""); + this._root.innerHTML = ` +
+ Plugins add data sources, map layers, and panels. Installed plugins are trusted. + +
+ + ${this._err ? `
${esc(this._err)}
` : ""} + ${this._plugins.length ? rows : `
No plugins installed.
`} + `; + this._root.querySelector("#install").onclick = () => this._root.querySelector("#file").click(); + this._root.querySelector("#file").onchange = (e) => this._install(e.target.files[0]); + for (const el of this._root.querySelectorAll("[data-act]")) { + el.onclick = () => this._action(el.dataset.act, el.dataset.id); + } + if (this._grantsOpen) this._renderGrants(this._grantsOpen); + } + + _rowHTML(p) { + const id = p.record.id; + const man = p.manifest || {}; + const name = man.name || id; + const state = p.record.enabled ? (p.status && p.status.state) || "enabled" : "disabled"; + const [color, label] = BADGE[state] || ["#6e7681", state]; + const tier = man.entry && man.entry.wasm ? "wasm" : man.entry && man.entry.native ? "native" : "ui"; + const detail = (p.status && p.status.detail) || ""; + const toggle = p.record.enabled ? "Disable" : "Enable"; + return ` +
+ +
+
${esc(name)} ${esc(label)} ${tier}
+
${esc(id)} · v${esc(man.version || p.record.version || "?")}${detail ? " · " + esc(detail) : ""}
+
+
+ ${man.capabilities && man.capabilities.length ? `` : ""} + + +
+
`; + } + + _patchBadges() { + for (const p of this._plugins) { + const row = this._root.querySelector(`[data-row="${cssEsc(p.record.id)}"]`); + if (!row) return this._render(); // structure drifted; full render + const state = p.record.enabled ? (p.status && p.status.state) || "enabled" : "disabled"; + const [color, label] = BADGE[state] || ["#6e7681", state]; + row.querySelector(".dot").style.background = color; + const badge = row.querySelector(".badge"); + if (badge) badge.textContent = label; + } + } + + async _action(act, id) { + try { + if (act === "toggle") { + const p = this._plugins.find((x) => x.record.id === id); + await (p && p.record.enabled ? this._service.disable(id) : this._service.enable(id)); + await this._load(); + } else if (act === "remove") { + if (!confirm(`Remove plugin ${id}?`)) return; + await this._service.remove(id, false); + this._grantsOpen = null; + await this._load(); + } else if (act === "grants") { + this._grantsOpen = this._grantsOpen === id ? null : id; + this._render(); + } + } catch (e) { + this._err = e.message || String(e); + this._render(); + if (this._notify) this._notify.error && this._notify.error(this._err); + } + } + + // _renderGrants injects the grant editor beneath the open plugin's row. + _renderGrants(id) { + const p = this._plugins.find((x) => x.record.id === id); + const row = this._root.querySelector(`[data-row="${cssEsc(id)}"]`); + if (!p || !row) return; + const caps = (p.manifest && p.manifest.capabilities) || []; + const granted = new Set((p.record.grants || []).map((g) => g.cap)); + const box = document.createElement("div"); + box.className = "grants"; + box.innerHTML = ` +
Capabilities
+ ${caps + .map( + (c, i) => ` + `, + ) + .join("")} +
+ + +
`; + row.after(box); + box.querySelector('[data-g="cancel"]').onclick = () => { this._grantsOpen = null; this._render(); }; + box.querySelector('[data-g="save"]').onclick = async () => { + const chosen = []; + box.querySelectorAll("input[data-cap]").forEach((cb) => { + if (cb.checked) chosen.push(caps[Number(cb.dataset.cap)]); + }); + try { + await this._service.setGrants(id, chosen, null); + this._grantsOpen = null; + await this._load(); + } catch (e) { + this._err = e.message || String(e); + this._render(); + } + }; + } + + async _install(file) { + if (!file) return; + try { + const man = await this._service.install(file); + this._err = ""; + if (this._notify && this._notify.info) this._notify.info(`Installed ${man.name || man.id}. Review its capabilities, then enable it.`); + await this._load(); + } catch (e) { + this._err = e.message || String(e); + this._render(); + } + } +} + +// structureSig is a signature of everything that requires a re-render (not status). +function structureSig(plugins) { + return JSON.stringify( + plugins.map((p) => [p.record.id, p.record.version, p.record.enabled, (p.record.grants || []).map((g) => g.cap)]), + ); +} + +function esc(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +} + +// cssEsc escapes a plugin id for use inside an attribute selector. +function cssEsc(s) { + return String(s).replace(/["\\]/g, "\\$&"); +} + +customElements.define("plugins-panel", PluginsPanel); From 52b8c7186779ea6713abfed978cb476aba524cfe Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 16:53:12 +0000 Subject: [PATCH 06/33] feat(plugin): revoking a grant tears down what it authorized; config API + live status - Revoking net.tcp-client (or serial) now closes the plugin's open host-dialed sockets, so a source plugin actually stops receiving/publishing instead of continuing over a connection the host opened under a now-revoked grant. - Revoking ais.write evicts the targets that plugin contributed (AISStore.EvictSource) so stale AIS doesn't linger until TTL; new publishes are already denied by the capability check. - Manager.SetConfig updates settings while leaving grants intact (the previous config path wiped grants); hot-applied via host.grantsChanged. - Plugin status.update is now reflected in the plugins UI (a per-runner status hook), so the badge shows the plugin's own state/detail. - core.tcp-client manifest gains a ui.settings schema (host/port). - Parity-test poll widened so a loaded machine doesn't flake; EvictSource test. --- cmd/chartplotter/plugin.go | 1 + internal/engine/nmea/ais.go | 19 ++++++++++++++++ internal/engine/nmea/evict_test.go | 30 ++++++++++++++++++++++++++ internal/engine/plugin/broker.go | 24 ++++++++++++++++++++- internal/engine/plugin/capabilities.go | 6 +++++- internal/engine/plugin/manager.go | 21 ++++++++++++++++-- internal/engine/plugin/parity_test.go | 3 ++- internal/engine/server/plugins.go | 3 ++- plugins/core.tcp-client/plugin.json | 8 +++++++ 9 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 internal/engine/nmea/evict_test.go diff --git a/cmd/chartplotter/plugin.go b/cmd/chartplotter/plugin.go index 9d82bdc..b03e70b 100644 --- a/cmd/chartplotter/plugin.go +++ b/cmd/chartplotter/plugin.go @@ -161,6 +161,7 @@ func (consoleHost) PublishRaw(src string, lines []string) { 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) } diff --git a/internal/engine/nmea/ais.go b/internal/engine/nmea/ais.go index 0f7b3b3..c4703c0 100644 --- a/internal/engine/nmea/ais.go +++ b/internal/engine/nmea/ais.go @@ -206,6 +206,25 @@ func (s *AISStore) Upsert(t AISTarget, source string) { 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 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/plugin/broker.go b/internal/engine/plugin/broker.go index d5e57d5..526cd20 100644 --- a/internal/engine/plugin/broker.go +++ b/internal/engine/plugin/broker.go @@ -36,6 +36,8 @@ type Host interface { 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) } @@ -53,6 +55,7 @@ type brokerSession struct { 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 @@ -228,13 +231,32 @@ func (b *brokerSession) configCopy() map[string]any { } // setGrants swaps the grant set + config at runtime and notifies the plugin -// (host.grantsChanged, spec §4). +// (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}) } diff --git a/internal/engine/plugin/capabilities.go b/internal/engine/plugin/capabilities.go index d42e058..9e861db 100644 --- a/internal/engine/plugin/capabilities.go +++ b/internal/engine/plugin/capabilities.go @@ -109,7 +109,11 @@ func (b *brokerSession) handleStatusUpdate(m *Message) { if json.Unmarshal(m.Params, &s) != nil { return } - b.host.UpdateStatus(b.id, PluginStatus(s)) + 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 { diff --git a/internal/engine/plugin/manager.go b/internal/engine/plugin/manager.go index a60c49a..32998b1 100644 --- a/internal/engine/plugin/manager.go +++ b/internal/engine/plugin/manager.go @@ -150,14 +150,26 @@ func (m *Manager) Disable(id string) error { } // SetGrants swaps a plugin's grant set (and optionally config), hot-applying it to a -// running plugin via host.grantsChanged (spec §4). +// 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 { - rec, ok := m.state.mutate(id, func(r *PluginRecord) { + 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) } @@ -337,6 +349,11 @@ func (r *pluginRunner) runOnce(parent context.Context) error { } 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) diff --git a/internal/engine/plugin/parity_test.go b/internal/engine/plugin/parity_test.go index 63da640..1aec18d 100644 --- a/internal/engine/plugin/parity_test.go +++ b/internal/engine/plugin/parity_test.go @@ -77,6 +77,7 @@ func (h *testHost) PublishRaw(source string, lines []string) { 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) @@ -121,7 +122,7 @@ func TestTCPClientPluginParity(t *testing.T) { // Poll until the host store converges (the plugin batches at ~75 ms). ok := false - for i := 0; i < 60; i++ { + 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 diff --git a/internal/engine/server/plugins.go b/internal/engine/server/plugins.go index 2dabd0f..b78edfb 100644 --- a/internal/engine/server/plugins.go +++ b/internal/engine/server/plugins.go @@ -53,6 +53,7 @@ func (h *pluginHost) PublishRaw(source string, lines []string) { 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) } @@ -132,7 +133,7 @@ func (s *Server) servePluginItem(w http.ResponseWriter, r *http.Request) { apiErr(w, http.StatusBadRequest, err.Error()) return } - s.pluginErr(w, s.pluginMgr.SetGrants(id, nil, cfg)) // config-only update keeps grants + 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") != "" s.pluginErr(w, s.pluginMgr.Remove(id, purge)) diff --git a/plugins/core.tcp-client/plugin.json b/plugins/core.tcp-client/plugin.json index f79176c..879492f 100644 --- a/plugins/core.tcp-client/plugin.json +++ b/plugins/core.tcp-client/plugin.json @@ -13,6 +13,14 @@ { "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 } ] From a7c31a214ad41d22f5fe7b27f03ff40026bb7f2b Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 16:53:31 +0000 Subject: [PATCH 07/33] feat(web): per-plugin config editor + roomier settings dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugins-panel: a Configure editor per plugin — a schema-driven form when the manifest declares ui.settings.items (text/number/toggle/select), or a raw-JSON fallback otherwise; saved via PUT /api/plugins//config. Tighter, roomier row/action spacing and a shared editor style for grants + config. - plugins-service: setConfig(). - Settings dialog widened (set-wide 520→760px) and taller (max-height 66vh/620→ 82vh/860) so the Plugins tab (and others) aren't cramped. --- web/src/chartplotter.view.mjs | 2 +- web/src/data/plugins-service.mjs | 5 + web/src/plugins/plugins-panel.mjs | 218 ++++++++++++++++------- web/src/plugins/settings-dialog.view.mjs | 2 +- 4 files changed, 160 insertions(+), 67 deletions(-) diff --git a/web/src/chartplotter.view.mjs b/web/src/chartplotter.view.mjs index e11458d..04ec9b5 100644 --- a/web/src/chartplotter.view.mjs +++ b/web/src/chartplotter.view.mjs @@ -414,7 +414,7 @@ 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 */ #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 */ diff --git a/web/src/data/plugins-service.mjs b/web/src/data/plugins-service.mjs index 279fdc1..2618dfd 100644 --- a/web/src/data/plugins-service.mjs +++ b/web/src/data/plugins-service.mjs @@ -38,6 +38,11 @@ export class PluginsService { return this._send("PUT", "api/plugins/" + encodeURIComponent(id) + "/grants", { grants, config }); } + /** Replace a plugin's settings (config), leaving grants intact. */ + setConfig(id, config) { + return this._send("PUT", "api/plugins/" + encodeURIComponent(id) + "/config", config); + } + async remove(id, purgeData) { const q = purgeData ? "?purgeData=1" : ""; const r = await fetch(this._assets + "api/plugins/" + encodeURIComponent(id) + q, { method: "DELETE" }); diff --git a/web/src/plugins/plugins-panel.mjs b/web/src/plugins/plugins-panel.mjs index 94fd53a..bd4c85e 100644 --- a/web/src/plugins/plugins-panel.mjs +++ b/web/src/plugins/plugins-panel.mjs @@ -1,48 +1,67 @@ // — the Plugins settings UI. Lists installed plugins with live // status badges (fed by the /api/plugins/stream SSE), an "Install plugin" upload, -// enable/disable, a per-plugin capability-grant editor, and remove. Mounted into the -// "Plugins" settings tab by PluginsController via the settings-dialog render(host) -// escape hatch. +// enable/disable, a per-plugin capability-grant editor, a per-plugin settings +// (config) editor, and remove. Mounted into the "Plugins" settings tab by +// PluginsController via the settings-dialog render(host) escape hatch. // // Status ticks patch each row's badge in place; structural changes (install / enable -// / grant edits / remove) re-render the list. +// / grant or config edits / remove) re-render the list. const STYLE = ` :host { display: block; color: var(--ui-text, #e6edf3); font-size: 13px; } - .bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; gap: 10px; } - .bar .hint { color: var(--ui-text-dim, #8b949e); font-size: 12px; } + .bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; gap: 14px; } + .bar .hint { color: var(--ui-text-dim, #8b949e); font-size: 12px; line-height: 1.5; flex: 1; min-width: 0; } button { background: var(--ui-surface-2, #21262d); color: var(--ui-text, #e6edf3); border: 1px solid var(--ui-border, #30363d); border-radius: 6px; - padding: 5px 10px; font: inherit; cursor: pointer; + padding: 6px 12px; font: inherit; cursor: pointer; white-space: nowrap; touch-action: manipulation; -webkit-user-select: none; user-select: none; } button:hover { background: var(--ui-hover, #30363d); } button.primary { background: var(--ui-accent, #2f81f7); color: var(--ui-accent-text, #fff); border-color: transparent; } button.danger { color: #f85149; } - .empty { color: var(--ui-text-dim, #8b949e); padding: 18px 4px; text-align: center; } + button.danger:hover { background: rgba(248,81,73,.12); } + .empty { color: var(--ui-text-dim, #8b949e); padding: 26px 4px; text-align: center; border: 1px dashed var(--ui-border, #30363d); border-radius: 10px; } .row { - display: flex; align-items: center; gap: 10px; - padding: 9px 10px; border: 1px solid var(--ui-border, #30363d); - border-radius: 8px; margin-bottom: 8px; background: var(--ui-surface, #161b22); + display: flex; align-items: center; gap: 12px; + padding: 12px 14px; border: 1px solid var(--ui-border, #30363d); + border-radius: 10px; margin-bottom: 10px; background: var(--ui-surface, #161b22); } + .row.open { border-bottom-left-radius: 0; border-bottom-right-radius: 0; margin-bottom: 0; } .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: #6e7681; } .info { flex: 1; min-width: 0; } - .name { font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .name { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .badge { font-size: 11px; font-weight: 500; color: var(--ui-text-dim, #8b949e); text-transform: uppercase; letter-spacing: .03em; } - .tag { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 10px; background: var(--ui-surface-2,#21262d); color: var(--ui-text-dim,#8b949e); text-transform: uppercase; letter-spacing:.03em; } - .meta { color: var(--ui-text-dim, #8b949e); font-size: 12px; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .actions { display: flex; align-items: center; gap: 8px; flex: none; } + .tag { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 10px; background: var(--ui-surface-2,#21262d); color: var(--ui-text-dim,#8b949e); text-transform: uppercase; letter-spacing:.03em; } + .meta { color: var(--ui-text-dim, #8b949e); font-size: 12px; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .actions { display: flex; align-items: center; gap: 7px; flex: none; flex-wrap: wrap; justify-content: flex-end; } - .grants { border: 1px solid var(--ui-border, #30363d); border-radius: 8px; padding: 10px 12px; margin: -4px 0 10px; background: var(--ui-bg, #0d1117); } - .grants h5 { margin: 0 0 8px; font-size: 12px; color: var(--ui-text-dim,#8b949e); text-transform: uppercase; letter-spacing:.03em; } - .cap { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; } - .cap input { margin-top: 3px; } - .cap .desc { color: var(--ui-text-dim, #8b949e); font-size: 12px; } + .editor { + border: 1px solid var(--ui-border, #30363d); border-top: none; + border-radius: 0 0 10px 10px; padding: 14px 16px; margin: 0 0 10px; + background: var(--ui-bg, #0d1117); + } + .editor h5 { margin: 0 0 12px; font-size: 12px; color: var(--ui-text-dim,#8b949e); text-transform: uppercase; letter-spacing:.03em; } + .cap { display: flex; align-items: flex-start; gap: 9px; margin-bottom: 10px; } + .cap input[type=checkbox] { margin-top: 3px; flex: none; } + .cap .desc { color: var(--ui-text-dim, #8b949e); font-size: 12px; margin-top: 2px; } .cap code { color: var(--ui-text, #e6edf3); } - .grants-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } - .err { color: #f85149; font-size: 12px; margin-top: 6px; } + + .field { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } + .field label { width: 110px; flex: none; color: var(--ui-text-dim, #8b949e); } + .field .input { flex: 1; min-width: 0; } + .field input[type=text], .field input[type=number], .field input[type=password], .field select, .editor textarea { + width: 100%; box-sizing: border-box; + background: var(--ui-surface, #161b22); color: var(--ui-text, #e6edf3); + border: 1px solid var(--ui-border-strong, #444c56); border-radius: 6px; padding: 7px 9px; font: inherit; + font-size: 16px; /* >=16px or iOS zooms on focus */ + } + .editor textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; line-height: 1.5; white-space: pre; } + .field .unit { color: var(--ui-text-dim, #8b949e); font-size: 12px; flex: none; } + .switch { display: inline-flex; align-items: center; } + .editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } + .err { color: #f85149; font-size: 12px; margin: 8px 0; } input[type=file] { display: none; } `; @@ -81,7 +100,7 @@ export class PluginsPanel extends HTMLElement { this._service = null; this._notify = null; this._plugins = []; // [{record, manifest, status, running}] - this._grantsOpen = null; // id whose grant editor is open + this._open = null; // { id, mode: "grants"|"config" } this._stop = null; this._err = ""; } @@ -95,7 +114,6 @@ export class PluginsPanel extends HTMLElement { this.shadowRoot.innerHTML = `
`; this._root = this.shadowRoot.getElementById("root"); this._load(); - // Live updates: patch badges when only status changed, else re-render. this._stop = this._service.stream((plugins) => this._onStream(plugins)); } @@ -115,32 +133,29 @@ export class PluginsPanel extends HTMLElement { } _onStream(plugins) { - const sameStructure = structureSig(plugins) === structureSig(this._plugins); + const same = structureSig(plugins) === structureSig(this._plugins); this._plugins = plugins; - if (sameStructure && !this._grantsOpen) { - this._patchBadges(); - } else { - this._render(); - } + if (same && !this._open) this._patchBadges(); + else this._render(); } _render() { const rows = this._plugins.map((p) => this._rowHTML(p)).join(""); this._root.innerHTML = `
- Plugins add data sources, map layers, and panels. Installed plugins are trusted. + Plugins add data sources, map layers, and panels. Installed plugins run trusted — review their capabilities before enabling.
${this._err ? `
${esc(this._err)}
` : ""} - ${this._plugins.length ? rows : `
No plugins installed.
`} + ${this._plugins.length ? rows : `
No plugins installed yet.
`} `; this._root.querySelector("#install").onclick = () => this._root.querySelector("#file").click(); this._root.querySelector("#file").onchange = (e) => this._install(e.target.files[0]); for (const el of this._root.querySelectorAll("[data-act]")) { el.onclick = () => this._action(el.dataset.act, el.dataset.id); } - if (this._grantsOpen) this._renderGrants(this._grantsOpen); + if (this._open) this._renderEditor(); } _rowHTML(p) { @@ -152,15 +167,18 @@ export class PluginsPanel extends HTMLElement { const tier = man.entry && man.entry.wasm ? "wasm" : man.entry && man.entry.native ? "native" : "ui"; const detail = (p.status && p.status.detail) || ""; const toggle = p.record.enabled ? "Disable" : "Enable"; + const openHere = this._open && this._open.id === id; + const hasCaps = man.capabilities && man.capabilities.length; return ` -
+
${esc(name)} ${esc(label)} ${tier}
${esc(id)} · v${esc(man.version || p.record.version || "?")}${detail ? " · " + esc(detail) : ""}
- ${man.capabilities && man.capabilities.length ? `` : ""} + + ${hasCaps ? `` : ""}
@@ -170,7 +188,7 @@ export class PluginsPanel extends HTMLElement { _patchBadges() { for (const p of this._plugins) { const row = this._root.querySelector(`[data-row="${cssEsc(p.record.id)}"]`); - if (!row) return this._render(); // structure drifted; full render + if (!row) return this._render(); const state = p.record.enabled ? (p.status && p.status.state) || "enabled" : "disabled"; const [color, label] = BADGE[state] || ["#6e7681", state]; row.querySelector(".dot").style.background = color; @@ -188,28 +206,35 @@ export class PluginsPanel extends HTMLElement { } else if (act === "remove") { if (!confirm(`Remove plugin ${id}?`)) return; await this._service.remove(id, false); - this._grantsOpen = null; + this._open = null; await this._load(); - } else if (act === "grants") { - this._grantsOpen = this._grantsOpen === id ? null : id; + } else if (act === "grants" || act === "config") { + this._open = this._open && this._open.id === id && this._open.mode === act ? null : { id, mode: act }; this._render(); } } catch (e) { this._err = e.message || String(e); this._render(); - if (this._notify) this._notify.error && this._notify.error(this._err); + if (this._notify && this._notify.error) this._notify.error(this._err); } } - // _renderGrants injects the grant editor beneath the open plugin's row. - _renderGrants(id) { - const p = this._plugins.find((x) => x.record.id === id); - const row = this._root.querySelector(`[data-row="${cssEsc(id)}"]`); + _renderEditor() { + const p = this._plugins.find((x) => x.record.id === this._open.id); + const row = this._root.querySelector(`[data-row="${cssEsc(this._open.id)}"]`); if (!p || !row) return; + const box = document.createElement("div"); + box.className = "editor"; + if (this._open.mode === "grants") this._buildGrants(box, p); + else this._buildConfig(box, p); + row.after(box); + } + + // --- grant editor --- + _buildGrants(box, p) { + const id = p.record.id; const caps = (p.manifest && p.manifest.capabilities) || []; const granted = new Set((p.record.grants || []).map((g) => g.cap)); - const box = document.createElement("div"); - box.className = "grants"; box.innerHTML = `
Capabilities
${caps @@ -222,28 +247,91 @@ export class PluginsPanel extends HTMLElement { `, ) .join("")} -
- - +
+ +
`; - row.after(box); - box.querySelector('[data-g="cancel"]').onclick = () => { this._grantsOpen = null; this._render(); }; - box.querySelector('[data-g="save"]').onclick = async () => { + box.querySelector('[data-x="cancel"]').onclick = () => { this._open = null; this._render(); }; + box.querySelector('[data-x="save"]').onclick = async () => { const chosen = []; - box.querySelectorAll("input[data-cap]").forEach((cb) => { - if (cb.checked) chosen.push(caps[Number(cb.dataset.cap)]); - }); - try { - await this._service.setGrants(id, chosen, null); - this._grantsOpen = null; - await this._load(); - } catch (e) { - this._err = e.message || String(e); - this._render(); - } + box.querySelectorAll("input[data-cap]").forEach((cb) => { if (cb.checked) chosen.push(caps[Number(cb.dataset.cap)]); }); + await this._save(() => this._service.setGrants(id, chosen, null)); }; } + // --- config editor: schema-driven form, or raw-JSON fallback --- + _buildConfig(box, p) { + const id = p.record.id; + const cfg = p.record.config || {}; + const schema = p.manifest && p.manifest.ui && p.manifest.ui.settings; + const items = schema && Array.isArray(schema.items) ? schema.items : null; + + if (items) { + box.innerHTML = `
Settings
${items.map((it) => this._fieldHTML(it, cfg)).join("")} +
`; + box.querySelector('[data-x="cancel"]').onclick = () => { this._open = null; this._render(); }; + box.querySelector('[data-x="save"]').onclick = async () => { + const out = {}; + for (const it of items) out[it.key] = this._fieldValue(box, it); + await this._save(() => this._service.setConfig(id, out)); + }; + } else { + // No schema: a raw JSON editor so any plugin's config is still editable. + box.innerHTML = `
Settings (JSON)
+ +
`; + box.querySelector('[data-x="cancel"]').onclick = () => { this._open = null; this._render(); }; + box.querySelector('[data-x="save"]').onclick = async () => { + let parsed; + try { + parsed = JSON.parse(box.querySelector("[data-json]").value || "{}"); + } catch (e) { + this._err = "Invalid JSON: " + e.message; + this._render(); + return; + } + await this._save(() => this._service.setConfig(id, parsed)); + }; + } + } + + _fieldHTML(it, cfg) { + const v = cfg[it.key] ?? it.default ?? ""; + const label = esc(it.label || it.key); + let ctl; + if (it.type === "toggle") { + ctl = ``; + } else if (it.type === "select" && Array.isArray(it.options)) { + ctl = ``; + } else { + const type = it.type === "number" ? "number" : it.type === "password" ? "password" : "text"; + ctl = ``; + } + return `
${ctl}
${it.unit ? `${esc(it.unit)}` : ""}
`; + } + + _fieldValue(box, it) { + const el = box.querySelector(`[data-k="${cssEsc(it.key)}"]`); + if (!el) return it.default; + if (it.type === "toggle") return el.checked; + if (it.type === "number") return el.value === "" ? null : Number(el.value); + return el.value; + } + + async _save(fn) { + try { + await fn(); + this._open = null; + this._err = ""; + await this._load(); + } catch (e) { + this._err = e.message || String(e); + this._render(); + } + } + async _install(file) { if (!file) return; try { @@ -261,7 +349,7 @@ export class PluginsPanel extends HTMLElement { // structureSig is a signature of everything that requires a re-render (not status). function structureSig(plugins) { return JSON.stringify( - plugins.map((p) => [p.record.id, p.record.version, p.record.enabled, (p.record.grants || []).map((g) => g.cap)]), + plugins.map((p) => [p.record.id, p.record.version, p.record.enabled, (p.record.grants || []).map((g) => g.cap), p.record.config]), ); } @@ -269,7 +357,7 @@ function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } -// cssEsc escapes a plugin id for use inside an attribute selector. +// cssEsc escapes a value for use inside an attribute selector. function cssEsc(s) { return String(s).replace(/["\\]/g, "\\$&"); } diff --git a/web/src/plugins/settings-dialog.view.mjs b/web/src/plugins/settings-dialog.view.mjs index 68e2654..5e6f2de 100644 --- a/web/src/plugins/settings-dialog.view.mjs +++ b/web/src/plugins/settings-dialog.view.mjs @@ -18,7 +18,7 @@ import { esc } from "../lib/util.mjs"; export const STYLE = ` :host { display:block; } #body { padding-top:2px; } - .set-shell { display:flex; align-items:stretch; border:1px solid var(--ui-border-2); border-radius:11px; overflow:hidden; min-height:360px; max-height:min(66vh,620px); max-height:min(66dvh,620px); } + .set-shell { display:flex; align-items:stretch; border:1px solid var(--ui-border-2); border-radius:11px; overflow:hidden; min-height:420px; max-height:min(82vh,860px); max-height:min(82dvh,860px); } .set-rail { flex:0 0 124px; display:flex; flex-direction:column; gap:3px; padding:8px 7px; border-right:1px solid var(--ui-border-2); background:var(--ui-surface-2); } .set-rail button { text-align:left; border:none; background:none; color:var(--ui-text-dim); font:inherit; font-size:13px; font-weight:600; padding:9px 11px; border-radius:8px; cursor:pointer; transition:background .1s,color .1s; } .set-rail button:hover { background:var(--ui-surface); color:var(--ui-text); } From fc10051d6d443a5bdf71c4565cf49f51e94340d4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 16:55:34 +0000 Subject: [PATCH 08/33] docs: multi-page plugin documentation Nine focused, cross-linked Docusaurus pages under docs/docs/plugins/ (overview, getting-started, manifest, capabilities, sdk, protocol, ui, packaging, examples) plus a "Plugins" sidebar category. Grounded in the implemented source, with implemented vs. roadmap features clearly marked (signing, services.*, serial I/O, net.udp/http, http.register, detachable, native sandboxing are noted as roadmap). --- docs/docs/plugins/capabilities.md | 101 +++++++++ docs/docs/plugins/examples.md | 259 +++++++++++++++++++++++ docs/docs/plugins/getting-started.md | 174 ++++++++++++++++ docs/docs/plugins/manifest.md | 167 +++++++++++++++ docs/docs/plugins/overview.md | 115 +++++++++++ docs/docs/plugins/packaging.md | 202 ++++++++++++++++++ docs/docs/plugins/protocol.md | 182 ++++++++++++++++ docs/docs/plugins/sdk.md | 254 +++++++++++++++++++++++ docs/docs/plugins/ui.md | 298 +++++++++++++++++++++++++++ docs/sidebars.js | 15 ++ 10 files changed, 1767 insertions(+) create mode 100644 docs/docs/plugins/capabilities.md create mode 100644 docs/docs/plugins/examples.md create mode 100644 docs/docs/plugins/getting-started.md create mode 100644 docs/docs/plugins/manifest.md create mode 100644 docs/docs/plugins/overview.md create mode 100644 docs/docs/plugins/packaging.md create mode 100644 docs/docs/plugins/protocol.md create mode 100644 docs/docs/plugins/sdk.md create mode 100644 docs/docs/plugins/ui.md diff --git a/docs/docs/plugins/capabilities.md b/docs/docs/plugins/capabilities.md new file mode 100644 index 0000000..6aca185 --- /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 tcp-client 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..ba2169d --- /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 tcp-client NMEA source + +`plugins/core.tcp-client` is the reference plugin — the built-in tcp-client NMEA +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.tcp-client", + "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.tcp-client/plugin.wasm ./plugins/core.tcp-client + +# Run live against a local NMEA source; dev grants all declared caps +chartplotter plugin dev plugins/core.tcp-client --config '{"host":"127.0.0.1","port":10110}' +``` + +`dev` prints the effects as they happen: + +``` +status[core.tcp-client]: running connected to 127.0.0.1:10110 +raw[core.tcp-client]: $GPGGA,123519,4807.038,N,01131.000,E,… +vessel[core.tcp-client]: 3 delta(s) +ais[core.tcp-client]: 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..7bb20a3 --- /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 [tcp-client 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 tcp-client NMEA source, walked through. diff --git a/docs/docs/plugins/manifest.md b/docs/docs/plugins/manifest.md new file mode 100644 index 0000000..8a50ded --- /dev/null +++ b/docs/docs/plugins/manifest.md @@ -0,0 +1,167 @@ +--- +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. **Roadmap** (see below). | +| `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` / `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..ef5af25 --- /dev/null +++ b/docs/docs/plugins/sdk.md @@ -0,0 +1,254 @@ +--- +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) + } +} +``` + +## 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 to stderr; the host tags it with your plugin id and level. | + +```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 tcp-client plugin, walked through. diff --git a/docs/docs/plugins/ui.md b/docs/docs/plugins/ui.md new file mode 100644 index 0000000..7db05d9 --- /dev/null +++ b/docs/docs/plugins/ui.md @@ -0,0 +1,298 @@ +--- +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` + +| 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.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/sidebars.js b/docs/sidebars.js index a4dbf73..f53a3f2 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -10,6 +10,21 @@ const sidebars = { 'widget', 'cli', 'architecture', + { + 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/plugins-packaging', + 'plugins/plugins-examples', + ], + }, 'tile-schema', 'limitations', ], From 6c30aa43d2c7972f1423838d260c2d4c4987f7dd Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 17:25:06 +0000 Subject: [PATCH 09/33] refactor(plugin): name the reference plugin "NMEA 0183"; drop tier tag from UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename core.tcp-client → core.nmea0183 (dir, manifest id, name "NMEA 0183"); the plugin's identity is the NMEA 0183 source it provides, not the TCP transport it happens to use. Description now says what it does (reads GPS/ heading/speed/depth/wind + AIS), not that it's a WASM plugin. - Update the parity test, Makefile build-plugins target, and docs references. - plugins-panel: remove the per-row wasm/native/ui tier tag (implementation detail, not useful in the list). --- Makefile | 2 +- docs/docs/plugins/capabilities.md | 2 +- docs/docs/plugins/examples.md | 18 +++++++++--------- docs/docs/plugins/getting-started.md | 4 ++-- docs/docs/plugins/sdk.md | 2 +- internal/engine/plugin/parity_test.go | 12 ++++++------ .../{core.tcp-client => core.nmea0183}/main.go | 8 ++++---- .../plugin.json | 6 +++--- web/src/plugins/plugins-panel.mjs | 4 +--- 9 files changed, 28 insertions(+), 30 deletions(-) rename plugins/{core.tcp-client => core.nmea0183}/main.go (95%) rename plugins/{core.tcp-client => core.nmea0183}/plugin.json (70%) diff --git a/Makefile b/Makefile index ac46042..128dd66 100644 --- a/Makefile +++ b/Makefile @@ -125,7 +125,7 @@ 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.tcp-client +CORE_PLUGINS := core.nmea0183 build-plugins: ## Build the in-tree reference plugins to plugin.wasm (wasip1) @for p in $(CORE_PLUGINS); do \ echo "→ plugins/$$p/plugin.wasm (wasip1)"; \ diff --git a/docs/docs/plugins/capabilities.md b/docs/docs/plugins/capabilities.md index 6aca185..96ca43f 100644 --- a/docs/docs/plugins/capabilities.md +++ b/docs/docs/plugins/capabilities.md @@ -54,7 +54,7 @@ The `hosts` allowlist patterns support: - 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 tcp-client plugin scopes its dial to the configured host.) + how the reference NMEA 0183 plugin scopes its dial to the configured host.) An **empty** `hosts` list denies every connection. diff --git a/docs/docs/plugins/examples.md b/docs/docs/plugins/examples.md index ba2169d..1b15abe 100644 --- a/docs/docs/plugins/examples.md +++ b/docs/docs/plugins/examples.md @@ -10,9 +10,9 @@ 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 tcp-client NMEA source +## Backend (Tier A): the NMEA 0183 source -`plugins/core.tcp-client` is the reference plugin — the built-in tcp-client NMEA +`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 @@ -23,7 +23,7 @@ vessel/AIS/raw deltas back. Parity between the two is the acceptance test. ```jsonc { "manifestVersion": 1, - "id": "core.tcp-client", + "id": "core.nmea0183", "name": "TCP Client (NMEA0183)", "version": "1.0.0", "description": "Reads NMEA0183 from a TCP server and publishes vessel/AIS state.", @@ -145,19 +145,19 @@ unknown paths would be dropped by the host anyway. ```bash # Build to WASM (this is what `make build-plugins` runs) -GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o plugins/core.tcp-client/plugin.wasm ./plugins/core.tcp-client +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.tcp-client --config '{"host":"127.0.0.1","port":10110}' +chartplotter plugin dev plugins/core.nmea0183 --config '{"host":"127.0.0.1","port":10110}' ``` `dev` prints the effects as they happen: ``` -status[core.tcp-client]: running connected to 127.0.0.1:10110 -raw[core.tcp-client]: $GPGGA,123519,4807.038,N,01131.000,E,… -vessel[core.tcp-client]: 3 delta(s) -ais[core.tcp-client]: 1 target(s) +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 diff --git a/docs/docs/plugins/getting-started.md b/docs/docs/plugins/getting-started.md index 7bb20a3..63106a8 100644 --- a/docs/docs/plugins/getting-started.md +++ b/docs/docs/plugins/getting-started.md @@ -10,7 +10,7 @@ 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 [tcp-client example](./examples.md); this +For a full, realistic plugin see the [NMEA 0183 example](./examples.md); this page keeps the logic trivial so the mechanics are clear. ## Prerequisites @@ -171,4 +171,4 @@ the runner immediately. See [packaging & CLI](./packaging.md). - [SDK reference](./sdk.md) — every `Host` method with examples. - [Capabilities](./capabilities.md) — what each grant lets you do. -- [Examples](./examples.md) — the full tcp-client NMEA source, walked through. +- [Examples](./examples.md) — the full NMEA 0183 source, walked through. diff --git a/docs/docs/plugins/sdk.md b/docs/docs/plugins/sdk.md index ef5af25..5ec20d6 100644 --- a/docs/docs/plugins/sdk.md +++ b/docs/docs/plugins/sdk.md @@ -251,4 +251,4 @@ go build -o bin/-linux-amd64 ./plugins/ - [Getting started](./getting-started.md) — end-to-end build & run. - [Protocol](./protocol.md) — the wire methods the SDK wraps. -- [Examples](./examples.md) — the tcp-client plugin, walked through. +- [Examples](./examples.md) — the NMEA 0183 plugin, walked through. diff --git a/internal/engine/plugin/parity_test.go b/internal/engine/plugin/parity_test.go index 1aec18d..8f58bf1 100644 --- a/internal/engine/plugin/parity_test.go +++ b/internal/engine/plugin/parity_test.go @@ -18,7 +18,7 @@ import ( ) // parity_test.go is the Phase-1 acceptance test (spec §12): the reference -// core.tcp-client WASM plugin, driven end-to-end through the real Manager → broker → +// 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. @@ -100,14 +100,14 @@ func TestTCPClientPluginParity(t *testing.T) { // Lay out an installed, enabled plugin under a temp data dir. dataDir := t.TempDir() - verDir := filepath.Join(dataDir, "plugins", "core.tcp-client", "1.0.0") + 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.tcp-client","version":"1.0.0","enabled":true,` + + 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)) @@ -153,8 +153,8 @@ func TestTCPClientPluginParity(t *testing.T) { const testManifest = `{ "manifestVersion": 1, - "id": "core.tcp-client", - "name": "TCP Client (NMEA0183)", + "id": "core.nmea0183", + "name": "NMEA 0183", "version": "1.0.0", "apiVersion": 1, "entry": { "wasm": "plugin.wasm" }, @@ -171,7 +171,7 @@ func buildPluginWasm(t *testing.T) string { 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.tcp-client") + 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 { diff --git a/plugins/core.tcp-client/main.go b/plugins/core.nmea0183/main.go similarity index 95% rename from plugins/core.tcp-client/main.go rename to plugins/core.nmea0183/main.go index 8e0edd9..33d0947 100644 --- a/plugins/core.tcp-client/main.go +++ b/plugins/core.nmea0183/main.go @@ -1,11 +1,11 @@ -// Command core.tcp-client is the reference chartplotter plugin (spec §12, Phase 1 -// milestone): the built-in tcp-client NMEA source reimplemented as a Tier-A WASM -// plugin. It owns no I/O of its own — the host dials the socket (net.tcp-client +// 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.tcp-client +// Build (Tier A): GOOS=wasip1 GOARCH=wasm go build -o plugin.wasm ./plugins/core.nmea0183 package main import ( diff --git a/plugins/core.tcp-client/plugin.json b/plugins/core.nmea0183/plugin.json similarity index 70% rename from plugins/core.tcp-client/plugin.json rename to plugins/core.nmea0183/plugin.json index 879492f..153aed1 100644 --- a/plugins/core.tcp-client/plugin.json +++ b/plugins/core.nmea0183/plugin.json @@ -1,9 +1,9 @@ { "manifestVersion": 1, - "id": "core.tcp-client", - "name": "TCP Client (NMEA0183)", + "id": "core.nmea0183", + "name": "NMEA 0183", "version": "1.0.0", - "description": "Reads NMEA0183 sentences from a TCP server and publishes vessel/AIS state. The reference Tier-A (WASM) plugin; a drop-in for the built-in tcp-client source.", + "description": "Connects to a NMEA 0183 server over TCP and reads instrument sentences — GPS position, heading, speed, depth, wind — plus AIS targets, feeding them to the plotter as a data source.", "publisher": "beetlebug.org", "license": "MIT", "apiVersion": 1, diff --git a/web/src/plugins/plugins-panel.mjs b/web/src/plugins/plugins-panel.mjs index bd4c85e..37c6fe0 100644 --- a/web/src/plugins/plugins-panel.mjs +++ b/web/src/plugins/plugins-panel.mjs @@ -33,7 +33,6 @@ const STYLE = ` .info { flex: 1; min-width: 0; } .name { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .badge { font-size: 11px; font-weight: 500; color: var(--ui-text-dim, #8b949e); text-transform: uppercase; letter-spacing: .03em; } - .tag { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 10px; background: var(--ui-surface-2,#21262d); color: var(--ui-text-dim,#8b949e); text-transform: uppercase; letter-spacing:.03em; } .meta { color: var(--ui-text-dim, #8b949e); font-size: 12px; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .actions { display: flex; align-items: center; gap: 7px; flex: none; flex-wrap: wrap; justify-content: flex-end; } @@ -164,7 +163,6 @@ export class PluginsPanel extends HTMLElement { const name = man.name || id; const state = p.record.enabled ? (p.status && p.status.state) || "enabled" : "disabled"; const [color, label] = BADGE[state] || ["#6e7681", state]; - const tier = man.entry && man.entry.wasm ? "wasm" : man.entry && man.entry.native ? "native" : "ui"; const detail = (p.status && p.status.detail) || ""; const toggle = p.record.enabled ? "Disable" : "Enable"; const openHere = this._open && this._open.id === id; @@ -173,7 +171,7 @@ export class PluginsPanel extends HTMLElement {
-
${esc(name)} ${esc(label)} ${tier}
+
${esc(name)} ${esc(label)}
${esc(id)} · v${esc(man.version || p.record.version || "?")}${detail ? " · " + esc(detail) : ""}
From d1830ec68f2b737a990340029ce0398bc0e5a086 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 18:05:39 +0000 Subject: [PATCH 10/33] feat(plugin): serve.set + net.http capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serve.set / serve.clear: publish a blob at GET /plugins//serve/, host-served with Range + caching. The zero-RPC "baker, not live server" path for tile archives and weather grids — a sandboxed WASM plugin (no filesystem) can now produce served artifacts. - http.fetch: host-mediated outbound HTTP, allow-listed by the net.http grant and size-capped, for live data pulls. - SDK: ServeSet/ServeClear and Fetch (event-driven callbacks). --- internal/engine/plugin/capabilities.go | 118 +++++++++++++++++++++++++ internal/engine/plugin/protocol.go | 36 ++++++++ sdk/sdk.go | 54 ++++++++++- 3 files changed, 205 insertions(+), 3 deletions(-) diff --git a/internal/engine/plugin/capabilities.go b/internal/engine/plugin/capabilities.go index 9e861db..516e42f 100644 --- a/internal/engine/plugin/capabilities.go +++ b/internal/engine/plugin/capabilities.go @@ -1,18 +1,26 @@ 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 @@ -35,6 +43,10 @@ func (b *brokerSession) handleRequest(ctx context.Context, m *Message) { 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). @@ -311,6 +323,112 @@ func (b *brokerSession) handleStorage(m *Message) { } } +// --- 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 { diff --git a/internal/engine/plugin/protocol.go b/internal/engine/plugin/protocol.go index c432fff..7f85a65 100644 --- a/internal/engine/plugin/protocol.go +++ b/internal/engine/plugin/protocol.go @@ -112,6 +112,13 @@ const ( 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) --------------------------------------------------- @@ -255,6 +262,35 @@ 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. diff --git a/sdk/sdk.go b/sdk/sdk.go index e3d69cf..2f2b033 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -31,9 +31,10 @@ import ( // Re-exported wire types so plugin authors import only the SDK. type ( - Capability = proto.Capability - Delta = proto.Delta - AISTarget = proto.AISTargetDTO + 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, @@ -292,6 +293,53 @@ func (h *Host) StorageSet(key string, value json.RawMessage, cb func(error)) { }) } +// --- 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 HTTP request (subject to the net.http +// allowlist). cb receives the response (or an error). +func (h *Host) Fetch(url string, cb func(*HTTPResponse, error)) { + h.request(proto.MethodHTTPFetch, proto.HTTPFetch{URL: url}, 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 From 222254cd77ab26ea7c8a68ec4650a5faedc2a928 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 18:05:59 +0000 Subject: [PATCH 11/33] feat(plugin): GRIB weather plugin (wind streamlines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "grid, not tiles" model end to end: a Tier-A WASM plugin decodes a GRIB2 surface-wind field and publishes it as a compact grid artifact; the frontend animates it as wind streamlines client-side (the plugin is never in the frame path). - plugins/core.weather/grib: a spec-compliant GRIB2 codec for regular lat/lon grids with grid-point simple packing (templates 3.0/4.0/5.0), with a round-trip test. GFS complex packing (5.3) is a documented follow-up. - gen/: synthesizes a plausible mid-Atlantic wind field (background flow + a cyclonic low) and writes the embedded offline sample.grib2. - main.go: decodes the embedded sample (or a live GRIB via net.http) → the standard two-record wind JSON → serve.set. Offline by default. - ui/plugin.mjs: self-contained wind-particle overlay (canvas streamlines over the chart, coloured by speed, with a HUD show/hide toggle) using ctx.map. --- Makefile | 2 +- plugins/core.weather/gen/main.go | 67 +++++++++ plugins/core.weather/grib/encode.go | 141 ++++++++++++++++++ plugins/core.weather/grib/grib.go | 188 ++++++++++++++++++++++++ plugins/core.weather/grib/grib_test.go | 49 +++++++ plugins/core.weather/main.go | 140 ++++++++++++++++++ plugins/core.weather/plugin.json | 25 ++++ plugins/core.weather/sample.grib2 | Bin 0 -> 5114 bytes plugins/core.weather/ui/plugin.mjs | 196 +++++++++++++++++++++++++ 9 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 plugins/core.weather/gen/main.go create mode 100644 plugins/core.weather/grib/encode.go create mode 100644 plugins/core.weather/grib/grib.go create mode 100644 plugins/core.weather/grib/grib_test.go create mode 100644 plugins/core.weather/main.go create mode 100644 plugins/core.weather/plugin.json create mode 100644 plugins/core.weather/sample.grib2 create mode 100644 plugins/core.weather/ui/plugin.mjs diff --git a/Makefile b/Makefile index 128dd66..2f175cd 100644 --- a/Makefile +++ b/Makefile @@ -125,7 +125,7 @@ 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_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)"; \ diff --git a/plugins/core.weather/gen/main.go b/plugins/core.weather/gen/main.go new file mode 100644 index 0000000..686fccf --- /dev/null +++ b/plugins/core.weather/gen/main.go @@ -0,0 +1,67 @@ +// 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 + + u := make([]float64, nx*ny) + v := make([]float64, nx*ny) + // Cyclonic low centred offshore; strength falls off with distance. + clat, clon := 37.5, -73.0 + 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 + // Background westerly. + bu, bv := 8.0, 1.5 + // Vortex: tangential flow around (clat,clon), counter-clockwise. + ddx := (lon - clon) * math.Cos(lat*math.Pi/180) // scale lon by latitude + ddy := lat - clat + r := math.Hypot(ddx, ddy) + strength := 22 * math.Exp(-r*r/18) // m/s, Gaussian falloff + if r > 1e-6 { + // Perpendicular (counter-clockwise): (-ddy, ddx)/r. + u[i] = bu + strength*(-ddy/r) + v[i] = bv + strength*(ddx/r) + } else { + u[i], v[i] = bu, bv + } + } + } + + 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} + ug := base + ug.Category, ug.Number, ug.Values = 2, 2, u // UGRD (2/2) + vg := base + vg.Category, vg.Number, vg.Values = 2, 3, v // VGRD (2/3) + + data := append(grib.Encode(ug), grib.Encode(vg)...) + if err := os.WriteFile(*out, data, 0o644); err != nil { + panic(err) + } +} 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/grib.go b/plugins/core.weather/grib/grib.go new file mode 100644 index 0000000..c80994c --- /dev/null +++ b/plugins/core.weather/grib/grib.go @@ -0,0 +1,188 @@ +// 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. +type Grid struct { + Nx, Ny int // columns, rows + La1, Lo1, La2, Lo2 float64 // grid corners, degrees (La1/Lo1 = first point) + Dx, Dy float64 // increments, degrees + 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 +} + +// 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 refValue float32 + var binScale, decScale int + var bitsPerValue int + var numPoints int + 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) + tmpl := binary.BigEndian.Uint16(s[12:14]) + if tmpl != 0 { + return g, fmt.Errorf("unsupported grid template %d (want 3.0)", tmpl) + } + 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 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: // Data representation (template 5.0, simple packing) + numPoints = int(binary.BigEndian.Uint32(s[5:9])) + tmpl := binary.BigEndian.Uint16(s[9:11]) + if tmpl != 0 { + return g, fmt.Errorf("unsupported data-rep template %d (want 5.0 simple packing)", tmpl) + } + refValue = math.Float32frombits(binary.BigEndian.Uint32(s[11:15])) + binScale = signed16(s[15:17]) + decScale = signed16(s[17:19]) + bitsPerValue = int(s[19]) + case 7: // Data + g.Values = unpackSimple(s[5:], numPoints, float64(refValue), binScale, decScale, bitsPerValue) + } + p += secLen + } + 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/main.go b/plugins/core.weather/main.go new file mode 100644 index 0000000..03b2a21 --- /dev/null +++ b/plugins/core.weather/main.go @@ -0,0 +1,140 @@ +// Command core.weather is a GRIB weather plugin (Tier A, WASM). It decodes a GRIB2 +// surface-wind field into a compact grid and publishes it as a served artifact at +// GET /plugins/core.weather/serve/wind.json — 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"): +// - "sample" (default): an embedded offline GRIB2 sample, decoded on start. +// - a URL: fetched via the host (net.http) and decoded — for a live GRIB feed +// that uses grid-point simple packing. +// +// Build (Tier A): GOOS=wasip1 GOARCH=wasm go build -o plugin.wasm ./plugins/core.weather +package main + +import ( + _ "embed" + "encoding/json" + "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 } + +func (p *weather) Start(h *sdk.Host) { + p.h = h + src := h.ConfigString("source") + if src == "" || src == "sample" { + p.publish(sampleGRIB, "embedded sample") + return + } + // Live GRIB over the host-mediated, allow-listed net.http capability. + h.Status("running", "fetching "+src) + h.Fetch(src, func(resp *sdk.HTTPResponse, err error) { + if err != nil || resp == nil || resp.Status != 200 { + detail := "fetch failed" + if err != nil { + detail += ": " + err.Error() + } + h.Status("degraded", detail) + return + } + p.publish(resp.Body, src) + }) +} + +func (p *weather) Stop() {} + +// publish decodes GRIB2 wind (UGRD/VGRD) and serves it as the standard wind-particle +// JSON (a two-record "velocity" document the frontend layer consumes). +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 + } + 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 { + p.h.Status("degraded", "no UGRD/VGRD in GRIB") + return + } + doc := []record{recordOf(u, "U-component_of_wind", "eastward_wind"), recordOf(v, "V-component_of_wind", "northward_wind")} + body, _ := json.Marshal(doc) + p.h.ServeSet("wind.json", body, func(url string, err error) { + if err != nil { + p.h.Status("error", "publish: "+err.Error()) + return + } + p.h.Status("running", "wind field published ("+srcLabel+"), "+isize(u.Nx, u.Ny)) + }) +} + +// record is one field in the wind-particle JSON (the de-facto "velocity" format). +type record struct { + Header header `json:"header"` + Data []float64 `json:"data"` +} + +type header struct { + ParameterCategory int `json:"parameterCategory"` + ParameterNumber int `json:"parameterNumber"` + ParameterName string `json:"parameterNumberName"` + ParameterUnit string `json:"parameterUnit"` + 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"` + RefTime string `json:"refTime"` + ForecastTime int `json:"forecastTime"` +} + +func recordOf(g *grib.Grid, name, _ string) record { + return record{ + Header: header{ + ParameterCategory: g.Category, ParameterNumber: g.Number, ParameterName: name, ParameterUnit: "m.s-1", + Nx: g.Nx, Ny: g.Ny, Lo1: g.Lo1, La1: g.La1, Lo2: g.Lo2, La2: g.La2, Dx: g.Dx, Dy: g.Dy, + RefTime: g.RefTime.Format(time.RFC3339), ForecastTime: g.ForecastHour, + }, + Data: g.Values, + } +} + +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/plugin.json b/plugins/core.weather/plugin.json new file mode 100644 index 0000000..a57449b --- /dev/null +++ b/plugins/core.weather/plugin.json @@ -0,0 +1,25 @@ +{ + "manifestVersion": 1, + "id": "core.weather", + "name": "Wind (GRIB)", + "version": "1.0.0", + "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": "source", "type": "text", "label": "GRIB source", "placeholder": "sample", "default": "sample" } + ] + } + } +} diff --git a/plugins/core.weather/sample.grib2 b/plugins/core.weather/sample.grib2 new file mode 100644 index 0000000000000000000000000000000000000000..96d951fa223415be3f352f750eb3400fc7883879 GIT binary patch literal 5114 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 M?mdvocOCNg5248tQUCw| literal 0 HcmV?d00001 diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs new file mode 100644 index 0000000..75f2ffe --- /dev/null +++ b/plugins/core.weather/ui/plugin.mjs @@ -0,0 +1,196 @@ +// 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. + +const PARTICLES = 3000; +const MAX_AGE = 90; // frames before a particle respawns +const STEP = 0.35; // screen px per (m/s) per frame +const FADE = 0.94; // trail persistence (higher = longer tails) + +// Wind-speed colour ramp (m/s → colour), light→strong. +const RAMP = [ + [0, "#6ea8d8"], [5, "#78c07a"], [10, "#e8d24a"], + [15, "#e79a3c"], [22, "#dc5a3c"], [32, "#b03060"], +]; + +export default class WindOverlay { + constructor(ctx) { + this.ctx = ctx; + this._grid = 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); + // Panning/zooming reprojects every frame; clear the trail canvas so old trails + // don't smear at stale screen positions. + this._onMove = () => this._c2d && this._c2d.clearRect(0, 0, canvas.width, canvas.height); + map.on("movestart", this._onMove); + map.on("zoomstart", this._onMove); + + // A HUD toggle (also demonstrates per-layer show/hide, distinct from disabling + // the plugin — hiding stops the animation but keeps the data loaded). + const hud = ctx.hud.mount("wind"); + hud.innerHTML = `
`; + hud.getElementById("t").addEventListener("click", () => this._toggle(hud.getElementById("t"))); + + await this._loadGrid(); + this._seed(); + this._loop(); + } + + async _loadGrid() { + try { + const url = `${this.ctx.assets}plugins/${this.ctx.plugin.id}/serve/wind.json`; + const doc = await (await fetch(url, { cache: "no-store" })).json(); + const u = doc.find((r) => r.header.parameterNumber === 2); + const v = doc.find((r) => r.header.parameterNumber === 3); + if (!u || !v) return; + const h = u.header; + this._grid = { + nx: h.nx, ny: h.ny, lo1: h.lo1, la1: h.la1, dx: h.dx, dy: h.dy, + lo2: h.lo2, la2: h.la2, u: u.data, v: v.data, + }; + } catch (e) { + this.ctx.plugin.log("warn", "wind grid load failed", e); + } + } + + // Bilinear-sample the wind at (lng,lat); returns [u,v] or null if off-grid. + _sample(lng, lat) { + const g = this._grid; + if (!g) return null; + const fx = (lng - g.lo1) / g.dx; + const fy = (g.la1 - lat) / g.dy; // la1 is the north edge; rows go south + if (fx < 0 || fy < 0 || fx > g.nx - 1 || fy > g.ny - 1) return null; + 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; + 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; + return [bil(g.u), bil(g.v)]; + } + + _seed() { + this._particles = []; + for (let i = 0; i < PARTICLES; i++) this._particles.push(this._spawn()); + } + + _spawn() { + const g = this._grid; + if (!g) return { lng: 0, lat: 0, age: 9999 }; + return { + lng: g.lo1 + Math.random() * (g.lo2 - g.lo1), + lat: g.la2 + Math.random() * (g.la1 - g.la2), + age: Math.floor(Math.random() * MAX_AGE), + }; + } + + _loop() { + if (!this._on) return; + this._raf = requestAnimationFrame(() => this._loop()); + const c = this._c2d; + if (!c || !this._grid) return; + const W = this._cw, H = this._ch; + // Fade previous frame (leaves fading tails). + c.globalCompositeOperation = "destination-in"; + c.fillStyle = `rgba(0,0,0,${FADE})`; + c.fillRect(0, 0, W, H); + c.globalCompositeOperation = "source-over"; + c.lineWidth = 1.2; + + const map = this.ctx.map; + 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; + } + const a = map.project([p.lng, p.lat]); + // Move in screen space for zoom-independent visual speed; v is northward → up. + const nx = a.x + wind[0] * STEP; + const ny = a.y - wind[1] * STEP; + const b = map.unproject([nx, ny]); + const spd = Math.hypot(wind[0], wind[1]); + c.strokeStyle = rampColor(spd); + c.beginPath(); + c.moveTo(a.x, a.y); + c.lineTo(nx, ny); + c.stroke(); + p.lng = b.lng; + p.lat = b.lat; + p.age++; + } + } + + _toggle(btn) { + this._on = !this._on; + btn.classList.toggle("on", this._on); + this._canvas.style.display = this._on ? "" : "none"; + if (this._on) this._loop(); + else if (this._c2d) this._c2d.clearRect(0, 0, this._cw, this._ch); + } + + _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); + const map = this.ctx.map; + if (this._onResize) map.off("resize", this._onResize); + if (this._onMove) { + map.off("movestart", this._onMove); + map.off("zoomstart", this._onMove); + } + if (this._canvas) this._canvas.remove(); + } +} + +// rampColor maps a wind speed (m/s) to a colour along RAMP. +function rampColor(spd) { + 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; + } + } + return hi[1]; // stepped ramp is plenty for streamlines; avoids per-pixel lerp cost +} From 419efecb23fee2784e685bfbe63a7c7106ed39ce Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 18:05:59 +0000 Subject: [PATCH 12/33] feat(web): dynamically load installed-plugin UI + ctx.map escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PluginHost.start() discovers installed, enabled plugins that ship a UI (manifest ui.entry), dynamically imports each one's entry module from its archive (/plugins//ui/…), and keeps the set in sync via the plugins SSE (load on enable, unload on disable). This is how third-party UI plugins — like the weather overlay — actually load; builtins still register directly. - ctx.map: the raw MapLibre instance, the use-at-your-own-risk tier for controllers that need custom rendering (the wind-particle layer). The declarative handles remain the compatibility-promised surface; own-ship/AIS still don't use ctx.map. --- web/src/chartplotter.mjs | 3 ++ web/src/core/plugin-host.mjs | 77 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 9edbf75..5a37709 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -732,6 +732,7 @@ export class ChartPlotter extends HTMLElement { map, plotter: this._plotter, vessel: this._vessel, + assets: this._assets, aisStreamURL: this._assets + "api/ais/stream", aisPollURL: this._assets + "api/ais", chrome: this.shadowRoot, @@ -743,6 +744,8 @@ export class ChartPlotter extends HTMLElement { }); 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 / diff --git a/web/src/core/plugin-host.mjs b/web/src/core/plugin-host.mjs index 3f7871a..99f3ee6 100644 --- a/web/src/core/plugin-host.mjs +++ b/web/src/core/plugin-host.mjs @@ -33,6 +33,8 @@ export class PluginHost { this._layers = new PluginLayers({ map: services.map, plotter: services.plotter }); this._ais = new AISFeed(services.aisStreamURL, services.aisPollURL); this._loaded = new Map(); // id -> { controller, cleanups } + this._installed = new Set(); // ids loaded dynamically from installed archives + this._es = null; } // register loads a controller for a plugin: builds its ctx, instantiates the @@ -50,6 +52,68 @@ export class PluginHost { } } + // start discovers installed, enabled plugins that ship a UI (manifest ui.entry), + // dynamically imports each one's entry module from its archive, and keeps the set + // in sync as plugins are enabled/disabled (via the /api/plugins SSE). Builtins are + // registered separately by the shell before this runs. + start() { + this._syncInstalled(); + if (typeof EventSource !== "undefined") { + this._es = new EventSource(this._svc.assets + "api/plugins/stream"); + this._es.onmessage = (ev) => { + let d; + try { + d = JSON.parse(ev.data); + } catch { + return; + } + this._syncInstalled(d.plugins); + }; + this._es.onerror = () => {}; + } + } + + async _syncInstalled(list) { + let plugins = list; + if (!plugins) { + try { + plugins = (await (await fetch(this._svc.assets + "api/plugins", { cache: "no-store" })).json()).plugins || []; + } catch { + return; + } + } + const want = new Set(); + for (const p of plugins) { + const ui = p.manifest && p.manifest.ui; + if (p.record.enabled && ui && ui.entry) { + want.add(p.record.id); + this._loadInstalled(p.record.id, p.record.version, ui.entry); + } + } + // Unload any installed (non-builtin) plugin UI that is no longer enabled. + for (const id of [...this._installed]) { + if (!want.has(id)) { + this._installed.delete(id); + this.unregister(id); + } + } + } + + async _loadInstalled(id, version, entry) { + if (this._loaded.has(id) || this._installed.has(id)) return; + this._installed.add(id); + const rel = String(entry).replace(/^ui\//, ""); + const url = `${this._svc.assets}plugins/${encodeURIComponent(id)}/ui/${rel}`; + try { + const mod = await import(url); + if (mod && mod.default) await this.register({ id, version, ControllerClass: mod.default }); + else console.warn(`[plugin ${id}] ui entry has no default export`); + } catch (e) { + this._installed.delete(id); + console.warn(`[plugin ${id}] UI load failed`, e); + } + } + unregister(id) { const rec = this._loaded.get(id); if (!rec) return; @@ -70,6 +134,7 @@ export class PluginHost { } destroy() { + if (this._es) this._es.close(); for (const id of [...this._loaded.keys()]) this.unregister(id); this._ais.destroy(); this._layers.destroy(); @@ -112,6 +177,18 @@ export class PluginHost { add: (layerId, spec) => this._layers.add(id, layerId, spec), }, + // The raw MapLibre instance — the use-at-your-own-risk tier (spec §8, §13). + // The declarative handles (layers/markers/camera) cover the common cases and + // carry the compatibility promise; a controller that needs more (a custom + // WebGL/canvas overlay like the wind-particle layer) uses this and accepts the + // same contract the built-ins live with (handle palette/style re-adds, stay in + // its z-band). own-ship/AIS deliberately do NOT use it. + map, + + // App asset base, so a plugin can fetch its own served artifacts + // (GET /plugins//serve/…, same-origin under the app CSP). + assets: svc.assets || "/", + // DOM markers (rotated glyphs) without handing the plugin the raw map. The // controller owns marker teardown (own-ship removes its one marker; AIS its // per-target set) — the host does not auto-track them, since a busy AIS feed From 3749747f6513a21dc1bdff4570e34c005607b3bb Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 18:49:54 +0000 Subject: [PATCH 13/33] =?UTF-8?q?feat(web):=20Layers=20control=20=E2=80=94?= =?UTF-8?q?=20show/hide=20core=20+=20plugin=20overlays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LayerRegistry aggregates every map overlay with a persisted show/hide state, surfaced as a "Layers" settings tab. Distinct from enabling/disabling a plugin: hiding is visual only — the plugin keeps running and is told via an onVisible signal so it can pause expensive work (the wind animation) while staying loaded. - core/layer-registry.mjs: register/setVisible/list + localStorage persistence. - plugins/layers-panel(.mjs/-el.mjs): the Layers tab (grouped switches). - ctx.overlays.register (plugin-host) — plugins register overlays, namespaced. - plugin-layers: setVisible on the declarative handle (survives style rebuilds). - own-ship registers "Course & heading vectors"; AIS registers "AIS targets" (feed keeps running while hidden); weather registers "Wind streamlines" (replacing its one-off HUD toggle). --- plugins/core.weather/ui/plugin.mjs | 56 ++++++++++------- web/src/chartplotter.mjs | 7 +++ web/src/core/layer-registry.mjs | 94 +++++++++++++++++++++++++++++ web/src/core/plugin-host.mjs | 11 ++++ web/src/plugins/ais-overlay.mjs | 15 +++++ web/src/plugins/layers-panel-el.mjs | 81 +++++++++++++++++++++++++ web/src/plugins/layers-panel.mjs | 33 ++++++++++ web/src/plugins/own-ship.mjs | 12 ++++ web/src/plugins/plugin-layers.mjs | 12 +++- 9 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 web/src/core/layer-registry.mjs create mode 100644 web/src/plugins/layers-panel-el.mjs create mode 100644 web/src/plugins/layers-panel.mjs diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs index 75f2ffe..0c3220d 100644 --- a/plugins/core.weather/ui/plugin.mjs +++ b/plugins/core.weather/ui/plugin.mjs @@ -47,20 +47,20 @@ export default class WindOverlay { map.on("movestart", this._onMove); map.on("zoomstart", this._onMove); - // A HUD toggle (also demonstrates per-layer show/hide, distinct from disabling - // the plugin — hiding stops the animation but keeps the data loaded). - const hud = ctx.hud.mount("wind"); - hud.innerHTML = `
`; - hud.getElementById("t").addEventListener("click", () => this._toggle(hud.getElementById("t"))); + // Show/hide from the Layers control — hiding stops the animation but keeps the + // wind data loaded (the "visual only" contract, distinct from disabling the + // plugin). onVisible fires immediately with the persisted state. + ctx.overlays.register({ + id: "wind", + title: "Wind streamlines", + group: "Wind", + onVisible: (v) => this._setOn(v), + }); await this._loadGrid(); this._seed(); - this._loop(); + // 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 _loadGrid() { @@ -112,9 +112,29 @@ export default class WindOverlay { }; } - _loop() { - if (!this._on) return; - this._raf = requestAnimationFrame(() => this._loop()); + _setOn(on) { + this._on = on; + if (this._canvas) this._canvas.style.display = on ? "" : "none"; + 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) return; const W = this._cw, H = this._ch; @@ -149,14 +169,6 @@ export default class WindOverlay { } } - _toggle(btn) { - this._on = !this._on; - btn.classList.toggle("on", this._on); - this._canvas.style.display = this._on ? "" : "none"; - if (this._on) this._loop(); - else if (this._c2d) this._c2d.clearRect(0, 0, this._cw, this._ch); - } - _resize() { const map = this.ctx.map; const el = map.getContainer(); diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 5a37709..d9fe13d 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -31,6 +31,8 @@ import { ConnectionsController } from "./plugins/connections.mjs"; // NMEA0183 d 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 { 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 @@ -407,6 +409,10 @@ 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({ registry: this._settingsRegistry, layers: this._layerRegistry }); this._settingsDlg = this.shadowRoot.getElementById("settings-dlg"); if (this._settingsDlg) this._settingsDlg.configure({ registry: this._settingsRegistry }); @@ -741,6 +747,7 @@ export class ChartPlotter extends HTMLElement { registerZoomAnchor: (fn) => { this._zoomAnchors.add(fn); return () => this._zoomAnchors.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 }); diff --git a/web/src/core/layer-registry.mjs b/web/src/core/layer-registry.mjs new file mode 100644 index 0000000..bf0681c --- /dev/null +++ b/web/src/core/layer-registry.mjs @@ -0,0 +1,94 @@ +// layer-registry.mjs — the map-overlay visibility registry. +// +// Overlays (core and plugin) register here with a title and a setter; the Layers +// control renders the list with show/hide switches and persists each choice. This is +// deliberately distinct from enabling/disabling a plugin: hiding an overlay is purely +// visual — the plugin keeps running (AIS keeps tracking for CPA, an anchor watch stays +// armed), and the overlay is told about the change so it can pause expensive work +// (a weather particle animation) while staying loaded. + +const LS_KEY = "cp.layers.hidden"; // persisted set of hidden layer ids + +export class LayerRegistry { + constructor() { + this._layers = new Map(); // id -> { id, title, group, visible, apply } + this._listeners = new Set(); + this._hidden = loadHidden(); + } + + // register adds an overlay. desc: { id, title, group?, defaultVisible?, onVisible }. + // onVisible(visible) is called immediately with the resolved state and on each + // toggle. Returns an unregister function. + register(desc) { + if (!desc || !desc.id) throw new Error("layer needs an id"); + const visible = this._hidden.has(desc.id) ? false : desc.defaultVisible !== false; + const rec = { id: desc.id, title: desc.title || desc.id, group: desc.group || "Overlays", visible, apply: desc.onVisible }; + this._layers.set(desc.id, rec); + safeApply(rec, visible); + this._emit(); + return () => { + this._layers.delete(desc.id); + this._emit(); + }; + } + + setVisible(id, visible) { + const rec = this._layers.get(id); + if (!rec || rec.visible === visible) return; + rec.visible = visible; + if (visible) this._hidden.delete(id); + else this._hidden.add(id); + saveHidden(this._hidden); + safeApply(rec, visible); + this._emit(); + } + + toggle(id) { + const rec = this._layers.get(id); + if (rec) this.setVisible(id, !rec.visible); + } + + // list returns the registered overlays grouped, in registration order. + list() { + return [...this._layers.values()]; + } + + onChange(fn) { + this._listeners.add(fn); + return () => this._listeners.delete(fn); + } + + _emit() { + for (const fn of this._listeners) { + try { + fn(); + } catch (e) { + console.warn("[layers] listener", e); + } + } + } +} + +function safeApply(rec, visible) { + try { + rec.apply && rec.apply(visible); + } catch (e) { + console.warn(`[layers] ${rec.id} apply`, e); + } +} + +function loadHidden() { + try { + return new Set(JSON.parse(localStorage.getItem(LS_KEY) || "[]")); + } catch { + return new Set(); + } +} + +function saveHidden(set) { + try { + localStorage.setItem(LS_KEY, JSON.stringify([...set])); + } catch { + /* private mode / quota — visibility just won't persist */ + } +} diff --git a/web/src/core/plugin-host.mjs b/web/src/core/plugin-host.mjs index 99f3ee6..3ac1b36 100644 --- a/web/src/core/plugin-host.mjs +++ b/web/src/core/plugin-host.mjs @@ -177,6 +177,17 @@ export class PluginHost { add: (layerId, spec) => this._layers.add(id, layerId, spec), }, + // Register a show/hide-able overlay in the Layers control. desc: + // { id, title, group?, defaultVisible?, onVisible(visible) }. The id is + // namespaced by plugin; onVisible fires immediately + on each toggle, so the + // plugin can pause expensive work while hidden (spec §8, visibility signal). + overlays: { + register: (desc) => { + if (!svc.overlays) return () => {}; + return track(svc.overlays.register({ ...desc, id: `${id}:${desc.id}`, group: desc.group || (svc.pluginTitle && svc.pluginTitle(id)) || "Overlays" })); + }, + }, + // The raw MapLibre instance — the use-at-your-own-risk tier (spec §8, §13). // The declarative handles (layers/markers/camera) cover the common cases and // carry the compatibility promise; a controller that needs more (a custom diff --git a/web/src/plugins/ais-overlay.mjs b/web/src/plugins/ais-overlay.mjs index 87517f8..723d7b4 100644 --- a/web/src/plugins/ais-overlay.mjs +++ b/web/src/plugins/ais-overlay.mjs @@ -26,10 +26,24 @@ export default class AISOverlay { constructor(ctx) { this.ctx = ctx; this._markers = new Map(); // mmsi -> {marker, hasDir, danger, t} + this._visible = true; } start() { this._off = this.ctx.ais.subscribe((targets) => this._apply(targets || [])); + // Show/hide all AIS glyphs from the Layers control — the feed keeps running + // (CPA/collision tracking is unaffected), only the markers hide. + this.ctx.overlays.register({ + id: "targets", + title: "AIS targets", + group: "AIS", + onVisible: (v) => this._setVisible(v), + }); + } + + _setVisible(v) { + this._visible = v; + for (const rec of this._markers.values()) rec.marker.element.style.display = v ? "" : "none"; } _apply(targets) { @@ -54,6 +68,7 @@ export default class AISOverlay { if (!rec) { const marker = this.ctx.markers.add(`ais-${t.mmsi}`, { rotationAlignment: "map", anchor: "center" }); marker.setStyle(GLYPH_STYLE); + if (!this._visible) marker.element.style.display = "none"; // honor a hidden overlay marker.element.title = t.name || String(t.mmsi); marker.setLngLat([t.lon, t.lat]); rec = { marker, hasDir: undefined, danger: undefined, t }; diff --git a/web/src/plugins/layers-panel-el.mjs b/web/src/plugins/layers-panel-el.mjs new file mode 100644 index 0000000..648d62a --- /dev/null +++ b/web/src/plugins/layers-panel-el.mjs @@ -0,0 +1,81 @@ +// — the element rendered into the Layers settings tab. It lists the +// LayerRegistry's overlays grouped by source, each with a switch, and re-renders when +// the registry changes (an overlay registers/unregisters or is toggled elsewhere). + +const STYLE = ` + :host { display:block; color:var(--ui-text,#e6edf3); font-size:13px; } + .empty { color:var(--ui-text-dim,#8b949e); padding:18px 4px; text-align:center; } + .group { margin-bottom:14px; } + .group h4 { margin:0 0 8px; font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--ui-text-dim,#8b949e); } + .row { display:flex; align-items:center; gap:10px; padding:9px 11px; border:1px solid var(--ui-border,#30363d); + border-radius:9px; margin-bottom:8px; background:var(--ui-surface,#161b22); } + .row .t { flex:1; min-width:0; font-weight:600; } + .switch { position:relative; width:38px; height:22px; flex:none; } + .switch input { opacity:0; width:0; height:0; } + .sl { position:absolute; inset:0; background:var(--ui-surface-2,#30363d); border-radius:22px; cursor:pointer; transition:.15s; } + .sl:before { content:""; position:absolute; width:16px; height:16px; left:3px; top:3px; background:#fff; border-radius:50%; transition:.15s; box-shadow:0 1px 2px rgba(0,0,0,.3); } + input:checked + .sl { background:var(--ui-accent,#2f81f7); } + input:checked + .sl:before { transform:translateX(16px); } +`; + +export class LayersPanel extends HTMLElement { + constructor() { + super(); + if (!this.shadowRoot) this.attachShadow({ mode: "open" }); + this._layers = null; + this._off = null; + } + + configure({ layers }) { + this._layers = layers; + } + + connectedCallback() { + this.shadowRoot.innerHTML = `
`; + this._root = this.shadowRoot.getElementById("root"); + this._render(); + if (this._layers) this._off = this._layers.onChange(() => this._render()); + } + + disconnectedCallback() { + if (this._off) this._off(); + this._off = null; + } + + _render() { + const list = this._layers ? this._layers.list() : []; + if (!list.length) { + this._root.innerHTML = `
No map overlays yet.
`; + return; + } + // Group by `group`, preserving first-seen order. + const groups = []; + const byName = new Map(); + for (const l of list) { + if (!byName.has(l.group)) { + byName.set(l.group, { name: l.group, items: [] }); + groups.push(byName.get(l.group)); + } + byName.get(l.group).items.push(l); + } + this._root.innerHTML = groups + .map( + (g) => `

${esc(g.name)}

${g.items + .map( + (l) => `
${esc(l.title)} +
`, + ) + .join("")}
`, + ) + .join(""); + for (const cb of this._root.querySelectorAll("input[data-id]")) { + cb.addEventListener("change", () => this._layers.setVisible(cb.dataset.id, cb.checked)); + } + } +} + +function esc(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +} + +customElements.define("layers-panel", LayersPanel); diff --git a/web/src/plugins/layers-panel.mjs b/web/src/plugins/layers-panel.mjs new file mode 100644 index 0000000..f0e0d9f --- /dev/null +++ b/web/src/plugins/layers-panel.mjs @@ -0,0 +1,33 @@ +// + LayersController — the "Layers" settings tab. Lists every +// registered map overlay (core: own-ship vectors, AIS targets; plus plugin overlays +// like Wind) grouped by source, each with a show/hide switch. Hiding an overlay is +// visual only — the underlying plugin keeps running. Mounted into the settings dialog +// via the render(host) escape hatch, like Connections/Plugins. + +import "./layers-panel-el.mjs"; // defines + +export class LayersController { + constructor({ registry, layers }) { + this._layers = layers; + this._panel = null; + this._unregister = registry.register({ + id: "layers", + tab: { id: "layers", label: "Layers" }, + order: 3, + render: (host) => this._render(host), + }); + } + + _render(host) { + if (!this._panel) { + this._panel = document.createElement("layers-panel"); + this._panel.configure({ layers: this._layers }); + } + host.appendChild(this._panel); + } + + destroy() { + if (this._unregister) this._unregister(); + this._unregister = null; + } +} diff --git a/web/src/plugins/own-ship.mjs b/web/src/plugins/own-ship.mjs index 0a43e50..bf042b5 100644 --- a/web/src/plugins/own-ship.mjs +++ b/web/src/plugins/own-ship.mjs @@ -131,6 +131,18 @@ export default class OwnShip { // Wheel-zoom anchor: keep the vessel fixed while zooming when we're following. ctx.camera.registerFollowAnchor(() => (this._follow && this._fix) ? [this._fix.lng, this._fix.lat] : null); + // Course/heading vectors are a show/hide-able overlay in the Layers control + // (the boat glyph itself always shows — you never hide own-ship). + ctx.overlays.register({ + id: "vectors", + title: "Course & heading vectors", + group: "Own ship", + onVisible: (v) => { + this._predLayer.setVisible(v); + this._headLayer.setVisible(v); + }, + }); + // GPS-freshness watchdog (ages the fix independent of the feed cadence). this._gpsTimer = setInterval(() => this._tickGps(), 1000); diff --git a/web/src/plugins/plugin-layers.mjs b/web/src/plugins/plugin-layers.mjs index 43ac64b..229c87b 100644 --- a/web/src/plugins/plugin-layers.mjs +++ b/web/src/plugins/plugin-layers.mjs @@ -56,6 +56,14 @@ export class PluginLayers { const src = this._map.getSource(rec.srcId); if (src) src.setData(rec.data); }, + // Show/hide the layer(s) without removing them (drives the Layers control). + setVisible: (visible) => { + rec.hidden = !visible; + const v = visible ? "visible" : "none"; + for (const lid of rec.layerIds) { + if (this._map.getLayer(lid)) this._map.setLayoutProperty(lid, "visibility", v); + } + }, remove: () => this._remove(key), }; } @@ -73,8 +81,10 @@ export class PluginLayers { specs.forEach((ls, i) => { const lid = `${rec.key}-l${i}`; rec.layerIds.push(lid); + const layout = { ...(ls.layout || {}) }; + if (rec.hidden) layout.visibility = "none"; // preserve hidden state across style rebuilds this._plotter.addOverlayLayer( - { id: lid, type: ls.type, source: rec.srcId, layout: ls.layout || {}, paint: ls.paint || {} }, + { id: lid, type: ls.type, source: rec.srcId, layout, paint: ls.paint || {} }, { belowLabels: band.belowLabels }, ); }); From eb1b7f86aeab89effd9272a359cdac406dff950f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 18:54:07 +0000 Subject: [PATCH 14/33] feat(plugin): forecast time slider for the wind overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The weather plugin now decodes and publishes multiple forecast hours (grouped by GRIB2 forecast time) as a multi-step wind document. - The sample generator produces a 7-step series (0–48h) in which the cyclonic low drifts north-east and intensifies, so scrubbing shows the storm evolve. - The wind overlay adds a time scrubber that linearly interpolates the u/v field between the two bracketing steps and re-labels the forecast hour; hidden with the overlay. --- plugins/core.weather/gen/main.go | 67 +++++++++++---------- plugins/core.weather/main.go | 92 +++++++++++++++++------------ plugins/core.weather/sample.grib2 | Bin 5114 -> 35798 bytes plugins/core.weather/ui/plugin.mjs | 73 +++++++++++++++++++---- 4 files changed, 152 insertions(+), 80 deletions(-) diff --git a/plugins/core.weather/gen/main.go b/plugins/core.weather/gen/main.go index 686fccf..c270554 100644 --- a/plugins/core.weather/gen/main.go +++ b/plugins/core.weather/gen/main.go @@ -27,40 +27,45 @@ func main() { nx := int(math.Round((lo2-lo1)/dx)) + 1 ny := int(math.Round((la1-la2)/dy)) + 1 - u := make([]float64, nx*ny) - v := make([]float64, nx*ny) - // Cyclonic low centred offshore; strength falls off with distance. - clat, clon := 37.5, -73.0 - 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 - // Background westerly. - bu, bv := 8.0, 1.5 - // Vortex: tangential flow around (clat,clon), counter-clockwise. - ddx := (lon - clon) * math.Cos(lat*math.Pi/180) // scale lon by latitude - ddy := lat - clat - r := math.Hypot(ddx, ddy) - strength := 22 * math.Exp(-r*r/18) // m/s, Gaussian falloff - if r > 1e-6 { - // Perpendicular (counter-clockwise): (-ddy, ddx)/r. - u[i] = bu + strength*(-ddy/r) - v[i] = bv + strength*(ddx/r) - } else { - u[i], v[i] = bu, bv - } - } - } - 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} - ug := base - ug.Category, ug.Number, ug.Values = 2, 2, u // UGRD (2/2) - vg := base - vg.Category, vg.Number, vg.Values = 2, 3, v // VGRD (2/3) - data := append(grib.Encode(ug), grib.Encode(vg)...) + // 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/main.go b/plugins/core.weather/main.go index 03b2a21..413f0a0 100644 --- a/plugins/core.weather/main.go +++ b/plugins/core.weather/main.go @@ -15,6 +15,7 @@ package main import ( _ "embed" "encoding/json" + "sort" "time" "github.com/beetlebugorg/chartplotter/plugins/core.weather/grib" @@ -50,71 +51,86 @@ func (p *weather) Start(h *sdk.Host) { func (p *weather) Stop() {} -// publish decodes GRIB2 wind (UGRD/VGRD) and serves it as the standard wind-particle -// JSON (a two-record "velocity" document the frontend layer consumes). +// 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 } - var u, v *grib.Grid + // 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 { - u = g + if g.Category != 2 || (g.Number != 2 && g.Number != 3) { + continue } - if g.Category == 2 && g.Number == 3 { - v = g + 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 + } + if doc.Header == (gridHeader{}) { + g := e.u + doc.RefTime = g.RefTime.Format(time.RFC3339) + doc.Header = gridHeader{Nx: g.Nx, Ny: g.Ny, Lo1: g.Lo1, La1: g.La1, Lo2: g.Lo2, La2: g.La2, Dx: g.Dx, Dy: g.Dy} + } + doc.Steps = append(doc.Steps, step{Hour: hr, U: e.u.Values, V: e.v.Values}) } - if u == nil || v == nil { + if len(doc.Steps) == 0 { p.h.Status("degraded", "no UGRD/VGRD in GRIB") return } - doc := []record{recordOf(u, "U-component_of_wind", "eastward_wind"), recordOf(v, "V-component_of_wind", "northward_wind")} body, _ := json.Marshal(doc) p.h.ServeSet("wind.json", body, func(url string, err error) { if err != nil { p.h.Status("error", "publish: "+err.Error()) return } - p.h.Status("running", "wind field published ("+srcLabel+"), "+isize(u.Nx, u.Ny)) + p.h.Status("running", "wind published ("+srcLabel+"): "+isize(doc.Header.Nx, doc.Header.Ny)+", "+itoa(len(doc.Steps))+" step(s)") }) } -// record is one field in the wind-particle JSON (the de-facto "velocity" format). -type record struct { - Header header `json:"header"` - Data []float64 `json:"data"` +// 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 header struct { - ParameterCategory int `json:"parameterCategory"` - ParameterNumber int `json:"parameterNumber"` - ParameterName string `json:"parameterNumberName"` - ParameterUnit string `json:"parameterUnit"` - 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"` - RefTime string `json:"refTime"` - ForecastTime int `json:"forecastTime"` +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"` } -func recordOf(g *grib.Grid, name, _ string) record { - return record{ - Header: header{ - ParameterCategory: g.Category, ParameterNumber: g.Number, ParameterName: name, ParameterUnit: "m.s-1", - Nx: g.Nx, Ny: g.Ny, Lo1: g.Lo1, La1: g.La1, Lo2: g.Lo2, La2: g.La2, Dx: g.Dx, Dy: g.Dy, - RefTime: g.RefTime.Format(time.RFC3339), ForecastTime: g.ForecastHour, - }, - Data: g.Values, - } +type step struct { + Hour int `json:"hour"` + U []float64 `json:"u"` + V []float64 `json:"v"` } func isize(nx, ny int) string { return itoa(nx) + "×" + itoa(ny) } diff --git a/plugins/core.weather/sample.grib2 b/plugins/core.weather/sample.grib2 index 96d951fa223415be3f352f750eb3400fc7883879..0b234757ff06248e157c8ced42eec2511d4e1a41 100644 GIT binary patch literal 35798 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 M?mdvocOCNg5248tQUCw| diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs index 0c3220d..82b04af 100644 --- a/plugins/core.weather/ui/plugin.mjs +++ b/plugins/core.weather/ui/plugin.mjs @@ -23,6 +23,7 @@ export default class WindOverlay { constructor(ctx) { this.ctx = ctx; this._grid = null; + this._doc = null; this._particles = []; this._raf = 0; this._on = true; @@ -57,29 +58,47 @@ export default class WindOverlay { onVisible: (v) => this._setOn(v), }); - await this._loadGrid(); + this._mountSlider(); + await this._loadDoc(); this._seed(); // 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 _loadGrid() { + async _loadDoc() { try { const url = `${this.ctx.assets}plugins/${this.ctx.plugin.id}/serve/wind.json`; const doc = await (await fetch(url, { cache: "no-store" })).json(); - const u = doc.find((r) => r.header.parameterNumber === 2); - const v = doc.find((r) => r.header.parameterNumber === 3); - if (!u || !v) return; - const h = u.header; - this._grid = { - nx: h.nx, ny: h.ny, lo1: h.lo1, la1: h.la1, dx: h.dx, dy: h.dy, - lo2: h.lo2, la2: h.la2, u: u.data, v: v.data, - }; + if (!doc.header || !doc.steps || !doc.steps.length) return; + this._doc = doc; + this._setStep(0); // build the initial grid from the first forecast step + this._buildSlider(); } catch (e) { - this.ctx.plugin.log("warn", "wind grid load failed", e); + this.ctx.plugin.log("warn", "wind doc load failed", e); } } + // _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._hour = Math.round(s0.hour * (1 - t) + s1.hour * t); + if (this._label) this._label.textContent = "+" + this._hour + "h"; + } + // Bilinear-sample the wind at (lng,lat); returns [u,v] or null if off-grid. _sample(lng, lat) { const g = this._grid; @@ -112,9 +131,41 @@ export default class WindOverlay { }; } + // _mountSlider builds the forecast time scrubber (hidden until data loads / the + // overlay is shown). Mounted in the shell chrome; theme vars inherit. + _mountSlider() { + const hud = this.ctx.hud.mount("wind-time"); + hud.innerHTML = `

🌬+0h
`; + this._sliderWrap = hud.querySelector(".wt"); + this._slider = hud.querySelector("input"); + this._label = hud.querySelector(".lbl"); + this._slider.addEventListener("input", () => this._setStep(Number(this._slider.value) / 10)); + } + + _buildSlider() { + if (!this._slider || !this._doc) return; + this._slider.max = String((this._doc.steps.length - 1) * 10); // ×10 for smooth interpolation + this._slider.value = "0"; + this._syncSlider(); + } + + _syncSlider() { + if (this._sliderWrap) { + this._sliderWrap.style.display = this._on && this._doc && this._doc.steps.length > 1 ? "flex" : "none"; + } + } + _setOn(on) { this._on = on; if (this._canvas) this._canvas.style.display = on ? "" : "none"; + this._syncSlider(); if (on) this._start(); else this._stop(); } From b87f1e276b44244be3fed1a2d52fd4298afbcad4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 19:05:13 +0000 Subject: [PATCH 15/33] feat(plugin): decode complex-packed GRIB2 (real GFS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GRIB2 data-representation templates 5.2 (complex packing) and 5.3 (complex packing + spatial differencing) — the packing GFS uses — so the weather plugin can decode real forecasts, not just the simple-packed sample. The decoder follows NCEP g2clib's comunpack: read the spatial-difference extras, the per-group reference/width/length metadata (byte-aligned between subsections — the NCEP encoder aligns them; without it the second-order integration runs away), the packed group data, then undo the differencing and apply the reference/scale. Validated in CI against a real GFS 1° UGRD/VGRD field byte-range-fetched from the NOAA open-data archive (testdata/gfs_1deg_uv.grib2): decodes to plausible wind (−26..33 m/s) at the correct 360×181 grid. --- plugins/core.weather/grib/complex.go | 160 ++++++++++++++++++ plugins/core.weather/grib/gfs_test.go | 50 ++++++ plugins/core.weather/grib/grib.go | 39 +++-- .../grib/testdata/gfs_1deg_uv.grib2 | Bin 0 -> 157218 bytes 4 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 plugins/core.weather/grib/complex.go create mode 100644 plugins/core.weather/grib/gfs_test.go create mode 100644 plugins/core.weather/grib/testdata/gfs_1deg_uv.grib2 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/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 index c80994c..c9c6050 100644 --- a/plugins/core.weather/grib/grib.go +++ b/plugins/core.weather/grib/grib.go @@ -59,10 +59,8 @@ func Decode(b []byte) ([]Grid, error) { func decodeMessage(b []byte) (Grid, error) { var g Grid - var refValue float32 - var binScale, decScale int - var bitsPerValue int - var numPoints int + var s5, s7 []byte // data-representation + data sections, decoded after the walk + numPoints := 0 p := 16 // past Section 0 for p+5 <= len(b) { @@ -100,21 +98,32 @@ func decodeMessage(b []byte) (Grid, error) { g.Category = int(s[9]) g.Number = int(s[10]) g.ForecastHour = int(binary.BigEndian.Uint32(s[18:22])) - case 5: // Data representation (template 5.0, simple packing) + case 5: numPoints = int(binary.BigEndian.Uint32(s[5:9])) - tmpl := binary.BigEndian.Uint16(s[9:11]) - if tmpl != 0 { - return g, fmt.Errorf("unsupported data-rep template %d (want 5.0 simple packing)", tmpl) - } - refValue = math.Float32frombits(binary.BigEndian.Uint32(s[11:15])) - binScale = signed16(s[15:17]) - decScale = signed16(s[17:19]) - bitsPerValue = int(s[19]) - case 7: // Data - g.Values = unpackSimple(s[5:], numPoints, float64(refValue), binScale, decScale, bitsPerValue) + 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") } 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 0000000000000000000000000000000000000000..a9daa48926fed37e6d9c1d20e83e38f8ff20fe0f GIT binary patch literal 157218 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

qQDlPH?B} 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 URal8th?W^lVpxe{CO0=XH)w%AdH?_b literal 0 HcmV?d00001 From f1ef2020f2392afe799e7ad2d455cfd1e05584c1 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 19:11:27 +0000 Subject: [PATCH 16/33] feat(plugin): fetch live GFS (byte-range subset) + fit the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The weather plugin now pulls live GFS: given a GFS product URL it fetches the wgrib2 .idx, finds the 10 m UGRD/VGRD records, and byte-ranges just those messages (a few MB) instead of the whole multi-hundred-MB file. SDK gains FetchOpts (request headers) for the Range request. - A global 0.25° field is ~2M points (~30 MB JSON) — over the 16 MiB NDJSON line limit and far finer than streamlines need — so the plugin downsamples oversized grids by an integer stride (regional fields pass through). 0.25° GFS lands as a 480×241 field. - The particle layer now spawns in the current viewport, so a global field is visible wherever the chart is, not scattered across the planet. --- plugins/core.weather/main.go | 132 ++++++++++++++++++++++++++--- plugins/core.weather/ui/plugin.mjs | 10 ++- sdk/sdk.go | 12 ++- 3 files changed, 135 insertions(+), 19 deletions(-) diff --git a/plugins/core.weather/main.go b/plugins/core.weather/main.go index 413f0a0..2f46c64 100644 --- a/plugins/core.weather/main.go +++ b/plugins/core.weather/main.go @@ -15,7 +15,11 @@ package main import ( _ "embed" "encoding/json" + "fmt" + "math" "sort" + "strconv" + "strings" "time" "github.com/beetlebugorg/chartplotter/plugins/core.weather/grib" @@ -36,19 +40,94 @@ func (p *weather) Start(h *sdk.Host) { } // Live GRIB over the host-mediated, allow-listed net.http capability. h.Status("running", "fetching "+src) - h.Fetch(src, func(resp *sdk.HTTPResponse, err error) { + if strings.Contains(src, "pgrb2") { // a GFS product: byte-range just the 10 m wind + p.fetchGFS(src) + return + } + h.Fetch(src, func(resp *sdk.HTTPResponse, err error) { p.onFetch(resp, err, src) }) +} + +// 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. +func (p *weather) fetchGFS(url string) { + p.h.Fetch(url+".idx", func(resp *sdk.HTTPResponse, err error) { if err != nil || resp == nil || resp.Status != 200 { - detail := "fetch failed" - if err != nil { - detail += ": " + err.Error() - } - h.Status("degraded", detail) + p.h.Status("degraded", "GFS index fetch failed") return } - p.publish(resp.Body, src) + start, end, ok := windRange(string(resp.Body)) + if !ok { + p.h.Status("degraded", "no 10 m wind in GFS index") + return + } + rng := fmt.Sprintf("bytes=%d-%d", start, end) + p.h.FetchOpts(url, map[string]string{"Range": rng}, func(r *sdk.HTTPResponse, e error) { p.onFetch(r, e, "GFS") }) }) } +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) +} + +// 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) { + type rec struct { + off int + field, level string + } + var recs []rec + for _, ln := range strings.Split(idx, "\n") { + f := strings.Split(ln, ":") + if len(f) < 5 { + continue + } + off, e := strconv.Atoi(f[1]) + if e != nil { + continue + } + recs = append(recs, rec{off, f[3], f[4]}) + } + uStart, vStart, vNext := -1, -1, -1 + for i, r := range recs { + if r.level != "10 m above ground" { + continue + } + if r.field == "UGRD" { + uStart = r.off + } + if r.field == "VGRD" { + vStart = r.off + if i+1 < len(recs) { + vNext = recs[i+1].off + } + } + } + if uStart < 0 || vStart < 0 { + return 0, 0, false + } + start = uStart + if vStart < start { + start = vStart + } + end = vNext - 1 + if vNext <= 0 { // VGRD is the last record: read a generous window + end = start + (1 << 24) + } + return start, end, true +} + func (p *weather) Stop() {} // publish decodes GRIB2 wind (UGRD/VGRD, possibly several forecast hours) and serves @@ -88,12 +167,15 @@ func (p *weather) publish(gribBytes []byte, srcLabel string) { if e.u == nil || e.v == nil { continue } + // A global 0.25° field is ~2M points (~30 MB JSON) — too big for the wire and + // far finer than streamlines need. Downsample oversized grids to a sane size; + // regional fields pass through untouched. + h, u, v := capGrid(e.u, e.v, maxGridPoints) if doc.Header == (gridHeader{}) { - g := e.u - doc.RefTime = g.RefTime.Format(time.RFC3339) - doc.Header = gridHeader{Nx: g.Nx, Ny: g.Ny, Lo1: g.Lo1, La1: g.La1, Lo2: g.Lo2, La2: g.La2, Dx: g.Dx, Dy: g.Dy} + doc.RefTime = e.u.RefTime.Format(time.RFC3339) + doc.Header = h } - doc.Steps = append(doc.Steps, step{Hour: hr, U: e.u.Values, V: e.v.Values}) + 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") @@ -133,6 +215,34 @@ type step struct { V []float64 `json:"v"` } +// maxGridPoints caps a published field so its JSON stays well under the 16 MiB wire +// line limit (each value is ~8 JSON bytes; ~200k points → ~3 MB per step). +const maxGridPoints = 200000 + +// 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 { diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs index 82b04af..b24df96 100644 --- a/plugins/core.weather/ui/plugin.mjs +++ b/plugins/core.weather/ui/plugin.mjs @@ -122,11 +122,13 @@ export default class WindOverlay { } _spawn() { - const g = this._grid; - if (!g) return { lng: 0, lat: 0, age: 9999 }; + 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: g.lo1 + Math.random() * (g.lo2 - g.lo1), - lat: g.la2 + Math.random() * (g.la1 - g.la2), + 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), }; } diff --git a/sdk/sdk.go b/sdk/sdk.go index 2f2b033..19bb9ba 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -320,10 +320,14 @@ func (h *Host) ServeClear(name string) { h.request(proto.MethodServeClear, proto.ServeClear{Name: name}, nil) } -// Fetch makes a host-mediated outbound HTTP request (subject to the net.http -// allowlist). cb receives the response (or an error). -func (h *Host) Fetch(url string, cb func(*HTTPResponse, error)) { - h.request(proto.MethodHTTPFetch, proto.HTTPFetch{URL: url}, func(res json.RawMessage, rerr *proto.RPCError) { +// 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 } From 0d929a4d6a047a6eddea504abd10767477bc84b8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 19:15:04 +0000 Subject: [PATCH 17/33] fix(web): plugin UI now actually loads + global-longitude wind sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dynamic import() of an installed plugin's UI resolved relative to /src/core/plugin-host.mjs (→ 404 /src/core/plugins//ui/…), so no plugin UI ever loaded. Resolve the archive URL against document.baseURI instead. This is why the wind overlay showed nothing. - The wind sampler assumed −180..180 longitude; GFS grids are 0–360, so it never found wind over the US. Wrap longitude for global grids (and wrap the bilinear x-neighbour across the antimeridian). --- plugins/core.weather/ui/plugin.mjs | 13 +++++++++---- web/src/core/plugin-host.mjs | 4 +++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs index b24df96..e732a81 100644 --- a/plugins/core.weather/ui/plugin.mjs +++ b/plugins/core.weather/ui/plugin.mjs @@ -99,15 +99,20 @@ export default class WindOverlay { if (this._label) this._label.textContent = "+" + this._hour + "h"; } - // Bilinear-sample the wind at (lng,lat); returns [u,v] or null if off-grid. + // Bilinear-sample the wind at (lng,lat); returns [u,v] or null if off-grid. Handles + // global grids expressed in 0–360° longitude (GFS) as well as −180..180. _sample(lng, lat) { const g = this._grid; if (!g) return null; - const fx = (lng - g.lo1) / g.dx; + 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 (fx < 0 || fy < 0 || fx > g.nx - 1 || fy > g.ny - 1) return null; + 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 = Math.min(x0 + 1, g.nx - 1), y1 = Math.min(y0 + 1, g.ny - 1); + 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) => diff --git a/web/src/core/plugin-host.mjs b/web/src/core/plugin-host.mjs index 3ac1b36..9cb1b91 100644 --- a/web/src/core/plugin-host.mjs +++ b/web/src/core/plugin-host.mjs @@ -103,7 +103,9 @@ export class PluginHost { if (this._loaded.has(id) || this._installed.has(id)) return; this._installed.add(id); const rel = String(entry).replace(/^ui\//, ""); - const url = `${this._svc.assets}plugins/${encodeURIComponent(id)}/ui/${rel}`; + // Resolve against the document base, not this module's URL: a dynamic import() + // otherwise resolves relative to /src/core/plugin-host.mjs and 404s. + const url = new URL(`${this._svc.assets || ""}plugins/${encodeURIComponent(id)}/ui/${rel}`, document.baseURI).href; try { const mod = await import(url); if (mod && mod.default) await this.register({ id, version, ControllerClass: mod.default }); From b1a38f84cbb67e7819361eab34207272b98a0528 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 19:32:32 +0000 Subject: [PATCH 18/33] feat(plugin): binary wind grid + readout, on-map toggle, contrast, no flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round of weather-overlay fixes from live testing: - Binary grid, not JSON: publish the wind field as a compact aligned Float32 blob (~8× smaller), so a full-resolution field fits the 16 MiB wire line without the aggressive downsampling JSON forced. The frontend zero-copies the Float32 arrays. - Real data, not just a visual: a live readout of the actual wind at the vessel (speed in the mariner's units + the compass direction it blows FROM, with an arrow), combined into one on-map control that is also the enable/disable toggle — kept in sync with the Layers control via the shared registry (ctx.overlays now returns a handle). - No more flicker: only clear the trail canvas on ZOOM, not on pan — the follow-camera eases the map every fix, and clearing there blanked the whole field ~1×/s (the "all at once" flicker). Re-projection tracks pans; the fade cleans smear. - Contrast: draw each streamline with a dark casing under a bright core so it reads on both light (day) and dark (night) charts. --- plugins/core.weather/main.go | 49 +++++++- plugins/core.weather/ui/plugin.mjs | 185 ++++++++++++++++++++++++----- web/src/core/plugin-host.mjs | 13 +- 3 files changed, 206 insertions(+), 41 deletions(-) diff --git a/plugins/core.weather/main.go b/plugins/core.weather/main.go index 2f46c64..6cb1d0f 100644 --- a/plugins/core.weather/main.go +++ b/plugins/core.weather/main.go @@ -14,7 +14,6 @@ package main import ( _ "embed" - "encoding/json" "fmt" "math" "sort" @@ -181,8 +180,11 @@ func (p *weather) publish(gribBytes []byte, srcLabel string) { p.h.Status("degraded", "no UGRD/VGRD in GRIB") return } - body, _ := json.Marshal(doc) - p.h.ServeSet("wind.json", body, func(url string, err error) { + // 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 @@ -191,6 +193,39 @@ func (p *weather) publish(gribBytes []byte, srcLabel string) { }) } +// 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 +// per step: hour i32 | u[nx*ny] f32 | v[nx*ny] f32 +func encodeWindBin(d *windDoc) []byte { + h := d.Header + np := h.Nx * h.Ny + out := make([]byte, 0, 36+len(d.Steps)*(4+np*8)) + put32 := func(v uint32) { out = append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)) } + putF := func(f float64) { put32(math.Float32bits(float32(f))) } + out = append(out, 'W', 'G', 'R', 'D') + put32(1) + 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) + for _, s := range d.Steps { + put32(uint32(int32(s.Hour))) + for _, x := range s.U { + putF(x) + } + for _, x := range s.V { + 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"` @@ -215,9 +250,11 @@ type step struct { V []float64 `json:"v"` } -// maxGridPoints caps a published field so its JSON stays well under the 16 MiB wire -// line limit (each value is ~8 JSON bytes; ~200k points → ~3 MB per step). -const maxGridPoints = 200000 +// 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 // 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 diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs index e732a81..8ab33f7 100644 --- a/plugins/core.weather/ui/plugin.mjs +++ b/plugins/core.weather/ui/plugin.mjs @@ -8,15 +8,19 @@ // uses ctx.map (accepting the same contract the built-ins live with) rather than the // declarative layers API. -const PARTICLES = 3000; -const MAX_AGE = 90; // frames before a particle respawns -const STEP = 0.35; // screen px per (m/s) per frame -const FADE = 0.94; // trail persistence (higher = longer tails) +const PARTICLES = 2600; +const MAX_AGE = 130; // frames before a particle respawns (longer → longer streamlines) +const STEP = 0.16; // screen px per (m/s) per frame (lower → slower, calmer motion) +const FADE = 0.96; // trail persistence (higher = longer tails) +const LINE_WIDTH = 2.1; -// Wind-speed colour ramp (m/s → colour), light→strong. +// 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, "#6ea8d8"], [5, "#78c07a"], [10, "#e8d24a"], - [15, "#e79a3c"], [22, "#dc5a3c"], [32, "#b03060"], + [0, "#5b9bd5"], [4, "#4fb0c6"], [8, "#5ec4a0"], + [12, "#8ecf6a"], [16, "#e6c84b"], [21, "#ef9a3d"], + [27, "#e5623c"], [34, "#c23b6b"], [45, "#8e3ca0"], ]; export default class WindOverlay { @@ -42,16 +46,17 @@ export default class WindOverlay { this._resize(); this._onResize = () => this._resize(); map.on("resize", this._onResize); - // Panning/zooming reprojects every frame; clear the trail canvas so old trails - // don't smear at stale screen positions. - this._onMove = () => this._c2d && this._c2d.clearRect(0, 0, canvas.width, canvas.height); - map.on("movestart", this._onMove); - map.on("zoomstart", this._onMove); + // Only clear on ZOOM (the projection scale changes, so trails would stretch). + // Do NOT clear on pan: the follow camera eases the map on every fix, and clearing + // there made the whole field flicker each time — re-projecting per frame already + // tracks a pan, and the trail fade cleans up any brief smear. + this._onZoom = () => this._c2d && this._c2d.clearRect(0, 0, this._cw, this._ch); + map.on("zoomstart", this._onZoom); - // Show/hide from the Layers control — hiding stops the animation but keeps the - // wind data loaded (the "visual only" contract, distinct from disabling the - // plugin). onVisible fires immediately with the persisted state. - ctx.overlays.register({ + // Show/hide from the Layers control AND the on-map control below — 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", @@ -59,6 +64,7 @@ export default class WindOverlay { }); this._mountSlider(); + this._mountControl(); await this._loadDoc(); this._seed(); // The animation is driven by _setOn (from the overlay's persisted state, @@ -67,14 +73,15 @@ export default class WindOverlay { async _loadDoc() { try { - const url = `${this.ctx.assets}plugins/${this.ctx.plugin.id}/serve/wind.json`; - const doc = await (await fetch(url, { cache: "no-store" })).json(); - if (!doc.header || !doc.steps || !doc.steps.length) return; + const url = `${this.ctx.assets}plugins/${this.ctx.plugin.id}/serve/wind.bin`; + const buf = await (await fetch(url, { cache: "no-store" })).arrayBuffer(); + const doc = parseWindBin(buf); + if (!doc) return; this._doc = doc; this._setStep(0); // build the initial grid from the first forecast step this._buildSlider(); } catch (e) { - this.ctx.plugin.log("warn", "wind doc load failed", e); + this.ctx.plugin.log("warn", "wind grid load failed", e); } } @@ -97,6 +104,7 @@ export default class WindOverlay { 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._hour = Math.round(s0.hour * (1 - t) + s1.hour * t); if (this._label) this._label.textContent = "+" + this._hour + "h"; + this._updateReadout(); // wind at the vessel changes with the forecast step } // Bilinear-sample the wind at (lng,lat); returns [u,v] or null if off-grid. Handles @@ -169,10 +177,58 @@ export default class WindOverlay { } } + // One on-map control that is both the enable/disable toggle AND the live wind + // readout: click to turn the overlay on/off; while on it shows the actual wind at + // the vessel (speed in the mariner's units + compass direction it blows FROM, with + // an arrow pointing where it blows). Kept in sync with the Layers control via the + // shared registry. + _mountControl() { + const hud = this.ctx.hud.mount("wind-control"); + hud.innerHTML = `

`; + this._ctl = hud.getElementById("c"); + this._ctlVal = hud.getElementById("v"); + this._ctlArrow = hud.getElementById("ar"); + this._ctl.addEventListener("click", () => this._layer.toggle()); + this.ctx.vessel.subscribe(() => this._updateReadout()); + } + + _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; + this._ctlArrow.style.transform = `rotate(${from}deg)`; // ↓ (south) rotated to blow-toward + this._ctlVal.textContent = `${this.ctx.units.format("wind", spdKn)} ${Math.round(from)}° ${compass(from)}`; + } + + _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 (on) this._start(); else this._stop(); } @@ -201,9 +257,13 @@ export default class WindOverlay { c.fillStyle = `rgba(0,0,0,${FADE})`; c.fillRect(0, 0, W, H); c.globalCompositeOperation = "source-over"; - c.lineWidth = 1.2; + c.lineCap = "round"; const map = this.ctx.map; + // Pass 1: advect every particle and collect its screen segment (flat array of + // x0,y0,x1,y1,speed to avoid per-frame allocations). + const segs = this._segs || (this._segs = []); + segs.length = 0; for (const p of this._particles) { const wind = this._sample(p.lng, p.lat); if (!wind || p.age > MAX_AGE) { @@ -215,16 +275,29 @@ export default class WindOverlay { const nx = a.x + wind[0] * STEP; const ny = a.y - wind[1] * STEP; const b = map.unproject([nx, ny]); - const spd = Math.hypot(wind[0], wind[1]); - c.strokeStyle = rampColor(spd); - c.beginPath(); - c.moveTo(a.x, a.y); - c.lineTo(nx, ny); - c.stroke(); + segs.push(a.x, a.y, nx, ny, Math.hypot(wind[0], wind[1])); p.lng = b.lng; p.lat = b.lat; p.age++; } + // Pass 2a: a dark casing (one batched stroke) so streamlines read on LIGHT charts. + c.strokeStyle = "rgba(0,0,0,0.5)"; + c.lineWidth = LINE_WIDTH + 2; + c.beginPath(); + for (let i = 0; i < segs.length; i += 5) { + c.moveTo(segs[i], segs[i + 1]); + c.lineTo(segs[i + 2], segs[i + 3]); + } + c.stroke(); + // Pass 2b: bright coloured cores (visible on DARK charts) on top. + c.lineWidth = LINE_WIDTH; + for (let i = 0; i < segs.length; i += 5) { + c.strokeStyle = rampColor(segs[i + 4]); + c.beginPath(); + c.moveTo(segs[i], segs[i + 1]); + c.lineTo(segs[i + 2], segs[i + 3]); + c.stroke(); + } } _resize() { @@ -244,16 +317,19 @@ export default class WindOverlay { if (this._raf) cancelAnimationFrame(this._raf); const map = this.ctx.map; if (this._onResize) map.off("resize", this._onResize); - if (this._onMove) { - map.off("movestart", this._onMove); - map.off("zoomstart", this._onMove); - } + if (this._onZoom) map.off("zoomstart", this._onZoom); if (this._canvas) this._canvas.remove(); } } -// rampColor maps a wind speed (m/s) to a colour along RAMP. +// 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]) { @@ -262,5 +338,48 @@ function rampColor(spd) { break; } } - return hi[1]; // stepped ramp is plenty for streamlines; avoids per-pixel lerp cost + 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 u/v. +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 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 np = nx * ny; + let o = 36; + const steps = []; + for (let s = 0; s < nSteps; s++) { + const hour = dv.getInt32(o, true); + o += 4; + const u = new Float32Array(buf, o, np); + o += np * 4; + const v = new Float32Array(buf, o, np); + o += np * 4; + steps.push({ hour, u, v }); + } + const lo2 = lo1 + (nx - 1) * dx, la2 = la1 - (ny - 1) * dy; + return { header: { nx, ny, lo1, la1, lo2, la2, dx, dy }, steps }; +} + +// 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]; +} + +// 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/web/src/core/plugin-host.mjs b/web/src/core/plugin-host.mjs index 9cb1b91..d02f05c 100644 --- a/web/src/core/plugin-host.mjs +++ b/web/src/core/plugin-host.mjs @@ -183,10 +183,19 @@ export class PluginHost { // { id, title, group?, defaultVisible?, onVisible(visible) }. The id is // namespaced by plugin; onVisible fires immediately + on each toggle, so the // plugin can pause expensive work while hidden (spec §8, visibility signal). + // Returns a handle so the plugin can also drive the same overlay (e.g. a HUD + // toggle) — the Layers control and the handle share the registry as the single + // source of truth, so they stay in sync. overlays: { register: (desc) => { - if (!svc.overlays) return () => {}; - return track(svc.overlays.register({ ...desc, id: `${id}:${desc.id}`, group: desc.group || (svc.pluginTitle && svc.pluginTitle(id)) || "Overlays" })); + if (!svc.overlays) return { setVisible() {}, toggle() {}, unregister() {} }; + const fullId = `${id}:${desc.id}`; + const unreg = track(svc.overlays.register({ ...desc, id: fullId, group: desc.group || (svc.pluginTitle && svc.pluginTitle(id)) || "Overlays" })); + return { + setVisible: (v) => svc.overlays.setVisible(fullId, v), + toggle: () => svc.overlays.toggle(fullId), + unregister: unreg, + }; }, }, From 14064bc0cecb8a0fc6505478ce0d8ca87c0e5891 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 19:44:20 +0000 Subject: [PATCH 19/33] feat(plugin): GFS as the default source, auto-discovering the latest cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weather plugin now defaults to "gfs": it lists the NOAA open-data archive, picks the newest date + cycle (so it stays current with no hardcoded date), and byte-range-fetches that cycle's 10 m wind. On any failure it reports degraded and draws nothing — no fabricated/fallback data. "sample" remains available for offline/demo use, and an explicit GFS or GRIB2 URL still works. --- plugins/core.weather/main.go | 104 ++++++++++++++++++++++++------- plugins/core.weather/plugin.json | 2 +- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/plugins/core.weather/main.go b/plugins/core.weather/main.go index 6cb1d0f..47a1c9a 100644 --- a/plugins/core.weather/main.go +++ b/plugins/core.weather/main.go @@ -1,13 +1,14 @@ // Command core.weather is a GRIB weather plugin (Tier A, WASM). It decodes a GRIB2 -// surface-wind field into a compact grid and publishes it as a served artifact at -// GET /plugins/core.weather/serve/wind.json — 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. +// surface-wind field 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"): -// - "sample" (default): an embedded offline GRIB2 sample, decoded on start. -// - a URL: fetched via the host (net.http) and decoded — for a live GRIB feed -// that uses grid-point simple packing. +// - "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 @@ -16,6 +17,7 @@ import ( _ "embed" "fmt" "math" + "regexp" "sort" "strconv" "strings" @@ -30,38 +32,79 @@ var sampleGRIB []byte type weather struct{ h *sdk.Host } +// gfsBucket is the NOAA GFS open-data archive (S3). +const gfsBucket = "https://noaa-gfs-bdp-pds.s3.amazonaws.com" + func (p *weather) Start(h *sdk.Host) { p.h = h src := h.ConfigString("source") - if src == "" || src == "sample" { - p.publish(sampleGRIB, "embedded sample") - return + if src == "" { + src = "gfs" // default: the latest real GFS forecast } - // Live GRIB over the host-mediated, allow-listed net.http capability. - h.Status("running", "fetching "+src) - if strings.Contains(src, "pgrb2") { // a GFS product: byte-range just the 10 m wind - p.fetchGFS(src) - return + switch { + case src == "sample": + p.publish(sampleGRIB, "embedded sample") + case src == "gfs": + p.discoverGFS() // auto-resolve the latest cycle → fetch + case strings.Contains(src, "pgrb2"): // an explicit GFS product URL + h.Status("running", "fetching GFS…") + p.fetchGFS(src, func(reason string) { h.Status("degraded", reason) }) + default: // any other GRIB2 URL + h.Status("running", "fetching "+src) + h.Fetch(src, func(resp *sdk.HTTPResponse, err error) { p.onFetch(resp, err, src) }) } - h.Fetch(src, func(resp *sdk.HTTPResponse, err error) { p.onFetch(resp, err, src) }) +} + +// discoverGFS finds the newest available GFS cycle in the archive (so "gfs" stays +// current without a hardcoded date) and fetches its 10 m wind. On any failure it +// reports degraded and draws nothing — no fabricated data. +func (p *weather) discoverGFS() { + p.h.Status("running", "finding latest GFS cycle…") + p.h.Fetch(gfsBucket+"/?list-type=2&prefix=gfs.&delimiter=/", func(resp *sdk.HTTPResponse, err error) { + date := "" + if err == nil && resp != nil && resp.Status == 200 { + date = latestGroup(string(resp.Body), gfsDateRE) + } + if date == "" { + p.h.Status("degraded", "could not list the GFS archive") + return + } + p.h.Fetch(gfsBucket+"/?list-type=2&prefix=gfs."+date+"/&delimiter=/", func(r2 *sdk.HTTPResponse, e2 error) { + cyc := "00" + if e2 == nil && r2 != nil && r2.Status == 200 { + if c := latestGroup(string(r2.Body), gfsCycleRE); c != "" { + cyc = c + } + } + url := fmt.Sprintf("%s/gfs.%s/%s/atmos/gfs.t%sz.pgrb2.0p25.f000", gfsBucket, date, cyc, cyc) + p.h.Status("running", "fetching GFS "+date+" "+cyc+"z…") + p.fetchGFS(url, func(reason string) { p.h.Status("degraded", "GFS "+date+" "+cyc+"z: "+reason) }) + }) + }) } // 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. -func (p *weather) fetchGFS(url string) { +// multi-hundred-MB file. onErr is called (with a reason) if the fetch fails. +func (p *weather) fetchGFS(url string, onErr func(string)) { p.h.Fetch(url+".idx", func(resp *sdk.HTTPResponse, err error) { if err != nil || resp == nil || resp.Status != 200 { - p.h.Status("degraded", "GFS index fetch failed") + onErr("index fetch failed") return } start, end, ok := windRange(string(resp.Body)) if !ok { - p.h.Status("degraded", "no 10 m wind in GFS index") + onErr("no 10 m wind in index") return } rng := fmt.Sprintf("bytes=%d-%d", start, end) - p.h.FetchOpts(url, map[string]string{"Range": rng}, func(r *sdk.HTTPResponse, e error) { p.onFetch(r, e, "GFS") }) + 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) { + onErr("data fetch failed") + return + } + p.publish(r.Body, "GFS") + }) }) } @@ -79,6 +122,25 @@ func (p *weather) onFetch(resp *sdk.HTTPResponse, err error, label string) { p.publish(resp.Body, label) } +// GFS archive listing patterns (S3 XML CommonPrefixes). +var ( + gfsDateRE = regexp.MustCompile(`gfs\.(\d{8})/`) // gfs.YYYYMMDD/ + gfsCycleRE = regexp.MustCompile(`gfs\.\d{8}/(\d{2})/`) // gfs.YYYYMMDD/HH/ +) + +// latestGroup returns the lexicographically-largest first capture group of re in s +// ("" if none). YYYYMMDD dates and HH cycles sort correctly as strings, so the max is +// the newest. +func latestGroup(s string, re *regexp.Regexp) string { + best := "" + for _, m := range re.FindAllStringSubmatch(s, -1) { + if m[1] > best { + best = m[1] + } + } + return best +} + // 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) { diff --git a/plugins/core.weather/plugin.json b/plugins/core.weather/plugin.json index a57449b..0875b81 100644 --- a/plugins/core.weather/plugin.json +++ b/plugins/core.weather/plugin.json @@ -18,7 +18,7 @@ "mapLayers": [{ "id": "wind", "title": "Wind" }], "settings": { "items": [ - { "key": "source", "type": "text", "label": "GRIB source", "placeholder": "sample", "default": "sample" } + { "key": "source", "type": "text", "label": "GRIB source", "placeholder": "gfs", "default": "gfs" } ] } } From 931716e56b5526bd88f1be1543365f6e42478feb Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 19:44:20 +0000 Subject: [PATCH 20/33] feat(server): CHARTPLOTTER_NO_REBAKE dev flag Set CHARTPLOTTER_NO_REBAKE=1 to treat baked charts as current regardless of the engine/version stamp, so rebuilding the app during dev doesn't kick off a slow re-bake on every restart. Genuinely-missing charts still bake. --- internal/engine/server/live_provider.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 From 1461352ac850b1f6e81813f5d3a6fad40c221932 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 19:44:20 +0000 Subject: [PATCH 21/33] fix(web): mount getElementById shim; lighter wind particles - ctx.hud.mount() returns a plain element (querySelector, not getElementById); the weather control used getElementById and threw, so the layer couldn't be turned on. Fix the call and add a scoped getElementById shim to the mount so the documented examples work and no plugin hits this again. - Wind particles were overwhelming: scale the count to the viewport (~900/Mpx, capped) instead of a fixed 2600, thinner lines, shorter/lighter trails. --- plugins/core.weather/ui/plugin.mjs | 28 +++++++++++++++++----------- web/src/core/plugin-host.mjs | 5 ++++- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs index 8ab33f7..6a8cbc2 100644 --- a/plugins/core.weather/ui/plugin.mjs +++ b/plugins/core.weather/ui/plugin.mjs @@ -8,11 +8,14 @@ // uses ctx.map (accepting the same contract the built-ins live with) rather than the // declarative layers API. -const PARTICLES = 2600; -const MAX_AGE = 130; // frames before a particle respawns (longer → longer streamlines) +// Density is scaled to the viewport in _seed (not a fixed count) so a zoomed-in view +// isn't a solid mat of lines; this is the target per ~1M screen px. +const DENSITY = 900; // particles per megapixel of map +const MAX_PARTICLES = 1600; // hard cap on big screens +const MAX_AGE = 100; // frames before a particle respawns const STEP = 0.16; // screen px per (m/s) per frame (lower → slower, calmer motion) -const FADE = 0.96; // trail persistence (higher = longer tails) -const LINE_WIDTH = 2.1; +const FADE = 0.92; // trail persistence (lower → shorter, lighter tails) +const LINE_WIDTH = 1.3; // 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, @@ -130,8 +133,10 @@ export default class WindOverlay { } _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 < PARTICLES; i++) this._particles.push(this._spawn()); + for (let i = 0; i < n; i++) this._particles.push(this._spawn()); } _spawn() { @@ -193,9 +198,9 @@ export default class WindOverlay { .wc.off{opacity:.65;} .wc .arrow{display:inline-block;font-size:13px;line-height:1;}
🌬Wind
`; - this._ctl = hud.getElementById("c"); - this._ctlVal = hud.getElementById("v"); - this._ctlArrow = hud.getElementById("ar"); + this._ctl = hud.querySelector("#c"); + this._ctlVal = hud.querySelector("#v"); + this._ctlArrow = hud.querySelector("#ar"); this._ctl.addEventListener("click", () => this._layer.toggle()); this.ctx.vessel.subscribe(() => this._updateReadout()); } @@ -280,9 +285,10 @@ export default class WindOverlay { p.lat = b.lat; p.age++; } - // Pass 2a: a dark casing (one batched stroke) so streamlines read on LIGHT charts. - c.strokeStyle = "rgba(0,0,0,0.5)"; - c.lineWidth = LINE_WIDTH + 2; + // Pass 2a: a subtle dark casing (one batched stroke) so streamlines read on LIGHT + // charts without looking heavy. + c.strokeStyle = "rgba(0,0,0,0.4)"; + c.lineWidth = LINE_WIDTH + 1; c.beginPath(); for (let i = 0; i < segs.length; i += 5) { c.moveTo(segs[i], segs[i + 1]); diff --git a/web/src/core/plugin-host.mjs b/web/src/core/plugin-host.mjs index d02f05c..4e4901e 100644 --- a/web/src/core/plugin-host.mjs +++ b/web/src/core/plugin-host.mjs @@ -327,11 +327,14 @@ export class PluginHost { return handle; } - // _mount returns a fresh element in the shell chrome for a plugin's overlay UI. + // _mount returns a fresh element in the shell chrome for a plugin's overlay UI. A + // plain element has querySelector but not getElementById; the docs' examples use + // getElementById on a mount, so provide it as a scoped convenience. _mount(pluginId, slotId, track) { const el = document.createElement("div"); el.dataset.plugin = pluginId; el.dataset.slot = slotId || ""; + el.getElementById = (id) => el.querySelector(`#${CSS.escape(id)}`); if (this._svc.chrome) this._svc.chrome.appendChild(el); track(() => el.remove()); return el; From 3298dfb60ff2d8790a423e40e6a274f42dac7c36 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 15 Jul 2026 19:58:55 +0000 Subject: [PATCH 22/33] fix(plugin): fetch the CURRENT GFS cycle (clock-based, not a bucket listing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old discovery listed the S3 bucket with prefix=gfs. and took the max — but S3 returns the first 1000 keys in lexicographic (chronological) order, i.e. the OLDEST dates, so it always resolved to an ancient cycle (~2023-09) regardless of what's actually current. Instead, use the module's wall clock: start ~5h back, floor to a 6h boundary, and walk older cycles until one's .idx is available. Now fetches today's run (verified: GFS 20260715 12z). Also: 0.5° resolution for speed, slider docked under the wind control (off the bottom status chrome), and lighter/slower/thicker particles. --- plugins/core.weather/main.go | 81 +++++++++++------------------- plugins/core.weather/ui/plugin.mjs | 25 +++++---- 2 files changed, 44 insertions(+), 62 deletions(-) diff --git a/plugins/core.weather/main.go b/plugins/core.weather/main.go index 47a1c9a..6e77080 100644 --- a/plugins/core.weather/main.go +++ b/plugins/core.weather/main.go @@ -17,7 +17,6 @@ import ( _ "embed" "fmt" "math" - "regexp" "sort" "strconv" "strings" @@ -48,62 +47,61 @@ func (p *weather) Start(h *sdk.Host) { p.discoverGFS() // auto-resolve the latest cycle → fetch case strings.Contains(src, "pgrb2"): // an explicit GFS product URL h.Status("running", "fetching GFS…") - p.fetchGFS(src, func(reason string) { h.Status("degraded", reason) }) + 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) }) } } -// discoverGFS finds the newest available GFS cycle in the archive (so "gfs" stays -// current without a hardcoded date) and fetches its 10 m wind. On any failure it -// reports degraded and draws nothing — no fabricated data. +// 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() { p.h.Status("running", "finding latest GFS cycle…") - p.h.Fetch(gfsBucket+"/?list-type=2&prefix=gfs.&delimiter=/", func(resp *sdk.HTTPResponse, err error) { - date := "" - if err == nil && resp != nil && resp.Status == 200 { - date = latestGroup(string(resp.Body), gfsDateRE) - } - if date == "" { - p.h.Status("degraded", "could not list the GFS archive") - return - } - p.h.Fetch(gfsBucket+"/?list-type=2&prefix=gfs."+date+"/&delimiter=/", func(r2 *sdk.HTTPResponse, e2 error) { - cyc := "00" - if e2 == nil && r2 != nil && r2.Status == 200 { - if c := latestGroup(string(r2.Body), gfsCycleRE); c != "" { - cyc = c - } - } - url := fmt.Sprintf("%s/gfs.%s/%s/atmos/gfs.t%sz.pgrb2.0p25.f000", gfsBucket, date, cyc, cyc) - p.h.Status("running", "fetching GFS "+date+" "+cyc+"z…") - p.fetchGFS(url, func(reason string) { p.h.Status("degraded", "GFS "+date+" "+cyc+"z: "+reason) }) - }) - }) + c := time.Now().UTC().Add(-5 * time.Hour).Truncate(6 * time.Hour) // newest likely-published cycle + p.tryCycle(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. +func (p *weather) tryCycle(c time.Time, back int) { + if back > 8 { + 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") + // 0.5° (not 0.25°): ~4× fewer points → far faster to decode + transfer, and plenty + // for streamlines. Users wanting finer can set an explicit 0p25 URL. + url := fmt.Sprintf("%s/gfs.%s/%s/atmos/gfs.t%sz.pgrb2.0p50.f000", gfsBucket, date, hh, hh) + p.fetchGFS(url, "GFS "+date+" "+hh+"z", func() { p.tryCycle(c, back+1) }) } // 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. onErr is called (with a reason) if the fetch fails. -func (p *weather) fetchGFS(url string, onErr func(string)) { +// 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 { - onErr("index fetch failed") + onMiss() return } start, end, ok := windRange(string(resp.Body)) if !ok { - onErr("no 10 m wind in index") + 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) { - onErr("data fetch failed") + onMiss() return } - p.publish(r.Body, "GFS") + p.publish(r.Body, label) }) }) } @@ -122,25 +120,6 @@ func (p *weather) onFetch(resp *sdk.HTTPResponse, err error, label string) { p.publish(resp.Body, label) } -// GFS archive listing patterns (S3 XML CommonPrefixes). -var ( - gfsDateRE = regexp.MustCompile(`gfs\.(\d{8})/`) // gfs.YYYYMMDD/ - gfsCycleRE = regexp.MustCompile(`gfs\.\d{8}/(\d{2})/`) // gfs.YYYYMMDD/HH/ -) - -// latestGroup returns the lexicographically-largest first capture group of re in s -// ("" if none). YYYYMMDD dates and HH cycles sort correctly as strings, so the max is -// the newest. -func latestGroup(s string, re *regexp.Regexp) string { - best := "" - for _, m := range re.FindAllStringSubmatch(s, -1) { - if m[1] > best { - best = m[1] - } - } - return best -} - // 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) { diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs index 6a8cbc2..9c32e99 100644 --- a/plugins/core.weather/ui/plugin.mjs +++ b/plugins/core.weather/ui/plugin.mjs @@ -9,13 +9,14 @@ // 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; this is the target per ~1M screen px. -const DENSITY = 900; // particles per megapixel of map -const MAX_PARTICLES = 1600; // hard cap on big screens -const MAX_AGE = 100; // frames before a particle respawns -const STEP = 0.16; // screen px per (m/s) per frame (lower → slower, calmer motion) -const FADE = 0.92; // trail persistence (lower → shorter, lighter tails) -const LINE_WIDTH = 1.3; +// isn't a solid mat of lines. Kept deliberately sparse — wind is a background hint, +// not a wall of streamlines. +const DENSITY = 180; // particles per megapixel of map +const MAX_PARTICLES = 450; // hard cap on big screens +const MAX_AGE = 110; // frames before a particle respawns +const STEP = 0.07; // screen px per (m/s) per frame (lower → slower, calmer motion) +const FADE = 0.94; // trail persistence (higher → longer streamline tails) +const LINE_WIDTH = 2.3; // 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, @@ -155,14 +156,16 @@ export default class WindOverlay { // overlay is shown). Mounted in the shell chrome; theme vars inherit. _mountSlider() { const hud = this.ctx.hud.mount("wind-time"); + // Docked under the wind control (top-right), so it never collides with the app's + // bottom status/progress chrome. hud.innerHTML = `
🌬+0h
`; +
+0h
`; this._sliderWrap = hud.querySelector(".wt"); this._slider = hud.querySelector("input"); this._label = hud.querySelector(".lbl"); From 13b94702e4b1881d7620171b08b2e8721020e205 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 17 Jul 2026 09:03:33 +0000 Subject: [PATCH 23/33] =?UTF-8?q?feat(sdk):=20ConfigWatcher=20=E2=80=94=20?= =?UTF-8?q?plugins=20react=20to=20hot=20config=20edits=20without=20a=20res?= =?UTF-8?q?tart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/sdk.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/sdk/sdk.go b/sdk/sdk.go index 19bb9ba..102fd39 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -45,6 +45,13 @@ type Plugin interface { 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 { @@ -118,7 +125,7 @@ func (h *Host) dispatch(p Plugin, m *proto.Message) bool { h.replyErr(m.ID, proto.CodeMethodNotFound, "unknown method: "+m.Method) } default: // host→plugin notification - h.onNotify(m) + h.onNotify(p, m) } return false } @@ -133,13 +140,16 @@ func (h *Host) onHello(m *proto.Message) { h.reply(m.ID, proto.HelloResult{APIVersion: proto.APIVersion, Framing: "ndjson"}) } -func (h *Host) onNotify(m *proto.Message) { +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 From 1df44627a2af34be10a9fe0734a87af99ff60333 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 17 Jul 2026 09:03:33 +0000 Subject: [PATCH 24/33] feat(grib): Lambert-conformal grids (template 3.30) + resampling with grid-relative wind rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HRRR decodes natively: projection forward-mapping, bilinear resample to regular lat/lon windows, and earth-relative wind rotation (~13° in the mid-Atlantic — skipping it visibly skews directions). pipeline_test locks the Go-encode → JS-parse byte layout (v4 plane-mask format). --- plugins/core.weather/grib/grib.go | 48 ++++-- plugins/core.weather/grib/lambert.go | 118 +++++++++++++++ plugins/core.weather/pipeline_test.go | 210 ++++++++++++++++++++++++++ 3 files changed, 363 insertions(+), 13 deletions(-) create mode 100644 plugins/core.weather/grib/lambert.go create mode 100644 plugins/core.weather/pipeline_test.go diff --git a/plugins/core.weather/grib/grib.go b/plugins/core.weather/grib/grib.go index c9c6050..e266e90 100644 --- a/plugins/core.weather/grib/grib.go +++ b/plugins/core.weather/grib/grib.go @@ -13,15 +13,22 @@ import ( "time" ) -// Grid is one decoded GRIB2 message: a field over a regular lat/lon grid. +// 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 + 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. @@ -77,19 +84,34 @@ func decodeMessage(b []byte) (Grid, error) { 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) + case 3: // Grid definition (template 3.0 regular lat/lon, or 3.30 Lambert) tmpl := binary.BigEndian.Uint16(s[12:14]) - if tmpl != 0 { - return g, fmt.Errorf("unsupported grid template %d (want 3.0)", tmpl) + 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) } - 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 4: // Product definition (template 4.0) tmpl := binary.BigEndian.Uint16(s[7:9]) if tmpl != 0 { 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/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) + } + } +} From 372541afbc08eaf42e437c1abfec4c889af03427 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 17 Jul 2026 09:03:33 +0000 Subject: [PATCH 25/33] =?UTF-8?q?feat(weather):=20HRRR=20layers,=20forecas?= =?UTF-8?q?t=20details,=20refresh,=20probe=20=E2=80=94=20and=20honest=20wi?= =?UTF-8?q?nd=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - three published layers: GFS base, 3 km HRRR sailing window (auto-centred from the GPS fix or viewport), ~15 km HRRR CONUS look-around; sampled hi→mid→base everywhere - map-bearing-corrected streamlines and readout arrows (course-up was skewing every direction by the bearing) - per-forecast details (wind/gust/temp/cloud), compact scrubber card, tap-to-probe marker, ship's-clock local times - ↻ refresh via config nonce + 10-min staleness auto-refresh - real id (org.beetlebug.weather) and a single honest setting: the HRRR data-budget toggle (~60 MB/refresh) instead of a fake 'source' field --- plugins/core.weather/main.go | 701 +++++++++++++++++++++-- plugins/core.weather/plugin.json | 39 +- plugins/core.weather/ui/plugin.mjs | 890 +++++++++++++++++++++++++---- 3 files changed, 1461 insertions(+), 169 deletions(-) diff --git a/plugins/core.weather/main.go b/plugins/core.weather/main.go index 6e77080..4f9cf04 100644 --- a/plugins/core.weather/main.go +++ b/plugins/core.weather/main.go @@ -1,6 +1,7 @@ -// Command core.weather is a GRIB weather plugin (Tier A, WASM). It decodes a GRIB2 -// surface-wind field 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 +// 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. // @@ -29,13 +30,76 @@ import ( //go:embed sample.grib2 var sampleGRIB []byte -type weather struct{ h *sdk.Host } +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 @@ -44,7 +108,9 @@ func (p *weather) Start(h *sdk.Host) { case src == "sample": p.publish(sampleGRIB, "embedded sample") case src == "gfs": - p.discoverGFS() // auto-resolve the latest cycle → fetch + 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") }) @@ -54,29 +120,452 @@ func (p *weather) Start(h *sdk.Host) { } } +// 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 + 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.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.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.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() { +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(c, 0) + 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. -func (p *weather) tryCycle(c time.Time, back int) { +// 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.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") - // 0.5° (not 0.25°): ~4× fewer points → far faster to decode + transfer, and plenty - // for streamlines. Users wanting finer can set an explicit 0p25 URL. - url := fmt.Sprintf("%s/gfs.%s/%s/atmos/gfs.t%sz.pgrb2.0p50.f000", gfsBucket, date, hh, hh) - p.fetchGFS(url, "GFS "+date+" "+hh+"z", func() { p.tryCycle(c, back+1) }) + 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.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.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 @@ -120,50 +609,69 @@ func (p *weather) onFetch(resp *sdk.HTTPResponse, err error, label string) { p.publish(resp.Body, label) } -// 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) { - type rec struct { - off int - field, level string - } - var recs []rec +// 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) < 5 { + if len(f) < 6 { continue } off, e := strconv.Atoi(f[1]) if e != nil { continue } - recs = append(recs, rec{off, f[3], f[4]}) + recs = append(recs, idxRec{off, f[3], f[4], f[5]}) } - uStart, vStart, vNext := -1, -1, -1 + 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.level != "10 m above ground" { + if r.field != field || r.level != level || strings.Contains(r.ftime, "ave") || strings.Contains(r.ftime, "acc") { continue } - if r.field == "UGRD" { - uStart = r.off - } - if r.field == "VGRD" { - vStart = r.off - if i+1 < len(recs) { - vNext = recs[i+1].off - } + 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 } - if uStart < 0 || vStart < 0 { + 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 = uStart + start, end = uStart, vEnd if vStart < start { start = vStart } - end = vNext - 1 - if vNext <= 0 { // VGRD is the last record: read a generous window - end = start + (1 << 24) + if uEnd > end { + end = uEnd } return start, end, true } @@ -207,10 +715,9 @@ func (p *weather) publish(gribBytes []byte, srcLabel string) { if e.u == nil || e.v == nil { continue } - // A global 0.25° field is ~2M points (~30 MB JSON) — too big for the wire and - // far finer than streamlines need. Downsample oversized grids to a sane size; - // regional fields pass through untouched. - h, u, v := capGrid(e.u, e.v, maxGridPoints) + // 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 @@ -238,16 +745,28 @@ func (p *weather) publish(gribBytes []byte, srcLabel string) { // 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 -// per step: hour i32 | u[nx*ny] f32 | v[nx*ny] f32 +// "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, 36+len(d.Steps)*(4+np*8)) + 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(1) + put32(4) put32(uint32(h.Nx)) put32(uint32(h.Ny)) put32(uint32(len(d.Steps))) @@ -255,13 +774,21 @@ func encodeWindBin(d *windDoc) []byte { putF(h.La1) putF(h.Dx) putF(h.Dy) + put64(math.Float64bits(refUnix)) for _, s := range d.Steps { put32(uint32(int32(s.Hour))) - for _, x := range s.U { - putF(x) + 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 + } } - for _, x := range s.V { - putF(x) + put32(mask) + for _, pl := range planes { + for _, x := range pl { // nil planes contribute nothing + putF(x) + } } } return out @@ -287,8 +814,13 @@ type gridHeader struct { type step struct { Hour int `json:"hour"` - U []float64 `json:"u"` - V []float64 `json:"v"` + 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, @@ -297,6 +829,71 @@ type step struct { // 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. diff --git a/plugins/core.weather/plugin.json b/plugins/core.weather/plugin.json index 0875b81..56770a5 100644 --- a/plugins/core.weather/plugin.json +++ b/plugins/core.weather/plugin.json @@ -1,24 +1,47 @@ { "manifestVersion": 1, - "id": "core.weather", + "id": "org.beetlebug.weather", "name": "Wind (GRIB)", - "version": "1.0.0", + "version": "1.2.4", "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" }, + "entry": { + "wasm": "plugin.wasm" + }, "capabilities": [ - { "cap": "storage", "quota": "16MB" }, - { "cap": "net.http", "hosts": ["nomads.ncep.noaa.gov", "*.amazonaws.com"] }, - { "cap": "ui.map-layer" } + { + "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" }], + "mapLayers": [ + { + "id": "wind", + "title": "Wind" + } + ], "settings": { "items": [ - { "key": "source", "type": "text", "label": "GRIB source", "placeholder": "gfs", "default": "gfs" } + { + "key": "hires", + "type": "toggle", + "label": "High-res coastal wind (HRRR, ~60 MB/refresh)", + "default": true + } ] } } diff --git a/plugins/core.weather/ui/plugin.mjs b/plugins/core.weather/ui/plugin.mjs index 9c32e99..92ed179 100644 --- a/plugins/core.weather/ui/plugin.mjs +++ b/plugins/core.weather/ui/plugin.mjs @@ -11,12 +11,23 @@ // 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 = 180; // particles per megapixel of map -const MAX_PARTICLES = 450; // hard cap on big screens +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.07; // screen px per (m/s) per frame (lower → slower, calmer motion) -const FADE = 0.94; // trail persistence (higher → longer streamline tails) -const LINE_WIDTH = 2.3; +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, @@ -50,16 +61,49 @@ export default class WindOverlay { this._resize(); this._onResize = () => this._resize(); map.on("resize", this._onResize); - // Only clear on ZOOM (the projection scale changes, so trails would stretch). - // Do NOT clear on pan: the follow camera eases the map on every fix, and clearing - // there made the whole field flicker each time — re-projecting per frame already - // tracks a pan, and the trail fade cleans up any brief smear. - this._onZoom = () => this._c2d && this._c2d.clearRect(0, 0, this._cw, this._ch); + // 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 control below — 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). + // 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", @@ -67,28 +111,46 @@ export default class WindOverlay { onVisible: (v) => this._setOn(v), }); - this._mountSlider(); - this._mountControl(); + 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 { - const url = `${this.ctx.assets}plugins/${this.ctx.plugin.id}/serve/wind.bin`; - const buf = await (await fetch(url, { cache: "no-store" })).arrayBuffer(); - const doc = parseWindBin(buf); - if (!doc) return; - this._doc = doc; + // 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) { @@ -106,16 +168,85 @@ export default class WindOverlay { } 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._hour = Math.round(s0.hour * (1 - t) + s1.hour * t); - if (this._label) this._label.textContent = "+" + this._hour + "h"; - this._updateReadout(); // wind at the vessel changes with the forecast step + 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); returns [u,v] or null if off-grid. Handles - // global grids expressed in 0–360° longitude (GFS) as well as −180..180. + // 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) { - const g = this._grid; - if (!g) return null; + 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) @@ -130,7 +261,9 @@ export default class WindOverlay { 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; - return [bil(g.u), bil(g.v)]; + const u = bil(g.u), v = bil(g.v); + if (!Number.isFinite(u) || !Number.isFinite(v)) return null; + return [u, v]; } _seed() { @@ -149,50 +282,115 @@ export default class WindOverlay { 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) }; } - // _mountSlider builds the forecast time scrubber (hidden until data loads / the - // overlay is shown). Mounted in the shell chrome; theme vars inherit. - _mountSlider() { + // _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"); - // Docked under the wind control (top-right), so it never collides with the app's - // bottom status/progress chrome. + // 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 = `
+0h
`; + box-shadow:0 4px 16px rgba(0,0,0,.3);color:var(--ui-text,#e6edf3); + font:12px/1.2 system-ui,-apple-system,sans-serif;} + .wt .row{display:flex;align-items:center;gap:7px;} + .wt .cap{font-size:13px;line-height:1;} + .wt input{flex:1;height:3px;min-width:0;accent-color:var(--ui-accent,#2f81f7);cursor:pointer;} + .wt .lead{min-width:34px;text-align:center;font-size:10px;font-weight:700;font-variant-numeric:tabular-nums; + padding:2px 5px;border-radius:7px;background:var(--ui-border,#30363d);opacity:.85;} + .wt .lead.now{background:var(--ui-accent,#2f81f7);color:#fff;opacity:1;} + .wt .lbl{font-weight:700;font-size:11.5px;font-variant-numeric:tabular-nums;white-space:nowrap;} + .wt .tgl{border:0;background:none;color:inherit;cursor:pointer;font:inherit;font-size:10px; + opacity:.65;padding:2px 3px;line-height:1;} + .wt .tgl:hover{opacity:1;} + .wt .tgl.spin{animation:wtspin 1s linear infinite;} + @keyframes wtspin{to{transform:rotate(360deg)}} + .wt .body{display:none;} + .wt.open .body{display:block;} + .wt .sum{display:flex;gap:5px;align-items:center;justify-content:center;margin-top:4px; + font-size:10.5px;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;} + .wt .sum b{font-weight:700;} + .wt .sum .dim{opacity:.55;} + .wt .sum .dar{display:inline-block;font-size:11px;} + .wt .strip{display:flex;gap:2px;margin-top:6px;overflow-x:auto;scrollbar-width:thin;} + .wt .cell{flex:1 1 0;min-width:34px;display:flex;flex-direction:column;align-items:center;gap:1px; + padding:3px 1px;border-radius:6px;border:1px solid transparent;background:none;cursor:pointer; + color:inherit;font:inherit;} + .wt .cell:hover{background:rgba(127,127,127,.12);} + .wt .cell.on{background:rgba(47,129,247,.16);border-color:var(--ui-accent,#2f81f7);} + .wt .cell .ct{font-size:8.5px;font-weight:600;opacity:.6;font-variant-numeric:tabular-nums;white-space:nowrap;} + .wt .cell .ca{font-size:11px;line-height:1;} + .wt .cell .cs{font-size:10.5px;font-weight:700;font-variant-numeric:tabular-nums;} + .wt .cell .cc{font-size:10.5px;line-height:1;} + .wt .foot{margin-top:5px;font-size:9.5px;opacity:.6;text-align:center;font-variant-numeric:tabular-nums;} +
+
+ + 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)); - } - - _buildSlider() { - if (!this._slider || !this._doc) return; - this._slider.max = String((this._doc.steps.length - 1) * 10); // ×10 for smooth interpolation - this._slider.value = "0"; - this._syncSlider(); - } + // 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()); - _syncSlider() { - if (this._sliderWrap) { - this._sliderWrap.style.display = this._on && this._doc && this._doc.steps.length > 1 ? "flex" : "none"; - } - } + // 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"))); - // One on-map control that is both the enable/disable toggle AND the live wind - // readout: click to turn the overlay on/off; while on it shows the actual wind at - // the vessel (speed in the mariner's units + compass direction it blows FROM, with - // an arrow pointing where it blows). Kept in sync with the Layers control via the - // shared registry. - _mountControl() { - const hud = this.ctx.hud.mount("wind-control"); - hud.innerHTML = `
🌬Wind
`; - this._ctl = hud.querySelector("#c"); - this._ctlVal = hud.querySelector("#v"); - this._ctlArrow = hud.querySelector("#ar"); + 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.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() { @@ -220,10 +613,215 @@ export default class WindOverlay { 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; - this._ctlArrow.style.transform = `rotate(${from}deg)`; // ↓ (south) rotated to blow-toward + // ↓ (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; @@ -237,6 +835,10 @@ export default class WindOverlay { 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(); } @@ -258,55 +860,71 @@ export default class WindOverlay { _frame() { const c = this._c2d; - if (!c || !this._grid) return; - const W = this._cw, H = this._ch; - // Fade previous frame (leaves fading tails). - c.globalCompositeOperation = "destination-in"; - c.fillStyle = `rgba(0,0,0,${FADE})`; - c.fillRect(0, 0, W, H); - c.globalCompositeOperation = "source-over"; + 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; - // Pass 1: advect every particle and collect its screen segment (flat array of - // x0,y0,x1,y1,speed to avoid per-frame allocations). - const segs = this._segs || (this._segs = []); - segs.length = 0; + // 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; } - const a = map.project([p.lng, p.lat]); - // Move in screen space for zoom-independent visual speed; v is northward → up. - const nx = a.x + wind[0] * STEP; - const ny = a.y - wind[1] * STEP; - const b = map.unproject([nx, ny]); - segs.push(a.x, a.y, nx, ny, Math.hypot(wind[0], wind[1])); + // 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; + } } - // Pass 2a: a subtle dark casing (one batched stroke) so streamlines read on LIGHT - // charts without looking heavy. - c.strokeStyle = "rgba(0,0,0,0.4)"; - c.lineWidth = LINE_WIDTH + 1; - c.beginPath(); - for (let i = 0; i < segs.length; i += 5) { - c.moveTo(segs[i], segs[i + 1]); - c.lineTo(segs[i + 2], segs[i + 3]); - } - c.stroke(); - // Pass 2b: bright coloured cores (visible on DARK charts) on top. - c.lineWidth = LINE_WIDTH; - for (let i = 0; i < segs.length; i += 5) { - c.strokeStyle = rampColor(segs[i + 4]); - c.beginPath(); - c.moveTo(segs[i], segs[i + 1]); - c.lineTo(segs[i + 2], segs[i + 3]); - c.stroke(); - } + c.globalAlpha = 1; + c.shadowBlur = 0; } _resize() { @@ -324,9 +942,16 @@ export default class WindOverlay { 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(); } } @@ -354,29 +979,49 @@ function rampColor(spd) { } // 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 u/v. +// 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 = 36; + 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; - const u = new Float32Array(buf, o, np); - o += np * 4; - const v = new Float32Array(buf, o, np); - o += np * 4; - steps.push({ hour, u, v }); + 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 }, steps }; + 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. @@ -384,6 +1029,33 @@ 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); From d4f110af4fda970027a2698fdc37d99d07275545 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 17 Jul 2026 09:03:33 +0000 Subject: [PATCH 26/33] chore(nmea0183): real plugin id org.beetlebug.nmea0183 (org.example was placeholder cruft) --- plugins/core.nmea0183/plugin.json | 42 ++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/plugins/core.nmea0183/plugin.json b/plugins/core.nmea0183/plugin.json index 153aed1..6775de0 100644 --- a/plugins/core.nmea0183/plugin.json +++ b/plugins/core.nmea0183/plugin.json @@ -1,27 +1,51 @@ { "manifestVersion": 1, - "id": "core.nmea0183", + "id": "org.beetlebug.nmea0183", "name": "NMEA 0183", "version": "1.0.0", - "description": "Connects to a NMEA 0183 server over TCP and reads instrument sentences — GPS position, heading, speed, depth, wind — plus AIS targets, feeding them to the plotter as a data source.", + "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" }, + "entry": { + "wasm": "plugin.wasm" + }, "capabilities": [ - { "cap": "vessel.write" }, - { "cap": "ais.write" }, - { "cap": "net.tcp-client", "hosts": ["${config:host}"] } + { + "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 } + { + "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 } + { + "service": "nmea.source", + "apiVersion": 1 + } ] } From 5ae453b2fca5d3fd038805eb2b554f0a49fae177 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 17 Jul 2026 09:03:34 +0000 Subject: [PATCH 27/33] =?UTF-8?q?fix(vessel):=20clear=20a=20disabled=20sou?= =?UTF-8?q?rce's=20readings=20=E2=80=94=20no=20phantom=20own-ship?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal loss keeps the greyed last-known fix (ECDIS convention); deliberately disabling/removing a source now clears every path it wrote (provenance-keyed Store.ClearSource) and own-ship drops off instead of freezing forever. --- internal/engine/nmea/publish.go | 156 +++++++++++++++++++----------- internal/engine/server/plugins.go | 15 ++- web/src/plugins/own-ship.mjs | 5 +- 3 files changed, 116 insertions(+), 60 deletions(-) diff --git a/internal/engine/nmea/publish.go b/internal/engine/nmea/publish.go index fbcd013..34ce4b9 100644 --- a/internal/engine/nmea/publish.go +++ b/internal/engine/nmea/publish.go @@ -33,14 +33,14 @@ func (s *Store) PublishDeltas(source string, deltas []Delta) (int, error) { applied := 0 var firstErr error for _, d := range deltas { - set := vesselSetters[d.Path] - if set == nil { + ops, ok := vesselPaths[d.Path] + if !ok { if firstErr == nil { firstErr = fmt.Errorf("unknown vessel path %q", d.Path) } continue } - if err := set(&s.state, d.Value); err != nil { + if err := ops.set(&s.state, d.Value); err != nil { if firstErr == nil { firstErr = fmt.Errorf("%s: %w", d.Path, err) } @@ -55,6 +55,30 @@ func (s *Store) PublishDeltas(source string, deltas []Delta) (int, error) { 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 { @@ -68,65 +92,83 @@ func (s *Store) Provenance() map[string]string { } // ValidVesselPath reports whether path is a writable vessel path. -func ValidVesselPath(path string) bool { _, ok := vesselSetters[path]; return ok } +func ValidVesselPath(path string) bool { _, ok := vesselPaths[path]; return ok } -// vesselSetters maps a dotted path to a function that unmarshals a value into the -// corresponding VesselState field. This IS the vessel.write schema (spec §6). -var vesselSetters = map[string]func(*VesselState, json.RawMessage) error{ - "navigation.position": 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 +// 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": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.COGTrue }), - "navigation.sog": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.SOG }), - "navigation.headingTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.HeadingTrue }), - "navigation.headingMagnetic": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.HeadingMagnetic }), - "navigation.magneticVariation": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.MagneticVariation }), - "navigation.rateOfTurn": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.RateOfTurn }), - "navigation.speedThroughWater": floatSetter(func(vs *VesselState) **float64 { return &vs.Navigation.SpeedThroughWater }), - "navigation.datetime": 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 + "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": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowTransducer }), - "environment.depth.belowKeel": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowKeel }), - "environment.depth.belowSurface": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Depth.BelowSurface }), - "environment.water.temperature": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Water.Temperature }), - "environment.wind.angleApparent": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.AngleApparent }), - "environment.wind.speedApparent": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.SpeedApparent }), - "environment.wind.angleTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.AngleTrue }), - "environment.wind.speedTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.SpeedTrue }), - "environment.wind.directionTrue": floatSetter(func(vs *VesselState) **float64 { return &vs.Environment.Wind.DirectionTrue }), - "route.xte": floatSetter(func(vs *VesselState) **float64 { return &vs.Route.XTE }), - "route.bearingToWaypoint": floatSetter(func(vs *VesselState) **float64 { return &vs.Route.BearingToWaypoint }), - "route.distanceToWaypoint": floatSetter(func(vs *VesselState) **float64 { return &vs.Route.DistanceToWaypoint }), - "route.activeWaypoint": 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 + "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 = "" }, }, } -// floatSetter builds a setter that unmarshals a JSON number into a *float64 field -// addressed by get. -func floatSetter(get func(*VesselState) **float64) func(*VesselState, json.RawMessage) error { - return 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 +// 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/server/plugins.go b/internal/engine/server/plugins.go index b78edfb..af2eb3c 100644 --- a/internal/engine/server/plugins.go +++ b/internal/engine/server/plugins.go @@ -116,7 +116,14 @@ func (s *Server) servePluginItem(w http.ResponseWriter, r *http.Request) { case action == "enable" && r.Method == http.MethodPost: s.pluginErr(w, s.pluginMgr.Enable(id)) case action == "disable" && r.Method == http.MethodPost: - s.pluginErr(w, s.pluginMgr.Disable(id)) + 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"` @@ -136,7 +143,11 @@ func (s *Server) servePluginItem(w http.ResponseWriter, r *http.Request) { 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") != "" - s.pluginErr(w, s.pluginMgr.Remove(id, purge)) + 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") } diff --git a/web/src/plugins/own-ship.mjs b/web/src/plugins/own-ship.mjs index bf042b5..8332b8a 100644 --- a/web/src/plugins/own-ship.mjs +++ b/web/src/plugins/own-ship.mjs @@ -195,7 +195,10 @@ export default class OwnShip { const nav = s && s.navigation; const pos = nav && nav.position; if (!pos || typeof pos.lat !== "number") { - if (!this._fix) this._hide(); + // Position REMOVED from the store — a source deliberately disabled, not signal + // loss (loss keeps the last fix in the store and only ages it via _tickGps). + // Drop the boat entirely: a phantom own-ship at a stale spot is worse than none. + this._hide(); return; } From 2951812580380afd484092ecf02a5c1347059c14 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 17 Jul 2026 09:03:56 +0000 Subject: [PATCH 28/33] feat(web): settings redesign, plugin-owned connections, tap claims, developer mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings: fixed-geometry dialog (no layout shift), single scroll container with bottom fade, no nested frames, 7-tab rail (Chart absorbs Display/Text/ Depths; Units into General; Vessel = calibration); Advanced is now Developer, gated by a General toggle so production builds can ship it off - connections: no standalone tab — a data-source plugin (provides: nmea.source) DEFINES a connection type; its Plugins row drills into the connections view (rows, schema-driven add/edit, sniffer, pause) - tap arbitration: ctx.taps.claim(fn) — overlays own chart taps while active (wind probe), pick report stays the fallback - HTTP/1.1 socket budget: settings panels poll with transient fetches, never pin SSE sockets (6/origin; the core streams hold three) — fixes blank panels from fetch starvation - plugin rows: full-width descriptions; loading states; normalized buttons/ radii/spacing per the new style guide (docs/style-guide) --- docs/docs/style-guide.md | 151 +++++++++++++ docs/sidebars.js | 1 + web/src/chartplotter.mjs | 28 ++- web/src/chartplotter.view.mjs | 5 + web/src/core/core-settings.mjs | 35 +-- web/src/core/plugin-host.mjs | 9 + web/src/core/settings-registry.mjs | 17 +- web/src/data/connections-service.mjs | 60 +++++ web/src/plugins/calibration.mjs | 3 +- web/src/plugins/connections-panel.mjs | 266 +++++++++++++++++++---- web/src/plugins/connections.mjs | 36 --- web/src/plugins/dev-tools.mjs | 4 +- web/src/plugins/layers-panel.mjs | 2 +- web/src/plugins/plugins-manager.mjs | 15 +- web/src/plugins/plugins-panel.mjs | 99 +++++++-- web/src/plugins/settings-dialog.view.mjs | 60 ++--- 16 files changed, 642 insertions(+), 149 deletions(-) create mode 100644 docs/docs/style-guide.md delete mode 100644 web/src/plugins/connections.mjs 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/sidebars.js b/docs/sidebars.js
index f53a3f2..b4f92ea 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -25,6 +25,7 @@ const sidebars = {
         'plugins/plugins-examples',
       ],
     },
+    'style-guide',
     'tile-schema',
     'limitations',
   ],
diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs
index d9fe13d..1b3b54e 100644
--- a/web/src/chartplotter.mjs
+++ b/web/src/chartplotter.mjs
@@ -27,7 +27,6 @@ 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 { PluginHost } from "./core/plugin-host.mjs"; // loads builtin/plugin UI controllers with a declarative ctx
@@ -684,6 +683,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,
@@ -706,16 +706,13 @@ 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({
-        registry: this._settingsRegistry,
-        assets: this._assets,
-        notify: this._notify,
-      });
       // Plugins settings tab: install/enable/disable/grant/remove plugins.
       this._pluginsCtl = new PluginsController({
         registry: this._settingsRegistry,
@@ -734,6 +731,10 @@ export class ChartPlotter extends HTMLElement {
       // 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,
@@ -745,6 +746,7 @@ export class ChartPlotter extends HTMLElement {
         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,
@@ -1286,6 +1288,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);
diff --git a/web/src/chartplotter.view.mjs b/web/src/chartplotter.view.mjs
index 04ec9b5..5671eeb 100644
--- a/web/src/chartplotter.view.mjs
+++ b/web/src/chartplotter.view.mjs
@@ -415,6 +415,11 @@ export const STYLE = `
         #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(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 */
diff --git a/web/src/core/core-settings.mjs b/web/src/core/core-settings.mjs
index fd63dd6..8aaea78 100644
--- a/web/src/core/core-settings.mjs
+++ b/web/src/core/core-settings.mjs
@@ -83,8 +83,9 @@ export function coreSettingsContributions(app) {
   // calibration has its own "Calibration" tab (plugins/calibration.mjs).
   const general = {
     id: "core-general",
-    tab: { id: "general", label: "General" },
+    tab: { id: "general", label: "General", tabOrder: 0 },
     order: 0,
+    group: "App",
     get, set,
     // A function: the basemap options depend on whether a vector basemap is
     // configured (app._osmVecUrl is resolved during boot).
@@ -97,6 +98,10 @@ export function coreSettingsContributions(app) {
         key: "showChartRadar", type: "toggle", label: "Off-screen chart pointers",
         desc: "Edge arrows to installed charts you can't currently see — tap one to fly there",
       },
+      {
+        key: "developerMode", type: "toggle", label: "Developer tools", default: true,
+        desc: "Show the Developer tab (diagnostics, chart rebuilds, feature inspector). Production builds ship this off; flip it on when debugging.",
+      },
     ],
   };
 
@@ -112,7 +117,7 @@ export function coreSettingsContributions(app) {
   // the non-conformant Standard-off / Other-on state the old multi-select allowed.
   const displayDetail = {
     id: "core-display-detail",
-    tab: { id: "display", label: "Display" },
+    tab: { id: "chart", label: "Chart", tabOrder: 1 },
     order: 0.6,
     group: "Detail level",
     get, set,
@@ -128,7 +133,7 @@ export function coreSettingsContributions(app) {
   // Water, depth areas & soundings.
   const displayWater = {
     id: "core-display-water",
-    tab: "display",
+    tab: "chart",
     order: 0.7,
     group: "Water & soundings",
     get, set,
@@ -143,7 +148,7 @@ export function coreSettingsContributions(app) {
   // Point symbols & line styling.
   const displaySymbols = {
     id: "core-display-symbols",
-    tab: "display",
+    tab: "chart",
     order: 0.8,
     group: "Symbols & lines",
     get, set,
@@ -165,7 +170,7 @@ export function coreSettingsContributions(app) {
   // Dangers & data quality.
   const displayDangers = {
     id: "core-display-dangers",
-    tab: "display",
+    tab: "chart",
     order: 0.9,
     group: "Dangers & quality",
     get, set,
@@ -178,7 +183,7 @@ export function coreSettingsContributions(app) {
   // Chart boundaries & informational callouts.
   const displayBounds = {
     id: "core-display-bounds",
-    tab: "display",
+    tab: "chart",
     order: 0.95,
     group: "Boundaries & callouts",
     get, set,
@@ -200,7 +205,7 @@ export function coreSettingsContributions(app) {
   // the tab; the rest slot into it. Ordered (0.96+) so the tab sits after Display.
   const viewingGroups = VIEWING_GROUP_SECTIONS.map((s, i) => ({
     id: `core-vg-${s.id}`,
-    tab: i === 0 ? { id: "viewing-groups", label: "Viewing groups" } : "viewing-groups",
+    tab: i === 0 ? { id: "viewing-groups", label: "Viewing groups", tabOrder: 2 } : "viewing-groups",
     order: 0.96 + i * 0.001,
     group: s.label,
     get, set,
@@ -210,8 +215,9 @@ export function coreSettingsContributions(app) {
   // TEXT — the S-52 text-group toggles.
   const text = {
     id: "core-text",
-    tab: { id: "text", label: "Text" },
+    tab: "chart",
     order: 1,
+    group: "Text",
     get, set,
     // "Important text" (bridge/cable clearances, route bearings) has no toggle —
     // it is safety-critical and always shown (see textGroupFilter).
@@ -226,8 +232,9 @@ export function coreSettingsContributions(app) {
   // UNITS — one segmented picker per unit category (depth + the five others).
   const units = {
     id: "core-units",
-    tab: { id: "units", label: "Units" },
-    order: 2,
+    tab: "general",
+    order: 0.2,
+    group: "Units",
     get, set,
     items: UNIT_CATEGORIES.map((c) => ({ key: c.key, type: "segmented", label: c.label, options: c.opts, default: c.def })),
   };
@@ -236,8 +243,9 @@ export function coreSettingsContributions(app) {
   // (metres under the hood). A function so the unit/label/step track depthUnit.
   const depths = {
     id: "core-depths",
-    tab: { id: "depths", label: "Depths" },
-    order: 3,
+    tab: "chart",
+    order: 1.1,
+    group: "Depths",
     get, set,
     items: () => {
       const ft = app._mariner.depthUnit === "ft";
@@ -264,8 +272,9 @@ export function coreSettingsContributions(app) {
   // viewer there are no dev tools, so the tab is just the toggle.
   const advanced = {
     id: "core-advanced",
-    tab: { id: "advanced", label: "Advanced" },
+    tab: { id: "advanced", label: "Developer", tabOrder: 7 },
     order: 4,
+    when: () => get("developerMode", true),
     get, set,
     items: () => [
       {
diff --git a/web/src/core/plugin-host.mjs b/web/src/core/plugin-host.mjs
index 4e4901e..fe679cc 100644
--- a/web/src/core/plugin-host.mjs
+++ b/web/src/core/plugin-host.mjs
@@ -273,6 +273,15 @@ export class PluginHost {
         show: (info) => svc.showInfo && svc.showInfo(info),
       },
 
+      // Map tap arbitration. claim(fn): fn(e) is offered each chart tap (e is the
+      // MapLibre click event — lngLat, point, originalEvent); return true to consume
+      // it (the ECDIS pick report and later claims don't fire), anything else to
+      // pass. This is how an overlay owns taps while it's active without stealing
+      // them permanently.
+      taps: {
+        claim: (fn) => track(svc.registerTapClaim ? svc.registerTapClaim(fn) : () => {}),
+      },
+
       // Unit formatting honoring the live mariner prefs (closed over, not exposed).
       units: {
         format: (kind, value) => format(kind, value, svc.getUnits ? svc.getUnits() : null),
diff --git a/web/src/core/settings-registry.mjs b/web/src/core/settings-registry.mjs
index ad72b9a..c15dea2 100644
--- a/web/src/core/settings-registry.mjs
+++ b/web/src/core/settings-registry.mjs
@@ -55,13 +55,26 @@ export class SettingsRegistry {
     return [...this._byId.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.id).localeCompare(String(b.id)));
   }
 
+  // Contributions that are currently VISIBLE: an optional `when()` predicate lets a
+  // contribution (and any tab only it declares) hide itself — e.g. the Developer tab
+  // gated behind the developer-mode toggle. A throwing predicate fails open.
+  _visible() {
+    return this.list().filter((c) => {
+      try {
+        return !c.when || !!c.when();
+      } catch {
+        return true;
+      }
+    });
+  }
+
   // The tabs to show, derived from contributions. A contribution's `tab` is
   // either a string id (slot into a tab declared elsewhere) or {id,label} (which
   // also DECLARES that tab). Tabs appear in first-declaration/contribution order;
   // an explicit `tabOrder` on the {id,label} form overrides. Returns [{id,label}].
   tabs() {
     const seen = new Map();
-    for (const c of this.list()) {
+    for (const c of this._visible()) {
       const t = c.tab;
       if (!t) continue;
       const id = typeof t === "string" ? t : t.id;
@@ -74,7 +87,7 @@ export class SettingsRegistry {
 
   // Contributions targeting a given tab id (in render order).
   forTab(tabId) {
-    return this.list().filter((c) => {
+    return this._visible().filter((c) => {
       const t = c.tab;
       return t && (typeof t === "string" ? t : t.id) === tabId;
     });
diff --git a/web/src/data/connections-service.mjs b/web/src/data/connections-service.mjs
index af958ba..2e4f060 100644
--- a/web/src/data/connections-service.mjs
+++ b/web/src/data/connections-service.mjs
@@ -45,6 +45,66 @@ export class ConnectionsService {
     return j.connection;
   }
 
+  // --- plugin-provided sources ---------------------------------------------
+  // Plugins that declare `provides: [{service:"nmea.source"}]` are data sources
+  // too; the Connections panel is the single place they're configured. Their
+  // config form comes from the manifest's ui.settings.items schema; state maps
+  // onto the plugin lifecycle (pause = disable plugin).
+
+  /** Installed plugins that provide nmea.source: [{id, name, config, enabled,
+      status, items}] (items = the manifest settings schema for the form). */
+  async pluginSources() {
+    const r = await fetch(this._assets + "api/plugins", { cache: "no-store" });
+    const j = await r.json();
+    return (j.plugins || [])
+      .filter((p) => ((p.manifest && p.manifest.provides) || []).some((x) => x.service === "nmea.source"))
+      .map((p) => ({
+        id: p.record.id,
+        name: (p.manifest && p.manifest.name) || p.record.id,
+        config: p.record.config || {},
+        enabled: !!p.record.enabled,
+        status: p.status || {},
+        items: (p.manifest && p.manifest.ui && p.manifest.ui.settings && p.manifest.ui.settings.items) || [],
+      }));
+  }
+
+  /** Save a plugin source's connection config and make sure it's running. */
+  async savePluginSource(id, config) {
+    await this._sendOK("POST", `api/plugins/${encodeURIComponent(id)}/config`, config);
+    await this._sendOK("POST", `api/plugins/${encodeURIComponent(id)}/enable`, null);
+  }
+
+  /** Pause/resume a plugin source (disable/enable the plugin). */
+  pausePluginSource(id, run) {
+    return this._sendOK("POST", `api/plugins/${encodeURIComponent(id)}/${run ? "enable" : "disable"}`, null);
+  }
+
+  /** Subscribe to plugin status updates (for plugin-source badges). */
+  streamPluginStatuses(onPlugins) {
+    if (!window.EventSource) return () => {};
+    const es = new EventSource(this._assets + "api/plugins/stream");
+    es.onmessage = (ev) => {
+      let s;
+      try {
+        s = JSON.parse(ev.data);
+      } catch {
+        return;
+      }
+      onPlugins(s.plugins || []);
+    };
+    return () => es.close();
+  }
+
+  async _sendOK(method, path, body) {
+    const r = await fetch(this._assets + path, {
+      method,
+      headers: body != null ? { "Content-Type": "application/json" } : {},
+      body: body != null ? JSON.stringify(body) : undefined,
+    });
+    const j = await r.json();
+    if (!j.ok) throw new Error(j.error || "request failed");
+  }
+
   /**
    * Subscribe to the live status map (id → SourceStatus). Returns a stop function.
    */
diff --git a/web/src/plugins/calibration.mjs b/web/src/plugins/calibration.mjs
index dc51c42..283c086 100644
--- a/web/src/plugins/calibration.mjs
+++ b/web/src/plugins/calibration.mjs
@@ -21,7 +21,8 @@ const boxPx = (pitch) => Math.max(1, Math.round(REF_MM / pitch));
 export function calibrationContribution(app) {
   return {
     id: "calibration",
-    tab: { id: "calibration", label: "Calibration" },
+    tab: { id: "vessel", label: "Vessel", tabOrder: 4 },
+    group: "Screen calibration",
     order: 4,
     render: (host) => renderCalibration(host, app),
   };
diff --git a/web/src/plugins/connections-panel.mjs b/web/src/plugins/connections-panel.mjs
index 6c03250..a752d3e 100644
--- a/web/src/plugins/connections-panel.mjs
+++ b/web/src/plugins/connections-panel.mjs
@@ -7,53 +7,57 @@
 // Structural changes (add/edit/delete) re-render the list; status updates patch
 // each row's badge in place so an open sniffer/form isn't disturbed.
 
+// Styling follows the shared panel conventions (docs/docs/style-guide.md): 13px
+// body type, 8px-radius buttons, 10px-radius row cards on --ui-surface, 12px card
+// padding, 92px form labels. The sniffer 
 is a documented exception to the
+// one-scroll-container rule (log viewers may scroll internally, capped height).
 const STYLE = `
   :host { display: block; color: var(--ui-text, #e6edf3); font-size: 13px; }
-  .bar { display: flex; justify-content: flex-end; margin-bottom: 10px; }
+  .bar { display: flex; justify-content: flex-end; margin-bottom: 12px; }
   button {
     background: var(--ui-surface-2, #21262d); color: var(--ui-text, #e6edf3);
-    border: 1px solid var(--ui-border, #30363d); border-radius: 6px;
-    padding: 5px 10px; font: inherit; cursor: pointer;
+    border: 1px solid var(--ui-border, #30363d); border-radius: 8px;
+    padding: 6px 12px; font: inherit; cursor: pointer; white-space: nowrap;
     touch-action: manipulation; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;
   }
   button:hover { background: var(--ui-hover, #30363d); }
   button.primary { background: var(--ui-accent, #2f81f7); color: var(--ui-accent-text, #fff); border-color: transparent; }
-  button.icon { padding: 4px 7px; line-height: 1; min-width: var(--tap-min, 44px); min-height: var(--tap-min, 44px); display: inline-flex; align-items: center; justify-content: center; }
-  button.icon[data-act=del] { padding: 4px 11px; }
-  .empty { color: var(--ui-text-dim, #8b949e); padding: 18px 4px; text-align: center; }
+  button.icon { padding: 6px 8px; line-height: 1; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
+  .empty { color: var(--ui-text-dim, #8b949e); padding: 26px 4px; text-align: center; border: 1px dashed var(--ui-border, #30363d); border-radius: 10px; }
 
   .row {
-    display: flex; align-items: center; gap: 10px;
-    padding: 9px 10px; border: 1px solid var(--ui-border, #30363d);
-    border-radius: 8px; margin-bottom: 8px; background: var(--ui-surface, #161b22);
+    display: flex; align-items: center; gap: 12px;
+    padding: 12px 14px; border: 1px solid var(--ui-border, #30363d);
+    border-radius: 10px; margin-bottom: 10px; background: var(--ui-surface, #161b22);
   }
   .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: #6e7681; }
   .info { flex: 1; min-width: 0; }
-  .name { font-weight: 600; display: flex; align-items: center; gap: 8px; }
+  .name { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 8px; }
   .badge { font-size: 11px; font-weight: 500; color: var(--ui-text-dim, #8b949e); text-transform: uppercase; letter-spacing: .03em; }
-  .meta { color: var(--ui-text-dim, #8b949e); font-size: 12px; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .actions { display: flex; align-items: center; gap: 8px; flex: none; }
+  .meta { color: var(--ui-text-dim, #8b949e); font-size: 12px; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+  .actions { display: flex; align-items: center; gap: 7px; flex: none; }
 
   pre.sniff {
-    margin: -4px 0 8px; max-height: 160px; overflow: auto;
+    margin: -6px 0 10px; max-height: 160px; overflow: auto;
     overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
     background: var(--ui-bg, #0d1117); border: 1px solid var(--ui-border, #30363d);
-    border-radius: 8px; padding: 8px; font-size: 11px; line-height: 1.45;
+    border-radius: 10px; padding: 10px; font-size: 11px; line-height: 1.45;
     color: var(--ui-text-dim, #8b949e); white-space: pre-wrap; word-break: break-all;
   }
 
-  .form { border: 1px solid var(--ui-border, #30363d); border-radius: 8px; padding: 12px; margin-bottom: 12px; background: var(--ui-surface, #161b22); }
-  .form h4 { margin: 0 0 10px; font-size: 13px; }
-  .field { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
-  .field label { width: 80px; color: var(--ui-text-dim, #8b949e); flex: none; }
-  .field input[type=text], .field input[type=number] {
-    flex: 1; background: var(--ui-bg, #0d1117); color: var(--ui-text, #e6edf3);
-    border: 1px solid var(--ui-border, #30363d); border-radius: 6px; padding: 5px 8px; font: inherit;
+  .form { border: 1px solid var(--ui-border, #30363d); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; background: var(--ui-surface, #161b22); }
+  .form h4 { margin: 0 0 12px; font-size: 12px; color: var(--ui-text-dim, #8b949e); text-transform: uppercase; letter-spacing: .03em; }
+  .field { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
+  .field label { width: 92px; color: var(--ui-text-dim, #8b949e); flex: none; }
+  .field input[type=text], .field input[type=number], .field select {
+    flex: 1; min-width: 0; background: var(--ui-bg, #0d1117); color: var(--ui-text, #e6edf3);
+    border: 1px solid var(--ui-border-strong, #444c56); border-radius: 7px; padding: 7px 9px; font: inherit;
     font-size: 16px; /* >=16px or iOS zooms the page on focus */
   }
   .field .static { color: var(--ui-text-dim, #8b949e); }
   .form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
   .err { color: #f85149; font-size: 12px; margin-top: 6px; }
+  @media (pointer:coarse) { button { min-height: var(--tap-min, 44px); } }
 `;
 
 // state → [dot colour, badge label]
@@ -74,11 +78,14 @@ export class ConnectionsPanel extends HTMLElement {
     this._service = null;
     this._notify = null;
     this._conns = []; // [{source, status}]
-    this._editing = null; // null | "new" | ""
+    this._plugSources = []; // plugin-provided sources (see ConnectionsService.pluginSources)
+    this._editing = null; // null | "new" | "" | "plugin:"
+    this._newKind = "tcp"; // add-form type: "tcp" | "plugin:"
     this._sniffId = null; // id of the connection whose sniffer is open
     this._sniffStop = null;
     this._sniffLines = [];
     this._statusStop = null;
+    this._plugStop = null;
   }
 
   configure({ service, notify }) {
@@ -86,13 +93,42 @@ export class ConnectionsPanel extends HTMLElement {
     this._notify = notify;
   }
 
+  // Scope the panel to one data-source plugin (the Plugins-tab drill-down): only
+  // that plugin's source row and add-form entry are shown. Built-in TCP
+  // connections always show — they're NMEA sources too, managed in the same view.
+  setPlugin(pluginId) {
+    if (this._pluginId !== pluginId) {
+      this._pluginId = pluginId || null;
+      this._editing = null;
+      this.refresh();
+    }
+  }
+
   connectedCallback() {
     this.shadowRoot.innerHTML = `
`; - this.shadowRoot.getElementById("root").addEventListener("click", (e) => this._onClick(e)); + const root = this.shadowRoot.getElementById("root"); + root.addEventListener("click", (e) => this._onClick(e)); + // The add-form's type picker swaps the field set (delegated: the form re-renders). + root.addEventListener("change", (e) => { + if (e.target && e.target.id === "f-kind") { + this._newKind = e.target.value; + this._render(); + } + }); this.refresh(); - // Live badges while the tab is open. + // Live badges for the built-in runners via POLLING (transient fetches), not SSE: + // the app is HTTP/1.1 (6 sockets/origin) and the core streams already hold + // three — a settings panel must not pin more (see style guide). Plugin-source + // rows are pushed down by the host panel via pluginsPush(). if (this._service) { - this._statusStop = this._service.streamStatuses((statuses) => this._applyStatuses(statuses)); + const tick = async () => { + const conns = await this._service.list(); + const statuses = {}; + for (const c of conns) statuses[c.source.id] = c.status; + this._applyStatuses(statuses); + }; + const t = setInterval(() => { tick().catch(() => {}); }, 2000); + this._statusStop = () => clearInterval(t); } } @@ -102,6 +138,27 @@ export class ConnectionsPanel extends HTMLElement { this._stopSniff(); } + // pluginsPush receives the host's live plugin list (from its existing stream) and + // patches the plugin-source rows in place. + pluginsPush(plugins) { + const rows = (plugins || []) + .filter((p) => ((p.manifest && p.manifest.provides) || []).some((x) => x.service === "nmea.source")) + .filter((p) => !this._pluginId || p.record.id === this._pluginId) + .map((p) => ({ + id: p.record.id, + name: (p.manifest && p.manifest.name) || p.record.id, + config: p.record.config || {}, + enabled: !!p.record.enabled, + status: p.status || {}, + items: (p.manifest && p.manifest.ui && p.manifest.ui.settings && p.manifest.ui.settings.items) || [], + })); + const structural = rows.length !== this._plugSources.length || + rows.some((r, i) => r.id !== this._plugSources[i].id || r.enabled !== this._plugSources[i].enabled); + this._plugSources = rows; + if (structural && !this._editing) this._render(); + else for (const p of rows) this._patchPluginStatus(p); + } + async refresh() { if (!this._service) return; try { @@ -110,6 +167,14 @@ export class ConnectionsPanel extends HTMLElement { console.warn("[connections] list", e); this._conns = []; } + try { + this._plugSources = await this._service.pluginSources(); + if (this._pluginId) this._plugSources = this._plugSources.filter((p) => p.id === this._pluginId); + } catch (e) { + console.warn("[connections] plugin sources", e); + this._plugSources = []; + } + this._loaded = true; this._render(); } @@ -120,17 +185,68 @@ export class ConnectionsPanel extends HTMLElement { if (!root) return; let html = ""; if (this._editing) html += this._formHtml(); - html += `
`; - if (!this._conns.length) { - html += `
No connections yet. Add a TCP source (e.g. a multiplexer on your boat network).
`; + else html += `
`; + // Only plugin sources that are configured (or running) show as rows; an + // installed-but-unconfigured provider only appears in the add-form type picker. + const activePlug = this._plugSources.filter((p) => p.enabled || Object.keys(p.config || {}).length); + if (!this._conns.length && !activePlug.length) { + html += `
${this._loaded + ? "No connections yet. Add a TCP source (e.g. a multiplexer on your boat network)." + : "Loading…"}
`; } else { html += this._conns.map((c) => this._rowHtml(c)).join(""); + html += activePlug.map((p) => this._pluginRowHtml(p)).join(""); } root.innerHTML = html; for (const c of this._conns) this._patchStatus(c.source.id, c.status); + for (const p of activePlug) this._patchPluginStatus(p); if (this._sniffId) this._renderSniff(); } + // A plugin-provided source row. Same look as a built-in connection; the badge is + // derived from the plugin lifecycle + its status detail, and "pause" maps to + // disabling the plugin. Removal lives in the Plugins tab (it uninstalls code, not + // just a connection), so there's no delete button here. + _pluginRowHtml(p) { + const id = p.id; + return ` +
+
+
+
${esc(p.name)} plugin
+
+
+
+ + + +
+
+
`;
+  }
+
+  // Map a plugin's lifecycle state onto the connection badge vocabulary.
+  _patchPluginStatus(p) {
+    const dot = this.shadowRoot.getElementById(`dot-${p.id}`);
+    const badge = this.shadowRoot.getElementById(`badge-${p.id}`);
+    const meta = this.shadowRoot.getElementById(`meta-${p.id}`);
+    if (!dot || !badge || !meta) return;
+    const detail = p.status.detail || "";
+    const state = !p.enabled ? "disabled"
+      : p.status.state === "running" ? (/connected|step\(s\)/.test(detail) ? "connected" : "connecting")
+        : p.status.state === "error" || p.status.state === "degraded" ? "error"
+          : "connecting";
+    const [color, label] = BADGE[state] || BADGE.disabled;
+    dot.style.background = color;
+    badge.textContent = label;
+    const where = [p.config.host, p.config.port].filter((x) => x != null && x !== "").join(":");
+    const detailText = !p.enabled ? "paused" : detail;
+    // Don't repeat the endpoint when the status line already names it.
+    meta.textContent = detailText && detailText.includes(where) && where
+      ? detailText : [where, detailText].filter(Boolean).join(" · ");
+    meta.title = meta.textContent;
+  }
+
   _rowHtml({ source, status }) {
     const id = source.id;
     const where = `${source.host}:${source.port}`;
@@ -142,7 +258,7 @@ export class ConnectionsPanel extends HTMLElement {
           
${esc(where)}
- + @@ -152,16 +268,45 @@ export class ConnectionsPanel extends HTMLElement { } _formHtml() { - // 10110 is the IANA-registered NMEA-0183-over-IP port — the sensible default. - const src = this._editing === "new" ? { port: 10110 } : (this._conns.find((c) => c.source.id === this._editing) || {}).source || {}; - return ` -
-

${this._editing === "new" ? "Add connection" : "Edit connection"}

+ const editingPlugin = String(this._editing).startsWith("plugin:") + ? this._plugSources.find((p) => p.id === this._editing.slice(7)) + : null; + const isNew = this._editing === "new"; + const kind = editingPlugin ? `plugin:${editingPlugin.id}` : isNew ? this._newKind : "tcp"; + // The type picker: the built-in TCP client plus every installed plugin that + // provides nmea.source — connections are configured HERE, the plugin only + // supplies the transport + its config schema. + const kindField = isNew + ? `
` + : `
${editingPlugin ? esc(editingPlugin.name) + " · plugin" : "TCP client"}
`; + + let fields; + if (kind.startsWith("plugin:")) { + const p = editingPlugin || this._plugSources.find((x) => `plugin:${x.id}` === kind); + fields = (p ? p.items : []).map((it) => { + const cur = p && p.config[it.key] != null ? p.config[it.key] : it.default != null ? it.default : ""; + const type = it.type === "number" ? "number" : "text"; + return `
+
`; + }).join(""); + } else { + // 10110 is the IANA-registered NMEA-0183-over-IP port — the sensible default. + const src = isNew ? { port: 10110 } : (this._conns.find((c) => c.source.id === this._editing) || {}).source || {}; + fields = `
-
TCP client
-
+
`; + } + return ` +
+

${isNew ? "Add connection" : "Edit connection"}

+ ${kindField} + ${fields}
@@ -264,6 +409,7 @@ export class ConnectionsPanel extends HTMLElement { case "add": this._stopSniff(); this._editing = "new"; + this._newKind = "tcp"; this._render(); break; case "edit": @@ -271,6 +417,14 @@ export class ConnectionsPanel extends HTMLElement { this._editing = id; this._render(); break; + case "pedit": + this._stopSniff(); + this._editing = "plugin:" + id; + this._render(); + break; + case "ptoggle": + this._togglePluginPause(id); + break; case "cancel": this._editing = null; this._render(); @@ -325,12 +479,46 @@ export class ConnectionsPanel extends HTMLElement { } } + // Pause/resume a plugin source (disable/enable the whole plugin — it IS the + // transport). The server clears its vessel readings on disable. + async _togglePluginPause(id) { + const p = this._plugSources.find((x) => x.id === id); + if (!p) return; + if (this._sniffId === id) this._stopSniff(); + try { + await this._service.pausePluginSource(id, !p.enabled); + await this.refresh(); + } catch (e) { + if (this._notify) this._notify.error("Connection: " + (e.message || e)); + } + } + + // _pluginFormValues reads the schema-driven fields into a config object. + _pluginFormValues() { + const cfg = {}; + this.shadowRoot.querySelectorAll("[data-cfg-key]").forEach((inp) => { + const v = inp.type === "number" ? parseFloat(inp.value) : inp.value.trim(); + if (v !== "" && !(typeof v === "number" && !isFinite(v))) cfg[inp.dataset.cfgKey] = v; + }); + return cfg; + } + async _save() { - const cfg = this._formValues(); const editing = this._editing; + const pluginId = String(editing).startsWith("plugin:") ? editing.slice(7) + : editing === "new" && this._newKind.startsWith("plugin:") ? this._newKind.slice(7) + : null; try { - if (editing === "new") await this._service.create(cfg); - else await this._service.update(editing, cfg); + if (pluginId) { + const p = this._plugSources.find((x) => x.id === pluginId); + // Preserve config keys the connection form doesn't own (e.g. non-connection + // settings a plugin may also have). + await this._service.savePluginSource(pluginId, { ...((p && p.config) || {}), ...this._pluginFormValues() }); + } else if (editing === "new") { + await this._service.create(this._formValues()); + } else { + await this._service.update(editing, this._formValues()); + } this._editing = null; await this.refresh(); } catch (e) { diff --git a/web/src/plugins/connections.mjs b/web/src/plugins/connections.mjs deleted file mode 100644 index f397354..0000000 --- a/web/src/plugins/connections.mjs +++ /dev/null @@ -1,36 +0,0 @@ -// ConnectionsController contributes the "Connections" settings tab — the NMEA0183 -// data-source manager. Like DevTools, it is a plain class (not a custom element) -// built once the app is ready; it registers a settings contribution whose -// render(host) mounts a persistent into the tab. The panel -// keeps its own state (open form/sniffer) across re-renders because the same -// element instance is moved into each fresh host. - -import "./connections-panel.mjs"; -import { ConnectionsService } from "../data/connections-service.mjs"; - -export class ConnectionsController { - constructor(deps = {}) { - this._service = new ConnectionsService({ assets: deps.assets || "/" }); - this._notify = deps.notify; - this._panel = null; - this._unregister = deps.registry.register({ - id: "connections", - tab: { id: "connections", label: "Connections" }, - order: 4, - render: (host) => this._render(host), - }); - } - - _render(host) { - if (!this._panel) { - this._panel = document.createElement("connections-panel"); - this._panel.configure({ service: this._service, notify: this._notify }); - } - host.appendChild(this._panel); // moving preserves the panel's state - } - - destroy() { - if (this._unregister) this._unregister(); - this._unregister = null; - } -} diff --git a/web/src/plugins/dev-tools.mjs b/web/src/plugins/dev-tools.mjs index bbcb01a..a5a04e8 100644 --- a/web/src/plugins/dev-tools.mjs +++ b/web/src/plugins/dev-tools.mjs @@ -79,6 +79,7 @@ export class DevTools { constructor(deps = {}) { this._d = deps; this._map = deps.map || null; + this._devMode = deps.devMode || null; // () => bool: gates the Developer tab this._host = null; // the contribution's custom-render host (a div in the dialog shadow) this._active = false; // the Advanced tab is currently showing // Inspector state (lifted from the shell). @@ -105,8 +106,9 @@ export class DevTools { if (!registry) return; this._unregister = registry.register({ id: "dev-tools", - tab: { id: "advanced", label: "Advanced" }, + tab: { id: "advanced", label: "Developer", tabOrder: 7 }, order: 5, // after the core "Cell boundaries" toggle (order 4) on the same tab + when: () => (this._devMode ? this._devMode() : true), render: (host) => this._render(host), }); } diff --git a/web/src/plugins/layers-panel.mjs b/web/src/plugins/layers-panel.mjs index f0e0d9f..a23fd0d 100644 --- a/web/src/plugins/layers-panel.mjs +++ b/web/src/plugins/layers-panel.mjs @@ -12,7 +12,7 @@ export class LayersController { this._panel = null; this._unregister = registry.register({ id: "layers", - tab: { id: "layers", label: "Layers" }, + tab: { id: "layers", label: "Layers", tabOrder: 3 }, order: 3, render: (host) => this._render(host), }); diff --git a/web/src/plugins/plugins-manager.mjs b/web/src/plugins/plugins-manager.mjs index 373060f..2049563 100644 --- a/web/src/plugins/plugins-manager.mjs +++ b/web/src/plugins/plugins-manager.mjs @@ -1,20 +1,21 @@ // PluginsController contributes the "Plugins" settings tab — install and manage -// plugins. Like ConnectionsController it is a plain class built once the app is -// ready; it registers a settings contribution whose render(host) mounts a persistent -// into the tab. The panel keeps its own state (open grant editor) -// across re-renders because the same element instance is moved into each fresh host. +// plugins. A plain class built once the app is ready; it registers a settings +// contribution whose render(host) mounts a persistent into the tab. +// The panel keeps its own state (open grant editor / connections drill-down) across +// re-renders because the same element instance is moved into each fresh host. import "./plugins-panel.mjs"; import { PluginsService } from "../data/plugins-service.mjs"; export class PluginsController { constructor(deps = {}) { - this._service = new PluginsService({ assets: deps.assets || "/" }); + this._assets = deps.assets || "/"; + this._service = new PluginsService({ assets: this._assets }); this._notify = deps.notify; this._panel = null; this._unregister = deps.registry.register({ id: "plugins", - tab: { id: "plugins", label: "Plugins" }, + tab: { id: "plugins", label: "Plugins", tabOrder: 6 }, order: 5, render: (host) => this._render(host), }); @@ -23,7 +24,7 @@ export class PluginsController { _render(host) { if (!this._panel) { this._panel = document.createElement("plugins-panel"); - this._panel.configure({ service: this._service, notify: this._notify }); + this._panel.configure({ service: this._service, notify: this._notify, assets: this._assets }); } host.appendChild(this._panel); // moving preserves the panel's state } diff --git a/web/src/plugins/plugins-panel.mjs b/web/src/plugins/plugins-panel.mjs index 37c6fe0..8803a82 100644 --- a/web/src/plugins/plugins-panel.mjs +++ b/web/src/plugins/plugins-panel.mjs @@ -6,6 +6,13 @@ // // Status ticks patch each row's badge in place; structural changes (install / enable // / grant or config edits / remove) re-render the list. +// +// Plugins that provide nmea.source DEFINE a connection type: their Configure button +// drills into a pushed view (back returns to the list) — there +// is no separate Connections tab. + +import "./connections-panel.mjs"; +import { ConnectionsService } from "../data/connections-service.mjs"; const STYLE = ` :host { display: block; color: var(--ui-text, #e6edf3); font-size: 13px; } @@ -24,16 +31,20 @@ const STYLE = ` .empty { color: var(--ui-text-dim, #8b949e); padding: 26px 4px; text-align: center; border: 1px dashed var(--ui-border, #30363d); border-radius: 10px; } .row { - display: flex; align-items: center; gap: 12px; + display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border: 1px solid var(--ui-border, #30363d); border-radius: 10px; margin-bottom: 10px; background: var(--ui-surface, #161b22); } .row.open { border-bottom-left-radius: 0; border-bottom-right-radius: 0; margin-bottom: 0; } + .row .head { display: flex; align-items: center; gap: 12px; min-width: 0; } .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: #6e7681; } .info { flex: 1; min-width: 0; } .name { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .badge { font-size: 11px; font-weight: 500; color: var(--ui-text-dim, #8b949e); text-transform: uppercase; letter-spacing: .03em; } - .meta { color: var(--ui-text-dim, #8b949e); font-size: 12px; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + /* The description spans the FULL row width under the header line — room to say + what the plugin actually does instead of an ellipsised fragment. */ + .desc { color: var(--ui-text-dim, #8b949e); font-size: 12px; line-height: 1.5; max-width: 64ch; } + .meta { color: var(--ui-text-faint, #6e7681); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .actions { display: flex; align-items: center; gap: 7px; flex: none; flex-wrap: wrap; justify-content: flex-end; } .editor { @@ -62,6 +73,7 @@ const STYLE = ` .editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } .err { color: #f85149; font-size: 12px; margin: 8px 0; } input[type=file] { display: none; } + @media (pointer:coarse) { button { min-height: var(--tap-min, 44px); } } `; // plugin status.State → [dot colour, badge label] @@ -104,16 +116,26 @@ export class PluginsPanel extends HTMLElement { this._err = ""; } - configure({ service, notify }) { + configure({ service, notify, assets }) { this._service = service; this._notify = notify; + this._assets = assets || "/"; + } + + // A plugin that provides nmea.source defines a connection type — its Configure + // drills into the connections view instead of a raw config form. + _isSource(p) { + return ((p.manifest && p.manifest.provides) || []).some((x) => x.service === "nmea.source"); } connectedCallback() { this.shadowRoot.innerHTML = `
`; this._root = this.shadowRoot.getElementById("root"); this._load(); - this._stop = this._service.stream((plugins) => this._onStream(plugins)); + // Poll (transient fetches) instead of SSE: the app is HTTP/1.1 with a 6-socket + // budget per origin, and the core streams (vessel, AIS, plugin-host) already + // hold three. A settings panel doesn't justify pinning a socket while open. + this._stop = this._pollLoop(2000, async () => this._onStream(await this._service.list())); } disconnectedCallback() { @@ -121,24 +143,62 @@ export class PluginsPanel extends HTMLElement { this._stop = null; } + _pollLoop(ms, fn) { + const t = setInterval(() => { fn().catch(() => {}); }, ms); + return () => clearInterval(t); + } + async _load() { + this._render(); // paint the frame (with a loading hint) before data lands try { this._plugins = await this._service.list(); this._err = ""; } catch (e) { this._err = e.message || String(e); } + this._loaded = true; this._render(); } _onStream(plugins) { const same = structureSig(plugins) === structureSig(this._plugins); this._plugins = plugins; + // Drill-down open: feed the child panel from THIS stream (it opens none of its + // own — HTTP/1.1 socket budget) and leave the DOM alone; re-mounting the child + // on every status tick would reset it mid-interaction. + if (this._open && this._open.mode === "connections") { + if (this._connPanel) this._connPanel.pluginsPush(plugins); + return; + } if (same && !this._open) this._patchBadges(); else this._render(); } _render() { + // Connections drill-down: a plugin that defines a connection type pushes its + // connections view over the list (back returns). The is a + // persistent element so its state (open form/sniffer) survives re-renders. + if (this._open && this._open.mode === "connections") { + const p = this._plugins.find((x) => x.record.id === this._open.id); + const name = (p && p.manifest && p.manifest.name) || this._open.id; + this._root.innerHTML = ` +
+ + ${esc(name)} — connections +
+
`; + this._root.querySelector("#back").onclick = () => { this._open = null; this._render(); }; + if (!this._connPanel) { + this._connPanel = document.createElement("connections-panel"); + this._connPanel.configure({ + service: new ConnectionsService({ assets: this._assets }), + notify: this._notify, + }); + } + this._connPanel.setPlugin(this._open.id); + this._root.querySelector("#conn-host").appendChild(this._connPanel); + return; + } const rows = this._plugins.map((p) => this._rowHTML(p)).join(""); this._root.innerHTML = `
@@ -147,7 +207,7 @@ export class PluginsPanel extends HTMLElement {
${this._err ? `
${esc(this._err)}
` : ""} - ${this._plugins.length ? rows : `
No plugins installed yet.
`} + ${this._plugins.length ? rows : `
${this._loaded ? "No plugins installed yet." : "Loading…"}
`} `; this._root.querySelector("#install").onclick = () => this._root.querySelector("#file").click(); this._root.querySelector("#file").onchange = (e) => this._install(e.target.files[0]); @@ -169,17 +229,20 @@ export class PluginsPanel extends HTMLElement { const hasCaps = man.capabilities && man.capabilities.length; return `
- -
-
${esc(name)} ${esc(label)}
-
${esc(id)} · v${esc(man.version || p.record.version || "?")}${detail ? " · " + esc(detail) : ""}
-
-
- - ${hasCaps ? `` : ""} - - +
+ +
+
${esc(name)} ${esc(label)}
+
+
+ + ${hasCaps ? `` : ""} + + +
+ ${man.description ? `
${esc(man.description)}
` : ""} +
${esc(id)} · v${esc(man.version || p.record.version || "?")}${detail ? " · " + esc(detail) : ""}
`; } @@ -207,7 +270,11 @@ export class PluginsPanel extends HTMLElement { this._open = null; await this._load(); } else if (act === "grants" || act === "config") { - this._open = this._open && this._open.id === id && this._open.mode === act ? null : { id, mode: act }; + // Data-source plugins drill into their connections view; others get the + // inline config/grants editor. + const p = this._plugins.find((x) => x.record.id === id); + const mode = act === "config" && p && this._isSource(p) ? "connections" : act; + this._open = this._open && this._open.id === id && this._open.mode === mode ? null : { id, mode }; this._render(); } } catch (e) { diff --git a/web/src/plugins/settings-dialog.view.mjs b/web/src/plugins/settings-dialog.view.mjs index 5e6f2de..44c4fc4 100644 --- a/web/src/plugins/settings-dialog.view.mjs +++ b/web/src/plugins/settings-dialog.view.mjs @@ -15,39 +15,47 @@ import { esc } from "../lib/util.mjs"; +// Layout rules (see docs/docs/style-guide.md — "Layout stability" + "One scroll +// container"): the shell has a FIXED height so the dialog never resizes as tabs +// change, the PANE is the only scroll container (the drawer body must never also +// scroll), and the drawer supplies the frame — no nested borders around the shell. export const STYLE = ` :host { display:block; } - #body { padding-top:2px; } - .set-shell { display:flex; align-items:stretch; border:1px solid var(--ui-border-2); border-radius:11px; overflow:hidden; min-height:420px; max-height:min(82vh,860px); max-height:min(82dvh,860px); } - .set-rail { flex:0 0 124px; display:flex; flex-direction:column; gap:3px; padding:8px 7px; border-right:1px solid var(--ui-border-2); background:var(--ui-surface-2); } - .set-rail button { text-align:left; border:none; background:none; color:var(--ui-text-dim); font:inherit; font-size:13px; font-weight:600; padding:9px 11px; border-radius:8px; cursor:pointer; transition:background .1s,color .1s; } - .set-rail button:hover { background:var(--ui-surface); color:var(--ui-text); } + .set-shell { display:flex; align-items:stretch; height:min(62dvh,620px); } + .set-rail { flex:0 0 136px; display:flex; flex-direction:column; gap:2px; padding:2px 12px 2px 0; + border-right:1px solid var(--ui-border-2); overflow-y:auto; overscroll-behavior:contain; } + .set-rail button { text-align:left; border:none; background:none; color:var(--ui-text-dim); font:inherit; + font-size:13px; font-weight:600; padding:8px 12px; border-radius:8px; cursor:pointer; transition:background .1s,color .1s; } + .set-rail button:hover { background:var(--ui-hover); color:var(--ui-text); } .set-rail button.sel { background:var(--ui-accent); color:var(--ui-accent-text); } /* No top padding: the first section header pins flush to the very top, so no row can peek through the gap above a stuck sticky header. */ - .set-pane { flex:1 1 0; min-width:0; overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; padding:0 18px 10px; } + .set-pane { flex:1 1 0; min-width:0; overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; + padding:0 6px 20px 20px; + /* A short fade at the cut edge so a clipped row reads as "scroll for more". */ + mask-image:linear-gradient(to bottom, #000 calc(100% - 14px), transparent); + -webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - 14px), transparent); } /* Section header: STICKS to the top of the scrolling pane until the next section's - header slides up and takes its place. A full-bleed opaque bar (the pane sits on - --ui-surface) so rows scroll cleanly underneath. Mirrors .mcol-head in the chart - library. */ - .set-group { position:sticky; top:0; z-index:2; margin:0 -18px; padding:9px 20px 7px; - font-size:11.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; - color:var(--ui-text-dim); background:var(--ui-surface); border-bottom:1px solid var(--ui-border-2); } + header slides up and takes its place. Full-bleed and opaque in the DRAWER's + background so rows scroll cleanly underneath without a seam. */ + .set-group { position:sticky; top:0; z-index:2; margin:0 -6px 0 -20px; padding:10px 6px 6px 20px; + font-size:11px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; + color:var(--ui-text-dim); background:var(--ui-bg); border-bottom:1px solid var(--ui-border-2); } .set-host { /* a contribution's custom-render slot */ } .set-host .dev-tools { border-top:1px solid var(--ui-border-2); margin-top:8px; } /* The row stacks: a header line (label + control side-by-side) and, beneath it, the description spanning the FULL row width so it doesn't wrap inside the narrow label column when a control sits beside it. */ - .set-row { display:flex; flex-direction:column; padding:13px 2px; border-bottom:1px solid var(--ui-border-2); } + .set-row { display:flex; flex-direction:column; padding:12px 0; border-bottom:1px solid var(--ui-border-2); } .set-row:last-child { border-bottom:none; } - .set-row .set-head { display:flex; align-items:center; gap:14px; } + .set-row .set-head { display:flex; align-items:center; gap:16px; } .set-row .t { font-weight:600; font-size:13.5px; flex:1 1 auto; min-width:0; } - .set-row .d { font-size:12px; color:var(--ui-text-faint); margin-top:6px; line-height:1.45; } + .set-row .d { font-size:12px; color:var(--ui-text-faint); margin-top:4px; line-height:1.5; max-width:56ch; } .set-row .ctl { flex:none; margin-left:auto; display:flex; align-items:center; gap:6px; } - .set-row .ctl input[type=number] { width:58px; text-align:right; border:1px solid var(--ui-border-strong); border-radius:6px; padding:5px 7px; font:inherit; font-size:16px; background:var(--ui-surface); color:var(--ui-text); } + .set-row .ctl input[type=number] { width:64px; text-align:right; border:1px solid var(--ui-border-strong); border-radius:7px; padding:6px 8px; font:inherit; font-size:16px; background:var(--ui-surface); color:var(--ui-text); } .set-row .ctl .unit { color:var(--ui-text-faint); font-size:12px; min-width:14px; } - .set-row .ctl select { border:1px solid var(--ui-border-strong); border-radius:6px; padding:5px 8px; font:inherit; font-size:16px; background:var(--ui-surface); color:var(--ui-text); } + .set-row .ctl select { border:1px solid var(--ui-border-strong); border-radius:7px; padding:6px 8px; font:inherit; font-size:16px; background:var(--ui-surface); color:var(--ui-text); } .set-rail button, .seg button, .switch, .set-row .ctl input[type=number], .set-row .ctl select { touch-action:manipulation; -webkit-touch-callout:none; -webkit-user-select:none; user-select:none; } .switch { position:relative; width:38px; height:22px; display:inline-block; flex:none; } @@ -57,21 +65,23 @@ export const STYLE = ` .switch input:checked + .sl { background:var(--ui-accent); } .switch input:checked + .sl:before { transform:translateX(16px); } - .seg { display:inline-flex; border:1px solid var(--ui-border-strong); border-radius:7px; overflow:hidden; } - .seg button { border:none; background:var(--ui-surface); padding:6px 11px; font:inherit; font-size:13px; cursor:pointer; border-left:1px solid var(--ui-border-2); color:var(--ui-text); } + .seg { display:inline-flex; border:1px solid var(--ui-border-strong); border-radius:8px; overflow:hidden; } + .seg button { border:none; background:var(--ui-surface); padding:6px 12px; font:inherit; font-size:13px; cursor:pointer; border-left:1px solid var(--ui-border-2); color:var(--ui-text); } .seg button:first-child { border-left:none; } .seg button.sel { background:var(--ui-accent); color:var(--ui-accent-text); } .seg button:disabled { cursor:default; } .set-empty { padding:24px 2px; color:var(--ui-text-faint); font-size:13px; } @media (max-width:560px) { - .set-row .set-head { flex-wrap:wrap; gap:8px 14px; } - /* Stack the shell: the rail becomes a horizontal scrolling tab strip above the pane. */ - .set-shell { flex-direction:column; max-height:none; } - .set-rail { flex:0 0 auto; flex-direction:row; gap:4px; overflow-x:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; - border-right:none; border-bottom:1px solid var(--ui-border-2); padding:7px 8px; } + .set-row .set-head { flex-wrap:wrap; gap:8px 16px; } + /* Stack the shell: the rail becomes a horizontal scrolling tab strip above the + pane. Height stays FIXED (72dvh) so tab switches don't resize the dialog. */ + .set-shell { flex-direction:column; height:min(72dvh,620px); } + .set-rail { flex:0 0 auto; flex-direction:row; gap:4px; overflow-x:auto; overflow-y:hidden; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; + border-right:none; border-bottom:1px solid var(--ui-border-2); padding:0 0 8px; } .set-rail button { flex:0 0 auto; white-space:nowrap; } - .set-pane { max-height:60dvh; } + .set-pane { padding:0 2px 16px 2px; } + .set-group { margin:0 -2px; padding:10px 2px 6px; } } /* Touch: rail tabs, segmented buttons and the switch reach a 44px hit area. */ @media (pointer:coarse) { From 3399b68efb350e3beb921647302c7412edd31125 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 17 Jul 2026 09:22:41 +0000 Subject: [PATCH 29/33] feat(plugins): per-plugin log capture + full-pane Logs viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin stderr (SDK Log) lands in a 400-line ring on the manager, served at /api/plugins//logs; ctx.plugin.log's UI-half lines are captured too and merged into one timeline. The Plugins row gains Logs — a drill-down view with level/substring filters, follow-tail, and copy (logs are voluminous; an inline box was too cramped). Toggle settings rows let the label breathe. --- internal/engine/plugin/manager.go | 41 ++++++++++- internal/engine/server/plugins.go | 2 + web/src/core/plugin-host.mjs | 22 +++++- web/src/data/plugins-service.mjs | 7 ++ web/src/plugins/plugins-manager.mjs | 3 +- web/src/plugins/plugins-panel.mjs | 102 +++++++++++++++++++++++++++- 6 files changed, 172 insertions(+), 5 deletions(-) diff --git a/internal/engine/plugin/manager.go b/internal/engine/plugin/manager.go index 32998b1..8a9a9da 100644 --- a/internal/engine/plugin/manager.go +++ b/internal/engine/plugin/manager.go @@ -54,6 +54,42 @@ type Manager struct { 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 @@ -384,7 +420,10 @@ func (r *pluginRunner) runOnce(parent context.Context) error { // 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.opts.Host.Log(r.id, level, msg) }} + 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) } diff --git a/internal/engine/server/plugins.go b/internal/engine/server/plugins.go index af2eb3c..114a127 100644 --- a/internal/engine/server/plugins.go +++ b/internal/engine/server/plugins.go @@ -113,6 +113,8 @@ func (s *Server) servePluginItem(w http.ResponseWriter, r *http.Request) { 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: diff --git a/web/src/core/plugin-host.mjs b/web/src/core/plugin-host.mjs index fe679cc..010fd76 100644 --- a/web/src/core/plugin-host.mjs +++ b/web/src/core/plugin-host.mjs @@ -156,7 +156,12 @@ export class PluginHost { plugin: { id, version, - log: (level, ...args) => console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](`[plugin ${id}]`, ...args), + // Logs go to the console AND a per-plugin ring the Plugins panel's Logs + // viewer merges with the WASM half's server-captured lines. + log: (level, ...args) => { + console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](`[plugin ${id}]`, ...args); + this._captureLog(id, level, args); + }, }, // Live vessel state (≤4 Hz coalesced) — same store the built-ins read. @@ -289,6 +294,21 @@ export class PluginHost { }; } + // _captureLog appends a UI-side log line to the plugin's capped ring. + _captureLog(id, level, args) { + this._uiLogs ||= new Map(); + const l = this._uiLogs.get(id) || []; + const msg = args.map((a) => (typeof a === "string" ? a : (() => { try { return JSON.stringify(a); } catch { return String(a); } })())).join(" "); + l.push({ time: new Date().toISOString(), level: level === "log" ? "info" : level, msg: "[ui] " + msg }); + if (l.length > 200) l.shift(); + this._uiLogs.set(id, l); + } + + // uiLogs returns a snapshot of a plugin's UI-side log ring (for the Logs viewer). + uiLogs(id) { + return [...((this._uiLogs && this._uiLogs.get(id)) || [])]; + } + // _makeMarker wraps a MapLibre Marker in a small chainable handle. _makeMarker(map, opts = {}) { const el = document.createElement("div"); diff --git a/web/src/data/plugins-service.mjs b/web/src/data/plugins-service.mjs index 2618dfd..41844f4 100644 --- a/web/src/data/plugins-service.mjs +++ b/web/src/data/plugins-service.mjs @@ -9,6 +9,13 @@ export class PluginsService { } /** List installed plugins, each as {record, manifest, status, running}. */ + /** A plugin's captured log ring (WASM stderr, newest last). */ + async logs(id) { + const r = await fetch(this._assets + `api/plugins/${encodeURIComponent(id)}/logs`, { cache: "no-store" }); + const j = await r.json(); + return j.logs || []; + } + async list() { const r = await fetch(this._assets + "api/plugins", { cache: "no-store" }); const j = await r.json(); diff --git a/web/src/plugins/plugins-manager.mjs b/web/src/plugins/plugins-manager.mjs index 2049563..60f6057 100644 --- a/web/src/plugins/plugins-manager.mjs +++ b/web/src/plugins/plugins-manager.mjs @@ -12,6 +12,7 @@ export class PluginsController { this._assets = deps.assets || "/"; this._service = new PluginsService({ assets: this._assets }); this._notify = deps.notify; + this._uiLogs = deps.uiLogs || null; this._panel = null; this._unregister = deps.registry.register({ id: "plugins", @@ -24,7 +25,7 @@ export class PluginsController { _render(host) { if (!this._panel) { this._panel = document.createElement("plugins-panel"); - this._panel.configure({ service: this._service, notify: this._notify, assets: this._assets }); + this._panel.configure({ service: this._service, notify: this._notify, assets: this._assets, uiLogs: this._uiLogs }); } host.appendChild(this._panel); // moving preserves the panel's state } diff --git a/web/src/plugins/plugins-panel.mjs b/web/src/plugins/plugins-panel.mjs index 8803a82..d682262 100644 --- a/web/src/plugins/plugins-panel.mjs +++ b/web/src/plugins/plugins-panel.mjs @@ -60,6 +60,9 @@ const STYLE = ` .field { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } .field label { width: 110px; flex: none; color: var(--ui-text-dim, #8b949e); } + /* Toggle rows: the label IS the row text — let it use the width, switch on the right. */ + .field.toggle label { width: auto; flex: 1; min-width: 0; color: var(--ui-text, #e6edf3); } + .field.toggle .input { flex: none; } .field .input { flex: 1; min-width: 0; } .field input[type=text], .field input[type=number], .field input[type=password], .field select, .editor textarea { width: 100%; box-sizing: border-box; @@ -68,6 +71,20 @@ const STYLE = ` font-size: 16px; /* >=16px or iOS zooms on focus */ } .editor textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; line-height: 1.5; white-space: pre; } + .logbar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } + .logbar input[type=text] { flex: 1; min-width: 120px; background: var(--ui-surface, #161b22); color: var(--ui-text, #e6edf3); + border: 1px solid var(--ui-border-strong, #444c56); border-radius: 7px; padding: 6px 9px; font: inherit; font-size: 16px; } + .logbar .follow { display: inline-flex; align-items: center; gap: 5px; color: var(--ui-text-dim, #8b949e); font-size: 12px; } + .seg { display: inline-flex; border: 1px solid var(--ui-border-strong, #444c56); border-radius: 8px; overflow: hidden; } + .seg button { border: none; border-radius: 0; border-left: 1px solid var(--ui-border, #30363d); background: var(--ui-surface, #161b22); padding: 6px 10px; font-size: 12px; } + .seg button:first-child { border-left: none; } + .seg button.sel { background: var(--ui-accent, #2f81f7); color: var(--ui-accent-text, #fff); } + pre.logs.tall { max-height: none; height: 44dvh; } + pre.logs { max-height: 240px; overflow: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; + margin: 0 0 10px; background: var(--ui-surface, #161b22); border: 1px solid var(--ui-border, #30363d); + border-radius: 10px; padding: 10px; font-size: 11px; line-height: 1.5; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--ui-text-dim, #8b949e); white-space: pre-wrap; word-break: break-word; } .field .unit { color: var(--ui-text-dim, #8b949e); font-size: 12px; flex: none; } .switch { display: inline-flex; align-items: center; } .editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } @@ -116,10 +133,11 @@ export class PluginsPanel extends HTMLElement { this._err = ""; } - configure({ service, notify, assets }) { + configure({ service, notify, assets, uiLogs }) { this._service = service; this._notify = notify; this._assets = assets || "/"; + this._uiLogs = uiLogs || null; // (id) => [{time,level,msg}] from the UI half } // A plugin that provides nmea.source defines a connection type — its Configure @@ -166,6 +184,10 @@ export class PluginsPanel extends HTMLElement { // Drill-down open: feed the child panel from THIS stream (it opens none of its // own — HTTP/1.1 socket budget) and leave the DOM alone; re-mounting the child // on every status tick would reset it mid-interaction. + if (this._open && this._open.mode === "logs") { + this._renderLogs(); + return; + } if (this._open && this._open.mode === "connections") { if (this._connPanel) this._connPanel.pluginsPush(plugins); return; @@ -178,6 +200,10 @@ export class PluginsPanel extends HTMLElement { // Connections drill-down: a plugin that defines a connection type pushes its // connections view over the list (back returns). The is a // persistent element so its state (open form/sniffer) survives re-renders. + if (this._open && this._open.mode === "logs") { + this._renderLogs(); + return; + } if (this._open && this._open.mode === "connections") { const p = this._plugins.find((x) => x.record.id === this._open.id); const name = (p && p.manifest && p.manifest.name) || this._open.id; @@ -236,6 +262,7 @@ export class PluginsPanel extends HTMLElement {
+ ${hasCaps ? `` : ""} @@ -269,11 +296,12 @@ export class PluginsPanel extends HTMLElement { await this._service.remove(id, false); this._open = null; await this._load(); - } else if (act === "grants" || act === "config") { + } else if (act === "grants" || act === "config" || act === "logs") { // Data-source plugins drill into their connections view; others get the // inline config/grants editor. const p = this._plugins.find((x) => x.record.id === id); const mode = act === "config" && p && this._isSource(p) ? "connections" : act; + if (this._open && this._open.mode === "logs") clearInterval(this._logTimer); this._open = this._open && this._open.id === id && this._open.mode === mode ? null : { id, mode }; this._render(); } @@ -295,6 +323,75 @@ export class PluginsPanel extends HTMLElement { row.after(box); } + // --- log viewer: a full-pane drill-down (logs are voluminous; an inline box is + // too cramped). Level + substring filters, follow-tail, copy. The
 is the
+  // documented log-viewer exception to the one-scroll-container rule.
+  _renderLogs() {
+    const p = this._plugins.find((x) => x.record.id === this._open.id);
+    const name = (p && p.manifest && p.manifest.name) || this._open.id;
+    const id = this._open.id;
+    this._root.innerHTML = `
+      
+ + ${esc(name)} — logs +
+
+
+ ${["all", "info", "warn", "error"].map((l) => + ``).join("")} +
+ + + + +
+
loading…
`; + this._root.querySelector("#back").onclick = () => { + clearInterval(this._logTimer); + this._open = null; + this._render(); + }; + this._root.querySelectorAll("#lvl button").forEach((b) => (b.onclick = () => { + this._logLevel = b.dataset.lvl; + this._root.querySelectorAll("#lvl button").forEach((x) => x.classList.toggle("sel", x === b)); + this._logTick(id); + })); + this._root.querySelector("#lfilter").oninput = (e) => { + this._logFilter = e.target.value; + this._logTick(id); + }; + this._root.querySelector("#lfollow").onchange = (e) => { + this._logFollow = e.target.checked; + }; + this._root.querySelector("#lcopy").onclick = () => { + const pre = this._root.querySelector("#lpre"); + if (pre && navigator.clipboard) navigator.clipboard.writeText(pre.textContent).catch(() => {}); + }; + clearInterval(this._logTimer); + this._logTick(id); + this._logTimer = setInterval(() => this._logTick(id), 2000); + } + + async _logTick(id) { + if (!this._open || this._open.mode !== "logs" || this._open.id !== id) return; + const server = await this._service.logs(id).catch(() => []); + const ui = this._uiLogs ? this._uiLogs(id) : []; + const all = [...server, ...ui].sort((a, b) => new Date(a.time) - new Date(b.time)); + const lvl = this._logLevel || "all"; + const q = (this._logFilter || "").toLowerCase(); + const shown = all.filter((l) => + (lvl === "all" || (l.level || "info") === lvl) && + (!q || (l.msg || "").toLowerCase().includes(q))); + const pre = this._root.querySelector("#lpre"); + const count = this._root.querySelector("#lcount"); + if (!pre) return; + pre.textContent = shown.length + ? shown.map((l) => `${String(l.time).slice(11, 19)} ${(l.level || "info").padEnd(5)} ${l.msg}`).join("\n") + : (all.length ? "nothing matches the filter" : "no log output yet"); + if (count) count.textContent = `${shown.length}/${all.length}`; + if (this._logFollow !== false) pre.scrollTop = pre.scrollHeight; + } + // --- grant editor --- _buildGrants(box, p) { const id = p.record.id; @@ -366,6 +463,7 @@ export class PluginsPanel extends HTMLElement { let ctl; if (it.type === "toggle") { ctl = ``; + return `
${ctl}
`; } else if (it.type === "select" && Array.isArray(it.options)) { ctl = `